From 73c45b7df7d438ec567ba53ee8288fd13d1ca906 Mon Sep 17 00:00:00 2001 From: Ytallo Layon Date: Fri, 12 Jun 2026 09:21:21 -0300 Subject: [PATCH 01/19] feat(provider-openai): scaffold standalone Rust worker crate Adds the initial crate scaffold for the provider-openai worker: Cargo.toml, build.rs, src/{lib,manifest,main}.rs, iii.worker.yaml, iii-permissions.yaml, and .gitignore. Includes a passing TDD test (json_roundtrip_has_required_fields) for the manifest module. --- provider-openai/.gitignore | 1 + provider-openai/Cargo.lock | 2306 ++++++++++++++++++++++++++ provider-openai/Cargo.toml | 28 + provider-openai/build.rs | 6 + provider-openai/iii-permissions.yaml | 10 + provider-openai/iii.worker.yaml | 7 + provider-openai/src/lib.rs | 30 + provider-openai/src/main.rs | 23 + provider-openai/src/manifest.rs | 43 + 9 files changed, 2454 insertions(+) create mode 100644 provider-openai/.gitignore create mode 100644 provider-openai/Cargo.lock create mode 100644 provider-openai/Cargo.toml create mode 100644 provider-openai/build.rs create mode 100644 provider-openai/iii-permissions.yaml create mode 100644 provider-openai/iii.worker.yaml create mode 100644 provider-openai/src/lib.rs create mode 100644 provider-openai/src/main.rs create mode 100644 provider-openai/src/manifest.rs diff --git a/provider-openai/.gitignore b/provider-openai/.gitignore new file mode 100644 index 000000000..ea8c4bf7f --- /dev/null +++ b/provider-openai/.gitignore @@ -0,0 +1 @@ +/target diff --git a/provider-openai/Cargo.lock b/provider-openai/Cargo.lock new file mode 100644 index 000000000..d6c491584 --- /dev/null +++ b/provider-openai/Cargo.lock @@ -0,0 +1,2306 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "aho-corasick" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ddd31a130427c27518df266943a5308ed92d4b226cc639f5a8f1002816174301" +dependencies = [ + "memchr", +] + +[[package]] +name = "anyhow" +version = "1.0.102" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c" + +[[package]] +name = "async-trait" +version = "0.1.89" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9035ad2d096bed7955a320ee7e2230574d28fd3c3a0f186cbea1ff3c7eed5dbb" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "atomic-waker" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" + +[[package]] +name = "base64" +version = "0.22.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" + +[[package]] +name = "bitflags" +version = "2.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b4388bee8683e3d04af747c73422af53102d2bd24d9eadb6cbc100baef4b43f8" + +[[package]] +name = "block-buffer" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" +dependencies = [ + "generic-array", +] + +[[package]] +name = "bumpalo" +version = "3.20.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649" + +[[package]] +name = "bytes" +version = "1.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e748733b7cbc798e1434b6ac524f0c1ff2ab456fe201501e6497c8417a4fc33" + +[[package]] +name = "cc" +version = "1.2.63" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "556e016178bb5662a08681bbe0f00f8e17631781a4dfc8c45e466e4b185ec27f" +dependencies = [ + "find-msvc-tools", + "shlex", +] + +[[package]] +name = "cfg-if" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" + +[[package]] +name = "cfg_aliases" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724" + +[[package]] +name = "core-foundation" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b2a6cd9ae233e7f62ba4e9353e81a88df7fc8a5987b8d445b4d90c879bd156f6" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "core-foundation-sys" +version = "0.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" + +[[package]] +name = "cpufeatures" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" +dependencies = [ + "libc", +] + +[[package]] +name = "crypto-common" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" +dependencies = [ + "generic-array", + "typenum", +] + +[[package]] +name = "data-encoding" +version = "2.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4ae5f15dda3c708c0ade84bfee31ccab44a3da4f88015ed22f63732abe300c8" + +[[package]] +name = "digest" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" +dependencies = [ + "block-buffer", + "crypto-common", +] + +[[package]] +name = "displaydoc" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ac70aa55017e108007fbaf5aa0f54b021c98f92ff8af59d42eda9da96e3dd4f" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "dyn-clone" +version = "1.0.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d0881ea181b1df73ff77ffaaf9c7544ecc11e82fba9b5f27b262a3c73a332555" + +[[package]] +name = "equivalent" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" + +[[package]] +name = "errno" +version = "0.3.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "find-msvc-tools" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" + +[[package]] +name = "foldhash" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" + +[[package]] +name = "form_urlencoded" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf" +dependencies = [ + "percent-encoding", +] + +[[package]] +name = "futures" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b147ee9d1f6d097cef9ce628cd2ee62288d963e16fb287bd9286455b241382d" +dependencies = [ + "futures-channel", + "futures-core", + "futures-executor", + "futures-io", + "futures-sink", + "futures-task", + "futures-util", +] + +[[package]] +name = "futures-channel" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "07bbe89c50d7a535e539b8c17bc0b49bdb77747034daa8087407d655f3f7cc1d" +dependencies = [ + "futures-core", + "futures-sink", +] + +[[package]] +name = "futures-core" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e3450815272ef58cec6d564423f6e755e25379b217b0bc688e295ba24df6b1d" + +[[package]] +name = "futures-executor" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "baf29c38818342a3b26b5b923639e7b1f4a61fc5e76102d4b1981c6dc7a7579d" +dependencies = [ + "futures-core", + "futures-task", + "futures-util", +] + +[[package]] +name = "futures-io" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cecba35d7ad927e23624b22ad55235f2239cfa44fd10428eecbeba6d6a717718" + +[[package]] +name = "futures-macro" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e835b70203e41293343137df5c0664546da5745f82ec9b84d40be8336958447b" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "futures-sink" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c39754e157331b013978ec91992bde1ac089843443c49cbc7f46150b0fad0893" + +[[package]] +name = "futures-task" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "037711b3d59c33004d3856fbdc83b99d4ff37a24768fa1be9ce3538a1cde4393" + +[[package]] +name = "futures-util" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "389ca41296e6190b48053de0321d02a77f32f8a5d2461dd38762c0593805c6d6" +dependencies = [ + "futures-channel", + "futures-core", + "futures-io", + "futures-macro", + "futures-sink", + "futures-task", + "memchr", + "pin-project-lite", + "slab", +] + +[[package]] +name = "generic-array" +version = "0.14.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" +dependencies = [ + "typenum", + "version_check", +] + +[[package]] +name = "getrandom" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" +dependencies = [ + "cfg-if", + "js-sys", + "libc", + "wasi", + "wasm-bindgen", +] + +[[package]] +name = "getrandom" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" +dependencies = [ + "cfg-if", + "js-sys", + "libc", + "r-efi 5.3.0", + "wasip2", + "wasm-bindgen", +] + +[[package]] +name = "getrandom" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0de51e6874e94e7bf76d726fc5d13ba782deca734ff60d5bb2fb2607c7406555" +dependencies = [ + "cfg-if", + "libc", + "r-efi 6.0.0", + "wasip2", + "wasip3", +] + +[[package]] +name = "hashbrown" +version = "0.15.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" +dependencies = [ + "foldhash", +] + +[[package]] +name = "hashbrown" +version = "0.17.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" + +[[package]] +name = "heck" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" + +[[package]] +name = "hostname" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "617aaa3557aef3810a6369d0a99fac8a080891b68bd9f9812a1eeda0c0730cbd" +dependencies = [ + "cfg-if", + "libc", + "windows-link", +] + +[[package]] +name = "http" +version = "1.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6970f50e31d6fc17d3fa27329444bfa74e196cf62e95052a3f6fee181dba6425" +dependencies = [ + "bytes", + "itoa", +] + +[[package]] +name = "http-body" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1efedce1fb8e6913f23e0c92de8e62cd5b772a67e7b3946df930a62566c93184" +dependencies = [ + "bytes", + "http", +] + +[[package]] +name = "http-body-util" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b021d93e26becf5dc7e1b75b1bed1fd93124b374ceb73f43d4d4eafec896a64a" +dependencies = [ + "bytes", + "futures-core", + "http", + "http-body", + "pin-project-lite", +] + +[[package]] +name = "httparse" +version = "1.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" + +[[package]] +name = "hyper" +version = "1.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "55281c53a1894c864990125767da440a4e630446785086f52523b20033b74498" +dependencies = [ + "atomic-waker", + "bytes", + "futures-channel", + "futures-core", + "http", + "http-body", + "httparse", + "itoa", + "pin-project-lite", + "smallvec", + "tokio", + "want", +] + +[[package]] +name = "hyper-rustls" +version = "0.27.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "33ca68d021ef39cf6463ab54c1d0f5daf03377b70561305bb89a8f83aab66e0f" +dependencies = [ + "http", + "hyper", + "hyper-util", + "rustls", + "tokio", + "tokio-rustls", + "tower-service", + "webpki-roots", +] + +[[package]] +name = "hyper-util" +version = "0.1.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96547c2556ec9d12fb1578c4eaf448b04993e7fb79cbaad930a656880a6bdfa0" +dependencies = [ + "base64", + "bytes", + "futures-channel", + "futures-util", + "http", + "http-body", + "hyper", + "ipnet", + "libc", + "percent-encoding", + "pin-project-lite", + "socket2", + "tokio", + "tower-service", + "tracing", +] + +[[package]] +name = "icu_collections" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2984d1cd16c883d7935b9e07e44071dca8d917fd52ecc02c04d5fa0b5a3f191c" +dependencies = [ + "displaydoc", + "potential_utf", + "utf8_iter", + "yoke", + "zerofrom", + "zerovec", +] + +[[package]] +name = "icu_locale_core" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92219b62b3e2b4d88ac5119f8904c10f8f61bf7e95b640d25ba3075e6cac2c29" +dependencies = [ + "displaydoc", + "litemap", + "tinystr", + "writeable", + "zerovec", +] + +[[package]] +name = "icu_normalizer" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c56e5ee99d6e3d33bd91c5d85458b6005a22140021cc324cea84dd0e72cff3b4" +dependencies = [ + "icu_collections", + "icu_normalizer_data", + "icu_properties", + "icu_provider", + "smallvec", + "zerovec", +] + +[[package]] +name = "icu_normalizer_data" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da3be0ae77ea334f4da67c12f149704f19f81d1adf7c51cf482943e84a2bad38" + +[[package]] +name = "icu_properties" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bee3b67d0ea5c2cca5003417989af8996f8604e34fb9ddf96208a033901e70de" +dependencies = [ + "icu_collections", + "icu_locale_core", + "icu_properties_data", + "icu_provider", + "zerotrie", + "zerovec", +] + +[[package]] +name = "icu_properties_data" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e2bbb201e0c04f7b4b3e14382af113e17ba4f63e2c9d2ee626b720cbce54a14" + +[[package]] +name = "icu_provider" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "139c4cf31c8b5f33d7e199446eff9c1e02decfc2f0eec2c8d71f65befa45b421" +dependencies = [ + "displaydoc", + "icu_locale_core", + "writeable", + "yoke", + "zerofrom", + "zerotrie", + "zerovec", +] + +[[package]] +name = "id-arena" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954" + +[[package]] +name = "idna" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b0875f23caa03898994f6ddc501886a45c7d3d62d04d2d90788d47be1b1e4de" +dependencies = [ + "idna_adapter", + "smallvec", + "utf8_iter", +] + +[[package]] +name = "idna_adapter" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb68373c0d6620ef8105e855e7745e18b0d00d3bdb07fb532e434244cdb9a714" +dependencies = [ + "icu_normalizer", + "icu_properties", +] + +[[package]] +name = "iii-observability" +version = "0.19.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e11586fcd304a563c143837f67b2e5f3cb73d23f6c8452c9734ff18e4a1402bf" +dependencies = [ + "futures-util", + "opentelemetry", + "opentelemetry-http", + "opentelemetry_sdk", + "reqwest", + "serde_json", + "sysinfo", + "tokio", + "tokio-tungstenite", + "tracing", + "uuid", +] + +[[package]] +name = "iii-sdk" +version = "0.19.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8490f2ad470d54cf7e0bc2f4105aca71bdb4c24752fcb406183f24b8dd328f80" +dependencies = [ + "async-trait", + "futures-util", + "hostname", + "iii-observability", + "reqwest", + "schemars", + "serde", + "serde_json", + "thiserror", + "tokio", + "tokio-tungstenite", + "tracing", + "uuid", +] + +[[package]] +name = "indexmap" +version = "2.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9" +dependencies = [ + "equivalent", + "hashbrown 0.17.1", + "serde", + "serde_core", +] + +[[package]] +name = "ipnet" +version = "2.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d98f6fed1fde3f8c21bc40a1abb88dd75e67924f9cffc3ef95607bad8017f8e2" + +[[package]] +name = "itoa" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" + +[[package]] +name = "js-sys" +version = "0.3.100" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2025f20d7a4fa7785846e7b63d10a76d3f1cee98ee5cb79ea59703f95e42162" +dependencies = [ + "cfg-if", + "futures-util", + "wasm-bindgen", +] + +[[package]] +name = "leb128fmt" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2" + +[[package]] +name = "libc" +version = "0.2.186" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66" + +[[package]] +name = "litemap" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92daf443525c4cce67b150400bc2316076100ce0b3686209eb8cf3c31612e6f0" + +[[package]] +name = "llm-router" +version = "0.1.0" +dependencies = [ + "async-trait", + "futures", + "iii-sdk", + "regex", + "serde", + "serde_json", + "sha2", + "thiserror", + "tokio", + "uuid", +] + +[[package]] +name = "log" +version = "0.4.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "953f07c43838f8e6f9758cab68bf5bed85465e7587ebe0b823f1bcd81978ad3a" + +[[package]] +name = "lru-slab" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154" + +[[package]] +name = "memchr" +version = "2.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "88904434abc2901f197fe8cc55f0445e7ded921dba5911dad2e2b39b48e663c4" + +[[package]] +name = "mio" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "02bd0af71c67b473010cbbc60715ee815645a4dc942899111f494b4b737d6fda" +dependencies = [ + "libc", + "wasi", + "windows-sys 0.61.2", +] + +[[package]] +name = "ntapi" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3b335231dfd352ffb0f8017f3b6027a4917f7df785ea2143d8af2adc66980ae" +dependencies = [ + "winapi", +] + +[[package]] +name = "objc2-core-foundation" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2a180dd8642fa45cdb7dd721cd4c11b1cadd4929ce112ebd8b9f5803cc79d536" +dependencies = [ + "bitflags", +] + +[[package]] +name = "objc2-io-kit" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "33fafba39597d6dc1fb709123dfa8289d39406734be322956a69f0931c73bb15" +dependencies = [ + "libc", + "objc2-core-foundation", +] + +[[package]] +name = "once_cell" +version = "1.21.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" + +[[package]] +name = "openssl-probe" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe" + +[[package]] +name = "opentelemetry" +version = "0.31.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b84bcd6ae87133e903af7ef497404dda70c60d0ea14895fc8a5e6722754fc2a0" +dependencies = [ + "futures-core", + "futures-sink", + "js-sys", + "pin-project-lite", + "thiserror", + "tracing", +] + +[[package]] +name = "opentelemetry-http" +version = "0.31.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7a6d09a73194e6b66df7c8f1b680f156d916a1a942abf2de06823dd02b7855d" +dependencies = [ + "async-trait", + "bytes", + "http", + "opentelemetry", + "reqwest", +] + +[[package]] +name = "opentelemetry_sdk" +version = "0.31.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e14ae4f5991976fd48df6d843de219ca6d31b01daaab2dad5af2badeded372bd" +dependencies = [ + "futures-channel", + "futures-executor", + "futures-util", + "opentelemetry", + "percent-encoding", + "rand", + "thiserror", + "tokio", + "tokio-stream", +] + +[[package]] +name = "percent-encoding" +version = "2.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" + +[[package]] +name = "pin-project-lite" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" + +[[package]] +name = "potential_utf" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0103b1cef7ec0cf76490e969665504990193874ea05c85ff9bab8b911d0a0564" +dependencies = [ + "zerovec", +] + +[[package]] +name = "ppv-lite86" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9" +dependencies = [ + "zerocopy", +] + +[[package]] +name = "prettyplease" +version = "0.2.37" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b" +dependencies = [ + "proc-macro2", + "syn", +] + +[[package]] +name = "proc-macro2" +version = "1.0.106" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "provider-openai" +version = "0.1.0" +dependencies = [ + "futures", + "iii-sdk", + "llm-router", + "reqwest", + "serde", + "serde_json", + "tokio", + "uuid", +] + +[[package]] +name = "quinn" +version = "0.11.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9e20a958963c291dc322d98411f541009df2ced7b5a4f2bd52337638cfccf20" +dependencies = [ + "bytes", + "cfg_aliases", + "pin-project-lite", + "quinn-proto", + "quinn-udp", + "rustc-hash", + "rustls", + "socket2", + "thiserror", + "tokio", + "tracing", + "web-time", +] + +[[package]] +name = "quinn-proto" +version = "0.11.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "434b42fec591c96ef50e21e886936e66d3cc3f737104fdb9b737c40ffb94c098" +dependencies = [ + "bytes", + "getrandom 0.3.4", + "lru-slab", + "rand", + "ring", + "rustc-hash", + "rustls", + "rustls-pki-types", + "slab", + "thiserror", + "tinyvec", + "tracing", + "web-time", +] + +[[package]] +name = "quinn-udp" +version = "0.5.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "addec6a0dcad8a8d96a771f815f0eaf55f9d1805756410b39f5fa81332574cbd" +dependencies = [ + "cfg_aliases", + "libc", + "once_cell", + "socket2", + "tracing", + "windows-sys 0.60.2", +] + +[[package]] +name = "quote" +version = "1.0.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41f2619966050689382d2b44f664f4bc593e129785a36d6ee376ddf37259b924" +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.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "44c5af06bb1b7d3216d91932aed5265164bf384dc89cd6ba05cf59a35f5f76ea" +dependencies = [ + "rand_chacha", + "rand_core", +] + +[[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", +] + +[[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 = "regex" +version = "1.12.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1292b7759ae1cb9ec195452d1390a074f0cd8541ab7a5a8c31cd6db45d4a6ba" +dependencies = [ + "aho-corasick", + "memchr", + "regex-automata", + "regex-syntax", +] + +[[package]] +name = "regex-automata" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e1dd4122fc1595e8162618945476892eefca7b88c52820e74af6262213cae8f" +dependencies = [ + "aho-corasick", + "memchr", + "regex-syntax", +] + +[[package]] +name = "regex-syntax" +version = "0.8.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4" + +[[package]] +name = "reqwest" +version = "0.12.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eddd3ca559203180a307f12d114c268abf583f59b03cb906fd0b3ff8646c1147" +dependencies = [ + "base64", + "bytes", + "futures-core", + "futures-util", + "http", + "http-body", + "http-body-util", + "hyper", + "hyper-rustls", + "hyper-util", + "js-sys", + "log", + "percent-encoding", + "pin-project-lite", + "quinn", + "rustls", + "rustls-pki-types", + "serde", + "serde_json", + "serde_urlencoded", + "sync_wrapper", + "tokio", + "tokio-rustls", + "tokio-util", + "tower", + "tower-http", + "tower-service", + "url", + "wasm-bindgen", + "wasm-bindgen-futures", + "wasm-streams", + "web-sys", + "webpki-roots", +] + +[[package]] +name = "ring" +version = "0.17.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7" +dependencies = [ + "cc", + "cfg-if", + "getrandom 0.2.17", + "libc", + "untrusted", + "windows-sys 0.52.0", +] + +[[package]] +name = "rustc-hash" +version = "2.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94300abf3f1ae2e2b8ffb7b58043de3d399c73fa6f4b73826402a5c457614dbe" + +[[package]] +name = "rustls" +version = "0.23.40" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ef86cd5876211988985292b91c96a8f2d298df24e75989a43a3c73f2d4d8168b" +dependencies = [ + "once_cell", + "ring", + "rustls-pki-types", + "rustls-webpki", + "subtle", + "zeroize", +] + +[[package]] +name = "rustls-native-certs" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dab5152771c58876a2146916e53e35057e1a4dfa2b9df0f0305b07f611fdea4d" +dependencies = [ + "openssl-probe", + "rustls-pki-types", + "schannel", + "security-framework", +] + +[[package]] +name = "rustls-pki-types" +version = "1.14.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "30a7197ae7eb376e574fe940d068c30fe0462554a3ddbe4eca7838e049c937a9" +dependencies = [ + "web-time", + "zeroize", +] + +[[package]] +name = "rustls-webpki" +version = "0.103.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "61c429a8649f110dddef65e2a5ad240f747e85f7758a6bccc7e5777bd33f756e" +dependencies = [ + "ring", + "rustls-pki-types", + "untrusted", +] + +[[package]] +name = "rustversion" +version = "1.0.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" + +[[package]] +name = "ryu" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" + +[[package]] +name = "schannel" +version = "0.1.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91c1b7e4904c873ef0710c1f407dde2e6287de2bebc1bbbf7d430bb7cbffd939" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "schemars" +version = "0.8.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3fbf2ae1b8bc8e02df939598064d22402220cd5bbcca1c76f7d6a310974d5615" +dependencies = [ + "dyn-clone", + "schemars_derive", + "serde", + "serde_json", +] + +[[package]] +name = "schemars_derive" +version = "0.8.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32e265784ad618884abaea0600a9adf15393368d840e0222d101a072f3f7534d" +dependencies = [ + "proc-macro2", + "quote", + "serde_derive_internals", + "syn", +] + +[[package]] +name = "security-framework" +version = "3.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7f4bc775c73d9a02cde8bf7b2ec4c9d12743edf609006c7facc23998404cd1d" +dependencies = [ + "bitflags", + "core-foundation", + "core-foundation-sys", + "libc", + "security-framework-sys", +] + +[[package]] +name = "security-framework-sys" +version = "2.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2691df843ecc5d231c0b14ece2acc3efb62c0a398c7e1d875f3983ce020e3" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[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.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde_core" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "serde_derive_internals" +version = "0.29.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "18d26a20a969b9e3fdf2fc2d9f21eda6c40e2de84c9408bb5d3b05d499aae711" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "serde_json" +version = "1.0.150" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e8014e44b4736ed0538adeecded0fce2a272f22dc9578a7eb6b2d9993c74cfb9" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "serde_urlencoded" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3491c14715ca2294c4d6a88f15e84739788c1d030eed8c110436aafdaa2f3fd" +dependencies = [ + "form_urlencoded", + "itoa", + "ryu", + "serde", +] + +[[package]] +name = "sha1" +version = "0.10.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3bf829a2d51ab4a5ddf1352d8470c140cadc8301b2ae1789db023f01cedd6ba" +dependencies = [ + "cfg-if", + "cpufeatures", + "digest", +] + +[[package]] +name = "sha2" +version = "0.10.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" +dependencies = [ + "cfg-if", + "cpufeatures", + "digest", +] + +[[package]] +name = "shlex" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba" + +[[package]] +name = "signal-hook-registry" +version = "1.4.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4db69cba1110affc0e9f7bcd48bbf87b3f4fc7c61fc9155afd4c469eb3d6c1b" +dependencies = [ + "errno", + "libc", +] + +[[package]] +name = "slab" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" + +[[package]] +name = "smallvec" +version = "1.15.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ed6a63f02c8539c91a8685a86f4099661ba3da017932f6ebbea6de3f0fa7c90" + +[[package]] +name = "socket2" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52d1cfed4120b4d927bf7c0f86d2087a4a7d6027c906d9f9d525a80573b9be51" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "stable_deref_trait" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" + +[[package]] +name = "subtle" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" + +[[package]] +name = "syn" +version = "2.0.117" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e665b8803e7b1d2a727f4023456bbbbe74da67099c585258af0ad9c5013b9b99" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "sync_wrapper" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0bf256ce5efdfa370213c1dabab5935a12e49f2c58d15e9eac2870d3b4f27263" +dependencies = [ + "futures-core", +] + +[[package]] +name = "synstructure" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "sysinfo" +version = "0.38.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92ab6a2f8bfe508deb3c6406578252e491d299cbbf3bc0529ecc3313aee4a52f" +dependencies = [ + "libc", + "memchr", + "ntapi", + "objc2-core-foundation", + "objc2-io-kit", + "windows", +] + +[[package]] +name = "thiserror" +version = "2.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4288b5bcbc7920c07a1149a35cf9590a2aa808e0bc1eafaade0b80947865fbc4" +dependencies = [ + "thiserror-impl", +] + +[[package]] +name = "thiserror-impl" +version = "2.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebc4ee7f67670e9b64d05fa4253e753e016c6c95ff35b89b7941d6b856dec1d5" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "tinystr" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8323304221c2a851516f22236c5722a72eaa19749016521d6dff0824447d96d" +dependencies = [ + "displaydoc", + "zerovec", +] + +[[package]] +name = "tinyvec" +version = "1.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3e61e67053d25a4e82c844e8424039d9745781b3fc4f32b8d55ed50f5f667ef3" +dependencies = [ + "tinyvec_macros", +] + +[[package]] +name = "tinyvec_macros" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" + +[[package]] +name = "tokio" +version = "1.52.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fc7f01b389ac15039e4dc9531aa973a135d7a4135281b12d7c1bc79fd57fffe" +dependencies = [ + "bytes", + "libc", + "mio", + "pin-project-lite", + "signal-hook-registry", + "socket2", + "tokio-macros", + "windows-sys 0.61.2", +] + +[[package]] +name = "tokio-macros" +version = "2.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "385a6cb71ab9ab790c5fe8d67f1645e6c450a7ce006a33de03daa956cf70a496" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "tokio-rustls" +version = "0.26.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1729aa945f29d91ba541258c8df89027d5792d85a8841fb65e8bf0f4ede4ef61" +dependencies = [ + "rustls", + "tokio", +] + +[[package]] +name = "tokio-stream" +version = "0.1.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32da49809aab5c3bc678af03902d4ccddea2a87d028d86392a4b1560c6906c70" +dependencies = [ + "futures-core", + "pin-project-lite", + "tokio", +] + +[[package]] +name = "tokio-tungstenite" +version = "0.28.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d25a406cddcc431a75d3d9afc6a7c0f7428d4891dd973e4d54c56b46127bf857" +dependencies = [ + "futures-util", + "log", + "rustls", + "rustls-native-certs", + "rustls-pki-types", + "tokio", + "tokio-rustls", + "tungstenite", +] + +[[package]] +name = "tokio-util" +version = "0.7.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ae9cec805b01e8fc3fd2fe289f89149a9b66dd16786abd8b19cfa7b48cb0098" +dependencies = [ + "bytes", + "futures-core", + "futures-sink", + "pin-project-lite", + "tokio", +] + +[[package]] +name = "tower" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebe5ef63511595f1344e2d5cfa636d973292adc0eec1f0ad45fae9f0851ab1d4" +dependencies = [ + "futures-core", + "futures-util", + "pin-project-lite", + "sync_wrapper", + "tokio", + "tower-layer", + "tower-service", +] + +[[package]] +name = "tower-http" +version = "0.6.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4cfcf7e2740e6fc6d4d688b4ef00650406bb94adf4731e43c096c3a19fe40840" +dependencies = [ + "bitflags", + "bytes", + "futures-util", + "http", + "http-body", + "pin-project-lite", + "tower", + "tower-layer", + "tower-service", + "url", +] + +[[package]] +name = "tower-layer" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "121c2a6cda46980bb0fcd1647ffaf6cd3fc79a013de288782836f6df9c48780e" + +[[package]] +name = "tower-service" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3" + +[[package]] +name = "tracing" +version = "0.1.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" +dependencies = [ + "pin-project-lite", + "tracing-attributes", + "tracing-core", +] + +[[package]] +name = "tracing-attributes" +version = "0.1.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "tracing-core" +version = "0.1.36" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a" +dependencies = [ + "once_cell", +] + +[[package]] +name = "try-lock" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" + +[[package]] +name = "tungstenite" +version = "0.28.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8628dcc84e5a09eb3d8423d6cb682965dea9133204e8fb3efee74c2a0c259442" +dependencies = [ + "bytes", + "data-encoding", + "http", + "httparse", + "log", + "rand", + "rustls", + "rustls-pki-types", + "sha1", + "thiserror", + "utf-8", +] + +[[package]] +name = "typenum" +version = "1.20.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20" + +[[package]] +name = "unicode-ident" +version = "1.0.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" + +[[package]] +name = "unicode-xid" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" + +[[package]] +name = "untrusted" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" + +[[package]] +name = "url" +version = "2.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff67a8a4397373c3ef660812acab3268222035010ab8680ec4215f38ba3d0eed" +dependencies = [ + "form_urlencoded", + "idna", + "percent-encoding", + "serde", +] + +[[package]] +name = "utf-8" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09cc8ee72d2a9becf2f2febe0205bbed8fc6615b7cb429ad062dc7b7ddd036a9" + +[[package]] +name = "utf8_iter" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" + +[[package]] +name = "uuid" +version = "1.23.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "144d6b123cef80b301b8f72a9e2ca4370ddec21950d0a103dd22c437006d2db7" +dependencies = [ + "getrandom 0.4.2", + "js-sys", + "serde_core", + "wasm-bindgen", +] + +[[package]] +name = "version_check" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" + +[[package]] +name = "want" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bfa7760aed19e106de2c7c0b581b509f2f25d3dacaf737cb82ac61bc6d760b0e" +dependencies = [ + "try-lock", +] + +[[package]] +name = "wasi" +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.3+wasi-0.2.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "20064672db26d7cdc89c7798c48a0fdfac8213434a1186e5ef29fd560ae223d6" +dependencies = [ + "wit-bindgen 0.57.1", +] + +[[package]] +name = "wasip3" +version = "0.4.0+wasi-0.3.0-rc-2026-01-06" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5428f8bf88ea5ddc08faddef2ac4a67e390b88186c703ce6dbd955e1c145aca5" +dependencies = [ + "wit-bindgen 0.51.0", +] + +[[package]] +name = "wasm-bindgen" +version = "0.2.123" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a254a4b10c19a76f09a27640e7ffbf9bc30bf67e16a3bf28aaefa4920fe81563" +dependencies = [ + "cfg-if", + "once_cell", + "rustversion", + "wasm-bindgen-macro", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-futures" +version = "0.4.73" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "54568702fabf5d4849ce2b90fadfa64168a097eaf4b351ce9df8b687a0086aaf" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.123" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24a40fc75b0ec6f3746ceb10d36f53a93dcd68a93b11b6445983945d79eba0dc" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.123" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "908f34bd9b9ce3d4caf07b72dfab63d61504d156856c6bd3cd87fa350cf3985b" +dependencies = [ + "bumpalo", + "proc-macro2", + "quote", + "syn", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.123" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7acbf7616c27b194bbb550bf77ed0c2c3e5b7fd1260a93082b95fb7f47959b92" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "wasm-encoder" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "990065f2fe63003fe337b932cfb5e3b80e0b4d0f5ff650e6985b1048f62c8319" +dependencies = [ + "leb128fmt", + "wasmparser", +] + +[[package]] +name = "wasm-metadata" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb0e353e6a2fbdc176932bbaab493762eb1255a7900fe0fea1a2f96c296cc909" +dependencies = [ + "anyhow", + "indexmap", + "wasm-encoder", + "wasmparser", +] + +[[package]] +name = "wasm-streams" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "15053d8d85c7eccdbefef60f06769760a563c7f0a9d6902a13d35c7800b0ad65" +dependencies = [ + "futures-util", + "js-sys", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", +] + +[[package]] +name = "wasmparser" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47b807c72e1bac69382b3a6fb3dbe8ea4c0ed87ff5629b8685ae6b9a611028fe" +dependencies = [ + "bitflags", + "hashbrown 0.15.5", + "indexmap", + "semver", +] + +[[package]] +name = "web-sys" +version = "0.3.100" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e0871acf327f283dc6da28a1696cdc64fb355ba9f935d052021fa77f35cce69" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "web-time" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a6580f308b1fad9207618087a65c04e7a10bc77e02c8e84e9b00dd4b12fa0bb" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "webpki-roots" +version = "1.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52f5ee44c96cf55f1b349600768e3ece3a8f26010c05265ab73f945bb1a2eb9d" +dependencies = [ + "rustls-pki-types", +] + +[[package]] +name = "winapi" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419" +dependencies = [ + "winapi-i686-pc-windows-gnu", + "winapi-x86_64-pc-windows-gnu", +] + +[[package]] +name = "winapi-i686-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" + +[[package]] +name = "winapi-x86_64-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" + +[[package]] +name = "windows" +version = "0.62.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "527fadee13e0c05939a6a05d5bd6eec6cd2e3dbd648b9f8e447c6518133d8580" +dependencies = [ + "windows-collections", + "windows-core", + "windows-future", + "windows-numerics", +] + +[[package]] +name = "windows-collections" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23b2d95af1a8a14a3c7367e1ed4fc9c20e0a26e79551b1454d72583c97cc6610" +dependencies = [ + "windows-core", +] + +[[package]] +name = "windows-core" +version = "0.62.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb" +dependencies = [ + "windows-implement", + "windows-interface", + "windows-link", + "windows-result", + "windows-strings", +] + +[[package]] +name = "windows-future" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e1d6f90251fe18a279739e78025bd6ddc52a7e22f921070ccdc67dde84c605cb" +dependencies = [ + "windows-core", + "windows-link", + "windows-threading", +] + +[[package]] +name = "windows-implement" +version = "0.60.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "windows-interface" +version = "0.59.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-numerics" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e2e40844ac143cdb44aead537bbf727de9b044e107a0f1220392177d15b0f26" +dependencies = [ + "windows-core", + "windows-link", +] + +[[package]] +name = "windows-result" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-strings" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-sys" +version = "0.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" +dependencies = [ + "windows-targets 0.52.6", +] + +[[package]] +name = "windows-sys" +version = "0.60.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2f500e4d28234f72040990ec9d39e3a6b950f9f22d3dba18416c35882612bcb" +dependencies = [ + "windows-targets 0.53.5", +] + +[[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" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" +dependencies = [ + "windows_aarch64_gnullvm 0.52.6", + "windows_aarch64_msvc 0.52.6", + "windows_i686_gnu 0.52.6", + "windows_i686_gnullvm 0.52.6", + "windows_i686_msvc 0.52.6", + "windows_x86_64_gnu 0.52.6", + "windows_x86_64_gnullvm 0.52.6", + "windows_x86_64_msvc 0.52.6", +] + +[[package]] +name = "windows-targets" +version = "0.53.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4945f9f551b88e0d65f3db0bc25c33b8acea4d9e41163edf90dcd0b19f9069f3" +dependencies = [ + "windows-link", + "windows_aarch64_gnullvm 0.53.1", + "windows_aarch64_msvc 0.53.1", + "windows_i686_gnu 0.53.1", + "windows_i686_gnullvm 0.53.1", + "windows_i686_msvc 0.53.1", + "windows_x86_64_gnu 0.53.1", + "windows_x86_64_gnullvm 0.53.1", + "windows_x86_64_msvc 0.53.1", +] + +[[package]] +name = "windows-threading" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3949bd5b99cafdf1c7ca86b43ca564028dfe27d66958f2470940f73d86d75b37" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a9d8416fa8b42f5c947f8482c43e7d89e73a173cead56d044f6a56104a6d1b53" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9d782e804c2f632e395708e99a94275910eb9100b2114651e04744e9b125006" + +[[package]] +name = "windows_i686_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" + +[[package]] +name = "windows_i686_gnu" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "960e6da069d81e09becb0ca57a65220ddff016ff2d6af6a223cf372a506593a3" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fa7359d10048f68ab8b09fa71c3daccfb0e9b559aed648a8f95469c27057180c" + +[[package]] +name = "windows_i686_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" + +[[package]] +name = "windows_i686_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e7ac75179f18232fe9c285163565a57ef8d3c89254a30685b57d83a38d326c2" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9c3842cdd74a865a8066ab39c8a7a473c0778a3f29370b5fd6b4b9aa7df4a499" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ffa179e2d07eee8ad8f57493436566c7cc30ac536a3379fdf008f47f6bb7ae1" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6bbff5f0aada427a1e5a6da5f1f98158182f26556f345ac9e04d36d0ebed650" + +[[package]] +name = "wit-bindgen" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7249219f66ced02969388cf2bb044a09756a083d0fab1e566056b04d9fbcaa5" +dependencies = [ + "wit-bindgen-rust-macro", +] + +[[package]] +name = "wit-bindgen" +version = "0.57.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e" + +[[package]] +name = "wit-bindgen-core" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ea61de684c3ea68cb082b7a88508a8b27fcc8b797d738bfc99a82facf1d752dc" +dependencies = [ + "anyhow", + "heck", + "wit-parser", +] + +[[package]] +name = "wit-bindgen-rust" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7c566e0f4b284dd6561c786d9cb0142da491f46a9fbed79ea69cdad5db17f21" +dependencies = [ + "anyhow", + "heck", + "indexmap", + "prettyplease", + "syn", + "wasm-metadata", + "wit-bindgen-core", + "wit-component", +] + +[[package]] +name = "wit-bindgen-rust-macro" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c0f9bfd77e6a48eccf51359e3ae77140a7f50b1e2ebfe62422d8afdaffab17a" +dependencies = [ + "anyhow", + "prettyplease", + "proc-macro2", + "quote", + "syn", + "wit-bindgen-core", + "wit-bindgen-rust", +] + +[[package]] +name = "wit-component" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d66ea20e9553b30172b5e831994e35fbde2d165325bec84fc43dbf6f4eb9cb2" +dependencies = [ + "anyhow", + "bitflags", + "indexmap", + "log", + "serde", + "serde_derive", + "serde_json", + "wasm-encoder", + "wasm-metadata", + "wasmparser", + "wit-parser", +] + +[[package]] +name = "wit-parser" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ecc8ac4bc1dc3381b7f59c34f00b67e18f910c2c0f50015669dde7def656a736" +dependencies = [ + "anyhow", + "id-arena", + "indexmap", + "log", + "semver", + "serde", + "serde_derive", + "serde_json", + "unicode-xid", + "wasmparser", +] + +[[package]] +name = "writeable" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ffae5123b2d3fc086436f8834ae3ab053a283cfac8fe0a0b8eaae044768a4c4" + +[[package]] +name = "yoke" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "709fe23a0424b6a435d82152b1bd3fdfb0833487d5fa90d05d42762a9891fef5" +dependencies = [ + "stable_deref_trait", + "yoke-derive", + "zerofrom", +] + +[[package]] +name = "yoke-derive" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "de844c262c8848816172cef550288e7dc6c7b7814b4ee56b3e1553f275f1858e" +dependencies = [ + "proc-macro2", + "quote", + "syn", + "synstructure", +] + +[[package]] +name = "zerocopy" +version = "0.8.52" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce1022995ff5ff5d841ad7d994facc23098cd40152f2c1d11cd607c6f530653f" +dependencies = [ + "zerocopy-derive", +] + +[[package]] +name = "zerocopy-derive" +version = "0.8.52" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ae7f38b72ec2a254e2b87ef277cf2cd4fb97cbebf944faa6f33354da0867930" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "zerofrom" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ec05a11813ea801ff6d75110ad09cd0824ddba17dfe17128ea0d5f68e6c5272" +dependencies = [ + "zerofrom-derive", +] + +[[package]] +name = "zerofrom-derive" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11532158c46691caf0f2593ea8358fed6bbf68a0315e80aae9bd41fbade684a1" +dependencies = [ + "proc-macro2", + "quote", + "syn", + "synstructure", +] + +[[package]] +name = "zeroize" +version = "1.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b97154e67e32c85465826e8bcc1c59429aaaf107c1e4a9e53c8d8ccd5eff88d0" + +[[package]] +name = "zerotrie" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0f9152d31db0792fa83f70fb2f83148effb5c1f5b8c7686c3459e361d9bc20bf" +dependencies = [ + "displaydoc", + "yoke", + "zerofrom", +] + +[[package]] +name = "zerovec" +version = "0.11.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "90f911cbc359ab6af17377d242225f4d75119aec87ea711a880987b18cd7b239" +dependencies = [ + "yoke", + "zerofrom", + "zerovec-derive", +] + +[[package]] +name = "zerovec-derive" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "625dc425cab0dca6dc3c3319506e6593dcb08a9f387ea3b284dbd52a92c40555" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "zmij" +version = "1.0.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa" diff --git a/provider-openai/Cargo.toml b/provider-openai/Cargo.toml new file mode 100644 index 000000000..2233f7a6d --- /dev/null +++ b/provider-openai/Cargo.toml @@ -0,0 +1,28 @@ +[workspace] + +[package] +name = "provider-openai" +version = "0.1.0" +edition = "2021" +publish = false +license = "Apache-2.0" +description = "OpenAI Chat Completions provider worker behind llm-router." + +[[bin]] +name = "provider-openai" +path = "src/main.rs" + +[lib] +path = "src/lib.rs" + +[dependencies] +llm-router = { path = "../llm-router" } +iii-sdk = "=0.19.2" +serde = { version = "1", features = ["derive"] } +serde_json = "1" +tokio = { version = "1", features = ["macros", "rt-multi-thread", "sync", "time", "signal"] } +futures = "0.3" +reqwest = { version = "0.12", default-features = false, features = ["json", "stream", "rustls-tls"] } + +[dev-dependencies] +uuid = { version = "1", features = ["v4"] } diff --git a/provider-openai/build.rs b/provider-openai/build.rs new file mode 100644 index 000000000..0d01da975 --- /dev/null +++ b/provider-openai/build.rs @@ -0,0 +1,6 @@ +fn main() { + println!( + "cargo:rustc-env=TARGET={}", + std::env::var("TARGET").expect("TARGET must be set by Cargo build scripts") + ); +} diff --git a/provider-openai/iii-permissions.yaml b/provider-openai/iii-permissions.yaml new file mode 100644 index 000000000..fdc012edf --- /dev/null +++ b/provider-openai/iii-permissions.yaml @@ -0,0 +1,10 @@ +# Agent permissions for the provider-openai worker. +# Spec: tech-specs/2026-06-agentic/llm-router.md § Security. +version: 1 + +rules: + # Direct provider calls bypass the router's accounting, budgets, and retry + # policy — never agent-callable. The router invokes these worker-to-worker. + - '!provider::openai::stream' + - '!provider::openai::refresh_models' + - '!provider::openai::on_router_ready' diff --git a/provider-openai/iii.worker.yaml b/provider-openai/iii.worker.yaml new file mode 100644 index 000000000..69a65b8a6 --- /dev/null +++ b/provider-openai/iii.worker.yaml @@ -0,0 +1,7 @@ +iii: v1 +name: provider-openai +language: rust +deploy: binary +manifest: Cargo.toml +bin: provider-openai +description: OpenAI Chat Completions provider worker; implements provider::openai::stream and provider::openai::refresh_models behind llm-router. diff --git a/provider-openai/src/lib.rs b/provider-openai/src/lib.rs new file mode 100644 index 000000000..14ea75289 --- /dev/null +++ b/provider-openai/src/lib.rs @@ -0,0 +1,30 @@ +//! provider-openai: OpenAI Chat Completions provider behind llm-router. +//! Spec: tech-specs/2026-06-agentic/llm-router.md § The provider protocol. + +// pub mod config; +// pub mod curated; +// pub mod discovery; +// pub mod errors; +pub mod manifest; +// pub mod reasoning; +// pub mod register; +// pub mod request; +// pub mod router_client; +// pub mod sse; +// pub mod state; +// pub mod stream_fn; +// pub mod upstream; +// pub mod wire; + +/// The provider id — also the `provider::::*` function prefix and the +/// router config slice key. +pub const PROVIDER_ID: &str = "openai"; + +/// Millisecond timestamps for AssistantMessage frames. +#[allow(dead_code)] +pub(crate) fn now_ms() -> i64 { + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|d| d.as_millis() as i64) + .unwrap_or(0) +} diff --git a/provider-openai/src/main.rs b/provider-openai/src/main.rs new file mode 100644 index 000000000..84f55a126 --- /dev/null +++ b/provider-openai/src/main.rs @@ -0,0 +1,23 @@ +use iii_sdk::{register_worker, InitOptions}; + +#[tokio::main] +async fn main() -> Result<(), Box> { + // Registry publish pipeline: print the manifest JSON and exit. + if std::env::args().nth(1).as_deref() == Some("--manifest") { + println!( + "{}", + serde_json::to_string_pretty(&provider_openai::manifest::build_manifest())? + ); + return Ok(()); + } + + let url = std::env::var("III_WS_URL").unwrap_or_else(|_| "ws://localhost:49134".to_string()); + let iii = register_worker(&url, InitOptions::default()); + + // Task 14 replaces this with register::register_provider(iii.clone()).await?; + println!("[provider-openai] connected to {url}"); + + tokio::signal::ctrl_c().await?; + iii.shutdown(); + Ok(()) +} diff --git a/provider-openai/src/manifest.rs b/provider-openai/src/manifest.rs new file mode 100644 index 000000000..09060ec5d --- /dev/null +++ b/provider-openai/src/manifest.rs @@ -0,0 +1,43 @@ +//! Registry-publish manifest emitted by `provider-openai --manifest` +//! (binary-worker.md § manifest; same shape as provider-anthropic/src/manifest.rs). +use serde::Serialize; + +const DESCRIPTION: &str = "OpenAI Chat Completions provider worker behind llm-router."; + +#[derive(Serialize)] +pub struct ModuleManifest { + pub name: String, + pub version: String, + pub description: String, + pub default_config: serde_json::Value, + pub supported_targets: Vec, +} + +/// Build the manifest for the currently-compiled binary. `default_config` is +/// empty: operator configuration lives in the router's `llm-router` entry. +pub fn build_manifest() -> ModuleManifest { + ModuleManifest { + name: env!("CARGO_PKG_NAME").to_string(), + version: env!("CARGO_PKG_VERSION").to_string(), + description: DESCRIPTION.to_string(), + default_config: serde_json::json!({}), + supported_targets: vec![env!("TARGET").to_string()], + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn json_roundtrip_has_required_fields() { + let m = build_manifest(); + let json = serde_json::to_string_pretty(&m).expect("serialize manifest"); + let parsed: serde_json::Value = serde_json::from_str(&json).expect("valid JSON"); + assert_eq!(parsed["name"], "provider-openai"); + assert!(!parsed["version"].as_str().unwrap().is_empty()); + assert!(!parsed["description"].as_str().unwrap().is_empty()); + assert!(parsed["default_config"].is_object()); + assert!(!parsed["supported_targets"].as_array().unwrap().is_empty()); + } +} From c67f9545d0bd64fae8de8f9c1e3d9c8cb4020c07 Mon Sep 17 00:00:00 2001 From: Ytallo Layon Date: Fri, 12 Jun 2026 09:25:20 -0300 Subject: [PATCH 02/19] feat(provider-openai): map upstream failures to the shared ErrorKind taxonomy --- provider-openai/src/errors.rs | 164 ++++++++++++++++++++++++++++++++++ provider-openai/src/lib.rs | 2 +- 2 files changed, 165 insertions(+), 1 deletion(-) create mode 100644 provider-openai/src/errors.rs diff --git a/provider-openai/src/errors.rs b/provider-openai/src/errors.rs new file mode 100644 index 000000000..03b7e6b74 --- /dev/null +++ b/provider-openai/src/errors.rs @@ -0,0 +1,164 @@ +//! Upstream failure → shared ErrorKind taxonomy (spec § provider protocol +//! rule 5: five providers MUST NOT invent five taxonomies). +use iii_sdk::IIIError; +use llm_router::types::events::ErrorKind; +use serde_json::Value; + +/// Map an OpenAI HTTP status + error body to the shared taxonomy. +/// `None` status = the request never got a response (connect/read failure). +pub fn classify(status: Option, message: &str) -> ErrorKind { + if let Ok(v) = serde_json::from_str::(message) { + if let Some(kind) = classify_openai_value(&v, status) { + return kind; + } + } + match status { + Some(401) | Some(403) => ErrorKind::AuthExpired, + Some(429) => ErrorKind::RateLimited, + Some(413) => ErrorKind::ContextOverflow, + Some(s) if s >= 500 => ErrorKind::Transient, + Some(_) if is_context_overflow_message(message) => ErrorKind::ContextOverflow, + Some(_) => ErrorKind::Permanent, + None => ErrorKind::Transient, + } +} + +/// Map router bus errors surfaced through `router::provider::resolve`. +pub fn classify_bus_error(err: &IIIError) -> ErrorKind { + match err { + IIIError::Remote { code, .. } if code == "router/registration_rejected" => { + ErrorKind::Permanent + } + _ => ErrorKind::Transient, + } +} + +/// The OpenAI error envelope: `{ "error": { "message", "type", "code" } }`. +/// `code` is the precise signal; `type` and the message text are fallbacks. +fn classify_openai_value(v: &Value, status: Option) -> Option { + let err = v.get("error")?; + let code = err.get("code").and_then(Value::as_str).unwrap_or(""); + let err_type = err.get("type").and_then(Value::as_str).unwrap_or(""); + let msg = err.get("message").and_then(Value::as_str).unwrap_or(""); + match code { + "context_length_exceeded" => return Some(ErrorKind::ContextOverflow), + // 429 with insufficient_quota is a billing wall, not a rate limit: + // the router's retry/backoff cannot fix it. + "insufficient_quota" => return Some(ErrorKind::Permanent), + "invalid_api_key" | "account_deactivated" => return Some(ErrorKind::AuthExpired), + _ => {} + } + match err_type { + "authentication_error" | "permission_error" => Some(ErrorKind::AuthExpired), + "rate_limit_error" => Some(ErrorKind::RateLimited), + "server_error" => Some(ErrorKind::Transient), + "invalid_request_error" => { + if status == Some(413) || is_context_overflow_message(msg) { + Some(ErrorKind::ContextOverflow) + } else { + Some(ErrorKind::Permanent) + } + } + _ => None, + } +} + +fn is_context_overflow_message(message: &str) -> bool { + let m = message.to_lowercase(); + m.contains("context length") + || m.contains("maximum context") + || m.contains("too many tokens") + || m.contains("exceeds context") + || m.contains("context window") +} + +/// Invalid handler input surfaced on the bus in the `{ code, message }` +/// convention (same shape RouterError uses on the router side). +pub fn invalid_request(message: impl Into) -> IIIError { + IIIError::Remote { + code: "provider/invalid_request".to_string(), + message: message.into(), + stacktrace: None, + } +} + +/// Discovery hit a transient upstream failure — caller keeps the old slice. +pub fn upstream_unavailable(message: impl Into) -> IIIError { + IIIError::Remote { + code: "provider/upstream_unavailable".to_string(), + message: message.into(), + stacktrace: None, + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn status_codes_map_to_the_shared_taxonomy() { + assert_eq!(classify(Some(401), ""), ErrorKind::AuthExpired); + assert_eq!(classify(Some(403), ""), ErrorKind::AuthExpired); + assert_eq!(classify(Some(429), ""), ErrorKind::RateLimited); + assert_eq!(classify(Some(413), ""), ErrorKind::ContextOverflow); + assert_eq!(classify(Some(500), ""), ErrorKind::Transient); + assert_eq!(classify(Some(503), ""), ErrorKind::Transient); + assert_eq!(classify(Some(400), "bad request"), ErrorKind::Permanent); + assert_eq!(classify(None, "connect refused"), ErrorKind::Transient); + } + + #[test] + fn openai_envelope_codes_are_honored() { + let body = r#"{"error":{"message":"This model's maximum context length is 400000 tokens.","type":"invalid_request_error","code":"context_length_exceeded"}}"#; + assert_eq!(classify(Some(400), body), ErrorKind::ContextOverflow); + + let body = r#"{"error":{"message":"You exceeded your current quota.","type":"insufficient_quota","code":"insufficient_quota"}}"#; + assert_eq!(classify(Some(429), body), ErrorKind::Permanent); + + let body = r#"{"error":{"message":"Incorrect API key provided.","type":"invalid_request_error","code":"invalid_api_key"}}"#; + assert_eq!(classify(Some(401), body), ErrorKind::AuthExpired); + } + + #[test] + fn context_overflow_detected_from_message_on_4xx() { + assert_eq!( + classify(Some(400), "This model's maximum context length is 128000 tokens"), + ErrorKind::ContextOverflow + ); + // generic "context" in tool validation must not false-positive + assert_eq!( + classify(Some(400), r#"tool_call_id "ctx-1" not found in context"#), + ErrorKind::Permanent + ); + // 5xx wins over message sniffing + assert_eq!(classify(Some(500), "context blah"), ErrorKind::Transient); + } + + #[test] + fn registration_rejected_is_permanent_on_the_bus() { + let err = IIIError::Remote { + code: "router/registration_rejected".into(), + message: "bad token".into(), + stacktrace: None, + }; + assert_eq!(classify_bus_error(&err), ErrorKind::Permanent); + let err = IIIError::Remote { + code: "engine/timeout".into(), + message: "t".into(), + stacktrace: None, + }; + assert_eq!(classify_bus_error(&err), ErrorKind::Transient); + } + + #[test] + fn bus_error_codes_are_worker_prefixed() { + match invalid_request("x") { + IIIError::Remote { code, .. } => assert_eq!(code, "provider/invalid_request"), + other => panic!("want Remote, got {other:?}"), + } + match upstream_unavailable("x") { + IIIError::Remote { code, .. } => assert_eq!(code, "provider/upstream_unavailable"), + other => panic!("want Remote, got {other:?}"), + } + } +} diff --git a/provider-openai/src/lib.rs b/provider-openai/src/lib.rs index 14ea75289..aeba9ff02 100644 --- a/provider-openai/src/lib.rs +++ b/provider-openai/src/lib.rs @@ -4,7 +4,7 @@ // pub mod config; // pub mod curated; // pub mod discovery; -// pub mod errors; +pub mod errors; pub mod manifest; // pub mod reasoning; // pub mod register; From e81bb404399ff17cfdcd00d53e048661dd133f30 Mon Sep 17 00:00:00 2001 From: Ytallo Layon Date: Fri, 12 Jun 2026 09:25:53 -0300 Subject: [PATCH 03/19] feat(provider-openai): tool-name codec for the OpenAI function-name charset --- provider-openai/src/lib.rs | 2 +- provider-openai/src/wire/mod.rs | 4 ++++ provider-openai/src/wire/names.rs | 28 ++++++++++++++++++++++++++++ 3 files changed, 33 insertions(+), 1 deletion(-) create mode 100644 provider-openai/src/wire/mod.rs create mode 100644 provider-openai/src/wire/names.rs diff --git a/provider-openai/src/lib.rs b/provider-openai/src/lib.rs index aeba9ff02..bc3e9dcab 100644 --- a/provider-openai/src/lib.rs +++ b/provider-openai/src/lib.rs @@ -14,7 +14,7 @@ pub mod manifest; // pub mod state; // pub mod stream_fn; // pub mod upstream; -// pub mod wire; +pub mod wire; /// The provider id — also the `provider::::*` function prefix and the /// router config slice key. diff --git a/provider-openai/src/wire/mod.rs b/provider-openai/src/wire/mod.rs new file mode 100644 index 000000000..bc8cdce9e --- /dev/null +++ b/provider-openai/src/wire/mod.rs @@ -0,0 +1,4 @@ +//! AgentMessage/AgentFunction → OpenAI Chat Completions wire shapes. +pub mod names; +// pub mod messages; +// pub mod tools; diff --git a/provider-openai/src/wire/names.rs b/provider-openai/src/wire/names.rs new file mode 100644 index 000000000..7bc372f89 --- /dev/null +++ b/provider-openai/src/wire/names.rs @@ -0,0 +1,28 @@ +//! iii function ids ↔ OpenAI function names. OpenAI enforces +//! `^[a-zA-Z0-9_-]{1,64}$`; bus ids use `::` separators. + +pub fn encode_tool_name(name: &str) -> String { + name.replace("::", "__") +} + +pub fn decode_tool_name(name: &str) -> String { + name.replace("__", "::") +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn round_trips_bus_ids() { + assert_eq!(encode_tool_name("web::fetch"), "web__fetch"); + assert_eq!(decode_tool_name("web__fetch"), "web::fetch"); + assert_eq!(decode_tool_name(&encode_tool_name("a::b::c")), "a::b::c"); + } + + #[test] + fn plain_names_pass_through() { + assert_eq!(encode_tool_name("submit_result"), "submit_result"); + assert_eq!(decode_tool_name("submit_result"), "submit_result"); + } +} From a7655d0d2640cfa7f5dc3eca11f6eb7dd8dba638 Mon Sep 17 00:00:00 2001 From: Ytallo Layon Date: Fri, 12 Jun 2026 09:26:17 -0300 Subject: [PATCH 04/19] =?UTF-8?q?feat(provider-openai):=20AgentFunction=20?= =?UTF-8?q?=E2=86=92=20OpenAI=20tools=20adapter?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- provider-openai/src/wire/mod.rs | 2 +- provider-openai/src/wire/tools.rs | 51 +++++++++++++++++++++++++++++++ 2 files changed, 52 insertions(+), 1 deletion(-) create mode 100644 provider-openai/src/wire/tools.rs diff --git a/provider-openai/src/wire/mod.rs b/provider-openai/src/wire/mod.rs index bc8cdce9e..87f1736da 100644 --- a/provider-openai/src/wire/mod.rs +++ b/provider-openai/src/wire/mod.rs @@ -1,4 +1,4 @@ //! AgentMessage/AgentFunction → OpenAI Chat Completions wire shapes. pub mod names; // pub mod messages; -// pub mod tools; +pub mod tools; diff --git a/provider-openai/src/wire/tools.rs b/provider-openai/src/wire/tools.rs new file mode 100644 index 000000000..21b46cbbc --- /dev/null +++ b/provider-openai/src/wire/tools.rs @@ -0,0 +1,51 @@ +//! AgentFunction (iii function invocation schemas) → OpenAI `tools` array. +use crate::wire::names::encode_tool_name; +use llm_router::types::model::AgentFunction; +use serde_json::{json, Value}; + +pub fn functions_to_wire(tools: &[AgentFunction]) -> Vec { + tools + .iter() + .map(|t| { + json!({ + "type": "function", + "function": { + "name": encode_tool_name(&t.name), + "description": t.description, + "parameters": t.parameters, + } + }) + }) + .collect() +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn maps_name_description_and_schema_under_function_envelope() { + let tools = vec![AgentFunction { + name: "agent::trigger".into(), + description: "Invoke an iii function".into(), + parameters: json!({ "type": "object", "properties": { "id": { "type": "string" } } }), + label: None, + execution_mode: None, + }]; + let wire = functions_to_wire(&tools); + assert_eq!(wire.len(), 1); + assert_eq!(wire[0]["type"], "function"); + assert_eq!(wire[0]["function"]["name"], "agent__trigger"); + assert_eq!(wire[0]["function"]["description"], "Invoke an iii function"); + assert_eq!(wire[0]["function"]["parameters"]["type"], "object"); + assert!( + wire[0]["function"].get("label").is_none(), + "label/execution_mode are iii-side only" + ); + } + + #[test] + fn empty_input_yields_empty_array() { + assert!(functions_to_wire(&[]).is_empty()); + } +} From 0dc8e57eaa76de8cc120410b573f7540b2a04b38 Mon Sep 17 00:00:00 2001 From: Ytallo Layon Date: Fri, 12 Jun 2026 09:27:33 -0300 Subject: [PATCH 05/19] =?UTF-8?q?feat(provider-openai):=20AgentMessage=20?= =?UTF-8?q?=E2=86=92=20Chat=20Completions=20adapter=20with=20boundary=20sa?= =?UTF-8?q?nitization?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- provider-openai/src/wire/messages.rs | 372 +++++++++++++++++++++++++++ provider-openai/src/wire/mod.rs | 2 +- 2 files changed, 373 insertions(+), 1 deletion(-) create mode 100644 provider-openai/src/wire/messages.rs diff --git a/provider-openai/src/wire/messages.rs b/provider-openai/src/wire/messages.rs new file mode 100644 index 000000000..8988aa67b --- /dev/null +++ b/provider-openai/src/wire/messages.rs @@ -0,0 +1,372 @@ +//! AgentMessage[] → OpenAI Chat Completions wire shape. Port of the TS +//! provider's wire-messages.ts with the orphan/dedup boundary sanitization +//! from provider-anthropic (each rule traces to a production incident). +use crate::wire::names::encode_tool_name; +use llm_router::types::content::ContentBlock; +use llm_router::types::messages::{AgentMessage, FunctionResultMessage}; +use serde_json::{json, Value}; +use std::collections::HashSet; + +/// Body of the synthetic `role: "tool"` row injected for an orphan tool call +/// (OpenAI rejects assistant `tool_calls` without a tool message per id). +const ORPHAN_TOOL_PLACEHOLDER: &str = + "Tool call was interrupted before completing. Continue without its output."; + +/// Flat text body for a tool message; `details.status == "denied"` gets the +/// `[PERMISSION_DENIED]` marker + single-line JSON envelope so the LLM can +/// parse the structured denial (port of harness/src/types/wire.ts; same body +/// as provider-anthropic/src/wire/messages.rs). +fn format_function_result_content(m: &FunctionResultMessage) -> String { + let body = m + .content + .iter() + .filter_map(|c| match c { + ContentBlock::Text { text } => Some(text.as_str()), + _ => None, + }) + .collect::>() + .join("\n"); + let denied = m.details.get("status").and_then(Value::as_str) == Some("denied"); + if denied { + let envelope = serde_json::to_string(&m.details).unwrap_or_else(|_| "{}".into()); + format!("[PERMISSION_DENIED]\n{envelope}\n\n{body}") + } else { + body + } +} + +/// User content: flat string when text-only; the content-part array form +/// when images are present (`image_url` data URIs). +fn user_content_to_wire(content: &[ContentBlock]) -> Value { + let has_images = content.iter().any(|c| matches!(c, ContentBlock::Image { .. })); + let text = content + .iter() + .filter_map(|c| match c { + ContentBlock::Text { text } => Some(text.as_str()), + _ => None, + }) + .collect::>() + .join("\n"); + if !has_images { + return Value::String(text); + } + let mut parts = Vec::new(); + if !text.is_empty() { + parts.push(json!({ "type": "text", "text": text })); + } + for c in content { + if let ContentBlock::Image { mime, data } = c { + parts.push(json!({ + "type": "image_url", + "image_url": { "url": format!("data:{mime};base64,{data}") } + })); + } + } + Value::Array(parts) +} + +fn tool_row(tool_call_id: &str, content: String) -> Value { + json!({ "role": "tool", "tool_call_id": tool_call_id, "content": content }) +} + +/// Latest-wins dedup: replace an existing `role:"tool"` row with the same id +/// (strict gateways reject duplicates; lenient ones silently overwrite). +fn upsert_tool_row(out: &mut Vec, row: Value) { + let id = row.get("tool_call_id").and_then(Value::as_str).unwrap_or(""); + let existing = out.iter().position(|e| { + e.get("role").and_then(Value::as_str) == Some("tool") + && e.get("tool_call_id").and_then(Value::as_str) == Some(id) + }); + match existing { + Some(i) => out[i] = row, + None => out.push(row), + } +} + +pub fn to_wire_messages(messages: &[AgentMessage], system_prompt: &str) -> Vec { + let mut out: Vec = Vec::new(); + if !system_prompt.is_empty() { + out.push(json!({ "role": "system", "content": system_prompt })); + } + + // Pre-pass: every function_call_id that has a matching function_result + // anywhere in the conversation. tool_calls NOT in this set get a synthetic + // placeholder so OpenAI never sees an unanswered tool_call_id. + let mut resolved_ids: HashSet = messages + .iter() + .filter_map(|m| match m { + AgentMessage::FunctionResult(r) => Some(r.function_call_id.clone()), + _ => None, + }) + .collect(); + + for m in messages { + match m { + AgentMessage::User(u) => { + out.push(json!({ "role": "user", "content": user_content_to_wire(&u.content) })); + } + AgentMessage::Assistant(a) => { + let text = a + .content + .iter() + .filter_map(|c| match c { + ContentBlock::Text { text } => Some(text.as_str()), + _ => None, + }) + .collect::>() + .join("\n"); + let tool_calls: Vec = a + .content + .iter() + .filter_map(|c| match c { + ContentBlock::FunctionCall { id, function_id, arguments } => { + Some(json!({ + "id": id, + "type": "function", + "function": { + "name": encode_tool_name(function_id), + "arguments": arguments.to_string(), + } + })) + } + // Thinking/RedactedThinking: no reasoning replay on + // Chat Completions; images never appear in assistant + // turns from this provider. + _ => None, + }) + .collect(); + let mut entry = json!({ "role": "assistant" }); + if !text.is_empty() { + entry["content"] = Value::String(text); + } + if !tool_calls.is_empty() { + entry["tool_calls"] = Value::Array(tool_calls); + } + out.push(entry); + // Placeholders for orphans go directly after the assistant + // row — exactly where OpenAI expects the tool messages. + for block in &a.content { + if let ContentBlock::FunctionCall { id, .. } = block { + if !resolved_ids.contains(id) { + out.push(tool_row(id, ORPHAN_TOOL_PLACEHOLDER.to_string())); + resolved_ids.insert(id.clone()); + } + } + } + } + AgentMessage::FunctionResult(r) => { + // Images inside results are dropped: Chat Completions tool + // messages accept text content only. + upsert_tool_row( + &mut out, + tool_row(&r.function_call_id, format_function_result_content(r)), + ); + } + // Never reach the provider per spec (stripped upstream); defensive. + AgentMessage::Custom(_) => {} + } + } + out +} + +#[cfg(test)] +mod tests { + use super::*; + use llm_router::types::events::StopReason; + use llm_router::types::messages::{ + AssistantMessage, AssistantRoleTag, CustomMessage, CustomRoleTag, + FunctionResultMessage, FunctionResultRoleTag, UserMessage, UserRoleTag, + }; + + fn user(content: Vec) -> AgentMessage { + AgentMessage::User(UserMessage { role: UserRoleTag::User, content, timestamp: 1 }) + } + fn assistant(content: Vec) -> AgentMessage { + AgentMessage::Assistant(AssistantMessage { + role: AssistantRoleTag::Assistant, + content, + stop_reason: StopReason::End, + native_stop_reason: None, + error_message: None, + error_kind: None, + warnings: None, + usage: None, + model: "m".into(), + provider: "openai".into(), + timestamp: 2, + }) + } + fn result(id: &str, text: &str, details: Value) -> AgentMessage { + AgentMessage::FunctionResult(FunctionResultMessage { + role: FunctionResultRoleTag::FunctionResult, + function_call_id: id.into(), + function_id: "shell::exec".into(), + content: vec![ContentBlock::Text { text: text.into() }], + details, + is_error: false, + timestamp: 3, + }) + } + fn call(id: &str) -> ContentBlock { + ContentBlock::FunctionCall { + id: id.into(), + function_id: "shell::exec".into(), + arguments: json!({ "cmd": "ls" }), + } + } + + #[test] + fn system_prompt_is_the_first_row_when_present() { + let wire = to_wire_messages( + &[user(vec![ContentBlock::Text { text: "hi".into() }])], + "be brief", + ); + assert_eq!(wire.len(), 2); + assert_eq!(wire[0]["role"], "system"); + assert_eq!(wire[0]["content"], "be brief"); + assert_eq!(wire[1]["role"], "user"); + // empty prompt: omitted entirely + let wire = to_wire_messages(&[user(vec![ContentBlock::Text { text: "hi".into() }])], ""); + assert_eq!(wire.len(), 1); + } + + #[test] + fn assistant_function_calls_become_tool_calls_with_encoded_names() { + let wire = to_wire_messages( + &[ + assistant(vec![ContentBlock::Text { text: "running".into() }, call("t1")]), + result("t1", "ok", json!({})), + ], + "", + ); + assert_eq!(wire.len(), 2); + assert_eq!(wire[0]["role"], "assistant"); + assert_eq!(wire[0]["content"], "running"); + assert_eq!(wire[0]["tool_calls"][0]["id"], "t1"); + assert_eq!(wire[0]["tool_calls"][0]["type"], "function"); + assert_eq!(wire[0]["tool_calls"][0]["function"]["name"], "shell__exec"); + assert_eq!( + wire[0]["tool_calls"][0]["function"]["arguments"], + r#"{"cmd":"ls"}"# + ); + assert_eq!(wire[1]["role"], "tool"); + assert_eq!(wire[1]["tool_call_id"], "t1"); + assert_eq!(wire[1]["content"], "ok"); + assert!(wire[1].get("is_error").is_none(), "nonstandard field never shipped"); + } + + #[test] + fn orphan_tool_call_gets_synthetic_placeholder_directly_after_assistant() { + let wire = to_wire_messages( + &[ + assistant(vec![call("orphan")]), + user(vec![ContentBlock::Text { text: "hi".into() }]), + ], + "", + ); + assert_eq!(wire.len(), 3); + assert_eq!(wire[1]["role"], "tool"); + assert_eq!(wire[1]["tool_call_id"], "orphan"); + assert!(wire[1]["content"].as_str().unwrap().contains("interrupted")); + assert_eq!(wire[2]["role"], "user"); + } + + #[test] + fn duplicate_tool_results_dedup_latest_wins() { + let wire = to_wire_messages( + &[ + assistant(vec![call("t1")]), + result("t1", "first", json!({})), + result("t1", "second", json!({})), + ], + "", + ); + let tool_rows: Vec<&Value> = wire + .iter() + .filter(|r| r["role"] == "tool") + .collect(); + assert_eq!(tool_rows.len(), 1); + assert_eq!(tool_rows[0]["content"], "second"); + } + + #[test] + fn denied_result_carries_permission_envelope() { + let wire = to_wire_messages( + &[ + assistant(vec![call("t1")]), + result("t1", "nope", json!({ "status": "denied", "reason": "operator" })), + ], + "", + ); + let body = wire[1]["content"].as_str().unwrap(); + assert!(body.starts_with("[PERMISSION_DENIED]\n")); + assert!(body.contains("\"status\":\"denied\"")); + assert!(body.ends_with("\n\nnope")); + } + + #[test] + fn user_images_use_the_content_part_array_with_data_uri() { + let wire = to_wire_messages( + &[user(vec![ + ContentBlock::Text { text: "what is this".into() }, + ContentBlock::Image { mime: "image/png".into(), data: "QUJD".into() }, + ])], + "", + ); + let parts = wire[0]["content"].as_array().unwrap(); + assert_eq!(parts[0]["type"], "text"); + assert_eq!(parts[1]["type"], "image_url"); + assert_eq!(parts[1]["image_url"]["url"], "data:image/png;base64,QUJD"); + // text-only stays a flat string + let wire = to_wire_messages(&[user(vec![ContentBlock::Text { text: "hi".into() }])], ""); + assert!(wire[0]["content"].is_string()); + } + + #[test] + fn thinking_blocks_and_result_images_are_dropped() { + let wire = to_wire_messages( + &[assistant(vec![ + ContentBlock::Thinking { text: "hmm".into(), signature: Some("sig".into()) }, + ContentBlock::Text { text: "answer".into() }, + ])], + "", + ); + assert_eq!(wire[0]["content"], "answer"); + assert!(wire[0].get("tool_calls").is_none()); + + let msg = AgentMessage::FunctionResult(FunctionResultMessage { + role: FunctionResultRoleTag::FunctionResult, + function_call_id: "t1".into(), + function_id: "web::fetch".into(), + content: vec![ + ContentBlock::Text { text: "page".into() }, + ContentBlock::Image { mime: "image/png".into(), data: "QUJD".into() }, + ], + details: json!({}), + is_error: false, + timestamp: 3, + }); + let wire = to_wire_messages(&[assistant(vec![call("t1")]), msg], ""); + assert_eq!(wire[1]["content"], "page", "tool content is text-only"); + } + + #[test] + fn custom_messages_are_skipped() { + let wire = to_wire_messages( + &[ + AgentMessage::Custom(CustomMessage { + role: CustomRoleTag::Custom, + custom_type: "note".into(), + content: vec![], + display: None, + details: None, + timestamp: 1, + }), + user(vec![ContentBlock::Text { text: "hi".into() }]), + ], + "", + ); + assert_eq!(wire.len(), 1); + assert_eq!(wire[0]["role"], "user"); + } +} diff --git a/provider-openai/src/wire/mod.rs b/provider-openai/src/wire/mod.rs index 87f1736da..909e4732a 100644 --- a/provider-openai/src/wire/mod.rs +++ b/provider-openai/src/wire/mod.rs @@ -1,4 +1,4 @@ //! AgentMessage/AgentFunction → OpenAI Chat Completions wire shapes. pub mod names; -// pub mod messages; +pub mod messages; pub mod tools; From 032a040db355f33ca77ec40d76be01d56e15d6c2 Mon Sep 17 00:00:00 2001 From: Ytallo Layon Date: Fri, 12 Jun 2026 09:28:21 -0300 Subject: [PATCH 06/19] feat(provider-openai): per-family reasoning_effort mapping --- provider-openai/src/lib.rs | 2 +- provider-openai/src/reasoning.rs | 145 +++++++++++++++++++++++++++++++ 2 files changed, 146 insertions(+), 1 deletion(-) create mode 100644 provider-openai/src/reasoning.rs diff --git a/provider-openai/src/lib.rs b/provider-openai/src/lib.rs index bc3e9dcab..8abc1a4c5 100644 --- a/provider-openai/src/lib.rs +++ b/provider-openai/src/lib.rs @@ -6,7 +6,7 @@ // pub mod discovery; pub mod errors; pub mod manifest; -// pub mod reasoning; +pub mod reasoning; // pub mod register; // pub mod request; // pub mod router_client; diff --git a/provider-openai/src/reasoning.rs b/provider-openai/src/reasoning.rs new file mode 100644 index 000000000..f08597032 --- /dev/null +++ b/provider-openai/src/reasoning.rs @@ -0,0 +1,145 @@ +//! thinking_level → Chat Completions `reasoning_effort`, per model family. +//! Each ladder branch traces to a documented 400 from the API — a wrong +//! effort string fails the whole request. +use llm_router::types::model::ThinkingLevel; + +/// Full effort vocabulary in ascending order (superset across families). +const EFFORT_ORDER: [&str; 6] = ["none", "minimal", "low", "medium", "high", "xhigh"]; + +/// Reasoning model detection: the catalog's `supports_thinking` flag wins; +/// id-pattern fallback for models the catalog doesn't know. +pub fn is_reasoning_model(model: &str, catalog_supports_thinking: Option) -> bool { + if let Some(flag) = catalog_supports_thinking { + return flag; + } + let id = model.to_ascii_lowercase(); + id.starts_with("gpt-5") || id.starts_with("o1") || id.starts_with("o3") || id.starts_with("o4") +} + +/// Efforts the model family accepts; empty = don't send the param. +fn supported_efforts(model: &str) -> &'static [&'static str] { + let id = model.to_ascii_lowercase(); + if !(id.starts_with("gpt-5") || id.starts_with("o1") || id.starts_with("o3") || id.starts_with("o4")) { + return &[]; + } + // The o1 family (o1, o1-mini, o1-preview, o1-pro) rejects reasoning_effort + // on Chat Completions with a 400 — even though catalogs flag it as a + // reasoning model. Omit the param entirely. + if id.starts_with("o1") { + return &[]; + } + // Chat-tuned variants only support the fixed default; omit the param. + if id.contains("chat") { + return &[]; + } + // gpt-5-pro / gpt-5.x-pro: high only. + if id.contains("pro") { + return &["high"]; + } + // gpt-5.1: none/low/medium/high; gpt-5.2+ adds xhigh. + if let Some(minor) = gpt5_minor(&id) { + return if minor >= 2 { + &["none", "low", "medium", "high", "xhigh"] + } else { + &["none", "low", "medium", "high"] + }; + } + // gpt-5 base family (mini/nano/codex). + if id.starts_with("gpt-5") { + return &["minimal", "low", "medium", "high"]; + } + // o-series (o3/o4). + &["low", "medium", "high"] +} + +/// `gpt-5.…` → minor version number. +fn gpt5_minor(id: &str) -> Option { + let rest = id.strip_prefix("gpt-5.")?; + let digits: String = rest.chars().take_while(|c| c.is_ascii_digit()).collect(); + digits.parse().ok() +} + +fn level_str(level: ThinkingLevel) -> &'static str { + match level { + ThinkingLevel::Minimal => "minimal", + ThinkingLevel::Low => "low", + ThinkingLevel::Medium => "medium", + ThinkingLevel::High => "high", + ThinkingLevel::Xhigh => "xhigh", + } +} + +/// Effort for a reasoning model: the requested level when the family +/// supports it, else the nearest supported effort below (then above). +/// `None` when the family takes no effort param or no level was requested. +pub fn reasoning_effort_for( + level: Option, + model: &str, +) -> Option<&'static str> { + let ladder = supported_efforts(model); + if ladder.is_empty() { + return None; + } + let want = level_str(level?); + if ladder.contains(&want) { + return Some(want); + } + let want_idx = EFFORT_ORDER.iter().position(|e| *e == want)?; + for &candidate in EFFORT_ORDER[..want_idx].iter().rev() { + if ladder.contains(&candidate) { + return Some(candidate); + } + } + for &candidate in EFFORT_ORDER[want_idx + 1..].iter() { + if ladder.contains(&candidate) { + return Some(candidate); + } + } + None +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn catalog_flag_wins_over_id_pattern() { + assert!(is_reasoning_model("weird-model", Some(true))); + assert!(!is_reasoning_model("gpt-5.2", Some(false))); + assert!(is_reasoning_model("gpt-5.2", None)); + assert!(is_reasoning_model("o4-mini", None)); + assert!(!is_reasoning_model("gpt-4o", None)); + } + + #[test] + fn exact_level_passes_through_per_family() { + assert_eq!(reasoning_effort_for(Some(ThinkingLevel::High), "gpt-5.2"), Some("high")); + assert_eq!(reasoning_effort_for(Some(ThinkingLevel::Xhigh), "gpt-5.2"), Some("xhigh")); + assert_eq!(reasoning_effort_for(Some(ThinkingLevel::Medium), "o3-mini"), Some("medium")); + assert_eq!(reasoning_effort_for(Some(ThinkingLevel::Minimal), "gpt-5-mini"), Some("minimal")); + } + + #[test] + fn unsupported_level_degrades_to_nearest_below_then_above() { + // gpt-5.1 has no xhigh → nearest below is high + assert_eq!(reasoning_effort_for(Some(ThinkingLevel::Xhigh), "gpt-5.1"), Some("high")); + // gpt-5.1 has no minimal → below is none + assert_eq!(reasoning_effort_for(Some(ThinkingLevel::Minimal), "gpt-5.1"), Some("none")); + // o3 has no minimal and no none below → above is low + assert_eq!(reasoning_effort_for(Some(ThinkingLevel::Minimal), "o3"), Some("low")); + // pro: everything lands on high + assert_eq!(reasoning_effort_for(Some(ThinkingLevel::Low), "gpt-5-pro"), Some("high")); + } + + #[test] + fn families_that_reject_the_param_get_none() { + assert_eq!(reasoning_effort_for(Some(ThinkingLevel::High), "o1-preview"), None); + assert_eq!(reasoning_effort_for(Some(ThinkingLevel::High), "gpt-5-chat-latest"), None); + assert_eq!(reasoning_effort_for(Some(ThinkingLevel::High), "gpt-4o"), None); + } + + #[test] + fn absent_level_omits_the_param() { + assert_eq!(reasoning_effort_for(None, "gpt-5.2"), None); + } +} From 188a590b4774fceac95fcedbc6c163af509cfa1e Mon Sep 17 00:00:00 2001 From: Ytallo Layon Date: Fri, 12 Jun 2026 09:29:02 -0300 Subject: [PATCH 07/19] chore(provider-openai): apply rustfmt and clippy fixes across tasks 2-6 --- provider-openai/src/errors.rs | 5 +- provider-openai/src/reasoning.rs | 88 ++++++++++++++++++--------- provider-openai/src/wire/messages.rs | 91 +++++++++++++++++++--------- provider-openai/src/wire/mod.rs | 2 +- 4 files changed, 129 insertions(+), 57 deletions(-) diff --git a/provider-openai/src/errors.rs b/provider-openai/src/errors.rs index 03b7e6b74..ee6699b2d 100644 --- a/provider-openai/src/errors.rs +++ b/provider-openai/src/errors.rs @@ -122,7 +122,10 @@ mod tests { #[test] fn context_overflow_detected_from_message_on_4xx() { assert_eq!( - classify(Some(400), "This model's maximum context length is 128000 tokens"), + classify( + Some(400), + "This model's maximum context length is 128000 tokens" + ), ErrorKind::ContextOverflow ); // generic "context" in tool validation must not false-positive diff --git a/provider-openai/src/reasoning.rs b/provider-openai/src/reasoning.rs index f08597032..7f2a24fd3 100644 --- a/provider-openai/src/reasoning.rs +++ b/provider-openai/src/reasoning.rs @@ -19,7 +19,11 @@ pub fn is_reasoning_model(model: &str, catalog_supports_thinking: Option) /// Efforts the model family accepts; empty = don't send the param. fn supported_efforts(model: &str) -> &'static [&'static str] { let id = model.to_ascii_lowercase(); - if !(id.starts_with("gpt-5") || id.starts_with("o1") || id.starts_with("o3") || id.starts_with("o4")) { + if !(id.starts_with("gpt-5") + || id.starts_with("o1") + || id.starts_with("o3") + || id.starts_with("o4")) + { return &[]; } // The o1 family (o1, o1-mini, o1-preview, o1-pro) rejects reasoning_effort @@ -72,10 +76,7 @@ fn level_str(level: ThinkingLevel) -> &'static str { /// Effort for a reasoning model: the requested level when the family /// supports it, else the nearest supported effort below (then above). /// `None` when the family takes no effort param or no level was requested. -pub fn reasoning_effort_for( - level: Option, - model: &str, -) -> Option<&'static str> { +pub fn reasoning_effort_for(level: Option, model: &str) -> Option<&'static str> { let ladder = supported_efforts(model); if ladder.is_empty() { return None; @@ -85,17 +86,17 @@ pub fn reasoning_effort_for( return Some(want); } let want_idx = EFFORT_ORDER.iter().position(|e| *e == want)?; - for &candidate in EFFORT_ORDER[..want_idx].iter().rev() { - if ladder.contains(&candidate) { - return Some(candidate); - } - } - for &candidate in EFFORT_ORDER[want_idx + 1..].iter() { - if ladder.contains(&candidate) { - return Some(candidate); - } - } - None + if let Some(&c) = EFFORT_ORDER[..want_idx] + .iter() + .rev() + .find(|&&c| ladder.contains(&c)) + { + return Some(c); + } + EFFORT_ORDER[want_idx + 1..] + .iter() + .find(|&&c| ladder.contains(&c)) + .copied() } #[cfg(test)] @@ -113,29 +114,62 @@ mod tests { #[test] fn exact_level_passes_through_per_family() { - assert_eq!(reasoning_effort_for(Some(ThinkingLevel::High), "gpt-5.2"), Some("high")); - assert_eq!(reasoning_effort_for(Some(ThinkingLevel::Xhigh), "gpt-5.2"), Some("xhigh")); - assert_eq!(reasoning_effort_for(Some(ThinkingLevel::Medium), "o3-mini"), Some("medium")); - assert_eq!(reasoning_effort_for(Some(ThinkingLevel::Minimal), "gpt-5-mini"), Some("minimal")); + assert_eq!( + reasoning_effort_for(Some(ThinkingLevel::High), "gpt-5.2"), + Some("high") + ); + assert_eq!( + reasoning_effort_for(Some(ThinkingLevel::Xhigh), "gpt-5.2"), + Some("xhigh") + ); + assert_eq!( + reasoning_effort_for(Some(ThinkingLevel::Medium), "o3-mini"), + Some("medium") + ); + assert_eq!( + reasoning_effort_for(Some(ThinkingLevel::Minimal), "gpt-5-mini"), + Some("minimal") + ); } #[test] fn unsupported_level_degrades_to_nearest_below_then_above() { // gpt-5.1 has no xhigh → nearest below is high - assert_eq!(reasoning_effort_for(Some(ThinkingLevel::Xhigh), "gpt-5.1"), Some("high")); + assert_eq!( + reasoning_effort_for(Some(ThinkingLevel::Xhigh), "gpt-5.1"), + Some("high") + ); // gpt-5.1 has no minimal → below is none - assert_eq!(reasoning_effort_for(Some(ThinkingLevel::Minimal), "gpt-5.1"), Some("none")); + assert_eq!( + reasoning_effort_for(Some(ThinkingLevel::Minimal), "gpt-5.1"), + Some("none") + ); // o3 has no minimal and no none below → above is low - assert_eq!(reasoning_effort_for(Some(ThinkingLevel::Minimal), "o3"), Some("low")); + assert_eq!( + reasoning_effort_for(Some(ThinkingLevel::Minimal), "o3"), + Some("low") + ); // pro: everything lands on high - assert_eq!(reasoning_effort_for(Some(ThinkingLevel::Low), "gpt-5-pro"), Some("high")); + assert_eq!( + reasoning_effort_for(Some(ThinkingLevel::Low), "gpt-5-pro"), + Some("high") + ); } #[test] fn families_that_reject_the_param_get_none() { - assert_eq!(reasoning_effort_for(Some(ThinkingLevel::High), "o1-preview"), None); - assert_eq!(reasoning_effort_for(Some(ThinkingLevel::High), "gpt-5-chat-latest"), None); - assert_eq!(reasoning_effort_for(Some(ThinkingLevel::High), "gpt-4o"), None); + assert_eq!( + reasoning_effort_for(Some(ThinkingLevel::High), "o1-preview"), + None + ); + assert_eq!( + reasoning_effort_for(Some(ThinkingLevel::High), "gpt-5-chat-latest"), + None + ); + assert_eq!( + reasoning_effort_for(Some(ThinkingLevel::High), "gpt-4o"), + None + ); } #[test] diff --git a/provider-openai/src/wire/messages.rs b/provider-openai/src/wire/messages.rs index 8988aa67b..13750a2af 100644 --- a/provider-openai/src/wire/messages.rs +++ b/provider-openai/src/wire/messages.rs @@ -38,7 +38,9 @@ fn format_function_result_content(m: &FunctionResultMessage) -> String { /// User content: flat string when text-only; the content-part array form /// when images are present (`image_url` data URIs). fn user_content_to_wire(content: &[ContentBlock]) -> Value { - let has_images = content.iter().any(|c| matches!(c, ContentBlock::Image { .. })); + let has_images = content + .iter() + .any(|c| matches!(c, ContentBlock::Image { .. })); let text = content .iter() .filter_map(|c| match c { @@ -72,7 +74,10 @@ fn tool_row(tool_call_id: &str, content: String) -> Value { /// Latest-wins dedup: replace an existing `role:"tool"` row with the same id /// (strict gateways reject duplicates; lenient ones silently overwrite). fn upsert_tool_row(out: &mut Vec, row: Value) { - let id = row.get("tool_call_id").and_then(Value::as_str).unwrap_or(""); + let id = row + .get("tool_call_id") + .and_then(Value::as_str) + .unwrap_or(""); let existing = out.iter().position(|e| { e.get("role").and_then(Value::as_str) == Some("tool") && e.get("tool_call_id").and_then(Value::as_str) == Some(id) @@ -119,16 +124,18 @@ pub fn to_wire_messages(messages: &[AgentMessage], system_prompt: &str) -> Vec { - Some(json!({ - "id": id, - "type": "function", - "function": { - "name": encode_tool_name(function_id), - "arguments": arguments.to_string(), - } - })) - } + ContentBlock::FunctionCall { + id, + function_id, + arguments, + } => Some(json!({ + "id": id, + "type": "function", + "function": { + "name": encode_tool_name(function_id), + "arguments": arguments.to_string(), + } + })), // Thinking/RedactedThinking: no reasoning replay on // Chat Completions; images never appear in assistant // turns from this provider. @@ -174,12 +181,16 @@ mod tests { use super::*; use llm_router::types::events::StopReason; use llm_router::types::messages::{ - AssistantMessage, AssistantRoleTag, CustomMessage, CustomRoleTag, - FunctionResultMessage, FunctionResultRoleTag, UserMessage, UserRoleTag, + AssistantMessage, AssistantRoleTag, CustomMessage, CustomRoleTag, FunctionResultMessage, + FunctionResultRoleTag, UserMessage, UserRoleTag, }; fn user(content: Vec) -> AgentMessage { - AgentMessage::User(UserMessage { role: UserRoleTag::User, content, timestamp: 1 }) + AgentMessage::User(UserMessage { + role: UserRoleTag::User, + content, + timestamp: 1, + }) } fn assistant(content: Vec) -> AgentMessage { AgentMessage::Assistant(AssistantMessage { @@ -234,7 +245,12 @@ mod tests { fn assistant_function_calls_become_tool_calls_with_encoded_names() { let wire = to_wire_messages( &[ - assistant(vec![ContentBlock::Text { text: "running".into() }, call("t1")]), + assistant(vec![ + ContentBlock::Text { + text: "running".into(), + }, + call("t1"), + ]), result("t1", "ok", json!({})), ], "", @@ -252,7 +268,10 @@ mod tests { assert_eq!(wire[1]["role"], "tool"); assert_eq!(wire[1]["tool_call_id"], "t1"); assert_eq!(wire[1]["content"], "ok"); - assert!(wire[1].get("is_error").is_none(), "nonstandard field never shipped"); + assert!( + wire[1].get("is_error").is_none(), + "nonstandard field never shipped" + ); } #[test] @@ -281,10 +300,7 @@ mod tests { ], "", ); - let tool_rows: Vec<&Value> = wire - .iter() - .filter(|r| r["role"] == "tool") - .collect(); + let tool_rows: Vec<&Value> = wire.iter().filter(|r| r["role"] == "tool").collect(); assert_eq!(tool_rows.len(), 1); assert_eq!(tool_rows[0]["content"], "second"); } @@ -294,7 +310,11 @@ mod tests { let wire = to_wire_messages( &[ assistant(vec![call("t1")]), - result("t1", "nope", json!({ "status": "denied", "reason": "operator" })), + result( + "t1", + "nope", + json!({ "status": "denied", "reason": "operator" }), + ), ], "", ); @@ -308,8 +328,13 @@ mod tests { fn user_images_use_the_content_part_array_with_data_uri() { let wire = to_wire_messages( &[user(vec![ - ContentBlock::Text { text: "what is this".into() }, - ContentBlock::Image { mime: "image/png".into(), data: "QUJD".into() }, + ContentBlock::Text { + text: "what is this".into(), + }, + ContentBlock::Image { + mime: "image/png".into(), + data: "QUJD".into(), + }, ])], "", ); @@ -326,8 +351,13 @@ mod tests { fn thinking_blocks_and_result_images_are_dropped() { let wire = to_wire_messages( &[assistant(vec![ - ContentBlock::Thinking { text: "hmm".into(), signature: Some("sig".into()) }, - ContentBlock::Text { text: "answer".into() }, + ContentBlock::Thinking { + text: "hmm".into(), + signature: Some("sig".into()), + }, + ContentBlock::Text { + text: "answer".into(), + }, ])], "", ); @@ -339,8 +369,13 @@ mod tests { function_call_id: "t1".into(), function_id: "web::fetch".into(), content: vec![ - ContentBlock::Text { text: "page".into() }, - ContentBlock::Image { mime: "image/png".into(), data: "QUJD".into() }, + ContentBlock::Text { + text: "page".into(), + }, + ContentBlock::Image { + mime: "image/png".into(), + data: "QUJD".into(), + }, ], details: json!({}), is_error: false, diff --git a/provider-openai/src/wire/mod.rs b/provider-openai/src/wire/mod.rs index 909e4732a..40a7428a1 100644 --- a/provider-openai/src/wire/mod.rs +++ b/provider-openai/src/wire/mod.rs @@ -1,4 +1,4 @@ //! AgentMessage/AgentFunction → OpenAI Chat Completions wire shapes. -pub mod names; pub mod messages; +pub mod names; pub mod tools; From 1fcb7364429e37496190625eae8ec426949e93a8 Mon Sep 17 00:00:00 2001 From: Ytallo Layon Date: Fri, 12 Jun 2026 09:34:33 -0300 Subject: [PATCH 08/19] =?UTF-8?q?feat(provider-openai):=20Chat=20Completio?= =?UTF-8?q?ns=20chunk=20=E2=86=92=20AssistantMessageEvent=20state=20machin?= =?UTF-8?q?e?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- provider-openai/src/lib.rs | 2 +- provider-openai/src/sse.rs | 504 +++++++++++++++++++++++++++++++++++++ 2 files changed, 505 insertions(+), 1 deletion(-) create mode 100644 provider-openai/src/sse.rs diff --git a/provider-openai/src/lib.rs b/provider-openai/src/lib.rs index 8abc1a4c5..ee60d292b 100644 --- a/provider-openai/src/lib.rs +++ b/provider-openai/src/lib.rs @@ -10,7 +10,7 @@ pub mod reasoning; // pub mod register; // pub mod request; // pub mod router_client; -// pub mod sse; +pub mod sse; // pub mod state; // pub mod stream_fn; // pub mod upstream; diff --git a/provider-openai/src/sse.rs b/provider-openai/src/sse.rs new file mode 100644 index 000000000..bc203b842 --- /dev/null +++ b/provider-openai/src/sse.rs @@ -0,0 +1,504 @@ +//! Chat Completions chunk → AssistantMessageEvent state machine. Pure: +//! consumes one parsed chunk at a time, threads it through PartialState, +//! returns 0+ events. [DONE] is the upstream pump's concern. +use crate::errors::classify; +use crate::wire::names::decode_tool_name; +use crate::{now_ms, PROVIDER_ID}; +use llm_router::types::content::ContentBlock; +use llm_router::types::events::{AssistantMessageEvent, ErrorKind, StopReason, Usage}; +use llm_router::types::messages::{AssistantMessage, AssistantRoleTag}; +use serde_json::Value; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum OpenBlock { + Text, + Call(usize), +} + +#[derive(Debug, Default)] +struct PartialFunctionCall { + id: String, + function_id: String, + args_json: String, +} + +pub struct PartialState { + text: String, + function_calls: Vec, + open_block: Option, + usage: Usage, + usage_seen: bool, + stop_reason: StopReason, + native_stop_reason: Option, + error_message: Option, + warnings: Vec, +} + +impl PartialState { + pub fn new(warnings: Vec) -> Self { + PartialState { + text: String::new(), + function_calls: Vec::new(), + open_block: None, + usage: Usage::default(), + usage_seen: false, + stop_reason: StopReason::End, + native_stop_reason: None, + error_message: None, + warnings, + } + } + + pub fn stop_reason(&self) -> StopReason { + self.stop_reason + } +} + +pub fn empty_assistant(model: &str) -> AssistantMessage { + AssistantMessage { + role: AssistantRoleTag::Assistant, + content: vec![], + stop_reason: StopReason::End, + native_stop_reason: None, + error_message: None, + error_kind: None, + warnings: None, + usage: None, + model: model.to_string(), + provider: PROVIDER_ID.to_string(), + timestamp: now_ms(), + } +} + +fn build_content(state: &PartialState) -> Vec { + let mut out = Vec::new(); + if !state.text.is_empty() { + out.push(ContentBlock::Text { + text: state.text.clone(), + }); + } + for fc in &state.function_calls { + if fc.function_id.is_empty() { + continue; + } + let arguments = if fc.args_json.is_empty() { + serde_json::json!({}) + } else { + serde_json::from_str(&fc.args_json).unwrap_or(Value::Null) + }; + out.push(ContentBlock::FunctionCall { + id: fc.id.clone(), + function_id: fc.function_id.clone(), + arguments, + }); + } + out +} + +pub fn build_partial(state: &PartialState, model: &str) -> AssistantMessage { + AssistantMessage { + role: AssistantRoleTag::Assistant, + content: build_content(state), + stop_reason: state.stop_reason, + native_stop_reason: state.native_stop_reason.clone(), + error_message: state.error_message.clone(), + error_kind: None, + warnings: if state.warnings.is_empty() { + None + } else { + Some(state.warnings.clone()) + }, + usage: if state.usage_seen { + Some(state.usage.clone()) + } else { + None + }, + model: model.to_string(), + provider: PROVIDER_ID.to_string(), + timestamp: now_ms(), + } +} + +pub fn build_final(state: &PartialState, model: &str) -> AssistantMessage { + build_partial(state, model) +} + +pub fn map_finish_reason(s: &str) -> StopReason { + match s { + "length" => StopReason::Length, + "tool_calls" | "function_call" => StopReason::FunctionCall, + // stop, content_filter, anything unknown + _ => StopReason::End, + } +} + +/// Last-wins merge: standard OpenAI reports usage once (final include_usage +/// chunk); OpenAI-compatible servers that report per-chunk report cumulative +/// values — overwriting is correct for both, adding double-counts. +pub fn merge_usage(raw: &Value, into: &mut Usage) { + let num = |k: &str| raw.get(k).and_then(Value::as_u64); + if let Some(v) = num("prompt_tokens").or_else(|| num("input_tokens")) { + into.input = Some(v); + } + if let Some(v) = num("completion_tokens").or_else(|| num("output_tokens")) { + into.output = Some(v); + } + for parent in ["prompt_tokens_details", "input_tokens_details"] { + if let Some(v) = raw + .pointer(&format!("/{parent}/cached_tokens")) + .and_then(Value::as_u64) + { + into.cache_read = Some(v); + } + } + if let Some(v) = raw + .pointer("/completion_tokens_details/reasoning_tokens") + .and_then(Value::as_u64) + { + into.reasoning = Some(v); + } +} + +/// Build a terminal error frame outside the SSE flow (fetch/HTTP failures). +pub fn synthetic_error_event(message: &str, model: &str, kind: ErrorKind) -> AssistantMessageEvent { + let mut error = empty_assistant(model); + error.content = vec![ContentBlock::Text { + text: message.to_string(), + }]; + error.stop_reason = StopReason::Error; + error.error_message = Some(message.to_string()); + error.error_kind = Some(kind); + AssistantMessageEvent::Error { error } +} + +/// Close the currently open block, emitting the matching end event. +fn close_open_block( + state: &mut PartialState, + model: &str, + events: &mut Vec, +) { + match state.open_block.take() { + Some(OpenBlock::Text) => events.push(AssistantMessageEvent::TextEnd { + partial: build_partial(state, model), + }), + Some(OpenBlock::Call(_)) => events.push(AssistantMessageEvent::FunctioncallEnd { + partial: build_partial(state, model), + }), + None => {} + } +} + +/// Process one parsed Chat Completions chunk into 0+ AssistantMessageEvents. +pub fn handle_chunk( + chunk: &Value, + state: &mut PartialState, + model: &str, +) -> Vec { + let mut events = Vec::new(); + + // Mid-stream error envelope (some gateways send {"error": {...}} as a + // chunk): terminal error frame carrying the partial content. + if let Some(err) = chunk.get("error") { + let msg = err + .get("message") + .and_then(Value::as_str) + .unwrap_or("upstream error") + .to_string(); + state.stop_reason = StopReason::Error; + state.error_message = Some(msg.clone()); + let mut error = build_final(state, model); + error.error_kind = Some(classify(None, &chunk.to_string())); + events.push(AssistantMessageEvent::Error { error }); + return events; + } + + if let Some(usage) = chunk.get("usage").filter(|u| u.is_object()) { + merge_usage(usage, &mut state.usage); + state.usage_seen = true; + // spec: usage SHOULD be emitted as soon as it is known + events.push(AssistantMessageEvent::Usage { + usage: state.usage.clone(), + }); + } + + let Some(choice) = chunk.pointer("/choices/0") else { + return events; + }; + + if let Some(delta) = choice.get("delta") { + if let Some(text) = delta.get("content").and_then(Value::as_str) { + if !text.is_empty() { + if state.open_block != Some(OpenBlock::Text) { + close_open_block(state, model, &mut events); + state.open_block = Some(OpenBlock::Text); + events.push(AssistantMessageEvent::TextStart { + partial: build_partial(state, model), + }); + } + state.text.push_str(text); + events.push(AssistantMessageEvent::TextDelta { + partial: build_partial(state, model), + delta: text.to_string(), + }); + } + } + if let Some(tool_calls) = delta.get("tool_calls").and_then(Value::as_array) { + for tc in tool_calls { + let index = tc.get("index").and_then(Value::as_u64).unwrap_or(0) as usize; + while state.function_calls.len() <= index { + state.function_calls.push(PartialFunctionCall::default()); + } + if state.open_block != Some(OpenBlock::Call(index)) { + close_open_block(state, model, &mut events); + state.open_block = Some(OpenBlock::Call(index)); + events.push(AssistantMessageEvent::FunctioncallStart { + partial: build_partial(state, model), + }); + } + let entry = &mut state.function_calls[index]; + if let Some(id) = tc.get("id").and_then(Value::as_str) { + if !id.is_empty() { + entry.id = id.to_string(); + } + } + if let Some(name) = tc.pointer("/function/name").and_then(Value::as_str) { + if !name.is_empty() { + entry.function_id = decode_tool_name(name); + } + } + if let Some(args) = tc.pointer("/function/arguments").and_then(Value::as_str) { + if !args.is_empty() { + state.function_calls[index].args_json.push_str(args); + events.push(AssistantMessageEvent::FunctioncallDelta { + partial: build_partial(state, model), + delta: args.to_string(), + }); + } + } + } + } + } + + if let Some(finish) = choice.get("finish_reason").and_then(Value::as_str) { + state.stop_reason = map_finish_reason(finish); + state.native_stop_reason = Some(finish.to_string()); + if finish == "content_filter" { + state + .warnings + .push("openai filtered the completion (finish_reason: content_filter)".to_string()); + } + close_open_block(state, model, &mut events); + } + events +} + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + + fn run(chunks: &[Value]) -> (PartialState, Vec) { + let mut state = PartialState::new(vec![]); + let mut events = Vec::new(); + for c in chunks { + events.extend(handle_chunk(c, &mut state, "gpt-test")); + } + (state, events) + } + + fn tags(events: &[AssistantMessageEvent]) -> Vec<&'static str> { + events + .iter() + .map(|e| match e { + AssistantMessageEvent::Usage { .. } => "usage", + AssistantMessageEvent::TextStart { .. } => "text_start", + AssistantMessageEvent::TextDelta { .. } => "text_delta", + AssistantMessageEvent::TextEnd { .. } => "text_end", + AssistantMessageEvent::FunctioncallStart { .. } => "functioncall_start", + AssistantMessageEvent::FunctioncallDelta { .. } => "functioncall_delta", + AssistantMessageEvent::FunctioncallEnd { .. } => "functioncall_end", + AssistantMessageEvent::Error { .. } => "error", + _ => "other", + }) + .collect() + } + + #[test] + fn text_stream_produces_start_delta_end_and_final_content() { + let (state, events) = run(&[ + json!({"choices":[{"index":0,"delta":{"role":"assistant","content":""}}]}), + json!({"choices":[{"index":0,"delta":{"content":"He"}}]}), + json!({"choices":[{"index":0,"delta":{"content":"llo"}}]}), + json!({"choices":[{"index":0,"delta":{},"finish_reason":"stop"}]}), + json!({"choices":[],"usage":{"prompt_tokens":12,"completion_tokens":2, + "prompt_tokens_details":{"cached_tokens":4}, + "completion_tokens_details":{"reasoning_tokens":0}}}), + ]); + assert_eq!( + tags(&events), + vec![ + "text_start", + "text_delta", + "text_delta", + "text_end", + "usage" + ] + ); + let final_msg = build_final(&state, "gpt-test"); + assert_eq!( + final_msg.content, + vec![ContentBlock::Text { + text: "Hello".into() + }] + ); + assert_eq!(final_msg.stop_reason, StopReason::End); + assert_eq!(final_msg.native_stop_reason.as_deref(), Some("stop")); + let usage = final_msg.usage.unwrap(); + assert_eq!(usage.input, Some(12)); + assert_eq!(usage.output, Some(2)); + assert_eq!(usage.cache_read, Some(4)); + assert_eq!(usage.reasoning, Some(0)); + } + + #[test] + fn tool_call_stream_decodes_name_and_parses_args() { + let (state, events) = run(&[ + json!({"choices":[{"index":0,"delta":{"tool_calls":[ + {"index":0,"id":"call_1","type":"function","function":{"name":"shell__exec","arguments":""}}]}}]}), + json!({"choices":[{"index":0,"delta":{"tool_calls":[ + {"index":0,"function":{"arguments":"{\"cmd\":"}}]}}]}), + json!({"choices":[{"index":0,"delta":{"tool_calls":[ + {"index":0,"function":{"arguments":"\"ls\"}"}}]}}]}), + json!({"choices":[{"index":0,"delta":{},"finish_reason":"tool_calls"}]}), + ]); + assert_eq!(tags(&events)[0], "functioncall_start"); + assert_eq!(*tags(&events).last().unwrap(), "functioncall_end"); + let final_msg = build_final(&state, "gpt-test"); + assert_eq!(final_msg.stop_reason, StopReason::FunctionCall); + assert_eq!(final_msg.native_stop_reason.as_deref(), Some("tool_calls")); + match &final_msg.content[0] { + ContentBlock::FunctionCall { + id, + function_id, + arguments, + } => { + assert_eq!(id, "call_1"); + assert_eq!(function_id, "shell::exec"); + assert_eq!(arguments["cmd"], "ls"); + } + other => panic!("want function_call, got {other:?}"), + } + } + + #[test] + fn text_then_tool_calls_closes_text_block_first() { + let (state, events) = run(&[ + json!({"choices":[{"index":0,"delta":{"content":"Let me check."}}]}), + json!({"choices":[{"index":0,"delta":{"tool_calls":[ + {"index":0,"id":"call_1","function":{"name":"web__fetch","arguments":"{}"}}]}}]}), + json!({"choices":[{"index":0,"delta":{},"finish_reason":"tool_calls"}]}), + ]); + assert_eq!( + tags(&events), + vec![ + "text_start", + "text_delta", + "text_end", + "functioncall_start", + "functioncall_delta", + "functioncall_end" + ] + ); + let final_msg = build_final(&state, "gpt-test"); + assert_eq!(final_msg.content.len(), 2, "text block then function call"); + } + + #[test] + fn parallel_tool_calls_emit_start_per_index() { + let (state, events) = run(&[ + json!({"choices":[{"index":0,"delta":{"tool_calls":[ + {"index":0,"id":"call_a","function":{"name":"f__a","arguments":"{}"}}]}}]}), + json!({"choices":[{"index":0,"delta":{"tool_calls":[ + {"index":1,"id":"call_b","function":{"name":"f__b","arguments":"{}"}}]}}]}), + json!({"choices":[{"index":0,"delta":{},"finish_reason":"tool_calls"}]}), + ]); + let starts = tags(&events) + .iter() + .filter(|t| **t == "functioncall_start") + .count(); + assert_eq!(starts, 2); + let final_msg = build_final(&state, "gpt-test"); + assert_eq!(final_msg.content.len(), 2); + } + + #[test] + fn content_filter_maps_to_end_with_warning() { + let (state, _) = run(&[ + json!({"choices":[{"index":0,"delta":{"content":"par"}}]}), + json!({"choices":[{"index":0,"delta":{},"finish_reason":"content_filter"}]}), + ]); + let final_msg = build_final(&state, "gpt-test"); + assert_eq!(final_msg.stop_reason, StopReason::End); + assert_eq!( + final_msg.native_stop_reason.as_deref(), + Some("content_filter") + ); + assert!(final_msg.warnings.unwrap()[0].contains("content_filter")); + } + + #[test] + fn mid_stream_error_chunk_is_terminal_with_partial_content() { + let (_, events) = run(&[ + json!({"choices":[{"index":0,"delta":{"content":"par"}}]}), + json!({"error":{"message":"The server is overloaded","type":"server_error"}}), + ]); + let last = events.last().unwrap(); + assert!(last.is_terminal()); + match last { + AssistantMessageEvent::Error { error } => { + assert_eq!(error.stop_reason, StopReason::Error); + assert_eq!( + error.error_message.as_deref(), + Some("The server is overloaded") + ); + assert_eq!(error.error_kind, Some(ErrorKind::Transient)); + assert!(matches!(&error.content[0], ContentBlock::Text { text } if text == "par")); + } + other => panic!("want error frame, got {other:?}"), + } + } + + #[test] + fn malformed_and_empty_chunks_are_ignored() { + let (_, events) = run(&[ + json!({"no_choices": true}), + json!({"choices": []}), + json!({"choices":[{"index":0}]}), + json!({"choices":[{"index":0,"delta":{"content":""}}]}), + ]); + assert!(events.is_empty()); + } + + #[test] + fn warnings_ride_the_final_message() { + let state = PartialState::new(vec!["response_format degraded".into()]); + let final_msg = build_final(&state, "m"); + assert_eq!( + final_msg.warnings, + Some(vec!["response_format degraded".to_string()]) + ); + } + + #[test] + fn synthetic_error_event_shape() { + let ev = synthetic_error_event("boom", "gpt-test", ErrorKind::RateLimited); + match ev { + AssistantMessageEvent::Error { error } => { + assert_eq!(error.error_kind, Some(ErrorKind::RateLimited)); + assert_eq!(error.stop_reason, StopReason::Error); + assert_eq!(error.provider, "openai"); + } + other => panic!("want error, got {other:?}"), + } + } +} From 3224da36b7c10251b4c985e4fd4af81279b7b3ee Mon Sep 17 00:00:00 2001 From: Ytallo Layon Date: Fri, 12 Jun 2026 09:36:00 -0300 Subject: [PATCH 09/19] feat(provider-openai): request assembly with reasoning effort and native structured output --- provider-openai/src/config.rs | 103 +++++++++++++++++++++ provider-openai/src/lib.rs | 4 +- provider-openai/src/request.rs | 164 +++++++++++++++++++++++++++++++++ 3 files changed, 269 insertions(+), 2 deletions(-) create mode 100644 provider-openai/src/config.rs create mode 100644 provider-openai/src/request.rs diff --git a/provider-openai/src/config.rs b/provider-openai/src/config.rs new file mode 100644 index 000000000..dd3208dd6 --- /dev/null +++ b/provider-openai/src/config.rs @@ -0,0 +1,103 @@ +//! Effective per-request config: credential + url + max_tokens. +//! Precedence for max_tokens: router-resolved effective budget +//! (`ProviderStreamInput.max_output_tokens`) → the operator's configured +//! `max_tokens` (from resolve) → the worker default. +use llm_router::types::credential::Credential; +use llm_router::types::router::ProviderResolveResponse; + +pub const DEFAULT_API_URL: &str = "https://api.openai.com/v1/chat/completions"; +pub const DEFAULT_MAX_TOKENS: u64 = 8192; + +#[derive(Debug, Clone)] +pub struct OpenaiConfig { + pub credential_value: String, + pub model: String, + pub max_tokens: u64, + pub api_url: String, +} + +/// No usable credential — the caller turns this into a permanent error frame. +#[derive(Debug, PartialEq, Eq)] +pub struct NotConfigured; + +/// The single Credential → bearer secret mapping; streaming and discovery +/// must agree on it. OpenAI takes `Authorization: Bearer` for both shapes. +pub fn credential_parts(credential: &Credential) -> &str { + match credential { + Credential::ApiKey { key } => key, + Credential::Oauth { access_token, .. } => access_token, + } +} + +pub fn config_from_resolve( + model: &str, + effective_max_tokens: Option, + resolved: &ProviderResolveResponse, +) -> Result { + let credential_value = match &resolved.credential { + Some(credential) => credential_parts(credential).to_string(), + None => return Err(NotConfigured), + }; + Ok(OpenaiConfig { + credential_value, + model: model.to_string(), + max_tokens: effective_max_tokens + .or(resolved.max_tokens) + .unwrap_or(DEFAULT_MAX_TOKENS), + api_url: resolved + .api_url + .clone() + .unwrap_or_else(|| DEFAULT_API_URL.to_string()), + }) +} + +#[cfg(test)] +mod tests { + use super::*; + use llm_router::types::router::CredentialSource; + + fn resolved( + credential: Option, + max_tokens: Option, + ) -> ProviderResolveResponse { + ProviderResolveResponse { + configured: credential.is_some(), + source: CredentialSource::Config, + credential, + api_url: None, + max_tokens, + } + } + + #[test] + fn missing_credential_is_not_configured() { + assert_eq!( + config_from_resolve("m", None, &resolved(None, None)).unwrap_err(), + NotConfigured + ); + } + + #[test] + fn max_tokens_precedence_effective_then_configured_then_default() { + let key = Some(Credential::ApiKey { key: "sk".into() }); + let cfg = config_from_resolve("m", Some(1000), &resolved(key.clone(), Some(2000))).unwrap(); + assert_eq!(cfg.max_tokens, 1000); + let cfg = config_from_resolve("m", None, &resolved(key.clone(), Some(2000))).unwrap(); + assert_eq!(cfg.max_tokens, 2000); + let cfg = config_from_resolve("m", None, &resolved(key, None)).unwrap(); + assert_eq!(cfg.max_tokens, DEFAULT_MAX_TOKENS); + } + + #[test] + fn oauth_credential_yields_its_access_token() { + let cred = Some(Credential::Oauth { + access_token: "at".into(), + refresh_token: None, + expires_at: None, + scopes: None, + provider_extra: None, + }); + let cfg = config_from_resolve("m", None, &resolved(cred, None)).unwrap(); + assert_eq!(cfg.credential_value, "at"); + } +} diff --git a/provider-openai/src/lib.rs b/provider-openai/src/lib.rs index ee60d292b..267dd09c9 100644 --- a/provider-openai/src/lib.rs +++ b/provider-openai/src/lib.rs @@ -1,14 +1,14 @@ //! provider-openai: OpenAI Chat Completions provider behind llm-router. //! Spec: tech-specs/2026-06-agentic/llm-router.md § The provider protocol. -// pub mod config; +pub mod config; // pub mod curated; // pub mod discovery; pub mod errors; pub mod manifest; pub mod reasoning; // pub mod register; -// pub mod request; +pub mod request; // pub mod router_client; pub mod sse; // pub mod state; diff --git a/provider-openai/src/request.rs b/provider-openai/src/request.rs new file mode 100644 index 000000000..2c71b1860 --- /dev/null +++ b/provider-openai/src/request.rs @@ -0,0 +1,164 @@ +//! Full Chat Completions request assembly: body (messages, tools, +//! reasoning_effort, response_format) + headers. +use crate::config::OpenaiConfig; +use crate::wire::messages::to_wire_messages; +use crate::wire::tools::functions_to_wire; +use llm_router::types::messages::AgentMessage; +use llm_router::types::model::AgentFunction; +use llm_router::types::router::ResponseFormat; +use serde_json::{json, Value}; + +pub struct BodyArgs { + pub model: String, + pub max_tokens: u64, + pub system_prompt: String, + pub messages: Vec, + pub tools: Vec, + /// Pre-resolved effort string (Task 6); None omits the param. + pub reasoning_effort: Option<&'static str>, + pub response_format: Option, +} + +/// `ResponseFormat { type: "json", schema? }` → native OpenAI knob. +/// With a schema: strict json_schema mode (constrained decoding — a schema +/// that violates OpenAI's strict-mode rules 400s as `permanent`, which is +/// correct: retrying cannot fix the schema). Without: json_object mode +/// (OpenAI requires the word "JSON" somewhere in the messages — the +/// caller's contract per spec § Model capabilities). +pub fn build_response_format(rf: &ResponseFormat) -> Value { + match &rf.schema { + Some(schema) => json!({ + "type": "json_schema", + "json_schema": { "name": "response", "strict": true, "schema": schema } + }), + None => json!({ "type": "json_object" }), + } +} + +/// `max_completion_tokens`, not the deprecated `max_tokens`: the o-series +/// and gpt-5 families reject the old param. Reasoning tokens count toward +/// it; the router-clamped budget leaves ample room. No `temperature`: the +/// API default applies (reasoning models reject non-default values). +pub fn build_body(args: &BodyArgs) -> Value { + let mut body = json!({ + "model": args.model, + "max_completion_tokens": args.max_tokens, + "messages": to_wire_messages(&args.messages, &args.system_prompt), + "stream": true, + "stream_options": { "include_usage": true }, + }); + let wire_tools = functions_to_wire(&args.tools); + if !wire_tools.is_empty() { + body["tools"] = Value::Array(wire_tools); + } + if let Some(effort) = args.reasoning_effort { + body["reasoning_effort"] = json!(effort); + } + if let Some(rf) = &args.response_format { + body["response_format"] = build_response_format(rf); + } + body +} + +pub fn build_headers(cfg: &OpenaiConfig) -> Vec<(&'static str, String)> { + vec![ + ("authorization", format!("Bearer {}", cfg.credential_value)), + ("content-type", "application/json".to_string()), + ] +} + +#[cfg(test)] +mod tests { + use super::*; + use llm_router::types::content::ContentBlock; + use llm_router::types::messages::{UserMessage, UserRoleTag}; + + fn args() -> BodyArgs { + BodyArgs { + model: "gpt-5.2".into(), + max_tokens: 4096, + system_prompt: "be brief".into(), + messages: vec![AgentMessage::User(UserMessage { + role: UserRoleTag::User, + content: vec![ContentBlock::Text { text: "hi".into() }], + timestamp: 1, + })], + tools: vec![], + reasoning_effort: None, + response_format: None, + } + } + + #[test] + fn body_has_required_fields_and_stream_options() { + let body = build_body(&args()); + assert_eq!(body["model"], "gpt-5.2"); + assert_eq!(body["max_completion_tokens"], 4096); + assert!( + body.get("max_tokens").is_none(), + "deprecated param never sent" + ); + assert_eq!(body["stream"], true); + assert_eq!(body["stream_options"]["include_usage"], true); + assert_eq!(body["messages"][0]["role"], "system"); + assert_eq!(body["messages"][1]["role"], "user"); + assert!(body.get("tools").is_none(), "empty tools array omitted"); + assert!(body.get("reasoning_effort").is_none()); + assert!(body.get("response_format").is_none()); + assert!(body.get("temperature").is_none()); + } + + #[test] + fn reasoning_effort_and_tools_serialize_when_present() { + let mut a = args(); + a.reasoning_effort = Some("high"); + a.tools = vec![AgentFunction { + name: "agent::trigger".into(), + description: "d".into(), + parameters: serde_json::json!({ "type": "object" }), + label: None, + execution_mode: None, + }]; + let body = build_body(&a); + assert_eq!(body["reasoning_effort"], "high"); + assert_eq!(body["tools"][0]["function"]["name"], "agent__trigger"); + } + + #[test] + fn response_format_maps_to_json_schema_or_json_object() { + let with_schema = build_response_format(&ResponseFormat { + r#type: "json".into(), + schema: Some(serde_json::json!({ "type": "object", "additionalProperties": false })), + }); + assert_eq!(with_schema["type"], "json_schema"); + assert_eq!(with_schema["json_schema"]["name"], "response"); + assert_eq!(with_schema["json_schema"]["strict"], true); + assert_eq!(with_schema["json_schema"]["schema"]["type"], "object"); + + let without = build_response_format(&ResponseFormat { + r#type: "json".into(), + schema: None, + }); + assert_eq!(without["type"], "json_object"); + + let mut a = args(); + a.response_format = Some(ResponseFormat { + r#type: "json".into(), + schema: None, + }); + assert_eq!(build_body(&a)["response_format"]["type"], "json_object"); + } + + #[test] + fn headers_carry_bearer_auth() { + let cfg = OpenaiConfig { + credential_value: "sk-test".into(), + model: "gpt-5.2".into(), + max_tokens: 4096, + api_url: "https://api.openai.com/v1/chat/completions".into(), + }; + let h = build_headers(&cfg); + assert!(h.contains(&("authorization", "Bearer sk-test".to_string()))); + assert!(h.contains(&("content-type", "application/json".to_string()))); + } +} From f9f767b6baaf316bdf1d8e73e9fce489d829ce36 Mon Sep 17 00:00:00 2001 From: Ytallo Layon Date: Fri, 12 Jun 2026 09:37:20 -0300 Subject: [PATCH 10/19] feat(provider-openai): curated capability snapshot and live-discovery merge --- provider-openai/src/curated.rs | 243 +++++++++++++++++++++++++++++++++ provider-openai/src/lib.rs | 2 +- 2 files changed, 244 insertions(+), 1 deletion(-) create mode 100644 provider-openai/src/curated.rs diff --git a/provider-openai/src/curated.rs b/provider-openai/src/curated.rs new file mode 100644 index 000000000..35d867562 --- /dev/null +++ b/provider-openai/src/curated.rs @@ -0,0 +1,243 @@ +//! Curated capability snapshot for the OpenAI catalog slice, and the +//! live-discovery merge. The static slice doubles as the declaration's +//! `models` so the catalog has no cold hole before first discovery. +use crate::PROVIDER_ID; +use llm_router::types::model::{Model, Pricing}; +use std::collections::HashSet; + +/// One live `GET /v1/models` row (OpenAI rows carry no display name). +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct LiveStub { + pub id: String, +} + +fn model( + id: &str, + display_name: &str, + context_window: u64, + max_output_tokens: u64, + xhigh: bool, + pricing: Pricing, +) -> Model { + Model { + id: id.into(), + provider: PROVIDER_ID.into(), + display_name: Some(display_name.into()), + context_window, + max_output_tokens, + input_limit: None, + supports_thinking: Some(true), + supports_xhigh: Some(xhigh), + supports_tools: Some(true), + supports_vision: Some(true), + supports_cache: Some(true), + supports_structured_output: Some(true), // native json_schema mode + thinking_budgets: None, // effort enum, not token budgets + pricing: Some(pricing), + } +} + +fn price(input: f64, output: f64) -> Pricing { + Pricing { + input: Some(input), + output: Some(output), + cache_read: Some(input * 0.1), + cache_write: None, // automatic caching; no write surcharge + } +} + +/// The hand-maintained snapshot (USD per MTok). Verify against models.dev +/// before release; stale records degrade preflight sizing and cost fill. +pub fn static_models() -> Vec { + vec![ + model( + "gpt-5.2", + "GPT-5.2", + 400_000, + 128_000, + true, + price(1.75, 14.0), + ), + model( + "gpt-5.1", + "GPT-5.1", + 400_000, + 128_000, + false, + price(1.25, 10.0), + ), + model( + "gpt-5-mini", + "GPT-5 Mini", + 400_000, + 128_000, + false, + price(0.25, 2.0), + ), + model( + "gpt-5-nano", + "GPT-5 Nano", + 400_000, + 128_000, + false, + price(0.05, 0.40), + ), + ] +} + +/// Curated lookup for request shaping when the router forwarded no +/// model_meta and the catalog has no record. +pub fn find(model_id: &str) -> Option { + let base = base_id(model_id); + static_models() + .into_iter() + .find(|m| m.id == model_id || m.id == base) +} + +/// Strip a trailing `-YYYY-MM-DD` date suffix +/// (`gpt-5.1-2025-11-13` → `gpt-5.1`). OpenAI dates use hyphenated ISO form, +/// unlike Anthropic's compact `-YYYYMMDD`. +fn base_id(id: &str) -> &str { + if id.len() > 11 { + let (head, tail) = id.split_at(id.len() - 11); + let bytes = tail.as_bytes(); + let shape_ok = bytes[0] == b'-' + && bytes[5] == b'-' + && bytes[8] == b'-' + && tail + .char_indices() + .all(|(i, c)| matches!(i, 0 | 5 | 8) || c.is_ascii_digit()); + if shape_ok { + return head; + } + } + id +} + +/// Live ids ∪ curated metadata (spec § Catalog metadata sources): every live +/// id gets curated capabilities when its base id matches, conservative +/// defaults otherwise; curated entries missing from the live list are kept +/// (aliases the API doesn't enumerate). +pub fn merge_with_live(live: &[LiveStub]) -> Vec { + let curated = static_models(); + let mut out: Vec = Vec::new(); + let mut covered: HashSet = HashSet::new(); + + for stub in live { + let base = base_id(&stub.id); + match curated.iter().find(|m| m.id == stub.id || m.id == base) { + Some(c) => { + covered.insert(c.id.clone()); + let mut m = c.clone(); + m.id = stub.id.clone(); + out.push(m); + } + None => out.push(unknown_model(stub)), + } + } + for c in curated { + if !covered.contains(&c.id) && !out.iter().any(|m| m.id == c.id) { + out.push(c); + } + } + out +} + +/// Conservative record for a live id with no curated metadata. Tools and +/// automatic caching are uniform across the chat families we admit; +/// vision/thinking/structured-output stay unknown. +fn unknown_model(stub: &LiveStub) -> Model { + Model { + id: stub.id.clone(), + provider: PROVIDER_ID.into(), + display_name: None, + context_window: 128_000, + max_output_tokens: 16_384, + input_limit: None, + supports_thinking: None, + supports_xhigh: None, + supports_tools: Some(true), + supports_vision: None, + supports_cache: Some(true), + supports_structured_output: None, + thinking_budgets: None, + pricing: None, + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn snapshot_is_well_formed() { + let models = static_models(); + assert_eq!(models.len(), 4); + for m in &models { + assert_eq!(m.provider, "openai"); + assert_eq!(m.supports_structured_output, Some(true)); + assert_eq!(m.supports_thinking, Some(true)); + assert!( + m.thinking_budgets.is_none(), + "OpenAI uses efforts, not budgets" + ); + let pricing = m.pricing.as_ref().unwrap(); + assert!(pricing.input.is_some() && pricing.output.is_some()); + assert!(pricing.cache_write.is_none(), "no cache-write surcharge"); + assert!(m.context_window > 0 && m.max_output_tokens > 0); + } + // xhigh is the gpt-5.2+ tier only (matches the Task 6 effort ladder) + assert_eq!( + models + .iter() + .filter(|m| m.supports_xhigh == Some(true)) + .count(), + 1 + ); + } + + #[test] + fn find_matches_dated_ids_via_base() { + assert_eq!(find("gpt-5.1").unwrap().id, "gpt-5.1"); + assert_eq!(find("gpt-5.1-2025-11-13").unwrap().id, "gpt-5.1"); + assert!(find("claude-sonnet-4-6").is_none()); + assert!( + find("gpt-5.1-turbo-extra").is_none(), + "non-date suffixes don't match" + ); + } + + #[test] + fn merge_enriches_live_ids_and_keeps_curated_aliases() { + let live = vec![ + LiveStub { + id: "gpt-5.1-2025-11-13".into(), + }, + LiveStub { + id: "o3-mini".into(), + }, + ]; + let merged = merge_with_live(&live); + // live dated id carries curated caps under the live id + let dated = merged + .iter() + .find(|m| m.id == "gpt-5.1-2025-11-13") + .unwrap(); + assert_eq!(dated.context_window, 400_000); + assert_eq!(dated.supports_structured_output, Some(true)); + // unknown live id gets conservative defaults + let mystery = merged.iter().find(|m| m.id == "o3-mini").unwrap(); + assert_eq!(mystery.max_output_tokens, 16_384); + assert!(mystery.pricing.is_none()); + // curated entries not in the live list are kept + assert!(merged.iter().any(|m| m.id == "gpt-5.2")); + assert!(merged.iter().any(|m| m.id == "gpt-5-mini")); + // the covered base id is NOT duplicated + assert!(!merged.iter().any(|m| m.id == "gpt-5.1")); + } + + #[test] + fn merge_with_empty_live_list_returns_full_snapshot() { + assert_eq!(merge_with_live(&[]).len(), 4); + } +} diff --git a/provider-openai/src/lib.rs b/provider-openai/src/lib.rs index 267dd09c9..b806a5306 100644 --- a/provider-openai/src/lib.rs +++ b/provider-openai/src/lib.rs @@ -2,7 +2,7 @@ //! Spec: tech-specs/2026-06-agentic/llm-router.md § The provider protocol. pub mod config; -// pub mod curated; +pub mod curated; // pub mod discovery; pub mod errors; pub mod manifest; From e4ce2d6d803ce056274845a097389ccbd2414391 Mon Sep 17 00:00:00 2001 From: Ytallo Layon Date: Fri, 12 Jun 2026 09:41:33 -0300 Subject: [PATCH 11/19] feat(provider-openai): token persistence and router protocol client --- provider-openai/src/lib.rs | 4 +- provider-openai/src/router_client.rs | 61 ++++++++++++++++++++++++++++ provider-openai/src/state.rs | 32 +++++++++++++++ 3 files changed, 95 insertions(+), 2 deletions(-) create mode 100644 provider-openai/src/router_client.rs create mode 100644 provider-openai/src/state.rs diff --git a/provider-openai/src/lib.rs b/provider-openai/src/lib.rs index b806a5306..8bb0369c6 100644 --- a/provider-openai/src/lib.rs +++ b/provider-openai/src/lib.rs @@ -9,9 +9,9 @@ pub mod manifest; pub mod reasoning; // pub mod register; pub mod request; -// pub mod router_client; +pub mod router_client; pub mod sse; -// pub mod state; +pub mod state; // pub mod stream_fn; // pub mod upstream; pub mod wire; diff --git a/provider-openai/src/router_client.rs b/provider-openai/src/router_client.rs new file mode 100644 index 000000000..f7cc02cae --- /dev/null +++ b/provider-openai/src/router_client.rs @@ -0,0 +1,61 @@ +//! Thin wrappers over the router's provider-protocol functions. All calls +//! carry the registration token (identity binding, spec adaptation #1). +use crate::PROVIDER_ID; +use iii_sdk::{IIIError, TriggerRequest, III}; +use llm_router::types::model::Model; +use llm_router::types::router::ProviderResolveResponse; +use serde_json::{json, Value}; + +async fn call(iii: &III, function_id: &str, payload: Value) -> Result { + iii.trigger(TriggerRequest { + function_id: function_id.into(), + payload, + action: None, + timeout_ms: Some(15_000), + }) + .await +} + +/// `router::provider::resolve` — credential + effective settings. +pub async fn resolve(iii: &III, token: Option<&str>) -> Result { + let mut payload = json!({ "id": PROVIDER_ID }); + if let Some(t) = token { + payload["token"] = json!(t); + } + let raw = call(iii, "router::provider::resolve", payload).await?; + serde_json::from_value(raw).map_err(|e| IIIError::Remote { + code: "provider/bad_resolve_response".into(), + message: e.to_string(), + stacktrace: None, + }) +} + +/// `router::models::reconcile` — replace this provider's catalog slice. +pub async fn reconcile(iii: &III, models: Vec, token: Option<&str>) -> Result<(), IIIError> { + let mut payload = json!({ + "provider": PROVIDER_ID, + "models": serde_json::to_value(models).expect("serializable models"), + }); + if let Some(t) = token { + payload["token"] = json!(t); + } + call(iii, "router::models::reconcile", payload).await?; + Ok(()) +} + +/// `router::models::get` — authoritative catalog record (None when absent). +pub async fn models_get(iii: &III, model_id: &str) -> Option { + let raw = call( + iii, + "router::models::get", + json!({ "provider": PROVIDER_ID, "id": model_id }), + ) + .await + .ok()?; + serde_json::from_value(raw.get("model")?.clone()).ok() +} + +/// `router::provider::register` — returns the registration token to persist. +pub async fn register(iii: &III, declaration: Value) -> Result { + call(iii, "router::provider::register", declaration).await +} diff --git a/provider-openai/src/state.rs b/provider-openai/src/state.rs new file mode 100644 index 000000000..f2f9953ac --- /dev/null +++ b/provider-openai/src/state.rs @@ -0,0 +1,32 @@ +//! Registration-token persistence in iii-state (engine `state::*` functions, +//! binary-worker.md § 7). The raw token lives here, under the provider's own +//! scope; the router persists only its sha256 hash. +use iii_sdk::{IIIError, TriggerRequest, III}; +use serde_json::{json, Value}; + +pub const STATE_SCOPE: &str = "provider-openai"; +const TOKEN_KEY: &str = "registration_token"; + +pub async fn load_token(iii: &III) -> Option { + let value = iii + .trigger(TriggerRequest { + function_id: "state::get".into(), + payload: json!({ "scope": STATE_SCOPE, "key": TOKEN_KEY }), + action: None, + timeout_ms: None, + }) + .await + .ok()?; + value.as_str().map(String::from) +} + +pub async fn store_token(iii: &III, token: &str) -> Result<(), IIIError> { + iii.trigger(TriggerRequest { + function_id: "state::set".into(), + payload: json!({ "scope": STATE_SCOPE, "key": TOKEN_KEY, "value": Value::from(token) }), + action: None, + timeout_ms: None, + }) + .await?; + Ok(()) +} From 8340e9c28a0b1aa911a893493916f2fae566a6f6 Mon Sep 17 00:00:00 2001 From: Ytallo Layon Date: Fri, 12 Jun 2026 09:43:04 -0300 Subject: [PATCH 12/19] feat(provider-openai): upstream Chat Completions SSE pump with stub-server coverage --- provider-openai/src/lib.rs | 2 +- provider-openai/src/upstream.rs | 285 ++++++++++++++++++++++++++++++++ 2 files changed, 286 insertions(+), 1 deletion(-) create mode 100644 provider-openai/src/upstream.rs diff --git a/provider-openai/src/lib.rs b/provider-openai/src/lib.rs index 8bb0369c6..858dcfd4a 100644 --- a/provider-openai/src/lib.rs +++ b/provider-openai/src/lib.rs @@ -13,7 +13,7 @@ pub mod router_client; pub mod sse; pub mod state; // pub mod stream_fn; -// pub mod upstream; +pub mod upstream; pub mod wire; /// The provider id — also the `provider::::*` function prefix and the diff --git a/provider-openai/src/upstream.rs b/provider-openai/src/upstream.rs new file mode 100644 index 000000000..0db9f7fe4 --- /dev/null +++ b/provider-openai/src/upstream.rs @@ -0,0 +1,285 @@ +//! POST /v1/chat/completions (stream:true) → SSE → mpsc. +//! The receiver dropping aborts the upstream: every send error returns, +//! which drops the reqwest response mid-body and closes the connection. +use crate::errors::classify; +use crate::sse::{build_final, build_partial, handle_chunk, synthetic_error_event, PartialState}; +use futures::StreamExt; +use llm_router::types::events::{AssistantMessageEvent, ErrorKind}; +use serde_json::Value; +use tokio::sync::mpsc; + +pub struct UpstreamArgs { + pub api_url: String, + pub model: String, + pub body: Value, + pub headers: Vec<(&'static str, String)>, + /// Report-and-continue notices for the final message (spec § stream contract). + pub warnings: Vec, +} + +pub fn spawn_upstream( + client: reqwest::Client, + args: UpstreamArgs, +) -> mpsc::Receiver { + let (tx, rx) = mpsc::channel(64); + tokio::spawn(async move { + run_upstream(client, args, tx).await; + }); + rx +} + +/// Last `data: ` payload in an SSE block, if any. +fn data_line(block: &str) -> Option<&str> { + block + .lines() + .filter_map(|l| l.strip_prefix("data: ")) + .next_back() +} + +async fn run_upstream( + client: reqwest::Client, + args: UpstreamArgs, + tx: mpsc::Sender, +) { + let mut req = client.post(&args.api_url); + for (name, value) in &args.headers { + req = req.header(*name, value); + } + let resp = match req.json(&args.body).send().await { + Ok(r) => r, + Err(e) => { + let _ = tx + .send(synthetic_error_event( + &format!("openai fetch failed: {e}"), + &args.model, + ErrorKind::Transient, + )) + .await; + return; + } + }; + + let status = resp.status(); + if !status.is_success() { + let text = resp.text().await.unwrap_or_default(); + let kind = classify(Some(status.as_u16()), &text); + let msg = if text.is_empty() { + format!("openai http {status}") + } else { + text + }; + let _ = tx + .send(synthetic_error_event(&msg, &args.model, kind)) + .await; + return; + } + + let mut state = PartialState::new(args.warnings); + if tx + .send(AssistantMessageEvent::Start { + partial: build_partial(&state, &args.model), + }) + .await + .is_err() + { + return; // receiver gone before the first frame + } + + let mut stream = resp.bytes_stream(); + let mut buf = String::new(); + while let Some(chunk) = stream.next().await { + let chunk = match chunk { + Ok(c) => c, + Err(e) => { + let _ = tx + .send(synthetic_error_event( + &format!("stream read failed: {e}"), + &args.model, + ErrorKind::Transient, + )) + .await; + return; + } + }; + buf.push_str(&String::from_utf8_lossy(&chunk)); + while let Some(idx) = buf.find("\n\n") { + let block: String = buf.drain(..idx + 2).collect(); + let Some(data) = data_line(&block) else { + continue; + }; + if data == "[DONE]" { + let _ = tx + .send(AssistantMessageEvent::Stop { + stop_reason: state.stop_reason(), + error_message: None, + error_kind: None, + }) + .await; + let _ = tx + .send(AssistantMessageEvent::Done { + message: build_final(&state, &args.model), + }) + .await; + return; + } + let Ok(parsed) = serde_json::from_str::(data) else { + continue; + }; + for ev in handle_chunk(&parsed, &mut state, &args.model) { + let terminal = ev.is_terminal(); + if tx.send(ev).await.is_err() { + return; // receiver dropped → abort upstream + } + if terminal { + return; // exactly one terminal event + } + } + } + } + // Stream ended without [DONE] (connection close framing): still terminal. + let _ = tx + .send(AssistantMessageEvent::Done { + message: build_final(&state, &args.model), + }) + .await; +} + +#[cfg(test)] +mod tests { + use super::*; + use tokio::io::{AsyncReadExt, AsyncWriteExt}; + + /// One-shot HTTP stub: accepts a single connection, consumes the request + /// head, writes `response` verbatim, closes (read-until-close framing). + async fn stub(response: &'static str) -> String { + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let addr = listener.local_addr().unwrap(); + tokio::spawn(async move { + if let Ok((mut sock, _)) = listener.accept().await { + let mut buf = [0u8; 65536]; + let _ = sock.read(&mut buf).await; + let _ = sock.write_all(response.as_bytes()).await; + let _ = sock.shutdown().await; + } + }); + format!("http://{addr}/v1/chat/completions") + } + + fn args(api_url: String) -> UpstreamArgs { + UpstreamArgs { + api_url, + model: "gpt-test".into(), + body: serde_json::json!({ "stream": true }), + headers: vec![("authorization", "Bearer sk-test".into())], + warnings: vec![], + } + } + + async fn drain(mut rx: mpsc::Receiver) -> Vec { + let mut out = Vec::new(); + while let Some(ev) = rx.recv().await { + out.push(ev); + } + out + } + + const HAPPY: &str = "HTTP/1.1 200 OK\r\ncontent-type: text/event-stream\r\nconnection: close\r\n\r\ndata: {\"choices\":[{\"index\":0,\"delta\":{\"role\":\"assistant\",\"content\":\"\"}}]}\n\ndata: {\"choices\":[{\"index\":0,\"delta\":{\"content\":\"Hello\"}}]}\n\ndata: {\"choices\":[{\"index\":0,\"delta\":{},\"finish_reason\":\"stop\"}]}\n\ndata: {\"choices\":[],\"usage\":{\"prompt_tokens\":12,\"completion_tokens\":2,\"prompt_tokens_details\":{\"cached_tokens\":4}}}\n\ndata: [DONE]\n\n"; + + #[tokio::test(flavor = "multi_thread")] + async fn happy_stream_yields_start_through_stop_and_done() { + let url = stub(HAPPY).await; + let events = drain(spawn_upstream(reqwest::Client::new(), args(url))).await; + assert!(matches!( + events.first(), + Some(AssistantMessageEvent::Start { .. }) + )); + assert!( + matches!( + events[events.len() - 2], + AssistantMessageEvent::Stop { + stop_reason: llm_router::types::events::StopReason::End, + .. + } + ), + "stop precedes done" + ); + match events.last() { + Some(AssistantMessageEvent::Done { message }) => { + assert_eq!(message.usage.as_ref().unwrap().input, Some(12)); + assert_eq!(message.usage.as_ref().unwrap().output, Some(2)); + assert_eq!(message.usage.as_ref().unwrap().cache_read, Some(4)); + assert_eq!(message.native_stop_reason.as_deref(), Some("stop")); + } + other => panic!("want done, got {other:?}"), + } + // exactly one terminal + assert_eq!(events.iter().filter(|e| e.is_terminal()).count(), 1); + } + + #[tokio::test(flavor = "multi_thread")] + async fn http_401_yields_auth_expired_error_frame() { + let url = stub( + "HTTP/1.1 401 Unauthorized\r\ncontent-type: application/json\r\nconnection: close\r\n\r\n{\"error\":{\"message\":\"Incorrect API key provided.\",\"type\":\"invalid_request_error\",\"code\":\"invalid_api_key\"}}", + ) + .await; + let events = drain(spawn_upstream(reqwest::Client::new(), args(url))).await; + assert_eq!(events.len(), 1); + match &events[0] { + AssistantMessageEvent::Error { error } => { + assert_eq!(error.error_kind, Some(ErrorKind::AuthExpired)); + } + other => panic!("want error, got {other:?}"), + } + } + + #[tokio::test(flavor = "multi_thread")] + async fn connect_failure_yields_transient_error_frame() { + // bind-then-drop guarantees a dead port + let dead = { + let l = std::net::TcpListener::bind("127.0.0.1:0").unwrap(); + format!("http://{}/v1/chat/completions", l.local_addr().unwrap()) + }; + let events = drain(spawn_upstream(reqwest::Client::new(), args(dead))).await; + assert_eq!(events.len(), 1); + match &events[0] { + AssistantMessageEvent::Error { error } => { + assert_eq!(error.error_kind, Some(ErrorKind::Transient)); + } + other => panic!("want error, got {other:?}"), + } + } + + #[tokio::test(flavor = "multi_thread")] + async fn stream_end_without_done_sentinel_still_emits_done() { + let url = stub( + "HTTP/1.1 200 OK\r\ncontent-type: text/event-stream\r\nconnection: close\r\n\r\ndata: {\"choices\":[{\"index\":0,\"delta\":{\"content\":\"Hi\"}}]}\n\n", + ) + .await; + let events = drain(spawn_upstream(reqwest::Client::new(), args(url))).await; + match events.last() { + Some(AssistantMessageEvent::Done { message }) => { + assert!( + matches!(&message.content[0], llm_router::types::content::ContentBlock::Text { text } if text == "Hi") + ); + } + other => panic!("want done, got {other:?}"), + } + } + + #[tokio::test(flavor = "multi_thread")] + async fn warnings_arrive_on_the_final_message() { + let url = stub(HAPPY).await; + let mut a = args(url); + a.warnings = vec!["thinking_level ignored".into()]; + let events = drain(spawn_upstream(reqwest::Client::new(), a)).await; + match events.last() { + Some(AssistantMessageEvent::Done { message }) => { + assert_eq!( + message.warnings.as_deref(), + Some(&["thinking_level ignored".to_string()][..]) + ); + } + other => panic!("want done, got {other:?}"), + } + } +} From 20899fc156a6582a81b0ad921e2ab64a6304b031 Mon Sep 17 00:00:00 2001 From: Ytallo Layon Date: Fri, 12 Jun 2026 09:44:44 -0300 Subject: [PATCH 13/19] feat(provider-openai): stream handler with ping watchdog and abort-on-channel-close --- provider-openai/src/lib.rs | 2 +- provider-openai/src/stream_fn.rs | 267 +++++++++++++++++++++++++++++++ 2 files changed, 268 insertions(+), 1 deletion(-) create mode 100644 provider-openai/src/stream_fn.rs diff --git a/provider-openai/src/lib.rs b/provider-openai/src/lib.rs index 858dcfd4a..3827e058a 100644 --- a/provider-openai/src/lib.rs +++ b/provider-openai/src/lib.rs @@ -12,7 +12,7 @@ pub mod request; pub mod router_client; pub mod sse; pub mod state; -// pub mod stream_fn; +pub mod stream_fn; pub mod upstream; pub mod wire; diff --git a/provider-openai/src/stream_fn.rs b/provider-openai/src/stream_fn.rs new file mode 100644 index 000000000..ae0a2adbf --- /dev/null +++ b/provider-openai/src/stream_fn.rs @@ -0,0 +1,267 @@ +//! The `provider::openai::stream` iii function (spec § Provider stream +//! contract): write AssistantMessageEvent frames as JSON text messages into +//! the router-owned channel, terminal done/error last, then close. +use crate::config::config_from_resolve; +use crate::errors::{classify_bus_error, invalid_request}; +use crate::reasoning::{is_reasoning_model, reasoning_effort_for}; +use crate::request::{build_body, build_headers, BodyArgs}; +use crate::sse::synthetic_error_event; +use crate::upstream::{spawn_upstream, UpstreamArgs}; +use crate::{curated, router_client, state}; +use futures::future::BoxFuture; +use iii_sdk::{IIIError, III}; +use llm_router::channels::open_sink; +use llm_router::chat::relay::FrameSink; +use llm_router::types::events::{AssistantMessageEvent, ErrorKind}; +use llm_router::types::router::ProviderStreamInput; +use serde_json::{json, Value}; +use std::time::Duration; +use tokio::sync::mpsc; + +/// Heartbeat cadence while the upstream is silent (spec: at least every 30s). +pub const PING_INTERVAL: Duration = Duration::from_secs(30); + +pub fn make_stream( + iii: III, + http: reqwest::Client, +) -> impl Fn(Value) -> BoxFuture<'static, Result> + Send + Sync + 'static { + move |raw: Value| { + let (iii, http) = (iii.clone(), http.clone()); + Box::pin(async move { + let input: ProviderStreamInput = serde_json::from_value(raw) + .map_err(|e| invalid_request(format!("bad ProviderStreamInput: {e}")))?; + let sink = open_sink(&iii, &input.writer_ref).await?; + run_stream_call(&iii, http, input, sink.as_ref()).await; + sink.close(); + // ProviderStreamOutput (spec § stream contract) + Ok(json!({ "ok": true })) + }) + } +} + +fn send_event(sink: &dyn FrameSink, ev: &AssistantMessageEvent) -> Result<(), ()> { + let frame = serde_json::to_string(ev).expect("serializable event"); + sink.send(&frame).map_err(|_| ()) +} + +async fn run_stream_call( + iii: &III, + http: reqwest::Client, + input: ProviderStreamInput, + sink: &dyn FrameSink, +) { + let model = input.model.clone(); + let mut warnings = Vec::new(); + + let token = state::load_token(iii).await; + let resolved = match router_client::resolve(iii, token.as_deref()).await { + Ok(r) => r, + Err(e) => { + let _ = send_event( + sink, + &synthetic_error_event( + &format!("router::provider::resolve failed: {e}"), + &model, + classify_bus_error(&e), + ), + ); + return; + } + }; + let cfg = match config_from_resolve(&model, input.max_output_tokens, &resolved) { + Ok(c) => c, + Err(_) => { + let _ = send_event( + sink, + &synthetic_error_event( + "provider openai not configured (no api_key in the llm-router entry and OPENAI_API_KEY unset)", + &model, + ErrorKind::Permanent, + ), + ); + return; + } + }; + + // model_meta is a hint, never source of truth (spec): absent → the + // catalog is authoritative → curated snapshot as a last resort. + let model_meta = match input.model_meta { + Some(m) => Some(m), + None => match router_client::models_get(iii, &model).await { + Some(m) => Some(m), + None => curated::find(&model), + }, + }; + let reasoning_effort = if is_reasoning_model( + &model, + model_meta.as_ref().and_then(|m| m.supports_thinking), + ) { + let effort = reasoning_effort_for(input.thinking_level, &model); + if input.thinking_level.is_some() && effort.is_none() { + // Report-and-continue: the family takes no effort param + // (o1/chat-tuned) — the request still succeeds at the default. + warnings.push(format!( + "thinking_level ignored: {model} does not accept reasoning_effort" + )); + } + effort + } else { + if input.thinking_level.is_some() { + warnings.push(format!( + "thinking_level ignored: {model} is not a reasoning model" + )); + } + None + }; + + let body = build_body(&BodyArgs { + model: cfg.model.clone(), + max_tokens: cfg.max_tokens, + system_prompt: input.system_prompt.unwrap_or_default(), + messages: input.messages, + tools: input.tools.unwrap_or_default(), + reasoning_effort, + response_format: input.response_format, + }); + let headers = build_headers(&cfg); + + let rx = spawn_upstream( + http, + UpstreamArgs { + api_url: cfg.api_url.clone(), + model, + body, + headers, + warnings, + }, + ); + pump(rx, sink, PING_INTERVAL).await; +} + +/// Forward upstream events to the sink; ping through silence; stop on the +/// terminal event or on a failed write (caller gone → dropping `rx` aborts +/// the upstream task and its in-flight HTTP request). +/// Verbatim copy of provider-anthropic's pump — shared extraction into +/// llm-router is a listed follow-up. +pub async fn pump( + mut rx: mpsc::Receiver, + sink: &dyn FrameSink, + ping_interval: Duration, +) { + loop { + match tokio::time::timeout(ping_interval, rx.recv()).await { + Ok(Some(ev)) => { + let terminal = ev.is_terminal(); + if send_event(sink, &ev).is_err() { + return; + } + if terminal { + return; + } + } + // Upstream task ended without a terminal (panic/abort): the + // router synthesizes the terminal frame — never two terminals. + Ok(None) => return, + // Silent stretch: heartbeat (also probes for a gone caller). + Err(_elapsed) => { + if send_event(sink, &AssistantMessageEvent::Ping).is_err() { + return; + } + } + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::sse::empty_assistant; + use llm_router::chat::relay::RelayRead; + use llm_router::testkit::fake_channels::FakeChannel; + + fn done_event() -> AssistantMessageEvent { + AssistantMessageEvent::Done { + message: empty_assistant("gpt-test"), + } + } + + #[tokio::test(flavor = "multi_thread")] + async fn forwards_events_and_stops_at_terminal() { + let ch = FakeChannel::new(); + let (tx, rx) = mpsc::channel(8); + tx.send(AssistantMessageEvent::Start { + partial: empty_assistant("m"), + }) + .await + .unwrap(); + tx.send(done_event()).await.unwrap(); + // a frame after the terminal must never be forwarded + tx.send(AssistantMessageEvent::Ping).await.unwrap(); + drop(tx); + + pump(rx, &ch.writer, Duration::from_secs(30)).await; + ch.writer.close(); + + let mut frames = Vec::new(); + let mut reader = ch.reader; + while let llm_router::chat::relay::ReadEvent::Msg(m) = + reader.next(Duration::from_millis(100)).await + { + frames.push(m); + } + assert_eq!(frames.len(), 2); + let last: Value = serde_json::from_str(&frames[1]).unwrap(); + assert_eq!(last["type"], "done"); + } + + #[tokio::test(flavor = "multi_thread")] + async fn pings_through_silence() { + let ch = FakeChannel::new(); + let (tx, rx) = mpsc::channel::(8); + // hold tx open, send nothing for > 2 ping intervals, then terminate + let pump_task = { + let writer = ch.writer.clone(); + tokio::spawn(async move { pump(rx, &writer, Duration::from_millis(50)).await }) + }; + tokio::time::sleep(Duration::from_millis(140)).await; + tx.send(done_event()).await.unwrap(); + drop(tx); + pump_task.await.unwrap(); + ch.writer.close(); + + let mut frames = Vec::new(); + let mut reader = ch.reader; + while let llm_router::chat::relay::ReadEvent::Msg(m) = + reader.next(Duration::from_millis(100)).await + { + frames.push(m); + } + let pings = frames + .iter() + .filter(|f| serde_json::from_str::(f).unwrap()["type"] == "ping") + .count(); + assert!( + pings >= 2, + "want >=2 pings through 140ms of silence, got {pings}" + ); + assert_eq!( + serde_json::from_str::(frames.last().unwrap()).unwrap()["type"], + "done" + ); + } + + #[tokio::test(flavor = "multi_thread")] + async fn reader_close_stops_the_pump_and_drops_the_receiver() { + let ch = FakeChannel::new(); + ch.reader.close(); // caller gone before anything is written + let (tx, rx) = mpsc::channel(8); + tx.send(AssistantMessageEvent::Start { + partial: empty_assistant("m"), + }) + .await + .unwrap(); + pump(rx, &ch.writer, Duration::from_secs(30)).await; // returns immediately + // the receiver was consumed and dropped by pump → upstream send fails + assert!(tx.send(done_event()).await.is_err()); + } +} From 58b4559a20cde791c695dbf7e046ca5fbcf35796 Mon Sep 17 00:00:00 2001 From: Ytallo Layon Date: Fri, 12 Jun 2026 09:45:49 -0300 Subject: [PATCH 14/19] feat(provider-openai): chat-family model discovery with reconcile-to-empty semantics --- provider-openai/src/discovery.rs | 203 +++++++++++++++++++++++++++++++ provider-openai/src/lib.rs | 2 +- 2 files changed, 204 insertions(+), 1 deletion(-) create mode 100644 provider-openai/src/discovery.rs diff --git a/provider-openai/src/discovery.rs b/provider-openai/src/discovery.rs new file mode 100644 index 000000000..fb018b2e6 --- /dev/null +++ b/provider-openai/src/discovery.rs @@ -0,0 +1,203 @@ +//! Live model discovery: GET /v1/models, filter to chat/reasoning families, +//! merge curated capability metadata, reconcile the catalog slice through +//! the router's single write path. +use crate::config::DEFAULT_API_URL; +use crate::curated::{merge_with_live, LiveStub}; +use crate::errors::upstream_unavailable; +use crate::{router_client, state}; +use futures::future::BoxFuture; +use iii_sdk::{IIIError, III}; +use serde_json::{json, Value}; + +/// Derive the models endpoint from the configured completions endpoint +/// (`…/v1/chat/completions` → `…/v1/models`). +pub fn models_url(api_url: &str) -> String { + match api_url.strip_suffix("/chat/completions") { + Some(base) => format!("{base}/models"), + None => "https://api.openai.com/v1/models".to_string(), + } +} + +/// Chat/reasoning families we route to (port of discover.ts). Excludes +/// embeddings, audio, image, moderation, realtime, and legacy +/// completion-only ids. +pub fn is_chat_model(id: &str) -> bool { + let lower = id.to_ascii_lowercase(); + let chat_family = lower.starts_with("gpt-") + || lower.starts_with("chatgpt") + || (lower.len() >= 2 && lower.starts_with('o') && lower.as_bytes()[1].is_ascii_digit()); + if !chat_family { + return false; + } + const NON_CHAT: [&str; 14] = [ + "embedding", + "whisper", + "tts", + "audio", + "dall-e", + "image", + "moderation", + "realtime", + "transcribe", + "search", + "babbage", + "davinci", + "ada", + "curie", + ]; + !NON_CHAT.iter().any(|term| lower.contains(term)) +} + +pub fn parse_live_models(json: &Value) -> Vec { + json.get("data") + .and_then(Value::as_array) + .map(|rows| { + rows.iter() + .filter_map(|raw| { + let id = raw + .get("id") + .and_then(Value::as_str) + .filter(|s| !s.is_empty())?; + is_chat_model(id).then(|| LiveStub { id: id.to_string() }) + }) + .collect() + }) + .unwrap_or_default() +} + +enum FetchOutcome { + Ok(Vec), + AuthFailed, + Transient(String), +} + +async fn fetch_live_models( + http: &reqwest::Client, + url: &str, + credential_value: &str, +) -> FetchOutcome { + let req = http + .get(url) + .header("authorization", format!("Bearer {credential_value}")); + let resp = match req.send().await { + Ok(r) => r, + Err(e) => return FetchOutcome::Transient(format!("models fetch failed: {e}")), + }; + let status = resp.status().as_u16(); + if status == 401 || status == 403 { + return FetchOutcome::AuthFailed; + } + if !(200..300).contains(&status) { + return FetchOutcome::Transient(format!("models fetch http {status}")); + } + match resp.json::().await { + Ok(v) => FetchOutcome::Ok(parse_live_models(&v)), + Err(e) => FetchOutcome::Transient(format!("models response not json: {e}")), + } +} + +/// The refresh flow; returns the reconciled slice size. +pub async fn refresh_models(iii: &III, http: &reqwest::Client) -> Result { + let token = state::load_token(iii).await; + let resolved = router_client::resolve(iii, token.as_deref()).await?; + + let Some(credential) = resolved.credential else { + // Key removed: prune the slice so the picker reflects removal + // instead of showing stale, unusable rows. + router_client::reconcile(iii, vec![], token.as_deref()).await?; + return Ok(0); + }; + let credential_value = crate::config::credential_parts(&credential); + + let url = models_url(resolved.api_url.as_deref().unwrap_or(DEFAULT_API_URL)); + match fetch_live_models(http, &url, credential_value).await { + FetchOutcome::Ok(stubs) => { + let models = merge_with_live(&stubs); + let count = models.len(); + router_client::reconcile(iii, models, token.as_deref()).await?; + Ok(count) + } + FetchOutcome::AuthFailed => { + // Revoked/invalid key: the models are genuinely unusable. + router_client::reconcile(iii, vec![], token.as_deref()).await?; + Ok(0) + } + // Blip: keep the previous slice (spec § reconcile-to-empty guidance). + FetchOutcome::Transient(msg) => Err(upstream_unavailable(msg)), + } +} + +pub fn make_refresh_models( + iii: III, + http: reqwest::Client, +) -> impl Fn(Value) -> BoxFuture<'static, Result> + Send + Sync + 'static { + move |_raw: Value| { + let (iii, http) = (iii.clone(), http.clone()); + Box::pin(async move { + let count = refresh_models(&iii, &http).await?; + Ok(json!({ "ok": true, "count": count })) + }) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn models_url_derives_from_completions_endpoint() { + assert_eq!( + models_url("https://api.openai.com/v1/chat/completions"), + "https://api.openai.com/v1/models" + ); + assert_eq!( + models_url("http://127.0.0.1:9999/v1/chat/completions"), + "http://127.0.0.1:9999/v1/models" + ); + // unrecognized shape falls back to the public endpoint + assert_eq!( + models_url("https://proxy.example/custom"), + "https://api.openai.com/v1/models" + ); + } + + #[test] + fn chat_family_filter_admits_gpt_o_series_and_chatgpt_only() { + assert!(is_chat_model("gpt-5.2")); + assert!(is_chat_model("gpt-5-mini")); + assert!(is_chat_model("o3-mini")); + assert!(is_chat_model("o4-mini")); + assert!(is_chat_model("chatgpt-4o-latest")); + assert!(!is_chat_model("text-embedding-3-large")); + assert!(!is_chat_model("gpt-4o-audio-preview")); + assert!(!is_chat_model("whisper-1")); + assert!(!is_chat_model("dall-e-3")); + assert!(!is_chat_model("gpt-4o-realtime-preview")); + assert!(!is_chat_model("gpt-image-1")); + assert!(!is_chat_model("omni-moderation-latest")); + assert!(!is_chat_model("davinci-002")); + } + + #[test] + fn parses_ids_skipping_malformed_and_non_chat_rows() { + let json = serde_json::json!({ + "data": [ + { "id": "gpt-5.1-2025-11-13", "object": "model" }, + { "id": "" }, + { "object": "model" }, + { "id": "text-embedding-3-large", "object": "model" }, + { "id": "o3-mini", "object": "model" }, + ] + }); + let stubs = parse_live_models(&json); + assert_eq!(stubs.len(), 2); + assert_eq!(stubs[0].id, "gpt-5.1-2025-11-13"); + assert_eq!(stubs[1].id, "o3-mini"); + } + + #[test] + fn missing_or_malformed_data_yields_empty() { + assert!(parse_live_models(&serde_json::json!({})).is_empty()); + assert!(parse_live_models(&serde_json::json!({ "data": "nope" })).is_empty()); + } +} diff --git a/provider-openai/src/lib.rs b/provider-openai/src/lib.rs index 3827e058a..c4ab7790b 100644 --- a/provider-openai/src/lib.rs +++ b/provider-openai/src/lib.rs @@ -3,7 +3,7 @@ pub mod config; pub mod curated; -// pub mod discovery; +pub mod discovery; pub mod errors; pub mod manifest; pub mod reasoning; From 5f031da64b9be075ea4fa17af79a66f0f3c63d53 Mon Sep 17 00:00:00 2001 From: Ytallo Layon Date: Fri, 12 Jun 2026 09:49:18 -0300 Subject: [PATCH 15/19] feat(provider-openai): declare-with-backoff registration and router::ready rebind --- provider-openai/src/lib.rs | 2 +- provider-openai/src/main.rs | 5 +- provider-openai/src/register.rs | 129 ++++++++++++++++++++++++++++++++ 3 files changed, 133 insertions(+), 3 deletions(-) create mode 100644 provider-openai/src/register.rs diff --git a/provider-openai/src/lib.rs b/provider-openai/src/lib.rs index c4ab7790b..4a59cb53a 100644 --- a/provider-openai/src/lib.rs +++ b/provider-openai/src/lib.rs @@ -7,7 +7,7 @@ pub mod discovery; pub mod errors; pub mod manifest; pub mod reasoning; -// pub mod register; +pub mod register; pub mod request; pub mod router_client; pub mod sse; diff --git a/provider-openai/src/main.rs b/provider-openai/src/main.rs index 84f55a126..b3e39018b 100644 --- a/provider-openai/src/main.rs +++ b/provider-openai/src/main.rs @@ -1,4 +1,5 @@ use iii_sdk::{register_worker, InitOptions}; +use provider_openai::register::register_provider; #[tokio::main] async fn main() -> Result<(), Box> { @@ -14,8 +15,8 @@ async fn main() -> Result<(), Box> { let url = std::env::var("III_WS_URL").unwrap_or_else(|_| "ws://localhost:49134".to_string()); let iii = register_worker(&url, InitOptions::default()); - // Task 14 replaces this with register::register_provider(iii.clone()).await?; - println!("[provider-openai] connected to {url}"); + register_provider(iii.clone()).await?; + println!("[provider-openai] registered against {url}"); tokio::signal::ctrl_c().await?; iii.shutdown(); diff --git a/provider-openai/src/register.rs b/provider-openai/src/register.rs new file mode 100644 index 000000000..06dafae4b --- /dev/null +++ b/provider-openai/src/register.rs @@ -0,0 +1,129 @@ +//! Boot wiring: function surface, the router::ready rebind, and the +//! declare-with-backoff loop (spec § Registration lifecycle). +use crate::config::{DEFAULT_API_URL, DEFAULT_MAX_TOKENS}; +use crate::discovery::make_refresh_models; +use crate::stream_fn::make_stream; +use crate::{curated, router_client, state, PROVIDER_ID}; +use iii_sdk::{IIIError, RegisterFunction, RegisterTriggerInput, III}; +use llm_router::types::router::{ProviderDeclaration, ProviderDefaults}; +use serde_json::{json, Value}; +use std::collections::BTreeMap; +use std::time::Duration; + +pub fn declaration() -> ProviderDeclaration { + ProviderDeclaration { + id: PROVIDER_ID.into(), + display_name: Some("OpenAI".into()), + credential_env_var: Some("OPENAI_API_KEY".into()), + defaults: Some(ProviderDefaults { + api_url: Some(DEFAULT_API_URL.into()), + max_tokens: Some(DEFAULT_MAX_TOKENS), + extra: BTreeMap::new(), + }), + config_schema: None, // the router's default {api_key, api_url, max_tokens} + supports_model_listing: Some(true), + // Static slice mirrors the curated snapshot: no cold catalog hole + // before first discovery (spec § register). + models: Some(curated::static_models()), + // Self-reported; availability mapping only, never authorization. + worker_id: Some("provider-openai".into()), + } +} + +/// One registration attempt: declare (with the persisted token when present) +/// and persist the token the router returns. +pub async fn declare_once(iii: &III) -> Result<(), IIIError> { + let token = state::load_token(iii).await; + let mut payload = serde_json::to_value(declaration()).expect("serializable declaration"); + if let Some(t) = &token { + payload["token"] = json!(t); + } + let resp = router_client::register(iii, payload).await?; + if let Some(t) = resp.get("registration_token").and_then(Value::as_str) { + if token.as_deref() != Some(t) { + persist_registration_token(iii, t).await?; + } + } + Ok(()) +} + +async fn persist_registration_token(iii: &III, token: &str) -> Result<(), IIIError> { + let mut delay = Duration::from_millis(200); + for attempt in 0..5 { + match state::store_token(iii, token).await { + Ok(()) => return Ok(()), + Err(e) if attempt < 4 => { + eprintln!( + "[provider-openai] store registration_token failed ({e}); retrying in {delay:?}" + ); + tokio::time::sleep(delay).await; + delay = (delay * 2).min(Duration::from_secs(2)); + } + Err(e) => return Err(e), + } + } + unreachable!("persist_registration_token loop always returns"); +} + +/// Retry until acknowledged: covers provider-before-router boot order. +/// A token mismatch also lands here — it never resolves on its own and +/// needs the operator to clear the binding (logged every attempt). +pub async fn declare_with_backoff(iii: III) { + let mut delay = Duration::from_millis(500); + loop { + match declare_once(&iii).await { + Ok(()) => { + println!("[provider-openai] registered with llm-router"); + return; + } + Err(e) => { + eprintln!("[provider-openai] register failed ({e}); retrying in {delay:?}"); + } + } + tokio::time::sleep(delay).await; + delay = (delay * 2).min(Duration::from_secs(10)); + } +} + +pub async fn register_provider(iii: III) -> Result<(), IIIError> { + // Streaming uses no total timeout (the router owns stream budgets); + // connect failures surface fast. + let http = reqwest::Client::builder() + .connect_timeout(Duration::from_secs(10)) + .build() + .expect("reqwest client"); + + iii.register_function( + "provider::openai::stream", + RegisterFunction::new_async(make_stream(iii.clone(), http.clone())), + ); + iii.register_function( + "provider::openai::refresh_models", + RegisterFunction::new_async(make_refresh_models(iii.clone(), http)), + ); + + // Re-declare when the router restarts: router::ready rides iii-pubsub. + { + let iii_ready = iii.clone(); + iii.register_function( + "provider::openai::on_router_ready", + RegisterFunction::new_async(move |_raw: Value| { + let iii = iii_ready.clone(); + async move { + tokio::spawn(declare_with_backoff(iii)); + Ok(json!({ "ok": true })) + } + }), + ); + } + let _ = iii.register_trigger(RegisterTriggerInput { + trigger_type: "subscribe".into(), + function_id: "provider::openai::on_router_ready".into(), + config: json!({ "topic": "router::ready" }), + metadata: None, + }); + + // Boot declare, off the boot path (a missing router must not block boot). + tokio::spawn(declare_with_backoff(iii)); + Ok(()) +} From 853d78cb4125ef32f444df48e86da5c3405a4b37 Mon Sep 17 00:00:00 2001 From: Ytallo Layon Date: Fri, 12 Jun 2026 09:51:48 -0300 Subject: [PATCH 16/19] test(provider-openai): live-engine integration suite with stubbed upstream --- provider-openai/tests/integration.rs | 498 +++++++++++++++++++++++++++ 1 file changed, 498 insertions(+) create mode 100644 provider-openai/tests/integration.rs diff --git a/provider-openai/tests/integration.rs b/provider-openai/tests/integration.rs new file mode 100644 index 000000000..97f7dcace --- /dev/null +++ b/provider-openai/tests/integration.rs @@ -0,0 +1,498 @@ +//! Engine-backed integration suite — real engine, real router, real provider, +//! stubbed upstream. Self-skips when no engine is available. +use std::io::Write as _; +use std::sync::Arc; +use std::time::{Duration, Instant}; + +use iii_sdk::{register_worker, InitOptions, TriggerRequest, III}; +use llm_router::register::register_router; +use provider_openai::register::register_provider; +use serde_json::{json, Value}; + +// ── engine bootstrap ──────────────────────────────────────────────────────── + +struct Engine { + url: String, + child: std::process::Child, + dir: std::path::PathBuf, +} + +impl Drop for Engine { + fn drop(&mut self) { + let _ = self.child.kill(); + let _ = self.child.wait(); + let _ = std::fs::remove_dir_all(&self.dir); + } +} + +fn engine_bin() -> Option { + if let Ok(p) = std::env::var("III_ENGINE_BIN") { + return Some(p.into()); + } + let on_path = std::process::Command::new("iii") + .arg("--version") + .stdout(std::process::Stdio::null()) + .stderr(std::process::Stdio::null()) + .status() + .map(|s| s.success()) + .unwrap_or(false); + on_path.then(|| "iii".into()) +} + +fn free_port() -> u16 { + std::net::TcpListener::bind("127.0.0.1:0") + .expect("bind ephemeral port") + .local_addr() + .expect("local addr") + .port() +} + +/// Spawn a minimal engine in a temp dir; poll until WS-reachable. +/// None = no engine available on this host → the caller self-skips. +async fn spawn_engine() -> Option { + let bin = engine_bin()?; + let port = free_port(); + let dir = std::env::temp_dir().join(format!("provider-openai-it-{}", uuid::Uuid::new_v4())); + std::fs::create_dir_all(&dir).expect("temp dir"); + + let config = format!( + r#"workers: + - name: iii-worker-manager + config: + port: {port} + - name: iii-pubsub + config: + adapter: + name: local + - name: configuration + config: + adapter: + name: fs + config: + directory: {dir}/configuration + ttl_seconds: 0 + - name: iii-state + config: + adapter: + name: kv + config: + file_path: {dir}/state_store.db + store_method: file_based +"#, + port = port, + dir = dir.display(), + ); + let config_path = dir.join("config.yaml"); + std::fs::File::create(&config_path) + .and_then(|mut f| f.write_all(config.as_bytes())) + .expect("write config"); + + let child = std::process::Command::new(&bin) + .arg("--no-update-check") + .arg("--config") + .arg(&config_path) + .current_dir(&dir) + .stdout(std::process::Stdio::null()) + .stderr(std::process::Stdio::null()) + .spawn() + .expect("spawn engine"); + + let url = format!("ws://127.0.0.1:{port}"); + let probe = register_worker(&url, InitOptions::default()); + let deadline = Instant::now() + Duration::from_secs(15); + loop { + let ready = probe + .trigger(TriggerRequest { + function_id: "engine::workers::list".into(), + payload: json!({}), + action: None, + timeout_ms: Some(1000), + }) + .await + .is_ok(); + if ready { + break; + } + assert!( + Instant::now() < deadline, + "engine did not become ready in 15s" + ); + tokio::time::sleep(Duration::from_millis(250)).await; + } + probe.shutdown(); + + Some(Engine { url, child, dir }) +} + +/// Self-skip macro: returns from the test when no engine is available. +macro_rules! engine_or_skip { + () => { + match spawn_engine().await { + Some(e) => e, + None => { + eprintln!("skipping: no iii engine (set III_ENGINE_BIN or put `iii` on PATH)"); + return; + } + } + }; +} + +async fn call(iii: &III, function_id: &str, payload: Value) -> Result { + iii.trigger(TriggerRequest { + function_id: function_id.into(), + payload, + action: None, + timeout_ms: Some(10_000), + }) + .await +} + +/// Consumer-side channel: collect frames + a pump that drives dispatch. +async fn consumer_channel( + iii: &III, +) -> ( + iii_sdk::StreamChannelRef, + Arc>>, + tokio::task::JoinHandle<()>, +) { + let channel = iii_sdk::helpers::create_channel(iii, None) + .await + .expect("channel"); + let frames = Arc::new(std::sync::Mutex::new(Vec::::new())); + let f2 = frames.clone(); + channel + .reader + .on_message(move |m| { + f2.lock().unwrap().push(m); + }) + .await; + let writer_ref = channel.writer_ref.clone(); + let pump = tokio::spawn(async move { + let _ = channel.reader.read_all().await; + }); + (writer_ref, frames, pump) +} + +// ── stub upstream ─────────────────────────────────────────────────────────── + +/// Routes by request line; loops over connections until dropped. +struct StubUpstream { + url: String, // http://addr/v1/chat/completions — what goes in the config slice + handle: tokio::task::JoinHandle<()>, +} + +impl Drop for StubUpstream { + fn drop(&mut self) { + self.handle.abort(); + } +} + +const STUB_SSE: &str = "HTTP/1.1 200 OK\r\ncontent-type: text/event-stream\r\nconnection: close\r\n\r\ndata: {\"choices\":[{\"index\":0,\"delta\":{\"role\":\"assistant\",\"content\":\"\"}}]}\n\ndata: {\"choices\":[{\"index\":0,\"delta\":{\"content\":\"Hello\"}}]}\n\ndata: {\"choices\":[{\"index\":0,\"delta\":{},\"finish_reason\":\"stop\"}]}\n\ndata: {\"choices\":[],\"usage\":{\"prompt_tokens\":12,\"completion_tokens\":2,\"prompt_tokens_details\":{\"cached_tokens\":4}}}\n\ndata: [DONE]\n\n"; + +const STUB_401: &str = "HTTP/1.1 401 Unauthorized\r\ncontent-type: application/json\r\nconnection: close\r\n\r\n{\"error\":{\"message\":\"Incorrect API key provided.\",\"type\":\"invalid_request_error\",\"code\":\"invalid_api_key\"}}"; + +const STUB_MODELS: &str = "HTTP/1.1 200 OK\r\ncontent-type: application/json\r\nconnection: close\r\n\r\n{\"data\":[{\"id\":\"gpt-5.1-2025-11-13\",\"object\":\"model\"},{\"id\":\"o3-mini\",\"object\":\"model\"},{\"id\":\"text-embedding-3-large\",\"object\":\"model\"}]}"; + +async fn stub_upstream(messages_response: &'static str) -> StubUpstream { + use tokio::io::{AsyncReadExt, AsyncWriteExt}; + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let addr = listener.local_addr().unwrap(); + let handle = tokio::spawn(async move { + loop { + let Ok((mut sock, _)) = listener.accept().await else { + break; + }; + tokio::spawn(async move { + let mut buf = vec![0u8; 65536]; + let n = sock.read(&mut buf).await.unwrap_or(0); + let head = String::from_utf8_lossy(&buf[..n]); + let response = if head.starts_with("GET /v1/models") { + STUB_MODELS + } else { + messages_response + }; + let _ = sock.write_all(response.as_bytes()).await; + let _ = sock.shutdown().await; + }); + } + }); + StubUpstream { + url: format!("http://{addr}/v1/chat/completions"), + handle, + } +} + +// ── boot + config ─────────────────────────────────────────────────────────── + +/// Boot router + provider on one engine; wait until the provider is listed. +async fn boot_stack(engine_url: &str) -> (III, III) { + let router_iii = register_worker(engine_url, InitOptions::default()); + register_router(router_iii.clone()) + .await + .expect("router boots"); + let provider_iii = register_worker(engine_url, InitOptions::default()); + register_provider(provider_iii.clone()) + .await + .expect("provider boots"); + + let deadline = Instant::now() + Duration::from_secs(15); + loop { + let list = call(&router_iii, "router::provider::list", json!({})) + .await + .unwrap(); + let registered = list["providers"] + .as_array() + .is_some_and(|p| p.iter().any(|x| x["id"] == "openai")); + if registered { + break; + } + assert!( + Instant::now() < deadline, + "provider never registered: {list}" + ); + tokio::time::sleep(Duration::from_millis(200)).await; + } + (router_iii, provider_iii) +} + +/// Point the openai slice at the stub. +async fn configure_stub_key(router_iii: &III, stub_url: &str) { + call( + router_iii, + "configuration::set", + json!({ "id": "llm-router", "value": { "providers": { + "openai": { "api_key": "sk-test", "api_url": stub_url } + } } }), + ) + .await + .expect("config set"); +} + +// ── scenarios ─────────────────────────────────────────────────────────────── + +#[tokio::test(flavor = "multi_thread")] +async fn provider_registers_with_static_catalog_and_persisted_token() { + let engine = engine_or_skip!(); + let (router_iii, provider_iii) = boot_stack(&engine.url).await; + + // static slice landed at registration (no cold catalog hole) + let list = call( + &router_iii, + "router::models::list", + json!({ "provider": "openai" }), + ) + .await + .unwrap(); + let ids: Vec<&str> = list["models"] + .as_array() + .map(|a| a.iter().filter_map(|m| m["id"].as_str()).collect()) + .unwrap_or_default(); + assert!( + ids.contains(&"gpt-5.2"), + "curated ids in catalog, got {ids:?}" + ); + assert!(ids.contains(&"gpt-5-mini")); + + // capability surface: gpt-5.2 supports structured output + let supports = call( + &router_iii, + "router::models::supports", + json!({ "provider": "openai", "id": "gpt-5.2", "capability": "structured_output" }), + ) + .await + .unwrap(); + assert_eq!( + supports["supported"], true, + "gpt-5.2 has native JSON schema mode" + ); + + // the registration token was persisted to the provider's state scope + let token = call( + &provider_iii, + "state::get", + json!({ "scope": "provider-openai", "key": "registration_token" }), + ) + .await + .unwrap(); + assert!( + token.as_str().is_some_and(|t| !t.is_empty()), + "token persisted, got {token}" + ); + + router_iii.shutdown(); + provider_iii.shutdown(); +} + +#[tokio::test(flavor = "multi_thread")] +async fn chat_streams_end_to_end_with_cost_fill() { + let engine = engine_or_skip!(); + let stub = stub_upstream(STUB_SSE).await; + let (router_iii, provider_iii) = boot_stack(&engine.url).await; + configure_stub_key(&router_iii, &stub.url).await; + + let consumer = register_worker(&engine.url, InitOptions::default()); + let (writer_ref, frames, pump) = consumer_channel(&consumer).await; + let res = consumer + .trigger(TriggerRequest { + function_id: "router::chat".into(), + payload: json!({ + "writer_ref": writer_ref, + "model": "gpt-5.2", + "messages": [{ "role": "user", "content": [{ "type": "text", "text": "hi" }], "timestamp": 1 }], + }), + action: None, + timeout_ms: Some(30_000), + }) + .await + .expect("chat succeeds"); + assert_eq!(res["ok"], true, "chat response: {res}"); + assert_eq!(res["provider"], "openai"); + assert_eq!(res["stop_reason"], "end"); + // the router filled cost_usd from the curated pricing (12 in + 2 out) + assert!( + res["usage"]["cost_usd"].as_f64().is_some_and(|c| c > 0.0), + "cost filled: {res}" + ); + + let _ = tokio::time::timeout(Duration::from_secs(5), pump).await; + let frames = frames.lock().unwrap(); + let first: Value = serde_json::from_str(frames.first().unwrap()).unwrap(); + assert_eq!(first["type"], "start"); + let last: Value = serde_json::from_str(frames.last().unwrap()).unwrap(); + assert_eq!(last["type"], "done"); + assert_eq!(last["message"]["content"][0]["text"], "Hello"); + assert_eq!(last["message"]["native_stop_reason"], "stop"); + assert_eq!(last["message"]["usage"]["cache_read"], 4); + + consumer.shutdown(); + router_iii.shutdown(); + provider_iii.shutdown(); +} + +#[tokio::test(flavor = "multi_thread")] +async fn upstream_401_surfaces_as_auth_expired_error_frame() { + let engine = engine_or_skip!(); + let stub = stub_upstream(STUB_401).await; + let (router_iii, provider_iii) = boot_stack(&engine.url).await; + configure_stub_key(&router_iii, &stub.url).await; + + let consumer = register_worker(&engine.url, InitOptions::default()); + let (writer_ref, frames, pump) = consumer_channel(&consumer).await; + let res = consumer + .trigger(TriggerRequest { + function_id: "router::chat".into(), + payload: json!({ + "writer_ref": writer_ref, + "model": "gpt-5.2", + "messages": [{ "role": "user", "content": [{ "type": "text", "text": "hi" }], "timestamp": 1 }], + }), + action: None, + timeout_ms: Some(30_000), + }) + .await + .expect("chat resolves even on upstream failure"); + assert_eq!(res["ok"], false, "chat response: {res}"); + + let _ = tokio::time::timeout(Duration::from_secs(5), pump).await; + let frames = frames.lock().unwrap(); + let last: Value = serde_json::from_str(frames.last().unwrap()).unwrap(); + assert_eq!(last["type"], "error"); + assert_eq!(last["error"]["error_kind"], "auth_expired"); + + consumer.shutdown(); + router_iii.shutdown(); + provider_iii.shutdown(); +} + +#[tokio::test(flavor = "multi_thread")] +async fn refresh_models_reconciles_filtered_live_union_curated() { + let engine = engine_or_skip!(); + let stub = stub_upstream(STUB_SSE).await; + let (router_iii, provider_iii) = boot_stack(&engine.url).await; + configure_stub_key(&router_iii, &stub.url).await; + + let res = call(&provider_iii, "provider::openai::refresh_models", json!({})) + .await + .expect("refresh succeeds"); + assert_eq!(res["ok"], true); + + let list = call( + &router_iii, + "router::models::list", + json!({ "provider": "openai" }), + ) + .await + .unwrap(); + let ids: Vec<&str> = list["models"] + .as_array() + .map(|a| a.iter().filter_map(|m| m["id"].as_str()).collect()) + .unwrap_or_default(); + + // live dated id enriched with curated caps + assert!(ids.contains(&"gpt-5.1-2025-11-13"), "got {ids:?}"); + // unknown live id kept (o3-mini — no curated record) + assert!(ids.contains(&"o3-mini"), "got {ids:?}"); + // text-embedding-3-large filtered out (not a chat/reasoning model) + assert!( + !ids.contains(&"text-embedding-3-large"), + "embedding model should be filtered: {ids:?}" + ); + // curated aliases retained even if not in the live list + assert!(ids.contains(&"gpt-5.2"), "got {ids:?}"); + // the covered base id is NOT duplicated + assert!( + !ids.contains(&"gpt-5.1"), + "base id should not be duplicated alongside dated id: {ids:?}" + ); + + // dated id carries curated context_window + let dated = list["models"] + .as_array() + .unwrap() + .iter() + .find(|m| m["id"] == "gpt-5.1-2025-11-13") + .unwrap(); + assert_eq!( + dated["context_window"], 400_000, + "curated metadata applied to the live id" + ); + + router_iii.shutdown(); + provider_iii.shutdown(); +} + +#[tokio::test(flavor = "multi_thread")] +async fn provider_redeclares_on_router_ready() { + let engine = engine_or_skip!(); + let (router_iii, provider_iii) = boot_stack(&engine.url).await; + + // simulate a router restart: drop the first router, boot a fresh one + router_iii.shutdown(); + tokio::time::sleep(Duration::from_millis(500)).await; + let router2 = register_worker(&engine.url, InitOptions::default()); + register_router(router2.clone()) + .await + .expect("router reboots"); + + // router::ready (pubsub) → provider re-declares with its persisted token + let deadline = Instant::now() + Duration::from_secs(15); + loop { + let list = call(&router2, "router::provider::list", json!({})) + .await + .unwrap(); + let listed = list["providers"] + .as_array() + .is_some_and(|p| p.iter().any(|x| x["id"] == "openai")); + if listed { + break; + } + assert!( + Instant::now() < deadline, + "provider never re-declared: {list}" + ); + tokio::time::sleep(Duration::from_millis(250)).await; + } + + router2.shutdown(); + provider_iii.shutdown(); +} From c607bb266e03768d813e9e1d2ffdf5f03e009307 Mon Sep 17 00:00:00 2001 From: Ytallo Layon Date: Fri, 12 Jun 2026 09:52:53 -0300 Subject: [PATCH 17/19] docs(provider-openai): worker README and router cross-reference --- llm-router/README.md | 2 ++ provider-openai/README.md | 57 +++++++++++++++++++++++++++++++++++++++ 2 files changed, 59 insertions(+) create mode 100644 provider-openai/README.md diff --git a/llm-router/README.md b/llm-router/README.md index 5e0c58704..21cab0cc2 100644 --- a/llm-router/README.md +++ b/llm-router/README.md @@ -171,6 +171,8 @@ A provider worker must: The first real provider implementing this protocol is [`provider-anthropic/`](../provider-anthropic/) — useful as a reference implementation alongside the scripted provider in the integration tests. +[`provider-openai/`](../provider-openai/) follows the same structure for the +OpenAI Chat Completions API (native structured output, reasoning_effort). ## Local development & testing diff --git a/provider-openai/README.md b/provider-openai/README.md new file mode 100644 index 000000000..e96a5f29b --- /dev/null +++ b/provider-openai/README.md @@ -0,0 +1,57 @@ +# provider-openai + +OpenAI Chat Completions provider worker behind [llm-router](../llm-router/). +Implements the provider protocol from +`tech-specs/2026-06-agentic/llm-router.md`: `provider::openai::stream` +(SSE chunks → `AssistantMessageEvent` frames into a router-owned channel) and +`provider::openai::refresh_models` (live `GET /v1/models` filtered to +chat/reasoning families ∪ curated capability snapshot → +`router::models::reconcile`). + +## Behavior + +- **Registration:** self-declares via `router::provider::register` with + backoff until acked, and re-declares on the `router::ready` pubsub topic. + The declaration ships a static curated `models` slice (no cold-catalog + hole) and `credential_env_var: OPENAI_API_KEY`. +- **Identity binding:** the router returns a `registration_token` on first + registration; it is persisted in iii-state (scope `provider-openai`, + key `registration_token`) and presented on every later + `register`/`resolve`/`reconcile`. If that state is lost the router rejects + re-registration — the operator must clear the binding on the router side. +- **Credentials:** resolved per request via `router::provider::resolve` + (config slice → `OPENAI_API_KEY` env on the router → none). Both + `api_key` and `oauth` credential shapes are sent as `Authorization: + Bearer`; v1 performs no OAuth refresh. +- **Liveness:** `ping` at least every 30s of upstream silence; a failed + channel write (caller gone / `router::abort`) drops the SSE receiver and + aborts the in-flight HTTP request. +- **Errors:** 401/403 → `auth_expired`, 429 → `rate_limited` (except + `insufficient_quota`, a billing wall → `permanent`), + `context_length_exceeded` → `context_overflow`, 5xx/network → `transient`, + other 4xx → `permanent`. No transport retries here — the router owns + retry policy. +- **Structured output:** native. A `response_format` with a schema maps to + strict `json_schema` mode; without one, `json_object` mode (the caller + must mention "JSON" in the prompt per OpenAI's rules). Every curated + record declares `supports_structured_output: true`. +- **Reasoning:** `thinking_level` maps to `reasoning_effort` per model + family (`src/reasoning.rs` — the ladders encode real 400s: o1 and + chat-tuned variants take no param, pro is high-only, xhigh is gpt-5.2+). + No reasoning text is streamed back on Chat Completions; + `completion_tokens_details.reasoning_tokens` lands on `usage.reasoning`. +- **Prompt caching:** automatic on OpenAI's side — no request markers. + `prompt_tokens_details.cached_tokens` lands on `usage.cache_read`. +- **Curated snapshot:** `src/curated.rs` carries windows / output ceilings / + capability flags / pricing (USD per MTok). Update it against models.dev + when OpenAI ships new models — discovery only supplies bare ids. + +## Tests + +```bash +cargo test # unit (pure modules + TCP stubs) +III_ENGINE_BIN=$(which iii) cargo test --test integration -- --test-threads=1 +``` + +The integration suite spawns a real engine, the real router (path dep), this +provider, and a local stub upstream — no external API calls anywhere. From c3323c0b91b7958765d4f152315cf7dea1db3a6d Mon Sep 17 00:00:00 2001 From: Ytallo Layon Date: Fri, 12 Jun 2026 17:10:41 -0300 Subject: [PATCH 18/19] refactor(provider-openai): live-only catalog with generation filter MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit GET /v1/models is now the source of truth for the catalog's id list: the registration declaration carries no static models and a refresh fires right after registering. OpenAI's models API returns bare ids — no capability tree, display names, or limits — so a local metadata table enriches known families and conservative defaults cover the rest; pricing stays local for the same reason. Two filters shape the slice: known legacy generations (gpt-3*/gpt-4*/ chatgpt*/o1/o3/o4 — a name denylist, since there is no capability data to key off; unrecognized future families pass through) are dropped, and dated snapshots fold into their undated alias when both are live. Against the real API this takes the picker from 70 rows to 23, all current-generation. --- provider-openai/src/curated.rs | 308 ++++++++++----------------- provider-openai/src/discovery.rs | 65 ++++-- provider-openai/src/register.rs | 31 ++- provider-openai/src/stream_fn.rs | 7 +- provider-openai/tests/integration.rs | 115 +++++----- 5 files changed, 255 insertions(+), 271 deletions(-) diff --git a/provider-openai/src/curated.rs b/provider-openai/src/curated.rs index 35d867562..e2f703207 100644 --- a/provider-openai/src/curated.rs +++ b/provider-openai/src/curated.rs @@ -1,41 +1,11 @@ -//! Curated capability snapshot for the OpenAI catalog slice, and the -//! live-discovery merge. The static slice doubles as the declaration's -//! `models` so the catalog has no cold hole before first discovery. +//! Local catalog metadata for the OpenAI slice. Unlike Anthropic's models +//! API, OpenAI's `GET /v1/models` returns bare ids — no capability tree, no +//! display names, no limits — so live discovery owns the *id list* while +//! this module fills in everything the API cannot provide: per-family +//! metadata for known families, conservative defaults for unknown ones, and +//! the legacy-generation denylist. use crate::PROVIDER_ID; use llm_router::types::model::{Model, Pricing}; -use std::collections::HashSet; - -/// One live `GET /v1/models` row (OpenAI rows carry no display name). -#[derive(Debug, Clone, PartialEq, Eq)] -pub struct LiveStub { - pub id: String, -} - -fn model( - id: &str, - display_name: &str, - context_window: u64, - max_output_tokens: u64, - xhigh: bool, - pricing: Pricing, -) -> Model { - Model { - id: id.into(), - provider: PROVIDER_ID.into(), - display_name: Some(display_name.into()), - context_window, - max_output_tokens, - input_limit: None, - supports_thinking: Some(true), - supports_xhigh: Some(xhigh), - supports_tools: Some(true), - supports_vision: Some(true), - supports_cache: Some(true), - supports_structured_output: Some(true), // native json_schema mode - thinking_budgets: None, // effort enum, not token budgets - pricing: Some(pricing), - } -} fn price(input: f64, output: f64) -> Pricing { Pricing { @@ -46,58 +16,81 @@ fn price(input: f64, output: f64) -> Pricing { } } -/// The hand-maintained snapshot (USD per MTok). Verify against models.dev -/// before release; stale records degrade preflight sizing and cost fill. -pub fn static_models() -> Vec { - vec![ - model( - "gpt-5.2", - "GPT-5.2", - 400_000, - 128_000, - true, - price(1.75, 14.0), - ), - model( - "gpt-5.1", - "GPT-5.1", - 400_000, - 128_000, - false, - price(1.25, 10.0), - ), - model( - "gpt-5-mini", - "GPT-5 Mini", - 400_000, - 128_000, - false, - price(0.25, 2.0), - ), - model( - "gpt-5-nano", - "GPT-5 Nano", - 400_000, - 128_000, - false, - price(0.05, 0.40), - ), - ] +/// Known legacy families (pre-gpt-5 generations). With no capability data on +/// the wire this has to be a name denylist, unlike provider-anthropic's +/// capability-driven filter — kept permissive on purpose: an unrecognized +/// future family (gpt-6, a new letter series) flows straight into the +/// catalog rather than vanishing. +pub fn is_legacy_generation(model_id: &str) -> bool { + let base = base_id(model_id).to_ascii_lowercase(); + base.starts_with("gpt-3") + || base.starts_with("gpt-4") + || base.starts_with("chatgpt") + || base.starts_with("o1") + || base.starts_with("o3") + || base.starts_with("o4") } -/// Curated lookup for request shaping when the router forwarded no -/// model_meta and the catalog has no record. -pub fn find(model_id: &str) -> Option { - let base = base_id(model_id); - static_models() - .into_iter() - .find(|m| m.id == model_id || m.id == base) +/// Hand-maintained metadata for the families we know (USD per MTok; verify +/// against openai.com/pricing before release). A missing row only degrades +/// display polish and cost enrichment, never routing. +fn family_meta(base: &str) -> Option<(&'static str, u64, u64, bool, Pricing)> { + match base { + "gpt-5.2" => Some(("GPT-5.2", 400_000, 128_000, true, price(1.75, 14.0))), + "gpt-5.1" => Some(("GPT-5.1", 400_000, 128_000, false, price(1.25, 10.0))), + "gpt-5-mini" => Some(("GPT-5 Mini", 400_000, 128_000, false, price(0.25, 2.0))), + "gpt-5-nano" => Some(("GPT-5 Nano", 400_000, 128_000, false, price(0.05, 0.40))), + _ => None, + } +} + +/// One live id → catalog Model: known-family metadata when the base id +/// matches, conservative defaults otherwise. Tools and automatic caching are +/// uniform across the chat families we admit; vision/thinking/xhigh stay +/// unknown for unrecognized families (reasoning.rs id-patterns decide per +/// request). +pub fn enrich(id: &str) -> Model { + let base = base_id(id); + match family_meta(base) { + Some((display, context_window, max_output_tokens, xhigh, pricing)) => Model { + id: id.into(), + provider: PROVIDER_ID.into(), + display_name: Some(display.into()), + context_window, + max_output_tokens, + input_limit: None, + supports_thinking: Some(true), + supports_xhigh: Some(xhigh), + supports_tools: Some(true), + supports_vision: Some(true), + supports_cache: Some(true), + supports_structured_output: Some(true), // native json_schema mode + thinking_budgets: None, // effort enum, not token budgets + pricing: Some(pricing), + }, + None => Model { + id: id.into(), + provider: PROVIDER_ID.into(), + display_name: None, + context_window: 128_000, + max_output_tokens: 16_384, + input_limit: None, + supports_thinking: None, + supports_xhigh: None, + supports_tools: Some(true), + supports_vision: None, + supports_cache: Some(true), + supports_structured_output: None, + thinking_budgets: None, + pricing: None, + }, + } } /// Strip a trailing `-YYYY-MM-DD` date suffix /// (`gpt-5.1-2025-11-13` → `gpt-5.1`). OpenAI dates use hyphenated ISO form, /// unlike Anthropic's compact `-YYYYMMDD`. -fn base_id(id: &str) -> &str { +pub fn base_id(id: &str) -> &str { if id.len() > 11 { let (head, tail) = id.split_at(id.len() - 11); let bytes = tail.as_bytes(); @@ -114,130 +107,63 @@ fn base_id(id: &str) -> &str { id } -/// Live ids ∪ curated metadata (spec § Catalog metadata sources): every live -/// id gets curated capabilities when its base id matches, conservative -/// defaults otherwise; curated entries missing from the live list are kept -/// (aliases the API doesn't enumerate). -pub fn merge_with_live(live: &[LiveStub]) -> Vec { - let curated = static_models(); - let mut out: Vec = Vec::new(); - let mut covered: HashSet = HashSet::new(); - - for stub in live { - let base = base_id(&stub.id); - match curated.iter().find(|m| m.id == stub.id || m.id == base) { - Some(c) => { - covered.insert(c.id.clone()); - let mut m = c.clone(); - m.id = stub.id.clone(); - out.push(m); - } - None => out.push(unknown_model(stub)), - } - } - for c in curated { - if !covered.contains(&c.id) && !out.iter().any(|m| m.id == c.id) { - out.push(c); - } - } - out -} - -/// Conservative record for a live id with no curated metadata. Tools and -/// automatic caching are uniform across the chat families we admit; -/// vision/thinking/structured-output stay unknown. -fn unknown_model(stub: &LiveStub) -> Model { - Model { - id: stub.id.clone(), - provider: PROVIDER_ID.into(), - display_name: None, - context_window: 128_000, - max_output_tokens: 16_384, - input_limit: None, - supports_thinking: None, - supports_xhigh: None, - supports_tools: Some(true), - supports_vision: None, - supports_cache: Some(true), - supports_structured_output: None, - thinking_budgets: None, - pricing: None, - } -} - #[cfg(test)] mod tests { use super::*; #[test] - fn snapshot_is_well_formed() { - let models = static_models(); - assert_eq!(models.len(), 4); - for m in &models { - assert_eq!(m.provider, "openai"); - assert_eq!(m.supports_structured_output, Some(true)); - assert_eq!(m.supports_thinking, Some(true)); - assert!( - m.thinking_budgets.is_none(), - "OpenAI uses efforts, not budgets" - ); - let pricing = m.pricing.as_ref().unwrap(); - assert!(pricing.input.is_some() && pricing.output.is_some()); - assert!(pricing.cache_write.is_none(), "no cache-write surcharge"); - assert!(m.context_window > 0 && m.max_output_tokens > 0); + fn legacy_generations_are_flagged_current_ones_are_not() { + for legacy in [ + "gpt-3.5-turbo", + "gpt-4", + "gpt-4-turbo-2024-04-09", + "gpt-4o", + "gpt-4o-mini", + "gpt-4.1-nano", + "chatgpt-4o-latest", + "o1-pro", + "o3-mini", + "o4-mini-2025-04-16", + ] { + assert!(is_legacy_generation(legacy), "{legacy} should be legacy"); + } + for current in [ + "gpt-5", + "gpt-5-mini", + "gpt-5.1-2025-11-13", + "gpt-5.4-pro", + "gpt-5.5", + "gpt-6", // future family stays permissive + ] { + assert!(!is_legacy_generation(current), "{current} should be kept"); } - // xhigh is the gpt-5.2+ tier only (matches the Task 6 effort ladder) - assert_eq!( - models - .iter() - .filter(|m| m.supports_xhigh == Some(true)) - .count(), - 1 - ); } #[test] - fn find_matches_dated_ids_via_base() { - assert_eq!(find("gpt-5.1").unwrap().id, "gpt-5.1"); - assert_eq!(find("gpt-5.1-2025-11-13").unwrap().id, "gpt-5.1"); - assert!(find("claude-sonnet-4-6").is_none()); - assert!( - find("gpt-5.1-turbo-extra").is_none(), - "non-date suffixes don't match" - ); + fn enrich_applies_family_metadata_via_base_id() { + let m = enrich("gpt-5.1-2025-11-13"); + assert_eq!(m.id, "gpt-5.1-2025-11-13"); + assert_eq!(m.display_name.as_deref(), Some("GPT-5.1")); + assert_eq!(m.context_window, 400_000); + assert_eq!(m.supports_structured_output, Some(true)); + assert_eq!(m.pricing.as_ref().unwrap().input, Some(1.25)); + assert!(m.pricing.as_ref().unwrap().cache_write.is_none()); } #[test] - fn merge_enriches_live_ids_and_keeps_curated_aliases() { - let live = vec![ - LiveStub { - id: "gpt-5.1-2025-11-13".into(), - }, - LiveStub { - id: "o3-mini".into(), - }, - ]; - let merged = merge_with_live(&live); - // live dated id carries curated caps under the live id - let dated = merged - .iter() - .find(|m| m.id == "gpt-5.1-2025-11-13") - .unwrap(); - assert_eq!(dated.context_window, 400_000); - assert_eq!(dated.supports_structured_output, Some(true)); - // unknown live id gets conservative defaults - let mystery = merged.iter().find(|m| m.id == "o3-mini").unwrap(); - assert_eq!(mystery.max_output_tokens, 16_384); - assert!(mystery.pricing.is_none()); - // curated entries not in the live list are kept - assert!(merged.iter().any(|m| m.id == "gpt-5.2")); - assert!(merged.iter().any(|m| m.id == "gpt-5-mini")); - // the covered base id is NOT duplicated - assert!(!merged.iter().any(|m| m.id == "gpt-5.1")); + fn enrich_defaults_conservatively_for_unknown_families() { + let m = enrich("gpt-5.4-pro"); + assert_eq!(m.display_name, None); + assert_eq!(m.context_window, 128_000); + assert_eq!(m.max_output_tokens, 16_384); + assert_eq!(m.supports_thinking, None); + assert!(m.pricing.is_none()); } #[test] - fn merge_with_empty_live_list_returns_full_snapshot() { - assert_eq!(merge_with_live(&[]).len(), 4); + fn base_id_strips_only_iso_date_suffixes() { + assert_eq!(base_id("gpt-5.1-2025-11-13"), "gpt-5.1"); + assert_eq!(base_id("gpt-5.1"), "gpt-5.1"); + assert_eq!(base_id("gpt-5.1-turbo-extra"), "gpt-5.1-turbo-extra"); } } diff --git a/provider-openai/src/discovery.rs b/provider-openai/src/discovery.rs index fb018b2e6..df5435047 100644 --- a/provider-openai/src/discovery.rs +++ b/provider-openai/src/discovery.rs @@ -1,13 +1,17 @@ -//! Live model discovery: GET /v1/models, filter to chat/reasoning families, -//! merge curated capability metadata, reconcile the catalog slice through -//! the router's single write path. +//! Live model discovery: `GET /v1/models` is the source of truth for the +//! catalog's id list — filtered to current-generation chat/reasoning +//! families, deduplicated against dated snapshots, enriched with the local +//! metadata table (OpenAI's API carries no capability data), and reconciled +//! through the router's single write path. use crate::config::DEFAULT_API_URL; -use crate::curated::{merge_with_live, LiveStub}; +use crate::curated::{base_id, enrich, is_legacy_generation}; use crate::errors::upstream_unavailable; use crate::{router_client, state}; use futures::future::BoxFuture; use iii_sdk::{IIIError, III}; +use llm_router::types::model::Model; use serde_json::{json, Value}; +use std::collections::HashSet; /// Derive the models endpoint from the configured completions endpoint /// (`…/v1/chat/completions` → `…/v1/models`). @@ -48,8 +52,9 @@ pub fn is_chat_model(id: &str) -> bool { !NON_CHAT.iter().any(|term| lower.contains(term)) } -pub fn parse_live_models(json: &Value) -> Vec { - json.get("data") +pub fn parse_live_models(json: &Value) -> Vec { + let ids: Vec = json + .get("data") .and_then(Value::as_array) .map(|rows| { rows.iter() @@ -58,15 +63,27 @@ pub fn parse_live_models(json: &Value) -> Vec { .get("id") .and_then(Value::as_str) .filter(|s| !s.is_empty())?; - is_chat_model(id).then(|| LiveStub { id: id.to_string() }) + (is_chat_model(id) && !is_legacy_generation(id)).then(|| id.to_string()) }) .collect() }) - .unwrap_or_default() + .unwrap_or_default(); + + // Dated snapshots are pinning artifacts: when the undated alias is also + // live (gpt-5.1 next to gpt-5.1-2025-11-13), keep only the alias so the + // picker carries one row per model. + let live: HashSet<&str> = ids.iter().map(String::as_str).collect(); + ids.iter() + .filter(|id| { + let base = base_id(id); + base == id.as_str() || !live.contains(base) + }) + .map(|id| enrich(id)) + .collect() } enum FetchOutcome { - Ok(Vec), + Ok(Vec), AuthFailed, Transient(String), } @@ -111,8 +128,7 @@ pub async fn refresh_models(iii: &III, http: &reqwest::Client) -> Result { - let models = merge_with_live(&stubs); + FetchOutcome::Ok(models) => { let count = models.len(); router_client::reconcile(iii, models, token.as_deref()).await?; Ok(count) @@ -179,7 +195,7 @@ mod tests { } #[test] - fn parses_ids_skipping_malformed_and_non_chat_rows() { + fn parses_ids_skipping_malformed_non_chat_and_legacy_rows() { let json = serde_json::json!({ "data": [ { "id": "gpt-5.1-2025-11-13", "object": "model" }, @@ -187,12 +203,29 @@ mod tests { { "object": "model" }, { "id": "text-embedding-3-large", "object": "model" }, { "id": "o3-mini", "object": "model" }, + { "id": "gpt-4o-mini", "object": "model" }, + { "id": "gpt-3.5-turbo", "object": "model" }, + ] + }); + let models = parse_live_models(&json); + // o-series, 4o, and 3.5 are legacy generations; the dated 5.1 stays + // (its undated alias is not in this list). + assert_eq!(models.len(), 1); + assert_eq!(models[0].id, "gpt-5.1-2025-11-13"); + assert_eq!(models[0].display_name.as_deref(), Some("GPT-5.1")); + } + + #[test] + fn dated_snapshot_drops_when_undated_alias_is_live() { + let json = serde_json::json!({ + "data": [ + { "id": "gpt-5.1", "object": "model" }, + { "id": "gpt-5.1-2025-11-13", "object": "model" }, + { "id": "gpt-5.4-2026-03-05", "object": "model" }, ] }); - let stubs = parse_live_models(&json); - assert_eq!(stubs.len(), 2); - assert_eq!(stubs[0].id, "gpt-5.1-2025-11-13"); - assert_eq!(stubs[1].id, "o3-mini"); + let ids: Vec = parse_live_models(&json).into_iter().map(|m| m.id).collect(); + assert_eq!(ids, ["gpt-5.1", "gpt-5.4-2026-03-05"]); } #[test] diff --git a/provider-openai/src/register.rs b/provider-openai/src/register.rs index 06dafae4b..d02d4f4b9 100644 --- a/provider-openai/src/register.rs +++ b/provider-openai/src/register.rs @@ -1,9 +1,9 @@ //! Boot wiring: function surface, the router::ready rebind, and the //! declare-with-backoff loop (spec § Registration lifecycle). use crate::config::{DEFAULT_API_URL, DEFAULT_MAX_TOKENS}; -use crate::discovery::make_refresh_models; +use crate::discovery::{make_refresh_models, refresh_models}; use crate::stream_fn::make_stream; -use crate::{curated, router_client, state, PROVIDER_ID}; +use crate::{router_client, state, PROVIDER_ID}; use iii_sdk::{IIIError, RegisterFunction, RegisterTriggerInput, III}; use llm_router::types::router::{ProviderDeclaration, ProviderDefaults}; use serde_json::{json, Value}; @@ -22,9 +22,10 @@ pub fn declaration() -> ProviderDeclaration { }), config_schema: None, // the router's default {api_key, api_url, max_tokens} supports_model_listing: Some(true), - // Static slice mirrors the curated snapshot: no cold catalog hole - // before first discovery (spec § register). - models: Some(curated::static_models()), + // No static slice: GET /v1/models is the source of truth, and a + // refresh fires right after registration (see declare_and_refresh), + // so the catalog fills from the API within seconds of boot. + models: None, // Self-reported; availability mapping only, never authorization. worker_id: Some("provider-openai".into()), } @@ -85,6 +86,17 @@ pub async fn declare_with_backoff(iii: III) { } } +/// Register, then populate the catalog from the live API. The declaration +/// carries no models, so the slice is empty until this refresh lands; +/// failures are logged and left to the next config-change refresh. +pub async fn declare_and_refresh(iii: III, http: reqwest::Client) { + declare_with_backoff(iii.clone()).await; + match refresh_models(&iii, &http).await { + Ok(count) => println!("[provider-openai] catalog refreshed: {count} models"), + Err(e) => eprintln!("[provider-openai] post-register refresh failed ({e})"), + } +} + pub async fn register_provider(iii: III) -> Result<(), IIIError> { // Streaming uses no total timeout (the router owns stream budgets); // connect failures surface fast. @@ -99,18 +111,19 @@ pub async fn register_provider(iii: III) -> Result<(), IIIError> { ); iii.register_function( "provider::openai::refresh_models", - RegisterFunction::new_async(make_refresh_models(iii.clone(), http)), + RegisterFunction::new_async(make_refresh_models(iii.clone(), http.clone())), ); // Re-declare when the router restarts: router::ready rides iii-pubsub. { let iii_ready = iii.clone(); + let http_ready = http.clone(); iii.register_function( "provider::openai::on_router_ready", RegisterFunction::new_async(move |_raw: Value| { - let iii = iii_ready.clone(); + let (iii, http) = (iii_ready.clone(), http_ready.clone()); async move { - tokio::spawn(declare_with_backoff(iii)); + tokio::spawn(declare_and_refresh(iii, http)); Ok(json!({ "ok": true })) } }), @@ -124,6 +137,6 @@ pub async fn register_provider(iii: III) -> Result<(), IIIError> { }); // Boot declare, off the boot path (a missing router must not block boot). - tokio::spawn(declare_with_backoff(iii)); + tokio::spawn(declare_and_refresh(iii, http)); Ok(()) } diff --git a/provider-openai/src/stream_fn.rs b/provider-openai/src/stream_fn.rs index ae0a2adbf..5275dc7ee 100644 --- a/provider-openai/src/stream_fn.rs +++ b/provider-openai/src/stream_fn.rs @@ -7,7 +7,7 @@ use crate::reasoning::{is_reasoning_model, reasoning_effort_for}; use crate::request::{build_body, build_headers, BodyArgs}; use crate::sse::synthetic_error_event; use crate::upstream::{spawn_upstream, UpstreamArgs}; -use crate::{curated, router_client, state}; +use crate::{router_client, state}; use futures::future::BoxFuture; use iii_sdk::{IIIError, III}; use llm_router::channels::open_sink; @@ -87,10 +87,7 @@ async fn run_stream_call( // catalog is authoritative → curated snapshot as a last resort. let model_meta = match input.model_meta { Some(m) => Some(m), - None => match router_client::models_get(iii, &model).await { - Some(m) => Some(m), - None => curated::find(&model), - }, + None => router_client::models_get(iii, &model).await, }; let reasoning_effort = if is_reasoning_model( &model, diff --git a/provider-openai/tests/integration.rs b/provider-openai/tests/integration.rs index 97f7dcace..f8400ade8 100644 --- a/provider-openai/tests/integration.rs +++ b/provider-openai/tests/integration.rs @@ -191,7 +191,7 @@ const STUB_SSE: &str = "HTTP/1.1 200 OK\r\ncontent-type: text/event-stream\r\nco const STUB_401: &str = "HTTP/1.1 401 Unauthorized\r\ncontent-type: application/json\r\nconnection: close\r\n\r\n{\"error\":{\"message\":\"Incorrect API key provided.\",\"type\":\"invalid_request_error\",\"code\":\"invalid_api_key\"}}"; -const STUB_MODELS: &str = "HTTP/1.1 200 OK\r\ncontent-type: application/json\r\nconnection: close\r\n\r\n{\"data\":[{\"id\":\"gpt-5.1-2025-11-13\",\"object\":\"model\"},{\"id\":\"o3-mini\",\"object\":\"model\"},{\"id\":\"text-embedding-3-large\",\"object\":\"model\"}]}"; +const STUB_MODELS: &str = "HTTP/1.1 200 OK\r\ncontent-type: application/json\r\nconnection: close\r\n\r\n{\"data\":[{\"id\":\"gpt-5.2\",\"object\":\"model\"},{\"id\":\"gpt-5.2-2025-12-11\",\"object\":\"model\"},{\"id\":\"gpt-5.4-2026-03-05\",\"object\":\"model\"},{\"id\":\"o3-mini\",\"object\":\"model\"},{\"id\":\"text-embedding-3-large\",\"object\":\"model\"}]}"; async fn stub_upstream(messages_response: &'static str) -> StubUpstream { use tokio::io::{AsyncReadExt, AsyncWriteExt}; @@ -268,14 +268,46 @@ async fn configure_stub_key(router_iii: &III, stub_url: &str) { .expect("config set"); } +/// Pull the live (stubbed) list into the catalog and wait until routing can +/// see it — the declaration carries no models, so tests that route by +/// catalog ownership must refresh first. +async fn refresh_and_wait(router_iii: &III, provider_iii: &III, expect_id: &str) { + let res = call(provider_iii, "provider::openai::refresh_models", json!({})) + .await + .expect("refresh succeeds"); + assert_eq!(res["ok"], true, "refresh response: {res}"); + let deadline = Instant::now() + Duration::from_secs(10); + loop { + let list = call( + router_iii, + "router::models::list", + json!({ "provider": "openai" }), + ) + .await + .unwrap(); + let present = list["models"] + .as_array() + .is_some_and(|a| a.iter().any(|m| m["id"] == expect_id)); + if present { + return; + } + assert!( + Instant::now() < deadline, + "catalog never gained {expect_id}: {list}" + ); + tokio::time::sleep(Duration::from_millis(100)).await; + } +} + // ── scenarios ─────────────────────────────────────────────────────────────── #[tokio::test(flavor = "multi_thread")] -async fn provider_registers_with_static_catalog_and_persisted_token() { +async fn provider_registers_with_persisted_token_and_live_only_catalog() { let engine = engine_or_skip!(); let (router_iii, provider_iii) = boot_stack(&engine.url).await; - // static slice landed at registration (no cold catalog hole) + // No static slice: with no key configured the catalog stays empty + // until live discovery can run (models come from GET /v1/models only). let list = call( &router_iii, "router::models::list", @@ -288,22 +320,8 @@ async fn provider_registers_with_static_catalog_and_persisted_token() { .map(|a| a.iter().filter_map(|m| m["id"].as_str()).collect()) .unwrap_or_default(); assert!( - ids.contains(&"gpt-5.2"), - "curated ids in catalog, got {ids:?}" - ); - assert!(ids.contains(&"gpt-5-mini")); - - // capability surface: gpt-5.2 supports structured output - let supports = call( - &router_iii, - "router::models::supports", - json!({ "provider": "openai", "id": "gpt-5.2", "capability": "structured_output" }), - ) - .await - .unwrap(); - assert_eq!( - supports["supported"], true, - "gpt-5.2 has native JSON schema mode" + ids.is_empty(), + "catalog empty before discovery, got {ids:?}" ); // the registration token was persisted to the provider's state scope @@ -329,6 +347,8 @@ async fn chat_streams_end_to_end_with_cost_fill() { let stub = stub_upstream(STUB_SSE).await; let (router_iii, provider_iii) = boot_stack(&engine.url).await; configure_stub_key(&router_iii, &stub.url).await; + // catalog-ownership routing needs the live slice in place + refresh_and_wait(&router_iii, &provider_iii, "gpt-5.2").await; let consumer = register_worker(&engine.url, InitOptions::default()); let (writer_ref, frames, pump) = consumer_channel(&consumer).await; @@ -384,6 +404,7 @@ async fn upstream_401_surfaces_as_auth_expired_error_frame() { payload: json!({ "writer_ref": writer_ref, "model": "gpt-5.2", + "provider": "openai", "messages": [{ "role": "user", "content": [{ "type": "text", "text": "hi" }], "timestamp": 1 }], }), action: None, @@ -405,16 +426,12 @@ async fn upstream_401_surfaces_as_auth_expired_error_frame() { } #[tokio::test(flavor = "multi_thread")] -async fn refresh_models_reconciles_filtered_live_union_curated() { +async fn refresh_models_reconciles_filtered_live_catalog() { let engine = engine_or_skip!(); let stub = stub_upstream(STUB_SSE).await; let (router_iii, provider_iii) = boot_stack(&engine.url).await; configure_stub_key(&router_iii, &stub.url).await; - - let res = call(&provider_iii, "provider::openai::refresh_models", json!({})) - .await - .expect("refresh succeeds"); - assert_eq!(res["ok"], true); + refresh_and_wait(&router_iii, &provider_iii, "gpt-5.2").await; let list = call( &router_iii, @@ -423,39 +440,37 @@ async fn refresh_models_reconciles_filtered_live_union_curated() { ) .await .unwrap(); - let ids: Vec<&str> = list["models"] - .as_array() - .map(|a| a.iter().filter_map(|m| m["id"].as_str()).collect()) - .unwrap_or_default(); + let models = list["models"].as_array().unwrap().clone(); + let ids: Vec<&str> = models.iter().filter_map(|m| m["id"].as_str()).collect(); - // live dated id enriched with curated caps - assert!(ids.contains(&"gpt-5.1-2025-11-13"), "got {ids:?}"); - // unknown live id kept (o3-mini — no curated record) - assert!(ids.contains(&"o3-mini"), "got {ids:?}"); - // text-embedding-3-large filtered out (not a chat/reasoning model) + // exactly the filtered live list: the undated alias wins over its dated + // snapshot, a dated id with no live alias stays, legacy generations and + // non-chat ids are gone + assert!(ids.contains(&"gpt-5.2"), "got {ids:?}"); assert!( - !ids.contains(&"text-embedding-3-large"), - "embedding model should be filtered: {ids:?}" + !ids.contains(&"gpt-5.2-2025-12-11"), + "dated snapshot should fold into the live alias: {ids:?}" ); - // curated aliases retained even if not in the live list - assert!(ids.contains(&"gpt-5.2"), "got {ids:?}"); - // the covered base id is NOT duplicated + assert!(ids.contains(&"gpt-5.4-2026-03-05"), "got {ids:?}"); assert!( - !ids.contains(&"gpt-5.1"), - "base id should not be duplicated alongside dated id: {ids:?}" + !ids.contains(&"o3-mini"), + "legacy generation should be filtered: {ids:?}" + ); + assert!( + !ids.contains(&"text-embedding-3-large"), + "embedding model should be filtered: {ids:?}" ); - // dated id carries curated context_window - let dated = list["models"] - .as_array() - .unwrap() + // known family carries the local metadata; unknown family stays default + let sonnet = models.iter().find(|m| m["id"] == "gpt-5.2").unwrap(); + assert_eq!(sonnet["context_window"], 400_000); + assert_eq!(sonnet["supports_structured_output"], true); + assert!(sonnet["pricing"]["input"].as_f64().is_some_and(|p| p > 0.0)); + let unknown = models .iter() - .find(|m| m["id"] == "gpt-5.1-2025-11-13") + .find(|m| m["id"] == "gpt-5.4-2026-03-05") .unwrap(); - assert_eq!( - dated["context_window"], 400_000, - "curated metadata applied to the live id" - ); + assert_eq!(unknown["context_window"], 128_000); router_iii.shutdown(); provider_iii.shutdown(); From df52d2f4f89563c467d3fe86a9ee1ab270f211c1 Mon Sep 17 00:00:00 2001 From: Ytallo Layon Date: Fri, 12 Jun 2026 21:18:02 -0300 Subject: [PATCH 19/19] chore(provider-openai): conform to the new-worker and binary-worker SOPs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - main.rs: clap CLI (--config/--url/--manifest), tracing init, WorkerMetadata identity, and shutdown_async on exit; --url honours III_WS_URL as a fallback. A --config file carrying real keys now warns instead of being silently ignored (provider config comes from the llm-router configuration entry). - config.yaml: committed stub documenting where configuration lives. - Release wiring: added to create-tag.yml options and release.yml tag patterns. Version pre-bumped to 0.3.0 above the retired bundled Node provider's tags (provider-openai/v0.1.0–v0.2.1) so the first Create Tag run cannot collide. - README: Modules table row; worker README documents the CLI flags. --- .github/workflows/create-tag.yml | 1 + .github/workflows/release.yml | 1 + README.md | 1 + provider-openai/Cargo.lock | 203 ++++++++++++++++++++++++++++++- provider-openai/Cargo.toml | 8 +- provider-openai/README.md | 8 ++ provider-openai/config.yaml | 8 ++ provider-openai/src/main.rs | 101 ++++++++++++++- 8 files changed, 323 insertions(+), 8 deletions(-) create mode 100644 provider-openai/config.yaml diff --git a/.github/workflows/create-tag.yml b/.github/workflows/create-tag.yml index bbf67aac7..d8b3a9f66 100644 --- a/.github/workflows/create-tag.yml +++ b/.github/workflows/create-tag.yml @@ -20,6 +20,7 @@ on: - iii-lsp-vscode - image-resize - mcp + - provider-openai - session-manager - shell - storage diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index fc371099f..bfd129b88 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -14,6 +14,7 @@ on: - 'iii-lsp/v*' - 'image-resize/v*' - 'mcp/v*' + - 'provider-openai/v*' - 'session-manager/v*' - 'shell/v*' - 'storage/v*' diff --git a/README.md b/README.md index 45d66e1a5..afab3e695 100644 --- a/README.md +++ b/README.md @@ -47,6 +47,7 @@ npx skills add iii-hq/iii --all | [`iii-lsp-vscode`](iii-lsp-vscode/) | Node | VS Code extension that embeds `iii-lsp`. | | [`image-resize`](image-resize/) | Rust | Image resize via channel I/O — JPEG/PNG/WebP with EXIF auto-orient, scale-to-fit / crop-to-fit. | | [`mcp`](mcp/) | Rust | MCP 2025-06-18 Streamable HTTP bridge — exposes iii functions tagged `mcp.expose` as MCP tools. | +| [`provider-openai`](provider-openai/) | Rust | OpenAI Chat Completions provider behind `llm-router` — `provider::openai::stream` with reasoning support and live chat-model discovery. | | [`shell`](shell/) | Rust | Unix shell + filesystem worker — `shell::exec` with allowlist/denylist/timeout/output caps and background jobs; `fs::ls`/`stat`/`mkdir`/`rm`/`chmod`/`mv`/`grep`/`sed`/`read`/`write` with host jail, denylist, and size caps. | | [`storage`](storage/) | Rust | S3-compatible object storage across AWS S3, GCS, Cloudflare R2, and a managed local rustfs backend. Streamed uploads, presigned URLs, and object change triggers. | | [`todo-worker`](todo-worker/) | Node | Quickstart CRUD todo worker using the Node iii SDK. | diff --git a/provider-openai/Cargo.lock b/provider-openai/Cargo.lock index d6c491584..a061bddef 100644 --- a/provider-openai/Cargo.lock +++ b/provider-openai/Cargo.lock @@ -11,6 +11,56 @@ dependencies = [ "memchr", ] +[[package]] +name = "anstream" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "824a212faf96e9acacdbd09febd34438f8f711fb84e09a8916013cd7815ca28d" +dependencies = [ + "anstyle", + "anstyle-parse", + "anstyle-query", + "anstyle-wincon", + "colorchoice", + "is_terminal_polyfill", + "utf8parse", +] + +[[package]] +name = "anstyle" +version = "1.0.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "940b3a0ca603d1eade50a4846a2afffd5ef57a9feac2c0e2ec2e14f9ead76000" + +[[package]] +name = "anstyle-parse" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52ce7f38b242319f7cabaa6813055467063ecdc9d355bbb4ce0c68908cd8130e" +dependencies = [ + "utf8parse", +] + +[[package]] +name = "anstyle-query" +version = "1.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "anstyle-wincon" +version = "3.0.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d" +dependencies = [ + "anstyle", + "once_cell_polyfill", + "windows-sys 0.61.2", +] + [[package]] name = "anyhow" version = "1.0.102" @@ -89,6 +139,52 @@ version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724" +[[package]] +name = "clap" +version = "4.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ddb117e43bbf7dacf0a4190fef4d345b9bad68dfc649cb349e7d17d28428e51" +dependencies = [ + "clap_builder", + "clap_derive", +] + +[[package]] +name = "clap_builder" +version = "4.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "714a53001bf66416adb0e2ef5ac857140e7dc3a0c48fb28b2f10762fc4b5069f" +dependencies = [ + "anstream", + "anstyle", + "clap_lex", + "strsim", +] + +[[package]] +name = "clap_derive" +version = "4.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2ce8604710f6733aa641a2b3731eaa1e8b3d9973d5e3565da11800813f997a9" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "clap_lex" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8d4a3bb8b1e0c1050499d1815f5ab16d04f0959b233085fb31653fbfc9d98f9" + +[[package]] +name = "colorchoice" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570" + [[package]] name = "core-foundation" version = "0.10.1" @@ -629,6 +725,12 @@ version = "2.12.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d98f6fed1fde3f8c21bc40a1abb88dd75e67924f9cffc3ef95607bad8017f8e2" +[[package]] +name = "is_terminal_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695" + [[package]] name = "itoa" version = "1.0.18" @@ -646,6 +748,12 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "lazy_static" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" + [[package]] name = "leb128fmt" version = "0.1.0" @@ -692,6 +800,15 @@ version = "0.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154" +[[package]] +name = "matchers" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d1525a2a28c7f4fa0fc98bb91ae755d1e2d1505079e05539e35bc876b5d65ae9" +dependencies = [ + "regex-automata", +] + [[package]] name = "memchr" version = "2.8.2" @@ -718,6 +835,15 @@ dependencies = [ "winapi", ] +[[package]] +name = "nu-ansi-term" +version = "0.50.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5" +dependencies = [ + "windows-sys 0.61.2", +] + [[package]] name = "objc2-core-foundation" version = "0.3.2" @@ -743,6 +869,12 @@ version = "1.21.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" +[[package]] +name = "once_cell_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" + [[package]] name = "openssl-probe" version = "0.2.1" @@ -844,8 +976,9 @@ dependencies = [ [[package]] name = "provider-openai" -version = "0.1.0" +version = "0.3.0" dependencies = [ + "clap", "futures", "iii-sdk", "llm-router", @@ -853,6 +986,8 @@ dependencies = [ "serde", "serde_json", "tokio", + "tracing", + "tracing-subscriber", "uuid", ] @@ -1260,6 +1395,15 @@ dependencies = [ "digest", ] +[[package]] +name = "sharded-slab" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f40ca3c46823713e0d4209592e8d6e826aa57e928f09752619fc696c499637f6" +dependencies = [ + "lazy_static", +] + [[package]] name = "shlex" version = "2.0.1" @@ -1304,6 +1448,12 @@ version = "1.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" +[[package]] +name = "strsim" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" + [[package]] name = "subtle" version = "2.6.1" @@ -1375,6 +1525,15 @@ dependencies = [ "syn", ] +[[package]] +name = "thread_local" +version = "1.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f60246a4944f24f6e018aa17cdeffb7818b76356965d03b07d6a9886e8962185" +dependencies = [ + "cfg-if", +] + [[package]] name = "tinystr" version = "0.8.3" @@ -1551,6 +1710,36 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a" dependencies = [ "once_cell", + "valuable", +] + +[[package]] +name = "tracing-log" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee855f1f400bd0e5c02d150ae5de3840039a3f54b025156404e34c23c03f47c3" +dependencies = [ + "log", + "once_cell", + "tracing-core", +] + +[[package]] +name = "tracing-subscriber" +version = "0.3.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb7f578e5945fb242538965c2d0b04418d38ec25c79d160cd279bf0731c8d319" +dependencies = [ + "matchers", + "nu-ansi-term", + "once_cell", + "regex-automata", + "sharded-slab", + "smallvec", + "thread_local", + "tracing", + "tracing-core", + "tracing-log", ] [[package]] @@ -1626,6 +1815,12 @@ version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" +[[package]] +name = "utf8parse" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" + [[package]] name = "uuid" version = "1.23.3" @@ -1638,6 +1833,12 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "valuable" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65" + [[package]] name = "version_check" version = "0.9.5" diff --git a/provider-openai/Cargo.toml b/provider-openai/Cargo.toml index 2233f7a6d..0df3e75a8 100644 --- a/provider-openai/Cargo.toml +++ b/provider-openai/Cargo.toml @@ -1,8 +1,11 @@ [workspace] +# 0.3.x: the provider-openai/v0.1.0–v0.2.1 tags belong to the retired +# bundled Node provider; this lineage starts above them so Create Tag never +# collides. [package] name = "provider-openai" -version = "0.1.0" +version = "0.3.0" edition = "2021" publish = false license = "Apache-2.0" @@ -23,6 +26,9 @@ serde_json = "1" tokio = { version = "1", features = ["macros", "rt-multi-thread", "sync", "time", "signal"] } futures = "0.3" reqwest = { version = "0.12", default-features = false, features = ["json", "stream", "rustls-tls"] } +clap = { version = "4", features = ["derive", "env"] } +tracing = "0.1" +tracing-subscriber = { version = "0.3", features = ["fmt", "env-filter"] } [dev-dependencies] uuid = { version = "1", features = ["v4"] } diff --git a/provider-openai/README.md b/provider-openai/README.md index e96a5f29b..ba033e31f 100644 --- a/provider-openai/README.md +++ b/provider-openai/README.md @@ -55,3 +55,11 @@ III_ENGINE_BIN=$(which iii) cargo test --test integration -- --test-threads=1 The integration suite spawns a real engine, the real router (path dep), this provider, and a local stub upstream — no external API calls anywhere. + +## Running + +The binary takes the standard worker CLI flags: `--url` (engine WebSocket, +default `ws://127.0.0.1:49134`, falls back to the `III_WS_URL` environment +variable), `--manifest` (print the registry manifest and exit), and +`--config` (accepted but ignored with a warning — provider config comes +from the `llm-router` configuration entry). diff --git a/provider-openai/config.yaml b/provider-openai/config.yaml new file mode 100644 index 000000000..9035a32bc --- /dev/null +++ b/provider-openai/config.yaml @@ -0,0 +1,8 @@ +# provider-openai has no file-based configuration. +# +# Credentials, `api_url`, and `max_tokens` arrive per request from +# llm-router's resolve step; the provider block lives in the engine's +# `llm-router` configuration entry (README § Configuration). +# +# This file exists to satisfy the standard worker layout +# (docs/sops/new-worker.md §2). Keys placed here are ignored with a warning. diff --git a/provider-openai/src/main.rs b/provider-openai/src/main.rs index b3e39018b..3fbdcb4c8 100644 --- a/provider-openai/src/main.rs +++ b/provider-openai/src/main.rs @@ -1,10 +1,56 @@ -use iii_sdk::{register_worker, InitOptions}; +//! `provider-openai` binary entry. +//! +//! The worker keeps no operator settings of its own: credentials, `api_url`, +//! and `max_tokens` arrive per request from llm-router's resolve step. +//! `--config` is still accepted per the binary-worker CLI contract (the +//! engine passes it when an operator sets a config block); keys found there +//! are warned about instead of silently dropped. + +use clap::Parser; +use iii_sdk::{register_worker, InitOptions, WorkerMetadata}; use provider_openai::register::register_provider; +#[derive(Parser, Debug)] +#[command( + name = "provider-openai", + about = "OpenAI Chat Completions provider worker behind llm-router." +)] +struct Cli { + /// Accepted for the standard worker CLI contract; provider config comes + /// from llm-router's resolve step, not from a file. + #[arg(long, default_value = "./config.yaml")] + config: String, + + #[arg(long, env = "III_WS_URL", default_value = "ws://127.0.0.1:49134")] + url: String, + + #[arg(long)] + manifest: bool, +} + +/// True when the YAML contents carry anything beyond comments, blank lines, +/// or a bare empty mapping (`{}`). +fn has_config_keys(contents: &str) -> bool { + contents + .lines() + .map(str::trim) + .filter(|line| !line.is_empty() && !line.starts_with('#')) + .any(|line| line != "{}") +} + #[tokio::main] async fn main() -> Result<(), Box> { + tracing_subscriber::fmt() + .with_env_filter( + tracing_subscriber::EnvFilter::try_from_default_env() + .unwrap_or_else(|_| tracing_subscriber::EnvFilter::new("info")), + ) + .init(); + + let cli = Cli::parse(); + // Registry publish pipeline: print the manifest JSON and exit. - if std::env::args().nth(1).as_deref() == Some("--manifest") { + if cli.manifest { println!( "{}", serde_json::to_string_pretty(&provider_openai::manifest::build_manifest())? @@ -12,13 +58,56 @@ async fn main() -> Result<(), Box> { return Ok(()); } - let url = std::env::var("III_WS_URL").unwrap_or_else(|_| "ws://localhost:49134".to_string()); - let iii = register_worker(&url, InitOptions::default()); + if let Ok(contents) = std::fs::read_to_string(&cli.config) { + if has_config_keys(&contents) { + tracing::warn!( + path = %cli.config, + "provider-openai takes no file-based config; configure the provider \ + via the engine's `llm-router` configuration entry — ignoring this file's keys" + ); + } + } + + let iii = register_worker( + &cli.url, + InitOptions { + metadata: Some(WorkerMetadata { + runtime: "rust".to_string(), + version: env!("CARGO_PKG_VERSION").to_string(), + name: "provider-openai".to_string(), + os: std::env::consts::OS.to_string(), + pid: Some(std::process::id()), + telemetry: None, + ..WorkerMetadata::default() + }), + ..InitOptions::default() + }, + ); register_provider(iii.clone()).await?; - println!("[provider-openai] registered against {url}"); + tracing::info!(url = %cli.url, "provider-openai registered"); tokio::signal::ctrl_c().await?; - iii.shutdown(); + iii.shutdown_async().await; Ok(()) } + +#[cfg(test)] +mod tests { + use super::has_config_keys; + + #[test] + fn empty_and_comment_only_contents_have_no_keys() { + assert!(!has_config_keys("")); + assert!(!has_config_keys("\n\n")); + assert!(!has_config_keys("# only a comment\n # indented comment\n")); + assert!(!has_config_keys("{}\n")); + assert!(!has_config_keys("# comment\n{}\n")); + } + + #[test] + fn real_keys_are_detected() { + assert!(has_config_keys("api_url: https://example.com\n")); + assert!(has_config_keys("# comment\nmax_tokens: 8192\n")); + } +}