Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a0a6ccbf0d | ||
|
|
03bc88ae97 | ||
|
|
aed07a4b71 | ||
|
|
2101602b8f | ||
|
|
b6ad75c610 | ||
|
|
face15ec16 | ||
|
|
618475b159 | ||
|
|
c23d91391d | ||
|
|
5894632829 | ||
|
|
33f092ea7f | ||
|
|
e669d2b867 | ||
|
|
5a4d8bd477 | ||
|
|
685f449ddb |
49 changed files with 3127 additions and 767 deletions
2
.gitattributes
vendored
Normal file
2
.gitattributes
vendored
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
schemas/*.json text eol=lf
|
||||
crates/model-connector/tests/fixtures/wire_v3/* -text
|
||||
431
Cargo.lock
generated
431
Cargo.lock
generated
|
|
@ -52,12 +52,33 @@ version = "1.0.104"
|
|||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "330a5ed07fa54e4702c9d6c4174f74427fc0ef6e214bbd677ae50a5099946470"
|
||||
|
||||
[[package]]
|
||||
name = "ar_archive_writer"
|
||||
version = "0.5.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "73cd58deff2140a0a8eae87e417bd01db68a33e148aa93d1e8cd837e55e312b6"
|
||||
dependencies = [
|
||||
"object",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "autocfg"
|
||||
version = "1.5.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53"
|
||||
|
||||
[[package]]
|
||||
name = "base64"
|
||||
version = "0.22.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6"
|
||||
|
||||
[[package]]
|
||||
name = "base64ct"
|
||||
version = "1.8.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2af50177e190e07a26ab74f8b1efbfe2ef87da2116221318cb1c2e82baf7de06"
|
||||
|
||||
[[package]]
|
||||
name = "block-buffer"
|
||||
version = "0.10.4"
|
||||
|
|
@ -113,6 +134,12 @@ dependencies = [
|
|||
"inout",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "const-oid"
|
||||
version = "0.9.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c2459377285ad874054d797f3ccebf984978aa39129f6eafde5cdc8315b612f8"
|
||||
|
||||
[[package]]
|
||||
name = "core-foundation-sys"
|
||||
version = "0.8.7"
|
||||
|
|
@ -135,7 +162,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
|||
checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a"
|
||||
dependencies = [
|
||||
"generic-array",
|
||||
"rand_core",
|
||||
"rand_core 0.6.4",
|
||||
"typenum",
|
||||
]
|
||||
|
||||
|
|
@ -150,8 +177,8 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "cultcache-rs"
|
||||
version = "0.1.0"
|
||||
source = "git+https://github.com/GameCult/CultLib.git?rev=e171eca32329baa928f4a1d810401a8b4c857029#e171eca32329baa928f4a1d810401a8b4c857029"
|
||||
version = "0.3.1"
|
||||
source = "git+https://github.com/GameCult/CultLib.git?rev=a4eae9056152335662f1115f97bf35fb00ab6d23#a4eae9056152335662f1115f97bf35fb00ab6d23"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"chrono",
|
||||
|
|
@ -162,20 +189,84 @@ dependencies = [
|
|||
"serde",
|
||||
"serde_bytes",
|
||||
"serde_json",
|
||||
"serde_stacker",
|
||||
"stacker",
|
||||
"uuid",
|
||||
"windows-sys",
|
||||
"windows-sys 0.59.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "cultcache-rs-derive"
|
||||
version = "0.1.0"
|
||||
source = "git+https://github.com/GameCult/CultLib.git?rev=e171eca32329baa928f4a1d810401a8b4c857029#e171eca32329baa928f4a1d810401a8b4c857029"
|
||||
version = "0.2.0"
|
||||
source = "git+https://github.com/GameCult/CultLib.git?rev=a4eae9056152335662f1115f97bf35fb00ab6d23#a4eae9056152335662f1115f97bf35fb00ab6d23"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.119",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "cultnet-rs"
|
||||
version = "0.2.0"
|
||||
source = "git+https://github.com/GameCult/CultLib.git?rev=a4eae9056152335662f1115f97bf35fb00ab6d23#a4eae9056152335662f1115f97bf35fb00ab6d23"
|
||||
dependencies = [
|
||||
"aes-gcm",
|
||||
"anyhow",
|
||||
"base64",
|
||||
"chrono",
|
||||
"cultcache-rs",
|
||||
"ed25519-dalek",
|
||||
"hmac",
|
||||
"rand",
|
||||
"rmp-serde",
|
||||
"rmpv",
|
||||
"serde",
|
||||
"serde_bytes",
|
||||
"serde_json",
|
||||
"sha2",
|
||||
"socket2",
|
||||
"uuid",
|
||||
"windows-sys 0.61.2",
|
||||
"zeroize",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "curve25519-dalek"
|
||||
version = "4.1.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "97fb8b7c4503de7d6ae7b42ab72a5a59857b4c937ec27a3d4539dba95b5ab2be"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"cpufeatures",
|
||||
"curve25519-dalek-derive",
|
||||
"digest",
|
||||
"fiat-crypto",
|
||||
"rustc_version",
|
||||
"subtle",
|
||||
"zeroize",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "curve25519-dalek-derive"
|
||||
version = "0.1.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f46882e17999c6cc590af592290432be3bce0428cb0d5f8b6715e4dc7b383eb3"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.119",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "der"
|
||||
version = "0.7.10"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e7c1832837b905bbfb5101e07cc24c8deddf52f93225eee6ead5f4d63d53ddcb"
|
||||
dependencies = [
|
||||
"const-oid",
|
||||
"zeroize",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "digest"
|
||||
version = "0.10.7"
|
||||
|
|
@ -184,8 +275,45 @@ checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292"
|
|||
dependencies = [
|
||||
"block-buffer",
|
||||
"crypto-common",
|
||||
"subtle",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "dyn-clone"
|
||||
version = "1.0.20"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d0881ea181b1df73ff77ffaaf9c7544ecc11e82fba9b5f27b262a3c73a332555"
|
||||
|
||||
[[package]]
|
||||
name = "ed25519"
|
||||
version = "2.2.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "115531babc129696a58c64a4fef0a8bf9e9698629fb97e9e40767d235cfbcd53"
|
||||
dependencies = [
|
||||
"pkcs8",
|
||||
"signature",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ed25519-dalek"
|
||||
version = "2.2.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "70e796c081cee67dc755e1a36a0a172b897fab85fc3f6bc48307991f64e4eca9"
|
||||
dependencies = [
|
||||
"curve25519-dalek",
|
||||
"ed25519",
|
||||
"serde",
|
||||
"sha2",
|
||||
"subtle",
|
||||
"zeroize",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "fiat-crypto"
|
||||
version = "0.2.9"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "28dea519a9695b9977216879a3ebfddf92f1c08c05d984f8996aecd6ecdc811d"
|
||||
|
||||
[[package]]
|
||||
name = "find-msvc-tools"
|
||||
version = "0.1.11"
|
||||
|
|
@ -247,6 +375,18 @@ dependencies = [
|
|||
"wasi",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "getrandom"
|
||||
version = "0.3.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"libc",
|
||||
"r-efi 5.3.0",
|
||||
"wasip2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "getrandom"
|
||||
version = "0.4.3"
|
||||
|
|
@ -255,7 +395,7 @@ checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099"
|
|||
dependencies = [
|
||||
"cfg-if",
|
||||
"libc",
|
||||
"r-efi",
|
||||
"r-efi 6.0.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
|
@ -268,6 +408,15 @@ dependencies = [
|
|||
"polyval",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "hmac"
|
||||
version = "0.12.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6c49c37c09c17a53d937dfbb742eb3a961d65a994e6bcdcf37e7399d0cc8ab5e"
|
||||
dependencies = [
|
||||
"digest",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "iana-time-zone"
|
||||
version = "0.1.65"
|
||||
|
|
@ -341,10 +490,15 @@ name = "model-connector"
|
|||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"aes-gcm",
|
||||
"base64",
|
||||
"cultcache-rs",
|
||||
"rand_core",
|
||||
"cultnet-rs",
|
||||
"rand_core 0.6.4",
|
||||
"rmp-serde",
|
||||
"rmpv",
|
||||
"schemars",
|
||||
"serde",
|
||||
"serde_bytes",
|
||||
"serde_json",
|
||||
"sha2",
|
||||
"thiserror",
|
||||
|
|
@ -360,6 +514,15 @@ dependencies = [
|
|||
"autocfg",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "object"
|
||||
version = "0.39.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2e5a6c098c7a3b6547378093f5cc30bc54fd361ce711e05293a5cc589562739b"
|
||||
dependencies = [
|
||||
"memchr",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "once_cell"
|
||||
version = "1.21.4"
|
||||
|
|
@ -378,6 +541,16 @@ version = "0.2.17"
|
|||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd"
|
||||
|
||||
[[package]]
|
||||
name = "pkcs8"
|
||||
version = "0.10.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f950b2377845cebe5cf8b5165cb3cc1a5e0fa5cfa3e1f7f55707d8fd82e0a7b7"
|
||||
dependencies = [
|
||||
"der",
|
||||
"spki",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "polyval"
|
||||
version = "0.6.2"
|
||||
|
|
@ -390,6 +563,15 @@ dependencies = [
|
|||
"universal-hash",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ppv-lite86"
|
||||
version = "0.2.21"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9"
|
||||
dependencies = [
|
||||
"zerocopy",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "proc-macro2"
|
||||
version = "1.0.107"
|
||||
|
|
@ -399,6 +581,16 @@ dependencies = [
|
|||
"unicode-ident",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "psm"
|
||||
version = "0.1.32"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "4dcd034599e63b970727f70d79e02d62390a4a84f7c6b827c27c46d5ac3fa622"
|
||||
dependencies = [
|
||||
"ar_archive_writer",
|
||||
"cc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "quote"
|
||||
version = "1.0.47"
|
||||
|
|
@ -408,12 +600,38 @@ dependencies = [
|
|||
"proc-macro2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "r-efi"
|
||||
version = "5.3.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f"
|
||||
|
||||
[[package]]
|
||||
name = "r-efi"
|
||||
version = "6.0.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf"
|
||||
|
||||
[[package]]
|
||||
name = "rand"
|
||||
version = "0.9.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b9ef1d0d795eb7d84685bca4f72f3649f064e6641543d3a8c415898726a57b41"
|
||||
dependencies = [
|
||||
"rand_chacha",
|
||||
"rand_core 0.9.5",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rand_chacha"
|
||||
version = "0.9.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb"
|
||||
dependencies = [
|
||||
"ppv-lite86",
|
||||
"rand_core 0.9.5",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rand_core"
|
||||
version = "0.6.4"
|
||||
|
|
@ -423,6 +641,15 @@ dependencies = [
|
|||
"getrandom 0.2.17",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rand_core"
|
||||
version = "0.9.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c"
|
||||
dependencies = [
|
||||
"getrandom 0.3.4",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "redb"
|
||||
version = "4.2.0"
|
||||
|
|
@ -432,6 +659,26 @@ dependencies = [
|
|||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ref-cast"
|
||||
version = "1.0.27"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7e440fb4e4b4147295338efb76001ab9e4efc0e5839df2c47fc5ac2381d365c3"
|
||||
dependencies = [
|
||||
"ref-cast-impl",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ref-cast-impl"
|
||||
version = "1.0.27"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "92ecd8964f8453721699a1ed72037b0db49ce2f5a5138486ee89bed6f67cdf3a"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 3.0.4",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rmp"
|
||||
version = "0.8.15"
|
||||
|
|
@ -451,12 +698,63 @@ dependencies = [
|
|||
"serde",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rmpv"
|
||||
version = "1.3.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7a4e1d4b9b938a26d2996af33229f0ca0956c652c1375067f0b45291c1df8417"
|
||||
dependencies = [
|
||||
"rmp",
|
||||
"serde",
|
||||
"serde_bytes",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rustc_version"
|
||||
version = "0.4.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "cfcb3a22ef46e85b45de6ee7e79d063319ebb6594faafcf1c225ea92ab6e9b92"
|
||||
dependencies = [
|
||||
"semver",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rustversion"
|
||||
version = "1.0.23"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f"
|
||||
|
||||
[[package]]
|
||||
name = "schemars"
|
||||
version = "1.2.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ab508826f74a77ca9d5aba6ff19b522583ee3eaf28a19384ff3d0e5835fadf6e"
|
||||
dependencies = [
|
||||
"dyn-clone",
|
||||
"ref-cast",
|
||||
"schemars_derive",
|
||||
"serde",
|
||||
"serde_json",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "schemars_derive"
|
||||
version = "1.2.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e1c3a92094fa7d61aa124645844facb6b554dfc797136d0f5fd1f890e2bffc69"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"serde_derive_internals",
|
||||
"syn 3.0.4",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "semver"
|
||||
version = "1.0.28"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd"
|
||||
|
||||
[[package]]
|
||||
name = "serde"
|
||||
version = "1.0.229"
|
||||
|
|
@ -497,6 +795,17 @@ dependencies = [
|
|||
"syn 3.0.4",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "serde_derive_internals"
|
||||
version = "0.30.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f852137cce035d6a4df67ccce505ff6b3e9fd3a10e3e52b24dc71e650bb1a9bd"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 3.0.4",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "serde_json"
|
||||
version = "1.0.151"
|
||||
|
|
@ -510,6 +819,17 @@ dependencies = [
|
|||
"zmij",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "serde_stacker"
|
||||
version = "0.1.14"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d4936375d50c4be7eff22293a9344f8e46f323ed2b3c243e52f89138d9bb0f4a"
|
||||
dependencies = [
|
||||
"serde",
|
||||
"serde_core",
|
||||
"stacker",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "sha2"
|
||||
version = "0.10.9"
|
||||
|
|
@ -527,12 +847,54 @@ version = "2.0.1"
|
|||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba"
|
||||
|
||||
[[package]]
|
||||
name = "signature"
|
||||
version = "2.2.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "77549399552de45a898a580c1b41d445bf730df867cc44e6c0233bbc4b8329de"
|
||||
dependencies = [
|
||||
"rand_core 0.6.4",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "slab"
|
||||
version = "0.4.12"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5"
|
||||
|
||||
[[package]]
|
||||
name = "socket2"
|
||||
version = "0.5.10"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e22376abed350d73dd1cd119b57ffccad95b4e585a7cda43e286245ce23c0678"
|
||||
dependencies = [
|
||||
"libc",
|
||||
"windows-sys 0.52.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "spki"
|
||||
version = "0.7.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d91ed6c858b01f942cd56b37a94b3e0a1798290327d1236e4d9cf4eaca44d29d"
|
||||
dependencies = [
|
||||
"base64ct",
|
||||
"der",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "stacker"
|
||||
version = "0.1.25"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "707f49d46706bacf8a2b00d51dace3f9de527c13eec3778f570c411f89e69967"
|
||||
dependencies = [
|
||||
"cc",
|
||||
"cfg-if",
|
||||
"libc",
|
||||
"psm",
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "subtle"
|
||||
version = "2.6.1"
|
||||
|
|
@ -626,6 +988,15 @@ version = "0.11.1+wasi-snapshot-preview1"
|
|||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b"
|
||||
|
||||
[[package]]
|
||||
name = "wasip2"
|
||||
version = "1.0.4+wasi-0.2.12"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b67efb37e106e55ce722a510d6b5f9c17f083e5fc79afc2badeb12cc313d9487"
|
||||
dependencies = [
|
||||
"wit-bindgen",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wasm-bindgen"
|
||||
version = "0.2.127"
|
||||
|
|
@ -752,6 +1123,15 @@ dependencies = [
|
|||
"windows-link",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-sys"
|
||||
version = "0.52.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d"
|
||||
dependencies = [
|
||||
"windows-targets",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-sys"
|
||||
version = "0.59.0"
|
||||
|
|
@ -761,6 +1141,15 @@ dependencies = [
|
|||
"windows-targets",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-sys"
|
||||
version = "0.61.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc"
|
||||
dependencies = [
|
||||
"windows-link",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-targets"
|
||||
version = "0.52.6"
|
||||
|
|
@ -825,6 +1214,32 @@ version = "0.52.6"
|
|||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec"
|
||||
|
||||
[[package]]
|
||||
name = "wit-bindgen"
|
||||
version = "0.57.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e"
|
||||
|
||||
[[package]]
|
||||
name = "zerocopy"
|
||||
version = "0.8.63"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e5fe1f8f1b06191a00962174c61aa5005e0bb391a6d80d07e24d115c01a92ed8"
|
||||
dependencies = [
|
||||
"zerocopy-derive",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "zerocopy-derive"
|
||||
version = "0.8.63"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "863ad3ac83293fb4d740aedbfdc9240dd8d1a50c1099acd76ce80ce7c7230c7f"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.119",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "zeroize"
|
||||
version = "1.9.0"
|
||||
|
|
|
|||
29
README.md
29
README.md
|
|
@ -11,11 +11,16 @@ Status: the `model-connector` crate is live. No connector binary exists yet
|
|||
|
||||
Crate `crates/model-connector`:
|
||||
|
||||
- `src/contract.rs` - the v2 wire contract: `ProviderRequest`, `TransportInvocation`,
|
||||
`TransportResult`, `TransportReceipt`, `Refusal`, and the canonical byte and digest
|
||||
functions that bind a result to its request.
|
||||
- `src/envelope.rs` - the AES-256-GCM `TransportEnvelope`, its identity-binding AAD, and
|
||||
the length-prefixed framing.
|
||||
- `src/contract.rs` - the v3 wire contract (`gamecult.model.*.v3`): `ProviderRequest`, `TransportInvocation`,
|
||||
`TransportResult`, `TransportReceipt`, the typed `StopReason`, `FailureKind` and `Refusal`,
|
||||
and the canonical byte and digest functions that bind a result to its request. Every
|
||||
document is named MessagePack with unknown fields refused.
|
||||
- `src/envelope.rs` - the AES-256-GCM `TransportEnvelope` (binary ciphertext), its
|
||||
identity-binding AAD, the length-prefixed framing, and the CultNet carriage: each frame is a
|
||||
`cultnet.operation_request.v0` / `operation_response.v0` for service
|
||||
`gamecult.model.connector`, operation `invoke`, its `message_id` the request id and its
|
||||
payload the base64 of the sealed envelope.
|
||||
- `schemas/` - one JSON Schema per v3 document, generated from the types.
|
||||
- `src/client.rs` - `ConnectorClient`, the one-request caller side.
|
||||
- `src/service.rs` (feature `daemon`) - `TransportService`: caller admission, durable
|
||||
replay (a retried request is answered byte-identically and the provider runs once),
|
||||
|
|
@ -24,16 +29,15 @@ Crate `crates/model-connector`:
|
|||
`ProviderBackend` trait. A connector binary implements `ProviderBackend` for its
|
||||
provider and calls `serve`; the kit names no provider.
|
||||
|
||||
Frame arithmetic is public in `src/envelope.rs`: `frame_bytes_for_payload(n)` is the frame a
|
||||
Frame arithmetic is public in `src/envelope.rs`, measured on the carriage frame: `frame_bytes_for_payload(n)` is the frame a
|
||||
caller payload bound of `n` needs, `payload_bytes_for_frame(frame)` is the largest payload bound
|
||||
a frame allows, and `MAX_IDENTITY_BYTES` (256) bounds caller ids, request ids and model names.
|
||||
`serve` refuses a frame/payload pair that violates them (`ServeError::FrameBound` states the frame
|
||||
needed); the smallest legal frame is `frame_bytes_for_payload(MIN_PAYLOAD_BYTES)`. A request id past
|
||||
the bound is refused on the wire as `Refusal::Malformed`, the reply echoing the id (the connection is dropped only if that reply cannot fit the frame); `ConnectorClient` refuses such an id locally with `RequestIdTooLong`.
|
||||
|
||||
The default feature set compiles contract, envelope and client only. Schema ids are
|
||||
still `gamecult.codex.*.v2`; renaming them changes the wire and belongs to a later
|
||||
contract version.
|
||||
The default feature set compiles contract, envelope and client only. The kit speaks v3
|
||||
only: a v2 frame gets no answer.
|
||||
|
||||
## Verify
|
||||
|
||||
|
|
@ -42,8 +46,11 @@ cargo test --locked --workspace
|
|||
cargo test --locked --workspace --features model-connector/daemon
|
||||
```
|
||||
|
||||
`crates/model-connector/tests/wire_golden_v2.rs` pins the kit byte-for-byte to the golden v2
|
||||
fixtures in `crates/model-connector/tests/fixtures/wire_v2`.
|
||||
`crates/model-connector/tests/wire_golden_v3.rs` pins the v3 documents and one sealed carriage
|
||||
frame to the fixtures in `crates/model-connector/tests/fixtures/wire_v3` (regenerate with
|
||||
`WIRE_V3_REGENERATE=1`; see the fixtures' README), and
|
||||
`crates/model-connector/tests/published_schemas.rs` fails when `schemas/` differs from the types
|
||||
(`MODEL_CONNECTOR_REGENERATE_SCHEMAS=1` rewrites it).
|
||||
|
||||
## Provenance and license
|
||||
|
||||
|
|
|
|||
|
|
@ -13,10 +13,15 @@ daemon = ["dep:cultcache-rs"]
|
|||
|
||||
[dependencies]
|
||||
aes-gcm = "0.10"
|
||||
cultcache-rs = { git = "https://github.com/GameCult/CultLib.git", rev = "e171eca32329baa928f4a1d810401a8b4c857029", package = "cultcache-rs", optional = true }
|
||||
base64 = "0.22"
|
||||
cultnet-rs = { git = "https://github.com/GameCult/CultLib.git", rev = "a4eae9056152335662f1115f97bf35fb00ab6d23", package = "cultnet-rs" }
|
||||
cultcache-rs = { git = "https://github.com/GameCult/CultLib.git", rev = "a4eae9056152335662f1115f97bf35fb00ab6d23", package = "cultcache-rs", optional = true }
|
||||
rand_core = { version = "0.6", features = ["getrandom"] }
|
||||
rmp-serde = "1"
|
||||
rmpv = "1"
|
||||
schemars = "1"
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
serde_bytes = "0.11"
|
||||
serde_json = "1"
|
||||
sha2 = "0.10"
|
||||
thiserror = "2"
|
||||
|
|
|
|||
|
|
@ -6,8 +6,8 @@ use zeroize::Zeroizing;
|
|||
|
||||
use crate::contract::{TransportInvocation, TransportKey, TransportResult};
|
||||
use crate::envelope::{
|
||||
MAX_IDENTITY_BYTES, ServiceError, TransportFrameError, decrypt_result, encrypt_invocation,
|
||||
read_transport_frame, write_transport_frame,
|
||||
MAX_IDENTITY_BYTES, ServiceError, TransportFrameError, decode_result_frame, decrypt_result,
|
||||
encode_invocation_frame, encrypt_invocation, read_transport_frame, write_transport_frame,
|
||||
};
|
||||
|
||||
#[derive(Clone)]
|
||||
|
|
@ -51,8 +51,7 @@ impl ConnectorClient {
|
|||
if invocation.request_id().len() > MAX_IDENTITY_BYTES {
|
||||
return Err(ConnectorClientError::RequestIdTooLong);
|
||||
}
|
||||
let request = rmp_serde::to_vec(&encrypt_invocation(invocation, &self.security)?)
|
||||
.map_err(|_| ConnectorClientError::Encoding)?;
|
||||
let request = encode_invocation_frame(&encrypt_invocation(invocation, &self.security)?)?;
|
||||
let connect_timeout = self
|
||||
.response_timeout
|
||||
.unwrap_or(Duration::from_secs(10))
|
||||
|
|
@ -67,8 +66,7 @@ impl ConnectorClient {
|
|||
.map_err(client_frame_error)?;
|
||||
let response =
|
||||
read_transport_frame(&mut stream, self.max_frame_bytes).map_err(client_frame_error)?;
|
||||
let envelope =
|
||||
rmp_serde::from_slice(&response).map_err(|_| ConnectorClientError::Encoding)?;
|
||||
let envelope = decode_result_frame(&response)?;
|
||||
decrypt_result(&envelope, &self.security, invocation).map_err(Into::into)
|
||||
}
|
||||
}
|
||||
|
|
@ -120,8 +118,6 @@ pub enum ConnectorClientError {
|
|||
Connection(#[source] std::io::Error),
|
||||
#[error("connector frame exceeded its bound")]
|
||||
FrameSize,
|
||||
#[error("connector MessagePack encoding failed")]
|
||||
Encoding,
|
||||
#[error("request id is longer than the connector identity bound")]
|
||||
RequestIdTooLong,
|
||||
#[error(transparent)]
|
||||
|
|
@ -151,16 +147,16 @@ mod fixture_tests {
|
|||
let server = std::thread::spawn(move || {
|
||||
let (mut stream, _) = listener.accept().unwrap();
|
||||
let request = read_transport_frame(&mut stream, 64 * 1024).unwrap();
|
||||
let envelope: TransportEnvelope = rmp_serde::from_slice(&request).unwrap();
|
||||
let envelope = decode_invocation_frame(&request).unwrap();
|
||||
assert_eq!(decrypt_invocation(&envelope, &security).unwrap(), expected);
|
||||
let response = encrypt_result(
|
||||
&TransportResult::refused(&expected, Refusal::Policy),
|
||||
&TransportResult::refused(&expected, Refusal::ModelNotAllowed),
|
||||
&security,
|
||||
)
|
||||
.unwrap();
|
||||
write_transport_frame(
|
||||
&mut stream,
|
||||
&rmp_serde::to_vec(&response).unwrap(),
|
||||
&encode_result_frame(&response).unwrap(),
|
||||
64 * 1024,
|
||||
)
|
||||
.unwrap();
|
||||
|
|
@ -175,7 +171,7 @@ mod fixture_tests {
|
|||
.unwrap();
|
||||
assert_eq!(
|
||||
client.execute(&invocation).unwrap().disposition,
|
||||
TransportDisposition::Refused(Refusal::Policy)
|
||||
TransportDisposition::Refused(Refusal::ModelNotAllowed)
|
||||
);
|
||||
server.join().unwrap();
|
||||
}
|
||||
|
|
@ -203,4 +199,31 @@ mod fixture_tests {
|
|||
"the client must not even connect"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_client_measures_a_request_id_in_bytes() {
|
||||
let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
|
||||
listener.set_nonblocking(true).unwrap();
|
||||
let client = ConnectorClient::new(
|
||||
listener.local_addr().unwrap(),
|
||||
"epiphany-distinct-test-key",
|
||||
64 * 1024,
|
||||
Some(Duration::from_secs(1)),
|
||||
)
|
||||
.unwrap();
|
||||
// 86 characters, 258 bytes: under the bound by characters, over by bytes.
|
||||
let id = "\u{20AC}".repeat(86);
|
||||
assert_eq!((id.chars().count(), id.len()), (86, 258));
|
||||
let mut long = invocation();
|
||||
long.request.request_id = id;
|
||||
assert!(matches!(
|
||||
client.execute(&long),
|
||||
Err(ConnectorClientError::RequestIdTooLong)
|
||||
));
|
||||
assert_eq!(
|
||||
listener.accept().unwrap_err().kind(),
|
||||
std::io::ErrorKind::WouldBlock,
|
||||
"the client must not even connect"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,5 +1,7 @@
|
|||
use std::collections::HashSet;
|
||||
|
||||
use schemars::JsonSchema;
|
||||
use serde::de::DeserializeOwned;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use sha2::{Digest, Sha256};
|
||||
use thiserror::Error;
|
||||
|
|
@ -7,11 +9,11 @@ use zeroize::Zeroize;
|
|||
|
||||
use crate::envelope::ServiceError;
|
||||
|
||||
pub const PROVIDER_REQUEST_SCHEMA_ID: &str = "gamecult.codex.provider_request.v2";
|
||||
pub const INVOCATION_SCHEMA_ID: &str = "gamecult.codex.transport_invocation.v2";
|
||||
pub const RESULT_SCHEMA_ID: &str = "gamecult.codex.transport_result.v2";
|
||||
pub const RECEIPT_SCHEMA_ID: &str = "gamecult.codex.transport_receipt.v2";
|
||||
pub const ENVELOPE_SCHEMA_ID: &str = "gamecult.codex.transport_envelope.v2";
|
||||
pub const PROVIDER_REQUEST_SCHEMA_ID: &str = "gamecult.model.provider_request.v3";
|
||||
pub const INVOCATION_SCHEMA_ID: &str = "gamecult.model.transport_invocation.v3";
|
||||
pub const RESULT_SCHEMA_ID: &str = "gamecult.model.transport_result.v3";
|
||||
pub const RECEIPT_SCHEMA_ID: &str = "gamecult.model.transport_receipt.v3";
|
||||
pub const ENVELOPE_SCHEMA_ID: &str = "gamecult.model.transport_envelope.v3";
|
||||
|
||||
#[derive(Clone, PartialEq, Eq)]
|
||||
pub struct TransportKey(pub(crate) [u8; 32]);
|
||||
|
|
@ -31,7 +33,8 @@ impl Drop for TransportKey {
|
|||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct ProviderRequest {
|
||||
pub schema_id: String,
|
||||
pub request_id: String,
|
||||
|
|
@ -39,17 +42,13 @@ pub struct ProviderRequest {
|
|||
pub model: String,
|
||||
pub instructions: String,
|
||||
pub input: Vec<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 reasoning_effort: Option<ReasoningEffort>,
|
||||
pub cache_retention: Option<CacheRetention>,
|
||||
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>,
|
||||
pub max_output_tokens: u32,
|
||||
}
|
||||
|
||||
impl ProviderRequest {
|
||||
|
|
@ -58,6 +57,7 @@ impl ProviderRequest {
|
|||
conversation_id: impl Into<String>,
|
||||
model: impl Into<String>,
|
||||
instructions: impl Into<String>,
|
||||
max_output_tokens: u32,
|
||||
) -> Self {
|
||||
Self {
|
||||
schema_id: PROVIDER_REQUEST_SCHEMA_ID.to_string(),
|
||||
|
|
@ -67,16 +67,12 @@ impl ProviderRequest {
|
|||
instructions: instructions.into(),
|
||||
input: Vec::new(),
|
||||
reasoning_effort: None,
|
||||
reasoning_summary: None,
|
||||
service_tier: None,
|
||||
output_format_name: None,
|
||||
previous_response_id: None,
|
||||
cache_retention: None,
|
||||
tools: Vec::new(),
|
||||
tool_choice: ToolChoice::Auto,
|
||||
parallel_tool_calls: false,
|
||||
output_schema_json: None,
|
||||
max_output_tokens: None,
|
||||
prompt_cache_key: None,
|
||||
max_output_tokens,
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -88,38 +84,12 @@ impl ProviderRequest {
|
|||
require_id(&self.conversation_id, "conversation_id")?;
|
||||
require_id(&self.model, "model")?;
|
||||
require_content(&self.instructions, "instructions")?;
|
||||
if matches!(self.max_output_tokens, Some(0)) {
|
||||
if self.max_output_tokens == 0 {
|
||||
return Err(ContractError::Invalid("max_output_tokens"));
|
||||
}
|
||||
if self
|
||||
.prompt_cache_key
|
||||
.as_deref()
|
||||
.is_some_and(|value| value.trim().is_empty())
|
||||
{
|
||||
return Err(ContractError::Invalid("prompt_cache_key"));
|
||||
}
|
||||
for (value, field) in [
|
||||
(self.reasoning_effort.as_deref(), "reasoning_effort"),
|
||||
(self.reasoning_summary.as_deref(), "reasoning_summary"),
|
||||
(self.service_tier.as_deref(), "service_tier"),
|
||||
(self.output_format_name.as_deref(), "output_format_name"),
|
||||
(self.previous_response_id.as_deref(), "previous_response_id"),
|
||||
] {
|
||||
if let Some(value) = value {
|
||||
require_id(value, field)?;
|
||||
}
|
||||
}
|
||||
if let Some(schema) = &self.output_schema_json {
|
||||
require_content(schema, "output_schema_json")?;
|
||||
require_json_object(schema, "output_schema_json")?;
|
||||
require_provider_name(
|
||||
self.output_format_name
|
||||
.as_deref()
|
||||
.ok_or(ContractError::Invalid("output_format_name"))?,
|
||||
"output_format_name",
|
||||
)?;
|
||||
} else if self.output_format_name.is_some() {
|
||||
return Err(ContractError::Invalid("output_schema_json"));
|
||||
}
|
||||
|
||||
if self.tools.is_empty() && self.tool_choice == ToolChoice::Required {
|
||||
|
|
@ -160,14 +130,70 @@ impl ProviderRequest {
|
|||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum ToolChoice {
|
||||
Auto,
|
||||
Required,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum ReasoningEffort {
|
||||
Low,
|
||||
Medium,
|
||||
High,
|
||||
Max,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum CacheRetention {
|
||||
Short,
|
||||
Long,
|
||||
}
|
||||
|
||||
/// Why a provider stopped producing output.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum StopReason {
|
||||
EndTurn,
|
||||
ToolUse,
|
||||
MaxOutputTokens,
|
||||
StopSequence,
|
||||
Refusal,
|
||||
ContextWindow,
|
||||
}
|
||||
|
||||
/// What kind of failure a provider reported. `ResultSize` is the connector's own
|
||||
/// stand-in for a result whose sealed frame cannot be carried.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum FailureKind {
|
||||
ResultSize,
|
||||
InvalidRequest,
|
||||
Authentication,
|
||||
RateLimited,
|
||||
Unavailable,
|
||||
Timeout,
|
||||
Provider,
|
||||
}
|
||||
|
||||
/// Token accounting for one completed request. Cache reads and writes are
|
||||
/// counted apart from plain input; `reasoning_tokens` is absent when the
|
||||
/// provider does not report it.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct Usage {
|
||||
pub input_tokens: u64,
|
||||
pub cache_read_tokens: u64,
|
||||
pub cache_write_tokens: u64,
|
||||
pub output_tokens: u64,
|
||||
pub reasoning_tokens: Option<u64>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub enum InputItem {
|
||||
UserText {
|
||||
text: String,
|
||||
|
|
@ -186,14 +212,16 @@ pub enum InputItem {
|
|||
},
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct ToolDefinition {
|
||||
pub name: String,
|
||||
pub description: String,
|
||||
pub parameters_json: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct TransportInvocation {
|
||||
pub schema_id: String,
|
||||
pub caller_runtime_id: String,
|
||||
|
|
@ -246,7 +274,8 @@ impl TransportInvocation {
|
|||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct TransportResult {
|
||||
pub schema_id: String,
|
||||
pub request_id: String,
|
||||
|
|
@ -254,7 +283,8 @@ pub struct TransportResult {
|
|||
pub disposition: TransportDisposition,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub enum TransportDisposition {
|
||||
Refused(Refusal),
|
||||
Transported {
|
||||
|
|
@ -312,7 +342,8 @@ impl TransportResult {
|
|||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub enum TransportEventPayload {
|
||||
TextDelta {
|
||||
text: String,
|
||||
|
|
@ -324,7 +355,8 @@ pub enum TransportEventPayload {
|
|||
},
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct TransportEvent {
|
||||
pub sequence: u64,
|
||||
pub payload: TransportEventPayload,
|
||||
|
|
@ -347,7 +379,8 @@ impl TransportEvent {
|
|||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct TransportReceipt {
|
||||
pub schema_id: String,
|
||||
pub request_id: String,
|
||||
|
|
@ -355,7 +388,7 @@ pub struct TransportReceipt {
|
|||
pub native_request_sha256: [u8; 32],
|
||||
pub provider_request_sha256: [u8; 32],
|
||||
pub model: String,
|
||||
pub transport: String,
|
||||
pub provider: String,
|
||||
pub outcome: TransportOutcome,
|
||||
}
|
||||
|
||||
|
|
@ -376,17 +409,13 @@ impl TransportReceipt {
|
|||
if self.native_request_sha256 != invocation.native_request_sha256 {
|
||||
return Err(ContractError::NativeDigest);
|
||||
}
|
||||
require_id(&self.transport, "receipt.transport")?;
|
||||
require_id(&self.provider, "receipt.provider")?;
|
||||
match &self.outcome {
|
||||
TransportOutcome::Completed {
|
||||
provider_response_id: Some(value),
|
||||
..
|
||||
} => require_id(value, "receipt.provider_response_id")?,
|
||||
TransportOutcome::Failed {
|
||||
failure_kind,
|
||||
message,
|
||||
} => {
|
||||
require_id(failure_kind, "receipt.failure_kind")?;
|
||||
TransportOutcome::Failed { message, .. } => {
|
||||
require_content(message, "receipt.failure_message")?;
|
||||
}
|
||||
TransportOutcome::Completed {
|
||||
|
|
@ -398,27 +427,28 @@ impl TransportReceipt {
|
|||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub enum TransportOutcome {
|
||||
Completed {
|
||||
provider_response_id: Option<String>,
|
||||
input_tokens: Option<u64>,
|
||||
output_tokens: Option<u64>,
|
||||
reasoning_output_tokens: Option<u64>,
|
||||
cached_input_tokens: Option<u64>,
|
||||
stop_reason: StopReason,
|
||||
usage: Usage,
|
||||
},
|
||||
Failed {
|
||||
failure_kind: String,
|
||||
kind: FailureKind,
|
||||
message: String,
|
||||
},
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
|
||||
pub enum Refusal {
|
||||
Expired,
|
||||
IdentitySubstitution,
|
||||
ProviderDigestSubstitution,
|
||||
Policy,
|
||||
ModelNotAllowed,
|
||||
OutputBound,
|
||||
PayloadBound,
|
||||
Capacity,
|
||||
InFlight,
|
||||
Indeterminate,
|
||||
|
|
@ -426,11 +456,61 @@ pub enum Refusal {
|
|||
Malformed,
|
||||
}
|
||||
|
||||
/// The one wire codec of every v3 document: named MessagePack (struct fields
|
||||
/// are keyed by name), with unknown fields refused by the types themselves.
|
||||
pub(crate) fn wire_encode<T: Serialize + ?Sized>(document: &T) -> Result<Vec<u8>, ContractError> {
|
||||
rmp_serde::to_vec_named(document).map_err(|_| ContractError::Encoding)
|
||||
}
|
||||
|
||||
/// Decodes a document and refuses every dialect the wire does not name. The
|
||||
/// typed decoder alone also reads a positional array for a struct, an integer
|
||||
/// array where `bin` is declared and an enum as its index; so the bytes must
|
||||
/// equal what [`wire_encode`] writes for the decoded value, compared as
|
||||
/// MessagePack values: key order, integer width and an absent optional field
|
||||
/// do not matter, array against map, array against `bin` and index against name do.
|
||||
pub(crate) fn wire_decode<T: DeserializeOwned + Serialize>(
|
||||
bytes: &[u8],
|
||||
) -> Result<T, ContractError> {
|
||||
let document: T = rmp_serde::from_slice(bytes).map_err(|_| ContractError::Encoding)?;
|
||||
if named_form(bytes)? != named_form(&wire_encode(&document)?)? {
|
||||
return Err(ContractError::Encoding);
|
||||
}
|
||||
Ok(document)
|
||||
}
|
||||
|
||||
fn named_form(bytes: &[u8]) -> Result<rmpv::Value, ContractError> {
|
||||
let mut reader = bytes;
|
||||
let value = rmpv::decode::read_value(&mut reader).map_err(|_| ContractError::Encoding)?;
|
||||
if !reader.is_empty() {
|
||||
return Err(ContractError::Encoding);
|
||||
}
|
||||
Ok(order_free(value))
|
||||
}
|
||||
|
||||
/// A map compared as a set of its present entries: sorted by key, nil entries dropped.
|
||||
fn order_free(value: rmpv::Value) -> rmpv::Value {
|
||||
match value {
|
||||
rmpv::Value::Map(entries) => {
|
||||
let mut kept: Vec<(rmpv::Value, rmpv::Value)> = entries
|
||||
.into_iter()
|
||||
.filter(|(_, entry)| !entry.is_nil())
|
||||
.map(|(key, entry)| (key, order_free(entry)))
|
||||
.collect();
|
||||
kept.sort_by_key(|(key, _)| key.to_string());
|
||||
rmpv::Value::Map(kept)
|
||||
}
|
||||
rmpv::Value::Array(items) => {
|
||||
rmpv::Value::Array(items.into_iter().map(order_free).collect())
|
||||
}
|
||||
other => other,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn canonical_provider_request_bytes(
|
||||
request: &ProviderRequest,
|
||||
) -> Result<Vec<u8>, ContractError> {
|
||||
request.validate()?;
|
||||
rmp_serde::to_vec(request).map_err(|_| ContractError::Encoding)
|
||||
wire_encode(request)
|
||||
}
|
||||
|
||||
pub fn provider_request_sha256(request: &ProviderRequest) -> Result<[u8; 32], ContractError> {
|
||||
|
|
@ -444,7 +524,7 @@ pub fn canonical_invocation_bytes(
|
|||
if invocation.provider_request_sha256 != provider_request_sha256(&invocation.request)? {
|
||||
return Err(ContractError::ProviderDigest);
|
||||
}
|
||||
rmp_serde::to_vec(invocation).map_err(|_| ContractError::Encoding)
|
||||
wire_encode(invocation)
|
||||
}
|
||||
|
||||
pub(crate) fn require_id(value: &str, field: &'static str) -> Result<(), ContractError> {
|
||||
|
|
@ -508,7 +588,7 @@ pub enum ContractError {
|
|||
Expiry,
|
||||
#[error("transport event sequence is not contiguous")]
|
||||
EventSequence,
|
||||
#[error("canonical MessagePack encoding failed")]
|
||||
#[error("wire encoding failed")]
|
||||
Encoding,
|
||||
}
|
||||
|
||||
|
|
@ -537,14 +617,14 @@ mod tests {
|
|||
#[test]
|
||||
fn provider_contract_refuses_transport_side_fixup_pressure() {
|
||||
let mut request = request();
|
||||
request.output_format_name = Some("epiphany.role.result".to_string());
|
||||
request.output_schema_json = Some(r#"{"type":"object"}"#.to_string());
|
||||
assert_eq!(request.validate(), Ok(()));
|
||||
request.output_schema_json = Some("[]".to_string());
|
||||
assert_eq!(
|
||||
request.validate(),
|
||||
Err(ContractError::Invalid("output_format_name"))
|
||||
Err(ContractError::Invalid("output_schema_json"))
|
||||
);
|
||||
|
||||
request.output_format_name = None;
|
||||
request.output_schema_json = None;
|
||||
request.input.push(InputItem::ToolResult {
|
||||
call_id: "x".repeat(65),
|
||||
|
|
@ -584,8 +664,8 @@ mod tests {
|
|||
#[test]
|
||||
fn typed_tool_round_survives_messagepack() {
|
||||
let invocation = invocation();
|
||||
let bytes = rmp_serde::to_vec(&invocation).unwrap();
|
||||
let decoded: TransportInvocation = rmp_serde::from_slice(&bytes).unwrap();
|
||||
let bytes = wire_encode(&invocation).unwrap();
|
||||
let decoded: TransportInvocation = wire_decode(&bytes).unwrap();
|
||||
assert_eq!(decoded, invocation);
|
||||
assert!(matches!(
|
||||
decoded.request.tools.as_slice(),
|
||||
|
|
@ -638,4 +718,189 @@ mod tests {
|
|||
Err(ContractError::NativeDigest)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn v3_documents_carry_their_v3_ids() {
|
||||
assert_eq!(
|
||||
[
|
||||
PROVIDER_REQUEST_SCHEMA_ID,
|
||||
INVOCATION_SCHEMA_ID,
|
||||
RESULT_SCHEMA_ID,
|
||||
RECEIPT_SCHEMA_ID,
|
||||
ENVELOPE_SCHEMA_ID
|
||||
],
|
||||
[
|
||||
"gamecult.model.provider_request.v3",
|
||||
"gamecult.model.transport_invocation.v3",
|
||||
"gamecult.model.transport_result.v3",
|
||||
"gamecult.model.transport_receipt.v3",
|
||||
"gamecult.model.transport_envelope.v3"
|
||||
]
|
||||
);
|
||||
let invocation = invocation();
|
||||
let result = TransportResult::transported(&invocation, Vec::new(), receipt(&invocation));
|
||||
let TransportDisposition::Transported { receipt: sent, .. } = &result.disposition else {
|
||||
unreachable!()
|
||||
};
|
||||
assert_eq!(invocation.request.schema_id, PROVIDER_REQUEST_SCHEMA_ID);
|
||||
assert_eq!(invocation.schema_id, INVOCATION_SCHEMA_ID);
|
||||
assert_eq!(result.schema_id, RESULT_SCHEMA_ID);
|
||||
assert_eq!(sent.schema_id, RECEIPT_SCHEMA_ID);
|
||||
|
||||
let v2 = |name: &str| format!("{}{name}.v2", concat!("gamecult.", "codex."));
|
||||
let mut request = invocation.request.clone();
|
||||
request.schema_id = v2("provider_request");
|
||||
assert_eq!(request.validate(), Err(ContractError::Schema));
|
||||
let mut stale = invocation.clone();
|
||||
stale.schema_id = v2("transport_invocation");
|
||||
assert_eq!(stale.validate(1_000, 2_000), Err(ContractError::Schema));
|
||||
let mut stale = result.clone();
|
||||
stale.schema_id = v2("transport_result");
|
||||
assert_eq!(
|
||||
stale.validate_against(&invocation),
|
||||
Err(ContractError::Schema)
|
||||
);
|
||||
let mut stale = (**sent).clone();
|
||||
stale.schema_id = v2("transport_receipt");
|
||||
assert_eq!(
|
||||
stale.validate_against(&invocation),
|
||||
Err(ContractError::Schema)
|
||||
);
|
||||
}
|
||||
|
||||
/// `document` re-encoded as a named map with one more key at its top level.
|
||||
fn with_extra_key<T: Serialize>(document: &T) -> Vec<u8> {
|
||||
let bytes = wire_encode(document).unwrap();
|
||||
let mut value = rmpv::decode::read_value(&mut bytes.as_slice()).unwrap();
|
||||
let rmpv::Value::Map(entries) = &mut value else {
|
||||
panic!("a v3 document is a named map");
|
||||
};
|
||||
entries.push((rmpv::Value::from("unexpected_field"), rmpv::Value::from(1)));
|
||||
let mut out = Vec::new();
|
||||
rmpv::encode::write_value(&mut out, &value).unwrap();
|
||||
out
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_unknown_field_is_refused_not_dropped() {
|
||||
let invocation = invocation();
|
||||
let result = TransportResult::transported(&invocation, Vec::new(), receipt(&invocation));
|
||||
let TransportDisposition::Transported { receipt: sent, .. } = &result.disposition else {
|
||||
unreachable!()
|
||||
};
|
||||
let envelope = crate::envelope::encrypt_invocation(
|
||||
&invocation,
|
||||
&security("epiphany-distinct-test-key"),
|
||||
)
|
||||
.unwrap();
|
||||
assert!(wire_decode::<ProviderRequest>(&wire_encode(&invocation.request).unwrap()).is_ok());
|
||||
assert!(wire_decode::<ProviderRequest>(&with_extra_key(&invocation.request)).is_err());
|
||||
assert!(wire_decode::<TransportInvocation>(&with_extra_key(&invocation)).is_err());
|
||||
assert!(wire_decode::<TransportResult>(&with_extra_key(&result)).is_err());
|
||||
assert!(wire_decode::<TransportReceipt>(&with_extra_key(&**sent)).is_err());
|
||||
assert!(
|
||||
wire_decode::<crate::envelope::TransportEnvelope>(&wire_encode(&envelope).unwrap())
|
||||
.is_ok()
|
||||
);
|
||||
assert!(
|
||||
wire_decode::<crate::envelope::TransportEnvelope>(&with_extra_key(&envelope)).is_err()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn max_output_tokens_is_required() {
|
||||
let mut request = request();
|
||||
assert_eq!(request.validate(), Ok(()));
|
||||
request.max_output_tokens = 0;
|
||||
assert_eq!(
|
||||
request.validate(),
|
||||
Err(ContractError::Invalid("max_output_tokens"))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn outcomes_are_typed() {
|
||||
let invocation = invocation();
|
||||
for stop_reason in [
|
||||
StopReason::EndTurn,
|
||||
StopReason::ToolUse,
|
||||
StopReason::MaxOutputTokens,
|
||||
StopReason::StopSequence,
|
||||
StopReason::Refusal,
|
||||
StopReason::ContextWindow,
|
||||
] {
|
||||
let mut sent = receipt(&invocation);
|
||||
sent.outcome = TransportOutcome::Completed {
|
||||
provider_response_id: None,
|
||||
stop_reason,
|
||||
usage: Usage {
|
||||
input_tokens: 1,
|
||||
cache_read_tokens: 2,
|
||||
cache_write_tokens: 3,
|
||||
output_tokens: 4,
|
||||
reasoning_tokens: None,
|
||||
},
|
||||
};
|
||||
let decoded: TransportReceipt = wire_decode(&wire_encode(&sent).unwrap()).unwrap();
|
||||
assert_eq!(decoded, sent);
|
||||
}
|
||||
for kind in [
|
||||
FailureKind::ResultSize,
|
||||
FailureKind::InvalidRequest,
|
||||
FailureKind::Authentication,
|
||||
FailureKind::RateLimited,
|
||||
FailureKind::Unavailable,
|
||||
FailureKind::Timeout,
|
||||
FailureKind::Provider,
|
||||
] {
|
||||
let mut sent = receipt(&invocation);
|
||||
sent.outcome = TransportOutcome::Failed {
|
||||
kind,
|
||||
message: "failed".to_string(),
|
||||
};
|
||||
assert_eq!(sent.validate_against(&invocation), Ok(()));
|
||||
let decoded: TransportReceipt = wire_decode(&wire_encode(&sent).unwrap()).unwrap();
|
||||
assert_eq!(decoded, sent);
|
||||
}
|
||||
let mut blank = receipt(&invocation);
|
||||
blank.outcome = TransportOutcome::Failed {
|
||||
kind: FailureKind::Provider,
|
||||
message: String::new(),
|
||||
};
|
||||
assert_eq!(
|
||||
blank.validate_against(&invocation),
|
||||
Err(ContractError::Invalid("receipt.failure_message"))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn validate_against_refuses_each_identity_field() {
|
||||
let invocation = invocation();
|
||||
let result = TransportResult::transported(&invocation, Vec::new(), receipt(&invocation));
|
||||
assert_eq!(result.validate_against(&invocation), Ok(()));
|
||||
let mut other_request = result.clone();
|
||||
other_request.request_id = "request-2".to_string();
|
||||
let mut other_caller = result.clone();
|
||||
other_caller.caller_runtime_id = "ghostlight-dungeon-yggdrasil".to_string();
|
||||
for changed in [other_request, other_caller] {
|
||||
assert_eq!(
|
||||
changed.validate_against(&invocation),
|
||||
Err(ContractError::Identity)
|
||||
);
|
||||
}
|
||||
|
||||
let good = receipt(&invocation);
|
||||
let mut other_request = good.clone();
|
||||
other_request.request_id = "request-2".to_string();
|
||||
let mut other_caller = good.clone();
|
||||
other_caller.caller_runtime_id = "ghostlight-dungeon-yggdrasil".to_string();
|
||||
let mut other_model = good.clone();
|
||||
other_model.model = "another-model".to_string();
|
||||
for changed in [other_request, other_caller, other_model] {
|
||||
assert_eq!(
|
||||
changed.validate_against(&invocation),
|
||||
Err(ContractError::Identity)
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,83 +2,264 @@ use std::io::{Read, Write};
|
|||
|
||||
use aes_gcm::aead::{Aead, Payload};
|
||||
use aes_gcm::{Aes256Gcm, KeyInit, Nonce};
|
||||
use base64::Engine;
|
||||
use base64::engine::general_purpose::STANDARD;
|
||||
use cultnet_rs::{
|
||||
CultNetMessage, CultNetWireContract, decode_cultnet_message_from_slice,
|
||||
encode_cultnet_message_to_vec,
|
||||
};
|
||||
use rand_core::{OsRng, RngCore};
|
||||
use schemars::JsonSchema;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use thiserror::Error;
|
||||
|
||||
use crate::contract::{
|
||||
ContractError, ENVELOPE_SCHEMA_ID, TransportInvocation, TransportKey, TransportResult,
|
||||
canonical_invocation_bytes, require_id,
|
||||
canonical_invocation_bytes, require_id, wire_decode, wire_encode,
|
||||
};
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
|
||||
pub enum EnvelopeKind {
|
||||
Invocation,
|
||||
Result,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct TransportEnvelope {
|
||||
pub schema_id: String,
|
||||
pub caller_runtime_id: String,
|
||||
pub request_id: String,
|
||||
pub message_kind: EnvelopeKind,
|
||||
pub nonce: [u8; 12],
|
||||
#[serde(with = "serde_bytes")]
|
||||
#[schemars(schema_with = "binary_schema")]
|
||||
pub ciphertext: Vec<u8>,
|
||||
}
|
||||
|
||||
/// The ciphertext is MessagePack `bin` on the wire, never an array of integers.
|
||||
fn binary_schema(_: &mut schemars::SchemaGenerator) -> schemars::Schema {
|
||||
schemars::json_schema!({
|
||||
"type": "string",
|
||||
"format": "binary",
|
||||
"description": "MessagePack bin: the raw ciphertext bytes, never an array of integers"
|
||||
})
|
||||
}
|
||||
|
||||
/// The CultNet service every frame of this connector addresses.
|
||||
pub const CONNECTOR_SERVICE_ID: &str = "gamecult.model.connector";
|
||||
/// The one operation of that service: run one sealed invocation.
|
||||
pub const INVOKE_OPERATION: &str = "invoke";
|
||||
|
||||
const PAYLOAD_ENCODING: &str = "messagepack-base64";
|
||||
const OK_STATUS: &str = "ok";
|
||||
|
||||
/// The longest caller id, request id or model name a service admits. The
|
||||
/// frame arithmetic below depends on it. A request whose id is longer is
|
||||
/// refused on the wire as malformed.
|
||||
pub const MAX_IDENTITY_BYTES: usize = 256;
|
||||
|
||||
/// The most bytes a serialized envelope holding `ciphertext_len` ciphertext
|
||||
/// bytes can occupy, with ids at their longest: the frame a caller payload
|
||||
/// bound (`CallerAdmission`'s `max_payload_bytes`, a bound on ciphertext
|
||||
/// length) needs. The v2 wire serializes the ciphertext as a MessagePack array
|
||||
/// of integers: a byte of 128 or more takes two bytes, so the worst case is
|
||||
/// twice the ciphertext, plus the array header and the envelope's other
|
||||
/// fields at their longest. Measured from the real encoding, not assumed.
|
||||
fn carriage_bytes(message: &CultNetMessage) -> Result<Vec<u8>, ServiceError> {
|
||||
encode_cultnet_message_to_vec(message, CultNetWireContract::CultNetSchemaV0)
|
||||
.map_err(|_| ServiceError::Carriage)
|
||||
}
|
||||
|
||||
fn seal_payload(envelope: &TransportEnvelope) -> Result<String, ServiceError> {
|
||||
Ok(STANDARD.encode(wire_encode(envelope)?))
|
||||
}
|
||||
|
||||
/// The CultNet frame an invocation travels in: a `cultnet.operation_request.v0`
|
||||
/// for [`CONNECTOR_SERVICE_ID`] and [`INVOKE_OPERATION`], its `message_id` the
|
||||
/// request id and its payload the base64 of the named sealed envelope.
|
||||
pub(crate) fn encode_invocation_frame(
|
||||
envelope: &TransportEnvelope,
|
||||
) -> Result<Vec<u8>, ServiceError> {
|
||||
carriage_bytes(&CultNetMessage::OperationRequest {
|
||||
message_id: envelope.request_id.clone(),
|
||||
service_id: CONNECTOR_SERVICE_ID.to_string(),
|
||||
operation: INVOKE_OPERATION.to_string(),
|
||||
payload_schema: ENVELOPE_SCHEMA_ID.to_string(),
|
||||
payload_encoding: PAYLOAD_ENCODING.to_string(),
|
||||
payload: seal_payload(envelope)?,
|
||||
source_runtime_id: None,
|
||||
target_runtime_id: None,
|
||||
})
|
||||
}
|
||||
|
||||
/// The CultNet frame a result travels in: a `cultnet.operation_response.v0`
|
||||
/// with status `ok`, otherwise shaped like the invocation's.
|
||||
#[cfg(any(feature = "daemon", test))]
|
||||
pub(crate) fn encode_result_frame(envelope: &TransportEnvelope) -> Result<Vec<u8>, ServiceError> {
|
||||
carriage_bytes(&CultNetMessage::OperationResponse {
|
||||
message_id: envelope.request_id.clone(),
|
||||
service_id: CONNECTOR_SERVICE_ID.to_string(),
|
||||
operation: INVOKE_OPERATION.to_string(),
|
||||
status: OK_STATUS.to_string(),
|
||||
payload_schema: ENVELOPE_SCHEMA_ID.to_string(),
|
||||
payload_encoding: PAYLOAD_ENCODING.to_string(),
|
||||
payload: seal_payload(envelope)?,
|
||||
diagnostics: Vec::new(),
|
||||
source_runtime_id: None,
|
||||
})
|
||||
}
|
||||
|
||||
/// Opens the carriage of one frame. Everything the carriage says is checked
|
||||
/// here, before any decryption: the service, the operation, the payload schema,
|
||||
/// and that the message id is the sealed envelope's request id.
|
||||
fn open_carriage(
|
||||
message_id: &str,
|
||||
service_id: &str,
|
||||
operation: &str,
|
||||
payload_schema: &str,
|
||||
payload: &str,
|
||||
) -> Result<TransportEnvelope, ServiceError> {
|
||||
if service_id != CONNECTOR_SERVICE_ID
|
||||
|| operation != INVOKE_OPERATION
|
||||
|| payload_schema != ENVELOPE_SCHEMA_ID
|
||||
{
|
||||
return Err(ServiceError::Carriage);
|
||||
}
|
||||
let bytes = STANDARD
|
||||
.decode(payload)
|
||||
.map_err(|_| ServiceError::Carriage)?;
|
||||
let envelope: TransportEnvelope = wire_decode(&bytes).map_err(|_| ServiceError::Encoding)?;
|
||||
if envelope.request_id != message_id {
|
||||
return Err(ServiceError::OuterIdentity);
|
||||
}
|
||||
Ok(envelope)
|
||||
}
|
||||
|
||||
fn read_carriage(bytes: &[u8]) -> Result<CultNetMessage, ServiceError> {
|
||||
decode_cultnet_message_from_slice(bytes, CultNetWireContract::CultNetSchemaV0)
|
||||
.map_err(|_| ServiceError::Carriage)
|
||||
}
|
||||
|
||||
#[cfg(any(feature = "daemon", test))]
|
||||
pub(crate) fn decode_invocation_frame(bytes: &[u8]) -> Result<TransportEnvelope, ServiceError> {
|
||||
match read_carriage(bytes)? {
|
||||
CultNetMessage::OperationRequest {
|
||||
message_id,
|
||||
service_id,
|
||||
operation,
|
||||
payload_schema,
|
||||
payload,
|
||||
..
|
||||
} => open_carriage(
|
||||
&message_id,
|
||||
&service_id,
|
||||
&operation,
|
||||
&payload_schema,
|
||||
&payload,
|
||||
),
|
||||
_ => Err(ServiceError::Carriage),
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn decode_result_frame(bytes: &[u8]) -> Result<TransportEnvelope, ServiceError> {
|
||||
match read_carriage(bytes)? {
|
||||
CultNetMessage::OperationResponse {
|
||||
message_id,
|
||||
service_id,
|
||||
operation,
|
||||
status,
|
||||
payload_schema,
|
||||
payload,
|
||||
..
|
||||
} if status == OK_STATUS => open_carriage(
|
||||
&message_id,
|
||||
&service_id,
|
||||
&operation,
|
||||
&payload_schema,
|
||||
&payload,
|
||||
),
|
||||
_ => Err(ServiceError::Carriage),
|
||||
}
|
||||
}
|
||||
|
||||
/// The most bytes one frame holding a sealed envelope of `ciphertext_len`
|
||||
/// ciphertext bytes can occupy, with ids at their longest: the frame a caller
|
||||
/// payload bound (`CallerAdmission`'s `max_payload_bytes`, a bound on
|
||||
/// ciphertext length) needs.
|
||||
///
|
||||
/// A frame is a CultNet operation message whose payload is the base64 of the
|
||||
/// named envelope. The result frame is the larger of the two directions (it
|
||||
/// also carries a status), so this bound holds for an invocation as well. The
|
||||
/// envelope shell and the message shell are measured from their real
|
||||
/// encodings; only the MessagePack length headers and base64 expansion that
|
||||
/// grow with `ciphertext_len` are arithmetic.
|
||||
pub fn frame_bytes_for_payload(ciphertext_len: usize) -> usize {
|
||||
let shell = rmp_serde::to_vec(&TransportEnvelope {
|
||||
let long = "x".repeat(MAX_IDENTITY_BYTES);
|
||||
let envelope_shell = wire_encode(&TransportEnvelope {
|
||||
schema_id: ENVELOPE_SCHEMA_ID.to_string(),
|
||||
caller_runtime_id: "x".repeat(MAX_IDENTITY_BYTES),
|
||||
request_id: "x".repeat(MAX_IDENTITY_BYTES),
|
||||
caller_runtime_id: long.clone(),
|
||||
request_id: long.clone(),
|
||||
message_kind: EnvelopeKind::Invocation,
|
||||
nonce: [0xff; 12],
|
||||
ciphertext: Vec::new(),
|
||||
})
|
||||
.map_or(usize::MAX, |bytes| bytes.len());
|
||||
// The shell holds an empty array header of one byte.
|
||||
let header = match ciphertext_len {
|
||||
0..=15 => 1,
|
||||
16..=65_535 => 3,
|
||||
let message_shell = encode_cultnet_message_to_vec(
|
||||
&CultNetMessage::OperationResponse {
|
||||
message_id: long,
|
||||
service_id: CONNECTOR_SERVICE_ID.to_string(),
|
||||
operation: INVOKE_OPERATION.to_string(),
|
||||
status: OK_STATUS.to_string(),
|
||||
payload_schema: ENVELOPE_SCHEMA_ID.to_string(),
|
||||
payload_encoding: PAYLOAD_ENCODING.to_string(),
|
||||
payload: "A".to_string(),
|
||||
diagnostics: Vec::new(),
|
||||
source_runtime_id: None,
|
||||
},
|
||||
CultNetWireContract::CultNetSchemaV0,
|
||||
)
|
||||
.map_or(usize::MAX, |bytes| bytes.len());
|
||||
// The shell holds an empty `bin 8`: a marker and a zero length. The shell's
|
||||
// one-character payload takes a one-byte string header and the character.
|
||||
let envelope = envelope_shell
|
||||
.saturating_sub(2)
|
||||
.saturating_add(bin_header_bytes(ciphertext_len))
|
||||
.saturating_add(ciphertext_len);
|
||||
let payload = envelope.div_ceil(3).saturating_mul(4);
|
||||
message_shell
|
||||
.saturating_sub(2)
|
||||
.saturating_add(str_header_bytes(payload))
|
||||
.saturating_add(payload)
|
||||
}
|
||||
|
||||
/// MessagePack `bin` header width for a byte string of `len` bytes.
|
||||
fn bin_header_bytes(len: usize) -> usize {
|
||||
match len {
|
||||
0..=255 => 2,
|
||||
256..=65_535 => 3,
|
||||
_ => 5,
|
||||
};
|
||||
shell
|
||||
.saturating_sub(1)
|
||||
.saturating_add(header)
|
||||
.saturating_add(ciphertext_len.saturating_mul(2))
|
||||
}
|
||||
}
|
||||
|
||||
/// MessagePack `str` header width for the base64 payload. The payload always
|
||||
/// exceeds 255 bytes: the envelope it encodes holds two ids of at least
|
||||
/// `MAX_IDENTITY_BYTES` in the worst case this bound measures.
|
||||
fn str_header_bytes(len: usize) -> usize {
|
||||
if len <= 65_535 { 3 } else { 5 }
|
||||
}
|
||||
|
||||
/// The largest payload bound whose worst-case frame fits `max_frame_bytes`:
|
||||
/// the inverse of [`frame_bytes_for_payload`]. `None` when the frame cannot
|
||||
/// carry even an empty envelope.
|
||||
pub fn payload_bytes_for_frame(max_frame_bytes: usize) -> Option<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()
|
||||
if frame_bytes_for_payload(0) > max_frame_bytes {
|
||||
return None;
|
||||
}
|
||||
// A frame is longer than its payload, so the answer is below the bound.
|
||||
let (mut fits, mut over) = (0, max_frame_bytes.saturating_add(1));
|
||||
while over - fits > 1 {
|
||||
let middle = fits + (over - fits) / 2;
|
||||
if frame_bytes_for_payload(middle) <= max_frame_bytes {
|
||||
fits = middle;
|
||||
} else {
|
||||
over = middle;
|
||||
}
|
||||
}
|
||||
Some(fits)
|
||||
}
|
||||
|
||||
pub fn encrypt_invocation(
|
||||
|
|
@ -102,7 +283,7 @@ pub fn decrypt_invocation(
|
|||
validate_envelope(envelope, EnvelopeKind::Invocation)?;
|
||||
let plaintext = decrypt_message(envelope, security)?;
|
||||
let invocation: TransportInvocation =
|
||||
rmp_serde::from_slice(&plaintext).map_err(|_| ServiceError::Encoding)?;
|
||||
wire_decode(&plaintext).map_err(|_| ServiceError::Encoding)?;
|
||||
if invocation.caller_runtime_id != envelope.caller_runtime_id
|
||||
|| invocation.request_id() != envelope.request_id
|
||||
{
|
||||
|
|
@ -115,7 +296,7 @@ pub fn encrypt_result(
|
|||
result: &TransportResult,
|
||||
security: &TransportKey,
|
||||
) -> Result<TransportEnvelope, ServiceError> {
|
||||
let plaintext = rmp_serde::to_vec(result).map_err(|_| ServiceError::Encoding)?;
|
||||
let plaintext = wire_encode(result).map_err(|_| ServiceError::Encoding)?;
|
||||
encrypt_message(
|
||||
&result.caller_runtime_id,
|
||||
&result.request_id,
|
||||
|
|
@ -132,8 +313,7 @@ pub fn decrypt_result(
|
|||
) -> Result<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)?;
|
||||
let result: TransportResult = wire_decode(&plaintext).map_err(|_| ServiceError::Encoding)?;
|
||||
if result.caller_runtime_id != envelope.caller_runtime_id
|
||||
|| result.request_id != envelope.request_id
|
||||
{
|
||||
|
|
@ -242,13 +422,15 @@ pub(crate) fn decrypt_message(
|
|||
.map_err(|_| ServiceError::Encryption)
|
||||
}
|
||||
|
||||
/// The authenticated data of a sealed message: a positional tuple, a MAC input
|
||||
/// and not a wire document (a tuple is positional under the named codec too).
|
||||
pub(crate) fn envelope_aad(
|
||||
schema_id: &str,
|
||||
caller_runtime_id: &str,
|
||||
request_id: &str,
|
||||
message_kind: EnvelopeKind,
|
||||
) -> Result<Vec<u8>, ServiceError> {
|
||||
rmp_serde::to_vec(&(schema_id, caller_runtime_id, request_id, message_kind))
|
||||
wire_encode(&(schema_id, caller_runtime_id, request_id, message_kind))
|
||||
.map_err(|_| ServiceError::Encoding)
|
||||
}
|
||||
|
||||
|
|
@ -291,8 +473,10 @@ pub enum ServiceError {
|
|||
OuterIdentity,
|
||||
#[error("transport encryption or authentication failed")]
|
||||
Encryption,
|
||||
#[error("transport MessagePack encoding failed")]
|
||||
#[error("transport encoding failed")]
|
||||
Encoding,
|
||||
#[error("frame is not a connector operation message")]
|
||||
Carriage,
|
||||
#[error("completion did not carry a transported provider outcome")]
|
||||
InvalidCompletion,
|
||||
#[error("completion has no active request")]
|
||||
|
|
@ -373,7 +557,7 @@ mod tests {
|
|||
let key = security("epiphany-distinct-test-key");
|
||||
let invocation = invocation();
|
||||
let result = TransportResult::transported(&invocation, Vec::new(), receipt(&invocation));
|
||||
let plaintext = rmp_serde::to_vec(&result).unwrap();
|
||||
let plaintext = wire_encode(&result).unwrap();
|
||||
let seal = |caller: &str, request_id: &str| {
|
||||
encrypt_message(caller, request_id, EnvelopeKind::Result, &plaintext, &key).unwrap()
|
||||
};
|
||||
|
|
@ -401,7 +585,7 @@ mod tests {
|
|||
let good = encrypt_invocation(&invocation, &key).unwrap();
|
||||
|
||||
let mut schema = good.clone();
|
||||
schema.schema_id = "gamecult.codex.transport_envelope.v1".to_string();
|
||||
schema.schema_id = concat!("gamecult.", "codex.transport_envelope.v2").to_string();
|
||||
let wrong_kind = sealed_invocation("epiphany-yggdrasil", "request-1", EnvelopeKind::Result);
|
||||
let mut empty = good.clone();
|
||||
empty.ciphertext.clear();
|
||||
|
|
@ -452,20 +636,44 @@ mod tests {
|
|||
assert!(matches!(write(&[], 1_000), Err(TransportFrameError::Size)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_worst_frame_is_the_real_encoding_of_an_all_high_ciphertext() {
|
||||
/// The real result frame for an envelope with ids at the longest admitted
|
||||
/// and a ciphertext of `length` bytes.
|
||||
fn real_worst_frame(length: usize) -> Vec<u8> {
|
||||
let long = "x".repeat(MAX_IDENTITY_BYTES);
|
||||
for length in [0, 1, 15, 16, 17, 255, 256, 65_535, 65_536, 70_000] {
|
||||
let envelope = TransportEnvelope {
|
||||
schema_id: ENVELOPE_SCHEMA_ID.to_string(),
|
||||
caller_runtime_id: long.clone(),
|
||||
request_id: long.clone(),
|
||||
message_kind: EnvelopeKind::Invocation,
|
||||
nonce: [0xff; 12],
|
||||
ciphertext: vec![0xff; length],
|
||||
};
|
||||
encode_result_frame(&TransportEnvelope {
|
||||
schema_id: ENVELOPE_SCHEMA_ID.to_string(),
|
||||
caller_runtime_id: long.clone(),
|
||||
request_id: long,
|
||||
message_kind: EnvelopeKind::Invocation,
|
||||
nonce: [0xff; 12],
|
||||
ciphertext: vec![0xff; length],
|
||||
})
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
/// Ciphertext sizes either side of every length-header boundary the frame
|
||||
/// crosses: bin 8 to 16 to 32, str 8 to 16 to 32, and each base64 padding.
|
||||
fn boundary_sizes() -> Vec<usize> {
|
||||
let mut sizes: Vec<usize> = (0..=40).collect();
|
||||
for center in [
|
||||
100, 200, 250, 256, 300, 1000, 3000, 65_535, 65_536, 49_000, 49_100, 49_152, 49_200,
|
||||
70_000,
|
||||
] {
|
||||
for delta in 0..=40 {
|
||||
sizes.push(center + delta);
|
||||
sizes.push(center.saturating_sub(delta));
|
||||
}
|
||||
}
|
||||
sizes.sort_unstable();
|
||||
sizes.dedup();
|
||||
sizes
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_frame_bound_is_the_real_carriage_encoding() {
|
||||
for length in boundary_sizes() {
|
||||
assert_eq!(
|
||||
rmp_serde::to_vec(&envelope).unwrap().len(),
|
||||
real_worst_frame(length).len(),
|
||||
frame_bytes_for_payload(length),
|
||||
"{length}"
|
||||
);
|
||||
|
|
@ -480,35 +688,451 @@ mod tests {
|
|||
let sealed =
|
||||
encrypt_message(&id, &id, EnvelopeKind::Result, &vec![0x41; plaintext], &key)
|
||||
.unwrap();
|
||||
let real = rmp_serde::to_vec(&sealed).unwrap().len();
|
||||
let worst = frame_bytes_for_payload(sealed.ciphertext.len());
|
||||
assert!(real <= worst, "{real} > {worst}");
|
||||
// Real ciphertext is random, so about half its bytes take two
|
||||
// bytes: the worst case is not wildly above what is written.
|
||||
for real in [
|
||||
encode_result_frame(&sealed).unwrap().len(),
|
||||
encode_invocation_frame(&sealed).unwrap().len(),
|
||||
] {
|
||||
assert!(real <= worst, "{real} > {worst}");
|
||||
}
|
||||
// The frame is the base64 of the envelope: its worst case is
|
||||
// within the base64 expansion of the real one.
|
||||
if id.len() == MAX_IDENTITY_BYTES {
|
||||
assert!(
|
||||
worst <= real + sealed.ciphertext.len() + 64,
|
||||
"{worst} {real}"
|
||||
);
|
||||
let real = encode_result_frame(&sealed).unwrap().len();
|
||||
assert!(worst <= real + 32, "{worst} {real}");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_payload_bound_a_frame_allows_inverts_the_frame_a_payload_needs() {
|
||||
fn the_payload_bound_a_frame_allows_is_the_largest_payload_that_fits() {
|
||||
// The frame grows in base64 groups of three bytes, so several payloads
|
||||
// share a frame: the inverse is the largest of them.
|
||||
assert_eq!(
|
||||
payload_bytes_for_frame(frame_bytes_for_payload(0) - 1),
|
||||
None
|
||||
);
|
||||
assert_eq!(payload_bytes_for_frame(frame_bytes_for_payload(0)), Some(0));
|
||||
for bytes in [1, 15, 16, 4096, 65_535, 65_536, 100_000, 2_000_000] {
|
||||
for bytes in boundary_sizes().into_iter().chain([2_000_000]) {
|
||||
let frame = frame_bytes_for_payload(bytes);
|
||||
assert_eq!(payload_bytes_for_frame(frame), Some(bytes), "{bytes}");
|
||||
let allowed = payload_bytes_for_frame(frame).unwrap();
|
||||
assert!(allowed >= bytes && allowed - bytes < 3, "{bytes} {allowed}");
|
||||
assert_eq!(frame_bytes_for_payload(allowed), frame, "{bytes}");
|
||||
assert!(frame_bytes_for_payload(allowed + 1) > frame, "{bytes}");
|
||||
assert!(
|
||||
payload_bytes_for_frame(frame - 1).unwrap() < bytes,
|
||||
payload_bytes_for_frame(frame - 1).is_none_or(|smaller| smaller < bytes),
|
||||
"{bytes}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ciphertext_travels_as_binary() {
|
||||
let key = security("epiphany-distinct-test-key");
|
||||
for plaintext in [10, 300, 70_000] {
|
||||
let sealed = encrypt_message(
|
||||
"caller",
|
||||
"request",
|
||||
EnvelopeKind::Invocation,
|
||||
&vec![0x41; plaintext],
|
||||
&key,
|
||||
)
|
||||
.unwrap();
|
||||
let length = sealed.ciphertext.len();
|
||||
let mut expected = match length {
|
||||
0..=255 => vec![0xc4, length as u8],
|
||||
256..=65_535 => [&[0xc5][..], &(length as u16).to_be_bytes()].concat(),
|
||||
_ => [&[0xc6][..], &(length as u32).to_be_bytes()].concat(),
|
||||
};
|
||||
expected.extend(&sealed.ciphertext);
|
||||
let encoded = wire_encode(&sealed).unwrap();
|
||||
assert!(
|
||||
encoded
|
||||
.windows(expected.len())
|
||||
.any(|window| window == expected),
|
||||
"{plaintext}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
fn sealed_for_carriage() -> TransportEnvelope {
|
||||
encrypt_invocation(&invocation(), &security("epiphany-distinct-test-key")).unwrap()
|
||||
}
|
||||
|
||||
fn request_message(edit: impl FnOnce(&mut CultNetMessage)) -> Vec<u8> {
|
||||
let envelope = sealed_for_carriage();
|
||||
let mut message = decode_cultnet_message_from_slice(
|
||||
&encode_invocation_frame(&envelope).unwrap(),
|
||||
CultNetWireContract::CultNetSchemaV0,
|
||||
)
|
||||
.unwrap();
|
||||
edit(&mut message);
|
||||
carriage_bytes(&message).unwrap()
|
||||
}
|
||||
|
||||
fn response_message(edit: impl FnOnce(&mut CultNetMessage)) -> Vec<u8> {
|
||||
let envelope = sealed_for_carriage();
|
||||
let mut message = decode_cultnet_message_from_slice(
|
||||
&encode_result_frame(&envelope).unwrap(),
|
||||
CultNetWireContract::CultNetSchemaV0,
|
||||
)
|
||||
.unwrap();
|
||||
edit(&mut message);
|
||||
carriage_bytes(&message).unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_carriage_round_trips_both_directions() {
|
||||
let envelope = sealed_for_carriage();
|
||||
assert_eq!(
|
||||
decode_invocation_frame(&encode_invocation_frame(&envelope).unwrap()).unwrap(),
|
||||
envelope
|
||||
);
|
||||
assert_eq!(
|
||||
decode_result_frame(&encode_result_frame(&envelope).unwrap()).unwrap(),
|
||||
envelope
|
||||
);
|
||||
// A request is not a response, in either direction.
|
||||
assert_eq!(
|
||||
decode_result_frame(&encode_invocation_frame(&envelope).unwrap()),
|
||||
Err(ServiceError::Carriage)
|
||||
);
|
||||
assert_eq!(
|
||||
decode_invocation_frame(&encode_result_frame(&envelope).unwrap()),
|
||||
Err(ServiceError::Carriage)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_carriage_refuses_another_service_operation_or_schema() {
|
||||
fn edit_request(field: &str, value: &str) -> Vec<u8> {
|
||||
request_message(|message| {
|
||||
if let CultNetMessage::OperationRequest {
|
||||
service_id,
|
||||
operation,
|
||||
payload_schema,
|
||||
..
|
||||
} = message
|
||||
{
|
||||
match field {
|
||||
"service" => *service_id = value.to_string(),
|
||||
"operation" => *operation = value.to_string(),
|
||||
_ => *payload_schema = value.to_string(),
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
fn edit_response(field: &str, value: &str) -> Vec<u8> {
|
||||
response_message(|message| {
|
||||
if let CultNetMessage::OperationResponse {
|
||||
service_id,
|
||||
operation,
|
||||
payload_schema,
|
||||
status,
|
||||
..
|
||||
} = message
|
||||
{
|
||||
match field {
|
||||
"service" => *service_id = value.to_string(),
|
||||
"operation" => *operation = value.to_string(),
|
||||
"status" => *status = value.to_string(),
|
||||
_ => *payload_schema = value.to_string(),
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
for (field, value) in [
|
||||
("service", "gamecult.model.other"),
|
||||
("operation", "cancel"),
|
||||
("schema", "gamecult.model.transport_envelope.v2"),
|
||||
] {
|
||||
assert_eq!(
|
||||
decode_invocation_frame(&edit_request(field, value)),
|
||||
Err(ServiceError::Carriage),
|
||||
"{field}"
|
||||
);
|
||||
assert_eq!(
|
||||
decode_result_frame(&edit_response(field, value)),
|
||||
Err(ServiceError::Carriage),
|
||||
"{field}"
|
||||
);
|
||||
}
|
||||
assert_eq!(
|
||||
decode_result_frame(&edit_response("status", "rejected")),
|
||||
Err(ServiceError::Carriage)
|
||||
);
|
||||
// The unedited message decodes, so each refusal above is the edit's.
|
||||
assert!(decode_invocation_frame(&edit_request("service", CONNECTOR_SERVICE_ID)).is_ok());
|
||||
assert!(decode_result_frame(&edit_response("status", "ok")).is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_carriage_refuses_a_message_id_that_is_not_the_envelopes_request_id() {
|
||||
let request = request_message(|message| {
|
||||
if let CultNetMessage::OperationRequest { message_id, .. } = message {
|
||||
*message_id = "another-request".to_string();
|
||||
}
|
||||
});
|
||||
let response = response_message(|message| {
|
||||
if let CultNetMessage::OperationResponse { message_id, .. } = message {
|
||||
*message_id = "another-request".to_string();
|
||||
}
|
||||
});
|
||||
assert_eq!(
|
||||
decode_invocation_frame(&request),
|
||||
Err(ServiceError::OuterIdentity)
|
||||
);
|
||||
assert_eq!(
|
||||
decode_result_frame(&response),
|
||||
Err(ServiceError::OuterIdentity)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_carriage_refuses_what_is_not_a_connector_message() {
|
||||
let good = encode_invocation_frame(&sealed_for_carriage()).unwrap();
|
||||
// Not MessagePack of any CultNet message, nor a truncated one.
|
||||
assert_eq!(
|
||||
decode_invocation_frame(&[0xc1, 0x00, 0x01]),
|
||||
Err(ServiceError::Carriage)
|
||||
);
|
||||
assert_eq!(
|
||||
decode_invocation_frame(&good[..good.len() / 2]),
|
||||
Err(ServiceError::Carriage)
|
||||
);
|
||||
// A payload that is not base64, and base64 of something else.
|
||||
let not_base64 = request_message(|message| {
|
||||
if let CultNetMessage::OperationRequest { payload, .. } = message {
|
||||
*payload = "!!not base64!!".to_string();
|
||||
}
|
||||
});
|
||||
assert_eq!(
|
||||
decode_invocation_frame(¬_base64),
|
||||
Err(ServiceError::Carriage)
|
||||
);
|
||||
let not_an_envelope = request_message(|message| {
|
||||
if let CultNetMessage::OperationRequest { payload, .. } = message {
|
||||
*payload = STANDARD.encode(b"\x01\x02");
|
||||
}
|
||||
});
|
||||
assert_eq!(
|
||||
decode_invocation_frame(¬_an_envelope),
|
||||
Err(ServiceError::Encoding)
|
||||
);
|
||||
}
|
||||
|
||||
fn golden_frame() -> Vec<u8> {
|
||||
std::fs::read(
|
||||
std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
|
||||
.join("tests/fixtures/wire_v3/carriage_invocation.frame"),
|
||||
)
|
||||
.expect("carriage_invocation.frame is committed")
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_committed_golden_frame_is_what_the_kit_reads_and_writes() {
|
||||
let frame = golden_frame();
|
||||
let envelope = decode_invocation_frame(&frame).unwrap();
|
||||
assert_eq!(encode_invocation_frame(&envelope).unwrap(), frame);
|
||||
}
|
||||
|
||||
fn top_level(frame: &[u8]) -> std::collections::BTreeMap<String, rmpv::Value> {
|
||||
let rmpv::Value::Map(entries) = rmpv::decode::read_value(&mut &frame[..]).unwrap() else {
|
||||
panic!("a CultNet frame is a map");
|
||||
};
|
||||
entries
|
||||
.into_iter()
|
||||
.map(|(key, value)| (key.as_str().unwrap().to_string(), value))
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_frames_name_the_connector_service_and_its_one_operation_on_the_wire() {
|
||||
let envelope = sealed_for_carriage();
|
||||
let text = |map: &std::collections::BTreeMap<String, rmpv::Value>, key: &str| {
|
||||
map[key].as_str().unwrap().to_string()
|
||||
};
|
||||
let request = top_level(&encode_invocation_frame(&envelope).unwrap());
|
||||
assert_eq!(
|
||||
text(&request, "schemaVersion"),
|
||||
"cultnet.operation_request.v0"
|
||||
);
|
||||
assert_eq!(text(&request, "serviceId"), "gamecult.model.connector");
|
||||
assert_eq!(text(&request, "operation"), "invoke");
|
||||
assert_eq!(
|
||||
text(&request, "payloadSchema"),
|
||||
"gamecult.model.transport_envelope.v3"
|
||||
);
|
||||
assert_eq!(text(&request, "payloadEncoding"), "messagepack-base64");
|
||||
assert_eq!(text(&request, "messageId"), envelope.request_id);
|
||||
let response = top_level(&encode_result_frame(&envelope).unwrap());
|
||||
assert_eq!(
|
||||
text(&response, "schemaVersion"),
|
||||
"cultnet.operation_response.v0"
|
||||
);
|
||||
assert_eq!(text(&response, "serviceId"), "gamecult.model.connector");
|
||||
assert_eq!(text(&response, "operation"), "invoke");
|
||||
assert_eq!(text(&response, "status"), "ok");
|
||||
assert_eq!(
|
||||
text(&response, "payloadSchema"),
|
||||
"gamecult.model.transport_envelope.v3"
|
||||
);
|
||||
assert_eq!(text(&response, "payloadEncoding"), "messagepack-base64");
|
||||
assert_eq!(text(&response, "messageId"), envelope.request_id);
|
||||
}
|
||||
|
||||
fn value_of(bytes: &[u8]) -> rmpv::Value {
|
||||
rmpv::decode::read_value(&mut &bytes[..]).unwrap()
|
||||
}
|
||||
|
||||
fn bytes_of(value: &rmpv::Value) -> Vec<u8> {
|
||||
let mut out = Vec::new();
|
||||
rmpv::encode::write_value(&mut out, value).unwrap();
|
||||
out
|
||||
}
|
||||
|
||||
fn with_entry(value: rmpv::Value, name: &str, entry: rmpv::Value) -> rmpv::Value {
|
||||
let rmpv::Value::Map(mut entries) = value else {
|
||||
panic!("a named document is a map");
|
||||
};
|
||||
let slot = entries
|
||||
.iter_mut()
|
||||
.find(|(key, _)| key.as_str() == Some(name))
|
||||
.unwrap();
|
||||
slot.1 = entry;
|
||||
rmpv::Value::Map(entries)
|
||||
}
|
||||
|
||||
fn reverse_maps(value: rmpv::Value) -> rmpv::Value {
|
||||
match value {
|
||||
rmpv::Value::Map(entries) => rmpv::Value::Map(
|
||||
entries
|
||||
.into_iter()
|
||||
.rev()
|
||||
.map(|(key, entry)| (key, reverse_maps(entry)))
|
||||
.collect(),
|
||||
),
|
||||
rmpv::Value::Array(items) => {
|
||||
rmpv::Value::Array(items.into_iter().map(reverse_maps).collect())
|
||||
}
|
||||
other => other,
|
||||
}
|
||||
}
|
||||
|
||||
fn without_nils(value: rmpv::Value) -> rmpv::Value {
|
||||
match value {
|
||||
rmpv::Value::Map(entries) => rmpv::Value::Map(
|
||||
entries
|
||||
.into_iter()
|
||||
.filter(|(_, entry)| !entry.is_nil())
|
||||
.map(|(key, entry)| (key, without_nils(entry)))
|
||||
.collect(),
|
||||
),
|
||||
rmpv::Value::Array(items) => {
|
||||
rmpv::Value::Array(items.into_iter().map(without_nils).collect())
|
||||
}
|
||||
other => other,
|
||||
}
|
||||
}
|
||||
|
||||
fn request_frame_carrying(payload: &[u8]) -> Vec<u8> {
|
||||
request_message(|message| {
|
||||
if let CultNetMessage::OperationRequest { payload: sent, .. } = message {
|
||||
*sent = STANDARD.encode(payload);
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
fn response_frame_carrying(payload: &[u8]) -> Vec<u8> {
|
||||
response_message(|message| {
|
||||
if let CultNetMessage::OperationResponse { payload: sent, .. } = message {
|
||||
*sent = STANDARD.encode(payload);
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_carriage_refuses_every_dialect_the_wire_does_not_name() {
|
||||
let envelope = sealed_for_carriage();
|
||||
let named = value_of(&wire_encode(&envelope).unwrap());
|
||||
let integer_ciphertext = rmpv::Value::Array(
|
||||
envelope
|
||||
.ciphertext
|
||||
.iter()
|
||||
.map(|byte| rmpv::Value::from(*byte))
|
||||
.collect(),
|
||||
);
|
||||
let dialects = [
|
||||
("positional envelope", rmp_serde::to_vec(&envelope).unwrap()),
|
||||
(
|
||||
"integer-array ciphertext",
|
||||
bytes_of(&with_entry(named.clone(), "ciphertext", integer_ciphertext)),
|
||||
),
|
||||
(
|
||||
"enum as its index",
|
||||
bytes_of(&with_entry(named, "message_kind", 0.into())),
|
||||
),
|
||||
(
|
||||
"bytes after the document",
|
||||
[wire_encode(&envelope).unwrap(), vec![0xc0]].concat(),
|
||||
),
|
||||
];
|
||||
for (dialect, payload) in &dialects {
|
||||
assert_eq!(
|
||||
decode_invocation_frame(&request_frame_carrying(payload)),
|
||||
Err(ServiceError::Encoding),
|
||||
"{dialect} on a request"
|
||||
);
|
||||
assert_eq!(
|
||||
decode_result_frame(&response_frame_carrying(payload)),
|
||||
Err(ServiceError::Encoding),
|
||||
"{dialect} on a response"
|
||||
);
|
||||
}
|
||||
// The named form itself is read, so the refusals above are the dialect's.
|
||||
let named_bytes = wire_encode(&envelope).unwrap();
|
||||
assert_eq!(
|
||||
decode_invocation_frame(&request_frame_carrying(&named_bytes)),
|
||||
Ok(envelope)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_sealed_positional_invocation_is_refused_after_decryption() {
|
||||
let key = security("epiphany-distinct-test-key");
|
||||
let invocation = invocation();
|
||||
let sealed = encrypt_message(
|
||||
&invocation.caller_runtime_id,
|
||||
invocation.request_id(),
|
||||
EnvelopeKind::Invocation,
|
||||
&rmp_serde::to_vec(&invocation).unwrap(),
|
||||
&key,
|
||||
)
|
||||
.unwrap();
|
||||
let arrived = decode_invocation_frame(&encode_invocation_frame(&sealed).unwrap()).unwrap();
|
||||
assert_eq!(
|
||||
decrypt_invocation(&arrived, &key),
|
||||
Err(ServiceError::Encoding)
|
||||
);
|
||||
let sealed = encrypt_invocation(&invocation, &key).unwrap();
|
||||
assert_eq!(decrypt_invocation(&sealed, &key), Ok(invocation));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn named_maps_in_any_key_order_and_without_absent_options_are_the_named_form() {
|
||||
let envelope = sealed_for_carriage();
|
||||
let reversed = bytes_of(&reverse_maps(value_of(&wire_encode(&envelope).unwrap())));
|
||||
assert_ne!(reversed, wire_encode(&envelope).unwrap());
|
||||
assert_eq!(wire_decode::<TransportEnvelope>(&reversed), Ok(envelope));
|
||||
let invocation = invocation();
|
||||
let named = value_of(&wire_encode(&invocation).unwrap());
|
||||
for edited in [reverse_maps(named.clone()), without_nils(named)] {
|
||||
let bytes = bytes_of(&edited);
|
||||
assert_ne!(bytes, wire_encode(&invocation).unwrap());
|
||||
assert_eq!(
|
||||
wire_decode::<TransportInvocation>(&bytes),
|
||||
Ok(invocation.clone())
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -43,6 +43,7 @@ pub(crate) fn request() -> ProviderRequest {
|
|||
"conversation-1",
|
||||
"gpt-5.4",
|
||||
"Return the typed result.",
|
||||
4_096,
|
||||
);
|
||||
request.input.push(InputItem::UserText {
|
||||
text: "Projected state".to_string(),
|
||||
|
|
@ -67,13 +68,17 @@ pub(crate) fn receipt(invocation: &TransportInvocation) -> TransportReceipt {
|
|||
native_request_sha256: invocation.native_request_sha256,
|
||||
provider_request_sha256: invocation.provider_request_sha256,
|
||||
model: invocation.request.model.clone(),
|
||||
transport: "fixture-sse".to_string(),
|
||||
provider: "fixture-sse".to_string(),
|
||||
outcome: TransportOutcome::Completed {
|
||||
provider_response_id: Some("response-1".to_string()),
|
||||
input_tokens: Some(10),
|
||||
output_tokens: Some(3),
|
||||
reasoning_output_tokens: Some(1),
|
||||
cached_input_tokens: None,
|
||||
stop_reason: StopReason::EndTurn,
|
||||
usage: Usage {
|
||||
input_tokens: 10,
|
||||
cache_read_tokens: 0,
|
||||
cache_write_tokens: 0,
|
||||
output_tokens: 3,
|
||||
reasoning_tokens: Some(1),
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -12,8 +12,8 @@ use zeroize::Zeroizing;
|
|||
|
||||
use crate::contract::{TransportInvocation, TransportResult};
|
||||
use crate::envelope::{
|
||||
ServiceError, TransportEnvelope, TransportFrameError, frame_bytes_for_payload,
|
||||
read_transport_frame, write_transport_frame,
|
||||
ServiceError, TransportFrameError, decode_invocation_frame, encode_result_frame,
|
||||
frame_bytes_for_payload, read_transport_frame, write_transport_frame,
|
||||
};
|
||||
use crate::service::{CallerAdmission, TransportAdmission, TransportService};
|
||||
|
||||
|
|
@ -222,8 +222,7 @@ fn execute_inference_frame(
|
|||
backend: &dyn ProviderBackend,
|
||||
limits: &ServeLimits,
|
||||
) -> Result<Vec<u8>, ServeError> {
|
||||
let envelope: TransportEnvelope =
|
||||
rmp_serde::from_slice(request).map_err(|_| ServeError::FrameEncoding)?;
|
||||
let envelope = decode_invocation_frame(request).map_err(|_| ServeError::FrameEncoding)?;
|
||||
let admission = service
|
||||
.lock()
|
||||
.map_err(|_| ServeError::ServicePoisoned)?
|
||||
|
|
@ -238,7 +237,7 @@ fn execute_inference_frame(
|
|||
.complete(*claim, result, limits.max_frame_bytes)?
|
||||
}
|
||||
};
|
||||
rmp_serde::to_vec(&response).map_err(|_| ServeError::FrameEncoding)
|
||||
encode_result_frame(&response).map_err(|_| ServeError::FrameEncoding)
|
||||
}
|
||||
|
||||
fn frame_error(error: TransportFrameError) -> ServeError {
|
||||
|
|
@ -314,7 +313,7 @@ pub enum ServeError {
|
|||
Connection(#[source] std::io::Error),
|
||||
#[error("connector frame exceeded its bound")]
|
||||
FrameSize,
|
||||
#[error("connector frame was not valid MessagePack")]
|
||||
#[error("connector frame was not a connector operation message")]
|
||||
FrameEncoding,
|
||||
#[error("system clock is before the Unix epoch")]
|
||||
Clock,
|
||||
|
|
@ -328,8 +327,8 @@ mod tests {
|
|||
use super::*;
|
||||
use crate::client::ConnectorClient;
|
||||
use crate::contract::{
|
||||
Refusal, TransportDisposition, TransportEvent, TransportEventPayload, TransportOutcome,
|
||||
TransportResult,
|
||||
FailureKind, Refusal, TransportDisposition, TransportEvent, TransportEventPayload,
|
||||
TransportOutcome, TransportResult,
|
||||
};
|
||||
use crate::fixtures::{TestStore, caller, invocation_for, receipt, request};
|
||||
|
||||
|
|
@ -506,7 +505,7 @@ mod tests {
|
|||
let backend = CountingBackend::new(true);
|
||||
let (_client, endpoint) = start(backend.clone(), &store);
|
||||
let security = crate::fixtures::security(KEY);
|
||||
let request = rmp_serde::to_vec(
|
||||
let request = crate::envelope::encode_invocation_frame(
|
||||
&crate::envelope::encrypt_invocation(&fresh("request-wire"), &security).unwrap(),
|
||||
)
|
||||
.unwrap();
|
||||
|
|
@ -531,14 +530,14 @@ mod tests {
|
|||
// requires the result to name the request by its own id.
|
||||
let send = |id: &str| {
|
||||
let invocation = fresh(id);
|
||||
let request = rmp_serde::to_vec(
|
||||
let request = crate::envelope::encode_invocation_frame(
|
||||
&crate::envelope::encrypt_invocation(&invocation, &security).unwrap(),
|
||||
)
|
||||
.unwrap();
|
||||
let mut stream = TcpStream::connect(endpoint).unwrap();
|
||||
write_transport_frame(&mut stream, &request, FRAME).unwrap();
|
||||
let reply = read_transport_frame(&mut stream, FRAME).unwrap();
|
||||
let envelope: TransportEnvelope = rmp_serde::from_slice(&reply).unwrap();
|
||||
let envelope = crate::envelope::decode_result_frame(&reply).unwrap();
|
||||
crate::envelope::decrypt_result(&envelope, &security, &invocation).unwrap()
|
||||
};
|
||||
let served = send(&"i".repeat(crate::envelope::MAX_IDENTITY_BYTES));
|
||||
|
|
@ -684,7 +683,7 @@ mod tests {
|
|||
#[test]
|
||||
fn the_smallest_frame_also_carries_the_failure_that_stands_in_for_a_result() {
|
||||
let long = "x".repeat(crate::envelope::MAX_IDENTITY_BYTES);
|
||||
let plaintext = rmp_serde::to_vec(&crate::service::result_size_failure(
|
||||
let plaintext = crate::contract::wire_encode(&crate::service::result_size_failure(
|
||||
&long, &long, &long, [0xff; 32], [0xff; 32],
|
||||
))
|
||||
.unwrap()
|
||||
|
|
@ -906,7 +905,7 @@ mod tests {
|
|||
let reply = client.execute(&invocation).unwrap();
|
||||
assert_eq!(
|
||||
reply.disposition,
|
||||
TransportDisposition::Refused(Refusal::Policy)
|
||||
TransportDisposition::Refused(Refusal::PayloadBound)
|
||||
);
|
||||
assert_eq!(backend.executions.load(Ordering::SeqCst), 0);
|
||||
}
|
||||
|
|
@ -949,7 +948,7 @@ mod tests {
|
|||
transport: &str,
|
||||
) -> TransportResult {
|
||||
let mut receipt = receipt(invocation);
|
||||
receipt.transport = transport.to_string();
|
||||
receipt.provider = transport.to_string();
|
||||
TransportResult::transported(
|
||||
invocation,
|
||||
vec![TransportEvent {
|
||||
|
|
@ -963,7 +962,7 @@ mod tests {
|
|||
}
|
||||
|
||||
fn encoded_len(result: &TransportResult) -> usize {
|
||||
rmp_serde::to_vec(result).unwrap().len()
|
||||
crate::contract::wire_encode(result).unwrap().len()
|
||||
}
|
||||
|
||||
/// Two calls with one request id, through a live serve loop.
|
||||
|
|
@ -984,9 +983,9 @@ mod tests {
|
|||
&result.disposition,
|
||||
TransportDisposition::Transported { events, receipt }
|
||||
if events.is_empty()
|
||||
&& receipt.transport == "model-connector"
|
||||
&& receipt.provider == "model-connector"
|
||||
&& receipt.outcome == TransportOutcome::Failed {
|
||||
failure_kind: "result_size".to_string(),
|
||||
kind: FailureKind::ResultSize,
|
||||
message: "provider result exceeded its byte bound".to_string(),
|
||||
}
|
||||
)
|
||||
|
|
@ -1154,4 +1153,92 @@ mod tests {
|
|||
client.execute(&fresh("request-key")).unwrap();
|
||||
assert_eq!(backend.executions.load(Ordering::SeqCst), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_v2_frame_gets_no_answer() {
|
||||
let store = TestStore::new();
|
||||
let backend = CountingBackend::new(true);
|
||||
let (client, endpoint) = start(backend.clone(), &store);
|
||||
// The v2 envelope: positional, an integer-array ciphertext, a v2 id.
|
||||
let v2 = rmp_serde::to_vec(&(
|
||||
concat!("gamecult.", "codex.transport_envelope.v2"),
|
||||
CALLER,
|
||||
"request-v2",
|
||||
"Invocation",
|
||||
[0_u8; 12],
|
||||
vec![1_u8; 64],
|
||||
))
|
||||
.unwrap();
|
||||
let mut stream = TcpStream::connect(endpoint).unwrap();
|
||||
write_transport_frame(&mut stream, &v2, FRAME).unwrap();
|
||||
assert!(matches!(
|
||||
read_transport_frame(&mut stream, FRAME),
|
||||
Err(TransportFrameError::Connection(_))
|
||||
));
|
||||
assert_eq!(backend.executions.load(Ordering::SeqCst), 0);
|
||||
// The loop survives the v2 peer and serves a v3 one.
|
||||
client.execute(&fresh("request-after-v2")).unwrap();
|
||||
assert_eq!(backend.executions.load(Ordering::SeqCst), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_reply_echoes_the_request_id() {
|
||||
use cultnet_rs::{CultNetMessage, CultNetWireContract, decode_cultnet_message_from_slice};
|
||||
let store = TestStore::new();
|
||||
let backend = CountingBackend::new(true);
|
||||
let (_client, endpoint) = start(backend.clone(), &store);
|
||||
let security = crate::fixtures::security(KEY);
|
||||
let send = |invocation: &TransportInvocation| {
|
||||
let request = crate::envelope::encode_invocation_frame(
|
||||
&crate::envelope::encrypt_invocation(invocation, &security).unwrap(),
|
||||
)
|
||||
.unwrap();
|
||||
let mut stream = TcpStream::connect(endpoint).unwrap();
|
||||
write_transport_frame(&mut stream, &request, FRAME).unwrap();
|
||||
let reply = read_transport_frame(&mut stream, FRAME).unwrap();
|
||||
let CultNetMessage::OperationResponse {
|
||||
message_id, status, ..
|
||||
} = decode_cultnet_message_from_slice(&reply, CultNetWireContract::CultNetSchemaV0)
|
||||
.unwrap()
|
||||
else {
|
||||
panic!("a reply is an operation response");
|
||||
};
|
||||
let envelope = crate::envelope::decode_result_frame(&reply).unwrap();
|
||||
let result = crate::envelope::decrypt_result(&envelope, &security, invocation).unwrap();
|
||||
(message_id, status, result)
|
||||
};
|
||||
|
||||
let transported = fresh("request-echo-transported");
|
||||
let (message_id, status, result) = send(&transported);
|
||||
assert_eq!(
|
||||
(
|
||||
message_id.as_str(),
|
||||
status.as_str(),
|
||||
result.request_id.as_str()
|
||||
),
|
||||
("request-echo-transported", "ok", "request-echo-transported")
|
||||
);
|
||||
assert!(matches!(
|
||||
result.disposition,
|
||||
TransportDisposition::Transported { .. }
|
||||
));
|
||||
|
||||
let mut refused = fresh("request-echo-refused");
|
||||
refused.request.model = "unlisted-model".to_string();
|
||||
refused.provider_request_sha256 =
|
||||
crate::contract::provider_request_sha256(&refused.request).unwrap();
|
||||
let (message_id, status, result) = send(&refused);
|
||||
assert_eq!(
|
||||
(
|
||||
message_id.as_str(),
|
||||
status.as_str(),
|
||||
result.request_id.as_str()
|
||||
),
|
||||
("request-echo-refused", "ok", "request-echo-refused")
|
||||
);
|
||||
assert_eq!(
|
||||
result.disposition,
|
||||
TransportDisposition::Refused(Refusal::ModelNotAllowed)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -7,13 +7,13 @@ use sha2::{Digest, Sha256};
|
|||
use zeroize::Zeroizing;
|
||||
|
||||
use crate::contract::{
|
||||
ContractError, RECEIPT_SCHEMA_ID, RESULT_SCHEMA_ID, Refusal, TransportDisposition,
|
||||
ContractError, FailureKind, RECEIPT_SCHEMA_ID, RESULT_SCHEMA_ID, Refusal, TransportDisposition,
|
||||
TransportInvocation, TransportKey, TransportOutcome, TransportReceipt, TransportResult,
|
||||
canonical_invocation_bytes, require_id,
|
||||
};
|
||||
use crate::envelope::{
|
||||
EnvelopeKind, MAX_IDENTITY_BYTES, ServiceError, TransportEnvelope, decrypt_invocation,
|
||||
encrypt_result, validate_envelope,
|
||||
encode_result_frame, encrypt_result, validate_envelope,
|
||||
};
|
||||
|
||||
pub struct CallerAdmission {
|
||||
|
|
@ -90,7 +90,7 @@ enum ReplayState {
|
|||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq, DatabaseEntry)]
|
||||
#[cultcache(type = "gamecult.codex.replay_record.v1")]
|
||||
#[cultcache(type = "gamecult.model.replay_record.v1")]
|
||||
struct ReplayRecord {
|
||||
#[cultcache(key = 0)]
|
||||
caller_runtime_id: String,
|
||||
|
|
@ -159,9 +159,12 @@ impl TransportService {
|
|||
replay_store
|
||||
.register_entry_type::<ReplayRecord>()
|
||||
.map_err(replay_store_error)?;
|
||||
replay_store.add_generic_backing_store(
|
||||
OwnedRedbMessagePackBackingStore::new(replay_store_path).map_err(replay_store_error)?,
|
||||
);
|
||||
replay_store
|
||||
.add_generic_backing_store(
|
||||
OwnedRedbMessagePackBackingStore::new(replay_store_path)
|
||||
.map_err(replay_store_error)?,
|
||||
)
|
||||
.map_err(replay_store_error)?;
|
||||
replay_store
|
||||
.pull_all_backing_stores()
|
||||
.map_err(replay_store_error)?;
|
||||
|
|
@ -228,18 +231,16 @@ impl TransportService {
|
|||
};
|
||||
}
|
||||
if envelope.ciphertext.len() > caller.max_payload_bytes {
|
||||
return self.reply_refusal(&invocation, Refusal::Policy, &security);
|
||||
return self.reply_refusal(&invocation, Refusal::PayloadBound, &security);
|
||||
}
|
||||
if let Err(error) = invocation.validate(now_unix_ms, self.max_expiry_skew_ms) {
|
||||
return self.reply_refusal(&invocation, contract_refusal(&error), &security);
|
||||
}
|
||||
if !caller.allowed_models.contains(&invocation.request.model)
|
||||
|| invocation
|
||||
.request
|
||||
.max_output_tokens
|
||||
.is_some_and(|limit| limit > caller.max_output_tokens)
|
||||
{
|
||||
return self.reply_refusal(&invocation, Refusal::Policy, &security);
|
||||
if !caller.allowed_models.contains(&invocation.request.model) {
|
||||
return self.reply_refusal(&invocation, Refusal::ModelNotAllowed, &security);
|
||||
}
|
||||
if invocation.request.max_output_tokens > caller.max_output_tokens {
|
||||
return self.reply_refusal(&invocation, Refusal::OutputBound, &security);
|
||||
}
|
||||
|
||||
let identity = RequestIdentity {
|
||||
|
|
@ -312,8 +313,8 @@ impl TransportService {
|
|||
})))
|
||||
}
|
||||
|
||||
/// Records a result and seals it. `max_frame_bytes` bounds the serialized
|
||||
/// envelope, which is what a frame carries: a result whose sealed envelope
|
||||
/// Records a result and seals it. `max_frame_bytes` bounds the frame the
|
||||
/// sealed envelope travels in (its carriage): a result whose sealed envelope
|
||||
/// does not fit is recorded as the typed `result_size` failure instead, so
|
||||
/// no completed record is ever undeliverable and a retry replays bytes
|
||||
/// that fit.
|
||||
|
|
@ -423,13 +424,11 @@ impl TransportService {
|
|||
}
|
||||
|
||||
fn fits_frame(envelope: &TransportEnvelope, max_frame_bytes: usize) -> Result<bool, ServiceError> {
|
||||
rmp_serde::to_vec(envelope)
|
||||
.map(|bytes| bytes.len() <= max_frame_bytes)
|
||||
.map_err(|_| ServiceError::Encoding)
|
||||
encode_result_frame(envelope).map(|bytes| bytes.len() <= max_frame_bytes)
|
||||
}
|
||||
|
||||
/// The transport named on the stand-in failure, fixed so its size is bounded.
|
||||
const STAND_IN_TRANSPORT: &str = "model-connector";
|
||||
/// The provider named on the stand-in failure, fixed so its size is bounded.
|
||||
const STAND_IN_PROVIDER: &str = "model-connector";
|
||||
|
||||
/// The failed result that stands in for one whose sealed frame cannot be
|
||||
/// carried: the same shape a provider failure takes, with nothing a provider
|
||||
|
|
@ -454,9 +453,9 @@ pub(crate) fn result_size_failure(
|
|||
native_request_sha256,
|
||||
provider_request_sha256,
|
||||
model: model.to_string(),
|
||||
transport: STAND_IN_TRANSPORT.to_string(),
|
||||
provider: STAND_IN_PROVIDER.to_string(),
|
||||
outcome: TransportOutcome::Failed {
|
||||
failure_kind: "result_size".to_string(),
|
||||
kind: FailureKind::ResultSize,
|
||||
message: "provider result exceeded its byte bound".to_string(),
|
||||
},
|
||||
}),
|
||||
|
|
@ -760,7 +759,7 @@ mod tests {
|
|||
&invocation.caller_runtime_id,
|
||||
invocation.request_id(),
|
||||
EnvelopeKind::Invocation,
|
||||
&rmp_serde::to_vec(invocation).unwrap(),
|
||||
&crate::contract::wire_encode(invocation).unwrap(),
|
||||
&security(KEY),
|
||||
)
|
||||
.unwrap()
|
||||
|
|
@ -775,25 +774,26 @@ mod tests {
|
|||
}
|
||||
|
||||
#[test]
|
||||
fn policy_refuses_unlisted_models_and_output_tokens_over_the_caller_cap() {
|
||||
fn an_unlisted_model_is_refused_model_not_allowed() {
|
||||
let store = TestStore::new();
|
||||
let mut service = open_service(&store, 8);
|
||||
|
||||
let (admission, invocation) = run(&mut service, "unlisted-model", |request| {
|
||||
request.model = "other-model".to_string();
|
||||
});
|
||||
assert_eq!(refusal_of(admission, &invocation), Refusal::Policy);
|
||||
assert_eq!(refusal_of(admission, &invocation), Refusal::ModelNotAllowed);
|
||||
let (admission, _) = run(&mut service, "listed-model", |_| {});
|
||||
assert!(matches!(admission, TransportAdmission::Execute(_)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn output_tokens_over_the_caller_cap_are_refused_output_bound_inclusively() {
|
||||
let store = TestStore::new();
|
||||
let mut service = open_service(&store, 8);
|
||||
let (admission, invocation) = run(&mut service, "over-cap", |request| {
|
||||
request.max_output_tokens = Some(32_769);
|
||||
request.max_output_tokens = 32_769;
|
||||
});
|
||||
assert_eq!(refusal_of(admission, &invocation), Refusal::Policy);
|
||||
|
||||
for (id, tokens) in [
|
||||
("at-cap", Some(32_768)),
|
||||
("under-cap", Some(100)),
|
||||
("unset", None),
|
||||
] {
|
||||
assert_eq!(refusal_of(admission, &invocation), Refusal::OutputBound);
|
||||
for (id, tokens) in [("at-cap", 32_768), ("under-cap", 100)] {
|
||||
let (admission, _) = run(&mut service, id, |request| {
|
||||
request.max_output_tokens = tokens;
|
||||
});
|
||||
|
|
@ -824,7 +824,7 @@ mod tests {
|
|||
};
|
||||
assert!(matches!(admit(size), Ok(TransportAdmission::Execute(_))));
|
||||
let refused = admit(size - 1).unwrap();
|
||||
assert_eq!(refusal_of(refused, &invocation), Refusal::Policy);
|
||||
assert_eq!(refusal_of(refused, &invocation), Refusal::PayloadBound);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
|
@ -869,7 +869,9 @@ mod tests {
|
|||
edit(&mut record);
|
||||
let mut cache = CultCache::new();
|
||||
cache.register_entry_type::<ReplayRecord>().unwrap();
|
||||
cache.add_generic_backing_store(OwnedRedbMessagePackBackingStore::new(path).unwrap());
|
||||
cache
|
||||
.add_generic_backing_store(OwnedRedbMessagePackBackingStore::new(path).unwrap())
|
||||
.unwrap();
|
||||
cache.pull_all_backing_stores().unwrap();
|
||||
cache.put(key.to_string(), &record).unwrap();
|
||||
}
|
||||
|
|
@ -1003,7 +1005,8 @@ mod tests {
|
|||
};
|
||||
assert_eq!(reply.request_id, id, "{length}");
|
||||
let result: TransportResult =
|
||||
rmp_serde::from_slice(&decrypt_message(&reply, &security(KEY)).unwrap()).unwrap();
|
||||
crate::contract::wire_decode(&decrypt_message(&reply, &security(KEY)).unwrap())
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
result.disposition,
|
||||
TransportDisposition::Refused(Refusal::Malformed)
|
||||
|
|
@ -1024,12 +1027,12 @@ mod tests {
|
|||
&security(KEY),
|
||||
)
|
||||
.unwrap();
|
||||
// The sealed size varies with the random nonce and ciphertext bytes
|
||||
// (a byte of 128 or more takes two), so bracket it: the ciphertext
|
||||
// plus the id is a floor, twice the ciphertext plus the id and the
|
||||
// envelope's other fields is a ceiling.
|
||||
// The frame carries the id twice (the message id and the envelope's
|
||||
// request id) and the base64 of the envelope, so bracket it: the
|
||||
// ciphertext plus the id is a floor, twice the ciphertext and both
|
||||
// ids plus the envelope's other fields is a ceiling.
|
||||
let floor = sealed.ciphertext.len() + id.len();
|
||||
let ceiling = 2 * sealed.ciphertext.len() + id.len() + 512;
|
||||
let ceiling = 2 * (sealed.ciphertext.len() + 2 * id.len()) + 512;
|
||||
let store = TestStore::new();
|
||||
let mut service = open_service(&store, 1);
|
||||
assert!(matches!(
|
||||
|
|
@ -1049,7 +1052,7 @@ mod tests {
|
|||
fn a_frame_check_is_inclusive_of_the_serialized_envelope() {
|
||||
let envelope =
|
||||
encrypt_invocation(&invocation_for(CALLER, "sized"), &security(KEY)).unwrap();
|
||||
let size = rmp_serde::to_vec(&envelope).unwrap().len();
|
||||
let size = encode_result_frame(&envelope).unwrap().len();
|
||||
assert_eq!(fits_frame(&envelope, size), Ok(true));
|
||||
assert_eq!(fits_frame(&envelope, size - 1), Ok(false));
|
||||
}
|
||||
|
|
@ -1074,7 +1077,7 @@ mod tests {
|
|||
transport: &str,
|
||||
) -> TransportResult {
|
||||
let mut receipt = receipt(invocation);
|
||||
receipt.transport = transport.to_string();
|
||||
receipt.provider = transport.to_string();
|
||||
TransportResult::transported(
|
||||
invocation,
|
||||
vec![TransportEvent {
|
||||
|
|
@ -1094,7 +1097,7 @@ mod tests {
|
|||
let (claim, invocation) = claim_for(&mut service, "stand-in");
|
||||
let big = text_result(&invocation, &"y".repeat(4000), &"t".repeat(5000));
|
||||
let response = service.complete(*claim, big, 2000).unwrap();
|
||||
assert!(rmp_serde::to_vec(&response).unwrap().len() <= 2000);
|
||||
assert!(encode_result_frame(&response).unwrap().len() <= 2000);
|
||||
let stand_in = decrypt_result(&response, &security(KEY), &invocation).unwrap();
|
||||
assert_eq!(
|
||||
stand_in,
|
||||
|
|
@ -1109,7 +1112,7 @@ mod tests {
|
|||
let TransportDisposition::Transported { receipt, .. } = &stand_in.disposition else {
|
||||
panic!("expected a transported failure");
|
||||
};
|
||||
assert_eq!(receipt.transport, "model-connector");
|
||||
assert_eq!(receipt.provider, "model-connector");
|
||||
|
||||
// The recorded replay is the stand-in's exact bytes.
|
||||
let TransportAdmission::Reply(replay) = service
|
||||
|
|
@ -1168,7 +1171,7 @@ mod tests {
|
|||
let (claim, invocation) = claim_for(&mut service, "restart");
|
||||
let big = text_result(&invocation, &"y".repeat(4000), "fixture-sse");
|
||||
let recorded = service.complete(*claim, big, FRAME).unwrap();
|
||||
assert!(rmp_serde::to_vec(&recorded).unwrap().len() > 2000);
|
||||
assert!(encode_result_frame(&recorded).unwrap().len() > 2000);
|
||||
drop(service);
|
||||
|
||||
let mut service = open_service(&store, 8);
|
||||
|
|
@ -1188,7 +1191,7 @@ mod tests {
|
|||
else {
|
||||
panic!("expected a replay");
|
||||
};
|
||||
assert!(rmp_serde::to_vec(&smaller).unwrap().len() <= 2000);
|
||||
assert!(encode_result_frame(&smaller).unwrap().len() <= 2000);
|
||||
assert_eq!(
|
||||
decrypt_result(&smaller, &security(KEY), &invocation).unwrap(),
|
||||
result_size_failure(
|
||||
|
|
@ -1247,4 +1250,25 @@ mod tests {
|
|||
TransportDisposition::Refused(Refusal::Indeterminate)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn contract_refusal_maps_each_error() {
|
||||
assert_eq!(contract_refusal(&ContractError::Expiry), Refusal::Expired);
|
||||
assert_eq!(
|
||||
contract_refusal(&ContractError::Identity),
|
||||
Refusal::IdentitySubstitution
|
||||
);
|
||||
assert_eq!(
|
||||
contract_refusal(&ContractError::ProviderDigest),
|
||||
Refusal::ProviderDigestSubstitution
|
||||
);
|
||||
for other in [
|
||||
ContractError::Schema,
|
||||
ContractError::Invalid("model"),
|
||||
ContractError::NativeDigest,
|
||||
ContractError::EventSequence,
|
||||
] {
|
||||
assert_eq!(contract_refusal(&other), Refusal::Malformed, "{other:?}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,2 +0,0 @@
|
|||
*.mp binary
|
||||
*.bin binary
|
||||
|
|
@ -1,21 +0,0 @@
|
|||
# Golden v2 wire fixtures
|
||||
|
||||
Bytes of the v2 connector wire, produced once by CodexConnector at commit
|
||||
`fd7cec4176f4e3f9a5aaa75e77ba10ce5e1522f8`. Ghostlight pins CodexConnector
|
||||
`68fe94b`, not that commit; `src/lib.rs`, the whole v2 wire, is identical in
|
||||
both, so these bytes are Ghostlight's wire. They are pinned by `tests/wire_golden_v2.rs`, so the kit cannot drift from
|
||||
them. No CodexConnector code or dependency is part of this repository.
|
||||
|
||||
Generator: `generator.rs` (not compiled here). Command, run in a clone of
|
||||
CodexConnector at that commit:
|
||||
|
||||
cp <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.
|
||||
Binary file not shown.
Binary file not shown.
|
|
@ -1,6 +0,0 @@
|
|||
栙$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屉
|
||||
|
|
@ -1,7 +0,0 @@
|
|||
–Ù$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.Ẁ̧&Ìå̶̑[
|
||||
Binary file not shown.
Binary file not shown.
|
|
@ -1,171 +0,0 @@
|
|||
//! Generator of the golden v2 wire fixtures beside it. It is not compiled by this
|
||||
//! workspace: it was run once, as an example of CodexConnector at commit
|
||||
//! fd7cec4176f4e3f9a5aaa75e77ba10ce5e1522f8 (see README.md here for the command).
|
||||
//! It prints one `FIXTURE <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()),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
|
|
@ -1 +0,0 @@
|
|||
”Ù"gamecult.codex.transport_result.v2©request-1parity-caller<65>§Refused¨Capacity
|
||||
|
|
@ -1 +0,0 @@
|
|||
”Ù"gamecult.codex.transport_result.v2©request-1parity-caller<65>§Refused§Expired
|
||||
|
|
@ -1 +0,0 @@
|
|||
”Ù"gamecult.codex.transport_result.v2©request-1parity-caller<65>§Refused´IdentitySubstitution
|
||||
|
|
@ -1 +0,0 @@
|
|||
”Ù"gamecult.codex.transport_result.v2©request-1parity-caller<65>§Refused¨InFlight
|
||||
|
|
@ -1 +0,0 @@
|
|||
”Ù"gamecult.codex.transport_result.v2©request-1parity-caller<65>§RefusedIndeterminate
|
||||
|
|
@ -1 +0,0 @@
|
|||
”Ù"gamecult.codex.transport_result.v2©request-1parity-caller<65>§Refused©Malformed
|
||||
|
|
@ -1 +0,0 @@
|
|||
”Ù"gamecult.codex.transport_result.v2©request-1parity-caller<65>§Refused¦Policy
|
||||
|
|
@ -1 +0,0 @@
|
|||
”Ù"gamecult.codex.transport_result.v2©request-1parity-caller<65>§RefusedºProviderDigestSubstitution
|
||||
|
|
@ -1 +0,0 @@
|
|||
”Ù"gamecult.codex.transport_result.v2©request-1parity-caller<65>§Refused®ReplayConflict
|
||||
Binary file not shown.
15
crates/model-connector/tests/fixtures/wire_v3/README.md
vendored
Normal file
15
crates/model-connector/tests/fixtures/wire_v3/README.md
vendored
Normal file
|
|
@ -0,0 +1,15 @@
|
|||
# wire_v3 fixtures
|
||||
|
||||
The named-MessagePack bytes of the v3 documents and one sealed carriage frame,
|
||||
produced by the kit at the revision that added them. They pin drift; the types in
|
||||
`src/contract.rs` own the shape, and `schemas/` publishes it.
|
||||
|
||||
- `provider_request.mp`, `transport_invocation.mp`, `transport_result_transported.mp`,
|
||||
`transport_result_refused.mp`, `transport_receipt_failed.mp`, `transport_envelope.mp`:
|
||||
the plaintext documents.
|
||||
- `carriage_invocation.frame`: a `cultnet.operation_request.v0` for service
|
||||
`gamecult.model.connector`, operation `invoke`, whose payload is the base64 of an envelope
|
||||
sealed under the fixed test key in `tests/wire_golden_v3.rs`.
|
||||
|
||||
Generator: `tests/wire_golden_v3.rs` itself. Run it with `WIRE_V3_REGENERATE=1` to rewrite
|
||||
the files from the kit, then review the diff: a changed byte is a wire change.
|
||||
1
crates/model-connector/tests/fixtures/wire_v3/carriage_invocation.frame
vendored
Normal file
1
crates/model-connector/tests/fixtures/wire_v3/carriage_invocation.frame
vendored
Normal file
|
|
@ -0,0 +1 @@
|
|||
<EFBFBD>messageIdョgolden-requestゥoperationヲinvokeァpayloadレ$hqlzY2hlbWFfaWTZJGdhbWVjdWx0Lm1vZGVsLnRyYW5zcG9ydF9lbnZlbG9wZS52M7FjYWxsZXJfcnVudGltZV9pZK1nb2xkZW4tY2FsbGVyqnJlcXVlc3RfaWSuZ29sZGVuLXJlcXVlc3SsbWVzc2FnZV9raW5kqkludm9jYXRpb26lbm9uY2WcS2XMwjgRzMrM9QTM037MsczmqmNpcGhlcnRleHTFAy8eYmQGV5JCOLETwqQE+wIz/6yyPltHTPywbakMD/mgkJUxAvKWLNljXa4RAVVITAb3GxklnGF8zoOX/9qGSM9r0JZ126s+0LqvsRNhllG1S0pfG49fQJy99pevHReOSvX/cNWs2cHppYNdJUbVVAkDuioEXwtEs/vRCsmD/ab0bKUX0XRzrNbmjMU4+dHUCus8yt+iNTK9GdjXbEQAweeKNehirXEgnngLnwylo/MDoI4G237du3PmwhMzyIHztoIbwD0Bv9x7WqY+jj90vqEBsOHl+FmYc+F8+OiP6NKTDNRHypUtdo32G/IgfMfbsP/XHiNpPHtLxDqZVIcYdYqLAvhDfLBReLzQVLuNJnE0a2P8xLVAkfbmQZNsxRqS14RjYVtRoDcacxH1h0afo9OWMSK8Sp8eEBAhAJ7ovhCMklc7xWB3o4ny4cmDSExx5zWRLdI14rC1CZ7nq8k3V8fIIHctSp6am35A5yyiTwDH0m3h7/hvXrzhOqtAwHd0jkfNv+qOXZCEPdtbzOXOz0fMVZwvTAJWhoEYmhfKcGvXu8SmEtZcZe0WjF4xpFIFCFsSxh8HcrDFvxKDa58bnimo6DE2VeEMolpaBvnIGEst6qNZ9VQQnQIpCGbbtFd7jMrncnfcq+36NI10qW30r8sqAqgORp8cAGP5fy2X9SQDmH13SXgSfIdr7uxAHhK0X1r0iopJn4XtVfBiTd+IfoLFN+KO/JTZvLc48e6Jl9QRnxkL/UCgT/5wENd9VRxP+oO1CXG3DthI882rVbZDkhhwxupM9J/EURONajVyVSkeju+r9vzHCgR5odRW8cwqY1RXoq6L6mhj4ph01MBkUugz8MFwBsem+8QpnGatyVbZB99knWjK/01o5J6SZihcXNowRdon6BTydFByJaV5bRvgIK7IGRct3BmC8xidyxN9p7QAZYMPscxhQ0hAM615w76Cy82kx6eAzREBQX3COKApXSlPA/xlOKa/iUUMWHvFKyf/n0KrOlVvHjoDzPflHi3hpxxWcQGt8CxxNdSQMufeqce/5/BCeEwRIJ99+k7CEqIqpFIs7cAsI1GpF+NO5w==ッpayloadEncodingイmessagepack-base64ュpayloadSchemaル$gamecult.model.transport_envelope.v3ュschemaVersionシcultnet.operation_request.v0ゥserviceIdクgamecult.model.connector
|
||||
BIN
crates/model-connector/tests/fixtures/wire_v3/provider_request.mp
vendored
Normal file
BIN
crates/model-connector/tests/fixtures/wire_v3/provider_request.mp
vendored
Normal file
Binary file not shown.
BIN
crates/model-connector/tests/fixtures/wire_v3/transport_envelope.mp
vendored
Normal file
BIN
crates/model-connector/tests/fixtures/wire_v3/transport_envelope.mp
vendored
Normal file
Binary file not shown.
BIN
crates/model-connector/tests/fixtures/wire_v3/transport_invocation.mp
vendored
Normal file
BIN
crates/model-connector/tests/fixtures/wire_v3/transport_invocation.mp
vendored
Normal file
Binary file not shown.
BIN
crates/model-connector/tests/fixtures/wire_v3/transport_receipt_failed.mp
vendored
Normal file
BIN
crates/model-connector/tests/fixtures/wire_v3/transport_receipt_failed.mp
vendored
Normal file
Binary file not shown.
1
crates/model-connector/tests/fixtures/wire_v3/transport_result_refused.mp
vendored
Normal file
1
crates/model-connector/tests/fixtures/wire_v3/transport_result_refused.mp
vendored
Normal file
|
|
@ -0,0 +1 @@
|
|||
„©schema_idÙ"gamecult.model.transport_result.v3ªrequest_id®golden-request±caller_runtime_idgolden-caller«disposition<6F>§Refused«OutputBound
|
||||
BIN
crates/model-connector/tests/fixtures/wire_v3/transport_result_transported.mp
vendored
Normal file
BIN
crates/model-connector/tests/fixtures/wire_v3/transport_result_transported.mp
vendored
Normal file
Binary file not shown.
100
crates/model-connector/tests/published_schemas.rs
Normal file
100
crates/model-connector/tests/published_schemas.rs
Normal file
|
|
@ -0,0 +1,100 @@
|
|||
//! `schemas/` at the repository root publishes one JSON Schema per v3 document,
|
||||
//! generated from the types. This test regenerates them and fails on any
|
||||
//! difference; run it with `MODEL_CONNECTOR_REGENERATE_SCHEMAS=1` to rewrite the
|
||||
//! files from the types.
|
||||
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use model_connector::*;
|
||||
use schemars::{JsonSchema, schema_for};
|
||||
|
||||
const REGENERATE: &str = "MODEL_CONNECTOR_REGENERATE_SCHEMAS";
|
||||
|
||||
fn render<T: JsonSchema>() -> String {
|
||||
let mut text = serde_json::to_string_pretty(&schema_for!(T)).unwrap();
|
||||
text.push('\n');
|
||||
text
|
||||
}
|
||||
|
||||
fn schemas_dir() -> PathBuf {
|
||||
Path::new(env!("CARGO_MANIFEST_DIR")).join("../../schemas")
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn published_schemas_are_the_types() {
|
||||
let documents = [
|
||||
("provider_request", render::<ProviderRequest>()),
|
||||
("transport_invocation", render::<TransportInvocation>()),
|
||||
("transport_result", render::<TransportResult>()),
|
||||
("transport_receipt", render::<TransportReceipt>()),
|
||||
("transport_envelope", render::<TransportEnvelope>()),
|
||||
];
|
||||
let dir = schemas_dir();
|
||||
if std::env::var_os(REGENERATE).is_some() {
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
for (name, text) in &documents {
|
||||
std::fs::write(dir.join(format!("{name}.json")), text).unwrap();
|
||||
}
|
||||
}
|
||||
for (name, text) in &documents {
|
||||
let committed = std::fs::read_to_string(dir.join(format!("{name}.json")))
|
||||
.unwrap_or_else(|_| panic!("schemas/{name}.json is missing"));
|
||||
assert_eq!(&committed, text, "schemas/{name}.json is not the type's");
|
||||
}
|
||||
let mut on_disk: Vec<String> = std::fs::read_dir(&dir)
|
||||
.unwrap()
|
||||
.map(|entry| entry.unwrap().file_name().into_string().unwrap())
|
||||
.collect();
|
||||
on_disk.sort();
|
||||
let mut expected: Vec<String> = documents
|
||||
.iter()
|
||||
.map(|(name, _)| format!("{name}.json"))
|
||||
.collect();
|
||||
expected.sort();
|
||||
assert_eq!(
|
||||
on_disk, expected,
|
||||
"schemas/ holds exactly the five documents"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_published_schema_refuses_an_unknown_field() {
|
||||
let schema = serde_json::to_value(schema_for!(ProviderRequest)).unwrap();
|
||||
assert_eq!(schema["additionalProperties"], serde_json::json!(false));
|
||||
let required = schema["required"].as_array().unwrap();
|
||||
assert!(required.contains(&serde_json::json!("max_output_tokens")));
|
||||
}
|
||||
|
||||
/// The schema and the wire agree on the ciphertext: the schema says binary, not
|
||||
/// an array of integers, and the encoder writes MessagePack `bin`.
|
||||
#[test]
|
||||
fn the_published_ciphertext_is_what_the_wire_sends() {
|
||||
let schema = serde_json::to_value(schema_for!(TransportEnvelope)).unwrap();
|
||||
let ciphertext = &schema["properties"]["ciphertext"];
|
||||
assert_eq!(ciphertext["type"], "string");
|
||||
assert_eq!(ciphertext["format"], "binary");
|
||||
assert!(ciphertext.get("items").is_none());
|
||||
let published = std::fs::read_to_string(schemas_dir().join("transport_envelope.json")).unwrap();
|
||||
let published: serde_json::Value = serde_json::from_str(&published).unwrap();
|
||||
assert_eq!(&published["properties"]["ciphertext"], ciphertext);
|
||||
|
||||
let envelope = TransportEnvelope {
|
||||
schema_id: "gamecult.model.transport_envelope.v3".to_string(),
|
||||
caller_runtime_id: "caller".to_string(),
|
||||
request_id: "request".to_string(),
|
||||
message_kind: EnvelopeKind::Invocation,
|
||||
nonce: [1; 12],
|
||||
ciphertext: vec![9; 40],
|
||||
};
|
||||
let sent =
|
||||
rmpv::decode::read_value(&mut rmp_serde::to_vec_named(&envelope).unwrap().as_slice())
|
||||
.unwrap();
|
||||
let rmpv::Value::Map(entries) = sent else {
|
||||
panic!("the envelope is a named map");
|
||||
};
|
||||
let (_, wire) = entries
|
||||
.iter()
|
||||
.find(|(key, _)| key.as_str() == Some("ciphertext"))
|
||||
.unwrap();
|
||||
assert!(matches!(wire, rmpv::Value::Binary(bytes) if bytes.len() == 40));
|
||||
}
|
||||
|
|
@ -1,301 +0,0 @@
|
|||
//! The kit speaks the v2 wire of CodexConnector fd7cec4, the library Ghostlight pins.
|
||||
//! The golden bytes in `fixtures/wire_v2` were produced by that library (see its README);
|
||||
//! nothing of it is linked here. Deleted with the fixtures by cut connector-contract-v3,
|
||||
//! which changes the wire.
|
||||
|
||||
use model_connector as kit;
|
||||
use serde::Serialize;
|
||||
use serde::de::DeserializeOwned;
|
||||
|
||||
const KEY: &str = "parity-distinct-test-key";
|
||||
const CALLER: &str = "parity-caller";
|
||||
|
||||
macro_rules! golden {
|
||||
($name:literal) => {
|
||||
include_bytes!(concat!("fixtures/wire_v2/", $name)) as &[u8]
|
||||
};
|
||||
}
|
||||
|
||||
fn bytes<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());
|
||||
}
|
||||
214
crates/model-connector/tests/wire_golden_v3.rs
Normal file
214
crates/model-connector/tests/wire_golden_v3.rs
Normal file
|
|
@ -0,0 +1,214 @@
|
|||
//! Pins the bytes of the v3 wire so a drift is a failing test. The fixtures in
|
||||
//! `fixtures/wire_v3` are produced by this file (see its README); they pin the
|
||||
//! kit against itself and are not a second contract: the types in
|
||||
//! `model_connector::contract` own the shape.
|
||||
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use base64::Engine;
|
||||
use base64::engine::general_purpose::STANDARD;
|
||||
use cultnet_rs::{
|
||||
CultNetMessage, CultNetWireContract, decode_cultnet_message_from_slice,
|
||||
encode_cultnet_message_to_vec,
|
||||
};
|
||||
use model_connector::*;
|
||||
use serde::Serialize;
|
||||
use serde::de::DeserializeOwned;
|
||||
|
||||
const KEY: &str = "golden-distinct-test-key";
|
||||
const CALLER: &str = "golden-caller";
|
||||
const REGENERATE: &str = "WIRE_V3_REGENERATE";
|
||||
|
||||
fn fixtures() -> PathBuf {
|
||||
Path::new(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures/wire_v3")
|
||||
}
|
||||
|
||||
fn named<T: Serialize>(value: &T) -> Vec<u8> {
|
||||
rmp_serde::to_vec_named(value).unwrap()
|
||||
}
|
||||
|
||||
/// The value serializes to the committed bytes and the committed bytes decode
|
||||
/// back to it. With `WIRE_V3_REGENERATE` set, the committed bytes are rewritten
|
||||
/// from the value first.
|
||||
fn assert_wire<T: Serialize + DeserializeOwned + PartialEq + std::fmt::Debug>(
|
||||
name: &str,
|
||||
value: &T,
|
||||
) {
|
||||
let path = fixtures().join(name);
|
||||
if std::env::var_os(REGENERATE).is_some() {
|
||||
std::fs::create_dir_all(fixtures()).unwrap();
|
||||
std::fs::write(&path, named(value)).unwrap();
|
||||
}
|
||||
let golden = std::fs::read(&path).unwrap_or_else(|_| panic!("{name}: fixture is missing"));
|
||||
assert_eq!(named(value), golden, "{name}: serialized bytes");
|
||||
let decoded: T = rmp_serde::from_slice(&golden).unwrap();
|
||||
assert_eq!(&decoded, value, "{name}: decoded value");
|
||||
}
|
||||
|
||||
fn request() -> ProviderRequest {
|
||||
let mut request = ProviderRequest::new(
|
||||
"golden-request",
|
||||
"golden-conversation",
|
||||
"claude-opus-4-5",
|
||||
"Return the typed result.",
|
||||
8_192,
|
||||
);
|
||||
request.input = vec![
|
||||
InputItem::UserText {
|
||||
text: "Projected state".to_string(),
|
||||
},
|
||||
InputItem::ToolCall {
|
||||
call_id: "call-1".to_string(),
|
||||
name: "read_source".to_string(),
|
||||
arguments: "{}".to_string(),
|
||||
},
|
||||
InputItem::ToolResult {
|
||||
call_id: "call-1".to_string(),
|
||||
output: "evidence".to_string(),
|
||||
},
|
||||
];
|
||||
request.reasoning_effort = Some(ReasoningEffort::High);
|
||||
request.cache_retention = Some(CacheRetention::Short);
|
||||
request.tools = vec![ToolDefinition {
|
||||
name: "read_source".to_string(),
|
||||
description: "Read one admitted source.".to_string(),
|
||||
parameters_json: r#"{"type":"object"}"#.to_string(),
|
||||
}];
|
||||
request.tool_choice = ToolChoice::Required;
|
||||
request.parallel_tool_calls = true;
|
||||
request.output_schema_json = Some(r#"{"type":"object"}"#.to_string());
|
||||
request
|
||||
}
|
||||
|
||||
fn invocation() -> TransportInvocation {
|
||||
TransportInvocation::new(CALLER, 1_800_000_000_000, [7; 32], request()).unwrap()
|
||||
}
|
||||
|
||||
fn receipt(outcome: TransportOutcome) -> TransportReceipt {
|
||||
let invocation = invocation();
|
||||
TransportReceipt {
|
||||
schema_id: RECEIPT_SCHEMA_ID.to_string(),
|
||||
request_id: invocation.request_id().to_string(),
|
||||
caller_runtime_id: CALLER.to_string(),
|
||||
native_request_sha256: invocation.native_request_sha256,
|
||||
provider_request_sha256: invocation.provider_request_sha256,
|
||||
model: invocation.request.model.clone(),
|
||||
provider: "anthropic-messages".to_string(),
|
||||
outcome,
|
||||
}
|
||||
}
|
||||
|
||||
fn completed() -> TransportOutcome {
|
||||
TransportOutcome::Completed {
|
||||
provider_response_id: Some("msg-1".to_string()),
|
||||
stop_reason: StopReason::ToolUse,
|
||||
usage: Usage {
|
||||
input_tokens: 120,
|
||||
cache_read_tokens: 40,
|
||||
cache_write_tokens: 8,
|
||||
output_tokens: 33,
|
||||
reasoning_tokens: Some(12),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn wire_golden_v3() {
|
||||
let invocation = invocation();
|
||||
assert_wire("provider_request.mp", &invocation.request);
|
||||
assert_wire("transport_invocation.mp", &invocation);
|
||||
|
||||
let transported = TransportResult::transported(
|
||||
&invocation,
|
||||
vec![
|
||||
TransportEvent {
|
||||
sequence: 0,
|
||||
payload: TransportEventPayload::TextDelta {
|
||||
text: "thinking".to_string(),
|
||||
},
|
||||
},
|
||||
TransportEvent {
|
||||
sequence: 1,
|
||||
payload: TransportEventPayload::ToolCall {
|
||||
call_id: "call-2".to_string(),
|
||||
name: "read_source".to_string(),
|
||||
arguments: "{}".to_string(),
|
||||
},
|
||||
},
|
||||
],
|
||||
receipt(completed()),
|
||||
);
|
||||
assert_eq!(transported.validate_against(&invocation), Ok(()));
|
||||
assert_wire("transport_result_transported.mp", &transported);
|
||||
|
||||
let refused = TransportResult::refused(&invocation, Refusal::OutputBound);
|
||||
assert_wire("transport_result_refused.mp", &refused);
|
||||
|
||||
let failed = receipt(TransportOutcome::Failed {
|
||||
kind: FailureKind::RateLimited,
|
||||
message: "provider asked to slow down".to_string(),
|
||||
});
|
||||
assert_eq!(failed.validate_against(&invocation), Ok(()));
|
||||
assert_wire("transport_receipt_failed.mp", &failed);
|
||||
|
||||
let envelope = TransportEnvelope {
|
||||
schema_id: ENVELOPE_SCHEMA_ID.to_string(),
|
||||
caller_runtime_id: CALLER.to_string(),
|
||||
request_id: "golden-request".to_string(),
|
||||
message_kind: EnvelopeKind::Invocation,
|
||||
nonce: [0x80, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 0xff],
|
||||
ciphertext: (0..40_u8).map(|byte| byte.wrapping_mul(7)).collect(),
|
||||
};
|
||||
assert_wire("transport_envelope.mp", &envelope);
|
||||
}
|
||||
|
||||
/// The committed carriage frame is a `cultnet.operation_request.v0` for the
|
||||
/// connector, read here with CultNet itself and not the kit's own carriage code.
|
||||
/// Its payload is the base64 of a sealed envelope that decrypts under the
|
||||
/// fixed key to the golden invocation.
|
||||
#[test]
|
||||
fn the_golden_carriage_frame_decrypts_and_validates_under_the_fixed_key() {
|
||||
let path = fixtures().join("carriage_invocation.frame");
|
||||
let key = TransportKey::from_connection_secret(KEY).unwrap();
|
||||
let invocation = invocation();
|
||||
if std::env::var_os(REGENERATE).is_some() {
|
||||
let envelope = encrypt_invocation(&invocation, &key).unwrap();
|
||||
let message = CultNetMessage::OperationRequest {
|
||||
message_id: envelope.request_id.clone(),
|
||||
service_id: CONNECTOR_SERVICE_ID.to_string(),
|
||||
operation: INVOKE_OPERATION.to_string(),
|
||||
payload_schema: ENVELOPE_SCHEMA_ID.to_string(),
|
||||
payload_encoding: "messagepack-base64".to_string(),
|
||||
payload: STANDARD.encode(named(&envelope)),
|
||||
source_runtime_id: None,
|
||||
target_runtime_id: None,
|
||||
};
|
||||
std::fs::write(
|
||||
&path,
|
||||
encode_cultnet_message_to_vec(&message, CultNetWireContract::CultNetSchemaV0).unwrap(),
|
||||
)
|
||||
.unwrap();
|
||||
}
|
||||
let frame = std::fs::read(&path).expect("carriage_invocation.frame is missing");
|
||||
let CultNetMessage::OperationRequest {
|
||||
message_id,
|
||||
service_id,
|
||||
operation,
|
||||
payload_schema,
|
||||
payload,
|
||||
..
|
||||
} = decode_cultnet_message_from_slice(&frame, CultNetWireContract::CultNetSchemaV0).unwrap()
|
||||
else {
|
||||
panic!("the golden frame is a CultNet operation request");
|
||||
};
|
||||
assert_eq!(service_id, "gamecult.model.connector");
|
||||
assert_eq!(operation, "invoke");
|
||||
assert_eq!(payload_schema, "gamecult.model.transport_envelope.v3");
|
||||
assert_eq!(message_id, invocation.request_id());
|
||||
let envelope: TransportEnvelope =
|
||||
rmp_serde::from_slice(&STANDARD.decode(payload).unwrap()).unwrap();
|
||||
assert_eq!(envelope.request_id, message_id);
|
||||
let decrypted = decrypt_invocation(&envelope, &key).unwrap();
|
||||
assert_eq!(decrypted, invocation);
|
||||
assert_eq!(decrypted.validate(1_799_999_999_000, 5_000), Ok(()));
|
||||
}
|
||||
229
schemas/provider_request.json
Normal file
229
schemas/provider_request.json
Normal file
|
|
@ -0,0 +1,229 @@
|
|||
{
|
||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||
"title": "ProviderRequest",
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"cache_retention": {
|
||||
"anyOf": [
|
||||
{
|
||||
"$ref": "#/$defs/CacheRetention"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
]
|
||||
},
|
||||
"conversation_id": {
|
||||
"type": "string"
|
||||
},
|
||||
"input": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"$ref": "#/$defs/InputItem"
|
||||
}
|
||||
},
|
||||
"instructions": {
|
||||
"type": "string"
|
||||
},
|
||||
"max_output_tokens": {
|
||||
"type": "integer",
|
||||
"format": "uint32",
|
||||
"minimum": 0
|
||||
},
|
||||
"model": {
|
||||
"type": "string"
|
||||
},
|
||||
"output_schema_json": {
|
||||
"type": [
|
||||
"string",
|
||||
"null"
|
||||
]
|
||||
},
|
||||
"parallel_tool_calls": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"reasoning_effort": {
|
||||
"anyOf": [
|
||||
{
|
||||
"$ref": "#/$defs/ReasoningEffort"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
]
|
||||
},
|
||||
"request_id": {
|
||||
"type": "string"
|
||||
},
|
||||
"schema_id": {
|
||||
"type": "string"
|
||||
},
|
||||
"tool_choice": {
|
||||
"$ref": "#/$defs/ToolChoice"
|
||||
},
|
||||
"tools": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"$ref": "#/$defs/ToolDefinition"
|
||||
}
|
||||
}
|
||||
},
|
||||
"additionalProperties": false,
|
||||
"required": [
|
||||
"schema_id",
|
||||
"request_id",
|
||||
"conversation_id",
|
||||
"model",
|
||||
"instructions",
|
||||
"input",
|
||||
"tools",
|
||||
"tool_choice",
|
||||
"parallel_tool_calls",
|
||||
"max_output_tokens"
|
||||
],
|
||||
"$defs": {
|
||||
"CacheRetention": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"short",
|
||||
"long"
|
||||
]
|
||||
},
|
||||
"InputItem": {
|
||||
"oneOf": [
|
||||
{
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"UserText": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"text": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"additionalProperties": false,
|
||||
"required": [
|
||||
"text"
|
||||
]
|
||||
}
|
||||
},
|
||||
"additionalProperties": false,
|
||||
"required": [
|
||||
"UserText"
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"AssistantText": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"text": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"additionalProperties": false,
|
||||
"required": [
|
||||
"text"
|
||||
]
|
||||
}
|
||||
},
|
||||
"additionalProperties": false,
|
||||
"required": [
|
||||
"AssistantText"
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"ToolCall": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"arguments": {
|
||||
"type": "string"
|
||||
},
|
||||
"call_id": {
|
||||
"type": "string"
|
||||
},
|
||||
"name": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"additionalProperties": false,
|
||||
"required": [
|
||||
"call_id",
|
||||
"name",
|
||||
"arguments"
|
||||
]
|
||||
}
|
||||
},
|
||||
"additionalProperties": false,
|
||||
"required": [
|
||||
"ToolCall"
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"ToolResult": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"call_id": {
|
||||
"type": "string"
|
||||
},
|
||||
"output": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"additionalProperties": false,
|
||||
"required": [
|
||||
"call_id",
|
||||
"output"
|
||||
]
|
||||
}
|
||||
},
|
||||
"additionalProperties": false,
|
||||
"required": [
|
||||
"ToolResult"
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
"ReasoningEffort": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"low",
|
||||
"medium",
|
||||
"high",
|
||||
"max"
|
||||
]
|
||||
},
|
||||
"ToolChoice": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"auto",
|
||||
"required"
|
||||
]
|
||||
},
|
||||
"ToolDefinition": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"description": {
|
||||
"type": "string"
|
||||
},
|
||||
"name": {
|
||||
"type": "string"
|
||||
},
|
||||
"parameters_json": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"additionalProperties": false,
|
||||
"required": [
|
||||
"name",
|
||||
"description",
|
||||
"parameters_json"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
53
schemas/transport_envelope.json
Normal file
53
schemas/transport_envelope.json
Normal file
|
|
@ -0,0 +1,53 @@
|
|||
{
|
||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||
"title": "TransportEnvelope",
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"caller_runtime_id": {
|
||||
"type": "string"
|
||||
},
|
||||
"ciphertext": {
|
||||
"description": "MessagePack bin: the raw ciphertext bytes, never an array of integers",
|
||||
"type": "string",
|
||||
"format": "binary"
|
||||
},
|
||||
"message_kind": {
|
||||
"$ref": "#/$defs/EnvelopeKind"
|
||||
},
|
||||
"nonce": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "integer",
|
||||
"format": "uint8",
|
||||
"maximum": 255,
|
||||
"minimum": 0
|
||||
},
|
||||
"maxItems": 12,
|
||||
"minItems": 12
|
||||
},
|
||||
"request_id": {
|
||||
"type": "string"
|
||||
},
|
||||
"schema_id": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"additionalProperties": false,
|
||||
"required": [
|
||||
"schema_id",
|
||||
"caller_runtime_id",
|
||||
"request_id",
|
||||
"message_kind",
|
||||
"nonce",
|
||||
"ciphertext"
|
||||
],
|
||||
"$defs": {
|
||||
"EnvelopeKind": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"Invocation",
|
||||
"Result"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
279
schemas/transport_invocation.json
Normal file
279
schemas/transport_invocation.json
Normal file
|
|
@ -0,0 +1,279 @@
|
|||
{
|
||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||
"title": "TransportInvocation",
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"caller_runtime_id": {
|
||||
"type": "string"
|
||||
},
|
||||
"expires_at_unix_ms": {
|
||||
"type": "integer",
|
||||
"format": "uint64",
|
||||
"minimum": 0
|
||||
},
|
||||
"native_request_sha256": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "integer",
|
||||
"format": "uint8",
|
||||
"maximum": 255,
|
||||
"minimum": 0
|
||||
},
|
||||
"maxItems": 32,
|
||||
"minItems": 32
|
||||
},
|
||||
"provider_request_sha256": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "integer",
|
||||
"format": "uint8",
|
||||
"maximum": 255,
|
||||
"minimum": 0
|
||||
},
|
||||
"maxItems": 32,
|
||||
"minItems": 32
|
||||
},
|
||||
"request": {
|
||||
"$ref": "#/$defs/ProviderRequest"
|
||||
},
|
||||
"schema_id": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"additionalProperties": false,
|
||||
"required": [
|
||||
"schema_id",
|
||||
"caller_runtime_id",
|
||||
"expires_at_unix_ms",
|
||||
"native_request_sha256",
|
||||
"provider_request_sha256",
|
||||
"request"
|
||||
],
|
||||
"$defs": {
|
||||
"CacheRetention": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"short",
|
||||
"long"
|
||||
]
|
||||
},
|
||||
"InputItem": {
|
||||
"oneOf": [
|
||||
{
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"UserText": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"text": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"additionalProperties": false,
|
||||
"required": [
|
||||
"text"
|
||||
]
|
||||
}
|
||||
},
|
||||
"additionalProperties": false,
|
||||
"required": [
|
||||
"UserText"
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"AssistantText": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"text": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"additionalProperties": false,
|
||||
"required": [
|
||||
"text"
|
||||
]
|
||||
}
|
||||
},
|
||||
"additionalProperties": false,
|
||||
"required": [
|
||||
"AssistantText"
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"ToolCall": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"arguments": {
|
||||
"type": "string"
|
||||
},
|
||||
"call_id": {
|
||||
"type": "string"
|
||||
},
|
||||
"name": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"additionalProperties": false,
|
||||
"required": [
|
||||
"call_id",
|
||||
"name",
|
||||
"arguments"
|
||||
]
|
||||
}
|
||||
},
|
||||
"additionalProperties": false,
|
||||
"required": [
|
||||
"ToolCall"
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"ToolResult": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"call_id": {
|
||||
"type": "string"
|
||||
},
|
||||
"output": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"additionalProperties": false,
|
||||
"required": [
|
||||
"call_id",
|
||||
"output"
|
||||
]
|
||||
}
|
||||
},
|
||||
"additionalProperties": false,
|
||||
"required": [
|
||||
"ToolResult"
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
"ProviderRequest": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"cache_retention": {
|
||||
"anyOf": [
|
||||
{
|
||||
"$ref": "#/$defs/CacheRetention"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
]
|
||||
},
|
||||
"conversation_id": {
|
||||
"type": "string"
|
||||
},
|
||||
"input": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"$ref": "#/$defs/InputItem"
|
||||
}
|
||||
},
|
||||
"instructions": {
|
||||
"type": "string"
|
||||
},
|
||||
"max_output_tokens": {
|
||||
"type": "integer",
|
||||
"format": "uint32",
|
||||
"minimum": 0
|
||||
},
|
||||
"model": {
|
||||
"type": "string"
|
||||
},
|
||||
"output_schema_json": {
|
||||
"type": [
|
||||
"string",
|
||||
"null"
|
||||
]
|
||||
},
|
||||
"parallel_tool_calls": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"reasoning_effort": {
|
||||
"anyOf": [
|
||||
{
|
||||
"$ref": "#/$defs/ReasoningEffort"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
]
|
||||
},
|
||||
"request_id": {
|
||||
"type": "string"
|
||||
},
|
||||
"schema_id": {
|
||||
"type": "string"
|
||||
},
|
||||
"tool_choice": {
|
||||
"$ref": "#/$defs/ToolChoice"
|
||||
},
|
||||
"tools": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"$ref": "#/$defs/ToolDefinition"
|
||||
}
|
||||
}
|
||||
},
|
||||
"additionalProperties": false,
|
||||
"required": [
|
||||
"schema_id",
|
||||
"request_id",
|
||||
"conversation_id",
|
||||
"model",
|
||||
"instructions",
|
||||
"input",
|
||||
"tools",
|
||||
"tool_choice",
|
||||
"parallel_tool_calls",
|
||||
"max_output_tokens"
|
||||
]
|
||||
},
|
||||
"ReasoningEffort": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"low",
|
||||
"medium",
|
||||
"high",
|
||||
"max"
|
||||
]
|
||||
},
|
||||
"ToolChoice": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"auto",
|
||||
"required"
|
||||
]
|
||||
},
|
||||
"ToolDefinition": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"description": {
|
||||
"type": "string"
|
||||
},
|
||||
"name": {
|
||||
"type": "string"
|
||||
},
|
||||
"parameters_json": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"additionalProperties": false,
|
||||
"required": [
|
||||
"name",
|
||||
"description",
|
||||
"parameters_json"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
186
schemas/transport_receipt.json
Normal file
186
schemas/transport_receipt.json
Normal file
|
|
@ -0,0 +1,186 @@
|
|||
{
|
||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||
"title": "TransportReceipt",
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"caller_runtime_id": {
|
||||
"type": "string"
|
||||
},
|
||||
"model": {
|
||||
"type": "string"
|
||||
},
|
||||
"native_request_sha256": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "integer",
|
||||
"format": "uint8",
|
||||
"maximum": 255,
|
||||
"minimum": 0
|
||||
},
|
||||
"maxItems": 32,
|
||||
"minItems": 32
|
||||
},
|
||||
"outcome": {
|
||||
"$ref": "#/$defs/TransportOutcome"
|
||||
},
|
||||
"provider": {
|
||||
"type": "string"
|
||||
},
|
||||
"provider_request_sha256": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "integer",
|
||||
"format": "uint8",
|
||||
"maximum": 255,
|
||||
"minimum": 0
|
||||
},
|
||||
"maxItems": 32,
|
||||
"minItems": 32
|
||||
},
|
||||
"request_id": {
|
||||
"type": "string"
|
||||
},
|
||||
"schema_id": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"additionalProperties": false,
|
||||
"required": [
|
||||
"schema_id",
|
||||
"request_id",
|
||||
"caller_runtime_id",
|
||||
"native_request_sha256",
|
||||
"provider_request_sha256",
|
||||
"model",
|
||||
"provider",
|
||||
"outcome"
|
||||
],
|
||||
"$defs": {
|
||||
"FailureKind": {
|
||||
"description": "What kind of failure a provider reported. `ResultSize` is the connector's own\nstand-in for a result whose sealed frame cannot be carried.",
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"result_size",
|
||||
"invalid_request",
|
||||
"authentication",
|
||||
"rate_limited",
|
||||
"unavailable",
|
||||
"timeout",
|
||||
"provider"
|
||||
]
|
||||
},
|
||||
"StopReason": {
|
||||
"description": "Why a provider stopped producing output.",
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"end_turn",
|
||||
"tool_use",
|
||||
"max_output_tokens",
|
||||
"stop_sequence",
|
||||
"refusal",
|
||||
"context_window"
|
||||
]
|
||||
},
|
||||
"TransportOutcome": {
|
||||
"oneOf": [
|
||||
{
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"Completed": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"provider_response_id": {
|
||||
"type": [
|
||||
"string",
|
||||
"null"
|
||||
]
|
||||
},
|
||||
"stop_reason": {
|
||||
"$ref": "#/$defs/StopReason"
|
||||
},
|
||||
"usage": {
|
||||
"$ref": "#/$defs/Usage"
|
||||
}
|
||||
},
|
||||
"additionalProperties": false,
|
||||
"required": [
|
||||
"stop_reason",
|
||||
"usage"
|
||||
]
|
||||
}
|
||||
},
|
||||
"additionalProperties": false,
|
||||
"required": [
|
||||
"Completed"
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"Failed": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"kind": {
|
||||
"$ref": "#/$defs/FailureKind"
|
||||
},
|
||||
"message": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"additionalProperties": false,
|
||||
"required": [
|
||||
"kind",
|
||||
"message"
|
||||
]
|
||||
}
|
||||
},
|
||||
"additionalProperties": false,
|
||||
"required": [
|
||||
"Failed"
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
"Usage": {
|
||||
"description": "Token accounting for one completed request. Cache reads and writes are\ncounted apart from plain input; `reasoning_tokens` is absent when the\nprovider does not report it.",
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"cache_read_tokens": {
|
||||
"type": "integer",
|
||||
"format": "uint64",
|
||||
"minimum": 0
|
||||
},
|
||||
"cache_write_tokens": {
|
||||
"type": "integer",
|
||||
"format": "uint64",
|
||||
"minimum": 0
|
||||
},
|
||||
"input_tokens": {
|
||||
"type": "integer",
|
||||
"format": "uint64",
|
||||
"minimum": 0
|
||||
},
|
||||
"output_tokens": {
|
||||
"type": "integer",
|
||||
"format": "uint64",
|
||||
"minimum": 0
|
||||
},
|
||||
"reasoning_tokens": {
|
||||
"type": [
|
||||
"integer",
|
||||
"null"
|
||||
],
|
||||
"format": "uint64",
|
||||
"minimum": 0
|
||||
}
|
||||
},
|
||||
"additionalProperties": false,
|
||||
"required": [
|
||||
"input_tokens",
|
||||
"cache_read_tokens",
|
||||
"cache_write_tokens",
|
||||
"output_tokens"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
342
schemas/transport_result.json
Normal file
342
schemas/transport_result.json
Normal file
|
|
@ -0,0 +1,342 @@
|
|||
{
|
||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||
"title": "TransportResult",
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"caller_runtime_id": {
|
||||
"type": "string"
|
||||
},
|
||||
"disposition": {
|
||||
"$ref": "#/$defs/TransportDisposition"
|
||||
},
|
||||
"request_id": {
|
||||
"type": "string"
|
||||
},
|
||||
"schema_id": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"additionalProperties": false,
|
||||
"required": [
|
||||
"schema_id",
|
||||
"request_id",
|
||||
"caller_runtime_id",
|
||||
"disposition"
|
||||
],
|
||||
"$defs": {
|
||||
"FailureKind": {
|
||||
"description": "What kind of failure a provider reported. `ResultSize` is the connector's own\nstand-in for a result whose sealed frame cannot be carried.",
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"result_size",
|
||||
"invalid_request",
|
||||
"authentication",
|
||||
"rate_limited",
|
||||
"unavailable",
|
||||
"timeout",
|
||||
"provider"
|
||||
]
|
||||
},
|
||||
"Refusal": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"Expired",
|
||||
"IdentitySubstitution",
|
||||
"ProviderDigestSubstitution",
|
||||
"ModelNotAllowed",
|
||||
"OutputBound",
|
||||
"PayloadBound",
|
||||
"Capacity",
|
||||
"InFlight",
|
||||
"Indeterminate",
|
||||
"ReplayConflict",
|
||||
"Malformed"
|
||||
]
|
||||
},
|
||||
"StopReason": {
|
||||
"description": "Why a provider stopped producing output.",
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"end_turn",
|
||||
"tool_use",
|
||||
"max_output_tokens",
|
||||
"stop_sequence",
|
||||
"refusal",
|
||||
"context_window"
|
||||
]
|
||||
},
|
||||
"TransportDisposition": {
|
||||
"oneOf": [
|
||||
{
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"Refused": {
|
||||
"$ref": "#/$defs/Refusal"
|
||||
}
|
||||
},
|
||||
"additionalProperties": false,
|
||||
"required": [
|
||||
"Refused"
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"Transported": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"events": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"$ref": "#/$defs/TransportEvent"
|
||||
}
|
||||
},
|
||||
"receipt": {
|
||||
"$ref": "#/$defs/TransportReceipt"
|
||||
}
|
||||
},
|
||||
"additionalProperties": false,
|
||||
"required": [
|
||||
"events",
|
||||
"receipt"
|
||||
]
|
||||
}
|
||||
},
|
||||
"additionalProperties": false,
|
||||
"required": [
|
||||
"Transported"
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
"TransportEvent": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"payload": {
|
||||
"$ref": "#/$defs/TransportEventPayload"
|
||||
},
|
||||
"sequence": {
|
||||
"type": "integer",
|
||||
"format": "uint64",
|
||||
"minimum": 0
|
||||
}
|
||||
},
|
||||
"additionalProperties": false,
|
||||
"required": [
|
||||
"sequence",
|
||||
"payload"
|
||||
]
|
||||
},
|
||||
"TransportEventPayload": {
|
||||
"oneOf": [
|
||||
{
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"TextDelta": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"text": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"additionalProperties": false,
|
||||
"required": [
|
||||
"text"
|
||||
]
|
||||
}
|
||||
},
|
||||
"additionalProperties": false,
|
||||
"required": [
|
||||
"TextDelta"
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"ToolCall": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"arguments": {
|
||||
"type": "string"
|
||||
},
|
||||
"call_id": {
|
||||
"type": "string"
|
||||
},
|
||||
"name": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"additionalProperties": false,
|
||||
"required": [
|
||||
"call_id",
|
||||
"name",
|
||||
"arguments"
|
||||
]
|
||||
}
|
||||
},
|
||||
"additionalProperties": false,
|
||||
"required": [
|
||||
"ToolCall"
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
"TransportOutcome": {
|
||||
"oneOf": [
|
||||
{
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"Completed": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"provider_response_id": {
|
||||
"type": [
|
||||
"string",
|
||||
"null"
|
||||
]
|
||||
},
|
||||
"stop_reason": {
|
||||
"$ref": "#/$defs/StopReason"
|
||||
},
|
||||
"usage": {
|
||||
"$ref": "#/$defs/Usage"
|
||||
}
|
||||
},
|
||||
"additionalProperties": false,
|
||||
"required": [
|
||||
"stop_reason",
|
||||
"usage"
|
||||
]
|
||||
}
|
||||
},
|
||||
"additionalProperties": false,
|
||||
"required": [
|
||||
"Completed"
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"Failed": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"kind": {
|
||||
"$ref": "#/$defs/FailureKind"
|
||||
},
|
||||
"message": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"additionalProperties": false,
|
||||
"required": [
|
||||
"kind",
|
||||
"message"
|
||||
]
|
||||
}
|
||||
},
|
||||
"additionalProperties": false,
|
||||
"required": [
|
||||
"Failed"
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
"TransportReceipt": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"caller_runtime_id": {
|
||||
"type": "string"
|
||||
},
|
||||
"model": {
|
||||
"type": "string"
|
||||
},
|
||||
"native_request_sha256": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "integer",
|
||||
"format": "uint8",
|
||||
"maximum": 255,
|
||||
"minimum": 0
|
||||
},
|
||||
"maxItems": 32,
|
||||
"minItems": 32
|
||||
},
|
||||
"outcome": {
|
||||
"$ref": "#/$defs/TransportOutcome"
|
||||
},
|
||||
"provider": {
|
||||
"type": "string"
|
||||
},
|
||||
"provider_request_sha256": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "integer",
|
||||
"format": "uint8",
|
||||
"maximum": 255,
|
||||
"minimum": 0
|
||||
},
|
||||
"maxItems": 32,
|
||||
"minItems": 32
|
||||
},
|
||||
"request_id": {
|
||||
"type": "string"
|
||||
},
|
||||
"schema_id": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"additionalProperties": false,
|
||||
"required": [
|
||||
"schema_id",
|
||||
"request_id",
|
||||
"caller_runtime_id",
|
||||
"native_request_sha256",
|
||||
"provider_request_sha256",
|
||||
"model",
|
||||
"provider",
|
||||
"outcome"
|
||||
]
|
||||
},
|
||||
"Usage": {
|
||||
"description": "Token accounting for one completed request. Cache reads and writes are\ncounted apart from plain input; `reasoning_tokens` is absent when the\nprovider does not report it.",
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"cache_read_tokens": {
|
||||
"type": "integer",
|
||||
"format": "uint64",
|
||||
"minimum": 0
|
||||
},
|
||||
"cache_write_tokens": {
|
||||
"type": "integer",
|
||||
"format": "uint64",
|
||||
"minimum": 0
|
||||
},
|
||||
"input_tokens": {
|
||||
"type": "integer",
|
||||
"format": "uint64",
|
||||
"minimum": 0
|
||||
},
|
||||
"output_tokens": {
|
||||
"type": "integer",
|
||||
"format": "uint64",
|
||||
"minimum": 0
|
||||
},
|
||||
"reasoning_tokens": {
|
||||
"type": [
|
||||
"integer",
|
||||
"null"
|
||||
],
|
||||
"format": "uint64",
|
||||
"minimum": 0
|
||||
}
|
||||
},
|
||||
"additionalProperties": false,
|
||||
"required": [
|
||||
"input_tokens",
|
||||
"cache_read_tokens",
|
||||
"cache_write_tokens",
|
||||
"output_tokens"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
Loading…
Reference in a new issue