connector-kit-lift: the provider-neutral connector kit, lifted from CodexConnector fd7cec4 (squash of eureka-body/connector-kit-lift 1cf879f..ba1f7eb)
The v2 contract, encrypted envelope and framing, client, caller admission and replay, and the serve loop move into the model-connector crate behind one ProviderBackend trait, relicensed AGPL-3.0-only by the sole copyright holder (ruling connector-kit-relicense-agpl). Same schema ids and wire bytes as v2, pinned by golden fixtures generated from fd7cec4. The Codex backend is not ported (ruling codex-connector-park). Serve owns the frame bounds: it measures the sealed envelope, refuses configurations that cannot deliver their payload bound, answers over-bound payloads and over-long ids with v2 refusals, and never records or replays an undeliverable result. Spec eureka-body:cut_spec:cut-connector-kit-lift.r1; reports h1-h5; Soul s5 holds (verdict eureka-body:verdict:cut-connector-kit-lift.s5). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
1cf879f1e0
commit
ec0519d9ff
36 changed files with 5348 additions and 15 deletions
1
.gitignore
vendored
Normal file
1
.gitignore
vendored
Normal file
|
|
@ -0,0 +1 @@
|
|||
/target
|
||||
838
Cargo.lock
generated
Normal file
838
Cargo.lock
generated
Normal file
|
|
@ -0,0 +1,838 @@
|
|||
# This file is automatically @generated by Cargo.
|
||||
# It is not intended for manual editing.
|
||||
version = 4
|
||||
|
||||
[[package]]
|
||||
name = "aead"
|
||||
version = "0.5.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d122413f284cf2d62fb1b7db97e02edb8cda96d769b16e443a4f6195e35662b0"
|
||||
dependencies = [
|
||||
"crypto-common",
|
||||
"generic-array",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "aes"
|
||||
version = "0.8.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b169f7a6d4742236a0a00c541b845991d0ac43e546831af1249753ab4c3aa3a0"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"cipher",
|
||||
"cpufeatures",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "aes-gcm"
|
||||
version = "0.10.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "831010a0f742e1209b3bcea8fab6a8e149051ba6099432c8cb2cc117dec3ead1"
|
||||
dependencies = [
|
||||
"aead",
|
||||
"aes",
|
||||
"cipher",
|
||||
"ctr",
|
||||
"ghash",
|
||||
"subtle",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "android_system_properties"
|
||||
version = "0.1.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ae221649c9976a6f6c56ae1facf410f3ddb33cc661c4b7b61020a912d4237fbc"
|
||||
dependencies = [
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "anyhow"
|
||||
version = "1.0.104"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "330a5ed07fa54e4702c9d6c4174f74427fc0ef6e214bbd677ae50a5099946470"
|
||||
|
||||
[[package]]
|
||||
name = "autocfg"
|
||||
version = "1.5.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53"
|
||||
|
||||
[[package]]
|
||||
name = "block-buffer"
|
||||
version = "0.10.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71"
|
||||
dependencies = [
|
||||
"generic-array",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "bumpalo"
|
||||
version = "3.20.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649"
|
||||
|
||||
[[package]]
|
||||
name = "cc"
|
||||
version = "1.4.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0ad534f4357a5264cce5019c989cf66a4f0dc4e0d1b1d15f8aacec0ff7360273"
|
||||
dependencies = [
|
||||
"find-msvc-tools",
|
||||
"shlex",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "cfg-if"
|
||||
version = "1.0.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801"
|
||||
|
||||
[[package]]
|
||||
name = "chrono"
|
||||
version = "0.4.45"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1aa79e62e7697b8e29b513a68abacf485adcd1fe8284a4316c5ae868e6633327"
|
||||
dependencies = [
|
||||
"iana-time-zone",
|
||||
"js-sys",
|
||||
"num-traits",
|
||||
"serde",
|
||||
"wasm-bindgen",
|
||||
"windows-link",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "cipher"
|
||||
version = "0.4.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "773f3b9af64447d2ce9850330c473515014aa235e6a783b02db81ff39e4a3dad"
|
||||
dependencies = [
|
||||
"crypto-common",
|
||||
"inout",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "core-foundation-sys"
|
||||
version = "0.8.7"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b"
|
||||
|
||||
[[package]]
|
||||
name = "cpufeatures"
|
||||
version = "0.2.17"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280"
|
||||
dependencies = [
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "crypto-common"
|
||||
version = "0.1.7"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a"
|
||||
dependencies = [
|
||||
"generic-array",
|
||||
"rand_core",
|
||||
"typenum",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ctr"
|
||||
version = "0.9.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0369ee1ad671834580515889b80f2ea915f23b8be8d0daa4bbaf2ac5c7590835"
|
||||
dependencies = [
|
||||
"cipher",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "cultcache-rs"
|
||||
version = "0.1.0"
|
||||
source = "git+https://github.com/GameCult/CultLib.git?rev=e171eca32329baa928f4a1d810401a8b4c857029#e171eca32329baa928f4a1d810401a8b4c857029"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"chrono",
|
||||
"cultcache-rs-derive",
|
||||
"fs2",
|
||||
"redb",
|
||||
"rmp-serde",
|
||||
"serde",
|
||||
"serde_bytes",
|
||||
"serde_json",
|
||||
"uuid",
|
||||
"windows-sys",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "cultcache-rs-derive"
|
||||
version = "0.1.0"
|
||||
source = "git+https://github.com/GameCult/CultLib.git?rev=e171eca32329baa928f4a1d810401a8b4c857029#e171eca32329baa928f4a1d810401a8b4c857029"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.119",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "digest"
|
||||
version = "0.10.7"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292"
|
||||
dependencies = [
|
||||
"block-buffer",
|
||||
"crypto-common",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "find-msvc-tools"
|
||||
version = "0.1.11"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d45db016d36b838f563236e9193d0ee6ce38f3f68b6c94e914b4929c96bbb890"
|
||||
|
||||
[[package]]
|
||||
name = "fs2"
|
||||
version = "0.4.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9564fc758e15025b46aa6643b1b77d047d1a56a1aea6e01002ac0c7026876213"
|
||||
dependencies = [
|
||||
"libc",
|
||||
"winapi",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "futures-core"
|
||||
version = "0.3.34"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "92d699e522242e69e3003b94ecc1f960f3a5e015aa7c5d7486e65ad01dd94f5e"
|
||||
|
||||
[[package]]
|
||||
name = "futures-task"
|
||||
version = "0.3.34"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "cd417de3d1d015fc3bfd2b1ea46dfc7bab72ef86f1cc7cc9c78e728b34a6d1fd"
|
||||
|
||||
[[package]]
|
||||
name = "futures-util"
|
||||
version = "0.3.34"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0d50a92467f8ba5dd6e3ee5d4bd04d73ab2e4e1c44474a0674821dfce14b79bc"
|
||||
dependencies = [
|
||||
"futures-core",
|
||||
"futures-task",
|
||||
"pin-project-lite",
|
||||
"slab",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "generic-array"
|
||||
version = "0.14.7"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a"
|
||||
dependencies = [
|
||||
"typenum",
|
||||
"version_check",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "getrandom"
|
||||
version = "0.2.17"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"libc",
|
||||
"wasi",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "getrandom"
|
||||
version = "0.4.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"libc",
|
||||
"r-efi",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ghash"
|
||||
version = "0.5.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f0d8a4362ccb29cb0b265253fb0a2728f592895ee6854fd9bc13f2ffda266ff1"
|
||||
dependencies = [
|
||||
"opaque-debug",
|
||||
"polyval",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "iana-time-zone"
|
||||
version = "0.1.65"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e31bc9ad994ba00e440a8aa5c9ef0ec67d5cb5e5cb0cc7f8b744a35b389cc470"
|
||||
dependencies = [
|
||||
"android_system_properties",
|
||||
"core-foundation-sys",
|
||||
"iana-time-zone-haiku",
|
||||
"js-sys",
|
||||
"log",
|
||||
"wasm-bindgen",
|
||||
"windows-core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "iana-time-zone-haiku"
|
||||
version = "0.1.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f31827a206f56af32e590ba56d5d2d085f558508192593743f16b2306495269f"
|
||||
dependencies = [
|
||||
"cc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "inout"
|
||||
version = "0.1.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "879f10e63c20629ecabbb64a8010319738c66a5cd0c29b02d63d272b03751d01"
|
||||
dependencies = [
|
||||
"generic-array",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "itoa"
|
||||
version = "1.0.18"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682"
|
||||
|
||||
[[package]]
|
||||
name = "js-sys"
|
||||
version = "0.3.104"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0e0c1080212aad755ea003d18543e8768dd432c48819efd73a7bf1e39b7a5a3a"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"futures-util",
|
||||
"wasm-bindgen",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "libc"
|
||||
version = "0.2.189"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2"
|
||||
|
||||
[[package]]
|
||||
name = "log"
|
||||
version = "0.4.34"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f9f8bd3e56ce4dfc153cf470fffbfa98c7620958b312ca5c3a4b8d5181fd13c6"
|
||||
|
||||
[[package]]
|
||||
name = "memchr"
|
||||
version = "2.8.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98"
|
||||
|
||||
[[package]]
|
||||
name = "model-connector"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"aes-gcm",
|
||||
"cultcache-rs",
|
||||
"rand_core",
|
||||
"rmp-serde",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"sha2",
|
||||
"thiserror",
|
||||
"zeroize",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "num-traits"
|
||||
version = "0.2.19"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841"
|
||||
dependencies = [
|
||||
"autocfg",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "once_cell"
|
||||
version = "1.21.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50"
|
||||
|
||||
[[package]]
|
||||
name = "opaque-debug"
|
||||
version = "0.3.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c08d65885ee38876c4f86fa503fb49d7b507c2b62552df7c70b2fce627e06381"
|
||||
|
||||
[[package]]
|
||||
name = "pin-project-lite"
|
||||
version = "0.2.17"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd"
|
||||
|
||||
[[package]]
|
||||
name = "polyval"
|
||||
version = "0.6.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9d1fe60d06143b2430aa532c94cfe9e29783047f06c0d7fd359a9a51b729fa25"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"cpufeatures",
|
||||
"opaque-debug",
|
||||
"universal-hash",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "proc-macro2"
|
||||
version = "1.0.107"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9"
|
||||
dependencies = [
|
||||
"unicode-ident",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "quote"
|
||||
version = "1.0.47"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "r-efi"
|
||||
version = "6.0.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf"
|
||||
|
||||
[[package]]
|
||||
name = "rand_core"
|
||||
version = "0.6.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c"
|
||||
dependencies = [
|
||||
"getrandom 0.2.17",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "redb"
|
||||
version = "4.2.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "de6c3b63e007e90ce536ec2ae4690826136a20ec8dbbbb400daef1bb999d2e36"
|
||||
dependencies = [
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rmp"
|
||||
version = "0.8.15"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "4ba8be72d372b2c9b35542551678538b562e7cf86c3315773cae48dfbfe7790c"
|
||||
dependencies = [
|
||||
"num-traits",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rmp-serde"
|
||||
version = "1.3.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "72f81bee8c8ef9b577d1681a70ebbc962c232461e397b22c208c43c04b67a155"
|
||||
dependencies = [
|
||||
"rmp",
|
||||
"serde",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rustversion"
|
||||
version = "1.0.23"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f"
|
||||
|
||||
[[package]]
|
||||
name = "serde"
|
||||
version = "1.0.229"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba"
|
||||
dependencies = [
|
||||
"serde_core",
|
||||
"serde_derive",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "serde_bytes"
|
||||
version = "0.11.19"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a5d440709e79d88e51ac01c4b72fc6cb7314017bb7da9eeff678aa94c10e3ea8"
|
||||
dependencies = [
|
||||
"serde",
|
||||
"serde_core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "serde_core"
|
||||
version = "1.0.229"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48"
|
||||
dependencies = [
|
||||
"serde_derive",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "serde_derive"
|
||||
version = "1.0.229"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 3.0.4",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "serde_json"
|
||||
version = "1.0.151"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c841b55ecdae098c80dcae9cf767f6f8a0c2cdb3416bbef72181df4d0fe73f14"
|
||||
dependencies = [
|
||||
"itoa",
|
||||
"memchr",
|
||||
"serde",
|
||||
"serde_core",
|
||||
"zmij",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "sha2"
|
||||
version = "0.10.9"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"cpufeatures",
|
||||
"digest",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "shlex"
|
||||
version = "2.0.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba"
|
||||
|
||||
[[package]]
|
||||
name = "slab"
|
||||
version = "0.4.12"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5"
|
||||
|
||||
[[package]]
|
||||
name = "subtle"
|
||||
version = "2.6.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292"
|
||||
|
||||
[[package]]
|
||||
name = "syn"
|
||||
version = "2.0.119"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "872831b642d1a07999a962a351ed35b955ea2cfc8f3862091e2a240a84f17297"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"unicode-ident",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "syn"
|
||||
version = "3.0.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e6275cddf4610d1775e6d1fe9469b2e77d0f39fd98fb7450901b821e0c53649f"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"unicode-ident",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "thiserror"
|
||||
version = "2.0.20"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ec86235f5fcc2a73650310756d2ac5b138a5780bbbdfae3eeccec992c435ba4f"
|
||||
dependencies = [
|
||||
"thiserror-impl",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "thiserror-impl"
|
||||
version = "2.0.20"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "bc04cd3e1236dd4a98afca4569f2deb3f120e5422a4023be2cb683f8486292af"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 3.0.4",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "typenum"
|
||||
version = "1.20.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20"
|
||||
|
||||
[[package]]
|
||||
name = "unicode-ident"
|
||||
version = "1.0.24"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75"
|
||||
|
||||
[[package]]
|
||||
name = "universal-hash"
|
||||
version = "0.5.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "fc1de2c688dc15305988b563c3854064043356019f97a4b46276fe734c4f07ea"
|
||||
dependencies = [
|
||||
"crypto-common",
|
||||
"subtle",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "uuid"
|
||||
version = "1.25.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f053576934f05a761a402421fbbe3d425d9366f75f978806a037b3ca481abecc"
|
||||
dependencies = [
|
||||
"getrandom 0.4.3",
|
||||
"js-sys",
|
||||
"wasm-bindgen",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "version_check"
|
||||
version = "0.9.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a"
|
||||
|
||||
[[package]]
|
||||
name = "wasi"
|
||||
version = "0.11.1+wasi-snapshot-preview1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b"
|
||||
|
||||
[[package]]
|
||||
name = "wasm-bindgen"
|
||||
version = "0.2.127"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1b70935747edd64d89de3efa29d73789b806c15798f8e7dca4d8ac356b50ce70"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"once_cell",
|
||||
"rustversion",
|
||||
"wasm-bindgen-macro",
|
||||
"wasm-bindgen-shared",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wasm-bindgen-macro"
|
||||
version = "0.2.127"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "77775f8f3f7217702089053b94958f8f54061a3f663417df76e19cbdcca29bc1"
|
||||
dependencies = [
|
||||
"quote",
|
||||
"wasm-bindgen-macro-support",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wasm-bindgen-macro-support"
|
||||
version = "0.2.127"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e11d33f857dc2fb11b8bc75aee111aa9cbeb12cd9f25efd3d4c2a3dd4e235284"
|
||||
dependencies = [
|
||||
"bumpalo",
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.119",
|
||||
"wasm-bindgen-shared",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wasm-bindgen-shared"
|
||||
version = "0.2.127"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7ef64dbcc55df09c7e5a46182d181c2cfa3e925f3da937ea764728b4bbb9dcbf"
|
||||
dependencies = [
|
||||
"unicode-ident",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "winapi"
|
||||
version = "0.3.9"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419"
|
||||
dependencies = [
|
||||
"winapi-i686-pc-windows-gnu",
|
||||
"winapi-x86_64-pc-windows-gnu",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "winapi-i686-pc-windows-gnu"
|
||||
version = "0.4.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6"
|
||||
|
||||
[[package]]
|
||||
name = "winapi-x86_64-pc-windows-gnu"
|
||||
version = "0.4.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f"
|
||||
|
||||
[[package]]
|
||||
name = "windows-core"
|
||||
version = "0.62.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb"
|
||||
dependencies = [
|
||||
"windows-implement",
|
||||
"windows-interface",
|
||||
"windows-link",
|
||||
"windows-result",
|
||||
"windows-strings",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-implement"
|
||||
version = "0.60.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.119",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-interface"
|
||||
version = "0.59.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.119",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-link"
|
||||
version = "0.2.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5"
|
||||
|
||||
[[package]]
|
||||
name = "windows-result"
|
||||
version = "0.4.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5"
|
||||
dependencies = [
|
||||
"windows-link",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-strings"
|
||||
version = "0.5.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091"
|
||||
dependencies = [
|
||||
"windows-link",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-sys"
|
||||
version = "0.59.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1e38bc4d79ed67fd075bcc251a1c39b32a1776bbe92e5bef1f0bf1f8c531853b"
|
||||
dependencies = [
|
||||
"windows-targets",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-targets"
|
||||
version = "0.52.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973"
|
||||
dependencies = [
|
||||
"windows_aarch64_gnullvm",
|
||||
"windows_aarch64_msvc",
|
||||
"windows_i686_gnu",
|
||||
"windows_i686_gnullvm",
|
||||
"windows_i686_msvc",
|
||||
"windows_x86_64_gnu",
|
||||
"windows_x86_64_gnullvm",
|
||||
"windows_x86_64_msvc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows_aarch64_gnullvm"
|
||||
version = "0.52.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3"
|
||||
|
||||
[[package]]
|
||||
name = "windows_aarch64_msvc"
|
||||
version = "0.52.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469"
|
||||
|
||||
[[package]]
|
||||
name = "windows_i686_gnu"
|
||||
version = "0.52.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b"
|
||||
|
||||
[[package]]
|
||||
name = "windows_i686_gnullvm"
|
||||
version = "0.52.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66"
|
||||
|
||||
[[package]]
|
||||
name = "windows_i686_msvc"
|
||||
version = "0.52.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66"
|
||||
|
||||
[[package]]
|
||||
name = "windows_x86_64_gnu"
|
||||
version = "0.52.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78"
|
||||
|
||||
[[package]]
|
||||
name = "windows_x86_64_gnullvm"
|
||||
version = "0.52.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d"
|
||||
|
||||
[[package]]
|
||||
name = "windows_x86_64_msvc"
|
||||
version = "0.52.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec"
|
||||
|
||||
[[package]]
|
||||
name = "zeroize"
|
||||
version = "1.9.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e13c156562582aa81c60cb29407084cdb54c4164760106ab78e6c5b0858cf64e"
|
||||
|
||||
[[package]]
|
||||
name = "zmij"
|
||||
version = "1.0.23"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b"
|
||||
9
Cargo.toml
Normal file
9
Cargo.toml
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
[workspace]
|
||||
members = ["crates/*"]
|
||||
resolver = "3"
|
||||
|
||||
[workspace.package]
|
||||
version = "0.1.0"
|
||||
edition = "2024"
|
||||
license = "AGPL-3.0-only"
|
||||
repository = "https://eureka.gamecult.org/forge/GameCult/ModelConnector"
|
||||
59
README.md
59
README.md
|
|
@ -1,26 +1,55 @@
|
|||
# ModelConnector
|
||||
|
||||
Status: planned. Nothing is implemented in this repository yet.
|
||||
The connector kit for GameCult minds: one typed, encrypted, replay-safe contract
|
||||
between a caller that needs a model turn and a daemon that holds the provider
|
||||
credential, so no project wires its own provider calls.
|
||||
|
||||
## What it will hold
|
||||
Status: the `model-connector` crate is live. No connector binary exists yet
|
||||
(the Claude connector is first, then an OpenAI-compatible one).
|
||||
|
||||
The connector kit for GameCult minds:
|
||||
## Entry points
|
||||
|
||||
- the connector contract, a Rust crate defining the typed boundary every model
|
||||
turn of a GameCult mind goes through;
|
||||
- connector daemons that implement the contract against a model provider,
|
||||
Claude first, then an OpenAI-compatible connector.
|
||||
Crate `crates/model-connector`:
|
||||
|
||||
The problem it solves: a mind should reach any model through one inspectable,
|
||||
typed contract, rather than each project wiring its own provider calls.
|
||||
- `src/contract.rs` - the v2 wire contract: `ProviderRequest`, `TransportInvocation`,
|
||||
`TransportResult`, `TransportReceipt`, `Refusal`, and the canonical byte and digest
|
||||
functions that bind a result to its request.
|
||||
- `src/envelope.rs` - the AES-256-GCM `TransportEnvelope`, its identity-binding AAD, and
|
||||
the length-prefixed framing.
|
||||
- `src/client.rs` - `ConnectorClient`, the one-request caller side.
|
||||
- `src/service.rs` (feature `daemon`) - `TransportService`: caller admission, durable
|
||||
replay (a retried request is answered byte-identically and the provider runs once),
|
||||
in-flight and capacity refusals.
|
||||
- `src/serve.rs` (feature `daemon`) - `serve()`, the loopback accept loop, over the
|
||||
`ProviderBackend` trait. A connector binary implements `ProviderBackend` for its
|
||||
provider and calls `serve`; the kit names no provider.
|
||||
|
||||
## Where it comes from
|
||||
Frame arithmetic is public in `src/envelope.rs`: `frame_bytes_for_payload(n)` is the frame a
|
||||
caller payload bound of `n` needs, `payload_bytes_for_frame(frame)` is the largest payload bound
|
||||
a frame allows, and `MAX_IDENTITY_BYTES` (256) bounds caller ids, request ids and model names.
|
||||
`serve` refuses a frame/payload pair that violates them (`ServeError::FrameBound` states the frame
|
||||
needed); the smallest legal frame is `frame_bytes_for_payload(MIN_PAYLOAD_BYTES)`. A request id past
|
||||
the bound is refused on the wire as `Refusal::Malformed`, the reply echoing the id (the connection is dropped only if that reply cannot fit the frame); `ConnectorClient` refuses such an id locally with `RequestIdTooLong`.
|
||||
|
||||
[CodexConnector](https://github.com/GameCult/CodexConnector)
|
||||
(GameCult/CodexConnector) is the existing connector this kit is lifted from.
|
||||
The lift is the first piece of work here; until it lands there are no entry
|
||||
points to run.
|
||||
The default feature set compiles contract, envelope and client only. Schema ids are
|
||||
still `gamecult.codex.*.v2`; renaming them changes the wire and belongs to a later
|
||||
contract version.
|
||||
|
||||
## License
|
||||
## Verify
|
||||
|
||||
```
|
||||
cargo test --locked --workspace
|
||||
cargo test --locked --workspace --features model-connector/daemon
|
||||
```
|
||||
|
||||
`crates/model-connector/tests/wire_golden_v2.rs` pins the kit byte-for-byte to the golden v2
|
||||
fixtures in `crates/model-connector/tests/fixtures/wire_v2`.
|
||||
|
||||
## Provenance and license
|
||||
|
||||
AGPL-3.0-only. See `LICENSE`.
|
||||
|
||||
The contract, envelope, client, replay service and serve loop were lifted from
|
||||
[CodexConnector](https://github.com/GameCult/CodexConnector) at fd7cec4, formerly
|
||||
PolyForm Noncommercial. Its sole copyright holder relicensed the lifted material as
|
||||
AGPL-3.0-only for this repository.
|
||||
|
|
|
|||
23
crates/model-connector/Cargo.toml
Normal file
23
crates/model-connector/Cargo.toml
Normal file
|
|
@ -0,0 +1,23 @@
|
|||
[package]
|
||||
name = "model-connector"
|
||||
description = "Provider-neutral connector contract, envelope, client, replay service and serve loop for GameCult minds"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
repository.workspace = true
|
||||
publish = false
|
||||
|
||||
[features]
|
||||
default = []
|
||||
daemon = ["dep:cultcache-rs"]
|
||||
|
||||
[dependencies]
|
||||
aes-gcm = "0.10"
|
||||
cultcache-rs = { git = "https://github.com/GameCult/CultLib.git", rev = "e171eca32329baa928f4a1d810401a8b4c857029", package = "cultcache-rs", optional = true }
|
||||
rand_core = { version = "0.6", features = ["getrandom"] }
|
||||
rmp-serde = "1"
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
serde_json = "1"
|
||||
sha2 = "0.10"
|
||||
thiserror = "2"
|
||||
zeroize = "1"
|
||||
206
crates/model-connector/src/client.rs
Normal file
206
crates/model-connector/src/client.rs
Normal file
|
|
@ -0,0 +1,206 @@
|
|||
use std::net::{SocketAddr, TcpStream};
|
||||
use std::time::Duration;
|
||||
|
||||
use thiserror::Error;
|
||||
use zeroize::Zeroizing;
|
||||
|
||||
use crate::contract::{TransportInvocation, TransportKey, TransportResult};
|
||||
use crate::envelope::{
|
||||
MAX_IDENTITY_BYTES, ServiceError, TransportFrameError, decrypt_result, encrypt_invocation,
|
||||
read_transport_frame, write_transport_frame,
|
||||
};
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct ConnectorClient {
|
||||
endpoint: SocketAddr,
|
||||
security: TransportKey,
|
||||
max_frame_bytes: usize,
|
||||
response_timeout: Option<Duration>,
|
||||
}
|
||||
|
||||
impl ConnectorClient {
|
||||
pub fn new(
|
||||
endpoint: SocketAddr,
|
||||
connection_key: impl Into<String>,
|
||||
max_frame_bytes: usize,
|
||||
response_timeout: Option<Duration>,
|
||||
) -> Result<Self, ConnectorClientError> {
|
||||
if endpoint.port() == 0
|
||||
|| endpoint.ip().is_unspecified()
|
||||
|| endpoint.ip().is_multicast()
|
||||
|| endpoint.ip() == std::net::Ipv4Addr::BROADCAST
|
||||
|| !(4096..=u32::MAX as usize).contains(&max_frame_bytes)
|
||||
|| response_timeout.is_some_and(|timeout| timeout.is_zero())
|
||||
{
|
||||
return Err(ConnectorClientError::InvalidConfig);
|
||||
}
|
||||
let connection_key = Zeroizing::new(connection_key.into());
|
||||
let security = TransportKey::from_connection_secret(connection_key.as_str())?;
|
||||
Ok(Self {
|
||||
endpoint,
|
||||
security,
|
||||
max_frame_bytes,
|
||||
response_timeout,
|
||||
})
|
||||
}
|
||||
|
||||
pub fn execute(
|
||||
&self,
|
||||
invocation: &TransportInvocation,
|
||||
) -> Result<TransportResult, ConnectorClientError> {
|
||||
if invocation.request_id().len() > MAX_IDENTITY_BYTES {
|
||||
return Err(ConnectorClientError::RequestIdTooLong);
|
||||
}
|
||||
let request = rmp_serde::to_vec(&encrypt_invocation(invocation, &self.security)?)
|
||||
.map_err(|_| ConnectorClientError::Encoding)?;
|
||||
let connect_timeout = self
|
||||
.response_timeout
|
||||
.unwrap_or(Duration::from_secs(10))
|
||||
.min(Duration::from_secs(10));
|
||||
let mut stream = TcpStream::connect_timeout(&self.endpoint, connect_timeout)
|
||||
.map_err(ConnectorClientError::Connection)?;
|
||||
stream
|
||||
.set_read_timeout(self.response_timeout)
|
||||
.and_then(|()| stream.set_write_timeout(self.response_timeout))
|
||||
.map_err(ConnectorClientError::Connection)?;
|
||||
write_transport_frame(&mut stream, &request, self.max_frame_bytes)
|
||||
.map_err(client_frame_error)?;
|
||||
let response =
|
||||
read_transport_frame(&mut stream, self.max_frame_bytes).map_err(client_frame_error)?;
|
||||
let envelope =
|
||||
rmp_serde::from_slice(&response).map_err(|_| ConnectorClientError::Encoding)?;
|
||||
decrypt_result(&envelope, &self.security, invocation).map_err(Into::into)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod client_endpoint_tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn client_admits_explicit_unicast_and_refuses_non_destinations() {
|
||||
for endpoint in ["127.0.0.1:4103", "172.19.0.1:4103", "[::1]:4103"] {
|
||||
assert!(
|
||||
ConnectorClient::new(
|
||||
endpoint.parse().unwrap(),
|
||||
"bounded-client-endpoint-key",
|
||||
64 * 1024,
|
||||
Some(Duration::from_secs(1)),
|
||||
)
|
||||
.is_ok(),
|
||||
"{endpoint} should be an admissible explicit destination"
|
||||
);
|
||||
}
|
||||
for endpoint in [
|
||||
"0.0.0.0:4103",
|
||||
"255.255.255.255:4103",
|
||||
"224.0.0.1:4103",
|
||||
"[::]:4103",
|
||||
"[ff02::1]:4103",
|
||||
"127.0.0.1:0",
|
||||
] {
|
||||
assert!(matches!(
|
||||
ConnectorClient::new(
|
||||
endpoint.parse().unwrap(),
|
||||
"bounded-client-endpoint-key",
|
||||
64 * 1024,
|
||||
Some(Duration::from_secs(1)),
|
||||
),
|
||||
Err(ConnectorClientError::InvalidConfig)
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Error)]
|
||||
pub enum ConnectorClientError {
|
||||
#[error("invalid connector client configuration")]
|
||||
InvalidConfig,
|
||||
#[error("connector client connection failed")]
|
||||
Connection(#[source] std::io::Error),
|
||||
#[error("connector frame exceeded its bound")]
|
||||
FrameSize,
|
||||
#[error("connector MessagePack encoding failed")]
|
||||
Encoding,
|
||||
#[error("request id is longer than the connector identity bound")]
|
||||
RequestIdTooLong,
|
||||
#[error(transparent)]
|
||||
Transport(#[from] ServiceError),
|
||||
}
|
||||
|
||||
fn client_frame_error(error: TransportFrameError) -> ConnectorClientError {
|
||||
match error {
|
||||
TransportFrameError::Connection(error) => ConnectorClientError::Connection(error),
|
||||
TransportFrameError::Size => ConnectorClientError::FrameSize,
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod fixture_tests {
|
||||
use super::*;
|
||||
use crate::fixtures::*;
|
||||
use crate::{contract::*, envelope::*};
|
||||
|
||||
#[test]
|
||||
fn shared_client_transports_one_exact_encrypted_request_and_result() {
|
||||
let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
|
||||
let endpoint = listener.local_addr().unwrap();
|
||||
let invocation = invocation();
|
||||
let expected = invocation.clone();
|
||||
let security = security("epiphany-distinct-test-key");
|
||||
let server = std::thread::spawn(move || {
|
||||
let (mut stream, _) = listener.accept().unwrap();
|
||||
let request = read_transport_frame(&mut stream, 64 * 1024).unwrap();
|
||||
let envelope: TransportEnvelope = rmp_serde::from_slice(&request).unwrap();
|
||||
assert_eq!(decrypt_invocation(&envelope, &security).unwrap(), expected);
|
||||
let response = encrypt_result(
|
||||
&TransportResult::refused(&expected, Refusal::Policy),
|
||||
&security,
|
||||
)
|
||||
.unwrap();
|
||||
write_transport_frame(
|
||||
&mut stream,
|
||||
&rmp_serde::to_vec(&response).unwrap(),
|
||||
64 * 1024,
|
||||
)
|
||||
.unwrap();
|
||||
});
|
||||
|
||||
let client = ConnectorClient::new(
|
||||
endpoint,
|
||||
"epiphany-distinct-test-key",
|
||||
64 * 1024,
|
||||
Some(Duration::from_secs(1)),
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
client.execute(&invocation).unwrap().disposition,
|
||||
TransportDisposition::Refused(Refusal::Policy)
|
||||
);
|
||||
server.join().unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_over_long_request_id_is_refused_locally_before_any_byte_is_sent() {
|
||||
let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
|
||||
listener.set_nonblocking(true).unwrap();
|
||||
let client = ConnectorClient::new(
|
||||
listener.local_addr().unwrap(),
|
||||
"epiphany-distinct-test-key",
|
||||
64 * 1024,
|
||||
Some(Duration::from_secs(1)),
|
||||
)
|
||||
.unwrap();
|
||||
let mut long = invocation();
|
||||
long.request.request_id = "r".repeat(MAX_IDENTITY_BYTES + 1);
|
||||
assert!(matches!(
|
||||
client.execute(&long),
|
||||
Err(ConnectorClientError::RequestIdTooLong)
|
||||
));
|
||||
assert_eq!(
|
||||
listener.accept().unwrap_err().kind(),
|
||||
std::io::ErrorKind::WouldBlock,
|
||||
"the client must not even connect"
|
||||
);
|
||||
}
|
||||
}
|
||||
641
crates/model-connector/src/contract.rs
Normal file
641
crates/model-connector/src/contract.rs
Normal file
|
|
@ -0,0 +1,641 @@
|
|||
use std::collections::HashSet;
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
use sha2::{Digest, Sha256};
|
||||
use thiserror::Error;
|
||||
use zeroize::Zeroize;
|
||||
|
||||
use crate::envelope::ServiceError;
|
||||
|
||||
pub const PROVIDER_REQUEST_SCHEMA_ID: &str = "gamecult.codex.provider_request.v2";
|
||||
pub const INVOCATION_SCHEMA_ID: &str = "gamecult.codex.transport_invocation.v2";
|
||||
pub const RESULT_SCHEMA_ID: &str = "gamecult.codex.transport_result.v2";
|
||||
pub const RECEIPT_SCHEMA_ID: &str = "gamecult.codex.transport_receipt.v2";
|
||||
pub const ENVELOPE_SCHEMA_ID: &str = "gamecult.codex.transport_envelope.v2";
|
||||
|
||||
#[derive(Clone, PartialEq, Eq)]
|
||||
pub struct TransportKey(pub(crate) [u8; 32]);
|
||||
|
||||
impl TransportKey {
|
||||
pub fn from_connection_secret(secret: &str) -> Result<Self, ServiceError> {
|
||||
if secret.trim().is_empty() || secret.trim() != secret {
|
||||
return Err(ServiceError::InvalidAdmission);
|
||||
}
|
||||
Ok(Self(Sha256::digest(secret.as_bytes()).into()))
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for TransportKey {
|
||||
fn drop(&mut self) {
|
||||
self.0.zeroize();
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct ProviderRequest {
|
||||
pub schema_id: String,
|
||||
pub request_id: String,
|
||||
pub conversation_id: String,
|
||||
pub model: String,
|
||||
pub instructions: String,
|
||||
pub input: Vec<InputItem>,
|
||||
pub reasoning_effort: Option<String>,
|
||||
pub reasoning_summary: Option<String>,
|
||||
pub service_tier: Option<String>,
|
||||
pub output_format_name: Option<String>,
|
||||
pub previous_response_id: Option<String>,
|
||||
pub tools: Vec<ToolDefinition>,
|
||||
pub tool_choice: ToolChoice,
|
||||
pub parallel_tool_calls: bool,
|
||||
pub output_schema_json: Option<String>,
|
||||
pub max_output_tokens: Option<u32>,
|
||||
pub prompt_cache_key: Option<String>,
|
||||
}
|
||||
|
||||
impl ProviderRequest {
|
||||
pub fn new(
|
||||
request_id: impl Into<String>,
|
||||
conversation_id: impl Into<String>,
|
||||
model: impl Into<String>,
|
||||
instructions: impl Into<String>,
|
||||
) -> Self {
|
||||
Self {
|
||||
schema_id: PROVIDER_REQUEST_SCHEMA_ID.to_string(),
|
||||
request_id: request_id.into(),
|
||||
conversation_id: conversation_id.into(),
|
||||
model: model.into(),
|
||||
instructions: instructions.into(),
|
||||
input: Vec::new(),
|
||||
reasoning_effort: None,
|
||||
reasoning_summary: None,
|
||||
service_tier: None,
|
||||
output_format_name: None,
|
||||
previous_response_id: None,
|
||||
tools: Vec::new(),
|
||||
tool_choice: ToolChoice::Auto,
|
||||
parallel_tool_calls: false,
|
||||
output_schema_json: None,
|
||||
max_output_tokens: None,
|
||||
prompt_cache_key: None,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn validate(&self) -> Result<(), ContractError> {
|
||||
if self.schema_id != PROVIDER_REQUEST_SCHEMA_ID {
|
||||
return Err(ContractError::Schema);
|
||||
}
|
||||
require_id(&self.request_id, "request_id")?;
|
||||
require_id(&self.conversation_id, "conversation_id")?;
|
||||
require_id(&self.model, "model")?;
|
||||
require_content(&self.instructions, "instructions")?;
|
||||
if matches!(self.max_output_tokens, Some(0)) {
|
||||
return Err(ContractError::Invalid("max_output_tokens"));
|
||||
}
|
||||
if self
|
||||
.prompt_cache_key
|
||||
.as_deref()
|
||||
.is_some_and(|value| value.trim().is_empty())
|
||||
{
|
||||
return Err(ContractError::Invalid("prompt_cache_key"));
|
||||
}
|
||||
for (value, field) in [
|
||||
(self.reasoning_effort.as_deref(), "reasoning_effort"),
|
||||
(self.reasoning_summary.as_deref(), "reasoning_summary"),
|
||||
(self.service_tier.as_deref(), "service_tier"),
|
||||
(self.output_format_name.as_deref(), "output_format_name"),
|
||||
(self.previous_response_id.as_deref(), "previous_response_id"),
|
||||
] {
|
||||
if let Some(value) = value {
|
||||
require_id(value, field)?;
|
||||
}
|
||||
}
|
||||
if let Some(schema) = &self.output_schema_json {
|
||||
require_content(schema, "output_schema_json")?;
|
||||
require_json_object(schema, "output_schema_json")?;
|
||||
require_provider_name(
|
||||
self.output_format_name
|
||||
.as_deref()
|
||||
.ok_or(ContractError::Invalid("output_format_name"))?,
|
||||
"output_format_name",
|
||||
)?;
|
||||
} else if self.output_format_name.is_some() {
|
||||
return Err(ContractError::Invalid("output_schema_json"));
|
||||
}
|
||||
|
||||
if self.tools.is_empty() && self.tool_choice == ToolChoice::Required {
|
||||
return Err(ContractError::Invalid("tool_choice"));
|
||||
}
|
||||
|
||||
let mut tool_names = HashSet::new();
|
||||
for tool in &self.tools {
|
||||
require_provider_name(&tool.name, "tool.name")?;
|
||||
require_content(&tool.description, "tool.description")?;
|
||||
require_content(&tool.parameters_json, "tool.parameters_json")?;
|
||||
require_json_object(&tool.parameters_json, "tool.parameters_json")?;
|
||||
if !tool_names.insert(tool.name.as_str()) {
|
||||
return Err(ContractError::DuplicateTool(tool.name.clone()));
|
||||
}
|
||||
}
|
||||
for item in &self.input {
|
||||
match item {
|
||||
InputItem::UserText { text } | InputItem::AssistantText { text } => {
|
||||
require_content(text, "input.text")?;
|
||||
}
|
||||
InputItem::ToolCall {
|
||||
call_id,
|
||||
name,
|
||||
arguments,
|
||||
} => {
|
||||
require_provider_call_id(call_id, "input.call_id")?;
|
||||
require_provider_name(name, "input.tool_name")?;
|
||||
require_content(arguments, "input.arguments")?;
|
||||
}
|
||||
InputItem::ToolResult { call_id, output } => {
|
||||
require_provider_call_id(call_id, "input.call_id")?;
|
||||
require_content(output, "input.output")?;
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum ToolChoice {
|
||||
Auto,
|
||||
Required,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub enum InputItem {
|
||||
UserText {
|
||||
text: String,
|
||||
},
|
||||
AssistantText {
|
||||
text: String,
|
||||
},
|
||||
ToolCall {
|
||||
call_id: String,
|
||||
name: String,
|
||||
arguments: String,
|
||||
},
|
||||
ToolResult {
|
||||
call_id: String,
|
||||
output: String,
|
||||
},
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct ToolDefinition {
|
||||
pub name: String,
|
||||
pub description: String,
|
||||
pub parameters_json: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct TransportInvocation {
|
||||
pub schema_id: String,
|
||||
pub caller_runtime_id: String,
|
||||
pub expires_at_unix_ms: u64,
|
||||
pub native_request_sha256: [u8; 32],
|
||||
pub provider_request_sha256: [u8; 32],
|
||||
pub request: ProviderRequest,
|
||||
}
|
||||
|
||||
impl TransportInvocation {
|
||||
pub fn new(
|
||||
caller_runtime_id: impl Into<String>,
|
||||
expires_at_unix_ms: u64,
|
||||
native_request_sha256: [u8; 32],
|
||||
request: ProviderRequest,
|
||||
) -> Result<Self, ContractError> {
|
||||
let provider_request_sha256 = provider_request_sha256(&request)?;
|
||||
Ok(Self {
|
||||
schema_id: INVOCATION_SCHEMA_ID.to_string(),
|
||||
caller_runtime_id: caller_runtime_id.into(),
|
||||
expires_at_unix_ms,
|
||||
native_request_sha256,
|
||||
provider_request_sha256,
|
||||
request,
|
||||
})
|
||||
}
|
||||
|
||||
pub fn validate(&self, now_unix_ms: u64, max_expiry_skew_ms: u64) -> Result<(), ContractError> {
|
||||
if self.schema_id != INVOCATION_SCHEMA_ID {
|
||||
return Err(ContractError::Schema);
|
||||
}
|
||||
require_id(&self.caller_runtime_id, "caller_runtime_id")?;
|
||||
if self.native_request_sha256 == [0; 32] {
|
||||
return Err(ContractError::Invalid("native_request_sha256"));
|
||||
}
|
||||
self.request.validate()?;
|
||||
if self.provider_request_sha256 != provider_request_sha256(&self.request)? {
|
||||
return Err(ContractError::ProviderDigest);
|
||||
}
|
||||
if self.expires_at_unix_ms < now_unix_ms
|
||||
|| self.expires_at_unix_ms > now_unix_ms.saturating_add(max_expiry_skew_ms)
|
||||
{
|
||||
return Err(ContractError::Expiry);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn request_id(&self) -> &str {
|
||||
&self.request.request_id
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct TransportResult {
|
||||
pub schema_id: String,
|
||||
pub request_id: String,
|
||||
pub caller_runtime_id: String,
|
||||
pub disposition: TransportDisposition,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub enum TransportDisposition {
|
||||
Refused(Refusal),
|
||||
Transported {
|
||||
events: Vec<TransportEvent>,
|
||||
receipt: Box<TransportReceipt>,
|
||||
},
|
||||
}
|
||||
|
||||
impl TransportResult {
|
||||
pub fn refused(invocation: &TransportInvocation, reason: Refusal) -> Self {
|
||||
Self {
|
||||
schema_id: RESULT_SCHEMA_ID.to_string(),
|
||||
request_id: invocation.request_id().to_string(),
|
||||
caller_runtime_id: invocation.caller_runtime_id.clone(),
|
||||
disposition: TransportDisposition::Refused(reason),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn transported(
|
||||
invocation: &TransportInvocation,
|
||||
events: Vec<TransportEvent>,
|
||||
receipt: TransportReceipt,
|
||||
) -> Self {
|
||||
Self {
|
||||
schema_id: RESULT_SCHEMA_ID.to_string(),
|
||||
request_id: invocation.request_id().to_string(),
|
||||
caller_runtime_id: invocation.caller_runtime_id.clone(),
|
||||
disposition: TransportDisposition::Transported {
|
||||
events,
|
||||
receipt: Box::new(receipt),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
pub fn validate_against(&self, invocation: &TransportInvocation) -> Result<(), ContractError> {
|
||||
if self.schema_id != RESULT_SCHEMA_ID {
|
||||
return Err(ContractError::Schema);
|
||||
}
|
||||
if self.request_id != invocation.request_id()
|
||||
|| self.caller_runtime_id != invocation.caller_runtime_id
|
||||
{
|
||||
return Err(ContractError::Identity);
|
||||
}
|
||||
|
||||
if let TransportDisposition::Transported { events, receipt } = &self.disposition {
|
||||
for (expected, event) in events.iter().enumerate() {
|
||||
if event.sequence != expected as u64 {
|
||||
return Err(ContractError::EventSequence);
|
||||
}
|
||||
event.validate()?;
|
||||
}
|
||||
receipt.validate_against(invocation)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub enum TransportEventPayload {
|
||||
TextDelta {
|
||||
text: String,
|
||||
},
|
||||
ToolCall {
|
||||
call_id: String,
|
||||
name: String,
|
||||
arguments: String,
|
||||
},
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct TransportEvent {
|
||||
pub sequence: u64,
|
||||
pub payload: TransportEventPayload,
|
||||
}
|
||||
|
||||
impl TransportEvent {
|
||||
fn validate(&self) -> Result<(), ContractError> {
|
||||
match &self.payload {
|
||||
TransportEventPayload::TextDelta { text } => require_content(text, "event.text"),
|
||||
TransportEventPayload::ToolCall {
|
||||
call_id,
|
||||
name,
|
||||
arguments,
|
||||
} => {
|
||||
require_id(call_id, "event.call_id")?;
|
||||
require_id(name, "event.tool_name")?;
|
||||
require_content(arguments, "event.arguments")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct TransportReceipt {
|
||||
pub schema_id: String,
|
||||
pub request_id: String,
|
||||
pub caller_runtime_id: String,
|
||||
pub native_request_sha256: [u8; 32],
|
||||
pub provider_request_sha256: [u8; 32],
|
||||
pub model: String,
|
||||
pub transport: String,
|
||||
pub outcome: TransportOutcome,
|
||||
}
|
||||
|
||||
impl TransportReceipt {
|
||||
pub fn validate_against(&self, invocation: &TransportInvocation) -> Result<(), ContractError> {
|
||||
if self.schema_id != RECEIPT_SCHEMA_ID {
|
||||
return Err(ContractError::Schema);
|
||||
}
|
||||
if self.request_id != invocation.request_id()
|
||||
|| self.caller_runtime_id != invocation.caller_runtime_id
|
||||
|| self.model != invocation.request.model
|
||||
{
|
||||
return Err(ContractError::Identity);
|
||||
}
|
||||
if self.provider_request_sha256 != invocation.provider_request_sha256 {
|
||||
return Err(ContractError::ProviderDigest);
|
||||
}
|
||||
if self.native_request_sha256 != invocation.native_request_sha256 {
|
||||
return Err(ContractError::NativeDigest);
|
||||
}
|
||||
require_id(&self.transport, "receipt.transport")?;
|
||||
match &self.outcome {
|
||||
TransportOutcome::Completed {
|
||||
provider_response_id: Some(value),
|
||||
..
|
||||
} => require_id(value, "receipt.provider_response_id")?,
|
||||
TransportOutcome::Failed {
|
||||
failure_kind,
|
||||
message,
|
||||
} => {
|
||||
require_id(failure_kind, "receipt.failure_kind")?;
|
||||
require_content(message, "receipt.failure_message")?;
|
||||
}
|
||||
TransportOutcome::Completed {
|
||||
provider_response_id: None,
|
||||
..
|
||||
} => {}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub enum TransportOutcome {
|
||||
Completed {
|
||||
provider_response_id: Option<String>,
|
||||
input_tokens: Option<u64>,
|
||||
output_tokens: Option<u64>,
|
||||
reasoning_output_tokens: Option<u64>,
|
||||
cached_input_tokens: Option<u64>,
|
||||
},
|
||||
Failed {
|
||||
failure_kind: String,
|
||||
message: String,
|
||||
},
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub enum Refusal {
|
||||
Expired,
|
||||
IdentitySubstitution,
|
||||
ProviderDigestSubstitution,
|
||||
Policy,
|
||||
Capacity,
|
||||
InFlight,
|
||||
Indeterminate,
|
||||
ReplayConflict,
|
||||
Malformed,
|
||||
}
|
||||
|
||||
pub fn canonical_provider_request_bytes(
|
||||
request: &ProviderRequest,
|
||||
) -> Result<Vec<u8>, ContractError> {
|
||||
request.validate()?;
|
||||
rmp_serde::to_vec(request).map_err(|_| ContractError::Encoding)
|
||||
}
|
||||
|
||||
pub fn provider_request_sha256(request: &ProviderRequest) -> Result<[u8; 32], ContractError> {
|
||||
Ok(Sha256::digest(canonical_provider_request_bytes(request)?).into())
|
||||
}
|
||||
|
||||
pub fn canonical_invocation_bytes(
|
||||
invocation: &TransportInvocation,
|
||||
) -> Result<Vec<u8>, ContractError> {
|
||||
invocation.request.validate()?;
|
||||
if invocation.provider_request_sha256 != provider_request_sha256(&invocation.request)? {
|
||||
return Err(ContractError::ProviderDigest);
|
||||
}
|
||||
rmp_serde::to_vec(invocation).map_err(|_| ContractError::Encoding)
|
||||
}
|
||||
|
||||
pub(crate) fn require_id(value: &str, field: &'static str) -> Result<(), ContractError> {
|
||||
if value.trim().is_empty() || value.trim() != value {
|
||||
return Err(ContractError::Invalid(field));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn require_content(value: &str, field: &'static str) -> Result<(), ContractError> {
|
||||
if value.is_empty() {
|
||||
return Err(ContractError::Invalid(field));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn require_json_object(value: &str, field: &'static str) -> Result<(), ContractError> {
|
||||
if !matches!(
|
||||
serde_json::from_str::<serde_json::Value>(value),
|
||||
Ok(serde_json::Value::Object(_))
|
||||
) {
|
||||
return Err(ContractError::Invalid(field));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn require_provider_name(value: &str, field: &'static str) -> Result<(), ContractError> {
|
||||
if value.is_empty()
|
||||
|| value.len() > 64
|
||||
|| !value
|
||||
.bytes()
|
||||
.all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'_' | b'-'))
|
||||
{
|
||||
return Err(ContractError::Invalid(field));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn require_provider_call_id(value: &str, field: &'static str) -> Result<(), ContractError> {
|
||||
if value.is_empty() || value.len() > 64 || !value.is_ascii() {
|
||||
return Err(ContractError::Invalid(field));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[derive(Debug, Error, PartialEq, Eq)]
|
||||
pub enum ContractError {
|
||||
#[error("unexpected schema")]
|
||||
Schema,
|
||||
#[error("invalid {0}")]
|
||||
Invalid(&'static str),
|
||||
#[error("duplicate tool {0:?}")]
|
||||
DuplicateTool(String),
|
||||
#[error("request identity substitution")]
|
||||
Identity,
|
||||
#[error("provider request digest substitution")]
|
||||
ProviderDigest,
|
||||
#[error("native request digest substitution")]
|
||||
NativeDigest,
|
||||
#[error("invocation expiry is outside the admitted horizon")]
|
||||
Expiry,
|
||||
#[error("transport event sequence is not contiguous")]
|
||||
EventSequence,
|
||||
#[error("canonical MessagePack encoding failed")]
|
||||
Encoding,
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
use crate::fixtures::*;
|
||||
|
||||
#[test]
|
||||
fn provider_digest_is_stable_and_content_addressed() {
|
||||
let original = request();
|
||||
let same = original.clone();
|
||||
assert_eq!(
|
||||
provider_request_sha256(&original).unwrap(),
|
||||
provider_request_sha256(&same).unwrap()
|
||||
);
|
||||
let mut changed = original.clone();
|
||||
changed.instructions.push_str(" Changed.");
|
||||
assert_ne!(
|
||||
provider_request_sha256(&original).unwrap(),
|
||||
provider_request_sha256(&changed).unwrap()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn provider_contract_refuses_transport_side_fixup_pressure() {
|
||||
let mut request = request();
|
||||
request.output_format_name = Some("epiphany.role.result".to_string());
|
||||
request.output_schema_json = Some(r#"{"type":"object"}"#.to_string());
|
||||
assert_eq!(
|
||||
request.validate(),
|
||||
Err(ContractError::Invalid("output_format_name"))
|
||||
);
|
||||
|
||||
request.output_format_name = None;
|
||||
request.output_schema_json = None;
|
||||
request.input.push(InputItem::ToolResult {
|
||||
call_id: "x".repeat(65),
|
||||
output: "evidence".to_string(),
|
||||
});
|
||||
assert_eq!(
|
||||
request.validate(),
|
||||
Err(ContractError::Invalid("input.call_id"))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn invocation_refuses_provider_request_substitution() {
|
||||
let mut invocation = invocation();
|
||||
invocation.request.model = "substituted".to_string();
|
||||
assert_eq!(
|
||||
invocation.validate(1_000, 2_000),
|
||||
Err(ContractError::ProviderDigest)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn invocation_refuses_expired_or_blank_native_provenance() {
|
||||
let mut invocation = invocation();
|
||||
assert_eq!(
|
||||
invocation.validate(2_001, 2_000),
|
||||
Err(ContractError::Expiry)
|
||||
);
|
||||
invocation.expires_at_unix_ms = 2_500;
|
||||
invocation.native_request_sha256 = [0; 32];
|
||||
assert_eq!(
|
||||
invocation.validate(2_001, 2_000),
|
||||
Err(ContractError::Invalid("native_request_sha256"))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn typed_tool_round_survives_messagepack() {
|
||||
let invocation = invocation();
|
||||
let bytes = rmp_serde::to_vec(&invocation).unwrap();
|
||||
let decoded: TransportInvocation = rmp_serde::from_slice(&bytes).unwrap();
|
||||
assert_eq!(decoded, invocation);
|
||||
assert!(matches!(
|
||||
decoded.request.tools.as_slice(),
|
||||
[ToolDefinition { name, .. }] if name == "read_source"
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn transported_result_binds_identity_digest_and_event_order() {
|
||||
let invocation = invocation();
|
||||
let result = TransportResult::transported(
|
||||
&invocation,
|
||||
vec![TransportEvent {
|
||||
sequence: 0,
|
||||
payload: TransportEventPayload::ToolCall {
|
||||
call_id: "call-1".to_string(),
|
||||
name: "read_source".to_string(),
|
||||
arguments: "{}".to_string(),
|
||||
},
|
||||
}],
|
||||
receipt(&invocation),
|
||||
);
|
||||
assert_eq!(result.validate_against(&invocation), Ok(()));
|
||||
|
||||
let mut reordered = result;
|
||||
let TransportDisposition::Transported { events, .. } = &mut reordered.disposition else {
|
||||
unreachable!()
|
||||
};
|
||||
events[0].sequence = 1;
|
||||
assert_eq!(
|
||||
reordered.validate_against(&invocation),
|
||||
Err(ContractError::EventSequence)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn receipt_refuses_caller_substitution() {
|
||||
let invocation = invocation();
|
||||
let mut substituted_caller = receipt(&invocation);
|
||||
substituted_caller.caller_runtime_id = "ghostlight-dungeon-yggdrasil".to_string();
|
||||
assert_eq!(
|
||||
substituted_caller.validate_against(&invocation),
|
||||
Err(ContractError::Identity)
|
||||
);
|
||||
|
||||
let mut substituted_native_basis = receipt(&invocation);
|
||||
substituted_native_basis.native_request_sha256 = [8; 32];
|
||||
assert_eq!(
|
||||
substituted_native_basis.validate_against(&invocation),
|
||||
Err(ContractError::NativeDigest)
|
||||
);
|
||||
}
|
||||
}
|
||||
514
crates/model-connector/src/envelope.rs
Normal file
514
crates/model-connector/src/envelope.rs
Normal file
|
|
@ -0,0 +1,514 @@
|
|||
use std::io::{Read, Write};
|
||||
|
||||
use aes_gcm::aead::{Aead, Payload};
|
||||
use aes_gcm::{Aes256Gcm, KeyInit, Nonce};
|
||||
use rand_core::{OsRng, RngCore};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use thiserror::Error;
|
||||
|
||||
use crate::contract::{
|
||||
ContractError, ENVELOPE_SCHEMA_ID, TransportInvocation, TransportKey, TransportResult,
|
||||
canonical_invocation_bytes, require_id,
|
||||
};
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub enum EnvelopeKind {
|
||||
Invocation,
|
||||
Result,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct TransportEnvelope {
|
||||
pub schema_id: String,
|
||||
pub caller_runtime_id: String,
|
||||
pub request_id: String,
|
||||
pub message_kind: EnvelopeKind,
|
||||
pub nonce: [u8; 12],
|
||||
pub ciphertext: Vec<u8>,
|
||||
}
|
||||
|
||||
/// The longest caller id, request id or model name a service admits. The
|
||||
/// frame arithmetic below depends on it. A request whose id is longer is
|
||||
/// refused on the wire as malformed.
|
||||
pub const MAX_IDENTITY_BYTES: usize = 256;
|
||||
|
||||
/// The most bytes a serialized envelope holding `ciphertext_len` ciphertext
|
||||
/// bytes can occupy, with ids at their longest: the frame a caller payload
|
||||
/// bound (`CallerAdmission`'s `max_payload_bytes`, a bound on ciphertext
|
||||
/// length) needs. The v2 wire serializes the ciphertext as a MessagePack array
|
||||
/// of integers: a byte of 128 or more takes two bytes, so the worst case is
|
||||
/// twice the ciphertext, plus the array header and the envelope's other
|
||||
/// fields at their longest. Measured from the real encoding, not assumed.
|
||||
pub fn frame_bytes_for_payload(ciphertext_len: usize) -> usize {
|
||||
let shell = rmp_serde::to_vec(&TransportEnvelope {
|
||||
schema_id: ENVELOPE_SCHEMA_ID.to_string(),
|
||||
caller_runtime_id: "x".repeat(MAX_IDENTITY_BYTES),
|
||||
request_id: "x".repeat(MAX_IDENTITY_BYTES),
|
||||
message_kind: EnvelopeKind::Invocation,
|
||||
nonce: [0xff; 12],
|
||||
ciphertext: Vec::new(),
|
||||
})
|
||||
.map_or(usize::MAX, |bytes| bytes.len());
|
||||
// The shell holds an empty array header of one byte.
|
||||
let header = match ciphertext_len {
|
||||
0..=15 => 1,
|
||||
16..=65_535 => 3,
|
||||
_ => 5,
|
||||
};
|
||||
shell
|
||||
.saturating_sub(1)
|
||||
.saturating_add(header)
|
||||
.saturating_add(ciphertext_len.saturating_mul(2))
|
||||
}
|
||||
|
||||
/// The largest payload bound whose worst-case frame fits `max_frame_bytes`:
|
||||
/// the inverse of [`frame_bytes_for_payload`]. `None` when the frame cannot
|
||||
/// carry even an empty envelope.
|
||||
pub fn payload_bytes_for_frame(max_frame_bytes: usize) -> Option<usize> {
|
||||
// frame_bytes_for_payload(0) is the shell plus a one-byte array header.
|
||||
let fixed = frame_bytes_for_payload(0) - 1;
|
||||
let room = max_frame_bytes.checked_sub(fixed)?;
|
||||
// Each array header width bounds the payloads it can describe; the
|
||||
// widest payload any width admits is the answer.
|
||||
let by_header = |header: usize, cap: usize| room.checked_sub(header).map(|r| (r / 2).min(cap));
|
||||
[
|
||||
by_header(5, usize::MAX),
|
||||
by_header(3, 65_535),
|
||||
by_header(1, 15),
|
||||
]
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.max()
|
||||
}
|
||||
|
||||
pub fn encrypt_invocation(
|
||||
invocation: &TransportInvocation,
|
||||
security: &TransportKey,
|
||||
) -> Result<TransportEnvelope, ServiceError> {
|
||||
let plaintext = canonical_invocation_bytes(invocation)?;
|
||||
encrypt_message(
|
||||
&invocation.caller_runtime_id,
|
||||
invocation.request_id(),
|
||||
EnvelopeKind::Invocation,
|
||||
&plaintext,
|
||||
security,
|
||||
)
|
||||
}
|
||||
|
||||
pub fn decrypt_invocation(
|
||||
envelope: &TransportEnvelope,
|
||||
security: &TransportKey,
|
||||
) -> Result<TransportInvocation, ServiceError> {
|
||||
validate_envelope(envelope, EnvelopeKind::Invocation)?;
|
||||
let plaintext = decrypt_message(envelope, security)?;
|
||||
let invocation: TransportInvocation =
|
||||
rmp_serde::from_slice(&plaintext).map_err(|_| ServiceError::Encoding)?;
|
||||
if invocation.caller_runtime_id != envelope.caller_runtime_id
|
||||
|| invocation.request_id() != envelope.request_id
|
||||
{
|
||||
return Err(ServiceError::OuterIdentity);
|
||||
}
|
||||
Ok(invocation)
|
||||
}
|
||||
|
||||
pub fn encrypt_result(
|
||||
result: &TransportResult,
|
||||
security: &TransportKey,
|
||||
) -> Result<TransportEnvelope, ServiceError> {
|
||||
let plaintext = rmp_serde::to_vec(result).map_err(|_| ServiceError::Encoding)?;
|
||||
encrypt_message(
|
||||
&result.caller_runtime_id,
|
||||
&result.request_id,
|
||||
EnvelopeKind::Result,
|
||||
&plaintext,
|
||||
security,
|
||||
)
|
||||
}
|
||||
|
||||
pub fn decrypt_result(
|
||||
envelope: &TransportEnvelope,
|
||||
security: &TransportKey,
|
||||
invocation: &TransportInvocation,
|
||||
) -> Result<TransportResult, ServiceError> {
|
||||
validate_envelope(envelope, EnvelopeKind::Result)?;
|
||||
let plaintext = decrypt_message(envelope, security)?;
|
||||
let result: TransportResult =
|
||||
rmp_serde::from_slice(&plaintext).map_err(|_| ServiceError::Encoding)?;
|
||||
if result.caller_runtime_id != envelope.caller_runtime_id
|
||||
|| result.request_id != envelope.request_id
|
||||
{
|
||||
return Err(ServiceError::OuterIdentity);
|
||||
}
|
||||
result.validate_against(invocation)?;
|
||||
Ok(result)
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub(crate) enum TransportFrameError {
|
||||
Connection(std::io::Error),
|
||||
Size,
|
||||
}
|
||||
|
||||
pub(crate) fn read_transport_frame(
|
||||
reader: &mut impl Read,
|
||||
max_frame_bytes: usize,
|
||||
) -> Result<Vec<u8>, TransportFrameError> {
|
||||
let mut length = [0_u8; 4];
|
||||
reader
|
||||
.read_exact(&mut length)
|
||||
.map_err(TransportFrameError::Connection)?;
|
||||
let length = u32::from_be_bytes(length) as usize;
|
||||
if length == 0 || length > max_frame_bytes {
|
||||
return Err(TransportFrameError::Size);
|
||||
}
|
||||
let mut payload = vec![0_u8; length];
|
||||
reader
|
||||
.read_exact(&mut payload)
|
||||
.map_err(TransportFrameError::Connection)?;
|
||||
Ok(payload)
|
||||
}
|
||||
|
||||
pub(crate) fn write_transport_frame(
|
||||
writer: &mut impl Write,
|
||||
payload: &[u8],
|
||||
max_frame_bytes: usize,
|
||||
) -> Result<(), TransportFrameError> {
|
||||
if payload.is_empty() || payload.len() > max_frame_bytes || payload.len() > u32::MAX as usize {
|
||||
return Err(TransportFrameError::Size);
|
||||
}
|
||||
writer
|
||||
.write_all(&(payload.len() as u32).to_be_bytes())
|
||||
.and_then(|()| writer.write_all(payload))
|
||||
.and_then(|()| writer.flush())
|
||||
.map_err(TransportFrameError::Connection)
|
||||
}
|
||||
|
||||
pub(crate) fn encrypt_message(
|
||||
caller_runtime_id: &str,
|
||||
request_id: &str,
|
||||
message_kind: EnvelopeKind,
|
||||
plaintext: &[u8],
|
||||
security: &TransportKey,
|
||||
) -> Result<TransportEnvelope, ServiceError> {
|
||||
require_id(caller_runtime_id, "caller_runtime_id")?;
|
||||
require_id(request_id, "request_id")?;
|
||||
let mut nonce = [0; 12];
|
||||
OsRng.fill_bytes(&mut nonce);
|
||||
let cipher = Aes256Gcm::new_from_slice(&security.0).map_err(|_| ServiceError::Encryption)?;
|
||||
let aad = envelope_aad(
|
||||
ENVELOPE_SCHEMA_ID,
|
||||
caller_runtime_id,
|
||||
request_id,
|
||||
message_kind,
|
||||
)?;
|
||||
let ciphertext = cipher
|
||||
.encrypt(
|
||||
Nonce::from_slice(&nonce),
|
||||
Payload {
|
||||
msg: plaintext,
|
||||
aad: &aad,
|
||||
},
|
||||
)
|
||||
.map_err(|_| ServiceError::Encryption)?;
|
||||
Ok(TransportEnvelope {
|
||||
schema_id: ENVELOPE_SCHEMA_ID.to_string(),
|
||||
caller_runtime_id: caller_runtime_id.to_string(),
|
||||
request_id: request_id.to_string(),
|
||||
message_kind,
|
||||
nonce,
|
||||
ciphertext,
|
||||
})
|
||||
}
|
||||
|
||||
pub(crate) fn decrypt_message(
|
||||
envelope: &TransportEnvelope,
|
||||
security: &TransportKey,
|
||||
) -> Result<Vec<u8>, ServiceError> {
|
||||
let cipher = Aes256Gcm::new_from_slice(&security.0).map_err(|_| ServiceError::Encryption)?;
|
||||
let aad = envelope_aad(
|
||||
&envelope.schema_id,
|
||||
&envelope.caller_runtime_id,
|
||||
&envelope.request_id,
|
||||
envelope.message_kind,
|
||||
)?;
|
||||
cipher
|
||||
.decrypt(
|
||||
Nonce::from_slice(&envelope.nonce),
|
||||
Payload {
|
||||
msg: &envelope.ciphertext,
|
||||
aad: &aad,
|
||||
},
|
||||
)
|
||||
.map_err(|_| ServiceError::Encryption)
|
||||
}
|
||||
|
||||
pub(crate) fn envelope_aad(
|
||||
schema_id: &str,
|
||||
caller_runtime_id: &str,
|
||||
request_id: &str,
|
||||
message_kind: EnvelopeKind,
|
||||
) -> Result<Vec<u8>, ServiceError> {
|
||||
rmp_serde::to_vec(&(schema_id, caller_runtime_id, request_id, message_kind))
|
||||
.map_err(|_| ServiceError::Encoding)
|
||||
}
|
||||
|
||||
pub(crate) fn validate_envelope(
|
||||
envelope: &TransportEnvelope,
|
||||
expected_kind: EnvelopeKind,
|
||||
) -> Result<(), ServiceError> {
|
||||
if envelope.schema_id != ENVELOPE_SCHEMA_ID || envelope.message_kind != expected_kind {
|
||||
return Err(ServiceError::Envelope);
|
||||
}
|
||||
require_id(&envelope.caller_runtime_id, "caller_runtime_id")?;
|
||||
require_id(&envelope.request_id, "request_id")?;
|
||||
if envelope.ciphertext.is_empty() {
|
||||
return Err(ServiceError::Envelope);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[derive(Debug, Error, PartialEq, Eq)]
|
||||
pub enum ServiceError {
|
||||
#[error(transparent)]
|
||||
Contract(#[from] ContractError),
|
||||
#[error("invalid caller admission")]
|
||||
InvalidAdmission,
|
||||
#[error("caller runtime was admitted twice")]
|
||||
DuplicateCaller,
|
||||
#[error("callers must not share transport keys")]
|
||||
SharedCallerKey,
|
||||
#[error("connector replay store failed: {0}")]
|
||||
ReplayStore(String),
|
||||
#[error("connector replay record is malformed")]
|
||||
InvalidReplayRecord,
|
||||
#[error("caller key changed while durable replay records still exist")]
|
||||
ReplayCallerKeyMismatch,
|
||||
#[error("caller runtime is not admitted")]
|
||||
CallerNotAdmitted,
|
||||
#[error("unexpected transport envelope")]
|
||||
Envelope,
|
||||
#[error("transport envelope identity substitution")]
|
||||
OuterIdentity,
|
||||
#[error("transport encryption or authentication failed")]
|
||||
Encryption,
|
||||
#[error("transport MessagePack encoding failed")]
|
||||
Encoding,
|
||||
#[error("completion did not carry a transported provider outcome")]
|
||||
InvalidCompletion,
|
||||
#[error("completion has no active request")]
|
||||
NoActiveRequest,
|
||||
#[error("completion does not match the active request")]
|
||||
ActiveRequestMismatch,
|
||||
#[error("sealed result does not fit the frame bound")]
|
||||
FrameBound,
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::fixtures::*;
|
||||
|
||||
#[test]
|
||||
fn encrypted_invocation_hides_content_and_binds_outer_identity() {
|
||||
let invocation = invocation();
|
||||
let epiphany_key = security("epiphany-distinct-test-key");
|
||||
let envelope = encrypt_invocation(&invocation, &epiphany_key).unwrap();
|
||||
assert!(
|
||||
!envelope
|
||||
.ciphertext
|
||||
.windows("Projected state".len())
|
||||
.any(|window| window == b"Projected state")
|
||||
);
|
||||
assert_eq!(
|
||||
decrypt_invocation(&envelope, &epiphany_key).unwrap(),
|
||||
invocation
|
||||
);
|
||||
assert_eq!(
|
||||
decrypt_invocation(&envelope, &security("wrong-caller-key")),
|
||||
Err(ServiceError::Encryption)
|
||||
);
|
||||
|
||||
let mut substituted = envelope;
|
||||
substituted.request_id = "request-2".to_string();
|
||||
assert_eq!(
|
||||
decrypt_invocation(&substituted, &epiphany_key),
|
||||
Err(ServiceError::Encryption)
|
||||
);
|
||||
}
|
||||
|
||||
fn sealed_invocation(caller: &str, request_id: &str, kind: EnvelopeKind) -> TransportEnvelope {
|
||||
let invocation = invocation();
|
||||
let plaintext = canonical_invocation_bytes(&invocation).unwrap();
|
||||
encrypt_message(
|
||||
caller,
|
||||
request_id,
|
||||
kind,
|
||||
&plaintext,
|
||||
&security("epiphany-distinct-test-key"),
|
||||
)
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_outer_identity_must_match_the_sealed_invocation_on_each_field() {
|
||||
let key = security("epiphany-distinct-test-key");
|
||||
let invocation = invocation();
|
||||
let caller = invocation.caller_runtime_id.clone();
|
||||
let request_id = invocation.request_id().to_string();
|
||||
let matching = sealed_invocation(&caller, &request_id, EnvelopeKind::Invocation);
|
||||
assert_eq!(decrypt_invocation(&matching, &key).unwrap(), invocation);
|
||||
for envelope in [
|
||||
sealed_invocation("other-caller", &request_id, EnvelopeKind::Invocation),
|
||||
sealed_invocation(&caller, "other-request", EnvelopeKind::Invocation),
|
||||
] {
|
||||
assert_eq!(
|
||||
decrypt_invocation(&envelope, &key),
|
||||
Err(ServiceError::OuterIdentity)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_outer_identity_must_match_the_sealed_result_on_each_field() {
|
||||
let key = security("epiphany-distinct-test-key");
|
||||
let invocation = invocation();
|
||||
let result = TransportResult::transported(&invocation, Vec::new(), receipt(&invocation));
|
||||
let plaintext = rmp_serde::to_vec(&result).unwrap();
|
||||
let seal = |caller: &str, request_id: &str| {
|
||||
encrypt_message(caller, request_id, EnvelopeKind::Result, &plaintext, &key).unwrap()
|
||||
};
|
||||
let caller = result.caller_runtime_id.clone();
|
||||
let request_id = result.request_id.clone();
|
||||
assert_eq!(
|
||||
decrypt_result(&seal(&caller, &request_id), &key, &invocation).unwrap(),
|
||||
result
|
||||
);
|
||||
for envelope in [
|
||||
seal("other-caller", &request_id),
|
||||
seal(&caller, "other-request"),
|
||||
] {
|
||||
assert_eq!(
|
||||
decrypt_result(&envelope, &key, &invocation),
|
||||
Err(ServiceError::OuterIdentity)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_malformed_envelope_is_refused_before_any_decryption() {
|
||||
let key = security("epiphany-distinct-test-key");
|
||||
let invocation = invocation();
|
||||
let good = encrypt_invocation(&invocation, &key).unwrap();
|
||||
|
||||
let mut schema = good.clone();
|
||||
schema.schema_id = "gamecult.codex.transport_envelope.v1".to_string();
|
||||
let wrong_kind = sealed_invocation("epiphany-yggdrasil", "request-1", EnvelopeKind::Result);
|
||||
let mut empty = good.clone();
|
||||
empty.ciphertext.clear();
|
||||
for envelope in [schema, wrong_kind, empty] {
|
||||
assert_eq!(
|
||||
decrypt_invocation(&envelope, &key),
|
||||
Err(ServiceError::Envelope)
|
||||
);
|
||||
}
|
||||
for field in ["caller_runtime_id", "request_id"] {
|
||||
let mut blank = good.clone();
|
||||
if field == "caller_runtime_id" {
|
||||
blank.caller_runtime_id = " ".to_string();
|
||||
} else {
|
||||
blank.request_id = String::new();
|
||||
}
|
||||
assert_eq!(
|
||||
decrypt_invocation(&blank, &key),
|
||||
Err(ServiceError::Contract(ContractError::Invalid(field)))
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_frame_is_bounded_inclusively_and_never_empty() {
|
||||
use std::io::Cursor;
|
||||
let read = |length: u32, body: usize, max: usize| {
|
||||
let mut frame = length.to_be_bytes().to_vec();
|
||||
frame.extend(vec![1; body]);
|
||||
read_transport_frame(&mut Cursor::new(frame), max)
|
||||
};
|
||||
assert_eq!(read(16, 16, 16).unwrap(), vec![1; 16]);
|
||||
assert!(matches!(read(17, 17, 16), Err(TransportFrameError::Size)));
|
||||
assert!(matches!(read(0, 0, 16), Err(TransportFrameError::Size)));
|
||||
// A bound above the length never turns an empty frame into an empty payload.
|
||||
assert!(matches!(read(0, 0, 1_000), Err(TransportFrameError::Size)));
|
||||
|
||||
let write = |payload: &[u8], max: usize| {
|
||||
let mut out = Vec::new();
|
||||
write_transport_frame(&mut out, payload, max).map(|()| out)
|
||||
};
|
||||
assert_eq!(write(&[1; 16], 16).unwrap().len(), 20);
|
||||
assert!(matches!(
|
||||
write(&[1; 17], 16),
|
||||
Err(TransportFrameError::Size)
|
||||
));
|
||||
assert!(matches!(write(&[], 16), Err(TransportFrameError::Size)));
|
||||
assert!(matches!(write(&[], 1_000), Err(TransportFrameError::Size)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_worst_frame_is_the_real_encoding_of_an_all_high_ciphertext() {
|
||||
let long = "x".repeat(MAX_IDENTITY_BYTES);
|
||||
for length in [0, 1, 15, 16, 17, 255, 256, 65_535, 65_536, 70_000] {
|
||||
let envelope = TransportEnvelope {
|
||||
schema_id: ENVELOPE_SCHEMA_ID.to_string(),
|
||||
caller_runtime_id: long.clone(),
|
||||
request_id: long.clone(),
|
||||
message_kind: EnvelopeKind::Invocation,
|
||||
nonce: [0xff; 12],
|
||||
ciphertext: vec![0xff; length],
|
||||
};
|
||||
assert_eq!(
|
||||
rmp_serde::to_vec(&envelope).unwrap().len(),
|
||||
frame_bytes_for_payload(length),
|
||||
"{length}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_sealed_envelope_never_exceeds_the_worst_frame_for_its_ciphertext() {
|
||||
let key = security("epiphany-distinct-test-key");
|
||||
for id in ["a".to_string(), "i".repeat(MAX_IDENTITY_BYTES)] {
|
||||
for plaintext in [1, 100, 5_000, 70_000] {
|
||||
let sealed =
|
||||
encrypt_message(&id, &id, EnvelopeKind::Result, &vec![0x41; plaintext], &key)
|
||||
.unwrap();
|
||||
let real = rmp_serde::to_vec(&sealed).unwrap().len();
|
||||
let worst = frame_bytes_for_payload(sealed.ciphertext.len());
|
||||
assert!(real <= worst, "{real} > {worst}");
|
||||
// Real ciphertext is random, so about half its bytes take two
|
||||
// bytes: the worst case is not wildly above what is written.
|
||||
if id.len() == MAX_IDENTITY_BYTES {
|
||||
assert!(
|
||||
worst <= real + sealed.ciphertext.len() + 64,
|
||||
"{worst} {real}"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_payload_bound_a_frame_allows_inverts_the_frame_a_payload_needs() {
|
||||
assert_eq!(
|
||||
payload_bytes_for_frame(frame_bytes_for_payload(0) - 1),
|
||||
None
|
||||
);
|
||||
assert_eq!(payload_bytes_for_frame(frame_bytes_for_payload(0)), Some(0));
|
||||
for bytes in [1, 15, 16, 4096, 65_535, 65_536, 100_000, 2_000_000] {
|
||||
let frame = frame_bytes_for_payload(bytes);
|
||||
assert_eq!(payload_bytes_for_frame(frame), Some(bytes), "{bytes}");
|
||||
assert!(
|
||||
payload_bytes_for_frame(frame - 1).unwrap() < bytes,
|
||||
"{bytes}"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
128
crates/model-connector/src/fixtures.rs
Normal file
128
crates/model-connector/src/fixtures.rs
Normal file
|
|
@ -0,0 +1,128 @@
|
|||
//! Shared fixtures for the carried contract, envelope, client and service tests.
|
||||
use crate::contract::*;
|
||||
|
||||
#[cfg(feature = "daemon")]
|
||||
use crate::service::*;
|
||||
|
||||
#[cfg(feature = "daemon")]
|
||||
use std::sync::atomic::{AtomicUsize, Ordering};
|
||||
|
||||
#[cfg(feature = "daemon")]
|
||||
static TEST_STORE_SEQUENCE: AtomicUsize = AtomicUsize::new(0);
|
||||
|
||||
#[cfg(feature = "daemon")]
|
||||
pub(crate) struct TestStore {
|
||||
pub(crate) root: std::path::PathBuf,
|
||||
pub(crate) path: std::path::PathBuf,
|
||||
}
|
||||
|
||||
#[cfg(feature = "daemon")]
|
||||
impl TestStore {
|
||||
pub(crate) fn new() -> Self {
|
||||
let sequence = TEST_STORE_SEQUENCE.fetch_add(1, Ordering::Relaxed);
|
||||
let root = std::env::temp_dir().join(format!(
|
||||
"model-connector-replay-{}-{sequence}",
|
||||
std::process::id()
|
||||
));
|
||||
std::fs::create_dir_all(&root).unwrap();
|
||||
let path = root.join("replay.cc");
|
||||
Self { root, path }
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(feature = "daemon")]
|
||||
impl Drop for TestStore {
|
||||
fn drop(&mut self) {
|
||||
let _ = std::fs::remove_dir_all(&self.root);
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn request() -> ProviderRequest {
|
||||
let mut request = ProviderRequest::new(
|
||||
"request-1",
|
||||
"conversation-1",
|
||||
"gpt-5.4",
|
||||
"Return the typed result.",
|
||||
);
|
||||
request.input.push(InputItem::UserText {
|
||||
text: "Projected state".to_string(),
|
||||
});
|
||||
request.tools.push(ToolDefinition {
|
||||
name: "read_source".to_string(),
|
||||
description: "Read one admitted source.".to_string(),
|
||||
parameters_json: r#"{"type":"object"}"#.to_string(),
|
||||
});
|
||||
request
|
||||
}
|
||||
|
||||
pub(crate) fn invocation() -> TransportInvocation {
|
||||
TransportInvocation::new("epiphany-yggdrasil", 2_000, [7; 32], request()).unwrap()
|
||||
}
|
||||
|
||||
pub(crate) fn receipt(invocation: &TransportInvocation) -> TransportReceipt {
|
||||
TransportReceipt {
|
||||
schema_id: RECEIPT_SCHEMA_ID.to_string(),
|
||||
request_id: invocation.request_id().to_string(),
|
||||
caller_runtime_id: invocation.caller_runtime_id.clone(),
|
||||
native_request_sha256: invocation.native_request_sha256,
|
||||
provider_request_sha256: invocation.provider_request_sha256,
|
||||
model: invocation.request.model.clone(),
|
||||
transport: "fixture-sse".to_string(),
|
||||
outcome: TransportOutcome::Completed {
|
||||
provider_response_id: Some("response-1".to_string()),
|
||||
input_tokens: Some(10),
|
||||
output_tokens: Some(3),
|
||||
reasoning_output_tokens: Some(1),
|
||||
cached_input_tokens: None,
|
||||
},
|
||||
}
|
||||
}
|
||||
#[cfg(feature = "daemon")]
|
||||
pub(crate) fn caller(
|
||||
caller_runtime_id: &str,
|
||||
key: &str,
|
||||
max_concurrent_requests: usize,
|
||||
) -> CallerAdmission {
|
||||
CallerAdmission::new(
|
||||
caller_runtime_id,
|
||||
key,
|
||||
1,
|
||||
["gpt-5.4".to_string()],
|
||||
max_concurrent_requests,
|
||||
64 * 1024,
|
||||
32_768,
|
||||
)
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
#[cfg(feature = "daemon")]
|
||||
pub(crate) fn open_service(store: &TestStore, max_concurrent_requests: usize) -> TransportService {
|
||||
TransportService::open(
|
||||
&store.path,
|
||||
[
|
||||
caller(
|
||||
"epiphany-yggdrasil",
|
||||
"epiphany-distinct-test-key",
|
||||
max_concurrent_requests,
|
||||
),
|
||||
caller(
|
||||
"ghostlight-yggdrasil",
|
||||
"ghostlight-distinct-test-key",
|
||||
max_concurrent_requests,
|
||||
),
|
||||
],
|
||||
5_000,
|
||||
)
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
pub(crate) fn security(key: &str) -> TransportKey {
|
||||
TransportKey::from_connection_secret(key).unwrap()
|
||||
}
|
||||
|
||||
#[cfg(feature = "daemon")]
|
||||
pub(crate) fn invocation_for(caller_runtime_id: &str, request_id: &str) -> TransportInvocation {
|
||||
let mut request = request();
|
||||
request.request_id = request_id.to_string();
|
||||
TransportInvocation::new(caller_runtime_id, 2_000, [7; 32], request).unwrap()
|
||||
}
|
||||
20
crates/model-connector/src/lib.rs
Normal file
20
crates/model-connector/src/lib.rs
Normal file
|
|
@ -0,0 +1,20 @@
|
|||
mod client;
|
||||
mod contract;
|
||||
mod envelope;
|
||||
|
||||
#[cfg(test)]
|
||||
mod fixtures;
|
||||
|
||||
#[cfg(feature = "daemon")]
|
||||
mod serve;
|
||||
#[cfg(feature = "daemon")]
|
||||
mod service;
|
||||
|
||||
pub use client::*;
|
||||
pub use contract::*;
|
||||
pub use envelope::*;
|
||||
|
||||
#[cfg(feature = "daemon")]
|
||||
pub use serve::*;
|
||||
#[cfg(feature = "daemon")]
|
||||
pub use service::*;
|
||||
1157
crates/model-connector/src/serve.rs
Normal file
1157
crates/model-connector/src/serve.rs
Normal file
File diff suppressed because it is too large
Load diff
1250
crates/model-connector/src/service.rs
Normal file
1250
crates/model-connector/src/service.rs
Normal file
File diff suppressed because it is too large
Load diff
2
crates/model-connector/tests/fixtures/wire_v2/.gitattributes
vendored
Normal file
2
crates/model-connector/tests/fixtures/wire_v2/.gitattributes
vendored
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
*.mp binary
|
||||
*.bin binary
|
||||
21
crates/model-connector/tests/fixtures/wire_v2/README.md
vendored
Normal file
21
crates/model-connector/tests/fixtures/wire_v2/README.md
vendored
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
# Golden v2 wire fixtures
|
||||
|
||||
Bytes of the v2 connector wire, produced once by CodexConnector at commit
|
||||
`fd7cec4176f4e3f9a5aaa75e77ba10ce5e1522f8`. Ghostlight pins CodexConnector
|
||||
`68fe94b`, not that commit; `src/lib.rs`, the whole v2 wire, is identical in
|
||||
both, so these bytes are Ghostlight's wire. They are pinned by `tests/wire_golden_v2.rs`, so the kit cannot drift from
|
||||
them. No CodexConnector code or dependency is part of this repository.
|
||||
|
||||
Generator: `generator.rs` (not compiled here). Command, run in a clone of
|
||||
CodexConnector at that commit:
|
||||
|
||||
cp <ModelConnector>/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 <name> <hex>` 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.
|
||||
BIN
crates/model-connector/tests/fixtures/wire_v2/canonical_invocation.bin
vendored
Normal file
BIN
crates/model-connector/tests/fixtures/wire_v2/canonical_invocation.bin
vendored
Normal file
Binary file not shown.
BIN
crates/model-connector/tests/fixtures/wire_v2/canonical_provider_request.bin
vendored
Normal file
BIN
crates/model-connector/tests/fixtures/wire_v2/canonical_provider_request.bin
vendored
Normal file
Binary file not shown.
6
crates/model-connector/tests/fixtures/wire_v2/envelope_invocation.mp
vendored
Normal file
6
crates/model-connector/tests/fixtures/wire_v2/envelope_invocation.mp
vendored
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
栙$gamecult.codex.transport_envelope.v2璸arity-caller﹔equest-1狪nvocation溙<6E>塘h虄虒抬x泰Y探<1B>環谭听题虠虆3陶
糖烃虘誊h抬台涕[酞藤!A屉虊甜铁疼;屉铁| 酞躺`!铁铁虤抬虗
|
||||
虂k剔'vCZ锑號虒虉
8J抬汤d蹋虠N炭F炭zm6<6D><36>
|
||||
眺添甜\\NMq號套虠贴1替苔摊惕4*{趟胎瘫
|
||||
讨苔e糖台虖
|
||||
贪虇i贪G*S虂坛碳桃胎坍誊!N汤萄虪虃太虣7虂糖贪天虄汤态c苔趟桃滩1k提~h踢3$腾虪K踢贪贴誊4{逃踢碳泰U.)烫汰踏r虪听天唐^bbI虉y^坛踢烫虂躺:虪虤-体YXo逃桃[Qy虡太
|
||||
X逃I:3探淘贪D&V'-舔uf虒X1跳桃.虧|4c蹄虌E体A踢替虩掏Q梯L虃-提w贪u檀3坦>田$Qz汤km'+绦挞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屉
|
||||
7
crates/model-connector/tests/fixtures/wire_v2/envelope_result_failed.mp
vendored
Normal file
7
crates/model-connector/tests/fixtures/wire_v2/envelope_result_failed.mp
vendored
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
–Ù$gamecult.codex.transport_envelope.v2parity-caller©request-1¦ResultœÌ…Ì¢ÌÇ"̇Ì<E280A1>GÌ©ÜCÌôÌË Ì<>ÌéHÌßÌ—ÌÿÌÐÌ´ÌÝÌá\Ìö]2/ÌÎe"̘
|
||||
Ì¿ÌáÌìÌü{GÌÚṴ̧^b3Ìûj̢̗̟ÌÃÌ’sÌ•*Ì» ÌøÌä≠̄<ÌÁ{ÌýÌã.ÌØ8~̫̓CÌâ
|
||||
̱̘ÌË
|
||||
[Ì‚ÌÉoÌ–(47ÌÌBML̲Ìó̢̳Ì<C2A2>hp,ÌîÌ®wÌê6DHÌðbÌÏÌÌb̹@a̧ÌÑ̵̴̎Ì<>kÌäÌ’ÌáÌóÌ€ÌÑN+ÌüMÌÀjG%Ìß/Ìô
|
||||
ÌÁÌò̸%Ì»Ì<C2BB>@sT`ÌâÌ’}P̈ÌÒ ÌëÌÊÌÞÌ«Ìâ:ÌÚ̟̖̔Ìû^Ìô>ÌûX.x
|
||||
r]F8Ì<38>ÌÛÌ‹Ì÷Ì<C3B7>5ÌÚGÌÆÌºÌÃÌ®C@(ÌóÌÓ̾{̾ÌÙMÌÓ~)Ìà7ÌÐÌòI+'vÌøUnP̈$U(NÌêÌçÌüsÌ¢T^[ÌÊYÌæÌ<C3A6>zÌâ.wÌÆeÌ™C̦̿̄"U^trÌöÌ<C3B6>ÌÙ̦Ìï̯̬ÌçÌÜÌž+ÌÑ/b̯Ì̼Ìî*$Ì×̲̘̊ÌÕ\K5̧f
|
||||
Ìò̃b}̦̬o.Ẁ̧&Ìå̶̑[
|
||||
BIN
crates/model-connector/tests/fixtures/wire_v2/envelope_result_refused_replay_conflict.mp
vendored
Normal file
BIN
crates/model-connector/tests/fixtures/wire_v2/envelope_result_refused_replay_conflict.mp
vendored
Normal file
Binary file not shown.
BIN
crates/model-connector/tests/fixtures/wire_v2/envelope_result_transported.mp
vendored
Normal file
BIN
crates/model-connector/tests/fixtures/wire_v2/envelope_result_transported.mp
vendored
Normal file
Binary file not shown.
171
crates/model-connector/tests/fixtures/wire_v2/generator.rs
vendored
Normal file
171
crates/model-connector/tests/fixtures/wire_v2/generator.rs
vendored
Normal file
|
|
@ -0,0 +1,171 @@
|
|||
//! Generator of the golden v2 wire fixtures beside it. It is not compiled by this
|
||||
//! workspace: it was run once, as an example of CodexConnector at commit
|
||||
//! fd7cec4176f4e3f9a5aaa75e77ba10ce5e1522f8 (see README.md here for the command).
|
||||
//! It prints one `FIXTURE <name> <hex>` 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<T: Serialize>(value: &T) -> Vec<u8> {
|
||||
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()),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
BIN
crates/model-connector/tests/fixtures/wire_v2/invocation_auto.mp
vendored
Normal file
BIN
crates/model-connector/tests/fixtures/wire_v2/invocation_auto.mp
vendored
Normal file
Binary file not shown.
BIN
crates/model-connector/tests/fixtures/wire_v2/invocation_required.mp
vendored
Normal file
BIN
crates/model-connector/tests/fixtures/wire_v2/invocation_required.mp
vendored
Normal file
Binary file not shown.
BIN
crates/model-connector/tests/fixtures/wire_v2/provider_request_sha256.bin
vendored
Normal file
BIN
crates/model-connector/tests/fixtures/wire_v2/provider_request_sha256.bin
vendored
Normal file
Binary file not shown.
BIN
crates/model-connector/tests/fixtures/wire_v2/result_failed.mp
vendored
Normal file
BIN
crates/model-connector/tests/fixtures/wire_v2/result_failed.mp
vendored
Normal file
Binary file not shown.
1
crates/model-connector/tests/fixtures/wire_v2/result_refused_capacity.mp
vendored
Normal file
1
crates/model-connector/tests/fixtures/wire_v2/result_refused_capacity.mp
vendored
Normal file
|
|
@ -0,0 +1 @@
|
|||
”Ù"gamecult.codex.transport_result.v2©request-1parity-caller<65>§Refused¨Capacity
|
||||
1
crates/model-connector/tests/fixtures/wire_v2/result_refused_expired.mp
vendored
Normal file
1
crates/model-connector/tests/fixtures/wire_v2/result_refused_expired.mp
vendored
Normal file
|
|
@ -0,0 +1 @@
|
|||
”Ù"gamecult.codex.transport_result.v2©request-1parity-caller<65>§Refused§Expired
|
||||
1
crates/model-connector/tests/fixtures/wire_v2/result_refused_identity_substitution.mp
vendored
Normal file
1
crates/model-connector/tests/fixtures/wire_v2/result_refused_identity_substitution.mp
vendored
Normal file
|
|
@ -0,0 +1 @@
|
|||
”Ù"gamecult.codex.transport_result.v2©request-1parity-caller<65>§Refused´IdentitySubstitution
|
||||
1
crates/model-connector/tests/fixtures/wire_v2/result_refused_in_flight.mp
vendored
Normal file
1
crates/model-connector/tests/fixtures/wire_v2/result_refused_in_flight.mp
vendored
Normal file
|
|
@ -0,0 +1 @@
|
|||
”Ù"gamecult.codex.transport_result.v2©request-1parity-caller<65>§Refused¨InFlight
|
||||
1
crates/model-connector/tests/fixtures/wire_v2/result_refused_indeterminate.mp
vendored
Normal file
1
crates/model-connector/tests/fixtures/wire_v2/result_refused_indeterminate.mp
vendored
Normal file
|
|
@ -0,0 +1 @@
|
|||
”Ù"gamecult.codex.transport_result.v2©request-1parity-caller<65>§RefusedIndeterminate
|
||||
1
crates/model-connector/tests/fixtures/wire_v2/result_refused_malformed.mp
vendored
Normal file
1
crates/model-connector/tests/fixtures/wire_v2/result_refused_malformed.mp
vendored
Normal file
|
|
@ -0,0 +1 @@
|
|||
”Ù"gamecult.codex.transport_result.v2©request-1parity-caller<65>§Refused©Malformed
|
||||
1
crates/model-connector/tests/fixtures/wire_v2/result_refused_policy.mp
vendored
Normal file
1
crates/model-connector/tests/fixtures/wire_v2/result_refused_policy.mp
vendored
Normal file
|
|
@ -0,0 +1 @@
|
|||
”Ù"gamecult.codex.transport_result.v2©request-1parity-caller<65>§Refused¦Policy
|
||||
1
crates/model-connector/tests/fixtures/wire_v2/result_refused_provider_digest_substitution.mp
vendored
Normal file
1
crates/model-connector/tests/fixtures/wire_v2/result_refused_provider_digest_substitution.mp
vendored
Normal file
|
|
@ -0,0 +1 @@
|
|||
”Ù"gamecult.codex.transport_result.v2©request-1parity-caller<65>§RefusedºProviderDigestSubstitution
|
||||
1
crates/model-connector/tests/fixtures/wire_v2/result_refused_replay_conflict.mp
vendored
Normal file
1
crates/model-connector/tests/fixtures/wire_v2/result_refused_replay_conflict.mp
vendored
Normal file
|
|
@ -0,0 +1 @@
|
|||
”Ù"gamecult.codex.transport_result.v2©request-1parity-caller<65>§Refused®ReplayConflict
|
||||
BIN
crates/model-connector/tests/fixtures/wire_v2/result_transported.mp
vendored
Normal file
BIN
crates/model-connector/tests/fixtures/wire_v2/result_transported.mp
vendored
Normal file
Binary file not shown.
301
crates/model-connector/tests/wire_golden_v2.rs
Normal file
301
crates/model-connector/tests/wire_golden_v2.rs
Normal file
|
|
@ -0,0 +1,301 @@
|
|||
//! The kit speaks the v2 wire of CodexConnector fd7cec4, the library Ghostlight pins.
|
||||
//! The golden bytes in `fixtures/wire_v2` were produced by that library (see its README);
|
||||
//! nothing of it is linked here. Deleted with the fixtures by cut connector-contract-v3,
|
||||
//! which changes the wire.
|
||||
|
||||
use model_connector as kit;
|
||||
use serde::Serialize;
|
||||
use serde::de::DeserializeOwned;
|
||||
|
||||
const KEY: &str = "parity-distinct-test-key";
|
||||
const CALLER: &str = "parity-caller";
|
||||
|
||||
macro_rules! golden {
|
||||
($name:literal) => {
|
||||
include_bytes!(concat!("fixtures/wire_v2/", $name)) as &[u8]
|
||||
};
|
||||
}
|
||||
|
||||
fn bytes<T: Serialize>(value: &T) -> Vec<u8> {
|
||||
rmp_serde::to_vec(value).unwrap()
|
||||
}
|
||||
|
||||
/// The value serializes to the golden bytes and the golden bytes decode back to it.
|
||||
fn assert_wire<T: Serialize + DeserializeOwned + PartialEq + std::fmt::Debug>(
|
||||
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());
|
||||
}
|
||||
Loading…
Reference in a new issue