From 76e4e4f2694fe544534cf7c42103386c4f80fabc Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Wed, 28 Oct 2026 04:39:58 +0100 Subject: [PATCH 01/69] Lift the connector contract, envelope and client from CodexConnector fd7cec4 Workspace with the model-connector crate. contract.rs, envelope.rs and client.rs are CodexConnector lib.rs moved verbatim under the rename rule (strip the leading Codex from identifiers); schema id strings are unchanged, so the v2 wire is unchanged. canonical_responses_body stays behind: it is OpenAI Responses lowering. The moved tests keep their bodies; shared fixtures live in fixtures.rs. Co-Authored-By: Claude Opus 5.5 --- .gitignore | 1 + Cargo.lock | 861 +++++++++++++++++++++++++ Cargo.toml | 9 + crates/model-connector/Cargo.toml | 26 + crates/model-connector/src/client.rs | 179 +++++ crates/model-connector/src/contract.rs | 649 +++++++++++++++++++ crates/model-connector/src/envelope.rs | 286 ++++++++ crates/model-connector/src/fixtures.rs | 129 ++++ crates/model-connector/src/lib.rs | 10 + 9 files changed, 2150 insertions(+) create mode 100644 .gitignore create mode 100644 Cargo.lock create mode 100644 Cargo.toml create mode 100644 crates/model-connector/Cargo.toml create mode 100644 crates/model-connector/src/client.rs create mode 100644 crates/model-connector/src/contract.rs create mode 100644 crates/model-connector/src/envelope.rs create mode 100644 crates/model-connector/src/fixtures.rs create mode 100644 crates/model-connector/src/lib.rs diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..ea8c4bf --- /dev/null +++ b/.gitignore @@ -0,0 +1 @@ +/target diff --git a/Cargo.lock b/Cargo.lock new file mode 100644 index 0000000..a3dfca5 --- /dev/null +++ b/Cargo.lock @@ -0,0 +1,861 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "aead" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d122413f284cf2d62fb1b7db97e02edb8cda96d769b16e443a4f6195e35662b0" +dependencies = [ + "crypto-common", + "generic-array", +] + +[[package]] +name = "aes" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b169f7a6d4742236a0a00c541b845991d0ac43e546831af1249753ab4c3aa3a0" +dependencies = [ + "cfg-if", + "cipher", + "cpufeatures", +] + +[[package]] +name = "aes-gcm" +version = "0.10.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "831010a0f742e1209b3bcea8fab6a8e149051ba6099432c8cb2cc117dec3ead1" +dependencies = [ + "aead", + "aes", + "cipher", + "ctr", + "ghash", + "subtle", +] + +[[package]] +name = "android_system_properties" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae221649c9976a6f6c56ae1facf410f3ddb33cc661c4b7b61020a912d4237fbc" +dependencies = [ + "libc", +] + +[[package]] +name = "anyhow" +version = "1.0.104" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "330a5ed07fa54e4702c9d6c4174f74427fc0ef6e214bbd677ae50a5099946470" + +[[package]] +name = "autocfg" +version = "1.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" + +[[package]] +name = "base64" +version = "0.23.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac07cdecf99051d9a5238b80f35af32cdeba5b336e55d957b318b50137e18da5" + +[[package]] +name = "block-buffer" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" +dependencies = [ + "generic-array", +] + +[[package]] +name = "bumpalo" +version = "3.20.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649" + +[[package]] +name = "cc" +version = "1.4.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ad534f4357a5264cce5019c989cf66a4f0dc4e0d1b1d15f8aacec0ff7360273" +dependencies = [ + "find-msvc-tools", + "shlex", +] + +[[package]] +name = "cfg-if" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" + +[[package]] +name = "chrono" +version = "0.4.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1aa79e62e7697b8e29b513a68abacf485adcd1fe8284a4316c5ae868e6633327" +dependencies = [ + "iana-time-zone", + "js-sys", + "num-traits", + "serde", + "wasm-bindgen", + "windows-link", +] + +[[package]] +name = "cipher" +version = "0.4.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773f3b9af64447d2ce9850330c473515014aa235e6a783b02db81ff39e4a3dad" +dependencies = [ + "crypto-common", + "inout", +] + +[[package]] +name = "codex-connector" +version = "0.1.0" +source = "git+https://github.com/GameCult/CodexConnector.git?rev=fd7cec4176f4e3f9a5aaa75e77ba10ce5e1522f8#fd7cec4176f4e3f9a5aaa75e77ba10ce5e1522f8" +dependencies = [ + "aes-gcm", + "base64", + "rand_core", + "rmp-serde", + "serde", + "serde_json", + "sha2", + "thiserror", + "zeroize", +] + +[[package]] +name = "core-foundation-sys" +version = "0.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" + +[[package]] +name = "cpufeatures" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" +dependencies = [ + "libc", +] + +[[package]] +name = "crypto-common" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" +dependencies = [ + "generic-array", + "rand_core", + "typenum", +] + +[[package]] +name = "ctr" +version = "0.9.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0369ee1ad671834580515889b80f2ea915f23b8be8d0daa4bbaf2ac5c7590835" +dependencies = [ + "cipher", +] + +[[package]] +name = "cultcache-rs" +version = "0.1.0" +source = "git+https://github.com/GameCult/CultLib.git?rev=e171eca32329baa928f4a1d810401a8b4c857029#e171eca32329baa928f4a1d810401a8b4c857029" +dependencies = [ + "anyhow", + "chrono", + "cultcache-rs-derive", + "fs2", + "redb", + "rmp-serde", + "serde", + "serde_bytes", + "serde_json", + "uuid", + "windows-sys", +] + +[[package]] +name = "cultcache-rs-derive" +version = "0.1.0" +source = "git+https://github.com/GameCult/CultLib.git?rev=e171eca32329baa928f4a1d810401a8b4c857029#e171eca32329baa928f4a1d810401a8b4c857029" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "digest" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" +dependencies = [ + "block-buffer", + "crypto-common", +] + +[[package]] +name = "find-msvc-tools" +version = "0.1.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d45db016d36b838f563236e9193d0ee6ce38f3f68b6c94e914b4929c96bbb890" + +[[package]] +name = "fs2" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9564fc758e15025b46aa6643b1b77d047d1a56a1aea6e01002ac0c7026876213" +dependencies = [ + "libc", + "winapi", +] + +[[package]] +name = "futures-core" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92d699e522242e69e3003b94ecc1f960f3a5e015aa7c5d7486e65ad01dd94f5e" + +[[package]] +name = "futures-task" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cd417de3d1d015fc3bfd2b1ea46dfc7bab72ef86f1cc7cc9c78e728b34a6d1fd" + +[[package]] +name = "futures-util" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d50a92467f8ba5dd6e3ee5d4bd04d73ab2e4e1c44474a0674821dfce14b79bc" +dependencies = [ + "futures-core", + "futures-task", + "pin-project-lite", + "slab", +] + +[[package]] +name = "generic-array" +version = "0.14.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" +dependencies = [ + "typenum", + "version_check", +] + +[[package]] +name = "getrandom" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" +dependencies = [ + "cfg-if", + "libc", + "wasi", +] + +[[package]] +name = "getrandom" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099" +dependencies = [ + "cfg-if", + "libc", + "r-efi", +] + +[[package]] +name = "ghash" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0d8a4362ccb29cb0b265253fb0a2728f592895ee6854fd9bc13f2ffda266ff1" +dependencies = [ + "opaque-debug", + "polyval", +] + +[[package]] +name = "iana-time-zone" +version = "0.1.65" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e31bc9ad994ba00e440a8aa5c9ef0ec67d5cb5e5cb0cc7f8b744a35b389cc470" +dependencies = [ + "android_system_properties", + "core-foundation-sys", + "iana-time-zone-haiku", + "js-sys", + "log", + "wasm-bindgen", + "windows-core", +] + +[[package]] +name = "iana-time-zone-haiku" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f31827a206f56af32e590ba56d5d2d085f558508192593743f16b2306495269f" +dependencies = [ + "cc", +] + +[[package]] +name = "inout" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "879f10e63c20629ecabbb64a8010319738c66a5cd0c29b02d63d272b03751d01" +dependencies = [ + "generic-array", +] + +[[package]] +name = "itoa" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" + +[[package]] +name = "js-sys" +version = "0.3.104" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0e0c1080212aad755ea003d18543e8768dd432c48819efd73a7bf1e39b7a5a3a" +dependencies = [ + "cfg-if", + "futures-util", + "wasm-bindgen", +] + +[[package]] +name = "libc" +version = "0.2.189" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2" + +[[package]] +name = "log" +version = "0.4.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f9f8bd3e56ce4dfc153cf470fffbfa98c7620958b312ca5c3a4b8d5181fd13c6" + +[[package]] +name = "memchr" +version = "2.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" + +[[package]] +name = "model-connector" +version = "0.1.0" +dependencies = [ + "aes-gcm", + "codex-connector", + "cultcache-rs", + "rand_core", + "rmp-serde", + "serde", + "serde_json", + "sha2", + "thiserror", + "zeroize", +] + +[[package]] +name = "num-traits" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +dependencies = [ + "autocfg", +] + +[[package]] +name = "once_cell" +version = "1.21.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" + +[[package]] +name = "opaque-debug" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c08d65885ee38876c4f86fa503fb49d7b507c2b62552df7c70b2fce627e06381" + +[[package]] +name = "pin-project-lite" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" + +[[package]] +name = "polyval" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d1fe60d06143b2430aa532c94cfe9e29783047f06c0d7fd359a9a51b729fa25" +dependencies = [ + "cfg-if", + "cpufeatures", + "opaque-debug", + "universal-hash", +] + +[[package]] +name = "proc-macro2" +version = "1.0.107" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quote" +version = "1.0.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "r-efi" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" + +[[package]] +name = "rand_core" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" +dependencies = [ + "getrandom 0.2.17", +] + +[[package]] +name = "redb" +version = "4.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "de6c3b63e007e90ce536ec2ae4690826136a20ec8dbbbb400daef1bb999d2e36" +dependencies = [ + "libc", +] + +[[package]] +name = "rmp" +version = "0.8.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ba8be72d372b2c9b35542551678538b562e7cf86c3315773cae48dfbfe7790c" +dependencies = [ + "num-traits", +] + +[[package]] +name = "rmp-serde" +version = "1.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72f81bee8c8ef9b577d1681a70ebbc962c232461e397b22c208c43c04b67a155" +dependencies = [ + "rmp", + "serde", +] + +[[package]] +name = "rustversion" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f" + +[[package]] +name = "serde" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde_bytes" +version = "0.11.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a5d440709e79d88e51ac01c4b72fc6cb7314017bb7da9eeff678aa94c10e3ea8" +dependencies = [ + "serde", + "serde_core", +] + +[[package]] +name = "serde_core" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.4", +] + +[[package]] +name = "serde_json" +version = "1.0.151" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c841b55ecdae098c80dcae9cf767f6f8a0c2cdb3416bbef72181df4d0fe73f14" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "sha2" +version = "0.10.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" +dependencies = [ + "cfg-if", + "cpufeatures", + "digest", +] + +[[package]] +name = "shlex" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba" + +[[package]] +name = "slab" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" + +[[package]] +name = "subtle" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" + +[[package]] +name = "syn" +version = "2.0.119" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "872831b642d1a07999a962a351ed35b955ea2cfc8f3862091e2a240a84f17297" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "syn" +version = "3.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6275cddf4610d1775e6d1fe9469b2e77d0f39fd98fb7450901b821e0c53649f" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "thiserror" +version = "2.0.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec86235f5fcc2a73650310756d2ac5b138a5780bbbdfae3eeccec992c435ba4f" +dependencies = [ + "thiserror-impl", +] + +[[package]] +name = "thiserror-impl" +version = "2.0.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bc04cd3e1236dd4a98afca4569f2deb3f120e5422a4023be2cb683f8486292af" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.4", +] + +[[package]] +name = "typenum" +version = "1.20.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20" + +[[package]] +name = "unicode-ident" +version = "1.0.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" + +[[package]] +name = "universal-hash" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc1de2c688dc15305988b563c3854064043356019f97a4b46276fe734c4f07ea" +dependencies = [ + "crypto-common", + "subtle", +] + +[[package]] +name = "uuid" +version = "1.25.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f053576934f05a761a402421fbbe3d425d9366f75f978806a037b3ca481abecc" +dependencies = [ + "getrandom 0.4.3", + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "version_check" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" + +[[package]] +name = "wasi" +version = "0.11.1+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" + +[[package]] +name = "wasm-bindgen" +version = "0.2.127" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1b70935747edd64d89de3efa29d73789b806c15798f8e7dca4d8ac356b50ce70" +dependencies = [ + "cfg-if", + "once_cell", + "rustversion", + "wasm-bindgen-macro", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.127" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77775f8f3f7217702089053b94958f8f54061a3f663417df76e19cbdcca29bc1" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.127" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e11d33f857dc2fb11b8bc75aee111aa9cbeb12cd9f25efd3d4c2a3dd4e235284" +dependencies = [ + "bumpalo", + "proc-macro2", + "quote", + "syn 2.0.119", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.127" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7ef64dbcc55df09c7e5a46182d181c2cfa3e925f3da937ea764728b4bbb9dcbf" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "winapi" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419" +dependencies = [ + "winapi-i686-pc-windows-gnu", + "winapi-x86_64-pc-windows-gnu", +] + +[[package]] +name = "winapi-i686-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" + +[[package]] +name = "winapi-x86_64-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" + +[[package]] +name = "windows-core" +version = "0.62.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb" +dependencies = [ + "windows-implement", + "windows-interface", + "windows-link", + "windows-result", + "windows-strings", +] + +[[package]] +name = "windows-implement" +version = "0.60.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "windows-interface" +version = "0.59.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-result" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-strings" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-sys" +version = "0.59.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e38bc4d79ed67fd075bcc251a1c39b32a1776bbe92e5bef1f0bf1f8c531853b" +dependencies = [ + "windows-targets", +] + +[[package]] +name = "windows-targets" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" +dependencies = [ + "windows_aarch64_gnullvm", + "windows_aarch64_msvc", + "windows_i686_gnu", + "windows_i686_gnullvm", + "windows_i686_msvc", + "windows_x86_64_gnu", + "windows_x86_64_gnullvm", + "windows_x86_64_msvc", +] + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" + +[[package]] +name = "windows_i686_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" + +[[package]] +name = "windows_i686_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" + +[[package]] +name = "zeroize" +version = "1.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e13c156562582aa81c60cb29407084cdb54c4164760106ab78e6c5b0858cf64e" + +[[package]] +name = "zmij" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b" diff --git a/Cargo.toml b/Cargo.toml new file mode 100644 index 0000000..10e8225 --- /dev/null +++ b/Cargo.toml @@ -0,0 +1,9 @@ +[workspace] +members = ["crates/*"] +resolver = "3" + +[workspace.package] +version = "0.1.0" +edition = "2024" +license = "AGPL-3.0-only" +repository = "https://eureka.gamecult.org/forge/GameCult/ModelConnector" diff --git a/crates/model-connector/Cargo.toml b/crates/model-connector/Cargo.toml new file mode 100644 index 0000000..cb30f96 --- /dev/null +++ b/crates/model-connector/Cargo.toml @@ -0,0 +1,26 @@ +[package] +name = "model-connector" +description = "Provider-neutral connector contract, envelope, client, replay service and serve loop for GameCult minds" +version.workspace = true +edition.workspace = true +license.workspace = true +repository.workspace = true +publish = false + +[features] +default = [] +daemon = ["dep:cultcache-rs"] + +[dependencies] +aes-gcm = "0.10" +cultcache-rs = { git = "https://github.com/GameCult/CultLib.git", rev = "e171eca32329baa928f4a1d810401a8b4c857029", package = "cultcache-rs", optional = true } +rand_core = { version = "0.6", features = ["getrandom"] } +rmp-serde = "1" +serde = { version = "1", features = ["derive"] } +serde_json = "1" +sha2 = "0.10" +thiserror = "2" +zeroize = "1" + +[dev-dependencies] +codex-connector = { git = "https://github.com/GameCult/CodexConnector.git", rev = "fd7cec4176f4e3f9a5aaa75e77ba10ce5e1522f8", default-features = false } diff --git a/crates/model-connector/src/client.rs b/crates/model-connector/src/client.rs new file mode 100644 index 0000000..ea8ea3d --- /dev/null +++ b/crates/model-connector/src/client.rs @@ -0,0 +1,179 @@ +use std::net::{SocketAddr, TcpStream}; +use std::time::Duration; + +use thiserror::Error; +use zeroize::Zeroizing; + +use crate::contract::{TransportInvocation, TransportKey, TransportResult}; +use crate::envelope::{ + ServiceError, TransportFrameError, decrypt_result, encrypt_invocation, read_transport_frame, + write_transport_frame, +}; + +#[derive(Clone)] +pub struct ConnectorClient { + endpoint: SocketAddr, + security: TransportKey, + max_frame_bytes: usize, + response_timeout: Option, +} + +impl ConnectorClient { + pub fn new( + endpoint: SocketAddr, + connection_key: impl Into, + max_frame_bytes: usize, + response_timeout: Option, + ) -> Result { + if endpoint.port() == 0 + || endpoint.ip().is_unspecified() + || endpoint.ip().is_multicast() + || endpoint.ip() == std::net::Ipv4Addr::BROADCAST + || !(4096..=u32::MAX as usize).contains(&max_frame_bytes) + || response_timeout.is_some_and(|timeout| timeout.is_zero()) + { + return Err(ConnectorClientError::InvalidConfig); + } + let connection_key = Zeroizing::new(connection_key.into()); + let security = TransportKey::from_connection_secret(connection_key.as_str())?; + Ok(Self { + endpoint, + security, + max_frame_bytes, + response_timeout, + }) + } + + pub fn execute( + &self, + invocation: &TransportInvocation, + ) -> Result { + let request = rmp_serde::to_vec(&encrypt_invocation(invocation, &self.security)?) + .map_err(|_| ConnectorClientError::Encoding)?; + let connect_timeout = self + .response_timeout + .unwrap_or(Duration::from_secs(10)) + .min(Duration::from_secs(10)); + let mut stream = TcpStream::connect_timeout(&self.endpoint, connect_timeout) + .map_err(ConnectorClientError::Connection)?; + stream + .set_read_timeout(self.response_timeout) + .and_then(|()| stream.set_write_timeout(self.response_timeout)) + .map_err(ConnectorClientError::Connection)?; + write_transport_frame(&mut stream, &request, self.max_frame_bytes) + .map_err(client_frame_error)?; + let response = + read_transport_frame(&mut stream, self.max_frame_bytes).map_err(client_frame_error)?; + let envelope = + rmp_serde::from_slice(&response).map_err(|_| ConnectorClientError::Encoding)?; + decrypt_result(&envelope, &self.security, invocation).map_err(Into::into) + } +} + + +#[cfg(test)] +mod client_endpoint_tests { + use super::*; + + #[test] + fn client_admits_explicit_unicast_and_refuses_non_destinations() { + for endpoint in ["127.0.0.1:4103", "172.19.0.1:4103", "[::1]:4103"] { + assert!( + ConnectorClient::new( + endpoint.parse().unwrap(), + "bounded-client-endpoint-key", + 64 * 1024, + Some(Duration::from_secs(1)), + ) + .is_ok(), + "{endpoint} should be an admissible explicit destination" + ); + } + for endpoint in [ + "0.0.0.0:4103", + "255.255.255.255:4103", + "224.0.0.1:4103", + "[::]:4103", + "[ff02::1]:4103", + "127.0.0.1:0", + ] { + assert!(matches!( + ConnectorClient::new( + endpoint.parse().unwrap(), + "bounded-client-endpoint-key", + 64 * 1024, + Some(Duration::from_secs(1)), + ), + Err(ConnectorClientError::InvalidConfig) + )); + } + } +} + + +#[derive(Debug, Error)] +pub enum ConnectorClientError { + #[error("invalid connector client configuration")] + InvalidConfig, + #[error("connector client connection failed")] + Connection(#[source] std::io::Error), + #[error("connector frame exceeded its bound")] + FrameSize, + #[error("connector MessagePack encoding failed")] + Encoding, + #[error(transparent)] + Transport(#[from] ServiceError), +} + +fn client_frame_error(error: TransportFrameError) -> ConnectorClientError { + match error { + TransportFrameError::Connection(error) => ConnectorClientError::Connection(error), + TransportFrameError::Size => ConnectorClientError::FrameSize, + } +} + +#[cfg(test)] +mod fixture_tests { + use super::*; + use crate::fixtures::*; + use crate::{contract::*, envelope::*}; + + #[test] + fn shared_client_transports_one_exact_encrypted_request_and_result() { + let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap(); + let endpoint = listener.local_addr().unwrap(); + let invocation = invocation(); + let expected = invocation.clone(); + let security = security("epiphany-distinct-test-key"); + let server = std::thread::spawn(move || { + let (mut stream, _) = listener.accept().unwrap(); + let request = read_transport_frame(&mut stream, 64 * 1024).unwrap(); + let envelope: TransportEnvelope = rmp_serde::from_slice(&request).unwrap(); + assert_eq!(decrypt_invocation(&envelope, &security).unwrap(), expected); + let response = encrypt_result( + &TransportResult::refused(&expected, Refusal::Policy), + &security, + ) + .unwrap(); + write_transport_frame( + &mut stream, + &rmp_serde::to_vec(&response).unwrap(), + 64 * 1024, + ) + .unwrap(); + }); + + let client = ConnectorClient::new( + endpoint, + "epiphany-distinct-test-key", + 64 * 1024, + Some(Duration::from_secs(1)), + ) + .unwrap(); + assert_eq!( + client.execute(&invocation).unwrap().disposition, + TransportDisposition::Refused(Refusal::Policy) + ); + server.join().unwrap(); + } +} diff --git a/crates/model-connector/src/contract.rs b/crates/model-connector/src/contract.rs new file mode 100644 index 0000000..2f11a48 --- /dev/null +++ b/crates/model-connector/src/contract.rs @@ -0,0 +1,649 @@ +use std::collections::HashSet; + +use serde::{Deserialize, Serialize}; +use sha2::{Digest, Sha256}; +use thiserror::Error; +use zeroize::Zeroize; + +use crate::envelope::ServiceError; + +pub const PROVIDER_REQUEST_SCHEMA_ID: &str = "gamecult.codex.provider_request.v2"; +pub const INVOCATION_SCHEMA_ID: &str = "gamecult.codex.transport_invocation.v2"; +pub const RESULT_SCHEMA_ID: &str = "gamecult.codex.transport_result.v2"; +pub const RECEIPT_SCHEMA_ID: &str = "gamecult.codex.transport_receipt.v2"; +pub const ENVELOPE_SCHEMA_ID: &str = "gamecult.codex.transport_envelope.v2"; + +#[derive(Clone, PartialEq, Eq)] +pub struct TransportKey(pub(crate) [u8; 32]); + +impl TransportKey { + pub fn from_connection_secret(secret: &str) -> Result { + if secret.trim().is_empty() || secret.trim() != secret { + return Err(ServiceError::InvalidAdmission); + } + Ok(Self(Sha256::digest(secret.as_bytes()).into())) + } +} + +impl Drop for TransportKey { + fn drop(&mut self) { + self.0.zeroize(); + } +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct ProviderRequest { + pub schema_id: String, + pub request_id: String, + pub conversation_id: String, + pub model: String, + pub instructions: String, + pub input: Vec, + pub reasoning_effort: Option, + pub reasoning_summary: Option, + pub service_tier: Option, + pub output_format_name: Option, + pub previous_response_id: Option, + pub tools: Vec, + pub tool_choice: ToolChoice, + pub parallel_tool_calls: bool, + pub output_schema_json: Option, + pub max_output_tokens: Option, + pub prompt_cache_key: Option, +} + +impl ProviderRequest { + pub fn new( + request_id: impl Into, + conversation_id: impl Into, + model: impl Into, + instructions: impl Into, + ) -> Self { + Self { + schema_id: PROVIDER_REQUEST_SCHEMA_ID.to_string(), + request_id: request_id.into(), + conversation_id: conversation_id.into(), + model: model.into(), + instructions: instructions.into(), + input: Vec::new(), + reasoning_effort: None, + reasoning_summary: None, + service_tier: None, + output_format_name: None, + previous_response_id: None, + tools: Vec::new(), + tool_choice: ToolChoice::Auto, + parallel_tool_calls: false, + output_schema_json: None, + max_output_tokens: None, + prompt_cache_key: None, + } + } + + pub fn validate(&self) -> Result<(), ContractError> { + if self.schema_id != PROVIDER_REQUEST_SCHEMA_ID { + return Err(ContractError::Schema); + } + require_id(&self.request_id, "request_id")?; + require_id(&self.conversation_id, "conversation_id")?; + require_id(&self.model, "model")?; + require_content(&self.instructions, "instructions")?; + if matches!(self.max_output_tokens, Some(0)) { + return Err(ContractError::Invalid("max_output_tokens")); + } + if self + .prompt_cache_key + .as_deref() + .is_some_and(|value| value.trim().is_empty()) + { + return Err(ContractError::Invalid("prompt_cache_key")); + } + for (value, field) in [ + (self.reasoning_effort.as_deref(), "reasoning_effort"), + (self.reasoning_summary.as_deref(), "reasoning_summary"), + (self.service_tier.as_deref(), "service_tier"), + (self.output_format_name.as_deref(), "output_format_name"), + (self.previous_response_id.as_deref(), "previous_response_id"), + ] { + if let Some(value) = value { + require_id(value, field)?; + } + } + if let Some(schema) = &self.output_schema_json { + require_content(schema, "output_schema_json")?; + require_json_object(schema, "output_schema_json")?; + require_provider_name( + self.output_format_name + .as_deref() + .ok_or(ContractError::Invalid("output_format_name"))?, + "output_format_name", + )?; + } else if self.output_format_name.is_some() { + return Err(ContractError::Invalid("output_schema_json")); + } + + if self.tools.is_empty() && self.tool_choice == ToolChoice::Required { + return Err(ContractError::Invalid("tool_choice")); + } + + let mut tool_names = HashSet::new(); + for tool in &self.tools { + require_provider_name(&tool.name, "tool.name")?; + require_content(&tool.description, "tool.description")?; + require_content(&tool.parameters_json, "tool.parameters_json")?; + require_json_object(&tool.parameters_json, "tool.parameters_json")?; + if !tool_names.insert(tool.name.as_str()) { + return Err(ContractError::DuplicateTool(tool.name.clone())); + } + } + for item in &self.input { + match item { + InputItem::UserText { text } | InputItem::AssistantText { text } => { + require_content(text, "input.text")?; + } + InputItem::ToolCall { + call_id, + name, + arguments, + } => { + require_provider_call_id(call_id, "input.call_id")?; + require_provider_name(name, "input.tool_name")?; + require_content(arguments, "input.arguments")?; + } + InputItem::ToolResult { call_id, output } => { + require_provider_call_id(call_id, "input.call_id")?; + require_content(output, "input.output")?; + } + } + } + Ok(()) + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum ToolChoice { + Auto, + Required, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub enum InputItem { + UserText { + text: String, + }, + AssistantText { + text: String, + }, + ToolCall { + call_id: String, + name: String, + arguments: String, + }, + ToolResult { + call_id: String, + output: String, + }, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct ToolDefinition { + pub name: String, + pub description: String, + pub parameters_json: String, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct TransportInvocation { + pub schema_id: String, + pub caller_runtime_id: String, + pub expires_at_unix_ms: u64, + pub native_request_sha256: [u8; 32], + pub provider_request_sha256: [u8; 32], + pub request: ProviderRequest, +} + +impl TransportInvocation { + pub fn new( + caller_runtime_id: impl Into, + expires_at_unix_ms: u64, + native_request_sha256: [u8; 32], + request: ProviderRequest, + ) -> Result { + let provider_request_sha256 = provider_request_sha256(&request)?; + Ok(Self { + schema_id: INVOCATION_SCHEMA_ID.to_string(), + caller_runtime_id: caller_runtime_id.into(), + expires_at_unix_ms, + native_request_sha256, + provider_request_sha256, + request, + }) + } + + pub fn validate(&self, now_unix_ms: u64, max_expiry_skew_ms: u64) -> Result<(), ContractError> { + if self.schema_id != INVOCATION_SCHEMA_ID { + return Err(ContractError::Schema); + } + require_id(&self.caller_runtime_id, "caller_runtime_id")?; + if self.native_request_sha256 == [0; 32] { + return Err(ContractError::Invalid("native_request_sha256")); + } + self.request.validate()?; + if self.provider_request_sha256 != provider_request_sha256(&self.request)? { + return Err(ContractError::ProviderDigest); + } + if self.expires_at_unix_ms < now_unix_ms + || self.expires_at_unix_ms > now_unix_ms.saturating_add(max_expiry_skew_ms) + { + return Err(ContractError::Expiry); + } + Ok(()) + } + + pub fn request_id(&self) -> &str { + &self.request.request_id + } +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct TransportResult { + pub schema_id: String, + pub request_id: String, + pub caller_runtime_id: String, + pub disposition: TransportDisposition, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub enum TransportDisposition { + Refused(Refusal), + Transported { + events: Vec, + receipt: Box, + }, +} + +impl TransportResult { + pub fn refused(invocation: &TransportInvocation, reason: Refusal) -> Self { + Self { + schema_id: RESULT_SCHEMA_ID.to_string(), + request_id: invocation.request_id().to_string(), + caller_runtime_id: invocation.caller_runtime_id.clone(), + disposition: TransportDisposition::Refused(reason), + } + } + + pub fn transported( + invocation: &TransportInvocation, + events: Vec, + receipt: TransportReceipt, + ) -> Self { + Self { + schema_id: RESULT_SCHEMA_ID.to_string(), + request_id: invocation.request_id().to_string(), + caller_runtime_id: invocation.caller_runtime_id.clone(), + disposition: TransportDisposition::Transported { + events, + receipt: Box::new(receipt), + }, + } + } + + pub fn validate_against( + &self, + invocation: &TransportInvocation, + ) -> Result<(), ContractError> { + if self.schema_id != RESULT_SCHEMA_ID { + return Err(ContractError::Schema); + } + if self.request_id != invocation.request_id() + || self.caller_runtime_id != invocation.caller_runtime_id + { + return Err(ContractError::Identity); + } + + if let TransportDisposition::Transported { events, receipt } = &self.disposition { + for (expected, event) in events.iter().enumerate() { + if event.sequence != expected as u64 { + return Err(ContractError::EventSequence); + } + event.validate()?; + } + receipt.validate_against(invocation)?; + } + Ok(()) + } +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub enum TransportEventPayload { + TextDelta { + text: String, + }, + ToolCall { + call_id: String, + name: String, + arguments: String, + }, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct TransportEvent { + pub sequence: u64, + pub payload: TransportEventPayload, +} + +impl TransportEvent { + fn validate(&self) -> Result<(), ContractError> { + match &self.payload { + TransportEventPayload::TextDelta { text } => require_content(text, "event.text"), + TransportEventPayload::ToolCall { + call_id, + name, + arguments, + } => { + require_id(call_id, "event.call_id")?; + require_id(name, "event.tool_name")?; + require_content(arguments, "event.arguments") + } + } + } +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct TransportReceipt { + pub schema_id: String, + pub request_id: String, + pub caller_runtime_id: String, + pub native_request_sha256: [u8; 32], + pub provider_request_sha256: [u8; 32], + pub model: String, + pub transport: String, + pub outcome: TransportOutcome, +} + +impl TransportReceipt { + pub fn validate_against( + &self, + invocation: &TransportInvocation, + ) -> Result<(), ContractError> { + if self.schema_id != RECEIPT_SCHEMA_ID { + return Err(ContractError::Schema); + } + if self.request_id != invocation.request_id() + || self.caller_runtime_id != invocation.caller_runtime_id + || self.model != invocation.request.model + { + return Err(ContractError::Identity); + } + if self.provider_request_sha256 != invocation.provider_request_sha256 { + return Err(ContractError::ProviderDigest); + } + if self.native_request_sha256 != invocation.native_request_sha256 { + return Err(ContractError::NativeDigest); + } + require_id(&self.transport, "receipt.transport")?; + match &self.outcome { + TransportOutcome::Completed { + provider_response_id: Some(value), + .. + } => require_id(value, "receipt.provider_response_id")?, + TransportOutcome::Failed { + failure_kind, + message, + } => { + require_id(failure_kind, "receipt.failure_kind")?; + require_content(message, "receipt.failure_message")?; + } + TransportOutcome::Completed { + provider_response_id: None, + .. + } => {} + } + Ok(()) + } +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub enum TransportOutcome { + Completed { + provider_response_id: Option, + input_tokens: Option, + output_tokens: Option, + reasoning_output_tokens: Option, + cached_input_tokens: Option, + }, + Failed { + failure_kind: String, + message: String, + }, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub enum Refusal { + Expired, + IdentitySubstitution, + ProviderDigestSubstitution, + Policy, + Capacity, + InFlight, + Indeterminate, + ReplayConflict, + Malformed, +} + +pub fn canonical_provider_request_bytes( + request: &ProviderRequest, +) -> Result, ContractError> { + request.validate()?; + rmp_serde::to_vec(request).map_err(|_| ContractError::Encoding) +} + +pub fn provider_request_sha256(request: &ProviderRequest) -> Result<[u8; 32], ContractError> { + Ok(Sha256::digest(canonical_provider_request_bytes(request)?).into()) +} + +pub fn canonical_invocation_bytes( + invocation: &TransportInvocation, +) -> Result, ContractError> { + invocation.request.validate()?; + if invocation.provider_request_sha256 != provider_request_sha256(&invocation.request)? { + return Err(ContractError::ProviderDigest); + } + rmp_serde::to_vec(invocation).map_err(|_| ContractError::Encoding) +} + +pub(crate) fn require_id(value: &str, field: &'static str) -> Result<(), ContractError> { + if value.trim().is_empty() || value.trim() != value { + return Err(ContractError::Invalid(field)); + } + Ok(()) +} + +fn require_content(value: &str, field: &'static str) -> Result<(), ContractError> { + if value.is_empty() { + return Err(ContractError::Invalid(field)); + } + Ok(()) +} + +fn require_json_object(value: &str, field: &'static str) -> Result<(), ContractError> { + if !matches!( + serde_json::from_str::(value), + Ok(serde_json::Value::Object(_)) + ) { + return Err(ContractError::Invalid(field)); + } + Ok(()) +} + +fn require_provider_name(value: &str, field: &'static str) -> Result<(), ContractError> { + if value.is_empty() + || value.len() > 64 + || !value + .bytes() + .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'_' | b'-')) + { + return Err(ContractError::Invalid(field)); + } + Ok(()) +} + +fn require_provider_call_id(value: &str, field: &'static str) -> Result<(), ContractError> { + if value.is_empty() || value.len() > 64 || !value.is_ascii() { + return Err(ContractError::Invalid(field)); + } + Ok(()) +} + +#[derive(Debug, Error, PartialEq, Eq)] +pub enum ContractError { + #[error("unexpected schema")] + Schema, + #[error("invalid {0}")] + Invalid(&'static str), + #[error("duplicate tool {0:?}")] + DuplicateTool(String), + #[error("request identity substitution")] + Identity, + #[error("provider request digest substitution")] + ProviderDigest, + #[error("native request digest substitution")] + NativeDigest, + #[error("invocation expiry is outside the admitted horizon")] + Expiry, + #[error("transport event sequence is not contiguous")] + EventSequence, + #[error("canonical MessagePack encoding failed")] + Encoding, +} + + +#[cfg(test)] +mod tests { + use super::*; + + use crate::fixtures::*; + + #[test] + fn provider_digest_is_stable_and_content_addressed() { + let original = request(); + let same = original.clone(); + assert_eq!( + provider_request_sha256(&original).unwrap(), + provider_request_sha256(&same).unwrap() + ); + let mut changed = original.clone(); + changed.instructions.push_str(" Changed."); + assert_ne!( + provider_request_sha256(&original).unwrap(), + provider_request_sha256(&changed).unwrap() + ); + } + + #[test] + fn provider_contract_refuses_transport_side_fixup_pressure() { + let mut request = request(); + request.output_format_name = Some("epiphany.role.result".to_string()); + request.output_schema_json = Some(r#"{"type":"object"}"#.to_string()); + assert_eq!( + request.validate(), + Err(ContractError::Invalid("output_format_name")) + ); + + request.output_format_name = None; + request.output_schema_json = None; + request.input.push(InputItem::ToolResult { + call_id: "x".repeat(65), + output: "evidence".to_string(), + }); + assert_eq!( + request.validate(), + Err(ContractError::Invalid("input.call_id")) + ); + } + + #[test] + fn invocation_refuses_provider_request_substitution() { + let mut invocation = invocation(); + invocation.request.model = "substituted".to_string(); + assert_eq!( + invocation.validate(1_000, 2_000), + Err(ContractError::ProviderDigest) + ); + } + + #[test] + fn invocation_refuses_expired_or_blank_native_provenance() { + let mut invocation = invocation(); + assert_eq!( + invocation.validate(2_001, 2_000), + Err(ContractError::Expiry) + ); + invocation.expires_at_unix_ms = 2_500; + invocation.native_request_sha256 = [0; 32]; + assert_eq!( + invocation.validate(2_001, 2_000), + Err(ContractError::Invalid("native_request_sha256")) + ); + } + + #[test] + fn typed_tool_round_survives_messagepack() { + let invocation = invocation(); + let bytes = rmp_serde::to_vec(&invocation).unwrap(); + let decoded: TransportInvocation = rmp_serde::from_slice(&bytes).unwrap(); + assert_eq!(decoded, invocation); + assert!(matches!( + decoded.request.tools.as_slice(), + [ToolDefinition { name, .. }] if name == "read_source" + )); + } + + #[test] + fn transported_result_binds_identity_digest_and_event_order() { + let invocation = invocation(); + let result = TransportResult::transported( + &invocation, + vec![TransportEvent { + sequence: 0, + payload: TransportEventPayload::ToolCall { + call_id: "call-1".to_string(), + name: "read_source".to_string(), + arguments: "{}".to_string(), + }, + }], + receipt(&invocation), + ); + assert_eq!(result.validate_against(&invocation), Ok(())); + + let mut reordered = result; + let TransportDisposition::Transported { events, .. } = &mut reordered.disposition + else { + unreachable!() + }; + events[0].sequence = 1; + assert_eq!( + reordered.validate_against(&invocation), + Err(ContractError::EventSequence) + ); + } + + #[test] + fn receipt_refuses_caller_substitution() { + let invocation = invocation(); + let mut substituted_caller = receipt(&invocation); + substituted_caller.caller_runtime_id = "ghostlight-dungeon-yggdrasil".to_string(); + assert_eq!( + substituted_caller.validate_against(&invocation), + Err(ContractError::Identity) + ); + + let mut substituted_native_basis = receipt(&invocation); + substituted_native_basis.native_request_sha256 = [8; 32]; + assert_eq!( + substituted_native_basis.validate_against(&invocation), + Err(ContractError::NativeDigest) + ); + } +} diff --git a/crates/model-connector/src/envelope.rs b/crates/model-connector/src/envelope.rs new file mode 100644 index 0000000..713eab8 --- /dev/null +++ b/crates/model-connector/src/envelope.rs @@ -0,0 +1,286 @@ +use std::io::{Read, Write}; + +use aes_gcm::aead::{Aead, Payload}; +use aes_gcm::{Aes256Gcm, KeyInit, Nonce}; +use rand_core::{OsRng, RngCore}; +use serde::{Deserialize, Serialize}; +use thiserror::Error; + +use crate::contract::{ + ContractError, ENVELOPE_SCHEMA_ID, TransportInvocation, TransportKey, TransportResult, + canonical_invocation_bytes, require_id, +}; + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +pub enum EnvelopeKind { + Invocation, + Result, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct TransportEnvelope { + pub schema_id: String, + pub caller_runtime_id: String, + pub request_id: String, + pub message_kind: EnvelopeKind, + pub nonce: [u8; 12], + pub ciphertext: Vec, +} + +pub fn encrypt_invocation( + invocation: &TransportInvocation, + security: &TransportKey, +) -> Result { + let plaintext = canonical_invocation_bytes(invocation)?; + encrypt_message( + &invocation.caller_runtime_id, + invocation.request_id(), + EnvelopeKind::Invocation, + &plaintext, + security, + ) +} + +pub fn decrypt_invocation( + envelope: &TransportEnvelope, + security: &TransportKey, +) -> Result { + validate_envelope(envelope, EnvelopeKind::Invocation)?; + let plaintext = decrypt_message(envelope, security)?; + let invocation: TransportInvocation = + rmp_serde::from_slice(&plaintext).map_err(|_| ServiceError::Encoding)?; + if invocation.caller_runtime_id != envelope.caller_runtime_id + || invocation.request_id() != envelope.request_id + { + return Err(ServiceError::OuterIdentity); + } + Ok(invocation) +} + +pub fn encrypt_result( + result: &TransportResult, + security: &TransportKey, +) -> Result { + let plaintext = rmp_serde::to_vec(result).map_err(|_| ServiceError::Encoding)?; + encrypt_message( + &result.caller_runtime_id, + &result.request_id, + EnvelopeKind::Result, + &plaintext, + security, + ) +} + +pub fn decrypt_result( + envelope: &TransportEnvelope, + security: &TransportKey, + invocation: &TransportInvocation, +) -> Result { + validate_envelope(envelope, EnvelopeKind::Result)?; + let plaintext = decrypt_message(envelope, security)?; + let result: TransportResult = + rmp_serde::from_slice(&plaintext).map_err(|_| ServiceError::Encoding)?; + if result.caller_runtime_id != envelope.caller_runtime_id + || result.request_id != envelope.request_id + { + return Err(ServiceError::OuterIdentity); + } + result.validate_against(invocation)?; + Ok(result) +} + +#[derive(Debug)] +pub(crate) enum TransportFrameError { + Connection(std::io::Error), + Size, +} + + +pub(crate) fn read_transport_frame( + reader: &mut impl Read, + max_frame_bytes: usize, +) -> Result, TransportFrameError> { + let mut length = [0_u8; 4]; + reader + .read_exact(&mut length) + .map_err(TransportFrameError::Connection)?; + let length = u32::from_be_bytes(length) as usize; + if length == 0 || length > max_frame_bytes { + return Err(TransportFrameError::Size); + } + let mut payload = vec![0_u8; length]; + reader + .read_exact(&mut payload) + .map_err(TransportFrameError::Connection)?; + Ok(payload) +} + +pub(crate) fn write_transport_frame( + writer: &mut impl Write, + payload: &[u8], + max_frame_bytes: usize, +) -> Result<(), TransportFrameError> { + if payload.is_empty() || payload.len() > max_frame_bytes || payload.len() > u32::MAX as usize { + return Err(TransportFrameError::Size); + } + writer + .write_all(&(payload.len() as u32).to_be_bytes()) + .and_then(|()| writer.write_all(payload)) + .and_then(|()| writer.flush()) + .map_err(TransportFrameError::Connection) +} + +pub(crate) fn encrypt_message( + caller_runtime_id: &str, + request_id: &str, + message_kind: EnvelopeKind, + plaintext: &[u8], + security: &TransportKey, +) -> Result { + require_id(caller_runtime_id, "caller_runtime_id")?; + require_id(request_id, "request_id")?; + let mut nonce = [0; 12]; + OsRng.fill_bytes(&mut nonce); + let cipher = Aes256Gcm::new_from_slice(&security.0).map_err(|_| ServiceError::Encryption)?; + let aad = envelope_aad( + ENVELOPE_SCHEMA_ID, + caller_runtime_id, + request_id, + message_kind, + )?; + let ciphertext = cipher + .encrypt( + Nonce::from_slice(&nonce), + Payload { + msg: plaintext, + aad: &aad, + }, + ) + .map_err(|_| ServiceError::Encryption)?; + Ok(TransportEnvelope { + schema_id: ENVELOPE_SCHEMA_ID.to_string(), + caller_runtime_id: caller_runtime_id.to_string(), + request_id: request_id.to_string(), + message_kind, + nonce, + ciphertext, + }) +} + +pub(crate) fn decrypt_message( + envelope: &TransportEnvelope, + security: &TransportKey, +) -> Result, ServiceError> { + let cipher = Aes256Gcm::new_from_slice(&security.0).map_err(|_| ServiceError::Encryption)?; + let aad = envelope_aad( + &envelope.schema_id, + &envelope.caller_runtime_id, + &envelope.request_id, + envelope.message_kind, + )?; + cipher + .decrypt( + Nonce::from_slice(&envelope.nonce), + Payload { + msg: &envelope.ciphertext, + aad: &aad, + }, + ) + .map_err(|_| ServiceError::Encryption) +} + +pub(crate) fn envelope_aad( + schema_id: &str, + caller_runtime_id: &str, + request_id: &str, + message_kind: EnvelopeKind, +) -> Result, ServiceError> { + rmp_serde::to_vec(&(schema_id, caller_runtime_id, request_id, message_kind)) + .map_err(|_| ServiceError::Encoding) +} + +pub(crate) fn validate_envelope( + envelope: &TransportEnvelope, + expected_kind: EnvelopeKind, +) -> Result<(), ServiceError> { + if envelope.schema_id != ENVELOPE_SCHEMA_ID || envelope.message_kind != expected_kind { + return Err(ServiceError::Envelope); + } + require_id(&envelope.caller_runtime_id, "caller_runtime_id")?; + require_id(&envelope.request_id, "request_id")?; + if envelope.ciphertext.is_empty() { + return Err(ServiceError::Envelope); + } + Ok(()) +} + +#[derive(Debug, Error, PartialEq, Eq)] +pub enum ServiceError { + #[error(transparent)] + Contract(#[from] ContractError), + #[error("invalid caller admission")] + InvalidAdmission, + #[error("caller runtime was admitted twice")] + DuplicateCaller, + #[error("callers must not share transport keys")] + SharedCallerKey, + #[error("connector replay store failed: {0}")] + ReplayStore(String), + #[error("connector replay record is malformed")] + InvalidReplayRecord, + #[error("caller key changed while durable replay records still exist")] + ReplayCallerKeyMismatch, + #[error("caller runtime is not admitted")] + CallerNotAdmitted, + #[error("transport payload exceeds the caller bound")] + PayloadTooLarge, + #[error("unexpected transport envelope")] + Envelope, + #[error("transport envelope identity substitution")] + OuterIdentity, + #[error("transport encryption or authentication failed")] + Encryption, + #[error("transport MessagePack encoding failed")] + Encoding, + #[error("completion did not carry a transported provider outcome")] + InvalidCompletion, + #[error("completion has no active request")] + NoActiveRequest, + #[error("completion does not match the active request")] + ActiveRequestMismatch, +} + + +#[cfg(test)] +mod tests { + use super::*; + use crate::fixtures::*; + + #[test] + fn encrypted_invocation_hides_content_and_binds_outer_identity() { + let invocation = invocation(); + let epiphany_key = security("epiphany-distinct-test-key"); + let envelope = encrypt_invocation(&invocation, &epiphany_key).unwrap(); + assert!( + !envelope + .ciphertext + .windows("Projected state".len()) + .any(|window| window == b"Projected state") + ); + assert_eq!( + decrypt_invocation(&envelope, &epiphany_key).unwrap(), + invocation + ); + assert_eq!( + decrypt_invocation(&envelope, &security("wrong-caller-key")), + Err(ServiceError::Encryption) + ); + + let mut substituted = envelope; + substituted.request_id = "request-2".to_string(); + assert_eq!( + decrypt_invocation(&substituted, &epiphany_key), + Err(ServiceError::Encryption) + ); + } +} diff --git a/crates/model-connector/src/fixtures.rs b/crates/model-connector/src/fixtures.rs new file mode 100644 index 0000000..bb89264 --- /dev/null +++ b/crates/model-connector/src/fixtures.rs @@ -0,0 +1,129 @@ +//! Shared fixtures for the carried contract, envelope, client and service tests. +use crate::contract::*; + +#[cfg(feature = "daemon")] +use crate::service::*; + +#[cfg(feature = "daemon")] +use std::sync::atomic::{AtomicUsize, Ordering}; + +#[cfg(feature = "daemon")] +static TEST_STORE_SEQUENCE: AtomicUsize = AtomicUsize::new(0); + +#[cfg(feature = "daemon")] +pub(crate) struct TestStore { + pub(crate) root: std::path::PathBuf, + pub(crate) path: std::path::PathBuf, +} + +#[cfg(feature = "daemon")] +impl TestStore { + pub(crate) fn new() -> Self { + let sequence = TEST_STORE_SEQUENCE.fetch_add(1, Ordering::Relaxed); + let root = std::env::temp_dir().join(format!( + "model-connector-replay-{}-{sequence}", + std::process::id() + )); + std::fs::create_dir_all(&root).unwrap(); + let path = root.join("replay.cc"); + Self { root, path } + } +} + +#[cfg(feature = "daemon")] +impl Drop for TestStore { + fn drop(&mut self) { + let _ = std::fs::remove_dir_all(&self.root); + } +} + +pub(crate) fn request() -> ProviderRequest { + let mut request = ProviderRequest::new( + "request-1", + "conversation-1", + "gpt-5.4", + "Return the typed result.", + ); + request.input.push(InputItem::UserText { + text: "Projected state".to_string(), + }); + request.tools.push(ToolDefinition { + name: "read_source".to_string(), + description: "Read one admitted source.".to_string(), + parameters_json: r#"{"type":"object"}"#.to_string(), + }); + request +} + +pub(crate) fn invocation() -> TransportInvocation { + TransportInvocation::new("epiphany-yggdrasil", 2_000, [7; 32], request()).unwrap() +} + +pub(crate) fn receipt(invocation: &TransportInvocation) -> TransportReceipt { + TransportReceipt { + schema_id: RECEIPT_SCHEMA_ID.to_string(), + request_id: invocation.request_id().to_string(), + caller_runtime_id: invocation.caller_runtime_id.clone(), + native_request_sha256: invocation.native_request_sha256, + provider_request_sha256: invocation.provider_request_sha256, + model: invocation.request.model.clone(), + transport: "fixture-sse".to_string(), + outcome: TransportOutcome::Completed { + provider_response_id: Some("response-1".to_string()), + input_tokens: Some(10), + output_tokens: Some(3), + reasoning_output_tokens: Some(1), + cached_input_tokens: None, + }, + } +} +#[cfg(feature = "daemon")] +pub(crate) fn caller( + caller_runtime_id: &str, + key: &str, + max_concurrent_requests: usize, +) -> CallerAdmission { + CallerAdmission::new( + caller_runtime_id, + key, + 1, + ["gpt-5.4".to_string()], + max_concurrent_requests, + 64 * 1024, + 32_768, + ) + .unwrap() +} + +#[cfg(feature = "daemon")] +pub(crate) fn open_service(store: &TestStore, max_concurrent_requests: usize) -> TransportService { + TransportService::open( + &store.path, + [ + caller( + "epiphany-yggdrasil", + "epiphany-distinct-test-key", + max_concurrent_requests, + ), + caller( + "ghostlight-yggdrasil", + "ghostlight-distinct-test-key", + max_concurrent_requests, + ), + ], + 5_000, + ) + .unwrap() +} + +pub(crate) fn security(key: &str) -> TransportKey { + TransportKey::from_connection_secret(key).unwrap() +} + +#[cfg(feature = "daemon")] +pub(crate) fn invocation_for(caller_runtime_id: &str, request_id: &str) -> TransportInvocation { + let mut request = request(); + request.request_id = request_id.to_string(); + TransportInvocation::new(caller_runtime_id, 2_000, [7; 32], request).unwrap() +} + diff --git a/crates/model-connector/src/lib.rs b/crates/model-connector/src/lib.rs new file mode 100644 index 0000000..f288476 --- /dev/null +++ b/crates/model-connector/src/lib.rs @@ -0,0 +1,10 @@ +mod client; +mod contract; +mod envelope; + +#[cfg(test)] +mod fixtures; + +pub use client::*; +pub use contract::*; +pub use envelope::*; From b26ee5e676df5c4986e0a8cae77221550ef63586 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Wed, 28 Oct 2026 04:40:15 +0100 Subject: [PATCH 02/69] Lift the replay service and serve loop behind a ProviderBackend trait service.rs is CodexConnector lib.rs 954-1313 verbatim under the rename rule. serve.rs is the accept loop of daemon.rs over ProviderBackend (ready, execute), ServeLimits, CallerConfig and load_admissions; no config document, no provider, no Idunn route observation. serve refuses a non-loopback listener and drops non-loopback peers. ready() is consulted once, before replay state is opened and before the first accept, as base consulted readiness at startup; execute is called on the request path without consulting it (base only checked readiness for health). New tests: end-to-end replay with a counting backend, readiness timing, limits, peer and listener refusal, connection permits, and behavioural kills for survivors in service policy, payload bound, refusal mapping, restart record validation and completion mismatch. Co-Authored-By: Claude Opus 5.5 --- crates/model-connector/src/lib.rs | 10 + crates/model-connector/src/serve.rs | 649 +++++++++++++++++++++ crates/model-connector/src/service.rs | 788 ++++++++++++++++++++++++++ 3 files changed, 1447 insertions(+) create mode 100644 crates/model-connector/src/serve.rs create mode 100644 crates/model-connector/src/service.rs diff --git a/crates/model-connector/src/lib.rs b/crates/model-connector/src/lib.rs index f288476..ba1642b 100644 --- a/crates/model-connector/src/lib.rs +++ b/crates/model-connector/src/lib.rs @@ -5,6 +5,16 @@ mod envelope; #[cfg(test)] mod fixtures; +#[cfg(feature = "daemon")] +mod serve; +#[cfg(feature = "daemon")] +mod service; + pub use client::*; pub use contract::*; pub use envelope::*; + +#[cfg(feature = "daemon")] +pub use serve::*; +#[cfg(feature = "daemon")] +pub use service::*; diff --git a/crates/model-connector/src/serve.rs b/crates/model-connector/src/serve.rs new file mode 100644 index 0000000..1b613f5 --- /dev/null +++ b/crates/model-connector/src/serve.rs @@ -0,0 +1,649 @@ +use std::fs; +use std::net::{IpAddr, TcpListener, TcpStream}; +use std::path::{Path, PathBuf}; +use std::sync::atomic::{AtomicUsize, Ordering}; +use std::sync::{Arc, Mutex}; +use std::thread; +use std::time::{Duration, SystemTime, UNIX_EPOCH}; + +use serde::{Deserialize, Serialize}; +use thiserror::Error; +use zeroize::Zeroizing; + +use crate::contract::{TransportInvocation, TransportResult}; +use crate::envelope::{ + ServiceError, TransportEnvelope, TransportFrameError, read_transport_frame, + write_transport_frame, +}; +use crate::service::{CallerAdmission, TransportAdmission, TransportService}; + +/// The only provider seam of the connector kit. A connector binary implements +/// it over one model provider; the kit names no provider. +pub trait ProviderBackend: Send + Sync { + /// Whether the provider can take work. `serve` consults it once, before it + /// opens the replay store or accepts a connection; the request path does + /// not consult it. + fn ready(&self) -> bool; + /// Executes one admitted invocation. The service has already bound the + /// invocation to its caller and recorded its replay claim. + fn execute(&self, invocation: &TransportInvocation) -> TransportResult; +} + +/// Bounds of one serve loop. The connector binary's own document owns where +/// they come from. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct ServeLimits { + pub max_frame_bytes: usize, + pub max_connections: usize, + pub socket_timeout_ms: u64, + pub max_expiry_skew_ms: u64, +} + +impl ServeLimits { + fn validate(&self) -> Result<(), ServeError> { + if self.max_frame_bytes == 0 + || self.max_frame_bytes > u32::MAX as usize + || self.max_connections == 0 + || self.socket_timeout_ms == 0 + || self.max_expiry_skew_ms == 0 + { + return Err(ServeError::InvalidConfig("serve limits")); + } + Ok(()) + } +} + +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct CallerConfig { + pub caller_runtime_id: String, + pub connection_key_file: PathBuf, + pub connection_key_epoch: u32, + pub allowed_models: Vec, + pub max_concurrent_requests: usize, + pub max_payload_bytes: usize, + pub max_output_tokens: u32, +} + +pub fn required_connection_capacity(callers: &[CallerConfig]) -> Result { + Ok(caller_request_capacity(callers)?.max(8)) +} + +pub fn caller_request_capacity(callers: &[CallerConfig]) -> Result { + callers + .iter() + .try_fold(0_usize, |total, caller| { + total.checked_add(caller.max_concurrent_requests) + }) + .ok_or(ServeError::InvalidConfig("connection capacity")) +} + +pub fn load_admissions(callers: &[CallerConfig]) -> Result, ServeError> { + let mut admissions = Vec::with_capacity(callers.len()); + for caller in callers { + let raw = Zeroizing::new( + fs::read(&caller.connection_key_file).map_err(ServeError::ConnectionKey)?, + ); + let secret = std::str::from_utf8(raw.as_slice()) + .map_err(|_| ServeError::InvalidConfig("caller connection key encoding"))? + .trim_end_matches(['\r', '\n']); + admissions.push( + CallerAdmission::new( + caller.caller_runtime_id.clone(), + secret.to_string(), + caller.connection_key_epoch, + caller.allowed_models.clone(), + caller.max_concurrent_requests, + caller.max_payload_bytes, + caller.max_output_tokens, + ) + .map_err(ServeError::Service)?, + ); + } + Ok(admissions) +} + +/// Serves admitted callers on a loopback listener until the listener fails. +/// The caller binds; a listener that is not bound to loopback is refused, and +/// peers that are not loopback are dropped. +pub fn serve( + listener: TcpListener, + limits: ServeLimits, + admissions: Vec, + replay_store: &Path, + backend: Arc, +) -> Result<(), ServeError> { + limits.validate()?; + if admissions.is_empty() { + return Err(ServeError::InvalidConfig("callers")); + } + if !listener + .local_addr() + .map_err(ServeError::Listen)? + .ip() + .is_loopback() + { + return Err(ServeError::InvalidConfig("listener address")); + } + if !backend.ready() { + return Err(ServeError::BackendUnready); + } + let service = Arc::new(Mutex::new(TransportService::open( + replay_store, + admissions, + limits.max_expiry_skew_ms, + )?)); + let active_connections = Arc::new(AtomicUsize::new(0)); + let timeout = Duration::from_millis(limits.socket_timeout_ms); + + loop { + let (stream, peer) = listener.accept().map_err(ServeError::Listen)?; + if !peer_admitted(peer.ip()) { + continue; + } + let Some(permit) = + ConnectionPermit::acquire(active_connections.clone(), limits.max_connections) + else { + continue; + }; + let service = service.clone(); + let backend = backend.clone(); + let max_frame_bytes = limits.max_frame_bytes; + thread::Builder::new() + .name("model-connector-request".to_string()) + .spawn(move || { + let _permit = permit; + let _ = serve_connection( + stream, + &service, + backend.as_ref(), + max_frame_bytes, + timeout, + ); + }) + .map_err(ServeError::Thread)?; + } +} + +fn peer_admitted(peer: IpAddr) -> bool { + peer.is_loopback() +} + +fn serve_connection( + mut stream: TcpStream, + service: &Mutex, + backend: &dyn ProviderBackend, + max_frame_bytes: usize, + timeout: Duration, +) -> Result<(), ServeError> { + stream + .set_read_timeout(Some(timeout)) + .and_then(|()| stream.set_write_timeout(Some(timeout))) + .map_err(ServeError::Connection)?; + let request = read_transport_frame(&mut stream, max_frame_bytes).map_err(frame_error)?; + let response = execute_inference_frame(&request, service, backend)?; + write_transport_frame(&mut stream, &response, max_frame_bytes).map_err(frame_error) +} + +fn execute_inference_frame( + request: &[u8], + service: &Mutex, + backend: &dyn ProviderBackend, +) -> Result, ServeError> { + let envelope: TransportEnvelope = + rmp_serde::from_slice(request).map_err(|_| ServeError::FrameEncoding)?; + let admission = service + .lock() + .map_err(|_| ServeError::ServicePoisoned)? + .begin(&envelope, unix_ms()?)?; + let response = match admission { + TransportAdmission::Reply(response) => response, + TransportAdmission::Execute(claim) => { + let result = backend.execute(claim.invocation()); + service + .lock() + .map_err(|_| ServeError::ServicePoisoned)? + .complete(*claim, result)? + } + }; + rmp_serde::to_vec(&response).map_err(|_| ServeError::FrameEncoding) +} + +fn frame_error(error: TransportFrameError) -> ServeError { + match error { + TransportFrameError::Connection(error) => ServeError::Connection(error), + TransportFrameError::Size => ServeError::FrameSize, + } +} + +fn unix_ms() -> Result { + let millis = SystemTime::now() + .duration_since(UNIX_EPOCH) + .map_err(|_| ServeError::Clock)? + .as_millis(); + u64::try_from(millis).map_err(|_| ServeError::Clock) +} + +struct ConnectionPermit { + active: Arc, +} + +impl ConnectionPermit { + fn acquire(active: Arc, limit: usize) -> Option { + let mut observed = active.load(Ordering::Acquire); + loop { + if observed >= limit { + return None; + } + match active.compare_exchange_weak( + observed, + observed + 1, + Ordering::AcqRel, + Ordering::Acquire, + ) { + Ok(_) => return Some(Self { active }), + Err(current) => observed = current, + } + } + } +} + +impl Drop for ConnectionPermit { + fn drop(&mut self) { + self.active.fetch_sub(1, Ordering::AcqRel); + } +} + +#[derive(Debug, Error)] +pub enum ServeError { + #[error("invalid serve configuration field {0}")] + InvalidConfig(&'static str), + #[error("connector connection key is unavailable")] + ConnectionKey(#[source] std::io::Error), + #[error(transparent)] + Service(#[from] ServiceError), + #[error("provider backend is not ready")] + BackendUnready, + #[error("connector service lock was poisoned")] + ServicePoisoned, + #[error("connector listener failed")] + Listen(#[source] std::io::Error), + #[error("connector request thread failed")] + Thread(#[source] std::io::Error), + #[error("connector connection failed")] + Connection(#[source] std::io::Error), + #[error("connector frame exceeded its bound")] + FrameSize, + #[error("connector frame was not valid MessagePack")] + FrameEncoding, + #[error("system clock is before the Unix epoch")] + Clock, +} + +#[cfg(test)] +mod tests { + use std::io::Cursor; + use std::net::Ipv4Addr; + + use super::*; + use crate::client::ConnectorClient; + use crate::contract::{ + Refusal, TransportDisposition, TransportEvent, TransportEventPayload, TransportResult, + }; + use crate::fixtures::{TestStore, caller, invocation_for, receipt, request}; + + const CALLER: &str = "epiphany-yggdrasil"; + const KEY: &str = "epiphany-distinct-test-key"; + + struct CountingBackend { + ready: bool, + ready_calls: AtomicUsize, + executions: AtomicUsize, + } + + impl CountingBackend { + fn new(ready: bool) -> Arc { + Arc::new(Self { + ready, + ready_calls: AtomicUsize::new(0), + executions: AtomicUsize::new(0), + }) + } + } + + impl ProviderBackend for CountingBackend { + fn ready(&self) -> bool { + self.ready_calls.fetch_add(1, Ordering::SeqCst); + self.ready + } + + fn execute(&self, invocation: &TransportInvocation) -> TransportResult { + self.executions.fetch_add(1, Ordering::SeqCst); + TransportResult::transported( + invocation, + vec![TransportEvent { + sequence: 0, + payload: TransportEventPayload::TextDelta { + text: "done".to_string(), + }, + }], + receipt(invocation), + ) + } + } + + fn limits() -> ServeLimits { + ServeLimits { + max_frame_bytes: 64 * 1024, + max_connections: 8, + socket_timeout_ms: 5_000, + max_expiry_skew_ms: 300_000, + } + } + + /// The test's own clock, so a mutated `unix_ms` cannot move the fixture with it. + fn now_ms() -> u64 { + SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap() + .as_millis() as u64 + } + + /// What `serve` returned, or `None` when it is still serving after two seconds. + fn outcome( + listener: TcpListener, + limits: ServeLimits, + admissions: Vec, + path: &Path, + backend: Arc, + ) -> Option> { + let (sender, receiver) = std::sync::mpsc::channel(); + let path = path.to_path_buf(); + thread::spawn(move || { + let _ = sender.send(serve(listener, limits, admissions, &path, backend)); + }); + receiver.recv_timeout(Duration::from_secs(2)).ok() + } + + fn fresh(request_id: &str) -> TransportInvocation { + let mut invocation = invocation_for(CALLER, request_id); + invocation.expires_at_unix_ms = now_ms() + 60_000; + invocation + } + + fn start( + backend: Arc, + store: &TestStore, + ) -> (ConnectorClient, std::net::SocketAddr) { + let listener = TcpListener::bind("127.0.0.1:0").unwrap(); + let endpoint = listener.local_addr().unwrap(); + let path = store.path.clone(); + thread::spawn(move || { + serve( + listener, + limits(), + vec![caller(CALLER, KEY, 4)], + &path, + backend, + ) + }); + let client = + ConnectorClient::new(endpoint, KEY, 64 * 1024, Some(Duration::from_secs(5))).unwrap(); + (client, endpoint) + } + + fn callers(capacities: &[usize]) -> Vec { + capacities + .iter() + .enumerate() + .map(|(index, capacity)| CallerConfig { + caller_runtime_id: format!("caller-{index}"), + connection_key_file: PathBuf::from(format!("caller-{index}.key")), + connection_key_epoch: 1, + allowed_models: vec!["gpt-5.6-luna".to_string()], + max_concurrent_requests: *capacity, + max_payload_bytes: 1_048_576, + max_output_tokens: 16_384, + }) + .collect() + } + + #[test] + fn serve_end_to_end_replays_without_a_second_execution() { + let store = TestStore::new(); + let backend = CountingBackend::new(true); + let (client, _) = start(backend.clone(), &store); + let invocation = fresh("request-1"); + + let first = client.execute(&invocation).unwrap(); + let second = client.execute(&invocation).unwrap(); + + assert!(matches!( + first.disposition, + TransportDisposition::Transported { .. } + )); + assert_eq!(first, second); + assert_eq!(backend.executions.load(Ordering::SeqCst), 1); + + let mut conflicting_request = request(); + conflicting_request.request_id = "request-1".to_string(); + conflicting_request.instructions = "A different instruction.".to_string(); + let mut conflicting = TransportInvocation::new( + CALLER, + invocation.expires_at_unix_ms, + [7; 32], + conflicting_request, + ) + .unwrap(); + conflicting.expires_at_unix_ms = invocation.expires_at_unix_ms; + let refused = client.execute(&conflicting).unwrap(); + assert_eq!( + refused.disposition, + TransportDisposition::Refused(Refusal::ReplayConflict) + ); + assert_eq!(backend.executions.load(Ordering::SeqCst), 1); + } + + #[test] + fn serve_replay_is_byte_identical_on_the_wire() { + let store = TestStore::new(); + let backend = CountingBackend::new(true); + let (_client, endpoint) = start(backend.clone(), &store); + let security = crate::fixtures::security(KEY); + let request = rmp_serde::to_vec( + &crate::envelope::encrypt_invocation(&fresh("request-wire"), &security).unwrap(), + ) + .unwrap(); + let mut replies = Vec::new(); + for _ in 0..2 { + let mut stream = TcpStream::connect(endpoint).unwrap(); + write_transport_frame(&mut stream, &request, 64 * 1024).unwrap(); + replies.push(read_transport_frame(&mut stream, 64 * 1024).unwrap()); + } + assert_eq!(replies[0], replies[1]); + assert_eq!(backend.executions.load(Ordering::SeqCst), 1); + } + + #[test] + fn serve_refuses_to_start_on_an_unready_backend() { + let store = TestStore::new(); + let backend = CountingBackend::new(false); + let result = outcome( + TcpListener::bind("127.0.0.1:0").unwrap(), + limits(), + vec![caller(CALLER, KEY, 4)], + &store.path, + backend.clone(), + ); + assert!(matches!(result, Some(Err(ServeError::BackendUnready)))); + assert_eq!(backend.executions.load(Ordering::SeqCst), 0); + assert!( + !store.path.exists(), + "an unready backend must not open replay state" + ); + } + + #[test] + fn serve_consults_readiness_at_start_and_never_on_the_request_path() { + let store = TestStore::new(); + let backend = CountingBackend::new(true); + let (client, _) = start(backend.clone(), &store); + client.execute(&fresh("request-a")).unwrap(); + client.execute(&fresh("request-b")).unwrap(); + assert_eq!(backend.executions.load(Ordering::SeqCst), 2); + assert_eq!(backend.ready_calls.load(Ordering::SeqCst), 1); + } + + #[test] + fn serve_refuses_a_listener_that_is_not_loopback() { + let store = TestStore::new(); + let backend = CountingBackend::new(true); + let result = outcome( + TcpListener::bind((Ipv4Addr::UNSPECIFIED, 0)).unwrap(), + limits(), + vec![caller(CALLER, KEY, 4)], + &store.path, + backend.clone(), + ); + assert!(matches!( + result, + Some(Err(ServeError::InvalidConfig("listener address"))) + )); + assert_eq!(backend.ready_calls.load(Ordering::SeqCst), 0); + } + + #[test] + fn serve_refuses_unbounded_limits_and_admits_the_widest_frame() { + let store = TestStore::new(); + let mutations: [fn(&mut ServeLimits); 5] = [ + |limits| limits.max_frame_bytes = 0, + |limits| limits.max_frame_bytes = u32::MAX as usize + 1, + |limits| limits.max_connections = 0, + |limits| limits.socket_timeout_ms = 0, + |limits| limits.max_expiry_skew_ms = 0, + ]; + for mutate in mutations { + let mut bounded = limits(); + mutate(&mut bounded); + let result = outcome( + TcpListener::bind("127.0.0.1:0").unwrap(), + bounded, + vec![caller(CALLER, KEY, 4)], + &store.path, + CountingBackend::new(true), + ); + assert!(matches!( + result, + Some(Err(ServeError::InvalidConfig("serve limits"))) + )); + } + let widest = ServeLimits { + max_frame_bytes: u32::MAX as usize, + ..limits() + }; + let result = outcome( + TcpListener::bind("127.0.0.1:0").unwrap(), + widest, + vec![caller(CALLER, KEY, 4)], + &store.path, + CountingBackend::new(true), + ); + assert!(result.is_none(), "the widest frame the wire can carry must serve"); + } + + #[test] + fn serve_refuses_an_empty_caller_set() { + let store = TestStore::new(); + let result = outcome( + TcpListener::bind("127.0.0.1:0").unwrap(), + limits(), + Vec::new(), + &store.path, + CountingBackend::new(true), + ); + assert!(matches!( + result, + Some(Err(ServeError::InvalidConfig("callers"))) + )); + } + + #[test] + fn only_loopback_peers_are_admitted() { + assert!(peer_admitted(IpAddr::from([127, 0, 0, 1]))); + assert!(peer_admitted(IpAddr::from(std::net::Ipv6Addr::LOCALHOST))); + assert!(!peer_admitted(IpAddr::from([192, 168, 1, 7]))); + assert!(!peer_admitted(IpAddr::from([172, 19, 0, 1]))); + } + + #[test] + fn connection_permits_are_bounded_and_returned_on_drop() { + let active = Arc::new(AtomicUsize::new(0)); + let first = ConnectionPermit::acquire(active.clone(), 2).unwrap(); + let second = ConnectionPermit::acquire(active.clone(), 2).unwrap(); + assert!(ConnectionPermit::acquire(active.clone(), 2).is_none()); + drop(first); + let third = ConnectionPermit::acquire(active.clone(), 2).unwrap(); + assert!(ConnectionPermit::acquire(active.clone(), 2).is_none()); + drop((second, third)); + assert_eq!(active.load(Ordering::SeqCst), 0); + } + + #[test] + fn caller_admission_reserves_every_independently_admitted_request_slot() { + let both = callers(&[8, 8]); + assert_eq!(caller_request_capacity(&both).unwrap(), 16); + assert_eq!(required_connection_capacity(&both).unwrap(), 16); + assert_eq!(required_connection_capacity(&callers(&[3])).unwrap(), 8); + assert!(matches!( + caller_request_capacity(&callers(&[usize::MAX, 1])), + Err(ServeError::InvalidConfig("connection capacity")) + )); + } + + #[test] + fn provider_capacity_is_caller_execution_capacity_not_listener_capacity() { + let callers = callers(&[4]); + assert_eq!(caller_request_capacity(&callers).unwrap(), 4); + assert_eq!(required_connection_capacity(&callers).unwrap(), 8); + } + + #[test] + fn direct_pipe_frame_is_big_endian_and_refuses_oversize_before_allocation() { + let mut encoded = Vec::new(); + write_transport_frame(&mut encoded, b"typed", 16).unwrap(); + assert_eq!(&encoded[..4], &5_u32.to_be_bytes()); + assert_eq!( + read_transport_frame(&mut Cursor::new(encoded), 16).unwrap(), + b"typed" + ); + + let oversized = 17_u32.to_be_bytes().to_vec(); + assert!(matches!( + read_transport_frame(&mut Cursor::new(oversized), 16), + Err(TransportFrameError::Size) + )); + } + + #[test] + fn load_admissions_reads_the_key_file_without_its_trailing_newline() { + let store = TestStore::new(); + let key_file = store.root.join("caller.key"); + fs::write(&key_file, format!("{KEY}\r\n")).unwrap(); + let mut config = callers(&[4]); + config[0].caller_runtime_id = CALLER.to_string(); + config[0].connection_key_file = key_file; + config[0].allowed_models = vec!["gpt-5.4".to_string()]; + config[0].max_payload_bytes = 64 * 1024; + let admissions = load_admissions(&config).unwrap(); + + let listener = TcpListener::bind("127.0.0.1:0").unwrap(); + let endpoint = listener.local_addr().unwrap(); + let backend = CountingBackend::new(true); + let path = store.path.clone(); + let served = backend.clone(); + thread::spawn(move || serve(listener, limits(), admissions, &path, served)); + let client = + ConnectorClient::new(endpoint, KEY, 64 * 1024, Some(Duration::from_secs(5))).unwrap(); + client.execute(&fresh("request-key")).unwrap(); + assert_eq!(backend.executions.load(Ordering::SeqCst), 1); + } +} diff --git a/crates/model-connector/src/service.rs b/crates/model-connector/src/service.rs new file mode 100644 index 0000000..1413537 --- /dev/null +++ b/crates/model-connector/src/service.rs @@ -0,0 +1,788 @@ +use std::collections::{HashMap, HashSet}; +use std::path::Path; + +use cultcache_rs::{CultCache, DatabaseEntry, OwnedRedbMessagePackBackingStore}; +use serde::{Deserialize, Serialize}; +use sha2::{Digest, Sha256}; +use zeroize::Zeroizing; + +use crate::contract::{ + ContractError, Refusal, TransportDisposition, TransportInvocation, TransportKey, + TransportResult, canonical_invocation_bytes, require_id, +}; +use crate::envelope::{ + EnvelopeKind, ServiceError, TransportEnvelope, decrypt_invocation, encrypt_result, + validate_envelope, +}; + +pub struct CallerAdmission { + caller_runtime_id: String, + connection_key_epoch: u32, + security: TransportKey, + allowed_models: HashSet, + max_concurrent_requests: usize, + max_payload_bytes: usize, + max_output_tokens: u32, +} + +impl CallerAdmission { + pub fn new( + caller_runtime_id: impl Into, + connection_key: impl Into, + connection_key_epoch: u32, + allowed_models: impl IntoIterator, + max_concurrent_requests: usize, + max_payload_bytes: usize, + max_output_tokens: u32, + ) -> Result { + let caller_runtime_id = caller_runtime_id.into(); + require_id(&caller_runtime_id, "caller_runtime_id")?; + let allowed_models = allowed_models.into_iter().collect::>(); + if connection_key_epoch == 0 + || allowed_models.is_empty() + || allowed_models + .iter() + .any(|model| require_id(model, "allowed_model").is_err()) + || max_concurrent_requests == 0 + || max_payload_bytes == 0 + || max_output_tokens == 0 + { + return Err(ServiceError::InvalidAdmission); + } + let connection_key = Zeroizing::new(connection_key.into()); + let security = TransportKey::from_connection_secret(connection_key.as_str())?; + Ok(Self { + caller_runtime_id, + connection_key_epoch, + security, + allowed_models, + max_concurrent_requests, + max_payload_bytes, + max_output_tokens, + }) + } + + pub fn caller_runtime_id(&self) -> &str { + &self.caller_runtime_id + } +} + +#[derive(Debug, Clone, PartialEq, Eq, Hash)] +struct RequestIdentity { + caller_runtime_id: String, + request_id: String, +} + +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +enum ReplayState { + Active, + Completed { response: TransportEnvelope }, +} + +#[derive(Clone, Debug, PartialEq, Eq, DatabaseEntry)] +#[cultcache(type = "gamecult.codex.replay_record.v1")] +struct ReplayRecord { + #[cultcache(key = 0)] + caller_runtime_id: String, + #[cultcache(key = 1)] + request_id: String, + #[cultcache(key = 2)] + connection_key_epoch: u32, + #[cultcache(key = 3)] + invocation_sha256: [u8; 32], + #[cultcache(key = 4)] + expires_at_unix_ms: u64, + #[cultcache(key = 5)] + state: ReplayState, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ExecutionClaim { + invocation: TransportInvocation, + invocation_sha256: [u8; 32], +} + +impl ExecutionClaim { + pub fn invocation(&self) -> &TransportInvocation { + &self.invocation + } +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum TransportAdmission { + Execute(Box), + Reply(TransportEnvelope), +} + +pub struct TransportService { + callers: HashMap, + live: HashSet, + replay_store: CultCache, + max_expiry_skew_ms: u64, +} + +impl TransportService { + pub fn open( + replay_store_path: &Path, + admissions: impl IntoIterator, + max_expiry_skew_ms: u64, + ) -> Result { + if max_expiry_skew_ms == 0 { + return Err(ServiceError::InvalidAdmission); + } + let mut callers = HashMap::new(); + let mut keys = HashSet::new(); + for admission in admissions { + if !keys.insert(admission.security.0) { + return Err(ServiceError::SharedCallerKey); + } + let caller_runtime_id = admission.caller_runtime_id.clone(); + if callers.insert(caller_runtime_id, admission).is_some() { + return Err(ServiceError::DuplicateCaller); + } + } + if callers.is_empty() { + return Err(ServiceError::InvalidAdmission); + } + + let mut replay_store = CultCache::new(); + replay_store + .register_entry_type::() + .map_err(replay_store_error)?; + replay_store.add_generic_backing_store( + OwnedRedbMessagePackBackingStore::new(replay_store_path).map_err(replay_store_error)?, + ); + replay_store + .pull_all_backing_stores() + .map_err(replay_store_error)?; + + for (key, record) in replay_store + .get_all_with_keys::() + .map_err(replay_store_error)? + { + let identity = RequestIdentity { + caller_runtime_id: record.caller_runtime_id.clone(), + request_id: record.request_id.clone(), + }; + if key != replay_key(&identity) + || record.invocation_sha256 == [0; 32] + || record.expires_at_unix_ms == 0 + { + return Err(ServiceError::InvalidReplayRecord); + } + let Some(caller) = callers.get(&record.caller_runtime_id) else { + continue; + }; + if record.connection_key_epoch != caller.connection_key_epoch { + return Err(ServiceError::ReplayCallerKeyMismatch); + } + } + Ok(Self { + callers, + live: HashSet::new(), + replay_store, + max_expiry_skew_ms, + }) + } + + pub fn begin( + &mut self, + envelope: &TransportEnvelope, + now_unix_ms: u64, + ) -> Result { + validate_envelope(envelope, EnvelopeKind::Invocation)?; + let caller = self + .callers + .get(&envelope.caller_runtime_id) + .ok_or(ServiceError::CallerNotAdmitted)?; + if envelope.ciphertext.len() > caller.max_payload_bytes { + return Err(ServiceError::PayloadTooLarge); + } + let security = caller.security.clone(); + let max_concurrent_requests = caller.max_concurrent_requests; + let invocation = decrypt_invocation(envelope, &security)?; + if let Err(error) = invocation.validate(now_unix_ms, self.max_expiry_skew_ms) { + return self.reply_refusal(&invocation, contract_refusal(&error), &security); + } + if !caller.allowed_models.contains(&invocation.request.model) + || invocation + .request + .max_output_tokens + .is_some_and(|limit| limit > caller.max_output_tokens) + { + return self.reply_refusal(&invocation, Refusal::Policy, &security); + } + + let identity = RequestIdentity { + caller_runtime_id: invocation.caller_runtime_id.clone(), + request_id: invocation.request_id().to_string(), + }; + let invocation_sha256: [u8; 32] = + Sha256::digest(canonical_invocation_bytes(&invocation)?).into(); + if let Some(record) = self + .replay_store + .get::(&replay_key(&identity)) + .map_err(replay_store_error)? + { + if record.invocation_sha256 != invocation_sha256 { + return self.reply_refusal(&invocation, Refusal::ReplayConflict, &security); + } + return match record.state { + ReplayState::Completed { response } => { + Ok(TransportAdmission::Reply(response)) + } + ReplayState::Active => { + let refusal = if self.live.contains(&identity) { + Refusal::InFlight + } else { + Refusal::Indeterminate + }; + self.reply_refusal(&invocation, refusal, &security) + } + }; + } + let active_for_caller = self + .live + .iter() + .filter(|key| key.caller_runtime_id == invocation.caller_runtime_id) + .count(); + if active_for_caller >= max_concurrent_requests { + return self.reply_refusal(&invocation, Refusal::Capacity, &security); + } + + let replay_record = ReplayRecord { + caller_runtime_id: identity.caller_runtime_id.clone(), + request_id: identity.request_id.clone(), + connection_key_epoch: caller.connection_key_epoch, + invocation_sha256, + expires_at_unix_ms: invocation.expires_at_unix_ms, + state: ReplayState::Active, + }; + self.replay_store + .put(replay_key(&identity), &replay_record) + .map_err(replay_store_error)?; + self.live.insert(identity); + Ok(TransportAdmission::Execute(Box::new( + ExecutionClaim { + invocation, + invocation_sha256, + }, + ))) + } + + pub fn complete( + &mut self, + claim: ExecutionClaim, + result: TransportResult, + ) -> Result { + if !matches!( + &result.disposition, + TransportDisposition::Transported { .. } + ) { + return Err(ServiceError::InvalidCompletion); + } + result.validate_against(&claim.invocation)?; + let identity = RequestIdentity { + caller_runtime_id: claim.invocation.caller_runtime_id.clone(), + request_id: claim.invocation.request_id().to_string(), + }; + if !self.live.contains(&identity) { + return Err(ServiceError::NoActiveRequest); + } + let active = self + .replay_store + .get::(&replay_key(&identity)) + .map_err(replay_store_error)? + .ok_or(ServiceError::NoActiveRequest)?; + if active.invocation_sha256 != claim.invocation_sha256 + || !matches!(active.state, ReplayState::Active) + { + return Err(ServiceError::ActiveRequestMismatch); + } + let caller = self + .callers + .get(&identity.caller_runtime_id) + .ok_or(ServiceError::CallerNotAdmitted)?; + let response = encrypt_result(&result, &caller.security)?; + let replay_record = ReplayRecord { + caller_runtime_id: identity.caller_runtime_id.clone(), + request_id: identity.request_id.clone(), + connection_key_epoch: caller.connection_key_epoch, + invocation_sha256: claim.invocation_sha256, + expires_at_unix_ms: claim.invocation.expires_at_unix_ms, + state: ReplayState::Completed { + response: response.clone(), + }, + }; + self.replay_store + .put(replay_key(&identity), &replay_record) + .map_err(replay_store_error)?; + self.live.remove(&identity); + Ok(response) + } + + fn reply_refusal( + &self, + invocation: &TransportInvocation, + refusal: Refusal, + security: &TransportKey, + ) -> Result { + Ok(TransportAdmission::Reply(encrypt_result( + &TransportResult::refused(invocation, refusal), + security, + )?)) + } +} + +fn replay_key(identity: &RequestIdentity) -> String { + let mut hasher = Sha256::new(); + hasher.update((identity.caller_runtime_id.len() as u64).to_be_bytes()); + hasher.update(identity.caller_runtime_id.as_bytes()); + hasher.update((identity.request_id.len() as u64).to_be_bytes()); + hasher.update(identity.request_id.as_bytes()); + const HEX: &[u8; 16] = b"0123456789abcdef"; + let digest = hasher.finalize(); + let mut key = String::with_capacity(64); + for byte in digest { + key.push(HEX[(byte >> 4) as usize] as char); + key.push(HEX[(byte & 0x0f) as usize] as char); + } + key +} + +fn replay_store_error(error: impl std::fmt::Display) -> ServiceError { + ServiceError::ReplayStore(error.to_string()) +} + +fn contract_refusal(error: &ContractError) -> Refusal { + match error { + ContractError::Expiry => Refusal::Expired, + ContractError::Identity => Refusal::IdentitySubstitution, + ContractError::ProviderDigest => Refusal::ProviderDigestSubstitution, + _ => Refusal::Malformed, + } +} + + +#[cfg(all(test, feature = "daemon"))] +mod tests { + use super::*; + use crate::fixtures::*; + use crate::{contract::*, envelope::*}; + + #[test] + fn service_refuses_shared_keys_and_isolates_caller_request_identities() { + let store = TestStore::new(); + assert!(matches!( + TransportService::open( + &store.path, + [ + caller("epiphany-yggdrasil", "shared-key", 1), + caller("ghostlight-yggdrasil", "shared-key", 1), + ], + 5_000, + ), + Err(ServiceError::SharedCallerKey) + )); + + let epiphany = invocation_for("epiphany-yggdrasil", "shared-request-id"); + let ghostlight = invocation_for("ghostlight-yggdrasil", "shared-request-id"); + let mut service = open_service(&store, 1); + assert!(matches!( + service + .begin( + &encrypt_invocation(&epiphany, &security("epiphany-distinct-test-key")) + .unwrap(), + 1_000, + ) + .unwrap(), + TransportAdmission::Execute(_) + )); + assert!(matches!( + service + .begin( + &encrypt_invocation(&ghostlight, &security("ghostlight-distinct-test-key")) + .unwrap(), + 1_000, + ) + .unwrap(), + TransportAdmission::Execute(_) + )); + } + + #[test] + fn service_returns_exact_completed_replay_and_refuses_conflicting_replay() { + let store = TestStore::new(); + let invocation = invocation(); + let security = security("epiphany-distinct-test-key"); + let mut service = open_service(&store, 1); + let claim = match service + .begin(&encrypt_invocation(&invocation, &security).unwrap(), 1_000) + .unwrap() + { + TransportAdmission::Execute(claim) => claim, + TransportAdmission::Reply(_) => panic!("first invocation must execute"), + }; + let response = service + .complete( + *claim, + TransportResult::transported( + &invocation, + vec![TransportEvent { + sequence: 0, + payload: TransportEventPayload::ToolCall { + call_id: "call-1".to_string(), + name: "read_source".to_string(), + arguments: "{}".to_string(), + }, + }], + receipt(&invocation), + ), + ) + .unwrap(); + drop(service); + let mut service = open_service(&store, 1); + + let replay = match service + .begin(&encrypt_invocation(&invocation, &security).unwrap(), 1_000) + .unwrap() + { + TransportAdmission::Reply(response) => response, + TransportAdmission::Execute(_) => panic!("completed invocation re-executed"), + }; + assert_eq!(replay, response); + let decoded = decrypt_result(&replay, &security, &invocation).unwrap(); + assert!(matches!( + decoded.disposition, + TransportDisposition::Transported { .. } + )); + + let mut changed_request = request(); + changed_request.instructions.push_str(" Different cargo."); + let conflicting = + TransportInvocation::new("epiphany-yggdrasil", 2_000, [7; 32], changed_request) + .unwrap(); + let refusal = match service + .begin(&encrypt_invocation(&conflicting, &security).unwrap(), 1_000) + .unwrap() + { + TransportAdmission::Reply(response) => response, + TransportAdmission::Execute(_) => panic!("conflicting replay executed"), + }; + assert!(matches!( + decrypt_result(&refusal, &security, &conflicting) + .unwrap() + .disposition, + TransportDisposition::Refused(Refusal::ReplayConflict) + )); + } + + #[test] + fn service_refuses_duplicate_inflight_and_per_caller_capacity() { + let store = TestStore::new(); + let first = invocation_for("epiphany-yggdrasil", "request-1"); + let second = invocation_for("epiphany-yggdrasil", "request-2"); + let security = security("epiphany-distinct-test-key"); + let mut service = open_service(&store, 1); + let first_envelope = encrypt_invocation(&first, &security).unwrap(); + assert!(matches!( + service.begin(&first_envelope, 1_000).unwrap(), + TransportAdmission::Execute(_) + )); + + for (invocation, expected) in [ + (&first, Refusal::InFlight), + (&second, Refusal::Capacity), + ] { + let response = match service + .begin(&encrypt_invocation(invocation, &security).unwrap(), 1_000) + .unwrap() + { + TransportAdmission::Reply(response) => response, + TransportAdmission::Execute(_) => panic!("refused invocation executed"), + }; + assert_eq!( + decrypt_result(&response, &security, invocation) + .unwrap() + .disposition, + TransportDisposition::Refused(expected) + ); + } + } + + #[test] + fn restart_refuses_ambiguous_claim_without_consuming_live_capacity() { + let store = TestStore::new(); + let first = invocation_for("epiphany-yggdrasil", "request-1"); + let second = invocation_for("epiphany-yggdrasil", "request-2"); + let security = security("epiphany-distinct-test-key"); + let mut service = open_service(&store, 1); + assert!(matches!( + service + .begin(&encrypt_invocation(&first, &security).unwrap(), 1_000) + .unwrap(), + TransportAdmission::Execute(_) + )); + drop(service); + + let mut restarted = open_service(&store, 1); + let ambiguous = match restarted + .begin(&encrypt_invocation(&first, &security).unwrap(), 1_000) + .unwrap() + { + TransportAdmission::Reply(response) => response, + TransportAdmission::Execute(_) => panic!("ambiguous claim re-executed"), + }; + assert_eq!( + decrypt_result(&ambiguous, &security, &first) + .unwrap() + .disposition, + TransportDisposition::Refused(Refusal::Indeterminate) + ); + assert!(matches!( + restarted + .begin(&encrypt_invocation(&second, &security).unwrap(), 1_000) + .unwrap(), + TransportAdmission::Execute(_) + )); + drop(restarted); + assert!(matches!( + TransportService::open( + &store.path, + [CallerAdmission::new( + "epiphany-yggdrasil", + "rotated-connection-key", + 2, + ["gpt-5.4".to_string()], + 1, + 64 * 1024, + 32_768, + ) + .unwrap()], + 5_000, + ), + Err(ServiceError::ReplayCallerKeyMismatch) + )); + } + + const CALLER: &str = "epiphany-yggdrasil"; + const KEY: &str = "epiphany-distinct-test-key"; + + fn run( + service: &mut TransportService, + request_id: &str, + edit: impl FnOnce(&mut ProviderRequest), + ) -> (TransportAdmission, TransportInvocation) { + let mut request = invocation_for(CALLER, request_id).request; + edit(&mut request); + let invocation = TransportInvocation::new(CALLER, 2_000, [7; 32], request).unwrap(); + let envelope = encrypt_invocation(&invocation, &security(KEY)).unwrap(); + (service.begin(&envelope, 1_000).unwrap(), invocation) + } + + fn refusal_of(admission: TransportAdmission, invocation: &TransportInvocation) -> Refusal { + let TransportAdmission::Reply(envelope) = admission else { + panic!("expected a refusal reply"); + }; + match decrypt_result(&envelope, &security(KEY), invocation) + .unwrap() + .disposition + { + TransportDisposition::Refused(refusal) => refusal, + TransportDisposition::Transported { .. } => panic!("expected a refusal"), + } + } + + fn raw_envelope(invocation: &TransportInvocation) -> TransportEnvelope { + encrypt_message( + &invocation.caller_runtime_id, + invocation.request_id(), + EnvelopeKind::Invocation, + &rmp_serde::to_vec(invocation).unwrap(), + &security(KEY), + ) + .unwrap() + } + + #[test] + fn caller_admission_reports_its_runtime_id() { + assert_eq!( + caller("ghostlight-yggdrasil", "some-key", 1).caller_runtime_id(), + "ghostlight-yggdrasil" + ); + } + + #[test] + fn policy_refuses_unlisted_models_and_output_tokens_over_the_caller_cap() { + let store = TestStore::new(); + let mut service = open_service(&store, 8); + + let (admission, invocation) = run(&mut service, "unlisted-model", |request| { + request.model = "other-model".to_string(); + }); + assert_eq!(refusal_of(admission, &invocation), Refusal::Policy); + + let (admission, invocation) = run(&mut service, "over-cap", |request| { + request.max_output_tokens = Some(32_769); + }); + assert_eq!(refusal_of(admission, &invocation), Refusal::Policy); + + for (id, tokens) in [ + ("at-cap", Some(32_768)), + ("under-cap", Some(100)), + ("unset", None), + ] { + let (admission, _) = run(&mut service, id, |request| { + request.max_output_tokens = tokens; + }); + assert!(matches!(admission, TransportAdmission::Execute(_)), "{id}"); + } + } + + #[test] + fn payload_bound_is_inclusive_of_the_encrypted_size() { + let invocation = invocation_for(CALLER, "sized"); + let envelope = encrypt_invocation(&invocation, &security(KEY)).unwrap(); + let size = envelope.ciphertext.len(); + let admit = |max_payload_bytes: usize| { + let store = TestStore::new(); + let admission = CallerAdmission::new( + CALLER, + KEY, + 1, + ["gpt-5.4".to_string()], + 4, + max_payload_bytes, + 32_768, + ) + .unwrap(); + TransportService::open(&store.path, [admission], 5_000) + .unwrap() + .begin(&envelope, 1_000) + }; + assert!(matches!(admit(size), Ok(TransportAdmission::Execute(_)))); + assert_eq!(admit(size - 1).unwrap_err(), ServiceError::PayloadTooLarge); + } + + #[test] + fn contract_failures_are_refused_with_the_matching_refusal() { + let store = TestStore::new(); + let mut service = open_service(&store, 8); + + let mut expired = invocation_for(CALLER, "expired"); + expired.expires_at_unix_ms = 10; + let reply = service.begin(&raw_envelope(&expired), 1_000).unwrap(); + assert_eq!(refusal_of(reply, &expired), Refusal::Expired); + + let mut substituted = invocation_for(CALLER, "substituted"); + substituted.provider_request_sha256 = [9; 32]; + let reply = service.begin(&raw_envelope(&substituted), 1_000).unwrap(); + assert_eq!( + refusal_of(reply, &substituted), + Refusal::ProviderDigestSubstitution + ); + + let mut malformed = invocation_for(CALLER, "malformed"); + malformed.native_request_sha256 = [0; 32]; + let reply = service.begin(&raw_envelope(&malformed), 1_000).unwrap(); + assert_eq!(refusal_of(reply, &malformed), Refusal::Malformed); + } + + fn seed(path: &Path, key: &str, edit: impl FnOnce(&mut ReplayRecord)) { + let mut record = ReplayRecord { + caller_runtime_id: CALLER.to_string(), + request_id: "seeded".to_string(), + connection_key_epoch: 1, + invocation_sha256: [1; 32], + expires_at_unix_ms: 5_000, + state: ReplayState::Active, + }; + edit(&mut record); + let mut cache = CultCache::new(); + cache.register_entry_type::().unwrap(); + cache.add_generic_backing_store(OwnedRedbMessagePackBackingStore::new(path).unwrap()); + cache.pull_all_backing_stores().unwrap(); + cache.put(key.to_string(), &record).unwrap(); + } + + fn seeded_key() -> String { + replay_key(&RequestIdentity { + caller_runtime_id: CALLER.to_string(), + request_id: "seeded".to_string(), + }) + } + + #[test] + fn restart_refuses_each_malformed_replay_record() { + let control = TestStore::new(); + seed(&control.path, &seeded_key(), |_| {}); + assert!(TransportService::open(&control.path, [caller(CALLER, KEY, 1)], 5_000).is_ok()); + + let cases: [(&str, fn(&mut ReplayRecord)); 3] = [ + ("wrong-key", |_| {}), + ("zero-digest", |record| record.invocation_sha256 = [0; 32]), + ("zero-expiry", |record| record.expires_at_unix_ms = 0), + ]; + for (name, edit) in cases { + let store = TestStore::new(); + let key = if name == "wrong-key" { + "not-the-replay-key".to_string() + } else { + seeded_key() + }; + seed(&store.path, &key, edit); + assert!( + matches!( + TransportService::open(&store.path, [caller(CALLER, KEY, 1)], 5_000), + Err(ServiceError::InvalidReplayRecord) + ), + "{name}" + ); + } + } + + #[test] + fn completion_refuses_a_claim_that_is_not_the_active_request() { + let store = TestStore::new(); + let mut service = open_service(&store, 4); + let (admission, invocation) = run(&mut service, "request-1", |_| {}); + let TransportAdmission::Execute(claim) = admission else { + panic!("first invocation must execute"); + }; + let result = TransportResult::transported( + claim.invocation(), + vec![TransportEvent { + sequence: 0, + payload: TransportEventPayload::TextDelta { + text: "done".to_string(), + }, + }], + receipt(claim.invocation()), + ); + + let mut forged = (*claim).clone(); + forged.invocation_sha256 = [1; 32]; + assert_eq!( + service.complete(forged, result.clone()).unwrap_err(), + ServiceError::ActiveRequestMismatch + ); + + let key = replay_key(&RequestIdentity { + caller_runtime_id: CALLER.to_string(), + request_id: invocation.request_id().to_string(), + }); + let mut record = service + .replay_store + .get::(&key) + .unwrap() + .unwrap(); + record.state = ReplayState::Completed { + response: encrypt_result(&result, &security(KEY)).unwrap(), + }; + service.replay_store.put(key, &record).unwrap(); + assert_eq!( + service.complete(*claim, result).unwrap_err(), + ServiceError::ActiveRequestMismatch + ); + } +} From 76f1c41cff09799563fd7ce8e5623d0cf37b6260 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Wed, 28 Oct 2026 04:41:40 +0100 Subject: [PATCH 03/69] Add the v2 wire parity test against CodexConnector fd7cec4 A kit-encrypted invocation and result decrypt and validate under codex-connector fd7cec4 and the reverse; canonical invocation bytes, canonical provider request bytes, the provider digest and the five schema ids are equal. The dev-dependency and this test are deleted by connector-contract-v3, which changes the wire. Co-Authored-By: Claude Opus 5.5 --- .../model-connector/tests/wire_parity_v2.rs | 242 ++++++++++++++++++ 1 file changed, 242 insertions(+) create mode 100644 crates/model-connector/tests/wire_parity_v2.rs diff --git a/crates/model-connector/tests/wire_parity_v2.rs b/crates/model-connector/tests/wire_parity_v2.rs new file mode 100644 index 0000000..fa2d3ad --- /dev/null +++ b/crates/model-connector/tests/wire_parity_v2.rs @@ -0,0 +1,242 @@ +//! The lift is wire-identical to CodexConnector fd7cec4, the v2 library +//! Ghostlight pins. Deleted by cut connector-contract-v3, which changes the wire. + +use codex_connector as v2; +use model_connector as kit; +use serde::Serialize; +use serde::de::DeserializeOwned; + +const KEY: &str = "parity-distinct-test-key"; +const CALLER: &str = "parity-caller"; + +/// Moves a value across the two crates by its MessagePack bytes, the shape the wire carries. +fn cross(value: &T) -> U { + rmp_serde::from_slice(&rmp_serde::to_vec(value).unwrap()).unwrap() +} + +fn bytes(value: &T) -> Vec { + rmp_serde::to_vec(value).unwrap() +} + +fn kit_request() -> kit::ProviderRequest { + let mut request = + kit::ProviderRequest::new("request-1", "conversation-1", "gpt-5.4", "Be exact."); + request.input = vec![ + kit::InputItem::UserText { + text: "state".to_string(), + }, + kit::InputItem::AssistantText { + text: "reply".to_string(), + }, + kit::InputItem::ToolCall { + call_id: "call-1".to_string(), + name: "read_source".to_string(), + arguments: "{}".to_string(), + }, + kit::InputItem::ToolResult { + call_id: "call-1".to_string(), + output: "ok".to_string(), + }, + ]; + request.tools = vec![kit::ToolDefinition { + name: "read_source".to_string(), + description: "Read one admitted source.".to_string(), + parameters_json: r#"{"type":"object"}"#.to_string(), + }]; + request.tool_choice = kit::ToolChoice::Required; + request.reasoning_effort = Some("high".to_string()); + request.parallel_tool_calls = true; + request.max_output_tokens = Some(512); + request.prompt_cache_key = Some("cache-1".to_string()); + request +} + +fn v2_request() -> v2::CodexProviderRequest { + let mut request = + v2::CodexProviderRequest::new("request-1", "conversation-1", "gpt-5.4", "Be exact."); + request.input = vec![ + v2::CodexInputItem::UserText { + text: "state".to_string(), + }, + v2::CodexInputItem::AssistantText { + text: "reply".to_string(), + }, + v2::CodexInputItem::ToolCall { + call_id: "call-1".to_string(), + name: "read_source".to_string(), + arguments: "{}".to_string(), + }, + v2::CodexInputItem::ToolResult { + call_id: "call-1".to_string(), + output: "ok".to_string(), + }, + ]; + request.tools = vec![v2::CodexToolDefinition { + name: "read_source".to_string(), + description: "Read one admitted source.".to_string(), + parameters_json: r#"{"type":"object"}"#.to_string(), + }]; + request.tool_choice = v2::CodexToolChoice::Required; + request.reasoning_effort = Some("high".to_string()); + request.parallel_tool_calls = true; + request.max_output_tokens = Some(512); + request.prompt_cache_key = Some("cache-1".to_string()); + request +} + +fn kit_invocation() -> kit::TransportInvocation { + kit::TransportInvocation::new(CALLER, 2_000, [7; 32], kit_request()).unwrap() +} + +fn v2_invocation() -> v2::CodexTransportInvocation { + v2::CodexTransportInvocation::new(CALLER, 2_000, [7; 32], v2_request()).unwrap() +} + +fn kit_result(invocation: &kit::TransportInvocation) -> kit::TransportResult { + let receipt = kit::TransportReceipt { + schema_id: kit::RECEIPT_SCHEMA_ID.to_string(), + request_id: invocation.request_id().to_string(), + caller_runtime_id: invocation.caller_runtime_id.clone(), + native_request_sha256: invocation.native_request_sha256, + provider_request_sha256: invocation.provider_request_sha256, + model: invocation.request.model.clone(), + transport: "parity-sse".to_string(), + outcome: kit::TransportOutcome::Completed { + provider_response_id: Some("response-1".to_string()), + input_tokens: Some(10), + output_tokens: Some(3), + reasoning_output_tokens: Some(1), + cached_input_tokens: None, + }, + }; + kit::TransportResult::transported( + invocation, + vec![ + kit::TransportEvent { + sequence: 0, + payload: kit::TransportEventPayload::TextDelta { + text: "hello".to_string(), + }, + }, + kit::TransportEvent { + sequence: 1, + payload: kit::TransportEventPayload::ToolCall { + call_id: "call-2".to_string(), + name: "read_source".to_string(), + arguments: "{}".to_string(), + }, + }, + ], + receipt, + ) +} + +fn v2_result(invocation: &v2::CodexTransportInvocation) -> v2::CodexTransportResult { + let receipt = v2::CodexTransportReceipt { + schema_id: v2::RECEIPT_SCHEMA_ID.to_string(), + request_id: invocation.request_id().to_string(), + caller_runtime_id: invocation.caller_runtime_id.clone(), + native_request_sha256: invocation.native_request_sha256, + provider_request_sha256: invocation.provider_request_sha256, + model: invocation.request.model.clone(), + transport: "parity-sse".to_string(), + outcome: v2::CodexTransportOutcome::Completed { + provider_response_id: Some("response-1".to_string()), + input_tokens: Some(10), + output_tokens: Some(3), + reasoning_output_tokens: Some(1), + cached_input_tokens: None, + }, + }; + v2::CodexTransportResult::transported( + invocation, + vec![ + v2::CodexTransportEvent { + sequence: 0, + payload: v2::CodexTransportEventPayload::TextDelta { + text: "hello".to_string(), + }, + }, + v2::CodexTransportEvent { + sequence: 1, + payload: v2::CodexTransportEventPayload::ToolCall { + call_id: "call-2".to_string(), + name: "read_source".to_string(), + arguments: "{}".to_string(), + }, + }, + ], + receipt, + ) +} + +fn kit_key() -> kit::TransportKey { + kit::TransportKey::from_connection_secret(KEY).unwrap() +} + +fn v2_key() -> v2::CodexTransportKey { + v2::CodexTransportKey::from_connection_secret(KEY).unwrap() +} + +#[test] +fn schema_ids_are_the_v2_ids() { + assert_eq!( + kit::PROVIDER_REQUEST_SCHEMA_ID, + v2::PROVIDER_REQUEST_SCHEMA_ID + ); + assert_eq!(kit::INVOCATION_SCHEMA_ID, v2::INVOCATION_SCHEMA_ID); + assert_eq!(kit::RESULT_SCHEMA_ID, v2::RESULT_SCHEMA_ID); + assert_eq!(kit::RECEIPT_SCHEMA_ID, v2::RECEIPT_SCHEMA_ID); + assert_eq!(kit::ENVELOPE_SCHEMA_ID, v2::ENVELOPE_SCHEMA_ID); +} + +#[test] +fn canonical_bytes_and_provider_digest_match_v2() { + let from_kit = kit_invocation(); + let from_v2 = v2_invocation(); + assert_eq!(bytes(&from_kit), bytes(&from_v2)); + assert_eq!( + kit::canonical_invocation_bytes(&from_kit).unwrap(), + v2::canonical_invocation_bytes(&from_v2).unwrap() + ); + assert_eq!( + kit::canonical_provider_request_bytes(&from_kit.request).unwrap(), + v2::canonical_provider_request_bytes(&from_v2.request).unwrap() + ); + assert_eq!( + kit::provider_request_sha256(&from_kit.request).unwrap(), + v2::provider_request_sha256(&from_v2.request).unwrap() + ); +} + +#[test] +fn kit_invocation_decrypts_and_validates_under_v2() { + let sent = kit_invocation(); + let envelope = kit::encrypt_invocation(&sent, &kit_key()).unwrap(); + let received = v2::decrypt_invocation(&cross(&envelope), &v2_key()).unwrap(); + assert_eq!(bytes(&received), bytes(&sent)); +} + +#[test] +fn v2_invocation_decrypts_and_validates_under_kit() { + let sent = v2_invocation(); + let envelope = v2::encrypt_invocation(&sent, &v2_key()).unwrap(); + let received = kit::decrypt_invocation(&cross(&envelope), &kit_key()).unwrap(); + assert_eq!(bytes(&received), bytes(&sent)); +} + +#[test] +fn kit_result_decrypts_and_validates_under_v2() { + let sent = kit_result(&kit_invocation()); + let envelope = kit::encrypt_result(&sent, &kit_key()).unwrap(); + let received = v2::decrypt_result(&cross(&envelope), &v2_key(), &v2_invocation()).unwrap(); + assert_eq!(bytes(&received), bytes(&sent)); +} + +#[test] +fn v2_result_decrypts_and_validates_under_kit() { + let sent = v2_result(&v2_invocation()); + let envelope = v2::encrypt_result(&sent, &v2_key()).unwrap(); + let received = kit::decrypt_result(&cross(&envelope), &kit_key(), &kit_invocation()).unwrap(); + assert_eq!(bytes(&received), bytes(&sent)); +} From 2e6aa9363f6db57c486d949baf566172fa35ddf8 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Wed, 28 Oct 2026 04:41:40 +0100 Subject: [PATCH 04/69] README front door for the live model-connector crate and its provenance Entry points, verify commands, and the AGPL-3.0-only relicense record: lifted from CodexConnector fd7cec4, formerly PolyForm Noncommercial. Co-Authored-By: Claude Opus 5.5 --- README.md | 52 +++++++++++++++++++++++++++++++++++++--------------- 1 file changed, 37 insertions(+), 15 deletions(-) diff --git a/README.md b/README.md index 65a124e..1155500 100644 --- a/README.md +++ b/README.md @@ -1,26 +1,48 @@ # ModelConnector -Status: planned. Nothing is implemented in this repository yet. +The connector kit for GameCult minds: one typed, encrypted, replay-safe contract +between a caller that needs a model turn and a daemon that holds the provider +credential, so no project wires its own provider calls. -## What it will hold +Status: the `model-connector` crate is live. No connector binary exists yet +(the Claude connector is first, then an OpenAI-compatible one). -The connector kit for GameCult minds: +## Entry points -- the connector contract, a Rust crate defining the typed boundary every model - turn of a GameCult mind goes through; -- connector daemons that implement the contract against a model provider, - Claude first, then an OpenAI-compatible connector. +Crate `crates/model-connector`: -The problem it solves: a mind should reach any model through one inspectable, -typed contract, rather than each project wiring its own provider calls. +- `src/contract.rs` - the v2 wire contract: `ProviderRequest`, `TransportInvocation`, + `TransportResult`, `TransportReceipt`, `Refusal`, and the canonical byte and digest + functions that bind a result to its request. +- `src/envelope.rs` - the AES-256-GCM `TransportEnvelope`, its identity-binding AAD, and + the length-prefixed framing. +- `src/client.rs` - `ConnectorClient`, the one-request caller side. +- `src/service.rs` (feature `daemon`) - `TransportService`: caller admission, durable + replay (a retried request is answered byte-identically and the provider runs once), + in-flight and capacity refusals. +- `src/serve.rs` (feature `daemon`) - `serve()`, the loopback accept loop, over the + `ProviderBackend` trait. A connector binary implements `ProviderBackend` for its + provider and calls `serve`; the kit names no provider. -## Where it comes from +The default feature set compiles contract, envelope and client only. Schema ids are +still `gamecult.codex.*.v2`; renaming them changes the wire and belongs to a later +contract version. -[CodexConnector](https://github.com/GameCult/CodexConnector) -(GameCult/CodexConnector) is the existing connector this kit is lifted from. -The lift is the first piece of work here; until it lands there are no entry -points to run. +## Verify -## License +``` +cargo test --locked --workspace +cargo test --locked --workspace --features model-connector/daemon +``` + +`tests/wire_parity_v2.rs` pins the kit byte-for-byte to CodexConnector fd7cec4 in both +directions (it fetches that revision as a dev-dependency). + +## Provenance and license AGPL-3.0-only. See `LICENSE`. + +The contract, envelope, client, replay service and serve loop were lifted from +[CodexConnector](https://github.com/GameCult/CodexConnector) at fd7cec4, formerly +PolyForm Noncommercial. Its sole copyright holder relicensed the lifted material as +AGPL-3.0-only for this repository. From a255b0d9bfb066ba3f8e60ee2394a2b7dc87c4ad Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Wed, 28 Oct 2026 04:42:30 +0100 Subject: [PATCH 05/69] service tests: name the record edit type for clippy Co-Authored-By: Claude Opus 5.5 --- crates/model-connector/src/service.rs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/crates/model-connector/src/service.rs b/crates/model-connector/src/service.rs index 1413537..16c719b 100644 --- a/crates/model-connector/src/service.rs +++ b/crates/model-connector/src/service.rs @@ -718,7 +718,8 @@ mod tests { seed(&control.path, &seeded_key(), |_| {}); assert!(TransportService::open(&control.path, [caller(CALLER, KEY, 1)], 5_000).is_ok()); - let cases: [(&str, fn(&mut ReplayRecord)); 3] = [ + type Edit = fn(&mut ReplayRecord); + let cases: [(&str, Edit); 3] = [ ("wrong-key", |_| {}), ("zero-digest", |record| record.invocation_sha256 = [0; 32]), ("zero-expiry", |record| record.expires_at_unix_ms = 0), From d1d28f0fd78a689be51893cb2175b596199066b9 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Wed, 28 Oct 2026 05:05:58 +0100 Subject: [PATCH 06/69] serve owns the config cross-bounds and bounds every result; oversize payload is refused on the wire serve now refuses, before opening anything, a frame under 8192 bytes, fewer connections than max(sum of caller slots, 8), and a caller payload bound outside 4096..=frame-4096 (the base daemon's validate, unchanged). A provider result larger than frame-4096 becomes a typed result_size failure before it is recorded, so a retry replays deliverable bytes. A payload over a caller's bound is refused Policy on the wire instead of dropping the connection. The orphan capacity helpers are deleted; the accept loop takes its connection source so a test can present a non-loopback peer. Co-Authored-By: Claude Opus 5.5 --- crates/model-connector/src/envelope.rs | 4 - crates/model-connector/src/serve.rs | 356 ++++++++++++++++++++----- crates/model-connector/src/service.rs | 37 +-- 3 files changed, 309 insertions(+), 88 deletions(-) diff --git a/crates/model-connector/src/envelope.rs b/crates/model-connector/src/envelope.rs index 713eab8..091452f 100644 --- a/crates/model-connector/src/envelope.rs +++ b/crates/model-connector/src/envelope.rs @@ -95,7 +95,6 @@ pub(crate) enum TransportFrameError { Size, } - pub(crate) fn read_transport_frame( reader: &mut impl Read, max_frame_bytes: usize, @@ -232,8 +231,6 @@ pub enum ServiceError { ReplayCallerKeyMismatch, #[error("caller runtime is not admitted")] CallerNotAdmitted, - #[error("transport payload exceeds the caller bound")] - PayloadTooLarge, #[error("unexpected transport envelope")] Envelope, #[error("transport envelope identity substitution")] @@ -250,7 +247,6 @@ pub enum ServiceError { ActiveRequestMismatch, } - #[cfg(test)] mod tests { use super::*; diff --git a/crates/model-connector/src/serve.rs b/crates/model-connector/src/serve.rs index 1b613f5..4158bdc 100644 --- a/crates/model-connector/src/serve.rs +++ b/crates/model-connector/src/serve.rs @@ -1,5 +1,5 @@ use std::fs; -use std::net::{IpAddr, TcpListener, TcpStream}; +use std::net::{IpAddr, SocketAddr, TcpListener, TcpStream}; use std::path::{Path, PathBuf}; use std::sync::atomic::{AtomicUsize, Ordering}; use std::sync::{Arc, Mutex}; @@ -10,7 +10,10 @@ use serde::{Deserialize, Serialize}; use thiserror::Error; use zeroize::Zeroizing; -use crate::contract::{TransportInvocation, TransportResult}; +use crate::contract::{ + RECEIPT_SCHEMA_ID, TransportDisposition, TransportInvocation, TransportOutcome, + TransportReceipt, TransportResult, +}; use crate::envelope::{ ServiceError, TransportEnvelope, TransportFrameError, read_transport_frame, write_transport_frame, @@ -39,9 +42,17 @@ pub struct ServeLimits { pub max_expiry_skew_ms: u64, } +/// What a frame carries besides the caller's payload: the envelope fields and +/// the headroom a result needs. The base daemon's budget, unchanged. +const FRAME_OVERHEAD_BUDGET: usize = 4096; + impl ServeLimits { - fn validate(&self) -> Result<(), ServeError> { - if self.max_frame_bytes == 0 + /// The bounds serve refuses before it opens anything: each limit nonzero, + /// a frame that can carry a minimum payload plus its overhead, enough + /// connections for every caller's request slots, and every caller's payload + /// bound between the minimum and what a frame can carry. + fn validate(&self, admissions: &[CallerAdmission]) -> Result<(), ServeError> { + if self.max_frame_bytes < FRAME_OVERHEAD_BUDGET * 2 || self.max_frame_bytes > u32::MAX as usize || self.max_connections == 0 || self.socket_timeout_ms == 0 @@ -49,8 +60,31 @@ impl ServeLimits { { return Err(ServeError::InvalidConfig("serve limits")); } + if admissions.is_empty() { + return Err(ServeError::InvalidConfig("callers")); + } + let slots = admissions + .iter() + .try_fold(0_usize, |total, caller| { + total.checked_add(caller.max_concurrent_requests()) + }) + .ok_or(ServeError::InvalidConfig("connection capacity"))?; + if self.max_connections < slots.max(8) { + return Err(ServeError::InvalidConfig("connection capacity")); + } + if admissions.iter().any(|caller| { + caller.max_payload_bytes() < FRAME_OVERHEAD_BUDGET + || caller.max_payload_bytes() > self.max_frame_bytes - FRAME_OVERHEAD_BUDGET + }) { + return Err(ServeError::InvalidConfig("caller admission")); + } Ok(()) } + + /// The largest encoded result a frame can carry once its envelope is added. + fn max_result_bytes(&self) -> usize { + self.max_frame_bytes - FRAME_OVERHEAD_BUDGET + } } #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] @@ -64,19 +98,6 @@ pub struct CallerConfig { pub max_output_tokens: u32, } -pub fn required_connection_capacity(callers: &[CallerConfig]) -> Result { - Ok(caller_request_capacity(callers)?.max(8)) -} - -pub fn caller_request_capacity(callers: &[CallerConfig]) -> Result { - callers - .iter() - .try_fold(0_usize, |total, caller| { - total.checked_add(caller.max_concurrent_requests) - }) - .ok_or(ServeError::InvalidConfig("connection capacity")) -} - pub fn load_admissions(callers: &[CallerConfig]) -> Result, ServeError> { let mut admissions = Vec::with_capacity(callers.len()); for caller in callers { @@ -112,10 +133,7 @@ pub fn serve( replay_store: &Path, backend: Arc, ) -> Result<(), ServeError> { - limits.validate()?; - if admissions.is_empty() { - return Err(ServeError::InvalidConfig("callers")); - } + limits.validate(&admissions)?; if !listener .local_addr() .map_err(ServeError::Listen)? @@ -132,11 +150,22 @@ pub fn serve( admissions, limits.max_expiry_skew_ms, )?)); + accept_loop(|| listener.accept(), limits, service, backend) +} + +/// The accept loop over any source of connections, so a test can present a +/// peer address that a loopback listener never reports. +fn accept_loop( + accept: impl Fn() -> std::io::Result<(TcpStream, SocketAddr)>, + limits: ServeLimits, + service: Arc>, + backend: Arc, +) -> Result<(), ServeError> { let active_connections = Arc::new(AtomicUsize::new(0)); let timeout = Duration::from_millis(limits.socket_timeout_ms); loop { - let (stream, peer) = listener.accept().map_err(ServeError::Listen)?; + let (stream, peer) = accept().map_err(ServeError::Listen)?; if !peer_admitted(peer.ip()) { continue; } @@ -147,18 +176,11 @@ pub fn serve( }; let service = service.clone(); let backend = backend.clone(); - let max_frame_bytes = limits.max_frame_bytes; thread::Builder::new() .name("model-connector-request".to_string()) .spawn(move || { let _permit = permit; - let _ = serve_connection( - stream, - &service, - backend.as_ref(), - max_frame_bytes, - timeout, - ); + let _ = serve_connection(stream, &service, backend.as_ref(), &limits, timeout); }) .map_err(ServeError::Thread)?; } @@ -172,22 +194,23 @@ fn serve_connection( mut stream: TcpStream, service: &Mutex, backend: &dyn ProviderBackend, - max_frame_bytes: usize, + limits: &ServeLimits, timeout: Duration, ) -> Result<(), ServeError> { stream .set_read_timeout(Some(timeout)) .and_then(|()| stream.set_write_timeout(Some(timeout))) .map_err(ServeError::Connection)?; - let request = read_transport_frame(&mut stream, max_frame_bytes).map_err(frame_error)?; - let response = execute_inference_frame(&request, service, backend)?; - write_transport_frame(&mut stream, &response, max_frame_bytes).map_err(frame_error) + let request = read_transport_frame(&mut stream, limits.max_frame_bytes).map_err(frame_error)?; + let response = execute_inference_frame(&request, service, backend, limits)?; + write_transport_frame(&mut stream, &response, limits.max_frame_bytes).map_err(frame_error) } fn execute_inference_frame( request: &[u8], service: &Mutex, backend: &dyn ProviderBackend, + limits: &ServeLimits, ) -> Result, ServeError> { let envelope: TransportEnvelope = rmp_serde::from_slice(request).map_err(|_| ServeError::FrameEncoding)?; @@ -198,7 +221,11 @@ fn execute_inference_frame( let response = match admission { TransportAdmission::Reply(response) => response, TransportAdmission::Execute(claim) => { - let result = backend.execute(claim.invocation()); + let result = bound_result( + claim.invocation(), + backend.execute(claim.invocation()), + limits.max_result_bytes(), + ); service .lock() .map_err(|_| ServeError::ServicePoisoned)? @@ -208,6 +235,40 @@ fn execute_inference_frame( rmp_serde::to_vec(&response).map_err(|_| ServeError::FrameEncoding) } +/// A result the frame cannot carry is never recorded: it becomes a failed +/// receipt in the same shape, so the caller is told and a retry replays bytes +/// that can be delivered. +fn bound_result( + invocation: &TransportInvocation, + result: TransportResult, + max_result_bytes: usize, +) -> TransportResult { + if rmp_serde::to_vec(&result).is_ok_and(|bytes| bytes.len() <= max_result_bytes) { + return result; + } + let transport = match &result.disposition { + TransportDisposition::Transported { receipt, .. } => receipt.transport.clone(), + TransportDisposition::Refused(_) => "model-connector".to_string(), + }; + TransportResult::transported( + invocation, + Vec::new(), + TransportReceipt { + schema_id: RECEIPT_SCHEMA_ID.to_string(), + request_id: invocation.request_id().to_string(), + caller_runtime_id: invocation.caller_runtime_id.clone(), + native_request_sha256: invocation.native_request_sha256, + provider_request_sha256: invocation.provider_request_sha256, + model: invocation.request.model.clone(), + transport, + outcome: TransportOutcome::Failed { + failure_kind: "result_size".to_string(), + message: "provider result exceeded its byte bound".to_string(), + }, + }, + ) +} + fn frame_error(error: TransportFrameError) -> ServeError { match error { TransportFrameError::Connection(error) => ServeError::Connection(error), @@ -287,12 +348,14 @@ mod tests { use super::*; use crate::client::ConnectorClient; use crate::contract::{ - Refusal, TransportDisposition, TransportEvent, TransportEventPayload, TransportResult, + Refusal, TransportDisposition, TransportEvent, TransportEventPayload, TransportOutcome, + TransportResult, }; use crate::fixtures::{TestStore, caller, invocation_for, receipt, request}; const CALLER: &str = "epiphany-yggdrasil"; const KEY: &str = "epiphany-distinct-test-key"; + const FRAME: usize = 128 * 1024; struct CountingBackend { ready: bool, @@ -333,7 +396,7 @@ mod tests { fn limits() -> ServeLimits { ServeLimits { - max_frame_bytes: 64 * 1024, + max_frame_bytes: FRAME, max_connections: 8, socket_timeout_ms: 5_000, max_expiry_skew_ms: 300_000, @@ -373,21 +436,21 @@ mod tests { fn start( backend: Arc, store: &TestStore, + ) -> (ConnectorClient, std::net::SocketAddr) { + start_with(backend, store, vec![caller(CALLER, KEY, 4)]) + } + + fn start_with( + backend: Arc, + store: &TestStore, + admissions: Vec, ) -> (ConnectorClient, std::net::SocketAddr) { let listener = TcpListener::bind("127.0.0.1:0").unwrap(); let endpoint = listener.local_addr().unwrap(); let path = store.path.clone(); - thread::spawn(move || { - serve( - listener, - limits(), - vec![caller(CALLER, KEY, 4)], - &path, - backend, - ) - }); + thread::spawn(move || serve(listener, limits(), admissions, &path, backend)); let client = - ConnectorClient::new(endpoint, KEY, 64 * 1024, Some(Duration::from_secs(5))).unwrap(); + ConnectorClient::new(endpoint, KEY, FRAME, Some(Duration::from_secs(5))).unwrap(); (client, endpoint) } @@ -456,8 +519,8 @@ mod tests { let mut replies = Vec::new(); for _ in 0..2 { let mut stream = TcpStream::connect(endpoint).unwrap(); - write_transport_frame(&mut stream, &request, 64 * 1024).unwrap(); - replies.push(read_transport_frame(&mut stream, 64 * 1024).unwrap()); + write_transport_frame(&mut stream, &request, FRAME).unwrap(); + replies.push(read_transport_frame(&mut stream, FRAME).unwrap()); } assert_eq!(replies[0], replies[1]); assert_eq!(backend.executions.load(Ordering::SeqCst), 1); @@ -514,8 +577,9 @@ mod tests { #[test] fn serve_refuses_unbounded_limits_and_admits_the_widest_frame() { let store = TestStore::new(); - let mutations: [fn(&mut ServeLimits); 5] = [ + let mutations: [fn(&mut ServeLimits); 6] = [ |limits| limits.max_frame_bytes = 0, + |limits| limits.max_frame_bytes = 8191, |limits| limits.max_frame_bytes = u32::MAX as usize + 1, |limits| limits.max_connections = 0, |limits| limits.socket_timeout_ms = 0, @@ -547,7 +611,10 @@ mod tests { &store.path, CountingBackend::new(true), ); - assert!(result.is_none(), "the widest frame the wire can carry must serve"); + assert!( + result.is_none(), + "the widest frame the wire can carry must serve" + ); } #[test] @@ -587,23 +654,180 @@ mod tests { assert_eq!(active.load(Ordering::SeqCst), 0); } - #[test] - fn caller_admission_reserves_every_independently_admitted_request_slot() { - let both = callers(&[8, 8]); - assert_eq!(caller_request_capacity(&both).unwrap(), 16); - assert_eq!(required_connection_capacity(&both).unwrap(), 16); - assert_eq!(required_connection_capacity(&callers(&[3])).unwrap(), 8); - assert!(matches!( - caller_request_capacity(&callers(&[usize::MAX, 1])), - Err(ServeError::InvalidConfig("connection capacity")) - )); + fn admission(id: &str, key: &str, slots: usize, payload: usize) -> CallerAdmission { + CallerAdmission::new(id, key, 1, ["gpt-5.4".to_string()], slots, payload, 32_768).unwrap() + } + + /// `Some(field)` when serve refused the configuration, `None` while it serves. + fn refusal(limits: ServeLimits, admissions: Vec) -> Option<&'static str> { + let store = TestStore::new(); + match outcome( + TcpListener::bind("127.0.0.1:0").unwrap(), + limits, + admissions, + &store.path, + CountingBackend::new(true), + ) { + None => None, + Some(Err(ServeError::InvalidConfig(field))) => Some(field), + Some(other) => panic!("unexpected serve outcome {other:?}"), + } } #[test] - fn provider_capacity_is_caller_execution_capacity_not_listener_capacity() { - let callers = callers(&[4]); - assert_eq!(caller_request_capacity(&callers).unwrap(), 4); - assert_eq!(required_connection_capacity(&callers).unwrap(), 8); + fn serve_refuses_fewer_connections_than_the_callers_request_slots() { + let two = || { + vec![ + admission("a", "key-a-distinct", 8, 8192), + admission("b", "key-b-distinct", 8, 8192), + ] + }; + let at = |connections| ServeLimits { + max_connections: connections, + ..limits() + }; + assert_eq!(refusal(at(15), two()), Some("connection capacity")); + assert_eq!(refusal(at(16), two()), None); + let small = || vec![admission("a", "key-a-distinct", 3, 8192)]; + assert_eq!(refusal(at(7), small()), Some("connection capacity")); + assert_eq!(refusal(at(8), small()), None); + let overflowing = vec![ + admission("a", "key-a-distinct", usize::MAX, 8192), + admission("b", "key-b-distinct", 1, 8192), + ]; + assert_eq!( + refusal(at(usize::MAX), overflowing), + Some("connection capacity") + ); + } + + #[test] + fn serve_refuses_a_frame_or_payload_bound_the_other_cannot_carry() { + let frame = |bytes| ServeLimits { + max_frame_bytes: bytes, + ..limits() + }; + let payload = |bytes| vec![admission("a", "key-a-distinct", 4, bytes)]; + assert_eq!(refusal(frame(8191), payload(4096)), Some("serve limits")); + // The tightest legal pair: a 4096-byte payload in an 8192-byte frame. + assert_eq!(refusal(frame(8192), payload(4096)), None); + assert_eq!( + refusal(frame(8192), payload(4097)), + Some("caller admission") + ); + assert_eq!(refusal(frame(12288), payload(8192)), None); + assert_eq!( + refusal(frame(12288), payload(8193)), + Some("caller admission") + ); + assert_eq!( + refusal(frame(FRAME), payload(4095)), + Some("caller admission") + ); + let mixed = vec![ + admission("a", "key-a-distinct", 4, 8192), + admission("b", "key-b-distinct", 4, FRAME), + ]; + assert_eq!(refusal(frame(FRAME), mixed), Some("caller admission")); + } + + #[test] + fn a_payload_over_the_caller_bound_is_refused_on_the_wire() { + let store = TestStore::new(); + let backend = CountingBackend::new(true); + let (client, _) = start_with( + backend.clone(), + &store, + vec![admission(CALLER, KEY, 4, 8192)], + ); + let mut invocation = fresh("request-big"); + invocation.request.instructions = "x".repeat(10_000); + invocation.provider_request_sha256 = + crate::contract::provider_request_sha256(&invocation.request).unwrap(); + let reply = client.execute(&invocation).unwrap(); + assert_eq!( + reply.disposition, + TransportDisposition::Refused(Refusal::Policy) + ); + assert_eq!(backend.executions.load(Ordering::SeqCst), 0); + } + + struct OversizeBackend { + executions: AtomicUsize, + } + + impl ProviderBackend for OversizeBackend { + fn ready(&self) -> bool { + true + } + + fn execute(&self, invocation: &TransportInvocation) -> TransportResult { + self.executions.fetch_add(1, Ordering::SeqCst); + TransportResult::transported( + invocation, + vec![TransportEvent { + sequence: 0, + payload: TransportEventPayload::TextDelta { + text: "y".repeat(FRAME), + }, + }], + receipt(invocation), + ) + } + } + + #[test] + fn a_result_larger_than_the_frame_fails_typed_and_is_never_replayed_as_completed() { + let store = TestStore::new(); + let backend = Arc::new(OversizeBackend { + executions: AtomicUsize::new(0), + }); + let (client, _) = start_with(backend.clone(), &store, vec![caller(CALLER, KEY, 4)]); + let invocation = fresh("request-oversize"); + let first = client.execute(&invocation).unwrap(); + let second = client.execute(&invocation).unwrap(); + let TransportDisposition::Transported { events, receipt } = &first.disposition else { + panic!("expected a transported failure"); + }; + assert!(events.is_empty()); + assert_eq!( + receipt.outcome, + TransportOutcome::Failed { + failure_kind: "result_size".to_string(), + message: "provider result exceeded its byte bound".to_string(), + } + ); + assert_eq!(receipt.transport, "fixture-sse"); + assert_eq!(first, second, "the retry replays the deliverable failure"); + assert_eq!(backend.executions.load(Ordering::SeqCst), 1); + } + + #[test] + fn the_accept_loop_drops_a_peer_that_is_not_loopback() { + let run = |reported: SocketAddr| { + let store = TestStore::new(); + let backend = CountingBackend::new(true); + let listener = TcpListener::bind("127.0.0.1:0").unwrap(); + let endpoint = listener.local_addr().unwrap(); + let service = Arc::new(Mutex::new( + TransportService::open(&store.path, vec![caller(CALLER, KEY, 4)], 300_000).unwrap(), + )); + let served = backend.clone(); + thread::spawn(move || { + accept_loop( + || listener.accept().map(|(stream, _)| (stream, reported)), + limits(), + service, + served, + ) + }); + let client = + ConnectorClient::new(endpoint, KEY, FRAME, Some(Duration::from_secs(5))).unwrap(); + let reply = client.execute(&fresh("request-peer")); + (reply.is_ok(), backend.executions.load(Ordering::SeqCst)) + }; + assert_eq!(run("127.0.0.1:50000".parse().unwrap()), (true, 1)); + assert_eq!(run("192.168.1.7:50000".parse().unwrap()), (false, 0)); } #[test] @@ -642,7 +866,7 @@ mod tests { let served = backend.clone(); thread::spawn(move || serve(listener, limits(), admissions, &path, served)); let client = - ConnectorClient::new(endpoint, KEY, 64 * 1024, Some(Duration::from_secs(5))).unwrap(); + ConnectorClient::new(endpoint, KEY, FRAME, Some(Duration::from_secs(5))).unwrap(); client.execute(&fresh("request-key")).unwrap(); assert_eq!(backend.executions.load(Ordering::SeqCst), 1); } diff --git a/crates/model-connector/src/service.rs b/crates/model-connector/src/service.rs index 16c719b..36d3d31 100644 --- a/crates/model-connector/src/service.rs +++ b/crates/model-connector/src/service.rs @@ -65,6 +65,14 @@ impl CallerAdmission { pub fn caller_runtime_id(&self) -> &str { &self.caller_runtime_id } + + pub(crate) fn max_concurrent_requests(&self) -> usize { + self.max_concurrent_requests + } + + pub(crate) fn max_payload_bytes(&self) -> usize { + self.max_payload_bytes + } } #[derive(Debug, Clone, PartialEq, Eq, Hash)] @@ -195,12 +203,12 @@ impl TransportService { .callers .get(&envelope.caller_runtime_id) .ok_or(ServiceError::CallerNotAdmitted)?; - if envelope.ciphertext.len() > caller.max_payload_bytes { - return Err(ServiceError::PayloadTooLarge); - } let security = caller.security.clone(); let max_concurrent_requests = caller.max_concurrent_requests; let invocation = decrypt_invocation(envelope, &security)?; + if envelope.ciphertext.len() > caller.max_payload_bytes { + return self.reply_refusal(&invocation, Refusal::Policy, &security); + } if let Err(error) = invocation.validate(now_unix_ms, self.max_expiry_skew_ms) { return self.reply_refusal(&invocation, contract_refusal(&error), &security); } @@ -228,9 +236,7 @@ impl TransportService { return self.reply_refusal(&invocation, Refusal::ReplayConflict, &security); } return match record.state { - ReplayState::Completed { response } => { - Ok(TransportAdmission::Reply(response)) - } + ReplayState::Completed { response } => Ok(TransportAdmission::Reply(response)), ReplayState::Active => { let refusal = if self.live.contains(&identity) { Refusal::InFlight @@ -262,12 +268,10 @@ impl TransportService { .put(replay_key(&identity), &replay_record) .map_err(replay_store_error)?; self.live.insert(identity); - Ok(TransportAdmission::Execute(Box::new( - ExecutionClaim { - invocation, - invocation_sha256, - }, - ))) + Ok(TransportAdmission::Execute(Box::new(ExecutionClaim { + invocation, + invocation_sha256, + }))) } pub fn complete( @@ -363,7 +367,6 @@ fn contract_refusal(error: &ContractError) -> Refusal { } } - #[cfg(all(test, feature = "daemon"))] mod tests { use super::*; @@ -490,10 +493,7 @@ mod tests { TransportAdmission::Execute(_) )); - for (invocation, expected) in [ - (&first, Refusal::InFlight), - (&second, Refusal::Capacity), - ] { + for (invocation, expected) in [(&first, Refusal::InFlight), (&second, Refusal::Capacity)] { let response = match service .begin(&encrypt_invocation(invocation, &security).unwrap(), 1_000) .unwrap() @@ -661,7 +661,8 @@ mod tests { .begin(&envelope, 1_000) }; assert!(matches!(admit(size), Ok(TransportAdmission::Execute(_)))); - assert_eq!(admit(size - 1).unwrap_err(), ServiceError::PayloadTooLarge); + let refused = admit(size - 1).unwrap(); + assert_eq!(refusal_of(refused, &invocation), Refusal::Policy); } #[test] From af99b3423f0cca84538fb70a826a2fb07f6eeed7 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Wed, 28 Oct 2026 05:06:59 +0100 Subject: [PATCH 07/69] wire_v2 fixtures: the generator source, run once against CodexConnector fd7cec4 Co-Authored-By: Claude Opus 5.5 --- .../tests/fixtures/wire_v2/generator.rs | 171 ++++++++++++++++++ 1 file changed, 171 insertions(+) create mode 100644 crates/model-connector/tests/fixtures/wire_v2/generator.rs diff --git a/crates/model-connector/tests/fixtures/wire_v2/generator.rs b/crates/model-connector/tests/fixtures/wire_v2/generator.rs new file mode 100644 index 0000000..baf36cc --- /dev/null +++ b/crates/model-connector/tests/fixtures/wire_v2/generator.rs @@ -0,0 +1,171 @@ +//! Generator of the golden v2 wire fixtures beside it. It is not compiled by this +//! workspace: it was run once, as an example of CodexConnector at commit +//! fd7cec4176f4e3f9a5aaa75e77ba10ce5e1522f8 (see README.md here for the command). +//! It prints one `FIXTURE ` line per file. + +use codex_connector as v2; +use serde::Serialize; + +const KEY: &str = "parity-distinct-test-key"; +const CALLER: &str = "parity-caller"; + +fn bytes(value: &T) -> Vec { + rmp_serde::to_vec(value).unwrap() +} + +fn emit(name: &str, data: &[u8]) { + let hex: String = data.iter().map(|byte| format!("{byte:02x}")).collect(); + println!("FIXTURE {name} {hex}"); +} + +fn request(choice: v2::CodexToolChoice) -> v2::CodexProviderRequest { + let mut request = + v2::CodexProviderRequest::new("request-1", "conversation-1", "gpt-5.4", "Be exact."); + request.input = vec![ + v2::CodexInputItem::UserText { + text: "state".to_string(), + }, + v2::CodexInputItem::AssistantText { + text: "reply".to_string(), + }, + v2::CodexInputItem::ToolCall { + call_id: "call-1".to_string(), + name: "read_source".to_string(), + arguments: "{}".to_string(), + }, + v2::CodexInputItem::ToolResult { + call_id: "call-1".to_string(), + output: "ok".to_string(), + }, + ]; + request.tools = vec![v2::CodexToolDefinition { + name: "read_source".to_string(), + description: "Read one admitted source.".to_string(), + parameters_json: r#"{"type":"object"}"#.to_string(), + }]; + request.tool_choice = choice; + request.reasoning_effort = Some("high".to_string()); + request.parallel_tool_calls = true; + request.max_output_tokens = Some(512); + request.prompt_cache_key = Some("cache-1".to_string()); + request +} + +fn invocation(choice: v2::CodexToolChoice) -> v2::CodexTransportInvocation { + v2::CodexTransportInvocation::new(CALLER, 2_000, [7; 32], request(choice)).unwrap() +} + +fn receipt( + invocation: &v2::CodexTransportInvocation, + outcome: v2::CodexTransportOutcome, +) -> v2::CodexTransportReceipt { + v2::CodexTransportReceipt { + schema_id: v2::RECEIPT_SCHEMA_ID.to_string(), + request_id: invocation.request_id().to_string(), + caller_runtime_id: invocation.caller_runtime_id.clone(), + native_request_sha256: invocation.native_request_sha256, + provider_request_sha256: invocation.provider_request_sha256, + model: invocation.request.model.clone(), + transport: "parity-sse".to_string(), + outcome, + } +} + +fn main() { + let key = v2::CodexTransportKey::from_connection_secret(KEY).unwrap(); + let auto = invocation(v2::CodexToolChoice::Auto); + let required = invocation(v2::CodexToolChoice::Required); + emit("invocation_auto.mp", &bytes(&auto)); + emit("invocation_required.mp", &bytes(&required)); + emit( + "canonical_invocation.bin", + &v2::canonical_invocation_bytes(&required).unwrap(), + ); + emit( + "canonical_provider_request.bin", + &v2::canonical_provider_request_bytes(&required.request).unwrap(), + ); + emit( + "provider_request_sha256.bin", + &v2::provider_request_sha256(&required.request).unwrap(), + ); + emit( + "envelope_invocation.mp", + &bytes(&v2::encrypt_invocation(&required, &key).unwrap()), + ); + + let completed = v2::CodexTransportResult::transported( + &required, + vec![ + v2::CodexTransportEvent { + sequence: 0, + payload: v2::CodexTransportEventPayload::TextDelta { + text: "hello".to_string(), + }, + }, + v2::CodexTransportEvent { + sequence: 1, + payload: v2::CodexTransportEventPayload::ToolCall { + call_id: "call-2".to_string(), + name: "read_source".to_string(), + arguments: "{}".to_string(), + }, + }, + ], + receipt( + &required, + v2::CodexTransportOutcome::Completed { + provider_response_id: Some("response-1".to_string()), + input_tokens: Some(10), + output_tokens: Some(3), + reasoning_output_tokens: Some(1), + cached_input_tokens: Some(4), + }, + ), + ); + emit("result_transported.mp", &bytes(&completed)); + emit( + "envelope_result_transported.mp", + &bytes(&v2::encrypt_result(&completed, &key).unwrap()), + ); + + let failed = v2::CodexTransportResult::transported( + &required, + Vec::new(), + receipt( + &required, + v2::CodexTransportOutcome::Failed { + failure_kind: "result_size".to_string(), + message: "provider result exceeded its byte bound".to_string(), + }, + ), + ); + emit("result_failed.mp", &bytes(&failed)); + emit( + "envelope_result_failed.mp", + &bytes(&v2::encrypt_result(&failed, &key).unwrap()), + ); + + use v2::CodexRefusal as R; + let refusals = [ + ("expired", R::Expired), + ("identity_substitution", R::IdentitySubstitution), + ("provider_digest_substitution", R::ProviderDigestSubstitution), + ("policy", R::Policy), + ("capacity", R::Capacity), + ("in_flight", R::InFlight), + ("indeterminate", R::Indeterminate), + ("replay_conflict", R::ReplayConflict), + ("malformed", R::Malformed), + ]; + for (name, refusal) in refusals { + let refused = v2::CodexTransportResult::refused(&required, refusal); + emit(&format!("result_refused_{name}.mp"), &bytes(&refused)); + if name == "replay_conflict" { + emit( + "envelope_result_refused_replay_conflict.mp", + &bytes(&v2::encrypt_result(&refused, &key).unwrap()), + ); + } + } +} From ae28dbedfdb485929272fb17e7a932fa31bdfcfc Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Wed, 28 Oct 2026 05:08:56 +0100 Subject: [PATCH 08/69] Golden v2 wire fixtures replace the CodexConnector dev-dependency; pin envelope validation and frame bounds wire_parity_v2 linked CodexConnector (PolyForm Noncommercial) into the kit's test build. The v2 bytes it compared against are now committed, generated once from fd7cec4 (generator and command beside them): invocations for both tool-choice values, a completed, a failed and a refused result for every refusal, canonical bytes, the provider digest and v2-sealed envelopes the kit must decrypt. envelope.rs tests pin the outer-identity match on each field, envelope validation, and the inclusive, never-empty frame bounds. Co-Authored-By: Claude Opus 5.5 --- Cargo.lock | 23 -- crates/model-connector/Cargo.toml | 3 - crates/model-connector/src/envelope.rs | 116 +++++++ .../tests/fixtures/wire_v2/.gitattributes | 2 + .../tests/fixtures/wire_v2/README.md | 20 ++ .../fixtures/wire_v2/canonical_invocation.bin | Bin 0 -> 413 bytes .../wire_v2/canonical_provider_request.bin | Bin 0 -> 267 bytes .../fixtures/wire_v2/envelope_invocation.mp | 6 + .../wire_v2/envelope_result_failed.mp | 7 + ...envelope_result_refused_replay_conflict.mp | Bin 0 -> 252 bytes .../wire_v2/envelope_result_transported.mp | Bin 0 -> 609 bytes .../tests/fixtures/wire_v2/invocation_auto.mp | Bin 0 -> 408 bytes .../fixtures/wire_v2/invocation_required.mp | Bin 0 -> 413 bytes .../wire_v2/provider_request_sha256.bin | Bin 0 -> 32 bytes .../tests/fixtures/wire_v2/result_failed.mp | Bin 0 -> 307 bytes .../wire_v2/result_refused_capacity.mp | 1 + .../wire_v2/result_refused_expired.mp | 1 + .../result_refused_identity_substitution.mp | 1 + .../wire_v2/result_refused_in_flight.mp | 1 + .../wire_v2/result_refused_indeterminate.mp | 1 + .../wire_v2/result_refused_malformed.mp | 1 + .../fixtures/wire_v2/result_refused_policy.mp | 1 + ...lt_refused_provider_digest_substitution.mp | 1 + .../wire_v2/result_refused_replay_conflict.mp | 1 + .../fixtures/wire_v2/result_transported.mp | Bin 0 -> 327 bytes .../model-connector/tests/wire_golden_v2.rs | 301 ++++++++++++++++++ .../model-connector/tests/wire_parity_v2.rs | 242 -------------- 27 files changed, 461 insertions(+), 268 deletions(-) create mode 100644 crates/model-connector/tests/fixtures/wire_v2/.gitattributes create mode 100644 crates/model-connector/tests/fixtures/wire_v2/README.md create mode 100644 crates/model-connector/tests/fixtures/wire_v2/canonical_invocation.bin create mode 100644 crates/model-connector/tests/fixtures/wire_v2/canonical_provider_request.bin create mode 100644 crates/model-connector/tests/fixtures/wire_v2/envelope_invocation.mp create mode 100644 crates/model-connector/tests/fixtures/wire_v2/envelope_result_failed.mp create mode 100644 crates/model-connector/tests/fixtures/wire_v2/envelope_result_refused_replay_conflict.mp create mode 100644 crates/model-connector/tests/fixtures/wire_v2/envelope_result_transported.mp create mode 100644 crates/model-connector/tests/fixtures/wire_v2/invocation_auto.mp create mode 100644 crates/model-connector/tests/fixtures/wire_v2/invocation_required.mp create mode 100644 crates/model-connector/tests/fixtures/wire_v2/provider_request_sha256.bin create mode 100644 crates/model-connector/tests/fixtures/wire_v2/result_failed.mp create mode 100644 crates/model-connector/tests/fixtures/wire_v2/result_refused_capacity.mp create mode 100644 crates/model-connector/tests/fixtures/wire_v2/result_refused_expired.mp create mode 100644 crates/model-connector/tests/fixtures/wire_v2/result_refused_identity_substitution.mp create mode 100644 crates/model-connector/tests/fixtures/wire_v2/result_refused_in_flight.mp create mode 100644 crates/model-connector/tests/fixtures/wire_v2/result_refused_indeterminate.mp create mode 100644 crates/model-connector/tests/fixtures/wire_v2/result_refused_malformed.mp create mode 100644 crates/model-connector/tests/fixtures/wire_v2/result_refused_policy.mp create mode 100644 crates/model-connector/tests/fixtures/wire_v2/result_refused_provider_digest_substitution.mp create mode 100644 crates/model-connector/tests/fixtures/wire_v2/result_refused_replay_conflict.mp create mode 100644 crates/model-connector/tests/fixtures/wire_v2/result_transported.mp create mode 100644 crates/model-connector/tests/wire_golden_v2.rs delete mode 100644 crates/model-connector/tests/wire_parity_v2.rs diff --git a/Cargo.lock b/Cargo.lock index a3dfca5..01f3693 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -58,12 +58,6 @@ version = "1.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" -[[package]] -name = "base64" -version = "0.23.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ac07cdecf99051d9a5238b80f35af32cdeba5b336e55d957b318b50137e18da5" - [[package]] name = "block-buffer" version = "0.10.4" @@ -119,22 +113,6 @@ dependencies = [ "inout", ] -[[package]] -name = "codex-connector" -version = "0.1.0" -source = "git+https://github.com/GameCult/CodexConnector.git?rev=fd7cec4176f4e3f9a5aaa75e77ba10ce5e1522f8#fd7cec4176f4e3f9a5aaa75e77ba10ce5e1522f8" -dependencies = [ - "aes-gcm", - "base64", - "rand_core", - "rmp-serde", - "serde", - "serde_json", - "sha2", - "thiserror", - "zeroize", -] - [[package]] name = "core-foundation-sys" version = "0.8.7" @@ -363,7 +341,6 @@ name = "model-connector" version = "0.1.0" dependencies = [ "aes-gcm", - "codex-connector", "cultcache-rs", "rand_core", "rmp-serde", diff --git a/crates/model-connector/Cargo.toml b/crates/model-connector/Cargo.toml index cb30f96..49f83db 100644 --- a/crates/model-connector/Cargo.toml +++ b/crates/model-connector/Cargo.toml @@ -21,6 +21,3 @@ serde_json = "1" sha2 = "0.10" thiserror = "2" zeroize = "1" - -[dev-dependencies] -codex-connector = { git = "https://github.com/GameCult/CodexConnector.git", rev = "fd7cec4176f4e3f9a5aaa75e77ba10ce5e1522f8", default-features = false } diff --git a/crates/model-connector/src/envelope.rs b/crates/model-connector/src/envelope.rs index 091452f..abebbb5 100644 --- a/crates/model-connector/src/envelope.rs +++ b/crates/model-connector/src/envelope.rs @@ -279,4 +279,120 @@ mod tests { Err(ServiceError::Encryption) ); } + + fn sealed_invocation(caller: &str, request_id: &str, kind: EnvelopeKind) -> TransportEnvelope { + let invocation = invocation(); + let plaintext = canonical_invocation_bytes(&invocation).unwrap(); + encrypt_message( + caller, + request_id, + kind, + &plaintext, + &security("epiphany-distinct-test-key"), + ) + .unwrap() + } + + #[test] + fn the_outer_identity_must_match_the_sealed_invocation_on_each_field() { + let key = security("epiphany-distinct-test-key"); + let invocation = invocation(); + let caller = invocation.caller_runtime_id.clone(); + let request_id = invocation.request_id().to_string(); + let matching = sealed_invocation(&caller, &request_id, EnvelopeKind::Invocation); + assert_eq!(decrypt_invocation(&matching, &key).unwrap(), invocation); + for envelope in [ + sealed_invocation("other-caller", &request_id, EnvelopeKind::Invocation), + sealed_invocation(&caller, "other-request", EnvelopeKind::Invocation), + ] { + assert_eq!( + decrypt_invocation(&envelope, &key), + Err(ServiceError::OuterIdentity) + ); + } + } + + #[test] + fn the_outer_identity_must_match_the_sealed_result_on_each_field() { + let key = security("epiphany-distinct-test-key"); + let invocation = invocation(); + let result = TransportResult::transported(&invocation, Vec::new(), receipt(&invocation)); + let plaintext = rmp_serde::to_vec(&result).unwrap(); + let seal = |caller: &str, request_id: &str| { + encrypt_message(caller, request_id, EnvelopeKind::Result, &plaintext, &key).unwrap() + }; + let caller = result.caller_runtime_id.clone(); + let request_id = result.request_id.clone(); + assert_eq!( + decrypt_result(&seal(&caller, &request_id), &key, &invocation).unwrap(), + result + ); + for envelope in [ + seal("other-caller", &request_id), + seal(&caller, "other-request"), + ] { + assert_eq!( + decrypt_result(&envelope, &key, &invocation), + Err(ServiceError::OuterIdentity) + ); + } + } + + #[test] + fn a_malformed_envelope_is_refused_before_any_decryption() { + let key = security("epiphany-distinct-test-key"); + let invocation = invocation(); + let good = encrypt_invocation(&invocation, &key).unwrap(); + + let mut schema = good.clone(); + schema.schema_id = "gamecult.codex.transport_envelope.v1".to_string(); + let wrong_kind = sealed_invocation("epiphany-yggdrasil", "request-1", EnvelopeKind::Result); + let mut empty = good.clone(); + empty.ciphertext.clear(); + for envelope in [schema, wrong_kind, empty] { + assert_eq!( + decrypt_invocation(&envelope, &key), + Err(ServiceError::Envelope) + ); + } + for field in ["caller_runtime_id", "request_id"] { + let mut blank = good.clone(); + if field == "caller_runtime_id" { + blank.caller_runtime_id = " ".to_string(); + } else { + blank.request_id = String::new(); + } + assert_eq!( + decrypt_invocation(&blank, &key), + Err(ServiceError::Contract(ContractError::Invalid(field))) + ); + } + } + + #[test] + fn a_frame_is_bounded_inclusively_and_never_empty() { + use std::io::Cursor; + let read = |length: u32, body: usize, max: usize| { + let mut frame = length.to_be_bytes().to_vec(); + frame.extend(vec![1; body]); + read_transport_frame(&mut Cursor::new(frame), max) + }; + assert_eq!(read(16, 16, 16).unwrap(), vec![1; 16]); + assert!(matches!(read(17, 17, 16), Err(TransportFrameError::Size))); + assert!(matches!(read(0, 0, 16), Err(TransportFrameError::Size))); + // A bound above the length never turns an empty frame into an empty payload. + assert!(matches!(read(0, 0, 1_000), Err(TransportFrameError::Size))); + + let write = |payload: &[u8], max: usize| { + let mut out = Vec::new(); + write_transport_frame(&mut out, payload, max).map(|()| out) + }; + assert_eq!(write(&[1; 16], 16).unwrap().len(), 20); + assert!(matches!( + write(&[1; 17], 16), + Err(TransportFrameError::Size) + )); + assert!(matches!(write(&[], 16), Err(TransportFrameError::Size))); + assert!(matches!(write(&[], 1_000), Err(TransportFrameError::Size))); + } } diff --git a/crates/model-connector/tests/fixtures/wire_v2/.gitattributes b/crates/model-connector/tests/fixtures/wire_v2/.gitattributes new file mode 100644 index 0000000..bf62110 --- /dev/null +++ b/crates/model-connector/tests/fixtures/wire_v2/.gitattributes @@ -0,0 +1,2 @@ +*.mp binary +*.bin binary diff --git a/crates/model-connector/tests/fixtures/wire_v2/README.md b/crates/model-connector/tests/fixtures/wire_v2/README.md new file mode 100644 index 0000000..b9fa9e5 --- /dev/null +++ b/crates/model-connector/tests/fixtures/wire_v2/README.md @@ -0,0 +1,20 @@ +# Golden v2 wire fixtures + +Bytes of the v2 connector wire, produced once by CodexConnector at commit +`fd7cec4176f4e3f9a5aaa75e77ba10ce5e1522f8` (the library Ghostlight pins), +and pinned by `tests/wire_golden_v2.rs` so that the kit cannot drift from +them. No CodexConnector code or dependency is part of this repository. + +Generator: `generator.rs` (not compiled here). Command, run in a clone of +CodexConnector at that commit: + + cp /crates/model-connector/tests/fixtures/wire_v2/generator.rs examples/gen_wire_v2.rs + cargo run --locked -q --example gen_wire_v2 + +It prints `FIXTURE ` lines; each file is the hex decoded. It was +run on Yggdrasil through the verify container with codex-connector at that +commit as a dev-dependency of the kit at d1d28f0 (same source). The `.mp` +files are rmp-serde output; `envelope_*` files carry random nonces and +AES-256-GCM ciphertext sealed under the connection key +`parity-distinct-test-key`. Regenerating changes the envelopes' bytes; the +plaintext fixtures must regenerate byte-identically. diff --git a/crates/model-connector/tests/fixtures/wire_v2/canonical_invocation.bin b/crates/model-connector/tests/fixtures/wire_v2/canonical_invocation.bin new file mode 100644 index 0000000000000000000000000000000000000000..727edf6cf9555f9c02825cc276855e2a1ffeb884 GIT binary patch literal 413 zcmah_u}T9$6bv@0Z7i&gE7HiFoW#aX1wSCfI&AhH$?E0y+uq!1gY8Xhx`nYo%}N~{9B>Y>f03pE=R^K~E<7K9&4&QM6- z4tItDQv&N2XPyUYlAg{}M*1Hl5}MK*&?h}-={-fGqYdgvy57tXQA`86*rs#(P?aL} zw&<0<=pjE(t#i841Mt2KA z4s^2CP!4L|aDs1QihXX}II>8El<4Id;K zSMYR|mBcT^`T80O8mUM;wcRkl575y3C6FYvA6FrmXbak>9;KbM-M~3}BduM$s;E4D z_mFxNT;&y((^>nr1NsQ@vd06mbobA>$?{E{`Qo_n-H+wO6-cBW8zSgrze#*e8BI#C bOXhCxpoq=7-8aTV|7^A=y_J$G2T%V2OD}Km literal 0 HcmV?d00001 diff --git a/crates/model-connector/tests/fixtures/wire_v2/envelope_invocation.mp b/crates/model-connector/tests/fixtures/wire_v2/envelope_invocation.mp new file mode 100644 index 0000000..3f95738 --- /dev/null +++ b/crates/model-connector/tests/fixtures/wire_v2/envelope_invocation.mp @@ -0,0 +1,6 @@ +–Ù$gamecult.codex.transport_envelope.v2­parity-caller©request-1ªInvocationœÌ£VÌÁḩ̃̌xÌ©Y̽Ü­hÌ·ÌýÌâÌ—Ì…3ÌÕ ÌÇÌþÌ‘ÌÜḩ̨Ìé[̪ÌÙ!AÌë̉ÌðÌúÌÛ;ÌëÌú| ̪ÌÉ`!ÌúÌú̧̚Ì� +Ì�kÌÞ'vCZÌà̖̌̈ 8J̧ÌÀḍ̗NÌ¿FÌ¿zm6Ìÿ +Ì÷ÌíÌð\\NMqÌ–Ì×Ì— Ìù1ÌæÌ¦Ì¯Ìè 4*{ÌË̥̱ +ÌÖ̦eÌÇ̨Ì� +̰̆ḭG*SÌ�̳̼ÌÒ̥̮ÌÜ!NÌÀÌÑ̠̫̙̂7Ì�ÌÇ̰Ìì̃ÌÀ̬c̦ÌË ÌÒ̲1kÌá~hÌß3$ÌÚÌ KÌß̰ÌùÌÜ4{ÌÓÌß̼̩U.)ÌÌ̭̤rÌ ÌýÌìÌÆ^bbÏy^̳ÌßÌÌÌ�ÌÉ:Ì Ìš-ÌåYXoÌÓÌÒ[Qy̘̫ +XÌÓI:3̽ÌÔ̰D&V'-ÌòufÌŒX1ÌøÌÒ.Ì�|4cÌãÌ‹EÌåAÌßÌæÌŸÌÍQÌÝLÌ‚-Ìáw̰uÌ´3̹>Ìï$QzÌÀk m'+ÌÐÌ¢G Ì´sÌ®00 f)Ì·C̯̙*̆Ì’ÌÛy~ÌùHzÌøÌåÌý"ÌÇÌ¢*%ÌôoÌÉ̯ÌÙxÌŽ}Ì‹ÌÏÌÝYÌÈCÌîÌ´Ì¿uÌü̸x/gÌëÌñ̘X-ÌÙÌÂ̮̆NÌìRI):HÌñÌÉÌ’ÌÀ̗̉fÌÛÌ '\s̨ Ì�s̉ÌÄGVaÌÓ̶̲C\\`=p ÌžwÌŽHÌÖ4ÌÆÌØ>lfLÌ£ÌþÌäÌÖvÌ¿ÌÞJÌë \ No newline at end of file diff --git a/crates/model-connector/tests/fixtures/wire_v2/envelope_result_failed.mp b/crates/model-connector/tests/fixtures/wire_v2/envelope_result_failed.mp new file mode 100644 index 0000000..4b94559 --- /dev/null +++ b/crates/model-connector/tests/fixtures/wire_v2/envelope_result_failed.mp @@ -0,0 +1,7 @@ +–Ù$gamecult.codex.transport_envelope.v2­parity-caller©request-1¦ResultœÌ…Ì¢ÌÇ"̇Ì�GÌ©ÜCÌôÌË Ì�ÌéHÌßÌ—ÌÿÌÐÌ´ÌÝÌá\Ìö]2/ÌÎe"̘ +Ì¿ÌáÌìÌü{GÌÚṴ̧^b3Ìûj̢̗̟ÌÃÌ’sÌ•*Ì» ÌøÌä= ̸̄<ÌÁ{ÌýÌã.ÌØ8~̫̓CÌâ +̱̘ÌË +[Ì‚ÌÉoÌ–(47ÌÌBML̲Ìó̢̳Ì�hp,ÌîÌ®wÌê6DHÌðbÌÏÌÌb̹@a̧ÌÑ̵̎Ì´Ì�kÌäÌ’ÌáÌóÌ€ÌÑN+ÌüMÌÀjG %Ìß/Ìô +ÌÁÌò̸%Ì»Ì�@sT`ÌâÌ’}P̈ÌÒ ÌëÌÊÌÞ Ì«Ìâ:ÌÚ̟̖̔Ìû^Ìô>ÌûX.x +r]F8Ì�ÌÛÌ‹Ì÷Ì�5ÌÚGÌÆÌºÌÃÌ®C@(ÌóÌÓ̾{̾ÌÙMÌÓ~)Ìà 7ÌÐÌòI+'v ÌøUnP̈$U(NÌêÌçÌüsÌ¢T^[ÌÊYÌæÌ�zÌâ.wÌÆeÌ™C̦̿̄"U^trÌöÌ�ÌÙ̦Ìï̯̬ÌçÌÜÌž+ÌÑ/b̯̭̼Ìî*$Ì× ÌŠÌ²̘ÌÕ\K5̧f +Ìò̃b}̦̬o.Ẁ̧&Ìå̶̑[ \ No newline at end of file diff --git a/crates/model-connector/tests/fixtures/wire_v2/envelope_result_refused_replay_conflict.mp b/crates/model-connector/tests/fixtures/wire_v2/envelope_result_refused_replay_conflict.mp new file mode 100644 index 0000000000000000000000000000000000000000..10e3726559fa374cd41b9b35c34f97bac50dd09f GIT binary patch literal 252 zcmVow+yG@3%-hVD z%)-o`e9W^F%(2YrHq4O`%&g4Y%-PK1%>53`kY&ujNzCoc-OSp|w9K_c%%Ty@ifzpJ zJj}?<;6FXg+01~<_{@#Wrp)EciF;?v?9B4a_H4|q%)!jT%Ql; zi5fzshECDCNW}`R(A1wvVW6QFW|7*^Bqk9?Zye6md7krpxMw0vhEmB`Zg$2Pt!Jdl z7^mG}aoDVOXQ5=Nkjz$_6jKq~Wi!}|omEPs!EBc7?RKfGTyi*-syn4ED39bBK5pXM&8R@Qxs6ZBQ#IeUjNv`M)xrji^lP%uo>cK@ zpG#LH@t3dkFp#9qSJTFNW*NN&eq43gXiHVn%D9*x+}8vNls7if$wYD>wQ{Btz)US` z$LVD*I5aOxXCia)mWb&tvm&pzzeF0o!EU`K=h0mjI1=2l>fRR&Ep?86Mn4F7akFlQ~vTr?D8%#2&3uBfQo!!-ARxZ31hwSed*K zQOXzQbPNfxk(|OqxYNUU!^$>>h504HWSxP0R`Dpb^z%o-6{c7Y=Q&S!C$@_b>O`zM z8Vg0CgS=uY!GcdOy6oX{%_%nYagKC0&har!oHxY`pWq>%=se6yG4;n}ypE$aN`xM- m;cgo3J5|v_`|+*}=K1Yk!F9bL`tp+sO65GD`6!Dt9Qg;?kz>{X literal 0 HcmV?d00001 diff --git a/crates/model-connector/tests/fixtures/wire_v2/invocation_auto.mp b/crates/model-connector/tests/fixtures/wire_v2/invocation_auto.mp new file mode 100644 index 0000000000000000000000000000000000000000..e505ca2bba33dc48ec8cfe6a607ba0f19a5c34eb GIT binary patch literal 408 zcmah_Jxjw-6b&w2Ttsj+WY9qph$w=a3XTq?4o>y?a%rP!UVQH+w3H6{1431t9HdnH zAr&0OrRv~v3msevot<3t7ns@(;^LVOmvb)XoQuIijun819$H*DP_$4oUj;&9*7YO7 z8M?x^qUBD&q>Ck!GtUFrOZA7Ep85xgga-SZzLuy>C)D4jXCiu{*F}1#H-j$M_ULm? zqpMk)hV(H_kMlc2ZDwHni4;f?xemyzgyRqtCUII5YT9>P_z_6;6V$XDxd6>oYo)ab zIusd4>pX7mDv%i{V)a@fLx?*YO1TO3aeS2!c%`_R5yIO_`kmd3TAKE)1mx`?7(h0E~4*92^2i Tqf%!fikEJ0N^5Y&^I+QF(pIMz literal 0 HcmV?d00001 diff --git a/crates/model-connector/tests/fixtures/wire_v2/invocation_required.mp b/crates/model-connector/tests/fixtures/wire_v2/invocation_required.mp new file mode 100644 index 0000000000000000000000000000000000000000..727edf6cf9555f9c02825cc276855e2a1ffeb884 GIT binary patch literal 413 zcmah_u}T9$6bv@0Z7i&gE7HiFoW#aX1wSCfI&AhH$?E0y+uq!1gY8Xhx`nYo%}N~{9B>Y>f03pE=R^K~E<7K9&4&QM6- z4tItDQv&N2XPyUYlAg{}M*1Hl5}MK*&?h}-={-fGqYdgvy57tXQA`86*rs#(P?aL} zw&<0<=pjE(t#i841Mt2KA z4s^2CwXig`xJ1`*Z9!sD zW=W-Pa$-(SYEk3r5SZfBlt~k2+*HP6R&r`)0Y1a-FetDS06-;YE}XfWdgkYueI{of zFq~QECU#~@{+U@)YG=-zS#f6NnRfj%&1W7JElA5BBSyHT!R9TX$ Kkd$AVmjVEMQG6Ky literal 0 HcmV?d00001 diff --git a/crates/model-connector/tests/fixtures/wire_v2/result_refused_capacity.mp b/crates/model-connector/tests/fixtures/wire_v2/result_refused_capacity.mp new file mode 100644 index 0000000..4425e6a --- /dev/null +++ b/crates/model-connector/tests/fixtures/wire_v2/result_refused_capacity.mp @@ -0,0 +1 @@ +”Ù"gamecult.codex.transport_result.v2©request-1­parity-caller�§Refused¨Capacity \ No newline at end of file diff --git a/crates/model-connector/tests/fixtures/wire_v2/result_refused_expired.mp b/crates/model-connector/tests/fixtures/wire_v2/result_refused_expired.mp new file mode 100644 index 0000000..47fff91 --- /dev/null +++ b/crates/model-connector/tests/fixtures/wire_v2/result_refused_expired.mp @@ -0,0 +1 @@ +”Ù"gamecult.codex.transport_result.v2©request-1­parity-caller�§Refused§Expired \ No newline at end of file diff --git a/crates/model-connector/tests/fixtures/wire_v2/result_refused_identity_substitution.mp b/crates/model-connector/tests/fixtures/wire_v2/result_refused_identity_substitution.mp new file mode 100644 index 0000000..8da919c --- /dev/null +++ b/crates/model-connector/tests/fixtures/wire_v2/result_refused_identity_substitution.mp @@ -0,0 +1 @@ +”Ù"gamecult.codex.transport_result.v2©request-1­parity-caller�§Refused´IdentitySubstitution \ No newline at end of file diff --git a/crates/model-connector/tests/fixtures/wire_v2/result_refused_in_flight.mp b/crates/model-connector/tests/fixtures/wire_v2/result_refused_in_flight.mp new file mode 100644 index 0000000..0c9f9d3 --- /dev/null +++ b/crates/model-connector/tests/fixtures/wire_v2/result_refused_in_flight.mp @@ -0,0 +1 @@ +”Ù"gamecult.codex.transport_result.v2©request-1­parity-caller�§Refused¨InFlight \ No newline at end of file diff --git a/crates/model-connector/tests/fixtures/wire_v2/result_refused_indeterminate.mp b/crates/model-connector/tests/fixtures/wire_v2/result_refused_indeterminate.mp new file mode 100644 index 0000000..068bed3 --- /dev/null +++ b/crates/model-connector/tests/fixtures/wire_v2/result_refused_indeterminate.mp @@ -0,0 +1 @@ +”Ù"gamecult.codex.transport_result.v2©request-1­parity-caller�§Refused­Indeterminate \ No newline at end of file diff --git a/crates/model-connector/tests/fixtures/wire_v2/result_refused_malformed.mp b/crates/model-connector/tests/fixtures/wire_v2/result_refused_malformed.mp new file mode 100644 index 0000000..f106b9f --- /dev/null +++ b/crates/model-connector/tests/fixtures/wire_v2/result_refused_malformed.mp @@ -0,0 +1 @@ +”Ù"gamecult.codex.transport_result.v2©request-1­parity-caller�§Refused©Malformed \ No newline at end of file diff --git a/crates/model-connector/tests/fixtures/wire_v2/result_refused_policy.mp b/crates/model-connector/tests/fixtures/wire_v2/result_refused_policy.mp new file mode 100644 index 0000000..28712db --- /dev/null +++ b/crates/model-connector/tests/fixtures/wire_v2/result_refused_policy.mp @@ -0,0 +1 @@ +”Ù"gamecult.codex.transport_result.v2©request-1­parity-caller�§Refused¦Policy \ No newline at end of file diff --git a/crates/model-connector/tests/fixtures/wire_v2/result_refused_provider_digest_substitution.mp b/crates/model-connector/tests/fixtures/wire_v2/result_refused_provider_digest_substitution.mp new file mode 100644 index 0000000..76b2169 --- /dev/null +++ b/crates/model-connector/tests/fixtures/wire_v2/result_refused_provider_digest_substitution.mp @@ -0,0 +1 @@ +”Ù"gamecult.codex.transport_result.v2©request-1­parity-caller�§RefusedºProviderDigestSubstitution \ No newline at end of file diff --git a/crates/model-connector/tests/fixtures/wire_v2/result_refused_replay_conflict.mp b/crates/model-connector/tests/fixtures/wire_v2/result_refused_replay_conflict.mp new file mode 100644 index 0000000..9f621de --- /dev/null +++ b/crates/model-connector/tests/fixtures/wire_v2/result_refused_replay_conflict.mp @@ -0,0 +1 @@ +”Ù"gamecult.codex.transport_result.v2©request-1­parity-caller�§Refused®ReplayConflict \ No newline at end of file diff --git a/crates/model-connector/tests/fixtures/wire_v2/result_transported.mp b/crates/model-connector/tests/fixtures/wire_v2/result_transported.mp new file mode 100644 index 0000000000000000000000000000000000000000..f4fedf862d0f0cd231090f0848610542f5a65cfa GIT binary patch literal 327 zcmbPoQz<<$H#NC5r$jF~KP9z7ucRn3uecz;s3g89wHPE^X0);>wXig`xJ1`*Z9!sD zW=W-Pa$-(SYEk3r5SZfBlu45&F*L3WNv$YxNzExqoVYY2H76&35@X|vko^1{XP}13 z%Rt(6jaC<>CZ@y}=a&{Gr!K0lopDncj}wwpGYjy!;SPfWI{^Sxa^}LByQyb>p4n$| z<^jW*b#7v3rsSWQC8c)e%$XHuR-S3sKhu2XQBiKvnPu8%PM!I4W=+WQ^nwyyQ$3Sa kP#+f;r#7y1&d)8#Ni9iDnYs!XI0gB6#i_c6T+ECt0JtTD5dZ)H literal 0 HcmV?d00001 diff --git a/crates/model-connector/tests/wire_golden_v2.rs b/crates/model-connector/tests/wire_golden_v2.rs new file mode 100644 index 0000000..198b731 --- /dev/null +++ b/crates/model-connector/tests/wire_golden_v2.rs @@ -0,0 +1,301 @@ +//! The kit speaks the v2 wire of CodexConnector fd7cec4, the library Ghostlight pins. +//! The golden bytes in `fixtures/wire_v2` were produced by that library (see its README); +//! nothing of it is linked here. Deleted with the fixtures by cut connector-contract-v3, +//! which changes the wire. + +use model_connector as kit; +use serde::Serialize; +use serde::de::DeserializeOwned; + +const KEY: &str = "parity-distinct-test-key"; +const CALLER: &str = "parity-caller"; + +macro_rules! golden { + ($name:literal) => { + include_bytes!(concat!("fixtures/wire_v2/", $name)) as &[u8] + }; +} + +fn bytes(value: &T) -> Vec { + rmp_serde::to_vec(value).unwrap() +} + +/// The value serializes to the golden bytes and the golden bytes decode back to it. +fn assert_wire( + name: &str, + value: &T, + golden: &[u8], +) { + assert_eq!(bytes(value), golden, "{name}: serialized bytes"); + let decoded: T = rmp_serde::from_slice(golden).unwrap(); + assert_eq!(&decoded, value, "{name}: decoded value"); +} + +fn request(choice: kit::ToolChoice) -> kit::ProviderRequest { + let mut request = + kit::ProviderRequest::new("request-1", "conversation-1", "gpt-5.4", "Be exact."); + request.input = vec![ + kit::InputItem::UserText { + text: "state".to_string(), + }, + kit::InputItem::AssistantText { + text: "reply".to_string(), + }, + kit::InputItem::ToolCall { + call_id: "call-1".to_string(), + name: "read_source".to_string(), + arguments: "{}".to_string(), + }, + kit::InputItem::ToolResult { + call_id: "call-1".to_string(), + output: "ok".to_string(), + }, + ]; + request.tools = vec![kit::ToolDefinition { + name: "read_source".to_string(), + description: "Read one admitted source.".to_string(), + parameters_json: r#"{"type":"object"}"#.to_string(), + }]; + request.tool_choice = choice; + request.reasoning_effort = Some("high".to_string()); + request.parallel_tool_calls = true; + request.max_output_tokens = Some(512); + request.prompt_cache_key = Some("cache-1".to_string()); + request +} + +fn invocation(choice: kit::ToolChoice) -> kit::TransportInvocation { + kit::TransportInvocation::new(CALLER, 2_000, [7; 32], request(choice)).unwrap() +} + +fn receipt( + invocation: &kit::TransportInvocation, + outcome: kit::TransportOutcome, +) -> kit::TransportReceipt { + kit::TransportReceipt { + schema_id: kit::RECEIPT_SCHEMA_ID.to_string(), + request_id: invocation.request_id().to_string(), + caller_runtime_id: invocation.caller_runtime_id.clone(), + native_request_sha256: invocation.native_request_sha256, + provider_request_sha256: invocation.provider_request_sha256, + model: invocation.request.model.clone(), + transport: "parity-sse".to_string(), + outcome, + } +} + +fn completed(invocation: &kit::TransportInvocation) -> kit::TransportResult { + kit::TransportResult::transported( + invocation, + vec![ + kit::TransportEvent { + sequence: 0, + payload: kit::TransportEventPayload::TextDelta { + text: "hello".to_string(), + }, + }, + kit::TransportEvent { + sequence: 1, + payload: kit::TransportEventPayload::ToolCall { + call_id: "call-2".to_string(), + name: "read_source".to_string(), + arguments: "{}".to_string(), + }, + }, + ], + receipt( + invocation, + kit::TransportOutcome::Completed { + provider_response_id: Some("response-1".to_string()), + input_tokens: Some(10), + output_tokens: Some(3), + reasoning_output_tokens: Some(1), + cached_input_tokens: Some(4), + }, + ), + ) +} + +fn failed(invocation: &kit::TransportInvocation) -> kit::TransportResult { + kit::TransportResult::transported( + invocation, + Vec::new(), + receipt( + invocation, + kit::TransportOutcome::Failed { + failure_kind: "result_size".to_string(), + message: "provider result exceeded its byte bound".to_string(), + }, + ), + ) +} + +fn key() -> kit::TransportKey { + kit::TransportKey::from_connection_secret(KEY).unwrap() +} + +fn refusals() -> [(&'static str, kit::Refusal, &'static [u8]); 9] { + use kit::Refusal as R; + [ + ("expired", R::Expired, golden!("result_refused_expired.mp")), + ( + "identity_substitution", + R::IdentitySubstitution, + golden!("result_refused_identity_substitution.mp"), + ), + ( + "provider_digest_substitution", + R::ProviderDigestSubstitution, + golden!("result_refused_provider_digest_substitution.mp"), + ), + ("policy", R::Policy, golden!("result_refused_policy.mp")), + ( + "capacity", + R::Capacity, + golden!("result_refused_capacity.mp"), + ), + ( + "in_flight", + R::InFlight, + golden!("result_refused_in_flight.mp"), + ), + ( + "indeterminate", + R::Indeterminate, + golden!("result_refused_indeterminate.mp"), + ), + ( + "replay_conflict", + R::ReplayConflict, + golden!("result_refused_replay_conflict.mp"), + ), + ( + "malformed", + R::Malformed, + golden!("result_refused_malformed.mp"), + ), + ] +} + +#[test] +fn schema_ids_are_the_v2_ids() { + assert_eq!( + kit::PROVIDER_REQUEST_SCHEMA_ID, + "gamecult.codex.provider_request.v2" + ); + assert_eq!( + kit::INVOCATION_SCHEMA_ID, + "gamecult.codex.transport_invocation.v2" + ); + assert_eq!(kit::RESULT_SCHEMA_ID, "gamecult.codex.transport_result.v2"); + assert_eq!( + kit::RECEIPT_SCHEMA_ID, + "gamecult.codex.transport_receipt.v2" + ); + assert_eq!( + kit::ENVELOPE_SCHEMA_ID, + "gamecult.codex.transport_envelope.v2" + ); +} + +#[test] +fn invocations_carry_the_v2_bytes_for_each_tool_choice() { + assert_wire( + "auto", + &invocation(kit::ToolChoice::Auto), + golden!("invocation_auto.mp"), + ); + assert_wire( + "required", + &invocation(kit::ToolChoice::Required), + golden!("invocation_required.mp"), + ); +} + +#[test] +fn results_carry_the_v2_bytes_for_success_and_failure() { + let invocation = invocation(kit::ToolChoice::Required); + assert_wire( + "transported", + &completed(&invocation), + golden!("result_transported.mp"), + ); + assert_wire("failed", &failed(&invocation), golden!("result_failed.mp")); +} + +#[test] +fn every_refusal_carries_its_v2_bytes() { + let invocation = invocation(kit::ToolChoice::Required); + for (name, refusal, golden) in refusals() { + assert_wire( + name, + &kit::TransportResult::refused(&invocation, refusal), + golden, + ); + } +} + +#[test] +fn canonical_bytes_and_provider_digest_are_the_v2_bytes() { + let invocation = invocation(kit::ToolChoice::Required); + assert_eq!( + kit::canonical_invocation_bytes(&invocation).unwrap(), + golden!("canonical_invocation.bin") + ); + assert_eq!( + kit::canonical_provider_request_bytes(&invocation.request).unwrap(), + golden!("canonical_provider_request.bin") + ); + assert_eq!( + kit::provider_request_sha256(&invocation.request) + .unwrap() + .as_slice(), + golden!("provider_request_sha256.bin") + ); +} + +#[test] +fn v2_sealed_envelopes_decrypt_and_validate_under_the_kit() { + let invocation = invocation(kit::ToolChoice::Required); + let envelope: kit::TransportEnvelope = + rmp_serde::from_slice(golden!("envelope_invocation.mp")).unwrap(); + let received = kit::decrypt_invocation(&envelope, &key()).unwrap(); + assert_eq!(bytes(&received), bytes(&invocation)); + + let results = [ + ( + golden!("envelope_result_transported.mp"), + completed(&invocation), + ), + (golden!("envelope_result_failed.mp"), failed(&invocation)), + ( + golden!("envelope_result_refused_replay_conflict.mp"), + kit::TransportResult::refused(&invocation, kit::Refusal::ReplayConflict), + ), + ]; + for (sealed, expected) in results { + let envelope: kit::TransportEnvelope = rmp_serde::from_slice(sealed).unwrap(); + let received = kit::decrypt_result(&envelope, &key(), &invocation).unwrap(); + assert_eq!(bytes(&received), bytes(&expected)); + } +} + +#[test] +fn kit_sealed_envelopes_have_the_v2_shape() { + let invocation = invocation(kit::ToolChoice::Required); + let golden: kit::TransportEnvelope = + rmp_serde::from_slice(golden!("envelope_invocation.mp")).unwrap(); + let sealed = kit::encrypt_invocation(&invocation, &key()).unwrap(); + assert_eq!(sealed.schema_id, golden.schema_id); + assert_eq!(sealed.caller_runtime_id, golden.caller_runtime_id); + assert_eq!(sealed.request_id, golden.request_id); + assert_eq!(sealed.message_kind, golden.message_kind); + assert_eq!(sealed.ciphertext.len(), golden.ciphertext.len()); + assert_ne!(sealed.nonce, golden.nonce, "nonces are fresh per seal"); + + let golden: kit::TransportEnvelope = + rmp_serde::from_slice(golden!("envelope_result_transported.mp")).unwrap(); + let sealed = kit::encrypt_result(&completed(&invocation), &key()).unwrap(); + assert_eq!(sealed.message_kind, golden.message_kind); + assert_eq!(sealed.ciphertext.len(), golden.ciphertext.len()); +} diff --git a/crates/model-connector/tests/wire_parity_v2.rs b/crates/model-connector/tests/wire_parity_v2.rs deleted file mode 100644 index fa2d3ad..0000000 --- a/crates/model-connector/tests/wire_parity_v2.rs +++ /dev/null @@ -1,242 +0,0 @@ -//! The lift is wire-identical to CodexConnector fd7cec4, the v2 library -//! Ghostlight pins. Deleted by cut connector-contract-v3, which changes the wire. - -use codex_connector as v2; -use model_connector as kit; -use serde::Serialize; -use serde::de::DeserializeOwned; - -const KEY: &str = "parity-distinct-test-key"; -const CALLER: &str = "parity-caller"; - -/// Moves a value across the two crates by its MessagePack bytes, the shape the wire carries. -fn cross(value: &T) -> U { - rmp_serde::from_slice(&rmp_serde::to_vec(value).unwrap()).unwrap() -} - -fn bytes(value: &T) -> Vec { - rmp_serde::to_vec(value).unwrap() -} - -fn kit_request() -> kit::ProviderRequest { - let mut request = - kit::ProviderRequest::new("request-1", "conversation-1", "gpt-5.4", "Be exact."); - request.input = vec![ - kit::InputItem::UserText { - text: "state".to_string(), - }, - kit::InputItem::AssistantText { - text: "reply".to_string(), - }, - kit::InputItem::ToolCall { - call_id: "call-1".to_string(), - name: "read_source".to_string(), - arguments: "{}".to_string(), - }, - kit::InputItem::ToolResult { - call_id: "call-1".to_string(), - output: "ok".to_string(), - }, - ]; - request.tools = vec![kit::ToolDefinition { - name: "read_source".to_string(), - description: "Read one admitted source.".to_string(), - parameters_json: r#"{"type":"object"}"#.to_string(), - }]; - request.tool_choice = kit::ToolChoice::Required; - request.reasoning_effort = Some("high".to_string()); - request.parallel_tool_calls = true; - request.max_output_tokens = Some(512); - request.prompt_cache_key = Some("cache-1".to_string()); - request -} - -fn v2_request() -> v2::CodexProviderRequest { - let mut request = - v2::CodexProviderRequest::new("request-1", "conversation-1", "gpt-5.4", "Be exact."); - request.input = vec![ - v2::CodexInputItem::UserText { - text: "state".to_string(), - }, - v2::CodexInputItem::AssistantText { - text: "reply".to_string(), - }, - v2::CodexInputItem::ToolCall { - call_id: "call-1".to_string(), - name: "read_source".to_string(), - arguments: "{}".to_string(), - }, - v2::CodexInputItem::ToolResult { - call_id: "call-1".to_string(), - output: "ok".to_string(), - }, - ]; - request.tools = vec![v2::CodexToolDefinition { - name: "read_source".to_string(), - description: "Read one admitted source.".to_string(), - parameters_json: r#"{"type":"object"}"#.to_string(), - }]; - request.tool_choice = v2::CodexToolChoice::Required; - request.reasoning_effort = Some("high".to_string()); - request.parallel_tool_calls = true; - request.max_output_tokens = Some(512); - request.prompt_cache_key = Some("cache-1".to_string()); - request -} - -fn kit_invocation() -> kit::TransportInvocation { - kit::TransportInvocation::new(CALLER, 2_000, [7; 32], kit_request()).unwrap() -} - -fn v2_invocation() -> v2::CodexTransportInvocation { - v2::CodexTransportInvocation::new(CALLER, 2_000, [7; 32], v2_request()).unwrap() -} - -fn kit_result(invocation: &kit::TransportInvocation) -> kit::TransportResult { - let receipt = kit::TransportReceipt { - schema_id: kit::RECEIPT_SCHEMA_ID.to_string(), - request_id: invocation.request_id().to_string(), - caller_runtime_id: invocation.caller_runtime_id.clone(), - native_request_sha256: invocation.native_request_sha256, - provider_request_sha256: invocation.provider_request_sha256, - model: invocation.request.model.clone(), - transport: "parity-sse".to_string(), - outcome: kit::TransportOutcome::Completed { - provider_response_id: Some("response-1".to_string()), - input_tokens: Some(10), - output_tokens: Some(3), - reasoning_output_tokens: Some(1), - cached_input_tokens: None, - }, - }; - kit::TransportResult::transported( - invocation, - vec![ - kit::TransportEvent { - sequence: 0, - payload: kit::TransportEventPayload::TextDelta { - text: "hello".to_string(), - }, - }, - kit::TransportEvent { - sequence: 1, - payload: kit::TransportEventPayload::ToolCall { - call_id: "call-2".to_string(), - name: "read_source".to_string(), - arguments: "{}".to_string(), - }, - }, - ], - receipt, - ) -} - -fn v2_result(invocation: &v2::CodexTransportInvocation) -> v2::CodexTransportResult { - let receipt = v2::CodexTransportReceipt { - schema_id: v2::RECEIPT_SCHEMA_ID.to_string(), - request_id: invocation.request_id().to_string(), - caller_runtime_id: invocation.caller_runtime_id.clone(), - native_request_sha256: invocation.native_request_sha256, - provider_request_sha256: invocation.provider_request_sha256, - model: invocation.request.model.clone(), - transport: "parity-sse".to_string(), - outcome: v2::CodexTransportOutcome::Completed { - provider_response_id: Some("response-1".to_string()), - input_tokens: Some(10), - output_tokens: Some(3), - reasoning_output_tokens: Some(1), - cached_input_tokens: None, - }, - }; - v2::CodexTransportResult::transported( - invocation, - vec![ - v2::CodexTransportEvent { - sequence: 0, - payload: v2::CodexTransportEventPayload::TextDelta { - text: "hello".to_string(), - }, - }, - v2::CodexTransportEvent { - sequence: 1, - payload: v2::CodexTransportEventPayload::ToolCall { - call_id: "call-2".to_string(), - name: "read_source".to_string(), - arguments: "{}".to_string(), - }, - }, - ], - receipt, - ) -} - -fn kit_key() -> kit::TransportKey { - kit::TransportKey::from_connection_secret(KEY).unwrap() -} - -fn v2_key() -> v2::CodexTransportKey { - v2::CodexTransportKey::from_connection_secret(KEY).unwrap() -} - -#[test] -fn schema_ids_are_the_v2_ids() { - assert_eq!( - kit::PROVIDER_REQUEST_SCHEMA_ID, - v2::PROVIDER_REQUEST_SCHEMA_ID - ); - assert_eq!(kit::INVOCATION_SCHEMA_ID, v2::INVOCATION_SCHEMA_ID); - assert_eq!(kit::RESULT_SCHEMA_ID, v2::RESULT_SCHEMA_ID); - assert_eq!(kit::RECEIPT_SCHEMA_ID, v2::RECEIPT_SCHEMA_ID); - assert_eq!(kit::ENVELOPE_SCHEMA_ID, v2::ENVELOPE_SCHEMA_ID); -} - -#[test] -fn canonical_bytes_and_provider_digest_match_v2() { - let from_kit = kit_invocation(); - let from_v2 = v2_invocation(); - assert_eq!(bytes(&from_kit), bytes(&from_v2)); - assert_eq!( - kit::canonical_invocation_bytes(&from_kit).unwrap(), - v2::canonical_invocation_bytes(&from_v2).unwrap() - ); - assert_eq!( - kit::canonical_provider_request_bytes(&from_kit.request).unwrap(), - v2::canonical_provider_request_bytes(&from_v2.request).unwrap() - ); - assert_eq!( - kit::provider_request_sha256(&from_kit.request).unwrap(), - v2::provider_request_sha256(&from_v2.request).unwrap() - ); -} - -#[test] -fn kit_invocation_decrypts_and_validates_under_v2() { - let sent = kit_invocation(); - let envelope = kit::encrypt_invocation(&sent, &kit_key()).unwrap(); - let received = v2::decrypt_invocation(&cross(&envelope), &v2_key()).unwrap(); - assert_eq!(bytes(&received), bytes(&sent)); -} - -#[test] -fn v2_invocation_decrypts_and_validates_under_kit() { - let sent = v2_invocation(); - let envelope = v2::encrypt_invocation(&sent, &v2_key()).unwrap(); - let received = kit::decrypt_invocation(&cross(&envelope), &kit_key()).unwrap(); - assert_eq!(bytes(&received), bytes(&sent)); -} - -#[test] -fn kit_result_decrypts_and_validates_under_v2() { - let sent = kit_result(&kit_invocation()); - let envelope = kit::encrypt_result(&sent, &kit_key()).unwrap(); - let received = v2::decrypt_result(&cross(&envelope), &v2_key(), &v2_invocation()).unwrap(); - assert_eq!(bytes(&received), bytes(&sent)); -} - -#[test] -fn v2_result_decrypts_and_validates_under_kit() { - let sent = v2_result(&v2_invocation()); - let envelope = v2::encrypt_result(&sent, &v2_key()).unwrap(); - let received = kit::decrypt_result(&cross(&envelope), &kit_key(), &kit_invocation()).unwrap(); - assert_eq!(bytes(&received), bytes(&sent)); -} From caddbc8d9a9226344addb795d15b63b4d623b845 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Wed, 28 Oct 2026 05:12:11 +0100 Subject: [PATCH 09/69] serve tests: pin the result bound's arithmetic and that in-bound results pass through untouched Co-Authored-By: Claude Opus 5.5 --- crates/model-connector/src/serve.rs | 49 +++++++++++++++++++++++++++++ 1 file changed, 49 insertions(+) diff --git a/crates/model-connector/src/serve.rs b/crates/model-connector/src/serve.rs index 4158bdc..fec618f 100644 --- a/crates/model-connector/src/serve.rs +++ b/crates/model-connector/src/serve.rs @@ -485,6 +485,18 @@ mod tests { TransportDisposition::Transported { .. } )); assert_eq!(first, second); + let TransportDisposition::Transported { events, receipt } = &first.disposition else { + unreachable!("matched above"); + }; + assert_eq!( + events.len(), + 1, + "a result within the bound is delivered as the provider made it" + ); + assert!(matches!( + receipt.outcome, + TransportOutcome::Completed { .. } + )); assert_eq!(backend.executions.load(Ordering::SeqCst), 1); let mut conflicting_request = request(); @@ -802,6 +814,43 @@ mod tests { assert_eq!(backend.executions.load(Ordering::SeqCst), 1); } + #[test] + fn a_result_is_bounded_by_the_frame_less_its_overhead_inclusively() { + let invocation = fresh("request-bound"); + let result_with = |text_len: usize| { + TransportResult::transported( + &invocation, + vec![TransportEvent { + sequence: 0, + payload: TransportEventPayload::TextDelta { + text: "z".repeat(text_len), + }, + }], + receipt(&invocation), + ) + }; + let bounded = ServeLimits { + max_frame_bytes: 20_000, + ..limits() + }; + let max = bounded.max_result_bytes(); + assert_eq!(max, 20_000 - 4096); + // MessagePack headers are the same width across this range, so one probe fixes the size. + let probe = rmp_serde::to_vec(&result_with(10_000)).unwrap().len(); + let exact = 10_000 + max - probe; + assert_eq!(rmp_serde::to_vec(&result_with(exact)).unwrap().len(), max); + assert_eq!( + bound_result(&invocation, result_with(exact), max), + result_with(exact) + ); + let over = bound_result(&invocation, result_with(exact + 1), max); + let TransportDisposition::Transported { receipt, .. } = &over.disposition else { + panic!("expected a transported failure"); + }; + assert!(matches!(receipt.outcome, TransportOutcome::Failed { .. })); + over.validate_against(&invocation).unwrap(); + } + #[test] fn the_accept_loop_drops_a_peer_that_is_not_loopback() { let run = |reported: SocketAddr| { From 868c248b4f1502029c89b564efe3dc0e1928329c Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Wed, 28 Oct 2026 05:31:41 +0100 Subject: [PATCH 10/69] Seal-measured frame bounds: results fit the frame as written, payload bound derives from the worst encoding complete() measures the serialized sealed envelope against the frame and records the typed result_size failure (fixed transport, itself guaranteed to fit) when it does not fit, so no Completed record is undeliverable. serve's validation derives the caller payload bound from the real worst-case encoding of the int-array ciphertext instead of plaintext length. Identities are capped at 256 bytes so the stand-in's size is bounded. The wire encoding is unchanged. Co-Authored-By: Claude Opus 5.5 --- crates/model-connector/src/envelope.rs | 74 +++++ crates/model-connector/src/serve.rs | 377 ++++++++++++++++--------- crates/model-connector/src/service.rs | 231 ++++++++++++++- 3 files changed, 536 insertions(+), 146 deletions(-) diff --git a/crates/model-connector/src/envelope.rs b/crates/model-connector/src/envelope.rs index abebbb5..2fbd820 100644 --- a/crates/model-connector/src/envelope.rs +++ b/crates/model-connector/src/envelope.rs @@ -27,6 +27,37 @@ pub struct TransportEnvelope { pub ciphertext: Vec, } +/// The longest caller id, request id or model name a service admits. The +/// frame arithmetic below depends on it, so a longer one is refused up front. +pub(crate) const MAX_IDENTITY_BYTES: usize = 256; + +/// The most bytes a serialized envelope holding `ciphertext_len` ciphertext +/// bytes can occupy. The v2 wire serializes the ciphertext as a MessagePack +/// array of integers: a byte of 128 or more takes two bytes, so the worst case +/// is twice the ciphertext, plus the array header and the envelope's other +/// fields at their longest. Measured from the real encoding, not assumed. +pub(crate) fn worst_frame_bytes(ciphertext_len: usize) -> usize { + let shell = rmp_serde::to_vec(&TransportEnvelope { + schema_id: ENVELOPE_SCHEMA_ID.to_string(), + caller_runtime_id: "x".repeat(MAX_IDENTITY_BYTES), + request_id: "x".repeat(MAX_IDENTITY_BYTES), + message_kind: EnvelopeKind::Invocation, + nonce: [0xff; 12], + ciphertext: Vec::new(), + }) + .map_or(usize::MAX, |bytes| bytes.len()); + // The shell holds an empty array header of one byte. + let header = match ciphertext_len { + 0..=15 => 1, + 16..=65_535 => 3, + _ => 5, + }; + shell + .saturating_sub(1) + .saturating_add(header) + .saturating_add(ciphertext_len.saturating_mul(2)) +} + pub fn encrypt_invocation( invocation: &TransportInvocation, security: &TransportKey, @@ -245,6 +276,8 @@ pub enum ServiceError { NoActiveRequest, #[error("completion does not match the active request")] ActiveRequestMismatch, + #[error("sealed result does not fit the frame bound")] + FrameBound, } #[cfg(test)] @@ -395,4 +428,45 @@ mod tests { assert!(matches!(write(&[], 16), Err(TransportFrameError::Size))); assert!(matches!(write(&[], 1_000), Err(TransportFrameError::Size))); } + + #[test] + fn the_worst_frame_is_the_real_encoding_of_an_all_high_ciphertext() { + let long = "x".repeat(MAX_IDENTITY_BYTES); + for length in [0, 1, 15, 16, 17, 255, 256, 65_535, 65_536, 70_000] { + let envelope = TransportEnvelope { + schema_id: ENVELOPE_SCHEMA_ID.to_string(), + caller_runtime_id: long.clone(), + request_id: long.clone(), + message_kind: EnvelopeKind::Invocation, + nonce: [0xff; 12], + ciphertext: vec![0xff; length], + }; + assert_eq!( + rmp_serde::to_vec(&envelope).unwrap().len(), + worst_frame_bytes(length), + "{length}" + ); + } + } + + #[test] + fn a_sealed_envelope_never_exceeds_the_worst_frame_for_its_ciphertext() { + let key = security("epiphany-distinct-test-key"); + for id in ["a".to_string(), "i".repeat(MAX_IDENTITY_BYTES)] { + for plaintext in [1, 100, 5_000, 70_000] { + let sealed = + encrypt_message(&id, &id, EnvelopeKind::Result, &vec![0x41; plaintext], &key) + .unwrap(); + let real = rmp_serde::to_vec(&sealed).unwrap().len(); + let worst = worst_frame_bytes(sealed.ciphertext.len()); + assert!(real <= worst, "{real} > {worst}"); + // Real ciphertext is random, so about half its bytes take two + // bytes: the worst case is not wildly above what is written. + assert!( + worst <= real + sealed.ciphertext.len() + 64, + "{worst} {real}" + ); + } + } + } } diff --git a/crates/model-connector/src/serve.rs b/crates/model-connector/src/serve.rs index fec618f..23b5bed 100644 --- a/crates/model-connector/src/serve.rs +++ b/crates/model-connector/src/serve.rs @@ -10,12 +10,9 @@ use serde::{Deserialize, Serialize}; use thiserror::Error; use zeroize::Zeroizing; -use crate::contract::{ - RECEIPT_SCHEMA_ID, TransportDisposition, TransportInvocation, TransportOutcome, - TransportReceipt, TransportResult, -}; +use crate::contract::{TransportInvocation, TransportResult}; use crate::envelope::{ - ServiceError, TransportEnvelope, TransportFrameError, read_transport_frame, + ServiceError, TransportEnvelope, TransportFrameError, read_transport_frame, worst_frame_bytes, write_transport_frame, }; use crate::service::{CallerAdmission, TransportAdmission, TransportService}; @@ -42,17 +39,19 @@ pub struct ServeLimits { pub max_expiry_skew_ms: u64, } -/// What a frame carries besides the caller's payload: the envelope fields and -/// the headroom a result needs. The base daemon's budget, unchanged. -const FRAME_OVERHEAD_BUDGET: usize = 4096; +/// The smallest caller payload bound the base daemon admitted. +const MIN_PAYLOAD_BYTES: usize = 4096; impl ServeLimits { /// The bounds serve refuses before it opens anything: each limit nonzero, - /// a frame that can carry a minimum payload plus its overhead, enough - /// connections for every caller's request slots, and every caller's payload - /// bound between the minimum and what a frame can carry. + /// a frame that carries the smallest payload (which also carries the failure + /// that stands in for an oversize result), enough connections for every caller's request + /// slots, and every caller's payload bound between the minimum and the + /// largest whose sealed frame, at its worst-case encoding, fits the frame. + /// An admitted payload therefore always fits, and `complete` is given the + /// same frame bound to fit every result in. fn validate(&self, admissions: &[CallerAdmission]) -> Result<(), ServeError> { - if self.max_frame_bytes < FRAME_OVERHEAD_BUDGET * 2 + if self.max_frame_bytes < worst_frame_bytes(MIN_PAYLOAD_BYTES) || self.max_frame_bytes > u32::MAX as usize || self.max_connections == 0 || self.socket_timeout_ms == 0 @@ -73,18 +72,13 @@ impl ServeLimits { return Err(ServeError::InvalidConfig("connection capacity")); } if admissions.iter().any(|caller| { - caller.max_payload_bytes() < FRAME_OVERHEAD_BUDGET - || caller.max_payload_bytes() > self.max_frame_bytes - FRAME_OVERHEAD_BUDGET + caller.max_payload_bytes() < MIN_PAYLOAD_BYTES + || worst_frame_bytes(caller.max_payload_bytes()) > self.max_frame_bytes }) { return Err(ServeError::InvalidConfig("caller admission")); } Ok(()) } - - /// The largest encoded result a frame can carry once its envelope is added. - fn max_result_bytes(&self) -> usize { - self.max_frame_bytes - FRAME_OVERHEAD_BUDGET - } } #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] @@ -221,54 +215,16 @@ fn execute_inference_frame( let response = match admission { TransportAdmission::Reply(response) => response, TransportAdmission::Execute(claim) => { - let result = bound_result( - claim.invocation(), - backend.execute(claim.invocation()), - limits.max_result_bytes(), - ); + let result = backend.execute(claim.invocation()); service .lock() .map_err(|_| ServeError::ServicePoisoned)? - .complete(*claim, result)? + .complete(*claim, result, limits.max_frame_bytes)? } }; rmp_serde::to_vec(&response).map_err(|_| ServeError::FrameEncoding) } -/// A result the frame cannot carry is never recorded: it becomes a failed -/// receipt in the same shape, so the caller is told and a retry replays bytes -/// that can be delivered. -fn bound_result( - invocation: &TransportInvocation, - result: TransportResult, - max_result_bytes: usize, -) -> TransportResult { - if rmp_serde::to_vec(&result).is_ok_and(|bytes| bytes.len() <= max_result_bytes) { - return result; - } - let transport = match &result.disposition { - TransportDisposition::Transported { receipt, .. } => receipt.transport.clone(), - TransportDisposition::Refused(_) => "model-connector".to_string(), - }; - TransportResult::transported( - invocation, - Vec::new(), - TransportReceipt { - schema_id: RECEIPT_SCHEMA_ID.to_string(), - request_id: invocation.request_id().to_string(), - caller_runtime_id: invocation.caller_runtime_id.clone(), - native_request_sha256: invocation.native_request_sha256, - provider_request_sha256: invocation.provider_request_sha256, - model: invocation.request.model.clone(), - transport, - outcome: TransportOutcome::Failed { - failure_kind: "result_size".to_string(), - message: "provider result exceeded its byte bound".to_string(), - }, - }, - ) -} - fn frame_error(error: TransportFrameError) -> ServeError { match error { TransportFrameError::Connection(error) => ServeError::Connection(error), @@ -355,7 +311,9 @@ mod tests { const CALLER: &str = "epiphany-yggdrasil"; const KEY: &str = "epiphany-distinct-test-key"; - const FRAME: usize = 128 * 1024; + const FRAME: usize = 256 * 1024; + /// AES-256-GCM appends a 16-byte tag to the ciphertext. + const GCM_TAG: usize = 16; struct CountingBackend { ready: bool, @@ -586,12 +544,30 @@ mod tests { assert_eq!(backend.ready_calls.load(Ordering::SeqCst), 0); } + /// The smallest frame serve admits: the smallest payload at its worst-case + /// sealed encoding. + fn smallest_frame() -> usize { + worst_frame_bytes(MIN_PAYLOAD_BYTES) + } + + #[test] + fn the_smallest_frame_also_carries_the_failure_that_stands_in_for_a_result() { + let long = "x".repeat(crate::envelope::MAX_IDENTITY_BYTES); + let plaintext = rmp_serde::to_vec(&crate::service::result_size_failure( + &long, &long, &long, [0xff; 32], [0xff; 32], + )) + .unwrap() + .len(); + let worst = worst_frame_bytes(plaintext + GCM_TAG); + assert!(worst <= smallest_frame(), "{worst} > {}", smallest_frame()); + } + #[test] fn serve_refuses_unbounded_limits_and_admits_the_widest_frame() { let store = TestStore::new(); let mutations: [fn(&mut ServeLimits); 6] = [ |limits| limits.max_frame_bytes = 0, - |limits| limits.max_frame_bytes = 8191, + |limits| limits.max_frame_bytes = smallest_frame() - 1, |limits| limits.max_frame_bytes = u32::MAX as usize + 1, |limits| limits.max_connections = 0, |limits| limits.socket_timeout_ms = 0, @@ -720,22 +696,36 @@ mod tests { ..limits() }; let payload = |bytes| vec![admission("a", "key-a-distinct", 4, bytes)]; - assert_eq!(refusal(frame(8191), payload(4096)), Some("serve limits")); - // The tightest legal pair: a 4096-byte payload in an 8192-byte frame. - assert_eq!(refusal(frame(8192), payload(4096)), None); + let tightest = smallest_frame(); assert_eq!( - refusal(frame(8192), payload(4097)), - Some("caller admission") + refusal(frame(tightest - 1), payload(4096)), + Some("serve limits") ); - assert_eq!(refusal(frame(12288), payload(8192)), None); + // The tightest legal pair: the smallest payload in the smallest frame. + assert_eq!(refusal(frame(tightest), payload(4096)), None); assert_eq!( - refusal(frame(12288), payload(8193)), + refusal(frame(tightest), payload(4097)), Some("caller admission") ); assert_eq!( - refusal(frame(FRAME), payload(4095)), + refusal(frame(tightest), payload(4095)), Some("caller admission") ); + // The bound is the sealed frame at its worst encoding, not the payload: + // a payload that is the whole frame cannot be carried. + assert_eq!( + refusal(frame(FRAME), payload(FRAME)), + Some("caller admission") + ); + for bytes in [8192, 65_535, 65_536, 100_000] { + let widest = worst_frame_bytes(bytes); + assert_eq!(refusal(frame(widest), payload(bytes)), None, "{bytes}"); + assert_eq!( + refusal(frame(widest - 1), payload(bytes)), + Some("caller admission"), + "{bytes}" + ); + } let mixed = vec![ admission("a", "key-a-distinct", 4, 8192), admission("b", "key-b-distinct", 4, FRAME), @@ -743,6 +733,35 @@ mod tests { assert_eq!(refusal(frame(FRAME), mixed), Some("caller admission")); } + #[test] + fn a_payload_at_the_widest_bound_its_frame_admits_is_carried_and_served() { + let store = TestStore::new(); + let backend = CountingBackend::new(true); + let invocation = fresh(&"q".repeat(256)); + let key = crate::fixtures::security(KEY); + let sealed = crate::envelope::encrypt_invocation(&invocation, &key).unwrap(); + let bound = sealed.ciphertext.len(); + let frame = worst_frame_bytes(bound); + let listener = TcpListener::bind("127.0.0.1:0").unwrap(); + let endpoint = listener.local_addr().unwrap(); + let path = store.path.clone(); + let served = backend.clone(); + let tight = ServeLimits { + max_frame_bytes: frame, + ..limits() + }; + let admissions = vec![admission(CALLER, KEY, 4, bound)]; + thread::spawn(move || serve(listener, tight, admissions, &path, served)); + let client = + ConnectorClient::new(endpoint, KEY, frame, Some(Duration::from_secs(5))).unwrap(); + let reply = client.execute(&invocation).unwrap(); + assert!(matches!( + reply.disposition, + TransportDisposition::Transported { .. } + )); + assert_eq!(backend.executions.load(Ordering::SeqCst), 1); + } + #[test] fn a_payload_over_the_caller_bound_is_refused_on_the_wire() { let store = TestStore::new(); @@ -764,91 +783,177 @@ mod tests { assert_eq!(backend.executions.load(Ordering::SeqCst), 0); } - struct OversizeBackend { + /// Answers every invocation with one text event of a chosen size. + struct SizedBackend { + text_len: usize, + transport: String, executions: AtomicUsize, } - impl ProviderBackend for OversizeBackend { + impl SizedBackend { + fn new(text_len: usize) -> Arc { + Self::with_transport(text_len, "fixture-sse".to_string()) + } + + fn with_transport(text_len: usize, transport: String) -> Arc { + Arc::new(Self { + text_len, + transport, + executions: AtomicUsize::new(0), + }) + } + } + + impl ProviderBackend for SizedBackend { fn ready(&self) -> bool { true } fn execute(&self, invocation: &TransportInvocation) -> TransportResult { self.executions.fetch_add(1, Ordering::SeqCst); - TransportResult::transported( - invocation, - vec![TransportEvent { - sequence: 0, - payload: TransportEventPayload::TextDelta { - text: "y".repeat(FRAME), - }, - }], - receipt(invocation), - ) + sized_result(invocation, self.text_len, &self.transport) + } + } + + fn sized_result( + invocation: &TransportInvocation, + text_len: usize, + transport: &str, + ) -> TransportResult { + let mut receipt = receipt(invocation); + receipt.transport = transport.to_string(); + TransportResult::transported( + invocation, + vec![TransportEvent { + sequence: 0, + payload: TransportEventPayload::TextDelta { + text: "y".repeat(text_len), + }, + }], + receipt, + ) + } + + fn encoded_len(result: &TransportResult) -> usize { + rmp_serde::to_vec(result).unwrap().len() + } + + /// Two calls with one request id, through a live serve loop. + fn call_twice( + backend: Arc, + request_id: &str, + ) -> (TransportResult, TransportResult, usize) { + let store = TestStore::new(); + let (client, _) = start_with(backend.clone(), &store, vec![caller(CALLER, KEY, 4)]); + let invocation = fresh(request_id); + let first = client.execute(&invocation).expect("first call delivered"); + let second = client.execute(&invocation).expect("retry delivered"); + (first, second, backend.executions.load(Ordering::SeqCst)) + } + + fn failed_result_size(result: &TransportResult) -> bool { + matches!( + &result.disposition, + TransportDisposition::Transported { events, receipt } + if events.is_empty() + && receipt.transport == "model-connector" + && receipt.outcome == TransportOutcome::Failed { + failure_kind: "result_size".to_string(), + message: "provider result exceeded its byte bound".to_string(), + } + ) + } + + fn id_cases() -> [String; 2] { + ["request-short".to_string(), "r".repeat(256)] + } + + /// The encoded size of a result carrying no text, for this request id. + fn empty_result_len(request_id: &str) -> usize { + encoded_len(&sized_result(&fresh(request_id), 0, "fixture-sse")) + } + + #[test] + fn a_result_whose_plaintext_fits_but_whose_sealed_frame_does_not_fails_typed_and_replays() { + // A plaintext within the frame less its overhead seals to about one and + // a half times its size, so it cannot be carried; measuring the + // plaintext would record it as completed and every retry would die. + for request_id in id_cases() { + let base = empty_result_len(&request_id); + let room = FRAME - 4096; + for plaintext in [room, room - 1, FRAME * 3 / 4] { + let backend = SizedBackend::new(plaintext - base); + let (first, second, executions) = call_twice(backend, &request_id); + assert!( + failed_result_size(&first), + "{} {plaintext}", + request_id.len() + ); + assert_eq!(first, second, "the retry replays the deliverable failure"); + assert_eq!(executions, 1); + } } } #[test] fn a_result_larger_than_the_frame_fails_typed_and_is_never_replayed_as_completed() { - let store = TestStore::new(); - let backend = Arc::new(OversizeBackend { - executions: AtomicUsize::new(0), - }); - let (client, _) = start_with(backend.clone(), &store, vec![caller(CALLER, KEY, 4)]); - let invocation = fresh("request-oversize"); - let first = client.execute(&invocation).unwrap(); - let second = client.execute(&invocation).unwrap(); - let TransportDisposition::Transported { events, receipt } = &first.disposition else { - panic!("expected a transported failure"); - }; - assert!(events.is_empty()); - assert_eq!( - receipt.outcome, - TransportOutcome::Failed { - failure_kind: "result_size".to_string(), - message: "provider result exceeded its byte bound".to_string(), - } - ); - assert_eq!(receipt.transport, "fixture-sse"); - assert_eq!(first, second, "the retry replays the deliverable failure"); - assert_eq!(backend.executions.load(Ordering::SeqCst), 1); + let (first, second, executions) = call_twice(SizedBackend::new(FRAME), "request-oversize"); + assert!(failed_result_size(&first)); + assert_eq!(first, second); + assert_eq!(executions, 1); } #[test] - fn a_result_is_bounded_by_the_frame_less_its_overhead_inclusively() { - let invocation = fresh("request-bound"); - let result_with = |text_len: usize| { - TransportResult::transported( - &invocation, - vec![TransportEvent { - sequence: 0, - payload: TransportEventPayload::TextDelta { - text: "z".repeat(text_len), - }, - }], - receipt(&invocation), - ) - }; - let bounded = ServeLimits { - max_frame_bytes: 20_000, - ..limits() - }; - let max = bounded.max_result_bytes(); - assert_eq!(max, 20_000 - 4096); - // MessagePack headers are the same width across this range, so one probe fixes the size. - let probe = rmp_serde::to_vec(&result_with(10_000)).unwrap().len(); - let exact = 10_000 + max - probe; - assert_eq!(rmp_serde::to_vec(&result_with(exact)).unwrap().len(), max); - assert_eq!( - bound_result(&invocation, result_with(exact), max), - result_with(exact) + fn a_stand_in_failure_never_carries_the_backends_transport() { + let (first, second, executions) = call_twice( + SizedBackend::with_transport(FRAME, "t".repeat(FRAME)), + "request-transport", ); - let over = bound_result(&invocation, result_with(exact + 1), max); - let TransportDisposition::Transported { receipt, .. } = &over.disposition else { - panic!("expected a transported failure"); - }; - assert!(matches!(receipt.outcome, TransportOutcome::Failed { .. })); - over.validate_against(&invocation).unwrap(); + assert!(failed_result_size(&first)); + assert_eq!(first, second); + assert_eq!(executions, 1); + } + + #[test] + fn a_result_within_what_the_worst_encoding_leaves_is_delivered_intact() { + // The largest plaintext whose sealed frame fits at the worst encoding is + // always carried, whatever the ciphertext bytes turn out to be. + for request_id in id_cases() { + let base = empty_result_len(&request_id); + let mut plaintext = base; + while worst_frame_bytes(plaintext + 1 + GCM_TAG) <= FRAME { + plaintext += 1; + } + let (first, second, _) = call_twice(SizedBackend::new(plaintext - base), &request_id); + let TransportDisposition::Transported { events, receipt } = &first.disposition else { + panic!("expected a transported result"); + }; + assert_eq!(events.len(), 1, "{}", request_id.len()); + assert!(matches!( + receipt.outcome, + TransportOutcome::Completed { .. } + )); + assert_eq!(first, second); + } + } + + #[test] + fn a_result_just_over_the_worst_encoding_is_delivered_whatever_the_nonce_makes_of_it() { + // Past the guaranteed size the sealed frame may or may not fit; either + // way the caller gets an answer, and the retry gets the same one. + for request_id in id_cases() { + let base = empty_result_len(&request_id); + let mut plaintext = base; + while worst_frame_bytes(plaintext + GCM_TAG) <= FRAME { + plaintext += 1; + } + for extra in [0, 1, 2] { + let backend = SizedBackend::new(plaintext + extra - base); + let (first, second, executions) = call_twice(backend, &request_id); + assert_eq!(first, second); + assert_eq!(executions, 1); + } + } } #[test] diff --git a/crates/model-connector/src/service.rs b/crates/model-connector/src/service.rs index 36d3d31..6836df1 100644 --- a/crates/model-connector/src/service.rs +++ b/crates/model-connector/src/service.rs @@ -7,12 +7,13 @@ use sha2::{Digest, Sha256}; use zeroize::Zeroizing; use crate::contract::{ - ContractError, Refusal, TransportDisposition, TransportInvocation, TransportKey, - TransportResult, canonical_invocation_bytes, require_id, + ContractError, RECEIPT_SCHEMA_ID, RESULT_SCHEMA_ID, Refusal, TransportDisposition, + TransportInvocation, TransportKey, TransportOutcome, TransportReceipt, TransportResult, + canonical_invocation_bytes, require_id, }; use crate::envelope::{ - EnvelopeKind, ServiceError, TransportEnvelope, decrypt_invocation, encrypt_result, - validate_envelope, + EnvelopeKind, MAX_IDENTITY_BYTES, ServiceError, TransportEnvelope, decrypt_invocation, + encrypt_result, validate_envelope, }; pub struct CallerAdmission { @@ -40,9 +41,10 @@ impl CallerAdmission { let allowed_models = allowed_models.into_iter().collect::>(); if connection_key_epoch == 0 || allowed_models.is_empty() - || allowed_models - .iter() - .any(|model| require_id(model, "allowed_model").is_err()) + || caller_runtime_id.len() > MAX_IDENTITY_BYTES + || allowed_models.iter().any(|model| { + require_id(model, "allowed_model").is_err() || model.len() > MAX_IDENTITY_BYTES + }) || max_concurrent_requests == 0 || max_payload_bytes == 0 || max_output_tokens == 0 @@ -199,6 +201,9 @@ impl TransportService { now_unix_ms: u64, ) -> Result { validate_envelope(envelope, EnvelopeKind::Invocation)?; + if envelope.request_id.len() > MAX_IDENTITY_BYTES { + return Err(ContractError::Invalid("request_id").into()); + } let caller = self .callers .get(&envelope.caller_runtime_id) @@ -274,10 +279,16 @@ impl TransportService { }))) } + /// Records a result and seals it. `max_frame_bytes` bounds the serialized + /// envelope, which is what a frame carries: a result whose sealed envelope + /// does not fit is recorded as the typed `result_size` failure instead, so + /// no completed record is ever undeliverable and a retry replays bytes + /// that fit. pub fn complete( &mut self, claim: ExecutionClaim, result: TransportResult, + max_frame_bytes: usize, ) -> Result { if !matches!( &result.disposition, @@ -307,7 +318,22 @@ impl TransportService { .callers .get(&identity.caller_runtime_id) .ok_or(ServiceError::CallerNotAdmitted)?; - let response = encrypt_result(&result, &caller.security)?; + let mut response = encrypt_result(&result, &caller.security)?; + if !fits_frame(&response, max_frame_bytes)? { + response = encrypt_result( + &result_size_failure( + &claim.invocation.caller_runtime_id, + claim.invocation.request_id(), + &claim.invocation.request.model, + claim.invocation.native_request_sha256, + claim.invocation.provider_request_sha256, + ), + &caller.security, + )?; + if !fits_frame(&response, max_frame_bytes)? { + return Err(ServiceError::FrameBound); + } + } let replay_record = ReplayRecord { caller_runtime_id: identity.caller_runtime_id.clone(), request_id: identity.request_id.clone(), @@ -338,6 +364,48 @@ impl TransportService { } } +fn fits_frame(envelope: &TransportEnvelope, max_frame_bytes: usize) -> Result { + rmp_serde::to_vec(envelope) + .map(|bytes| bytes.len() <= max_frame_bytes) + .map_err(|_| ServiceError::Encoding) +} + +/// The transport named on the stand-in failure, fixed so its size is bounded. +const STAND_IN_TRANSPORT: &str = "model-connector"; + +/// The failed result that stands in for one whose sealed frame cannot be +/// carried: the same shape a provider failure takes, with nothing a provider +/// controls but the identities the invocation already bound. +pub(crate) fn result_size_failure( + caller_runtime_id: &str, + request_id: &str, + model: &str, + native_request_sha256: [u8; 32], + provider_request_sha256: [u8; 32], +) -> TransportResult { + TransportResult { + schema_id: RESULT_SCHEMA_ID.to_string(), + request_id: request_id.to_string(), + caller_runtime_id: caller_runtime_id.to_string(), + disposition: TransportDisposition::Transported { + events: Vec::new(), + receipt: Box::new(TransportReceipt { + schema_id: RECEIPT_SCHEMA_ID.to_string(), + request_id: request_id.to_string(), + caller_runtime_id: caller_runtime_id.to_string(), + native_request_sha256, + provider_request_sha256, + model: model.to_string(), + transport: STAND_IN_TRANSPORT.to_string(), + outcome: TransportOutcome::Failed { + failure_kind: "result_size".to_string(), + message: "provider result exceeded its byte bound".to_string(), + }, + }), + }, + } +} + fn replay_key(identity: &RequestIdentity) -> String { let mut hasher = Sha256::new(); hasher.update((identity.caller_runtime_id.len() as u64).to_be_bytes()); @@ -441,6 +509,7 @@ mod tests { }], receipt(&invocation), ), + 1 << 20, ) .unwrap(); drop(service); @@ -765,7 +834,9 @@ mod tests { let mut forged = (*claim).clone(); forged.invocation_sha256 = [1; 32]; assert_eq!( - service.complete(forged, result.clone()).unwrap_err(), + service + .complete(forged, result.clone(), 1 << 20) + .unwrap_err(), ServiceError::ActiveRequestMismatch ); @@ -783,8 +854,148 @@ mod tests { }; service.replay_store.put(key, &record).unwrap(); assert_eq!( - service.complete(*claim, result).unwrap_err(), + service.complete(*claim, result, 1 << 20).unwrap_err(), ServiceError::ActiveRequestMismatch ); } + + #[test] + fn identities_are_bounded_at_admission_and_on_the_request() { + let admit = |caller_id: String, model: String| { + CallerAdmission::new(caller_id, KEY, 1, [model], 4, 64 * 1024, 32_768) + }; + let at = "i".repeat(MAX_IDENTITY_BYTES); + let over = "i".repeat(MAX_IDENTITY_BYTES + 1); + assert!(admit(at.clone(), at.clone()).is_ok()); + assert!(matches!( + admit(over.clone(), at.clone()), + Err(ServiceError::InvalidAdmission) + )); + assert!(matches!( + admit(at.clone(), over.clone()), + Err(ServiceError::InvalidAdmission) + )); + + let store = TestStore::new(); + let mut service = open_service(&store, 8); + let ok = invocation_for(CALLER, &at); + assert!(matches!( + service.begin(&raw_envelope(&ok), 1_000).unwrap(), + TransportAdmission::Execute(_) + )); + let long = invocation_for(CALLER, &over); + assert_eq!( + service.begin(&raw_envelope(&long), 1_000).err(), + Some(ServiceError::Contract(ContractError::Invalid("request_id"))) + ); + } + + #[test] + fn a_frame_check_is_inclusive_of_the_serialized_envelope() { + let envelope = + encrypt_invocation(&invocation_for(CALLER, "sized"), &security(KEY)).unwrap(); + let size = rmp_serde::to_vec(&envelope).unwrap().len(); + assert_eq!(fits_frame(&envelope, size), Ok(true)); + assert_eq!(fits_frame(&envelope, size - 1), Ok(false)); + } + + fn claim_for( + service: &mut TransportService, + request_id: &str, + ) -> (Box, TransportInvocation) { + let invocation = invocation_for(CALLER, request_id); + match service.begin(&raw_envelope(&invocation), 1_000).unwrap() { + TransportAdmission::Execute(claim) => (claim, invocation), + TransportAdmission::Reply(_) => panic!("expected an execution claim"), + } + } + + fn text_result( + invocation: &TransportInvocation, + text: &str, + transport: &str, + ) -> TransportResult { + let mut receipt = receipt(invocation); + receipt.transport = transport.to_string(); + TransportResult::transported( + invocation, + vec![TransportEvent { + sequence: 0, + payload: TransportEventPayload::TextDelta { + text: text.to_string(), + }, + }], + receipt, + ) + } + + #[test] + fn a_result_whose_sealed_envelope_exceeds_the_frame_is_recorded_as_the_stand_in_failure() { + let store = TestStore::new(); + let mut service = open_service(&store, 8); + let (claim, invocation) = claim_for(&mut service, "stand-in"); + let big = text_result(&invocation, &"y".repeat(4000), &"t".repeat(5000)); + let response = service.complete(*claim, big, 2000).unwrap(); + assert!(rmp_serde::to_vec(&response).unwrap().len() <= 2000); + let stand_in = decrypt_result(&response, &security(KEY), &invocation).unwrap(); + assert_eq!( + stand_in, + result_size_failure( + CALLER, + "stand-in", + &invocation.request.model, + invocation.native_request_sha256, + invocation.provider_request_sha256, + ) + ); + let TransportDisposition::Transported { receipt, .. } = &stand_in.disposition else { + panic!("expected a transported failure"); + }; + assert_eq!(receipt.transport, "model-connector"); + + // The recorded replay is the stand-in's exact bytes. + let TransportAdmission::Reply(replay) = + service.begin(&raw_envelope(&invocation), 1_000).unwrap() + else { + panic!("expected a replay"); + }; + assert_eq!(replay, response); + } + + #[test] + fn a_result_whose_sealed_envelope_fits_is_recorded_as_the_backend_made_it() { + let store = TestStore::new(); + let mut service = open_service(&store, 8); + let (claim, invocation) = claim_for(&mut service, "fits"); + let small = text_result(&invocation, "done", "fixture-sse"); + let response = service.complete(*claim, small.clone(), 1 << 20).unwrap(); + assert_eq!( + decrypt_result(&response, &security(KEY), &invocation).unwrap(), + small + ); + } + + #[test] + fn nothing_is_recorded_when_not_even_the_stand_in_fits() { + let store = TestStore::new(); + let mut service = open_service(&store, 8); + let (claim, invocation) = claim_for(&mut service, "nothing-fits"); + let big = text_result(&invocation, "y", "fixture-sse"); + assert_eq!( + service.complete(*claim, big, 10).unwrap_err(), + ServiceError::FrameBound + ); + // Still active, never completed: the retry is told it is in flight. + let TransportAdmission::Reply(reply) = + service.begin(&raw_envelope(&invocation), 1_000).unwrap() + else { + panic!("expected a refusal"); + }; + assert_eq!( + decrypt_result(&reply, &security(KEY), &invocation) + .unwrap() + .disposition, + TransportDisposition::Refused(Refusal::InFlight) + ); + } } From 749a58985b8f4f1ed2853331df6df0c59a34a3da Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Wed, 28 Oct 2026 05:32:26 +0100 Subject: [PATCH 11/69] Frame bound tests: pad the widest-payload invocation past the minimum bound, tightness only at the longest ids Co-Authored-By: Claude Opus 5.5 --- crates/model-connector/src/envelope.rs | 10 ++++++---- crates/model-connector/src/serve.rs | 5 ++++- 2 files changed, 10 insertions(+), 5 deletions(-) diff --git a/crates/model-connector/src/envelope.rs b/crates/model-connector/src/envelope.rs index 2fbd820..77a077c 100644 --- a/crates/model-connector/src/envelope.rs +++ b/crates/model-connector/src/envelope.rs @@ -462,10 +462,12 @@ mod tests { assert!(real <= worst, "{real} > {worst}"); // Real ciphertext is random, so about half its bytes take two // bytes: the worst case is not wildly above what is written. - assert!( - worst <= real + sealed.ciphertext.len() + 64, - "{worst} {real}" - ); + if id.len() == MAX_IDENTITY_BYTES { + assert!( + worst <= real + sealed.ciphertext.len() + 64, + "{worst} {real}" + ); + } } } } diff --git a/crates/model-connector/src/serve.rs b/crates/model-connector/src/serve.rs index 23b5bed..9b34596 100644 --- a/crates/model-connector/src/serve.rs +++ b/crates/model-connector/src/serve.rs @@ -737,7 +737,10 @@ mod tests { fn a_payload_at_the_widest_bound_its_frame_admits_is_carried_and_served() { let store = TestStore::new(); let backend = CountingBackend::new(true); - let invocation = fresh(&"q".repeat(256)); + let mut invocation = fresh(&"q".repeat(256)); + invocation.request.instructions = "x".repeat(5_000); + invocation.provider_request_sha256 = + crate::contract::provider_request_sha256(&invocation.request).unwrap(); let key = crate::fixtures::security(KEY); let sealed = crate::envelope::encrypt_invocation(&invocation, &key).unwrap(); let bound = sealed.ciphertext.len(); From d281343dc92d330207478198444fc42493b83d0a Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Wed, 28 Oct 2026 05:33:02 +0100 Subject: [PATCH 12/69] Golden fixture README: Ghostlight pins CodexConnector 68fe94b; src/lib.rs is identical in both Co-Authored-By: Claude Opus 5.5 --- crates/model-connector/tests/fixtures/wire_v2/README.md | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/crates/model-connector/tests/fixtures/wire_v2/README.md b/crates/model-connector/tests/fixtures/wire_v2/README.md index b9fa9e5..476b362 100644 --- a/crates/model-connector/tests/fixtures/wire_v2/README.md +++ b/crates/model-connector/tests/fixtures/wire_v2/README.md @@ -1,8 +1,9 @@ # Golden v2 wire fixtures Bytes of the v2 connector wire, produced once by CodexConnector at commit -`fd7cec4176f4e3f9a5aaa75e77ba10ce5e1522f8` (the library Ghostlight pins), -and pinned by `tests/wire_golden_v2.rs` so that the kit cannot drift from +`fd7cec4176f4e3f9a5aaa75e77ba10ce5e1522f8`. Ghostlight pins CodexConnector +`68fe94b`, not that commit; `src/lib.rs`, the whole v2 wire, is identical in +both, so these bytes are Ghostlight's wire. They are pinned by `tests/wire_golden_v2.rs`, so the kit cannot drift from them. No CodexConnector code or dependency is part of this repository. Generator: `generator.rs` (not compiled here). Command, run in a clone of From bf07b2cd3823fd6dcb89786ecf4f14c6696e051e Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Wed, 28 Oct 2026 05:40:05 +0100 Subject: [PATCH 13/69] Frame arithmetic is daemon-gated with its only consumers; boundary tests are finite Co-Authored-By: Claude Opus 5.5 --- crates/model-connector/src/envelope.rs | 4 ++++ crates/model-connector/src/serve.rs | 20 +++++++++++--------- 2 files changed, 15 insertions(+), 9 deletions(-) diff --git a/crates/model-connector/src/envelope.rs b/crates/model-connector/src/envelope.rs index 77a077c..48e1548 100644 --- a/crates/model-connector/src/envelope.rs +++ b/crates/model-connector/src/envelope.rs @@ -29,6 +29,7 @@ pub struct TransportEnvelope { /// The longest caller id, request id or model name a service admits. The /// frame arithmetic below depends on it, so a longer one is refused up front. +#[cfg(feature = "daemon")] pub(crate) const MAX_IDENTITY_BYTES: usize = 256; /// The most bytes a serialized envelope holding `ciphertext_len` ciphertext @@ -36,6 +37,7 @@ pub(crate) const MAX_IDENTITY_BYTES: usize = 256; /// array of integers: a byte of 128 or more takes two bytes, so the worst case /// is twice the ciphertext, plus the array header and the envelope's other /// fields at their longest. Measured from the real encoding, not assumed. +#[cfg(feature = "daemon")] pub(crate) fn worst_frame_bytes(ciphertext_len: usize) -> usize { let shell = rmp_serde::to_vec(&TransportEnvelope { schema_id: ENVELOPE_SCHEMA_ID.to_string(), @@ -429,6 +431,7 @@ mod tests { assert!(matches!(write(&[], 1_000), Err(TransportFrameError::Size))); } + #[cfg(feature = "daemon")] #[test] fn the_worst_frame_is_the_real_encoding_of_an_all_high_ciphertext() { let long = "x".repeat(MAX_IDENTITY_BYTES); @@ -449,6 +452,7 @@ mod tests { } } + #[cfg(feature = "daemon")] #[test] fn a_sealed_envelope_never_exceeds_the_worst_frame_for_its_ciphertext() { let key = security("epiphany-distinct-test-key"); diff --git a/crates/model-connector/src/serve.rs b/crates/model-connector/src/serve.rs index 9b34596..1c763e9 100644 --- a/crates/model-connector/src/serve.rs +++ b/crates/model-connector/src/serve.rs @@ -917,16 +917,21 @@ mod tests { assert_eq!(executions, 1); } + /// The largest result plaintext whose sealed frame fits at the worst encoding. + fn widest_guaranteed_plaintext(base: usize) -> usize { + (base..=FRAME) + .take_while(|plaintext| worst_frame_bytes(plaintext + GCM_TAG) <= FRAME) + .last() + .unwrap_or(base) + } + #[test] fn a_result_within_what_the_worst_encoding_leaves_is_delivered_intact() { // The largest plaintext whose sealed frame fits at the worst encoding is // always carried, whatever the ciphertext bytes turn out to be. for request_id in id_cases() { let base = empty_result_len(&request_id); - let mut plaintext = base; - while worst_frame_bytes(plaintext + 1 + GCM_TAG) <= FRAME { - plaintext += 1; - } + let plaintext = widest_guaranteed_plaintext(base); let (first, second, _) = call_twice(SizedBackend::new(plaintext - base), &request_id); let TransportDisposition::Transported { events, receipt } = &first.disposition else { panic!("expected a transported result"); @@ -946,11 +951,8 @@ mod tests { // way the caller gets an answer, and the retry gets the same one. for request_id in id_cases() { let base = empty_result_len(&request_id); - let mut plaintext = base; - while worst_frame_bytes(plaintext + GCM_TAG) <= FRAME { - plaintext += 1; - } - for extra in [0, 1, 2] { + let plaintext = widest_guaranteed_plaintext(base); + for extra in [1, 2, 3] { let backend = SizedBackend::new(plaintext + extra - base); let (first, second, executions) = call_twice(backend, &request_id); assert_eq!(first, second); From 2d383d781885a20d7b5860118da03ddb8447a3ed Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Wed, 28 Oct 2026 06:05:09 +0100 Subject: [PATCH 14/69] Fix pass 4: long request ids are refused on the wire, frame rule is public, a FrameBound completion frees its slot, replays are re-measured against the frame - begin(): an id past MAX_IDENTITY_BYTES gets Refusal::Malformed naming the request by its SHA-256, so the reply fits the smallest frame; no dropped connection - frame_bytes_for_payload, payload_bytes_for_frame and MAX_IDENTITY_BYTES are public; ServeError::FrameBound states the frame needed - complete(): FrameBound releases the caller's slot - begin() takes the frame: a recorded result that no longer fits is replaced by the stand-in failure Co-Authored-By: Claude Opus 5.5 --- README.md | 7 + crates/model-connector/src/envelope.rs | 54 +++-- crates/model-connector/src/serve.rs | 107 +++++++-- crates/model-connector/src/service.rs | 288 ++++++++++++++++++++----- 4 files changed, 372 insertions(+), 84 deletions(-) diff --git a/README.md b/README.md index 1155500..d01fb9e 100644 --- a/README.md +++ b/README.md @@ -24,6 +24,13 @@ Crate `crates/model-connector`: `ProviderBackend` trait. A connector binary implements `ProviderBackend` for its provider and calls `serve`; the kit names no provider. +Frame arithmetic is public in `src/envelope.rs`: `frame_bytes_for_payload(n)` is the frame a +caller payload bound of `n` needs, `payload_bytes_for_frame(frame)` is the largest payload bound +a frame allows, and `MAX_IDENTITY_BYTES` (256) bounds caller ids, request ids and model names. +`serve` refuses a frame/payload pair that violates them (`ServeError::FrameBound` states the frame +needed); the smallest legal frame is `frame_bytes_for_payload(MIN_PAYLOAD_BYTES)`. A request id past +the bound is refused on the wire as `Refusal::Malformed`, the reply naming it by its SHA-256. + The default feature set compiles contract, envelope and client only. Schema ids are still `gamecult.codex.*.v2`; renaming them changes the wire and belongs to a later contract version. diff --git a/crates/model-connector/src/envelope.rs b/crates/model-connector/src/envelope.rs index 48e1548..a0df968 100644 --- a/crates/model-connector/src/envelope.rs +++ b/crates/model-connector/src/envelope.rs @@ -28,17 +28,18 @@ pub struct TransportEnvelope { } /// The longest caller id, request id or model name a service admits. The -/// frame arithmetic below depends on it, so a longer one is refused up front. -#[cfg(feature = "daemon")] -pub(crate) const MAX_IDENTITY_BYTES: usize = 256; +/// frame arithmetic below depends on it. A request whose id is longer is +/// refused on the wire as malformed. +pub const MAX_IDENTITY_BYTES: usize = 256; /// The most bytes a serialized envelope holding `ciphertext_len` ciphertext -/// bytes can occupy. The v2 wire serializes the ciphertext as a MessagePack -/// array of integers: a byte of 128 or more takes two bytes, so the worst case -/// is twice the ciphertext, plus the array header and the envelope's other +/// bytes can occupy, with ids at their longest: the frame a caller payload +/// bound (`CallerAdmission`'s `max_payload_bytes`, a bound on ciphertext +/// length) needs. The v2 wire serializes the ciphertext as a MessagePack array +/// of integers: a byte of 128 or more takes two bytes, so the worst case is +/// twice the ciphertext, plus the array header and the envelope's other /// fields at their longest. Measured from the real encoding, not assumed. -#[cfg(feature = "daemon")] -pub(crate) fn worst_frame_bytes(ciphertext_len: usize) -> usize { +pub fn frame_bytes_for_payload(ciphertext_len: usize) -> usize { let shell = rmp_serde::to_vec(&TransportEnvelope { schema_id: ENVELOPE_SCHEMA_ID.to_string(), caller_runtime_id: "x".repeat(MAX_IDENTITY_BYTES), @@ -60,6 +61,26 @@ pub(crate) fn worst_frame_bytes(ciphertext_len: usize) -> usize { .saturating_add(ciphertext_len.saturating_mul(2)) } +/// The largest payload bound whose worst-case frame fits `max_frame_bytes`: +/// the inverse of [`frame_bytes_for_payload`]. `None` when the frame cannot +/// carry even an empty envelope. +pub fn payload_bytes_for_frame(max_frame_bytes: usize) -> Option { + if frame_bytes_for_payload(0) > max_frame_bytes { + return None; + } + // frame_bytes_for_payload is increasing; the answer is at most half the frame. + let (mut low, mut high) = (0, max_frame_bytes / 2); + while low < high { + let mid = low + (high - low).div_ceil(2); + if frame_bytes_for_payload(mid) <= max_frame_bytes { + low = mid; + } else { + high = mid - 1; + } + } + Some(low) +} + pub fn encrypt_invocation( invocation: &TransportInvocation, security: &TransportKey, @@ -431,7 +452,6 @@ mod tests { assert!(matches!(write(&[], 1_000), Err(TransportFrameError::Size))); } - #[cfg(feature = "daemon")] #[test] fn the_worst_frame_is_the_real_encoding_of_an_all_high_ciphertext() { let long = "x".repeat(MAX_IDENTITY_BYTES); @@ -446,13 +466,12 @@ mod tests { }; assert_eq!( rmp_serde::to_vec(&envelope).unwrap().len(), - worst_frame_bytes(length), + frame_bytes_for_payload(length), "{length}" ); } } - #[cfg(feature = "daemon")] #[test] fn a_sealed_envelope_never_exceeds_the_worst_frame_for_its_ciphertext() { let key = security("epiphany-distinct-test-key"); @@ -462,7 +481,7 @@ mod tests { encrypt_message(&id, &id, EnvelopeKind::Result, &vec![0x41; plaintext], &key) .unwrap(); let real = rmp_serde::to_vec(&sealed).unwrap().len(); - let worst = worst_frame_bytes(sealed.ciphertext.len()); + let worst = frame_bytes_for_payload(sealed.ciphertext.len()); assert!(real <= worst, "{real} > {worst}"); // Real ciphertext is random, so about half its bytes take two // bytes: the worst case is not wildly above what is written. @@ -475,4 +494,15 @@ mod tests { } } } + + #[test] + fn the_payload_bound_a_frame_allows_inverts_the_frame_a_payload_needs() { + assert_eq!(payload_bytes_for_frame(frame_bytes_for_payload(0) - 1), None); + assert_eq!(payload_bytes_for_frame(frame_bytes_for_payload(0)), Some(0)); + for bytes in [1, 15, 16, 4096, 65_535, 65_536, 100_000, 2_000_000] { + let frame = frame_bytes_for_payload(bytes); + assert_eq!(payload_bytes_for_frame(frame), Some(bytes), "{bytes}"); + assert!(payload_bytes_for_frame(frame - 1).unwrap() < bytes, "{bytes}"); + } + } } diff --git a/crates/model-connector/src/serve.rs b/crates/model-connector/src/serve.rs index 1c763e9..e27a501 100644 --- a/crates/model-connector/src/serve.rs +++ b/crates/model-connector/src/serve.rs @@ -12,7 +12,7 @@ use zeroize::Zeroizing; use crate::contract::{TransportInvocation, TransportResult}; use crate::envelope::{ - ServiceError, TransportEnvelope, TransportFrameError, read_transport_frame, worst_frame_bytes, + ServiceError, TransportEnvelope, TransportFrameError, read_transport_frame, frame_bytes_for_payload, write_transport_frame, }; use crate::service::{CallerAdmission, TransportAdmission, TransportService}; @@ -39,8 +39,9 @@ pub struct ServeLimits { pub max_expiry_skew_ms: u64, } -/// The smallest caller payload bound the base daemon admitted. -const MIN_PAYLOAD_BYTES: usize = 4096; +/// The smallest caller payload bound serve admits. The smallest legal frame is +/// `frame_bytes_for_payload(MIN_PAYLOAD_BYTES)`. +pub const MIN_PAYLOAD_BYTES: usize = 4096; impl ServeLimits { /// The bounds serve refuses before it opens anything: each limit nonzero, @@ -51,7 +52,15 @@ impl ServeLimits { /// An admitted payload therefore always fits, and `complete` is given the /// same frame bound to fit every result in. fn validate(&self, admissions: &[CallerAdmission]) -> Result<(), ServeError> { - if self.max_frame_bytes < worst_frame_bytes(MIN_PAYLOAD_BYTES) + let smallest = frame_bytes_for_payload(MIN_PAYLOAD_BYTES); + if self.max_frame_bytes > 0 && self.max_frame_bytes < smallest { + return Err(ServeError::FrameBound { + max_frame_bytes: self.max_frame_bytes, + payload_bytes: MIN_PAYLOAD_BYTES, + needed_frame_bytes: smallest, + }); + } + if self.max_frame_bytes == 0 || self.max_frame_bytes > u32::MAX as usize || self.max_connections == 0 || self.socket_timeout_ms == 0 @@ -71,11 +80,18 @@ impl ServeLimits { if self.max_connections < slots.max(8) { return Err(ServeError::InvalidConfig("connection capacity")); } - if admissions.iter().any(|caller| { - caller.max_payload_bytes() < MIN_PAYLOAD_BYTES - || worst_frame_bytes(caller.max_payload_bytes()) > self.max_frame_bytes - }) { - return Err(ServeError::InvalidConfig("caller admission")); + for caller in admissions { + if caller.max_payload_bytes() < MIN_PAYLOAD_BYTES { + return Err(ServeError::InvalidConfig("caller admission")); + } + let needed = frame_bytes_for_payload(caller.max_payload_bytes()); + if needed > self.max_frame_bytes { + return Err(ServeError::FrameBound { + max_frame_bytes: self.max_frame_bytes, + payload_bytes: caller.max_payload_bytes(), + needed_frame_bytes: needed, + }); + } } Ok(()) } @@ -211,7 +227,7 @@ fn execute_inference_frame( let admission = service .lock() .map_err(|_| ServeError::ServicePoisoned)? - .begin(&envelope, unix_ms()?)?; + .begin(&envelope, unix_ms()?, limits.max_frame_bytes)?; let response = match admission { TransportAdmission::Reply(response) => response, TransportAdmission::Execute(claim) => { @@ -274,6 +290,14 @@ impl Drop for ConnectionPermit { pub enum ServeError { #[error("invalid serve configuration field {0}")] InvalidConfig(&'static str), + #[error( + "max_frame_bytes {max_frame_bytes} cannot carry a {payload_bytes}-byte payload bound: at least {needed_frame_bytes} frame bytes are needed" + )] + FrameBound { + max_frame_bytes: usize, + payload_bytes: usize, + needed_frame_bytes: usize, + }, #[error("connector connection key is unavailable")] ConnectionKey(#[source] std::io::Error), #[error(transparent)] @@ -496,6 +520,45 @@ mod tests { assert_eq!(backend.executions.load(Ordering::SeqCst), 1); } + #[test] + fn serve_refuses_a_long_request_id_on_the_wire_without_dropping_the_connection() { + let store = TestStore::new(); + let backend = CountingBackend::new(true); + let (_client, endpoint) = start(backend.clone(), &store); + let security = crate::fixtures::security(KEY); + let send = |id: &str| { + let request = rmp_serde::to_vec( + &crate::envelope::encrypt_invocation(&fresh(id), &security).unwrap(), + ) + .unwrap(); + let mut stream = TcpStream::connect(endpoint).unwrap(); + write_transport_frame(&mut stream, &request, FRAME).unwrap(); + let reply = read_transport_frame(&mut stream, FRAME).unwrap(); + let envelope: TransportEnvelope = rmp_serde::from_slice(&reply).unwrap(); + let result: TransportResult = rmp_serde::from_slice( + &crate::envelope::decrypt_message(&envelope, &security).unwrap(), + ) + .unwrap(); + (reply.len(), result) + }; + let (_, served) = send(&"i".repeat(crate::envelope::MAX_IDENTITY_BYTES)); + assert!(matches!( + served.disposition, + TransportDisposition::Transported { .. } + )); + assert_eq!(backend.executions.load(Ordering::SeqCst), 1); + for length in [257, 258, 5_000, 60_000] { + let (size, result) = send(&"i".repeat(length)); + assert_eq!( + result.disposition, + TransportDisposition::Refused(Refusal::Malformed), + "{length}" + ); + assert!(size <= smallest_frame(), "{length}: {size}"); + } + assert_eq!(backend.executions.load(Ordering::SeqCst), 1); + } + #[test] fn serve_refuses_to_start_on_an_unready_backend() { let store = TestStore::new(); @@ -547,7 +610,7 @@ mod tests { /// The smallest frame serve admits: the smallest payload at its worst-case /// sealed encoding. fn smallest_frame() -> usize { - worst_frame_bytes(MIN_PAYLOAD_BYTES) + frame_bytes_for_payload(MIN_PAYLOAD_BYTES) } #[test] @@ -558,16 +621,15 @@ mod tests { )) .unwrap() .len(); - let worst = worst_frame_bytes(plaintext + GCM_TAG); + let worst = frame_bytes_for_payload(plaintext + GCM_TAG); assert!(worst <= smallest_frame(), "{worst} > {}", smallest_frame()); } #[test] fn serve_refuses_unbounded_limits_and_admits_the_widest_frame() { let store = TestStore::new(); - let mutations: [fn(&mut ServeLimits); 6] = [ + let mutations: [fn(&mut ServeLimits); 5] = [ |limits| limits.max_frame_bytes = 0, - |limits| limits.max_frame_bytes = smallest_frame() - 1, |limits| limits.max_frame_bytes = u32::MAX as usize + 1, |limits| limits.max_connections = 0, |limits| limits.socket_timeout_ms = 0, @@ -658,6 +720,7 @@ mod tests { ) { None => None, Some(Err(ServeError::InvalidConfig(field))) => Some(field), + Some(Err(ServeError::FrameBound { .. })) => Some("frame bound"), Some(other) => panic!("unexpected serve outcome {other:?}"), } } @@ -699,13 +762,13 @@ mod tests { let tightest = smallest_frame(); assert_eq!( refusal(frame(tightest - 1), payload(4096)), - Some("serve limits") + Some("frame bound") ); // The tightest legal pair: the smallest payload in the smallest frame. assert_eq!(refusal(frame(tightest), payload(4096)), None); assert_eq!( refusal(frame(tightest), payload(4097)), - Some("caller admission") + Some("frame bound") ); assert_eq!( refusal(frame(tightest), payload(4095)), @@ -715,14 +778,14 @@ mod tests { // a payload that is the whole frame cannot be carried. assert_eq!( refusal(frame(FRAME), payload(FRAME)), - Some("caller admission") + Some("frame bound") ); for bytes in [8192, 65_535, 65_536, 100_000] { - let widest = worst_frame_bytes(bytes); + let widest = frame_bytes_for_payload(bytes); assert_eq!(refusal(frame(widest), payload(bytes)), None, "{bytes}"); assert_eq!( refusal(frame(widest - 1), payload(bytes)), - Some("caller admission"), + Some("frame bound"), "{bytes}" ); } @@ -730,7 +793,7 @@ mod tests { admission("a", "key-a-distinct", 4, 8192), admission("b", "key-b-distinct", 4, FRAME), ]; - assert_eq!(refusal(frame(FRAME), mixed), Some("caller admission")); + assert_eq!(refusal(frame(FRAME), mixed), Some("frame bound")); } #[test] @@ -744,7 +807,7 @@ mod tests { let key = crate::fixtures::security(KEY); let sealed = crate::envelope::encrypt_invocation(&invocation, &key).unwrap(); let bound = sealed.ciphertext.len(); - let frame = worst_frame_bytes(bound); + let frame = frame_bytes_for_payload(bound); let listener = TcpListener::bind("127.0.0.1:0").unwrap(); let endpoint = listener.local_addr().unwrap(); let path = store.path.clone(); @@ -920,7 +983,7 @@ mod tests { /// The largest result plaintext whose sealed frame fits at the worst encoding. fn widest_guaranteed_plaintext(base: usize) -> usize { (base..=FRAME) - .take_while(|plaintext| worst_frame_bytes(plaintext + GCM_TAG) <= FRAME) + .take_while(|plaintext| frame_bytes_for_payload(plaintext + GCM_TAG) <= FRAME) .last() .unwrap_or(base) } diff --git a/crates/model-connector/src/service.rs b/crates/model-connector/src/service.rs index 6836df1..4ec8cb6 100644 --- a/crates/model-connector/src/service.rs +++ b/crates/model-connector/src/service.rs @@ -195,15 +195,19 @@ impl TransportService { }) } + /// Admits one request. `max_frame_bytes` is the frame every reply must + /// fit, the same bound `complete` is given: a recorded result that no + /// longer fits it (the service reopened at a smaller frame) is answered + /// with the typed `result_size` failure, which is recorded in its place. + /// A request id longer than `MAX_IDENTITY_BYTES` is refused as malformed; + /// the refusal names the id by its digest so it fits any legal frame. pub fn begin( &mut self, envelope: &TransportEnvelope, now_unix_ms: u64, + max_frame_bytes: usize, ) -> Result { validate_envelope(envelope, EnvelopeKind::Invocation)?; - if envelope.request_id.len() > MAX_IDENTITY_BYTES { - return Err(ContractError::Invalid("request_id").into()); - } let caller = self .callers .get(&envelope.caller_runtime_id) @@ -211,6 +215,12 @@ impl TransportService { let security = caller.security.clone(); let max_concurrent_requests = caller.max_concurrent_requests; let invocation = decrypt_invocation(envelope, &security)?; + if envelope.request_id.len() > MAX_IDENTITY_BYTES { + return Ok(TransportAdmission::Reply(encrypt_result( + &unnamed_refusal(&invocation, Refusal::Malformed), + &security, + )?)); + } if envelope.ciphertext.len() > caller.max_payload_bytes { return self.reply_refusal(&invocation, Refusal::Policy, &security); } @@ -240,8 +250,25 @@ impl TransportService { if record.invocation_sha256 != invocation_sha256 { return self.reply_refusal(&invocation, Refusal::ReplayConflict, &security); } - return match record.state { - ReplayState::Completed { response } => Ok(TransportAdmission::Reply(response)), + return match &record.state { + ReplayState::Completed { response } => { + if fits_frame(response, max_frame_bytes)? { + return Ok(TransportAdmission::Reply(response.clone())); + } + let stand_in = self.seal_stand_in(&invocation, &security, max_frame_bytes)?; + self.replay_store + .put( + replay_key(&identity), + &ReplayRecord { + state: ReplayState::Completed { + response: stand_in.clone(), + }, + ..record.clone() + }, + ) + .map_err(replay_store_error)?; + Ok(TransportAdmission::Reply(stand_in)) + } ReplayState::Active => { let refusal = if self.live.contains(&identity) { Refusal::InFlight @@ -318,22 +345,23 @@ impl TransportService { .callers .get(&identity.caller_runtime_id) .ok_or(ServiceError::CallerNotAdmitted)?; - let mut response = encrypt_result(&result, &caller.security)?; + let security = caller.security.clone(); + let mut response = encrypt_result(&result, &security)?; if !fits_frame(&response, max_frame_bytes)? { - response = encrypt_result( - &result_size_failure( - &claim.invocation.caller_runtime_id, - claim.invocation.request_id(), - &claim.invocation.request.model, - claim.invocation.native_request_sha256, - claim.invocation.provider_request_sha256, - ), - &caller.security, - )?; - if !fits_frame(&response, max_frame_bytes)? { - return Err(ServiceError::FrameBound); - } + response = match self.seal_stand_in(&claim.invocation, &security, max_frame_bytes) { + Ok(stand_in) => stand_in, + Err(error) => { + // Nothing is recorded, but the caller's slot is not held + // by a request that can no longer finish. + self.live.remove(&identity); + return Err(error); + } + }; } + let caller = self + .callers + .get(&identity.caller_runtime_id) + .ok_or(ServiceError::CallerNotAdmitted)?; let replay_record = ReplayRecord { caller_runtime_id: identity.caller_runtime_id.clone(), request_id: identity.request_id.clone(), @@ -351,6 +379,30 @@ impl TransportService { Ok(response) } + /// The `result_size` failure sealed for `invocation`, if it fits the frame. + fn seal_stand_in( + &self, + invocation: &TransportInvocation, + security: &TransportKey, + max_frame_bytes: usize, + ) -> Result { + let stand_in = encrypt_result( + &result_size_failure( + &invocation.caller_runtime_id, + invocation.request_id(), + &invocation.request.model, + invocation.native_request_sha256, + invocation.provider_request_sha256, + ), + security, + )?; + if fits_frame(&stand_in, max_frame_bytes)? { + Ok(stand_in) + } else { + Err(ServiceError::FrameBound) + } + } + fn reply_refusal( &self, invocation: &TransportInvocation, @@ -412,14 +464,27 @@ fn replay_key(identity: &RequestIdentity) -> String { hasher.update(identity.caller_runtime_id.as_bytes()); hasher.update((identity.request_id.len() as u64).to_be_bytes()); hasher.update(identity.request_id.as_bytes()); + hex(hasher.finalize().into()) +} + +fn hex(digest: [u8; 32]) -> String { const HEX: &[u8; 16] = b"0123456789abcdef"; - let digest = hasher.finalize(); - let mut key = String::with_capacity(64); + let mut text = String::with_capacity(64); for byte in digest { - key.push(HEX[(byte >> 4) as usize] as char); - key.push(HEX[(byte & 0x0f) as usize] as char); + text.push(HEX[(byte >> 4) as usize] as char); + text.push(HEX[(byte & 0x0f) as usize] as char); + } + text +} + +/// A refusal for an invocation whose request id is too long to echo: it names +/// the request by the hex SHA-256 of the id (64 bytes), so the reply fits the +/// smallest legal frame whatever the id's length. +fn unnamed_refusal(invocation: &TransportInvocation, reason: Refusal) -> TransportResult { + TransportResult { + request_id: hex(Sha256::digest(invocation.request_id().as_bytes()).into()), + ..TransportResult::refused(invocation, reason) } - key } fn replay_store_error(error: impl std::fmt::Display) -> ServiceError { @@ -441,6 +506,8 @@ mod tests { use crate::fixtures::*; use crate::{contract::*, envelope::*}; + const FRAME: usize = 1 << 20; + #[test] fn service_refuses_shared_keys_and_isolates_caller_request_identities() { let store = TestStore::new(); @@ -464,7 +531,7 @@ mod tests { .begin( &encrypt_invocation(&epiphany, &security("epiphany-distinct-test-key")) .unwrap(), - 1_000, + 1_000, FRAME, ) .unwrap(), TransportAdmission::Execute(_) @@ -474,7 +541,7 @@ mod tests { .begin( &encrypt_invocation(&ghostlight, &security("ghostlight-distinct-test-key")) .unwrap(), - 1_000, + 1_000, FRAME, ) .unwrap(), TransportAdmission::Execute(_) @@ -488,7 +555,7 @@ mod tests { let security = security("epiphany-distinct-test-key"); let mut service = open_service(&store, 1); let claim = match service - .begin(&encrypt_invocation(&invocation, &security).unwrap(), 1_000) + .begin(&encrypt_invocation(&invocation, &security).unwrap(), 1_000, FRAME) .unwrap() { TransportAdmission::Execute(claim) => claim, @@ -516,7 +583,7 @@ mod tests { let mut service = open_service(&store, 1); let replay = match service - .begin(&encrypt_invocation(&invocation, &security).unwrap(), 1_000) + .begin(&encrypt_invocation(&invocation, &security).unwrap(), 1_000, FRAME) .unwrap() { TransportAdmission::Reply(response) => response, @@ -535,7 +602,7 @@ mod tests { TransportInvocation::new("epiphany-yggdrasil", 2_000, [7; 32], changed_request) .unwrap(); let refusal = match service - .begin(&encrypt_invocation(&conflicting, &security).unwrap(), 1_000) + .begin(&encrypt_invocation(&conflicting, &security).unwrap(), 1_000, FRAME) .unwrap() { TransportAdmission::Reply(response) => response, @@ -558,13 +625,13 @@ mod tests { let mut service = open_service(&store, 1); let first_envelope = encrypt_invocation(&first, &security).unwrap(); assert!(matches!( - service.begin(&first_envelope, 1_000).unwrap(), + service.begin(&first_envelope, 1_000, FRAME).unwrap(), TransportAdmission::Execute(_) )); for (invocation, expected) in [(&first, Refusal::InFlight), (&second, Refusal::Capacity)] { let response = match service - .begin(&encrypt_invocation(invocation, &security).unwrap(), 1_000) + .begin(&encrypt_invocation(invocation, &security).unwrap(), 1_000, FRAME) .unwrap() { TransportAdmission::Reply(response) => response, @@ -588,7 +655,7 @@ mod tests { let mut service = open_service(&store, 1); assert!(matches!( service - .begin(&encrypt_invocation(&first, &security).unwrap(), 1_000) + .begin(&encrypt_invocation(&first, &security).unwrap(), 1_000, FRAME) .unwrap(), TransportAdmission::Execute(_) )); @@ -596,7 +663,7 @@ mod tests { let mut restarted = open_service(&store, 1); let ambiguous = match restarted - .begin(&encrypt_invocation(&first, &security).unwrap(), 1_000) + .begin(&encrypt_invocation(&first, &security).unwrap(), 1_000, FRAME) .unwrap() { TransportAdmission::Reply(response) => response, @@ -610,7 +677,7 @@ mod tests { ); assert!(matches!( restarted - .begin(&encrypt_invocation(&second, &security).unwrap(), 1_000) + .begin(&encrypt_invocation(&second, &security).unwrap(), 1_000, FRAME) .unwrap(), TransportAdmission::Execute(_) )); @@ -646,7 +713,7 @@ mod tests { edit(&mut request); let invocation = TransportInvocation::new(CALLER, 2_000, [7; 32], request).unwrap(); let envelope = encrypt_invocation(&invocation, &security(KEY)).unwrap(); - (service.begin(&envelope, 1_000).unwrap(), invocation) + (service.begin(&envelope, 1_000, FRAME).unwrap(), invocation) } fn refusal_of(admission: TransportAdmission, invocation: &TransportInvocation) -> Refusal { @@ -727,7 +794,7 @@ mod tests { .unwrap(); TransportService::open(&store.path, [admission], 5_000) .unwrap() - .begin(&envelope, 1_000) + .begin(&envelope, 1_000, FRAME) }; assert!(matches!(admit(size), Ok(TransportAdmission::Execute(_)))); let refused = admit(size - 1).unwrap(); @@ -741,12 +808,12 @@ mod tests { let mut expired = invocation_for(CALLER, "expired"); expired.expires_at_unix_ms = 10; - let reply = service.begin(&raw_envelope(&expired), 1_000).unwrap(); + let reply = service.begin(&raw_envelope(&expired), 1_000, FRAME).unwrap(); assert_eq!(refusal_of(reply, &expired), Refusal::Expired); let mut substituted = invocation_for(CALLER, "substituted"); substituted.provider_request_sha256 = [9; 32]; - let reply = service.begin(&raw_envelope(&substituted), 1_000).unwrap(); + let reply = service.begin(&raw_envelope(&substituted), 1_000, FRAME).unwrap(); assert_eq!( refusal_of(reply, &substituted), Refusal::ProviderDigestSubstitution @@ -754,7 +821,7 @@ mod tests { let mut malformed = invocation_for(CALLER, "malformed"); malformed.native_request_sha256 = [0; 32]; - let reply = service.begin(&raw_envelope(&malformed), 1_000).unwrap(); + let reply = service.begin(&raw_envelope(&malformed), 1_000, FRAME).unwrap(); assert_eq!(refusal_of(reply, &malformed), Refusal::Malformed); } @@ -880,14 +947,46 @@ mod tests { let mut service = open_service(&store, 8); let ok = invocation_for(CALLER, &at); assert!(matches!( - service.begin(&raw_envelope(&ok), 1_000).unwrap(), + service.begin(&raw_envelope(&ok), 1_000, FRAME).unwrap(), TransportAdmission::Execute(_) )); - let long = invocation_for(CALLER, &over); - assert_eq!( - service.begin(&raw_envelope(&long), 1_000).err(), - Some(ServiceError::Contract(ContractError::Invalid("request_id"))) - ); + } + + #[test] + fn a_request_id_past_the_bound_is_refused_malformed_in_a_reply_that_fits_the_smallest_frame() { + let smallest = frame_bytes_for_payload(4096); + for length in [ + MAX_IDENTITY_BYTES + 1, + MAX_IDENTITY_BYTES + 2, + 5_000, + 100_000, + 400_000, + ] { + let store = TestStore::new(); + let mut service = open_service(&store, 1); + let id = "i".repeat(length); + let long = invocation_for(CALLER, &id); + let TransportAdmission::Reply(reply) = service + .begin(&raw_envelope(&long), 1_000, smallest) + .unwrap() + else { + panic!("an over-long id must be refused, not executed ({length})"); + }; + assert!( + rmp_serde::to_vec(&reply).unwrap().len() <= smallest, + "{length}" + ); + let result: TransportResult = + rmp_serde::from_slice(&decrypt_message(&reply, &security(KEY)).unwrap()).unwrap(); + assert_eq!( + result.disposition, + TransportDisposition::Refused(Refusal::Malformed) + ); + assert_eq!(result.request_id, reply.request_id); + assert_eq!(result.request_id.len(), 64, "the id is named, not echoed"); + // Nothing was recorded or held: the caller's one slot is free. + let (_claim, _) = claim_for(&mut service, "after-the-long-one"); + } } #[test] @@ -904,7 +1003,7 @@ mod tests { request_id: &str, ) -> (Box, TransportInvocation) { let invocation = invocation_for(CALLER, request_id); - match service.begin(&raw_envelope(&invocation), 1_000).unwrap() { + match service.begin(&raw_envelope(&invocation), 1_000, FRAME).unwrap() { TransportAdmission::Execute(claim) => (claim, invocation), TransportAdmission::Reply(_) => panic!("expected an execution claim"), } @@ -955,7 +1054,7 @@ mod tests { // The recorded replay is the stand-in's exact bytes. let TransportAdmission::Reply(replay) = - service.begin(&raw_envelope(&invocation), 1_000).unwrap() + service.begin(&raw_envelope(&invocation), 1_000, FRAME).unwrap() else { panic!("expected a replay"); }; @@ -975,6 +1074,94 @@ mod tests { ); } + #[test] + fn a_frame_bound_failure_releases_the_callers_slot() { + let store = TestStore::new(); + let mut service = open_service(&store, 1); + let (claim, invocation) = claim_for(&mut service, "wedge"); + let big = text_result(&invocation, "y", "fixture-sse"); + assert_eq!( + service.complete(*claim, big, 10).unwrap_err(), + ServiceError::FrameBound + ); + // The caller's only slot is free again, and the dead request is no + // longer in flight. + let (_next, _) = claim_for(&mut service, "after-wedge"); + let TransportAdmission::Reply(reply) = service + .begin(&raw_envelope(&invocation), 1_000, FRAME) + .unwrap() + else { + panic!("expected a refusal"); + }; + assert_eq!( + decrypt_result(&reply, &security(KEY), &invocation) + .unwrap() + .disposition, + TransportDisposition::Refused(Refusal::Indeterminate) + ); + } + + #[test] + fn a_recorded_result_replayed_after_a_restart_at_a_smaller_frame_is_re_measured() { + let store = TestStore::new(); + let mut service = open_service(&store, 8); + let (claim, invocation) = claim_for(&mut service, "restart"); + let big = text_result(&invocation, &"y".repeat(4000), "fixture-sse"); + let recorded = service.complete(*claim, big, FRAME).unwrap(); + assert!(rmp_serde::to_vec(&recorded).unwrap().len() > 2000); + drop(service); + + let mut service = open_service(&store, 8); + let TransportAdmission::Reply(same) = service + .begin(&raw_envelope(&invocation), 1_000, FRAME) + .unwrap() + else { + panic!("expected a replay"); + }; + assert_eq!(same, recorded, "a frame that still fits replays the record"); + drop(service); + + let mut service = open_service(&store, 8); + let TransportAdmission::Reply(smaller) = service + .begin(&raw_envelope(&invocation), 1_000, 2000) + .unwrap() + else { + panic!("expected a replay"); + }; + assert!(rmp_serde::to_vec(&smaller).unwrap().len() <= 2000); + assert_eq!( + decrypt_result(&smaller, &security(KEY), &invocation).unwrap(), + result_size_failure( + CALLER, + "restart", + &invocation.request.model, + invocation.native_request_sha256, + invocation.provider_request_sha256, + ) + ); + // The stand-in is what is recorded now, byte for byte, at any frame. + let TransportAdmission::Reply(again) = service + .begin(&raw_envelope(&invocation), 1_000, FRAME) + .unwrap() + else { + panic!("expected a replay"); + }; + assert_eq!(again, smaller); + } + + #[test] + fn a_replay_whose_stand_in_cannot_fit_either_is_a_frame_bound_failure() { + let store = TestStore::new(); + let mut service = open_service(&store, 8); + let (claim, invocation) = claim_for(&mut service, "no-room"); + let big = text_result(&invocation, "y", "fixture-sse"); + service.complete(*claim, big, FRAME).unwrap(); + assert_eq!( + service.begin(&raw_envelope(&invocation), 1_000, 10).err(), + Some(ServiceError::FrameBound) + ); + } + #[test] fn nothing_is_recorded_when_not_even_the_stand_in_fits() { let store = TestStore::new(); @@ -985,9 +1172,10 @@ mod tests { service.complete(*claim, big, 10).unwrap_err(), ServiceError::FrameBound ); - // Still active, never completed: the retry is told it is in flight. + // Still active, never completed, and no longer live: the retry is told the + // outcome is indeterminate. let TransportAdmission::Reply(reply) = - service.begin(&raw_envelope(&invocation), 1_000).unwrap() + service.begin(&raw_envelope(&invocation), 1_000, FRAME).unwrap() else { panic!("expected a refusal"); }; @@ -995,7 +1183,7 @@ mod tests { decrypt_result(&reply, &security(KEY), &invocation) .unwrap() .disposition, - TransportDisposition::Refused(Refusal::InFlight) + TransportDisposition::Refused(Refusal::Indeterminate) ); } } From 15a41343ce028960637e289da528d629b2587cc2 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Wed, 28 Oct 2026 06:05:16 +0100 Subject: [PATCH 15/69] rustfmt the connector kit Co-Authored-By: Claude Opus 5.5 --- crates/model-connector/src/client.rs | 2 - crates/model-connector/src/contract.rs | 14 ++--- crates/model-connector/src/envelope.rs | 10 +++- crates/model-connector/src/fixtures.rs | 1 - crates/model-connector/src/serve.rs | 14 ++--- crates/model-connector/src/service.rs | 75 ++++++++++++++++++++------ 6 files changed, 73 insertions(+), 43 deletions(-) diff --git a/crates/model-connector/src/client.rs b/crates/model-connector/src/client.rs index ea8ea3d..b2997ce 100644 --- a/crates/model-connector/src/client.rs +++ b/crates/model-connector/src/client.rs @@ -70,7 +70,6 @@ impl ConnectorClient { } } - #[cfg(test)] mod client_endpoint_tests { use super::*; @@ -110,7 +109,6 @@ mod client_endpoint_tests { } } - #[derive(Debug, Error)] pub enum ConnectorClientError { #[error("invalid connector client configuration")] diff --git a/crates/model-connector/src/contract.rs b/crates/model-connector/src/contract.rs index 2f11a48..7ae5be5 100644 --- a/crates/model-connector/src/contract.rs +++ b/crates/model-connector/src/contract.rs @@ -289,10 +289,7 @@ impl TransportResult { } } - pub fn validate_against( - &self, - invocation: &TransportInvocation, - ) -> Result<(), ContractError> { + pub fn validate_against(&self, invocation: &TransportInvocation) -> Result<(), ContractError> { if self.schema_id != RESULT_SCHEMA_ID { return Err(ContractError::Schema); } @@ -363,10 +360,7 @@ pub struct TransportReceipt { } impl TransportReceipt { - pub fn validate_against( - &self, - invocation: &TransportInvocation, - ) -> Result<(), ContractError> { + pub fn validate_against(&self, invocation: &TransportInvocation) -> Result<(), ContractError> { if self.schema_id != RECEIPT_SCHEMA_ID { return Err(ContractError::Schema); } @@ -518,7 +512,6 @@ pub enum ContractError { Encoding, } - #[cfg(test)] mod tests { use super::*; @@ -618,8 +611,7 @@ mod tests { assert_eq!(result.validate_against(&invocation), Ok(())); let mut reordered = result; - let TransportDisposition::Transported { events, .. } = &mut reordered.disposition - else { + let TransportDisposition::Transported { events, .. } = &mut reordered.disposition else { unreachable!() }; events[0].sequence = 1; diff --git a/crates/model-connector/src/envelope.rs b/crates/model-connector/src/envelope.rs index a0df968..f8d4a22 100644 --- a/crates/model-connector/src/envelope.rs +++ b/crates/model-connector/src/envelope.rs @@ -497,12 +497,18 @@ mod tests { #[test] fn the_payload_bound_a_frame_allows_inverts_the_frame_a_payload_needs() { - assert_eq!(payload_bytes_for_frame(frame_bytes_for_payload(0) - 1), None); + assert_eq!( + payload_bytes_for_frame(frame_bytes_for_payload(0) - 1), + None + ); assert_eq!(payload_bytes_for_frame(frame_bytes_for_payload(0)), Some(0)); for bytes in [1, 15, 16, 4096, 65_535, 65_536, 100_000, 2_000_000] { let frame = frame_bytes_for_payload(bytes); assert_eq!(payload_bytes_for_frame(frame), Some(bytes), "{bytes}"); - assert!(payload_bytes_for_frame(frame - 1).unwrap() < bytes, "{bytes}"); + assert!( + payload_bytes_for_frame(frame - 1).unwrap() < bytes, + "{bytes}" + ); } } } diff --git a/crates/model-connector/src/fixtures.rs b/crates/model-connector/src/fixtures.rs index bb89264..9b48c1d 100644 --- a/crates/model-connector/src/fixtures.rs +++ b/crates/model-connector/src/fixtures.rs @@ -126,4 +126,3 @@ pub(crate) fn invocation_for(caller_runtime_id: &str, request_id: &str) -> Trans request.request_id = request_id.to_string(); TransportInvocation::new(caller_runtime_id, 2_000, [7; 32], request).unwrap() } - diff --git a/crates/model-connector/src/serve.rs b/crates/model-connector/src/serve.rs index e27a501..0ea0766 100644 --- a/crates/model-connector/src/serve.rs +++ b/crates/model-connector/src/serve.rs @@ -12,8 +12,8 @@ use zeroize::Zeroizing; use crate::contract::{TransportInvocation, TransportResult}; use crate::envelope::{ - ServiceError, TransportEnvelope, TransportFrameError, read_transport_frame, frame_bytes_for_payload, - write_transport_frame, + ServiceError, TransportEnvelope, TransportFrameError, frame_bytes_for_payload, + read_transport_frame, write_transport_frame, }; use crate::service::{CallerAdmission, TransportAdmission, TransportService}; @@ -766,20 +766,14 @@ mod tests { ); // The tightest legal pair: the smallest payload in the smallest frame. assert_eq!(refusal(frame(tightest), payload(4096)), None); - assert_eq!( - refusal(frame(tightest), payload(4097)), - Some("frame bound") - ); + assert_eq!(refusal(frame(tightest), payload(4097)), Some("frame bound")); assert_eq!( refusal(frame(tightest), payload(4095)), Some("caller admission") ); // The bound is the sealed frame at its worst encoding, not the payload: // a payload that is the whole frame cannot be carried. - assert_eq!( - refusal(frame(FRAME), payload(FRAME)), - Some("frame bound") - ); + assert_eq!(refusal(frame(FRAME), payload(FRAME)), Some("frame bound")); for bytes in [8192, 65_535, 65_536, 100_000] { let widest = frame_bytes_for_payload(bytes); assert_eq!(refusal(frame(widest), payload(bytes)), None, "{bytes}"); diff --git a/crates/model-connector/src/service.rs b/crates/model-connector/src/service.rs index 4ec8cb6..a2a7768 100644 --- a/crates/model-connector/src/service.rs +++ b/crates/model-connector/src/service.rs @@ -531,7 +531,8 @@ mod tests { .begin( &encrypt_invocation(&epiphany, &security("epiphany-distinct-test-key")) .unwrap(), - 1_000, FRAME, + 1_000, + FRAME, ) .unwrap(), TransportAdmission::Execute(_) @@ -541,7 +542,8 @@ mod tests { .begin( &encrypt_invocation(&ghostlight, &security("ghostlight-distinct-test-key")) .unwrap(), - 1_000, FRAME, + 1_000, + FRAME, ) .unwrap(), TransportAdmission::Execute(_) @@ -555,7 +557,11 @@ mod tests { let security = security("epiphany-distinct-test-key"); let mut service = open_service(&store, 1); let claim = match service - .begin(&encrypt_invocation(&invocation, &security).unwrap(), 1_000, FRAME) + .begin( + &encrypt_invocation(&invocation, &security).unwrap(), + 1_000, + FRAME, + ) .unwrap() { TransportAdmission::Execute(claim) => claim, @@ -583,7 +589,11 @@ mod tests { let mut service = open_service(&store, 1); let replay = match service - .begin(&encrypt_invocation(&invocation, &security).unwrap(), 1_000, FRAME) + .begin( + &encrypt_invocation(&invocation, &security).unwrap(), + 1_000, + FRAME, + ) .unwrap() { TransportAdmission::Reply(response) => response, @@ -602,7 +612,11 @@ mod tests { TransportInvocation::new("epiphany-yggdrasil", 2_000, [7; 32], changed_request) .unwrap(); let refusal = match service - .begin(&encrypt_invocation(&conflicting, &security).unwrap(), 1_000, FRAME) + .begin( + &encrypt_invocation(&conflicting, &security).unwrap(), + 1_000, + FRAME, + ) .unwrap() { TransportAdmission::Reply(response) => response, @@ -631,7 +645,11 @@ mod tests { for (invocation, expected) in [(&first, Refusal::InFlight), (&second, Refusal::Capacity)] { let response = match service - .begin(&encrypt_invocation(invocation, &security).unwrap(), 1_000, FRAME) + .begin( + &encrypt_invocation(invocation, &security).unwrap(), + 1_000, + FRAME, + ) .unwrap() { TransportAdmission::Reply(response) => response, @@ -655,7 +673,11 @@ mod tests { let mut service = open_service(&store, 1); assert!(matches!( service - .begin(&encrypt_invocation(&first, &security).unwrap(), 1_000, FRAME) + .begin( + &encrypt_invocation(&first, &security).unwrap(), + 1_000, + FRAME + ) .unwrap(), TransportAdmission::Execute(_) )); @@ -663,7 +685,11 @@ mod tests { let mut restarted = open_service(&store, 1); let ambiguous = match restarted - .begin(&encrypt_invocation(&first, &security).unwrap(), 1_000, FRAME) + .begin( + &encrypt_invocation(&first, &security).unwrap(), + 1_000, + FRAME, + ) .unwrap() { TransportAdmission::Reply(response) => response, @@ -677,7 +703,11 @@ mod tests { ); assert!(matches!( restarted - .begin(&encrypt_invocation(&second, &security).unwrap(), 1_000, FRAME) + .begin( + &encrypt_invocation(&second, &security).unwrap(), + 1_000, + FRAME + ) .unwrap(), TransportAdmission::Execute(_) )); @@ -808,12 +838,16 @@ mod tests { let mut expired = invocation_for(CALLER, "expired"); expired.expires_at_unix_ms = 10; - let reply = service.begin(&raw_envelope(&expired), 1_000, FRAME).unwrap(); + let reply = service + .begin(&raw_envelope(&expired), 1_000, FRAME) + .unwrap(); assert_eq!(refusal_of(reply, &expired), Refusal::Expired); let mut substituted = invocation_for(CALLER, "substituted"); substituted.provider_request_sha256 = [9; 32]; - let reply = service.begin(&raw_envelope(&substituted), 1_000, FRAME).unwrap(); + let reply = service + .begin(&raw_envelope(&substituted), 1_000, FRAME) + .unwrap(); assert_eq!( refusal_of(reply, &substituted), Refusal::ProviderDigestSubstitution @@ -821,7 +855,9 @@ mod tests { let mut malformed = invocation_for(CALLER, "malformed"); malformed.native_request_sha256 = [0; 32]; - let reply = service.begin(&raw_envelope(&malformed), 1_000, FRAME).unwrap(); + let reply = service + .begin(&raw_envelope(&malformed), 1_000, FRAME) + .unwrap(); assert_eq!(refusal_of(reply, &malformed), Refusal::Malformed); } @@ -1003,7 +1039,10 @@ mod tests { request_id: &str, ) -> (Box, TransportInvocation) { let invocation = invocation_for(CALLER, request_id); - match service.begin(&raw_envelope(&invocation), 1_000, FRAME).unwrap() { + match service + .begin(&raw_envelope(&invocation), 1_000, FRAME) + .unwrap() + { TransportAdmission::Execute(claim) => (claim, invocation), TransportAdmission::Reply(_) => panic!("expected an execution claim"), } @@ -1053,8 +1092,9 @@ mod tests { assert_eq!(receipt.transport, "model-connector"); // The recorded replay is the stand-in's exact bytes. - let TransportAdmission::Reply(replay) = - service.begin(&raw_envelope(&invocation), 1_000, FRAME).unwrap() + let TransportAdmission::Reply(replay) = service + .begin(&raw_envelope(&invocation), 1_000, FRAME) + .unwrap() else { panic!("expected a replay"); }; @@ -1174,8 +1214,9 @@ mod tests { ); // Still active, never completed, and no longer live: the retry is told the // outcome is indeterminate. - let TransportAdmission::Reply(reply) = - service.begin(&raw_envelope(&invocation), 1_000, FRAME).unwrap() + let TransportAdmission::Reply(reply) = service + .begin(&raw_envelope(&invocation), 1_000, FRAME) + .unwrap() else { panic!("expected a refusal"); }; From 41c75bb9e7c1afe252b1a6a1efc02ba3e0a56f0c Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Wed, 28 Oct 2026 06:16:11 +0100 Subject: [PATCH 16/69] payload_bytes_for_frame: closed form instead of a search; serve checks the plain limits before the frame Co-Authored-By: Claude Opus 5.5 --- crates/model-connector/src/envelope.rs | 28 +++++++++++++------------- crates/model-connector/src/serve.rs | 16 +++++++-------- 2 files changed, 22 insertions(+), 22 deletions(-) diff --git a/crates/model-connector/src/envelope.rs b/crates/model-connector/src/envelope.rs index f8d4a22..5400eac 100644 --- a/crates/model-connector/src/envelope.rs +++ b/crates/model-connector/src/envelope.rs @@ -65,20 +65,20 @@ pub fn frame_bytes_for_payload(ciphertext_len: usize) -> usize { /// the inverse of [`frame_bytes_for_payload`]. `None` when the frame cannot /// carry even an empty envelope. pub fn payload_bytes_for_frame(max_frame_bytes: usize) -> Option { - if frame_bytes_for_payload(0) > max_frame_bytes { - return None; - } - // frame_bytes_for_payload is increasing; the answer is at most half the frame. - let (mut low, mut high) = (0, max_frame_bytes / 2); - while low < high { - let mid = low + (high - low).div_ceil(2); - if frame_bytes_for_payload(mid) <= max_frame_bytes { - low = mid; - } else { - high = mid - 1; - } - } - Some(low) + // frame_bytes_for_payload(0) is the shell plus a one-byte array header. + let fixed = frame_bytes_for_payload(0) - 1; + let room = max_frame_bytes.checked_sub(fixed)?; + // Each array header width bounds the payloads it can describe; the + // widest payload any width admits is the answer. + let by_header = |header: usize, cap: usize| room.checked_sub(header).map(|r| (r / 2).min(cap)); + [ + by_header(5, usize::MAX), + by_header(3, 65_535), + by_header(1, 15), + ] + .into_iter() + .flatten() + .max() } pub fn encrypt_invocation( diff --git a/crates/model-connector/src/serve.rs b/crates/model-connector/src/serve.rs index 0ea0766..9e3c9ff 100644 --- a/crates/model-connector/src/serve.rs +++ b/crates/model-connector/src/serve.rs @@ -52,14 +52,6 @@ impl ServeLimits { /// An admitted payload therefore always fits, and `complete` is given the /// same frame bound to fit every result in. fn validate(&self, admissions: &[CallerAdmission]) -> Result<(), ServeError> { - let smallest = frame_bytes_for_payload(MIN_PAYLOAD_BYTES); - if self.max_frame_bytes > 0 && self.max_frame_bytes < smallest { - return Err(ServeError::FrameBound { - max_frame_bytes: self.max_frame_bytes, - payload_bytes: MIN_PAYLOAD_BYTES, - needed_frame_bytes: smallest, - }); - } if self.max_frame_bytes == 0 || self.max_frame_bytes > u32::MAX as usize || self.max_connections == 0 @@ -68,6 +60,14 @@ impl ServeLimits { { return Err(ServeError::InvalidConfig("serve limits")); } + let smallest = frame_bytes_for_payload(MIN_PAYLOAD_BYTES); + if self.max_frame_bytes < smallest { + return Err(ServeError::FrameBound { + max_frame_bytes: self.max_frame_bytes, + payload_bytes: MIN_PAYLOAD_BYTES, + needed_frame_bytes: smallest, + }); + } if admissions.is_empty() { return Err(ServeError::InvalidConfig("callers")); } From ec9722a2d82f5258e531cac18bf0fcc2753c1289 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Wed, 28 Oct 2026 06:40:18 +0100 Subject: [PATCH 17/69] Long request ids: the Malformed refusal echoes the id; the client refuses locally (long-id-refusal-unreadable) Naming a request by the SHA-256 of its id is a wire meaning v2 does not have: no v2 client could read that reply. begin now seals Refused(Malformed) with the request's own id and drops the connection (FrameBound) only when that sealed refusal cannot fit the frame. ConnectorClient refuses an id past MAX_IDENTITY_BYTES before connecting, with the typed RequestIdTooLong error. Co-Authored-By: Claude Opus 5.5 --- crates/model-connector/src/client.rs | 33 +++++++++++- crates/model-connector/src/serve.rs | 19 ++++--- crates/model-connector/src/service.rs | 74 +++++++++++++++++---------- 3 files changed, 87 insertions(+), 39 deletions(-) diff --git a/crates/model-connector/src/client.rs b/crates/model-connector/src/client.rs index b2997ce..a1cbcb3 100644 --- a/crates/model-connector/src/client.rs +++ b/crates/model-connector/src/client.rs @@ -6,8 +6,8 @@ use zeroize::Zeroizing; use crate::contract::{TransportInvocation, TransportKey, TransportResult}; use crate::envelope::{ - ServiceError, TransportFrameError, decrypt_result, encrypt_invocation, read_transport_frame, - write_transport_frame, + MAX_IDENTITY_BYTES, ServiceError, TransportFrameError, decrypt_result, encrypt_invocation, + read_transport_frame, write_transport_frame, }; #[derive(Clone)] @@ -48,6 +48,9 @@ impl ConnectorClient { &self, invocation: &TransportInvocation, ) -> Result { + if invocation.request_id().len() > MAX_IDENTITY_BYTES { + return Err(ConnectorClientError::RequestIdTooLong); + } let request = rmp_serde::to_vec(&encrypt_invocation(invocation, &self.security)?) .map_err(|_| ConnectorClientError::Encoding)?; let connect_timeout = self @@ -119,6 +122,8 @@ pub enum ConnectorClientError { FrameSize, #[error("connector MessagePack encoding failed")] Encoding, + #[error("request id is longer than the connector identity bound")] + RequestIdTooLong, #[error(transparent)] Transport(#[from] ServiceError), } @@ -174,4 +179,28 @@ mod fixture_tests { ); server.join().unwrap(); } + + #[test] + fn an_over_long_request_id_is_refused_locally_before_any_byte_is_sent() { + let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap(); + listener.set_nonblocking(true).unwrap(); + let client = ConnectorClient::new( + listener.local_addr().unwrap(), + "epiphany-distinct-test-key", + 64 * 1024, + Some(Duration::from_secs(1)), + ) + .unwrap(); + let mut long = invocation(); + long.request.request_id = "r".repeat(MAX_IDENTITY_BYTES + 1); + assert!(matches!( + client.execute(&long), + Err(ConnectorClientError::RequestIdTooLong) + )); + assert_eq!( + listener.accept().unwrap_err().kind(), + std::io::ErrorKind::WouldBlock, + "the client must not even connect" + ); + } } diff --git a/crates/model-connector/src/serve.rs b/crates/model-connector/src/serve.rs index 9e3c9ff..6bac7c1 100644 --- a/crates/model-connector/src/serve.rs +++ b/crates/model-connector/src/serve.rs @@ -521,40 +521,39 @@ mod tests { } #[test] - fn serve_refuses_a_long_request_id_on_the_wire_without_dropping_the_connection() { + fn serve_refuses_a_long_request_id_on_the_wire_echoing_the_id_without_dropping_the_connection() + { let store = TestStore::new(); let backend = CountingBackend::new(true); let (_client, endpoint) = start(backend.clone(), &store); let security = crate::fixtures::security(KEY); + // Read the way ConnectorClient reads a reply: decrypt_result, which + // requires the result to name the request by its own id. let send = |id: &str| { + let invocation = fresh(id); let request = rmp_serde::to_vec( - &crate::envelope::encrypt_invocation(&fresh(id), &security).unwrap(), + &crate::envelope::encrypt_invocation(&invocation, &security).unwrap(), ) .unwrap(); let mut stream = TcpStream::connect(endpoint).unwrap(); write_transport_frame(&mut stream, &request, FRAME).unwrap(); let reply = read_transport_frame(&mut stream, FRAME).unwrap(); let envelope: TransportEnvelope = rmp_serde::from_slice(&reply).unwrap(); - let result: TransportResult = rmp_serde::from_slice( - &crate::envelope::decrypt_message(&envelope, &security).unwrap(), - ) - .unwrap(); - (reply.len(), result) + crate::envelope::decrypt_result(&envelope, &security, &invocation).unwrap() }; - let (_, served) = send(&"i".repeat(crate::envelope::MAX_IDENTITY_BYTES)); + let served = send(&"i".repeat(crate::envelope::MAX_IDENTITY_BYTES)); assert!(matches!( served.disposition, TransportDisposition::Transported { .. } )); assert_eq!(backend.executions.load(Ordering::SeqCst), 1); for length in [257, 258, 5_000, 60_000] { - let (size, result) = send(&"i".repeat(length)); + let result = send(&"i".repeat(length)); assert_eq!( result.disposition, TransportDisposition::Refused(Refusal::Malformed), "{length}" ); - assert!(size <= smallest_frame(), "{length}: {size}"); } assert_eq!(backend.executions.load(Ordering::SeqCst), 1); } diff --git a/crates/model-connector/src/service.rs b/crates/model-connector/src/service.rs index a2a7768..e377c19 100644 --- a/crates/model-connector/src/service.rs +++ b/crates/model-connector/src/service.rs @@ -199,8 +199,9 @@ impl TransportService { /// fit, the same bound `complete` is given: a recorded result that no /// longer fits it (the service reopened at a smaller frame) is answered /// with the typed `result_size` failure, which is recorded in its place. - /// A request id longer than `MAX_IDENTITY_BYTES` is refused as malformed; - /// the refusal names the id by its digest so it fits any legal frame. + /// A request id longer than `MAX_IDENTITY_BYTES` is refused as malformed, + /// the refusal echoing the id like every other. The connection is dropped + /// (`FrameBound`) only when that sealed refusal does not fit `max_frame_bytes`. pub fn begin( &mut self, envelope: &TransportEnvelope, @@ -216,10 +217,15 @@ impl TransportService { let max_concurrent_requests = caller.max_concurrent_requests; let invocation = decrypt_invocation(envelope, &security)?; if envelope.request_id.len() > MAX_IDENTITY_BYTES { - return Ok(TransportAdmission::Reply(encrypt_result( - &unnamed_refusal(&invocation, Refusal::Malformed), + let refusal = encrypt_result( + &TransportResult::refused(&invocation, Refusal::Malformed), &security, - )?)); + )?; + return if fits_frame(&refusal, max_frame_bytes)? { + Ok(TransportAdmission::Reply(refusal)) + } else { + Err(ServiceError::FrameBound) + }; } if envelope.ciphertext.len() > caller.max_payload_bytes { return self.reply_refusal(&invocation, Refusal::Policy, &security); @@ -477,16 +483,6 @@ fn hex(digest: [u8; 32]) -> String { text } -/// A refusal for an invocation whose request id is too long to echo: it names -/// the request by the hex SHA-256 of the id (64 bytes), so the reply fits the -/// smallest legal frame whatever the id's length. -fn unnamed_refusal(invocation: &TransportInvocation, reason: Refusal) -> TransportResult { - TransportResult { - request_id: hex(Sha256::digest(invocation.request_id().as_bytes()).into()), - ..TransportResult::refused(invocation, reason) - } -} - fn replay_store_error(error: impl std::fmt::Display) -> ServiceError { ServiceError::ReplayStore(error.to_string()) } @@ -989,42 +985,66 @@ mod tests { } #[test] - fn a_request_id_past_the_bound_is_refused_malformed_in_a_reply_that_fits_the_smallest_frame() { - let smallest = frame_bytes_for_payload(4096); + fn a_request_id_past_the_bound_is_refused_malformed_echoing_the_id() { for length in [ MAX_IDENTITY_BYTES + 1, MAX_IDENTITY_BYTES + 2, 5_000, 100_000, - 400_000, ] { let store = TestStore::new(); let mut service = open_service(&store, 1); let id = "i".repeat(length); let long = invocation_for(CALLER, &id); - let TransportAdmission::Reply(reply) = service - .begin(&raw_envelope(&long), 1_000, smallest) - .unwrap() + let TransportAdmission::Reply(reply) = + service.begin(&raw_envelope(&long), 1_000, FRAME).unwrap() else { panic!("an over-long id must be refused, not executed ({length})"); }; - assert!( - rmp_serde::to_vec(&reply).unwrap().len() <= smallest, - "{length}" - ); + assert_eq!(reply.request_id, id, "{length}"); let result: TransportResult = rmp_serde::from_slice(&decrypt_message(&reply, &security(KEY)).unwrap()).unwrap(); assert_eq!( result.disposition, TransportDisposition::Refused(Refusal::Malformed) ); - assert_eq!(result.request_id, reply.request_id); - assert_eq!(result.request_id.len(), 64, "the id is named, not echoed"); + assert_eq!(result.request_id, id); + result.validate_against(&long).unwrap(); // Nothing was recorded or held: the caller's one slot is free. let (_claim, _) = claim_for(&mut service, "after-the-long-one"); } } + #[test] + fn an_over_long_id_drops_the_connection_only_when_its_refusal_cannot_fit() { + let id = "i".repeat(5_000); + let long = invocation_for(CALLER, &id); + let sealed = encrypt_result( + &TransportResult::refused(&long, Refusal::Malformed), + &security(KEY), + ) + .unwrap(); + // The sealed size varies with the random nonce and ciphertext bytes + // (a byte of 128 or more takes two), so bracket it: the ciphertext + // plus the id is a floor, twice the ciphertext plus the id and the + // envelope's other fields is a ceiling. + let floor = sealed.ciphertext.len() + id.len(); + let ceiling = 2 * sealed.ciphertext.len() + id.len() + 512; + let store = TestStore::new(); + let mut service = open_service(&store, 1); + assert!(matches!( + service.begin(&raw_envelope(&long), 1_000, ceiling).unwrap(), + TransportAdmission::Reply(_) + )); + assert_eq!( + service + .begin(&raw_envelope(&long), 1_000, floor - 1) + .unwrap_err(), + ServiceError::FrameBound + ); + let (_claim, _) = claim_for(&mut service, "after-the-drop"); + } + #[test] fn a_frame_check_is_inclusive_of_the_serialized_envelope() { let envelope = From 89b56b5300f3b91bd0c77d875cc11ba143df55ee Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Wed, 28 Oct 2026 06:40:24 +0100 Subject: [PATCH 18/69] serve: pin that serve hands begin its own max frame (serve-frame-wiring-unpinned) A record completed under a wide frame and replayed by a serve at a narrower one must come back as the typed stand-in; passing begin any other frame (usize::MAX) delivers the wide record the narrow write cannot carry. Co-Authored-By: Claude Opus 5.5 --- crates/model-connector/src/serve.rs | 39 +++++++++++++++++++++++++++++ 1 file changed, 39 insertions(+) diff --git a/crates/model-connector/src/serve.rs b/crates/model-connector/src/serve.rs index 6bac7c1..e307fb6 100644 --- a/crates/model-connector/src/serve.rs +++ b/crates/model-connector/src/serve.rs @@ -558,6 +558,45 @@ mod tests { assert_eq!(backend.executions.load(Ordering::SeqCst), 1); } + #[test] + fn serve_measures_a_replayed_record_against_its_own_max_frame() { + // A record completed under a wide frame, replayed by a serve at a + // narrower one, must come back as the typed stand-in that fits the + // narrower frame; handing `begin` any other frame delivers the wide + // record, which the narrow write then cannot carry. + let store = TestStore::new(); + let invocation = fresh("request-narrowed"); + let wide = SizedBackend::new(80_000); + let (client, _) = start_with(wide.clone(), &store, vec![caller(CALLER, KEY, 4)]); + let first = client.execute(&invocation).unwrap(); + assert!(!failed_result_size(&first)); + + let narrow = 100_000; + let narrowed = ServeLimits { + max_frame_bytes: narrow, + ..limits() + }; + let listener = TcpListener::bind("127.0.0.1:0").unwrap(); + let endpoint = listener.local_addr().unwrap(); + let path = store.path.clone(); + let backend = SizedBackend::new(80_000); + let served = backend.clone(); + thread::spawn(move || { + serve( + listener, + narrowed, + vec![caller(CALLER, KEY, 4)], + &path, + served, + ) + }); + let client = + ConnectorClient::new(endpoint, KEY, narrow, Some(Duration::from_secs(5))).unwrap(); + let replayed = client.execute(&invocation).unwrap(); + assert!(failed_result_size(&replayed)); + assert_eq!(backend.executions.load(Ordering::SeqCst), 0); + } + #[test] fn serve_refuses_to_start_on_an_unready_backend() { let store = TestStore::new(); From 925d055bb535d9913219be070f071329b2cdb6ed Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Wed, 28 Oct 2026 06:40:29 +0100 Subject: [PATCH 19/69] ServeError::FrameBound: no run of spaces in the message; the text is pinned (frame-bound-display-spaces) Co-Authored-By: Claude Opus 5.5 --- crates/model-connector/src/serve.rs | 15 ++++++++++++++- 1 file changed, 14 insertions(+), 1 deletion(-) diff --git a/crates/model-connector/src/serve.rs b/crates/model-connector/src/serve.rs index e307fb6..a6b8faf 100644 --- a/crates/model-connector/src/serve.rs +++ b/crates/model-connector/src/serve.rs @@ -291,7 +291,7 @@ pub enum ServeError { #[error("invalid serve configuration field {0}")] InvalidConfig(&'static str), #[error( - "max_frame_bytes {max_frame_bytes} cannot carry a {payload_bytes}-byte payload bound: at least {needed_frame_bytes} frame bytes are needed" + "max_frame_bytes {max_frame_bytes} cannot carry a {payload_bytes}-byte payload bound: at least {needed_frame_bytes} frame bytes are needed" )] FrameBound { max_frame_bytes: usize, @@ -597,6 +597,19 @@ mod tests { assert_eq!(backend.executions.load(Ordering::SeqCst), 0); } + #[test] + fn frame_bound_error_states_the_frame_needed() { + assert_eq!( + ServeError::FrameBound { + max_frame_bytes: 1000, + payload_bytes: 2000, + needed_frame_bytes: 4000, + } + .to_string(), + "max_frame_bytes 1000 cannot carry a 2000-byte payload bound: at least 4000 frame bytes are needed" + ); + } + #[test] fn serve_refuses_to_start_on_an_unready_backend() { let store = TestStore::new(); From 953eef26e45ef3e33af123733cb47758c55edb50 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Wed, 28 Oct 2026 06:40:39 +0100 Subject: [PATCH 20/69] README: Verify names the golden fixture test; the long-id refusal echoes the id (readme-stale-parity-test) Co-Authored-By: Claude Opus 5.5 --- README.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index d01fb9e..433f540 100644 --- a/README.md +++ b/README.md @@ -29,7 +29,7 @@ caller payload bound of `n` needs, `payload_bytes_for_frame(frame)` is the large a frame allows, and `MAX_IDENTITY_BYTES` (256) bounds caller ids, request ids and model names. `serve` refuses a frame/payload pair that violates them (`ServeError::FrameBound` states the frame needed); the smallest legal frame is `frame_bytes_for_payload(MIN_PAYLOAD_BYTES)`. A request id past -the bound is refused on the wire as `Refusal::Malformed`, the reply naming it by its SHA-256. +the bound is refused on the wire as `Refusal::Malformed`, the reply echoing the id (the connection is dropped only if that reply cannot fit the frame); `ConnectorClient` refuses such an id locally with `RequestIdTooLong`. The default feature set compiles contract, envelope and client only. Schema ids are still `gamecult.codex.*.v2`; renaming them changes the wire and belongs to a later @@ -42,8 +42,8 @@ cargo test --locked --workspace cargo test --locked --workspace --features model-connector/daemon ``` -`tests/wire_parity_v2.rs` pins the kit byte-for-byte to CodexConnector fd7cec4 in both -directions (it fetches that revision as a dev-dependency). +`crates/model-connector/tests/wire_golden_v2.rs` pins the kit byte-for-byte to the golden v2 +fixtures in `crates/model-connector/tests/fixtures/wire_v2`. ## Provenance and license From 2dc8d070e0981bfa92149ab1c7cb664b47c83bc4 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Wed, 28 Oct 2026 06:43:36 +0100 Subject: [PATCH 21/69] serve wiring test: complete the wide record through the service, since serve holds the replay store (serve-frame-wiring-unpinned) Co-Authored-By: Claude Opus 5.5 --- crates/model-connector/src/serve.rs | 24 ++++++++++++++++++++---- 1 file changed, 20 insertions(+), 4 deletions(-) diff --git a/crates/model-connector/src/serve.rs b/crates/model-connector/src/serve.rs index a6b8faf..4bd29a4 100644 --- a/crates/model-connector/src/serve.rs +++ b/crates/model-connector/src/serve.rs @@ -566,10 +566,26 @@ mod tests { // record, which the narrow write then cannot carry. let store = TestStore::new(); let invocation = fresh("request-narrowed"); - let wide = SizedBackend::new(80_000); - let (client, _) = start_with(wide.clone(), &store, vec![caller(CALLER, KEY, 4)]); - let first = client.execute(&invocation).unwrap(); - assert!(!failed_result_size(&first)); + // The wide completion goes straight through the service: serve holds + // the replay store for as long as it runs, so a second serve cannot + // open it while the first lives. + let mut service = + TransportService::open(&store.path, vec![caller(CALLER, KEY, 4)], 300_000).unwrap(); + let envelope = + crate::envelope::encrypt_invocation(&invocation, &crate::fixtures::security(KEY)) + .unwrap(); + let TransportAdmission::Execute(claim) = service.begin(&envelope, now_ms(), FRAME).unwrap() + else { + panic!("a fresh request must execute"); + }; + service + .complete( + *claim, + sized_result(&invocation, 80_000, "fixture-sse"), + FRAME, + ) + .unwrap(); + drop(service); let narrow = 100_000; let narrowed = ServeLimits { From ba1f7eb10b3a254fac6d3c8d6fe50abd92304ba6 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Wed, 28 Oct 2026 06:45:57 +0100 Subject: [PATCH 22/69] serve wiring test: frames sized to the caller's payload bound, so serve starts (serve-frame-wiring-unpinned) Co-Authored-By: Claude Opus 5.5 --- crates/model-connector/src/serve.rs | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/crates/model-connector/src/serve.rs b/crates/model-connector/src/serve.rs index 4bd29a4..8eda706 100644 --- a/crates/model-connector/src/serve.rs +++ b/crates/model-connector/src/serve.rs @@ -574,20 +574,21 @@ mod tests { let envelope = crate::envelope::encrypt_invocation(&invocation, &crate::fixtures::security(KEY)) .unwrap(); - let TransportAdmission::Execute(claim) = service.begin(&envelope, now_ms(), FRAME).unwrap() + let TransportAdmission::Execute(claim) = + service.begin(&envelope, now_ms(), 1 << 20).unwrap() else { panic!("a fresh request must execute"); }; service .complete( *claim, - sized_result(&invocation, 80_000, "fixture-sse"), - FRAME, + sized_result(&invocation, 150_000, "fixture-sse"), + 1 << 20, ) .unwrap(); drop(service); - let narrow = 100_000; + let narrow = 140_000; let narrowed = ServeLimits { max_frame_bytes: narrow, ..limits() @@ -595,7 +596,7 @@ mod tests { let listener = TcpListener::bind("127.0.0.1:0").unwrap(); let endpoint = listener.local_addr().unwrap(); let path = store.path.clone(); - let backend = SizedBackend::new(80_000); + let backend = SizedBackend::new(150_000); let served = backend.clone(); thread::spawn(move || { serve( From ec0519d9ff0674fd63642725b12188b5ac8e7a1e Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Wed, 28 Oct 2026 07:12:42 +0100 Subject: [PATCH 23/69] connector-kit-lift: the provider-neutral connector kit, lifted from CodexConnector fd7cec4 (squash of eureka-body/connector-kit-lift 1cf879f..ba1f7eb) The v2 contract, encrypted envelope and framing, client, caller admission and replay, and the serve loop move into the model-connector crate behind one ProviderBackend trait, relicensed AGPL-3.0-only by the sole copyright holder (ruling connector-kit-relicense-agpl). Same schema ids and wire bytes as v2, pinned by golden fixtures generated from fd7cec4. The Codex backend is not ported (ruling codex-connector-park). Serve owns the frame bounds: it measures the sealed envelope, refuses configurations that cannot deliver their payload bound, answers over-bound payloads and over-long ids with v2 refusals, and never records or replays an undeliverable result. Spec eureka-body:cut_spec:cut-connector-kit-lift.r1; reports h1-h5; Soul s5 holds (verdict eureka-body:verdict:cut-connector-kit-lift.s5). Co-Authored-By: Claude Opus 5.5 --- .gitignore | 1 + Cargo.lock | 838 +++++++++++ Cargo.toml | 9 + README.md | 59 +- crates/model-connector/Cargo.toml | 23 + crates/model-connector/src/client.rs | 206 +++ crates/model-connector/src/contract.rs | 641 +++++++++ crates/model-connector/src/envelope.rs | 514 +++++++ crates/model-connector/src/fixtures.rs | 128 ++ crates/model-connector/src/lib.rs | 20 + crates/model-connector/src/serve.rs | 1157 +++++++++++++++ crates/model-connector/src/service.rs | 1250 +++++++++++++++++ .../tests/fixtures/wire_v2/.gitattributes | 2 + .../tests/fixtures/wire_v2/README.md | 21 + .../fixtures/wire_v2/canonical_invocation.bin | Bin 0 -> 413 bytes .../wire_v2/canonical_provider_request.bin | Bin 0 -> 267 bytes .../fixtures/wire_v2/envelope_invocation.mp | 6 + .../wire_v2/envelope_result_failed.mp | 7 + ...envelope_result_refused_replay_conflict.mp | Bin 0 -> 252 bytes .../wire_v2/envelope_result_transported.mp | Bin 0 -> 609 bytes .../tests/fixtures/wire_v2/generator.rs | 171 +++ .../tests/fixtures/wire_v2/invocation_auto.mp | Bin 0 -> 408 bytes .../fixtures/wire_v2/invocation_required.mp | Bin 0 -> 413 bytes .../wire_v2/provider_request_sha256.bin | Bin 0 -> 32 bytes .../tests/fixtures/wire_v2/result_failed.mp | Bin 0 -> 307 bytes .../wire_v2/result_refused_capacity.mp | 1 + .../wire_v2/result_refused_expired.mp | 1 + .../result_refused_identity_substitution.mp | 1 + .../wire_v2/result_refused_in_flight.mp | 1 + .../wire_v2/result_refused_indeterminate.mp | 1 + .../wire_v2/result_refused_malformed.mp | 1 + .../fixtures/wire_v2/result_refused_policy.mp | 1 + ...lt_refused_provider_digest_substitution.mp | 1 + .../wire_v2/result_refused_replay_conflict.mp | 1 + .../fixtures/wire_v2/result_transported.mp | Bin 0 -> 327 bytes .../model-connector/tests/wire_golden_v2.rs | 301 ++++ 36 files changed, 5348 insertions(+), 15 deletions(-) create mode 100644 .gitignore create mode 100644 Cargo.lock create mode 100644 Cargo.toml create mode 100644 crates/model-connector/Cargo.toml create mode 100644 crates/model-connector/src/client.rs create mode 100644 crates/model-connector/src/contract.rs create mode 100644 crates/model-connector/src/envelope.rs create mode 100644 crates/model-connector/src/fixtures.rs create mode 100644 crates/model-connector/src/lib.rs create mode 100644 crates/model-connector/src/serve.rs create mode 100644 crates/model-connector/src/service.rs create mode 100644 crates/model-connector/tests/fixtures/wire_v2/.gitattributes create mode 100644 crates/model-connector/tests/fixtures/wire_v2/README.md create mode 100644 crates/model-connector/tests/fixtures/wire_v2/canonical_invocation.bin create mode 100644 crates/model-connector/tests/fixtures/wire_v2/canonical_provider_request.bin create mode 100644 crates/model-connector/tests/fixtures/wire_v2/envelope_invocation.mp create mode 100644 crates/model-connector/tests/fixtures/wire_v2/envelope_result_failed.mp create mode 100644 crates/model-connector/tests/fixtures/wire_v2/envelope_result_refused_replay_conflict.mp create mode 100644 crates/model-connector/tests/fixtures/wire_v2/envelope_result_transported.mp create mode 100644 crates/model-connector/tests/fixtures/wire_v2/generator.rs create mode 100644 crates/model-connector/tests/fixtures/wire_v2/invocation_auto.mp create mode 100644 crates/model-connector/tests/fixtures/wire_v2/invocation_required.mp create mode 100644 crates/model-connector/tests/fixtures/wire_v2/provider_request_sha256.bin create mode 100644 crates/model-connector/tests/fixtures/wire_v2/result_failed.mp create mode 100644 crates/model-connector/tests/fixtures/wire_v2/result_refused_capacity.mp create mode 100644 crates/model-connector/tests/fixtures/wire_v2/result_refused_expired.mp create mode 100644 crates/model-connector/tests/fixtures/wire_v2/result_refused_identity_substitution.mp create mode 100644 crates/model-connector/tests/fixtures/wire_v2/result_refused_in_flight.mp create mode 100644 crates/model-connector/tests/fixtures/wire_v2/result_refused_indeterminate.mp create mode 100644 crates/model-connector/tests/fixtures/wire_v2/result_refused_malformed.mp create mode 100644 crates/model-connector/tests/fixtures/wire_v2/result_refused_policy.mp create mode 100644 crates/model-connector/tests/fixtures/wire_v2/result_refused_provider_digest_substitution.mp create mode 100644 crates/model-connector/tests/fixtures/wire_v2/result_refused_replay_conflict.mp create mode 100644 crates/model-connector/tests/fixtures/wire_v2/result_transported.mp create mode 100644 crates/model-connector/tests/wire_golden_v2.rs diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..ea8c4bf --- /dev/null +++ b/.gitignore @@ -0,0 +1 @@ +/target diff --git a/Cargo.lock b/Cargo.lock new file mode 100644 index 0000000..01f3693 --- /dev/null +++ b/Cargo.lock @@ -0,0 +1,838 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "aead" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d122413f284cf2d62fb1b7db97e02edb8cda96d769b16e443a4f6195e35662b0" +dependencies = [ + "crypto-common", + "generic-array", +] + +[[package]] +name = "aes" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b169f7a6d4742236a0a00c541b845991d0ac43e546831af1249753ab4c3aa3a0" +dependencies = [ + "cfg-if", + "cipher", + "cpufeatures", +] + +[[package]] +name = "aes-gcm" +version = "0.10.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "831010a0f742e1209b3bcea8fab6a8e149051ba6099432c8cb2cc117dec3ead1" +dependencies = [ + "aead", + "aes", + "cipher", + "ctr", + "ghash", + "subtle", +] + +[[package]] +name = "android_system_properties" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae221649c9976a6f6c56ae1facf410f3ddb33cc661c4b7b61020a912d4237fbc" +dependencies = [ + "libc", +] + +[[package]] +name = "anyhow" +version = "1.0.104" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "330a5ed07fa54e4702c9d6c4174f74427fc0ef6e214bbd677ae50a5099946470" + +[[package]] +name = "autocfg" +version = "1.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" + +[[package]] +name = "block-buffer" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" +dependencies = [ + "generic-array", +] + +[[package]] +name = "bumpalo" +version = "3.20.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649" + +[[package]] +name = "cc" +version = "1.4.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ad534f4357a5264cce5019c989cf66a4f0dc4e0d1b1d15f8aacec0ff7360273" +dependencies = [ + "find-msvc-tools", + "shlex", +] + +[[package]] +name = "cfg-if" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" + +[[package]] +name = "chrono" +version = "0.4.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1aa79e62e7697b8e29b513a68abacf485adcd1fe8284a4316c5ae868e6633327" +dependencies = [ + "iana-time-zone", + "js-sys", + "num-traits", + "serde", + "wasm-bindgen", + "windows-link", +] + +[[package]] +name = "cipher" +version = "0.4.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773f3b9af64447d2ce9850330c473515014aa235e6a783b02db81ff39e4a3dad" +dependencies = [ + "crypto-common", + "inout", +] + +[[package]] +name = "core-foundation-sys" +version = "0.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" + +[[package]] +name = "cpufeatures" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" +dependencies = [ + "libc", +] + +[[package]] +name = "crypto-common" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" +dependencies = [ + "generic-array", + "rand_core", + "typenum", +] + +[[package]] +name = "ctr" +version = "0.9.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0369ee1ad671834580515889b80f2ea915f23b8be8d0daa4bbaf2ac5c7590835" +dependencies = [ + "cipher", +] + +[[package]] +name = "cultcache-rs" +version = "0.1.0" +source = "git+https://github.com/GameCult/CultLib.git?rev=e171eca32329baa928f4a1d810401a8b4c857029#e171eca32329baa928f4a1d810401a8b4c857029" +dependencies = [ + "anyhow", + "chrono", + "cultcache-rs-derive", + "fs2", + "redb", + "rmp-serde", + "serde", + "serde_bytes", + "serde_json", + "uuid", + "windows-sys", +] + +[[package]] +name = "cultcache-rs-derive" +version = "0.1.0" +source = "git+https://github.com/GameCult/CultLib.git?rev=e171eca32329baa928f4a1d810401a8b4c857029#e171eca32329baa928f4a1d810401a8b4c857029" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "digest" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" +dependencies = [ + "block-buffer", + "crypto-common", +] + +[[package]] +name = "find-msvc-tools" +version = "0.1.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d45db016d36b838f563236e9193d0ee6ce38f3f68b6c94e914b4929c96bbb890" + +[[package]] +name = "fs2" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9564fc758e15025b46aa6643b1b77d047d1a56a1aea6e01002ac0c7026876213" +dependencies = [ + "libc", + "winapi", +] + +[[package]] +name = "futures-core" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92d699e522242e69e3003b94ecc1f960f3a5e015aa7c5d7486e65ad01dd94f5e" + +[[package]] +name = "futures-task" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cd417de3d1d015fc3bfd2b1ea46dfc7bab72ef86f1cc7cc9c78e728b34a6d1fd" + +[[package]] +name = "futures-util" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d50a92467f8ba5dd6e3ee5d4bd04d73ab2e4e1c44474a0674821dfce14b79bc" +dependencies = [ + "futures-core", + "futures-task", + "pin-project-lite", + "slab", +] + +[[package]] +name = "generic-array" +version = "0.14.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" +dependencies = [ + "typenum", + "version_check", +] + +[[package]] +name = "getrandom" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" +dependencies = [ + "cfg-if", + "libc", + "wasi", +] + +[[package]] +name = "getrandom" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099" +dependencies = [ + "cfg-if", + "libc", + "r-efi", +] + +[[package]] +name = "ghash" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0d8a4362ccb29cb0b265253fb0a2728f592895ee6854fd9bc13f2ffda266ff1" +dependencies = [ + "opaque-debug", + "polyval", +] + +[[package]] +name = "iana-time-zone" +version = "0.1.65" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e31bc9ad994ba00e440a8aa5c9ef0ec67d5cb5e5cb0cc7f8b744a35b389cc470" +dependencies = [ + "android_system_properties", + "core-foundation-sys", + "iana-time-zone-haiku", + "js-sys", + "log", + "wasm-bindgen", + "windows-core", +] + +[[package]] +name = "iana-time-zone-haiku" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f31827a206f56af32e590ba56d5d2d085f558508192593743f16b2306495269f" +dependencies = [ + "cc", +] + +[[package]] +name = "inout" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "879f10e63c20629ecabbb64a8010319738c66a5cd0c29b02d63d272b03751d01" +dependencies = [ + "generic-array", +] + +[[package]] +name = "itoa" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" + +[[package]] +name = "js-sys" +version = "0.3.104" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0e0c1080212aad755ea003d18543e8768dd432c48819efd73a7bf1e39b7a5a3a" +dependencies = [ + "cfg-if", + "futures-util", + "wasm-bindgen", +] + +[[package]] +name = "libc" +version = "0.2.189" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2" + +[[package]] +name = "log" +version = "0.4.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f9f8bd3e56ce4dfc153cf470fffbfa98c7620958b312ca5c3a4b8d5181fd13c6" + +[[package]] +name = "memchr" +version = "2.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" + +[[package]] +name = "model-connector" +version = "0.1.0" +dependencies = [ + "aes-gcm", + "cultcache-rs", + "rand_core", + "rmp-serde", + "serde", + "serde_json", + "sha2", + "thiserror", + "zeroize", +] + +[[package]] +name = "num-traits" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +dependencies = [ + "autocfg", +] + +[[package]] +name = "once_cell" +version = "1.21.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" + +[[package]] +name = "opaque-debug" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c08d65885ee38876c4f86fa503fb49d7b507c2b62552df7c70b2fce627e06381" + +[[package]] +name = "pin-project-lite" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" + +[[package]] +name = "polyval" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d1fe60d06143b2430aa532c94cfe9e29783047f06c0d7fd359a9a51b729fa25" +dependencies = [ + "cfg-if", + "cpufeatures", + "opaque-debug", + "universal-hash", +] + +[[package]] +name = "proc-macro2" +version = "1.0.107" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quote" +version = "1.0.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "r-efi" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" + +[[package]] +name = "rand_core" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" +dependencies = [ + "getrandom 0.2.17", +] + +[[package]] +name = "redb" +version = "4.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "de6c3b63e007e90ce536ec2ae4690826136a20ec8dbbbb400daef1bb999d2e36" +dependencies = [ + "libc", +] + +[[package]] +name = "rmp" +version = "0.8.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ba8be72d372b2c9b35542551678538b562e7cf86c3315773cae48dfbfe7790c" +dependencies = [ + "num-traits", +] + +[[package]] +name = "rmp-serde" +version = "1.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72f81bee8c8ef9b577d1681a70ebbc962c232461e397b22c208c43c04b67a155" +dependencies = [ + "rmp", + "serde", +] + +[[package]] +name = "rustversion" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f" + +[[package]] +name = "serde" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde_bytes" +version = "0.11.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a5d440709e79d88e51ac01c4b72fc6cb7314017bb7da9eeff678aa94c10e3ea8" +dependencies = [ + "serde", + "serde_core", +] + +[[package]] +name = "serde_core" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.4", +] + +[[package]] +name = "serde_json" +version = "1.0.151" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c841b55ecdae098c80dcae9cf767f6f8a0c2cdb3416bbef72181df4d0fe73f14" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "sha2" +version = "0.10.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" +dependencies = [ + "cfg-if", + "cpufeatures", + "digest", +] + +[[package]] +name = "shlex" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba" + +[[package]] +name = "slab" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" + +[[package]] +name = "subtle" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" + +[[package]] +name = "syn" +version = "2.0.119" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "872831b642d1a07999a962a351ed35b955ea2cfc8f3862091e2a240a84f17297" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "syn" +version = "3.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6275cddf4610d1775e6d1fe9469b2e77d0f39fd98fb7450901b821e0c53649f" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "thiserror" +version = "2.0.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec86235f5fcc2a73650310756d2ac5b138a5780bbbdfae3eeccec992c435ba4f" +dependencies = [ + "thiserror-impl", +] + +[[package]] +name = "thiserror-impl" +version = "2.0.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bc04cd3e1236dd4a98afca4569f2deb3f120e5422a4023be2cb683f8486292af" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.4", +] + +[[package]] +name = "typenum" +version = "1.20.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20" + +[[package]] +name = "unicode-ident" +version = "1.0.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" + +[[package]] +name = "universal-hash" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc1de2c688dc15305988b563c3854064043356019f97a4b46276fe734c4f07ea" +dependencies = [ + "crypto-common", + "subtle", +] + +[[package]] +name = "uuid" +version = "1.25.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f053576934f05a761a402421fbbe3d425d9366f75f978806a037b3ca481abecc" +dependencies = [ + "getrandom 0.4.3", + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "version_check" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" + +[[package]] +name = "wasi" +version = "0.11.1+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" + +[[package]] +name = "wasm-bindgen" +version = "0.2.127" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1b70935747edd64d89de3efa29d73789b806c15798f8e7dca4d8ac356b50ce70" +dependencies = [ + "cfg-if", + "once_cell", + "rustversion", + "wasm-bindgen-macro", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.127" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77775f8f3f7217702089053b94958f8f54061a3f663417df76e19cbdcca29bc1" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.127" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e11d33f857dc2fb11b8bc75aee111aa9cbeb12cd9f25efd3d4c2a3dd4e235284" +dependencies = [ + "bumpalo", + "proc-macro2", + "quote", + "syn 2.0.119", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.127" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7ef64dbcc55df09c7e5a46182d181c2cfa3e925f3da937ea764728b4bbb9dcbf" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "winapi" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419" +dependencies = [ + "winapi-i686-pc-windows-gnu", + "winapi-x86_64-pc-windows-gnu", +] + +[[package]] +name = "winapi-i686-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" + +[[package]] +name = "winapi-x86_64-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" + +[[package]] +name = "windows-core" +version = "0.62.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb" +dependencies = [ + "windows-implement", + "windows-interface", + "windows-link", + "windows-result", + "windows-strings", +] + +[[package]] +name = "windows-implement" +version = "0.60.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "windows-interface" +version = "0.59.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-result" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-strings" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-sys" +version = "0.59.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e38bc4d79ed67fd075bcc251a1c39b32a1776bbe92e5bef1f0bf1f8c531853b" +dependencies = [ + "windows-targets", +] + +[[package]] +name = "windows-targets" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" +dependencies = [ + "windows_aarch64_gnullvm", + "windows_aarch64_msvc", + "windows_i686_gnu", + "windows_i686_gnullvm", + "windows_i686_msvc", + "windows_x86_64_gnu", + "windows_x86_64_gnullvm", + "windows_x86_64_msvc", +] + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" + +[[package]] +name = "windows_i686_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" + +[[package]] +name = "windows_i686_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" + +[[package]] +name = "zeroize" +version = "1.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e13c156562582aa81c60cb29407084cdb54c4164760106ab78e6c5b0858cf64e" + +[[package]] +name = "zmij" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b" diff --git a/Cargo.toml b/Cargo.toml new file mode 100644 index 0000000..10e8225 --- /dev/null +++ b/Cargo.toml @@ -0,0 +1,9 @@ +[workspace] +members = ["crates/*"] +resolver = "3" + +[workspace.package] +version = "0.1.0" +edition = "2024" +license = "AGPL-3.0-only" +repository = "https://eureka.gamecult.org/forge/GameCult/ModelConnector" diff --git a/README.md b/README.md index 65a124e..433f540 100644 --- a/README.md +++ b/README.md @@ -1,26 +1,55 @@ # ModelConnector -Status: planned. Nothing is implemented in this repository yet. +The connector kit for GameCult minds: one typed, encrypted, replay-safe contract +between a caller that needs a model turn and a daemon that holds the provider +credential, so no project wires its own provider calls. -## What it will hold +Status: the `model-connector` crate is live. No connector binary exists yet +(the Claude connector is first, then an OpenAI-compatible one). -The connector kit for GameCult minds: +## Entry points -- the connector contract, a Rust crate defining the typed boundary every model - turn of a GameCult mind goes through; -- connector daemons that implement the contract against a model provider, - Claude first, then an OpenAI-compatible connector. +Crate `crates/model-connector`: -The problem it solves: a mind should reach any model through one inspectable, -typed contract, rather than each project wiring its own provider calls. +- `src/contract.rs` - the v2 wire contract: `ProviderRequest`, `TransportInvocation`, + `TransportResult`, `TransportReceipt`, `Refusal`, and the canonical byte and digest + functions that bind a result to its request. +- `src/envelope.rs` - the AES-256-GCM `TransportEnvelope`, its identity-binding AAD, and + the length-prefixed framing. +- `src/client.rs` - `ConnectorClient`, the one-request caller side. +- `src/service.rs` (feature `daemon`) - `TransportService`: caller admission, durable + replay (a retried request is answered byte-identically and the provider runs once), + in-flight and capacity refusals. +- `src/serve.rs` (feature `daemon`) - `serve()`, the loopback accept loop, over the + `ProviderBackend` trait. A connector binary implements `ProviderBackend` for its + provider and calls `serve`; the kit names no provider. -## Where it comes from +Frame arithmetic is public in `src/envelope.rs`: `frame_bytes_for_payload(n)` is the frame a +caller payload bound of `n` needs, `payload_bytes_for_frame(frame)` is the largest payload bound +a frame allows, and `MAX_IDENTITY_BYTES` (256) bounds caller ids, request ids and model names. +`serve` refuses a frame/payload pair that violates them (`ServeError::FrameBound` states the frame +needed); the smallest legal frame is `frame_bytes_for_payload(MIN_PAYLOAD_BYTES)`. A request id past +the bound is refused on the wire as `Refusal::Malformed`, the reply echoing the id (the connection is dropped only if that reply cannot fit the frame); `ConnectorClient` refuses such an id locally with `RequestIdTooLong`. -[CodexConnector](https://github.com/GameCult/CodexConnector) -(GameCult/CodexConnector) is the existing connector this kit is lifted from. -The lift is the first piece of work here; until it lands there are no entry -points to run. +The default feature set compiles contract, envelope and client only. Schema ids are +still `gamecult.codex.*.v2`; renaming them changes the wire and belongs to a later +contract version. -## License +## Verify + +``` +cargo test --locked --workspace +cargo test --locked --workspace --features model-connector/daemon +``` + +`crates/model-connector/tests/wire_golden_v2.rs` pins the kit byte-for-byte to the golden v2 +fixtures in `crates/model-connector/tests/fixtures/wire_v2`. + +## Provenance and license AGPL-3.0-only. See `LICENSE`. + +The contract, envelope, client, replay service and serve loop were lifted from +[CodexConnector](https://github.com/GameCult/CodexConnector) at fd7cec4, formerly +PolyForm Noncommercial. Its sole copyright holder relicensed the lifted material as +AGPL-3.0-only for this repository. diff --git a/crates/model-connector/Cargo.toml b/crates/model-connector/Cargo.toml new file mode 100644 index 0000000..49f83db --- /dev/null +++ b/crates/model-connector/Cargo.toml @@ -0,0 +1,23 @@ +[package] +name = "model-connector" +description = "Provider-neutral connector contract, envelope, client, replay service and serve loop for GameCult minds" +version.workspace = true +edition.workspace = true +license.workspace = true +repository.workspace = true +publish = false + +[features] +default = [] +daemon = ["dep:cultcache-rs"] + +[dependencies] +aes-gcm = "0.10" +cultcache-rs = { git = "https://github.com/GameCult/CultLib.git", rev = "e171eca32329baa928f4a1d810401a8b4c857029", package = "cultcache-rs", optional = true } +rand_core = { version = "0.6", features = ["getrandom"] } +rmp-serde = "1" +serde = { version = "1", features = ["derive"] } +serde_json = "1" +sha2 = "0.10" +thiserror = "2" +zeroize = "1" diff --git a/crates/model-connector/src/client.rs b/crates/model-connector/src/client.rs new file mode 100644 index 0000000..a1cbcb3 --- /dev/null +++ b/crates/model-connector/src/client.rs @@ -0,0 +1,206 @@ +use std::net::{SocketAddr, TcpStream}; +use std::time::Duration; + +use thiserror::Error; +use zeroize::Zeroizing; + +use crate::contract::{TransportInvocation, TransportKey, TransportResult}; +use crate::envelope::{ + MAX_IDENTITY_BYTES, ServiceError, TransportFrameError, decrypt_result, encrypt_invocation, + read_transport_frame, write_transport_frame, +}; + +#[derive(Clone)] +pub struct ConnectorClient { + endpoint: SocketAddr, + security: TransportKey, + max_frame_bytes: usize, + response_timeout: Option, +} + +impl ConnectorClient { + pub fn new( + endpoint: SocketAddr, + connection_key: impl Into, + max_frame_bytes: usize, + response_timeout: Option, + ) -> Result { + if endpoint.port() == 0 + || endpoint.ip().is_unspecified() + || endpoint.ip().is_multicast() + || endpoint.ip() == std::net::Ipv4Addr::BROADCAST + || !(4096..=u32::MAX as usize).contains(&max_frame_bytes) + || response_timeout.is_some_and(|timeout| timeout.is_zero()) + { + return Err(ConnectorClientError::InvalidConfig); + } + let connection_key = Zeroizing::new(connection_key.into()); + let security = TransportKey::from_connection_secret(connection_key.as_str())?; + Ok(Self { + endpoint, + security, + max_frame_bytes, + response_timeout, + }) + } + + pub fn execute( + &self, + invocation: &TransportInvocation, + ) -> Result { + if invocation.request_id().len() > MAX_IDENTITY_BYTES { + return Err(ConnectorClientError::RequestIdTooLong); + } + let request = rmp_serde::to_vec(&encrypt_invocation(invocation, &self.security)?) + .map_err(|_| ConnectorClientError::Encoding)?; + let connect_timeout = self + .response_timeout + .unwrap_or(Duration::from_secs(10)) + .min(Duration::from_secs(10)); + let mut stream = TcpStream::connect_timeout(&self.endpoint, connect_timeout) + .map_err(ConnectorClientError::Connection)?; + stream + .set_read_timeout(self.response_timeout) + .and_then(|()| stream.set_write_timeout(self.response_timeout)) + .map_err(ConnectorClientError::Connection)?; + write_transport_frame(&mut stream, &request, self.max_frame_bytes) + .map_err(client_frame_error)?; + let response = + read_transport_frame(&mut stream, self.max_frame_bytes).map_err(client_frame_error)?; + let envelope = + rmp_serde::from_slice(&response).map_err(|_| ConnectorClientError::Encoding)?; + decrypt_result(&envelope, &self.security, invocation).map_err(Into::into) + } +} + +#[cfg(test)] +mod client_endpoint_tests { + use super::*; + + #[test] + fn client_admits_explicit_unicast_and_refuses_non_destinations() { + for endpoint in ["127.0.0.1:4103", "172.19.0.1:4103", "[::1]:4103"] { + assert!( + ConnectorClient::new( + endpoint.parse().unwrap(), + "bounded-client-endpoint-key", + 64 * 1024, + Some(Duration::from_secs(1)), + ) + .is_ok(), + "{endpoint} should be an admissible explicit destination" + ); + } + for endpoint in [ + "0.0.0.0:4103", + "255.255.255.255:4103", + "224.0.0.1:4103", + "[::]:4103", + "[ff02::1]:4103", + "127.0.0.1:0", + ] { + assert!(matches!( + ConnectorClient::new( + endpoint.parse().unwrap(), + "bounded-client-endpoint-key", + 64 * 1024, + Some(Duration::from_secs(1)), + ), + Err(ConnectorClientError::InvalidConfig) + )); + } + } +} + +#[derive(Debug, Error)] +pub enum ConnectorClientError { + #[error("invalid connector client configuration")] + InvalidConfig, + #[error("connector client connection failed")] + Connection(#[source] std::io::Error), + #[error("connector frame exceeded its bound")] + FrameSize, + #[error("connector MessagePack encoding failed")] + Encoding, + #[error("request id is longer than the connector identity bound")] + RequestIdTooLong, + #[error(transparent)] + Transport(#[from] ServiceError), +} + +fn client_frame_error(error: TransportFrameError) -> ConnectorClientError { + match error { + TransportFrameError::Connection(error) => ConnectorClientError::Connection(error), + TransportFrameError::Size => ConnectorClientError::FrameSize, + } +} + +#[cfg(test)] +mod fixture_tests { + use super::*; + use crate::fixtures::*; + use crate::{contract::*, envelope::*}; + + #[test] + fn shared_client_transports_one_exact_encrypted_request_and_result() { + let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap(); + let endpoint = listener.local_addr().unwrap(); + let invocation = invocation(); + let expected = invocation.clone(); + let security = security("epiphany-distinct-test-key"); + let server = std::thread::spawn(move || { + let (mut stream, _) = listener.accept().unwrap(); + let request = read_transport_frame(&mut stream, 64 * 1024).unwrap(); + let envelope: TransportEnvelope = rmp_serde::from_slice(&request).unwrap(); + assert_eq!(decrypt_invocation(&envelope, &security).unwrap(), expected); + let response = encrypt_result( + &TransportResult::refused(&expected, Refusal::Policy), + &security, + ) + .unwrap(); + write_transport_frame( + &mut stream, + &rmp_serde::to_vec(&response).unwrap(), + 64 * 1024, + ) + .unwrap(); + }); + + let client = ConnectorClient::new( + endpoint, + "epiphany-distinct-test-key", + 64 * 1024, + Some(Duration::from_secs(1)), + ) + .unwrap(); + assert_eq!( + client.execute(&invocation).unwrap().disposition, + TransportDisposition::Refused(Refusal::Policy) + ); + server.join().unwrap(); + } + + #[test] + fn an_over_long_request_id_is_refused_locally_before_any_byte_is_sent() { + let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap(); + listener.set_nonblocking(true).unwrap(); + let client = ConnectorClient::new( + listener.local_addr().unwrap(), + "epiphany-distinct-test-key", + 64 * 1024, + Some(Duration::from_secs(1)), + ) + .unwrap(); + let mut long = invocation(); + long.request.request_id = "r".repeat(MAX_IDENTITY_BYTES + 1); + assert!(matches!( + client.execute(&long), + Err(ConnectorClientError::RequestIdTooLong) + )); + assert_eq!( + listener.accept().unwrap_err().kind(), + std::io::ErrorKind::WouldBlock, + "the client must not even connect" + ); + } +} diff --git a/crates/model-connector/src/contract.rs b/crates/model-connector/src/contract.rs new file mode 100644 index 0000000..7ae5be5 --- /dev/null +++ b/crates/model-connector/src/contract.rs @@ -0,0 +1,641 @@ +use std::collections::HashSet; + +use serde::{Deserialize, Serialize}; +use sha2::{Digest, Sha256}; +use thiserror::Error; +use zeroize::Zeroize; + +use crate::envelope::ServiceError; + +pub const PROVIDER_REQUEST_SCHEMA_ID: &str = "gamecult.codex.provider_request.v2"; +pub const INVOCATION_SCHEMA_ID: &str = "gamecult.codex.transport_invocation.v2"; +pub const RESULT_SCHEMA_ID: &str = "gamecult.codex.transport_result.v2"; +pub const RECEIPT_SCHEMA_ID: &str = "gamecult.codex.transport_receipt.v2"; +pub const ENVELOPE_SCHEMA_ID: &str = "gamecult.codex.transport_envelope.v2"; + +#[derive(Clone, PartialEq, Eq)] +pub struct TransportKey(pub(crate) [u8; 32]); + +impl TransportKey { + pub fn from_connection_secret(secret: &str) -> Result { + if secret.trim().is_empty() || secret.trim() != secret { + return Err(ServiceError::InvalidAdmission); + } + Ok(Self(Sha256::digest(secret.as_bytes()).into())) + } +} + +impl Drop for TransportKey { + fn drop(&mut self) { + self.0.zeroize(); + } +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct ProviderRequest { + pub schema_id: String, + pub request_id: String, + pub conversation_id: String, + pub model: String, + pub instructions: String, + pub input: Vec, + pub reasoning_effort: Option, + pub reasoning_summary: Option, + pub service_tier: Option, + pub output_format_name: Option, + pub previous_response_id: Option, + pub tools: Vec, + pub tool_choice: ToolChoice, + pub parallel_tool_calls: bool, + pub output_schema_json: Option, + pub max_output_tokens: Option, + pub prompt_cache_key: Option, +} + +impl ProviderRequest { + pub fn new( + request_id: impl Into, + conversation_id: impl Into, + model: impl Into, + instructions: impl Into, + ) -> Self { + Self { + schema_id: PROVIDER_REQUEST_SCHEMA_ID.to_string(), + request_id: request_id.into(), + conversation_id: conversation_id.into(), + model: model.into(), + instructions: instructions.into(), + input: Vec::new(), + reasoning_effort: None, + reasoning_summary: None, + service_tier: None, + output_format_name: None, + previous_response_id: None, + tools: Vec::new(), + tool_choice: ToolChoice::Auto, + parallel_tool_calls: false, + output_schema_json: None, + max_output_tokens: None, + prompt_cache_key: None, + } + } + + pub fn validate(&self) -> Result<(), ContractError> { + if self.schema_id != PROVIDER_REQUEST_SCHEMA_ID { + return Err(ContractError::Schema); + } + require_id(&self.request_id, "request_id")?; + require_id(&self.conversation_id, "conversation_id")?; + require_id(&self.model, "model")?; + require_content(&self.instructions, "instructions")?; + if matches!(self.max_output_tokens, Some(0)) { + return Err(ContractError::Invalid("max_output_tokens")); + } + if self + .prompt_cache_key + .as_deref() + .is_some_and(|value| value.trim().is_empty()) + { + return Err(ContractError::Invalid("prompt_cache_key")); + } + for (value, field) in [ + (self.reasoning_effort.as_deref(), "reasoning_effort"), + (self.reasoning_summary.as_deref(), "reasoning_summary"), + (self.service_tier.as_deref(), "service_tier"), + (self.output_format_name.as_deref(), "output_format_name"), + (self.previous_response_id.as_deref(), "previous_response_id"), + ] { + if let Some(value) = value { + require_id(value, field)?; + } + } + if let Some(schema) = &self.output_schema_json { + require_content(schema, "output_schema_json")?; + require_json_object(schema, "output_schema_json")?; + require_provider_name( + self.output_format_name + .as_deref() + .ok_or(ContractError::Invalid("output_format_name"))?, + "output_format_name", + )?; + } else if self.output_format_name.is_some() { + return Err(ContractError::Invalid("output_schema_json")); + } + + if self.tools.is_empty() && self.tool_choice == ToolChoice::Required { + return Err(ContractError::Invalid("tool_choice")); + } + + let mut tool_names = HashSet::new(); + for tool in &self.tools { + require_provider_name(&tool.name, "tool.name")?; + require_content(&tool.description, "tool.description")?; + require_content(&tool.parameters_json, "tool.parameters_json")?; + require_json_object(&tool.parameters_json, "tool.parameters_json")?; + if !tool_names.insert(tool.name.as_str()) { + return Err(ContractError::DuplicateTool(tool.name.clone())); + } + } + for item in &self.input { + match item { + InputItem::UserText { text } | InputItem::AssistantText { text } => { + require_content(text, "input.text")?; + } + InputItem::ToolCall { + call_id, + name, + arguments, + } => { + require_provider_call_id(call_id, "input.call_id")?; + require_provider_name(name, "input.tool_name")?; + require_content(arguments, "input.arguments")?; + } + InputItem::ToolResult { call_id, output } => { + require_provider_call_id(call_id, "input.call_id")?; + require_content(output, "input.output")?; + } + } + } + Ok(()) + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum ToolChoice { + Auto, + Required, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub enum InputItem { + UserText { + text: String, + }, + AssistantText { + text: String, + }, + ToolCall { + call_id: String, + name: String, + arguments: String, + }, + ToolResult { + call_id: String, + output: String, + }, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct ToolDefinition { + pub name: String, + pub description: String, + pub parameters_json: String, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct TransportInvocation { + pub schema_id: String, + pub caller_runtime_id: String, + pub expires_at_unix_ms: u64, + pub native_request_sha256: [u8; 32], + pub provider_request_sha256: [u8; 32], + pub request: ProviderRequest, +} + +impl TransportInvocation { + pub fn new( + caller_runtime_id: impl Into, + expires_at_unix_ms: u64, + native_request_sha256: [u8; 32], + request: ProviderRequest, + ) -> Result { + let provider_request_sha256 = provider_request_sha256(&request)?; + Ok(Self { + schema_id: INVOCATION_SCHEMA_ID.to_string(), + caller_runtime_id: caller_runtime_id.into(), + expires_at_unix_ms, + native_request_sha256, + provider_request_sha256, + request, + }) + } + + pub fn validate(&self, now_unix_ms: u64, max_expiry_skew_ms: u64) -> Result<(), ContractError> { + if self.schema_id != INVOCATION_SCHEMA_ID { + return Err(ContractError::Schema); + } + require_id(&self.caller_runtime_id, "caller_runtime_id")?; + if self.native_request_sha256 == [0; 32] { + return Err(ContractError::Invalid("native_request_sha256")); + } + self.request.validate()?; + if self.provider_request_sha256 != provider_request_sha256(&self.request)? { + return Err(ContractError::ProviderDigest); + } + if self.expires_at_unix_ms < now_unix_ms + || self.expires_at_unix_ms > now_unix_ms.saturating_add(max_expiry_skew_ms) + { + return Err(ContractError::Expiry); + } + Ok(()) + } + + pub fn request_id(&self) -> &str { + &self.request.request_id + } +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct TransportResult { + pub schema_id: String, + pub request_id: String, + pub caller_runtime_id: String, + pub disposition: TransportDisposition, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub enum TransportDisposition { + Refused(Refusal), + Transported { + events: Vec, + receipt: Box, + }, +} + +impl TransportResult { + pub fn refused(invocation: &TransportInvocation, reason: Refusal) -> Self { + Self { + schema_id: RESULT_SCHEMA_ID.to_string(), + request_id: invocation.request_id().to_string(), + caller_runtime_id: invocation.caller_runtime_id.clone(), + disposition: TransportDisposition::Refused(reason), + } + } + + pub fn transported( + invocation: &TransportInvocation, + events: Vec, + receipt: TransportReceipt, + ) -> Self { + Self { + schema_id: RESULT_SCHEMA_ID.to_string(), + request_id: invocation.request_id().to_string(), + caller_runtime_id: invocation.caller_runtime_id.clone(), + disposition: TransportDisposition::Transported { + events, + receipt: Box::new(receipt), + }, + } + } + + pub fn validate_against(&self, invocation: &TransportInvocation) -> Result<(), ContractError> { + if self.schema_id != RESULT_SCHEMA_ID { + return Err(ContractError::Schema); + } + if self.request_id != invocation.request_id() + || self.caller_runtime_id != invocation.caller_runtime_id + { + return Err(ContractError::Identity); + } + + if let TransportDisposition::Transported { events, receipt } = &self.disposition { + for (expected, event) in events.iter().enumerate() { + if event.sequence != expected as u64 { + return Err(ContractError::EventSequence); + } + event.validate()?; + } + receipt.validate_against(invocation)?; + } + Ok(()) + } +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub enum TransportEventPayload { + TextDelta { + text: String, + }, + ToolCall { + call_id: String, + name: String, + arguments: String, + }, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct TransportEvent { + pub sequence: u64, + pub payload: TransportEventPayload, +} + +impl TransportEvent { + fn validate(&self) -> Result<(), ContractError> { + match &self.payload { + TransportEventPayload::TextDelta { text } => require_content(text, "event.text"), + TransportEventPayload::ToolCall { + call_id, + name, + arguments, + } => { + require_id(call_id, "event.call_id")?; + require_id(name, "event.tool_name")?; + require_content(arguments, "event.arguments") + } + } + } +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct TransportReceipt { + pub schema_id: String, + pub request_id: String, + pub caller_runtime_id: String, + pub native_request_sha256: [u8; 32], + pub provider_request_sha256: [u8; 32], + pub model: String, + pub transport: String, + pub outcome: TransportOutcome, +} + +impl TransportReceipt { + pub fn validate_against(&self, invocation: &TransportInvocation) -> Result<(), ContractError> { + if self.schema_id != RECEIPT_SCHEMA_ID { + return Err(ContractError::Schema); + } + if self.request_id != invocation.request_id() + || self.caller_runtime_id != invocation.caller_runtime_id + || self.model != invocation.request.model + { + return Err(ContractError::Identity); + } + if self.provider_request_sha256 != invocation.provider_request_sha256 { + return Err(ContractError::ProviderDigest); + } + if self.native_request_sha256 != invocation.native_request_sha256 { + return Err(ContractError::NativeDigest); + } + require_id(&self.transport, "receipt.transport")?; + match &self.outcome { + TransportOutcome::Completed { + provider_response_id: Some(value), + .. + } => require_id(value, "receipt.provider_response_id")?, + TransportOutcome::Failed { + failure_kind, + message, + } => { + require_id(failure_kind, "receipt.failure_kind")?; + require_content(message, "receipt.failure_message")?; + } + TransportOutcome::Completed { + provider_response_id: None, + .. + } => {} + } + Ok(()) + } +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub enum TransportOutcome { + Completed { + provider_response_id: Option, + input_tokens: Option, + output_tokens: Option, + reasoning_output_tokens: Option, + cached_input_tokens: Option, + }, + Failed { + failure_kind: String, + message: String, + }, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub enum Refusal { + Expired, + IdentitySubstitution, + ProviderDigestSubstitution, + Policy, + Capacity, + InFlight, + Indeterminate, + ReplayConflict, + Malformed, +} + +pub fn canonical_provider_request_bytes( + request: &ProviderRequest, +) -> Result, ContractError> { + request.validate()?; + rmp_serde::to_vec(request).map_err(|_| ContractError::Encoding) +} + +pub fn provider_request_sha256(request: &ProviderRequest) -> Result<[u8; 32], ContractError> { + Ok(Sha256::digest(canonical_provider_request_bytes(request)?).into()) +} + +pub fn canonical_invocation_bytes( + invocation: &TransportInvocation, +) -> Result, ContractError> { + invocation.request.validate()?; + if invocation.provider_request_sha256 != provider_request_sha256(&invocation.request)? { + return Err(ContractError::ProviderDigest); + } + rmp_serde::to_vec(invocation).map_err(|_| ContractError::Encoding) +} + +pub(crate) fn require_id(value: &str, field: &'static str) -> Result<(), ContractError> { + if value.trim().is_empty() || value.trim() != value { + return Err(ContractError::Invalid(field)); + } + Ok(()) +} + +fn require_content(value: &str, field: &'static str) -> Result<(), ContractError> { + if value.is_empty() { + return Err(ContractError::Invalid(field)); + } + Ok(()) +} + +fn require_json_object(value: &str, field: &'static str) -> Result<(), ContractError> { + if !matches!( + serde_json::from_str::(value), + Ok(serde_json::Value::Object(_)) + ) { + return Err(ContractError::Invalid(field)); + } + Ok(()) +} + +fn require_provider_name(value: &str, field: &'static str) -> Result<(), ContractError> { + if value.is_empty() + || value.len() > 64 + || !value + .bytes() + .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'_' | b'-')) + { + return Err(ContractError::Invalid(field)); + } + Ok(()) +} + +fn require_provider_call_id(value: &str, field: &'static str) -> Result<(), ContractError> { + if value.is_empty() || value.len() > 64 || !value.is_ascii() { + return Err(ContractError::Invalid(field)); + } + Ok(()) +} + +#[derive(Debug, Error, PartialEq, Eq)] +pub enum ContractError { + #[error("unexpected schema")] + Schema, + #[error("invalid {0}")] + Invalid(&'static str), + #[error("duplicate tool {0:?}")] + DuplicateTool(String), + #[error("request identity substitution")] + Identity, + #[error("provider request digest substitution")] + ProviderDigest, + #[error("native request digest substitution")] + NativeDigest, + #[error("invocation expiry is outside the admitted horizon")] + Expiry, + #[error("transport event sequence is not contiguous")] + EventSequence, + #[error("canonical MessagePack encoding failed")] + Encoding, +} + +#[cfg(test)] +mod tests { + use super::*; + + use crate::fixtures::*; + + #[test] + fn provider_digest_is_stable_and_content_addressed() { + let original = request(); + let same = original.clone(); + assert_eq!( + provider_request_sha256(&original).unwrap(), + provider_request_sha256(&same).unwrap() + ); + let mut changed = original.clone(); + changed.instructions.push_str(" Changed."); + assert_ne!( + provider_request_sha256(&original).unwrap(), + provider_request_sha256(&changed).unwrap() + ); + } + + #[test] + fn provider_contract_refuses_transport_side_fixup_pressure() { + let mut request = request(); + request.output_format_name = Some("epiphany.role.result".to_string()); + request.output_schema_json = Some(r#"{"type":"object"}"#.to_string()); + assert_eq!( + request.validate(), + Err(ContractError::Invalid("output_format_name")) + ); + + request.output_format_name = None; + request.output_schema_json = None; + request.input.push(InputItem::ToolResult { + call_id: "x".repeat(65), + output: "evidence".to_string(), + }); + assert_eq!( + request.validate(), + Err(ContractError::Invalid("input.call_id")) + ); + } + + #[test] + fn invocation_refuses_provider_request_substitution() { + let mut invocation = invocation(); + invocation.request.model = "substituted".to_string(); + assert_eq!( + invocation.validate(1_000, 2_000), + Err(ContractError::ProviderDigest) + ); + } + + #[test] + fn invocation_refuses_expired_or_blank_native_provenance() { + let mut invocation = invocation(); + assert_eq!( + invocation.validate(2_001, 2_000), + Err(ContractError::Expiry) + ); + invocation.expires_at_unix_ms = 2_500; + invocation.native_request_sha256 = [0; 32]; + assert_eq!( + invocation.validate(2_001, 2_000), + Err(ContractError::Invalid("native_request_sha256")) + ); + } + + #[test] + fn typed_tool_round_survives_messagepack() { + let invocation = invocation(); + let bytes = rmp_serde::to_vec(&invocation).unwrap(); + let decoded: TransportInvocation = rmp_serde::from_slice(&bytes).unwrap(); + assert_eq!(decoded, invocation); + assert!(matches!( + decoded.request.tools.as_slice(), + [ToolDefinition { name, .. }] if name == "read_source" + )); + } + + #[test] + fn transported_result_binds_identity_digest_and_event_order() { + let invocation = invocation(); + let result = TransportResult::transported( + &invocation, + vec![TransportEvent { + sequence: 0, + payload: TransportEventPayload::ToolCall { + call_id: "call-1".to_string(), + name: "read_source".to_string(), + arguments: "{}".to_string(), + }, + }], + receipt(&invocation), + ); + assert_eq!(result.validate_against(&invocation), Ok(())); + + let mut reordered = result; + let TransportDisposition::Transported { events, .. } = &mut reordered.disposition else { + unreachable!() + }; + events[0].sequence = 1; + assert_eq!( + reordered.validate_against(&invocation), + Err(ContractError::EventSequence) + ); + } + + #[test] + fn receipt_refuses_caller_substitution() { + let invocation = invocation(); + let mut substituted_caller = receipt(&invocation); + substituted_caller.caller_runtime_id = "ghostlight-dungeon-yggdrasil".to_string(); + assert_eq!( + substituted_caller.validate_against(&invocation), + Err(ContractError::Identity) + ); + + let mut substituted_native_basis = receipt(&invocation); + substituted_native_basis.native_request_sha256 = [8; 32]; + assert_eq!( + substituted_native_basis.validate_against(&invocation), + Err(ContractError::NativeDigest) + ); + } +} diff --git a/crates/model-connector/src/envelope.rs b/crates/model-connector/src/envelope.rs new file mode 100644 index 0000000..5400eac --- /dev/null +++ b/crates/model-connector/src/envelope.rs @@ -0,0 +1,514 @@ +use std::io::{Read, Write}; + +use aes_gcm::aead::{Aead, Payload}; +use aes_gcm::{Aes256Gcm, KeyInit, Nonce}; +use rand_core::{OsRng, RngCore}; +use serde::{Deserialize, Serialize}; +use thiserror::Error; + +use crate::contract::{ + ContractError, ENVELOPE_SCHEMA_ID, TransportInvocation, TransportKey, TransportResult, + canonical_invocation_bytes, require_id, +}; + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +pub enum EnvelopeKind { + Invocation, + Result, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct TransportEnvelope { + pub schema_id: String, + pub caller_runtime_id: String, + pub request_id: String, + pub message_kind: EnvelopeKind, + pub nonce: [u8; 12], + pub ciphertext: Vec, +} + +/// The longest caller id, request id or model name a service admits. The +/// frame arithmetic below depends on it. A request whose id is longer is +/// refused on the wire as malformed. +pub const MAX_IDENTITY_BYTES: usize = 256; + +/// The most bytes a serialized envelope holding `ciphertext_len` ciphertext +/// bytes can occupy, with ids at their longest: the frame a caller payload +/// bound (`CallerAdmission`'s `max_payload_bytes`, a bound on ciphertext +/// length) needs. The v2 wire serializes the ciphertext as a MessagePack array +/// of integers: a byte of 128 or more takes two bytes, so the worst case is +/// twice the ciphertext, plus the array header and the envelope's other +/// fields at their longest. Measured from the real encoding, not assumed. +pub fn frame_bytes_for_payload(ciphertext_len: usize) -> usize { + let shell = rmp_serde::to_vec(&TransportEnvelope { + schema_id: ENVELOPE_SCHEMA_ID.to_string(), + caller_runtime_id: "x".repeat(MAX_IDENTITY_BYTES), + request_id: "x".repeat(MAX_IDENTITY_BYTES), + message_kind: EnvelopeKind::Invocation, + nonce: [0xff; 12], + ciphertext: Vec::new(), + }) + .map_or(usize::MAX, |bytes| bytes.len()); + // The shell holds an empty array header of one byte. + let header = match ciphertext_len { + 0..=15 => 1, + 16..=65_535 => 3, + _ => 5, + }; + shell + .saturating_sub(1) + .saturating_add(header) + .saturating_add(ciphertext_len.saturating_mul(2)) +} + +/// The largest payload bound whose worst-case frame fits `max_frame_bytes`: +/// the inverse of [`frame_bytes_for_payload`]. `None` when the frame cannot +/// carry even an empty envelope. +pub fn payload_bytes_for_frame(max_frame_bytes: usize) -> Option { + // frame_bytes_for_payload(0) is the shell plus a one-byte array header. + let fixed = frame_bytes_for_payload(0) - 1; + let room = max_frame_bytes.checked_sub(fixed)?; + // Each array header width bounds the payloads it can describe; the + // widest payload any width admits is the answer. + let by_header = |header: usize, cap: usize| room.checked_sub(header).map(|r| (r / 2).min(cap)); + [ + by_header(5, usize::MAX), + by_header(3, 65_535), + by_header(1, 15), + ] + .into_iter() + .flatten() + .max() +} + +pub fn encrypt_invocation( + invocation: &TransportInvocation, + security: &TransportKey, +) -> Result { + let plaintext = canonical_invocation_bytes(invocation)?; + encrypt_message( + &invocation.caller_runtime_id, + invocation.request_id(), + EnvelopeKind::Invocation, + &plaintext, + security, + ) +} + +pub fn decrypt_invocation( + envelope: &TransportEnvelope, + security: &TransportKey, +) -> Result { + validate_envelope(envelope, EnvelopeKind::Invocation)?; + let plaintext = decrypt_message(envelope, security)?; + let invocation: TransportInvocation = + rmp_serde::from_slice(&plaintext).map_err(|_| ServiceError::Encoding)?; + if invocation.caller_runtime_id != envelope.caller_runtime_id + || invocation.request_id() != envelope.request_id + { + return Err(ServiceError::OuterIdentity); + } + Ok(invocation) +} + +pub fn encrypt_result( + result: &TransportResult, + security: &TransportKey, +) -> Result { + let plaintext = rmp_serde::to_vec(result).map_err(|_| ServiceError::Encoding)?; + encrypt_message( + &result.caller_runtime_id, + &result.request_id, + EnvelopeKind::Result, + &plaintext, + security, + ) +} + +pub fn decrypt_result( + envelope: &TransportEnvelope, + security: &TransportKey, + invocation: &TransportInvocation, +) -> Result { + validate_envelope(envelope, EnvelopeKind::Result)?; + let plaintext = decrypt_message(envelope, security)?; + let result: TransportResult = + rmp_serde::from_slice(&plaintext).map_err(|_| ServiceError::Encoding)?; + if result.caller_runtime_id != envelope.caller_runtime_id + || result.request_id != envelope.request_id + { + return Err(ServiceError::OuterIdentity); + } + result.validate_against(invocation)?; + Ok(result) +} + +#[derive(Debug)] +pub(crate) enum TransportFrameError { + Connection(std::io::Error), + Size, +} + +pub(crate) fn read_transport_frame( + reader: &mut impl Read, + max_frame_bytes: usize, +) -> Result, TransportFrameError> { + let mut length = [0_u8; 4]; + reader + .read_exact(&mut length) + .map_err(TransportFrameError::Connection)?; + let length = u32::from_be_bytes(length) as usize; + if length == 0 || length > max_frame_bytes { + return Err(TransportFrameError::Size); + } + let mut payload = vec![0_u8; length]; + reader + .read_exact(&mut payload) + .map_err(TransportFrameError::Connection)?; + Ok(payload) +} + +pub(crate) fn write_transport_frame( + writer: &mut impl Write, + payload: &[u8], + max_frame_bytes: usize, +) -> Result<(), TransportFrameError> { + if payload.is_empty() || payload.len() > max_frame_bytes || payload.len() > u32::MAX as usize { + return Err(TransportFrameError::Size); + } + writer + .write_all(&(payload.len() as u32).to_be_bytes()) + .and_then(|()| writer.write_all(payload)) + .and_then(|()| writer.flush()) + .map_err(TransportFrameError::Connection) +} + +pub(crate) fn encrypt_message( + caller_runtime_id: &str, + request_id: &str, + message_kind: EnvelopeKind, + plaintext: &[u8], + security: &TransportKey, +) -> Result { + require_id(caller_runtime_id, "caller_runtime_id")?; + require_id(request_id, "request_id")?; + let mut nonce = [0; 12]; + OsRng.fill_bytes(&mut nonce); + let cipher = Aes256Gcm::new_from_slice(&security.0).map_err(|_| ServiceError::Encryption)?; + let aad = envelope_aad( + ENVELOPE_SCHEMA_ID, + caller_runtime_id, + request_id, + message_kind, + )?; + let ciphertext = cipher + .encrypt( + Nonce::from_slice(&nonce), + Payload { + msg: plaintext, + aad: &aad, + }, + ) + .map_err(|_| ServiceError::Encryption)?; + Ok(TransportEnvelope { + schema_id: ENVELOPE_SCHEMA_ID.to_string(), + caller_runtime_id: caller_runtime_id.to_string(), + request_id: request_id.to_string(), + message_kind, + nonce, + ciphertext, + }) +} + +pub(crate) fn decrypt_message( + envelope: &TransportEnvelope, + security: &TransportKey, +) -> Result, ServiceError> { + let cipher = Aes256Gcm::new_from_slice(&security.0).map_err(|_| ServiceError::Encryption)?; + let aad = envelope_aad( + &envelope.schema_id, + &envelope.caller_runtime_id, + &envelope.request_id, + envelope.message_kind, + )?; + cipher + .decrypt( + Nonce::from_slice(&envelope.nonce), + Payload { + msg: &envelope.ciphertext, + aad: &aad, + }, + ) + .map_err(|_| ServiceError::Encryption) +} + +pub(crate) fn envelope_aad( + schema_id: &str, + caller_runtime_id: &str, + request_id: &str, + message_kind: EnvelopeKind, +) -> Result, ServiceError> { + rmp_serde::to_vec(&(schema_id, caller_runtime_id, request_id, message_kind)) + .map_err(|_| ServiceError::Encoding) +} + +pub(crate) fn validate_envelope( + envelope: &TransportEnvelope, + expected_kind: EnvelopeKind, +) -> Result<(), ServiceError> { + if envelope.schema_id != ENVELOPE_SCHEMA_ID || envelope.message_kind != expected_kind { + return Err(ServiceError::Envelope); + } + require_id(&envelope.caller_runtime_id, "caller_runtime_id")?; + require_id(&envelope.request_id, "request_id")?; + if envelope.ciphertext.is_empty() { + return Err(ServiceError::Envelope); + } + Ok(()) +} + +#[derive(Debug, Error, PartialEq, Eq)] +pub enum ServiceError { + #[error(transparent)] + Contract(#[from] ContractError), + #[error("invalid caller admission")] + InvalidAdmission, + #[error("caller runtime was admitted twice")] + DuplicateCaller, + #[error("callers must not share transport keys")] + SharedCallerKey, + #[error("connector replay store failed: {0}")] + ReplayStore(String), + #[error("connector replay record is malformed")] + InvalidReplayRecord, + #[error("caller key changed while durable replay records still exist")] + ReplayCallerKeyMismatch, + #[error("caller runtime is not admitted")] + CallerNotAdmitted, + #[error("unexpected transport envelope")] + Envelope, + #[error("transport envelope identity substitution")] + OuterIdentity, + #[error("transport encryption or authentication failed")] + Encryption, + #[error("transport MessagePack encoding failed")] + Encoding, + #[error("completion did not carry a transported provider outcome")] + InvalidCompletion, + #[error("completion has no active request")] + NoActiveRequest, + #[error("completion does not match the active request")] + ActiveRequestMismatch, + #[error("sealed result does not fit the frame bound")] + FrameBound, +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::fixtures::*; + + #[test] + fn encrypted_invocation_hides_content_and_binds_outer_identity() { + let invocation = invocation(); + let epiphany_key = security("epiphany-distinct-test-key"); + let envelope = encrypt_invocation(&invocation, &epiphany_key).unwrap(); + assert!( + !envelope + .ciphertext + .windows("Projected state".len()) + .any(|window| window == b"Projected state") + ); + assert_eq!( + decrypt_invocation(&envelope, &epiphany_key).unwrap(), + invocation + ); + assert_eq!( + decrypt_invocation(&envelope, &security("wrong-caller-key")), + Err(ServiceError::Encryption) + ); + + let mut substituted = envelope; + substituted.request_id = "request-2".to_string(); + assert_eq!( + decrypt_invocation(&substituted, &epiphany_key), + Err(ServiceError::Encryption) + ); + } + + fn sealed_invocation(caller: &str, request_id: &str, kind: EnvelopeKind) -> TransportEnvelope { + let invocation = invocation(); + let plaintext = canonical_invocation_bytes(&invocation).unwrap(); + encrypt_message( + caller, + request_id, + kind, + &plaintext, + &security("epiphany-distinct-test-key"), + ) + .unwrap() + } + + #[test] + fn the_outer_identity_must_match_the_sealed_invocation_on_each_field() { + let key = security("epiphany-distinct-test-key"); + let invocation = invocation(); + let caller = invocation.caller_runtime_id.clone(); + let request_id = invocation.request_id().to_string(); + let matching = sealed_invocation(&caller, &request_id, EnvelopeKind::Invocation); + assert_eq!(decrypt_invocation(&matching, &key).unwrap(), invocation); + for envelope in [ + sealed_invocation("other-caller", &request_id, EnvelopeKind::Invocation), + sealed_invocation(&caller, "other-request", EnvelopeKind::Invocation), + ] { + assert_eq!( + decrypt_invocation(&envelope, &key), + Err(ServiceError::OuterIdentity) + ); + } + } + + #[test] + fn the_outer_identity_must_match_the_sealed_result_on_each_field() { + let key = security("epiphany-distinct-test-key"); + let invocation = invocation(); + let result = TransportResult::transported(&invocation, Vec::new(), receipt(&invocation)); + let plaintext = rmp_serde::to_vec(&result).unwrap(); + let seal = |caller: &str, request_id: &str| { + encrypt_message(caller, request_id, EnvelopeKind::Result, &plaintext, &key).unwrap() + }; + let caller = result.caller_runtime_id.clone(); + let request_id = result.request_id.clone(); + assert_eq!( + decrypt_result(&seal(&caller, &request_id), &key, &invocation).unwrap(), + result + ); + for envelope in [ + seal("other-caller", &request_id), + seal(&caller, "other-request"), + ] { + assert_eq!( + decrypt_result(&envelope, &key, &invocation), + Err(ServiceError::OuterIdentity) + ); + } + } + + #[test] + fn a_malformed_envelope_is_refused_before_any_decryption() { + let key = security("epiphany-distinct-test-key"); + let invocation = invocation(); + let good = encrypt_invocation(&invocation, &key).unwrap(); + + let mut schema = good.clone(); + schema.schema_id = "gamecult.codex.transport_envelope.v1".to_string(); + let wrong_kind = sealed_invocation("epiphany-yggdrasil", "request-1", EnvelopeKind::Result); + let mut empty = good.clone(); + empty.ciphertext.clear(); + for envelope in [schema, wrong_kind, empty] { + assert_eq!( + decrypt_invocation(&envelope, &key), + Err(ServiceError::Envelope) + ); + } + for field in ["caller_runtime_id", "request_id"] { + let mut blank = good.clone(); + if field == "caller_runtime_id" { + blank.caller_runtime_id = " ".to_string(); + } else { + blank.request_id = String::new(); + } + assert_eq!( + decrypt_invocation(&blank, &key), + Err(ServiceError::Contract(ContractError::Invalid(field))) + ); + } + } + + #[test] + fn a_frame_is_bounded_inclusively_and_never_empty() { + use std::io::Cursor; + let read = |length: u32, body: usize, max: usize| { + let mut frame = length.to_be_bytes().to_vec(); + frame.extend(vec![1; body]); + read_transport_frame(&mut Cursor::new(frame), max) + }; + assert_eq!(read(16, 16, 16).unwrap(), vec![1; 16]); + assert!(matches!(read(17, 17, 16), Err(TransportFrameError::Size))); + assert!(matches!(read(0, 0, 16), Err(TransportFrameError::Size))); + // A bound above the length never turns an empty frame into an empty payload. + assert!(matches!(read(0, 0, 1_000), Err(TransportFrameError::Size))); + + let write = |payload: &[u8], max: usize| { + let mut out = Vec::new(); + write_transport_frame(&mut out, payload, max).map(|()| out) + }; + assert_eq!(write(&[1; 16], 16).unwrap().len(), 20); + assert!(matches!( + write(&[1; 17], 16), + Err(TransportFrameError::Size) + )); + assert!(matches!(write(&[], 16), Err(TransportFrameError::Size))); + assert!(matches!(write(&[], 1_000), Err(TransportFrameError::Size))); + } + + #[test] + fn the_worst_frame_is_the_real_encoding_of_an_all_high_ciphertext() { + let long = "x".repeat(MAX_IDENTITY_BYTES); + for length in [0, 1, 15, 16, 17, 255, 256, 65_535, 65_536, 70_000] { + let envelope = TransportEnvelope { + schema_id: ENVELOPE_SCHEMA_ID.to_string(), + caller_runtime_id: long.clone(), + request_id: long.clone(), + message_kind: EnvelopeKind::Invocation, + nonce: [0xff; 12], + ciphertext: vec![0xff; length], + }; + assert_eq!( + rmp_serde::to_vec(&envelope).unwrap().len(), + frame_bytes_for_payload(length), + "{length}" + ); + } + } + + #[test] + fn a_sealed_envelope_never_exceeds_the_worst_frame_for_its_ciphertext() { + let key = security("epiphany-distinct-test-key"); + for id in ["a".to_string(), "i".repeat(MAX_IDENTITY_BYTES)] { + for plaintext in [1, 100, 5_000, 70_000] { + let sealed = + encrypt_message(&id, &id, EnvelopeKind::Result, &vec![0x41; plaintext], &key) + .unwrap(); + let real = rmp_serde::to_vec(&sealed).unwrap().len(); + let worst = frame_bytes_for_payload(sealed.ciphertext.len()); + assert!(real <= worst, "{real} > {worst}"); + // Real ciphertext is random, so about half its bytes take two + // bytes: the worst case is not wildly above what is written. + if id.len() == MAX_IDENTITY_BYTES { + assert!( + worst <= real + sealed.ciphertext.len() + 64, + "{worst} {real}" + ); + } + } + } + } + + #[test] + fn the_payload_bound_a_frame_allows_inverts_the_frame_a_payload_needs() { + assert_eq!( + payload_bytes_for_frame(frame_bytes_for_payload(0) - 1), + None + ); + assert_eq!(payload_bytes_for_frame(frame_bytes_for_payload(0)), Some(0)); + for bytes in [1, 15, 16, 4096, 65_535, 65_536, 100_000, 2_000_000] { + let frame = frame_bytes_for_payload(bytes); + assert_eq!(payload_bytes_for_frame(frame), Some(bytes), "{bytes}"); + assert!( + payload_bytes_for_frame(frame - 1).unwrap() < bytes, + "{bytes}" + ); + } + } +} diff --git a/crates/model-connector/src/fixtures.rs b/crates/model-connector/src/fixtures.rs new file mode 100644 index 0000000..9b48c1d --- /dev/null +++ b/crates/model-connector/src/fixtures.rs @@ -0,0 +1,128 @@ +//! Shared fixtures for the carried contract, envelope, client and service tests. +use crate::contract::*; + +#[cfg(feature = "daemon")] +use crate::service::*; + +#[cfg(feature = "daemon")] +use std::sync::atomic::{AtomicUsize, Ordering}; + +#[cfg(feature = "daemon")] +static TEST_STORE_SEQUENCE: AtomicUsize = AtomicUsize::new(0); + +#[cfg(feature = "daemon")] +pub(crate) struct TestStore { + pub(crate) root: std::path::PathBuf, + pub(crate) path: std::path::PathBuf, +} + +#[cfg(feature = "daemon")] +impl TestStore { + pub(crate) fn new() -> Self { + let sequence = TEST_STORE_SEQUENCE.fetch_add(1, Ordering::Relaxed); + let root = std::env::temp_dir().join(format!( + "model-connector-replay-{}-{sequence}", + std::process::id() + )); + std::fs::create_dir_all(&root).unwrap(); + let path = root.join("replay.cc"); + Self { root, path } + } +} + +#[cfg(feature = "daemon")] +impl Drop for TestStore { + fn drop(&mut self) { + let _ = std::fs::remove_dir_all(&self.root); + } +} + +pub(crate) fn request() -> ProviderRequest { + let mut request = ProviderRequest::new( + "request-1", + "conversation-1", + "gpt-5.4", + "Return the typed result.", + ); + request.input.push(InputItem::UserText { + text: "Projected state".to_string(), + }); + request.tools.push(ToolDefinition { + name: "read_source".to_string(), + description: "Read one admitted source.".to_string(), + parameters_json: r#"{"type":"object"}"#.to_string(), + }); + request +} + +pub(crate) fn invocation() -> TransportInvocation { + TransportInvocation::new("epiphany-yggdrasil", 2_000, [7; 32], request()).unwrap() +} + +pub(crate) fn receipt(invocation: &TransportInvocation) -> TransportReceipt { + TransportReceipt { + schema_id: RECEIPT_SCHEMA_ID.to_string(), + request_id: invocation.request_id().to_string(), + caller_runtime_id: invocation.caller_runtime_id.clone(), + native_request_sha256: invocation.native_request_sha256, + provider_request_sha256: invocation.provider_request_sha256, + model: invocation.request.model.clone(), + transport: "fixture-sse".to_string(), + outcome: TransportOutcome::Completed { + provider_response_id: Some("response-1".to_string()), + input_tokens: Some(10), + output_tokens: Some(3), + reasoning_output_tokens: Some(1), + cached_input_tokens: None, + }, + } +} +#[cfg(feature = "daemon")] +pub(crate) fn caller( + caller_runtime_id: &str, + key: &str, + max_concurrent_requests: usize, +) -> CallerAdmission { + CallerAdmission::new( + caller_runtime_id, + key, + 1, + ["gpt-5.4".to_string()], + max_concurrent_requests, + 64 * 1024, + 32_768, + ) + .unwrap() +} + +#[cfg(feature = "daemon")] +pub(crate) fn open_service(store: &TestStore, max_concurrent_requests: usize) -> TransportService { + TransportService::open( + &store.path, + [ + caller( + "epiphany-yggdrasil", + "epiphany-distinct-test-key", + max_concurrent_requests, + ), + caller( + "ghostlight-yggdrasil", + "ghostlight-distinct-test-key", + max_concurrent_requests, + ), + ], + 5_000, + ) + .unwrap() +} + +pub(crate) fn security(key: &str) -> TransportKey { + TransportKey::from_connection_secret(key).unwrap() +} + +#[cfg(feature = "daemon")] +pub(crate) fn invocation_for(caller_runtime_id: &str, request_id: &str) -> TransportInvocation { + let mut request = request(); + request.request_id = request_id.to_string(); + TransportInvocation::new(caller_runtime_id, 2_000, [7; 32], request).unwrap() +} diff --git a/crates/model-connector/src/lib.rs b/crates/model-connector/src/lib.rs new file mode 100644 index 0000000..ba1642b --- /dev/null +++ b/crates/model-connector/src/lib.rs @@ -0,0 +1,20 @@ +mod client; +mod contract; +mod envelope; + +#[cfg(test)] +mod fixtures; + +#[cfg(feature = "daemon")] +mod serve; +#[cfg(feature = "daemon")] +mod service; + +pub use client::*; +pub use contract::*; +pub use envelope::*; + +#[cfg(feature = "daemon")] +pub use serve::*; +#[cfg(feature = "daemon")] +pub use service::*; diff --git a/crates/model-connector/src/serve.rs b/crates/model-connector/src/serve.rs new file mode 100644 index 0000000..8eda706 --- /dev/null +++ b/crates/model-connector/src/serve.rs @@ -0,0 +1,1157 @@ +use std::fs; +use std::net::{IpAddr, SocketAddr, TcpListener, TcpStream}; +use std::path::{Path, PathBuf}; +use std::sync::atomic::{AtomicUsize, Ordering}; +use std::sync::{Arc, Mutex}; +use std::thread; +use std::time::{Duration, SystemTime, UNIX_EPOCH}; + +use serde::{Deserialize, Serialize}; +use thiserror::Error; +use zeroize::Zeroizing; + +use crate::contract::{TransportInvocation, TransportResult}; +use crate::envelope::{ + ServiceError, TransportEnvelope, TransportFrameError, frame_bytes_for_payload, + read_transport_frame, write_transport_frame, +}; +use crate::service::{CallerAdmission, TransportAdmission, TransportService}; + +/// The only provider seam of the connector kit. A connector binary implements +/// it over one model provider; the kit names no provider. +pub trait ProviderBackend: Send + Sync { + /// Whether the provider can take work. `serve` consults it once, before it + /// opens the replay store or accepts a connection; the request path does + /// not consult it. + fn ready(&self) -> bool; + /// Executes one admitted invocation. The service has already bound the + /// invocation to its caller and recorded its replay claim. + fn execute(&self, invocation: &TransportInvocation) -> TransportResult; +} + +/// Bounds of one serve loop. The connector binary's own document owns where +/// they come from. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct ServeLimits { + pub max_frame_bytes: usize, + pub max_connections: usize, + pub socket_timeout_ms: u64, + pub max_expiry_skew_ms: u64, +} + +/// The smallest caller payload bound serve admits. The smallest legal frame is +/// `frame_bytes_for_payload(MIN_PAYLOAD_BYTES)`. +pub const MIN_PAYLOAD_BYTES: usize = 4096; + +impl ServeLimits { + /// The bounds serve refuses before it opens anything: each limit nonzero, + /// a frame that carries the smallest payload (which also carries the failure + /// that stands in for an oversize result), enough connections for every caller's request + /// slots, and every caller's payload bound between the minimum and the + /// largest whose sealed frame, at its worst-case encoding, fits the frame. + /// An admitted payload therefore always fits, and `complete` is given the + /// same frame bound to fit every result in. + fn validate(&self, admissions: &[CallerAdmission]) -> Result<(), ServeError> { + if self.max_frame_bytes == 0 + || self.max_frame_bytes > u32::MAX as usize + || self.max_connections == 0 + || self.socket_timeout_ms == 0 + || self.max_expiry_skew_ms == 0 + { + return Err(ServeError::InvalidConfig("serve limits")); + } + let smallest = frame_bytes_for_payload(MIN_PAYLOAD_BYTES); + if self.max_frame_bytes < smallest { + return Err(ServeError::FrameBound { + max_frame_bytes: self.max_frame_bytes, + payload_bytes: MIN_PAYLOAD_BYTES, + needed_frame_bytes: smallest, + }); + } + if admissions.is_empty() { + return Err(ServeError::InvalidConfig("callers")); + } + let slots = admissions + .iter() + .try_fold(0_usize, |total, caller| { + total.checked_add(caller.max_concurrent_requests()) + }) + .ok_or(ServeError::InvalidConfig("connection capacity"))?; + if self.max_connections < slots.max(8) { + return Err(ServeError::InvalidConfig("connection capacity")); + } + for caller in admissions { + if caller.max_payload_bytes() < MIN_PAYLOAD_BYTES { + return Err(ServeError::InvalidConfig("caller admission")); + } + let needed = frame_bytes_for_payload(caller.max_payload_bytes()); + if needed > self.max_frame_bytes { + return Err(ServeError::FrameBound { + max_frame_bytes: self.max_frame_bytes, + payload_bytes: caller.max_payload_bytes(), + needed_frame_bytes: needed, + }); + } + } + Ok(()) + } +} + +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct CallerConfig { + pub caller_runtime_id: String, + pub connection_key_file: PathBuf, + pub connection_key_epoch: u32, + pub allowed_models: Vec, + pub max_concurrent_requests: usize, + pub max_payload_bytes: usize, + pub max_output_tokens: u32, +} + +pub fn load_admissions(callers: &[CallerConfig]) -> Result, ServeError> { + let mut admissions = Vec::with_capacity(callers.len()); + for caller in callers { + let raw = Zeroizing::new( + fs::read(&caller.connection_key_file).map_err(ServeError::ConnectionKey)?, + ); + let secret = std::str::from_utf8(raw.as_slice()) + .map_err(|_| ServeError::InvalidConfig("caller connection key encoding"))? + .trim_end_matches(['\r', '\n']); + admissions.push( + CallerAdmission::new( + caller.caller_runtime_id.clone(), + secret.to_string(), + caller.connection_key_epoch, + caller.allowed_models.clone(), + caller.max_concurrent_requests, + caller.max_payload_bytes, + caller.max_output_tokens, + ) + .map_err(ServeError::Service)?, + ); + } + Ok(admissions) +} + +/// Serves admitted callers on a loopback listener until the listener fails. +/// The caller binds; a listener that is not bound to loopback is refused, and +/// peers that are not loopback are dropped. +pub fn serve( + listener: TcpListener, + limits: ServeLimits, + admissions: Vec, + replay_store: &Path, + backend: Arc, +) -> Result<(), ServeError> { + limits.validate(&admissions)?; + if !listener + .local_addr() + .map_err(ServeError::Listen)? + .ip() + .is_loopback() + { + return Err(ServeError::InvalidConfig("listener address")); + } + if !backend.ready() { + return Err(ServeError::BackendUnready); + } + let service = Arc::new(Mutex::new(TransportService::open( + replay_store, + admissions, + limits.max_expiry_skew_ms, + )?)); + accept_loop(|| listener.accept(), limits, service, backend) +} + +/// The accept loop over any source of connections, so a test can present a +/// peer address that a loopback listener never reports. +fn accept_loop( + accept: impl Fn() -> std::io::Result<(TcpStream, SocketAddr)>, + limits: ServeLimits, + service: Arc>, + backend: Arc, +) -> Result<(), ServeError> { + let active_connections = Arc::new(AtomicUsize::new(0)); + let timeout = Duration::from_millis(limits.socket_timeout_ms); + + loop { + let (stream, peer) = accept().map_err(ServeError::Listen)?; + if !peer_admitted(peer.ip()) { + continue; + } + let Some(permit) = + ConnectionPermit::acquire(active_connections.clone(), limits.max_connections) + else { + continue; + }; + let service = service.clone(); + let backend = backend.clone(); + thread::Builder::new() + .name("model-connector-request".to_string()) + .spawn(move || { + let _permit = permit; + let _ = serve_connection(stream, &service, backend.as_ref(), &limits, timeout); + }) + .map_err(ServeError::Thread)?; + } +} + +fn peer_admitted(peer: IpAddr) -> bool { + peer.is_loopback() +} + +fn serve_connection( + mut stream: TcpStream, + service: &Mutex, + backend: &dyn ProviderBackend, + limits: &ServeLimits, + timeout: Duration, +) -> Result<(), ServeError> { + stream + .set_read_timeout(Some(timeout)) + .and_then(|()| stream.set_write_timeout(Some(timeout))) + .map_err(ServeError::Connection)?; + let request = read_transport_frame(&mut stream, limits.max_frame_bytes).map_err(frame_error)?; + let response = execute_inference_frame(&request, service, backend, limits)?; + write_transport_frame(&mut stream, &response, limits.max_frame_bytes).map_err(frame_error) +} + +fn execute_inference_frame( + request: &[u8], + service: &Mutex, + backend: &dyn ProviderBackend, + limits: &ServeLimits, +) -> Result, ServeError> { + let envelope: TransportEnvelope = + rmp_serde::from_slice(request).map_err(|_| ServeError::FrameEncoding)?; + let admission = service + .lock() + .map_err(|_| ServeError::ServicePoisoned)? + .begin(&envelope, unix_ms()?, limits.max_frame_bytes)?; + let response = match admission { + TransportAdmission::Reply(response) => response, + TransportAdmission::Execute(claim) => { + let result = backend.execute(claim.invocation()); + service + .lock() + .map_err(|_| ServeError::ServicePoisoned)? + .complete(*claim, result, limits.max_frame_bytes)? + } + }; + rmp_serde::to_vec(&response).map_err(|_| ServeError::FrameEncoding) +} + +fn frame_error(error: TransportFrameError) -> ServeError { + match error { + TransportFrameError::Connection(error) => ServeError::Connection(error), + TransportFrameError::Size => ServeError::FrameSize, + } +} + +fn unix_ms() -> Result { + let millis = SystemTime::now() + .duration_since(UNIX_EPOCH) + .map_err(|_| ServeError::Clock)? + .as_millis(); + u64::try_from(millis).map_err(|_| ServeError::Clock) +} + +struct ConnectionPermit { + active: Arc, +} + +impl ConnectionPermit { + fn acquire(active: Arc, limit: usize) -> Option { + let mut observed = active.load(Ordering::Acquire); + loop { + if observed >= limit { + return None; + } + match active.compare_exchange_weak( + observed, + observed + 1, + Ordering::AcqRel, + Ordering::Acquire, + ) { + Ok(_) => return Some(Self { active }), + Err(current) => observed = current, + } + } + } +} + +impl Drop for ConnectionPermit { + fn drop(&mut self) { + self.active.fetch_sub(1, Ordering::AcqRel); + } +} + +#[derive(Debug, Error)] +pub enum ServeError { + #[error("invalid serve configuration field {0}")] + InvalidConfig(&'static str), + #[error( + "max_frame_bytes {max_frame_bytes} cannot carry a {payload_bytes}-byte payload bound: at least {needed_frame_bytes} frame bytes are needed" + )] + FrameBound { + max_frame_bytes: usize, + payload_bytes: usize, + needed_frame_bytes: usize, + }, + #[error("connector connection key is unavailable")] + ConnectionKey(#[source] std::io::Error), + #[error(transparent)] + Service(#[from] ServiceError), + #[error("provider backend is not ready")] + BackendUnready, + #[error("connector service lock was poisoned")] + ServicePoisoned, + #[error("connector listener failed")] + Listen(#[source] std::io::Error), + #[error("connector request thread failed")] + Thread(#[source] std::io::Error), + #[error("connector connection failed")] + Connection(#[source] std::io::Error), + #[error("connector frame exceeded its bound")] + FrameSize, + #[error("connector frame was not valid MessagePack")] + FrameEncoding, + #[error("system clock is before the Unix epoch")] + Clock, +} + +#[cfg(test)] +mod tests { + use std::io::Cursor; + use std::net::Ipv4Addr; + + use super::*; + use crate::client::ConnectorClient; + use crate::contract::{ + Refusal, TransportDisposition, TransportEvent, TransportEventPayload, TransportOutcome, + TransportResult, + }; + use crate::fixtures::{TestStore, caller, invocation_for, receipt, request}; + + const CALLER: &str = "epiphany-yggdrasil"; + const KEY: &str = "epiphany-distinct-test-key"; + const FRAME: usize = 256 * 1024; + /// AES-256-GCM appends a 16-byte tag to the ciphertext. + const GCM_TAG: usize = 16; + + struct CountingBackend { + ready: bool, + ready_calls: AtomicUsize, + executions: AtomicUsize, + } + + impl CountingBackend { + fn new(ready: bool) -> Arc { + Arc::new(Self { + ready, + ready_calls: AtomicUsize::new(0), + executions: AtomicUsize::new(0), + }) + } + } + + impl ProviderBackend for CountingBackend { + fn ready(&self) -> bool { + self.ready_calls.fetch_add(1, Ordering::SeqCst); + self.ready + } + + fn execute(&self, invocation: &TransportInvocation) -> TransportResult { + self.executions.fetch_add(1, Ordering::SeqCst); + TransportResult::transported( + invocation, + vec![TransportEvent { + sequence: 0, + payload: TransportEventPayload::TextDelta { + text: "done".to_string(), + }, + }], + receipt(invocation), + ) + } + } + + fn limits() -> ServeLimits { + ServeLimits { + max_frame_bytes: FRAME, + max_connections: 8, + socket_timeout_ms: 5_000, + max_expiry_skew_ms: 300_000, + } + } + + /// The test's own clock, so a mutated `unix_ms` cannot move the fixture with it. + fn now_ms() -> u64 { + SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap() + .as_millis() as u64 + } + + /// What `serve` returned, or `None` when it is still serving after two seconds. + fn outcome( + listener: TcpListener, + limits: ServeLimits, + admissions: Vec, + path: &Path, + backend: Arc, + ) -> Option> { + let (sender, receiver) = std::sync::mpsc::channel(); + let path = path.to_path_buf(); + thread::spawn(move || { + let _ = sender.send(serve(listener, limits, admissions, &path, backend)); + }); + receiver.recv_timeout(Duration::from_secs(2)).ok() + } + + fn fresh(request_id: &str) -> TransportInvocation { + let mut invocation = invocation_for(CALLER, request_id); + invocation.expires_at_unix_ms = now_ms() + 60_000; + invocation + } + + fn start( + backend: Arc, + store: &TestStore, + ) -> (ConnectorClient, std::net::SocketAddr) { + start_with(backend, store, vec![caller(CALLER, KEY, 4)]) + } + + fn start_with( + backend: Arc, + store: &TestStore, + admissions: Vec, + ) -> (ConnectorClient, std::net::SocketAddr) { + let listener = TcpListener::bind("127.0.0.1:0").unwrap(); + let endpoint = listener.local_addr().unwrap(); + let path = store.path.clone(); + thread::spawn(move || serve(listener, limits(), admissions, &path, backend)); + let client = + ConnectorClient::new(endpoint, KEY, FRAME, Some(Duration::from_secs(5))).unwrap(); + (client, endpoint) + } + + fn callers(capacities: &[usize]) -> Vec { + capacities + .iter() + .enumerate() + .map(|(index, capacity)| CallerConfig { + caller_runtime_id: format!("caller-{index}"), + connection_key_file: PathBuf::from(format!("caller-{index}.key")), + connection_key_epoch: 1, + allowed_models: vec!["gpt-5.6-luna".to_string()], + max_concurrent_requests: *capacity, + max_payload_bytes: 1_048_576, + max_output_tokens: 16_384, + }) + .collect() + } + + #[test] + fn serve_end_to_end_replays_without_a_second_execution() { + let store = TestStore::new(); + let backend = CountingBackend::new(true); + let (client, _) = start(backend.clone(), &store); + let invocation = fresh("request-1"); + + let first = client.execute(&invocation).unwrap(); + let second = client.execute(&invocation).unwrap(); + + assert!(matches!( + first.disposition, + TransportDisposition::Transported { .. } + )); + assert_eq!(first, second); + let TransportDisposition::Transported { events, receipt } = &first.disposition else { + unreachable!("matched above"); + }; + assert_eq!( + events.len(), + 1, + "a result within the bound is delivered as the provider made it" + ); + assert!(matches!( + receipt.outcome, + TransportOutcome::Completed { .. } + )); + assert_eq!(backend.executions.load(Ordering::SeqCst), 1); + + let mut conflicting_request = request(); + conflicting_request.request_id = "request-1".to_string(); + conflicting_request.instructions = "A different instruction.".to_string(); + let mut conflicting = TransportInvocation::new( + CALLER, + invocation.expires_at_unix_ms, + [7; 32], + conflicting_request, + ) + .unwrap(); + conflicting.expires_at_unix_ms = invocation.expires_at_unix_ms; + let refused = client.execute(&conflicting).unwrap(); + assert_eq!( + refused.disposition, + TransportDisposition::Refused(Refusal::ReplayConflict) + ); + assert_eq!(backend.executions.load(Ordering::SeqCst), 1); + } + + #[test] + fn serve_replay_is_byte_identical_on_the_wire() { + let store = TestStore::new(); + let backend = CountingBackend::new(true); + let (_client, endpoint) = start(backend.clone(), &store); + let security = crate::fixtures::security(KEY); + let request = rmp_serde::to_vec( + &crate::envelope::encrypt_invocation(&fresh("request-wire"), &security).unwrap(), + ) + .unwrap(); + let mut replies = Vec::new(); + for _ in 0..2 { + let mut stream = TcpStream::connect(endpoint).unwrap(); + write_transport_frame(&mut stream, &request, FRAME).unwrap(); + replies.push(read_transport_frame(&mut stream, FRAME).unwrap()); + } + assert_eq!(replies[0], replies[1]); + assert_eq!(backend.executions.load(Ordering::SeqCst), 1); + } + + #[test] + fn serve_refuses_a_long_request_id_on_the_wire_echoing_the_id_without_dropping_the_connection() + { + let store = TestStore::new(); + let backend = CountingBackend::new(true); + let (_client, endpoint) = start(backend.clone(), &store); + let security = crate::fixtures::security(KEY); + // Read the way ConnectorClient reads a reply: decrypt_result, which + // requires the result to name the request by its own id. + let send = |id: &str| { + let invocation = fresh(id); + let request = rmp_serde::to_vec( + &crate::envelope::encrypt_invocation(&invocation, &security).unwrap(), + ) + .unwrap(); + let mut stream = TcpStream::connect(endpoint).unwrap(); + write_transport_frame(&mut stream, &request, FRAME).unwrap(); + let reply = read_transport_frame(&mut stream, FRAME).unwrap(); + let envelope: TransportEnvelope = rmp_serde::from_slice(&reply).unwrap(); + crate::envelope::decrypt_result(&envelope, &security, &invocation).unwrap() + }; + let served = send(&"i".repeat(crate::envelope::MAX_IDENTITY_BYTES)); + assert!(matches!( + served.disposition, + TransportDisposition::Transported { .. } + )); + assert_eq!(backend.executions.load(Ordering::SeqCst), 1); + for length in [257, 258, 5_000, 60_000] { + let result = send(&"i".repeat(length)); + assert_eq!( + result.disposition, + TransportDisposition::Refused(Refusal::Malformed), + "{length}" + ); + } + assert_eq!(backend.executions.load(Ordering::SeqCst), 1); + } + + #[test] + fn serve_measures_a_replayed_record_against_its_own_max_frame() { + // A record completed under a wide frame, replayed by a serve at a + // narrower one, must come back as the typed stand-in that fits the + // narrower frame; handing `begin` any other frame delivers the wide + // record, which the narrow write then cannot carry. + let store = TestStore::new(); + let invocation = fresh("request-narrowed"); + // The wide completion goes straight through the service: serve holds + // the replay store for as long as it runs, so a second serve cannot + // open it while the first lives. + let mut service = + TransportService::open(&store.path, vec![caller(CALLER, KEY, 4)], 300_000).unwrap(); + let envelope = + crate::envelope::encrypt_invocation(&invocation, &crate::fixtures::security(KEY)) + .unwrap(); + let TransportAdmission::Execute(claim) = + service.begin(&envelope, now_ms(), 1 << 20).unwrap() + else { + panic!("a fresh request must execute"); + }; + service + .complete( + *claim, + sized_result(&invocation, 150_000, "fixture-sse"), + 1 << 20, + ) + .unwrap(); + drop(service); + + let narrow = 140_000; + let narrowed = ServeLimits { + max_frame_bytes: narrow, + ..limits() + }; + let listener = TcpListener::bind("127.0.0.1:0").unwrap(); + let endpoint = listener.local_addr().unwrap(); + let path = store.path.clone(); + let backend = SizedBackend::new(150_000); + let served = backend.clone(); + thread::spawn(move || { + serve( + listener, + narrowed, + vec![caller(CALLER, KEY, 4)], + &path, + served, + ) + }); + let client = + ConnectorClient::new(endpoint, KEY, narrow, Some(Duration::from_secs(5))).unwrap(); + let replayed = client.execute(&invocation).unwrap(); + assert!(failed_result_size(&replayed)); + assert_eq!(backend.executions.load(Ordering::SeqCst), 0); + } + + #[test] + fn frame_bound_error_states_the_frame_needed() { + assert_eq!( + ServeError::FrameBound { + max_frame_bytes: 1000, + payload_bytes: 2000, + needed_frame_bytes: 4000, + } + .to_string(), + "max_frame_bytes 1000 cannot carry a 2000-byte payload bound: at least 4000 frame bytes are needed" + ); + } + + #[test] + fn serve_refuses_to_start_on_an_unready_backend() { + let store = TestStore::new(); + let backend = CountingBackend::new(false); + let result = outcome( + TcpListener::bind("127.0.0.1:0").unwrap(), + limits(), + vec![caller(CALLER, KEY, 4)], + &store.path, + backend.clone(), + ); + assert!(matches!(result, Some(Err(ServeError::BackendUnready)))); + assert_eq!(backend.executions.load(Ordering::SeqCst), 0); + assert!( + !store.path.exists(), + "an unready backend must not open replay state" + ); + } + + #[test] + fn serve_consults_readiness_at_start_and_never_on_the_request_path() { + let store = TestStore::new(); + let backend = CountingBackend::new(true); + let (client, _) = start(backend.clone(), &store); + client.execute(&fresh("request-a")).unwrap(); + client.execute(&fresh("request-b")).unwrap(); + assert_eq!(backend.executions.load(Ordering::SeqCst), 2); + assert_eq!(backend.ready_calls.load(Ordering::SeqCst), 1); + } + + #[test] + fn serve_refuses_a_listener_that_is_not_loopback() { + let store = TestStore::new(); + let backend = CountingBackend::new(true); + let result = outcome( + TcpListener::bind((Ipv4Addr::UNSPECIFIED, 0)).unwrap(), + limits(), + vec![caller(CALLER, KEY, 4)], + &store.path, + backend.clone(), + ); + assert!(matches!( + result, + Some(Err(ServeError::InvalidConfig("listener address"))) + )); + assert_eq!(backend.ready_calls.load(Ordering::SeqCst), 0); + } + + /// The smallest frame serve admits: the smallest payload at its worst-case + /// sealed encoding. + fn smallest_frame() -> usize { + frame_bytes_for_payload(MIN_PAYLOAD_BYTES) + } + + #[test] + fn the_smallest_frame_also_carries_the_failure_that_stands_in_for_a_result() { + let long = "x".repeat(crate::envelope::MAX_IDENTITY_BYTES); + let plaintext = rmp_serde::to_vec(&crate::service::result_size_failure( + &long, &long, &long, [0xff; 32], [0xff; 32], + )) + .unwrap() + .len(); + let worst = frame_bytes_for_payload(plaintext + GCM_TAG); + assert!(worst <= smallest_frame(), "{worst} > {}", smallest_frame()); + } + + #[test] + fn serve_refuses_unbounded_limits_and_admits_the_widest_frame() { + let store = TestStore::new(); + let mutations: [fn(&mut ServeLimits); 5] = [ + |limits| limits.max_frame_bytes = 0, + |limits| limits.max_frame_bytes = u32::MAX as usize + 1, + |limits| limits.max_connections = 0, + |limits| limits.socket_timeout_ms = 0, + |limits| limits.max_expiry_skew_ms = 0, + ]; + for mutate in mutations { + let mut bounded = limits(); + mutate(&mut bounded); + let result = outcome( + TcpListener::bind("127.0.0.1:0").unwrap(), + bounded, + vec![caller(CALLER, KEY, 4)], + &store.path, + CountingBackend::new(true), + ); + assert!(matches!( + result, + Some(Err(ServeError::InvalidConfig("serve limits"))) + )); + } + let widest = ServeLimits { + max_frame_bytes: u32::MAX as usize, + ..limits() + }; + let result = outcome( + TcpListener::bind("127.0.0.1:0").unwrap(), + widest, + vec![caller(CALLER, KEY, 4)], + &store.path, + CountingBackend::new(true), + ); + assert!( + result.is_none(), + "the widest frame the wire can carry must serve" + ); + } + + #[test] + fn serve_refuses_an_empty_caller_set() { + let store = TestStore::new(); + let result = outcome( + TcpListener::bind("127.0.0.1:0").unwrap(), + limits(), + Vec::new(), + &store.path, + CountingBackend::new(true), + ); + assert!(matches!( + result, + Some(Err(ServeError::InvalidConfig("callers"))) + )); + } + + #[test] + fn only_loopback_peers_are_admitted() { + assert!(peer_admitted(IpAddr::from([127, 0, 0, 1]))); + assert!(peer_admitted(IpAddr::from(std::net::Ipv6Addr::LOCALHOST))); + assert!(!peer_admitted(IpAddr::from([192, 168, 1, 7]))); + assert!(!peer_admitted(IpAddr::from([172, 19, 0, 1]))); + } + + #[test] + fn connection_permits_are_bounded_and_returned_on_drop() { + let active = Arc::new(AtomicUsize::new(0)); + let first = ConnectionPermit::acquire(active.clone(), 2).unwrap(); + let second = ConnectionPermit::acquire(active.clone(), 2).unwrap(); + assert!(ConnectionPermit::acquire(active.clone(), 2).is_none()); + drop(first); + let third = ConnectionPermit::acquire(active.clone(), 2).unwrap(); + assert!(ConnectionPermit::acquire(active.clone(), 2).is_none()); + drop((second, third)); + assert_eq!(active.load(Ordering::SeqCst), 0); + } + + fn admission(id: &str, key: &str, slots: usize, payload: usize) -> CallerAdmission { + CallerAdmission::new(id, key, 1, ["gpt-5.4".to_string()], slots, payload, 32_768).unwrap() + } + + /// `Some(field)` when serve refused the configuration, `None` while it serves. + fn refusal(limits: ServeLimits, admissions: Vec) -> Option<&'static str> { + let store = TestStore::new(); + match outcome( + TcpListener::bind("127.0.0.1:0").unwrap(), + limits, + admissions, + &store.path, + CountingBackend::new(true), + ) { + None => None, + Some(Err(ServeError::InvalidConfig(field))) => Some(field), + Some(Err(ServeError::FrameBound { .. })) => Some("frame bound"), + Some(other) => panic!("unexpected serve outcome {other:?}"), + } + } + + #[test] + fn serve_refuses_fewer_connections_than_the_callers_request_slots() { + let two = || { + vec![ + admission("a", "key-a-distinct", 8, 8192), + admission("b", "key-b-distinct", 8, 8192), + ] + }; + let at = |connections| ServeLimits { + max_connections: connections, + ..limits() + }; + assert_eq!(refusal(at(15), two()), Some("connection capacity")); + assert_eq!(refusal(at(16), two()), None); + let small = || vec![admission("a", "key-a-distinct", 3, 8192)]; + assert_eq!(refusal(at(7), small()), Some("connection capacity")); + assert_eq!(refusal(at(8), small()), None); + let overflowing = vec![ + admission("a", "key-a-distinct", usize::MAX, 8192), + admission("b", "key-b-distinct", 1, 8192), + ]; + assert_eq!( + refusal(at(usize::MAX), overflowing), + Some("connection capacity") + ); + } + + #[test] + fn serve_refuses_a_frame_or_payload_bound_the_other_cannot_carry() { + let frame = |bytes| ServeLimits { + max_frame_bytes: bytes, + ..limits() + }; + let payload = |bytes| vec![admission("a", "key-a-distinct", 4, bytes)]; + let tightest = smallest_frame(); + assert_eq!( + refusal(frame(tightest - 1), payload(4096)), + Some("frame bound") + ); + // The tightest legal pair: the smallest payload in the smallest frame. + assert_eq!(refusal(frame(tightest), payload(4096)), None); + assert_eq!(refusal(frame(tightest), payload(4097)), Some("frame bound")); + assert_eq!( + refusal(frame(tightest), payload(4095)), + Some("caller admission") + ); + // The bound is the sealed frame at its worst encoding, not the payload: + // a payload that is the whole frame cannot be carried. + assert_eq!(refusal(frame(FRAME), payload(FRAME)), Some("frame bound")); + for bytes in [8192, 65_535, 65_536, 100_000] { + let widest = frame_bytes_for_payload(bytes); + assert_eq!(refusal(frame(widest), payload(bytes)), None, "{bytes}"); + assert_eq!( + refusal(frame(widest - 1), payload(bytes)), + Some("frame bound"), + "{bytes}" + ); + } + let mixed = vec![ + admission("a", "key-a-distinct", 4, 8192), + admission("b", "key-b-distinct", 4, FRAME), + ]; + assert_eq!(refusal(frame(FRAME), mixed), Some("frame bound")); + } + + #[test] + fn a_payload_at_the_widest_bound_its_frame_admits_is_carried_and_served() { + let store = TestStore::new(); + let backend = CountingBackend::new(true); + let mut invocation = fresh(&"q".repeat(256)); + invocation.request.instructions = "x".repeat(5_000); + invocation.provider_request_sha256 = + crate::contract::provider_request_sha256(&invocation.request).unwrap(); + let key = crate::fixtures::security(KEY); + let sealed = crate::envelope::encrypt_invocation(&invocation, &key).unwrap(); + let bound = sealed.ciphertext.len(); + let frame = frame_bytes_for_payload(bound); + let listener = TcpListener::bind("127.0.0.1:0").unwrap(); + let endpoint = listener.local_addr().unwrap(); + let path = store.path.clone(); + let served = backend.clone(); + let tight = ServeLimits { + max_frame_bytes: frame, + ..limits() + }; + let admissions = vec![admission(CALLER, KEY, 4, bound)]; + thread::spawn(move || serve(listener, tight, admissions, &path, served)); + let client = + ConnectorClient::new(endpoint, KEY, frame, Some(Duration::from_secs(5))).unwrap(); + let reply = client.execute(&invocation).unwrap(); + assert!(matches!( + reply.disposition, + TransportDisposition::Transported { .. } + )); + assert_eq!(backend.executions.load(Ordering::SeqCst), 1); + } + + #[test] + fn a_payload_over_the_caller_bound_is_refused_on_the_wire() { + let store = TestStore::new(); + let backend = CountingBackend::new(true); + let (client, _) = start_with( + backend.clone(), + &store, + vec![admission(CALLER, KEY, 4, 8192)], + ); + let mut invocation = fresh("request-big"); + invocation.request.instructions = "x".repeat(10_000); + invocation.provider_request_sha256 = + crate::contract::provider_request_sha256(&invocation.request).unwrap(); + let reply = client.execute(&invocation).unwrap(); + assert_eq!( + reply.disposition, + TransportDisposition::Refused(Refusal::Policy) + ); + assert_eq!(backend.executions.load(Ordering::SeqCst), 0); + } + + /// Answers every invocation with one text event of a chosen size. + struct SizedBackend { + text_len: usize, + transport: String, + executions: AtomicUsize, + } + + impl SizedBackend { + fn new(text_len: usize) -> Arc { + Self::with_transport(text_len, "fixture-sse".to_string()) + } + + fn with_transport(text_len: usize, transport: String) -> Arc { + Arc::new(Self { + text_len, + transport, + executions: AtomicUsize::new(0), + }) + } + } + + impl ProviderBackend for SizedBackend { + fn ready(&self) -> bool { + true + } + + fn execute(&self, invocation: &TransportInvocation) -> TransportResult { + self.executions.fetch_add(1, Ordering::SeqCst); + sized_result(invocation, self.text_len, &self.transport) + } + } + + fn sized_result( + invocation: &TransportInvocation, + text_len: usize, + transport: &str, + ) -> TransportResult { + let mut receipt = receipt(invocation); + receipt.transport = transport.to_string(); + TransportResult::transported( + invocation, + vec![TransportEvent { + sequence: 0, + payload: TransportEventPayload::TextDelta { + text: "y".repeat(text_len), + }, + }], + receipt, + ) + } + + fn encoded_len(result: &TransportResult) -> usize { + rmp_serde::to_vec(result).unwrap().len() + } + + /// Two calls with one request id, through a live serve loop. + fn call_twice( + backend: Arc, + request_id: &str, + ) -> (TransportResult, TransportResult, usize) { + let store = TestStore::new(); + let (client, _) = start_with(backend.clone(), &store, vec![caller(CALLER, KEY, 4)]); + let invocation = fresh(request_id); + let first = client.execute(&invocation).expect("first call delivered"); + let second = client.execute(&invocation).expect("retry delivered"); + (first, second, backend.executions.load(Ordering::SeqCst)) + } + + fn failed_result_size(result: &TransportResult) -> bool { + matches!( + &result.disposition, + TransportDisposition::Transported { events, receipt } + if events.is_empty() + && receipt.transport == "model-connector" + && receipt.outcome == TransportOutcome::Failed { + failure_kind: "result_size".to_string(), + message: "provider result exceeded its byte bound".to_string(), + } + ) + } + + fn id_cases() -> [String; 2] { + ["request-short".to_string(), "r".repeat(256)] + } + + /// The encoded size of a result carrying no text, for this request id. + fn empty_result_len(request_id: &str) -> usize { + encoded_len(&sized_result(&fresh(request_id), 0, "fixture-sse")) + } + + #[test] + fn a_result_whose_plaintext_fits_but_whose_sealed_frame_does_not_fails_typed_and_replays() { + // A plaintext within the frame less its overhead seals to about one and + // a half times its size, so it cannot be carried; measuring the + // plaintext would record it as completed and every retry would die. + for request_id in id_cases() { + let base = empty_result_len(&request_id); + let room = FRAME - 4096; + for plaintext in [room, room - 1, FRAME * 3 / 4] { + let backend = SizedBackend::new(plaintext - base); + let (first, second, executions) = call_twice(backend, &request_id); + assert!( + failed_result_size(&first), + "{} {plaintext}", + request_id.len() + ); + assert_eq!(first, second, "the retry replays the deliverable failure"); + assert_eq!(executions, 1); + } + } + } + + #[test] + fn a_result_larger_than_the_frame_fails_typed_and_is_never_replayed_as_completed() { + let (first, second, executions) = call_twice(SizedBackend::new(FRAME), "request-oversize"); + assert!(failed_result_size(&first)); + assert_eq!(first, second); + assert_eq!(executions, 1); + } + + #[test] + fn a_stand_in_failure_never_carries_the_backends_transport() { + let (first, second, executions) = call_twice( + SizedBackend::with_transport(FRAME, "t".repeat(FRAME)), + "request-transport", + ); + assert!(failed_result_size(&first)); + assert_eq!(first, second); + assert_eq!(executions, 1); + } + + /// The largest result plaintext whose sealed frame fits at the worst encoding. + fn widest_guaranteed_plaintext(base: usize) -> usize { + (base..=FRAME) + .take_while(|plaintext| frame_bytes_for_payload(plaintext + GCM_TAG) <= FRAME) + .last() + .unwrap_or(base) + } + + #[test] + fn a_result_within_what_the_worst_encoding_leaves_is_delivered_intact() { + // The largest plaintext whose sealed frame fits at the worst encoding is + // always carried, whatever the ciphertext bytes turn out to be. + for request_id in id_cases() { + let base = empty_result_len(&request_id); + let plaintext = widest_guaranteed_plaintext(base); + let (first, second, _) = call_twice(SizedBackend::new(plaintext - base), &request_id); + let TransportDisposition::Transported { events, receipt } = &first.disposition else { + panic!("expected a transported result"); + }; + assert_eq!(events.len(), 1, "{}", request_id.len()); + assert!(matches!( + receipt.outcome, + TransportOutcome::Completed { .. } + )); + assert_eq!(first, second); + } + } + + #[test] + fn a_result_just_over_the_worst_encoding_is_delivered_whatever_the_nonce_makes_of_it() { + // Past the guaranteed size the sealed frame may or may not fit; either + // way the caller gets an answer, and the retry gets the same one. + for request_id in id_cases() { + let base = empty_result_len(&request_id); + let plaintext = widest_guaranteed_plaintext(base); + for extra in [1, 2, 3] { + let backend = SizedBackend::new(plaintext + extra - base); + let (first, second, executions) = call_twice(backend, &request_id); + assert_eq!(first, second); + assert_eq!(executions, 1); + } + } + } + + #[test] + fn the_accept_loop_drops_a_peer_that_is_not_loopback() { + let run = |reported: SocketAddr| { + let store = TestStore::new(); + let backend = CountingBackend::new(true); + let listener = TcpListener::bind("127.0.0.1:0").unwrap(); + let endpoint = listener.local_addr().unwrap(); + let service = Arc::new(Mutex::new( + TransportService::open(&store.path, vec![caller(CALLER, KEY, 4)], 300_000).unwrap(), + )); + let served = backend.clone(); + thread::spawn(move || { + accept_loop( + || listener.accept().map(|(stream, _)| (stream, reported)), + limits(), + service, + served, + ) + }); + let client = + ConnectorClient::new(endpoint, KEY, FRAME, Some(Duration::from_secs(5))).unwrap(); + let reply = client.execute(&fresh("request-peer")); + (reply.is_ok(), backend.executions.load(Ordering::SeqCst)) + }; + assert_eq!(run("127.0.0.1:50000".parse().unwrap()), (true, 1)); + assert_eq!(run("192.168.1.7:50000".parse().unwrap()), (false, 0)); + } + + #[test] + fn direct_pipe_frame_is_big_endian_and_refuses_oversize_before_allocation() { + let mut encoded = Vec::new(); + write_transport_frame(&mut encoded, b"typed", 16).unwrap(); + assert_eq!(&encoded[..4], &5_u32.to_be_bytes()); + assert_eq!( + read_transport_frame(&mut Cursor::new(encoded), 16).unwrap(), + b"typed" + ); + + let oversized = 17_u32.to_be_bytes().to_vec(); + assert!(matches!( + read_transport_frame(&mut Cursor::new(oversized), 16), + Err(TransportFrameError::Size) + )); + } + + #[test] + fn load_admissions_reads_the_key_file_without_its_trailing_newline() { + let store = TestStore::new(); + let key_file = store.root.join("caller.key"); + fs::write(&key_file, format!("{KEY}\r\n")).unwrap(); + let mut config = callers(&[4]); + config[0].caller_runtime_id = CALLER.to_string(); + config[0].connection_key_file = key_file; + config[0].allowed_models = vec!["gpt-5.4".to_string()]; + config[0].max_payload_bytes = 64 * 1024; + let admissions = load_admissions(&config).unwrap(); + + let listener = TcpListener::bind("127.0.0.1:0").unwrap(); + let endpoint = listener.local_addr().unwrap(); + let backend = CountingBackend::new(true); + let path = store.path.clone(); + let served = backend.clone(); + thread::spawn(move || serve(listener, limits(), admissions, &path, served)); + let client = + ConnectorClient::new(endpoint, KEY, FRAME, Some(Duration::from_secs(5))).unwrap(); + client.execute(&fresh("request-key")).unwrap(); + assert_eq!(backend.executions.load(Ordering::SeqCst), 1); + } +} diff --git a/crates/model-connector/src/service.rs b/crates/model-connector/src/service.rs new file mode 100644 index 0000000..e377c19 --- /dev/null +++ b/crates/model-connector/src/service.rs @@ -0,0 +1,1250 @@ +use std::collections::{HashMap, HashSet}; +use std::path::Path; + +use cultcache_rs::{CultCache, DatabaseEntry, OwnedRedbMessagePackBackingStore}; +use serde::{Deserialize, Serialize}; +use sha2::{Digest, Sha256}; +use zeroize::Zeroizing; + +use crate::contract::{ + ContractError, RECEIPT_SCHEMA_ID, RESULT_SCHEMA_ID, Refusal, TransportDisposition, + TransportInvocation, TransportKey, TransportOutcome, TransportReceipt, TransportResult, + canonical_invocation_bytes, require_id, +}; +use crate::envelope::{ + EnvelopeKind, MAX_IDENTITY_BYTES, ServiceError, TransportEnvelope, decrypt_invocation, + encrypt_result, validate_envelope, +}; + +pub struct CallerAdmission { + caller_runtime_id: String, + connection_key_epoch: u32, + security: TransportKey, + allowed_models: HashSet, + max_concurrent_requests: usize, + max_payload_bytes: usize, + max_output_tokens: u32, +} + +impl CallerAdmission { + pub fn new( + caller_runtime_id: impl Into, + connection_key: impl Into, + connection_key_epoch: u32, + allowed_models: impl IntoIterator, + max_concurrent_requests: usize, + max_payload_bytes: usize, + max_output_tokens: u32, + ) -> Result { + let caller_runtime_id = caller_runtime_id.into(); + require_id(&caller_runtime_id, "caller_runtime_id")?; + let allowed_models = allowed_models.into_iter().collect::>(); + if connection_key_epoch == 0 + || allowed_models.is_empty() + || caller_runtime_id.len() > MAX_IDENTITY_BYTES + || allowed_models.iter().any(|model| { + require_id(model, "allowed_model").is_err() || model.len() > MAX_IDENTITY_BYTES + }) + || max_concurrent_requests == 0 + || max_payload_bytes == 0 + || max_output_tokens == 0 + { + return Err(ServiceError::InvalidAdmission); + } + let connection_key = Zeroizing::new(connection_key.into()); + let security = TransportKey::from_connection_secret(connection_key.as_str())?; + Ok(Self { + caller_runtime_id, + connection_key_epoch, + security, + allowed_models, + max_concurrent_requests, + max_payload_bytes, + max_output_tokens, + }) + } + + pub fn caller_runtime_id(&self) -> &str { + &self.caller_runtime_id + } + + pub(crate) fn max_concurrent_requests(&self) -> usize { + self.max_concurrent_requests + } + + pub(crate) fn max_payload_bytes(&self) -> usize { + self.max_payload_bytes + } +} + +#[derive(Debug, Clone, PartialEq, Eq, Hash)] +struct RequestIdentity { + caller_runtime_id: String, + request_id: String, +} + +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +enum ReplayState { + Active, + Completed { response: TransportEnvelope }, +} + +#[derive(Clone, Debug, PartialEq, Eq, DatabaseEntry)] +#[cultcache(type = "gamecult.codex.replay_record.v1")] +struct ReplayRecord { + #[cultcache(key = 0)] + caller_runtime_id: String, + #[cultcache(key = 1)] + request_id: String, + #[cultcache(key = 2)] + connection_key_epoch: u32, + #[cultcache(key = 3)] + invocation_sha256: [u8; 32], + #[cultcache(key = 4)] + expires_at_unix_ms: u64, + #[cultcache(key = 5)] + state: ReplayState, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ExecutionClaim { + invocation: TransportInvocation, + invocation_sha256: [u8; 32], +} + +impl ExecutionClaim { + pub fn invocation(&self) -> &TransportInvocation { + &self.invocation + } +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum TransportAdmission { + Execute(Box), + Reply(TransportEnvelope), +} + +pub struct TransportService { + callers: HashMap, + live: HashSet, + replay_store: CultCache, + max_expiry_skew_ms: u64, +} + +impl TransportService { + pub fn open( + replay_store_path: &Path, + admissions: impl IntoIterator, + max_expiry_skew_ms: u64, + ) -> Result { + if max_expiry_skew_ms == 0 { + return Err(ServiceError::InvalidAdmission); + } + let mut callers = HashMap::new(); + let mut keys = HashSet::new(); + for admission in admissions { + if !keys.insert(admission.security.0) { + return Err(ServiceError::SharedCallerKey); + } + let caller_runtime_id = admission.caller_runtime_id.clone(); + if callers.insert(caller_runtime_id, admission).is_some() { + return Err(ServiceError::DuplicateCaller); + } + } + if callers.is_empty() { + return Err(ServiceError::InvalidAdmission); + } + + let mut replay_store = CultCache::new(); + replay_store + .register_entry_type::() + .map_err(replay_store_error)?; + replay_store.add_generic_backing_store( + OwnedRedbMessagePackBackingStore::new(replay_store_path).map_err(replay_store_error)?, + ); + replay_store + .pull_all_backing_stores() + .map_err(replay_store_error)?; + + for (key, record) in replay_store + .get_all_with_keys::() + .map_err(replay_store_error)? + { + let identity = RequestIdentity { + caller_runtime_id: record.caller_runtime_id.clone(), + request_id: record.request_id.clone(), + }; + if key != replay_key(&identity) + || record.invocation_sha256 == [0; 32] + || record.expires_at_unix_ms == 0 + { + return Err(ServiceError::InvalidReplayRecord); + } + let Some(caller) = callers.get(&record.caller_runtime_id) else { + continue; + }; + if record.connection_key_epoch != caller.connection_key_epoch { + return Err(ServiceError::ReplayCallerKeyMismatch); + } + } + Ok(Self { + callers, + live: HashSet::new(), + replay_store, + max_expiry_skew_ms, + }) + } + + /// Admits one request. `max_frame_bytes` is the frame every reply must + /// fit, the same bound `complete` is given: a recorded result that no + /// longer fits it (the service reopened at a smaller frame) is answered + /// with the typed `result_size` failure, which is recorded in its place. + /// A request id longer than `MAX_IDENTITY_BYTES` is refused as malformed, + /// the refusal echoing the id like every other. The connection is dropped + /// (`FrameBound`) only when that sealed refusal does not fit `max_frame_bytes`. + pub fn begin( + &mut self, + envelope: &TransportEnvelope, + now_unix_ms: u64, + max_frame_bytes: usize, + ) -> Result { + validate_envelope(envelope, EnvelopeKind::Invocation)?; + let caller = self + .callers + .get(&envelope.caller_runtime_id) + .ok_or(ServiceError::CallerNotAdmitted)?; + let security = caller.security.clone(); + let max_concurrent_requests = caller.max_concurrent_requests; + let invocation = decrypt_invocation(envelope, &security)?; + if envelope.request_id.len() > MAX_IDENTITY_BYTES { + let refusal = encrypt_result( + &TransportResult::refused(&invocation, Refusal::Malformed), + &security, + )?; + return if fits_frame(&refusal, max_frame_bytes)? { + Ok(TransportAdmission::Reply(refusal)) + } else { + Err(ServiceError::FrameBound) + }; + } + if envelope.ciphertext.len() > caller.max_payload_bytes { + return self.reply_refusal(&invocation, Refusal::Policy, &security); + } + if let Err(error) = invocation.validate(now_unix_ms, self.max_expiry_skew_ms) { + return self.reply_refusal(&invocation, contract_refusal(&error), &security); + } + if !caller.allowed_models.contains(&invocation.request.model) + || invocation + .request + .max_output_tokens + .is_some_and(|limit| limit > caller.max_output_tokens) + { + return self.reply_refusal(&invocation, Refusal::Policy, &security); + } + + let identity = RequestIdentity { + caller_runtime_id: invocation.caller_runtime_id.clone(), + request_id: invocation.request_id().to_string(), + }; + let invocation_sha256: [u8; 32] = + Sha256::digest(canonical_invocation_bytes(&invocation)?).into(); + if let Some(record) = self + .replay_store + .get::(&replay_key(&identity)) + .map_err(replay_store_error)? + { + if record.invocation_sha256 != invocation_sha256 { + return self.reply_refusal(&invocation, Refusal::ReplayConflict, &security); + } + return match &record.state { + ReplayState::Completed { response } => { + if fits_frame(response, max_frame_bytes)? { + return Ok(TransportAdmission::Reply(response.clone())); + } + let stand_in = self.seal_stand_in(&invocation, &security, max_frame_bytes)?; + self.replay_store + .put( + replay_key(&identity), + &ReplayRecord { + state: ReplayState::Completed { + response: stand_in.clone(), + }, + ..record.clone() + }, + ) + .map_err(replay_store_error)?; + Ok(TransportAdmission::Reply(stand_in)) + } + ReplayState::Active => { + let refusal = if self.live.contains(&identity) { + Refusal::InFlight + } else { + Refusal::Indeterminate + }; + self.reply_refusal(&invocation, refusal, &security) + } + }; + } + let active_for_caller = self + .live + .iter() + .filter(|key| key.caller_runtime_id == invocation.caller_runtime_id) + .count(); + if active_for_caller >= max_concurrent_requests { + return self.reply_refusal(&invocation, Refusal::Capacity, &security); + } + + let replay_record = ReplayRecord { + caller_runtime_id: identity.caller_runtime_id.clone(), + request_id: identity.request_id.clone(), + connection_key_epoch: caller.connection_key_epoch, + invocation_sha256, + expires_at_unix_ms: invocation.expires_at_unix_ms, + state: ReplayState::Active, + }; + self.replay_store + .put(replay_key(&identity), &replay_record) + .map_err(replay_store_error)?; + self.live.insert(identity); + Ok(TransportAdmission::Execute(Box::new(ExecutionClaim { + invocation, + invocation_sha256, + }))) + } + + /// Records a result and seals it. `max_frame_bytes` bounds the serialized + /// envelope, which is what a frame carries: a result whose sealed envelope + /// does not fit is recorded as the typed `result_size` failure instead, so + /// no completed record is ever undeliverable and a retry replays bytes + /// that fit. + pub fn complete( + &mut self, + claim: ExecutionClaim, + result: TransportResult, + max_frame_bytes: usize, + ) -> Result { + if !matches!( + &result.disposition, + TransportDisposition::Transported { .. } + ) { + return Err(ServiceError::InvalidCompletion); + } + result.validate_against(&claim.invocation)?; + let identity = RequestIdentity { + caller_runtime_id: claim.invocation.caller_runtime_id.clone(), + request_id: claim.invocation.request_id().to_string(), + }; + if !self.live.contains(&identity) { + return Err(ServiceError::NoActiveRequest); + } + let active = self + .replay_store + .get::(&replay_key(&identity)) + .map_err(replay_store_error)? + .ok_or(ServiceError::NoActiveRequest)?; + if active.invocation_sha256 != claim.invocation_sha256 + || !matches!(active.state, ReplayState::Active) + { + return Err(ServiceError::ActiveRequestMismatch); + } + let caller = self + .callers + .get(&identity.caller_runtime_id) + .ok_or(ServiceError::CallerNotAdmitted)?; + let security = caller.security.clone(); + let mut response = encrypt_result(&result, &security)?; + if !fits_frame(&response, max_frame_bytes)? { + response = match self.seal_stand_in(&claim.invocation, &security, max_frame_bytes) { + Ok(stand_in) => stand_in, + Err(error) => { + // Nothing is recorded, but the caller's slot is not held + // by a request that can no longer finish. + self.live.remove(&identity); + return Err(error); + } + }; + } + let caller = self + .callers + .get(&identity.caller_runtime_id) + .ok_or(ServiceError::CallerNotAdmitted)?; + let replay_record = ReplayRecord { + caller_runtime_id: identity.caller_runtime_id.clone(), + request_id: identity.request_id.clone(), + connection_key_epoch: caller.connection_key_epoch, + invocation_sha256: claim.invocation_sha256, + expires_at_unix_ms: claim.invocation.expires_at_unix_ms, + state: ReplayState::Completed { + response: response.clone(), + }, + }; + self.replay_store + .put(replay_key(&identity), &replay_record) + .map_err(replay_store_error)?; + self.live.remove(&identity); + Ok(response) + } + + /// The `result_size` failure sealed for `invocation`, if it fits the frame. + fn seal_stand_in( + &self, + invocation: &TransportInvocation, + security: &TransportKey, + max_frame_bytes: usize, + ) -> Result { + let stand_in = encrypt_result( + &result_size_failure( + &invocation.caller_runtime_id, + invocation.request_id(), + &invocation.request.model, + invocation.native_request_sha256, + invocation.provider_request_sha256, + ), + security, + )?; + if fits_frame(&stand_in, max_frame_bytes)? { + Ok(stand_in) + } else { + Err(ServiceError::FrameBound) + } + } + + fn reply_refusal( + &self, + invocation: &TransportInvocation, + refusal: Refusal, + security: &TransportKey, + ) -> Result { + Ok(TransportAdmission::Reply(encrypt_result( + &TransportResult::refused(invocation, refusal), + security, + )?)) + } +} + +fn fits_frame(envelope: &TransportEnvelope, max_frame_bytes: usize) -> Result { + rmp_serde::to_vec(envelope) + .map(|bytes| bytes.len() <= max_frame_bytes) + .map_err(|_| ServiceError::Encoding) +} + +/// The transport named on the stand-in failure, fixed so its size is bounded. +const STAND_IN_TRANSPORT: &str = "model-connector"; + +/// The failed result that stands in for one whose sealed frame cannot be +/// carried: the same shape a provider failure takes, with nothing a provider +/// controls but the identities the invocation already bound. +pub(crate) fn result_size_failure( + caller_runtime_id: &str, + request_id: &str, + model: &str, + native_request_sha256: [u8; 32], + provider_request_sha256: [u8; 32], +) -> TransportResult { + TransportResult { + schema_id: RESULT_SCHEMA_ID.to_string(), + request_id: request_id.to_string(), + caller_runtime_id: caller_runtime_id.to_string(), + disposition: TransportDisposition::Transported { + events: Vec::new(), + receipt: Box::new(TransportReceipt { + schema_id: RECEIPT_SCHEMA_ID.to_string(), + request_id: request_id.to_string(), + caller_runtime_id: caller_runtime_id.to_string(), + native_request_sha256, + provider_request_sha256, + model: model.to_string(), + transport: STAND_IN_TRANSPORT.to_string(), + outcome: TransportOutcome::Failed { + failure_kind: "result_size".to_string(), + message: "provider result exceeded its byte bound".to_string(), + }, + }), + }, + } +} + +fn replay_key(identity: &RequestIdentity) -> String { + let mut hasher = Sha256::new(); + hasher.update((identity.caller_runtime_id.len() as u64).to_be_bytes()); + hasher.update(identity.caller_runtime_id.as_bytes()); + hasher.update((identity.request_id.len() as u64).to_be_bytes()); + hasher.update(identity.request_id.as_bytes()); + hex(hasher.finalize().into()) +} + +fn hex(digest: [u8; 32]) -> String { + const HEX: &[u8; 16] = b"0123456789abcdef"; + let mut text = String::with_capacity(64); + for byte in digest { + text.push(HEX[(byte >> 4) as usize] as char); + text.push(HEX[(byte & 0x0f) as usize] as char); + } + text +} + +fn replay_store_error(error: impl std::fmt::Display) -> ServiceError { + ServiceError::ReplayStore(error.to_string()) +} + +fn contract_refusal(error: &ContractError) -> Refusal { + match error { + ContractError::Expiry => Refusal::Expired, + ContractError::Identity => Refusal::IdentitySubstitution, + ContractError::ProviderDigest => Refusal::ProviderDigestSubstitution, + _ => Refusal::Malformed, + } +} + +#[cfg(all(test, feature = "daemon"))] +mod tests { + use super::*; + use crate::fixtures::*; + use crate::{contract::*, envelope::*}; + + const FRAME: usize = 1 << 20; + + #[test] + fn service_refuses_shared_keys_and_isolates_caller_request_identities() { + let store = TestStore::new(); + assert!(matches!( + TransportService::open( + &store.path, + [ + caller("epiphany-yggdrasil", "shared-key", 1), + caller("ghostlight-yggdrasil", "shared-key", 1), + ], + 5_000, + ), + Err(ServiceError::SharedCallerKey) + )); + + let epiphany = invocation_for("epiphany-yggdrasil", "shared-request-id"); + let ghostlight = invocation_for("ghostlight-yggdrasil", "shared-request-id"); + let mut service = open_service(&store, 1); + assert!(matches!( + service + .begin( + &encrypt_invocation(&epiphany, &security("epiphany-distinct-test-key")) + .unwrap(), + 1_000, + FRAME, + ) + .unwrap(), + TransportAdmission::Execute(_) + )); + assert!(matches!( + service + .begin( + &encrypt_invocation(&ghostlight, &security("ghostlight-distinct-test-key")) + .unwrap(), + 1_000, + FRAME, + ) + .unwrap(), + TransportAdmission::Execute(_) + )); + } + + #[test] + fn service_returns_exact_completed_replay_and_refuses_conflicting_replay() { + let store = TestStore::new(); + let invocation = invocation(); + let security = security("epiphany-distinct-test-key"); + let mut service = open_service(&store, 1); + let claim = match service + .begin( + &encrypt_invocation(&invocation, &security).unwrap(), + 1_000, + FRAME, + ) + .unwrap() + { + TransportAdmission::Execute(claim) => claim, + TransportAdmission::Reply(_) => panic!("first invocation must execute"), + }; + let response = service + .complete( + *claim, + TransportResult::transported( + &invocation, + vec![TransportEvent { + sequence: 0, + payload: TransportEventPayload::ToolCall { + call_id: "call-1".to_string(), + name: "read_source".to_string(), + arguments: "{}".to_string(), + }, + }], + receipt(&invocation), + ), + 1 << 20, + ) + .unwrap(); + drop(service); + let mut service = open_service(&store, 1); + + let replay = match service + .begin( + &encrypt_invocation(&invocation, &security).unwrap(), + 1_000, + FRAME, + ) + .unwrap() + { + TransportAdmission::Reply(response) => response, + TransportAdmission::Execute(_) => panic!("completed invocation re-executed"), + }; + assert_eq!(replay, response); + let decoded = decrypt_result(&replay, &security, &invocation).unwrap(); + assert!(matches!( + decoded.disposition, + TransportDisposition::Transported { .. } + )); + + let mut changed_request = request(); + changed_request.instructions.push_str(" Different cargo."); + let conflicting = + TransportInvocation::new("epiphany-yggdrasil", 2_000, [7; 32], changed_request) + .unwrap(); + let refusal = match service + .begin( + &encrypt_invocation(&conflicting, &security).unwrap(), + 1_000, + FRAME, + ) + .unwrap() + { + TransportAdmission::Reply(response) => response, + TransportAdmission::Execute(_) => panic!("conflicting replay executed"), + }; + assert!(matches!( + decrypt_result(&refusal, &security, &conflicting) + .unwrap() + .disposition, + TransportDisposition::Refused(Refusal::ReplayConflict) + )); + } + + #[test] + fn service_refuses_duplicate_inflight_and_per_caller_capacity() { + let store = TestStore::new(); + let first = invocation_for("epiphany-yggdrasil", "request-1"); + let second = invocation_for("epiphany-yggdrasil", "request-2"); + let security = security("epiphany-distinct-test-key"); + let mut service = open_service(&store, 1); + let first_envelope = encrypt_invocation(&first, &security).unwrap(); + assert!(matches!( + service.begin(&first_envelope, 1_000, FRAME).unwrap(), + TransportAdmission::Execute(_) + )); + + for (invocation, expected) in [(&first, Refusal::InFlight), (&second, Refusal::Capacity)] { + let response = match service + .begin( + &encrypt_invocation(invocation, &security).unwrap(), + 1_000, + FRAME, + ) + .unwrap() + { + TransportAdmission::Reply(response) => response, + TransportAdmission::Execute(_) => panic!("refused invocation executed"), + }; + assert_eq!( + decrypt_result(&response, &security, invocation) + .unwrap() + .disposition, + TransportDisposition::Refused(expected) + ); + } + } + + #[test] + fn restart_refuses_ambiguous_claim_without_consuming_live_capacity() { + let store = TestStore::new(); + let first = invocation_for("epiphany-yggdrasil", "request-1"); + let second = invocation_for("epiphany-yggdrasil", "request-2"); + let security = security("epiphany-distinct-test-key"); + let mut service = open_service(&store, 1); + assert!(matches!( + service + .begin( + &encrypt_invocation(&first, &security).unwrap(), + 1_000, + FRAME + ) + .unwrap(), + TransportAdmission::Execute(_) + )); + drop(service); + + let mut restarted = open_service(&store, 1); + let ambiguous = match restarted + .begin( + &encrypt_invocation(&first, &security).unwrap(), + 1_000, + FRAME, + ) + .unwrap() + { + TransportAdmission::Reply(response) => response, + TransportAdmission::Execute(_) => panic!("ambiguous claim re-executed"), + }; + assert_eq!( + decrypt_result(&ambiguous, &security, &first) + .unwrap() + .disposition, + TransportDisposition::Refused(Refusal::Indeterminate) + ); + assert!(matches!( + restarted + .begin( + &encrypt_invocation(&second, &security).unwrap(), + 1_000, + FRAME + ) + .unwrap(), + TransportAdmission::Execute(_) + )); + drop(restarted); + assert!(matches!( + TransportService::open( + &store.path, + [CallerAdmission::new( + "epiphany-yggdrasil", + "rotated-connection-key", + 2, + ["gpt-5.4".to_string()], + 1, + 64 * 1024, + 32_768, + ) + .unwrap()], + 5_000, + ), + Err(ServiceError::ReplayCallerKeyMismatch) + )); + } + + const CALLER: &str = "epiphany-yggdrasil"; + const KEY: &str = "epiphany-distinct-test-key"; + + fn run( + service: &mut TransportService, + request_id: &str, + edit: impl FnOnce(&mut ProviderRequest), + ) -> (TransportAdmission, TransportInvocation) { + let mut request = invocation_for(CALLER, request_id).request; + edit(&mut request); + let invocation = TransportInvocation::new(CALLER, 2_000, [7; 32], request).unwrap(); + let envelope = encrypt_invocation(&invocation, &security(KEY)).unwrap(); + (service.begin(&envelope, 1_000, FRAME).unwrap(), invocation) + } + + fn refusal_of(admission: TransportAdmission, invocation: &TransportInvocation) -> Refusal { + let TransportAdmission::Reply(envelope) = admission else { + panic!("expected a refusal reply"); + }; + match decrypt_result(&envelope, &security(KEY), invocation) + .unwrap() + .disposition + { + TransportDisposition::Refused(refusal) => refusal, + TransportDisposition::Transported { .. } => panic!("expected a refusal"), + } + } + + fn raw_envelope(invocation: &TransportInvocation) -> TransportEnvelope { + encrypt_message( + &invocation.caller_runtime_id, + invocation.request_id(), + EnvelopeKind::Invocation, + &rmp_serde::to_vec(invocation).unwrap(), + &security(KEY), + ) + .unwrap() + } + + #[test] + fn caller_admission_reports_its_runtime_id() { + assert_eq!( + caller("ghostlight-yggdrasil", "some-key", 1).caller_runtime_id(), + "ghostlight-yggdrasil" + ); + } + + #[test] + fn policy_refuses_unlisted_models_and_output_tokens_over_the_caller_cap() { + let store = TestStore::new(); + let mut service = open_service(&store, 8); + + let (admission, invocation) = run(&mut service, "unlisted-model", |request| { + request.model = "other-model".to_string(); + }); + assert_eq!(refusal_of(admission, &invocation), Refusal::Policy); + + let (admission, invocation) = run(&mut service, "over-cap", |request| { + request.max_output_tokens = Some(32_769); + }); + assert_eq!(refusal_of(admission, &invocation), Refusal::Policy); + + for (id, tokens) in [ + ("at-cap", Some(32_768)), + ("under-cap", Some(100)), + ("unset", None), + ] { + let (admission, _) = run(&mut service, id, |request| { + request.max_output_tokens = tokens; + }); + assert!(matches!(admission, TransportAdmission::Execute(_)), "{id}"); + } + } + + #[test] + fn payload_bound_is_inclusive_of_the_encrypted_size() { + let invocation = invocation_for(CALLER, "sized"); + let envelope = encrypt_invocation(&invocation, &security(KEY)).unwrap(); + let size = envelope.ciphertext.len(); + let admit = |max_payload_bytes: usize| { + let store = TestStore::new(); + let admission = CallerAdmission::new( + CALLER, + KEY, + 1, + ["gpt-5.4".to_string()], + 4, + max_payload_bytes, + 32_768, + ) + .unwrap(); + TransportService::open(&store.path, [admission], 5_000) + .unwrap() + .begin(&envelope, 1_000, FRAME) + }; + assert!(matches!(admit(size), Ok(TransportAdmission::Execute(_)))); + let refused = admit(size - 1).unwrap(); + assert_eq!(refusal_of(refused, &invocation), Refusal::Policy); + } + + #[test] + fn contract_failures_are_refused_with_the_matching_refusal() { + let store = TestStore::new(); + let mut service = open_service(&store, 8); + + let mut expired = invocation_for(CALLER, "expired"); + expired.expires_at_unix_ms = 10; + let reply = service + .begin(&raw_envelope(&expired), 1_000, FRAME) + .unwrap(); + assert_eq!(refusal_of(reply, &expired), Refusal::Expired); + + let mut substituted = invocation_for(CALLER, "substituted"); + substituted.provider_request_sha256 = [9; 32]; + let reply = service + .begin(&raw_envelope(&substituted), 1_000, FRAME) + .unwrap(); + assert_eq!( + refusal_of(reply, &substituted), + Refusal::ProviderDigestSubstitution + ); + + let mut malformed = invocation_for(CALLER, "malformed"); + malformed.native_request_sha256 = [0; 32]; + let reply = service + .begin(&raw_envelope(&malformed), 1_000, FRAME) + .unwrap(); + assert_eq!(refusal_of(reply, &malformed), Refusal::Malformed); + } + + fn seed(path: &Path, key: &str, edit: impl FnOnce(&mut ReplayRecord)) { + let mut record = ReplayRecord { + caller_runtime_id: CALLER.to_string(), + request_id: "seeded".to_string(), + connection_key_epoch: 1, + invocation_sha256: [1; 32], + expires_at_unix_ms: 5_000, + state: ReplayState::Active, + }; + edit(&mut record); + let mut cache = CultCache::new(); + cache.register_entry_type::().unwrap(); + cache.add_generic_backing_store(OwnedRedbMessagePackBackingStore::new(path).unwrap()); + cache.pull_all_backing_stores().unwrap(); + cache.put(key.to_string(), &record).unwrap(); + } + + fn seeded_key() -> String { + replay_key(&RequestIdentity { + caller_runtime_id: CALLER.to_string(), + request_id: "seeded".to_string(), + }) + } + + #[test] + fn restart_refuses_each_malformed_replay_record() { + let control = TestStore::new(); + seed(&control.path, &seeded_key(), |_| {}); + assert!(TransportService::open(&control.path, [caller(CALLER, KEY, 1)], 5_000).is_ok()); + + type Edit = fn(&mut ReplayRecord); + let cases: [(&str, Edit); 3] = [ + ("wrong-key", |_| {}), + ("zero-digest", |record| record.invocation_sha256 = [0; 32]), + ("zero-expiry", |record| record.expires_at_unix_ms = 0), + ]; + for (name, edit) in cases { + let store = TestStore::new(); + let key = if name == "wrong-key" { + "not-the-replay-key".to_string() + } else { + seeded_key() + }; + seed(&store.path, &key, edit); + assert!( + matches!( + TransportService::open(&store.path, [caller(CALLER, KEY, 1)], 5_000), + Err(ServiceError::InvalidReplayRecord) + ), + "{name}" + ); + } + } + + #[test] + fn completion_refuses_a_claim_that_is_not_the_active_request() { + let store = TestStore::new(); + let mut service = open_service(&store, 4); + let (admission, invocation) = run(&mut service, "request-1", |_| {}); + let TransportAdmission::Execute(claim) = admission else { + panic!("first invocation must execute"); + }; + let result = TransportResult::transported( + claim.invocation(), + vec![TransportEvent { + sequence: 0, + payload: TransportEventPayload::TextDelta { + text: "done".to_string(), + }, + }], + receipt(claim.invocation()), + ); + + let mut forged = (*claim).clone(); + forged.invocation_sha256 = [1; 32]; + assert_eq!( + service + .complete(forged, result.clone(), 1 << 20) + .unwrap_err(), + ServiceError::ActiveRequestMismatch + ); + + let key = replay_key(&RequestIdentity { + caller_runtime_id: CALLER.to_string(), + request_id: invocation.request_id().to_string(), + }); + let mut record = service + .replay_store + .get::(&key) + .unwrap() + .unwrap(); + record.state = ReplayState::Completed { + response: encrypt_result(&result, &security(KEY)).unwrap(), + }; + service.replay_store.put(key, &record).unwrap(); + assert_eq!( + service.complete(*claim, result, 1 << 20).unwrap_err(), + ServiceError::ActiveRequestMismatch + ); + } + + #[test] + fn identities_are_bounded_at_admission_and_on_the_request() { + let admit = |caller_id: String, model: String| { + CallerAdmission::new(caller_id, KEY, 1, [model], 4, 64 * 1024, 32_768) + }; + let at = "i".repeat(MAX_IDENTITY_BYTES); + let over = "i".repeat(MAX_IDENTITY_BYTES + 1); + assert!(admit(at.clone(), at.clone()).is_ok()); + assert!(matches!( + admit(over.clone(), at.clone()), + Err(ServiceError::InvalidAdmission) + )); + assert!(matches!( + admit(at.clone(), over.clone()), + Err(ServiceError::InvalidAdmission) + )); + + let store = TestStore::new(); + let mut service = open_service(&store, 8); + let ok = invocation_for(CALLER, &at); + assert!(matches!( + service.begin(&raw_envelope(&ok), 1_000, FRAME).unwrap(), + TransportAdmission::Execute(_) + )); + } + + #[test] + fn a_request_id_past_the_bound_is_refused_malformed_echoing_the_id() { + for length in [ + MAX_IDENTITY_BYTES + 1, + MAX_IDENTITY_BYTES + 2, + 5_000, + 100_000, + ] { + let store = TestStore::new(); + let mut service = open_service(&store, 1); + let id = "i".repeat(length); + let long = invocation_for(CALLER, &id); + let TransportAdmission::Reply(reply) = + service.begin(&raw_envelope(&long), 1_000, FRAME).unwrap() + else { + panic!("an over-long id must be refused, not executed ({length})"); + }; + assert_eq!(reply.request_id, id, "{length}"); + let result: TransportResult = + rmp_serde::from_slice(&decrypt_message(&reply, &security(KEY)).unwrap()).unwrap(); + assert_eq!( + result.disposition, + TransportDisposition::Refused(Refusal::Malformed) + ); + assert_eq!(result.request_id, id); + result.validate_against(&long).unwrap(); + // Nothing was recorded or held: the caller's one slot is free. + let (_claim, _) = claim_for(&mut service, "after-the-long-one"); + } + } + + #[test] + fn an_over_long_id_drops_the_connection_only_when_its_refusal_cannot_fit() { + let id = "i".repeat(5_000); + let long = invocation_for(CALLER, &id); + let sealed = encrypt_result( + &TransportResult::refused(&long, Refusal::Malformed), + &security(KEY), + ) + .unwrap(); + // The sealed size varies with the random nonce and ciphertext bytes + // (a byte of 128 or more takes two), so bracket it: the ciphertext + // plus the id is a floor, twice the ciphertext plus the id and the + // envelope's other fields is a ceiling. + let floor = sealed.ciphertext.len() + id.len(); + let ceiling = 2 * sealed.ciphertext.len() + id.len() + 512; + let store = TestStore::new(); + let mut service = open_service(&store, 1); + assert!(matches!( + service.begin(&raw_envelope(&long), 1_000, ceiling).unwrap(), + TransportAdmission::Reply(_) + )); + assert_eq!( + service + .begin(&raw_envelope(&long), 1_000, floor - 1) + .unwrap_err(), + ServiceError::FrameBound + ); + let (_claim, _) = claim_for(&mut service, "after-the-drop"); + } + + #[test] + fn a_frame_check_is_inclusive_of_the_serialized_envelope() { + let envelope = + encrypt_invocation(&invocation_for(CALLER, "sized"), &security(KEY)).unwrap(); + let size = rmp_serde::to_vec(&envelope).unwrap().len(); + assert_eq!(fits_frame(&envelope, size), Ok(true)); + assert_eq!(fits_frame(&envelope, size - 1), Ok(false)); + } + + fn claim_for( + service: &mut TransportService, + request_id: &str, + ) -> (Box, TransportInvocation) { + let invocation = invocation_for(CALLER, request_id); + match service + .begin(&raw_envelope(&invocation), 1_000, FRAME) + .unwrap() + { + TransportAdmission::Execute(claim) => (claim, invocation), + TransportAdmission::Reply(_) => panic!("expected an execution claim"), + } + } + + fn text_result( + invocation: &TransportInvocation, + text: &str, + transport: &str, + ) -> TransportResult { + let mut receipt = receipt(invocation); + receipt.transport = transport.to_string(); + TransportResult::transported( + invocation, + vec![TransportEvent { + sequence: 0, + payload: TransportEventPayload::TextDelta { + text: text.to_string(), + }, + }], + receipt, + ) + } + + #[test] + fn a_result_whose_sealed_envelope_exceeds_the_frame_is_recorded_as_the_stand_in_failure() { + let store = TestStore::new(); + let mut service = open_service(&store, 8); + let (claim, invocation) = claim_for(&mut service, "stand-in"); + let big = text_result(&invocation, &"y".repeat(4000), &"t".repeat(5000)); + let response = service.complete(*claim, big, 2000).unwrap(); + assert!(rmp_serde::to_vec(&response).unwrap().len() <= 2000); + let stand_in = decrypt_result(&response, &security(KEY), &invocation).unwrap(); + assert_eq!( + stand_in, + result_size_failure( + CALLER, + "stand-in", + &invocation.request.model, + invocation.native_request_sha256, + invocation.provider_request_sha256, + ) + ); + let TransportDisposition::Transported { receipt, .. } = &stand_in.disposition else { + panic!("expected a transported failure"); + }; + assert_eq!(receipt.transport, "model-connector"); + + // The recorded replay is the stand-in's exact bytes. + let TransportAdmission::Reply(replay) = service + .begin(&raw_envelope(&invocation), 1_000, FRAME) + .unwrap() + else { + panic!("expected a replay"); + }; + assert_eq!(replay, response); + } + + #[test] + fn a_result_whose_sealed_envelope_fits_is_recorded_as_the_backend_made_it() { + let store = TestStore::new(); + let mut service = open_service(&store, 8); + let (claim, invocation) = claim_for(&mut service, "fits"); + let small = text_result(&invocation, "done", "fixture-sse"); + let response = service.complete(*claim, small.clone(), 1 << 20).unwrap(); + assert_eq!( + decrypt_result(&response, &security(KEY), &invocation).unwrap(), + small + ); + } + + #[test] + fn a_frame_bound_failure_releases_the_callers_slot() { + let store = TestStore::new(); + let mut service = open_service(&store, 1); + let (claim, invocation) = claim_for(&mut service, "wedge"); + let big = text_result(&invocation, "y", "fixture-sse"); + assert_eq!( + service.complete(*claim, big, 10).unwrap_err(), + ServiceError::FrameBound + ); + // The caller's only slot is free again, and the dead request is no + // longer in flight. + let (_next, _) = claim_for(&mut service, "after-wedge"); + let TransportAdmission::Reply(reply) = service + .begin(&raw_envelope(&invocation), 1_000, FRAME) + .unwrap() + else { + panic!("expected a refusal"); + }; + assert_eq!( + decrypt_result(&reply, &security(KEY), &invocation) + .unwrap() + .disposition, + TransportDisposition::Refused(Refusal::Indeterminate) + ); + } + + #[test] + fn a_recorded_result_replayed_after_a_restart_at_a_smaller_frame_is_re_measured() { + let store = TestStore::new(); + let mut service = open_service(&store, 8); + let (claim, invocation) = claim_for(&mut service, "restart"); + let big = text_result(&invocation, &"y".repeat(4000), "fixture-sse"); + let recorded = service.complete(*claim, big, FRAME).unwrap(); + assert!(rmp_serde::to_vec(&recorded).unwrap().len() > 2000); + drop(service); + + let mut service = open_service(&store, 8); + let TransportAdmission::Reply(same) = service + .begin(&raw_envelope(&invocation), 1_000, FRAME) + .unwrap() + else { + panic!("expected a replay"); + }; + assert_eq!(same, recorded, "a frame that still fits replays the record"); + drop(service); + + let mut service = open_service(&store, 8); + let TransportAdmission::Reply(smaller) = service + .begin(&raw_envelope(&invocation), 1_000, 2000) + .unwrap() + else { + panic!("expected a replay"); + }; + assert!(rmp_serde::to_vec(&smaller).unwrap().len() <= 2000); + assert_eq!( + decrypt_result(&smaller, &security(KEY), &invocation).unwrap(), + result_size_failure( + CALLER, + "restart", + &invocation.request.model, + invocation.native_request_sha256, + invocation.provider_request_sha256, + ) + ); + // The stand-in is what is recorded now, byte for byte, at any frame. + let TransportAdmission::Reply(again) = service + .begin(&raw_envelope(&invocation), 1_000, FRAME) + .unwrap() + else { + panic!("expected a replay"); + }; + assert_eq!(again, smaller); + } + + #[test] + fn a_replay_whose_stand_in_cannot_fit_either_is_a_frame_bound_failure() { + let store = TestStore::new(); + let mut service = open_service(&store, 8); + let (claim, invocation) = claim_for(&mut service, "no-room"); + let big = text_result(&invocation, "y", "fixture-sse"); + service.complete(*claim, big, FRAME).unwrap(); + assert_eq!( + service.begin(&raw_envelope(&invocation), 1_000, 10).err(), + Some(ServiceError::FrameBound) + ); + } + + #[test] + fn nothing_is_recorded_when_not_even_the_stand_in_fits() { + let store = TestStore::new(); + let mut service = open_service(&store, 8); + let (claim, invocation) = claim_for(&mut service, "nothing-fits"); + let big = text_result(&invocation, "y", "fixture-sse"); + assert_eq!( + service.complete(*claim, big, 10).unwrap_err(), + ServiceError::FrameBound + ); + // Still active, never completed, and no longer live: the retry is told the + // outcome is indeterminate. + let TransportAdmission::Reply(reply) = service + .begin(&raw_envelope(&invocation), 1_000, FRAME) + .unwrap() + else { + panic!("expected a refusal"); + }; + assert_eq!( + decrypt_result(&reply, &security(KEY), &invocation) + .unwrap() + .disposition, + TransportDisposition::Refused(Refusal::Indeterminate) + ); + } +} diff --git a/crates/model-connector/tests/fixtures/wire_v2/.gitattributes b/crates/model-connector/tests/fixtures/wire_v2/.gitattributes new file mode 100644 index 0000000..bf62110 --- /dev/null +++ b/crates/model-connector/tests/fixtures/wire_v2/.gitattributes @@ -0,0 +1,2 @@ +*.mp binary +*.bin binary diff --git a/crates/model-connector/tests/fixtures/wire_v2/README.md b/crates/model-connector/tests/fixtures/wire_v2/README.md new file mode 100644 index 0000000..476b362 --- /dev/null +++ b/crates/model-connector/tests/fixtures/wire_v2/README.md @@ -0,0 +1,21 @@ +# Golden v2 wire fixtures + +Bytes of the v2 connector wire, produced once by CodexConnector at commit +`fd7cec4176f4e3f9a5aaa75e77ba10ce5e1522f8`. Ghostlight pins CodexConnector +`68fe94b`, not that commit; `src/lib.rs`, the whole v2 wire, is identical in +both, so these bytes are Ghostlight's wire. They are pinned by `tests/wire_golden_v2.rs`, so the kit cannot drift from +them. No CodexConnector code or dependency is part of this repository. + +Generator: `generator.rs` (not compiled here). Command, run in a clone of +CodexConnector at that commit: + + cp /crates/model-connector/tests/fixtures/wire_v2/generator.rs examples/gen_wire_v2.rs + cargo run --locked -q --example gen_wire_v2 + +It prints `FIXTURE ` lines; each file is the hex decoded. It was +run on Yggdrasil through the verify container with codex-connector at that +commit as a dev-dependency of the kit at d1d28f0 (same source). The `.mp` +files are rmp-serde output; `envelope_*` files carry random nonces and +AES-256-GCM ciphertext sealed under the connection key +`parity-distinct-test-key`. Regenerating changes the envelopes' bytes; the +plaintext fixtures must regenerate byte-identically. diff --git a/crates/model-connector/tests/fixtures/wire_v2/canonical_invocation.bin b/crates/model-connector/tests/fixtures/wire_v2/canonical_invocation.bin new file mode 100644 index 0000000000000000000000000000000000000000..727edf6cf9555f9c02825cc276855e2a1ffeb884 GIT binary patch literal 413 zcmah_u}T9$6bv@0Z7i&gE7HiFoW#aX1wSCfI&AhH$?E0y+uq!1gY8Xhx`nYo%}N~{9B>Y>f03pE=R^K~E<7K9&4&QM6- z4tItDQv&N2XPyUYlAg{}M*1Hl5}MK*&?h}-={-fGqYdgvy57tXQA`86*rs#(P?aL} zw&<0<=pjE(t#i841Mt2KA z4s^2CP!4L|aDs1QihXX}II>8El<4Id;K zSMYR|mBcT^`T80O8mUM;wcRkl575y3C6FYvA6FrmXbak>9;KbM-M~3}BduM$s;E4D z_mFxNT;&y((^>nr1NsQ@vd06mbobA>$?{E{`Qo_n-H+wO6-cBW8zSgrze#*e8BI#C bOXhCxpoq=7-8aTV|7^A=y_J$G2T%V2OD}Km literal 0 HcmV?d00001 diff --git a/crates/model-connector/tests/fixtures/wire_v2/envelope_invocation.mp b/crates/model-connector/tests/fixtures/wire_v2/envelope_invocation.mp new file mode 100644 index 0000000..3f95738 --- /dev/null +++ b/crates/model-connector/tests/fixtures/wire_v2/envelope_invocation.mp @@ -0,0 +1,6 @@ +–Ù$gamecult.codex.transport_envelope.v2­parity-caller©request-1ªInvocationœÌ£VÌÁḩ̃̌xÌ©Y̽Ü­hÌ·ÌýÌâÌ—Ì…3ÌÕ ÌÇÌþÌ‘ÌÜḩ̨Ìé[̪ÌÙ!AÌë̉ÌðÌúÌÛ;ÌëÌú| ̪ÌÉ`!ÌúÌú̧̚Ì� +Ì�kÌÞ'vCZÌà̖̌̈ 8J̧ÌÀḍ̗NÌ¿FÌ¿zm6Ìÿ +Ì÷ÌíÌð\\NMqÌ–Ì×Ì— Ìù1ÌæÌ¦Ì¯Ìè 4*{ÌË̥̱ +ÌÖ̦eÌÇ̨Ì� +̰̆ḭG*SÌ�̳̼ÌÒ̥̮ÌÜ!NÌÀÌÑ̠̫̙̂7Ì�ÌÇ̰Ìì̃ÌÀ̬c̦ÌË ÌÒ̲1kÌá~hÌß3$ÌÚÌ KÌß̰ÌùÌÜ4{ÌÓÌß̼̩U.)ÌÌ̭̤rÌ ÌýÌìÌÆ^bbÏy^̳ÌßÌÌÌ�ÌÉ:Ì Ìš-ÌåYXoÌÓÌÒ[Qy̘̫ +XÌÓI:3̽ÌÔ̰D&V'-ÌòufÌŒX1ÌøÌÒ.Ì�|4cÌãÌ‹EÌåAÌßÌæÌŸÌÍQÌÝLÌ‚-Ìáw̰uÌ´3̹>Ìï$QzÌÀk m'+ÌÐÌ¢G Ì´sÌ®00 f)Ì·C̯̙*̆Ì’ÌÛy~ÌùHzÌøÌåÌý"ÌÇÌ¢*%ÌôoÌÉ̯ÌÙxÌŽ}Ì‹ÌÏÌÝYÌÈCÌîÌ´Ì¿uÌü̸x/gÌëÌñ̘X-ÌÙÌÂ̮̆NÌìRI):HÌñÌÉÌ’ÌÀ̗̉fÌÛÌ '\s̨ Ì�s̉ÌÄGVaÌÓ̶̲C\\`=p ÌžwÌŽHÌÖ4ÌÆÌØ>lfLÌ£ÌþÌäÌÖvÌ¿ÌÞJÌë \ No newline at end of file diff --git a/crates/model-connector/tests/fixtures/wire_v2/envelope_result_failed.mp b/crates/model-connector/tests/fixtures/wire_v2/envelope_result_failed.mp new file mode 100644 index 0000000..4b94559 --- /dev/null +++ b/crates/model-connector/tests/fixtures/wire_v2/envelope_result_failed.mp @@ -0,0 +1,7 @@ +–Ù$gamecult.codex.transport_envelope.v2­parity-caller©request-1¦ResultœÌ…Ì¢ÌÇ"̇Ì�GÌ©ÜCÌôÌË Ì�ÌéHÌßÌ—ÌÿÌÐÌ´ÌÝÌá\Ìö]2/ÌÎe"̘ +Ì¿ÌáÌìÌü{GÌÚṴ̧^b3Ìûj̢̗̟ÌÃÌ’sÌ•*Ì» ÌøÌä= ̸̄<ÌÁ{ÌýÌã.ÌØ8~̫̓CÌâ +̱̘ÌË +[Ì‚ÌÉoÌ–(47ÌÌBML̲Ìó̢̳Ì�hp,ÌîÌ®wÌê6DHÌðbÌÏÌÌb̹@a̧ÌÑ̵̎Ì´Ì�kÌäÌ’ÌáÌóÌ€ÌÑN+ÌüMÌÀjG %Ìß/Ìô +ÌÁÌò̸%Ì»Ì�@sT`ÌâÌ’}P̈ÌÒ ÌëÌÊÌÞ Ì«Ìâ:ÌÚ̟̖̔Ìû^Ìô>ÌûX.x +r]F8Ì�ÌÛÌ‹Ì÷Ì�5ÌÚGÌÆÌºÌÃÌ®C@(ÌóÌÓ̾{̾ÌÙMÌÓ~)Ìà 7ÌÐÌòI+'v ÌøUnP̈$U(NÌêÌçÌüsÌ¢T^[ÌÊYÌæÌ�zÌâ.wÌÆeÌ™C̦̿̄"U^trÌöÌ�ÌÙ̦Ìï̯̬ÌçÌÜÌž+ÌÑ/b̯̭̼Ìî*$Ì× ÌŠÌ²̘ÌÕ\K5̧f +Ìò̃b}̦̬o.Ẁ̧&Ìå̶̑[ \ No newline at end of file diff --git a/crates/model-connector/tests/fixtures/wire_v2/envelope_result_refused_replay_conflict.mp b/crates/model-connector/tests/fixtures/wire_v2/envelope_result_refused_replay_conflict.mp new file mode 100644 index 0000000000000000000000000000000000000000..10e3726559fa374cd41b9b35c34f97bac50dd09f GIT binary patch literal 252 zcmVow+yG@3%-hVD z%)-o`e9W^F%(2YrHq4O`%&g4Y%-PK1%>53`kY&ujNzCoc-OSp|w9K_c%%Ty@ifzpJ zJj}?<;6FXg+01~<_{@#Wrp)EciF;?v?9B4a_H4|q%)!jT%Ql; zi5fzshECDCNW}`R(A1wvVW6QFW|7*^Bqk9?Zye6md7krpxMw0vhEmB`Zg$2Pt!Jdl z7^mG}aoDVOXQ5=Nkjz$_6jKq~Wi!}|omEPs!EBc7?RKfGTyi*-syn4ED39bBK5pXM&8R@Qxs6ZBQ#IeUjNv`M)xrji^lP%uo>cK@ zpG#LH@t3dkFp#9qSJTFNW*NN&eq43gXiHVn%D9*x+}8vNls7if$wYD>wQ{Btz)US` z$LVD*I5aOxXCia)mWb&tvm&pzzeF0o!EU`K=h0mjI1=2l>fRR&Ep?86Mn4F7akFlQ~vTr?D8%#2&3uBfQo!!-ARxZ31hwSed*K zQOXzQbPNfxk(|OqxYNUU!^$>>h504HWSxP0R`Dpb^z%o-6{c7Y=Q&S!C$@_b>O`zM z8Vg0CgS=uY!GcdOy6oX{%_%nYagKC0&har!oHxY`pWq>%=se6yG4;n}ypE$aN`xM- m;cgo3J5|v_`|+*}=K1Yk!F9bL`tp+sO65GD`6!Dt9Qg;?kz>{X literal 0 HcmV?d00001 diff --git a/crates/model-connector/tests/fixtures/wire_v2/generator.rs b/crates/model-connector/tests/fixtures/wire_v2/generator.rs new file mode 100644 index 0000000..baf36cc --- /dev/null +++ b/crates/model-connector/tests/fixtures/wire_v2/generator.rs @@ -0,0 +1,171 @@ +//! Generator of the golden v2 wire fixtures beside it. It is not compiled by this +//! workspace: it was run once, as an example of CodexConnector at commit +//! fd7cec4176f4e3f9a5aaa75e77ba10ce5e1522f8 (see README.md here for the command). +//! It prints one `FIXTURE ` line per file. + +use codex_connector as v2; +use serde::Serialize; + +const KEY: &str = "parity-distinct-test-key"; +const CALLER: &str = "parity-caller"; + +fn bytes(value: &T) -> Vec { + rmp_serde::to_vec(value).unwrap() +} + +fn emit(name: &str, data: &[u8]) { + let hex: String = data.iter().map(|byte| format!("{byte:02x}")).collect(); + println!("FIXTURE {name} {hex}"); +} + +fn request(choice: v2::CodexToolChoice) -> v2::CodexProviderRequest { + let mut request = + v2::CodexProviderRequest::new("request-1", "conversation-1", "gpt-5.4", "Be exact."); + request.input = vec![ + v2::CodexInputItem::UserText { + text: "state".to_string(), + }, + v2::CodexInputItem::AssistantText { + text: "reply".to_string(), + }, + v2::CodexInputItem::ToolCall { + call_id: "call-1".to_string(), + name: "read_source".to_string(), + arguments: "{}".to_string(), + }, + v2::CodexInputItem::ToolResult { + call_id: "call-1".to_string(), + output: "ok".to_string(), + }, + ]; + request.tools = vec![v2::CodexToolDefinition { + name: "read_source".to_string(), + description: "Read one admitted source.".to_string(), + parameters_json: r#"{"type":"object"}"#.to_string(), + }]; + request.tool_choice = choice; + request.reasoning_effort = Some("high".to_string()); + request.parallel_tool_calls = true; + request.max_output_tokens = Some(512); + request.prompt_cache_key = Some("cache-1".to_string()); + request +} + +fn invocation(choice: v2::CodexToolChoice) -> v2::CodexTransportInvocation { + v2::CodexTransportInvocation::new(CALLER, 2_000, [7; 32], request(choice)).unwrap() +} + +fn receipt( + invocation: &v2::CodexTransportInvocation, + outcome: v2::CodexTransportOutcome, +) -> v2::CodexTransportReceipt { + v2::CodexTransportReceipt { + schema_id: v2::RECEIPT_SCHEMA_ID.to_string(), + request_id: invocation.request_id().to_string(), + caller_runtime_id: invocation.caller_runtime_id.clone(), + native_request_sha256: invocation.native_request_sha256, + provider_request_sha256: invocation.provider_request_sha256, + model: invocation.request.model.clone(), + transport: "parity-sse".to_string(), + outcome, + } +} + +fn main() { + let key = v2::CodexTransportKey::from_connection_secret(KEY).unwrap(); + let auto = invocation(v2::CodexToolChoice::Auto); + let required = invocation(v2::CodexToolChoice::Required); + emit("invocation_auto.mp", &bytes(&auto)); + emit("invocation_required.mp", &bytes(&required)); + emit( + "canonical_invocation.bin", + &v2::canonical_invocation_bytes(&required).unwrap(), + ); + emit( + "canonical_provider_request.bin", + &v2::canonical_provider_request_bytes(&required.request).unwrap(), + ); + emit( + "provider_request_sha256.bin", + &v2::provider_request_sha256(&required.request).unwrap(), + ); + emit( + "envelope_invocation.mp", + &bytes(&v2::encrypt_invocation(&required, &key).unwrap()), + ); + + let completed = v2::CodexTransportResult::transported( + &required, + vec![ + v2::CodexTransportEvent { + sequence: 0, + payload: v2::CodexTransportEventPayload::TextDelta { + text: "hello".to_string(), + }, + }, + v2::CodexTransportEvent { + sequence: 1, + payload: v2::CodexTransportEventPayload::ToolCall { + call_id: "call-2".to_string(), + name: "read_source".to_string(), + arguments: "{}".to_string(), + }, + }, + ], + receipt( + &required, + v2::CodexTransportOutcome::Completed { + provider_response_id: Some("response-1".to_string()), + input_tokens: Some(10), + output_tokens: Some(3), + reasoning_output_tokens: Some(1), + cached_input_tokens: Some(4), + }, + ), + ); + emit("result_transported.mp", &bytes(&completed)); + emit( + "envelope_result_transported.mp", + &bytes(&v2::encrypt_result(&completed, &key).unwrap()), + ); + + let failed = v2::CodexTransportResult::transported( + &required, + Vec::new(), + receipt( + &required, + v2::CodexTransportOutcome::Failed { + failure_kind: "result_size".to_string(), + message: "provider result exceeded its byte bound".to_string(), + }, + ), + ); + emit("result_failed.mp", &bytes(&failed)); + emit( + "envelope_result_failed.mp", + &bytes(&v2::encrypt_result(&failed, &key).unwrap()), + ); + + use v2::CodexRefusal as R; + let refusals = [ + ("expired", R::Expired), + ("identity_substitution", R::IdentitySubstitution), + ("provider_digest_substitution", R::ProviderDigestSubstitution), + ("policy", R::Policy), + ("capacity", R::Capacity), + ("in_flight", R::InFlight), + ("indeterminate", R::Indeterminate), + ("replay_conflict", R::ReplayConflict), + ("malformed", R::Malformed), + ]; + for (name, refusal) in refusals { + let refused = v2::CodexTransportResult::refused(&required, refusal); + emit(&format!("result_refused_{name}.mp"), &bytes(&refused)); + if name == "replay_conflict" { + emit( + "envelope_result_refused_replay_conflict.mp", + &bytes(&v2::encrypt_result(&refused, &key).unwrap()), + ); + } + } +} diff --git a/crates/model-connector/tests/fixtures/wire_v2/invocation_auto.mp b/crates/model-connector/tests/fixtures/wire_v2/invocation_auto.mp new file mode 100644 index 0000000000000000000000000000000000000000..e505ca2bba33dc48ec8cfe6a607ba0f19a5c34eb GIT binary patch literal 408 zcmah_Jxjw-6b&w2Ttsj+WY9qph$w=a3XTq?4o>y?a%rP!UVQH+w3H6{1431t9HdnH zAr&0OrRv~v3msevot<3t7ns@(;^LVOmvb)XoQuIijun819$H*DP_$4oUj;&9*7YO7 z8M?x^qUBD&q>Ck!GtUFrOZA7Ep85xgga-SZzLuy>C)D4jXCiu{*F}1#H-j$M_ULm? zqpMk)hV(H_kMlc2ZDwHni4;f?xemyzgyRqtCUII5YT9>P_z_6;6V$XDxd6>oYo)ab zIusd4>pX7mDv%i{V)a@fLx?*YO1TO3aeS2!c%`_R5yIO_`kmd3TAKE)1mx`?7(h0E~4*92^2i Tqf%!fikEJ0N^5Y&^I+QF(pIMz literal 0 HcmV?d00001 diff --git a/crates/model-connector/tests/fixtures/wire_v2/invocation_required.mp b/crates/model-connector/tests/fixtures/wire_v2/invocation_required.mp new file mode 100644 index 0000000000000000000000000000000000000000..727edf6cf9555f9c02825cc276855e2a1ffeb884 GIT binary patch literal 413 zcmah_u}T9$6bv@0Z7i&gE7HiFoW#aX1wSCfI&AhH$?E0y+uq!1gY8Xhx`nYo%}N~{9B>Y>f03pE=R^K~E<7K9&4&QM6- z4tItDQv&N2XPyUYlAg{}M*1Hl5}MK*&?h}-={-fGqYdgvy57tXQA`86*rs#(P?aL} zw&<0<=pjE(t#i841Mt2KA z4s^2CwXig`xJ1`*Z9!sD zW=W-Pa$-(SYEk3r5SZfBlt~k2+*HP6R&r`)0Y1a-FetDS06-;YE}XfWdgkYueI{of zFq~QECU#~@{+U@)YG=-zS#f6NnRfj%&1W7JElA5BBSyHT!R9TX$ Kkd$AVmjVEMQG6Ky literal 0 HcmV?d00001 diff --git a/crates/model-connector/tests/fixtures/wire_v2/result_refused_capacity.mp b/crates/model-connector/tests/fixtures/wire_v2/result_refused_capacity.mp new file mode 100644 index 0000000..4425e6a --- /dev/null +++ b/crates/model-connector/tests/fixtures/wire_v2/result_refused_capacity.mp @@ -0,0 +1 @@ +”Ù"gamecult.codex.transport_result.v2©request-1­parity-caller�§Refused¨Capacity \ No newline at end of file diff --git a/crates/model-connector/tests/fixtures/wire_v2/result_refused_expired.mp b/crates/model-connector/tests/fixtures/wire_v2/result_refused_expired.mp new file mode 100644 index 0000000..47fff91 --- /dev/null +++ b/crates/model-connector/tests/fixtures/wire_v2/result_refused_expired.mp @@ -0,0 +1 @@ +”Ù"gamecult.codex.transport_result.v2©request-1­parity-caller�§Refused§Expired \ No newline at end of file diff --git a/crates/model-connector/tests/fixtures/wire_v2/result_refused_identity_substitution.mp b/crates/model-connector/tests/fixtures/wire_v2/result_refused_identity_substitution.mp new file mode 100644 index 0000000..8da919c --- /dev/null +++ b/crates/model-connector/tests/fixtures/wire_v2/result_refused_identity_substitution.mp @@ -0,0 +1 @@ +”Ù"gamecult.codex.transport_result.v2©request-1­parity-caller�§Refused´IdentitySubstitution \ No newline at end of file diff --git a/crates/model-connector/tests/fixtures/wire_v2/result_refused_in_flight.mp b/crates/model-connector/tests/fixtures/wire_v2/result_refused_in_flight.mp new file mode 100644 index 0000000..0c9f9d3 --- /dev/null +++ b/crates/model-connector/tests/fixtures/wire_v2/result_refused_in_flight.mp @@ -0,0 +1 @@ +”Ù"gamecult.codex.transport_result.v2©request-1­parity-caller�§Refused¨InFlight \ No newline at end of file diff --git a/crates/model-connector/tests/fixtures/wire_v2/result_refused_indeterminate.mp b/crates/model-connector/tests/fixtures/wire_v2/result_refused_indeterminate.mp new file mode 100644 index 0000000..068bed3 --- /dev/null +++ b/crates/model-connector/tests/fixtures/wire_v2/result_refused_indeterminate.mp @@ -0,0 +1 @@ +”Ù"gamecult.codex.transport_result.v2©request-1­parity-caller�§Refused­Indeterminate \ No newline at end of file diff --git a/crates/model-connector/tests/fixtures/wire_v2/result_refused_malformed.mp b/crates/model-connector/tests/fixtures/wire_v2/result_refused_malformed.mp new file mode 100644 index 0000000..f106b9f --- /dev/null +++ b/crates/model-connector/tests/fixtures/wire_v2/result_refused_malformed.mp @@ -0,0 +1 @@ +”Ù"gamecult.codex.transport_result.v2©request-1­parity-caller�§Refused©Malformed \ No newline at end of file diff --git a/crates/model-connector/tests/fixtures/wire_v2/result_refused_policy.mp b/crates/model-connector/tests/fixtures/wire_v2/result_refused_policy.mp new file mode 100644 index 0000000..28712db --- /dev/null +++ b/crates/model-connector/tests/fixtures/wire_v2/result_refused_policy.mp @@ -0,0 +1 @@ +”Ù"gamecult.codex.transport_result.v2©request-1­parity-caller�§Refused¦Policy \ No newline at end of file diff --git a/crates/model-connector/tests/fixtures/wire_v2/result_refused_provider_digest_substitution.mp b/crates/model-connector/tests/fixtures/wire_v2/result_refused_provider_digest_substitution.mp new file mode 100644 index 0000000..76b2169 --- /dev/null +++ b/crates/model-connector/tests/fixtures/wire_v2/result_refused_provider_digest_substitution.mp @@ -0,0 +1 @@ +”Ù"gamecult.codex.transport_result.v2©request-1­parity-caller�§RefusedºProviderDigestSubstitution \ No newline at end of file diff --git a/crates/model-connector/tests/fixtures/wire_v2/result_refused_replay_conflict.mp b/crates/model-connector/tests/fixtures/wire_v2/result_refused_replay_conflict.mp new file mode 100644 index 0000000..9f621de --- /dev/null +++ b/crates/model-connector/tests/fixtures/wire_v2/result_refused_replay_conflict.mp @@ -0,0 +1 @@ +”Ù"gamecult.codex.transport_result.v2©request-1­parity-caller�§Refused®ReplayConflict \ No newline at end of file diff --git a/crates/model-connector/tests/fixtures/wire_v2/result_transported.mp b/crates/model-connector/tests/fixtures/wire_v2/result_transported.mp new file mode 100644 index 0000000000000000000000000000000000000000..f4fedf862d0f0cd231090f0848610542f5a65cfa GIT binary patch literal 327 zcmbPoQz<<$H#NC5r$jF~KP9z7ucRn3uecz;s3g89wHPE^X0);>wXig`xJ1`*Z9!sD zW=W-Pa$-(SYEk3r5SZfBlu45&F*L3WNv$YxNzExqoVYY2H76&35@X|vko^1{XP}13 z%Rt(6jaC<>CZ@y}=a&{Gr!K0lopDncj}wwpGYjy!;SPfWI{^Sxa^}LByQyb>p4n$| z<^jW*b#7v3rsSWQC8c)e%$XHuR-S3sKhu2XQBiKvnPu8%PM!I4W=+WQ^nwyyQ$3Sa kP#+f;r#7y1&d)8#Ni9iDnYs!XI0gB6#i_c6T+ECt0JtTD5dZ)H literal 0 HcmV?d00001 diff --git a/crates/model-connector/tests/wire_golden_v2.rs b/crates/model-connector/tests/wire_golden_v2.rs new file mode 100644 index 0000000..198b731 --- /dev/null +++ b/crates/model-connector/tests/wire_golden_v2.rs @@ -0,0 +1,301 @@ +//! The kit speaks the v2 wire of CodexConnector fd7cec4, the library Ghostlight pins. +//! The golden bytes in `fixtures/wire_v2` were produced by that library (see its README); +//! nothing of it is linked here. Deleted with the fixtures by cut connector-contract-v3, +//! which changes the wire. + +use model_connector as kit; +use serde::Serialize; +use serde::de::DeserializeOwned; + +const KEY: &str = "parity-distinct-test-key"; +const CALLER: &str = "parity-caller"; + +macro_rules! golden { + ($name:literal) => { + include_bytes!(concat!("fixtures/wire_v2/", $name)) as &[u8] + }; +} + +fn bytes(value: &T) -> Vec { + rmp_serde::to_vec(value).unwrap() +} + +/// The value serializes to the golden bytes and the golden bytes decode back to it. +fn assert_wire( + name: &str, + value: &T, + golden: &[u8], +) { + assert_eq!(bytes(value), golden, "{name}: serialized bytes"); + let decoded: T = rmp_serde::from_slice(golden).unwrap(); + assert_eq!(&decoded, value, "{name}: decoded value"); +} + +fn request(choice: kit::ToolChoice) -> kit::ProviderRequest { + let mut request = + kit::ProviderRequest::new("request-1", "conversation-1", "gpt-5.4", "Be exact."); + request.input = vec![ + kit::InputItem::UserText { + text: "state".to_string(), + }, + kit::InputItem::AssistantText { + text: "reply".to_string(), + }, + kit::InputItem::ToolCall { + call_id: "call-1".to_string(), + name: "read_source".to_string(), + arguments: "{}".to_string(), + }, + kit::InputItem::ToolResult { + call_id: "call-1".to_string(), + output: "ok".to_string(), + }, + ]; + request.tools = vec![kit::ToolDefinition { + name: "read_source".to_string(), + description: "Read one admitted source.".to_string(), + parameters_json: r#"{"type":"object"}"#.to_string(), + }]; + request.tool_choice = choice; + request.reasoning_effort = Some("high".to_string()); + request.parallel_tool_calls = true; + request.max_output_tokens = Some(512); + request.prompt_cache_key = Some("cache-1".to_string()); + request +} + +fn invocation(choice: kit::ToolChoice) -> kit::TransportInvocation { + kit::TransportInvocation::new(CALLER, 2_000, [7; 32], request(choice)).unwrap() +} + +fn receipt( + invocation: &kit::TransportInvocation, + outcome: kit::TransportOutcome, +) -> kit::TransportReceipt { + kit::TransportReceipt { + schema_id: kit::RECEIPT_SCHEMA_ID.to_string(), + request_id: invocation.request_id().to_string(), + caller_runtime_id: invocation.caller_runtime_id.clone(), + native_request_sha256: invocation.native_request_sha256, + provider_request_sha256: invocation.provider_request_sha256, + model: invocation.request.model.clone(), + transport: "parity-sse".to_string(), + outcome, + } +} + +fn completed(invocation: &kit::TransportInvocation) -> kit::TransportResult { + kit::TransportResult::transported( + invocation, + vec![ + kit::TransportEvent { + sequence: 0, + payload: kit::TransportEventPayload::TextDelta { + text: "hello".to_string(), + }, + }, + kit::TransportEvent { + sequence: 1, + payload: kit::TransportEventPayload::ToolCall { + call_id: "call-2".to_string(), + name: "read_source".to_string(), + arguments: "{}".to_string(), + }, + }, + ], + receipt( + invocation, + kit::TransportOutcome::Completed { + provider_response_id: Some("response-1".to_string()), + input_tokens: Some(10), + output_tokens: Some(3), + reasoning_output_tokens: Some(1), + cached_input_tokens: Some(4), + }, + ), + ) +} + +fn failed(invocation: &kit::TransportInvocation) -> kit::TransportResult { + kit::TransportResult::transported( + invocation, + Vec::new(), + receipt( + invocation, + kit::TransportOutcome::Failed { + failure_kind: "result_size".to_string(), + message: "provider result exceeded its byte bound".to_string(), + }, + ), + ) +} + +fn key() -> kit::TransportKey { + kit::TransportKey::from_connection_secret(KEY).unwrap() +} + +fn refusals() -> [(&'static str, kit::Refusal, &'static [u8]); 9] { + use kit::Refusal as R; + [ + ("expired", R::Expired, golden!("result_refused_expired.mp")), + ( + "identity_substitution", + R::IdentitySubstitution, + golden!("result_refused_identity_substitution.mp"), + ), + ( + "provider_digest_substitution", + R::ProviderDigestSubstitution, + golden!("result_refused_provider_digest_substitution.mp"), + ), + ("policy", R::Policy, golden!("result_refused_policy.mp")), + ( + "capacity", + R::Capacity, + golden!("result_refused_capacity.mp"), + ), + ( + "in_flight", + R::InFlight, + golden!("result_refused_in_flight.mp"), + ), + ( + "indeterminate", + R::Indeterminate, + golden!("result_refused_indeterminate.mp"), + ), + ( + "replay_conflict", + R::ReplayConflict, + golden!("result_refused_replay_conflict.mp"), + ), + ( + "malformed", + R::Malformed, + golden!("result_refused_malformed.mp"), + ), + ] +} + +#[test] +fn schema_ids_are_the_v2_ids() { + assert_eq!( + kit::PROVIDER_REQUEST_SCHEMA_ID, + "gamecult.codex.provider_request.v2" + ); + assert_eq!( + kit::INVOCATION_SCHEMA_ID, + "gamecult.codex.transport_invocation.v2" + ); + assert_eq!(kit::RESULT_SCHEMA_ID, "gamecult.codex.transport_result.v2"); + assert_eq!( + kit::RECEIPT_SCHEMA_ID, + "gamecult.codex.transport_receipt.v2" + ); + assert_eq!( + kit::ENVELOPE_SCHEMA_ID, + "gamecult.codex.transport_envelope.v2" + ); +} + +#[test] +fn invocations_carry_the_v2_bytes_for_each_tool_choice() { + assert_wire( + "auto", + &invocation(kit::ToolChoice::Auto), + golden!("invocation_auto.mp"), + ); + assert_wire( + "required", + &invocation(kit::ToolChoice::Required), + golden!("invocation_required.mp"), + ); +} + +#[test] +fn results_carry_the_v2_bytes_for_success_and_failure() { + let invocation = invocation(kit::ToolChoice::Required); + assert_wire( + "transported", + &completed(&invocation), + golden!("result_transported.mp"), + ); + assert_wire("failed", &failed(&invocation), golden!("result_failed.mp")); +} + +#[test] +fn every_refusal_carries_its_v2_bytes() { + let invocation = invocation(kit::ToolChoice::Required); + for (name, refusal, golden) in refusals() { + assert_wire( + name, + &kit::TransportResult::refused(&invocation, refusal), + golden, + ); + } +} + +#[test] +fn canonical_bytes_and_provider_digest_are_the_v2_bytes() { + let invocation = invocation(kit::ToolChoice::Required); + assert_eq!( + kit::canonical_invocation_bytes(&invocation).unwrap(), + golden!("canonical_invocation.bin") + ); + assert_eq!( + kit::canonical_provider_request_bytes(&invocation.request).unwrap(), + golden!("canonical_provider_request.bin") + ); + assert_eq!( + kit::provider_request_sha256(&invocation.request) + .unwrap() + .as_slice(), + golden!("provider_request_sha256.bin") + ); +} + +#[test] +fn v2_sealed_envelopes_decrypt_and_validate_under_the_kit() { + let invocation = invocation(kit::ToolChoice::Required); + let envelope: kit::TransportEnvelope = + rmp_serde::from_slice(golden!("envelope_invocation.mp")).unwrap(); + let received = kit::decrypt_invocation(&envelope, &key()).unwrap(); + assert_eq!(bytes(&received), bytes(&invocation)); + + let results = [ + ( + golden!("envelope_result_transported.mp"), + completed(&invocation), + ), + (golden!("envelope_result_failed.mp"), failed(&invocation)), + ( + golden!("envelope_result_refused_replay_conflict.mp"), + kit::TransportResult::refused(&invocation, kit::Refusal::ReplayConflict), + ), + ]; + for (sealed, expected) in results { + let envelope: kit::TransportEnvelope = rmp_serde::from_slice(sealed).unwrap(); + let received = kit::decrypt_result(&envelope, &key(), &invocation).unwrap(); + assert_eq!(bytes(&received), bytes(&expected)); + } +} + +#[test] +fn kit_sealed_envelopes_have_the_v2_shape() { + let invocation = invocation(kit::ToolChoice::Required); + let golden: kit::TransportEnvelope = + rmp_serde::from_slice(golden!("envelope_invocation.mp")).unwrap(); + let sealed = kit::encrypt_invocation(&invocation, &key()).unwrap(); + assert_eq!(sealed.schema_id, golden.schema_id); + assert_eq!(sealed.caller_runtime_id, golden.caller_runtime_id); + assert_eq!(sealed.request_id, golden.request_id); + assert_eq!(sealed.message_kind, golden.message_kind); + assert_eq!(sealed.ciphertext.len(), golden.ciphertext.len()); + assert_ne!(sealed.nonce, golden.nonce, "nonces are fresh per seal"); + + let golden: kit::TransportEnvelope = + rmp_serde::from_slice(golden!("envelope_result_transported.mp")).unwrap(); + let sealed = kit::encrypt_result(&completed(&invocation), &key()).unwrap(); + assert_eq!(sealed.message_kind, golden.message_kind); + assert_eq!(sealed.ciphertext.len(), golden.ciphertext.len()); +} From bf958fc32794b6c50e3a89e4925d19f6680a2691 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Wed, 28 Oct 2026 12:26:34 +0100 Subject: [PATCH 24/69] connector-contract-v3 (B3a): the v3 contract as typed documents over CultNet operations (squash of eureka-body/connector-contract-v3 ec0519d..a0a6ccb) Each frame is a cultnet.operation_request.v0 / operation_response.v0 (service gamecult.model.connector, operation invoke) over loopback TcpFramed, carrying the kit's sealed per-caller envelope: named MessagePack only, unknown fields refused, binary ciphertext, JSON Schema published from the types (ruling connector-v3-wire-cultnet-operation). v2 is deleted with its fixtures (ruling connector-v3-only). The request is provider-neutral, with max_output_tokens required; failure kinds, stop reasons and usage are typed, and Policy is three typed refusals. Golden v3 frames pin the carriage and envelope. Spec eureka-body:cut_spec:cut-connector-contract-v3.r2; reports h1-h2; Soul s2 holds (verdict eureka-body:verdict:cut-connector-contract-v3.s2). Co-Authored-By: Claude Opus 5.5 --- .gitattributes | 2 + Cargo.lock | 431 +++++++++- README.md | 29 +- crates/model-connector/Cargo.toml | 7 +- crates/model-connector/src/client.rs | 47 +- crates/model-connector/src/contract.rs | 421 ++++++++-- crates/model-connector/src/envelope.rs | 762 ++++++++++++++++-- crates/model-connector/src/fixtures.rs | 15 +- crates/model-connector/src/serve.rs | 121 ++- crates/model-connector/src/service.rs | 122 +-- .../tests/fixtures/wire_v2/.gitattributes | 2 - .../tests/fixtures/wire_v2/README.md | 21 - .../fixtures/wire_v2/canonical_invocation.bin | Bin 413 -> 0 bytes .../wire_v2/canonical_provider_request.bin | Bin 267 -> 0 bytes .../fixtures/wire_v2/envelope_invocation.mp | 6 - .../wire_v2/envelope_result_failed.mp | 7 - ...envelope_result_refused_replay_conflict.mp | Bin 252 -> 0 bytes .../wire_v2/envelope_result_transported.mp | Bin 609 -> 0 bytes .../tests/fixtures/wire_v2/generator.rs | 171 ---- .../tests/fixtures/wire_v2/invocation_auto.mp | Bin 408 -> 0 bytes .../fixtures/wire_v2/invocation_required.mp | Bin 413 -> 0 bytes .../wire_v2/provider_request_sha256.bin | Bin 32 -> 0 bytes .../tests/fixtures/wire_v2/result_failed.mp | Bin 307 -> 0 bytes .../wire_v2/result_refused_capacity.mp | 1 - .../wire_v2/result_refused_expired.mp | 1 - .../result_refused_identity_substitution.mp | 1 - .../wire_v2/result_refused_in_flight.mp | 1 - .../wire_v2/result_refused_indeterminate.mp | 1 - .../wire_v2/result_refused_malformed.mp | 1 - .../fixtures/wire_v2/result_refused_policy.mp | 1 - ...lt_refused_provider_digest_substitution.mp | 1 - .../wire_v2/result_refused_replay_conflict.mp | 1 - .../fixtures/wire_v2/result_transported.mp | Bin 327 -> 0 bytes .../tests/fixtures/wire_v3/README.md | 15 + .../wire_v3/carriage_invocation.frame | 1 + .../fixtures/wire_v3/provider_request.mp | Bin 0 -> 549 bytes .../fixtures/wire_v3/transport_envelope.mp | Bin 0 -> 205 bytes .../fixtures/wire_v3/transport_invocation.mp | Bin 0 -> 799 bytes .../wire_v3/transport_receipt_failed.mp | Bin 0 -> 358 bytes .../wire_v3/transport_result_refused.mp | 1 + .../wire_v3/transport_result_transported.mp | Bin 0 -> 715 bytes .../tests/published_schemas.rs | 100 +++ .../model-connector/tests/wire_golden_v2.rs | 301 ------- .../model-connector/tests/wire_golden_v3.rs | 214 +++++ schemas/provider_request.json | 229 ++++++ schemas/transport_envelope.json | 53 ++ schemas/transport_invocation.json | 279 +++++++ schemas/transport_receipt.json | 186 +++++ schemas/transport_result.json | 342 ++++++++ 49 files changed, 3127 insertions(+), 767 deletions(-) create mode 100644 .gitattributes delete mode 100644 crates/model-connector/tests/fixtures/wire_v2/.gitattributes delete mode 100644 crates/model-connector/tests/fixtures/wire_v2/README.md delete mode 100644 crates/model-connector/tests/fixtures/wire_v2/canonical_invocation.bin delete mode 100644 crates/model-connector/tests/fixtures/wire_v2/canonical_provider_request.bin delete mode 100644 crates/model-connector/tests/fixtures/wire_v2/envelope_invocation.mp delete mode 100644 crates/model-connector/tests/fixtures/wire_v2/envelope_result_failed.mp delete mode 100644 crates/model-connector/tests/fixtures/wire_v2/envelope_result_refused_replay_conflict.mp delete mode 100644 crates/model-connector/tests/fixtures/wire_v2/envelope_result_transported.mp delete mode 100644 crates/model-connector/tests/fixtures/wire_v2/generator.rs delete mode 100644 crates/model-connector/tests/fixtures/wire_v2/invocation_auto.mp delete mode 100644 crates/model-connector/tests/fixtures/wire_v2/invocation_required.mp delete mode 100644 crates/model-connector/tests/fixtures/wire_v2/provider_request_sha256.bin delete mode 100644 crates/model-connector/tests/fixtures/wire_v2/result_failed.mp delete mode 100644 crates/model-connector/tests/fixtures/wire_v2/result_refused_capacity.mp delete mode 100644 crates/model-connector/tests/fixtures/wire_v2/result_refused_expired.mp delete mode 100644 crates/model-connector/tests/fixtures/wire_v2/result_refused_identity_substitution.mp delete mode 100644 crates/model-connector/tests/fixtures/wire_v2/result_refused_in_flight.mp delete mode 100644 crates/model-connector/tests/fixtures/wire_v2/result_refused_indeterminate.mp delete mode 100644 crates/model-connector/tests/fixtures/wire_v2/result_refused_malformed.mp delete mode 100644 crates/model-connector/tests/fixtures/wire_v2/result_refused_policy.mp delete mode 100644 crates/model-connector/tests/fixtures/wire_v2/result_refused_provider_digest_substitution.mp delete mode 100644 crates/model-connector/tests/fixtures/wire_v2/result_refused_replay_conflict.mp delete mode 100644 crates/model-connector/tests/fixtures/wire_v2/result_transported.mp create mode 100644 crates/model-connector/tests/fixtures/wire_v3/README.md create mode 100644 crates/model-connector/tests/fixtures/wire_v3/carriage_invocation.frame create mode 100644 crates/model-connector/tests/fixtures/wire_v3/provider_request.mp create mode 100644 crates/model-connector/tests/fixtures/wire_v3/transport_envelope.mp create mode 100644 crates/model-connector/tests/fixtures/wire_v3/transport_invocation.mp create mode 100644 crates/model-connector/tests/fixtures/wire_v3/transport_receipt_failed.mp create mode 100644 crates/model-connector/tests/fixtures/wire_v3/transport_result_refused.mp create mode 100644 crates/model-connector/tests/fixtures/wire_v3/transport_result_transported.mp create mode 100644 crates/model-connector/tests/published_schemas.rs delete mode 100644 crates/model-connector/tests/wire_golden_v2.rs create mode 100644 crates/model-connector/tests/wire_golden_v3.rs create mode 100644 schemas/provider_request.json create mode 100644 schemas/transport_envelope.json create mode 100644 schemas/transport_invocation.json create mode 100644 schemas/transport_receipt.json create mode 100644 schemas/transport_result.json diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..0a1d199 --- /dev/null +++ b/.gitattributes @@ -0,0 +1,2 @@ +schemas/*.json text eol=lf +crates/model-connector/tests/fixtures/wire_v3/* -text diff --git a/Cargo.lock b/Cargo.lock index 01f3693..3c3ff8d 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -52,12 +52,33 @@ version = "1.0.104" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "330a5ed07fa54e4702c9d6c4174f74427fc0ef6e214bbd677ae50a5099946470" +[[package]] +name = "ar_archive_writer" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "73cd58deff2140a0a8eae87e417bd01db68a33e148aa93d1e8cd837e55e312b6" +dependencies = [ + "object", +] + [[package]] name = "autocfg" version = "1.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" +[[package]] +name = "base64" +version = "0.22.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" + +[[package]] +name = "base64ct" +version = "1.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2af50177e190e07a26ab74f8b1efbfe2ef87da2116221318cb1c2e82baf7de06" + [[package]] name = "block-buffer" version = "0.10.4" @@ -113,6 +134,12 @@ dependencies = [ "inout", ] +[[package]] +name = "const-oid" +version = "0.9.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2459377285ad874054d797f3ccebf984978aa39129f6eafde5cdc8315b612f8" + [[package]] name = "core-foundation-sys" version = "0.8.7" @@ -135,7 +162,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" dependencies = [ "generic-array", - "rand_core", + "rand_core 0.6.4", "typenum", ] @@ -150,8 +177,8 @@ dependencies = [ [[package]] name = "cultcache-rs" -version = "0.1.0" -source = "git+https://github.com/GameCult/CultLib.git?rev=e171eca32329baa928f4a1d810401a8b4c857029#e171eca32329baa928f4a1d810401a8b4c857029" +version = "0.3.1" +source = "git+https://github.com/GameCult/CultLib.git?rev=a4eae9056152335662f1115f97bf35fb00ab6d23#a4eae9056152335662f1115f97bf35fb00ab6d23" dependencies = [ "anyhow", "chrono", @@ -162,20 +189,84 @@ dependencies = [ "serde", "serde_bytes", "serde_json", + "serde_stacker", + "stacker", "uuid", - "windows-sys", + "windows-sys 0.59.0", ] [[package]] name = "cultcache-rs-derive" -version = "0.1.0" -source = "git+https://github.com/GameCult/CultLib.git?rev=e171eca32329baa928f4a1d810401a8b4c857029#e171eca32329baa928f4a1d810401a8b4c857029" +version = "0.2.0" +source = "git+https://github.com/GameCult/CultLib.git?rev=a4eae9056152335662f1115f97bf35fb00ab6d23#a4eae9056152335662f1115f97bf35fb00ab6d23" dependencies = [ "proc-macro2", "quote", "syn 2.0.119", ] +[[package]] +name = "cultnet-rs" +version = "0.2.0" +source = "git+https://github.com/GameCult/CultLib.git?rev=a4eae9056152335662f1115f97bf35fb00ab6d23#a4eae9056152335662f1115f97bf35fb00ab6d23" +dependencies = [ + "aes-gcm", + "anyhow", + "base64", + "chrono", + "cultcache-rs", + "ed25519-dalek", + "hmac", + "rand", + "rmp-serde", + "rmpv", + "serde", + "serde_bytes", + "serde_json", + "sha2", + "socket2", + "uuid", + "windows-sys 0.61.2", + "zeroize", +] + +[[package]] +name = "curve25519-dalek" +version = "4.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "97fb8b7c4503de7d6ae7b42ab72a5a59857b4c937ec27a3d4539dba95b5ab2be" +dependencies = [ + "cfg-if", + "cpufeatures", + "curve25519-dalek-derive", + "digest", + "fiat-crypto", + "rustc_version", + "subtle", + "zeroize", +] + +[[package]] +name = "curve25519-dalek-derive" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f46882e17999c6cc590af592290432be3bce0428cb0d5f8b6715e4dc7b383eb3" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "der" +version = "0.7.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7c1832837b905bbfb5101e07cc24c8deddf52f93225eee6ead5f4d63d53ddcb" +dependencies = [ + "const-oid", + "zeroize", +] + [[package]] name = "digest" version = "0.10.7" @@ -184,8 +275,45 @@ checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" dependencies = [ "block-buffer", "crypto-common", + "subtle", ] +[[package]] +name = "dyn-clone" +version = "1.0.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d0881ea181b1df73ff77ffaaf9c7544ecc11e82fba9b5f27b262a3c73a332555" + +[[package]] +name = "ed25519" +version = "2.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "115531babc129696a58c64a4fef0a8bf9e9698629fb97e9e40767d235cfbcd53" +dependencies = [ + "pkcs8", + "signature", +] + +[[package]] +name = "ed25519-dalek" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "70e796c081cee67dc755e1a36a0a172b897fab85fc3f6bc48307991f64e4eca9" +dependencies = [ + "curve25519-dalek", + "ed25519", + "serde", + "sha2", + "subtle", + "zeroize", +] + +[[package]] +name = "fiat-crypto" +version = "0.2.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "28dea519a9695b9977216879a3ebfddf92f1c08c05d984f8996aecd6ecdc811d" + [[package]] name = "find-msvc-tools" version = "0.1.11" @@ -247,6 +375,18 @@ dependencies = [ "wasi", ] +[[package]] +name = "getrandom" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" +dependencies = [ + "cfg-if", + "libc", + "r-efi 5.3.0", + "wasip2", +] + [[package]] name = "getrandom" version = "0.4.3" @@ -255,7 +395,7 @@ checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099" dependencies = [ "cfg-if", "libc", - "r-efi", + "r-efi 6.0.0", ] [[package]] @@ -268,6 +408,15 @@ dependencies = [ "polyval", ] +[[package]] +name = "hmac" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c49c37c09c17a53d937dfbb742eb3a961d65a994e6bcdcf37e7399d0cc8ab5e" +dependencies = [ + "digest", +] + [[package]] name = "iana-time-zone" version = "0.1.65" @@ -341,10 +490,15 @@ name = "model-connector" version = "0.1.0" dependencies = [ "aes-gcm", + "base64", "cultcache-rs", - "rand_core", + "cultnet-rs", + "rand_core 0.6.4", "rmp-serde", + "rmpv", + "schemars", "serde", + "serde_bytes", "serde_json", "sha2", "thiserror", @@ -360,6 +514,15 @@ dependencies = [ "autocfg", ] +[[package]] +name = "object" +version = "0.39.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2e5a6c098c7a3b6547378093f5cc30bc54fd361ce711e05293a5cc589562739b" +dependencies = [ + "memchr", +] + [[package]] name = "once_cell" version = "1.21.4" @@ -378,6 +541,16 @@ version = "0.2.17" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" +[[package]] +name = "pkcs8" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f950b2377845cebe5cf8b5165cb3cc1a5e0fa5cfa3e1f7f55707d8fd82e0a7b7" +dependencies = [ + "der", + "spki", +] + [[package]] name = "polyval" version = "0.6.2" @@ -390,6 +563,15 @@ dependencies = [ "universal-hash", ] +[[package]] +name = "ppv-lite86" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9" +dependencies = [ + "zerocopy", +] + [[package]] name = "proc-macro2" version = "1.0.107" @@ -399,6 +581,16 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "psm" +version = "0.1.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4dcd034599e63b970727f70d79e02d62390a4a84f7c6b827c27c46d5ac3fa622" +dependencies = [ + "ar_archive_writer", + "cc", +] + [[package]] name = "quote" version = "1.0.47" @@ -408,12 +600,38 @@ dependencies = [ "proc-macro2", ] +[[package]] +name = "r-efi" +version = "5.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" + [[package]] name = "r-efi" version = "6.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" +[[package]] +name = "rand" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9ef1d0d795eb7d84685bca4f72f3649f064e6641543d3a8c415898726a57b41" +dependencies = [ + "rand_chacha", + "rand_core 0.9.5", +] + +[[package]] +name = "rand_chacha" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb" +dependencies = [ + "ppv-lite86", + "rand_core 0.9.5", +] + [[package]] name = "rand_core" version = "0.6.4" @@ -423,6 +641,15 @@ dependencies = [ "getrandom 0.2.17", ] +[[package]] +name = "rand_core" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c" +dependencies = [ + "getrandom 0.3.4", +] + [[package]] name = "redb" version = "4.2.0" @@ -432,6 +659,26 @@ dependencies = [ "libc", ] +[[package]] +name = "ref-cast" +version = "1.0.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e440fb4e4b4147295338efb76001ab9e4efc0e5839df2c47fc5ac2381d365c3" +dependencies = [ + "ref-cast-impl", +] + +[[package]] +name = "ref-cast-impl" +version = "1.0.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92ecd8964f8453721699a1ed72037b0db49ce2f5a5138486ee89bed6f67cdf3a" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.4", +] + [[package]] name = "rmp" version = "0.8.15" @@ -451,12 +698,63 @@ dependencies = [ "serde", ] +[[package]] +name = "rmpv" +version = "1.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7a4e1d4b9b938a26d2996af33229f0ca0956c652c1375067f0b45291c1df8417" +dependencies = [ + "rmp", + "serde", + "serde_bytes", +] + +[[package]] +name = "rustc_version" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfcb3a22ef46e85b45de6ee7e79d063319ebb6594faafcf1c225ea92ab6e9b92" +dependencies = [ + "semver", +] + [[package]] name = "rustversion" version = "1.0.23" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f" +[[package]] +name = "schemars" +version = "1.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ab508826f74a77ca9d5aba6ff19b522583ee3eaf28a19384ff3d0e5835fadf6e" +dependencies = [ + "dyn-clone", + "ref-cast", + "schemars_derive", + "serde", + "serde_json", +] + +[[package]] +name = "schemars_derive" +version = "1.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e1c3a92094fa7d61aa124645844facb6b554dfc797136d0f5fd1f890e2bffc69" +dependencies = [ + "proc-macro2", + "quote", + "serde_derive_internals", + "syn 3.0.4", +] + +[[package]] +name = "semver" +version = "1.0.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd" + [[package]] name = "serde" version = "1.0.229" @@ -497,6 +795,17 @@ dependencies = [ "syn 3.0.4", ] +[[package]] +name = "serde_derive_internals" +version = "0.30.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f852137cce035d6a4df67ccce505ff6b3e9fd3a10e3e52b24dc71e650bb1a9bd" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.4", +] + [[package]] name = "serde_json" version = "1.0.151" @@ -510,6 +819,17 @@ dependencies = [ "zmij", ] +[[package]] +name = "serde_stacker" +version = "0.1.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d4936375d50c4be7eff22293a9344f8e46f323ed2b3c243e52f89138d9bb0f4a" +dependencies = [ + "serde", + "serde_core", + "stacker", +] + [[package]] name = "sha2" version = "0.10.9" @@ -527,12 +847,54 @@ version = "2.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba" +[[package]] +name = "signature" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77549399552de45a898a580c1b41d445bf730df867cc44e6c0233bbc4b8329de" +dependencies = [ + "rand_core 0.6.4", +] + [[package]] name = "slab" version = "0.4.12" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" +[[package]] +name = "socket2" +version = "0.5.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e22376abed350d73dd1cd119b57ffccad95b4e585a7cda43e286245ce23c0678" +dependencies = [ + "libc", + "windows-sys 0.52.0", +] + +[[package]] +name = "spki" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d91ed6c858b01f942cd56b37a94b3e0a1798290327d1236e4d9cf4eaca44d29d" +dependencies = [ + "base64ct", + "der", +] + +[[package]] +name = "stacker" +version = "0.1.25" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "707f49d46706bacf8a2b00d51dace3f9de527c13eec3778f570c411f89e69967" +dependencies = [ + "cc", + "cfg-if", + "libc", + "psm", + "windows-sys 0.61.2", +] + [[package]] name = "subtle" version = "2.6.1" @@ -626,6 +988,15 @@ version = "0.11.1+wasi-snapshot-preview1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" +[[package]] +name = "wasip2" +version = "1.0.4+wasi-0.2.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b67efb37e106e55ce722a510d6b5f9c17f083e5fc79afc2badeb12cc313d9487" +dependencies = [ + "wit-bindgen", +] + [[package]] name = "wasm-bindgen" version = "0.2.127" @@ -752,6 +1123,15 @@ dependencies = [ "windows-link", ] +[[package]] +name = "windows-sys" +version = "0.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" +dependencies = [ + "windows-targets", +] + [[package]] name = "windows-sys" version = "0.59.0" @@ -761,6 +1141,15 @@ dependencies = [ "windows-targets", ] +[[package]] +name = "windows-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link", +] + [[package]] name = "windows-targets" version = "0.52.6" @@ -825,6 +1214,32 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" +[[package]] +name = "wit-bindgen" +version = "0.57.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e" + +[[package]] +name = "zerocopy" +version = "0.8.63" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e5fe1f8f1b06191a00962174c61aa5005e0bb391a6d80d07e24d115c01a92ed8" +dependencies = [ + "zerocopy-derive", +] + +[[package]] +name = "zerocopy-derive" +version = "0.8.63" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "863ad3ac83293fb4d740aedbfdc9240dd8d1a50c1099acd76ce80ce7c7230c7f" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + [[package]] name = "zeroize" version = "1.9.0" diff --git a/README.md b/README.md index 433f540..cbcccb5 100644 --- a/README.md +++ b/README.md @@ -11,11 +11,16 @@ Status: the `model-connector` crate is live. No connector binary exists yet Crate `crates/model-connector`: -- `src/contract.rs` - the v2 wire contract: `ProviderRequest`, `TransportInvocation`, - `TransportResult`, `TransportReceipt`, `Refusal`, and the canonical byte and digest - functions that bind a result to its request. -- `src/envelope.rs` - the AES-256-GCM `TransportEnvelope`, its identity-binding AAD, and - the length-prefixed framing. +- `src/contract.rs` - the v3 wire contract (`gamecult.model.*.v3`): `ProviderRequest`, `TransportInvocation`, + `TransportResult`, `TransportReceipt`, the typed `StopReason`, `FailureKind` and `Refusal`, + and the canonical byte and digest functions that bind a result to its request. Every + document is named MessagePack with unknown fields refused. +- `src/envelope.rs` - the AES-256-GCM `TransportEnvelope` (binary ciphertext), its + identity-binding AAD, the length-prefixed framing, and the CultNet carriage: each frame is a + `cultnet.operation_request.v0` / `operation_response.v0` for service + `gamecult.model.connector`, operation `invoke`, its `message_id` the request id and its + payload the base64 of the sealed envelope. +- `schemas/` - one JSON Schema per v3 document, generated from the types. - `src/client.rs` - `ConnectorClient`, the one-request caller side. - `src/service.rs` (feature `daemon`) - `TransportService`: caller admission, durable replay (a retried request is answered byte-identically and the provider runs once), @@ -24,16 +29,15 @@ Crate `crates/model-connector`: `ProviderBackend` trait. A connector binary implements `ProviderBackend` for its provider and calls `serve`; the kit names no provider. -Frame arithmetic is public in `src/envelope.rs`: `frame_bytes_for_payload(n)` is the frame a +Frame arithmetic is public in `src/envelope.rs`, measured on the carriage frame: `frame_bytes_for_payload(n)` is the frame a caller payload bound of `n` needs, `payload_bytes_for_frame(frame)` is the largest payload bound a frame allows, and `MAX_IDENTITY_BYTES` (256) bounds caller ids, request ids and model names. `serve` refuses a frame/payload pair that violates them (`ServeError::FrameBound` states the frame needed); the smallest legal frame is `frame_bytes_for_payload(MIN_PAYLOAD_BYTES)`. A request id past the bound is refused on the wire as `Refusal::Malformed`, the reply echoing the id (the connection is dropped only if that reply cannot fit the frame); `ConnectorClient` refuses such an id locally with `RequestIdTooLong`. -The default feature set compiles contract, envelope and client only. Schema ids are -still `gamecult.codex.*.v2`; renaming them changes the wire and belongs to a later -contract version. +The default feature set compiles contract, envelope and client only. The kit speaks v3 +only: a v2 frame gets no answer. ## Verify @@ -42,8 +46,11 @@ cargo test --locked --workspace cargo test --locked --workspace --features model-connector/daemon ``` -`crates/model-connector/tests/wire_golden_v2.rs` pins the kit byte-for-byte to the golden v2 -fixtures in `crates/model-connector/tests/fixtures/wire_v2`. +`crates/model-connector/tests/wire_golden_v3.rs` pins the v3 documents and one sealed carriage +frame to the fixtures in `crates/model-connector/tests/fixtures/wire_v3` (regenerate with +`WIRE_V3_REGENERATE=1`; see the fixtures' README), and +`crates/model-connector/tests/published_schemas.rs` fails when `schemas/` differs from the types +(`MODEL_CONNECTOR_REGENERATE_SCHEMAS=1` rewrites it). ## Provenance and license diff --git a/crates/model-connector/Cargo.toml b/crates/model-connector/Cargo.toml index 49f83db..c54547b 100644 --- a/crates/model-connector/Cargo.toml +++ b/crates/model-connector/Cargo.toml @@ -13,10 +13,15 @@ daemon = ["dep:cultcache-rs"] [dependencies] aes-gcm = "0.10" -cultcache-rs = { git = "https://github.com/GameCult/CultLib.git", rev = "e171eca32329baa928f4a1d810401a8b4c857029", package = "cultcache-rs", optional = true } +base64 = "0.22" +cultnet-rs = { git = "https://github.com/GameCult/CultLib.git", rev = "a4eae9056152335662f1115f97bf35fb00ab6d23", package = "cultnet-rs" } +cultcache-rs = { git = "https://github.com/GameCult/CultLib.git", rev = "a4eae9056152335662f1115f97bf35fb00ab6d23", package = "cultcache-rs", optional = true } rand_core = { version = "0.6", features = ["getrandom"] } rmp-serde = "1" +rmpv = "1" +schemars = "1" serde = { version = "1", features = ["derive"] } +serde_bytes = "0.11" serde_json = "1" sha2 = "0.10" thiserror = "2" diff --git a/crates/model-connector/src/client.rs b/crates/model-connector/src/client.rs index a1cbcb3..1421a5b 100644 --- a/crates/model-connector/src/client.rs +++ b/crates/model-connector/src/client.rs @@ -6,8 +6,8 @@ use zeroize::Zeroizing; use crate::contract::{TransportInvocation, TransportKey, TransportResult}; use crate::envelope::{ - MAX_IDENTITY_BYTES, ServiceError, TransportFrameError, decrypt_result, encrypt_invocation, - read_transport_frame, write_transport_frame, + MAX_IDENTITY_BYTES, ServiceError, TransportFrameError, decode_result_frame, decrypt_result, + encode_invocation_frame, encrypt_invocation, read_transport_frame, write_transport_frame, }; #[derive(Clone)] @@ -51,8 +51,7 @@ impl ConnectorClient { if invocation.request_id().len() > MAX_IDENTITY_BYTES { return Err(ConnectorClientError::RequestIdTooLong); } - let request = rmp_serde::to_vec(&encrypt_invocation(invocation, &self.security)?) - .map_err(|_| ConnectorClientError::Encoding)?; + let request = encode_invocation_frame(&encrypt_invocation(invocation, &self.security)?)?; let connect_timeout = self .response_timeout .unwrap_or(Duration::from_secs(10)) @@ -67,8 +66,7 @@ impl ConnectorClient { .map_err(client_frame_error)?; let response = read_transport_frame(&mut stream, self.max_frame_bytes).map_err(client_frame_error)?; - let envelope = - rmp_serde::from_slice(&response).map_err(|_| ConnectorClientError::Encoding)?; + let envelope = decode_result_frame(&response)?; decrypt_result(&envelope, &self.security, invocation).map_err(Into::into) } } @@ -120,8 +118,6 @@ pub enum ConnectorClientError { Connection(#[source] std::io::Error), #[error("connector frame exceeded its bound")] FrameSize, - #[error("connector MessagePack encoding failed")] - Encoding, #[error("request id is longer than the connector identity bound")] RequestIdTooLong, #[error(transparent)] @@ -151,16 +147,16 @@ mod fixture_tests { let server = std::thread::spawn(move || { let (mut stream, _) = listener.accept().unwrap(); let request = read_transport_frame(&mut stream, 64 * 1024).unwrap(); - let envelope: TransportEnvelope = rmp_serde::from_slice(&request).unwrap(); + let envelope = decode_invocation_frame(&request).unwrap(); assert_eq!(decrypt_invocation(&envelope, &security).unwrap(), expected); let response = encrypt_result( - &TransportResult::refused(&expected, Refusal::Policy), + &TransportResult::refused(&expected, Refusal::ModelNotAllowed), &security, ) .unwrap(); write_transport_frame( &mut stream, - &rmp_serde::to_vec(&response).unwrap(), + &encode_result_frame(&response).unwrap(), 64 * 1024, ) .unwrap(); @@ -175,7 +171,7 @@ mod fixture_tests { .unwrap(); assert_eq!( client.execute(&invocation).unwrap().disposition, - TransportDisposition::Refused(Refusal::Policy) + TransportDisposition::Refused(Refusal::ModelNotAllowed) ); server.join().unwrap(); } @@ -203,4 +199,31 @@ mod fixture_tests { "the client must not even connect" ); } + + #[test] + fn the_client_measures_a_request_id_in_bytes() { + let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap(); + listener.set_nonblocking(true).unwrap(); + let client = ConnectorClient::new( + listener.local_addr().unwrap(), + "epiphany-distinct-test-key", + 64 * 1024, + Some(Duration::from_secs(1)), + ) + .unwrap(); + // 86 characters, 258 bytes: under the bound by characters, over by bytes. + let id = "\u{20AC}".repeat(86); + assert_eq!((id.chars().count(), id.len()), (86, 258)); + let mut long = invocation(); + long.request.request_id = id; + assert!(matches!( + client.execute(&long), + Err(ConnectorClientError::RequestIdTooLong) + )); + assert_eq!( + listener.accept().unwrap_err().kind(), + std::io::ErrorKind::WouldBlock, + "the client must not even connect" + ); + } } diff --git a/crates/model-connector/src/contract.rs b/crates/model-connector/src/contract.rs index 7ae5be5..d60fab5 100644 --- a/crates/model-connector/src/contract.rs +++ b/crates/model-connector/src/contract.rs @@ -1,5 +1,7 @@ use std::collections::HashSet; +use schemars::JsonSchema; +use serde::de::DeserializeOwned; use serde::{Deserialize, Serialize}; use sha2::{Digest, Sha256}; use thiserror::Error; @@ -7,11 +9,11 @@ use zeroize::Zeroize; use crate::envelope::ServiceError; -pub const PROVIDER_REQUEST_SCHEMA_ID: &str = "gamecult.codex.provider_request.v2"; -pub const INVOCATION_SCHEMA_ID: &str = "gamecult.codex.transport_invocation.v2"; -pub const RESULT_SCHEMA_ID: &str = "gamecult.codex.transport_result.v2"; -pub const RECEIPT_SCHEMA_ID: &str = "gamecult.codex.transport_receipt.v2"; -pub const ENVELOPE_SCHEMA_ID: &str = "gamecult.codex.transport_envelope.v2"; +pub const PROVIDER_REQUEST_SCHEMA_ID: &str = "gamecult.model.provider_request.v3"; +pub const INVOCATION_SCHEMA_ID: &str = "gamecult.model.transport_invocation.v3"; +pub const RESULT_SCHEMA_ID: &str = "gamecult.model.transport_result.v3"; +pub const RECEIPT_SCHEMA_ID: &str = "gamecult.model.transport_receipt.v3"; +pub const ENVELOPE_SCHEMA_ID: &str = "gamecult.model.transport_envelope.v3"; #[derive(Clone, PartialEq, Eq)] pub struct TransportKey(pub(crate) [u8; 32]); @@ -31,7 +33,8 @@ impl Drop for TransportKey { } } -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] +#[serde(deny_unknown_fields)] pub struct ProviderRequest { pub schema_id: String, pub request_id: String, @@ -39,17 +42,13 @@ pub struct ProviderRequest { pub model: String, pub instructions: String, pub input: Vec, - pub reasoning_effort: Option, - pub reasoning_summary: Option, - pub service_tier: Option, - pub output_format_name: Option, - pub previous_response_id: Option, + pub reasoning_effort: Option, + pub cache_retention: Option, pub tools: Vec, pub tool_choice: ToolChoice, pub parallel_tool_calls: bool, pub output_schema_json: Option, - pub max_output_tokens: Option, - pub prompt_cache_key: Option, + pub max_output_tokens: u32, } impl ProviderRequest { @@ -58,6 +57,7 @@ impl ProviderRequest { conversation_id: impl Into, model: impl Into, instructions: impl Into, + max_output_tokens: u32, ) -> Self { Self { schema_id: PROVIDER_REQUEST_SCHEMA_ID.to_string(), @@ -67,16 +67,12 @@ impl ProviderRequest { instructions: instructions.into(), input: Vec::new(), reasoning_effort: None, - reasoning_summary: None, - service_tier: None, - output_format_name: None, - previous_response_id: None, + cache_retention: None, tools: Vec::new(), tool_choice: ToolChoice::Auto, parallel_tool_calls: false, output_schema_json: None, - max_output_tokens: None, - prompt_cache_key: None, + max_output_tokens, } } @@ -88,38 +84,12 @@ impl ProviderRequest { require_id(&self.conversation_id, "conversation_id")?; require_id(&self.model, "model")?; require_content(&self.instructions, "instructions")?; - if matches!(self.max_output_tokens, Some(0)) { + if self.max_output_tokens == 0 { return Err(ContractError::Invalid("max_output_tokens")); } - if self - .prompt_cache_key - .as_deref() - .is_some_and(|value| value.trim().is_empty()) - { - return Err(ContractError::Invalid("prompt_cache_key")); - } - for (value, field) in [ - (self.reasoning_effort.as_deref(), "reasoning_effort"), - (self.reasoning_summary.as_deref(), "reasoning_summary"), - (self.service_tier.as_deref(), "service_tier"), - (self.output_format_name.as_deref(), "output_format_name"), - (self.previous_response_id.as_deref(), "previous_response_id"), - ] { - if let Some(value) = value { - require_id(value, field)?; - } - } if let Some(schema) = &self.output_schema_json { require_content(schema, "output_schema_json")?; require_json_object(schema, "output_schema_json")?; - require_provider_name( - self.output_format_name - .as_deref() - .ok_or(ContractError::Invalid("output_format_name"))?, - "output_format_name", - )?; - } else if self.output_format_name.is_some() { - return Err(ContractError::Invalid("output_schema_json")); } if self.tools.is_empty() && self.tool_choice == ToolChoice::Required { @@ -160,14 +130,70 @@ impl ProviderRequest { } } -#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] #[serde(rename_all = "snake_case")] pub enum ToolChoice { Auto, Required, } -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] +#[serde(rename_all = "snake_case")] +pub enum ReasoningEffort { + Low, + Medium, + High, + Max, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] +#[serde(rename_all = "snake_case")] +pub enum CacheRetention { + Short, + Long, +} + +/// Why a provider stopped producing output. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] +#[serde(rename_all = "snake_case")] +pub enum StopReason { + EndTurn, + ToolUse, + MaxOutputTokens, + StopSequence, + Refusal, + ContextWindow, +} + +/// What kind of failure a provider reported. `ResultSize` is the connector's own +/// stand-in for a result whose sealed frame cannot be carried. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] +#[serde(rename_all = "snake_case")] +pub enum FailureKind { + ResultSize, + InvalidRequest, + Authentication, + RateLimited, + Unavailable, + Timeout, + Provider, +} + +/// Token accounting for one completed request. Cache reads and writes are +/// counted apart from plain input; `reasoning_tokens` is absent when the +/// provider does not report it. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] +#[serde(deny_unknown_fields)] +pub struct Usage { + pub input_tokens: u64, + pub cache_read_tokens: u64, + pub cache_write_tokens: u64, + pub output_tokens: u64, + pub reasoning_tokens: Option, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] +#[serde(deny_unknown_fields)] pub enum InputItem { UserText { text: String, @@ -186,14 +212,16 @@ pub enum InputItem { }, } -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] +#[serde(deny_unknown_fields)] pub struct ToolDefinition { pub name: String, pub description: String, pub parameters_json: String, } -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] +#[serde(deny_unknown_fields)] pub struct TransportInvocation { pub schema_id: String, pub caller_runtime_id: String, @@ -246,7 +274,8 @@ impl TransportInvocation { } } -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] +#[serde(deny_unknown_fields)] pub struct TransportResult { pub schema_id: String, pub request_id: String, @@ -254,7 +283,8 @@ pub struct TransportResult { pub disposition: TransportDisposition, } -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] +#[serde(deny_unknown_fields)] pub enum TransportDisposition { Refused(Refusal), Transported { @@ -312,7 +342,8 @@ impl TransportResult { } } -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] +#[serde(deny_unknown_fields)] pub enum TransportEventPayload { TextDelta { text: String, @@ -324,7 +355,8 @@ pub enum TransportEventPayload { }, } -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] +#[serde(deny_unknown_fields)] pub struct TransportEvent { pub sequence: u64, pub payload: TransportEventPayload, @@ -347,7 +379,8 @@ impl TransportEvent { } } -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] +#[serde(deny_unknown_fields)] pub struct TransportReceipt { pub schema_id: String, pub request_id: String, @@ -355,7 +388,7 @@ pub struct TransportReceipt { pub native_request_sha256: [u8; 32], pub provider_request_sha256: [u8; 32], pub model: String, - pub transport: String, + pub provider: String, pub outcome: TransportOutcome, } @@ -376,17 +409,13 @@ impl TransportReceipt { if self.native_request_sha256 != invocation.native_request_sha256 { return Err(ContractError::NativeDigest); } - require_id(&self.transport, "receipt.transport")?; + require_id(&self.provider, "receipt.provider")?; match &self.outcome { TransportOutcome::Completed { provider_response_id: Some(value), .. } => require_id(value, "receipt.provider_response_id")?, - TransportOutcome::Failed { - failure_kind, - message, - } => { - require_id(failure_kind, "receipt.failure_kind")?; + TransportOutcome::Failed { message, .. } => { require_content(message, "receipt.failure_message")?; } TransportOutcome::Completed { @@ -398,27 +427,28 @@ impl TransportReceipt { } } -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] +#[serde(deny_unknown_fields)] pub enum TransportOutcome { Completed { provider_response_id: Option, - input_tokens: Option, - output_tokens: Option, - reasoning_output_tokens: Option, - cached_input_tokens: Option, + stop_reason: StopReason, + usage: Usage, }, Failed { - failure_kind: String, + kind: FailureKind, message: String, }, } -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] pub enum Refusal { Expired, IdentitySubstitution, ProviderDigestSubstitution, - Policy, + ModelNotAllowed, + OutputBound, + PayloadBound, Capacity, InFlight, Indeterminate, @@ -426,11 +456,61 @@ pub enum Refusal { Malformed, } +/// The one wire codec of every v3 document: named MessagePack (struct fields +/// are keyed by name), with unknown fields refused by the types themselves. +pub(crate) fn wire_encode(document: &T) -> Result, ContractError> { + rmp_serde::to_vec_named(document).map_err(|_| ContractError::Encoding) +} + +/// Decodes a document and refuses every dialect the wire does not name. The +/// typed decoder alone also reads a positional array for a struct, an integer +/// array where `bin` is declared and an enum as its index; so the bytes must +/// equal what [`wire_encode`] writes for the decoded value, compared as +/// MessagePack values: key order, integer width and an absent optional field +/// do not matter, array against map, array against `bin` and index against name do. +pub(crate) fn wire_decode( + bytes: &[u8], +) -> Result { + let document: T = rmp_serde::from_slice(bytes).map_err(|_| ContractError::Encoding)?; + if named_form(bytes)? != named_form(&wire_encode(&document)?)? { + return Err(ContractError::Encoding); + } + Ok(document) +} + +fn named_form(bytes: &[u8]) -> Result { + let mut reader = bytes; + let value = rmpv::decode::read_value(&mut reader).map_err(|_| ContractError::Encoding)?; + if !reader.is_empty() { + return Err(ContractError::Encoding); + } + Ok(order_free(value)) +} + +/// A map compared as a set of its present entries: sorted by key, nil entries dropped. +fn order_free(value: rmpv::Value) -> rmpv::Value { + match value { + rmpv::Value::Map(entries) => { + let mut kept: Vec<(rmpv::Value, rmpv::Value)> = entries + .into_iter() + .filter(|(_, entry)| !entry.is_nil()) + .map(|(key, entry)| (key, order_free(entry))) + .collect(); + kept.sort_by_key(|(key, _)| key.to_string()); + rmpv::Value::Map(kept) + } + rmpv::Value::Array(items) => { + rmpv::Value::Array(items.into_iter().map(order_free).collect()) + } + other => other, + } +} + pub fn canonical_provider_request_bytes( request: &ProviderRequest, ) -> Result, ContractError> { request.validate()?; - rmp_serde::to_vec(request).map_err(|_| ContractError::Encoding) + wire_encode(request) } pub fn provider_request_sha256(request: &ProviderRequest) -> Result<[u8; 32], ContractError> { @@ -444,7 +524,7 @@ pub fn canonical_invocation_bytes( if invocation.provider_request_sha256 != provider_request_sha256(&invocation.request)? { return Err(ContractError::ProviderDigest); } - rmp_serde::to_vec(invocation).map_err(|_| ContractError::Encoding) + wire_encode(invocation) } pub(crate) fn require_id(value: &str, field: &'static str) -> Result<(), ContractError> { @@ -508,7 +588,7 @@ pub enum ContractError { Expiry, #[error("transport event sequence is not contiguous")] EventSequence, - #[error("canonical MessagePack encoding failed")] + #[error("wire encoding failed")] Encoding, } @@ -537,14 +617,14 @@ mod tests { #[test] fn provider_contract_refuses_transport_side_fixup_pressure() { let mut request = request(); - request.output_format_name = Some("epiphany.role.result".to_string()); request.output_schema_json = Some(r#"{"type":"object"}"#.to_string()); + assert_eq!(request.validate(), Ok(())); + request.output_schema_json = Some("[]".to_string()); assert_eq!( request.validate(), - Err(ContractError::Invalid("output_format_name")) + Err(ContractError::Invalid("output_schema_json")) ); - request.output_format_name = None; request.output_schema_json = None; request.input.push(InputItem::ToolResult { call_id: "x".repeat(65), @@ -584,8 +664,8 @@ mod tests { #[test] fn typed_tool_round_survives_messagepack() { let invocation = invocation(); - let bytes = rmp_serde::to_vec(&invocation).unwrap(); - let decoded: TransportInvocation = rmp_serde::from_slice(&bytes).unwrap(); + let bytes = wire_encode(&invocation).unwrap(); + let decoded: TransportInvocation = wire_decode(&bytes).unwrap(); assert_eq!(decoded, invocation); assert!(matches!( decoded.request.tools.as_slice(), @@ -638,4 +718,189 @@ mod tests { Err(ContractError::NativeDigest) ); } + + #[test] + fn v3_documents_carry_their_v3_ids() { + assert_eq!( + [ + PROVIDER_REQUEST_SCHEMA_ID, + INVOCATION_SCHEMA_ID, + RESULT_SCHEMA_ID, + RECEIPT_SCHEMA_ID, + ENVELOPE_SCHEMA_ID + ], + [ + "gamecult.model.provider_request.v3", + "gamecult.model.transport_invocation.v3", + "gamecult.model.transport_result.v3", + "gamecult.model.transport_receipt.v3", + "gamecult.model.transport_envelope.v3" + ] + ); + let invocation = invocation(); + let result = TransportResult::transported(&invocation, Vec::new(), receipt(&invocation)); + let TransportDisposition::Transported { receipt: sent, .. } = &result.disposition else { + unreachable!() + }; + assert_eq!(invocation.request.schema_id, PROVIDER_REQUEST_SCHEMA_ID); + assert_eq!(invocation.schema_id, INVOCATION_SCHEMA_ID); + assert_eq!(result.schema_id, RESULT_SCHEMA_ID); + assert_eq!(sent.schema_id, RECEIPT_SCHEMA_ID); + + let v2 = |name: &str| format!("{}{name}.v2", concat!("gamecult.", "codex.")); + let mut request = invocation.request.clone(); + request.schema_id = v2("provider_request"); + assert_eq!(request.validate(), Err(ContractError::Schema)); + let mut stale = invocation.clone(); + stale.schema_id = v2("transport_invocation"); + assert_eq!(stale.validate(1_000, 2_000), Err(ContractError::Schema)); + let mut stale = result.clone(); + stale.schema_id = v2("transport_result"); + assert_eq!( + stale.validate_against(&invocation), + Err(ContractError::Schema) + ); + let mut stale = (**sent).clone(); + stale.schema_id = v2("transport_receipt"); + assert_eq!( + stale.validate_against(&invocation), + Err(ContractError::Schema) + ); + } + + /// `document` re-encoded as a named map with one more key at its top level. + fn with_extra_key(document: &T) -> Vec { + let bytes = wire_encode(document).unwrap(); + let mut value = rmpv::decode::read_value(&mut bytes.as_slice()).unwrap(); + let rmpv::Value::Map(entries) = &mut value else { + panic!("a v3 document is a named map"); + }; + entries.push((rmpv::Value::from("unexpected_field"), rmpv::Value::from(1))); + let mut out = Vec::new(); + rmpv::encode::write_value(&mut out, &value).unwrap(); + out + } + + #[test] + fn an_unknown_field_is_refused_not_dropped() { + let invocation = invocation(); + let result = TransportResult::transported(&invocation, Vec::new(), receipt(&invocation)); + let TransportDisposition::Transported { receipt: sent, .. } = &result.disposition else { + unreachable!() + }; + let envelope = crate::envelope::encrypt_invocation( + &invocation, + &security("epiphany-distinct-test-key"), + ) + .unwrap(); + assert!(wire_decode::(&wire_encode(&invocation.request).unwrap()).is_ok()); + assert!(wire_decode::(&with_extra_key(&invocation.request)).is_err()); + assert!(wire_decode::(&with_extra_key(&invocation)).is_err()); + assert!(wire_decode::(&with_extra_key(&result)).is_err()); + assert!(wire_decode::(&with_extra_key(&**sent)).is_err()); + assert!( + wire_decode::(&wire_encode(&envelope).unwrap()) + .is_ok() + ); + assert!( + wire_decode::(&with_extra_key(&envelope)).is_err() + ); + } + + #[test] + fn max_output_tokens_is_required() { + let mut request = request(); + assert_eq!(request.validate(), Ok(())); + request.max_output_tokens = 0; + assert_eq!( + request.validate(), + Err(ContractError::Invalid("max_output_tokens")) + ); + } + + #[test] + fn outcomes_are_typed() { + let invocation = invocation(); + for stop_reason in [ + StopReason::EndTurn, + StopReason::ToolUse, + StopReason::MaxOutputTokens, + StopReason::StopSequence, + StopReason::Refusal, + StopReason::ContextWindow, + ] { + let mut sent = receipt(&invocation); + sent.outcome = TransportOutcome::Completed { + provider_response_id: None, + stop_reason, + usage: Usage { + input_tokens: 1, + cache_read_tokens: 2, + cache_write_tokens: 3, + output_tokens: 4, + reasoning_tokens: None, + }, + }; + let decoded: TransportReceipt = wire_decode(&wire_encode(&sent).unwrap()).unwrap(); + assert_eq!(decoded, sent); + } + for kind in [ + FailureKind::ResultSize, + FailureKind::InvalidRequest, + FailureKind::Authentication, + FailureKind::RateLimited, + FailureKind::Unavailable, + FailureKind::Timeout, + FailureKind::Provider, + ] { + let mut sent = receipt(&invocation); + sent.outcome = TransportOutcome::Failed { + kind, + message: "failed".to_string(), + }; + assert_eq!(sent.validate_against(&invocation), Ok(())); + let decoded: TransportReceipt = wire_decode(&wire_encode(&sent).unwrap()).unwrap(); + assert_eq!(decoded, sent); + } + let mut blank = receipt(&invocation); + blank.outcome = TransportOutcome::Failed { + kind: FailureKind::Provider, + message: String::new(), + }; + assert_eq!( + blank.validate_against(&invocation), + Err(ContractError::Invalid("receipt.failure_message")) + ); + } + + #[test] + fn validate_against_refuses_each_identity_field() { + let invocation = invocation(); + let result = TransportResult::transported(&invocation, Vec::new(), receipt(&invocation)); + assert_eq!(result.validate_against(&invocation), Ok(())); + let mut other_request = result.clone(); + other_request.request_id = "request-2".to_string(); + let mut other_caller = result.clone(); + other_caller.caller_runtime_id = "ghostlight-dungeon-yggdrasil".to_string(); + for changed in [other_request, other_caller] { + assert_eq!( + changed.validate_against(&invocation), + Err(ContractError::Identity) + ); + } + + let good = receipt(&invocation); + let mut other_request = good.clone(); + other_request.request_id = "request-2".to_string(); + let mut other_caller = good.clone(); + other_caller.caller_runtime_id = "ghostlight-dungeon-yggdrasil".to_string(); + let mut other_model = good.clone(); + other_model.model = "another-model".to_string(); + for changed in [other_request, other_caller, other_model] { + assert_eq!( + changed.validate_against(&invocation), + Err(ContractError::Identity) + ); + } + } } diff --git a/crates/model-connector/src/envelope.rs b/crates/model-connector/src/envelope.rs index 5400eac..0f87546 100644 --- a/crates/model-connector/src/envelope.rs +++ b/crates/model-connector/src/envelope.rs @@ -2,83 +2,264 @@ use std::io::{Read, Write}; use aes_gcm::aead::{Aead, Payload}; use aes_gcm::{Aes256Gcm, KeyInit, Nonce}; +use base64::Engine; +use base64::engine::general_purpose::STANDARD; +use cultnet_rs::{ + CultNetMessage, CultNetWireContract, decode_cultnet_message_from_slice, + encode_cultnet_message_to_vec, +}; use rand_core::{OsRng, RngCore}; +use schemars::JsonSchema; use serde::{Deserialize, Serialize}; use thiserror::Error; use crate::contract::{ ContractError, ENVELOPE_SCHEMA_ID, TransportInvocation, TransportKey, TransportResult, - canonical_invocation_bytes, require_id, + canonical_invocation_bytes, require_id, wire_decode, wire_encode, }; -#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] pub enum EnvelopeKind { Invocation, Result, } -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] +#[serde(deny_unknown_fields)] pub struct TransportEnvelope { pub schema_id: String, pub caller_runtime_id: String, pub request_id: String, pub message_kind: EnvelopeKind, pub nonce: [u8; 12], + #[serde(with = "serde_bytes")] + #[schemars(schema_with = "binary_schema")] pub ciphertext: Vec, } +/// The ciphertext is MessagePack `bin` on the wire, never an array of integers. +fn binary_schema(_: &mut schemars::SchemaGenerator) -> schemars::Schema { + schemars::json_schema!({ + "type": "string", + "format": "binary", + "description": "MessagePack bin: the raw ciphertext bytes, never an array of integers" + }) +} + +/// The CultNet service every frame of this connector addresses. +pub const CONNECTOR_SERVICE_ID: &str = "gamecult.model.connector"; +/// The one operation of that service: run one sealed invocation. +pub const INVOKE_OPERATION: &str = "invoke"; + +const PAYLOAD_ENCODING: &str = "messagepack-base64"; +const OK_STATUS: &str = "ok"; + /// The longest caller id, request id or model name a service admits. The /// frame arithmetic below depends on it. A request whose id is longer is /// refused on the wire as malformed. pub const MAX_IDENTITY_BYTES: usize = 256; -/// The most bytes a serialized envelope holding `ciphertext_len` ciphertext -/// bytes can occupy, with ids at their longest: the frame a caller payload -/// bound (`CallerAdmission`'s `max_payload_bytes`, a bound on ciphertext -/// length) needs. The v2 wire serializes the ciphertext as a MessagePack array -/// of integers: a byte of 128 or more takes two bytes, so the worst case is -/// twice the ciphertext, plus the array header and the envelope's other -/// fields at their longest. Measured from the real encoding, not assumed. +fn carriage_bytes(message: &CultNetMessage) -> Result, ServiceError> { + encode_cultnet_message_to_vec(message, CultNetWireContract::CultNetSchemaV0) + .map_err(|_| ServiceError::Carriage) +} + +fn seal_payload(envelope: &TransportEnvelope) -> Result { + Ok(STANDARD.encode(wire_encode(envelope)?)) +} + +/// The CultNet frame an invocation travels in: a `cultnet.operation_request.v0` +/// for [`CONNECTOR_SERVICE_ID`] and [`INVOKE_OPERATION`], its `message_id` the +/// request id and its payload the base64 of the named sealed envelope. +pub(crate) fn encode_invocation_frame( + envelope: &TransportEnvelope, +) -> Result, ServiceError> { + carriage_bytes(&CultNetMessage::OperationRequest { + message_id: envelope.request_id.clone(), + service_id: CONNECTOR_SERVICE_ID.to_string(), + operation: INVOKE_OPERATION.to_string(), + payload_schema: ENVELOPE_SCHEMA_ID.to_string(), + payload_encoding: PAYLOAD_ENCODING.to_string(), + payload: seal_payload(envelope)?, + source_runtime_id: None, + target_runtime_id: None, + }) +} + +/// The CultNet frame a result travels in: a `cultnet.operation_response.v0` +/// with status `ok`, otherwise shaped like the invocation's. +#[cfg(any(feature = "daemon", test))] +pub(crate) fn encode_result_frame(envelope: &TransportEnvelope) -> Result, ServiceError> { + carriage_bytes(&CultNetMessage::OperationResponse { + message_id: envelope.request_id.clone(), + service_id: CONNECTOR_SERVICE_ID.to_string(), + operation: INVOKE_OPERATION.to_string(), + status: OK_STATUS.to_string(), + payload_schema: ENVELOPE_SCHEMA_ID.to_string(), + payload_encoding: PAYLOAD_ENCODING.to_string(), + payload: seal_payload(envelope)?, + diagnostics: Vec::new(), + source_runtime_id: None, + }) +} + +/// Opens the carriage of one frame. Everything the carriage says is checked +/// here, before any decryption: the service, the operation, the payload schema, +/// and that the message id is the sealed envelope's request id. +fn open_carriage( + message_id: &str, + service_id: &str, + operation: &str, + payload_schema: &str, + payload: &str, +) -> Result { + if service_id != CONNECTOR_SERVICE_ID + || operation != INVOKE_OPERATION + || payload_schema != ENVELOPE_SCHEMA_ID + { + return Err(ServiceError::Carriage); + } + let bytes = STANDARD + .decode(payload) + .map_err(|_| ServiceError::Carriage)?; + let envelope: TransportEnvelope = wire_decode(&bytes).map_err(|_| ServiceError::Encoding)?; + if envelope.request_id != message_id { + return Err(ServiceError::OuterIdentity); + } + Ok(envelope) +} + +fn read_carriage(bytes: &[u8]) -> Result { + decode_cultnet_message_from_slice(bytes, CultNetWireContract::CultNetSchemaV0) + .map_err(|_| ServiceError::Carriage) +} + +#[cfg(any(feature = "daemon", test))] +pub(crate) fn decode_invocation_frame(bytes: &[u8]) -> Result { + match read_carriage(bytes)? { + CultNetMessage::OperationRequest { + message_id, + service_id, + operation, + payload_schema, + payload, + .. + } => open_carriage( + &message_id, + &service_id, + &operation, + &payload_schema, + &payload, + ), + _ => Err(ServiceError::Carriage), + } +} + +pub(crate) fn decode_result_frame(bytes: &[u8]) -> Result { + match read_carriage(bytes)? { + CultNetMessage::OperationResponse { + message_id, + service_id, + operation, + status, + payload_schema, + payload, + .. + } if status == OK_STATUS => open_carriage( + &message_id, + &service_id, + &operation, + &payload_schema, + &payload, + ), + _ => Err(ServiceError::Carriage), + } +} + +/// The most bytes one frame holding a sealed envelope of `ciphertext_len` +/// ciphertext bytes can occupy, with ids at their longest: the frame a caller +/// payload bound (`CallerAdmission`'s `max_payload_bytes`, a bound on +/// ciphertext length) needs. +/// +/// A frame is a CultNet operation message whose payload is the base64 of the +/// named envelope. The result frame is the larger of the two directions (it +/// also carries a status), so this bound holds for an invocation as well. The +/// envelope shell and the message shell are measured from their real +/// encodings; only the MessagePack length headers and base64 expansion that +/// grow with `ciphertext_len` are arithmetic. pub fn frame_bytes_for_payload(ciphertext_len: usize) -> usize { - let shell = rmp_serde::to_vec(&TransportEnvelope { + let long = "x".repeat(MAX_IDENTITY_BYTES); + let envelope_shell = wire_encode(&TransportEnvelope { schema_id: ENVELOPE_SCHEMA_ID.to_string(), - caller_runtime_id: "x".repeat(MAX_IDENTITY_BYTES), - request_id: "x".repeat(MAX_IDENTITY_BYTES), + caller_runtime_id: long.clone(), + request_id: long.clone(), message_kind: EnvelopeKind::Invocation, nonce: [0xff; 12], ciphertext: Vec::new(), }) .map_or(usize::MAX, |bytes| bytes.len()); - // The shell holds an empty array header of one byte. - let header = match ciphertext_len { - 0..=15 => 1, - 16..=65_535 => 3, + let message_shell = encode_cultnet_message_to_vec( + &CultNetMessage::OperationResponse { + message_id: long, + service_id: CONNECTOR_SERVICE_ID.to_string(), + operation: INVOKE_OPERATION.to_string(), + status: OK_STATUS.to_string(), + payload_schema: ENVELOPE_SCHEMA_ID.to_string(), + payload_encoding: PAYLOAD_ENCODING.to_string(), + payload: "A".to_string(), + diagnostics: Vec::new(), + source_runtime_id: None, + }, + CultNetWireContract::CultNetSchemaV0, + ) + .map_or(usize::MAX, |bytes| bytes.len()); + // The shell holds an empty `bin 8`: a marker and a zero length. The shell's + // one-character payload takes a one-byte string header and the character. + let envelope = envelope_shell + .saturating_sub(2) + .saturating_add(bin_header_bytes(ciphertext_len)) + .saturating_add(ciphertext_len); + let payload = envelope.div_ceil(3).saturating_mul(4); + message_shell + .saturating_sub(2) + .saturating_add(str_header_bytes(payload)) + .saturating_add(payload) +} + +/// MessagePack `bin` header width for a byte string of `len` bytes. +fn bin_header_bytes(len: usize) -> usize { + match len { + 0..=255 => 2, + 256..=65_535 => 3, _ => 5, - }; - shell - .saturating_sub(1) - .saturating_add(header) - .saturating_add(ciphertext_len.saturating_mul(2)) + } +} + +/// MessagePack `str` header width for the base64 payload. The payload always +/// exceeds 255 bytes: the envelope it encodes holds two ids of at least +/// `MAX_IDENTITY_BYTES` in the worst case this bound measures. +fn str_header_bytes(len: usize) -> usize { + if len <= 65_535 { 3 } else { 5 } } /// The largest payload bound whose worst-case frame fits `max_frame_bytes`: /// the inverse of [`frame_bytes_for_payload`]. `None` when the frame cannot /// carry even an empty envelope. pub fn payload_bytes_for_frame(max_frame_bytes: usize) -> Option { - // frame_bytes_for_payload(0) is the shell plus a one-byte array header. - let fixed = frame_bytes_for_payload(0) - 1; - let room = max_frame_bytes.checked_sub(fixed)?; - // Each array header width bounds the payloads it can describe; the - // widest payload any width admits is the answer. - let by_header = |header: usize, cap: usize| room.checked_sub(header).map(|r| (r / 2).min(cap)); - [ - by_header(5, usize::MAX), - by_header(3, 65_535), - by_header(1, 15), - ] - .into_iter() - .flatten() - .max() + if frame_bytes_for_payload(0) > max_frame_bytes { + return None; + } + // A frame is longer than its payload, so the answer is below the bound. + let (mut fits, mut over) = (0, max_frame_bytes.saturating_add(1)); + while over - fits > 1 { + let middle = fits + (over - fits) / 2; + if frame_bytes_for_payload(middle) <= max_frame_bytes { + fits = middle; + } else { + over = middle; + } + } + Some(fits) } pub fn encrypt_invocation( @@ -102,7 +283,7 @@ pub fn decrypt_invocation( validate_envelope(envelope, EnvelopeKind::Invocation)?; let plaintext = decrypt_message(envelope, security)?; let invocation: TransportInvocation = - rmp_serde::from_slice(&plaintext).map_err(|_| ServiceError::Encoding)?; + wire_decode(&plaintext).map_err(|_| ServiceError::Encoding)?; if invocation.caller_runtime_id != envelope.caller_runtime_id || invocation.request_id() != envelope.request_id { @@ -115,7 +296,7 @@ pub fn encrypt_result( result: &TransportResult, security: &TransportKey, ) -> Result { - let plaintext = rmp_serde::to_vec(result).map_err(|_| ServiceError::Encoding)?; + let plaintext = wire_encode(result).map_err(|_| ServiceError::Encoding)?; encrypt_message( &result.caller_runtime_id, &result.request_id, @@ -132,8 +313,7 @@ pub fn decrypt_result( ) -> Result { validate_envelope(envelope, EnvelopeKind::Result)?; let plaintext = decrypt_message(envelope, security)?; - let result: TransportResult = - rmp_serde::from_slice(&plaintext).map_err(|_| ServiceError::Encoding)?; + let result: TransportResult = wire_decode(&plaintext).map_err(|_| ServiceError::Encoding)?; if result.caller_runtime_id != envelope.caller_runtime_id || result.request_id != envelope.request_id { @@ -242,13 +422,15 @@ pub(crate) fn decrypt_message( .map_err(|_| ServiceError::Encryption) } +/// The authenticated data of a sealed message: a positional tuple, a MAC input +/// and not a wire document (a tuple is positional under the named codec too). pub(crate) fn envelope_aad( schema_id: &str, caller_runtime_id: &str, request_id: &str, message_kind: EnvelopeKind, ) -> Result, ServiceError> { - rmp_serde::to_vec(&(schema_id, caller_runtime_id, request_id, message_kind)) + wire_encode(&(schema_id, caller_runtime_id, request_id, message_kind)) .map_err(|_| ServiceError::Encoding) } @@ -291,8 +473,10 @@ pub enum ServiceError { OuterIdentity, #[error("transport encryption or authentication failed")] Encryption, - #[error("transport MessagePack encoding failed")] + #[error("transport encoding failed")] Encoding, + #[error("frame is not a connector operation message")] + Carriage, #[error("completion did not carry a transported provider outcome")] InvalidCompletion, #[error("completion has no active request")] @@ -373,7 +557,7 @@ mod tests { let key = security("epiphany-distinct-test-key"); let invocation = invocation(); let result = TransportResult::transported(&invocation, Vec::new(), receipt(&invocation)); - let plaintext = rmp_serde::to_vec(&result).unwrap(); + let plaintext = wire_encode(&result).unwrap(); let seal = |caller: &str, request_id: &str| { encrypt_message(caller, request_id, EnvelopeKind::Result, &plaintext, &key).unwrap() }; @@ -401,7 +585,7 @@ mod tests { let good = encrypt_invocation(&invocation, &key).unwrap(); let mut schema = good.clone(); - schema.schema_id = "gamecult.codex.transport_envelope.v1".to_string(); + schema.schema_id = concat!("gamecult.", "codex.transport_envelope.v2").to_string(); let wrong_kind = sealed_invocation("epiphany-yggdrasil", "request-1", EnvelopeKind::Result); let mut empty = good.clone(); empty.ciphertext.clear(); @@ -452,20 +636,44 @@ mod tests { assert!(matches!(write(&[], 1_000), Err(TransportFrameError::Size))); } - #[test] - fn the_worst_frame_is_the_real_encoding_of_an_all_high_ciphertext() { + /// The real result frame for an envelope with ids at the longest admitted + /// and a ciphertext of `length` bytes. + fn real_worst_frame(length: usize) -> Vec { let long = "x".repeat(MAX_IDENTITY_BYTES); - for length in [0, 1, 15, 16, 17, 255, 256, 65_535, 65_536, 70_000] { - let envelope = TransportEnvelope { - schema_id: ENVELOPE_SCHEMA_ID.to_string(), - caller_runtime_id: long.clone(), - request_id: long.clone(), - message_kind: EnvelopeKind::Invocation, - nonce: [0xff; 12], - ciphertext: vec![0xff; length], - }; + encode_result_frame(&TransportEnvelope { + schema_id: ENVELOPE_SCHEMA_ID.to_string(), + caller_runtime_id: long.clone(), + request_id: long, + message_kind: EnvelopeKind::Invocation, + nonce: [0xff; 12], + ciphertext: vec![0xff; length], + }) + .unwrap() + } + + /// Ciphertext sizes either side of every length-header boundary the frame + /// crosses: bin 8 to 16 to 32, str 8 to 16 to 32, and each base64 padding. + fn boundary_sizes() -> Vec { + let mut sizes: Vec = (0..=40).collect(); + for center in [ + 100, 200, 250, 256, 300, 1000, 3000, 65_535, 65_536, 49_000, 49_100, 49_152, 49_200, + 70_000, + ] { + for delta in 0..=40 { + sizes.push(center + delta); + sizes.push(center.saturating_sub(delta)); + } + } + sizes.sort_unstable(); + sizes.dedup(); + sizes + } + + #[test] + fn the_frame_bound_is_the_real_carriage_encoding() { + for length in boundary_sizes() { assert_eq!( - rmp_serde::to_vec(&envelope).unwrap().len(), + real_worst_frame(length).len(), frame_bytes_for_payload(length), "{length}" ); @@ -480,35 +688,451 @@ mod tests { let sealed = encrypt_message(&id, &id, EnvelopeKind::Result, &vec![0x41; plaintext], &key) .unwrap(); - let real = rmp_serde::to_vec(&sealed).unwrap().len(); let worst = frame_bytes_for_payload(sealed.ciphertext.len()); - assert!(real <= worst, "{real} > {worst}"); - // Real ciphertext is random, so about half its bytes take two - // bytes: the worst case is not wildly above what is written. + for real in [ + encode_result_frame(&sealed).unwrap().len(), + encode_invocation_frame(&sealed).unwrap().len(), + ] { + assert!(real <= worst, "{real} > {worst}"); + } + // The frame is the base64 of the envelope: its worst case is + // within the base64 expansion of the real one. if id.len() == MAX_IDENTITY_BYTES { - assert!( - worst <= real + sealed.ciphertext.len() + 64, - "{worst} {real}" - ); + let real = encode_result_frame(&sealed).unwrap().len(); + assert!(worst <= real + 32, "{worst} {real}"); } } } } #[test] - fn the_payload_bound_a_frame_allows_inverts_the_frame_a_payload_needs() { + fn the_payload_bound_a_frame_allows_is_the_largest_payload_that_fits() { + // The frame grows in base64 groups of three bytes, so several payloads + // share a frame: the inverse is the largest of them. assert_eq!( payload_bytes_for_frame(frame_bytes_for_payload(0) - 1), None ); - assert_eq!(payload_bytes_for_frame(frame_bytes_for_payload(0)), Some(0)); - for bytes in [1, 15, 16, 4096, 65_535, 65_536, 100_000, 2_000_000] { + for bytes in boundary_sizes().into_iter().chain([2_000_000]) { let frame = frame_bytes_for_payload(bytes); - assert_eq!(payload_bytes_for_frame(frame), Some(bytes), "{bytes}"); + let allowed = payload_bytes_for_frame(frame).unwrap(); + assert!(allowed >= bytes && allowed - bytes < 3, "{bytes} {allowed}"); + assert_eq!(frame_bytes_for_payload(allowed), frame, "{bytes}"); + assert!(frame_bytes_for_payload(allowed + 1) > frame, "{bytes}"); assert!( - payload_bytes_for_frame(frame - 1).unwrap() < bytes, + payload_bytes_for_frame(frame - 1).is_none_or(|smaller| smaller < bytes), "{bytes}" ); } } + + #[test] + fn ciphertext_travels_as_binary() { + let key = security("epiphany-distinct-test-key"); + for plaintext in [10, 300, 70_000] { + let sealed = encrypt_message( + "caller", + "request", + EnvelopeKind::Invocation, + &vec![0x41; plaintext], + &key, + ) + .unwrap(); + let length = sealed.ciphertext.len(); + let mut expected = match length { + 0..=255 => vec![0xc4, length as u8], + 256..=65_535 => [&[0xc5][..], &(length as u16).to_be_bytes()].concat(), + _ => [&[0xc6][..], &(length as u32).to_be_bytes()].concat(), + }; + expected.extend(&sealed.ciphertext); + let encoded = wire_encode(&sealed).unwrap(); + assert!( + encoded + .windows(expected.len()) + .any(|window| window == expected), + "{plaintext}" + ); + } + } + + fn sealed_for_carriage() -> TransportEnvelope { + encrypt_invocation(&invocation(), &security("epiphany-distinct-test-key")).unwrap() + } + + fn request_message(edit: impl FnOnce(&mut CultNetMessage)) -> Vec { + let envelope = sealed_for_carriage(); + let mut message = decode_cultnet_message_from_slice( + &encode_invocation_frame(&envelope).unwrap(), + CultNetWireContract::CultNetSchemaV0, + ) + .unwrap(); + edit(&mut message); + carriage_bytes(&message).unwrap() + } + + fn response_message(edit: impl FnOnce(&mut CultNetMessage)) -> Vec { + let envelope = sealed_for_carriage(); + let mut message = decode_cultnet_message_from_slice( + &encode_result_frame(&envelope).unwrap(), + CultNetWireContract::CultNetSchemaV0, + ) + .unwrap(); + edit(&mut message); + carriage_bytes(&message).unwrap() + } + + #[test] + fn the_carriage_round_trips_both_directions() { + let envelope = sealed_for_carriage(); + assert_eq!( + decode_invocation_frame(&encode_invocation_frame(&envelope).unwrap()).unwrap(), + envelope + ); + assert_eq!( + decode_result_frame(&encode_result_frame(&envelope).unwrap()).unwrap(), + envelope + ); + // A request is not a response, in either direction. + assert_eq!( + decode_result_frame(&encode_invocation_frame(&envelope).unwrap()), + Err(ServiceError::Carriage) + ); + assert_eq!( + decode_invocation_frame(&encode_result_frame(&envelope).unwrap()), + Err(ServiceError::Carriage) + ); + } + + #[test] + fn the_carriage_refuses_another_service_operation_or_schema() { + fn edit_request(field: &str, value: &str) -> Vec { + request_message(|message| { + if let CultNetMessage::OperationRequest { + service_id, + operation, + payload_schema, + .. + } = message + { + match field { + "service" => *service_id = value.to_string(), + "operation" => *operation = value.to_string(), + _ => *payload_schema = value.to_string(), + } + } + }) + } + fn edit_response(field: &str, value: &str) -> Vec { + response_message(|message| { + if let CultNetMessage::OperationResponse { + service_id, + operation, + payload_schema, + status, + .. + } = message + { + match field { + "service" => *service_id = value.to_string(), + "operation" => *operation = value.to_string(), + "status" => *status = value.to_string(), + _ => *payload_schema = value.to_string(), + } + } + }) + } + for (field, value) in [ + ("service", "gamecult.model.other"), + ("operation", "cancel"), + ("schema", "gamecult.model.transport_envelope.v2"), + ] { + assert_eq!( + decode_invocation_frame(&edit_request(field, value)), + Err(ServiceError::Carriage), + "{field}" + ); + assert_eq!( + decode_result_frame(&edit_response(field, value)), + Err(ServiceError::Carriage), + "{field}" + ); + } + assert_eq!( + decode_result_frame(&edit_response("status", "rejected")), + Err(ServiceError::Carriage) + ); + // The unedited message decodes, so each refusal above is the edit's. + assert!(decode_invocation_frame(&edit_request("service", CONNECTOR_SERVICE_ID)).is_ok()); + assert!(decode_result_frame(&edit_response("status", "ok")).is_ok()); + } + + #[test] + fn the_carriage_refuses_a_message_id_that_is_not_the_envelopes_request_id() { + let request = request_message(|message| { + if let CultNetMessage::OperationRequest { message_id, .. } = message { + *message_id = "another-request".to_string(); + } + }); + let response = response_message(|message| { + if let CultNetMessage::OperationResponse { message_id, .. } = message { + *message_id = "another-request".to_string(); + } + }); + assert_eq!( + decode_invocation_frame(&request), + Err(ServiceError::OuterIdentity) + ); + assert_eq!( + decode_result_frame(&response), + Err(ServiceError::OuterIdentity) + ); + } + + #[test] + fn the_carriage_refuses_what_is_not_a_connector_message() { + let good = encode_invocation_frame(&sealed_for_carriage()).unwrap(); + // Not MessagePack of any CultNet message, nor a truncated one. + assert_eq!( + decode_invocation_frame(&[0xc1, 0x00, 0x01]), + Err(ServiceError::Carriage) + ); + assert_eq!( + decode_invocation_frame(&good[..good.len() / 2]), + Err(ServiceError::Carriage) + ); + // A payload that is not base64, and base64 of something else. + let not_base64 = request_message(|message| { + if let CultNetMessage::OperationRequest { payload, .. } = message { + *payload = "!!not base64!!".to_string(); + } + }); + assert_eq!( + decode_invocation_frame(¬_base64), + Err(ServiceError::Carriage) + ); + let not_an_envelope = request_message(|message| { + if let CultNetMessage::OperationRequest { payload, .. } = message { + *payload = STANDARD.encode(b"\x01\x02"); + } + }); + assert_eq!( + decode_invocation_frame(¬_an_envelope), + Err(ServiceError::Encoding) + ); + } + + fn golden_frame() -> Vec { + std::fs::read( + std::path::Path::new(env!("CARGO_MANIFEST_DIR")) + .join("tests/fixtures/wire_v3/carriage_invocation.frame"), + ) + .expect("carriage_invocation.frame is committed") + } + + #[test] + fn the_committed_golden_frame_is_what_the_kit_reads_and_writes() { + let frame = golden_frame(); + let envelope = decode_invocation_frame(&frame).unwrap(); + assert_eq!(encode_invocation_frame(&envelope).unwrap(), frame); + } + + fn top_level(frame: &[u8]) -> std::collections::BTreeMap { + let rmpv::Value::Map(entries) = rmpv::decode::read_value(&mut &frame[..]).unwrap() else { + panic!("a CultNet frame is a map"); + }; + entries + .into_iter() + .map(|(key, value)| (key.as_str().unwrap().to_string(), value)) + .collect() + } + + #[test] + fn the_frames_name_the_connector_service_and_its_one_operation_on_the_wire() { + let envelope = sealed_for_carriage(); + let text = |map: &std::collections::BTreeMap, key: &str| { + map[key].as_str().unwrap().to_string() + }; + let request = top_level(&encode_invocation_frame(&envelope).unwrap()); + assert_eq!( + text(&request, "schemaVersion"), + "cultnet.operation_request.v0" + ); + assert_eq!(text(&request, "serviceId"), "gamecult.model.connector"); + assert_eq!(text(&request, "operation"), "invoke"); + assert_eq!( + text(&request, "payloadSchema"), + "gamecult.model.transport_envelope.v3" + ); + assert_eq!(text(&request, "payloadEncoding"), "messagepack-base64"); + assert_eq!(text(&request, "messageId"), envelope.request_id); + let response = top_level(&encode_result_frame(&envelope).unwrap()); + assert_eq!( + text(&response, "schemaVersion"), + "cultnet.operation_response.v0" + ); + assert_eq!(text(&response, "serviceId"), "gamecult.model.connector"); + assert_eq!(text(&response, "operation"), "invoke"); + assert_eq!(text(&response, "status"), "ok"); + assert_eq!( + text(&response, "payloadSchema"), + "gamecult.model.transport_envelope.v3" + ); + assert_eq!(text(&response, "payloadEncoding"), "messagepack-base64"); + assert_eq!(text(&response, "messageId"), envelope.request_id); + } + + fn value_of(bytes: &[u8]) -> rmpv::Value { + rmpv::decode::read_value(&mut &bytes[..]).unwrap() + } + + fn bytes_of(value: &rmpv::Value) -> Vec { + let mut out = Vec::new(); + rmpv::encode::write_value(&mut out, value).unwrap(); + out + } + + fn with_entry(value: rmpv::Value, name: &str, entry: rmpv::Value) -> rmpv::Value { + let rmpv::Value::Map(mut entries) = value else { + panic!("a named document is a map"); + }; + let slot = entries + .iter_mut() + .find(|(key, _)| key.as_str() == Some(name)) + .unwrap(); + slot.1 = entry; + rmpv::Value::Map(entries) + } + + fn reverse_maps(value: rmpv::Value) -> rmpv::Value { + match value { + rmpv::Value::Map(entries) => rmpv::Value::Map( + entries + .into_iter() + .rev() + .map(|(key, entry)| (key, reverse_maps(entry))) + .collect(), + ), + rmpv::Value::Array(items) => { + rmpv::Value::Array(items.into_iter().map(reverse_maps).collect()) + } + other => other, + } + } + + fn without_nils(value: rmpv::Value) -> rmpv::Value { + match value { + rmpv::Value::Map(entries) => rmpv::Value::Map( + entries + .into_iter() + .filter(|(_, entry)| !entry.is_nil()) + .map(|(key, entry)| (key, without_nils(entry))) + .collect(), + ), + rmpv::Value::Array(items) => { + rmpv::Value::Array(items.into_iter().map(without_nils).collect()) + } + other => other, + } + } + + fn request_frame_carrying(payload: &[u8]) -> Vec { + request_message(|message| { + if let CultNetMessage::OperationRequest { payload: sent, .. } = message { + *sent = STANDARD.encode(payload); + } + }) + } + + fn response_frame_carrying(payload: &[u8]) -> Vec { + response_message(|message| { + if let CultNetMessage::OperationResponse { payload: sent, .. } = message { + *sent = STANDARD.encode(payload); + } + }) + } + + #[test] + fn the_carriage_refuses_every_dialect_the_wire_does_not_name() { + let envelope = sealed_for_carriage(); + let named = value_of(&wire_encode(&envelope).unwrap()); + let integer_ciphertext = rmpv::Value::Array( + envelope + .ciphertext + .iter() + .map(|byte| rmpv::Value::from(*byte)) + .collect(), + ); + let dialects = [ + ("positional envelope", rmp_serde::to_vec(&envelope).unwrap()), + ( + "integer-array ciphertext", + bytes_of(&with_entry(named.clone(), "ciphertext", integer_ciphertext)), + ), + ( + "enum as its index", + bytes_of(&with_entry(named, "message_kind", 0.into())), + ), + ( + "bytes after the document", + [wire_encode(&envelope).unwrap(), vec![0xc0]].concat(), + ), + ]; + for (dialect, payload) in &dialects { + assert_eq!( + decode_invocation_frame(&request_frame_carrying(payload)), + Err(ServiceError::Encoding), + "{dialect} on a request" + ); + assert_eq!( + decode_result_frame(&response_frame_carrying(payload)), + Err(ServiceError::Encoding), + "{dialect} on a response" + ); + } + // The named form itself is read, so the refusals above are the dialect's. + let named_bytes = wire_encode(&envelope).unwrap(); + assert_eq!( + decode_invocation_frame(&request_frame_carrying(&named_bytes)), + Ok(envelope) + ); + } + + #[test] + fn a_sealed_positional_invocation_is_refused_after_decryption() { + let key = security("epiphany-distinct-test-key"); + let invocation = invocation(); + let sealed = encrypt_message( + &invocation.caller_runtime_id, + invocation.request_id(), + EnvelopeKind::Invocation, + &rmp_serde::to_vec(&invocation).unwrap(), + &key, + ) + .unwrap(); + let arrived = decode_invocation_frame(&encode_invocation_frame(&sealed).unwrap()).unwrap(); + assert_eq!( + decrypt_invocation(&arrived, &key), + Err(ServiceError::Encoding) + ); + let sealed = encrypt_invocation(&invocation, &key).unwrap(); + assert_eq!(decrypt_invocation(&sealed, &key), Ok(invocation)); + } + + #[test] + fn named_maps_in_any_key_order_and_without_absent_options_are_the_named_form() { + let envelope = sealed_for_carriage(); + let reversed = bytes_of(&reverse_maps(value_of(&wire_encode(&envelope).unwrap()))); + assert_ne!(reversed, wire_encode(&envelope).unwrap()); + assert_eq!(wire_decode::(&reversed), Ok(envelope)); + let invocation = invocation(); + let named = value_of(&wire_encode(&invocation).unwrap()); + for edited in [reverse_maps(named.clone()), without_nils(named)] { + let bytes = bytes_of(&edited); + assert_ne!(bytes, wire_encode(&invocation).unwrap()); + assert_eq!( + wire_decode::(&bytes), + Ok(invocation.clone()) + ); + } + } } diff --git a/crates/model-connector/src/fixtures.rs b/crates/model-connector/src/fixtures.rs index 9b48c1d..e94ee52 100644 --- a/crates/model-connector/src/fixtures.rs +++ b/crates/model-connector/src/fixtures.rs @@ -43,6 +43,7 @@ pub(crate) fn request() -> ProviderRequest { "conversation-1", "gpt-5.4", "Return the typed result.", + 4_096, ); request.input.push(InputItem::UserText { text: "Projected state".to_string(), @@ -67,13 +68,17 @@ pub(crate) fn receipt(invocation: &TransportInvocation) -> TransportReceipt { native_request_sha256: invocation.native_request_sha256, provider_request_sha256: invocation.provider_request_sha256, model: invocation.request.model.clone(), - transport: "fixture-sse".to_string(), + provider: "fixture-sse".to_string(), outcome: TransportOutcome::Completed { provider_response_id: Some("response-1".to_string()), - input_tokens: Some(10), - output_tokens: Some(3), - reasoning_output_tokens: Some(1), - cached_input_tokens: None, + stop_reason: StopReason::EndTurn, + usage: Usage { + input_tokens: 10, + cache_read_tokens: 0, + cache_write_tokens: 0, + output_tokens: 3, + reasoning_tokens: Some(1), + }, }, } } diff --git a/crates/model-connector/src/serve.rs b/crates/model-connector/src/serve.rs index 8eda706..cef686a 100644 --- a/crates/model-connector/src/serve.rs +++ b/crates/model-connector/src/serve.rs @@ -12,8 +12,8 @@ use zeroize::Zeroizing; use crate::contract::{TransportInvocation, TransportResult}; use crate::envelope::{ - ServiceError, TransportEnvelope, TransportFrameError, frame_bytes_for_payload, - read_transport_frame, write_transport_frame, + ServiceError, TransportFrameError, decode_invocation_frame, encode_result_frame, + frame_bytes_for_payload, read_transport_frame, write_transport_frame, }; use crate::service::{CallerAdmission, TransportAdmission, TransportService}; @@ -222,8 +222,7 @@ fn execute_inference_frame( backend: &dyn ProviderBackend, limits: &ServeLimits, ) -> Result, ServeError> { - let envelope: TransportEnvelope = - rmp_serde::from_slice(request).map_err(|_| ServeError::FrameEncoding)?; + let envelope = decode_invocation_frame(request).map_err(|_| ServeError::FrameEncoding)?; let admission = service .lock() .map_err(|_| ServeError::ServicePoisoned)? @@ -238,7 +237,7 @@ fn execute_inference_frame( .complete(*claim, result, limits.max_frame_bytes)? } }; - rmp_serde::to_vec(&response).map_err(|_| ServeError::FrameEncoding) + encode_result_frame(&response).map_err(|_| ServeError::FrameEncoding) } fn frame_error(error: TransportFrameError) -> ServeError { @@ -314,7 +313,7 @@ pub enum ServeError { Connection(#[source] std::io::Error), #[error("connector frame exceeded its bound")] FrameSize, - #[error("connector frame was not valid MessagePack")] + #[error("connector frame was not a connector operation message")] FrameEncoding, #[error("system clock is before the Unix epoch")] Clock, @@ -328,8 +327,8 @@ mod tests { use super::*; use crate::client::ConnectorClient; use crate::contract::{ - Refusal, TransportDisposition, TransportEvent, TransportEventPayload, TransportOutcome, - TransportResult, + FailureKind, Refusal, TransportDisposition, TransportEvent, TransportEventPayload, + TransportOutcome, TransportResult, }; use crate::fixtures::{TestStore, caller, invocation_for, receipt, request}; @@ -506,7 +505,7 @@ mod tests { let backend = CountingBackend::new(true); let (_client, endpoint) = start(backend.clone(), &store); let security = crate::fixtures::security(KEY); - let request = rmp_serde::to_vec( + let request = crate::envelope::encode_invocation_frame( &crate::envelope::encrypt_invocation(&fresh("request-wire"), &security).unwrap(), ) .unwrap(); @@ -531,14 +530,14 @@ mod tests { // requires the result to name the request by its own id. let send = |id: &str| { let invocation = fresh(id); - let request = rmp_serde::to_vec( + let request = crate::envelope::encode_invocation_frame( &crate::envelope::encrypt_invocation(&invocation, &security).unwrap(), ) .unwrap(); let mut stream = TcpStream::connect(endpoint).unwrap(); write_transport_frame(&mut stream, &request, FRAME).unwrap(); let reply = read_transport_frame(&mut stream, FRAME).unwrap(); - let envelope: TransportEnvelope = rmp_serde::from_slice(&reply).unwrap(); + let envelope = crate::envelope::decode_result_frame(&reply).unwrap(); crate::envelope::decrypt_result(&envelope, &security, &invocation).unwrap() }; let served = send(&"i".repeat(crate::envelope::MAX_IDENTITY_BYTES)); @@ -684,7 +683,7 @@ mod tests { #[test] fn the_smallest_frame_also_carries_the_failure_that_stands_in_for_a_result() { let long = "x".repeat(crate::envelope::MAX_IDENTITY_BYTES); - let plaintext = rmp_serde::to_vec(&crate::service::result_size_failure( + let plaintext = crate::contract::wire_encode(&crate::service::result_size_failure( &long, &long, &long, [0xff; 32], [0xff; 32], )) .unwrap() @@ -906,7 +905,7 @@ mod tests { let reply = client.execute(&invocation).unwrap(); assert_eq!( reply.disposition, - TransportDisposition::Refused(Refusal::Policy) + TransportDisposition::Refused(Refusal::PayloadBound) ); assert_eq!(backend.executions.load(Ordering::SeqCst), 0); } @@ -949,7 +948,7 @@ mod tests { transport: &str, ) -> TransportResult { let mut receipt = receipt(invocation); - receipt.transport = transport.to_string(); + receipt.provider = transport.to_string(); TransportResult::transported( invocation, vec![TransportEvent { @@ -963,7 +962,7 @@ mod tests { } fn encoded_len(result: &TransportResult) -> usize { - rmp_serde::to_vec(result).unwrap().len() + crate::contract::wire_encode(result).unwrap().len() } /// Two calls with one request id, through a live serve loop. @@ -984,9 +983,9 @@ mod tests { &result.disposition, TransportDisposition::Transported { events, receipt } if events.is_empty() - && receipt.transport == "model-connector" + && receipt.provider == "model-connector" && receipt.outcome == TransportOutcome::Failed { - failure_kind: "result_size".to_string(), + kind: FailureKind::ResultSize, message: "provider result exceeded its byte bound".to_string(), } ) @@ -1154,4 +1153,92 @@ mod tests { client.execute(&fresh("request-key")).unwrap(); assert_eq!(backend.executions.load(Ordering::SeqCst), 1); } + + #[test] + fn a_v2_frame_gets_no_answer() { + let store = TestStore::new(); + let backend = CountingBackend::new(true); + let (client, endpoint) = start(backend.clone(), &store); + // The v2 envelope: positional, an integer-array ciphertext, a v2 id. + let v2 = rmp_serde::to_vec(&( + concat!("gamecult.", "codex.transport_envelope.v2"), + CALLER, + "request-v2", + "Invocation", + [0_u8; 12], + vec![1_u8; 64], + )) + .unwrap(); + let mut stream = TcpStream::connect(endpoint).unwrap(); + write_transport_frame(&mut stream, &v2, FRAME).unwrap(); + assert!(matches!( + read_transport_frame(&mut stream, FRAME), + Err(TransportFrameError::Connection(_)) + )); + assert_eq!(backend.executions.load(Ordering::SeqCst), 0); + // The loop survives the v2 peer and serves a v3 one. + client.execute(&fresh("request-after-v2")).unwrap(); + assert_eq!(backend.executions.load(Ordering::SeqCst), 1); + } + + #[test] + fn a_reply_echoes_the_request_id() { + use cultnet_rs::{CultNetMessage, CultNetWireContract, decode_cultnet_message_from_slice}; + let store = TestStore::new(); + let backend = CountingBackend::new(true); + let (_client, endpoint) = start(backend.clone(), &store); + let security = crate::fixtures::security(KEY); + let send = |invocation: &TransportInvocation| { + let request = crate::envelope::encode_invocation_frame( + &crate::envelope::encrypt_invocation(invocation, &security).unwrap(), + ) + .unwrap(); + let mut stream = TcpStream::connect(endpoint).unwrap(); + write_transport_frame(&mut stream, &request, FRAME).unwrap(); + let reply = read_transport_frame(&mut stream, FRAME).unwrap(); + let CultNetMessage::OperationResponse { + message_id, status, .. + } = decode_cultnet_message_from_slice(&reply, CultNetWireContract::CultNetSchemaV0) + .unwrap() + else { + panic!("a reply is an operation response"); + }; + let envelope = crate::envelope::decode_result_frame(&reply).unwrap(); + let result = crate::envelope::decrypt_result(&envelope, &security, invocation).unwrap(); + (message_id, status, result) + }; + + let transported = fresh("request-echo-transported"); + let (message_id, status, result) = send(&transported); + assert_eq!( + ( + message_id.as_str(), + status.as_str(), + result.request_id.as_str() + ), + ("request-echo-transported", "ok", "request-echo-transported") + ); + assert!(matches!( + result.disposition, + TransportDisposition::Transported { .. } + )); + + let mut refused = fresh("request-echo-refused"); + refused.request.model = "unlisted-model".to_string(); + refused.provider_request_sha256 = + crate::contract::provider_request_sha256(&refused.request).unwrap(); + let (message_id, status, result) = send(&refused); + assert_eq!( + ( + message_id.as_str(), + status.as_str(), + result.request_id.as_str() + ), + ("request-echo-refused", "ok", "request-echo-refused") + ); + assert_eq!( + result.disposition, + TransportDisposition::Refused(Refusal::ModelNotAllowed) + ); + } } diff --git a/crates/model-connector/src/service.rs b/crates/model-connector/src/service.rs index e377c19..157ab20 100644 --- a/crates/model-connector/src/service.rs +++ b/crates/model-connector/src/service.rs @@ -7,13 +7,13 @@ use sha2::{Digest, Sha256}; use zeroize::Zeroizing; use crate::contract::{ - ContractError, RECEIPT_SCHEMA_ID, RESULT_SCHEMA_ID, Refusal, TransportDisposition, + ContractError, FailureKind, RECEIPT_SCHEMA_ID, RESULT_SCHEMA_ID, Refusal, TransportDisposition, TransportInvocation, TransportKey, TransportOutcome, TransportReceipt, TransportResult, canonical_invocation_bytes, require_id, }; use crate::envelope::{ EnvelopeKind, MAX_IDENTITY_BYTES, ServiceError, TransportEnvelope, decrypt_invocation, - encrypt_result, validate_envelope, + encode_result_frame, encrypt_result, validate_envelope, }; pub struct CallerAdmission { @@ -90,7 +90,7 @@ enum ReplayState { } #[derive(Clone, Debug, PartialEq, Eq, DatabaseEntry)] -#[cultcache(type = "gamecult.codex.replay_record.v1")] +#[cultcache(type = "gamecult.model.replay_record.v1")] struct ReplayRecord { #[cultcache(key = 0)] caller_runtime_id: String, @@ -159,9 +159,12 @@ impl TransportService { replay_store .register_entry_type::() .map_err(replay_store_error)?; - replay_store.add_generic_backing_store( - OwnedRedbMessagePackBackingStore::new(replay_store_path).map_err(replay_store_error)?, - ); + replay_store + .add_generic_backing_store( + OwnedRedbMessagePackBackingStore::new(replay_store_path) + .map_err(replay_store_error)?, + ) + .map_err(replay_store_error)?; replay_store .pull_all_backing_stores() .map_err(replay_store_error)?; @@ -228,18 +231,16 @@ impl TransportService { }; } if envelope.ciphertext.len() > caller.max_payload_bytes { - return self.reply_refusal(&invocation, Refusal::Policy, &security); + return self.reply_refusal(&invocation, Refusal::PayloadBound, &security); } if let Err(error) = invocation.validate(now_unix_ms, self.max_expiry_skew_ms) { return self.reply_refusal(&invocation, contract_refusal(&error), &security); } - if !caller.allowed_models.contains(&invocation.request.model) - || invocation - .request - .max_output_tokens - .is_some_and(|limit| limit > caller.max_output_tokens) - { - return self.reply_refusal(&invocation, Refusal::Policy, &security); + if !caller.allowed_models.contains(&invocation.request.model) { + return self.reply_refusal(&invocation, Refusal::ModelNotAllowed, &security); + } + if invocation.request.max_output_tokens > caller.max_output_tokens { + return self.reply_refusal(&invocation, Refusal::OutputBound, &security); } let identity = RequestIdentity { @@ -312,8 +313,8 @@ impl TransportService { }))) } - /// Records a result and seals it. `max_frame_bytes` bounds the serialized - /// envelope, which is what a frame carries: a result whose sealed envelope + /// Records a result and seals it. `max_frame_bytes` bounds the frame the + /// sealed envelope travels in (its carriage): a result whose sealed envelope /// does not fit is recorded as the typed `result_size` failure instead, so /// no completed record is ever undeliverable and a retry replays bytes /// that fit. @@ -423,13 +424,11 @@ impl TransportService { } fn fits_frame(envelope: &TransportEnvelope, max_frame_bytes: usize) -> Result { - rmp_serde::to_vec(envelope) - .map(|bytes| bytes.len() <= max_frame_bytes) - .map_err(|_| ServiceError::Encoding) + encode_result_frame(envelope).map(|bytes| bytes.len() <= max_frame_bytes) } -/// The transport named on the stand-in failure, fixed so its size is bounded. -const STAND_IN_TRANSPORT: &str = "model-connector"; +/// The provider named on the stand-in failure, fixed so its size is bounded. +const STAND_IN_PROVIDER: &str = "model-connector"; /// The failed result that stands in for one whose sealed frame cannot be /// carried: the same shape a provider failure takes, with nothing a provider @@ -454,9 +453,9 @@ pub(crate) fn result_size_failure( native_request_sha256, provider_request_sha256, model: model.to_string(), - transport: STAND_IN_TRANSPORT.to_string(), + provider: STAND_IN_PROVIDER.to_string(), outcome: TransportOutcome::Failed { - failure_kind: "result_size".to_string(), + kind: FailureKind::ResultSize, message: "provider result exceeded its byte bound".to_string(), }, }), @@ -760,7 +759,7 @@ mod tests { &invocation.caller_runtime_id, invocation.request_id(), EnvelopeKind::Invocation, - &rmp_serde::to_vec(invocation).unwrap(), + &crate::contract::wire_encode(invocation).unwrap(), &security(KEY), ) .unwrap() @@ -775,25 +774,26 @@ mod tests { } #[test] - fn policy_refuses_unlisted_models_and_output_tokens_over_the_caller_cap() { + fn an_unlisted_model_is_refused_model_not_allowed() { let store = TestStore::new(); let mut service = open_service(&store, 8); - let (admission, invocation) = run(&mut service, "unlisted-model", |request| { request.model = "other-model".to_string(); }); - assert_eq!(refusal_of(admission, &invocation), Refusal::Policy); + assert_eq!(refusal_of(admission, &invocation), Refusal::ModelNotAllowed); + let (admission, _) = run(&mut service, "listed-model", |_| {}); + assert!(matches!(admission, TransportAdmission::Execute(_))); + } + #[test] + fn output_tokens_over_the_caller_cap_are_refused_output_bound_inclusively() { + let store = TestStore::new(); + let mut service = open_service(&store, 8); let (admission, invocation) = run(&mut service, "over-cap", |request| { - request.max_output_tokens = Some(32_769); + request.max_output_tokens = 32_769; }); - assert_eq!(refusal_of(admission, &invocation), Refusal::Policy); - - for (id, tokens) in [ - ("at-cap", Some(32_768)), - ("under-cap", Some(100)), - ("unset", None), - ] { + assert_eq!(refusal_of(admission, &invocation), Refusal::OutputBound); + for (id, tokens) in [("at-cap", 32_768), ("under-cap", 100)] { let (admission, _) = run(&mut service, id, |request| { request.max_output_tokens = tokens; }); @@ -824,7 +824,7 @@ mod tests { }; assert!(matches!(admit(size), Ok(TransportAdmission::Execute(_)))); let refused = admit(size - 1).unwrap(); - assert_eq!(refusal_of(refused, &invocation), Refusal::Policy); + assert_eq!(refusal_of(refused, &invocation), Refusal::PayloadBound); } #[test] @@ -869,7 +869,9 @@ mod tests { edit(&mut record); let mut cache = CultCache::new(); cache.register_entry_type::().unwrap(); - cache.add_generic_backing_store(OwnedRedbMessagePackBackingStore::new(path).unwrap()); + cache + .add_generic_backing_store(OwnedRedbMessagePackBackingStore::new(path).unwrap()) + .unwrap(); cache.pull_all_backing_stores().unwrap(); cache.put(key.to_string(), &record).unwrap(); } @@ -1003,7 +1005,8 @@ mod tests { }; assert_eq!(reply.request_id, id, "{length}"); let result: TransportResult = - rmp_serde::from_slice(&decrypt_message(&reply, &security(KEY)).unwrap()).unwrap(); + crate::contract::wire_decode(&decrypt_message(&reply, &security(KEY)).unwrap()) + .unwrap(); assert_eq!( result.disposition, TransportDisposition::Refused(Refusal::Malformed) @@ -1024,12 +1027,12 @@ mod tests { &security(KEY), ) .unwrap(); - // The sealed size varies with the random nonce and ciphertext bytes - // (a byte of 128 or more takes two), so bracket it: the ciphertext - // plus the id is a floor, twice the ciphertext plus the id and the - // envelope's other fields is a ceiling. + // The frame carries the id twice (the message id and the envelope's + // request id) and the base64 of the envelope, so bracket it: the + // ciphertext plus the id is a floor, twice the ciphertext and both + // ids plus the envelope's other fields is a ceiling. let floor = sealed.ciphertext.len() + id.len(); - let ceiling = 2 * sealed.ciphertext.len() + id.len() + 512; + let ceiling = 2 * (sealed.ciphertext.len() + 2 * id.len()) + 512; let store = TestStore::new(); let mut service = open_service(&store, 1); assert!(matches!( @@ -1049,7 +1052,7 @@ mod tests { fn a_frame_check_is_inclusive_of_the_serialized_envelope() { let envelope = encrypt_invocation(&invocation_for(CALLER, "sized"), &security(KEY)).unwrap(); - let size = rmp_serde::to_vec(&envelope).unwrap().len(); + let size = encode_result_frame(&envelope).unwrap().len(); assert_eq!(fits_frame(&envelope, size), Ok(true)); assert_eq!(fits_frame(&envelope, size - 1), Ok(false)); } @@ -1074,7 +1077,7 @@ mod tests { transport: &str, ) -> TransportResult { let mut receipt = receipt(invocation); - receipt.transport = transport.to_string(); + receipt.provider = transport.to_string(); TransportResult::transported( invocation, vec![TransportEvent { @@ -1094,7 +1097,7 @@ mod tests { let (claim, invocation) = claim_for(&mut service, "stand-in"); let big = text_result(&invocation, &"y".repeat(4000), &"t".repeat(5000)); let response = service.complete(*claim, big, 2000).unwrap(); - assert!(rmp_serde::to_vec(&response).unwrap().len() <= 2000); + assert!(encode_result_frame(&response).unwrap().len() <= 2000); let stand_in = decrypt_result(&response, &security(KEY), &invocation).unwrap(); assert_eq!( stand_in, @@ -1109,7 +1112,7 @@ mod tests { let TransportDisposition::Transported { receipt, .. } = &stand_in.disposition else { panic!("expected a transported failure"); }; - assert_eq!(receipt.transport, "model-connector"); + assert_eq!(receipt.provider, "model-connector"); // The recorded replay is the stand-in's exact bytes. let TransportAdmission::Reply(replay) = service @@ -1168,7 +1171,7 @@ mod tests { let (claim, invocation) = claim_for(&mut service, "restart"); let big = text_result(&invocation, &"y".repeat(4000), "fixture-sse"); let recorded = service.complete(*claim, big, FRAME).unwrap(); - assert!(rmp_serde::to_vec(&recorded).unwrap().len() > 2000); + assert!(encode_result_frame(&recorded).unwrap().len() > 2000); drop(service); let mut service = open_service(&store, 8); @@ -1188,7 +1191,7 @@ mod tests { else { panic!("expected a replay"); }; - assert!(rmp_serde::to_vec(&smaller).unwrap().len() <= 2000); + assert!(encode_result_frame(&smaller).unwrap().len() <= 2000); assert_eq!( decrypt_result(&smaller, &security(KEY), &invocation).unwrap(), result_size_failure( @@ -1247,4 +1250,25 @@ mod tests { TransportDisposition::Refused(Refusal::Indeterminate) ); } + + #[test] + fn contract_refusal_maps_each_error() { + assert_eq!(contract_refusal(&ContractError::Expiry), Refusal::Expired); + assert_eq!( + contract_refusal(&ContractError::Identity), + Refusal::IdentitySubstitution + ); + assert_eq!( + contract_refusal(&ContractError::ProviderDigest), + Refusal::ProviderDigestSubstitution + ); + for other in [ + ContractError::Schema, + ContractError::Invalid("model"), + ContractError::NativeDigest, + ContractError::EventSequence, + ] { + assert_eq!(contract_refusal(&other), Refusal::Malformed, "{other:?}"); + } + } } diff --git a/crates/model-connector/tests/fixtures/wire_v2/.gitattributes b/crates/model-connector/tests/fixtures/wire_v2/.gitattributes deleted file mode 100644 index bf62110..0000000 --- a/crates/model-connector/tests/fixtures/wire_v2/.gitattributes +++ /dev/null @@ -1,2 +0,0 @@ -*.mp binary -*.bin binary diff --git a/crates/model-connector/tests/fixtures/wire_v2/README.md b/crates/model-connector/tests/fixtures/wire_v2/README.md deleted file mode 100644 index 476b362..0000000 --- a/crates/model-connector/tests/fixtures/wire_v2/README.md +++ /dev/null @@ -1,21 +0,0 @@ -# Golden v2 wire fixtures - -Bytes of the v2 connector wire, produced once by CodexConnector at commit -`fd7cec4176f4e3f9a5aaa75e77ba10ce5e1522f8`. Ghostlight pins CodexConnector -`68fe94b`, not that commit; `src/lib.rs`, the whole v2 wire, is identical in -both, so these bytes are Ghostlight's wire. They are pinned by `tests/wire_golden_v2.rs`, so the kit cannot drift from -them. No CodexConnector code or dependency is part of this repository. - -Generator: `generator.rs` (not compiled here). Command, run in a clone of -CodexConnector at that commit: - - cp /crates/model-connector/tests/fixtures/wire_v2/generator.rs examples/gen_wire_v2.rs - cargo run --locked -q --example gen_wire_v2 - -It prints `FIXTURE ` lines; each file is the hex decoded. It was -run on Yggdrasil through the verify container with codex-connector at that -commit as a dev-dependency of the kit at d1d28f0 (same source). The `.mp` -files are rmp-serde output; `envelope_*` files carry random nonces and -AES-256-GCM ciphertext sealed under the connection key -`parity-distinct-test-key`. Regenerating changes the envelopes' bytes; the -plaintext fixtures must regenerate byte-identically. diff --git a/crates/model-connector/tests/fixtures/wire_v2/canonical_invocation.bin b/crates/model-connector/tests/fixtures/wire_v2/canonical_invocation.bin deleted file mode 100644 index 727edf6cf9555f9c02825cc276855e2a1ffeb884..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 413 zcmah_u}T9$6bv@0Z7i&gE7HiFoW#aX1wSCfI&AhH$?E0y+uq!1gY8Xhx`nYo%}N~{9B>Y>f03pE=R^K~E<7K9&4&QM6- z4tItDQv&N2XPyUYlAg{}M*1Hl5}MK*&?h}-={-fGqYdgvy57tXQA`86*rs#(P?aL} zw&<0<=pjE(t#i841Mt2KA z4s^2CP!4L|aDs1QihXX}II>8El<4Id;K zSMYR|mBcT^`T80O8mUM;wcRkl575y3C6FYvA6FrmXbak>9;KbM-M~3}BduM$s;E4D z_mFxNT;&y((^>nr1NsQ@vd06mbobA>$?{E{`Qo_n-H+wO6-cBW8zSgrze#*e8BI#C bOXhCxpoq=7-8aTV|7^A=y_J$G2T%V2OD}Km diff --git a/crates/model-connector/tests/fixtures/wire_v2/envelope_invocation.mp b/crates/model-connector/tests/fixtures/wire_v2/envelope_invocation.mp deleted file mode 100644 index 3f95738..0000000 --- a/crates/model-connector/tests/fixtures/wire_v2/envelope_invocation.mp +++ /dev/null @@ -1,6 +0,0 @@ -–Ù$gamecult.codex.transport_envelope.v2­parity-caller©request-1ªInvocationœÌ£VÌÁḩ̃̌xÌ©Y̽Ü­hÌ·ÌýÌâÌ—Ì…3ÌÕ ÌÇÌþÌ‘ÌÜḩ̨Ìé[̪ÌÙ!AÌë̉ÌðÌúÌÛ;ÌëÌú| ̪ÌÉ`!ÌúÌú̧̚Ì� -Ì�kÌÞ'vCZÌà̖̌̈ 8J̧ÌÀḍ̗NÌ¿FÌ¿zm6Ìÿ -Ì÷ÌíÌð\\NMqÌ–Ì×Ì— Ìù1ÌæÌ¦Ì¯Ìè 4*{ÌË̥̱ -ÌÖ̦eÌÇ̨Ì� -̰̆ḭG*SÌ�̳̼ÌÒ̥̮ÌÜ!NÌÀÌÑ̠̫̙̂7Ì�ÌÇ̰Ìì̃ÌÀ̬c̦ÌË ÌÒ̲1kÌá~hÌß3$ÌÚÌ KÌß̰ÌùÌÜ4{ÌÓÌß̼̩U.)ÌÌ̭̤rÌ ÌýÌìÌÆ^bbÏy^̳ÌßÌÌÌ�ÌÉ:Ì Ìš-ÌåYXoÌÓÌÒ[Qy̘̫ -XÌÓI:3̽ÌÔ̰D&V'-ÌòufÌŒX1ÌøÌÒ.Ì�|4cÌãÌ‹EÌåAÌßÌæÌŸÌÍQÌÝLÌ‚-Ìáw̰uÌ´3̹>Ìï$QzÌÀk m'+ÌÐÌ¢G Ì´sÌ®00 f)Ì·C̯̙*̆Ì’ÌÛy~ÌùHzÌøÌåÌý"ÌÇÌ¢*%ÌôoÌÉ̯ÌÙxÌŽ}Ì‹ÌÏÌÝYÌÈCÌîÌ´Ì¿uÌü̸x/gÌëÌñ̘X-ÌÙÌÂ̮̆NÌìRI):HÌñÌÉÌ’ÌÀ̗̉fÌÛÌ '\s̨ Ì�s̉ÌÄGVaÌÓ̶̲C\\`=p ÌžwÌŽHÌÖ4ÌÆÌØ>lfLÌ£ÌþÌäÌÖvÌ¿ÌÞJÌë \ No newline at end of file diff --git a/crates/model-connector/tests/fixtures/wire_v2/envelope_result_failed.mp b/crates/model-connector/tests/fixtures/wire_v2/envelope_result_failed.mp deleted file mode 100644 index 4b94559..0000000 --- a/crates/model-connector/tests/fixtures/wire_v2/envelope_result_failed.mp +++ /dev/null @@ -1,7 +0,0 @@ -–Ù$gamecult.codex.transport_envelope.v2­parity-caller©request-1¦ResultœÌ…Ì¢ÌÇ"̇Ì�GÌ©ÜCÌôÌË Ì�ÌéHÌßÌ—ÌÿÌÐÌ´ÌÝÌá\Ìö]2/ÌÎe"̘ -Ì¿ÌáÌìÌü{GÌÚṴ̧^b3Ìûj̢̗̟ÌÃÌ’sÌ•*Ì» ÌøÌä= ̸̄<ÌÁ{ÌýÌã.ÌØ8~̫̓CÌâ -̱̘ÌË -[Ì‚ÌÉoÌ–(47ÌÌBML̲Ìó̢̳Ì�hp,ÌîÌ®wÌê6DHÌðbÌÏÌÌb̹@a̧ÌÑ̵̎Ì´Ì�kÌäÌ’ÌáÌóÌ€ÌÑN+ÌüMÌÀjG %Ìß/Ìô -ÌÁÌò̸%Ì»Ì�@sT`ÌâÌ’}P̈ÌÒ ÌëÌÊÌÞ Ì«Ìâ:ÌÚ̟̖̔Ìû^Ìô>ÌûX.x -r]F8Ì�ÌÛÌ‹Ì÷Ì�5ÌÚGÌÆÌºÌÃÌ®C@(ÌóÌÓ̾{̾ÌÙMÌÓ~)Ìà 7ÌÐÌòI+'v ÌøUnP̈$U(NÌêÌçÌüsÌ¢T^[ÌÊYÌæÌ�zÌâ.wÌÆeÌ™C̦̿̄"U^trÌöÌ�ÌÙ̦Ìï̯̬ÌçÌÜÌž+ÌÑ/b̯̭̼Ìî*$Ì× ÌŠÌ²̘ÌÕ\K5̧f -Ìò̃b}̦̬o.Ẁ̧&Ìå̶̑[ \ No newline at end of file diff --git a/crates/model-connector/tests/fixtures/wire_v2/envelope_result_refused_replay_conflict.mp b/crates/model-connector/tests/fixtures/wire_v2/envelope_result_refused_replay_conflict.mp deleted file mode 100644 index 10e3726559fa374cd41b9b35c34f97bac50dd09f..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 252 zcmVow+yG@3%-hVD z%)-o`e9W^F%(2YrHq4O`%&g4Y%-PK1%>53`kY&ujNzCoc-OSp|w9K_c%%Ty@ifzpJ zJj}?<;6FXg+01~<_{@#Wrp)EciF;?v?9B4a_H4|q%)!jT%Ql; zi5fzshECDCNW}`R(A1wvVW6QFW|7*^Bqk9?Zye6md7krpxMw0vhEmB`Zg$2Pt!Jdl z7^mG}aoDVOXQ5=Nkjz$_6jKq~Wi!}|omEPs!EBc7?RKfGTyi*-syn4ED39bBK5pXM&8R@Qxs6ZBQ#IeUjNv`M)xrji^lP%uo>cK@ zpG#LH@t3dkFp#9qSJTFNW*NN&eq43gXiHVn%D9*x+}8vNls7if$wYD>wQ{Btz)US` z$LVD*I5aOxXCia)mWb&tvm&pzzeF0o!EU`K=h0mjI1=2l>fRR&Ep?86Mn4F7akFlQ~vTr?D8%#2&3uBfQo!!-ARxZ31hwSed*K zQOXzQbPNfxk(|OqxYNUU!^$>>h504HWSxP0R`Dpb^z%o-6{c7Y=Q&S!C$@_b>O`zM z8Vg0CgS=uY!GcdOy6oX{%_%nYagKC0&har!oHxY`pWq>%=se6yG4;n}ypE$aN`xM- m;cgo3J5|v_`|+*}=K1Yk!F9bL`tp+sO65GD`6!Dt9Qg;?kz>{X diff --git a/crates/model-connector/tests/fixtures/wire_v2/generator.rs b/crates/model-connector/tests/fixtures/wire_v2/generator.rs deleted file mode 100644 index baf36cc..0000000 --- a/crates/model-connector/tests/fixtures/wire_v2/generator.rs +++ /dev/null @@ -1,171 +0,0 @@ -//! Generator of the golden v2 wire fixtures beside it. It is not compiled by this -//! workspace: it was run once, as an example of CodexConnector at commit -//! fd7cec4176f4e3f9a5aaa75e77ba10ce5e1522f8 (see README.md here for the command). -//! It prints one `FIXTURE ` line per file. - -use codex_connector as v2; -use serde::Serialize; - -const KEY: &str = "parity-distinct-test-key"; -const CALLER: &str = "parity-caller"; - -fn bytes(value: &T) -> Vec { - rmp_serde::to_vec(value).unwrap() -} - -fn emit(name: &str, data: &[u8]) { - let hex: String = data.iter().map(|byte| format!("{byte:02x}")).collect(); - println!("FIXTURE {name} {hex}"); -} - -fn request(choice: v2::CodexToolChoice) -> v2::CodexProviderRequest { - let mut request = - v2::CodexProviderRequest::new("request-1", "conversation-1", "gpt-5.4", "Be exact."); - request.input = vec![ - v2::CodexInputItem::UserText { - text: "state".to_string(), - }, - v2::CodexInputItem::AssistantText { - text: "reply".to_string(), - }, - v2::CodexInputItem::ToolCall { - call_id: "call-1".to_string(), - name: "read_source".to_string(), - arguments: "{}".to_string(), - }, - v2::CodexInputItem::ToolResult { - call_id: "call-1".to_string(), - output: "ok".to_string(), - }, - ]; - request.tools = vec![v2::CodexToolDefinition { - name: "read_source".to_string(), - description: "Read one admitted source.".to_string(), - parameters_json: r#"{"type":"object"}"#.to_string(), - }]; - request.tool_choice = choice; - request.reasoning_effort = Some("high".to_string()); - request.parallel_tool_calls = true; - request.max_output_tokens = Some(512); - request.prompt_cache_key = Some("cache-1".to_string()); - request -} - -fn invocation(choice: v2::CodexToolChoice) -> v2::CodexTransportInvocation { - v2::CodexTransportInvocation::new(CALLER, 2_000, [7; 32], request(choice)).unwrap() -} - -fn receipt( - invocation: &v2::CodexTransportInvocation, - outcome: v2::CodexTransportOutcome, -) -> v2::CodexTransportReceipt { - v2::CodexTransportReceipt { - schema_id: v2::RECEIPT_SCHEMA_ID.to_string(), - request_id: invocation.request_id().to_string(), - caller_runtime_id: invocation.caller_runtime_id.clone(), - native_request_sha256: invocation.native_request_sha256, - provider_request_sha256: invocation.provider_request_sha256, - model: invocation.request.model.clone(), - transport: "parity-sse".to_string(), - outcome, - } -} - -fn main() { - let key = v2::CodexTransportKey::from_connection_secret(KEY).unwrap(); - let auto = invocation(v2::CodexToolChoice::Auto); - let required = invocation(v2::CodexToolChoice::Required); - emit("invocation_auto.mp", &bytes(&auto)); - emit("invocation_required.mp", &bytes(&required)); - emit( - "canonical_invocation.bin", - &v2::canonical_invocation_bytes(&required).unwrap(), - ); - emit( - "canonical_provider_request.bin", - &v2::canonical_provider_request_bytes(&required.request).unwrap(), - ); - emit( - "provider_request_sha256.bin", - &v2::provider_request_sha256(&required.request).unwrap(), - ); - emit( - "envelope_invocation.mp", - &bytes(&v2::encrypt_invocation(&required, &key).unwrap()), - ); - - let completed = v2::CodexTransportResult::transported( - &required, - vec![ - v2::CodexTransportEvent { - sequence: 0, - payload: v2::CodexTransportEventPayload::TextDelta { - text: "hello".to_string(), - }, - }, - v2::CodexTransportEvent { - sequence: 1, - payload: v2::CodexTransportEventPayload::ToolCall { - call_id: "call-2".to_string(), - name: "read_source".to_string(), - arguments: "{}".to_string(), - }, - }, - ], - receipt( - &required, - v2::CodexTransportOutcome::Completed { - provider_response_id: Some("response-1".to_string()), - input_tokens: Some(10), - output_tokens: Some(3), - reasoning_output_tokens: Some(1), - cached_input_tokens: Some(4), - }, - ), - ); - emit("result_transported.mp", &bytes(&completed)); - emit( - "envelope_result_transported.mp", - &bytes(&v2::encrypt_result(&completed, &key).unwrap()), - ); - - let failed = v2::CodexTransportResult::transported( - &required, - Vec::new(), - receipt( - &required, - v2::CodexTransportOutcome::Failed { - failure_kind: "result_size".to_string(), - message: "provider result exceeded its byte bound".to_string(), - }, - ), - ); - emit("result_failed.mp", &bytes(&failed)); - emit( - "envelope_result_failed.mp", - &bytes(&v2::encrypt_result(&failed, &key).unwrap()), - ); - - use v2::CodexRefusal as R; - let refusals = [ - ("expired", R::Expired), - ("identity_substitution", R::IdentitySubstitution), - ("provider_digest_substitution", R::ProviderDigestSubstitution), - ("policy", R::Policy), - ("capacity", R::Capacity), - ("in_flight", R::InFlight), - ("indeterminate", R::Indeterminate), - ("replay_conflict", R::ReplayConflict), - ("malformed", R::Malformed), - ]; - for (name, refusal) in refusals { - let refused = v2::CodexTransportResult::refused(&required, refusal); - emit(&format!("result_refused_{name}.mp"), &bytes(&refused)); - if name == "replay_conflict" { - emit( - "envelope_result_refused_replay_conflict.mp", - &bytes(&v2::encrypt_result(&refused, &key).unwrap()), - ); - } - } -} diff --git a/crates/model-connector/tests/fixtures/wire_v2/invocation_auto.mp b/crates/model-connector/tests/fixtures/wire_v2/invocation_auto.mp deleted file mode 100644 index e505ca2bba33dc48ec8cfe6a607ba0f19a5c34eb..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 408 zcmah_Jxjw-6b&w2Ttsj+WY9qph$w=a3XTq?4o>y?a%rP!UVQH+w3H6{1431t9HdnH zAr&0OrRv~v3msevot<3t7ns@(;^LVOmvb)XoQuIijun819$H*DP_$4oUj;&9*7YO7 z8M?x^qUBD&q>Ck!GtUFrOZA7Ep85xgga-SZzLuy>C)D4jXCiu{*F}1#H-j$M_ULm? zqpMk)hV(H_kMlc2ZDwHni4;f?xemyzgyRqtCUII5YT9>P_z_6;6V$XDxd6>oYo)ab zIusd4>pX7mDv%i{V)a@fLx?*YO1TO3aeS2!c%`_R5yIO_`kmd3TAKE)1mx`?7(h0E~4*92^2i Tqf%!fikEJ0N^5Y&^I+QF(pIMz diff --git a/crates/model-connector/tests/fixtures/wire_v2/invocation_required.mp b/crates/model-connector/tests/fixtures/wire_v2/invocation_required.mp deleted file mode 100644 index 727edf6cf9555f9c02825cc276855e2a1ffeb884..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 413 zcmah_u}T9$6bv@0Z7i&gE7HiFoW#aX1wSCfI&AhH$?E0y+uq!1gY8Xhx`nYo%}N~{9B>Y>f03pE=R^K~E<7K9&4&QM6- z4tItDQv&N2XPyUYlAg{}M*1Hl5}MK*&?h}-={-fGqYdgvy57tXQA`86*rs#(P?aL} zw&<0<=pjE(t#i841Mt2KA z4s^2CwXig`xJ1`*Z9!sD zW=W-Pa$-(SYEk3r5SZfBlt~k2+*HP6R&r`)0Y1a-FetDS06-;YE}XfWdgkYueI{of zFq~QECU#~@{+U@)YG=-zS#f6NnRfj%&1W7JElA5BBSyHT!R9TX$ Kkd$AVmjVEMQG6Ky diff --git a/crates/model-connector/tests/fixtures/wire_v2/result_refused_capacity.mp b/crates/model-connector/tests/fixtures/wire_v2/result_refused_capacity.mp deleted file mode 100644 index 4425e6a..0000000 --- a/crates/model-connector/tests/fixtures/wire_v2/result_refused_capacity.mp +++ /dev/null @@ -1 +0,0 @@ -”Ù"gamecult.codex.transport_result.v2©request-1­parity-caller�§Refused¨Capacity \ No newline at end of file diff --git a/crates/model-connector/tests/fixtures/wire_v2/result_refused_expired.mp b/crates/model-connector/tests/fixtures/wire_v2/result_refused_expired.mp deleted file mode 100644 index 47fff91..0000000 --- a/crates/model-connector/tests/fixtures/wire_v2/result_refused_expired.mp +++ /dev/null @@ -1 +0,0 @@ -”Ù"gamecult.codex.transport_result.v2©request-1­parity-caller�§Refused§Expired \ No newline at end of file diff --git a/crates/model-connector/tests/fixtures/wire_v2/result_refused_identity_substitution.mp b/crates/model-connector/tests/fixtures/wire_v2/result_refused_identity_substitution.mp deleted file mode 100644 index 8da919c..0000000 --- a/crates/model-connector/tests/fixtures/wire_v2/result_refused_identity_substitution.mp +++ /dev/null @@ -1 +0,0 @@ -”Ù"gamecult.codex.transport_result.v2©request-1­parity-caller�§Refused´IdentitySubstitution \ No newline at end of file diff --git a/crates/model-connector/tests/fixtures/wire_v2/result_refused_in_flight.mp b/crates/model-connector/tests/fixtures/wire_v2/result_refused_in_flight.mp deleted file mode 100644 index 0c9f9d3..0000000 --- a/crates/model-connector/tests/fixtures/wire_v2/result_refused_in_flight.mp +++ /dev/null @@ -1 +0,0 @@ -”Ù"gamecult.codex.transport_result.v2©request-1­parity-caller�§Refused¨InFlight \ No newline at end of file diff --git a/crates/model-connector/tests/fixtures/wire_v2/result_refused_indeterminate.mp b/crates/model-connector/tests/fixtures/wire_v2/result_refused_indeterminate.mp deleted file mode 100644 index 068bed3..0000000 --- a/crates/model-connector/tests/fixtures/wire_v2/result_refused_indeterminate.mp +++ /dev/null @@ -1 +0,0 @@ -”Ù"gamecult.codex.transport_result.v2©request-1­parity-caller�§Refused­Indeterminate \ No newline at end of file diff --git a/crates/model-connector/tests/fixtures/wire_v2/result_refused_malformed.mp b/crates/model-connector/tests/fixtures/wire_v2/result_refused_malformed.mp deleted file mode 100644 index f106b9f..0000000 --- a/crates/model-connector/tests/fixtures/wire_v2/result_refused_malformed.mp +++ /dev/null @@ -1 +0,0 @@ -”Ù"gamecult.codex.transport_result.v2©request-1­parity-caller�§Refused©Malformed \ No newline at end of file diff --git a/crates/model-connector/tests/fixtures/wire_v2/result_refused_policy.mp b/crates/model-connector/tests/fixtures/wire_v2/result_refused_policy.mp deleted file mode 100644 index 28712db..0000000 --- a/crates/model-connector/tests/fixtures/wire_v2/result_refused_policy.mp +++ /dev/null @@ -1 +0,0 @@ -”Ù"gamecult.codex.transport_result.v2©request-1­parity-caller�§Refused¦Policy \ No newline at end of file diff --git a/crates/model-connector/tests/fixtures/wire_v2/result_refused_provider_digest_substitution.mp b/crates/model-connector/tests/fixtures/wire_v2/result_refused_provider_digest_substitution.mp deleted file mode 100644 index 76b2169..0000000 --- a/crates/model-connector/tests/fixtures/wire_v2/result_refused_provider_digest_substitution.mp +++ /dev/null @@ -1 +0,0 @@ -”Ù"gamecult.codex.transport_result.v2©request-1­parity-caller�§RefusedºProviderDigestSubstitution \ No newline at end of file diff --git a/crates/model-connector/tests/fixtures/wire_v2/result_refused_replay_conflict.mp b/crates/model-connector/tests/fixtures/wire_v2/result_refused_replay_conflict.mp deleted file mode 100644 index 9f621de..0000000 --- a/crates/model-connector/tests/fixtures/wire_v2/result_refused_replay_conflict.mp +++ /dev/null @@ -1 +0,0 @@ -”Ù"gamecult.codex.transport_result.v2©request-1­parity-caller�§Refused®ReplayConflict \ No newline at end of file diff --git a/crates/model-connector/tests/fixtures/wire_v2/result_transported.mp b/crates/model-connector/tests/fixtures/wire_v2/result_transported.mp deleted file mode 100644 index f4fedf862d0f0cd231090f0848610542f5a65cfa..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 327 zcmbPoQz<<$H#NC5r$jF~KP9z7ucRn3uecz;s3g89wHPE^X0);>wXig`xJ1`*Z9!sD zW=W-Pa$-(SYEk3r5SZfBlu45&F*L3WNv$YxNzExqoVYY2H76&35@X|vko^1{XP}13 z%Rt(6jaC<>CZ@y}=a&{Gr!K0lopDncj}wwpGYjy!;SPfWI{^Sxa^}LByQyb>p4n$| z<^jW*b#7v3rsSWQC8c)e%$XHuR-S3sKhu2XQBiKvnPu8%PM!I4W=+WQ^nwyyQ$3Sa kP#+f;r#7y1&d)8#Ni9iDnYs!XI0gB6#i_c6T+ECt0JtTD5dZ)H diff --git a/crates/model-connector/tests/fixtures/wire_v3/README.md b/crates/model-connector/tests/fixtures/wire_v3/README.md new file mode 100644 index 0000000..918df33 --- /dev/null +++ b/crates/model-connector/tests/fixtures/wire_v3/README.md @@ -0,0 +1,15 @@ +# wire_v3 fixtures + +The named-MessagePack bytes of the v3 documents and one sealed carriage frame, +produced by the kit at the revision that added them. They pin drift; the types in +`src/contract.rs` own the shape, and `schemas/` publishes it. + +- `provider_request.mp`, `transport_invocation.mp`, `transport_result_transported.mp`, + `transport_result_refused.mp`, `transport_receipt_failed.mp`, `transport_envelope.mp`: + the plaintext documents. +- `carriage_invocation.frame`: a `cultnet.operation_request.v0` for service + `gamecult.model.connector`, operation `invoke`, whose payload is the base64 of an envelope + sealed under the fixed test key in `tests/wire_golden_v3.rs`. + +Generator: `tests/wire_golden_v3.rs` itself. Run it with `WIRE_V3_REGENERATE=1` to rewrite +the files from the kit, then review the diff: a changed byte is a wire change. diff --git a/crates/model-connector/tests/fixtures/wire_v3/carriage_invocation.frame b/crates/model-connector/tests/fixtures/wire_v3/carriage_invocation.frame new file mode 100644 index 0000000..6051b64 --- /dev/null +++ b/crates/model-connector/tests/fixtures/wire_v3/carriage_invocation.frame @@ -0,0 +1 @@ +‡©messageId®golden-request©operation¦invoke§payloadÚ$hqlzY2hlbWFfaWTZJGdhbWVjdWx0Lm1vZGVsLnRyYW5zcG9ydF9lbnZlbG9wZS52M7FjYWxsZXJfcnVudGltZV9pZK1nb2xkZW4tY2FsbGVyqnJlcXVlc3RfaWSuZ29sZGVuLXJlcXVlc3SsbWVzc2FnZV9raW5kqkludm9jYXRpb26lbm9uY2WcS2XMwjgRzMrM9QTM037MsczmqmNpcGhlcnRleHTFAy8eYmQGV5JCOLETwqQE+wIz/6yyPltHTPywbakMD/mgkJUxAvKWLNljXa4RAVVITAb3GxklnGF8zoOX/9qGSM9r0JZ126s+0LqvsRNhllG1S0pfG49fQJy99pevHReOSvX/cNWs2cHppYNdJUbVVAkDuioEXwtEs/vRCsmD/ab0bKUX0XRzrNbmjMU4+dHUCus8yt+iNTK9GdjXbEQAweeKNehirXEgnngLnwylo/MDoI4G237du3PmwhMzyIHztoIbwD0Bv9x7WqY+jj90vqEBsOHl+FmYc+F8+OiP6NKTDNRHypUtdo32G/IgfMfbsP/XHiNpPHtLxDqZVIcYdYqLAvhDfLBReLzQVLuNJnE0a2P8xLVAkfbmQZNsxRqS14RjYVtRoDcacxH1h0afo9OWMSK8Sp8eEBAhAJ7ovhCMklc7xWB3o4ny4cmDSExx5zWRLdI14rC1CZ7nq8k3V8fIIHctSp6am35A5yyiTwDH0m3h7/hvXrzhOqtAwHd0jkfNv+qOXZCEPdtbzOXOz0fMVZwvTAJWhoEYmhfKcGvXu8SmEtZcZe0WjF4xpFIFCFsSxh8HcrDFvxKDa58bnimo6DE2VeEMolpaBvnIGEst6qNZ9VQQnQIpCGbbtFd7jMrncnfcq+36NI10qW30r8sqAqgORp8cAGP5fy2X9SQDmH13SXgSfIdr7uxAHhK0X1r0iopJn4XtVfBiTd+IfoLFN+KO/JTZvLc48e6Jl9QRnxkL/UCgT/5wENd9VRxP+oO1CXG3DthI882rVbZDkhhwxupM9J/EURONajVyVSkeju+r9vzHCgR5odRW8cwqY1RXoq6L6mhj4ph01MBkUugz8MFwBsem+8QpnGatyVbZB99knWjK/01o5J6SZihcXNowRdon6BTydFByJaV5bRvgIK7IGRct3BmC8xidyxN9p7QAZYMPscxhQ0hAM615w76Cy82kx6eAzREBQX3COKApXSlPA/xlOKa/iUUMWHvFKyf/n0KrOlVvHjoDzPflHi3hpxxWcQGt8CxxNdSQMufeqce/5/BCeEwRIJ99+k7CEqIqpFIs7cAsI1GpF+NO5w==¯payloadEncoding²messagepack-base64­payloadSchemaÙ$gamecult.model.transport_envelope.v3­schemaVersion¼cultnet.operation_request.v0©serviceId¸gamecult.model.connector \ No newline at end of file diff --git a/crates/model-connector/tests/fixtures/wire_v3/provider_request.mp b/crates/model-connector/tests/fixtures/wire_v3/provider_request.mp new file mode 100644 index 0000000000000000000000000000000000000000..9ff75e4ce83891c17b6d223ad6da896c4ecc0ae5 GIT binary patch literal 549 zcmZuuy-wsX5WYu=(q|=fM?=dCpcPkJK6^+Ejwg7=D-fbss3{O{fOcVF5rLzDnwkgT zZEn1zKnIO&&CmDevl}uEv|2g)Ue>w=lRS@FwBScw#EG*IRfPQn#G}d2+u22N{ueeVB+q}ZW!o7f;MlkRoAcVn%TEWf^MxHgUZhE67Ny&-@ zQmo9Wk=C3Amd;PVurDo;iLT3|FJn5Y0w#1{DLz9=!VK%9~#Qe%MgMhkpmxiqIlFE>9WHAkCOE(`pfA{qdGnXI$^iWmh literal 0 HcmV?d00001 diff --git a/crates/model-connector/tests/fixtures/wire_v3/transport_invocation.mp b/crates/model-connector/tests/fixtures/wire_v3/transport_invocation.mp new file mode 100644 index 0000000000000000000000000000000000000000..95447869386911a8ebd04061ff2f8126e337e8a1 GIT binary patch literal 799 zcmah{ziSjh6h0emv`@KQVRr}qh(%J`X>%OWN*KnOm)(iGGwaNo<1|D#gMxxZuuK)b zOD;Lhc?qI+f{n$9wPIuGpCE$x=I#RF6x?EWnK$qI-uHd);jR-I6r3sfJ)%wI-AnFblUmIAkv4=jShdt0brX^SmjoX(<_LXIrlncf)@8dM5k|kHh!y zGi+QBzmxE>F=rOTW_X|8I~g8a4UZPXR`_vxVP^{PWxV>XNmC_8L&9aDa4cp#VtrV0OJgG$%T|bUv|5d> zR2Qn|W|UoB8$<#oGNT0aV-~b@@-Vj1dgg3WrGNL~U?PJ}SN-2`qk^~D^dg#DpxvjW F@&{N|N`(Lb literal 0 HcmV?d00001 diff --git a/crates/model-connector/tests/fixtures/wire_v3/transport_receipt_failed.mp b/crates/model-connector/tests/fixtures/wire_v3/transport_receipt_failed.mp new file mode 100644 index 0000000000000000000000000000000000000000..4e0f1b1e9fd852297b60da82763d620e0cd9e8d0 GIT binary patch literal 358 zcmaivu`dKc6vkCF^vX%py9G|+dIgP2yP3?q>`iWF-kN#4mnbA85_D>n1cz`!A_|RC z@%{m#QTh`^B9_~0G+z0Azwej4-SenA;ACd>^Gq%ssM1pEP=ie=NFNIflm$>=3QDWF ziva5-M5_5gj#h(Dn&d`FYawKz^kf{=z*Pebi#spLR8YHyg4mI>t%cVyG4apc7Xd4y zf7E}c%Wi`Q{4&L#yw7j^!3S&nCHQG|8ryuxkNM^}@2v1%n~(T?a`EK%uWMyxsUg9l zjLCe`Ive?KrKe87!l=YSj53GV$C4B}*g9R3#)95H?i#NzgCxkTafU$m8}?zO5i)i` N3qlbs_Jqcs{|1!MkQM*{ literal 0 HcmV?d00001 diff --git a/crates/model-connector/tests/fixtures/wire_v3/transport_result_refused.mp b/crates/model-connector/tests/fixtures/wire_v3/transport_result_refused.mp new file mode 100644 index 0000000..0c4f7b1 --- /dev/null +++ b/crates/model-connector/tests/fixtures/wire_v3/transport_result_refused.mp @@ -0,0 +1 @@ +„©schema_idÙ"gamecult.model.transport_result.v3ªrequest_id®golden-request±caller_runtime_id­golden-caller«disposition�§Refused«OutputBound \ No newline at end of file diff --git a/crates/model-connector/tests/fixtures/wire_v3/transport_result_transported.mp b/crates/model-connector/tests/fixtures/wire_v3/transport_result_transported.mp new file mode 100644 index 0000000000000000000000000000000000000000..1922073e7fd0adc6f1bbfa370f1fbcb1cef3e569 GIT binary patch literal 715 zcmbu7F>e$x5QRen6rraK(9moo0-+#fsGy-Z(XOoZXwQo6F`n@eKps-St^paeb7 zURZMIx%5C;?p=KD;Z^|Rgzwj=CD@`>UROeE@TCuym4O88wZP=ZSgPbjiWS=axHGi| zxwZ>EuuM=)d05cP7p#`-YV zOjp>=-(q3MriYC^2w4&aU%^QDI+$Gb*}eNi4;3iKkA92d++U(Ng#Rmxw^p#~L5g(} zCUwQa;^ogXb0_|>-Z_swC3DPw9=&Gn#{Kx|RQw(v$1m|)d~!4Xn2R57oW`ZN72nqn zj>nDbadRnd$FH+j28S(vS!od@6zBpKmx{$^>VF_CcRe~)6$Xez4K&2S6&mP|meDv3 z408R=l+P35&{FREz))RW7!#vQvLLh#r^b|lU=Xs^#tUU#;F56#EUl)=Nh6q1*-GaR aCem%M7+US*UaEg|>ddQ&bsB{hnEeR=ZYo#+ literal 0 HcmV?d00001 diff --git a/crates/model-connector/tests/published_schemas.rs b/crates/model-connector/tests/published_schemas.rs new file mode 100644 index 0000000..4634280 --- /dev/null +++ b/crates/model-connector/tests/published_schemas.rs @@ -0,0 +1,100 @@ +//! `schemas/` at the repository root publishes one JSON Schema per v3 document, +//! generated from the types. This test regenerates them and fails on any +//! difference; run it with `MODEL_CONNECTOR_REGENERATE_SCHEMAS=1` to rewrite the +//! files from the types. + +use std::path::{Path, PathBuf}; + +use model_connector::*; +use schemars::{JsonSchema, schema_for}; + +const REGENERATE: &str = "MODEL_CONNECTOR_REGENERATE_SCHEMAS"; + +fn render() -> String { + let mut text = serde_json::to_string_pretty(&schema_for!(T)).unwrap(); + text.push('\n'); + text +} + +fn schemas_dir() -> PathBuf { + Path::new(env!("CARGO_MANIFEST_DIR")).join("../../schemas") +} + +#[test] +fn published_schemas_are_the_types() { + let documents = [ + ("provider_request", render::()), + ("transport_invocation", render::()), + ("transport_result", render::()), + ("transport_receipt", render::()), + ("transport_envelope", render::()), + ]; + let dir = schemas_dir(); + if std::env::var_os(REGENERATE).is_some() { + std::fs::create_dir_all(&dir).unwrap(); + for (name, text) in &documents { + std::fs::write(dir.join(format!("{name}.json")), text).unwrap(); + } + } + for (name, text) in &documents { + let committed = std::fs::read_to_string(dir.join(format!("{name}.json"))) + .unwrap_or_else(|_| panic!("schemas/{name}.json is missing")); + assert_eq!(&committed, text, "schemas/{name}.json is not the type's"); + } + let mut on_disk: Vec = std::fs::read_dir(&dir) + .unwrap() + .map(|entry| entry.unwrap().file_name().into_string().unwrap()) + .collect(); + on_disk.sort(); + let mut expected: Vec = documents + .iter() + .map(|(name, _)| format!("{name}.json")) + .collect(); + expected.sort(); + assert_eq!( + on_disk, expected, + "schemas/ holds exactly the five documents" + ); +} + +#[test] +fn a_published_schema_refuses_an_unknown_field() { + let schema = serde_json::to_value(schema_for!(ProviderRequest)).unwrap(); + assert_eq!(schema["additionalProperties"], serde_json::json!(false)); + let required = schema["required"].as_array().unwrap(); + assert!(required.contains(&serde_json::json!("max_output_tokens"))); +} + +/// The schema and the wire agree on the ciphertext: the schema says binary, not +/// an array of integers, and the encoder writes MessagePack `bin`. +#[test] +fn the_published_ciphertext_is_what_the_wire_sends() { + let schema = serde_json::to_value(schema_for!(TransportEnvelope)).unwrap(); + let ciphertext = &schema["properties"]["ciphertext"]; + assert_eq!(ciphertext["type"], "string"); + assert_eq!(ciphertext["format"], "binary"); + assert!(ciphertext.get("items").is_none()); + let published = std::fs::read_to_string(schemas_dir().join("transport_envelope.json")).unwrap(); + let published: serde_json::Value = serde_json::from_str(&published).unwrap(); + assert_eq!(&published["properties"]["ciphertext"], ciphertext); + + let envelope = TransportEnvelope { + schema_id: "gamecult.model.transport_envelope.v3".to_string(), + caller_runtime_id: "caller".to_string(), + request_id: "request".to_string(), + message_kind: EnvelopeKind::Invocation, + nonce: [1; 12], + ciphertext: vec![9; 40], + }; + let sent = + rmpv::decode::read_value(&mut rmp_serde::to_vec_named(&envelope).unwrap().as_slice()) + .unwrap(); + let rmpv::Value::Map(entries) = sent else { + panic!("the envelope is a named map"); + }; + let (_, wire) = entries + .iter() + .find(|(key, _)| key.as_str() == Some("ciphertext")) + .unwrap(); + assert!(matches!(wire, rmpv::Value::Binary(bytes) if bytes.len() == 40)); +} diff --git a/crates/model-connector/tests/wire_golden_v2.rs b/crates/model-connector/tests/wire_golden_v2.rs deleted file mode 100644 index 198b731..0000000 --- a/crates/model-connector/tests/wire_golden_v2.rs +++ /dev/null @@ -1,301 +0,0 @@ -//! The kit speaks the v2 wire of CodexConnector fd7cec4, the library Ghostlight pins. -//! The golden bytes in `fixtures/wire_v2` were produced by that library (see its README); -//! nothing of it is linked here. Deleted with the fixtures by cut connector-contract-v3, -//! which changes the wire. - -use model_connector as kit; -use serde::Serialize; -use serde::de::DeserializeOwned; - -const KEY: &str = "parity-distinct-test-key"; -const CALLER: &str = "parity-caller"; - -macro_rules! golden { - ($name:literal) => { - include_bytes!(concat!("fixtures/wire_v2/", $name)) as &[u8] - }; -} - -fn bytes(value: &T) -> Vec { - rmp_serde::to_vec(value).unwrap() -} - -/// The value serializes to the golden bytes and the golden bytes decode back to it. -fn assert_wire( - name: &str, - value: &T, - golden: &[u8], -) { - assert_eq!(bytes(value), golden, "{name}: serialized bytes"); - let decoded: T = rmp_serde::from_slice(golden).unwrap(); - assert_eq!(&decoded, value, "{name}: decoded value"); -} - -fn request(choice: kit::ToolChoice) -> kit::ProviderRequest { - let mut request = - kit::ProviderRequest::new("request-1", "conversation-1", "gpt-5.4", "Be exact."); - request.input = vec![ - kit::InputItem::UserText { - text: "state".to_string(), - }, - kit::InputItem::AssistantText { - text: "reply".to_string(), - }, - kit::InputItem::ToolCall { - call_id: "call-1".to_string(), - name: "read_source".to_string(), - arguments: "{}".to_string(), - }, - kit::InputItem::ToolResult { - call_id: "call-1".to_string(), - output: "ok".to_string(), - }, - ]; - request.tools = vec![kit::ToolDefinition { - name: "read_source".to_string(), - description: "Read one admitted source.".to_string(), - parameters_json: r#"{"type":"object"}"#.to_string(), - }]; - request.tool_choice = choice; - request.reasoning_effort = Some("high".to_string()); - request.parallel_tool_calls = true; - request.max_output_tokens = Some(512); - request.prompt_cache_key = Some("cache-1".to_string()); - request -} - -fn invocation(choice: kit::ToolChoice) -> kit::TransportInvocation { - kit::TransportInvocation::new(CALLER, 2_000, [7; 32], request(choice)).unwrap() -} - -fn receipt( - invocation: &kit::TransportInvocation, - outcome: kit::TransportOutcome, -) -> kit::TransportReceipt { - kit::TransportReceipt { - schema_id: kit::RECEIPT_SCHEMA_ID.to_string(), - request_id: invocation.request_id().to_string(), - caller_runtime_id: invocation.caller_runtime_id.clone(), - native_request_sha256: invocation.native_request_sha256, - provider_request_sha256: invocation.provider_request_sha256, - model: invocation.request.model.clone(), - transport: "parity-sse".to_string(), - outcome, - } -} - -fn completed(invocation: &kit::TransportInvocation) -> kit::TransportResult { - kit::TransportResult::transported( - invocation, - vec![ - kit::TransportEvent { - sequence: 0, - payload: kit::TransportEventPayload::TextDelta { - text: "hello".to_string(), - }, - }, - kit::TransportEvent { - sequence: 1, - payload: kit::TransportEventPayload::ToolCall { - call_id: "call-2".to_string(), - name: "read_source".to_string(), - arguments: "{}".to_string(), - }, - }, - ], - receipt( - invocation, - kit::TransportOutcome::Completed { - provider_response_id: Some("response-1".to_string()), - input_tokens: Some(10), - output_tokens: Some(3), - reasoning_output_tokens: Some(1), - cached_input_tokens: Some(4), - }, - ), - ) -} - -fn failed(invocation: &kit::TransportInvocation) -> kit::TransportResult { - kit::TransportResult::transported( - invocation, - Vec::new(), - receipt( - invocation, - kit::TransportOutcome::Failed { - failure_kind: "result_size".to_string(), - message: "provider result exceeded its byte bound".to_string(), - }, - ), - ) -} - -fn key() -> kit::TransportKey { - kit::TransportKey::from_connection_secret(KEY).unwrap() -} - -fn refusals() -> [(&'static str, kit::Refusal, &'static [u8]); 9] { - use kit::Refusal as R; - [ - ("expired", R::Expired, golden!("result_refused_expired.mp")), - ( - "identity_substitution", - R::IdentitySubstitution, - golden!("result_refused_identity_substitution.mp"), - ), - ( - "provider_digest_substitution", - R::ProviderDigestSubstitution, - golden!("result_refused_provider_digest_substitution.mp"), - ), - ("policy", R::Policy, golden!("result_refused_policy.mp")), - ( - "capacity", - R::Capacity, - golden!("result_refused_capacity.mp"), - ), - ( - "in_flight", - R::InFlight, - golden!("result_refused_in_flight.mp"), - ), - ( - "indeterminate", - R::Indeterminate, - golden!("result_refused_indeterminate.mp"), - ), - ( - "replay_conflict", - R::ReplayConflict, - golden!("result_refused_replay_conflict.mp"), - ), - ( - "malformed", - R::Malformed, - golden!("result_refused_malformed.mp"), - ), - ] -} - -#[test] -fn schema_ids_are_the_v2_ids() { - assert_eq!( - kit::PROVIDER_REQUEST_SCHEMA_ID, - "gamecult.codex.provider_request.v2" - ); - assert_eq!( - kit::INVOCATION_SCHEMA_ID, - "gamecult.codex.transport_invocation.v2" - ); - assert_eq!(kit::RESULT_SCHEMA_ID, "gamecult.codex.transport_result.v2"); - assert_eq!( - kit::RECEIPT_SCHEMA_ID, - "gamecult.codex.transport_receipt.v2" - ); - assert_eq!( - kit::ENVELOPE_SCHEMA_ID, - "gamecult.codex.transport_envelope.v2" - ); -} - -#[test] -fn invocations_carry_the_v2_bytes_for_each_tool_choice() { - assert_wire( - "auto", - &invocation(kit::ToolChoice::Auto), - golden!("invocation_auto.mp"), - ); - assert_wire( - "required", - &invocation(kit::ToolChoice::Required), - golden!("invocation_required.mp"), - ); -} - -#[test] -fn results_carry_the_v2_bytes_for_success_and_failure() { - let invocation = invocation(kit::ToolChoice::Required); - assert_wire( - "transported", - &completed(&invocation), - golden!("result_transported.mp"), - ); - assert_wire("failed", &failed(&invocation), golden!("result_failed.mp")); -} - -#[test] -fn every_refusal_carries_its_v2_bytes() { - let invocation = invocation(kit::ToolChoice::Required); - for (name, refusal, golden) in refusals() { - assert_wire( - name, - &kit::TransportResult::refused(&invocation, refusal), - golden, - ); - } -} - -#[test] -fn canonical_bytes_and_provider_digest_are_the_v2_bytes() { - let invocation = invocation(kit::ToolChoice::Required); - assert_eq!( - kit::canonical_invocation_bytes(&invocation).unwrap(), - golden!("canonical_invocation.bin") - ); - assert_eq!( - kit::canonical_provider_request_bytes(&invocation.request).unwrap(), - golden!("canonical_provider_request.bin") - ); - assert_eq!( - kit::provider_request_sha256(&invocation.request) - .unwrap() - .as_slice(), - golden!("provider_request_sha256.bin") - ); -} - -#[test] -fn v2_sealed_envelopes_decrypt_and_validate_under_the_kit() { - let invocation = invocation(kit::ToolChoice::Required); - let envelope: kit::TransportEnvelope = - rmp_serde::from_slice(golden!("envelope_invocation.mp")).unwrap(); - let received = kit::decrypt_invocation(&envelope, &key()).unwrap(); - assert_eq!(bytes(&received), bytes(&invocation)); - - let results = [ - ( - golden!("envelope_result_transported.mp"), - completed(&invocation), - ), - (golden!("envelope_result_failed.mp"), failed(&invocation)), - ( - golden!("envelope_result_refused_replay_conflict.mp"), - kit::TransportResult::refused(&invocation, kit::Refusal::ReplayConflict), - ), - ]; - for (sealed, expected) in results { - let envelope: kit::TransportEnvelope = rmp_serde::from_slice(sealed).unwrap(); - let received = kit::decrypt_result(&envelope, &key(), &invocation).unwrap(); - assert_eq!(bytes(&received), bytes(&expected)); - } -} - -#[test] -fn kit_sealed_envelopes_have_the_v2_shape() { - let invocation = invocation(kit::ToolChoice::Required); - let golden: kit::TransportEnvelope = - rmp_serde::from_slice(golden!("envelope_invocation.mp")).unwrap(); - let sealed = kit::encrypt_invocation(&invocation, &key()).unwrap(); - assert_eq!(sealed.schema_id, golden.schema_id); - assert_eq!(sealed.caller_runtime_id, golden.caller_runtime_id); - assert_eq!(sealed.request_id, golden.request_id); - assert_eq!(sealed.message_kind, golden.message_kind); - assert_eq!(sealed.ciphertext.len(), golden.ciphertext.len()); - assert_ne!(sealed.nonce, golden.nonce, "nonces are fresh per seal"); - - let golden: kit::TransportEnvelope = - rmp_serde::from_slice(golden!("envelope_result_transported.mp")).unwrap(); - let sealed = kit::encrypt_result(&completed(&invocation), &key()).unwrap(); - assert_eq!(sealed.message_kind, golden.message_kind); - assert_eq!(sealed.ciphertext.len(), golden.ciphertext.len()); -} diff --git a/crates/model-connector/tests/wire_golden_v3.rs b/crates/model-connector/tests/wire_golden_v3.rs new file mode 100644 index 0000000..5f91513 --- /dev/null +++ b/crates/model-connector/tests/wire_golden_v3.rs @@ -0,0 +1,214 @@ +//! Pins the bytes of the v3 wire so a drift is a failing test. The fixtures in +//! `fixtures/wire_v3` are produced by this file (see its README); they pin the +//! kit against itself and are not a second contract: the types in +//! `model_connector::contract` own the shape. + +use std::path::{Path, PathBuf}; + +use base64::Engine; +use base64::engine::general_purpose::STANDARD; +use cultnet_rs::{ + CultNetMessage, CultNetWireContract, decode_cultnet_message_from_slice, + encode_cultnet_message_to_vec, +}; +use model_connector::*; +use serde::Serialize; +use serde::de::DeserializeOwned; + +const KEY: &str = "golden-distinct-test-key"; +const CALLER: &str = "golden-caller"; +const REGENERATE: &str = "WIRE_V3_REGENERATE"; + +fn fixtures() -> PathBuf { + Path::new(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures/wire_v3") +} + +fn named(value: &T) -> Vec { + rmp_serde::to_vec_named(value).unwrap() +} + +/// The value serializes to the committed bytes and the committed bytes decode +/// back to it. With `WIRE_V3_REGENERATE` set, the committed bytes are rewritten +/// from the value first. +fn assert_wire( + name: &str, + value: &T, +) { + let path = fixtures().join(name); + if std::env::var_os(REGENERATE).is_some() { + std::fs::create_dir_all(fixtures()).unwrap(); + std::fs::write(&path, named(value)).unwrap(); + } + let golden = std::fs::read(&path).unwrap_or_else(|_| panic!("{name}: fixture is missing")); + assert_eq!(named(value), golden, "{name}: serialized bytes"); + let decoded: T = rmp_serde::from_slice(&golden).unwrap(); + assert_eq!(&decoded, value, "{name}: decoded value"); +} + +fn request() -> ProviderRequest { + let mut request = ProviderRequest::new( + "golden-request", + "golden-conversation", + "claude-opus-4-5", + "Return the typed result.", + 8_192, + ); + request.input = vec![ + InputItem::UserText { + text: "Projected state".to_string(), + }, + InputItem::ToolCall { + call_id: "call-1".to_string(), + name: "read_source".to_string(), + arguments: "{}".to_string(), + }, + InputItem::ToolResult { + call_id: "call-1".to_string(), + output: "evidence".to_string(), + }, + ]; + request.reasoning_effort = Some(ReasoningEffort::High); + request.cache_retention = Some(CacheRetention::Short); + request.tools = vec![ToolDefinition { + name: "read_source".to_string(), + description: "Read one admitted source.".to_string(), + parameters_json: r#"{"type":"object"}"#.to_string(), + }]; + request.tool_choice = ToolChoice::Required; + request.parallel_tool_calls = true; + request.output_schema_json = Some(r#"{"type":"object"}"#.to_string()); + request +} + +fn invocation() -> TransportInvocation { + TransportInvocation::new(CALLER, 1_800_000_000_000, [7; 32], request()).unwrap() +} + +fn receipt(outcome: TransportOutcome) -> TransportReceipt { + let invocation = invocation(); + TransportReceipt { + schema_id: RECEIPT_SCHEMA_ID.to_string(), + request_id: invocation.request_id().to_string(), + caller_runtime_id: CALLER.to_string(), + native_request_sha256: invocation.native_request_sha256, + provider_request_sha256: invocation.provider_request_sha256, + model: invocation.request.model.clone(), + provider: "anthropic-messages".to_string(), + outcome, + } +} + +fn completed() -> TransportOutcome { + TransportOutcome::Completed { + provider_response_id: Some("msg-1".to_string()), + stop_reason: StopReason::ToolUse, + usage: Usage { + input_tokens: 120, + cache_read_tokens: 40, + cache_write_tokens: 8, + output_tokens: 33, + reasoning_tokens: Some(12), + }, + } +} + +#[test] +fn wire_golden_v3() { + let invocation = invocation(); + assert_wire("provider_request.mp", &invocation.request); + assert_wire("transport_invocation.mp", &invocation); + + let transported = TransportResult::transported( + &invocation, + vec![ + TransportEvent { + sequence: 0, + payload: TransportEventPayload::TextDelta { + text: "thinking".to_string(), + }, + }, + TransportEvent { + sequence: 1, + payload: TransportEventPayload::ToolCall { + call_id: "call-2".to_string(), + name: "read_source".to_string(), + arguments: "{}".to_string(), + }, + }, + ], + receipt(completed()), + ); + assert_eq!(transported.validate_against(&invocation), Ok(())); + assert_wire("transport_result_transported.mp", &transported); + + let refused = TransportResult::refused(&invocation, Refusal::OutputBound); + assert_wire("transport_result_refused.mp", &refused); + + let failed = receipt(TransportOutcome::Failed { + kind: FailureKind::RateLimited, + message: "provider asked to slow down".to_string(), + }); + assert_eq!(failed.validate_against(&invocation), Ok(())); + assert_wire("transport_receipt_failed.mp", &failed); + + let envelope = TransportEnvelope { + schema_id: ENVELOPE_SCHEMA_ID.to_string(), + caller_runtime_id: CALLER.to_string(), + request_id: "golden-request".to_string(), + message_kind: EnvelopeKind::Invocation, + nonce: [0x80, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 0xff], + ciphertext: (0..40_u8).map(|byte| byte.wrapping_mul(7)).collect(), + }; + assert_wire("transport_envelope.mp", &envelope); +} + +/// The committed carriage frame is a `cultnet.operation_request.v0` for the +/// connector, read here with CultNet itself and not the kit's own carriage code. +/// Its payload is the base64 of a sealed envelope that decrypts under the +/// fixed key to the golden invocation. +#[test] +fn the_golden_carriage_frame_decrypts_and_validates_under_the_fixed_key() { + let path = fixtures().join("carriage_invocation.frame"); + let key = TransportKey::from_connection_secret(KEY).unwrap(); + let invocation = invocation(); + if std::env::var_os(REGENERATE).is_some() { + let envelope = encrypt_invocation(&invocation, &key).unwrap(); + let message = CultNetMessage::OperationRequest { + message_id: envelope.request_id.clone(), + service_id: CONNECTOR_SERVICE_ID.to_string(), + operation: INVOKE_OPERATION.to_string(), + payload_schema: ENVELOPE_SCHEMA_ID.to_string(), + payload_encoding: "messagepack-base64".to_string(), + payload: STANDARD.encode(named(&envelope)), + source_runtime_id: None, + target_runtime_id: None, + }; + std::fs::write( + &path, + encode_cultnet_message_to_vec(&message, CultNetWireContract::CultNetSchemaV0).unwrap(), + ) + .unwrap(); + } + let frame = std::fs::read(&path).expect("carriage_invocation.frame is missing"); + let CultNetMessage::OperationRequest { + message_id, + service_id, + operation, + payload_schema, + payload, + .. + } = decode_cultnet_message_from_slice(&frame, CultNetWireContract::CultNetSchemaV0).unwrap() + else { + panic!("the golden frame is a CultNet operation request"); + }; + assert_eq!(service_id, "gamecult.model.connector"); + assert_eq!(operation, "invoke"); + assert_eq!(payload_schema, "gamecult.model.transport_envelope.v3"); + assert_eq!(message_id, invocation.request_id()); + let envelope: TransportEnvelope = + rmp_serde::from_slice(&STANDARD.decode(payload).unwrap()).unwrap(); + assert_eq!(envelope.request_id, message_id); + let decrypted = decrypt_invocation(&envelope, &key).unwrap(); + assert_eq!(decrypted, invocation); + assert_eq!(decrypted.validate(1_799_999_999_000, 5_000), Ok(())); +} diff --git a/schemas/provider_request.json b/schemas/provider_request.json new file mode 100644 index 0000000..d4fb346 --- /dev/null +++ b/schemas/provider_request.json @@ -0,0 +1,229 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "title": "ProviderRequest", + "type": "object", + "properties": { + "cache_retention": { + "anyOf": [ + { + "$ref": "#/$defs/CacheRetention" + }, + { + "type": "null" + } + ] + }, + "conversation_id": { + "type": "string" + }, + "input": { + "type": "array", + "items": { + "$ref": "#/$defs/InputItem" + } + }, + "instructions": { + "type": "string" + }, + "max_output_tokens": { + "type": "integer", + "format": "uint32", + "minimum": 0 + }, + "model": { + "type": "string" + }, + "output_schema_json": { + "type": [ + "string", + "null" + ] + }, + "parallel_tool_calls": { + "type": "boolean" + }, + "reasoning_effort": { + "anyOf": [ + { + "$ref": "#/$defs/ReasoningEffort" + }, + { + "type": "null" + } + ] + }, + "request_id": { + "type": "string" + }, + "schema_id": { + "type": "string" + }, + "tool_choice": { + "$ref": "#/$defs/ToolChoice" + }, + "tools": { + "type": "array", + "items": { + "$ref": "#/$defs/ToolDefinition" + } + } + }, + "additionalProperties": false, + "required": [ + "schema_id", + "request_id", + "conversation_id", + "model", + "instructions", + "input", + "tools", + "tool_choice", + "parallel_tool_calls", + "max_output_tokens" + ], + "$defs": { + "CacheRetention": { + "type": "string", + "enum": [ + "short", + "long" + ] + }, + "InputItem": { + "oneOf": [ + { + "type": "object", + "properties": { + "UserText": { + "type": "object", + "properties": { + "text": { + "type": "string" + } + }, + "additionalProperties": false, + "required": [ + "text" + ] + } + }, + "additionalProperties": false, + "required": [ + "UserText" + ] + }, + { + "type": "object", + "properties": { + "AssistantText": { + "type": "object", + "properties": { + "text": { + "type": "string" + } + }, + "additionalProperties": false, + "required": [ + "text" + ] + } + }, + "additionalProperties": false, + "required": [ + "AssistantText" + ] + }, + { + "type": "object", + "properties": { + "ToolCall": { + "type": "object", + "properties": { + "arguments": { + "type": "string" + }, + "call_id": { + "type": "string" + }, + "name": { + "type": "string" + } + }, + "additionalProperties": false, + "required": [ + "call_id", + "name", + "arguments" + ] + } + }, + "additionalProperties": false, + "required": [ + "ToolCall" + ] + }, + { + "type": "object", + "properties": { + "ToolResult": { + "type": "object", + "properties": { + "call_id": { + "type": "string" + }, + "output": { + "type": "string" + } + }, + "additionalProperties": false, + "required": [ + "call_id", + "output" + ] + } + }, + "additionalProperties": false, + "required": [ + "ToolResult" + ] + } + ] + }, + "ReasoningEffort": { + "type": "string", + "enum": [ + "low", + "medium", + "high", + "max" + ] + }, + "ToolChoice": { + "type": "string", + "enum": [ + "auto", + "required" + ] + }, + "ToolDefinition": { + "type": "object", + "properties": { + "description": { + "type": "string" + }, + "name": { + "type": "string" + }, + "parameters_json": { + "type": "string" + } + }, + "additionalProperties": false, + "required": [ + "name", + "description", + "parameters_json" + ] + } + } +} diff --git a/schemas/transport_envelope.json b/schemas/transport_envelope.json new file mode 100644 index 0000000..1983423 --- /dev/null +++ b/schemas/transport_envelope.json @@ -0,0 +1,53 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "title": "TransportEnvelope", + "type": "object", + "properties": { + "caller_runtime_id": { + "type": "string" + }, + "ciphertext": { + "description": "MessagePack bin: the raw ciphertext bytes, never an array of integers", + "type": "string", + "format": "binary" + }, + "message_kind": { + "$ref": "#/$defs/EnvelopeKind" + }, + "nonce": { + "type": "array", + "items": { + "type": "integer", + "format": "uint8", + "maximum": 255, + "minimum": 0 + }, + "maxItems": 12, + "minItems": 12 + }, + "request_id": { + "type": "string" + }, + "schema_id": { + "type": "string" + } + }, + "additionalProperties": false, + "required": [ + "schema_id", + "caller_runtime_id", + "request_id", + "message_kind", + "nonce", + "ciphertext" + ], + "$defs": { + "EnvelopeKind": { + "type": "string", + "enum": [ + "Invocation", + "Result" + ] + } + } +} diff --git a/schemas/transport_invocation.json b/schemas/transport_invocation.json new file mode 100644 index 0000000..9e899b1 --- /dev/null +++ b/schemas/transport_invocation.json @@ -0,0 +1,279 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "title": "TransportInvocation", + "type": "object", + "properties": { + "caller_runtime_id": { + "type": "string" + }, + "expires_at_unix_ms": { + "type": "integer", + "format": "uint64", + "minimum": 0 + }, + "native_request_sha256": { + "type": "array", + "items": { + "type": "integer", + "format": "uint8", + "maximum": 255, + "minimum": 0 + }, + "maxItems": 32, + "minItems": 32 + }, + "provider_request_sha256": { + "type": "array", + "items": { + "type": "integer", + "format": "uint8", + "maximum": 255, + "minimum": 0 + }, + "maxItems": 32, + "minItems": 32 + }, + "request": { + "$ref": "#/$defs/ProviderRequest" + }, + "schema_id": { + "type": "string" + } + }, + "additionalProperties": false, + "required": [ + "schema_id", + "caller_runtime_id", + "expires_at_unix_ms", + "native_request_sha256", + "provider_request_sha256", + "request" + ], + "$defs": { + "CacheRetention": { + "type": "string", + "enum": [ + "short", + "long" + ] + }, + "InputItem": { + "oneOf": [ + { + "type": "object", + "properties": { + "UserText": { + "type": "object", + "properties": { + "text": { + "type": "string" + } + }, + "additionalProperties": false, + "required": [ + "text" + ] + } + }, + "additionalProperties": false, + "required": [ + "UserText" + ] + }, + { + "type": "object", + "properties": { + "AssistantText": { + "type": "object", + "properties": { + "text": { + "type": "string" + } + }, + "additionalProperties": false, + "required": [ + "text" + ] + } + }, + "additionalProperties": false, + "required": [ + "AssistantText" + ] + }, + { + "type": "object", + "properties": { + "ToolCall": { + "type": "object", + "properties": { + "arguments": { + "type": "string" + }, + "call_id": { + "type": "string" + }, + "name": { + "type": "string" + } + }, + "additionalProperties": false, + "required": [ + "call_id", + "name", + "arguments" + ] + } + }, + "additionalProperties": false, + "required": [ + "ToolCall" + ] + }, + { + "type": "object", + "properties": { + "ToolResult": { + "type": "object", + "properties": { + "call_id": { + "type": "string" + }, + "output": { + "type": "string" + } + }, + "additionalProperties": false, + "required": [ + "call_id", + "output" + ] + } + }, + "additionalProperties": false, + "required": [ + "ToolResult" + ] + } + ] + }, + "ProviderRequest": { + "type": "object", + "properties": { + "cache_retention": { + "anyOf": [ + { + "$ref": "#/$defs/CacheRetention" + }, + { + "type": "null" + } + ] + }, + "conversation_id": { + "type": "string" + }, + "input": { + "type": "array", + "items": { + "$ref": "#/$defs/InputItem" + } + }, + "instructions": { + "type": "string" + }, + "max_output_tokens": { + "type": "integer", + "format": "uint32", + "minimum": 0 + }, + "model": { + "type": "string" + }, + "output_schema_json": { + "type": [ + "string", + "null" + ] + }, + "parallel_tool_calls": { + "type": "boolean" + }, + "reasoning_effort": { + "anyOf": [ + { + "$ref": "#/$defs/ReasoningEffort" + }, + { + "type": "null" + } + ] + }, + "request_id": { + "type": "string" + }, + "schema_id": { + "type": "string" + }, + "tool_choice": { + "$ref": "#/$defs/ToolChoice" + }, + "tools": { + "type": "array", + "items": { + "$ref": "#/$defs/ToolDefinition" + } + } + }, + "additionalProperties": false, + "required": [ + "schema_id", + "request_id", + "conversation_id", + "model", + "instructions", + "input", + "tools", + "tool_choice", + "parallel_tool_calls", + "max_output_tokens" + ] + }, + "ReasoningEffort": { + "type": "string", + "enum": [ + "low", + "medium", + "high", + "max" + ] + }, + "ToolChoice": { + "type": "string", + "enum": [ + "auto", + "required" + ] + }, + "ToolDefinition": { + "type": "object", + "properties": { + "description": { + "type": "string" + }, + "name": { + "type": "string" + }, + "parameters_json": { + "type": "string" + } + }, + "additionalProperties": false, + "required": [ + "name", + "description", + "parameters_json" + ] + } + } +} diff --git a/schemas/transport_receipt.json b/schemas/transport_receipt.json new file mode 100644 index 0000000..68916ed --- /dev/null +++ b/schemas/transport_receipt.json @@ -0,0 +1,186 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "title": "TransportReceipt", + "type": "object", + "properties": { + "caller_runtime_id": { + "type": "string" + }, + "model": { + "type": "string" + }, + "native_request_sha256": { + "type": "array", + "items": { + "type": "integer", + "format": "uint8", + "maximum": 255, + "minimum": 0 + }, + "maxItems": 32, + "minItems": 32 + }, + "outcome": { + "$ref": "#/$defs/TransportOutcome" + }, + "provider": { + "type": "string" + }, + "provider_request_sha256": { + "type": "array", + "items": { + "type": "integer", + "format": "uint8", + "maximum": 255, + "minimum": 0 + }, + "maxItems": 32, + "minItems": 32 + }, + "request_id": { + "type": "string" + }, + "schema_id": { + "type": "string" + } + }, + "additionalProperties": false, + "required": [ + "schema_id", + "request_id", + "caller_runtime_id", + "native_request_sha256", + "provider_request_sha256", + "model", + "provider", + "outcome" + ], + "$defs": { + "FailureKind": { + "description": "What kind of failure a provider reported. `ResultSize` is the connector's own\nstand-in for a result whose sealed frame cannot be carried.", + "type": "string", + "enum": [ + "result_size", + "invalid_request", + "authentication", + "rate_limited", + "unavailable", + "timeout", + "provider" + ] + }, + "StopReason": { + "description": "Why a provider stopped producing output.", + "type": "string", + "enum": [ + "end_turn", + "tool_use", + "max_output_tokens", + "stop_sequence", + "refusal", + "context_window" + ] + }, + "TransportOutcome": { + "oneOf": [ + { + "type": "object", + "properties": { + "Completed": { + "type": "object", + "properties": { + "provider_response_id": { + "type": [ + "string", + "null" + ] + }, + "stop_reason": { + "$ref": "#/$defs/StopReason" + }, + "usage": { + "$ref": "#/$defs/Usage" + } + }, + "additionalProperties": false, + "required": [ + "stop_reason", + "usage" + ] + } + }, + "additionalProperties": false, + "required": [ + "Completed" + ] + }, + { + "type": "object", + "properties": { + "Failed": { + "type": "object", + "properties": { + "kind": { + "$ref": "#/$defs/FailureKind" + }, + "message": { + "type": "string" + } + }, + "additionalProperties": false, + "required": [ + "kind", + "message" + ] + } + }, + "additionalProperties": false, + "required": [ + "Failed" + ] + } + ] + }, + "Usage": { + "description": "Token accounting for one completed request. Cache reads and writes are\ncounted apart from plain input; `reasoning_tokens` is absent when the\nprovider does not report it.", + "type": "object", + "properties": { + "cache_read_tokens": { + "type": "integer", + "format": "uint64", + "minimum": 0 + }, + "cache_write_tokens": { + "type": "integer", + "format": "uint64", + "minimum": 0 + }, + "input_tokens": { + "type": "integer", + "format": "uint64", + "minimum": 0 + }, + "output_tokens": { + "type": "integer", + "format": "uint64", + "minimum": 0 + }, + "reasoning_tokens": { + "type": [ + "integer", + "null" + ], + "format": "uint64", + "minimum": 0 + } + }, + "additionalProperties": false, + "required": [ + "input_tokens", + "cache_read_tokens", + "cache_write_tokens", + "output_tokens" + ] + } + } +} diff --git a/schemas/transport_result.json b/schemas/transport_result.json new file mode 100644 index 0000000..ceb1e42 --- /dev/null +++ b/schemas/transport_result.json @@ -0,0 +1,342 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "title": "TransportResult", + "type": "object", + "properties": { + "caller_runtime_id": { + "type": "string" + }, + "disposition": { + "$ref": "#/$defs/TransportDisposition" + }, + "request_id": { + "type": "string" + }, + "schema_id": { + "type": "string" + } + }, + "additionalProperties": false, + "required": [ + "schema_id", + "request_id", + "caller_runtime_id", + "disposition" + ], + "$defs": { + "FailureKind": { + "description": "What kind of failure a provider reported. `ResultSize` is the connector's own\nstand-in for a result whose sealed frame cannot be carried.", + "type": "string", + "enum": [ + "result_size", + "invalid_request", + "authentication", + "rate_limited", + "unavailable", + "timeout", + "provider" + ] + }, + "Refusal": { + "type": "string", + "enum": [ + "Expired", + "IdentitySubstitution", + "ProviderDigestSubstitution", + "ModelNotAllowed", + "OutputBound", + "PayloadBound", + "Capacity", + "InFlight", + "Indeterminate", + "ReplayConflict", + "Malformed" + ] + }, + "StopReason": { + "description": "Why a provider stopped producing output.", + "type": "string", + "enum": [ + "end_turn", + "tool_use", + "max_output_tokens", + "stop_sequence", + "refusal", + "context_window" + ] + }, + "TransportDisposition": { + "oneOf": [ + { + "type": "object", + "properties": { + "Refused": { + "$ref": "#/$defs/Refusal" + } + }, + "additionalProperties": false, + "required": [ + "Refused" + ] + }, + { + "type": "object", + "properties": { + "Transported": { + "type": "object", + "properties": { + "events": { + "type": "array", + "items": { + "$ref": "#/$defs/TransportEvent" + } + }, + "receipt": { + "$ref": "#/$defs/TransportReceipt" + } + }, + "additionalProperties": false, + "required": [ + "events", + "receipt" + ] + } + }, + "additionalProperties": false, + "required": [ + "Transported" + ] + } + ] + }, + "TransportEvent": { + "type": "object", + "properties": { + "payload": { + "$ref": "#/$defs/TransportEventPayload" + }, + "sequence": { + "type": "integer", + "format": "uint64", + "minimum": 0 + } + }, + "additionalProperties": false, + "required": [ + "sequence", + "payload" + ] + }, + "TransportEventPayload": { + "oneOf": [ + { + "type": "object", + "properties": { + "TextDelta": { + "type": "object", + "properties": { + "text": { + "type": "string" + } + }, + "additionalProperties": false, + "required": [ + "text" + ] + } + }, + "additionalProperties": false, + "required": [ + "TextDelta" + ] + }, + { + "type": "object", + "properties": { + "ToolCall": { + "type": "object", + "properties": { + "arguments": { + "type": "string" + }, + "call_id": { + "type": "string" + }, + "name": { + "type": "string" + } + }, + "additionalProperties": false, + "required": [ + "call_id", + "name", + "arguments" + ] + } + }, + "additionalProperties": false, + "required": [ + "ToolCall" + ] + } + ] + }, + "TransportOutcome": { + "oneOf": [ + { + "type": "object", + "properties": { + "Completed": { + "type": "object", + "properties": { + "provider_response_id": { + "type": [ + "string", + "null" + ] + }, + "stop_reason": { + "$ref": "#/$defs/StopReason" + }, + "usage": { + "$ref": "#/$defs/Usage" + } + }, + "additionalProperties": false, + "required": [ + "stop_reason", + "usage" + ] + } + }, + "additionalProperties": false, + "required": [ + "Completed" + ] + }, + { + "type": "object", + "properties": { + "Failed": { + "type": "object", + "properties": { + "kind": { + "$ref": "#/$defs/FailureKind" + }, + "message": { + "type": "string" + } + }, + "additionalProperties": false, + "required": [ + "kind", + "message" + ] + } + }, + "additionalProperties": false, + "required": [ + "Failed" + ] + } + ] + }, + "TransportReceipt": { + "type": "object", + "properties": { + "caller_runtime_id": { + "type": "string" + }, + "model": { + "type": "string" + }, + "native_request_sha256": { + "type": "array", + "items": { + "type": "integer", + "format": "uint8", + "maximum": 255, + "minimum": 0 + }, + "maxItems": 32, + "minItems": 32 + }, + "outcome": { + "$ref": "#/$defs/TransportOutcome" + }, + "provider": { + "type": "string" + }, + "provider_request_sha256": { + "type": "array", + "items": { + "type": "integer", + "format": "uint8", + "maximum": 255, + "minimum": 0 + }, + "maxItems": 32, + "minItems": 32 + }, + "request_id": { + "type": "string" + }, + "schema_id": { + "type": "string" + } + }, + "additionalProperties": false, + "required": [ + "schema_id", + "request_id", + "caller_runtime_id", + "native_request_sha256", + "provider_request_sha256", + "model", + "provider", + "outcome" + ] + }, + "Usage": { + "description": "Token accounting for one completed request. Cache reads and writes are\ncounted apart from plain input; `reasoning_tokens` is absent when the\nprovider does not report it.", + "type": "object", + "properties": { + "cache_read_tokens": { + "type": "integer", + "format": "uint64", + "minimum": 0 + }, + "cache_write_tokens": { + "type": "integer", + "format": "uint64", + "minimum": 0 + }, + "input_tokens": { + "type": "integer", + "format": "uint64", + "minimum": 0 + }, + "output_tokens": { + "type": "integer", + "format": "uint64", + "minimum": 0 + }, + "reasoning_tokens": { + "type": [ + "integer", + "null" + ], + "format": "uint64", + "minimum": 0 + } + }, + "additionalProperties": false, + "required": [ + "input_tokens", + "cache_read_tokens", + "cache_write_tokens", + "output_tokens" + ] + } + } +} From 1d51e71ebf772142c57935df844a31ba589e02ec Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Wed, 28 Oct 2026 13:31:06 +0100 Subject: [PATCH 25/69] connector-reasoning-item (B3b): one opaque reasoning item per provider block; the service owns foreign state (squash of eureka-body/connector-reasoning-item bf958fc..d915f67) A ReasoningState item carries each provider block as raw bytes. The service decides what is foreign: foreign state after the last user text is refused, earlier foreign state is dropped before the backend and counted on the receipt. Backends receive a ProviderCall that holds only what they may see, and the service fills and checks the receipt's provider tag and drop count; a refused result reaches the caller as a typed Provider failure, releases its slot and replays on retry. transcript_items turns a completed result into transcript items from the receipt, so callers never branch on provider. Spec eureka-body:cut_spec:cut-connector-reasoning-item.r1; reports h1-h2; Soul s2 holds (verdict eureka-body:verdict:cut-connector-reasoning-item.s2). Co-Authored-By: Claude Opus 5.5 --- README.md | 3 + crates/model-connector/src/contract.rs | 227 ++++++- crates/model-connector/src/envelope.rs | 9 +- crates/model-connector/src/fixtures.rs | 2 + crates/model-connector/src/serve.rs | 117 ++-- crates/model-connector/src/service.rs | 616 ++++++++++++++++-- .../tests/fixtures/wire_v3/README.md | 3 +- .../fixtures/wire_v3/input_reasoning_state.mp | 1 + .../wire_v3/transport_receipt_failed.mp | Bin 358 -> 385 bytes .../wire_v3/transport_result_transported.mp | Bin 715 -> 914 bytes .../tests/published_schemas.rs | 15 + .../model-connector/tests/reasoning_state.rs | 348 ++++++++++ .../model-connector/tests/wire_golden_v3.rs | 68 +- schemas/provider_request.json | 32 + schemas/transport_envelope.json | 2 +- schemas/transport_invocation.json | 32 + schemas/transport_receipt.json | 7 + schemas/transport_result.json | 34 +- 18 files changed, 1366 insertions(+), 150 deletions(-) create mode 100644 crates/model-connector/tests/fixtures/wire_v3/input_reasoning_state.mp create mode 100644 crates/model-connector/tests/reasoning_state.rs diff --git a/README.md b/README.md index cbcccb5..596f571 100644 --- a/README.md +++ b/README.md @@ -13,6 +13,9 @@ Crate `crates/model-connector`: - `src/contract.rs` - the v3 wire contract (`gamecult.model.*.v3`): `ProviderRequest`, `TransportInvocation`, `TransportResult`, `TransportReceipt`, the typed `StopReason`, `FailureKind` and `Refusal`, + `InputItem::ReasoningState` (a provider's opaque reasoning block, replayed by callers + verbatim via `TransportResult::transcript_items`; the service refuses another provider's + state in the in-flight tool exchange and drops it from earlier turns), and the canonical byte and digest functions that bind a result to its request. Every document is named MessagePack with unknown fields refused. - `src/envelope.rs` - the AES-256-GCM `TransportEnvelope` (binary ciphertext), its diff --git a/crates/model-connector/src/contract.rs b/crates/model-connector/src/contract.rs index d60fab5..3062fe4 100644 --- a/crates/model-connector/src/contract.rs +++ b/crates/model-connector/src/contract.rs @@ -7,7 +7,7 @@ use sha2::{Digest, Sha256}; use thiserror::Error; use zeroize::Zeroize; -use crate::envelope::ServiceError; +use crate::envelope::{ServiceError, binary_schema}; pub const PROVIDER_REQUEST_SCHEMA_ID: &str = "gamecult.model.provider_request.v3"; pub const INVOCATION_SCHEMA_ID: &str = "gamecult.model.transport_invocation.v3"; @@ -124,6 +124,17 @@ impl ProviderRequest { require_provider_call_id(call_id, "input.call_id")?; require_content(output, "input.output")?; } + InputItem::ReasoningState { + provider, + model, + state, + } => { + require_id(provider, "input.provider")?; + require_id(model, "input.model")?; + if state.is_empty() { + return Err(ContractError::Invalid("input.state")); + } + } } } Ok(()) @@ -210,6 +221,18 @@ pub enum InputItem { call_id: String, output: String, }, + /// One provider reasoning block, opaque to every caller: `state` is the + /// connector's own encoding of the whole block. Callers replay it + /// verbatim in transcript order and never inspect or branch on it; only + /// the connector of `provider` reads it, and the service drops or refuses + /// the state of any other provider before a backend sees the request. + ReasoningState { + provider: String, + model: String, + #[serde(with = "serde_bytes")] + #[schemars(schema_with = "binary_schema")] + state: Vec, + }, } #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] @@ -303,15 +326,14 @@ impl TransportResult { } } - pub fn transported( - invocation: &TransportInvocation, - events: Vec, - receipt: TransportReceipt, - ) -> Self { + /// Built by `ProviderCall` in the daemon and by in-crate tests only; the + /// result's identity is its receipt's. + #[cfg(any(test, feature = "daemon"))] + pub(crate) fn transported(events: Vec, receipt: TransportReceipt) -> Self { Self { schema_id: RESULT_SCHEMA_ID.to_string(), - request_id: invocation.request_id().to_string(), - caller_runtime_id: invocation.caller_runtime_id.clone(), + request_id: receipt.request_id.clone(), + caller_runtime_id: receipt.caller_runtime_id.clone(), disposition: TransportDisposition::Transported { events, receipt: Box::new(receipt), @@ -319,6 +341,53 @@ impl TransportResult { } } + /// The transcript items a completed result adds, ready to append to the + /// next request verbatim: consecutive text deltas become one assistant + /// text, a tool call stays a tool call and reasoning state is stamped with + /// the receipt's provider and model. A refused or failed result yields + /// none, because a partial reasoning block must never be replayed. + pub fn transcript_items(&self) -> Vec { + let TransportDisposition::Transported { events, receipt } = &self.disposition else { + return Vec::new(); + }; + if !matches!(receipt.outcome, TransportOutcome::Completed { .. }) { + return Vec::new(); + } + let mut items: Vec = Vec::new(); + let mut text = String::new(); + for event in events { + if !matches!(event.payload, TransportEventPayload::TextDelta { .. }) && !text.is_empty() + { + items.push(InputItem::AssistantText { + text: std::mem::take(&mut text), + }); + } + match &event.payload { + TransportEventPayload::TextDelta { text: delta } => text.push_str(delta), + TransportEventPayload::ToolCall { + call_id, + name, + arguments, + } => items.push(InputItem::ToolCall { + call_id: call_id.clone(), + name: name.clone(), + arguments: arguments.clone(), + }), + TransportEventPayload::ReasoningState { state } => { + items.push(InputItem::ReasoningState { + provider: receipt.provider.clone(), + model: receipt.model.clone(), + state: state.clone(), + }) + } + } + } + if !text.is_empty() { + items.push(InputItem::AssistantText { text }); + } + items + } + pub fn validate_against(&self, invocation: &TransportInvocation) -> Result<(), ContractError> { if self.schema_id != RESULT_SCHEMA_ID { return Err(ContractError::Schema); @@ -353,6 +422,14 @@ pub enum TransportEventPayload { name: String, arguments: String, }, + /// One reasoning block of the provider that answered. It carries no + /// provider or model: the receipt names them, so a backend cannot tag its + /// own state. + ReasoningState { + #[serde(with = "serde_bytes")] + #[schemars(schema_with = "binary_schema")] + state: Vec, + }, } #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] @@ -375,6 +452,12 @@ impl TransportEvent { require_id(name, "event.tool_name")?; require_content(arguments, "event.arguments") } + TransportEventPayload::ReasoningState { state } => { + if state.is_empty() { + return Err(ContractError::Invalid("event.state")); + } + Ok(()) + } } } } @@ -389,6 +472,9 @@ pub struct TransportReceipt { pub provider_request_sha256: [u8; 32], pub model: String, pub provider: String, + /// How many foreign `ReasoningState` items the service removed from the + /// request before the backend saw it. + pub foreign_reasoning_dropped: u32, pub outcome: TransportOutcome, } @@ -454,6 +540,10 @@ pub enum Refusal { Indeterminate, ReplayConflict, Malformed, + // A `ReasoningState` of another provider sits in the in-flight tool + // exchange, where dropping it would break the provider's own rules. A plain + // comment: a doc comment would split the published schema into a union. + ForeignReasoningState, } /// The one wire codec of every v3 document: named MessagePack (struct fields @@ -677,7 +767,6 @@ mod tests { fn transported_result_binds_identity_digest_and_event_order() { let invocation = invocation(); let result = TransportResult::transported( - &invocation, vec![TransportEvent { sequence: 0, payload: TransportEventPayload::ToolCall { @@ -738,7 +827,7 @@ mod tests { ] ); let invocation = invocation(); - let result = TransportResult::transported(&invocation, Vec::new(), receipt(&invocation)); + let result = TransportResult::transported(Vec::new(), receipt(&invocation)); let TransportDisposition::Transported { receipt: sent, .. } = &result.disposition else { unreachable!() }; @@ -784,7 +873,7 @@ mod tests { #[test] fn an_unknown_field_is_refused_not_dropped() { let invocation = invocation(); - let result = TransportResult::transported(&invocation, Vec::new(), receipt(&invocation)); + let result = TransportResult::transported(Vec::new(), receipt(&invocation)); let TransportDisposition::Transported { receipt: sent, .. } = &result.disposition else { unreachable!() }; @@ -876,7 +965,7 @@ mod tests { #[test] fn validate_against_refuses_each_identity_field() { let invocation = invocation(); - let result = TransportResult::transported(&invocation, Vec::new(), receipt(&invocation)); + let result = TransportResult::transported(Vec::new(), receipt(&invocation)); assert_eq!(result.validate_against(&invocation), Ok(())); let mut other_request = result.clone(); other_request.request_id = "request-2".to_string(); @@ -903,4 +992,118 @@ mod tests { ); } } + + fn reasoning(state: &[u8]) -> TransportEventPayload { + TransportEventPayload::ReasoningState { + state: state.to_vec(), + } + } + + fn event(sequence: u64, payload: TransportEventPayload) -> TransportEvent { + TransportEvent { sequence, payload } + } + + fn text(delta: &str) -> TransportEventPayload { + TransportEventPayload::TextDelta { + text: delta.to_string(), + } + } + + fn tool_call() -> TransportEventPayload { + TransportEventPayload::ToolCall { + call_id: "call-9".to_string(), + name: "read_source".to_string(), + arguments: "{}".to_string(), + } + } + + #[test] + fn transcript_items_follow_event_order() { + let invocation = invocation(); + let events = vec![ + event(0, text("one ")), + event(1, text("two")), + event(2, reasoning(&[0x80, 0xff])), + event(3, tool_call()), + event(4, text("three")), + event(5, reasoning(&[7])), + ]; + let result = TransportResult::transported(events, receipt(&invocation)); + assert_eq!( + result.transcript_items(), + vec![ + InputItem::AssistantText { + text: "one two".to_string() + }, + InputItem::ReasoningState { + provider: "fixture-sse".to_string(), + model: invocation.request.model.clone(), + state: vec![0x80, 0xff], + }, + InputItem::ToolCall { + call_id: "call-9".to_string(), + name: "read_source".to_string(), + arguments: "{}".to_string(), + }, + InputItem::AssistantText { + text: "three".to_string() + }, + InputItem::ReasoningState { + provider: "fixture-sse".to_string(), + model: invocation.request.model.clone(), + state: vec![7], + }, + ] + ); + } + + #[test] + fn transcript_items_of_refused_or_failed_are_empty() { + let invocation = invocation(); + let refused = TransportResult::refused(&invocation, Refusal::OutputBound); + assert_eq!(refused.transcript_items(), Vec::new()); + + let mut failed = receipt(&invocation); + failed.outcome = TransportOutcome::Failed { + kind: FailureKind::RateLimited, + message: "slow down".to_string(), + }; + let partial = TransportResult::transported( + vec![event(0, text("half")), event(1, reasoning(&[1, 2, 3]))], + failed, + ); + assert_eq!(partial.transcript_items(), Vec::new()); + } + + #[test] + fn reasoning_state_validation() { + let item = |provider: &str, model: &str, state: &[u8]| InputItem::ReasoningState { + provider: provider.to_string(), + model: model.to_string(), + state: state.to_vec(), + }; + let validate = |item: InputItem| { + let mut request = request(); + request.input.push(item); + request.validate() + }; + assert_eq!(validate(item("anthropic", "claude", &[1])), Ok(())); + assert_eq!( + validate(item("", "claude", &[1])), + Err(ContractError::Invalid("input.provider")) + ); + assert_eq!( + validate(item("anthropic", "", &[1])), + Err(ContractError::Invalid("input.model")) + ); + assert_eq!( + validate(item("anthropic", "claude", &[])), + Err(ContractError::Invalid("input.state")) + ); + assert_eq!(event(0, reasoning(&[1])).validate(), Ok(())); + assert_eq!( + event(0, reasoning(&[])).validate(), + Err(ContractError::Invalid("event.state")) + ); + } } diff --git a/crates/model-connector/src/envelope.rs b/crates/model-connector/src/envelope.rs index 0f87546..56bb1c2 100644 --- a/crates/model-connector/src/envelope.rs +++ b/crates/model-connector/src/envelope.rs @@ -37,12 +37,13 @@ pub struct TransportEnvelope { pub ciphertext: Vec, } -/// The ciphertext is MessagePack `bin` on the wire, never an array of integers. -fn binary_schema(_: &mut schemars::SchemaGenerator) -> schemars::Schema { +/// Raw bytes are MessagePack `bin` on the wire, never an array of integers. +/// Serves the ciphertext and both reasoning states. +pub(crate) fn binary_schema(_: &mut schemars::SchemaGenerator) -> schemars::Schema { schemars::json_schema!({ "type": "string", "format": "binary", - "description": "MessagePack bin: the raw ciphertext bytes, never an array of integers" + "description": "MessagePack bin: raw bytes, never an array of integers" }) } @@ -556,7 +557,7 @@ mod tests { fn the_outer_identity_must_match_the_sealed_result_on_each_field() { let key = security("epiphany-distinct-test-key"); let invocation = invocation(); - let result = TransportResult::transported(&invocation, Vec::new(), receipt(&invocation)); + let result = TransportResult::transported(Vec::new(), receipt(&invocation)); let plaintext = wire_encode(&result).unwrap(); let seal = |caller: &str, request_id: &str| { encrypt_message(caller, request_id, EnvelopeKind::Result, &plaintext, &key).unwrap() diff --git a/crates/model-connector/src/fixtures.rs b/crates/model-connector/src/fixtures.rs index e94ee52..b96fea8 100644 --- a/crates/model-connector/src/fixtures.rs +++ b/crates/model-connector/src/fixtures.rs @@ -69,6 +69,7 @@ pub(crate) fn receipt(invocation: &TransportInvocation) -> TransportReceipt { provider_request_sha256: invocation.provider_request_sha256, model: invocation.request.model.clone(), provider: "fixture-sse".to_string(), + foreign_reasoning_dropped: 0, outcome: TransportOutcome::Completed { provider_response_id: Some("response-1".to_string()), stop_reason: StopReason::EndTurn, @@ -104,6 +105,7 @@ pub(crate) fn caller( pub(crate) fn open_service(store: &TestStore, max_concurrent_requests: usize) -> TransportService { TransportService::open( &store.path, + "fixture-sse", [ caller( "epiphany-yggdrasil", diff --git a/crates/model-connector/src/serve.rs b/crates/model-connector/src/serve.rs index cef686a..40ece2d 100644 --- a/crates/model-connector/src/serve.rs +++ b/crates/model-connector/src/serve.rs @@ -10,12 +10,12 @@ use serde::{Deserialize, Serialize}; use thiserror::Error; use zeroize::Zeroizing; -use crate::contract::{TransportInvocation, TransportResult}; +use crate::contract::TransportResult; use crate::envelope::{ ServiceError, TransportFrameError, decode_invocation_frame, encode_result_frame, frame_bytes_for_payload, read_transport_frame, write_transport_frame, }; -use crate::service::{CallerAdmission, TransportAdmission, TransportService}; +use crate::service::{CallerAdmission, ProviderCall, TransportAdmission, TransportService}; /// The only provider seam of the connector kit. A connector binary implements /// it over one model provider; the kit names no provider. @@ -24,9 +24,14 @@ pub trait ProviderBackend: Send + Sync { /// opens the replay store or accepts a connection; the request path does /// not consult it. fn ready(&self) -> bool; - /// Executes one admitted invocation. The service has already bound the - /// invocation to its caller and recorded its replay claim. - fn execute(&self, invocation: &TransportInvocation) -> TransportResult; + /// The tag of the provider this backend fronts. The service owns it from + /// `serve` on: it names the provider on every receipt and decides which + /// reasoning state is foreign. + fn provider(&self) -> &str; + /// Executes one admitted call. The service has already bound it to its + /// caller and recorded its replay claim. The call cannot show a foreign + /// reasoning item and builds the result's receipt itself. + fn execute(&self, call: &ProviderCall) -> TransportResult; } /// Bounds of one serve loop. The connector binary's own document owns where @@ -157,6 +162,7 @@ pub fn serve( } let service = Arc::new(Mutex::new(TransportService::open( replay_store, + backend.provider(), admissions, limits.max_expiry_skew_ms, )?)); @@ -230,7 +236,7 @@ fn execute_inference_frame( let response = match admission { TransportAdmission::Reply(response) => response, TransportAdmission::Execute(claim) => { - let result = backend.execute(claim.invocation()); + let result = backend.execute(claim.call()); service .lock() .map_err(|_| ServeError::ServicePoisoned)? @@ -328,7 +334,7 @@ mod tests { use crate::client::ConnectorClient; use crate::contract::{ FailureKind, Refusal, TransportDisposition, TransportEvent, TransportEventPayload, - TransportOutcome, TransportResult, + TransportInvocation, TransportOutcome, TransportResult, }; use crate::fixtures::{TestStore, caller, invocation_for, receipt, request}; @@ -360,17 +366,20 @@ mod tests { self.ready } - fn execute(&self, invocation: &TransportInvocation) -> TransportResult { + fn provider(&self) -> &str { + "fixture-sse" + } + + fn execute(&self, call: &ProviderCall) -> TransportResult { self.executions.fetch_add(1, Ordering::SeqCst); - TransportResult::transported( - invocation, + call.transported( vec![TransportEvent { sequence: 0, payload: TransportEventPayload::TextDelta { text: "done".to_string(), }, }], - receipt(invocation), + receipt(&fresh("sizing")).outcome, ) } } @@ -568,8 +577,13 @@ mod tests { // The wide completion goes straight through the service: serve holds // the replay store for as long as it runs, so a second serve cannot // open it while the first lives. - let mut service = - TransportService::open(&store.path, vec![caller(CALLER, KEY, 4)], 300_000).unwrap(); + let mut service = TransportService::open( + &store.path, + "fixture-sse", + vec![caller(CALLER, KEY, 4)], + 300_000, + ) + .unwrap(); let envelope = crate::envelope::encrypt_invocation(&invocation, &crate::fixtures::security(KEY)) .unwrap(); @@ -578,13 +592,8 @@ mod tests { else { panic!("a fresh request must execute"); }; - service - .complete( - *claim, - sized_result(&invocation, 150_000, "fixture-sse"), - 1 << 20, - ) - .unwrap(); + let wide = sized_result(claim.call(), 150_000); + service.complete(*claim, wide, 1 << 20).unwrap(); drop(service); let narrow = 140_000; @@ -684,7 +693,7 @@ mod tests { fn the_smallest_frame_also_carries_the_failure_that_stands_in_for_a_result() { let long = "x".repeat(crate::envelope::MAX_IDENTITY_BYTES); let plaintext = crate::contract::wire_encode(&crate::service::result_size_failure( - &long, &long, &long, [0xff; 32], [0xff; 32], + &long, &long, &long, [0xff; 32], [0xff; 32], 0, )) .unwrap() .len(); @@ -913,19 +922,13 @@ mod tests { /// Answers every invocation with one text event of a chosen size. struct SizedBackend { text_len: usize, - transport: String, executions: AtomicUsize, } impl SizedBackend { fn new(text_len: usize) -> Arc { - Self::with_transport(text_len, "fixture-sse".to_string()) - } - - fn with_transport(text_len: usize, transport: String) -> Arc { Arc::new(Self { text_len, - transport, executions: AtomicUsize::new(0), }) } @@ -936,28 +939,25 @@ mod tests { true } - fn execute(&self, invocation: &TransportInvocation) -> TransportResult { + fn provider(&self) -> &str { + "fixture-sse" + } + + fn execute(&self, call: &ProviderCall) -> TransportResult { self.executions.fetch_add(1, Ordering::SeqCst); - sized_result(invocation, self.text_len, &self.transport) + sized_result(call, self.text_len) } } - fn sized_result( - invocation: &TransportInvocation, - text_len: usize, - transport: &str, - ) -> TransportResult { - let mut receipt = receipt(invocation); - receipt.provider = transport.to_string(); - TransportResult::transported( - invocation, + fn sized_result(call: &ProviderCall, text_len: usize) -> TransportResult { + call.transported( vec![TransportEvent { sequence: 0, payload: TransportEventPayload::TextDelta { text: "y".repeat(text_len), }, }], - receipt, + receipt(&fresh("sizing")).outcome, ) } @@ -997,7 +997,25 @@ mod tests { /// The encoded size of a result carrying no text, for this request id. fn empty_result_len(request_id: &str) -> usize { - encoded_len(&sized_result(&fresh(request_id), 0, "fixture-sse")) + let store = TestStore::new(); + let mut service = TransportService::open( + &store.path, + "fixture-sse", + vec![caller(CALLER, KEY, 4)], + 300_000, + ) + .unwrap(); + let envelope = crate::envelope::encrypt_invocation( + &fresh(request_id), + &crate::fixtures::security(KEY), + ) + .unwrap(); + let TransportAdmission::Execute(claim) = + service.begin(&envelope, now_ms(), 1 << 20).unwrap() + else { + panic!("a fresh request must execute"); + }; + encoded_len(&sized_result(claim.call(), 0)) } #[test] @@ -1030,17 +1048,6 @@ mod tests { assert_eq!(executions, 1); } - #[test] - fn a_stand_in_failure_never_carries_the_backends_transport() { - let (first, second, executions) = call_twice( - SizedBackend::with_transport(FRAME, "t".repeat(FRAME)), - "request-transport", - ); - assert!(failed_result_size(&first)); - assert_eq!(first, second); - assert_eq!(executions, 1); - } - /// The largest result plaintext whose sealed frame fits at the worst encoding. fn widest_guaranteed_plaintext(base: usize) -> usize { (base..=FRAME) @@ -1093,7 +1100,13 @@ mod tests { let listener = TcpListener::bind("127.0.0.1:0").unwrap(); let endpoint = listener.local_addr().unwrap(); let service = Arc::new(Mutex::new( - TransportService::open(&store.path, vec![caller(CALLER, KEY, 4)], 300_000).unwrap(), + TransportService::open( + &store.path, + "fixture-sse", + vec![caller(CALLER, KEY, 4)], + 300_000, + ) + .unwrap(), )); let served = backend.clone(); thread::spawn(move || { diff --git a/crates/model-connector/src/service.rs b/crates/model-connector/src/service.rs index 157ab20..9a2ea2a 100644 --- a/crates/model-connector/src/service.rs +++ b/crates/model-connector/src/service.rs @@ -7,9 +7,9 @@ use sha2::{Digest, Sha256}; use zeroize::Zeroizing; use crate::contract::{ - ContractError, FailureKind, RECEIPT_SCHEMA_ID, RESULT_SCHEMA_ID, Refusal, TransportDisposition, - TransportInvocation, TransportKey, TransportOutcome, TransportReceipt, TransportResult, - canonical_invocation_bytes, require_id, + ContractError, FailureKind, InputItem, ProviderRequest, RECEIPT_SCHEMA_ID, RESULT_SCHEMA_ID, + Refusal, TransportDisposition, TransportEvent, TransportInvocation, TransportKey, + TransportOutcome, TransportReceipt, TransportResult, canonical_invocation_bytes, require_id, }; use crate::envelope::{ EnvelopeKind, MAX_IDENTITY_BYTES, ServiceError, TransportEnvelope, decrypt_invocation, @@ -106,15 +106,57 @@ struct ReplayRecord { state: ReplayState, } +/// What a backend is handed: the request with every foreign reasoning item +/// already removed, and the means to answer it. The caller's own invocation is +/// not held here at all, so no path from a call (Debug included) reaches +/// foreign reasoning state, and a backend cannot write the identity of its +/// receipt. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ProviderCall { + request: ProviderRequest, + receipt: TransportReceipt, +} + +impl ProviderCall { + /// The request as the provider may see it: foreign reasoning items absent, + /// every other item in the caller's order. + pub fn request(&self) -> &ProviderRequest { + &self.request + } + + /// How many foreign reasoning items the service removed from the request. + pub fn foreign_reasoning_dropped(&self) -> u32 { + self.receipt.foreign_reasoning_dropped + } + + /// The result of this call. The whole receipt (schema, ids, both digests, + /// model, provider tag and drop count) is derived here, so a backend + /// writes only the events and the outcome. + pub fn transported( + &self, + events: Vec, + outcome: TransportOutcome, + ) -> TransportResult { + TransportResult::transported( + events, + TransportReceipt { + outcome, + ..self.receipt.clone() + }, + ) + } +} + #[derive(Debug, Clone, PartialEq, Eq)] pub struct ExecutionClaim { + call: ProviderCall, invocation: TransportInvocation, invocation_sha256: [u8; 32], } impl ExecutionClaim { - pub fn invocation(&self) -> &TransportInvocation { - &self.invocation + pub fn call(&self) -> &ProviderCall { + &self.call } } @@ -129,15 +171,23 @@ pub struct TransportService { live: HashSet, replay_store: CultCache, max_expiry_skew_ms: u64, + provider: String, } impl TransportService { + /// `provider` is the tag of the backend this service fronts. The service + /// owns it from here on: it decides which reasoning state is foreign and + /// what provider every receipt names. pub fn open( replay_store_path: &Path, + provider: &str, admissions: impl IntoIterator, max_expiry_skew_ms: u64, ) -> Result { - if max_expiry_skew_ms == 0 { + if max_expiry_skew_ms == 0 + || require_id(provider, "provider").is_err() + || provider == STAND_IN_PROVIDER + { return Err(ServiceError::InvalidAdmission); } let mut callers = HashMap::new(); @@ -195,6 +245,7 @@ impl TransportService { live: HashSet::new(), replay_store, max_expiry_skew_ms, + provider: provider.to_string(), }) } @@ -242,6 +293,11 @@ impl TransportService { if invocation.request.max_output_tokens > caller.max_output_tokens { return self.reply_refusal(&invocation, Refusal::OutputBound, &security); } + let (request, foreign_reasoning_dropped) = + match partition_reasoning(&invocation.request, &self.provider) { + Ok(partition) => partition, + Err(refusal) => return self.reply_refusal(&invocation, refusal, &security), + }; let identity = RequestIdentity { caller_runtime_id: invocation.caller_runtime_id.clone(), @@ -262,7 +318,12 @@ impl TransportService { if fits_frame(response, max_frame_bytes)? { return Ok(TransportAdmission::Reply(response.clone())); } - let stand_in = self.seal_stand_in(&invocation, &security, max_frame_bytes)?; + let stand_in = self.seal_stand_in( + &invocation, + &security, + max_frame_bytes, + FailureKind::ResultSize, + )?; self.replay_store .put( replay_key(&identity), @@ -308,6 +369,24 @@ impl TransportService { .map_err(replay_store_error)?; self.live.insert(identity); Ok(TransportAdmission::Execute(Box::new(ExecutionClaim { + call: ProviderCall { + receipt: TransportReceipt { + schema_id: RECEIPT_SCHEMA_ID.to_string(), + request_id: invocation.request_id().to_string(), + caller_runtime_id: invocation.caller_runtime_id.clone(), + native_request_sha256: invocation.native_request_sha256, + provider_request_sha256: invocation.provider_request_sha256, + model: invocation.request.model.clone(), + provider: self.provider.clone(), + foreign_reasoning_dropped, + // Replaced by the backend's outcome in `transported`. + outcome: TransportOutcome::Failed { + kind: FailureKind::Provider, + message: String::new(), + }, + }, + request, + }, invocation, invocation_sha256, }))) @@ -324,16 +403,10 @@ impl TransportService { result: TransportResult, max_frame_bytes: usize, ) -> Result { - if !matches!( - &result.disposition, - TransportDisposition::Transported { .. } - ) { - return Err(ServiceError::InvalidCompletion); - } - result.validate_against(&claim.invocation)?; + let invocation = &claim.invocation; let identity = RequestIdentity { - caller_runtime_id: claim.invocation.caller_runtime_id.clone(), - request_id: claim.invocation.request_id().to_string(), + caller_runtime_id: invocation.caller_runtime_id.clone(), + request_id: invocation.request_id().to_string(), }; if !self.live.contains(&identity) { return Err(ServiceError::NoActiveRequest); @@ -353,17 +426,39 @@ impl TransportService { .get(&identity.caller_runtime_id) .ok_or(ServiceError::CallerNotAdmitted)?; let security = caller.security.clone(); - let mut response = encrypt_result(&result, &security)?; - if !fits_frame(&response, max_frame_bytes)? { - response = match self.seal_stand_in(&claim.invocation, &security, max_frame_bytes) { - Ok(stand_in) => stand_in, - Err(error) => { - // Nothing is recorded, but the caller's slot is not held - // by a request that can no longer finish. + // A result the service refuses (not a transported result, not bound to + // this invocation, or carrying a provider tag or drop count the call + // did not derive) is answered like an oversized one: a typed failure + // is recorded and the slot released, so the caller gets an answer and + // a retry replays it. + let admissible = self.admits_result(&result, &claim); + let mut response = if admissible { + encrypt_result(&result, &security)? + } else { + self.seal_stand_in( + invocation, + &security, + max_frame_bytes, + FailureKind::Provider, + ) + .inspect_err(|_| { + self.live.remove(&identity); + })? + }; + if admissible && !fits_frame(&response, max_frame_bytes)? { + // Nothing is recorded when even the stand-in cannot be carried, + // but the caller's slot is not held by a request that can no + // longer finish. + response = self + .seal_stand_in( + invocation, + &security, + max_frame_bytes, + FailureKind::ResultSize, + ) + .inspect_err(|_| { self.live.remove(&identity); - return Err(error); - } - }; + })?; } let caller = self .callers @@ -374,7 +469,7 @@ impl TransportService { request_id: identity.request_id.clone(), connection_key_epoch: caller.connection_key_epoch, invocation_sha256: claim.invocation_sha256, - expires_at_unix_ms: claim.invocation.expires_at_unix_ms, + expires_at_unix_ms: invocation.expires_at_unix_ms, state: ReplayState::Completed { response: response.clone(), }, @@ -392,14 +487,20 @@ impl TransportService { invocation: &TransportInvocation, security: &TransportKey, max_frame_bytes: usize, + kind: FailureKind, ) -> Result { + let (_, foreign_reasoning_dropped) = + partition_reasoning(&invocation.request, &self.provider) + .map_err(|_| ServiceError::InvalidCompletion)?; let stand_in = encrypt_result( - &result_size_failure( + &stand_in_failure( &invocation.caller_runtime_id, invocation.request_id(), &invocation.request.model, invocation.native_request_sha256, invocation.provider_request_sha256, + foreign_reasoning_dropped, + kind, ), security, )?; @@ -410,6 +511,18 @@ impl TransportService { } } + /// Whether `result` is the answer to this claim's call: transported, + /// bound to the caller's invocation, and carrying the provider tag and drop + /// count the service derived. + fn admits_result(&self, result: &TransportResult, claim: &ExecutionClaim) -> bool { + let TransportDisposition::Transported { receipt, .. } = &result.disposition else { + return false; + }; + result.validate_against(&claim.invocation).is_ok() + && receipt.provider == self.provider + && receipt.foreign_reasoning_dropped == claim.call.receipt.foreign_reasoning_dropped + } + fn reply_refusal( &self, invocation: &TransportInvocation, @@ -433,13 +546,41 @@ const STAND_IN_PROVIDER: &str = "model-connector"; /// The failed result that stands in for one whose sealed frame cannot be /// carried: the same shape a provider failure takes, with nothing a provider /// controls but the identities the invocation already bound. +#[cfg(test)] pub(crate) fn result_size_failure( caller_runtime_id: &str, request_id: &str, model: &str, native_request_sha256: [u8; 32], provider_request_sha256: [u8; 32], + foreign_reasoning_dropped: u32, ) -> TransportResult { + stand_in_failure( + caller_runtime_id, + request_id, + model, + native_request_sha256, + provider_request_sha256, + foreign_reasoning_dropped, + FailureKind::ResultSize, + ) +} + +/// The stand-in failure for `kind`: `ResultSize` for a result that cannot be +/// carried, `Provider` for one the service refused. +fn stand_in_failure( + caller_runtime_id: &str, + request_id: &str, + model: &str, + native_request_sha256: [u8; 32], + provider_request_sha256: [u8; 32], + foreign_reasoning_dropped: u32, + kind: FailureKind, +) -> TransportResult { + let message = match kind { + FailureKind::ResultSize => "provider result exceeded its byte bound", + _ => "the service refused the backend's result", + }; TransportResult { schema_id: RESULT_SCHEMA_ID.to_string(), request_id: request_id.to_string(), @@ -454,15 +595,50 @@ pub(crate) fn result_size_failure( provider_request_sha256, model: model.to_string(), provider: STAND_IN_PROVIDER.to_string(), + foreign_reasoning_dropped, outcome: TransportOutcome::Failed { - kind: FailureKind::ResultSize, - message: "provider result exceeded its byte bound".to_string(), + kind, + message: message.to_string(), }, }), }, } } +/// The one place foreignness is decided. A reasoning item of another provider +/// in the in-flight exchange (after the last user text; the whole input when +/// there is none) is refused, because dropping it would break the provider's +/// own tool-exchange rules. Earlier foreign items are removed and counted. +/// Foreignness is the provider tag alone: another model of this provider +/// passes through for the provider to judge. +fn partition_reasoning( + request: &ProviderRequest, + provider: &str, +) -> Result<(ProviderRequest, u32), Refusal> { + let in_flight_from = request + .input + .iter() + .rposition(|item| matches!(item, InputItem::UserText { .. })) + .map_or(0, |last_user_text| last_user_text + 1); + let mut input = Vec::with_capacity(request.input.len()); + let mut dropped = 0u32; + for (position, item) in request.input.iter().enumerate() { + if let InputItem::ReasoningState { provider: tag, .. } = item + && tag != provider + { + if position >= in_flight_from { + return Err(Refusal::ForeignReasoningState); + } + dropped += 1; + continue; + } + input.push(item.clone()); + } + let mut filtered = request.clone(); + filtered.input = input; + Ok((filtered, dropped)) +} + fn replay_key(identity: &RequestIdentity) -> String { let mut hasher = Sha256::new(); hasher.update((identity.caller_runtime_id.len() as u64).to_be_bytes()); @@ -509,6 +685,7 @@ mod tests { assert!(matches!( TransportService::open( &store.path, + "fixture-sse", [ caller("epiphany-yggdrasil", "shared-key", 1), caller("ghostlight-yggdrasil", "shared-key", 1), @@ -566,7 +743,6 @@ mod tests { .complete( *claim, TransportResult::transported( - &invocation, vec![TransportEvent { sequence: 0, payload: TransportEventPayload::ToolCall { @@ -710,6 +886,7 @@ mod tests { assert!(matches!( TransportService::open( &store.path, + "fixture-sse", [CallerAdmission::new( "epiphany-yggdrasil", "rotated-connection-key", @@ -818,7 +995,7 @@ mod tests { 32_768, ) .unwrap(); - TransportService::open(&store.path, [admission], 5_000) + TransportService::open(&store.path, "fixture-sse", [admission], 5_000) .unwrap() .begin(&envelope, 1_000, FRAME) }; @@ -887,7 +1064,15 @@ mod tests { fn restart_refuses_each_malformed_replay_record() { let control = TestStore::new(); seed(&control.path, &seeded_key(), |_| {}); - assert!(TransportService::open(&control.path, [caller(CALLER, KEY, 1)], 5_000).is_ok()); + assert!( + TransportService::open( + &control.path, + "fixture-sse", + [caller(CALLER, KEY, 1)], + 5_000 + ) + .is_ok() + ); type Edit = fn(&mut ReplayRecord); let cases: [(&str, Edit); 3] = [ @@ -905,7 +1090,12 @@ mod tests { seed(&store.path, &key, edit); assert!( matches!( - TransportService::open(&store.path, [caller(CALLER, KEY, 1)], 5_000), + TransportService::open( + &store.path, + "fixture-sse", + [caller(CALLER, KEY, 1)], + 5_000 + ), Err(ServiceError::InvalidReplayRecord) ), "{name}" @@ -921,16 +1111,7 @@ mod tests { let TransportAdmission::Execute(claim) = admission else { panic!("first invocation must execute"); }; - let result = TransportResult::transported( - claim.invocation(), - vec![TransportEvent { - sequence: 0, - payload: TransportEventPayload::TextDelta { - text: "done".to_string(), - }, - }], - receipt(claim.invocation()), - ); + let result = text_result(claim.call(), "done"); let mut forged = (*claim).clone(); forged.invocation_sha256 = [1; 32]; @@ -1071,22 +1252,15 @@ mod tests { } } - fn text_result( - invocation: &TransportInvocation, - text: &str, - transport: &str, - ) -> TransportResult { - let mut receipt = receipt(invocation); - receipt.provider = transport.to_string(); - TransportResult::transported( - invocation, + fn text_result(call: &ProviderCall, text: &str) -> TransportResult { + call.transported( vec![TransportEvent { sequence: 0, payload: TransportEventPayload::TextDelta { text: text.to_string(), }, }], - receipt, + receipt(&invocation_for(CALLER, "outcome")).outcome, ) } @@ -1095,7 +1269,7 @@ mod tests { let store = TestStore::new(); let mut service = open_service(&store, 8); let (claim, invocation) = claim_for(&mut service, "stand-in"); - let big = text_result(&invocation, &"y".repeat(4000), &"t".repeat(5000)); + let big = text_result(claim.call(), &"y".repeat(4000)); let response = service.complete(*claim, big, 2000).unwrap(); assert!(encode_result_frame(&response).unwrap().len() <= 2000); let stand_in = decrypt_result(&response, &security(KEY), &invocation).unwrap(); @@ -1107,6 +1281,7 @@ mod tests { &invocation.request.model, invocation.native_request_sha256, invocation.provider_request_sha256, + 0, ) ); let TransportDisposition::Transported { receipt, .. } = &stand_in.disposition else { @@ -1129,7 +1304,7 @@ mod tests { let store = TestStore::new(); let mut service = open_service(&store, 8); let (claim, invocation) = claim_for(&mut service, "fits"); - let small = text_result(&invocation, "done", "fixture-sse"); + let small = text_result(claim.call(), "done"); let response = service.complete(*claim, small.clone(), 1 << 20).unwrap(); assert_eq!( decrypt_result(&response, &security(KEY), &invocation).unwrap(), @@ -1142,7 +1317,7 @@ mod tests { let store = TestStore::new(); let mut service = open_service(&store, 1); let (claim, invocation) = claim_for(&mut service, "wedge"); - let big = text_result(&invocation, "y", "fixture-sse"); + let big = text_result(claim.call(), "y"); assert_eq!( service.complete(*claim, big, 10).unwrap_err(), ServiceError::FrameBound @@ -1169,7 +1344,7 @@ mod tests { let store = TestStore::new(); let mut service = open_service(&store, 8); let (claim, invocation) = claim_for(&mut service, "restart"); - let big = text_result(&invocation, &"y".repeat(4000), "fixture-sse"); + let big = text_result(claim.call(), &"y".repeat(4000)); let recorded = service.complete(*claim, big, FRAME).unwrap(); assert!(encode_result_frame(&recorded).unwrap().len() > 2000); drop(service); @@ -1200,6 +1375,7 @@ mod tests { &invocation.request.model, invocation.native_request_sha256, invocation.provider_request_sha256, + 0, ) ); // The stand-in is what is recorded now, byte for byte, at any frame. @@ -1217,7 +1393,7 @@ mod tests { let store = TestStore::new(); let mut service = open_service(&store, 8); let (claim, invocation) = claim_for(&mut service, "no-room"); - let big = text_result(&invocation, "y", "fixture-sse"); + let big = text_result(claim.call(), "y"); service.complete(*claim, big, FRAME).unwrap(); assert_eq!( service.begin(&raw_envelope(&invocation), 1_000, 10).err(), @@ -1230,7 +1406,7 @@ mod tests { let store = TestStore::new(); let mut service = open_service(&store, 8); let (claim, invocation) = claim_for(&mut service, "nothing-fits"); - let big = text_result(&invocation, "y", "fixture-sse"); + let big = text_result(claim.call(), "y"); assert_eq!( service.complete(*claim, big, 10).unwrap_err(), ServiceError::FrameBound @@ -1271,4 +1447,324 @@ mod tests { assert_eq!(contract_refusal(&other), Refusal::Malformed, "{other:?}"); } } + + fn user(text: &str) -> InputItem { + InputItem::UserText { + text: text.to_string(), + } + } + + fn state(provider: &str, model: &str, byte: u8) -> InputItem { + InputItem::ReasoningState { + provider: provider.to_string(), + model: model.to_string(), + state: vec![byte], + } + } + + fn admit_input( + service: &mut TransportService, + request_id: &str, + input: Vec, + ) -> (TransportAdmission, TransportInvocation) { + run(service, request_id, |request| request.input = input) + } + + #[test] + fn foreign_reasoning_in_flight_is_refused_and_never_executed() { + let store = TestStore::new(); + let mut service = open_service(&store, 1); + let (admission, invocation) = admit_input( + &mut service, + "in-flight", + vec![ + user("question"), + state("fixture-sse", "gpt-5.4", 1), + state("other-provider", "x", 2), + InputItem::ToolResult { + call_id: "call-1".to_string(), + output: "evidence".to_string(), + }, + ], + ); + assert_eq!( + refusal_of(admission, &invocation), + Refusal::ForeignReasoningState + ); + // Nothing was recorded or held: the caller's one slot is free and the + // same request id is not a replay. + let (admission, _) = admit_input(&mut service, "in-flight", vec![user("question")]); + assert!(matches!(admission, TransportAdmission::Execute(_))); + } + + #[test] + fn foreign_reasoning_with_no_user_text_is_in_flight() { + let store = TestStore::new(); + let mut service = open_service(&store, 4); + let (admission, invocation) = admit_input( + &mut service, + "no-user-text", + vec![ + state("other-provider", "x", 2), + InputItem::AssistantText { + text: "earlier".to_string(), + }, + ], + ); + assert_eq!( + refusal_of(admission, &invocation), + Refusal::ForeignReasoningState + ); + // The boundary is the position after the last user text: a foreign item + // directly after it is in flight too. + let (admission, invocation) = admit_input( + &mut service, + "just-after", + vec![user("a"), state("other-provider", "x", 2)], + ); + assert_eq!( + refusal_of(admission, &invocation), + Refusal::ForeignReasoningState + ); + } + + #[test] + fn foreign_reasoning_in_completed_turns_is_dropped_and_counted() { + let store = TestStore::new(); + let mut service = open_service(&store, 4); + let own = state("fixture-sse", "gpt-5.4", 3); + let tool_call = InputItem::ToolCall { + call_id: "call-1".to_string(), + name: "read_source".to_string(), + arguments: "{}".to_string(), + }; + let (admission, invocation) = admit_input( + &mut service, + "completed-turns", + vec![ + state("other-provider", "x", 1), + user("first"), + own.clone(), + state("third-provider", "y", 2), + tool_call.clone(), + user("second"), + tool_call.clone(), + own.clone(), + ], + ); + let TransportAdmission::Execute(claim) = admission else { + panic!("foreign state in completed turns must not stop the request"); + }; + assert_eq!( + claim.call().request().input, + vec![ + user("first"), + own.clone(), + tool_call.clone(), + user("second"), + tool_call, + own, + ] + ); + assert_eq!(claim.call().foreign_reasoning_dropped(), 2); + // The caller's digest binds what the caller sent, not the filtered view, + // and the completion below is accepted against it. + assert_ne!( + invocation.provider_request_sha256, + provider_request_sha256(claim.call().request()).unwrap() + ); + let result = text_result(claim.call(), "done"); + let response = service.complete(*claim, result, FRAME).unwrap(); + let TransportDisposition::Transported { receipt, .. } = + decrypt_result(&response, &security(KEY), &invocation) + .unwrap() + .disposition + else { + panic!("completed"); + }; + assert_eq!(receipt.foreign_reasoning_dropped, 2); + } + + #[test] + fn same_provider_other_model_reasoning_passes_through() { + let store = TestStore::new(); + let mut service = open_service(&store, 4); + let other_model = state("fixture-sse", "some-other-model", 9); + let (admission, _) = admit_input( + &mut service, + "other-model", + vec![other_model.clone(), user("first"), other_model.clone()], + ); + let TransportAdmission::Execute(claim) = admission else { + panic!("an own-provider item is never foreign"); + }; + assert_eq!( + claim.call().request().input, + vec![other_model.clone(), user("first"), other_model] + ); + assert_eq!(claim.call().foreign_reasoning_dropped(), 0); + } + + #[test] + fn open_refuses_a_blank_or_stand_in_provider_tag() { + let store = TestStore::new(); + for tag in ["", " ", STAND_IN_PROVIDER] { + assert!( + matches!( + TransportService::open(&store.path, tag, [caller(CALLER, KEY, 1)], 5_000), + Err(ServiceError::InvalidAdmission) + ), + "{tag:?}" + ); + } + assert!( + TransportService::open(&store.path, "fixture-sse", [caller(CALLER, KEY, 1)], 5_000) + .is_ok() + ); + } + + #[test] + fn complete_answers_a_hand_built_receipt_with_another_provider_or_count_as_a_typed_failure() { + let store = TestStore::new(); + let mut service = open_service(&store, 1); + let input = vec![state("other-provider", "x", 1), user("first")]; + let tamper_edits: [fn(&mut TransportReceipt); 4] = [ + |receipt| receipt.provider = "other-provider".to_string(), + |receipt| receipt.provider = STAND_IN_PROVIDER.to_string(), + |receipt| receipt.foreign_reasoning_dropped = 2, + |receipt| receipt.foreign_reasoning_dropped = 0, + ]; + for (index, edit) in tamper_edits.into_iter().enumerate() { + let id = format!("hand-built-{index}"); + let (admission, invocation) = admit_input(&mut service, &id, input.clone()); + let TransportAdmission::Execute(claim) = admission else { + panic!("a refused completion must have released the slot"); + }; + assert_eq!(claim.call().foreign_reasoning_dropped(), 1); + let mut bad = text_result(claim.call(), "done"); + let TransportDisposition::Transported { receipt, .. } = &mut bad.disposition else { + unreachable!(); + }; + edit(receipt); + let response = service.complete(*claim, bad, FRAME).unwrap(); + let answer = decrypt_result(&response, &security(KEY), &invocation).unwrap(); + let TransportDisposition::Transported { events, receipt } = &answer.disposition else { + panic!("a typed failure is transported"); + }; + assert!(events.is_empty()); + assert_eq!(receipt.provider, STAND_IN_PROVIDER); + assert_eq!(receipt.foreign_reasoning_dropped, 1); + assert!(matches!( + receipt.outcome, + TransportOutcome::Failed { + kind: FailureKind::Provider, + .. + } + )); + // A retry of the same id replays the failure, not ReplayConflict. + let TransportAdmission::Reply(replay) = service + .begin(&raw_envelope(&invocation), 1_000, FRAME) + .unwrap() + else { + panic!("a retry must replay the recorded failure"); + }; + assert_eq!(replay, response); + } + } + + #[test] + fn a_result_that_fails_validation_is_answered_as_a_typed_failure_and_releases_the_slot() { + let store = TestStore::new(); + let mut service = open_service(&store, 1); + let (claim, invocation) = claim_for(&mut service, "invalid-result"); + let mut bad = text_result(claim.call(), "done"); + bad.request_id = "someone-elses".to_string(); + let response = service.complete(*claim, bad, FRAME).unwrap(); + let answer = decrypt_result(&response, &security(KEY), &invocation).unwrap(); + let TransportDisposition::Transported { receipt, .. } = &answer.disposition else { + panic!("a typed failure is transported"); + }; + assert!(matches!( + receipt.outcome, + TransportOutcome::Failed { + kind: FailureKind::Provider, + .. + } + )); + // The caller's only slot is free for the next request. + let (next, _) = claim_for(&mut service, "next"); + drop(next); + } + + #[test] + fn a_provider_call_formats_without_foreign_reasoning_state() { + let store = TestStore::new(); + let mut service = open_service(&store, 1); + let canary = "canary-foreign-provider-tag"; + let foreign = InputItem::ReasoningState { + provider: canary.to_string(), + model: canary.to_string(), + state: vec![0xC7, 0xC8, 0xC9, 0xCA], + }; + let (admission, _) = admit_input(&mut service, "debug-leak", vec![foreign, user("first")]); + let TransportAdmission::Execute(claim) = admission else { + panic!("expected an execution claim"); + }; + assert_eq!(claim.call().foreign_reasoning_dropped(), 1); + for text in [ + format!("{:?}", claim.call()), + format!("{:#?}", claim.call()), + ] { + assert!(!text.contains(canary), "{text}"); + assert!(!text.contains("199, 200, 201, 202"), "{text}"); + } + } + + #[test] + fn stand_in_carries_the_drop_count() { + let store = TestStore::new(); + let mut service = open_service(&store, 4); + let input = vec![state("other-provider", "x", 1), user("first")]; + let (admission, invocation) = admit_input(&mut service, "stand-in-count", input.clone()); + let TransportAdmission::Execute(claim) = admission else { + panic!("expected an execution claim"); + }; + let big = text_result(claim.call(), &"y".repeat(4000)); + let response = service.complete(*claim, big, 2000).unwrap(); + let expected = result_size_failure( + CALLER, + "stand-in-count", + &invocation.request.model, + invocation.native_request_sha256, + invocation.provider_request_sha256, + 1, + ); + assert_eq!( + decrypt_result(&response, &security(KEY), &invocation).unwrap(), + expected + ); + + // A recorded wide result replayed at a smaller frame keeps the count too. + let (admission, invocation) = admit_input(&mut service, "replay-count", input); + let TransportAdmission::Execute(claim) = admission else { + panic!("expected an execution claim"); + }; + let big = text_result(claim.call(), &"y".repeat(4000)); + service.complete(*claim, big, FRAME).unwrap(); + let TransportAdmission::Reply(smaller) = service + .begin(&raw_envelope(&invocation), 1_000, 2000) + .unwrap() + else { + panic!("expected a replay"); + }; + let TransportDisposition::Transported { receipt, .. } = + decrypt_result(&smaller, &security(KEY), &invocation) + .unwrap() + .disposition + else { + panic!("expected the stand-in"); + }; + assert_eq!(receipt.provider, STAND_IN_PROVIDER); + assert_eq!(receipt.foreign_reasoning_dropped, 1); + } } diff --git a/crates/model-connector/tests/fixtures/wire_v3/README.md b/crates/model-connector/tests/fixtures/wire_v3/README.md index 918df33..2751c8f 100644 --- a/crates/model-connector/tests/fixtures/wire_v3/README.md +++ b/crates/model-connector/tests/fixtures/wire_v3/README.md @@ -5,7 +5,8 @@ produced by the kit at the revision that added them. They pin drift; the types i `src/contract.rs` own the shape, and `schemas/` publishes it. - `provider_request.mp`, `transport_invocation.mp`, `transport_result_transported.mp`, - `transport_result_refused.mp`, `transport_receipt_failed.mp`, `transport_envelope.mp`: + `transport_result_refused.mp`, `transport_receipt_failed.mp`, `transport_envelope.mp`, + `input_reasoning_state.mp` (an `InputItem::ReasoningState`, its state as MessagePack `bin`): the plaintext documents. - `carriage_invocation.frame`: a `cultnet.operation_request.v0` for service `gamecult.model.connector`, operation `invoke`, whose payload is the base64 of an envelope diff --git a/crates/model-connector/tests/fixtures/wire_v3/input_reasoning_state.mp b/crates/model-connector/tests/fixtures/wire_v3/input_reasoning_state.mp new file mode 100644 index 0000000..c6eae0c --- /dev/null +++ b/crates/model-connector/tests/fixtures/wire_v3/input_reasoning_state.mp @@ -0,0 +1 @@ +�®ReasoningStateƒ¨provider²anthropic-messages¥model¯claude-opus-4-5¥stateÄ€€�‚ƒ„…†‡ˆ‰Š‹Œ�Ž��‘’“”•–—˜™š›œ�žŸ ¡¢£¤¥¦§¨©ª«¬­®¯°±²³´µ¶·¸¹º»¼½¾¿ÀÁÂÃÄÅÆÇÈÉÊËÌÍÎÏÐÑÒÓÔÕÖרÙÚÛÜÝÞßàáâãäåæçèéêëìíîïðñòóôõö÷øùúûüýþÿ \ No newline at end of file diff --git a/crates/model-connector/tests/fixtures/wire_v3/transport_receipt_failed.mp b/crates/model-connector/tests/fixtures/wire_v3/transport_receipt_failed.mp index 4e0f1b1e9fd852297b60da82763d620e0cd9e8d0..0c5feb28476fc1102939f7a750b06510305c5243 100644 GIT binary patch delta 42 xcmaFH)X2=(xsg$hQF>=ueo<;>dR}}{YGQGIUS?i;d`eM%K|yK?(`0!@cK|~&4)g#3 delta 14 VcmZon;k=;`X~?CtLF z@bU8V^!4`l`1$(#{Qds_f~a$4adl;GV`T!7!$<*%vkU()).unwrap(); + assert!(schema.get("oneOf").is_none(), "{schema}"); + assert_eq!(schema["type"], "string"); + let variants: Vec<&str> = schema["enum"] + .as_array() + .expect("a closed set of strings") + .iter() + .map(|value| value.as_str().unwrap()) + .collect(); + assert_eq!(variants.len(), 12); + assert!(variants.contains(&"ForeignReasoningState")); +} diff --git a/crates/model-connector/tests/reasoning_state.rs b/crates/model-connector/tests/reasoning_state.rs new file mode 100644 index 0000000..559bd2f --- /dev/null +++ b/crates/model-connector/tests/reasoning_state.rs @@ -0,0 +1,348 @@ +//! The reasoning-state rules through a live serve loop and the public surface +//! only: what a backend is shown, what a caller gets back and what it replays. +#![cfg(feature = "daemon")] + +use std::net::TcpListener; +use std::path::PathBuf; +use std::sync::atomic::{AtomicUsize, Ordering}; +use std::sync::{Arc, Mutex}; +use std::thread; +use std::time::{Duration, SystemTime, UNIX_EPOCH}; + +use model_connector::*; + +const CALLER: &str = "reasoning-caller"; +const KEY: &str = "reasoning-distinct-test-key"; +const PROVIDER: &str = "anthropic-messages"; +const FRAME: usize = 256 * 1024; + +/// Answers every call with fixed events and records what it was shown. +struct RecordingBackend { + events: Vec, + executions: AtomicUsize, + seen: Mutex>, +} + +impl RecordingBackend { + fn new(events: Vec) -> Arc { + Arc::new(Self { + events, + executions: AtomicUsize::new(0), + seen: Mutex::new(Vec::new()), + }) + } + + fn seen(&self) -> Vec { + self.seen.lock().unwrap().clone() + } +} + +impl ProviderBackend for RecordingBackend { + fn ready(&self) -> bool { + true + } + + fn provider(&self) -> &str { + PROVIDER + } + + fn execute(&self, call: &ProviderCall) -> TransportResult { + self.executions.fetch_add(1, Ordering::SeqCst); + self.seen.lock().unwrap().push(call.request().clone()); + call.transported( + self.events + .iter() + .cloned() + .enumerate() + .map(|(sequence, payload)| TransportEvent { + sequence: sequence as u64, + payload, + }) + .collect(), + TransportOutcome::Completed { + provider_response_id: Some("msg-1".to_string()), + stop_reason: StopReason::ToolUse, + usage: Usage { + input_tokens: 1, + cache_read_tokens: 0, + cache_write_tokens: 0, + output_tokens: 1, + reasoning_tokens: None, + }, + }, + ) + } +} + +fn now_ms() -> u64 { + SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap() + .as_millis() as u64 +} + +/// A running serve loop and a client for it. +fn start(backend: Arc) -> ConnectorClient { + start_with(backend, 4) +} + +/// As `start`, with the caller's concurrent-request limit. +fn start_with(backend: Arc, max_concurrent: usize) -> ConnectorClient { + static SEQUENCE: AtomicUsize = AtomicUsize::new(0); + let root: PathBuf = std::env::temp_dir().join(format!( + "model-connector-reasoning-{}-{}", + std::process::id(), + SEQUENCE.fetch_add(1, Ordering::Relaxed) + )); + std::fs::create_dir_all(&root).unwrap(); + let store = root.join("replay.cc"); + let listener = TcpListener::bind("127.0.0.1:0").unwrap(); + let endpoint = listener.local_addr().unwrap(); + let admission = CallerAdmission::new( + CALLER, + KEY, + 1, + ["claude-opus-4-5".to_string()], + max_concurrent, + 64 * 1024, + 32_768, + ) + .unwrap(); + let limits = ServeLimits { + max_frame_bytes: FRAME, + max_connections: 8, + socket_timeout_ms: 5_000, + max_expiry_skew_ms: 300_000, + }; + thread::spawn(move || serve(listener, limits, vec![admission], &store, backend)); + ConnectorClient::new(endpoint, KEY, FRAME, Some(Duration::from_secs(5))).unwrap() +} + +fn invocation(request_id: &str, input: Vec) -> TransportInvocation { + let mut request = ProviderRequest::new( + request_id, + "conversation-1", + "claude-opus-4-5", + "Return the typed result.", + 4_096, + ); + request.input = input; + TransportInvocation::new(CALLER, now_ms() + 60_000, [7; 32], request).unwrap() +} + +fn user(text: &str) -> InputItem { + InputItem::UserText { + text: text.to_string(), + } +} + +fn state(provider: &str, byte: u8) -> InputItem { + InputItem::ReasoningState { + provider: provider.to_string(), + model: "claude-opus-4-5".to_string(), + state: vec![0x80 | byte, byte], + } +} + +fn receipt_of(result: &TransportResult) -> &TransportReceipt { + let TransportDisposition::Transported { receipt, .. } = &result.disposition else { + panic!( + "expected a transported result, got {:?}", + result.disposition + ); + }; + receipt +} + +#[test] +fn foreign_reasoning_in_flight_is_refused_and_never_executed() { + let backend = RecordingBackend::new(vec![TransportEventPayload::TextDelta { + text: "done".to_string(), + }]); + let client = start(backend.clone()); + let result = client + .execute(&invocation( + "foreign-in-flight", + vec![ + user("question"), + InputItem::ToolCall { + call_id: "call-1".to_string(), + name: "read_source".to_string(), + arguments: "{}".to_string(), + }, + state("another-provider", 1), + InputItem::ToolResult { + call_id: "call-1".to_string(), + output: "evidence".to_string(), + }, + ], + )) + .unwrap(); + assert_eq!( + result.disposition, + TransportDisposition::Refused(Refusal::ForeignReasoningState) + ); + assert_eq!(backend.executions.load(Ordering::SeqCst), 0); +} + +#[test] +fn a_backend_never_sees_foreign_reasoning_state() { + let backend = RecordingBackend::new(vec![TransportEventPayload::TextDelta { + text: "done".to_string(), + }]); + let client = start(backend.clone()); + let own = state(PROVIDER, 2); + let result = client + .execute(&invocation( + "foreign-earlier", + vec![ + state("another-provider", 1), + user("first"), + own.clone(), + state("a-third-provider", 3), + user("second"), + ], + )) + .unwrap(); + assert_eq!(receipt_of(&result).foreign_reasoning_dropped, 2); + assert_eq!(receipt_of(&result).provider, PROVIDER); + assert_eq!( + backend.seen()[0].input, + vec![user("first"), own, user("second")] + ); +} + +#[test] +fn replayed_transcript_reaches_the_same_provider_verbatim() { + let thinking = vec![0x80, 0xfe, 0x00, 0xff]; + let backend = RecordingBackend::new(vec![ + TransportEventPayload::TextDelta { + text: "let me ".to_string(), + }, + TransportEventPayload::TextDelta { + text: "look".to_string(), + }, + TransportEventPayload::ReasoningState { + state: thinking.clone(), + }, + TransportEventPayload::ToolCall { + call_id: "call-1".to_string(), + name: "read_source".to_string(), + arguments: "{}".to_string(), + }, + ]); + let client = start(backend.clone()); + let first = client + .execute(&invocation("turn-1", vec![user("question")])) + .unwrap(); + let added = first.transcript_items(); + assert_eq!( + added, + vec![ + InputItem::AssistantText { + text: "let me look".to_string() + }, + InputItem::ReasoningState { + provider: PROVIDER.to_string(), + model: "claude-opus-4-5".to_string(), + state: thinking, + }, + InputItem::ToolCall { + call_id: "call-1".to_string(), + name: "read_source".to_string(), + arguments: "{}".to_string(), + }, + ] + ); + + // The caller appends the items untouched and answers the tool call; the + // next call is in flight on the same provider and is admitted as it is. + let mut next = vec![user("question")]; + next.extend(added.clone()); + next.push(InputItem::ToolResult { + call_id: "call-1".to_string(), + output: "evidence".to_string(), + }); + let second = client.execute(&invocation("turn-2", next.clone())).unwrap(); + assert_eq!(receipt_of(&second).foreign_reasoning_dropped, 0); + assert_eq!(backend.seen()[1].input, next); +} + +/// A backend that answers with a receipt it has edited after the service +/// derived it. +struct TamperingBackend { + edit: fn(&mut TransportReceipt), +} + +impl ProviderBackend for TamperingBackend { + fn ready(&self) -> bool { + true + } + + fn provider(&self) -> &str { + PROVIDER + } + + fn execute(&self, call: &ProviderCall) -> TransportResult { + let mut result = call.transported( + vec![TransportEvent { + sequence: 0, + payload: TransportEventPayload::TextDelta { + text: "done".to_string(), + }, + }], + TransportOutcome::Completed { + provider_response_id: None, + stop_reason: StopReason::EndTurn, + usage: Usage { + input_tokens: 1, + cache_read_tokens: 0, + cache_write_tokens: 0, + output_tokens: 1, + reasoning_tokens: None, + }, + }, + ); + let TransportDisposition::Transported { receipt, .. } = &mut result.disposition else { + unreachable!(); + }; + (self.edit)(receipt); + result + } +} + +#[test] +fn a_refused_completion_reaches_the_caller_as_a_typed_failure_and_frees_the_slot() { + let edits: [fn(&mut TransportReceipt); 2] = [ + |receipt| receipt.provider = "another-provider".to_string(), + |receipt| receipt.foreign_reasoning_dropped += 1, + ]; + for edit in edits { + // One concurrent request: a wedged slot would refuse the next id. + let client = start_with(Arc::new(TamperingBackend { edit }), 1); + let first = invocation("tampered", vec![user("question")]); + let answer = client.execute(&first).unwrap(); + let TransportDisposition::Transported { events, receipt } = &answer.disposition else { + panic!("expected a typed failure, got {:?}", answer.disposition); + }; + assert!(events.is_empty()); + assert!(matches!( + receipt.outcome, + TransportOutcome::Failed { + kind: FailureKind::Provider, + .. + } + )); + // The same id replays that failure rather than conflicting. + assert_eq!(client.execute(&first).unwrap(), answer); + // The next id is not refused for capacity. + let next = client + .execute(&invocation("tampered-next", vec![user("again")])) + .unwrap(); + assert!( + !matches!(next.disposition, TransportDisposition::Refused(_)), + "{:?}", + next.disposition + ); + } +} diff --git a/crates/model-connector/tests/wire_golden_v3.rs b/crates/model-connector/tests/wire_golden_v3.rs index 5f91513..dddf9ef 100644 --- a/crates/model-connector/tests/wire_golden_v3.rs +++ b/crates/model-connector/tests/wire_golden_v3.rs @@ -94,6 +94,7 @@ fn receipt(outcome: TransportOutcome) -> TransportReceipt { provider_request_sha256: invocation.provider_request_sha256, model: invocation.request.model.clone(), provider: "anthropic-messages".to_string(), + foreign_reasoning_dropped: 2, outcome, } } @@ -118,26 +119,46 @@ fn wire_golden_v3() { assert_wire("provider_request.mp", &invocation.request); assert_wire("transport_invocation.mp", &invocation); - let transported = TransportResult::transported( - &invocation, - vec![ - TransportEvent { - sequence: 0, - payload: TransportEventPayload::TextDelta { - text: "thinking".to_string(), - }, - }, - TransportEvent { - sequence: 1, - payload: TransportEventPayload::ToolCall { - call_id: "call-2".to_string(), - name: "read_source".to_string(), - arguments: "{}".to_string(), - }, - }, - ], - receipt(completed()), + // The reasoning bytes are all >= 0x80 so a byte-array encoding cannot pass + // for MessagePack bin. + let reasoning = || (0x80..=0xff_u8).collect::>(); + assert_wire( + "input_reasoning_state.mp", + &InputItem::ReasoningState { + provider: "anthropic-messages".to_string(), + model: "claude-opus-4-5".to_string(), + state: reasoning(), + }, ); + + let transported = TransportResult { + schema_id: RESULT_SCHEMA_ID.to_string(), + request_id: invocation.request_id().to_string(), + caller_runtime_id: CALLER.to_string(), + disposition: TransportDisposition::Transported { + events: vec![ + TransportEvent { + sequence: 0, + payload: TransportEventPayload::TextDelta { + text: "thinking".to_string(), + }, + }, + TransportEvent { + sequence: 1, + payload: TransportEventPayload::ReasoningState { state: reasoning() }, + }, + TransportEvent { + sequence: 2, + payload: TransportEventPayload::ToolCall { + call_id: "call-2".to_string(), + name: "read_source".to_string(), + arguments: "{}".to_string(), + }, + }, + ], + receipt: Box::new(receipt(completed())), + }, + }; assert_eq!(transported.validate_against(&invocation), Ok(())); assert_wire("transport_result_transported.mp", &transported); @@ -212,3 +233,12 @@ fn the_golden_carriage_frame_decrypts_and_validates_under_the_fixed_key() { assert_eq!(decrypted, invocation); assert_eq!(decrypted.validate(1_799_999_999_000, 5_000), Ok(())); } + +/// The reasoning bytes travel as MessagePack `bin` (0xc4 length marker), never +/// as an array of integers. +#[test] +fn reasoning_state_is_messagepack_bin() { + let golden = std::fs::read(fixtures().join("input_reasoning_state.mp")).unwrap(); + let marker = golden.windows(3).any(|window| window == [0xc4, 0x80, 0x80]); + assert!(marker, "state must be a bin 8 of 128 bytes starting 0x80"); +} diff --git a/schemas/provider_request.json b/schemas/provider_request.json index d4fb346..0ecf545 100644 --- a/schemas/provider_request.json +++ b/schemas/provider_request.json @@ -186,6 +186,38 @@ "required": [ "ToolResult" ] + }, + { + "description": "One provider reasoning block, opaque to every caller: `state` is the\nconnector's own encoding of the whole block. Callers replay it\nverbatim in transcript order and never inspect or branch on it; only\nthe connector of `provider` reads it, and the service drops or refuses\nthe state of any other provider before a backend sees the request.", + "type": "object", + "properties": { + "ReasoningState": { + "type": "object", + "properties": { + "model": { + "type": "string" + }, + "provider": { + "type": "string" + }, + "state": { + "description": "MessagePack bin: raw bytes, never an array of integers", + "type": "string", + "format": "binary" + } + }, + "additionalProperties": false, + "required": [ + "provider", + "model", + "state" + ] + } + }, + "additionalProperties": false, + "required": [ + "ReasoningState" + ] } ] }, diff --git a/schemas/transport_envelope.json b/schemas/transport_envelope.json index 1983423..69a97d3 100644 --- a/schemas/transport_envelope.json +++ b/schemas/transport_envelope.json @@ -7,7 +7,7 @@ "type": "string" }, "ciphertext": { - "description": "MessagePack bin: the raw ciphertext bytes, never an array of integers", + "description": "MessagePack bin: raw bytes, never an array of integers", "type": "string", "format": "binary" }, diff --git a/schemas/transport_invocation.json b/schemas/transport_invocation.json index 9e899b1..5435b2e 100644 --- a/schemas/transport_invocation.json +++ b/schemas/transport_invocation.json @@ -154,6 +154,38 @@ "required": [ "ToolResult" ] + }, + { + "description": "One provider reasoning block, opaque to every caller: `state` is the\nconnector's own encoding of the whole block. Callers replay it\nverbatim in transcript order and never inspect or branch on it; only\nthe connector of `provider` reads it, and the service drops or refuses\nthe state of any other provider before a backend sees the request.", + "type": "object", + "properties": { + "ReasoningState": { + "type": "object", + "properties": { + "model": { + "type": "string" + }, + "provider": { + "type": "string" + }, + "state": { + "description": "MessagePack bin: raw bytes, never an array of integers", + "type": "string", + "format": "binary" + } + }, + "additionalProperties": false, + "required": [ + "provider", + "model", + "state" + ] + } + }, + "additionalProperties": false, + "required": [ + "ReasoningState" + ] } ] }, diff --git a/schemas/transport_receipt.json b/schemas/transport_receipt.json index 68916ed..722a42a 100644 --- a/schemas/transport_receipt.json +++ b/schemas/transport_receipt.json @@ -6,6 +6,12 @@ "caller_runtime_id": { "type": "string" }, + "foreign_reasoning_dropped": { + "description": "How many foreign `ReasoningState` items the service removed from the\nrequest before the backend saw it.", + "type": "integer", + "format": "uint32", + "minimum": 0 + }, "model": { "type": "string" }, @@ -53,6 +59,7 @@ "provider_request_sha256", "model", "provider", + "foreign_reasoning_dropped", "outcome" ], "$defs": { diff --git a/schemas/transport_result.json b/schemas/transport_result.json index ceb1e42..acdf6b6 100644 --- a/schemas/transport_result.json +++ b/schemas/transport_result.json @@ -50,7 +50,8 @@ "InFlight", "Indeterminate", "ReplayConflict", - "Malformed" + "Malformed", + "ForeignReasoningState" ] }, "StopReason": { @@ -178,6 +179,30 @@ "required": [ "ToolCall" ] + }, + { + "description": "One reasoning block of the provider that answered. It carries no\nprovider or model: the receipt names them, so a backend cannot tag its\nown state.", + "type": "object", + "properties": { + "ReasoningState": { + "type": "object", + "properties": { + "state": { + "description": "MessagePack bin: raw bytes, never an array of integers", + "type": "string", + "format": "binary" + } + }, + "additionalProperties": false, + "required": [ + "state" + ] + } + }, + "additionalProperties": false, + "required": [ + "ReasoningState" + ] } ] }, @@ -247,6 +272,12 @@ "caller_runtime_id": { "type": "string" }, + "foreign_reasoning_dropped": { + "description": "How many foreign `ReasoningState` items the service removed from the\nrequest before the backend saw it.", + "type": "integer", + "format": "uint32", + "minimum": 0 + }, "model": { "type": "string" }, @@ -294,6 +325,7 @@ "provider_request_sha256", "model", "provider", + "foreign_reasoning_dropped", "outcome" ] }, From e0c0832ae0248e9eeaa8fabef86638b4fcb7253a Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Wed, 28 Oct 2026 18:47:06 +0100 Subject: [PATCH 26/69] connector-spend (B3c): reserve-then-settle spend in the replay ledger, one charge owner, a windowed per-caller ceiling (squash of eureka-body/connector-spend 1d51e71..45d0927) The reservation and the replay claim are one write in the connector's replay store; a failed ledger write runs nothing. Amounts are integer micro-USD with checked arithmetic, and the service derives every cost from usage and the configured price. One function, ProviderCall::charge, decides the charge: a result bound to its request and caller pays its true cost with a truthful overrun, anything else pays the reservation (ruling spend-bills-on-identity-not-validity). Each caller has a spend ceiling over fixed epoch-aligned windows in its root-owned config (connector-spend-caller-window-ceiling); the admission window is monotonic, so a clock stepping back cannot reopen budget, and a clock-locked caller is refused ClockBehindAdmissionWindow (spend-window-clock-ahead-fails-closed). Refusals CostBound, SpendCeiling and ClockBehindAdmissionWindow are typed. Spec eureka-body:cut_spec:cut-connector-spend.r1; reports h1-h5; Soul s5 holds (verdict eureka-body:verdict:cut-connector-spend.s5). Co-Authored-By: Claude Opus 5.5 --- Cargo.lock | 1 + crates/model-connector/Cargo.toml | 3 + crates/model-connector/src/contract.rs | 117 +- crates/model-connector/src/envelope.rs | 2 + crates/model-connector/src/fixtures.rs | 122 +- crates/model-connector/src/serve.rs | 109 +- crates/model-connector/src/service.rs | 1957 ++++++++++++++++- .../wire_v3/carriage_invocation.frame | 2 +- .../fixtures/wire_v3/transport_invocation.mp | Bin 799 -> 823 bytes .../wire_v3/transport_receipt_failed.mp | Bin 385 -> 517 bytes .../wire_v3/transport_result_transported.mp | Bin 914 -> 953 bytes .../tests/published_schemas.rs | 5 +- .../model-connector/tests/reasoning_state.rs | 23 +- .../model-connector/tests/wire_golden_v3.rs | 11 +- schemas/transport_invocation.json | 7 + schemas/transport_receipt.json | 27 +- schemas/transport_result.json | 32 +- 17 files changed, 2342 insertions(+), 76 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 3c3ff8d..a3cbdf7 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -490,6 +490,7 @@ name = "model-connector" version = "0.1.0" dependencies = [ "aes-gcm", + "anyhow", "base64", "cultcache-rs", "cultnet-rs", diff --git a/crates/model-connector/Cargo.toml b/crates/model-connector/Cargo.toml index c54547b..33d139f 100644 --- a/crates/model-connector/Cargo.toml +++ b/crates/model-connector/Cargo.toml @@ -26,3 +26,6 @@ serde_json = "1" sha2 = "0.10" thiserror = "2" zeroize = "1" + +[dev-dependencies] +anyhow = "1" diff --git a/crates/model-connector/src/contract.rs b/crates/model-connector/src/contract.rs index 3062fe4..f22b8f9 100644 --- a/crates/model-connector/src/contract.rs +++ b/crates/model-connector/src/contract.rs @@ -190,9 +190,10 @@ pub enum FailureKind { Provider, } -/// Token accounting for one completed request. Cache reads and writes are -/// counted apart from plain input; `reasoning_tokens` is absent when the -/// provider does not report it. +/// Token accounting for one request, as the backend reports it; the service +/// prices it. Cache reads and writes are counted apart from plain input. +/// `output_tokens` includes reasoning tokens: `reasoning_tokens` is a detail +/// (absent when the provider does not report it) and is never priced apart. #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] #[serde(deny_unknown_fields)] pub struct Usage { @@ -251,6 +252,10 @@ pub struct TransportInvocation { pub expires_at_unix_ms: u64, pub native_request_sha256: [u8; 32], pub provider_request_sha256: [u8; 32], + /// The most this request may cost, in micro-USD: the caller's own bound + /// on it. Required nonzero. It is part of the invocation's identity, so a + /// changed budget under a recorded request id is a `ReplayConflict`. + pub max_cost_micro_usd: u64, pub request: ProviderRequest, } @@ -259,6 +264,7 @@ impl TransportInvocation { caller_runtime_id: impl Into, expires_at_unix_ms: u64, native_request_sha256: [u8; 32], + max_cost_micro_usd: u64, request: ProviderRequest, ) -> Result { let provider_request_sha256 = provider_request_sha256(&request)?; @@ -268,6 +274,7 @@ impl TransportInvocation { expires_at_unix_ms, native_request_sha256, provider_request_sha256, + max_cost_micro_usd, request, }) } @@ -280,6 +287,9 @@ impl TransportInvocation { if self.native_request_sha256 == [0; 32] { return Err(ContractError::Invalid("native_request_sha256")); } + if self.max_cost_micro_usd == 0 { + return Err(ContractError::Invalid("max_cost_micro_usd")); + } self.request.validate()?; if self.provider_request_sha256 != provider_request_sha256(&self.request)? { return Err(ContractError::ProviderDigest); @@ -475,6 +485,15 @@ pub struct TransportReceipt { /// How many foreign `ReasoningState` items the service removed from the /// request before the backend saw it. pub foreign_reasoning_dropped: u32, + /// What the request cost, in micro-USD. The service derives it from the + /// outcome's usage and its price table; a backend never writes it. + pub cost_micro_usd: u64, + /// How far `cost_micro_usd` exceeds what the service reserved for the + /// request: zero unless the backend reported usage above the worst case + /// the request allowed, which is a breach of the backend's contract. The + /// cost is still the true one and the caller's window is charged all of + /// it. The service derives this; a backend never writes it. + pub overrun_micro_usd: u64, pub outcome: TransportOutcome, } @@ -524,6 +543,10 @@ pub enum TransportOutcome { Failed { kind: FailureKind, message: String, + /// What the provider billed for the failed request: `Some` when it + /// reported usage (`Some` of zeros when it billed nothing), `None` + /// when unknown. + usage: Option, }, } @@ -535,6 +558,12 @@ pub enum Refusal { ModelNotAllowed, OutputBound, PayloadBound, + // The worst case of the request exceeds the invocation's own + // `max_cost_micro_usd`. Plain comments: a doc comment would split the + // published schema into a union. + CostBound, + // The caller's spend window cannot hold the request's reservation. + SpendCeiling, Capacity, InFlight, Indeterminate, @@ -544,6 +573,12 @@ pub enum Refusal { // exchange, where dropping it would break the provider's own rules. A plain // comment: a doc comment would split the published schema into a union. ForeignReasoningState, + // The caller's newest admission window starts after the clock's reading, + // and the request does not fit what that window holds: the host's clock + // is behind the window and the caller stays refused until it catches up. + // A plain comment: a doc comment would split the published schema into a + // union. + ClockBehindAdmissionWindow, } /// The one wire codec of every v3 document: named MessagePack (struct fields @@ -751,6 +786,31 @@ mod tests { ); } + #[test] + fn max_cost_zero_is_malformed() { + let mut invocation = invocation(); + invocation.max_cost_micro_usd = 0; + assert_eq!( + invocation.validate(1_000, 2_000), + Err(ContractError::Invalid("max_cost_micro_usd")) + ); + invocation.max_cost_micro_usd = 1; + assert_eq!(invocation.validate(1_000, 2_000), Ok(())); + } + + #[test] + fn max_cost_is_part_of_the_invocations_identity() { + let one = invocation(); + let mut other = one.clone(); + other.max_cost_micro_usd += 1; + assert_ne!( + canonical_invocation_bytes(&one).unwrap(), + canonical_invocation_bytes(&other).unwrap() + ); + let decoded: TransportInvocation = wire_decode(&wire_encode(&one).unwrap()).unwrap(); + assert_eq!(decoded.max_cost_micro_usd, one.max_cost_micro_usd); + } + #[test] fn typed_tool_round_survives_messagepack() { let invocation = invocation(); @@ -946,6 +1006,7 @@ mod tests { sent.outcome = TransportOutcome::Failed { kind, message: "failed".to_string(), + usage: None, }; assert_eq!(sent.validate_against(&invocation), Ok(())); let decoded: TransportReceipt = wire_decode(&wire_encode(&sent).unwrap()).unwrap(); @@ -955,6 +1016,7 @@ mod tests { blank.outcome = TransportOutcome::Failed { kind: FailureKind::Provider, message: String::new(), + usage: None, }; assert_eq!( blank.validate_against(&invocation), @@ -993,6 +1055,54 @@ mod tests { } } + #[test] + fn validate_against_refuses_each_binding_field_of_the_receipt() { + let invocation = invocation(); + let good = receipt(&invocation); + assert_eq!(good.validate_against(&invocation), Ok(())); + + let mut schema = good.clone(); + schema.schema_id = "other".to_string(); + assert_eq!( + schema.validate_against(&invocation), + Err(ContractError::Schema) + ); + let mut provider_digest = good.clone(); + provider_digest.provider_request_sha256 = [9; 32]; + assert_eq!( + provider_digest.validate_against(&invocation), + Err(ContractError::ProviderDigest) + ); + let mut native_digest = good.clone(); + native_digest.native_request_sha256 = [9; 32]; + assert_eq!( + native_digest.validate_against(&invocation), + Err(ContractError::NativeDigest) + ); + let mut blank_provider = good.clone(); + blank_provider.provider = " ".to_string(); + assert_eq!( + blank_provider.validate_against(&invocation), + Err(ContractError::Invalid("receipt.provider")) + ); + let mut blank_response = good.clone(); + blank_response.outcome = TransportOutcome::Completed { + provider_response_id: Some(String::new()), + stop_reason: StopReason::EndTurn, + usage: Usage { + input_tokens: 0, + cache_read_tokens: 0, + cache_write_tokens: 0, + output_tokens: 0, + reasoning_tokens: None, + }, + }; + assert_eq!( + blank_response.validate_against(&invocation), + Err(ContractError::Invalid("receipt.provider_response_id")) + ); + } + fn reasoning(state: &[u8]) -> TransportEventPayload { TransportEventPayload::ReasoningState { state: state.to_vec(), @@ -1067,6 +1177,7 @@ mod tests { failed.outcome = TransportOutcome::Failed { kind: FailureKind::RateLimited, message: "slow down".to_string(), + usage: None, }; let partial = TransportResult::transported( vec![event(0, text("half")), event(1, reasoning(&[1, 2, 3]))], diff --git a/crates/model-connector/src/envelope.rs b/crates/model-connector/src/envelope.rs index 56bb1c2..40cfd09 100644 --- a/crates/model-connector/src/envelope.rs +++ b/crates/model-connector/src/envelope.rs @@ -462,6 +462,8 @@ pub enum ServiceError { SharedCallerKey, #[error("connector replay store failed: {0}")] ReplayStore(String), + #[error("an allowed model has no price: {0}")] + UnpricedModel(String), #[error("connector replay record is malformed")] InvalidReplayRecord, #[error("caller key changed while durable replay records still exist")] diff --git a/crates/model-connector/src/fixtures.rs b/crates/model-connector/src/fixtures.rs index b96fea8..079a99c 100644 --- a/crates/model-connector/src/fixtures.rs +++ b/crates/model-connector/src/fixtures.rs @@ -4,8 +4,15 @@ use crate::contract::*; #[cfg(feature = "daemon")] use crate::service::*; +#[cfg(feature = "daemon")] +use std::collections::BTreeMap; #[cfg(feature = "daemon")] use std::sync::atomic::{AtomicUsize, Ordering}; +#[cfg(feature = "daemon")] +use std::sync::{Arc, Mutex}; + +#[cfg(feature = "daemon")] +use cultcache_rs::{CacheBackingStore, CultCacheEnvelope, PushAllOptions}; #[cfg(feature = "daemon")] static TEST_STORE_SEQUENCE: AtomicUsize = AtomicUsize::new(0); @@ -37,6 +44,76 @@ impl Drop for TestStore { } } +/// An in-memory ledger that accepts a fixed number of writes and then refuses +/// every one, so a test can make the replay store fail where it chooses. A +/// store reopened from it sees what was written, like a restart. +#[cfg(feature = "daemon")] +pub(crate) struct FlakyStore { + held: Arc>>, + pushes_allowed: Arc, +} + +#[cfg(feature = "daemon")] +impl FlakyStore { + pub(crate) fn new(pushes_allowed: usize) -> Self { + Self { + held: Arc::default(), + pushes_allowed: Arc::new(AtomicUsize::new(pushes_allowed)), + } + } + + /// The remaining writes this store accepts, shared with it after it has + /// been handed to a service. + pub(crate) fn allowance(&self) -> Arc { + self.pushes_allowed.clone() + } + + /// A store over the same records that accepts `pushes_allowed` more writes. + pub(crate) fn reopened(&self, pushes_allowed: usize) -> Self { + Self { + held: self.held.clone(), + pushes_allowed: Arc::new(AtomicUsize::new(pushes_allowed)), + } + } +} + +#[cfg(feature = "daemon")] +impl CacheBackingStore for FlakyStore { + fn pull_all(&self) -> anyhow::Result> { + Ok(self.held.lock().unwrap().values().cloned().collect()) + } + + fn push(&mut self, entry: &CultCacheEnvelope) -> anyhow::Result<()> { + let allowed = self.pushes_allowed.load(Ordering::SeqCst); + if allowed == 0 { + return Err(anyhow::anyhow!("ledger write refused")); + } + self.pushes_allowed.store(allowed - 1, Ordering::SeqCst); + self.held + .lock() + .unwrap() + .insert(entry.key.clone(), entry.clone()); + Ok(()) + } + + fn delete(&mut self, entry: &CultCacheEnvelope) -> anyhow::Result<()> { + self.held.lock().unwrap().remove(&entry.key); + Ok(()) + } + + fn push_all( + &mut self, + entries: &[CultCacheEnvelope], + _options: PushAllOptions, + ) -> anyhow::Result<()> { + *self.held.lock().unwrap() = entries + .iter() + .map(|entry| (entry.key.clone(), entry.clone())) + .collect(); + Ok(()) + } +} + pub(crate) fn request() -> ProviderRequest { let mut request = ProviderRequest::new( "request-1", @@ -56,8 +133,16 @@ pub(crate) fn request() -> ProviderRequest { request } +/// Generous next to the fixture request's worst case (about 64,000), so it +/// binds only in the tests that are about spend. +pub(crate) const MAX_COST: u64 = 10_000_000; + +/// What the fixture usage (10 input and 3 output tokens) costs under +/// [`fixture_price`]: 10 x 3,000,000 + 3 x 15,000,000 = 75,000,000 / 1e6. +pub(crate) const FIXTURE_USAGE_COST: u64 = 75; + pub(crate) fn invocation() -> TransportInvocation { - TransportInvocation::new("epiphany-yggdrasil", 2_000, [7; 32], request()).unwrap() + TransportInvocation::new("epiphany-yggdrasil", 2_000, [7; 32], MAX_COST, request()).unwrap() } pub(crate) fn receipt(invocation: &TransportInvocation) -> TransportReceipt { @@ -70,6 +155,8 @@ pub(crate) fn receipt(invocation: &TransportInvocation) -> TransportReceipt { model: invocation.request.model.clone(), provider: "fixture-sse".to_string(), foreign_reasoning_dropped: 0, + cost_micro_usd: FIXTURE_USAGE_COST, + overrun_micro_usd: 0, outcome: TransportOutcome::Completed { provider_response_id: Some("response-1".to_string()), stop_reason: StopReason::EndTurn, @@ -83,6 +170,34 @@ pub(crate) fn receipt(invocation: &TransportInvocation) -> TransportReceipt { }, } } +/// A price with a distinct nonzero rate per class, so a swapped rate changes a cost. +#[cfg(feature = "daemon")] +pub(crate) fn fixture_price() -> ModelPrice { + ModelPrice { + rates: Rates { + input: 3_000_000, + cache_write_short: 3_750_000, + cache_write_long: 6_000_000, + cache_read: 300_000, + output: 15_000_000, + }, + long_context: None, + overhead_tokens: 100, + } +} + +#[cfg(feature = "daemon")] +pub(crate) fn fixture_prices() -> PriceTable { + PriceTable::from([("gpt-5.4".to_string(), fixture_price())]) +} + +/// Far above anything a fixture request reserves: the ceiling binds only in +/// the tests that are about it. +#[cfg(feature = "daemon")] +pub(crate) const CEILING: u64 = 1 << 40; +#[cfg(feature = "daemon")] +pub(crate) const WINDOW_MS: u64 = 3_600_000; + #[cfg(feature = "daemon")] pub(crate) fn caller( caller_runtime_id: &str, @@ -97,6 +212,8 @@ pub(crate) fn caller( max_concurrent_requests, 64 * 1024, 32_768, + SpendCeiling(CEILING), + SpendWindowMs(WINDOW_MS), ) .unwrap() } @@ -106,6 +223,7 @@ pub(crate) fn open_service(store: &TestStore, max_concurrent_requests: usize) -> TransportService::open( &store.path, "fixture-sse", + fixture_prices(), [ caller( "epiphany-yggdrasil", @@ -131,5 +249,5 @@ pub(crate) fn security(key: &str) -> TransportKey { pub(crate) fn invocation_for(caller_runtime_id: &str, request_id: &str) -> TransportInvocation { let mut request = request(); request.request_id = request_id.to_string(); - TransportInvocation::new(caller_runtime_id, 2_000, [7; 32], request).unwrap() + TransportInvocation::new(caller_runtime_id, 2_000, [7; 32], MAX_COST, request).unwrap() } diff --git a/crates/model-connector/src/serve.rs b/crates/model-connector/src/serve.rs index 40ece2d..6d87f6e 100644 --- a/crates/model-connector/src/serve.rs +++ b/crates/model-connector/src/serve.rs @@ -15,7 +15,10 @@ use crate::envelope::{ ServiceError, TransportFrameError, decode_invocation_frame, encode_result_frame, frame_bytes_for_payload, read_transport_frame, write_transport_frame, }; -use crate::service::{CallerAdmission, ProviderCall, TransportAdmission, TransportService}; +use crate::service::{ + CallerAdmission, PriceTable, ProviderCall, SpendCeiling, SpendWindowMs, TransportAdmission, + TransportService, +}; /// The only provider seam of the connector kit. A connector binary implements /// it over one model provider; the kit names no provider. @@ -111,6 +114,12 @@ pub struct CallerConfig { pub max_concurrent_requests: usize, pub max_payload_bytes: usize, pub max_output_tokens: u32, + /// The most the caller may spend in one window, in micro-USD. Required + /// nonzero. A fuse set once, generously above what the dial allows. + pub spend_ceiling_micro_usd: u64, + /// The length of the caller's spend window, in milliseconds. Windows are + /// fixed and aligned to the unix epoch. Required nonzero. + pub spend_window_ms: u64, } pub fn load_admissions(callers: &[CallerConfig]) -> Result, ServeError> { @@ -131,6 +140,8 @@ pub fn load_admissions(callers: &[CallerConfig]) -> Result, caller.max_concurrent_requests, caller.max_payload_bytes, caller.max_output_tokens, + SpendCeiling(caller.spend_ceiling_micro_usd), + SpendWindowMs(caller.spend_window_ms), ) .map_err(ServeError::Service)?, ); @@ -139,12 +150,15 @@ pub fn load_admissions(callers: &[CallerConfig]) -> Result, } /// Serves admitted callers on a loopback listener until the listener fails. +/// `prices` is the connector binary's config document's table of what every +/// allowed model costs (B4 owns that document). /// The caller binds; a listener that is not bound to loopback is refused, and /// peers that are not loopback are dropped. pub fn serve( listener: TcpListener, limits: ServeLimits, admissions: Vec, + prices: PriceTable, replay_store: &Path, backend: Arc, ) -> Result<(), ServeError> { @@ -163,6 +177,7 @@ pub fn serve( let service = Arc::new(Mutex::new(TransportService::open( replay_store, backend.provider(), + prices, admissions, limits.max_expiry_skew_ms, )?)); @@ -336,7 +351,10 @@ mod tests { FailureKind, Refusal, TransportDisposition, TransportEvent, TransportEventPayload, TransportInvocation, TransportOutcome, TransportResult, }; - use crate::fixtures::{TestStore, caller, invocation_for, receipt, request}; + use crate::fixtures::{ + CEILING, MAX_COST, TestStore, WINDOW_MS, caller, fixture_prices, invocation_for, receipt, + request, + }; const CALLER: &str = "epiphany-yggdrasil"; const KEY: &str = "epiphany-distinct-test-key"; @@ -412,7 +430,14 @@ mod tests { let (sender, receiver) = std::sync::mpsc::channel(); let path = path.to_path_buf(); thread::spawn(move || { - let _ = sender.send(serve(listener, limits, admissions, &path, backend)); + let _ = sender.send(serve( + listener, + limits, + admissions, + fixture_prices(), + &path, + backend, + )); }); receiver.recv_timeout(Duration::from_secs(2)).ok() } @@ -438,7 +463,16 @@ mod tests { let listener = TcpListener::bind("127.0.0.1:0").unwrap(); let endpoint = listener.local_addr().unwrap(); let path = store.path.clone(); - thread::spawn(move || serve(listener, limits(), admissions, &path, backend)); + thread::spawn(move || { + serve( + listener, + limits(), + admissions, + fixture_prices(), + &path, + backend, + ) + }); let client = ConnectorClient::new(endpoint, KEY, FRAME, Some(Duration::from_secs(5))).unwrap(); (client, endpoint) @@ -456,10 +490,37 @@ mod tests { max_concurrent_requests: *capacity, max_payload_bytes: 1_048_576, max_output_tokens: 16_384, + spend_ceiling_micro_usd: CEILING, + spend_window_ms: WINDOW_MS, }) .collect() } + #[test] + fn serve_refuses_a_request_over_its_max_cost_without_executing_it() { + let store = TestStore::new(); + let backend = CountingBackend::new(true); + let (client, _) = start(backend.clone(), &store); + let mut invocation = fresh("request-costly"); + invocation.max_cost_micro_usd = 1; + let refused = client.execute(&invocation).unwrap(); + assert_eq!( + refused.disposition, + TransportDisposition::Refused(Refusal::CostBound) + ); + assert_eq!(backend.executions.load(Ordering::SeqCst), 0); + + // The refusal was not recorded: the same id with a budget that holds + // the worst case is admitted and executed. + invocation.max_cost_micro_usd = MAX_COST; + let admitted = client.execute(&invocation).unwrap(); + assert!(matches!( + admitted.disposition, + TransportDisposition::Transported { .. } + )); + assert_eq!(backend.executions.load(Ordering::SeqCst), 1); + } + #[test] fn serve_end_to_end_replays_without_a_second_execution() { let store = TestStore::new(); @@ -496,6 +557,7 @@ mod tests { CALLER, invocation.expires_at_unix_ms, [7; 32], + MAX_COST, conflicting_request, ) .unwrap(); @@ -580,6 +642,7 @@ mod tests { let mut service = TransportService::open( &store.path, "fixture-sse", + fixture_prices(), vec![caller(CALLER, KEY, 4)], 300_000, ) @@ -611,6 +674,7 @@ mod tests { listener, narrowed, vec![caller(CALLER, KEY, 4)], + fixture_prices(), &path, served, ) @@ -693,7 +757,13 @@ mod tests { fn the_smallest_frame_also_carries_the_failure_that_stands_in_for_a_result() { let long = "x".repeat(crate::envelope::MAX_IDENTITY_BYTES); let plaintext = crate::contract::wire_encode(&crate::service::result_size_failure( - &long, &long, &long, [0xff; 32], [0xff; 32], 0, + &long, + &long, + &long, + [0xff; 32], + [0xff; 32], + 0, + u64::MAX, )) .unwrap() .len(); @@ -781,7 +851,18 @@ mod tests { } fn admission(id: &str, key: &str, slots: usize, payload: usize) -> CallerAdmission { - CallerAdmission::new(id, key, 1, ["gpt-5.4".to_string()], slots, payload, 32_768).unwrap() + CallerAdmission::new( + id, + key, + 1, + ["gpt-5.4".to_string()], + slots, + payload, + 32_768, + SpendCeiling(CEILING), + SpendWindowMs(WINDOW_MS), + ) + .unwrap() } /// `Some(field)` when serve refused the configuration, `None` while it serves. @@ -887,7 +968,7 @@ mod tests { ..limits() }; let admissions = vec![admission(CALLER, KEY, 4, bound)]; - thread::spawn(move || serve(listener, tight, admissions, &path, served)); + thread::spawn(move || serve(listener, tight, admissions, fixture_prices(), &path, served)); let client = ConnectorClient::new(endpoint, KEY, frame, Some(Duration::from_secs(5))).unwrap(); let reply = client.execute(&invocation).unwrap(); @@ -987,6 +1068,7 @@ mod tests { && receipt.outcome == TransportOutcome::Failed { kind: FailureKind::ResultSize, message: "provider result exceeded its byte bound".to_string(), + usage: None, } ) } @@ -1001,6 +1083,7 @@ mod tests { let mut service = TransportService::open( &store.path, "fixture-sse", + fixture_prices(), vec![caller(CALLER, KEY, 4)], 300_000, ) @@ -1103,6 +1186,7 @@ mod tests { TransportService::open( &store.path, "fixture-sse", + fixture_prices(), vec![caller(CALLER, KEY, 4)], 300_000, ) @@ -1160,7 +1244,16 @@ mod tests { let backend = CountingBackend::new(true); let path = store.path.clone(); let served = backend.clone(); - thread::spawn(move || serve(listener, limits(), admissions, &path, served)); + thread::spawn(move || { + serve( + listener, + limits(), + admissions, + fixture_prices(), + &path, + served, + ) + }); let client = ConnectorClient::new(endpoint, KEY, FRAME, Some(Duration::from_secs(5))).unwrap(); client.execute(&fresh("request-key")).unwrap(); diff --git a/crates/model-connector/src/service.rs b/crates/model-connector/src/service.rs index 9a2ea2a..8d4fd80 100644 --- a/crates/model-connector/src/service.rs +++ b/crates/model-connector/src/service.rs @@ -1,21 +1,50 @@ -use std::collections::{HashMap, HashSet}; +use std::collections::{BTreeMap, HashMap, HashSet}; use std::path::Path; -use cultcache_rs::{CultCache, DatabaseEntry, OwnedRedbMessagePackBackingStore}; +use cultcache_rs::{CacheBackingStore, CultCache, DatabaseEntry, OwnedRedbMessagePackBackingStore}; use serde::{Deserialize, Serialize}; use sha2::{Digest, Sha256}; use zeroize::Zeroizing; use crate::contract::{ - ContractError, FailureKind, InputItem, ProviderRequest, RECEIPT_SCHEMA_ID, RESULT_SCHEMA_ID, - Refusal, TransportDisposition, TransportEvent, TransportInvocation, TransportKey, - TransportOutcome, TransportReceipt, TransportResult, canonical_invocation_bytes, require_id, + CacheRetention, ContractError, FailureKind, InputItem, ProviderRequest, RECEIPT_SCHEMA_ID, + RESULT_SCHEMA_ID, Refusal, TransportDisposition, TransportEvent, TransportInvocation, + TransportKey, TransportOutcome, TransportReceipt, TransportResult, Usage, + canonical_invocation_bytes, require_id, wire_encode, }; use crate::envelope::{ EnvelopeKind, MAX_IDENTITY_BYTES, ServiceError, TransportEnvelope, decrypt_invocation, encode_result_frame, encrypt_result, validate_envelope, }; +/// What a caller may spend in one window, in micro-USD. A distinct type from +/// [`SpendWindowMs`] so the pair cannot be passed swapped: +/// +/// ``` +/// use model_connector::{CallerAdmission, SpendCeiling, SpendWindowMs}; +/// CallerAdmission::new( +/// "caller-1", "a-connection-key", 1, ["model-1".to_string()], 1, 1, 1, +/// SpendCeiling(50_000_000), SpendWindowMs(3_600_000), +/// ) +/// .unwrap(); +/// ``` +/// +/// ```compile_fail +/// use model_connector::{CallerAdmission, SpendCeiling, SpendWindowMs}; +/// CallerAdmission::new( +/// "caller-1", "a-connection-key", 1, ["model-1".to_string()], 1, 1, 1, +/// SpendWindowMs(3_600_000), SpendCeiling(50_000_000), +/// ) +/// .unwrap(); +/// ``` +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct SpendCeiling(pub u64); + +/// How long a spend window lasts, in milliseconds. Windows are fixed and +/// aligned to the unix epoch. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct SpendWindowMs(pub u64); + pub struct CallerAdmission { caller_runtime_id: String, connection_key_epoch: u32, @@ -24,9 +53,14 @@ pub struct CallerAdmission { max_concurrent_requests: usize, max_payload_bytes: usize, max_output_tokens: u32, + spend_ceiling_micro_usd: u64, + spend_window_ms: u64, } impl CallerAdmission { + // The ceiling and window ride beside the other limits the root-owned caller + // config sets, one parameter each. + #[allow(clippy::too_many_arguments)] pub fn new( caller_runtime_id: impl Into, connection_key: impl Into, @@ -35,7 +69,10 @@ impl CallerAdmission { max_concurrent_requests: usize, max_payload_bytes: usize, max_output_tokens: u32, + spend_ceiling: SpendCeiling, + spend_window: SpendWindowMs, ) -> Result { + let (spend_ceiling_micro_usd, spend_window_ms) = (spend_ceiling.0, spend_window.0); let caller_runtime_id = caller_runtime_id.into(); require_id(&caller_runtime_id, "caller_runtime_id")?; let allowed_models = allowed_models.into_iter().collect::>(); @@ -48,6 +85,8 @@ impl CallerAdmission { || max_concurrent_requests == 0 || max_payload_bytes == 0 || max_output_tokens == 0 + || spend_ceiling_micro_usd == 0 + || spend_window_ms == 0 { return Err(ServiceError::InvalidAdmission); } @@ -61,6 +100,8 @@ impl CallerAdmission { max_concurrent_requests, max_payload_bytes, max_output_tokens, + spend_ceiling_micro_usd, + spend_window_ms, }) } @@ -77,6 +118,197 @@ impl CallerAdmission { } } +/// What one million tokens of each class cost, in micro-USD. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct Rates { + pub input: u64, + pub cache_write_short: u64, + pub cache_write_long: u64, + pub cache_read: u64, + pub output: u64, +} + +/// The rates a prompt strictly above `above_prompt_tokens` is priced at. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct LongContext { + pub above_prompt_tokens: u64, + pub rates: Rates, +} + +/// One model's price. Explicit zero rates are admitted (a local model is +/// free); a missing price is not. `overhead_tokens` is added to the byte +/// bound of a request when its worst case is reserved. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct ModelPrice { + pub rates: Rates, + pub long_context: Option, + pub overhead_tokens: u64, +} + +/// The price of every model a caller may be allowed, supplied at open by the +/// connector binary's config document. +pub type PriceTable = BTreeMap; + +const TOKENS_PER_RATE: u128 = 1_000_000; + +/// Rounds up to whole micro-USD; `None` when the amount does not fit a u64. +fn checked_ceil_micro_usd(rate_weighted_tokens: u128) -> Option { + let whole = rate_weighted_tokens / TOKENS_PER_RATE; + let rounded = whole + u128::from(!rate_weighted_tokens.is_multiple_of(TOKENS_PER_RATE)); + u64::try_from(rounded).ok() +} + +fn ceil_micro_usd(rate_weighted_tokens: u128) -> u64 { + checked_ceil_micro_usd(rate_weighted_tokens).unwrap_or(u64::MAX) +} + +impl Rates { + /// The dearer of each class. + fn dearest(self, other: Rates) -> Rates { + Rates { + input: self.input.max(other.input), + cache_write_short: self.cache_write_short.max(other.cache_write_short), + cache_write_long: self.cache_write_long.max(other.cache_write_long), + cache_read: self.cache_read.max(other.cache_read), + output: self.output.max(other.output), + } + } +} + +fn rates_for(price: &ModelPrice, prompt_tokens: u128) -> &Rates { + match &price.long_context { + Some(long) if prompt_tokens > u128::from(long.above_prompt_tokens) => &long.rates, + _ => &price.rates, + } +} + +/// A cache write nobody asked to keep short is priced dear. +fn write_rate(rates: &Rates, retention: Option) -> u64 { + match retention { + Some(CacheRetention::Short) => rates.cache_write_short, + Some(CacheRetention::Long) | None => rates.cache_write_long, + } +} + +/// What `usage` costs under `price`, in micro-USD: the sum over token classes +/// of tokens times rate, rounded up once, saturating at `u64::MAX`. +pub(crate) fn cost_micro_usd( + price: &ModelPrice, + usage: &Usage, + retention: Option, +) -> u64 { + let prompt = u128::from(usage.input_tokens) + + u128::from(usage.cache_read_tokens) + + u128::from(usage.cache_write_tokens); + let rates = rates_for(price, prompt); + let total = [ + u128::from(usage.input_tokens) * u128::from(rates.input), + u128::from(usage.cache_read_tokens) * u128::from(rates.cache_read), + u128::from(usage.cache_write_tokens) * u128::from(write_rate(rates, retention)), + u128::from(usage.output_tokens) * u128::from(rates.output), + ] + .into_iter() + .fold(0u128, u128::saturating_add); + ceil_micro_usd(total) +} + +/// The worst case `request` can cost, in micro-USD: its encoded length plus +/// the model's overhead as input tokens (a token is at least one byte) at the +/// dearest input rate the cache retention allows, plus `max_output_tokens` at +/// the output rate, rounded up once. A prompt that may cross the long-context +/// threshold is bounded at the dearer of the two rate sets, so a backend whose +/// usage prices above this broke its contract. `None` when the worst case does +/// not fit a u64: such a request can never be afforded. +pub(crate) fn reservation_micro_usd( + price: &ModelPrice, + request: &ProviderRequest, +) -> Result, ContractError> { + let bound = (wire_encode(request)?.len() as u128) + u128::from(price.overhead_tokens); + let rates = match &price.long_context { + Some(long) if bound > u128::from(long.above_prompt_tokens) => { + price.rates.dearest(long.rates) + } + _ => price.rates, + }; + let input_rate = rates + .input + .max(rates.cache_read) + .max(write_rate(&rates, request.cache_retention)); + let total = bound.checked_mul(u128::from(input_rate)).and_then(|input| { + input.checked_add(u128::from(request.max_output_tokens) * u128::from(rates.output)) + }); + Ok(total.and_then(checked_ceil_micro_usd)) +} + +/// Moves `held` of a caller's window: releases `release`, then adds `charge`. +fn add_spend( + window_spend: &mut HashMap>, + caller_runtime_id: &str, + window: u64, + release: u64, + charge: u64, +) { + let held = window_spend + .entry(caller_runtime_id.to_string()) + .or_default() + .entry(window) + .or_insert(0); + *held = held.saturating_sub(release).saturating_add(charge); +} + +/// The clock a request is admitted at: `now`, held monotonic per caller. A +/// caller's window never moves back, so a clock that steps back (or a boot +/// with the clock behind a recorded window) cannot reopen spent budget: the +/// request is admitted into the caller's newest recorded window. The record +/// stores this value, so the window re-derives from the records at open. +fn admission_clock( + window_spend: &HashMap>, + caller_runtime_id: &str, + now_unix_ms: u64, + spend_window_ms: u64, +) -> u64 { + let newest = window_spend + .get(caller_runtime_id) + .and_then(|windows| windows.keys().next_back().copied()) + .unwrap_or(0); + now_unix_ms.max(newest.saturating_mul(spend_window_ms)) +} + +/// Drops the windows of `caller_runtime_id` that nothing can still read: +/// those before `floor` that hold no live request's reservation. Admission +/// reads the current window alone, and a live request settles into the window +/// it was admitted in, so nothing older is kept. +fn prune_windows( + window_spend: &mut HashMap>, + live: &HashMap, + caller_runtime_id: &str, + floor: u64, +) { + if let Some(windows) = window_spend.get_mut(caller_runtime_id) { + windows.retain(|window, _| { + *window >= floor + || live.iter().any(|(identity, live_window)| { + live_window == window && identity.caller_runtime_id == caller_runtime_id + }) + }); + } +} + +/// `prune_windows` with the caller's newest window as the floor, for the +/// places that do not know the clock (open, settle). +fn prune_to_latest( + window_spend: &mut HashMap>, + live: &HashMap, + caller_runtime_id: &str, +) { + let latest = window_spend + .get(caller_runtime_id) + .and_then(|windows| windows.keys().next_back().copied()); + if let Some(latest) = latest { + prune_windows(window_spend, live, caller_runtime_id, latest); + } +} + #[derive(Debug, Clone, PartialEq, Eq, Hash)] struct RequestIdentity { caller_runtime_id: String, @@ -85,12 +317,21 @@ struct RequestIdentity { #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] enum ReplayState { - Active, - Completed { response: TransportEnvelope }, + /// Admitted and possibly executing: the worst case is held against the + /// caller's window until the request settles (an indeterminate one is + /// billed at it). + Active { reserved_micro_usd: u64 }, + /// `overrun_micro_usd` is what the charge exceeded the reservation by: a + /// backend contract breach, kept so a replayed stand-in still says so. + Completed { + response: TransportEnvelope, + charged_micro_usd: u64, + overrun_micro_usd: u64, + }, } #[derive(Clone, Debug, PartialEq, Eq, DatabaseEntry)] -#[cultcache(type = "gamecult.model.replay_record.v1")] +#[cultcache(type = "gamecult.model.replay_record.v2")] struct ReplayRecord { #[cultcache(key = 0)] caller_runtime_id: String, @@ -104,6 +345,8 @@ struct ReplayRecord { expires_at_unix_ms: u64, #[cultcache(key = 5)] state: ReplayState, + #[cultcache(key = 6)] + admitted_at_unix_ms: u64, } /// What a backend is handed: the request with every foreign reasoning item @@ -115,6 +358,8 @@ struct ReplayRecord { pub struct ProviderCall { request: ProviderRequest, receipt: TransportReceipt, + price: ModelPrice, + reserved_micro_usd: u64, } impl ProviderCall { @@ -130,20 +375,69 @@ impl ProviderCall { } /// The result of this call. The whole receipt (schema, ids, both digests, - /// model, provider tag and drop count) is derived here, so a backend + /// model, provider tag, drop count and cost) is derived here, so a backend /// writes only the events and the outcome. pub fn transported( &self, events: Vec, outcome: TransportOutcome, ) -> TransportResult { - TransportResult::transported( - events, + TransportResult::transported(events, { + let cost_micro_usd = self.cost_of(&outcome); TransportReceipt { + cost_micro_usd, + overrun_micro_usd: self.overrun_of(cost_micro_usd), outcome, ..self.receipt.clone() + } + }) + } + + /// What a completed `result` is billed: the one owner of the charge + /// decision. A transported result bound to `invocation` by request id and + /// caller (on the result and on its receipt) costs what its own outcome's + /// usage derives, with the overrun past the reservation recorded + /// truthfully, whether or not it is otherwise malformed. Any other result + /// (refused, or bound to another request or caller) costs the whole + /// reservation. Validity decides what the caller is told, never this. + fn charge(&self, invocation: &TransportInvocation, result: &TransportResult) -> Charge { + match &result.disposition { + TransportDisposition::Transported { receipt, .. } + if result.request_id == invocation.request_id() + && result.caller_runtime_id == invocation.caller_runtime_id + && receipt.request_id == invocation.request_id() + && receipt.caller_runtime_id == invocation.caller_runtime_id => + { + let cost_micro_usd = self.cost_of(&receipt.outcome); + Charge { + cost_micro_usd, + overrun_micro_usd: self.overrun_of(cost_micro_usd), + } + } + _ => Charge { + cost_micro_usd: self.reserved_micro_usd, + overrun_micro_usd: 0, }, - ) + } + } + + /// How far `cost_micro_usd` exceeds what was reserved. The reservation is + /// the worst case the kit can compute, so a nonzero overrun means the + /// backend broke its contract (usage above its own request's bounds). + fn overrun_of(&self, cost_micro_usd: u64) -> u64 { + cost_micro_usd.saturating_sub(self.reserved_micro_usd) + } + + /// What `outcome` costs: its usage priced, or the whole reservation when a + /// failure does not say what it billed (unknown is billed as the worst case). + fn cost_of(&self, outcome: &TransportOutcome) -> u64 { + match outcome { + TransportOutcome::Completed { usage, .. } + | TransportOutcome::Failed { + usage: Some(usage), .. + } => cost_micro_usd(&self.price, usage, self.request.cache_retention), + TransportOutcome::Failed { usage: None, .. } => self.reserved_micro_usd, + } } } @@ -168,19 +462,49 @@ pub enum TransportAdmission { pub struct TransportService { callers: HashMap, - live: HashSet, + /// The requests between claim and settlement, each with the window it was + /// admitted in. + live: HashMap, replay_store: CultCache, max_expiry_skew_ms: u64, provider: String, + prices: PriceTable, + /// Per caller, what each spend window holds: the reservations of its + /// Active records and the charges of its Completed ones. A cache of the + /// replay records, re-derived at open and moved only after a record's put + /// returns Ok; the records are the ledger. A request belongs to the window + /// it was admitted in, so settlement never moves it to another. Only what + /// admission and settlement can still read is kept: the caller's newest + /// window and any older one a live request is reserved in. + window_spend: HashMap>, } impl TransportService { /// `provider` is the tag of the backend this service fronts. The service /// owns it from here on: it decides which reasoning state is foreign and - /// what provider every receipt names. + /// what provider every receipt names. `prices` must price every model any + /// caller is allowed. pub fn open( replay_store_path: &Path, provider: &str, + prices: PriceTable, + admissions: impl IntoIterator, + max_expiry_skew_ms: u64, + ) -> Result { + Self::with_store( + OwnedRedbMessagePackBackingStore::new(replay_store_path).map_err(replay_store_error)?, + provider, + prices, + admissions, + max_expiry_skew_ms, + ) + } + + /// `open` over any backing store, so a test can inject one that fails. + pub fn with_store( + store: impl CacheBackingStore + 'static, + provider: &str, + prices: PriceTable, admissions: impl IntoIterator, max_expiry_skew_ms: u64, ) -> Result { @@ -204,21 +528,27 @@ impl TransportService { if callers.is_empty() { return Err(ServiceError::InvalidAdmission); } + if let Some(model) = callers + .values() + .flat_map(|caller| caller.allowed_models.iter()) + .filter(|model| !prices.contains_key(*model)) + .min() + { + return Err(ServiceError::UnpricedModel(model.clone())); + } let mut replay_store = CultCache::new(); replay_store .register_entry_type::() .map_err(replay_store_error)?; replay_store - .add_generic_backing_store( - OwnedRedbMessagePackBackingStore::new(replay_store_path) - .map_err(replay_store_error)?, - ) + .add_generic_backing_store(store) .map_err(replay_store_error)?; replay_store .pull_all_backing_stores() .map_err(replay_store_error)?; + let mut window_spend: HashMap> = HashMap::new(); for (key, record) in replay_store .get_all_with_keys::() .map_err(replay_store_error)? @@ -230,6 +560,7 @@ impl TransportService { if key != replay_key(&identity) || record.invocation_sha256 == [0; 32] || record.expires_at_unix_ms == 0 + || record.admitted_at_unix_ms == 0 { return Err(ServiceError::InvalidReplayRecord); } @@ -239,16 +570,47 @@ impl TransportService { if record.connection_key_epoch != caller.connection_key_epoch { return Err(ServiceError::ReplayCallerKeyMismatch); } + // An Active record left by a crash counts at its full reservation: + // indeterminate is billed as the worst case. + let held = match &record.state { + ReplayState::Active { reserved_micro_usd } => *reserved_micro_usd, + ReplayState::Completed { + charged_micro_usd, .. + } => *charged_micro_usd, + }; + add_spend( + &mut window_spend, + &record.caller_runtime_id, + record.admitted_at_unix_ms / caller.spend_window_ms, + 0, + held, + ); + } + let live = HashMap::new(); + let spenders: Vec = window_spend.keys().cloned().collect(); + for caller_runtime_id in &spenders { + prune_to_latest(&mut window_spend, &live, caller_runtime_id); } Ok(Self { callers, - live: HashSet::new(), + live, replay_store, max_expiry_skew_ms, provider: provider.to_string(), + prices, + window_spend, }) } + /// What `caller_runtime_id`'s spend window `window` holds. + pub(crate) fn window_spend(&self, caller_runtime_id: &str, window: u64) -> u64 { + self.window_spend + .get(caller_runtime_id) + .and_then(|windows| windows.get(&window)) + .copied() + .unwrap_or(0) + } + /// Admits one request. `max_frame_bytes` is the frame every reply must /// fit, the same bound `complete` is given: a recorded result that no /// longer fits it (the service reopened at a smaller frame) is answered @@ -269,6 +631,9 @@ impl TransportService { .ok_or(ServiceError::CallerNotAdmitted)?; let security = caller.security.clone(); let max_concurrent_requests = caller.max_concurrent_requests; + let connection_key_epoch = caller.connection_key_epoch; + let spend_ceiling = caller.spend_ceiling_micro_usd; + let spend_window_ms = caller.spend_window_ms; let invocation = decrypt_invocation(envelope, &security)?; if envelope.request_id.len() > MAX_IDENTITY_BYTES { let refusal = encrypt_result( @@ -298,7 +663,6 @@ impl TransportService { Ok(partition) => partition, Err(refusal) => return self.reply_refusal(&invocation, refusal, &security), }; - let identity = RequestIdentity { caller_runtime_id: invocation.caller_runtime_id.clone(), request_id: invocation.request_id().to_string(), @@ -314,15 +678,25 @@ impl TransportService { return self.reply_refusal(&invocation, Refusal::ReplayConflict, &security); } return match &record.state { - ReplayState::Completed { response } => { + ReplayState::Completed { + response, + charged_micro_usd, + overrun_micro_usd, + } => { if fits_frame(response, max_frame_bytes)? { return Ok(TransportAdmission::Reply(response.clone())); } + // The stand-in keeps the recorded charge: the request cost + // what it cost whatever frame its answer travels in. let stand_in = self.seal_stand_in( &invocation, &security, max_frame_bytes, FailureKind::ResultSize, + Charge { + cost_micro_usd: *charged_micro_usd, + overrun_micro_usd: *overrun_micro_usd, + }, )?; self.replay_store .put( @@ -330,6 +704,8 @@ impl TransportService { &ReplayRecord { state: ReplayState::Completed { response: stand_in.clone(), + charged_micro_usd: *charged_micro_usd, + overrun_micro_usd: *overrun_micro_usd, }, ..record.clone() }, @@ -337,8 +713,8 @@ impl TransportService { .map_err(replay_store_error)?; Ok(TransportAdmission::Reply(stand_in)) } - ReplayState::Active => { - let refusal = if self.live.contains(&identity) { + ReplayState::Active { .. } => { + let refusal = if self.live.contains_key(&identity) { Refusal::InFlight } else { Refusal::Indeterminate @@ -347,27 +723,78 @@ impl TransportService { } }; } + // The worst case is priced at today's table, so it is checked only for + // new work: a request already recorded is answered whatever it would + // cost now. + let price = *self + .prices + .get(&invocation.request.model) + .ok_or_else(|| ServiceError::UnpricedModel(invocation.request.model.clone()))?; + let reserved_micro_usd = match reservation_micro_usd(&price, &request)? { + Some(reserved) if reserved <= invocation.max_cost_micro_usd => reserved, + _ => return self.reply_refusal(&invocation, Refusal::CostBound, &security), + }; let active_for_caller = self .live - .iter() + .keys() .filter(|key| key.caller_runtime_id == invocation.caller_runtime_id) .count(); if active_for_caller >= max_concurrent_requests { return self.reply_refusal(&invocation, Refusal::Capacity, &security); } + // A request belongs to the window it is admitted in. The ceiling is + // checked after the replay lookup, so a recorded answer is replayed + // whatever the caller has left. + let admitted_at_unix_ms = admission_clock( + &self.window_spend, + &identity.caller_runtime_id, + now_unix_ms, + spend_window_ms, + ); + let window = admitted_at_unix_ms / spend_window_ms; + prune_windows( + &mut self.window_spend, + &self.live, + &identity.caller_runtime_id, + window, + ); + if self + .window_spend(&identity.caller_runtime_id, window) + .saturating_add(reserved_micro_usd) + > spend_ceiling + { + // A clock reading behind the caller's newest window is named: the + // refusal holds until the clock reaches that window's start. + let refusal = if now_unix_ms < window.saturating_mul(spend_window_ms) { + Refusal::ClockBehindAdmissionWindow + } else { + Refusal::SpendCeiling + }; + return self.reply_refusal(&invocation, refusal, &security); + } + // Reserving and claiming the request are one write: a ledger that + // cannot be written means nothing runs. let replay_record = ReplayRecord { caller_runtime_id: identity.caller_runtime_id.clone(), request_id: identity.request_id.clone(), - connection_key_epoch: caller.connection_key_epoch, + connection_key_epoch, invocation_sha256, expires_at_unix_ms: invocation.expires_at_unix_ms, - state: ReplayState::Active, + state: ReplayState::Active { reserved_micro_usd }, + admitted_at_unix_ms, }; self.replay_store .put(replay_key(&identity), &replay_record) .map_err(replay_store_error)?; - self.live.insert(identity); + add_spend( + &mut self.window_spend, + &identity.caller_runtime_id, + window, + 0, + reserved_micro_usd, + ); + self.live.insert(identity, window); Ok(TransportAdmission::Execute(Box::new(ExecutionClaim { call: ProviderCall { receipt: TransportReceipt { @@ -380,12 +807,17 @@ impl TransportService { provider: self.provider.clone(), foreign_reasoning_dropped, // Replaced by the backend's outcome in `transported`. + cost_micro_usd: 0, + overrun_micro_usd: 0, outcome: TransportOutcome::Failed { kind: FailureKind::Provider, message: String::new(), + usage: None, }, }, request, + price, + reserved_micro_usd, }, invocation, invocation_sha256, @@ -408,7 +840,7 @@ impl TransportService { caller_runtime_id: invocation.caller_runtime_id.clone(), request_id: invocation.request_id().to_string(), }; - if !self.live.contains(&identity) { + if !self.live.contains_key(&identity) { return Err(ServiceError::NoActiveRequest); } let active = self @@ -416,9 +848,10 @@ impl TransportService { .get::(&replay_key(&identity)) .map_err(replay_store_error)? .ok_or(ServiceError::NoActiveRequest)?; - if active.invocation_sha256 != claim.invocation_sha256 - || !matches!(active.state, ReplayState::Active) - { + let ReplayState::Active { reserved_micro_usd } = active.state else { + return Err(ServiceError::ActiveRequestMismatch); + }; + if active.invocation_sha256 != claim.invocation_sha256 { return Err(ServiceError::ActiveRequestMismatch); } let caller = self @@ -426,20 +859,27 @@ impl TransportService { .get(&identity.caller_runtime_id) .ok_or(ServiceError::CallerNotAdmitted)?; let security = caller.security.clone(); + let connection_key_epoch = caller.connection_key_epoch; + let spend_window_ms = caller.spend_window_ms; // A result the service refuses (not a transported result, not bound to // this invocation, or carrying a provider tag or drop count the call // did not derive) is answered like an oversized one: a typed failure // is recorded and the slot released, so the caller gets an answer and // a retry replays it. let admissible = self.admits_result(&result, &claim); + let charge = claim.call.charge(invocation, &result); + let charged_micro_usd = charge.cost_micro_usd; let mut response = if admissible { - encrypt_result(&result, &security)? + encrypt_result(&result, &security).inspect_err(|_| { + self.live.remove(&identity); + })? } else { self.seal_stand_in( invocation, &security, max_frame_bytes, FailureKind::Provider, + charge, ) .inspect_err(|_| { self.live.remove(&identity); @@ -455,29 +895,50 @@ impl TransportService { &security, max_frame_bytes, FailureKind::ResultSize, + charge, ) .inspect_err(|_| { self.live.remove(&identity); })?; } - let caller = self - .callers - .get(&identity.caller_runtime_id) - .ok_or(ServiceError::CallerNotAdmitted)?; let replay_record = ReplayRecord { caller_runtime_id: identity.caller_runtime_id.clone(), request_id: identity.request_id.clone(), - connection_key_epoch: caller.connection_key_epoch, + connection_key_epoch, invocation_sha256: claim.invocation_sha256, expires_at_unix_ms: invocation.expires_at_unix_ms, state: ReplayState::Completed { response: response.clone(), + charged_micro_usd, + overrun_micro_usd: charge.overrun_micro_usd, }, + admitted_at_unix_ms: active.admitted_at_unix_ms, }; + // A failed settle write frees the caller's slot and answers the typed + // store failure; the Active record stays, so the reservation stays + // counted and a retry is told the request is indeterminate. self.replay_store .put(replay_key(&identity), &replay_record) - .map_err(replay_store_error)?; + .map_err(replay_store_error) + .inspect_err(|_| { + self.live.remove(&identity); + })?; + // Settled: the window the request was admitted in holds the charge in + // place of the reservation. A failed put above leaves the reservation + // counted, as the store does. + add_spend( + &mut self.window_spend, + &identity.caller_runtime_id, + active.admitted_at_unix_ms / spend_window_ms, + reserved_micro_usd, + charged_micro_usd, + ); self.live.remove(&identity); + prune_to_latest( + &mut self.window_spend, + &self.live, + &identity.caller_runtime_id, + ); Ok(response) } @@ -488,6 +949,7 @@ impl TransportService { security: &TransportKey, max_frame_bytes: usize, kind: FailureKind, + charge: Charge, ) -> Result { let (_, foreign_reasoning_dropped) = partition_reasoning(&invocation.request, &self.provider) @@ -501,6 +963,7 @@ impl TransportService { invocation.provider_request_sha256, foreign_reasoning_dropped, kind, + charge, ), security, )?; @@ -512,8 +975,8 @@ impl TransportService { } /// Whether `result` is the answer to this claim's call: transported, - /// bound to the caller's invocation, and carrying the provider tag and drop - /// count the service derived. + /// bound to the caller's invocation, and carrying the provider tag, drop + /// count and cost the service derived. fn admits_result(&self, result: &TransportResult, claim: &ExecutionClaim) -> bool { let TransportDisposition::Transported { receipt, .. } = &result.disposition else { return false; @@ -521,6 +984,8 @@ impl TransportService { result.validate_against(&claim.invocation).is_ok() && receipt.provider == self.provider && receipt.foreign_reasoning_dropped == claim.call.receipt.foreign_reasoning_dropped + && receipt.cost_micro_usd == claim.call.cost_of(&receipt.outcome) + && receipt.overrun_micro_usd == claim.call.overrun_of(receipt.cost_micro_usd) } fn reply_refusal( @@ -554,7 +1019,12 @@ pub(crate) fn result_size_failure( native_request_sha256: [u8; 32], provider_request_sha256: [u8; 32], foreign_reasoning_dropped: u32, + cost_micro_usd: u64, ) -> TransportResult { + let charge = Charge { + cost_micro_usd, + overrun_micro_usd: 0, + }; stand_in_failure( caller_runtime_id, request_id, @@ -563,11 +1033,21 @@ pub(crate) fn result_size_failure( provider_request_sha256, foreign_reasoning_dropped, FailureKind::ResultSize, + charge, ) } +/// What a request cost, and by how much that exceeded its reservation. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +struct Charge { + cost_micro_usd: u64, + overrun_micro_usd: u64, +} + /// The stand-in failure for `kind`: `ResultSize` for a result that cannot be /// carried, `Provider` for one the service refused. +// One argument per fact the stand-in repeats from the invocation it answers. +#[allow(clippy::too_many_arguments)] fn stand_in_failure( caller_runtime_id: &str, request_id: &str, @@ -576,6 +1056,7 @@ fn stand_in_failure( provider_request_sha256: [u8; 32], foreign_reasoning_dropped: u32, kind: FailureKind, + charge: Charge, ) -> TransportResult { let message = match kind { FailureKind::ResultSize => "provider result exceeded its byte bound", @@ -596,9 +1077,12 @@ fn stand_in_failure( model: model.to_string(), provider: STAND_IN_PROVIDER.to_string(), foreign_reasoning_dropped, + cost_micro_usd: charge.cost_micro_usd, + overrun_micro_usd: charge.overrun_micro_usd, outcome: TransportOutcome::Failed { kind, message: message.to_string(), + usage: None, }, }), }, @@ -686,6 +1170,7 @@ mod tests { TransportService::open( &store.path, "fixture-sse", + fixture_prices(), [ caller("epiphany-yggdrasil", "shared-key", 1), caller("ghostlight-yggdrasil", "shared-key", 1), @@ -779,9 +1264,14 @@ mod tests { let mut changed_request = request(); changed_request.instructions.push_str(" Different cargo."); - let conflicting = - TransportInvocation::new("epiphany-yggdrasil", 2_000, [7; 32], changed_request) - .unwrap(); + let conflicting = TransportInvocation::new( + "epiphany-yggdrasil", + 2_000, + [7; 32], + MAX_COST, + changed_request, + ) + .unwrap(); let refusal = match service .begin( &encrypt_invocation(&conflicting, &security).unwrap(), @@ -887,6 +1377,7 @@ mod tests { TransportService::open( &store.path, "fixture-sse", + fixture_prices(), [CallerAdmission::new( "epiphany-yggdrasil", "rotated-connection-key", @@ -895,6 +1386,8 @@ mod tests { 1, 64 * 1024, 32_768, + SpendCeiling(CEILING), + SpendWindowMs(WINDOW_MS) ) .unwrap()], 5_000, @@ -913,7 +1406,8 @@ mod tests { ) -> (TransportAdmission, TransportInvocation) { let mut request = invocation_for(CALLER, request_id).request; edit(&mut request); - let invocation = TransportInvocation::new(CALLER, 2_000, [7; 32], request).unwrap(); + let invocation = + TransportInvocation::new(CALLER, 2_000, [7; 32], MAX_COST, request).unwrap(); let envelope = encrypt_invocation(&invocation, &security(KEY)).unwrap(); (service.begin(&envelope, 1_000, FRAME).unwrap(), invocation) } @@ -993,11 +1487,19 @@ mod tests { 4, max_payload_bytes, 32_768, + SpendCeiling(CEILING), + SpendWindowMs(WINDOW_MS), ) .unwrap(); - TransportService::open(&store.path, "fixture-sse", [admission], 5_000) - .unwrap() - .begin(&envelope, 1_000, FRAME) + TransportService::open( + &store.path, + "fixture-sse", + fixture_prices(), + [admission], + 5_000, + ) + .unwrap() + .begin(&envelope, 1_000, FRAME) }; assert!(matches!(admit(size), Ok(TransportAdmission::Execute(_)))); let refused = admit(size - 1).unwrap(); @@ -1041,7 +1543,10 @@ mod tests { connection_key_epoch: 1, invocation_sha256: [1; 32], expires_at_unix_ms: 5_000, - state: ReplayState::Active, + state: ReplayState::Active { + reserved_micro_usd: 1, + }, + admitted_at_unix_ms: 1_000, }; edit(&mut record); let mut cache = CultCache::new(); @@ -1068,6 +1573,7 @@ mod tests { TransportService::open( &control.path, "fixture-sse", + fixture_prices(), [caller(CALLER, KEY, 1)], 5_000 ) @@ -1075,10 +1581,11 @@ mod tests { ); type Edit = fn(&mut ReplayRecord); - let cases: [(&str, Edit); 3] = [ + let cases: [(&str, Edit); 4] = [ ("wrong-key", |_| {}), ("zero-digest", |record| record.invocation_sha256 = [0; 32]), ("zero-expiry", |record| record.expires_at_unix_ms = 0), + ("zero-admission", |record| record.admitted_at_unix_ms = 0), ]; for (name, edit) in cases { let store = TestStore::new(); @@ -1093,6 +1600,7 @@ mod tests { TransportService::open( &store.path, "fixture-sse", + fixture_prices(), [caller(CALLER, KEY, 1)], 5_000 ), @@ -1133,6 +1641,8 @@ mod tests { .unwrap(); record.state = ReplayState::Completed { response: encrypt_result(&result, &security(KEY)).unwrap(), + charged_micro_usd: 0, + overrun_micro_usd: 0, }; service.replay_store.put(key, &record).unwrap(); assert_eq!( @@ -1144,7 +1654,17 @@ mod tests { #[test] fn identities_are_bounded_at_admission_and_on_the_request() { let admit = |caller_id: String, model: String| { - CallerAdmission::new(caller_id, KEY, 1, [model], 4, 64 * 1024, 32_768) + CallerAdmission::new( + caller_id, + KEY, + 1, + [model], + 4, + 64 * 1024, + 32_768, + SpendCeiling(CEILING), + SpendWindowMs(WINDOW_MS), + ) }; let at = "i".repeat(MAX_IDENTITY_BYTES); let over = "i".repeat(MAX_IDENTITY_BYTES + 1); @@ -1282,6 +1802,7 @@ mod tests { invocation.native_request_sha256, invocation.provider_request_sha256, 0, + FIXTURE_USAGE_COST, ) ); let TransportDisposition::Transported { receipt, .. } = &stand_in.disposition else { @@ -1303,7 +1824,7 @@ mod tests { fn a_result_whose_sealed_envelope_fits_is_recorded_as_the_backend_made_it() { let store = TestStore::new(); let mut service = open_service(&store, 8); - let (claim, invocation) = claim_for(&mut service, "fits"); + let (claim, invocation) = claim_for(&mut service, "request-4"); let small = text_result(claim.call(), "done"); let response = service.complete(*claim, small.clone(), 1 << 20).unwrap(); assert_eq!( @@ -1376,6 +1897,7 @@ mod tests { invocation.native_request_sha256, invocation.provider_request_sha256, 0, + FIXTURE_USAGE_COST, ) ); // The stand-in is what is recorded now, byte for byte, at any frame. @@ -1611,15 +2133,27 @@ mod tests { for tag in ["", " ", STAND_IN_PROVIDER] { assert!( matches!( - TransportService::open(&store.path, tag, [caller(CALLER, KEY, 1)], 5_000), + TransportService::open( + &store.path, + tag, + fixture_prices(), + [caller(CALLER, KEY, 1)], + 5_000 + ), Err(ServiceError::InvalidAdmission) ), "{tag:?}" ); } assert!( - TransportService::open(&store.path, "fixture-sse", [caller(CALLER, KEY, 1)], 5_000) - .is_ok() + TransportService::open( + &store.path, + "fixture-sse", + fixture_prices(), + [caller(CALLER, KEY, 1)], + 5_000 + ) + .is_ok() ); } @@ -1738,6 +2272,7 @@ mod tests { invocation.native_request_sha256, invocation.provider_request_sha256, 1, + FIXTURE_USAGE_COST, ); assert_eq!( decrypt_result(&response, &security(KEY), &invocation).unwrap(), @@ -1767,4 +2302,1318 @@ mod tests { assert_eq!(receipt.provider, STAND_IN_PROVIDER); assert_eq!(receipt.foreign_reasoning_dropped, 1); } + + // ---- spend ---- + + fn usage(input: u64, cache_read: u64, cache_write: u64, output: u64) -> Usage { + Usage { + input_tokens: input, + cache_read_tokens: cache_read, + cache_write_tokens: cache_write, + output_tokens: output, + reasoning_tokens: None, + } + } + + const MTOK: u64 = 1_000_000; + + fn flat_price(rate: u64) -> ModelPrice { + ModelPrice { + rates: Rates { + input: rate, + cache_write_short: rate, + cache_write_long: rate, + cache_read: rate, + output: rate, + }, + long_context: None, + overhead_tokens: 0, + } + } + + fn dearest_input(rates: &Rates, retention: Option) -> u64 { + let write = match retention { + Some(CacheRetention::Short) => rates.cache_write_short, + Some(CacheRetention::Long) | None => rates.cache_write_long, + }; + rates.input.max(rates.cache_read).max(write) + } + + /// ceil(((encoded length + overhead) x dearest input rate + max_output_tokens + /// x output rate) / 1e6), spelled out apart from the service. + fn expected_reservation(request: &ProviderRequest, rates: &Rates, overhead: u64) -> u64 { + let bound = wire_encode(request).unwrap().len() as u128 + u128::from(overhead); + let total = bound * u128::from(dearest_input(rates, request.cache_retention)) + + u128::from(request.max_output_tokens) * u128::from(rates.output); + total.div_ceil(1_000_000) as u64 + } + + /// What every fixture request (request ids of one length) reserves. + fn reservation() -> u64 { + expected_reservation(&request(), &fixture_price().rates, 100) + } + + fn invocation_at(request_id: &str, now: u64, max_cost: u64) -> TransportInvocation { + let mut request = request(); + request.request_id = request_id.to_string(); + TransportInvocation::new(CALLER, now + 1_000, [7; 32], max_cost, request).unwrap() + } + + fn begin_at( + service: &mut TransportService, + request_id: &str, + now: u64, + max_cost: u64, + ) -> (TransportAdmission, TransportInvocation) { + let invocation = invocation_at(request_id, now, max_cost); + let admission = service + .begin(&raw_envelope(&invocation), now, FRAME) + .unwrap(); + (admission, invocation) + } + + fn claim_at(service: &mut TransportService, request_id: &str, now: u64) -> Box { + match begin_at(service, request_id, now, MAX_COST).0 { + TransportAdmission::Execute(claim) => claim, + TransportAdmission::Reply(_) => panic!("{request_id} should have executed"), + } + } + + fn refusal_at( + service: &mut TransportService, + request_id: &str, + now: u64, + max_cost: u64, + ) -> Refusal { + let (admission, invocation) = begin_at(service, request_id, now, max_cost); + refusal_of(admission, &invocation) + } + + fn open_limited(store: &TestStore, ceiling: u64, window_ms: u64) -> TransportService { + TransportService::open( + &store.path, + "fixture-sse", + fixture_prices(), + [CallerAdmission::new( + CALLER, + KEY, + 1, + ["gpt-5.4".to_string()], + 8, + 65_536, + 32_768, + SpendCeiling(ceiling), + SpendWindowMs(window_ms), + ) + .unwrap()], + 5_000, + ) + .unwrap() + } + + fn record_of(service: &TransportService, request_id: &str) -> Option { + service + .replay_store + .get::(&replay_key(&RequestIdentity { + caller_runtime_id: CALLER.to_string(), + request_id: request_id.to_string(), + })) + .unwrap() + } + + fn charged_of(service: &TransportService, request_id: &str) -> u64 { + match record_of(service, request_id).unwrap().state { + ReplayState::Completed { + charged_micro_usd, .. + } => charged_micro_usd, + ReplayState::Active { .. } => panic!("{request_id} is not completed"), + } + } + + fn receipt_of( + response: &TransportEnvelope, + invocation: &TransportInvocation, + ) -> TransportReceipt { + let TransportDisposition::Transported { receipt, .. } = + decrypt_result(response, &security(KEY), invocation) + .unwrap() + .disposition + else { + panic!("expected a transported result"); + }; + *receipt + } + + #[test] + fn cost_prices_each_usage_class_at_its_own_rate() { + let price = fixture_price(); + let cost = |usage: Usage| cost_micro_usd(&price, &usage, Some(CacheRetention::Long)); + assert_eq!(cost(usage(MTOK, 0, 0, 0)), 3_000_000); + assert_eq!(cost(usage(0, MTOK, 0, 0)), 300_000); + assert_eq!(cost(usage(0, 0, MTOK, 0)), 6_000_000); + assert_eq!(cost(usage(0, 0, 0, MTOK)), 15_000_000); + assert_eq!( + cost(usage(2 * MTOK, 3 * MTOK, 5 * MTOK, 7 * MTOK)), + 6_000_000 + 900_000 + 30_000_000 + 105_000_000 + ); + // Reasoning tokens are a detail of the output tokens, never priced apart. + let mut reasoning_only = usage(0, 0, 0, 0); + reasoning_only.reasoning_tokens = Some(MTOK); + assert_eq!(cost(reasoning_only), 0); + let mut detailed = usage(0, 0, 0, MTOK); + detailed.reasoning_tokens = Some(MTOK); + assert_eq!(cost(detailed), 15_000_000); + } + + #[test] + fn cost_rounds_up_once_per_request() { + let price = flat_price(10_000); + let cost = |usage: Usage| cost_micro_usd(&price, &usage, None); + assert_eq!(cost(usage(0, 1, 0, 0)), 1); + assert_eq!(cost(usage(0, 0, 0, 0)), 0); + // Four classes at one token each: 40,000 / 1e6 rounds up once, to 1. + assert_eq!(cost(usage(1, 1, 1, 1)), 1); + assert_eq!(cost(usage(100, 0, 0, 0)), 1); + assert_eq!(cost(usage(101, 0, 0, 0)), 2); + } + + #[test] + fn cache_writes_price_by_retention() { + let price = fixture_price(); + let written = usage(0, 0, MTOK, 0); + assert_eq!( + cost_micro_usd(&price, &written, Some(CacheRetention::Short)), + 3_750_000 + ); + assert_eq!( + cost_micro_usd(&price, &written, Some(CacheRetention::Long)), + 6_000_000 + ); + // A write nobody asked to keep short is priced dear. + assert_eq!(cost_micro_usd(&price, &written, None), 6_000_000); + } + + #[test] + fn long_context_rates_apply_strictly_above_the_threshold() { + let long = Rates { + input: 9_000_000, + cache_write_short: 9_500_000, + cache_write_long: 12_000_000, + cache_read: 900_000, + output: 45_000_000, + }; + let price = |above_prompt_tokens: u64, overhead_tokens: u64| ModelPrice { + long_context: Some(LongContext { + above_prompt_tokens, + rates: long, + }), + overhead_tokens, + ..fixture_price() + }; + // Prompt tokens are input + cache reads + cache writes. + let at_threshold = cost_micro_usd(&price(100, 0), &usage(1, 1, 98, 0), None); + assert_eq!(at_threshold, 592); // 3e6 + 3e5 + 98 x 6e6 = 591.3e6, base rates + let above = cost_micro_usd(&price(100, 0), &usage(1, 1, 99, 0), None); + assert_eq!(above, 1198); // 9e6 + 9e5 + 99 x 12e6 = 1197.9e6, long rates + assert_eq!( + cost_micro_usd(&price(100, 0), &usage(100, 0, 0, 0), None), + 300 + ); + assert_eq!( + cost_micro_usd(&price(100, 0), &usage(101, 0, 0, 0), None), + 909 + ); + + // The reservation switches at the same strict threshold, on its byte bound. + let request = request(); + let length = wire_encode(&request).unwrap().len() as u64; + let bound = length + 7; + assert_eq!( + reservation_micro_usd(&price(bound, 7), &request) + .unwrap() + .unwrap(), + expected_reservation(&request, &fixture_price().rates, 7) + ); + assert_eq!( + reservation_micro_usd(&price(bound - 1, 7), &request) + .unwrap() + .unwrap(), + expected_reservation(&request, &long, 7) + ); + } + + #[test] + fn cost_saturates_instead_of_wrapping() { + let price = fixture_price(); + for huge in [ + usage(u64::MAX, 0, 0, 0), + usage(0, 0, u64::MAX, 0), + usage(0, 0, 0, u64::MAX), + usage(u64::MAX, u64::MAX, u64::MAX, u64::MAX), + ] { + assert_eq!(cost_micro_usd(&price, &huge, None), u64::MAX, "{huge:?}"); + } + // A class that does not overflow is exact: 300,000 x u64::MAX / 1e6, rounded up. + assert_eq!( + cost_micro_usd(&price, &usage(0, u64::MAX, 0, 0), None), + 5_534_023_222_112_865_485 + ); + } + + #[test] + fn reservation_is_the_byte_bound_plus_max_output() { + let rate_sets = [ + (fixture_price().rates, 100), + // The dearest input rate is a cache read here... + ( + Rates { + input: 1_000_000, + cache_write_short: 2_000_000, + cache_write_long: 3_000_000, + cache_read: 8_000_000, + output: 5_000_000, + }, + 7, + ), + // ...and plain input here. + ( + Rates { + input: 9_000_000, + cache_write_short: 1_000_000, + cache_write_long: 2_000_000, + cache_read: 3_000_000, + output: 4_000_000, + }, + 0, + ), + ]; + for (rates, overhead_tokens) in rate_sets { + let price = ModelPrice { + rates, + long_context: None, + overhead_tokens, + }; + for retention in [ + None, + Some(CacheRetention::Short), + Some(CacheRetention::Long), + ] { + let mut request = request(); + request.cache_retention = retention; + assert_eq!( + reservation_micro_usd(&price, &request).unwrap().unwrap(), + expected_reservation(&request, &rates, overhead_tokens), + "{rates:?} {retention:?}" + ); + } + } + // More output costs at least its price, and the bound follows the encoding + // (a wider integer is more bytes). + let price = fixture_price(); + let mut reserved = Vec::new(); + for max_output_tokens in [1, 1_000, 1_001_000] { + let mut request = request(); + request.max_output_tokens = max_output_tokens; + let amount = reservation_micro_usd(&price, &request).unwrap().unwrap(); + assert_eq!( + amount, + expected_reservation(&request, &price.rates, 100), + "{max_output_tokens}" + ); + reserved.push(amount); + } + assert!(reserved[2] - reserved[1] >= 15_000_000); + } + + #[test] + fn worst_case_over_max_cost_is_refused_cost_bound_inclusively() { + let store = TestStore::new(); + let mut service = open_service(&store, 1); + let reserved = reservation(); + let (admission, _) = begin_at(&mut service, "request-1", 1_000, reserved); + assert!(matches!(admission, TransportAdmission::Execute(_))); + // The only slot is taken: a request refused for its cost is refused + // for that, before the replay lookup and the capacity check. + assert_eq!( + refusal_at(&mut service, "request-2", 1_000, reserved - 1), + Refusal::CostBound + ); + assert!(record_of(&service, "request-2").is_none()); + assert_eq!(service.window_spend(CALLER, 0), reserved); + assert_eq!( + refusal_at(&mut service, "request-3", 1_000, reserved), + Refusal::Capacity + ); + } + + #[test] + fn max_cost_zero_is_malformed() { + let store = TestStore::new(); + let mut service = open_service(&store, 4); + assert_eq!( + refusal_at(&mut service, "request-1", 1_000, 0), + Refusal::Malformed + ); + assert!(record_of(&service, "request-1").is_none()); + } + + #[test] + fn a_changed_max_cost_is_a_replay_conflict() { + let store = TestStore::new(); + let mut service = open_service(&store, 4); + let claim = claim_at(&mut service, "request-1", 1_000); + let result = text_result(claim.call(), "done"); + service.complete(*claim, result, FRAME).unwrap(); + assert_eq!( + refusal_at(&mut service, "request-1", 1_000, MAX_COST + 1), + Refusal::ReplayConflict + ); + } + + #[test] + fn spend_ceiling_refuses_inclusively_and_resets_next_window() { + let reserved = reservation(); + let window = 10_000; + let store = TestStore::new(); + let mut service = open_limited(&store, 2 * reserved, window); + claim_at(&mut service, "request-1", 1_000); + // Windows are aligned to the epoch: the last millisecond of window 0. + claim_at(&mut service, "request-2", 9_999); + assert_eq!(service.window_spend(CALLER, 0), 2 * reserved); + assert_eq!( + refusal_at(&mut service, "request-3", 9_999, MAX_COST), + Refusal::SpendCeiling + ); + assert!(record_of(&service, "request-3").is_none()); + // The first millisecond of window 1: the same request id is admitted. + claim_at(&mut service, "request-3", 10_000); + assert_eq!(service.window_spend(CALLER, 0), 2 * reserved); + assert_eq!(service.window_spend(CALLER, 1), reserved); + + // One micro-USD below the two reservations refuses the second. + let store = TestStore::new(); + let mut service = open_limited(&store, 2 * reserved - 1, window); + claim_at(&mut service, "request-1", 1_000); + assert_eq!( + refusal_at(&mut service, "request-2", 1_000, MAX_COST), + Refusal::SpendCeiling + ); + } + + #[test] + fn a_recorded_answer_replays_past_an_exhausted_ceiling() { + let reserved = reservation(); + let store = TestStore::new(); + let mut service = open_limited(&store, reserved, 3_600_000); + let (admission, _) = begin_at(&mut service, "request-1", 1_000, MAX_COST); + let TransportAdmission::Execute(claim) = admission else { + panic!("the first request fits the ceiling exactly"); + }; + let result = text_result(claim.call(), "done"); + let response = service.complete(*claim, result, FRAME).unwrap(); + // Spent 75 of a ceiling that holds one reservation: nothing else fits. + assert_eq!( + refusal_at(&mut service, "request-2", 1_000, MAX_COST), + Refusal::SpendCeiling + ); + // The recorded answer is replayed whatever the caller has left. + let (replay, _) = begin_at(&mut service, "request-1", 1_000, MAX_COST); + assert_eq!(replay, TransportAdmission::Reply(response)); + } + + #[test] + fn settlement_replaces_the_reservation_with_the_charge() { + let store = TestStore::new(); + let mut service = open_service(&store, 4); + let claim = claim_at(&mut service, "request-1", 1_000); + assert_eq!(service.window_spend(CALLER, 0), reservation()); + let result = text_result(claim.call(), "done"); + service.complete(*claim, result, FRAME).unwrap(); + assert_eq!(service.window_spend(CALLER, 0), FIXTURE_USAGE_COST); + assert_eq!(charged_of(&service, "request-1"), FIXTURE_USAGE_COST); + } + + #[test] + fn failed_with_usage_charges_usage_and_failed_without_keeps_the_reservation() { + let failed = |usage: Option| TransportOutcome::Failed { + kind: FailureKind::Provider, + message: "the provider refused".to_string(), + usage, + }; + let store = TestStore::new(); + let mut service = open_service(&store, 4); + + let claim = claim_at(&mut service, "request-1", 1_000); + let result = claim + .call() + .transported(Vec::new(), failed(Some(usage(0, 0, 0, 0)))); + let invocation = invocation_at("request-1", 1_000, MAX_COST); + let response = service.complete(*claim, result, FRAME).unwrap(); + assert_eq!(receipt_of(&response, &invocation).cost_micro_usd, 0); + assert_eq!(charged_of(&service, "request-1"), 0); + assert_eq!(service.window_spend(CALLER, 0), 0); + + let claim = claim_at(&mut service, "request-2", 1_000); + let result = claim + .call() + .transported(Vec::new(), failed(Some(usage(MTOK, 0, 0, 0)))); + service.complete(*claim, result, FRAME).unwrap(); + assert_eq!(charged_of(&service, "request-2"), 3_000_000); + + let claim = claim_at(&mut service, "request-3", 1_000); + let result = claim.call().transported(Vec::new(), failed(None)); + let invocation = invocation_at("request-3", 1_000, MAX_COST); + let response = service.complete(*claim, result, FRAME).unwrap(); + assert_eq!( + receipt_of(&response, &invocation).cost_micro_usd, + reservation() + ); + assert_eq!(charged_of(&service, "request-3"), reservation()); + assert_eq!(service.window_spend(CALLER, 0), 3_000_000 + reservation()); + } + + #[test] + fn a_backend_cost_misreport_is_answered_with_the_stand_in() { + let edits: [fn(u64) -> u64; 3] = [|cost| cost + 1, |cost| cost - 1, |_| 0]; + for (index, edit) in edits.into_iter().enumerate() { + let store = TestStore::new(); + let mut service = open_service(&store, 4); + let id = format!("request-{index}"); + let claim = claim_at(&mut service, &id, 1_000); + let mut result = text_result(claim.call(), "done"); + let TransportDisposition::Transported { receipt, .. } = &mut result.disposition else { + unreachable!(); + }; + receipt.cost_micro_usd = edit(receipt.cost_micro_usd); + let invocation = invocation_at(&id, 1_000, MAX_COST); + let response = service.complete(*claim, result, FRAME).unwrap(); + let receipt = receipt_of(&response, &invocation); + assert_eq!(receipt.provider, STAND_IN_PROVIDER, "{index}"); + // A result the service refused is still billed what its usage costs. + assert_eq!(receipt.cost_micro_usd, FIXTURE_USAGE_COST, "{index}"); + assert_eq!(charged_of(&service, &id), FIXTURE_USAGE_COST, "{index}"); + assert_eq!( + service.window_spend(CALLER, 0), + FIXTURE_USAGE_COST, + "{index}" + ); + } + } + + #[test] + fn result_size_stand_in_keeps_the_real_charge() { + let store = TestStore::new(); + let mut service = open_service(&store, 4); + let claim = claim_at(&mut service, "request-1", 1_000); + let big = text_result(claim.call(), &"y".repeat(4000)); + let invocation = invocation_at("request-1", 1_000, MAX_COST); + let response = service.complete(*claim, big, 2000).unwrap(); + let receipt = receipt_of(&response, &invocation); + assert_eq!(receipt.provider, STAND_IN_PROVIDER); + assert_eq!(receipt.cost_micro_usd, FIXTURE_USAGE_COST); + assert_eq!(charged_of(&service, "request-1"), FIXTURE_USAGE_COST); + assert_eq!(service.window_spend(CALLER, 0), FIXTURE_USAGE_COST); + + // The replay path: a wide record re-measured at a smaller frame. + let claim = claim_at(&mut service, "request-2", 1_000); + let big = text_result(claim.call(), &"y".repeat(4000)); + service.complete(*claim, big, FRAME).unwrap(); + drop(service); + let mut service = open_service(&store, 4); + let invocation = invocation_at("request-2", 1_000, MAX_COST); + let TransportAdmission::Reply(smaller) = service + .begin(&raw_envelope(&invocation), 1_000, 2000) + .unwrap() + else { + panic!("expected a replay"); + }; + let receipt = receipt_of(&smaller, &invocation); + assert_eq!(receipt.provider, STAND_IN_PROVIDER); + assert_eq!(receipt.cost_micro_usd, FIXTURE_USAGE_COST); + assert_eq!(charged_of(&service, "request-2"), FIXTURE_USAGE_COST); + assert_eq!(service.window_spend(CALLER, 0), 2 * FIXTURE_USAGE_COST); + } + + #[test] + fn a_ledger_write_failure_executes_nothing() { + let refusing = FlakyStore::new(0); + let healthy = refusing.reopened(5); + let mut service = open_flaky(refusing); + let invocation = invocation_for(CALLER, "request-1"); + assert!(matches!( + service.begin(&raw_envelope(&invocation), 1_000, FRAME), + Err(ServiceError::ReplayStore(_)) + )); + assert_eq!(service.window_spend(CALLER, 0), 0); + assert!(service.live.is_empty()); + assert!(record_of(&service, "request-1").is_none()); + drop(service); + + // Nothing was written, so the same ledger admits the request afresh. + let mut service = open_flaky(healthy); + claim_at(&mut service, "request-1", 1_000); + } + + #[test] + fn a_settle_write_failure_keeps_the_reservation_counted() { + let one_write = FlakyStore::new(1); + let restart = one_write.reopened(5); + let mut service = open_flaky(one_write); + let claim = claim_at(&mut service, "request-1", 1_000); + let result = text_result(claim.call(), "done"); + assert!(matches!( + service.complete(*claim, result, FRAME), + Err(ServiceError::ReplayStore(_)) + )); + assert_eq!(service.window_spend(CALLER, 0), reservation()); + assert!(matches!( + record_of(&service, "request-1").unwrap().state, + ReplayState::Active { .. } + )); + drop(service); + + // A restart bills the indeterminate request at its whole reservation. + let service = open_flaky(restart); + assert_eq!(service.window_spend(CALLER, 0), reservation()); + } + + #[test] + fn restart_rederives_window_spend_from_the_records() { + let reserved = reservation(); + let window = 10_000; + let store = TestStore::new(); + let mut service = open_limited(&store, 1 << 40, window); + // An earlier window: completed. + let claim = claim_at(&mut service, "request-1", 1_000); + let result = text_result(claim.call(), "done"); + service.complete(*claim, result, FRAME).unwrap(); + // The current window: one completed, one left Active by a crash. + let claim = claim_at(&mut service, "request-2", 10_500); + let result = text_result(claim.call(), "done"); + service.complete(*claim, result, FRAME).unwrap(); + claim_at(&mut service, "request-3", 10_600); + drop(service); + + let mut service = open_limited(&store, FIXTURE_USAGE_COST + 2 * reserved, window); + // Only the current window is kept: the earlier one is pruned. + assert_eq!(service.window_spend(CALLER, 0), 0); + assert_eq!( + service.window_spend(CALLER, 1), + FIXTURE_USAGE_COST + reserved + ); + // The ceiling sees the rebuilt window: one more reservation fits it + // exactly, and the next does not. The earlier window counts for nothing. + claim_at(&mut service, "request-4", 11_000); + assert_eq!( + refusal_at(&mut service, "request-5", 11_100, MAX_COST), + Refusal::SpendCeiling + ); + } + + #[test] + fn open_refuses_an_unpriced_model_and_admits_a_zero_price() { + let store = TestStore::new(); + assert_eq!( + TransportService::open( + &store.path, + "fixture-sse", + PriceTable::from([("another-model".to_string(), fixture_price())]), + [caller(CALLER, KEY, 1)], + 5_000, + ) + .err(), + Some(ServiceError::UnpricedModel("gpt-5.4".to_string())) + ); + + let free = PriceTable::from([("gpt-5.4".to_string(), flat_price(0))]); + let mut service = TransportService::open( + &store.path, + "fixture-sse", + free, + [caller(CALLER, KEY, 1)], + 5_000, + ) + .unwrap(); + let claim = claim_at(&mut service, "request-1", 1_000); + assert_eq!(service.window_spend(CALLER, 0), 0); + let result = text_result(claim.call(), "done"); + service.complete(*claim, result, FRAME).unwrap(); + assert_eq!(charged_of(&service, "request-1"), 0); + assert_eq!(service.window_spend(CALLER, 0), 0); + } + + #[test] + fn a_ceiling_and_a_window_are_required_nonzero() { + let admit = |ceiling: u64, window_ms: u64| { + CallerAdmission::new( + CALLER, + KEY, + 1, + ["gpt-5.4".to_string()], + 4, + 64 * 1024, + 32_768, + SpendCeiling(ceiling), + SpendWindowMs(window_ms), + ) + }; + assert!(admit(1, 1).is_ok()); + assert!(matches!(admit(0, 1), Err(ServiceError::InvalidAdmission))); + assert!(matches!(admit(1, 0), Err(ServiceError::InvalidAdmission))); + } + + // ---- settle past the reservation, pruning, extremes ---- + + fn overrun_result(call: &ProviderCall, text: &str) -> TransportResult { + let mut outcome = receipt(&invocation_for(CALLER, "outcome")).outcome; + let TransportOutcome::Completed { usage, .. } = &mut outcome else { + unreachable!(); + }; + // 2 million output tokens at 15,000,000 per million: 30,000,000. + *usage = self::usage(0, 0, 0, 2 * MTOK); + call.transported( + vec![TransportEvent { + sequence: 0, + payload: TransportEventPayload::TextDelta { + text: text.to_string(), + }, + }], + outcome, + ) + } + + const OVERRUN_COST: u64 = 30_000_000; + + #[test] + fn an_overrun_settles_at_the_true_cost_and_the_receipt_says_so() { + let reserved = reservation(); + let store = TestStore::new(); + let mut service = open_limited(&store, 2 * reserved, 3_600_000); + let claim = claim_at(&mut service, "request-1", 1_000); + let result = overrun_result(claim.call(), "done"); + let invocation = invocation_at("request-1", 1_000, MAX_COST); + let response = service.complete(*claim, result, FRAME).unwrap(); + let receipt = receipt_of(&response, &invocation); + // The provider's own answer, not a stand-in, carrying the true cost + // and the marker. + assert_eq!(receipt.provider, "fixture-sse"); + assert_eq!(receipt.cost_micro_usd, OVERRUN_COST); + assert_eq!(receipt.overrun_micro_usd, OVERRUN_COST - reserved); + assert_eq!(charged_of(&service, "request-1"), OVERRUN_COST); + assert_eq!(service.window_spend(CALLER, 0), OVERRUN_COST); + // The ceiling holds for every later request in the window. + assert_eq!( + refusal_at(&mut service, "request-2", 1_000, MAX_COST), + Refusal::SpendCeiling + ); + // A result inside its reservation carries no marker. + drop(service); + let mut service = open_service(&store, 4); + let claim = claim_at(&mut service, "request-3", 1_000); + let result = text_result(claim.call(), "done"); + let invocation = invocation_at("request-3", 1_000, MAX_COST); + let response = service.complete(*claim, result, FRAME).unwrap(); + assert_eq!(receipt_of(&response, &invocation).overrun_micro_usd, 0); + } + + #[test] + fn an_overrun_survives_the_result_size_stand_in_and_its_replay() { + let reserved = reservation(); + let store = TestStore::new(); + let mut service = open_service(&store, 4); + let claim = claim_at(&mut service, "request-1", 1_000); + let big = overrun_result(claim.call(), &"y".repeat(4000)); + let invocation = invocation_at("request-1", 1_000, MAX_COST); + let response = service.complete(*claim, big, 2000).unwrap(); + let receipt = receipt_of(&response, &invocation); + assert_eq!(receipt.provider, STAND_IN_PROVIDER); + assert_eq!(receipt.cost_micro_usd, OVERRUN_COST); + assert_eq!(receipt.overrun_micro_usd, OVERRUN_COST - reserved); + + let claim = claim_at(&mut service, "request-2", 1_000); + let big = overrun_result(claim.call(), &"y".repeat(4000)); + service.complete(*claim, big, FRAME).unwrap(); + drop(service); + let mut service = open_service(&store, 4); + let invocation = invocation_at("request-2", 1_000, MAX_COST); + let TransportAdmission::Reply(smaller) = service + .begin(&raw_envelope(&invocation), 1_000, 2000) + .unwrap() + else { + panic!("expected a replay"); + }; + let receipt = receipt_of(&smaller, &invocation); + assert_eq!(receipt.provider, STAND_IN_PROVIDER); + assert_eq!(receipt.cost_micro_usd, OVERRUN_COST); + assert_eq!(receipt.overrun_micro_usd, OVERRUN_COST - reserved); + } + + #[test] + fn a_backend_overrun_misreport_is_answered_with_the_stand_in() { + let reserved = reservation(); + for (index, overrun) in [1, 0].into_iter().enumerate() { + let store = TestStore::new(); + let mut service = open_service(&store, 4); + let id = format!("request-{index}"); + let claim = claim_at(&mut service, &id, 1_000); + // The first result is inside its reservation and claims an overrun; + // the second overran and claims none. + let mut result = if index == 0 { + text_result(claim.call(), "done") + } else { + overrun_result(claim.call(), "done") + }; + let TransportDisposition::Transported { receipt, .. } = &mut result.disposition else { + unreachable!(); + }; + receipt.overrun_micro_usd = overrun; + let invocation = invocation_at(&id, 1_000, MAX_COST); + let response = service.complete(*claim, result, FRAME).unwrap(); + let receipt = receipt_of(&response, &invocation); + // The service prices the usage itself: the stand-in and the ledger + // carry the true cost and the true overrun, whatever was claimed. + let (cost, true_overrun) = if index == 0 { + (FIXTURE_USAGE_COST, 0) + } else { + (OVERRUN_COST, OVERRUN_COST - reserved) + }; + assert_eq!(receipt.provider, STAND_IN_PROVIDER, "{index}"); + assert_eq!(receipt.cost_micro_usd, cost, "{index}"); + assert_eq!(receipt.overrun_micro_usd, true_overrun, "{index}"); + assert_eq!(charged_of(&service, &id), cost, "{index}"); + assert_eq!(service.window_spend(CALLER, 0), cost, "{index}"); + } + } + + #[test] + fn the_window_map_stays_bounded_live_and_after_reopen() { + let window = 10; + let store = TestStore::new(); + let mut service = open_limited(&store, 1 << 40, window); + let held = + |service: &TransportService| service.window_spend.get(CALLER).map_or(0, |w| w.len()); + for index in 0..300u64 { + let id = format!("request-{index}"); + let claim = claim_at(&mut service, &id, 1_000 + index * window); + let result = text_result(claim.call(), "done"); + service.complete(*claim, result, FRAME).unwrap(); + assert_eq!(held(&service), 1, "{index}"); + } + // A request still in flight keeps its own window until it settles. + let early = claim_at(&mut service, "request-e", 1_000 + 300 * window); + claim_at(&mut service, "request-l", 1_000 + 305 * window); + assert_eq!(held(&service), 2); + let result = text_result(early.call(), "done"); + service.complete(*early, result, FRAME).unwrap(); + assert_eq!(held(&service), 1); + drop(service); + let service = open_limited(&store, 1 << 40, window); + assert_eq!(held(&service), 1); + assert_eq!( + service.window_spend(CALLER, 1_000 / window + 305), + reservation() + ); + } + + #[test] + fn a_reservation_that_overflows_is_refused_cost_bound_not_wrapped() { + for price in [ + ModelPrice { + overhead_tokens: u64::MAX, + ..flat_price(u64::MAX) + }, + ModelPrice { + overhead_tokens: u64::MAX, + ..flat_price(1 << 40) + }, + ] { + assert_eq!(reservation_micro_usd(&price, &request()).unwrap(), None); + let store = TestStore::new(); + let mut service = TransportService::open( + &store.path, + "fixture-sse", + PriceTable::from([("gpt-5.4".to_string(), price)]), + [caller(CALLER, KEY, 4)], + 5_000, + ) + .unwrap(); + // Not even a caller that bounds its request at u64::MAX can afford it. + assert_eq!( + refusal_at(&mut service, "request-1", 1_000, u64::MAX), + Refusal::CostBound + ); + assert!(record_of(&service, "request-1").is_none()); + } + } + + #[test] + fn a_recorded_answer_replays_after_a_price_raise() { + let store = TestStore::new(); + let mut service = open_service(&store, 4); + let (admission, _) = begin_at(&mut service, "request-1", 1_000, reservation()); + let TransportAdmission::Execute(claim) = admission else { + panic!("expected an execution claim"); + }; + let result = text_result(claim.call(), "done"); + service.complete(*claim, result, FRAME).unwrap(); + drop(service); + + let mut dear = fixture_price(); + dear.rates.output *= 2; + let mut service = TransportService::open( + &store.path, + "fixture-sse", + PriceTable::from([("gpt-5.4".to_string(), dear)]), + [caller(CALLER, KEY, 4)], + 5_000, + ) + .unwrap(); + let (admission, invocation) = begin_at(&mut service, "request-1", 1_000, reservation()); + let TransportAdmission::Reply(response) = admission else { + panic!("expected the recorded answer"); + }; + assert_eq!( + receipt_of(&response, &invocation).cost_micro_usd, + FIXTURE_USAGE_COST + ); + // New work is still checked against today's prices. + assert_eq!( + refusal_at(&mut service, "request-2", 1_000, reservation()), + Refusal::CostBound + ); + } + + #[test] + fn a_settle_write_failure_releases_the_slot_and_keeps_the_reservation() { + let store = FlakyStore::new(1); + let allowance = store.allowance(); + let mut service = open_flaky(store); + let claim = claim_at(&mut service, "request-1", 1_000); + let result = text_result(claim.call(), "done"); + assert!(matches!( + service.complete(*claim, result, FRAME), + Err(ServiceError::ReplayStore(_)) + )); + assert!(service.live.is_empty()); + assert_eq!(service.window_spend(CALLER, 0), reservation()); + // The store recovers: the caller's only slot is free again, and the + // unsettled request is told it is indeterminate, not in flight. + allowance.store(5, std::sync::atomic::Ordering::SeqCst); + claim_at(&mut service, "request-2", 1_000); + assert_eq!(service.window_spend(CALLER, 0), 2 * reservation()); + let (admission, invocation) = begin_at(&mut service, "request-1", 1_000, MAX_COST); + assert_eq!(refusal_of(admission, &invocation), Refusal::Indeterminate); + } + + fn open_flaky(store: FlakyStore) -> TransportService { + TransportService::with_store( + store, + "fixture-sse", + fixture_prices(), + [caller(CALLER, KEY, 1)], + 5_000, + ) + .unwrap() + } + + fn run_window(service: &mut TransportService, prefix: &str, now: u64) -> u32 { + let mut admitted = 0; + for i in 0..20u64 { + let (admission, _) = begin_at(service, &format!("{prefix}-{i:02}"), now, MAX_COST); + let TransportAdmission::Execute(claim) = admission else { + break; + }; + admitted += 1; + let result = text_result(claim.call(), "done"); + service.complete(*claim, result, FRAME).unwrap(); + } + admitted + } + + #[test] + fn a_clock_stepping_back_cannot_reopen_the_callers_budget() { + let window = 10_000; + // Room for the reservation plus two settled requests: three admitted. + let ceiling = reservation() + 2 * FIXTURE_USAGE_COST; + let control_store = TestStore::new(); + let mut control = open_limited(&control_store, ceiling, window); + assert_eq!(run_window(&mut control, "back", 20_000), 3); + + let store = TestStore::new(); + let mut service = open_limited(&store, ceiling, window); + let claim = claim_at(&mut service, "ahead", 50_000); + let result = text_result(claim.call(), "done"); + service.complete(*claim, result, FRAME).unwrap(); + // The clock now reads window 2, behind the recorded window 5: the + // request belongs to window 5, which already holds one. + assert_eq!(run_window(&mut service, "back", 20_000), 2); + assert_eq!(service.window_spend(CALLER, 5), 3 * FIXTURE_USAGE_COST); + assert_eq!(service.window_spend(CALLER, 2), 0); + drop(service); + + let mut service = open_limited(&store, ceiling, window); + assert_eq!(service.window_spend(CALLER, 5), 3 * FIXTURE_USAGE_COST); + assert_eq!(run_window(&mut service, "again", 20_100), 0); + assert_eq!( + refusal_at(&mut service, "last", 20_200, MAX_COST), + Refusal::ClockBehindAdmissionWindow + ); + } + + #[test] + fn a_clock_jumping_forward_opens_a_fresh_window() { + let window = 10_000; + let ceiling = reservation() + 2 * FIXTURE_USAGE_COST; + let store = TestStore::new(); + let mut service = open_limited(&store, ceiling, window); + assert_eq!(run_window(&mut service, "early", 20_000), 3); + assert_eq!(run_window(&mut service, "later", 90_000), 3); + assert_eq!(service.window_spend(CALLER, 9), 3 * FIXTURE_USAGE_COST); + assert_eq!(service.window_spend(CALLER, 2), 0); + } + + /// Hand-written rates: the long tier is cheaper on input and dearer on + /// output, so neither tier alone bounds both. + fn mixed_long_tier(above_prompt_tokens: u64) -> ModelPrice { + ModelPrice { + rates: Rates { + input: 3_000_000, + cache_write_short: 3_750_000, + cache_write_long: 6_000_000, + cache_read: 300_000, + output: 15_000_000, + }, + long_context: Some(LongContext { + above_prompt_tokens, + rates: Rates { + input: 1_000_000, + cache_write_short: 1_250_000, + cache_write_long: 2_000_000, + cache_read: 100_000, + output: 30_000_000, + }, + }), + overhead_tokens: 100, + } + } + + #[test] + fn the_reservation_is_the_dearer_of_both_tiers_class_by_class() { + let request = request(); + let bound = wire_encode(&request).unwrap().len() as u64 + 100; + // Above the threshold: input at the base tier's dearer rate, output at + // the long tier's. + let dearest = Rates { + input: 3_000_000, + cache_write_short: 3_750_000, + cache_write_long: 6_000_000, + cache_read: 300_000, + output: 30_000_000, + }; + assert_eq!( + reservation_micro_usd(&mixed_long_tier(bound - 1), &request).unwrap(), + Some(expected_reservation(&request, &dearest, 100)) + ); + // At the threshold the base tier alone applies. + assert_eq!( + reservation_micro_usd(&mixed_long_tier(bound), &request).unwrap(), + Some(expected_reservation(&request, &fixture_price().rates, 100)) + ); + } + + fn xorshift(state: &mut u64) -> u64 { + *state ^= *state << 13; + *state ^= *state >> 7; + *state ^= *state << 17; + *state + } + + fn random_rates(state: &mut u64) -> Rates { + Rates { + input: xorshift(state) % 50_000_000, + cache_write_short: xorshift(state) % 50_000_000, + cache_write_long: xorshift(state) % 50_000_000, + cache_read: xorshift(state) % 50_000_000, + output: xorshift(state) % 200_000_000, + } + } + + #[test] + fn the_property_test_generator_is_xorshift64_within_the_rate_bounds() { + let mut s = 0x9E37_79B9_7F4A_7C15u64; + let first: Vec = (0..3).map(|_| xorshift(&mut s)).collect(); + assert_eq!( + first, + [0xdc1b77ae0bf34dad, 0x64f0eeb9026e6076, 0x7b07ce91e5906136] + ); + for _ in 0..200 { + let rates = random_rates(&mut s); + for (rate, bound) in [ + (rates.input, 50_000_000), + (rates.cache_write_short, 50_000_000), + (rates.cache_write_long, 50_000_000), + (rates.cache_read, 50_000_000), + (rates.output, 200_000_000), + ] { + assert!(rate < bound); + } + } + } + + /// Any usage within the bound the reservation assumes (prompt tokens no + /// more than encoded bytes plus overhead, output no more than the cap) + /// costs no more than the reservation, whatever the price shape. + #[test] + fn no_usage_within_the_bound_prices_above_the_reservation() { + let mut s = 0x9E37_79B9_7F4A_7C15u64; + for _ in 0..5_000 { + let mut request = request(); + request.max_output_tokens = (xorshift(&mut s) % 100_000) as u32; + request.cache_retention = match xorshift(&mut s) % 3 { + 0 => None, + 1 => Some(CacheRetention::Short), + _ => Some(CacheRetention::Long), + }; + request.instructions = "x".repeat((xorshift(&mut s) % 3000) as usize); + let overhead = xorshift(&mut s) % 5_000; + let bound = wire_encode(&request).unwrap().len() as u64 + overhead; + let long_context = (xorshift(&mut s).is_multiple_of(2)).then(|| LongContext { + above_prompt_tokens: xorshift(&mut s) % (2 * bound + 1), + rates: random_rates(&mut s), + }); + let price = ModelPrice { + rates: random_rates(&mut s), + long_context, + overhead_tokens: overhead, + }; + let reserved = reservation_micro_usd(&price, &request).unwrap().unwrap(); + let prompt = if xorshift(&mut s).is_multiple_of(4) { + bound + } else { + xorshift(&mut s) % (bound + 1) + }; + let a = xorshift(&mut s) % (prompt + 1); + let b = xorshift(&mut s) % (prompt - a + 1); + let max_output = u64::from(request.max_output_tokens); + let output = if xorshift(&mut s).is_multiple_of(4) { + max_output + } else { + xorshift(&mut s) % (max_output + 1) + }; + let cost = cost_micro_usd( + &price, + &usage(a, b, prompt - a - b, output), + request.cache_retention, + ); + assert!( + cost <= reserved, + "{price:?} {a} {b} {output}: {cost} > {reserved}" + ); + } + } + + #[test] + fn another_callers_live_request_does_not_keep_this_callers_old_window() { + let store = TestStore::new(); + let mut service = open_service(&store, 4); + // Ghostlight holds a request live in window 0 throughout. + let other = invocation_for("ghostlight-yggdrasil", "request-1"); + let envelope = + encrypt_invocation(&other, &security("ghostlight-distinct-test-key")).unwrap(); + let TransportAdmission::Execute(_held) = service.begin(&envelope, 1_000, FRAME).unwrap() + else { + panic!("ghostlight should have executed"); + }; + for (id, now) in [("first", 1_000), ("second", WINDOW_MS + 1_000)] { + let claim = claim_at(&mut service, id, now); + let result = text_result(claim.call(), "done"); + service.complete(*claim, result, FRAME).unwrap(); + } + assert_eq!(service.window_spend(CALLER, 0), 0); + assert_eq!(service.window_spend(CALLER, 1), FIXTURE_USAGE_COST); + assert_eq!( + service.window_spend("ghostlight-yggdrasil", 0), + reservation() + ); + } + + #[test] + fn a_result_bound_to_another_request_is_charged_the_reservation() { + let store = TestStore::new(); + let mut service = open_service(&store, 4); + let one = claim_at(&mut service, "request-1", 1_000); + let two = claim_at(&mut service, "request-2", 1_000); + // request-2's result reports no usage at all; request-1 must not be + // billed what another request's backend claims to have spent. + let foreign = two.call().transported( + Vec::new(), + TransportOutcome::Failed { + kind: FailureKind::Provider, + message: String::new(), + usage: Some(usage(0, 0, 0, 0)), + }, + ); + let invocation = invocation_at("request-1", 1_000, MAX_COST); + let response = service.complete(*one, foreign, FRAME).unwrap(); + assert_eq!( + receipt_of(&response, &invocation).provider, + STAND_IN_PROVIDER + ); + assert_eq!(charged_of(&service, "request-1"), reservation()); + // request-1 is charged the reservation; request-2 still holds its own. + assert_eq!(service.window_spend(CALLER, 0), 2 * reservation()); + drop(two); + } + + #[test] + fn a_bound_malformed_overrun_is_charged_its_true_cost_and_reaches_the_ceiling() { + let reserved = reservation(); + for case in 0..3 { + let store = TestStore::new(); + let mut service = open_limited(&store, 2 * reserved, 3_600_000); + let claim = claim_at(&mut service, "request-1", 1_000); + let mut result = overrun_result(claim.call(), "done"); + let TransportDisposition::Transported { events, receipt } = &mut result.disposition + else { + unreachable!(); + }; + match case { + 0 => events[0].sequence = 1, + 1 => { + let TransportOutcome::Completed { usage, .. } = &receipt.outcome else { + unreachable!(); + }; + receipt.outcome = TransportOutcome::Failed { + kind: FailureKind::Provider, + message: String::new(), + usage: Some(usage.clone()), + }; + } + _ => receipt.provider = String::new(), + } + let invocation = invocation_at("request-1", 1_000, MAX_COST); + assert!(result.validate_against(&invocation).is_err(), "{case}"); + let response = service.complete(*claim, result, FRAME).unwrap(); + // The caller is told the result was refused; the bill is its true cost. + assert_eq!( + receipt_of(&response, &invocation).provider, + STAND_IN_PROVIDER, + "{case}" + ); + assert_eq!(charged_of(&service, "request-1"), OVERRUN_COST, "{case}"); + assert_eq!(service.window_spend(CALLER, 0), OVERRUN_COST, "{case}"); + assert_eq!( + refusal_at(&mut service, "request-2", 1_000, MAX_COST), + Refusal::SpendCeiling, + "{case}" + ); + } + } + + #[test] + fn each_binding_check_alone_sends_a_result_to_the_reservation() { + let reserved = reservation(); + // Every other field is valid and the usage overruns: a guard that + // ignored the one changed identity field would charge 30,000,000. + for case in 0..4 { + let store = TestStore::new(); + let mut service = open_limited(&store, 2 * reserved, 3_600_000); + let claim = claim_at(&mut service, "request-1", 1_000); + let mut result = overrun_result(claim.call(), "done"); + let TransportDisposition::Transported { receipt, .. } = &mut result.disposition else { + unreachable!(); + }; + match case { + 0 => result.request_id = "request-2".to_string(), + 1 => result.caller_runtime_id = "another-caller".to_string(), + 2 => receipt.request_id = "request-2".to_string(), + _ => receipt.caller_runtime_id = "another-caller".to_string(), + } + service.complete(*claim, result, FRAME).unwrap(); + assert_eq!(charged_of(&service, "request-1"), reserved, "{case}"); + assert_eq!(service.window_spend(CALLER, 0), reserved, "{case}"); + } + } + + #[test] + fn a_result_that_is_not_transported_is_charged_the_reservation() { + let store = TestStore::new(); + let mut service = open_service(&store, 4); + let claim = claim_at(&mut service, "request-1", 1_000); + let invocation = invocation_at("request-1", 1_000, MAX_COST); + let refused = TransportResult::refused(&invocation, Refusal::Capacity); + service.complete(*claim, refused, FRAME).unwrap(); + assert_eq!(charged_of(&service, "request-1"), reservation()); + assert_eq!(service.window_spend(CALLER, 0), reservation()); + } + + #[test] + fn a_stepped_back_clock_admits_into_the_newest_window_not_an_older_live_one() { + let window = 10_000; + // Two live reservations fill window 9 (the newest); one live + // reservation leaves window 2 (older, still held) room for another. + // Request ids share a length, so every reservation is the same size. + let ceiling = reservation() + reservation() + reservation() / 2; + let store = TestStore::new(); + let mut service = open_limited(&store, ceiling, window); + let old = claim_at(&mut service, "request-a", 20_000); + let newest = [ + claim_at(&mut service, "request-b", 90_000), + claim_at(&mut service, "request-c", 90_000), + ]; + assert_eq!(service.window_spend(CALLER, 2), reservation()); + assert_eq!(service.window_spend(CALLER, 9), 2 * reservation()); + // The clock reads window 2 again. Window 2 would hold the request; + // the caller's window is the newest, which cannot. + assert_eq!( + refusal_at(&mut service, "request-d", 20_500, MAX_COST), + Refusal::ClockBehindAdmissionWindow + ); + assert_eq!(service.window_spend(CALLER, 2), reservation()); + assert_eq!(service.window_spend(CALLER, 9), 2 * reservation()); + drop((old, newest)); + } + + #[test] + fn a_clock_behind_the_callers_window_is_refused_by_name_until_it_catches_up() { + let window = 10_000; + let ceiling = reservation() + 2 * FIXTURE_USAGE_COST; + let store = TestStore::new(); + let mut service = open_limited(&store, ceiling, window); + assert_eq!(run_window(&mut service, "fill", 50_000), 3); + // Behind the full window: the cause is named, no slot is taken and + // nothing is reserved. + assert_eq!( + refusal_at(&mut service, "behind", 20_000, MAX_COST), + Refusal::ClockBehindAdmissionWindow + ); + assert!(service.live.is_empty()); + assert_eq!(service.window_spend(CALLER, 5), 3 * FIXTURE_USAGE_COST); + // At the window's first instant, and inside it, the clock is not + // behind: an honest ceiling. + assert_eq!( + refusal_at(&mut service, "at-start", 50_000, MAX_COST), + Refusal::SpendCeiling + ); + assert_eq!( + refusal_at(&mut service, "inside", 50_500, MAX_COST), + Refusal::SpendCeiling + ); + // Once the clock reaches the next window the caller is admitted. + assert_eq!(run_window(&mut service, "later", 60_000), 3); + } + + #[test] + fn the_clock_behind_refusal_is_no_wider_than_the_widest_other_refusal() { + let invocation = invocation_at("request-1", 1_000, MAX_COST); + let sized = |refusal| { + wire_encode(&TransportResult::refused(&invocation, refusal)) + .unwrap() + .len() + }; + assert!( + sized(Refusal::ClockBehindAdmissionWindow) + <= sized(Refusal::ProviderDigestSubstitution) + ); + } } diff --git a/crates/model-connector/tests/fixtures/wire_v3/carriage_invocation.frame b/crates/model-connector/tests/fixtures/wire_v3/carriage_invocation.frame index 6051b64..b531c71 100644 --- a/crates/model-connector/tests/fixtures/wire_v3/carriage_invocation.frame +++ b/crates/model-connector/tests/fixtures/wire_v3/carriage_invocation.frame @@ -1 +1 @@ -‡©messageId®golden-request©operation¦invoke§payloadÚ$hqlzY2hlbWFfaWTZJGdhbWVjdWx0Lm1vZGVsLnRyYW5zcG9ydF9lbnZlbG9wZS52M7FjYWxsZXJfcnVudGltZV9pZK1nb2xkZW4tY2FsbGVyqnJlcXVlc3RfaWSuZ29sZGVuLXJlcXVlc3SsbWVzc2FnZV9raW5kqkludm9jYXRpb26lbm9uY2WcS2XMwjgRzMrM9QTM037MsczmqmNpcGhlcnRleHTFAy8eYmQGV5JCOLETwqQE+wIz/6yyPltHTPywbakMD/mgkJUxAvKWLNljXa4RAVVITAb3GxklnGF8zoOX/9qGSM9r0JZ126s+0LqvsRNhllG1S0pfG49fQJy99pevHReOSvX/cNWs2cHppYNdJUbVVAkDuioEXwtEs/vRCsmD/ab0bKUX0XRzrNbmjMU4+dHUCus8yt+iNTK9GdjXbEQAweeKNehirXEgnngLnwylo/MDoI4G237du3PmwhMzyIHztoIbwD0Bv9x7WqY+jj90vqEBsOHl+FmYc+F8+OiP6NKTDNRHypUtdo32G/IgfMfbsP/XHiNpPHtLxDqZVIcYdYqLAvhDfLBReLzQVLuNJnE0a2P8xLVAkfbmQZNsxRqS14RjYVtRoDcacxH1h0afo9OWMSK8Sp8eEBAhAJ7ovhCMklc7xWB3o4ny4cmDSExx5zWRLdI14rC1CZ7nq8k3V8fIIHctSp6am35A5yyiTwDH0m3h7/hvXrzhOqtAwHd0jkfNv+qOXZCEPdtbzOXOz0fMVZwvTAJWhoEYmhfKcGvXu8SmEtZcZe0WjF4xpFIFCFsSxh8HcrDFvxKDa58bnimo6DE2VeEMolpaBvnIGEst6qNZ9VQQnQIpCGbbtFd7jMrncnfcq+36NI10qW30r8sqAqgORp8cAGP5fy2X9SQDmH13SXgSfIdr7uxAHhK0X1r0iopJn4XtVfBiTd+IfoLFN+KO/JTZvLc48e6Jl9QRnxkL/UCgT/5wENd9VRxP+oO1CXG3DthI882rVbZDkhhwxupM9J/EURONajVyVSkeju+r9vzHCgR5odRW8cwqY1RXoq6L6mhj4ph01MBkUugz8MFwBsem+8QpnGatyVbZB99knWjK/01o5J6SZihcXNowRdon6BTydFByJaV5bRvgIK7IGRct3BmC8xidyxN9p7QAZYMPscxhQ0hAM615w76Cy82kx6eAzREBQX3COKApXSlPA/xlOKa/iUUMWHvFKyf/n0KrOlVvHjoDzPflHi3hpxxWcQGt8CxxNdSQMufeqce/5/BCeEwRIJ99+k7CEqIqpFIs7cAsI1GpF+NO5w==¯payloadEncoding²messagepack-base64­payloadSchemaÙ$gamecult.model.transport_envelope.v3­schemaVersion¼cultnet.operation_request.v0©serviceId¸gamecult.model.connector \ No newline at end of file +‡©messageId®golden-request©operation¦invoke§payloadÚDhqlzY2hlbWFfaWTZJGdhbWVjdWx0Lm1vZGVsLnRyYW5zcG9ydF9lbnZlbG9wZS52M7FjYWxsZXJfcnVudGltZV9pZK1nb2xkZW4tY2FsbGVyqnJlcXVlc3RfaWSuZ29sZGVuLXJlcXVlc3SsbWVzc2FnZV9raW5kqkludm9jYXRpb26lbm9uY2WczO7MxRrMxw7MhTh7SMzwzNB2qmNpcGhlcnRleHTFA0d65EskLG6kIwJ/G+XDK03gZOGtLFi6vZHJbI+/KpRLwnLT0TPF7RQa0KD98CrW+VwkKY3sRa4+Z9+/5OdPq1YmDFwbxvMVjZsFjXXw84tzvuLKHsb/3kQSEvAlFE4K6FXHvznLcMo4CnGUzoXwNGALf+xDTqFG0aFbdG4xGynV/kttsL6f34F4+l6xiXMUy87Q8/1UyOK/YaVxMMmlT3tdTllhq8YU8Dvfq0KuMaj/f9Anya8A5bJwlxafZPtc53RoH1/oNjLdaDqvwrTEnTB9TvKlnEOxKyyKhmmwGaC03qqPJXRaj8cDQO6EHDbML4J0zMN8TvssQISd5UCeVMkvDGjmvEOmRUoPk1/SnSrIHdha4+6rU4lUSumloLmlx5vv++Mq4AqzOdd/vPB6Ov/AoF32X27o4RI6H3xCKKHjKC4m3+cVjXCrOcGojuRf9yeDFkgbiPaC31hmSc8rAm6HO/NcaIHvx+G+6AaurAEhDLdF6DdrXb4xwjuv6yWQKihNvLXpnwmhRFkSeD5lS6FHW+CoOIdq/RBnFFyDrboTPWFoxQYIrBuqFoHDOajIz7dXYif0sf9sB9JBrDhlcbTpiGcyURfzDhW55NOPqKFGi6ropaJ1/SyLP3f7E57kA22aQ2Op7o36Tu06Pnzo2wI7UsL/Qz27F+AGAukQCvTJyjxARiGr3TRpChjVXx6GXhLrG1T9P+Mrhyrt9vwwhboN8qz1uvDZwKhGWBKXPXkxluq/NY1DwB3JTCCZfEwukWjMZbqiINLEKvW9BStcBR1kfXcK6sQvn/F0g9r81e9brbe+w3U24GERnHwgruOqytTeax/xPbQ7uISV48g5L8DAYf788KhUXwI43QDHNBdBUyo0rCsgoWJKeO05fcAgKgYYGsyLtMW10Vstw3q4POyn3o7rrBz4S2cnl2iZgr0ixg/KgF0V+loEVuj/6rYrcW0SLCHwV1+w1ClFMn0ZPsrz28EoZMA617G0Rrch66cS5uI9/Zf/wLX3z6n7xoPLNXk51ZsLSZ2bqEQkcBJbW/dT5MdH6anWVogRDvIW1coUbkIq9l+pkQSuelWsfakX4UQwYuUD0UwNbqxHiGwlEFj0f1bdhEj26Q==¯payloadEncoding²messagepack-base64­payloadSchemaÙ$gamecult.model.transport_envelope.v3­schemaVersion¼cultnet.operation_request.v0©serviceId¸gamecult.model.connector \ No newline at end of file diff --git a/crates/model-connector/tests/fixtures/wire_v3/transport_invocation.mp b/crates/model-connector/tests/fixtures/wire_v3/transport_invocation.mp index 95447869386911a8ebd04061ff2f8126e337e8a1..dff0ca28b054caeaea7a24b39f301fb7aba54c9d 100644 GIT binary patch delta 38 ucmbQwww;Z!eInyWiA}kQ74gaW#U=5%naM@@@ukHn=NNpv9X5W_WdZ;uY7a30 delta 14 VcmdnaHlK~LZ6f2x&0LJSOaLVh1X=(9 diff --git a/crates/model-connector/tests/fixtures/wire_v3/transport_receipt_failed.mp b/crates/model-connector/tests/fixtures/wire_v3/transport_receipt_failed.mp index 0c5feb28476fc1102939f7a750b06510305c5243..385a3188e4656e1de2360443cb81bdd64346d8da 100644 GIT binary patch delta 165 zcmZo~ diff --git a/crates/model-connector/tests/fixtures/wire_v3/transport_result_transported.mp b/crates/model-connector/tests/fixtures/wire_v3/transport_result_transported.mp index b85b43448f0a2afb42cb4d04961e2b3f3e75634b..79e6ebd3cd6a3a79292b265afe20da18941a2f1a 100644 GIT binary patch delta 58 zcmbQlzLR~!c1FhT%{v$^8P(S%=NFg6=Vm4s<;Ry6r<`LDy4Joizbv(=s5B2D969+T G(?S5Pu^34J delta 18 acmdnVK8bz9c1Fg|%{v$^87E6HF9ZNU7zRNA diff --git a/crates/model-connector/tests/published_schemas.rs b/crates/model-connector/tests/published_schemas.rs index 8a1bf47..15ce197 100644 --- a/crates/model-connector/tests/published_schemas.rs +++ b/crates/model-connector/tests/published_schemas.rs @@ -110,6 +110,9 @@ fn the_refusal_schema_is_one_flat_string_enum() { .iter() .map(|value| value.as_str().unwrap()) .collect(); - assert_eq!(variants.len(), 12); + assert_eq!(variants.len(), 15); + assert!(variants.contains(&"ClockBehindAdmissionWindow")); assert!(variants.contains(&"ForeignReasoningState")); + assert!(variants.contains(&"CostBound")); + assert!(variants.contains(&"SpendCeiling")); } diff --git a/crates/model-connector/tests/reasoning_state.rs b/crates/model-connector/tests/reasoning_state.rs index 559bd2f..f42e0f1 100644 --- a/crates/model-connector/tests/reasoning_state.rs +++ b/crates/model-connector/tests/reasoning_state.rs @@ -106,6 +106,8 @@ fn start_with(backend: Arc, max_concurrent: usize) -> Conne max_concurrent, 64 * 1024, 32_768, + SpendCeiling(1 << 40), + SpendWindowMs(3_600_000), ) .unwrap(); let limits = ServeLimits { @@ -114,7 +116,21 @@ fn start_with(backend: Arc, max_concurrent: usize) -> Conne socket_timeout_ms: 5_000, max_expiry_skew_ms: 300_000, }; - thread::spawn(move || serve(listener, limits, vec![admission], &store, backend)); + let prices = PriceTable::from([( + "claude-opus-4-5".to_string(), + ModelPrice { + rates: Rates { + input: 5_000_000, + cache_write_short: 6_250_000, + cache_write_long: 10_000_000, + cache_read: 500_000, + output: 25_000_000, + }, + long_context: None, + overhead_tokens: 100, + }, + )]); + thread::spawn(move || serve(listener, limits, vec![admission], prices, &store, backend)); ConnectorClient::new(endpoint, KEY, FRAME, Some(Duration::from_secs(5))).unwrap() } @@ -127,7 +143,7 @@ fn invocation(request_id: &str, input: Vec) -> TransportInvocation { 4_096, ); request.input = input; - TransportInvocation::new(CALLER, now_ms() + 60_000, [7; 32], request).unwrap() + TransportInvocation::new(CALLER, now_ms() + 60_000, [7; 32], 10_000_000, request).unwrap() } fn user(text: &str) -> InputItem { @@ -313,9 +329,10 @@ impl ProviderBackend for TamperingBackend { #[test] fn a_refused_completion_reaches_the_caller_as_a_typed_failure_and_frees_the_slot() { - let edits: [fn(&mut TransportReceipt); 2] = [ + let edits: [fn(&mut TransportReceipt); 3] = [ |receipt| receipt.provider = "another-provider".to_string(), |receipt| receipt.foreign_reasoning_dropped += 1, + |receipt| receipt.cost_micro_usd += 1, ]; for edit in edits { // One concurrent request: a wedged slot would refuse the next id. diff --git a/crates/model-connector/tests/wire_golden_v3.rs b/crates/model-connector/tests/wire_golden_v3.rs index dddf9ef..9364035 100644 --- a/crates/model-connector/tests/wire_golden_v3.rs +++ b/crates/model-connector/tests/wire_golden_v3.rs @@ -81,7 +81,7 @@ fn request() -> ProviderRequest { } fn invocation() -> TransportInvocation { - TransportInvocation::new(CALLER, 1_800_000_000_000, [7; 32], request()).unwrap() + TransportInvocation::new(CALLER, 1_800_000_000_000, [7; 32], 5_000_000, request()).unwrap() } fn receipt(outcome: TransportOutcome) -> TransportReceipt { @@ -95,6 +95,8 @@ fn receipt(outcome: TransportOutcome) -> TransportReceipt { model: invocation.request.model.clone(), provider: "anthropic-messages".to_string(), foreign_reasoning_dropped: 2, + cost_micro_usd: 1_234_567, + overrun_micro_usd: 89, outcome, } } @@ -168,6 +170,13 @@ fn wire_golden_v3() { let failed = receipt(TransportOutcome::Failed { kind: FailureKind::RateLimited, message: "provider asked to slow down".to_string(), + usage: Some(Usage { + input_tokens: 120, + cache_read_tokens: 0, + cache_write_tokens: 0, + output_tokens: 0, + reasoning_tokens: None, + }), }); assert_eq!(failed.validate_against(&invocation), Ok(())); assert_wire("transport_receipt_failed.mp", &failed); diff --git a/schemas/transport_invocation.json b/schemas/transport_invocation.json index 5435b2e..7c32147 100644 --- a/schemas/transport_invocation.json +++ b/schemas/transport_invocation.json @@ -11,6 +11,12 @@ "format": "uint64", "minimum": 0 }, + "max_cost_micro_usd": { + "description": "The most this request may cost, in micro-USD: the caller's own bound\non it. Required nonzero. It is part of the invocation's identity, so a\nchanged budget under a recorded request id is a `ReplayConflict`.", + "type": "integer", + "format": "uint64", + "minimum": 0 + }, "native_request_sha256": { "type": "array", "items": { @@ -47,6 +53,7 @@ "expires_at_unix_ms", "native_request_sha256", "provider_request_sha256", + "max_cost_micro_usd", "request" ], "$defs": { diff --git a/schemas/transport_receipt.json b/schemas/transport_receipt.json index 722a42a..01ff920 100644 --- a/schemas/transport_receipt.json +++ b/schemas/transport_receipt.json @@ -6,6 +6,12 @@ "caller_runtime_id": { "type": "string" }, + "cost_micro_usd": { + "description": "What the request cost, in micro-USD. The service derives it from the\noutcome's usage and its price table; a backend never writes it.", + "type": "integer", + "format": "uint64", + "minimum": 0 + }, "foreign_reasoning_dropped": { "description": "How many foreign `ReasoningState` items the service removed from the\nrequest before the backend saw it.", "type": "integer", @@ -29,6 +35,12 @@ "outcome": { "$ref": "#/$defs/TransportOutcome" }, + "overrun_micro_usd": { + "description": "How far `cost_micro_usd` exceeds what the service reserved for the\nrequest: zero unless the backend reported usage above the worst case\nthe request allowed, which is a breach of the backend's contract. The\ncost is still the true one and the caller's window is charged all of\nit. The service derives this; a backend never writes it.", + "type": "integer", + "format": "uint64", + "minimum": 0 + }, "provider": { "type": "string" }, @@ -60,6 +72,8 @@ "model", "provider", "foreign_reasoning_dropped", + "cost_micro_usd", + "overrun_micro_usd", "outcome" ], "$defs": { @@ -132,6 +146,17 @@ }, "message": { "type": "string" + }, + "usage": { + "description": "What the provider billed for the failed request: `Some` when it\nreported usage (`Some` of zeros when it billed nothing), `None`\nwhen unknown.", + "anyOf": [ + { + "$ref": "#/$defs/Usage" + }, + { + "type": "null" + } + ] } }, "additionalProperties": false, @@ -149,7 +174,7 @@ ] }, "Usage": { - "description": "Token accounting for one completed request. Cache reads and writes are\ncounted apart from plain input; `reasoning_tokens` is absent when the\nprovider does not report it.", + "description": "Token accounting for one request, as the backend reports it; the service\nprices it. Cache reads and writes are counted apart from plain input.\n`output_tokens` includes reasoning tokens: `reasoning_tokens` is a detail\n(absent when the provider does not report it) and is never priced apart.", "type": "object", "properties": { "cache_read_tokens": { diff --git a/schemas/transport_result.json b/schemas/transport_result.json index acdf6b6..39e010f 100644 --- a/schemas/transport_result.json +++ b/schemas/transport_result.json @@ -46,12 +46,15 @@ "ModelNotAllowed", "OutputBound", "PayloadBound", + "CostBound", + "SpendCeiling", "Capacity", "InFlight", "Indeterminate", "ReplayConflict", "Malformed", - "ForeignReasoningState" + "ForeignReasoningState", + "ClockBehindAdmissionWindow" ] }, "StopReason": { @@ -250,6 +253,17 @@ }, "message": { "type": "string" + }, + "usage": { + "description": "What the provider billed for the failed request: `Some` when it\nreported usage (`Some` of zeros when it billed nothing), `None`\nwhen unknown.", + "anyOf": [ + { + "$ref": "#/$defs/Usage" + }, + { + "type": "null" + } + ] } }, "additionalProperties": false, @@ -272,6 +286,12 @@ "caller_runtime_id": { "type": "string" }, + "cost_micro_usd": { + "description": "What the request cost, in micro-USD. The service derives it from the\noutcome's usage and its price table; a backend never writes it.", + "type": "integer", + "format": "uint64", + "minimum": 0 + }, "foreign_reasoning_dropped": { "description": "How many foreign `ReasoningState` items the service removed from the\nrequest before the backend saw it.", "type": "integer", @@ -295,6 +315,12 @@ "outcome": { "$ref": "#/$defs/TransportOutcome" }, + "overrun_micro_usd": { + "description": "How far `cost_micro_usd` exceeds what the service reserved for the\nrequest: zero unless the backend reported usage above the worst case\nthe request allowed, which is a breach of the backend's contract. The\ncost is still the true one and the caller's window is charged all of\nit. The service derives this; a backend never writes it.", + "type": "integer", + "format": "uint64", + "minimum": 0 + }, "provider": { "type": "string" }, @@ -326,11 +352,13 @@ "model", "provider", "foreign_reasoning_dropped", + "cost_micro_usd", + "overrun_micro_usd", "outcome" ] }, "Usage": { - "description": "Token accounting for one completed request. Cache reads and writes are\ncounted apart from plain input; `reasoning_tokens` is absent when the\nprovider does not report it.", + "description": "Token accounting for one request, as the backend reports it; the service\nprices it. Cache reads and writes are counted apart from plain input.\n`output_tokens` includes reasoning tokens: `reasoning_tokens` is a detail\n(absent when the provider does not report it) and is never priced apart.", "type": "object", "properties": { "cache_read_tokens": { From 200933f7cf90261a590bc84ba2b4e3917c127da3 Mon Sep 17 00:00:00 2001 From: Eureka Date: Thu, 29 Oct 2026 11:03:03 +0100 Subject: [PATCH 27/69] B4a claude-code-backend: Claude backend driving the unmodified claude binary on the operator's subscription; tool-less turns, per-turn usage, process-group kill (squash of eureka-body/claude-code-backend e0c0832..341be82; Soul s1, s2) Co-Authored-By: Claude Opus 5.5 --- Cargo.lock | 14 + README.md | 17 +- crates/claude-connector/Cargo.toml | 20 + crates/claude-connector/src/backend.rs | 263 +++ crates/claude-connector/src/backend/tests.rs | 1851 ++++++++++++++++++ crates/claude-connector/src/config.rs | 35 + crates/claude-connector/src/handle.rs | 266 +++ crates/claude-connector/src/launch.rs | 148 ++ crates/claude-connector/src/lib.rs | 19 + crates/claude-connector/src/process.rs | 232 +++ crates/claude-connector/src/stream.rs | 295 +++ 11 files changed, 3158 insertions(+), 2 deletions(-) create mode 100644 crates/claude-connector/Cargo.toml create mode 100644 crates/claude-connector/src/backend.rs create mode 100644 crates/claude-connector/src/backend/tests.rs create mode 100644 crates/claude-connector/src/config.rs create mode 100644 crates/claude-connector/src/handle.rs create mode 100644 crates/claude-connector/src/launch.rs create mode 100644 crates/claude-connector/src/lib.rs create mode 100644 crates/claude-connector/src/process.rs create mode 100644 crates/claude-connector/src/stream.rs diff --git a/Cargo.lock b/Cargo.lock index a3cbdf7..34dec06 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -134,6 +134,20 @@ dependencies = [ "inout", ] +[[package]] +name = "claude-connector" +version = "0.1.0" +dependencies = [ + "libc", + "model-connector", + "rmp-serde", + "serde", + "serde_json", + "sha2", + "thiserror", + "zeroize", +] + [[package]] name = "const-oid" version = "0.9.6" diff --git a/README.md b/README.md index 596f571..339e793 100644 --- a/README.md +++ b/README.md @@ -4,8 +4,8 @@ The connector kit for GameCult minds: one typed, encrypted, replay-safe contract between a caller that needs a model turn and a daemon that holds the provider credential, so no project wires its own provider calls. -Status: the `model-connector` crate is live. No connector binary exists yet -(the Claude connector is first, then an OpenAI-compatible one). +Status: the `model-connector` crate and the `claude-connector` backend crate are live. No +connector binary exists yet (the Claude connector binary is first, then an OpenAI-compatible one). ## Entry points @@ -32,6 +32,19 @@ Crate `crates/model-connector`: `ProviderBackend` trait. A connector binary implements `ProviderBackend` for its provider and calls `serve`; the kit names no provider. +Crate `crates/claude-connector` (library only; the binary that wires it to `serve` is not written): + +- `ClaudeCodeBackend` implements `ProviderBackend` for provider `claude-code` by driving the + unmodified `claude` binary, one child per call, in print stream-json mode with no tools, no MCP + servers, no settings sources and the system prompt replaced. Tool-less turns only: a request + with tools, an output schema or a final tool result is refused before any spawn. +- Reasoning state is a session handle (`ClaudeCodeHandle`); a handle that cannot be proven to + match the request re-seeds from the caller's transcript, and every resume forks. +- `ProcessPort` is the one OS seam (`StdProcessPort`, unix, kills the child's whole process + group); `SessionStore` and `StderrSink` are the other ports. `ClaudeCodeConfig` is what the + binary hands in; the credential is read by name from the connector's own environment + (`CLAUDE_CODE_OAUTH_TOKEN`) and copied only into the child. + Frame arithmetic is public in `src/envelope.rs`, measured on the carriage frame: `frame_bytes_for_payload(n)` is the frame a caller payload bound of `n` needs, `payload_bytes_for_frame(frame)` is the largest payload bound a frame allows, and `MAX_IDENTITY_BYTES` (256) bounds caller ids, request ids and model names. diff --git a/crates/claude-connector/Cargo.toml b/crates/claude-connector/Cargo.toml new file mode 100644 index 0000000..a35bb4a --- /dev/null +++ b/crates/claude-connector/Cargo.toml @@ -0,0 +1,20 @@ +[package] +name = "claude-connector" +description = "Claude backend for the connector kit: drives the unmodified claude binary in print stream-json mode" +version.workspace = true +edition.workspace = true +license.workspace = true +repository.workspace = true +publish = false + +[dependencies] +model-connector = { path = "../model-connector", features = ["daemon"] } +rmp-serde = "1" +serde = { version = "1", features = ["derive"] } +serde_json = "1" +sha2 = "0.10" +thiserror = "2" +zeroize = "1" + +[target.'cfg(unix)'.dependencies] +libc = "0.2" diff --git a/crates/claude-connector/src/backend.rs b/crates/claude-connector/src/backend.rs new file mode 100644 index 0000000..ad3fb8c --- /dev/null +++ b/crates/claude-connector/src/backend.rs @@ -0,0 +1,263 @@ +use std::fs::File; +use std::io; +use std::time::Instant; + +use model_connector::{ + FailureKind, InputItem, ProviderBackend, ProviderCall, ProviderRequest, TransportEvent, + TransportEventPayload, TransportOutcome, TransportResult, Usage, +}; +use sha2::{Digest, Sha256}; +use zeroize::Zeroizing; + +use crate::config::{ClaudeCodeConfig, PROVIDER}; +use crate::handle::{ClaudeCodeHandle, SessionStore, plan, prefix_digest, stdin_bytes}; +use crate::launch::{PromptFile, TOKEN_VAR, argv, child_env}; +use crate::process::{ProcessPort, RunningChild, StdoutRead}; +use crate::stream::{Failure, Finished, Step, StreamState}; + +/// How the backend reads its own environment. +type EnvLookup = Box Option + Send + Sync>; + +/// Where the tail of a child's stderr goes. The backend never puts it in a +/// result; the connector binary wires this to its log. +pub trait StderrSink: Send + Sync { + fn stderr_tail(&self, tail: &[u8]); +} + +/// Answers each call with one `claude` child process: never more than one per +/// call, and a failed call is never run again. +pub struct ClaudeCodeBackend { + config: ClaudeCodeConfig, + port: Box, + sessions: Box, + sink: Box, + env_lookup: EnvLookup, +} + +impl ClaudeCodeBackend { + /// The backend over real processes and the CLI's real session store. + #[cfg(unix)] + pub fn new(config: ClaudeCodeConfig, sink: Box) -> Self { + let port = Box::new(crate::process::StdProcessPort::new(config.limits)); + let sessions = Box::new(crate::handle::FsSessionStore::new( + config.config_dir.clone(), + )); + Self::with_ports(config, port, sessions, sink) + } + + pub fn with_ports( + config: ClaudeCodeConfig, + port: Box, + sessions: Box, + sink: Box, + ) -> Self { + Self { + config, + port, + sessions, + sink, + env_lookup: Box::new(|name| std::env::var(name).ok()), + } + } + + fn token(&self) -> Option> { + (self.env_lookup)(TOKEN_VAR) + .filter(|value| !value.is_empty()) + .map(Zeroizing::new) + } + + fn binary_matches_pin(&self) -> bool { + let Ok(mut file) = File::open(&self.config.claude_binary) else { + return false; + }; + let mut hasher = Sha256::new(); + io::copy(&mut file, &mut hasher).is_ok() + && <[u8; 32]>::from(hasher.finalize()) == self.config.claude_binary_sha256 + } + + fn run(&self, request: &ProviderRequest) -> (Vec, TransportOutcome) { + let plan = match plan(request, &self.config, self.sessions.as_ref()) { + Ok(plan) => plan, + Err(error) => { + return failed( + FailureKind::InvalidRequest, + error.to_string(), + Some(zero_usage()), + ); + } + }; + let Some(token) = self.token() else { + return failed( + FailureKind::Authentication, + "claude-code: the connector has no credential to give the child".to_string(), + Some(zero_usage()), + ); + }; + let Ok(prompt) = PromptFile::create(&request.instructions) else { + return failure(Failure::unbilled( + FailureKind::Provider, + "claude-code: the system prompt could not be staged", + )); + }; + let argv = argv(&self.config, request, &plan, prompt.path()); + let env = child_env(&self.config, request, token); + let deadline = Instant::now() + self.config.limits.deadline; + + let spawned = self.port.spawn( + &argv, + &env, + &self.config.work_dir, + stdin_bytes(plan.blocks()), + ); + let Ok(mut child) = spawned else { + return failure(Failure::unbilled( + FailureKind::Provider, + "claude-code: the child could not be spawned", + )); + }; + let finished = read_stream(child.as_mut(), deadline, &request.model); + child.kill(); + child.wait(); + let tail = child.stderr_tail(); + if !tail.is_empty() { + self.sink.stderr_tail(&tail); + } + + match finished { + Ok(finished) => self.completed(request, finished), + Err(failure_) => failure(failure_), + } + } + + fn completed( + &self, + request: &ProviderRequest, + finished: Finished, + ) -> (Vec, TransportOutcome) { + let mut payloads: Vec = finished + .texts + .iter() + .map(|text| TransportEventPayload::TextDelta { text: text.clone() }) + .collect(); + + let mut prefix = request.input.clone(); + if !finished.texts.is_empty() { + prefix.push(InputItem::AssistantText { + text: finished.texts.concat(), + }); + } + let handle = ClaudeCodeHandle::Ended { + session_id: finished.session_id, + instance: self.config.instance.clone(), + prefix_sha256: prefix_digest(&request.instructions, &request.model, &prefix), + }; + payloads.push(TransportEventPayload::ReasoningState { + state: handle.encode(), + }); + + let events = payloads + .into_iter() + .enumerate() + .map(|(sequence, payload)| TransportEvent { + sequence: sequence as u64, + payload, + }) + .collect(); + ( + events, + TransportOutcome::Completed { + provider_response_id: finished.message_id, + stop_reason: finished.stop_reason, + usage: finished.usage, + }, + ) + } +} + +impl ProviderBackend for ClaudeCodeBackend { + fn ready(&self) -> bool { + self.config.work_dir.is_dir() + && self.config.config_dir.is_dir() + && self.token().is_some() + && self.binary_matches_pin() + } + + fn provider(&self) -> &str { + PROVIDER + } + + fn execute(&self, call: &ProviderCall) -> TransportResult { + let (events, outcome) = self.run(call.request()); + call.transported(events, outcome) + } +} + +fn read_stream( + child: &mut dyn RunningChild, + deadline: Instant, + model: &str, +) -> Result { + let mut state = StreamState::new(model); + let finished = loop { + match child.read_line(deadline) { + StdoutRead::Line(line) => { + if let Step::Done(done) = state.feed(&line) { + break *done; + } + } + StdoutRead::Eof => { + return Err(Failure::unbilled( + FailureKind::Provider, + "claude-code: the stream ended without a result", + )); + } + StdoutRead::Timeout => { + return Err(Failure::unbilled( + FailureKind::Timeout, + "claude-code: the deadline passed before a result", + )); + } + StdoutRead::Breach => { + return Err(Failure::unbilled( + FailureKind::Provider, + "claude-code: the output passed its size cap", + )); + } + } + }; + // The result is in; give the child until its deadline to close stdout. + while matches!(child.read_line(deadline), StdoutRead::Line(_)) {} + finished +} + +fn zero_usage() -> Usage { + Usage { + input_tokens: 0, + cache_read_tokens: 0, + cache_write_tokens: 0, + output_tokens: 0, + reasoning_tokens: None, + } +} + +fn failed( + kind: FailureKind, + message: String, + usage: Option, +) -> (Vec, TransportOutcome) { + ( + Vec::new(), + TransportOutcome::Failed { + kind, + message, + usage, + }, + ) +} + +fn failure(failure: Failure) -> (Vec, TransportOutcome) { + failed(failure.kind, failure.message.to_string(), failure.usage) +} + +#[cfg(test)] +mod tests; diff --git a/crates/claude-connector/src/backend/tests.rs b/crates/claude-connector/src/backend/tests.rs new file mode 100644 index 0000000..309e2d1 --- /dev/null +++ b/crates/claude-connector/src/backend/tests.rs @@ -0,0 +1,1851 @@ +use std::collections::VecDeque; +use std::path::{Path, PathBuf}; +use std::sync::atomic::{AtomicUsize, Ordering}; +use std::sync::{Arc, Mutex}; +use std::time::Duration; + +use model_connector::{ + CallerAdmission, ModelPrice, PriceTable, Rates, ReasoningEffort, SpendCeiling, SpendWindowMs, + ToolDefinition, TransportAdmission, TransportDisposition, TransportInvocation, TransportKey, + TransportReceipt, TransportService, decrypt_result, encrypt_invocation, +}; + +use super::*; +use crate::config::ClaudeCodeLimits; +use crate::handle::{FsSessionStore, SessionStore}; +use crate::launch::ChildEnv; + +const CANARY_TOKEN: &str = "canary-oauth-token-value"; +const MODEL: &str = "claude-haiku-5-5"; +const INSTANCE: &str = "persona-a"; +const SESSION: &str = "11111111-2222-3333-4444-555555555555"; +const HEX_SESSION: &str = "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee"; +const OTHER_MODEL: &str = "claude-opus-5"; +const FORK: &str = "66666666-7777-8888-9999-000000000000"; +const FRAME: usize = 1 << 20; + +// ---- fixtures, written by hand from the fields the probes observed ---- + +fn init_line(model: &str, session: &str) -> String { + format!( + r#"{{"type":"system","subtype":"init","session_id":"{session}","model":"{model}","tools":[],"apiKeySource":"none"}}"# + ) +} + +fn text_line(id: &str, text: &str) -> String { + format!( + r#"{{"type":"assistant","message":{{"id":"{id}","role":"assistant","content":[{{"type":"text","text":"{text}"}}],"stop_reason":null}},"session_id":"{SESSION}"}}"# + ) +} + +fn thinking_line(id: &str) -> String { + format!( + r#"{{"type":"assistant","message":{{"id":"{id}","role":"assistant","content":[{{"type":"thinking","thinking":"","signature":"sig"}}]}}}}"# + ) +} + +fn tool_use_line() -> String { + r#"{"type":"assistant","message":{"id":"msg_t","content":[{"type":"tool_use","id":"toolu_1","name":"x","input":{}}]}}"#.to_string() +} + +fn rate_limit_line(status: &str) -> String { + format!( + r#"{{"type":"rate_limit_event","rate_limit_info":{{"status":"{status}","rateLimitType":"five_hour"}}}}"# + ) +} + +const USAGE: &str = r#"{"claude-haiku-5-5":{"inputTokens":2,"outputTokens":4,"cacheReadInputTokens":7,"cacheCreationInputTokens":1043,"thinkingTokens":3,"costUSD":0.5}}"#; + +/// What a resumed session's `modelUsage` says: every turn so far. Every result +/// fixture carries it beside the turn's own `model_usage_since_last_result`, +/// so reading the wrong field shows in a price. +const CUMULATIVE: &str = r#"{"claude-haiku-5-5":{"inputTokens":900000,"outputTokens":900000,"cacheReadInputTokens":900000,"cacheCreationInputTokens":900000,"thinkingTokens":900000,"costUSD":99.0}}"#; + +fn result_line(stop_reason: &str, since_last: &str) -> String { + format!( + r#"{{"type":"result","subtype":"success","is_error":false,"stop_reason":"{stop_reason}","session_id":"{SESSION}","total_cost_usd":99.0,"modelUsage":{CUMULATIVE},"model_usage_since_last_result":{since_last}}}"# + ) +} + +fn error_result_line(extra: &str) -> String { + format!( + r#"{{"type":"result","subtype":"error_during_execution","is_error":true,"session_id":"{SESSION}","total_cost_usd":99.0,"modelUsage":{CUMULATIVE},"model_usage_since_last_result":{USAGE}{extra}}}"# + ) +} + +fn ok_script(session: &str, text: &str) -> Script { + Script::lines(vec![ + init_line(MODEL, session), + text_line("msg_1", text), + rate_limit_line("allowed"), + result_line("end_turn", USAGE), + ]) +} + +fn usage( + input: u64, + cache_read: u64, + cache_write: u64, + output: u64, + reasoning: Option, +) -> Usage { + Usage { + input_tokens: input, + cache_read_tokens: cache_read, + cache_write_tokens: cache_write, + output_tokens: output, + reasoning_tokens: reasoning, + } +} + +fn user(text: &str) -> InputItem { + InputItem::UserText { text: text.into() } +} + +fn assistant(text: &str) -> InputItem { + InputItem::AssistantText { text: text.into() } +} + +fn request(input: Vec) -> ProviderRequest { + let mut request = ProviderRequest::new("req", "conv", MODEL, "Be terse.", 1024); + request.input = input; + request +} + +// ---- the scripted port: replays fixture lines and records what it was given ---- + +#[derive(Clone, Copy)] +enum Terminal { + Eof, + Timeout, + Breach, +} + +struct Script { + lines: Vec, + terminal: Terminal, +} + +impl Script { + fn lines(lines: Vec) -> Self { + Self { + lines, + terminal: Terminal::Eof, + } + } + + fn ending(mut self, terminal: Terminal) -> Self { + self.terminal = terminal; + self + } +} + +#[derive(Clone, Default)] +struct Seen { + argv: Vec, + env: Vec<(String, String)>, + cwd: PathBuf, + stdin: Vec, + /// The file `--system-prompt-file` named, as it was when the child started. + prompt_file: Option<(PathBuf, String)>, + /// Every deadline the backend passed to a read of this child. + deadlines: Arc>>, +} + +struct ScriptedPort { + scripts: Mutex>, + spawns: Arc, + seen: Arc>, + stderr: Vec, +} + +struct ScriptedChild { + lines: VecDeque, + terminal: Terminal, + stderr: Vec, + deadlines: Arc>>, +} + +impl ProcessPort for ScriptedPort { + fn spawn( + &self, + argv: &[String], + env: &ChildEnv, + cwd: &Path, + stdin: Vec, + ) -> io::Result> { + self.spawns.fetch_add(1, Ordering::SeqCst); + let deadlines = Arc::new(Mutex::new(Vec::new())); + *self.seen.lock().unwrap() = Seen { + prompt_file: arg_after(argv, "--system-prompt-file").map(|path| { + ( + PathBuf::from(path), + std::fs::read_to_string(path).expect("the prompt file exists at spawn"), + ) + }), + deadlines: deadlines.clone(), + argv: argv.to_vec(), + env: env + .iter() + .map(|(k, v)| (k.to_string(), v.to_string())) + .collect(), + cwd: cwd.to_path_buf(), + stdin, + }; + let script = self + .scripts + .lock() + .unwrap() + .pop_front() + .expect("the test scripted no further spawn"); + Ok(Box::new(ScriptedChild { + lines: script.lines.into(), + terminal: script.terminal, + stderr: self.stderr.clone(), + deadlines, + })) + } +} + +impl RunningChild for ScriptedChild { + fn read_line(&mut self, deadline: Instant) -> StdoutRead { + self.deadlines.lock().unwrap().push(deadline); + match self.lines.pop_front() { + Some(line) => StdoutRead::Line(line.into_bytes()), + None => match self.terminal { + Terminal::Eof => StdoutRead::Eof, + Terminal::Timeout => StdoutRead::Timeout, + Terminal::Breach => StdoutRead::Breach, + }, + } + } + fn stderr_tail(&self) -> Vec { + self.stderr.clone() + } + fn kill(&mut self) {} + fn wait(&mut self) {} +} + +struct Sessions(Vec); + +impl SessionStore for Sessions { + fn exists(&self, session_id: &str) -> bool { + self.0.iter().any(|known| known == session_id) + } +} + +#[derive(Clone, Default)] +struct Sink(Arc>>); + +impl StderrSink for Sink { + fn stderr_tail(&self, tail: &[u8]) { + self.0.lock().unwrap().extend_from_slice(tail); + } +} + +fn config(instance: &str, limits: ClaudeCodeLimits) -> ClaudeCodeConfig { + ClaudeCodeConfig { + claude_binary: PathBuf::from("/opt/claude"), + claude_binary_sha256: [9; 32], + work_dir: PathBuf::from("/work"), + config_dir: PathBuf::from("/cfg"), + instance: instance.to_string(), + limits, + } +} + +fn limits() -> ClaudeCodeLimits { + ClaudeCodeLimits { + deadline: Duration::from_secs(30), + max_line_bytes: 1 << 20, + max_total_bytes: 1 << 24, + stderr_tail_bytes: 4096, + } +} + +fn with_canary_token(mut backend: ClaudeCodeBackend) -> ClaudeCodeBackend { + backend.env_lookup = Box::new(|name| (name == TOKEN_VAR).then(|| CANARY_TOKEN.to_string())); + backend +} + +struct Rig { + backend: ClaudeCodeBackend, + spawns: Arc, + seen: Arc>, + sink: Sink, +} + +fn rig_for(instance: &str, scripts: Vec