From bf958fc32794b6c50e3a89e4925d19f6680a2691 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Wed, 28 Oct 2026 12:26:34 +0100 Subject: [PATCH 01/35] 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 02/35] 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 03/35] 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 04/35] 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