diff --git a/Cargo.lock b/Cargo.lock index b1cbce1be..549bd99c0 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2,6 +2,12 @@ # It is not intended for manual editing. version = 4 +[[package]] +name = "adler2" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" + [[package]] name = "ahash" version = "0.8.12" @@ -31,6 +37,15 @@ version = "0.2.21" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923" +[[package]] +name = "android_system_properties" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae221649c9976a6f6c56ae1facf410f3ddb33cc661c4b7b61020a912d4237fbc" +dependencies = [ + "libc", +] + [[package]] name = "anstream" version = "1.0.0" @@ -139,6 +154,29 @@ dependencies = [ "syn 2.0.119", ] +[[package]] +name = "atk" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "241b621213072e993be4f6f3a9e4b45f65b7e6faad43001be957184b7bb1824b" +dependencies = [ + "atk-sys", + "glib", + "libc", +] + +[[package]] +name = "atk-sys" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c5e48b684b0ca77d2bbadeef17424c2ea3c897d44d566a1617e7e8f30614d086" +dependencies = [ + "glib-sys", + "gobject-sys", + "libc", + "system-deps", +] + [[package]] name = "atomic-waker" version = "1.1.2" @@ -278,6 +316,15 @@ dependencies = [ "serde_core", ] +[[package]] +name = "block2" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cdeb9d870516001442e364c5220d3574d2da8dc765554b4a617230d33fa58ef5" +dependencies = [ + "objc2", +] + [[package]] name = "borrow-or-share" version = "0.2.4" @@ -302,6 +349,31 @@ version = "1.12.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04" +[[package]] +name = "cairo-rs" +version = "0.18.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ca26ef0159422fb77631dc9d17b102f253b876fe1586b03b803e63a309b4ee2" +dependencies = [ + "bitflags", + "cairo-sys-rs", + "glib", + "libc", + "once_cell", + "thiserror 1.0.69", +] + +[[package]] +name = "cairo-sys-rs" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "685c9fa8e590b8b3d678873528d83411db17242a73fccaed827770ea0fedda51" +dependencies = [ + "glib-sys", + "libc", + "system-deps", +] + [[package]] name = "cc" version = "1.2.67" @@ -314,6 +386,16 @@ dependencies = [ "shlex", ] +[[package]] +name = "cfg-expr" +version = "0.15.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d067ad48b8650848b989a59a86c6c36a995d02d2bf778d45c3c5d57bc2718f02" +dependencies = [ + "smallvec", + "target-lexicon 0.12.16", +] + [[package]] name = "cfg-if" version = "1.0.4" @@ -343,8 +425,10 @@ version = "0.4.45" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1aa79e62e7697b8e29b513a68abacf485adcd1fe8284a4316c5ae868e6633327" dependencies = [ + "iana-time-zone", "num-traits", "serde", + "windows-link", ] [[package]] @@ -375,7 +459,7 @@ version = "4.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f2ce8604710f6733aa641a2b3731eaa1e8b3d9973d5e3565da11800813f997a9" dependencies = [ - "heck", + "heck 0.5.0", "proc-macro2", "quote", "syn 2.0.119", @@ -437,6 +521,30 @@ dependencies = [ "libc", ] +[[package]] +name = "crc32fast" +version = "1.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "01a7799fd6b852db0e61728dde9a204c423b44d689dbd432522543614b490e78" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "crossbeam-channel" +version = "0.5.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "98b0cc327b5bc766e7fda9c9260cc0fa81b43a8e240440422dff70788e3f9ef1" +dependencies = [ + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-utils" +version = "0.8.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a31eee39dddec8330830986fcd7625edb5a24ec90ea038215273bbc3adb08ac6" + [[package]] name = "data-encoding" version = "2.11.1" @@ -467,6 +575,37 @@ version = "0.1.13" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "56254986775e3233ffa9c4d7d3faaf6d36a2c09d30b20687e9f88bc8bafc16c8" +[[package]] +name = "dirs" +version = "7.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8d57d423b3c82e89b9a24ca3091fee61f456a26edbd28d26c65906f4bc1dcd8f" +dependencies = [ + "dirs-sys", +] + +[[package]] +name = "dirs-sys" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e01a3366d27ee9890022452ee61b2b63a67e6f13f58900b651ff5665f0bb1fab" +dependencies = [ + "libc", + "option-ext", + "redox_users", + "windows-sys 0.61.2", +] + +[[package]] +name = "dispatch2" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e0e367e4e7da84520dedcac1901e4da967309406d1e51017ae1abfb97adbd38" +dependencies = [ + "bitflags", + "objc2", +] + [[package]] name = "displaydoc" version = "0.2.6" @@ -478,6 +617,12 @@ dependencies = [ "syn 2.0.119", ] +[[package]] +name = "dpi" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d8b14ccef22fc6f5a8f4d7d768562a182c04ce9a3b3157b91390b52ddfdf1a76" + [[package]] name = "dunce" version = "1.0.5" @@ -512,7 +657,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" dependencies = [ "libc", - "windows-sys 0.52.0", + "windows-sys 0.61.2", ] [[package]] @@ -532,12 +677,42 @@ version = "2.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "da7c62ceae207dd37ea5b845da6a0696c799f85e97da1ab5b7910be3c1c80223" +[[package]] +name = "fdeflate" +version = "0.3.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e6853b52649d4ac5c0bd02320cddc5ba956bdb407c4b75a2c6b75bf51500f8c" +dependencies = [ + "simd-adler32", +] + +[[package]] +name = "field-offset" +version = "0.3.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "38e2275cc4e4fc009b0669731a1e5ab7ebf11f469eaede2bab9309a5b4d6057f" +dependencies = [ + "memoffset", + "rustc_version", +] + [[package]] name = "find-msvc-tools" version = "0.1.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" +[[package]] +name = "flate2" +version = "1.1.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e634e2e0ebac1ee034020da1ca582e17ffe4e0f5e985823721e168928136dcb" +dependencies = [ + "crc32fast", + "miniz_oxide 0.9.1", + "zlib-rs", +] + [[package]] name = "fluent-uri" version = "0.4.1" @@ -684,6 +859,64 @@ dependencies = [ "slab", ] +[[package]] +name = "gdk" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9f245958c627ac99d8e529166f9823fb3b838d1d41fd2b297af3075093c2691" +dependencies = [ + "cairo-rs", + "gdk-pixbuf", + "gdk-sys", + "gio", + "glib", + "libc", + "pango", +] + +[[package]] +name = "gdk-pixbuf" +version = "0.18.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "50e1f5f1b0bfb830d6ccc8066d18db35c487b1b2b1e8589b5dfe9f07e8defaec" +dependencies = [ + "gdk-pixbuf-sys", + "gio", + "glib", + "libc", + "once_cell", +] + +[[package]] +name = "gdk-pixbuf-sys" +version = "0.18.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f9839ea644ed9c97a34d129ad56d38a25e6756f99f3a88e15cd39c20629caf7" +dependencies = [ + "gio-sys", + "glib-sys", + "gobject-sys", + "libc", + "system-deps", +] + +[[package]] +name = "gdk-sys" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c2d13f38594ac1e66619e188c6d5a1adb98d11b2fcf7894fc416ad76aa2f3f7" +dependencies = [ + "cairo-sys-rs", + "gdk-pixbuf-sys", + "gio-sys", + "glib-sys", + "gobject-sys", + "libc", + "pango-sys", + "pkg-config", + "system-deps", +] + [[package]] name = "getrandom" version = "0.2.17" @@ -725,6 +958,148 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "gio" +version = "0.18.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d4fc8f532f87b79cbc51a79748f16a6828fb784be93145a322fa14d06d354c73" +dependencies = [ + "futures-channel", + "futures-core", + "futures-io", + "futures-util", + "gio-sys", + "glib", + "libc", + "once_cell", + "pin-project-lite", + "smallvec", + "thiserror 1.0.69", +] + +[[package]] +name = "gio-sys" +version = "0.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "37566df850baf5e4cb0dfb78af2e4b9898d817ed9263d1090a2df958c64737d2" +dependencies = [ + "glib-sys", + "gobject-sys", + "libc", + "system-deps", + "winapi", +] + +[[package]] +name = "glib" +version = "0.18.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "233daaf6e83ae6a12a52055f568f9d7cf4671dabb78ff9560ab6da230ce00ee5" +dependencies = [ + "bitflags", + "futures-channel", + "futures-core", + "futures-executor", + "futures-task", + "futures-util", + "gio-sys", + "glib-macros", + "glib-sys", + "gobject-sys", + "libc", + "memchr", + "once_cell", + "smallvec", + "thiserror 1.0.69", +] + +[[package]] +name = "glib-macros" +version = "0.18.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0bb0228f477c0900c880fd78c8759b95c7636dbd7842707f49e132378aa2acdc" +dependencies = [ + "heck 0.4.1", + "proc-macro-crate 2.0.2", + "proc-macro-error", + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "glib-sys" +version = "0.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "063ce2eb6a8d0ea93d2bf8ba1957e78dbab6be1c2220dd3daca57d5a9d869898" +dependencies = [ + "libc", + "system-deps", +] + +[[package]] +name = "gobject-sys" +version = "0.18.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0850127b514d1c4a4654ead6dedadb18198999985908e6ffe4436f53c785ce44" +dependencies = [ + "glib-sys", + "libc", + "system-deps", +] + +[[package]] +name = "gtk" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fd56fb197bfc42bd5d2751f4f017d44ff59fbb58140c6b49f9b3b2bdab08506a" +dependencies = [ + "atk", + "cairo-rs", + "field-offset", + "futures-channel", + "gdk", + "gdk-pixbuf", + "gio", + "glib", + "gtk-sys", + "gtk3-macros", + "libc", + "pango", + "pkg-config", +] + +[[package]] +name = "gtk-sys" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f29a1c21c59553eb7dd40e918be54dccd60c52b049b75119d5d96ce6b624414" +dependencies = [ + "atk-sys", + "cairo-sys-rs", + "gdk-pixbuf-sys", + "gdk-sys", + "gio-sys", + "glib-sys", + "gobject-sys", + "libc", + "pango-sys", + "system-deps", +] + +[[package]] +name = "gtk3-macros" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52ff3c5b21f14f0736fed6dcfc0bfb4225ebf5725f3c0209edeec181e4d73e9d" +dependencies = [ + "proc-macro-crate 1.3.1", + "proc-macro-error", + "proc-macro2", + "quote", + "syn 2.0.119", +] + [[package]] name = "h2" version = "0.4.15" @@ -755,6 +1130,12 @@ dependencies = [ "foldhash", ] +[[package]] +name = "heck" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "95505c38b4572b2d910cecb0281560f54b440a19336cbbcb27bf6ce6adc6f5a8" + [[package]] name = "heck" version = "0.5.0" @@ -878,6 +1259,30 @@ dependencies = [ "tracing", ] +[[package]] +name = "iana-time-zone" +version = "0.1.65" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e31bc9ad994ba00e440a8aa5c9ef0ec67d5cb5e5cb0cc7f8b744a35b389cc470" +dependencies = [ + "android_system_properties", + "core-foundation-sys", + "iana-time-zone-haiku", + "js-sys", + "log", + "wasm-bindgen", + "windows-core", +] + +[[package]] +name = "iana-time-zone-haiku" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f31827a206f56af32e590ba56d5d2d085f558508192593743f16b2306495269f" +dependencies = [ + "cc", +] + [[package]] name = "icu_collections" version = "2.2.0" @@ -1150,18 +1555,90 @@ dependencies = [ "serde_json", ] +[[package]] +name = "keyboard-types" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fbe853b403ae61a04233030ae8a79d94975281ed9770a1f9e246732b534b28d" +dependencies = [ + "bitflags", + "serde", +] + [[package]] name = "lazy_static" version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" +[[package]] +name = "libappindicator" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "03589b9607c868cc7ae54c0b2a22c8dc03dd41692d48f2d7df73615c6a95dc0a" +dependencies = [ + "glib", + "gtk", + "gtk-sys", + "libappindicator-sys", + "log", +] + +[[package]] +name = "libappindicator-sys" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e9ec52138abedcc58dc17a7c6c0c00a2bdb4f3427c7f63fa97fd0d859155caf" +dependencies = [ + "gtk-sys", + "libloading", + "once_cell", +] + [[package]] name = "libc" version = "0.2.186" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66" +[[package]] +name = "libloading" +version = "0.7.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b67380fd3b2fbe7527a606e18729d21c6f3951633d0500574c4dc22d2d638b9f" +dependencies = [ + "cfg-if", + "winapi", +] + +[[package]] +name = "libredox" +version = "0.1.25" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "61ff90caf6077a803a240f62fdbe88645a890bbca49ef8174c3cb0404362171d" +dependencies = [ + "libc", +] + +[[package]] +name = "libxdo" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "00333b8756a3d28e78def82067a377de7fa61b24909000aeaa2b446a948d14db" +dependencies = [ + "libxdo-sys", +] + +[[package]] +name = "libxdo-sys" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db23b9e7e2b7831bbd8aac0bbeeeb7b68cbebc162b227e7052e8e55829a09212" +dependencies = [ + "libc", + "x11", +] + [[package]] name = "linux-raw-sys" version = "0.12.1" @@ -1216,6 +1693,15 @@ version = "2.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" +[[package]] +name = "memoffset" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "488016bfae457b036d996092f6cb448677611ce4449e970ceaf42695203f218a" +dependencies = [ + "autocfg", +] + [[package]] name = "micromap" version = "0.3.0" @@ -1228,6 +1714,26 @@ version = "0.3.17" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" +[[package]] +name = "miniz_oxide" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fa76a2c86f704bdb222d66965fb3d63269ce38518b83cb0575fca855ebb6316" +dependencies = [ + "adler2", + "simd-adler32", +] + +[[package]] +name = "miniz_oxide" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b63fbc4a50860e98e7b2aa7804ded1db5cbc3aff9193adaff57a6931bf7c4b4c" +dependencies = [ + "adler2", + "simd-adler32", +] + [[package]] name = "mio" version = "1.2.2" @@ -1239,6 +1745,27 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "muda" +version = "0.20.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cca139e57da4383727f189e43e66e84fa372347027fc996f9f1a007ec0a37996" +dependencies = [ + "crossbeam-channel", + "dpi", + "gtk", + "keyboard-types", + "libxdo", + "objc2", + "objc2-app-kit", + "objc2-core-foundation", + "objc2-foundation", + "once_cell", + "png", + "thiserror 2.0.18", + "windows-sys 0.61.2", +] + [[package]] name = "nemo-relay-plugin" version = "0.8.4" @@ -1349,19 +1876,171 @@ dependencies = [ name = "num-traits" version = "0.2.19" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +dependencies = [ + "autocfg", +] + +[[package]] +name = "num_cpus" +version = "1.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91df4bbde75afed763b708b7eee1e8e7651e02d97f6d5dd763e89367e957b23b" +dependencies = [ + "hermit-abi", + "libc", +] + +[[package]] +name = "objc2" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a12a8ed07aefc768292f076dc3ac8c48f3781c8f2d5851dd3d98950e8c5a89f" +dependencies = [ + "objc2-encode", +] + +[[package]] +name = "objc2-app-kit" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d49e936b501e5c5bf01fda3a9452ff86dc3ea98ad5f283e1455153142d97518c" +dependencies = [ + "bitflags", + "block2", + "libc", + "objc2", + "objc2-cloud-kit", + "objc2-core-data", + "objc2-core-foundation", + "objc2-core-graphics", + "objc2-core-image", + "objc2-core-text", + "objc2-core-video", + "objc2-foundation", + "objc2-quartz-core", +] + +[[package]] +name = "objc2-cloud-kit" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "73ad74d880bb43877038da939b7427bba67e9dd42004a18b809ba7d87cee241c" +dependencies = [ + "bitflags", + "objc2", + "objc2-foundation", +] + +[[package]] +name = "objc2-core-data" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b402a653efbb5e82ce4df10683b6b28027616a2715e90009947d50b8dd298fa" +dependencies = [ + "bitflags", + "objc2", + "objc2-foundation", +] + +[[package]] +name = "objc2-core-foundation" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2a180dd8642fa45cdb7dd721cd4c11b1cadd4929ce112ebd8b9f5803cc79d536" +dependencies = [ + "bitflags", + "dispatch2", + "objc2", +] + +[[package]] +name = "objc2-core-graphics" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e022c9d066895efa1345f8e33e584b9f958da2fd4cd116792e15e07e4720a807" +dependencies = [ + "bitflags", + "dispatch2", + "objc2", + "objc2-core-foundation", + "objc2-io-surface", +] + +[[package]] +name = "objc2-core-image" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e5d563b38d2b97209f8e861173de434bd0214cf020e3423a52624cd1d989f006" +dependencies = [ + "objc2", + "objc2-foundation", +] + +[[package]] +name = "objc2-core-text" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0cde0dfb48d25d2b4862161a4d5fcc0e3c24367869ad306b0c9ec0073bfed92d" +dependencies = [ + "bitflags", + "objc2", + "objc2-core-foundation", + "objc2-core-graphics", +] + +[[package]] +name = "objc2-core-video" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d425caf1df73233f29fd8a5c3e5edbc30d2d4307870f802d18f00d83dc5141a6" +dependencies = [ + "bitflags", + "objc2", + "objc2-core-foundation", + "objc2-core-graphics", + "objc2-io-surface", +] + +[[package]] +name = "objc2-encode" +version = "4.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ef25abbcd74fb2609453eb695bd2f860d389e457f67dc17cafc8b8cbc89d0c33" + +[[package]] +name = "objc2-foundation" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3e0adef53c21f888deb4fa59fc59f7eb17404926ee8a6f59f5df0fd7f9f3272" +dependencies = [ + "bitflags", + "block2", + "libc", + "objc2", + "objc2-core-foundation", +] + +[[package]] +name = "objc2-io-surface" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "180788110936d59bab6bd83b6060ffdfffb3b922ba1396b312ae795e1de9d81d" dependencies = [ - "autocfg", + "bitflags", + "objc2", + "objc2-core-foundation", ] [[package]] -name = "num_cpus" -version = "1.17.0" +name = "objc2-quartz-core" +version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "91df4bbde75afed763b708b7eee1e8e7651e02d97f6d5dd763e89367e957b23b" +checksum = "96c1358452b371bf9f104e21ec536d37a650eb10f7ee379fff67d2e08d537f1f" dependencies = [ - "hermit-abi", - "libc", + "bitflags", + "objc2", + "objc2-foundation", ] [[package]] @@ -1466,12 +2145,43 @@ dependencies = [ "tokio", ] +[[package]] +name = "option-ext" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d" + [[package]] name = "outref" version = "0.5.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1a80800c0488c3a21695ea981a54918fbb37abf04f4d0720c453632255e2ff0e" +[[package]] +name = "pango" +version = "0.18.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7ca27ec1eb0457ab26f3036ea52229edbdb74dee1edd29063f5b9b010e7ebee4" +dependencies = [ + "gio", + "glib", + "libc", + "once_cell", + "pango-sys", +] + +[[package]] +name = "pango-sys" +version = "0.18.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "436737e391a843e5933d6d9aa102cb126d501e815b83601365a948a518555dc5" +dependencies = [ + "glib-sys", + "gobject-sys", + "libc", + "system-deps", +] + [[package]] name = "parking_lot" version = "0.12.5" @@ -1513,6 +2223,19 @@ version = "0.3.33" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "19f132c84eca552bf34cab8ec81f1c1dcc229b811638f9d283dceabe58c5569e" +[[package]] +name = "png" +version = "0.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "60769b8b31b2a9f263dae2776c37b1b28ae246943cf719eb6946a1db05128a61" +dependencies = [ + "bitflags", + "crc32fast", + "fdeflate", + "flate2", + "miniz_oxide 0.8.9", +] + [[package]] name = "portable-atomic" version = "1.13.1" @@ -1561,6 +2284,50 @@ dependencies = [ "yansi", ] +[[package]] +name = "proc-macro-crate" +version = "1.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f4c021e1093a56626774e81216a4ce732a735e5bad4868a03f3ed65ca0c3919" +dependencies = [ + "once_cell", + "toml_edit 0.19.15", +] + +[[package]] +name = "proc-macro-crate" +version = "2.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b00f26d3400549137f92511a46ac1cd8ce37cb5598a96d382381458b992a5d24" +dependencies = [ + "toml_datetime 0.6.3", + "toml_edit 0.20.2", +] + +[[package]] +name = "proc-macro-error" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da25490ff9892aab3fcf7c36f08cfb902dd3e71ca0f9f9517bea02a73a5ce38c" +dependencies = [ + "proc-macro-error-attr", + "proc-macro2", + "quote", + "syn 1.0.109", + "version_check", +] + +[[package]] +name = "proc-macro-error-attr" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a1be40180e52ecc98ad80b184934baf3d0d29f979574e439af5a55274b35f869" +dependencies = [ + "proc-macro2", + "quote", + "version_check", +] + [[package]] name = "proc-macro2" version = "1.0.106" @@ -1662,7 +2429,7 @@ version = "0.28.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e368e7ddfdeb98c9bca7f8383be1648fd84ab466bf2bc015e94008db6d35611e" dependencies = [ - "target-lexicon", + "target-lexicon 0.13.5", ] [[package]] @@ -1693,7 +2460,7 @@ version = "0.28.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c4cdc218d835738f81c2338f822078af45b4afdf8b2e33cbb5916f108b813acb" dependencies = [ - "heck", + "heck 0.5.0", "proc-macro2", "pyo3-build-config", "quote", @@ -1764,7 +2531,7 @@ dependencies = [ "once_cell", "socket2", "tracing", - "windows-sys 0.52.0", + "windows-sys 0.61.2", ] [[package]] @@ -1852,6 +2619,16 @@ dependencies = [ "bitflags", ] +[[package]] +name = "redox_users" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "60dc65c0ff1a7ae1294b0c67b9f14baf70b644404010370171787bfac1038fc0" +dependencies = [ + "libredox", + "thiserror 2.0.18", +] + [[package]] name = "ref-cast" version = "1.0.26" @@ -1998,7 +2775,7 @@ dependencies = [ "errno", "libc", "linux-raw-sys", - "windows-sys 0.52.0", + "windows-sys 0.61.2", ] [[package]] @@ -2055,7 +2832,7 @@ dependencies = [ "security-framework", "security-framework-sys", "webpki-root-certs", - "windows-sys 0.52.0", + "windows-sys 0.61.2", ] [[package]] @@ -2196,6 +2973,15 @@ dependencies = [ "serde_core", ] +[[package]] +name = "serde_spanned" +version = "0.6.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf41e0cfaf7226dca15e8197172c295a782857fcb97fad1808a166870dee75a3" +dependencies = [ + "serde", +] + [[package]] name = "serde_spanned" version = "1.1.1" @@ -2242,6 +3028,12 @@ dependencies = [ "libc", ] +[[package]] +name = "simd-adler32" +version = "0.3.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a219298ac11a56ea9a6d2120044824d6f01aeb034955e7af7bc16858527deea" + [[package]] name = "simd_cesu8" version = "1.2.0" @@ -2307,7 +3099,7 @@ version = "0.28.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ab85eea0270ee17587ed4156089e10b9e6880ee688791d45a905f5b1ca36f664" dependencies = [ - "heck", + "heck 0.5.0", "proc-macro2", "quote", "syn 2.0.119", @@ -2370,6 +3162,24 @@ dependencies = [ "wiremock", ] +[[package]] +name = "switchyard-menubar" +version = "0.3.0" +dependencies = [ + "chrono", + "objc2", + "objc2-app-kit", + "objc2-foundation", + "security-framework", + "serde", + "serde_json", + "switchyard-runner", + "tempfile", + "toml 0.9.12+spec-1.1.0", + "toml_edit 0.25.13+spec-1.1.0", + "tray-icon", +] + [[package]] name = "switchyard-nemo-relay-plugin" version = "0.3.0" @@ -2385,7 +3195,7 @@ dependencies = [ "switchyard-translation", "tempfile", "tokio", - "toml", + "toml 1.1.3+spec-1.1.0", "wiremock", ] @@ -2434,7 +3244,7 @@ dependencies = [ "switchyard-protocol", "thiserror 2.0.18", "tokio", - "toml", + "toml 1.1.3+spec-1.1.0", "tracing", ] @@ -2517,6 +3327,16 @@ dependencies = [ "tracing", ] +[[package]] +name = "syn" +version = "1.0.109" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b64191b275b66ffe2469e8af2c1cfe3bafa67b529ead792a6d0160888b4237" +dependencies = [ + "proc-macro2", + "unicode-ident", +] + [[package]] name = "syn" version = "2.0.119" @@ -2559,6 +3379,25 @@ dependencies = [ "syn 2.0.119", ] +[[package]] +name = "system-deps" +version = "6.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a3e535eb8dded36d55ec13eddacd30dec501792ff23a0b1682c38601b8cf2349" +dependencies = [ + "cfg-expr", + "heck 0.5.0", + "pkg-config", + "toml 0.8.2", + "version-compare", +] + +[[package]] +name = "target-lexicon" +version = "0.12.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "61c41af27dd6d1e27b1b16b489db798443478cef1f06a660c96db617ba5de3b1" + [[package]] name = "target-lexicon" version = "0.13.5" @@ -2575,7 +3414,7 @@ dependencies = [ "getrandom 0.4.3", "once_cell", "rustix", - "windows-sys 0.52.0", + "windows-sys 0.61.2", ] [[package]] @@ -2715,6 +3554,33 @@ dependencies = [ "tokio", ] +[[package]] +name = "toml" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "185d8ab0dfbb35cf1399a6344d8484209c088f75f8f68230da55d48d95d43e3d" +dependencies = [ + "serde", + "serde_spanned 0.6.9", + "toml_datetime 0.6.3", + "toml_edit 0.20.2", +] + +[[package]] +name = "toml" +version = "0.9.12+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf92845e79fc2e2def6a5d828f0801e29a2f8acc037becc5ab08595c7d5e9863" +dependencies = [ + "indexmap", + "serde_core", + "serde_spanned 1.1.1", + "toml_datetime 0.7.5+spec-1.1.0", + "toml_parser", + "toml_writer", + "winnow 0.7.15", +] + [[package]] name = "toml" version = "1.1.3+spec-1.1.0" @@ -2723,11 +3589,29 @@ checksum = "53c96ecdfa941c8fc4fcaed14f99ada8ebed502eef533015095a07e3301d4c3c" dependencies = [ "indexmap", "serde_core", - "serde_spanned", - "toml_datetime", + "serde_spanned 1.1.1", + "toml_datetime 1.1.1+spec-1.1.0", "toml_parser", "toml_writer", - "winnow", + "winnow 1.0.4", +] + +[[package]] +name = "toml_datetime" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7cda73e2f1397b1262d6dfdcef8aafae14d1de7748d66822d3bfeeb6d03e5e4b" +dependencies = [ + "serde", +] + +[[package]] +name = "toml_datetime" +version = "0.7.5+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92e1cfed4a3038bc5a127e35a2d360f145e1f4b971b551a2ba5fd7aedf7e1347" +dependencies = [ + "serde_core", ] [[package]] @@ -2739,13 +3623,50 @@ dependencies = [ "serde_core", ] +[[package]] +name = "toml_edit" +version = "0.19.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1b5bb770da30e5cbfde35a2d7b9b8a2c4b8ef89548a7a6aeab5c9a576e3e7421" +dependencies = [ + "indexmap", + "toml_datetime 0.6.3", + "winnow 0.5.40", +] + +[[package]] +name = "toml_edit" +version = "0.20.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "396e4d48bbb2b7554c944bde63101b5ae446cff6ec4a24227428f15eb72ef338" +dependencies = [ + "indexmap", + "serde", + "serde_spanned 0.6.9", + "toml_datetime 0.6.3", + "winnow 0.5.40", +] + +[[package]] +name = "toml_edit" +version = "0.25.13+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6975367e4d2ef766d86af01ffad14b622fecc8d4357a998fbc4deb6e9bacaf9b" +dependencies = [ + "indexmap", + "toml_datetime 1.1.1+spec-1.1.0", + "toml_parser", + "toml_writer", + "winnow 1.0.4", +] + [[package]] name = "toml_parser" version = "1.1.2+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a2abe9b86193656635d2411dc43050282ca48aa31c2451210f4202550afb7526" dependencies = [ - "winnow", + "winnow 1.0.4", ] [[package]] @@ -2878,6 +3799,27 @@ dependencies = [ "tracing-log", ] +[[package]] +name = "tray-icon" +version = "0.25.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b2b9c52859a94554803ccd4a24b98f74148ebc73b90676d783f3490b1bff9d72" +dependencies = [ + "crossbeam-channel", + "dirs", + "libappindicator", + "muda", + "objc2", + "objc2-app-kit", + "objc2-core-foundation", + "objc2-core-graphics", + "objc2-foundation", + "once_cell", + "png", + "thiserror 2.0.18", + "windows-sys 0.61.2", +] + [[package]] name = "try-lock" version = "0.2.5" @@ -2974,6 +3916,12 @@ version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65" +[[package]] +name = "version-compare" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "03c2856837ef78f57382f06b2b8563a2f512f7185d732608fd9176cb3b8edf0e" + [[package]] name = "version_check" version = "0.9.5" @@ -3117,13 +4065,70 @@ 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-util" version = "0.1.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" dependencies = [ - "windows-sys 0.52.0", + "windows-sys 0.61.2", +] + +[[package]] +name = "winapi-x86_64-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" + +[[package]] +name = "windows-core" +version = "0.62.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb" +dependencies = [ + "windows-implement", + "windows-interface", + "windows-link", + "windows-result", + "windows-strings", +] + +[[package]] +name = "windows-implement" +version = "0.60.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "windows-interface" +version = "0.59.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", ] [[package]] @@ -3132,6 +4137,24 @@ version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" +[[package]] +name = "windows-result" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-strings" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091" +dependencies = [ + "windows-link", +] + [[package]] name = "windows-sys" version = "0.52.0" @@ -3214,11 +4237,29 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" +[[package]] +name = "winnow" +version = "0.5.40" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f593a95398737aeed53e489c785df13f3618e41dbcd6718c6addbf1395aa6876" +dependencies = [ + "memchr", +] + +[[package]] +name = "winnow" +version = "0.7.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df79d97927682d2fd8adb29682d1140b343be4ac0f08fd68b7765d9c059d3945" + [[package]] name = "winnow" version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "23b97319f7b8343df12cc98938e5c3eb436064524c8d2b4e30a1d3a36eecdf81" +dependencies = [ + "memchr", +] [[package]] name = "wiremock" @@ -3255,6 +4296,16 @@ version = "0.6.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1ffae5123b2d3fc086436f8834ae3ab053a283cfac8fe0a0b8eaae044768a4c4" +[[package]] +name = "x11" +version = "2.21.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "502da5464ccd04011667b11c435cb992822c2c0dbde1770c988480d312a0db2e" +dependencies = [ + "libc", + "pkg-config", +] + [[package]] name = "yansi" version = "1.0.1" @@ -3364,6 +4415,12 @@ dependencies = [ "syn 2.0.119", ] +[[package]] +name = "zlib-rs" +version = "0.6.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b268e58e7c693d7c271f93ffc4ba3b380412554231c85bf61ca7af91042a4112" + [[package]] name = "zmij" version = "1.0.23" diff --git a/Cargo.toml b/Cargo.toml index 4151bc415..e732ecaed 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -11,6 +11,7 @@ members = [ "crates/protocol", "crates/switchyard-nemo-relay-plugin", "crates/switchyard-runner", + "crates/switchyard-menubar", "crates/switchyard-server", "crates/switchyard-skill-distillation", "crates/switchyard-soak", diff --git a/Makefile b/Makefile new file mode 100644 index 000000000..9a09db0f5 --- /dev/null +++ b/Makefile @@ -0,0 +1,16 @@ +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +.PHONY: install-macos install-macos-dry-run uninstall-macos + +## Install the Switchyard background server and menu bar app for this user. +install-macos: + @scripts/macos/install.sh + +## Print what install-macos would do, without changing anything. +install-macos-dry-run: + @scripts/macos/install.sh --dry-run + +## Remove the LaunchAgents, the sy Codex profile, and the codex alias. +uninstall-macos: + @scripts/macos/uninstall.sh diff --git a/crates/switchyard-menubar/Cargo.toml b/crates/switchyard-menubar/Cargo.toml new file mode 100644 index 000000000..e4ede3faa --- /dev/null +++ b/crates/switchyard-menubar/Cargo.toml @@ -0,0 +1,33 @@ +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +[package] +name = "switchyard-menubar" +version.workspace = true +description = "macOS menu bar companion for a locally running Switchyard server" +authors.workspace = true +edition.workspace = true +homepage = "https://github.com/NVIDIA-NeMo/Switchyard" +license.workspace = true +repository.workspace = true +rust-version.workspace = true +publish = false + +[dependencies] +chrono = { version = "0.4", default-features = false, features = ["std", "clock"] } +serde.workspace = true +serde_json.workspace = true +tempfile = "3" +toml = "0.9" +toml_edit = "0.25" + +[target.'cfg(target_os = "macos")'.dependencies] +objc2 = "0.6" +objc2-app-kit = "0.3" +objc2-foundation = "0.3" +security-framework = "3" +tray-icon = "0.25" + +[dev-dependencies] +# The tests check each edited config with the server's own config parser. +switchyard-runner.workspace = true diff --git a/crates/switchyard-menubar/README.md b/crates/switchyard-menubar/README.md new file mode 100644 index 000000000..89faaa75f --- /dev/null +++ b/crates/switchyard-menubar/README.md @@ -0,0 +1,256 @@ +# switchyard-menubar + +A macOS menu bar companion for a Switchyard server running in the background. + +The server does the routing. This process reads what the server wrote and +edits the server config only when you ask it to, so quitting it never affects +traffic. + +Click the glyph to see whether the server is answering, today's and this +week's requests and tokens, estimated savings, and which models served the +week. It can also restart the server, open either config file, and change a +route's algorithm and models. + +## Install + +From the repository root: + +```sh +make install-macos # or install-macos-dry-run to see the steps first +make uninstall-macos +``` + +That builds `switchyard-server` and `switchyard-menubar` into +`~/.switchyard/bin`, writes a server config and menu bar settings, loads two +LaunchAgents, writes a `sy` Codex profile, and aliases `codex` to use it. + +Your server config and menu bar settings are never overwritten once they +exist, so edits survive a reinstall. Logs are in `~/.switchyard/logs/`. + +## Settings + +`~/.switchyard/menubar.toml`. Any key may be omitted. + +```toml +server_url = "http://127.0.0.1:4123" +routing_log = "~/.switchyard/routing.jsonl" +config_file = "~/.switchyard/composite.toml" +launchd_label = "com.nvidia.switchyard.server" +baseline_model = "gpt-5.6-sol" +refresh_seconds = 30 + +[prices."gpt-5.6-sol"] +input_per_mtok = 1.25 +cached_input_per_mtok = 0.125 +output_per_mtok = 10.0 +``` + +## Change a route's algorithm and models + +Click the glyph, then **Change routing…**. The window reads the server config +named by `config_file`. + +1. Pick a route. The window shows its algorithm and the model for each role. +2. Pick an algorithm: `passthrough`, `random`, `llm_classifier`, `composite`, + `stage_router`, `advisor`, `plan_execute`, or `auto`. The window says what + each one does and which roles it needs. For example, `composite` needs a + judge, a capable model, and an efficient model. +3. For each role, pick an LLM client from the config, then a model. The model + box lists the models from the client's `GET /models` endpoint. Type to + filter the list, or type a model ID that is not listed. +4. Click **Apply**, or press Return. Escape or Cmd-W closes the window. + +When a route has more roles than the screen can show, such as a `random` +route with many models, the roles scroll, and the buttons stay on the screen. + +Apply checks the new config with +`switchyard-server --config --dry-run`, using the `switchyard-server` +installed next to the menu bar app. If the check fails, the window shows the +server's error and the file does not change. If it passes, the app saves the +file, keeps the old one as `.switchyard-backup.`, and +restarts the server with `launchctl kickstart -k gui//`. +A second backup in the same second gets `-2` added to its name, so a backup +never replaces another one. If another program changes the file while the +check runs, Apply saves nothing and asks you to click Apply again. If +`config_file` is a symlink, the app writes the file that the link points to, +puts the backup next to that file, and leaves the link in place. + +The result area lists what happened, most important first: the saved file +and its backup, the restart and whether the server answers `/health` within +10 seconds, notes about the change, and how many routes passed the check. If +the server does not answer, the result says how to put the backup back. + +The app changes as little of the file as it can: + +- Comments, formatting, and every table the change does not touch stay as + they were. +- A role keeps its target when that target already names the chosen model. +- If another target names that model on that client, the route uses it when + the route would get the same `system_prompt`, `reasoning_effort`, + `extra_body`, and `omit_body_fields` from it. The route also uses it when + `reasoning_effort`, `extra_body`, or `omit_body_fields` differ, because the + server rejects two targets that name one model on one client with + different values for those. When the role then shares the target with + another route, the result says so. +- Otherwise the app changes the route's own target in place, so settings such + as `extra_body` and `omit_body_fields` stay. +- The app copies a target instead of changing it when another route uses it, + or when an earlier role in the same Apply already took it. For example, if + you move the efficient model up to Capable and pick a new efficient model, + Capable takes the old efficient target, and Efficient gets a copy of that + target with the new model. +- A changed or copied target also keeps its model-specific settings. For + example, a target with `omit_body_fields = ["reasoning_effort"]` keeps that + setting when it switches from a Claude model to a GPT model. `--dry-run` + does not catch a setting that the new model rejects. So when a target moves + to another model family, such as from `gpt-…` to `claude-…`, or to a client + with another `format`, the result lists the `omit_body_fields`, + `reasoning_effort`, and `extra_body` that the target kept, or says that it + has none. The app does not change them; edit the file if the new model + needs other values. +- Switching algorithms keeps the route's `id`, `context_window`, + `tool_calling`, `reasoning`, and `vision`, and keeps `subagents` when the new + algorithm accepts it (`passthrough`, `stage_router`, and `composite`). It + removes the old type's other settings and writes the settings the new type + requires with the values from the routing docs, such as + `confidence_threshold = 0.5`. The result lists the settings it removed, and + the backup still has them. Edit the file to tune them. +- Targets that no route uses any more stay in the file. + +The window cannot show the settings of a custom-mode `llm_classifier` route +or of a route whose `type` is not in the Algorithm list. Applying to such a +route replaces its settings with the algorithm and models you pick. + +The check runs with the menu bar app's environment, not your shell's. If a +client reads its key from `api_key_env`, the menu bar app's LaunchAgent needs +that variable too. Otherwise the check fails and the app saves nothing. The +window says so under each role that uses such a client, and again in the +result when the check fails. To add the variable, put it under +`EnvironmentVariables` in +`~/Library/LaunchAgents/com.nvidia.switchyard.menubar.plist`, then load the +agent again: + +```sh +launchctl bootout gui/$UID/com.nvidia.switchyard.menubar +launchctl bootstrap gui/$UID ~/Library/LaunchAgents/com.nvidia.switchyard.menubar.plist +``` + +That puts the key in the plist file as plain text, and `make install-macos` +writes the plist again without it. A client with `forward_auth = true` needs +no key in the config, because the server sends each caller's own key. + +Apply does not add prices, because the app has no price source besides +`menubar.toml`. If a chosen model has no price there, the result names the +model. Savings stay hidden until you add the price and restart the menu bar +app. + +### Model lists + +The app saves every model list it fetches in `model-lists.json`, in the same +directory as the settings file it was started with. With the default settings +file, that is `~/.switchyard/model-lists.json`. The file holds the model IDs +and the time each list was fetched, keyed by the list's URL. It never holds a +key. + +When a role needs a list, the window uses the list it already has, or else +the one in `model-lists.json`. Only when neither exists does the app send one +`GET /models` request. After a list is in `model-lists.json`, the app does not +fetch it again on its own, even after a restart. If a fetch fails and the +window has no list to show, the app tries again the next time you open the +window. Clients whose models share a URL share one list and one request. + +If the app cannot write `model-lists.json`, the window still uses the fetched +list and shows "Could not save the list" with the error. Because the list is +not in the file, the app fetches it again after a restart. + +Click **Refresh models** to fetch every list that the window's roles use, +even a cached one. The app sends one request per URL, and fetches the URLs at +the same time, so a slow URL does not hold back the others. Each role's note +changes as its list arrives, and the result area then shows each list's +model count or error. If a fetch fails, the window keeps the list it has and +shows the error under it. + +When a role's model box is empty or holds a listed model ID, the note under +it shows the list's model count and age, such as "10 models, fetched 2 +minutes ago. Type to filter." While you type, the note says how many models +match, such as "2 of 10 models match." When nothing matches, it says "No +listed model matches. Apply uses the ID as typed." + +### Keys for model lists + +The app never writes a key to a file. To list a client's models, it uses the +first key it finds: the key you just typed, then the variable named by the +client's `api_key_env` in the menu bar app's own environment, then the login +Keychain item for the client's `base_url`. A LaunchAgent does not load your +shell profile, so a key you export in `~/.zshrc` is not in the app's +environment. A `forward_auth` client has no key in the config, because the +server sends each caller's own key upstream. + +When the app finds no key, the role's note says so and a key field appears +under the roles. Paste the API key for the `base_url` that the field names, +and click **Save key**. The app first lists the models with the key. If the +models endpoint rejects it, the app does not save it. Otherwise the app saves +the key in your login Keychain as "Switchyard model list", with the client's +`base_url` as the account. Clients with the same `base_url` share the key. +The app uses a saved key only to list models at that `base_url`. It refuses +a key with a line break, because a line break would add a request header. + +If macOS cannot save the key, or cannot read a saved one, the window shows +the Keychain's error. You can always type a model ID that is not listed. + +The app lists models with the system `curl`. It writes the key to curl's +stdin, never to its command line, and runs curl with `-q`, so curl ignores +`~/.curlrc`. Without `-q`, a `verbose` line in that file would print the key +into the error text that the window shows. + +The app uses the Keychain rather than your login shell's environment. +Reading that environment means starting your shell from the app, which runs +your whole shell profile and fails if the profile waits for input. The +Keychain needs no shell, and macOS asks before another app reads the key. +After you reinstall the menu bar app, macOS may ask once whether the new +build may read it. + +## How savings are computed + +The server writes one JSONL record per call, with the model that answered and +its token counts. + +- **Actual** is every call priced at the model that served it, including + Switchyard's own classifier calls. +- **Baseline** is the caller-facing calls only, priced as if each had used + `baseline_model`. Classifier calls have no baseline counterpart, because + without Switchyard they would not happen. + +Savings are the difference, so routing overhead counts against the figure and +a bad day shows a negative number. Dollar figures stay hidden until every +model seen has a price, so a partial table cannot mislead. + +These are list-price estimates. On a ChatGPT login there is no per-token bill, +so read them as "what this traffic would have cost at API rates". + +## Codex profiles + +`codex --profile sy` reads `~/.codex/sy.config.toml`. A `[profiles.sy]` table +inside `config.toml` is legacy config the CLI now refuses to start with, so the +installer removes one if an older version left it there. + +The profile sets only `model` and `model_provider`. It deliberately leaves +`approval_policy` and `sandbox_mode` alone, because routing should not quietly +change how Codex asks before it acts. + +**Codex.app cannot use the profile.** It still writes profiles in the legacy +format its own bundled CLI rejects. So it gets a whole config instead: the +installer builds `~/.codex/config.toml.sy`, which is your `config.toml` with +the routing inlined at the top level, the one format both accept. Swap it in +and restart the app: + +```sh +cp ~/.codex/config.toml.sy ~/.codex/config.toml # route +cp ~/.codex/config.toml.direct ~/.codex/config.toml # back to normal +``` + +`config.toml.direct` is a snapshot of your unrouted config, never taken while +`config.toml` is already routed, and restored by `make uninstall-macos`. + +Swapping routes everything the app does. Unlike the CLI alias, there is no +unrouted escape hatch while it is in place. diff --git a/crates/switchyard-menubar/src/app.rs b/crates/switchyard-menubar/src/app.rs new file mode 100644 index 000000000..1c96407ac --- /dev/null +++ b/crates/switchyard-menubar/src/app.rs @@ -0,0 +1,53 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +//! Ties the pieces together: read the log, probe the server, build the rows. + +use chrono::Local; + +use crate::config::Config; +use crate::health::probe; +use crate::rollup; +use crate::summary::{Row, build}; + +/// Rereads the log, probes the server, and builds the menu body. +pub fn refresh(config: &Config) -> Vec { + let usage = + rollup::read(&config.routing_log, Local::now().date_naive()).unwrap_or_else(|error| { + eprintln!( + "switchyard-menubar: read {}: {error}", + config.routing_log.display() + ); + rollup::Usage::default() + }); + build(probe(&config.server_url), &usage, config) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn reads_the_configured_log() { + let dir = tempfile::tempdir().expect("tempdir"); + let path = dir.path().join("routing.jsonl"); + let ts = Local::now().to_rfc3339(); + std::fs::write( + &path, + format!( + r#"{{"ts":"{ts}","model":"luna","tier":"","prompt_tokens":1000,"cached_tokens":0,"completion_tokens":100,"reasoning_tokens":0}}"# + ) + "\n", + ) + .expect("write log"); + + let rows = refresh(&Config { + routing_log: path, + ..Config::default() + }); + + assert!( + rows.contains(&Row::Label("Today — 1 request · 1100 tokens".to_string())), + "unexpected rows: {rows:?}" + ); + } +} diff --git a/crates/switchyard-menubar/src/config.rs b/crates/switchyard-menubar/src/config.rs new file mode 100644 index 000000000..6e10dc653 --- /dev/null +++ b/crates/switchyard-menubar/src/config.rs @@ -0,0 +1,146 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +//! Menu bar settings, read from `~/.switchyard/menubar.toml`. + +use std::path::{Path, PathBuf}; + +use serde::Deserialize; + +use crate::pricing::PriceTable; + +/// Everything the menu bar needs to find the server and price its traffic. +/// Missing keys fall back to [`Config::default`]. +#[derive(Clone, Debug, Deserialize)] +#[serde(default, deny_unknown_fields)] +pub struct Config { + /// Base URL the server listens on, used for the health check. + pub server_url: String, + /// JSONL file the server appends routing records to. + pub routing_log: PathBuf, + /// Server TOML, opened by the "Open server config" menu item. + pub config_file: PathBuf, + /// LaunchAgent label used to restart the server. + pub launchd_label: String, + /// How often the menu contents are recomputed. + pub refresh_seconds: u64, + /// Model the traffic is assumed to have used without Switchyard. + pub baseline_model: String, + /// Per-model rates. Dollar figures are hidden while a seen model is absent. + pub prices: PriceTable, +} + +impl Default for Config { + fn default() -> Self { + Self { + server_url: "http://127.0.0.1:4123".to_string(), + routing_log: PathBuf::from("~/.switchyard/routing.jsonl"), + config_file: PathBuf::from("~/.switchyard/composite.toml"), + launchd_label: "com.nvidia.switchyard.server".to_string(), + refresh_seconds: 30, + baseline_model: "gpt-5.6-sol".to_string(), + prices: PriceTable::new(), + } + } +} + +impl Config { + /// Parses settings, falling back to defaults when the file is absent. + pub fn load(path: &Path) -> Result { + let text = match std::fs::read_to_string(path) { + Ok(text) => text, + Err(error) if error.kind() == std::io::ErrorKind::NotFound => { + return Ok(Self::default()); + } + Err(error) => return Err(format!("read {}: {error}", path.display())), + }; + let mut config: Self = + toml::from_str(&text).map_err(|error| format!("parse {}: {error}", path.display()))?; + config.routing_log = expand_home(&config.routing_log); + config.config_file = expand_home(&config.config_file); + Ok(config) + } + + /// Default settings path, `~/.switchyard/menubar.toml`. + pub fn default_path() -> PathBuf { + expand_home(Path::new("~/.switchyard/menubar.toml")) + } +} + +/// Rewrites a leading `~`. Paths are written by hand in the settings file, +/// where `~` is the natural way to spell a home path. +pub fn expand_home(path: &Path) -> PathBuf { + let home = std::env::var_os("HOME").map_or_else(|| PathBuf::from("."), PathBuf::from); + expand_under(path, &home) +} + +fn expand_under(path: &Path, home: &Path) -> PathBuf { + match path.strip_prefix("~") { + Ok(rest) => home.join(rest), + Err(_) => path.to_path_buf(), + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn reads_settings_and_prices() { + let dir = tempfile::tempdir().expect("tempdir"); + let path = dir.path().join("menubar.toml"); + std::fs::write( + &path, + r#" +server_url = "http://127.0.0.1:9000" +baseline_model = "sol" + +[prices.luna] +input_per_mtok = 0.25 +cached_input_per_mtok = 0.025 +output_per_mtok = 2.0 +"#, + ) + .expect("write settings"); + + let config = Config::load(&path).expect("load"); + + assert_eq!(config.server_url, "http://127.0.0.1:9000"); + assert_eq!(config.baseline_model, "sol"); + assert_eq!(config.prices["luna"].cached_input_per_mtok, Some(0.025)); + assert_eq!(config.refresh_seconds, 30, "unset keys keep their default"); + } + + #[test] + fn a_missing_file_yields_defaults() { + let dir = tempfile::tempdir().expect("tempdir"); + + let config = Config::load(&dir.path().join("absent.toml")).expect("load"); + + assert_eq!(config.server_url, "http://127.0.0.1:4123"); + assert!(config.prices.is_empty()); + } + + #[test] + fn rejects_unparsable_settings() { + let dir = tempfile::tempdir().expect("tempdir"); + let path = dir.path().join("menubar.toml"); + std::fs::write(&path, "server_url = ").expect("write settings"); + + assert!(Config::load(&path).is_err()); + } + + #[test] + fn expands_a_leading_tilde() { + let home = Path::new("/Users/example"); + + assert_eq!( + expand_under(Path::new("~/.switchyard/routing.jsonl"), home), + PathBuf::from("/Users/example/.switchyard/routing.jsonl") + ); + assert_eq!( + expand_under(Path::new("/tmp/routing.jsonl"), home), + PathBuf::from("/tmp/routing.jsonl") + ); + } +} diff --git a/crates/switchyard-menubar/src/health.rs b/crates/switchyard-menubar/src/health.rs new file mode 100644 index 000000000..1f13aee0b --- /dev/null +++ b/crates/switchyard-menubar/src/health.rs @@ -0,0 +1,101 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +//! Liveness probe against the local server's `/health` endpoint. +//! +//! The request is written by hand over TCP. The server is always on loopback +//! over plain HTTP, so a full HTTP client would buy nothing here. + +use std::io::{BufRead, BufReader, Write}; +use std::net::{TcpStream, ToSocketAddrs}; +use std::time::Duration; + +/// How long the probe waits before calling the server unreachable. Short, +/// because it runs on the thread that draws the menu. +const TIMEOUT: Duration = Duration::from_millis(500); + +/// Whether the server answered its health check. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum ServerStatus { + Running, + Stopped, +} + +/// Probes `GET /health`, treating any failure as a stopped server. +pub fn probe(server_url: &str) -> ServerStatus { + match request(server_url) { + Ok(true) => ServerStatus::Running, + _ => ServerStatus::Stopped, + } +} + +fn request(server_url: &str) -> std::io::Result { + // Strip the scheme and any path, then default the port. + let rest = server_url.rsplit("://").next().unwrap_or(server_url); + let mut authority = rest.split('/').next().unwrap_or(rest).to_string(); + if !authority.contains(':') { + authority.push_str(":4123"); + } + + let address = authority + .to_socket_addrs()? + .next() + .ok_or_else(|| std::io::Error::other(format!("no address for {authority}")))?; + let mut stream = TcpStream::connect_timeout(&address, TIMEOUT)?; + stream.set_read_timeout(Some(TIMEOUT))?; + stream.set_write_timeout(Some(TIMEOUT))?; + // HTTP/1.0 needs no Host header and closes the connection for us. + stream.write_all(b"GET /health HTTP/1.0\r\n\r\n")?; + + let mut status_line = String::new(); + BufReader::new(stream).read_line(&mut status_line)?; + Ok(status_line.contains(" 200")) +} + +#[cfg(test)] +mod tests { + use super::*; + use std::net::TcpListener; + + /// Serves one canned response, then closes. + fn serve(response: &'static str) -> String { + let listener = TcpListener::bind("127.0.0.1:0").expect("bind"); + let port = listener.local_addr().expect("addr").port(); + std::thread::spawn(move || { + if let Ok((mut stream, _)) = listener.accept() { + let _ = stream.write_all(response.as_bytes()); + } + }); + format!("http://127.0.0.1:{port}") + } + + #[test] + fn reports_running_only_on_a_200() { + let ok = serve("HTTP/1.1 200 OK\r\n\r\n"); + let down = serve("HTTP/1.1 503 Service Unavailable\r\n\r\n"); + + assert_eq!(probe(&ok), ServerStatus::Running); + assert_eq!(probe(&down), ServerStatus::Stopped); + } + + #[test] + fn reports_stopped_when_nothing_is_listening() { + // Binding then dropping yields a port with no listener. + let listener = TcpListener::bind("127.0.0.1:0").expect("bind"); + let port = listener.local_addr().expect("addr").port(); + drop(listener); + + assert_eq!( + probe(&format!("http://127.0.0.1:{port}")), + ServerStatus::Stopped + ); + } + + #[test] + fn accepts_a_url_with_a_path_or_no_port() { + let ok = serve("HTTP/1.1 200 OK\r\n\r\n"); + + assert_eq!(probe(&format!("{ok}/v1")), ServerStatus::Running); + assert_eq!(probe("http://localhost"), ServerStatus::Stopped); + } +} diff --git a/crates/switchyard-menubar/src/icon.rs b/crates/switchyard-menubar/src/icon.rs new file mode 100644 index 000000000..5139e4ad0 --- /dev/null +++ b/crates/switchyard-menubar/src/icon.rs @@ -0,0 +1,68 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +//! The menu bar glyph: a track that splits in two, drawn in code. +//! +//! macOS scales the image to 18 points tall, so it is drawn at 2x for retina +//! displays. It is a template image, so only the alpha channel matters and the +//! system recolors it for light and dark menu bars. + +/// Side length of the generated image, in pixels. +pub const SIZE: u32 = 36; + +/// Half the stroke width, in pixels. +const HALF_STROKE: f64 = 1.7; + +const SEGMENTS: [((f64, f64), (f64, f64)); 3] = [ + ((5.0, 18.0), (17.0, 18.0)), + ((17.0, 18.0), (30.0, 7.0)), + ((17.0, 18.0), (30.0, 29.0)), +]; + +/// Renders the glyph as RGBA pixels, row-major from the top left. +pub fn glyph() -> Vec { + let mut pixels = Vec::with_capacity((SIZE * SIZE * 4) as usize); + for y in 0..SIZE { + for x in 0..SIZE { + let point = (f64::from(x) + 0.5, f64::from(y) + 0.5); + let distance = SEGMENTS + .iter() + .map(|(start, end)| distance_to_segment(point, *start, *end)) + .fold(f64::INFINITY, f64::min); + // One pixel of falloff past the stroke edge softens the diagonals. + let coverage = (HALF_STROKE + 0.5 - distance).clamp(0.0, 1.0); + pixels.extend_from_slice(&[0, 0, 0, (coverage * 255.0).round() as u8]); + } + } + pixels +} + +fn distance_to_segment(point: (f64, f64), start: (f64, f64), end: (f64, f64)) -> f64 { + let (dx, dy) = (end.0 - start.0, end.1 - start.1); + let length_squared = dx * dx + dy * dy; + let along = ((point.0 - start.0) * dx + (point.1 - start.1) * dy) / length_squared; + let along = along.clamp(0.0, 1.0); + let (cx, cy) = (start.0 + dx * along, start.1 + dy * along); + ((point.0 - cx).powi(2) + (point.1 - cy).powi(2)).sqrt() +} + +#[cfg(test)] +mod tests { + use super::*; + + fn alpha(pixels: &[u8], x: u32, y: u32) -> u8 { + pixels[((y * SIZE + x) * 4 + 3) as usize] + } + + #[test] + fn draws_a_trunk_that_splits_in_two() { + let pixels = glyph(); + + assert_eq!(pixels.len(), (SIZE * SIZE * 4) as usize); + assert_eq!(alpha(&pixels, 10, 18), 255, "the trunk is solid"); + assert!(alpha(&pixels, 29, 7) > 0, "upper branch reaches its end"); + assert!(alpha(&pixels, 29, 29) > 0, "lower branch reaches its end"); + assert_eq!(alpha(&pixels, 29, 18), 0, "nothing runs straight through"); + assert_eq!(alpha(&pixels, 0, 0), 0, "corners stay transparent"); + } +} diff --git a/crates/switchyard-menubar/src/main.rs b/crates/switchyard-menubar/src/main.rs new file mode 100644 index 000000000..76aa24069 --- /dev/null +++ b/crates/switchyard-menubar/src/main.rs @@ -0,0 +1,92 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +//! Menu bar companion for a locally running Switchyard server. +//! +//! The server owns routing; this process reads what the server wrote. It +//! shows today's and this week's traffic, and what that traffic would have +//! cost had every call gone to the capable model instead. Its "Change +//! routing…" window edits a route in the server config and restarts the +//! server. + +mod app; +mod config; +mod health; +mod pricing; +mod rollup; +mod summary; +// Only the macOS picker window calls these. They build on every target so +// that their tests also run in CI, which does not run on macOS. +#[cfg_attr(not(target_os = "macos"), allow(dead_code))] +mod models; +#[cfg_attr(not(target_os = "macos"), allow(dead_code))] +mod server; +#[cfg_attr(not(target_os = "macos"), allow(dead_code))] +mod server_config; +// The status item, the glyph it draws, and the picker window are the only +// platform-specific code. +#[cfg(target_os = "macos")] +mod icon; +#[cfg(target_os = "macos")] +mod picker; +#[cfg(target_os = "macos")] +mod tray; + +use std::path::PathBuf; +use std::process::ExitCode; + +use config::Config; + +const USAGE: &str = "\ +Usage: switchyard-menubar [--print] [SETTINGS_FILE] + +Shows Switchyard usage and estimated savings in the macOS menu bar. + + SETTINGS_FILE Settings TOML [default: ~/.switchyard/menubar.toml] + --print Print the current summary and exit, instead of running"; + +fn main() -> ExitCode { + let mut settings = None; + let mut print_only = false; + for arg in std::env::args().skip(1) { + match arg.as_str() { + "-h" | "--help" => { + println!("{USAGE}"); + return ExitCode::SUCCESS; + } + "--print" => print_only = true, + _ if arg.starts_with('-') => { + eprintln!("switchyard-menubar: unknown option {arg}\n\n{USAGE}"); + return ExitCode::FAILURE; + } + _ => settings = Some(PathBuf::from(arg)), + } + } + + let settings = settings.unwrap_or_else(Config::default_path); + let config = match Config::load(&settings) { + Ok(config) => config, + Err(error) => { + eprintln!("switchyard-menubar: {error}"); + return ExitCode::FAILURE; + } + }; + // Off macOS there is no menu bar to attach to, so printing is the whole + // program. That keeps the rollup and pricing logic buildable everywhere. + if print_only || !cfg!(target_os = "macos") { + for row in app::refresh(&config) { + match row { + summary::Row::Separator => println!(), + summary::Row::Label(text) => println!("{text}"), + } + } + return ExitCode::SUCCESS; + } + + #[cfg(target_os = "macos")] + if let Err(error) = tray::run(config, &settings) { + eprintln!("switchyard-menubar: {error}"); + return ExitCode::FAILURE; + } + ExitCode::SUCCESS +} diff --git a/crates/switchyard-menubar/src/models.rs b/crates/switchyard-menubar/src/models.rs new file mode 100644 index 000000000..a19e4af3c --- /dev/null +++ b/crates/switchyard-menubar/src/models.rs @@ -0,0 +1,834 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +//! Lists the models an LLM client offers, from its `GET /models` endpoint, +//! and keeps each fetched list in a cache file. +//! +//! The request goes through the system `curl`, the same way the menu runs +//! `launchctl`, so the app needs no HTTP or TLS stack of its own. The key is +//! written to curl's stdin, never to its command line, where other processes +//! could read it. +//! +//! The cache file holds model IDs and fetch times, never a key. The app uses +//! a list from the cache file until the user refreshes it, so it fetches a +//! list on its own only when the file does not have it. + +use std::collections::{BTreeMap, HashMap}; +use std::io::Write; +use std::path::Path; +use std::process::{Command, Stdio}; +use std::sync::{Mutex, PoisonError}; +use std::time::{SystemTime, UNIX_EPOCH}; + +use serde::{Deserialize, Serialize}; + +use crate::server_config::Client; + +/// The name of the cache file. It sits next to the settings file that the +/// app was started with. +pub const CACHE_FILE: &str = "model-lists.json"; + +/// The Keychain service that holds keys saved from the route picker. Each +/// item's account is the client's `base_url`, so clients that share a +/// gateway share its key. +#[cfg(target_os = "macos")] +const KEYCHAIN_SERVICE: &str = "Switchyard model list"; + +/// How long one listing may take before curl gives up. +const TIMEOUT_SECONDS: &str = "20"; + +/// Held while a thread rewrites the cache file, so that two threads that +/// finish at the same time do not drop each other's lists. +static CACHE_WRITE: Mutex<()> = Mutex::new(()); + +/// One fetched model list, as the cache file stores it. +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +pub struct ModelList { + /// The model IDs, sorted. + pub models: Vec, + /// When the list was fetched, in seconds since 1970-01-01 UTC. + pub fetched_at: u64, +} + +/// The model list at one URL, as [`load`] found it. +#[derive(Clone, Debug, PartialEq)] +pub struct Loaded { + /// The URL that serves the list. + pub url: String, + /// The newest list: fetched just now, or read from the cache file. + pub list: Option, + /// Why the list could not be fetched or saved in the cache file, when + /// that happened. + pub error: Option, +} + +/// Why a client's models could not be listed. +#[derive(Clone, Debug, PartialEq)] +pub enum ListError { + /// The client needs a key, and none is set or saved. + NoKey, + /// The client reads its key from this `api_key_env` variable, which this + /// process does not have, and no key is saved. + NoEnv(String), + /// The Keychain did not return the saved key. + Keychain(String), + /// The models endpoint answered 401 or 403. + Rejected(u16), + /// The request failed, or the answer was not a model list. + Failed(String), + /// The list was fetched, but the cache file could not be written. + NotCached(String), +} + +impl ListError { + /// Says what went wrong, in one sentence. + pub fn reason(&self) -> String { + match self { + Self::NoKey => { + "This LLM client needs an API key to list models, and the app does not have one." + .to_string() + } + Self::NoEnv(variable) => missing_env_note(variable), + Self::Keychain(error) => { + format!("Could not read the saved key from your login Keychain: {error}.") + } + Self::Rejected(status) => { + format!("The models endpoint rejected the key (HTTP {status}).") + } + Self::Failed(error) => format!("Could not list models: {error}."), + Self::NotCached(error) => format!("Could not save the list: {error}."), + } + } + + /// Says what the user can do while the window has no list to show. The + /// window accepts a typed model ID, so most answers offer that. A missing + /// variable also stops Apply, so that answer offers only the key field. + pub fn advice(&self) -> &'static str { + match self { + Self::NoEnv(_) => "Or paste the key below to list models.", + _ if self.needs_key() => { + "Paste a valid API key below and click Save key, or type a model ID." + } + _ => "Click Refresh models to try again, or type a model ID.", + } + } + + /// Returns whether saving a key could fix this error. + pub fn needs_key(&self) -> bool { + matches!( + self, + Self::NoKey | Self::NoEnv(_) | Self::Keychain(_) | Self::Rejected(_) + ) + } +} + +/// Says that this process lacks a client's `api_key_env` variable. Apply's +/// `--dry-run` check runs in this process's environment, so the check cannot +/// read the key either. +pub fn missing_env_note(variable: &str) -> String { + format!( + "The app's environment has no {variable}, which Apply needs: add it to the menu bar \ + app's LaunchAgent." + ) +} + +/// Returns the client's `api_key_env` variable when this process does not +/// have it set. +pub fn missing_env(client: &Client) -> Option<&str> { + client + .api_key_env + .as_deref() + .filter(|_| env_key(client).is_none()) +} + +/// The Keychain's answer for each `base_url`. The threads of one [`load`] +/// share it. +type SavedKeys = Mutex, String>>>; + +/// Returns the model list at each URL that `clients` use, in the order the +/// URLs first appear. Clients whose models share a URL share one list and +/// one request. +/// +/// Each URL loads on its own thread, so a slow URL does not hold back the +/// others. `loaded` gets each URL's result as soon as it is ready. +/// +/// Without `refresh`, a list in the cache file comes back without a +/// request, and only a URL with no cached list is fetched. With `refresh`, +/// every URL is fetched. A fetched list replaces the one in the cache file +/// unless the file holds a newer list for that URL. When the file cannot be +/// written, the fetched list still comes back, with +/// [`ListError::NotCached`], and the file keeps its old list. When a fetch +/// fails, the cached list comes back with the error. +/// +/// `typed_key` is a key the user just entered. When it is set, clients that +/// send a key use it instead of their environment variable or the Keychain. +pub fn load( + cache: &Path, + clients: &[Client], + refresh: bool, + typed_key: Option<&str>, + loaded: &(dyn Fn(&Loaded) + Sync), +) -> Vec { + let cached = read_cache(cache); + let saved_keys = SavedKeys::default(); + std::thread::scope(|scope| { + let workers: Vec<_> = by_url(clients) + .into_iter() + .map(|(url, client)| { + let old = cached.get(&url).cloned(); + let saved_keys = &saved_keys; + scope.spawn(move || { + let entry = load_url(cache, url, client, old, refresh, typed_key, saved_keys); + loaded(&entry); + entry + }) + }) + .collect(); + workers + .into_iter() + .map(|worker| { + worker + .join() + .unwrap_or_else(|panic| std::panic::resume_unwind(panic)) + }) + .collect() + }) +} + +/// Loads the list at one URL: from the cache file, or fetched with the +/// client's key and then added to the cache file. +fn load_url( + cache: &Path, + url: String, + client: &Client, + old: Option, + refresh: bool, + typed_key: Option<&str>, + saved_keys: &SavedKeys, +) -> Loaded { + if old.is_some() && !refresh { + return Loaded { + url, + list: old, + error: None, + }; + } + match key(client, typed_key, saved_keys).and_then(|key| list(client, key.as_deref())) { + Ok(models) => { + let new = ModelList { + models, + fetched_at: now(), + }; + let error = write_cache(cache, &url, &new) + .err() + .map(ListError::NotCached); + Loaded { + url, + list: Some(new), + error, + } + } + Err(error) => Loaded { + url, + list: old, + error: Some(error), + }, + } +} + +/// Returns the URL that serves the client's model list. It is also the +/// list's key in the cache file and in [`Loaded::url`]. +pub fn list_url(client: &Client) -> String { + models_url(&client.format, &client.base_url) +} + +/// Lists the client's model IDs, sorted. When the client needs a key and +/// `key` is `None`, returns [`ListError::NoEnv`] or [`ListError::NoKey`] +/// without sending a request. +fn list(client: &Client, key: Option<&str>) -> Result, ListError> { + match (key, &client.api_key_env) { + (None, Some(variable)) => Err(ListError::NoEnv(variable.clone())), + (None, None) if client.forward_auth => Err(ListError::NoKey), + _ => fetch(&list_url(client), &client.format, key), + } +} + +/// Returns the models that contain every word of `query`, ignoring case. +pub fn matching<'a>(models: &'a [String], query: &str) -> Vec<&'a str> { + let query = query.to_lowercase(); + let words: Vec<&str> = query.split_whitespace().collect(); + models + .iter() + .map(String::as_str) + .filter(|model| { + let model = model.to_lowercase(); + words.iter().all(|word| model.contains(word)) + }) + .collect() +} + +/// Returns the models endpoint for a client. It builds the URL the way the +/// server builds its request URLs. OpenAI clients drop a trailing +/// `/chat/completions` or `/responses`. Anthropic clients drop a trailing +/// `/v1/messages` to `/v1`, and add `/v1` to any other path. +fn models_url(format: &str, base_url: &str) -> String { + let (base, query) = match base_url.split_once('?') { + Some((base, query)) => (base, format!("?{query}")), + None => (base_url, String::new()), + }; + let base = base.trim_end_matches('/'); + let path = if format == "anthropic_messages" { + let root = base + .strip_suffix("/messages") + .filter(|root| root.ends_with("/v1")) + .unwrap_or(base); + if root.ends_with("/v1") { + format!("{root}/models") + } else { + format!("{root}/v1/models") + } + } else { + let root = base + .strip_suffix("/chat/completions") + .or_else(|| base.strip_suffix("/responses")) + .unwrap_or(base); + format!("{root}/models") + }; + format!("{path}{query}") +} + +/// Pairs each models URL with the client whose settings fetch it: the first +/// client with that URL that sends a key, or else the first client with it. +fn by_url(clients: &[Client]) -> Vec<(String, &Client)> { + let mut urls: Vec<(String, &Client)> = Vec::new(); + for client in clients { + let url = list_url(client); + match urls.iter_mut().find(|(seen, _)| *seen == url) { + Some(entry) if !sends_key(entry.1) && sends_key(client) => entry.1 = client, + Some(_) => {} + None => urls.push((url, client)), + } + } + urls +} + +/// Returns whether the server sends this client a key. If it does, listing +/// the client's models needs a key too. +fn sends_key(client: &Client) -> bool { + client.forward_auth || client.api_key_env.is_some() +} + +/// Returns the key to list the client's models with, or `None` when the +/// client needs no key or none is available. +/// +/// The typed key comes first, then the client's `api_key_env` variable when +/// this process has it, then the Keychain item for the client's `base_url`. +/// `saved` stores each Keychain answer, and a thread holds its lock while +/// it reads the Keychain. So macOS asks at most once per `base_url` when it +/// needs the user's permission to hand over a key. +fn key( + client: &Client, + typed_key: Option<&str>, + saved: &SavedKeys, +) -> Result, ListError> { + if !sends_key(client) { + return Ok(None); + } + if let Some(key) = typed_key.map(str::to_string).or_else(|| env_key(client)) { + return Ok(Some(key)); + } + saved + .lock() + .unwrap_or_else(PoisonError::into_inner) + .entry(client.base_url.clone()) + .or_insert_with(|| saved_key(&client.base_url)) + .clone() + .map_err(ListError::Keychain) +} + +fn env_key(client: &Client) -> Option { + client + .api_key_env + .as_deref() + .and_then(|variable| std::env::var(variable).ok()) + .filter(|key| !key.trim().is_empty()) +} + +/// Returns the key saved in the Keychain for `base_url`, or `None` when no +/// key is saved. +#[cfg(target_os = "macos")] +fn saved_key(base_url: &str) -> Result, String> { + /// The Keychain's `errSecItemNotFound` status. + const NOT_FOUND: i32 = -25300; + match security_framework::passwords::get_generic_password(KEYCHAIN_SERVICE, base_url) { + Ok(bytes) => String::from_utf8(bytes) + .map(|key| Some(key).filter(|key| !key.trim().is_empty())) + .map_err(|_| "the saved key is not text".to_string()), + Err(error) if error.code() == NOT_FOUND => Ok(None), + Err(error) => Err(keychain_error(&error)), + } +} + +#[cfg(not(target_os = "macos"))] +fn saved_key(_base_url: &str) -> Result, String> { + Ok(None) +} + +/// Saves a key in the login Keychain for every client with this `base_url`. +#[cfg(target_os = "macos")] +pub fn save_key(base_url: &str, key: &str) -> Result<(), String> { + security_framework::passwords::set_generic_password( + KEYCHAIN_SERVICE, + base_url, + key.trim().as_bytes(), + ) + .map_err(|error| keychain_error(&error)) +} + +/// Returns the Keychain's message without its final period, so the window +/// can end its own sentence after it. +#[cfg(target_os = "macos")] +fn keychain_error(error: &security_framework::base::Error) -> String { + error.to_string().trim_end_matches('.').to_string() +} + +/// Returns whether a key has a line break, which would end the header that +/// carries the key and start another one. +pub fn has_line_break(key: &str) -> bool { + key.contains(['\r', '\n']) +} + +/// Reads the cache file. A missing or unreadable file reads as empty, so the +/// app fetches its lists again and writes a new file. +fn read_cache(path: &Path) -> BTreeMap { + std::fs::read_to_string(path) + .ok() + .and_then(|text| serde_json::from_str(&text).ok()) + .unwrap_or_default() +} + +/// Adds the list at `url` to the cache file. The list replaces the one at +/// the same URL unless that one is newer: two loads can overlap, and the +/// load that fetched a list first can write it last. The text goes to a +/// temporary file that is renamed over the cache file, so a reader never +/// sees half a file. +fn write_cache(path: &Path, url: &str, list: &ModelList) -> Result<(), String> { + let _writing = CACHE_WRITE.lock().unwrap_or_else(PoisonError::into_inner); + let mut all = read_cache(path); + if all + .get(url) + .is_some_and(|cached| cached.fetched_at > list.fetched_at) + { + return Ok(()); + } + all.insert(url.to_string(), list.clone()); + let text = serde_json::to_string_pretty(&all) + .map_err(|error| format!("write {}: {error}", path.display()))?; + let dir = path + .parent() + .filter(|dir| !dir.as_os_str().is_empty()) + .unwrap_or(Path::new(".")); + let mut file = tempfile::NamedTempFile::new_in(dir) + .map_err(|error| format!("create a file in {}: {error}", dir.display()))?; + file.write_all(text.as_bytes()) + .and_then(|()| file.as_file().sync_all()) + .map_err(|error| format!("write {}: {error}", path.display()))?; + file.persist(path) + .map_err(|error| format!("replace {}: {}", path.display(), error.error))?; + Ok(()) +} + +/// Returns the current time in seconds since 1970-01-01 UTC. +pub fn now() -> u64 { + SystemTime::now() + .duration_since(UNIX_EPOCH) + .map_or(0, |since| since.as_secs()) +} + +fn fetch(url: &str, format: &str, key: Option<&str>) -> Result, ListError> { + let failed = |error: String| ListError::Failed(error); + if key.is_some_and(has_line_break) { + return Err(failed( + "the key has a line break, so the app did not send it".to_string(), + )); + } + let mut child = Command::new("curl") + // curl honors `-q` only as its first argument. `-q` makes curl ignore + // ~/.curlrc, where a `verbose` line would print the key into the + // error text that the window shows. + .args([ + "-q", + "--silent", + "--show-error", + "--max-time", + TIMEOUT_SECONDS, + ]) + // Read request headers from stdin, one per line. + .args(["--header", "@-"]) + // `--url` keeps a URL that starts with `-` from being read as an + // option. + .args(["--write-out", "\n%{http_code}", "--url", url]) + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .spawn() + .map_err(|error| failed(format!("run curl: {error}")))?; + if let Some(mut stdin) = child.stdin.take() { + let headers = match (key, format) { + (Some(key), "anthropic_messages") => { + format!("x-api-key: {key}\nanthropic-version: 2023-06-01\n") + } + (Some(key), _) => format!("Authorization: Bearer {key}\n"), + (None, _) => String::new(), + }; + stdin + .write_all(headers.as_bytes()) + .map_err(|error| failed(format!("send headers to curl: {error}")))?; + } + let output = child + .wait_with_output() + .map_err(|error| failed(format!("run curl: {error}")))?; + if !output.status.success() { + return Err(failed( + String::from_utf8_lossy(&output.stderr).trim().to_string(), + )); + } + + let stdout = String::from_utf8_lossy(&output.stdout); + let (body, status) = stdout.rsplit_once('\n').unwrap_or(("", &stdout)); + match status.trim().parse::() { + Ok(200) => parse_ids(body).map_err(failed), + Ok(status @ (401 | 403)) => Err(ListError::Rejected(status)), + Ok(status) => Err(failed(format!("{url} answered HTTP {status}"))), + Err(_) => Err(failed(format!("{url} sent no HTTP status"))), + } +} + +/// Parses the model IDs. OpenAI and Anthropic both answer with +/// `{"data": [{"id": ...}, ...]}`. +fn parse_ids(body: &str) -> Result, String> { + #[derive(Deserialize)] + struct Listing { + data: Vec, + } + #[derive(Deserialize)] + struct Entry { + id: String, + } + + let listing: Listing = serde_json::from_str(body) + .map_err(|error| format!("the model list is not the expected JSON: {error}"))?; + let mut ids: Vec = listing.data.into_iter().map(|entry| entry.id).collect(); + ids.sort(); + ids.dedup(); + Ok(ids) +} + +#[cfg(test)] +mod tests { + use super::*; + use std::io::{BufRead, BufReader}; + use std::net::TcpListener; + use std::sync::Arc; + + /// A local HTTP server that answers every request with the current + /// `response` and records the head of each request. Its thread waits in + /// `accept` until the test process exits. + struct Stub { + /// The `base_url` of a client that lists models here. + url: String, + response: Arc>, + requests: Arc>>>, + } + + impl Stub { + fn start(response: String) -> Self { + let listener = TcpListener::bind("127.0.0.1:0").expect("bind"); + let url = format!("http://{}/v1", listener.local_addr().expect("addr")); + let response = Arc::new(Mutex::new(response)); + let requests = Arc::new(Mutex::new(Vec::new())); + let (answer, record) = (Arc::clone(&response), Arc::clone(&requests)); + std::thread::spawn(move || { + for mut stream in listener.incoming().map_while(Result::ok) { + let mut head = Vec::new(); + let mut reader = BufReader::new(stream.try_clone().expect("clone stream")); + let mut line = String::new(); + while reader.read_line(&mut line).is_ok_and(|read| read > 0) && line != "\r\n" { + head.push(line.trim_end().to_string()); + line.clear(); + } + record.lock().expect("lock").push(head); + let response = answer.lock().expect("lock").clone(); + let _ = stream.write_all(response.as_bytes()); + } + }); + Self { + url, + response, + requests, + } + } + + fn answer(&self, response: String) { + *self.response.lock().expect("lock") = response; + } + + fn requests(&self) -> Vec> { + self.requests.lock().expect("lock").clone() + } + } + + fn listing(models: &[&str]) -> String { + let data: Vec<_> = models + .iter() + .map(|id| serde_json::json!({ "id": id })) + .collect(); + format!( + "HTTP/1.1 200 OK\r\nConnection: close\r\n\r\n{}", + serde_json::json!({ "data": data }) + ) + } + + fn client(name: &str, format: &str, base_url: &str) -> Client { + Client { + name: name.to_string(), + format: format.to_string(), + base_url: base_url.to_string(), + api_key_env: None, + forward_auth: true, + } + } + + fn models(loaded: &[Loaded]) -> Vec<&str> { + loaded + .iter() + .flat_map(|entry| entry.list.iter().flat_map(|list| &list.models)) + .map(String::as_str) + .collect() + } + + #[test] + fn fetches_a_missing_list_once_and_then_uses_the_cached_list() { + let stub = Stub::start(listing(&["model-b", "model-a"])); + let dir = tempfile::tempdir().expect("tempdir"); + let cache = dir.path().join(CACHE_FILE); + // Both clients list their models at the same URL. + let clients = [ + client("gateway", "openai_responses", &stub.url), + client("gateway_chat", "openai_chat", &stub.url), + ]; + + let first = load(&cache, &clients, false, Some("test-key"), &|_| {}); + // No key this time: a cached list needs neither a request nor a key. + let second = load(&cache, &clients, false, None, &|_| {}); + + let requests = stub.requests(); + assert_eq!(requests.len(), 1, "{requests:?}"); + assert_eq!(requests[0][0], "GET /v1/models HTTP/1.1"); + assert!( + requests[0].contains(&"Authorization: Bearer test-key".to_string()), + "{requests:?}" + ); + assert_eq!(first.len(), 1); + assert_eq!(first[0].url, format!("{}/models", stub.url)); + assert_eq!(first[0].error, None); + assert_eq!(models(&first), ["model-a", "model-b"]); + assert_eq!(second, first); + let text = std::fs::read_to_string(&cache).expect("read cache"); + assert!(!text.contains("test-key"), "the cache holds no key: {text}"); + } + + #[test] + fn refresh_fetches_again_and_replaces_the_cached_list() { + let stub = Stub::start(listing(&["old-model"])); + let dir = tempfile::tempdir().expect("tempdir"); + let cache = dir.path().join(CACHE_FILE); + let clients = [client("gateway", "openai_chat", &stub.url)]; + load(&cache, &clients, false, Some("test-key"), &|_| {}); + stub.answer(listing(&["new-model"])); + + let refreshed = load(&cache, &clients, true, Some("test-key"), &|_| {}); + let later = load(&cache, &clients, false, None, &|_| {}); + + assert_eq!(stub.requests().len(), 2); + assert_eq!(models(&refreshed), ["new-model"]); + assert_eq!(refreshed[0].error, None); + assert_eq!(later, refreshed, "the cache file holds the new list"); + } + + #[test] + fn a_failed_refresh_keeps_the_cached_list() { + let stub = Stub::start(listing(&["old-model"])); + let dir = tempfile::tempdir().expect("tempdir"); + let cache = dir.path().join(CACHE_FILE); + let clients = [client("gateway", "openai_chat", &stub.url)]; + let first = load(&cache, &clients, false, Some("test-key"), &|_| {}); + stub.answer( + "HTTP/1.1 500 Internal Server Error\r\nConnection: close\r\n\r\n{}".to_string(), + ); + + let refreshed = load(&cache, &clients, true, Some("test-key"), &|_| {}); + let later = load(&cache, &clients, false, None, &|_| {}); + + assert_eq!(stub.requests().len(), 2); + assert_eq!(refreshed[0].list, first[0].list); + assert_eq!( + refreshed[0].error, + Some(ListError::Failed(format!( + "{}/models answered HTTP 500", + stub.url + ))) + ); + assert_eq!(later, first, "the cache file still holds the old list"); + } + + #[test] + fn fetches_a_shared_url_with_the_client_that_sends_a_key() { + let stub = Stub::start(listing(&["model-a"])); + let dir = tempfile::tempdir().expect("tempdir"); + // The first client at the URL sends no key, so listing with it would + // leave out the key the second client needs. + let keyless = Client { + forward_auth: false, + ..client("open", "openai_chat", &stub.url) + }; + let clients = [keyless, client("gateway", "openai_responses", &stub.url)]; + + let loaded = load( + &dir.path().join(CACHE_FILE), + &clients, + false, + Some("test-key"), + &|_| {}, + ); + + let requests = stub.requests(); + assert_eq!(requests.len(), 1, "{requests:?}"); + assert!( + requests[0].contains(&"Authorization: Bearer test-key".to_string()), + "{requests:?}" + ); + assert_eq!(models(&loaded), ["model-a"]); + } + + #[test] + fn returns_a_fetched_list_that_the_cache_file_cannot_hold() { + let stub = Stub::start(listing(&["model-a"])); + let dir = tempfile::tempdir().expect("tempdir"); + // The cache file's directory does not exist, so the file cannot be + // written. + let cache = dir.path().join("missing").join(CACHE_FILE); + let clients = [client("gateway", "openai_chat", &stub.url)]; + + let loaded = load(&cache, &clients, false, Some("test-key"), &|_| {}); + + assert_eq!(models(&loaded), ["model-a"]); + assert!( + matches!(loaded[0].error, Some(ListError::NotCached(_))), + "{loaded:?}" + ); + assert!(!cache.exists()); + } + + #[test] + fn the_cache_file_keeps_the_newer_of_two_lists() { + let dir = tempfile::tempdir().expect("tempdir"); + let cache = dir.path().join(CACHE_FILE); + let url = "https://api.example/v1/models"; + let list = |model: &str, fetched_at| ModelList { + models: vec![model.to_string()], + fetched_at, + }; + + // The load that fetched first writes last. + write_cache(&cache, url, &list("new", 200)).expect("write"); + write_cache(&cache, url, &list("old", 100)).expect("write"); + + assert_eq!(read_cache(&cache).get(url), Some(&list("new", 200))); + } + + #[test] + fn reports_a_rejected_key_and_a_missing_key() { + let stub = + Stub::start("HTTP/1.1 401 Unauthorized\r\nConnection: close\r\n\r\n{}".to_string()); + let gateway = client("gateway", "openai_chat", &stub.url); + let from_env = Client { + api_key_env: Some("SWITCHYARD_MENUBAR_TEST_UNSET_KEY".to_string()), + forward_auth: false, + ..gateway.clone() + }; + + assert_eq!(list(&gateway, Some("wrong")), Err(ListError::Rejected(401))); + assert_eq!(list(&gateway, None), Err(ListError::NoKey)); + assert_eq!( + list(&from_env, None), + Err(ListError::NoEnv( + "SWITCHYARD_MENUBAR_TEST_UNSET_KEY".to_string() + )) + ); + assert!(matches!( + list(&gateway, Some("key\r\nX-Injected: yes")), + Err(ListError::Failed(_)) + )); + assert_eq!( + stub.requests().len(), + 1, + "a missing key or a key with a line break sends no request" + ); + } + + #[test] + fn builds_the_models_url_with_the_servers_url_rules() { + for (format, base_url, expected) in [ + ( + "openai_chat", + "https://api.example/v1/", + "https://api.example/v1/models", + ), + ( + "openai_responses", + "https://api.example/v1/responses", + "https://api.example/v1/models", + ), + ( + "anthropic_messages", + "https://api.example", + "https://api.example/v1/models", + ), + ( + "anthropic_messages", + "https://api.example/v1/messages", + "https://api.example/v1/models", + ), + // The server sends messages to /foo/messages/v1/messages here. + ( + "anthropic_messages", + "https://api.example/foo/messages", + "https://api.example/foo/messages/v1/models", + ), + ( + "openai_chat", + "https://api.example/v1?api-version=1", + "https://api.example/v1/models?api-version=1", + ), + ] { + assert_eq!( + models_url(format, base_url), + expected, + "{format} {base_url}" + ); + } + } + + #[test] + fn matches_every_word_ignoring_case() { + let models = [ + "claude-opus-5-5".to_string(), + "claude-sonnet-5".to_string(), + "gpt-5.6-sol".to_string(), + ]; + + assert_eq!(matching(&models, "CLAUDE opus"), ["claude-opus-5-5"]); + assert_eq!(matching(&models, "").len(), 3); + } +} diff --git a/crates/switchyard-menubar/src/picker.rs b/crates/switchyard-menubar/src/picker.rs new file mode 100644 index 000000000..f6b27f0c4 --- /dev/null +++ b/crates/switchyard-menubar/src/picker.rs @@ -0,0 +1,1268 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +//! The "Change routing…" window. The user picks a route, its algorithm, and +//! a model for each role, and Apply checks, saves, and restarts the server. +//! +//! The window only collects choices. `server_config` edits the config, +//! `models` lists and caches models, and `server` checks, saves, and +//! restarts, so all of that runs without a GUI. Model lists, Keychain access, +//! and Apply run on worker threads, which send their results through a +//! channel that the menu bar's event loop drains by calling [`Picker::poll`]. + +use std::cell::RefCell; +use std::collections::HashMap; +use std::path::PathBuf; +use std::sync::mpsc::{Receiver, Sender, channel}; + +use objc2::rc::Retained; +use objc2::runtime::ProtocolObject; +use objc2::{DefinedClass, MainThreadOnly, define_class, msg_send, sel}; +use objc2_app_kit::{ + NSAccessibility, NSApplication, NSBackingStoreType, NSButton, NSColor, NSComboBox, + NSComboBoxDelegate, NSControl, NSControlTextEditingDelegate, NSFont, NSPopUpButton, + NSResponder, NSScreen, NSScrollView, NSSecureTextField, NSTextField, NSTextFieldDelegate, + NSTextView, NSView, NSWindow, NSWindowStyleMask, +}; +use objc2_foundation::{ + MainThreadMarker, NSNotification, NSObject, NSObjectProtocol, NSPoint, NSRect, NSSize, + NSString, ns_string, +}; + +use crate::config::Config; +use crate::models::{self, ListError, Loaded, ModelList}; +use crate::server; +use crate::server_config::{ALGORITHMS, Choice, Client, Role, Route, ServerConfig, Tier}; + +// Control tags. Role rows use `CLIENT_TAG + row` for the client popup and +// `MODEL_TAG + row` for the model box. A pick from the model list arrives as +// `SELECTED_TAG + row`. +const ROUTE_TAG: isize = 1; +const ALGORITHM_TAG: isize = 2; +const SAVE_KEY_TAG: isize = 3; +const APPLY_TAG: isize = 4; +const REFRESH_TAG: isize = 5; +const CLOSE_TAG: isize = 6; +const CLIENT_TAG: isize = 100; +const MODEL_TAG: isize = 200; +const SELECTED_TAG: isize = 300; + +const WIDTH: f64 = 780.0; +const MARGIN: f64 = 20.0; +const FIELD_X: f64 = 110.0; +const FIELD_WIDTH: f64 = WIDTH - FIELD_X - MARGIN; +const CLIENT_WIDTH: f64 = 290.0; +const MODEL_X: f64 = FIELD_X + CLIENT_WIDTH + 10.0; +const MODEL_WIDTH: f64 = WIDTH - MODEL_X - MARGIN; +/// Height of one role row: the popup and combo box, then a note of up to +/// three lines under them. +const ROLE_HEIGHT: f64 = 80.0; +const NOTE_HEIGHT: f64 = 44.0; +/// Height of the key area: three lines of text above the key field. +const KEY_HEIGHT: f64 = 80.0; +/// Height of the algorithm's description: up to two lines, then its roles. +const SUMMARY_HEIGHT: f64 = 60.0; +/// Height of the result area. A longer result scrolls. +const STATUS_HEIGHT: f64 = 124.0; + +define_class!( + /// Receives every control's action and text change, and queues the + /// control's tag for [`Picker::poll`]. The picker handles the tags later, + /// outside AppKit's callback, so an event that AppKit sends while the + /// picker updates its controls only adds a tag and does not reenter it. + // SAFETY: NSObject has no subclassing requirements, and `ControlTarget` + // does not implement `Drop`. + #[unsafe(super(NSObject))] + #[thread_kind = MainThreadOnly] + #[name = "SwitchyardPickerControlTarget"] + #[ivars = RefCell>] + struct ControlTarget; + + impl ControlTarget { + // SAFETY: AppKit calls an action method with the control that sent it. + #[unsafe(method(controlChanged:))] + fn control_changed(&self, sender: &NSControl) { + self.ivars().borrow_mut().push(sender.tag()); + } + } + + unsafe impl NSObjectProtocol for ControlTarget {} + + unsafe impl NSControlTextEditingDelegate for ControlTarget { + // SAFETY: the signature matches the protocol method. + #[unsafe(method(controlTextDidChange:))] + fn control_text_did_change(&self, notification: &NSNotification) { + let control = notification + .object() + .and_then(|object| object.downcast::().ok()); + if let Some(control) = control { + self.ivars().borrow_mut().push(control.tag()); + } + } + } + + unsafe impl NSTextFieldDelegate for ControlTarget {} + + unsafe impl NSComboBoxDelegate for ControlTarget { + // SAFETY: the signature matches the protocol method. + #[unsafe(method(comboBoxSelectionDidChange:))] + fn combo_box_selection_did_change(&self, notification: &NSNotification) { + let control = notification + .object() + .and_then(|object| object.downcast::().ok()); + if let Some(control) = control { + self.ivars() + .borrow_mut() + .push(control.tag() - MODEL_TAG + SELECTED_TAG); + } + } + } +); + +impl ControlTarget { + fn new(mtm: MainThreadMarker) -> Retained { + let this = Self::alloc(mtm).set_ivars(RefCell::new(Vec::new())); + // SAFETY: NSObject's `init` has this signature. + unsafe { msg_send![super(this), init] } + } +} + +define_class!( + /// A model box that scrolls itself into view when it gets keyboard + /// focus, so Tab can reach a role row that the scroll view hides. + // SAFETY: NSComboBox has no subclassing requirements, and `ModelBox` + // does not implement `Drop`. + #[unsafe(super(NSComboBox, NSTextField, NSControl, NSView, NSResponder, NSObject))] + #[thread_kind = MainThreadOnly] + #[name = "SwitchyardPickerModelBox"] + struct ModelBox; + + impl ModelBox { + // SAFETY: the signature matches NSResponder's method. + #[unsafe(method(becomeFirstResponder))] + fn become_first_responder(&self) -> bool { + // SAFETY: NSResponder's `becomeFirstResponder` takes no + // arguments and returns a BOOL. + let became: bool = unsafe { msg_send![super(self), becomeFirstResponder] }; + if became { + self.scrollRectToVisible(self.bounds()); + } + became + } + } +); + +impl ModelBox { + fn new(mtm: MainThreadMarker, frame: NSRect) -> Retained { + // SAFETY: NSComboBox's `initWithFrame:` has this signature. + unsafe { msg_send![Self::alloc(mtm), initWithFrame: frame] } + } +} + +/// A result that a worker thread sends back to the window. +enum Done { + /// The model list at one URL, from load number `load`. + Listed { + load: u64, + loaded: Loaded, + }, + /// The outcome of a load that the user asked for, for the result area. + Status(String), + Applied(Result), +} + +/// What the window knows about the model list at one URL. +#[derive(Default)] +struct ListState { + /// The newest list: fetched, or read from the cache file. + list: Option, + /// Why the last load failed, when it did. + error: Option, + /// `started` is the newest load started for this list, and `shown` is the + /// newest load whose result the window shows. Loads are numbered in the + /// order they start. + started: u64, + shown: u64, +} + +impl ListState { + /// Returns whether a worker thread is loading this list now. + fn loading(&self) -> bool { + self.started > self.shown + } + + /// Records the result of load number `load`. A worker that started + /// earlier can finish later, so the window ignores a result from a load + /// older than the one it shows. Otherwise the error comes from this + /// result, and the newer of the two lists stays, because a failed load + /// returns the list from the cache file, which can be older than the one + /// the window has. The result of the newest load ends the loading state. + fn finish(&mut self, load: u64, list: Option, error: Option) { + if load < self.shown { + return; + } + self.shown = load; + let fetched_at = |list: &Option| list.as_ref().map(|list| list.fetched_at); + if fetched_at(&list) >= fetched_at(&self.list) { + self.list = list; + } + self.error = error; + } +} + +/// The controls of one role row. +struct RoleRow { + client: Retained, + model: Retained, + note: Retained, +} + +/// The controls that the picker reads or updates after it lays them out. +struct Controls { + route: Retained, + algorithm: Retained, + rows: Vec, + key_label: Retained, + key: Retained, + save_key: Retained, + status: Retained, + apply: Retained, +} + +/// The picker window and what the user has chosen in it. +pub struct Picker { + mtm: MainThreadMarker, + window: Retained, + target: Retained, + settings: Config, + /// The file that keeps fetched model lists. + cache: PathBuf, + config: Option, + routes: Vec, + clients: Vec, + route: usize, + algorithm: usize, + roles: Vec, + /// One choice per role, in role order. + choices: Vec, + /// The latest choice for each tier on this route. When the next algorithm + /// has no role for a tier, its choice stays here, so switching back + /// restores it. `select_route` clears the map. + by_tier: HashMap, + /// Model lists by URL, kept while the app runs. Clients that list their + /// models at the same URL share one entry. + lists: HashMap, + /// How many list loads the window has started. It numbers the loads. + loads: u64, + status: String, + /// Whether an Apply is running. + busy: bool, + sender: Sender, + receiver: Receiver, + controls: Option, +} + +impl Picker { + pub fn new(mtm: MainThreadMarker, settings: &Config, cache: PathBuf) -> Self { + // SAFETY: the window is not released when closed, because `Picker` + // owns it and shows it again from the menu. + let window = unsafe { + let window = NSWindow::initWithContentRect_styleMask_backing_defer( + NSWindow::alloc(mtm), + rect(0.0, 0.0, WIDTH, 400.0), + NSWindowStyleMask::Titled + | NSWindowStyleMask::Closable + | NSWindowStyleMask::Miniaturizable, + NSBackingStoreType::Buffered, + false, + ); + window.setReleasedWhenClosed(false); + window + }; + window.setTitle(ns_string!("Change routing")); + let (sender, receiver) = channel(); + Self { + mtm, + window, + target: ControlTarget::new(mtm), + settings: settings.clone(), + cache, + config: None, + routes: Vec::new(), + clients: Vec::new(), + route: 0, + algorithm: 0, + roles: Vec::new(), + choices: Vec::new(), + by_tier: HashMap::new(), + lists: HashMap::new(), + loads: 0, + status: String::new(), + busy: false, + sender, + receiver, + controls: None, + } + } + + /// Rereads the server config and brings the window to the front. + pub fn show(&mut self) { + if !self.busy { + // Opening the window again retries a list that failed and has + // nothing to show. The window fetches a list that it already has + // only when the user clicks Refresh models or Save key. + self.lists + .retain(|_, state| state.loading() || state.list.is_some()); + let route = self.routes.get(self.route).map(|route| route.key.clone()); + self.load(route.as_deref()); + } + let first = self.controls.is_none(); + self.layout(); + if first { + self.window.center(); + } + // An accessory app must activate itself before its window can take + // keyboard focus. `activate` needs macOS 14, so use the older call. + #[allow(deprecated)] + NSApplication::sharedApplication(self.mtm).activateIgnoringOtherApps(true); + self.window.makeKeyAndOrderFront(None); + } + + /// Handles queued control events and finished background work. + pub fn poll(&mut self) { + let tags = std::mem::take(&mut *self.target.ivars().borrow_mut()); + for tag in tags { + self.on_control(tag); + } + while let Ok(done) = self.receiver.try_recv() { + self.on_done(done); + } + } + + fn load(&mut self, route: Option<&str>) { + let path = &self.settings.config_file; + let loaded = std::fs::read_to_string(path) + .map_err(|error| { + format!( + "Could not read {}: {error}. Check config_file in the menu bar settings.", + path.display() + ) + }) + .and_then(|text| ServerConfig::parse(&text)); + match loaded { + Ok(config) => { + self.routes = config.routes(); + self.clients = config.clients(); + self.route = route + .and_then(|key| self.routes.iter().position(|route| route.key == key)) + .unwrap_or(0); + self.config = Some(config); + self.select_route(); + if self.routes.is_empty() { + self.status = format!( + "{} has no routes to change.", + self.settings.config_file.display() + ); + } + } + Err(error) => { + self.config = None; + self.routes.clear(); + self.clients.clear(); + self.roles.clear(); + self.choices.clear(); + self.status = error; + } + } + } + + /// Shows the selected route's current algorithm and models. + fn select_route(&mut self) { + self.by_tier.clear(); + let (Some(config), Some(route)) = (&self.config, self.routes.get(self.route)) else { + self.roles.clear(); + self.choices.clear(); + return; + }; + match config.algorithm(&route.key) { + Some(current) => { + self.algorithm = ALGORITHMS + .iter() + .position(|algorithm| algorithm.kind == current.kind) + .unwrap_or(0); + self.roles = config.roles(&route.key, current); + self.choices = config.choices(&route.key); + self.status.clear(); + } + None => { + self.algorithm = 0; + self.roles = config.roles(&route.key, &ALGORITHMS[0]); + self.choices = vec![Choice::default(); self.roles.len()]; + self.status = format!( + "This window cannot show the current settings of route {}: its type is not \ + in the Algorithm list, or it is a custom-mode llm_classifier. Apply replaces \ + those settings with the algorithm and models you pick.", + route.id + ); + } + } + self.fill_clients(); + self.list_models(); + } + + /// Switches the algorithm. Each new role starts from the latest choice + /// for its tier on this route, so the capable model stays the capable one. + /// Choices for tiers the new algorithm lacks stay in `by_tier`. + fn select_algorithm(&mut self, index: usize) { + self.read_models(); + for (role, choice) in self.roles.iter().zip(&self.choices) { + if let Some(tier) = role.tier { + self.by_tier.insert(tier, choice.clone()); + } + } + let (Some(config), Some(route), Some(algorithm)) = ( + &self.config, + self.routes.get(self.route), + ALGORITHMS.get(index), + ) else { + return; + }; + let roles = config.roles(&route.key, algorithm); + self.choices = roles + .iter() + .map(|role| { + role.tier + .and_then(|tier| self.by_tier.get(&tier).cloned()) + .unwrap_or_default() + }) + .collect(); + self.roles = roles; + self.algorithm = index; + self.fill_clients(); + self.list_models(); + self.layout(); + } + + /// Gives every role without a client the first role's client. + fn fill_clients(&mut self) { + let fallback = self + .choices + .iter() + .map(|choice| &choice.client) + .find(|client| !client.is_empty()) + .or_else(|| self.clients.first().map(|client| &client.name)) + .cloned() + .unwrap_or_default(); + for choice in &mut self.choices { + if choice.client.is_empty() { + choice.client = fallback.clone(); + } + } + } + + /// Loads the lists of the rows' clients that the window does not have: + /// from the cache file, or fetched when the cache file has none. + fn list_models(&mut self) { + let clients = self.row_clients(|state| state.is_none()); + if !clients.is_empty() { + self.load_lists(clients, false, None); + } + } + + /// Fetches the lists that the rows use again, one request per URL. + fn refresh_models(&mut self) { + let clients = self.row_clients(|state| !state.is_some_and(ListState::loading)); + if clients.is_empty() { + return; + } + self.set_status("Refreshing the model lists…".to_string()); + self.load_lists(clients, true, None); + } + + /// Returns the clients that the rows use and whose list state passes + /// `wanted`, each client once. + fn row_clients(&self, wanted: impl Fn(Option<&ListState>) -> bool) -> Vec { + let mut clients: Vec = Vec::new(); + for choice in &self.choices { + if let Some(client) = self.client(&choice.client) + && wanted(self.lists.get(&models::list_url(client))) + && !clients.contains(client) + { + clients.push(client.clone()); + } + } + clients + } + + /// Loads the lists that `clients` use on a worker thread, which loads + /// each URL on its own thread and reports each list as it arrives. With + /// `refresh`, the worker fetches the lists even when the cache file has + /// them, and the outcome replaces the result area's text, because the + /// user asked for it. `key` is a key the user just typed, with its + /// `base_url`. The worker lists the models with it first, and saves it in + /// the Keychain only when the models endpoint did not reject it. + fn load_lists(&mut self, clients: Vec, refresh: bool, key: Option<(String, String)>) { + self.loads += 1; + let load = self.loads; + for client in &clients { + self.lists + .entry(models::list_url(client)) + .or_default() + .started = load; + } + self.update_rows(); + let cache = self.cache.clone(); + let sender = self.sender.clone(); + std::thread::spawn(move || { + let typed_key = key.as_ref().map(|(_, key)| key.as_str()); + let lists = models::load(&cache, &clients, refresh, typed_key, &|loaded| { + let _ = sender.send(Done::Listed { + load, + loaded: loaded.clone(), + }); + }); + if !refresh { + return; + } + let mut status = Vec::new(); + if let Some((base_url, key)) = &key { + status.push(keep_key(base_url, key, &lists)); + } + status.extend(lists.iter().map(outcome)); + let _ = sender.send(Done::Status(status.join("\n"))); + }); + } + + fn client(&self, name: &str) -> Option<&Client> { + self.clients.iter().find(|client| client.name == name) + } + + fn on_control(&mut self, tag: isize) { + let Some(controls) = &self.controls else { + return; + }; + match tag { + ROUTE_TAG => { + if let Some(index) = selected(&controls.route) + && index != self.route + { + self.route = index; + self.select_route(); + self.layout(); + } + } + ALGORITHM_TAG => { + if let Some(index) = selected(&controls.algorithm) + && index != self.algorithm + { + self.select_algorithm(index); + } + } + SAVE_KEY_TAG => self.save_key(), + REFRESH_TAG => self.refresh_models(), + APPLY_TAG => self.apply(), + CLOSE_TAG => self.window.close(), + _ if (CLIENT_TAG..MODEL_TAG).contains(&tag) => { + let row = tag.abs_diff(CLIENT_TAG); + let client = controls + .rows + .get(row) + .and_then(|ui| selected(&ui.client)) + .and_then(|index| self.clients.get(index)) + .map(|client| client.name.clone()); + if let (Some(client), Some(choice)) = (client, self.choices.get_mut(row)) { + choice.client = client; + self.list_models(); + // A new client can show or hide the key field and + // enable or disable Apply. + self.update_rows(); + } + } + _ if (MODEL_TAG..SELECTED_TAG).contains(&tag) => { + let row = tag.abs_diff(MODEL_TAG); + if let (Some(ui), Some(choice)) = + (controls.rows.get(row), self.choices.get_mut(row)) + { + choice.model = ui.model.stringValue().to_string(); + } + self.update_row(row); + } + _ if tag >= SELECTED_TAG => { + // The box shows the picked model only after this event, so + // read the picked item rather than the box's text. + let row = tag.abs_diff(SELECTED_TAG); + let picked = controls + .rows + .get(row) + .and_then(|ui| ui.model.objectValueOfSelectedItem()) + .and_then(|value| value.downcast::().ok()); + if let (Some(picked), Some(choice)) = (picked, self.choices.get_mut(row)) { + choice.model = picked.to_string(); + self.update_row(row); + } + } + _ => {} + } + } + + fn on_done(&mut self, done: Done) { + match done { + Done::Listed { + load, + loaded: Loaded { url, list, error }, + } => { + self.lists.entry(url).or_default().finish(load, list, error); + self.update_rows(); + } + Done::Status(status) => self.set_status(status), + Done::Applied(result) => { + self.busy = false; + match result { + Ok(message) => { + let unpriced = self.unpriced_models(); + let route = self.routes.get(self.route).map(|route| route.key.clone()); + self.load(route.as_deref()); + self.layout(); + self.set_status(format!("{message}{unpriced}")); + } + Err(error) => self.set_status(format!("Not saved. {error}")), + } + } + } + } + + /// Returns a note for each chosen model that has no price in menubar.toml. + fn unpriced_models(&self) -> String { + let mut notes = String::new(); + let mut seen = Vec::new(); + for choice in &self.choices { + let model = choice.model.trim(); + if !self.settings.prices.contains_key(model) && !seen.contains(&model) { + seen.push(model); + notes.push_str(&format!( + "\nmenubar.toml has no price for {model}. Savings stay hidden until you add \ + one and restart the menu bar app." + )); + } + } + notes + } + + /// Lists the models of every client with the key field's `base_url` + /// using the typed key, and then saves the key unless the models + /// endpoint rejected it. + fn save_key(&mut self) { + let (Some(controls), Some(base_url)) = (&self.controls, self.key_url().map(str::to_string)) + else { + return; + }; + let key = controls.key.stringValue().to_string().trim().to_string(); + controls.key.setStringValue(ns_string!("")); + if key.is_empty() { + self.set_status("Paste a key into the field first.".to_string()); + return; + } + if models::has_line_break(&key) { + self.set_status( + "Could not use the key, because it has a line break. Paste the key again \ + without line breaks." + .to_string(), + ); + return; + } + let clients: Vec = self + .clients + .iter() + .filter(|client| client.base_url == base_url) + .cloned() + .collect(); + self.set_status(format!( + "Checking the key by listing the models at {base_url}…" + )); + self.load_lists(clients, true, Some((base_url, key))); + } + + fn apply(&mut self) { + if self.busy { + return; + } + self.read_models(); + let (Some(route), Some(algorithm)) = + (self.routes.get(self.route), ALGORITHMS.get(self.algorithm)) + else { + return; + }; + let route = route.key.clone(); + let choices = self.choices.clone(); + let settings = self.settings.clone(); + let sender = self.sender.clone(); + std::thread::spawn(move || { + let result = server::apply(&settings, &route, algorithm, &choices); + let _ = sender.send(Done::Applied(result)); + }); + self.busy = true; + self.set_status("Checking the config with switchyard-server --dry-run…".to_string()); + } + + /// Copies the typed model IDs into the choices. + fn read_models(&mut self) { + let Some(controls) = &self.controls else { + return; + }; + for (row, choice) in controls.rows.iter().zip(&mut self.choices) { + choice.model = row.model.stringValue().to_string(); + } + } + + fn set_status(&mut self, status: String) { + self.status = status; + if let Some(controls) = &self.controls { + controls.status.setString(&NSString::from_str(&self.status)); + controls.apply.setEnabled(self.can_apply()); + } + } + + /// Returns whether Apply can run: no Apply is running, the config has + /// loaded, and every role names an LLM client from the config. + fn can_apply(&self) -> bool { + !self.busy + && self.config.is_some() + && self + .choices + .iter() + .all(|choice| self.client(&choice.client).is_some()) + } + + fn update_rows(&self) { + for row in 0..self.choices.len() { + self.update_row(row); + } + let Some(controls) = &self.controls else { + return; + }; + controls.apply.setEnabled(self.can_apply()); + let key_url = self.key_url(); + let hidden = key_url.is_none(); + controls.key_label.setHidden(hidden); + controls.key.setHidden(hidden); + controls.save_key.setHidden(hidden); + if let Some(url) = key_url { + controls + .key_label + .setStringValue(&NSString::from_str(&format!( + "To list the models at {url}, paste the API key for that address and click \ + Save key. The app uses the key only to list models there and keeps it in \ + your login Keychain, not in a file." + ))); + controls + .key + .setAccessibilityLabel(Some(&NSString::from_str(&format!("API key for {url}")))); + } + } + + /// Returns the `base_url` that the key field saves a key for: the + /// `base_url` of the first row whose list failed because it needs a + /// working key. + fn key_url(&self) -> Option<&str> { + self.choices.iter().find_map(|choice| { + let client = self.client(&choice.client)?; + let error = self.lists.get(&models::list_url(client))?.error.as_ref()?; + error.needs_key().then_some(client.base_url.as_str()) + }) + } + + /// Fills a row's model list and writes the note under it. + fn update_row(&self, row: usize) { + let (Some(controls), Some(choice)) = (&self.controls, self.choices.get(row)) else { + return; + }; + let Some(ui) = controls.rows.get(row) else { + return; + }; + ui.model.removeAllItems(); + let client = self.client(&choice.client); + let state = client.and_then(|client| self.lists.get(&models::list_url(client))); + let loading = state.is_some_and(ListState::loading); + let note = match (client, state) { + (None, _) if self.clients.is_empty() => "The config has no [llm_clients] entries. \ + Add an LLM client to the server config, \ + then open this window again." + .to_string(), + (None, _) => format!( + "The config has no LLM client named \"{}\". Pick an LLM client from the list on \ + the left.", + choice.client + ), + ( + Some(client), + Some(ListState { + list: Some(list), + error, + .. + }), + ) => { + let mut note = fill(ui, list, &choice.model); + if loading { + note.push_str(" Refreshing…"); + } else if let Some(error) = error { + note.push('\n'); + note.push_str(&error.reason()); + } + // A cached list loads without a key, so the note must name + // the missing variable that Apply needs. + if let Some(variable) = models::missing_env(client) { + note.push('\n'); + note.push_str(&models::missing_env_note(variable)); + } + note + } + ( + Some(_), + Some(ListState { + list: None, + error: Some(error), + .. + }), + ) if !loading => format!("No model list yet. {} {}", error.reason(), error.advice()), + (Some(_), _) => "Loading models…".to_string(), + }; + let note = NSString::from_str(¬e); + ui.note.setStringValue(¬e); + // The note fits three lines, and its tooltip shows the whole text. + ui.note.setToolTip(Some(¬e)); + } + + /// Rebuilds the window's controls for the current route and algorithm. + /// The role rows sit in a scroll view. It is as tall as the rows when the + /// screen has room, and shorter when it does not, so the buttons under + /// the rows stay on the screen. + fn layout(&mut self) { + let mtm = self.mtm; + let fixed = 2.0 * MARGIN + 34.0 + 32.0 + SUMMARY_HEIGHT + KEY_HEIGHT + STATUS_HEIGHT + 32.0; + let rows_height = ROLE_HEIGHT * self.roles.len() as f64; + let screen = self.window.screen().or_else(|| NSScreen::mainScreen(mtm)); + let room = screen.as_ref().map_or(f64::INFINITY, |screen| { + self.window + .contentRectForFrameRect(screen.visibleFrame()) + .size + .height + - fixed + }); + let rows_shown = rows_height.min(room.max(ROLE_HEIGHT)); + let height = fixed + rows_shown; + let view = NSView::initWithFrame(NSView::alloc(mtm), rect(0.0, 0.0, WIDTH, height)); + // Controls are placed from the top; AppKit's origin is bottom left. + let mut top = MARGIN; + let mut next = |row_height: f64| { + let y = height - top - row_height; + top += row_height; + y + }; + + let y = next(34.0); + view.addSubview(&self.label("Route", rect(MARGIN, y + 12.0, 80.0, 18.0))); + let routes: Vec = self.routes.iter().map(|route| route.id.clone()).collect(); + let route = self.popup( + &routes, + Some(self.route), + rect(FIELD_X, y + 8.0, FIELD_WIDTH, 26.0), + ); + route.setTag(ROUTE_TAG); + route.setAccessibilityLabel(Some(ns_string!("Route"))); + view.addSubview(&route); + + let y = next(32.0); + view.addSubview(&self.label("Algorithm", rect(MARGIN, y + 10.0, 80.0, 18.0))); + let kinds: Vec = ALGORITHMS.iter().map(|a| a.kind.to_string()).collect(); + let algorithm = self.popup( + &kinds, + Some(self.algorithm), + rect(FIELD_X, y + 6.0, FIELD_WIDTH, 26.0), + ); + algorithm.setTag(ALGORITHM_TAG); + algorithm.setAccessibilityLabel(Some(ns_string!("Algorithm"))); + view.addSubview(&algorithm); + + let y = next(SUMMARY_HEIGHT); + let roles: Vec<&str> = self.roles.iter().map(|role| role.label.as_str()).collect(); + let summary = format!( + "{}\nRoles: {}.", + ALGORITHMS.get(self.algorithm).map_or("", |a| a.summary), + roles.join(", ") + ); + let summary = self.wrapping( + &summary, + rect(FIELD_X, y + 4.0, FIELD_WIDTH, SUMMARY_HEIGHT - 8.0), + ); + view.addSubview(&summary); + + let clients: Vec = self + .clients + .iter() + .map(|client| format!("{} ({})", client.name, client.format)) + .collect(); + let rows_y = next(rows_shown); + let rows_view = + NSView::initWithFrame(NSView::alloc(mtm), rect(0.0, 0.0, WIDTH, rows_height)); + let mut rows = Vec::with_capacity(self.roles.len()); + for (row, (role, choice)) in self.roles.iter().zip(&self.choices).enumerate() { + let y = rows_height - ROLE_HEIGHT * (row + 1) as f64; + let tag = isize::try_from(row).unwrap_or(0); + // The popup and the combo box sit above the note. + let controls_y = y + NOTE_HEIGHT + 8.0; + rows_view + .addSubview(&self.label(&role.label, rect(MARGIN, controls_y + 4.0, 86.0, 18.0))); + // When the config has no client with the choice's name, the + // popup selects nothing, and the note names the missing client. + let selected = self + .clients + .iter() + .position(|client| client.name == choice.client); + let client = self.popup( + &clients, + selected, + rect(FIELD_X, controls_y, CLIENT_WIDTH, 26.0), + ); + client.setTag(CLIENT_TAG + tag); + client.setAccessibilityLabel(Some(&NSString::from_str(&format!( + "{} LLM client", + role.label + )))); + rows_view.addSubview(&client); + + let model = ModelBox::new(mtm, rect(MODEL_X, controls_y, MODEL_WIDTH, 26.0)); + model.setStringValue(&NSString::from_str(&choice.model)); + model.setPlaceholderString(Some(ns_string!("Type to filter, or type any model ID"))); + model.setNumberOfVisibleItems(14); + model.setCompletes(false); + model.setTag(MODEL_TAG + tag); + model + .setAccessibilityLabel(Some(&NSString::from_str(&format!("{} model", role.label)))); + // SAFETY: the target lives as long as the window. + unsafe { model.setDelegate(Some(ProtocolObject::from_ref(&*self.target))) }; + rows_view.addSubview(&model); + + let note = self.wrapping( + "", + rect(MODEL_X + 2.0, y + 4.0, MODEL_WIDTH - 2.0, NOTE_HEIGHT), + ); + note.setFont(Some(&NSFont::systemFontOfSize( + NSFont::smallSystemFontSize(), + ))); + note.setTextColor(Some(&NSColor::secondaryLabelColor())); + rows_view.addSubview(¬e); + rows.push(RoleRow { + client, + model, + note, + }); + } + let scroll = NSScrollView::initWithFrame( + NSScrollView::alloc(mtm), + rect(0.0, rows_y, WIDTH, rows_shown), + ); + scroll.setHasVerticalScroller(true); + scroll.setAutohidesScrollers(true); + scroll.setDrawsBackground(false); + scroll.setDocumentView(Some(&rows_view)); + view.addSubview(&scroll); + // The rows view's origin is its bottom left, so the scroll view + // starts at the last role. Show the first role instead. + rows_view.scrollPoint(NSPoint::new(0.0, rows_height - rows_shown)); + + let y = next(KEY_HEIGHT); + let key_label = self.wrapping("", rect(FIELD_X, y + 30.0, FIELD_WIDTH, 48.0)); + view.addSubview(&key_label); + let key = NSSecureTextField::initWithFrame( + NSSecureTextField::alloc(mtm), + rect(FIELD_X, y + 4.0, FIELD_WIDTH - 110.0, 22.0), + ); + key.setPlaceholderString(Some(ns_string!("Paste the API key"))); + view.addSubview(&key); + let save_key = self.button( + "Save key", + SAVE_KEY_TAG, + rect(WIDTH - MARGIN - 100.0, y, 100.0, 30.0), + ); + view.addSubview(&save_key); + + let y = next(STATUS_HEIGHT); + // The result can be longer than the area, such as a long --dry-run + // error, so the area scrolls. + let status_scroll = NSScrollView::initWithFrame( + NSScrollView::alloc(mtm), + rect(MARGIN, y + 4.0, WIDTH - 2.0 * MARGIN, STATUS_HEIGHT - 8.0), + ); + status_scroll.setHasVerticalScroller(true); + status_scroll.setAutohidesScrollers(true); + status_scroll.setDrawsBackground(false); + let size = status_scroll.contentSize(); + let status = NSTextView::initWithFrame( + NSTextView::alloc(mtm), + rect(0.0, 0.0, size.width, size.height), + ); + // The text view grows down as the text gets longer, and wraps at the + // area's width. + status.setMinSize(size); + status.setMaxSize(NSSize::new(size.width, f64::MAX)); + status.setVerticallyResizable(true); + status.setHorizontallyResizable(false); + status.setEditable(false); + status.setDrawsBackground(false); + status.setFont(Some(&NSFont::systemFontOfSize(NSFont::systemFontSize()))); + status.setString(&NSString::from_str(&self.status)); + status_scroll.setDocumentView(Some(&status)); + view.addSubview(&status_scroll); + + let refresh = self.button( + "Refresh models", + REFRESH_TAG, + rect(MARGIN, MARGIN - 4.0, 150.0, 32.0), + ); + refresh.setEnabled(!self.choices.is_empty()); + view.addSubview(&refresh); + let close = self.button( + "Close", + CLOSE_TAG, + rect(WIDTH - MARGIN - 210.0, MARGIN - 4.0, 100.0, 32.0), + ); + // Escape closes the window, as in a macOS dialog. + close.setKeyEquivalent(&NSString::from_str("\u{1b}")); + view.addSubview(&close); + let apply = self.button( + "Apply", + APPLY_TAG, + rect(WIDTH - MARGIN - 100.0, MARGIN - 4.0, 100.0, 32.0), + ); + // Return runs Apply, the window's default button. + apply.setKeyEquivalent(ns_string!("\r")); + view.addSubview(&apply); + + // Keep the window's top edge in place as its height changes. + let frame = self.window.frame(); + let top_left = NSPoint::new(frame.origin.x, frame.origin.y + frame.size.height); + self.window.setContentSize(NSSize::new(WIDTH, height)); + self.window.setContentView(Some(&view)); + // Tab moves between the controls in the order they appear, including + // the controls inside the scroll view. + self.window.recalculateKeyViewLoop(); + self.window.setFrameTopLeftPoint(top_left); + // A taller window can reach past the bottom of the screen. Move it + // back onto the screen, so that Apply stays in reach. + if let Some(screen) = &screen { + let visible = screen.visibleFrame(); + let mut frame = self.window.frame(); + frame.origin.x = frame + .origin + .x + .min(visible.origin.x + visible.size.width - frame.size.width) + .max(visible.origin.x); + frame.origin.y = frame + .origin + .y + .max(visible.origin.y) + .min(visible.origin.y + visible.size.height - frame.size.height); + self.window.setFrameOrigin(frame.origin); + } + + self.controls = Some(Controls { + route, + algorithm, + rows, + key_label, + key, + save_key, + status, + apply, + }); + self.update_rows(); + } + + fn label(&self, text: &str, frame: NSRect) -> Retained { + let label = NSTextField::labelWithString(&NSString::from_str(text), self.mtm); + label.setFrame(frame); + label + } + + fn wrapping(&self, text: &str, frame: NSRect) -> Retained { + let label = NSTextField::wrappingLabelWithString(&NSString::from_str(text), self.mtm); + label.setFrame(frame); + label + } + + /// Returns a popup that lists `items` with `selected` chosen, or with no + /// item chosen when `selected` is `None`. + fn popup( + &self, + items: &[String], + selected: Option, + frame: NSRect, + ) -> Retained { + let popup = + NSPopUpButton::initWithFrame_pullsDown(NSPopUpButton::alloc(self.mtm), frame, false); + for item in items { + popup.addItemWithTitle(&NSString::from_str(item)); + } + popup.selectItemAtIndex( + selected + .and_then(|index| isize::try_from(index).ok()) + .unwrap_or(-1), + ); + // SAFETY: `ControlTarget` implements `controlChanged:` and lives as + // long as the window. + unsafe { + popup.setTarget(Some(&self.target)); + popup.setAction(Some(sel!(controlChanged:))); + } + popup + } + + fn button(&self, title: &str, tag: isize, frame: NSRect) -> Retained { + // SAFETY: `ControlTarget` implements `controlChanged:` and lives as + // long as the window. + let button = unsafe { + NSButton::buttonWithTitle_target_action( + &NSString::from_str(title), + Some(&self.target), + Some(sel!(controlChanged:)), + self.mtm, + ) + }; + button.setFrame(frame); + button.setTag(tag); + button + } +} + +/// The selected item of a popup, if any. +fn selected(popup: &NSPopUpButton) -> Option { + usize::try_from(popup.indexOfSelectedItem()).ok() +} + +fn rect(x: f64, y: f64, width: f64, height: f64) -> NSRect { + NSRect::new(NSPoint::new(x, y), NSSize::new(width, height)) +} + +/// Puts the listed models that match `query` in the row's list, and says how +/// many there are. +fn fill(ui: &RoleRow, list: &ModelList, query: &str) -> String { + let query = query.trim(); + let listed = &list.models; + // A complete ID shows the whole list, so another model is one click away. + let shown: Vec<&str> = if listed.iter().any(|model| model == query) { + listed.iter().map(String::as_str).collect() + } else { + models::matching(listed, query) + }; + for model in &shown { + // SAFETY: the combo box stores NSString values. + unsafe { ui.model.addItemWithObjectValue(&NSString::from_str(model)) }; + } + if query.is_empty() || shown.len() == listed.len() { + format!( + "{} models, fetched {}. Type to filter.", + listed.len(), + age(list.fetched_at) + ) + } else if shown.is_empty() { + "No listed model matches. Apply uses the ID as typed.".to_string() + } else { + format!("{} of {} models match.", shown.len(), listed.len()) + } +} + +/// Saves a typed key in the Keychain unless the models endpoint rejected +/// it while the app listed `lists` with it, and says what happened. +fn keep_key(base_url: &str, key: &str, lists: &[Loaded]) -> String { + if lists + .iter() + .any(|loaded| matches!(loaded.error, Some(ListError::Rejected(_)))) + { + return format!( + "Did not save the key for {base_url}, because the models endpoint rejected it." + ); + } + match models::save_key(base_url, key) { + Ok(()) => format!("Saved the key for {base_url} in your login Keychain."), + Err(error) => format!( + "Could not save the key in your login Keychain: {error}. The app used the key this \ + time only and did not keep it. To try again, paste the key and click Save key." + ), + } +} + +/// Describes one loaded list for the result area. +fn outcome(loaded: &Loaded) -> String { + let mut line = format!("{}:", loaded.url); + if let Some(list) = &loaded.list { + line.push_str(&format!( + " {} models, fetched {}.", + list.models.len(), + age(list.fetched_at) + )); + } + if let Some(error) = &loaded.error { + line.push(' '); + line.push_str(&error.reason()); + } + line +} + +/// Says how long ago `fetched_at` was, such as "3 hours ago". +fn age(fetched_at: u64) -> String { + let minutes = models::now().saturating_sub(fetched_at) / 60; + let (count, unit) = match minutes { + 0 => return "just now".to_string(), + 1..60 => (minutes, "minute"), + 60..1440 => (minutes / 60, "hour"), + _ => (minutes / 1440, "day"), + }; + let plural = if count == 1 { "" } else { "s" }; + format!("{count} {unit}{plural} ago") +} + +#[cfg(test)] +mod tests { + use super::*; + + fn list(fetched_at: u64) -> Option { + Some(ModelList { + models: vec!["model".to_string()], + fetched_at, + }) + } + + #[test] + fn a_failed_load_keeps_the_newer_list_and_shows_the_error() { + // Load 1 fetched a list that the cache file does not have, so load 2 + // fails with no list at all. + let mut state = ListState { + list: list(100), + started: 2, + shown: 1, + ..ListState::default() + }; + let failed = ListError::Failed("connection refused".to_string()); + + state.finish(2, None, Some(failed.clone())); + + assert!(!state.loading()); + assert_eq!(state.list, list(100)); + assert_eq!(state.error, Some(failed)); + } + + #[test] + fn ignores_a_load_that_finishes_after_a_newer_one() { + let mut state = ListState { + started: 2, + ..ListState::default() + }; + + state.finish(2, list(200), None); + state.finish(1, None, Some(ListError::Rejected(401))); + + assert!(!state.loading()); + assert_eq!(state.list, list(200)); + assert_eq!(state.error, None); + } +} diff --git a/crates/switchyard-menubar/src/pricing.rs b/crates/switchyard-menubar/src/pricing.rs new file mode 100644 index 000000000..e551bdd71 --- /dev/null +++ b/crates/switchyard-menubar/src/pricing.rs @@ -0,0 +1,186 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +//! Counterfactual cost: what routed traffic cost against what it would have +//! cost had every call gone to one capable model. + +use std::collections::BTreeMap; + +use serde::Deserialize; + +use crate::rollup::{ModelTokens, Totals}; + +const PER_MILLION: f64 = 1_000_000.0; + +/// Per-million-token rates for one model. +#[derive(Clone, Copy, Debug, Deserialize, PartialEq)] +pub struct ModelPrice { + pub input_per_mtok: f64, + /// Rate for cache reads. Falls back to the full input rate. + #[serde(default)] + pub cached_input_per_mtok: Option, + /// Rate for generated tokens, reasoning included. + pub output_per_mtok: f64, +} + +impl ModelPrice { + fn cost(&self, tokens: &ModelTokens) -> f64 { + let cached_rate = self.cached_input_per_mtok.unwrap_or(self.input_per_mtok); + (tokens.input as f64 * self.input_per_mtok + + tokens.cached_input as f64 * cached_rate + + tokens.output as f64 * self.output_per_mtok) + / PER_MILLION + } +} + +/// Rates keyed by the model id the server records in the routing log. +pub type PriceTable = BTreeMap; + +/// What the traffic cost, and what it would have cost without routing. +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct Savings { + /// Cost of the calls that actually ran, classifier overhead included. + pub actual: f64, + /// Cost of the same caller-facing calls had they all used the baseline model. + pub baseline: f64, +} + +impl Savings { + /// Dollars kept. Negative when routing overhead outweighed the cheaper tier. + pub fn saved(&self) -> f64 { + self.baseline - self.actual + } + + /// Fraction of the baseline bill avoided, or `None` with nothing to compare. + pub fn percent(&self) -> Option { + (self.baseline > 0.0).then(|| self.saved() / self.baseline * 100.0) + } +} + +/// Prices a period against the baseline model. +/// +/// Returns `None` when any model seen has no entry in the table, since a +/// partial bill would understate cost and overstate savings. +pub fn estimate(totals: &Totals, prices: &PriceTable, baseline_model: &str) -> Option { + let baseline_price = prices.get(baseline_model)?; + + let mut actual = 0.0; + let mut baseline = 0.0; + for (model, tokens) in &totals.routed { + actual += prices.get(model)?.cost(tokens); + baseline += baseline_price.cost(tokens); + } + // Classifier calls are Switchyard's own cost. They have no baseline + // counterpart, so they only ever reduce the savings figure. + for (model, tokens) in &totals.classifier { + actual += prices.get(model)?.cost(tokens); + } + + Some(Savings { actual, baseline }) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn price(input: f64, output: f64) -> ModelPrice { + ModelPrice { + input_per_mtok: input, + cached_input_per_mtok: None, + output_per_mtok: output, + } + } + + fn table() -> PriceTable { + PriceTable::from([ + ("sol".to_string(), price(1.25, 10.0)), + ("luna".to_string(), price(0.25, 2.0)), + ("terra".to_string(), price(0.05, 0.4)), + ]) + } + + fn tokens(input: u64, output: u64) -> ModelTokens { + ModelTokens { + requests: 1, + input, + cached_input: 0, + output, + } + } + + #[test] + fn prices_the_cheaper_tier_against_the_capable_baseline() { + let mut totals = Totals::default(); + totals + .routed + .insert("luna".to_string(), tokens(1_000_000, 100_000)); + + let savings = estimate(&totals, &table(), "sol").expect("priced"); + + assert!((savings.actual - 0.45).abs() < 1e-9); + assert!((savings.baseline - 2.25).abs() < 1e-9); + assert!((savings.percent().expect("percent") - 80.0).abs() < 1e-9); + } + + #[test] + fn classifier_overhead_counts_against_savings() { + let mut totals = Totals::default(); + totals + .routed + .insert("luna".to_string(), tokens(1_000_000, 100_000)); + let before = estimate(&totals, &table(), "sol").expect("priced"); + + totals + .classifier + .insert("terra".to_string(), tokens(1_000_000, 10_000)); + let after = estimate(&totals, &table(), "sol").expect("priced"); + + assert_eq!(after.baseline, before.baseline); + assert!(after.saved() < before.saved()); + } + + #[test] + fn cache_reads_use_the_cached_rate() { + let prices = PriceTable::from([( + "sol".to_string(), + ModelPrice { + input_per_mtok: 1.0, + cached_input_per_mtok: Some(0.1), + output_per_mtok: 0.0, + }, + )]); + let mut totals = Totals::default(); + totals.routed.insert( + "sol".to_string(), + ModelTokens { + requests: 1, + input: 1_000_000, + cached_input: 1_000_000, + output: 0, + }, + ); + + let savings = estimate(&totals, &prices, "sol").expect("priced"); + + assert!((savings.actual - 1.1).abs() < 1e-9); + } + + #[test] + fn an_unpriced_model_suppresses_the_estimate() { + let mut totals = Totals::default(); + totals + .routed + .insert("unknown".to_string(), tokens(1_000, 100)); + + assert!(estimate(&totals, &table(), "sol").is_none()); + assert!(estimate(&Totals::default(), &table(), "absent").is_none()); + } + + #[test] + fn an_empty_period_has_no_percentage() { + let savings = estimate(&Totals::default(), &table(), "sol").expect("priced"); + + assert_eq!(savings.saved(), 0.0); + assert!(savings.percent().is_none()); + } +} diff --git a/crates/switchyard-menubar/src/rollup.rs b/crates/switchyard-menubar/src/rollup.rs new file mode 100644 index 000000000..8e3ad22b7 --- /dev/null +++ b/crates/switchyard-menubar/src/rollup.rs @@ -0,0 +1,259 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +//! Token totals for today and the past week, read from the server's routing log. + +use std::collections::BTreeMap; +use std::fs::File; +use std::io::{BufRead, BufReader}; +use std::path::Path; + +use chrono::{DateTime, Local, NaiveDate}; +use serde::Deserialize; + +/// Tier value the server writes for classifier and judge calls. +const CLASSIFIER_TIER: &str = "classifier"; + +/// Billable token counts for one model. +#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)] +pub struct ModelTokens { + pub requests: u64, + /// Input tokens billed at the full rate. Cache writes bill at that rate + /// too, so they are counted here rather than tracked separately. + pub input: u64, + /// Input tokens served from the provider cache. + pub cached_input: u64, + /// Generated tokens, including reasoning. + pub output: u64, +} + +impl ModelTokens { + /// Every token the provider counted, cached reads included. + pub fn total(&self) -> u64 { + self.input + self.cached_input + self.output + } + + fn add(&mut self, other: &Self) { + self.requests += other.requests; + self.input += other.input; + self.cached_input += other.cached_input; + self.output += other.output; + } +} + +/// Tokens for a period, split by who asked for them. +/// +/// The split matters for savings: without Switchyard the `routed` calls would +/// still have happened, but the `classifier` calls would not exist at all. +#[derive(Clone, Debug, Default, PartialEq, Eq)] +pub struct Totals { + /// Calls that served the caller, keyed by the model that answered. + pub routed: BTreeMap, + /// Switchyard's own classifier and judge calls, keyed by model. + pub classifier: BTreeMap, +} + +impl Totals { + fn add(&mut self, record: &Record) { + let bucket = if record.tier == CLASSIFIER_TIER { + &mut self.classifier + } else { + &mut self.routed + }; + bucket + .entry(record.model.clone()) + .or_default() + .add(&record.tokens()); + } + + /// Calls made on the caller's behalf. + pub fn requests(&self) -> u64 { + self.routed.values().map(|tokens| tokens.requests).sum() + } + + /// Every token spent, Switchyard's own routing overhead included. + pub fn tokens(&self) -> u64 { + self.routed + .values() + .chain(self.classifier.values()) + .map(ModelTokens::total) + .sum() + } + + /// True when nothing was recorded. + pub fn is_empty(&self) -> bool { + self.routed.is_empty() && self.classifier.is_empty() + } +} + +/// What the menu shows. +#[derive(Clone, Debug, Default)] +pub struct Usage { + pub today: Totals, + pub week: Totals, +} + +/// Fields the rollup needs from one routing log line. +#[derive(Deserialize)] +struct Record { + ts: String, + model: String, + #[serde(default)] + tier: String, + #[serde(default)] + prompt_tokens: u64, + #[serde(default)] + cached_tokens: u64, + #[serde(default)] + completion_tokens: u64, + #[serde(default)] + reasoning_tokens: u64, +} + +impl Record { + fn tokens(&self) -> ModelTokens { + ModelTokens { + requests: 1, + // `prompt_tokens` is the whole input, cache reads included. + input: self.prompt_tokens.saturating_sub(self.cached_tokens), + cached_input: self.cached_tokens, + output: self.completion_tokens + self.reasoning_tokens, + } + } +} + +/// Sums the log into today's and the trailing week's totals. +/// +/// A missing log is not an error: the server creates it on its first request. +/// Unparsable lines are skipped, which also covers the last line while the +/// server is still writing it. +pub fn read(path: &Path, today: NaiveDate) -> std::io::Result { + let file = match File::open(path) { + Ok(file) => file, + Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(Usage::default()), + Err(error) => return Err(error), + }; + + let week_start = today - chrono::Duration::days(6); + let mut usage = Usage::default(); + for line in BufReader::new(file).lines() { + let line = line?; + let Ok(record) = serde_json::from_str::(&line) else { + continue; + }; + let Ok(ts) = DateTime::parse_from_rfc3339(&record.ts) else { + continue; + }; + let day = ts.with_timezone(&Local).date_naive(); + if day == today { + usage.today.add(&record); + } + if day >= week_start && day <= today { + usage.week.add(&record); + } + } + Ok(usage) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn date(text: &str) -> NaiveDate { + text.parse().expect("valid date") + } + + /// Builds a log line stamped at a local time, mirroring how the server + /// writes timestamps for events that happened in the local day. + fn line(local_ts: &str, model: &str, tier: &str, prompt: u64, completion: u64) -> String { + let offset = Local::now().offset().to_string(); + format!( + r#"{{"ts":"{local_ts}{offset}","model":"{model}","tier":"{tier}","prompt_tokens":{prompt},"cached_tokens":0,"completion_tokens":{completion},"reasoning_tokens":0}}"# + ) + } + + fn log(contents: &str) -> (tempfile::TempDir, std::path::PathBuf) { + let dir = tempfile::tempdir().expect("tempdir"); + let path = dir.path().join("routing.jsonl"); + std::fs::write(&path, contents).expect("write log"); + (dir, path) + } + + #[test] + fn splits_routed_and_classifier_tokens() { + let (_dir, path) = log(&format!( + "{}\n{}\n", + line("2026-09-28T10:00:00.000", "luna", "", 1_000, 200), + line("2026-09-28T10:00:01.000", "terra", "classifier", 300, 10), + )); + + let usage = read(&path, date("2026-09-28")).expect("read"); + + assert_eq!( + usage.today.requests(), + 1, + "classifier calls are not requests" + ); + assert_eq!(usage.today.routed["luna"].input, 1_000); + assert_eq!(usage.today.classifier["terra"].input, 300); + assert_eq!(usage.today.tokens(), 1_000 + 200 + 300 + 10); + } + + #[test] + fn separates_cache_reads_from_full_price_input() { + let record: Record = serde_json::from_str( + r#"{"ts":"2026-09-28T10:00:00.000Z","model":"m","prompt_tokens":1000,"cached_tokens":600,"completion_tokens":50,"reasoning_tokens":25}"#, + ) + .expect("parse record"); + + let tokens = record.tokens(); + + assert_eq!(tokens.input, 400); + assert_eq!(tokens.cached_input, 600); + assert_eq!(tokens.output, 75, "reasoning tokens are billed as output"); + assert_eq!(tokens.total(), 1_075); + } + + #[test] + fn the_week_covers_seven_days() { + let mut contents = String::new(); + for day in 18..=28 { + contents.push_str(&line( + &format!("2026-09-{day:02}T10:00:00.000"), + "luna", + "", + 100, + 10, + )); + contents.push('\n'); + } + let (_dir, path) = log(&contents); + + let usage = read(&path, date("2026-09-28")).expect("read"); + + assert_eq!(usage.today.requests(), 1); + assert_eq!(usage.week.requests(), 7); + } + + #[test] + fn skips_lines_it_cannot_parse() { + // The trailing line has no newline yet, as when the server is mid-write. + let (_dir, path) = log(&format!( + "not json\n{}\n{{\"ts\":\"2026-09-2", + line("2026-09-28T10:00:00.000", "luna", "", 100, 10), + )); + + let usage = read(&path, date("2026-09-28")).expect("read"); + + assert_eq!(usage.today.requests(), 1); + } + + #[test] + fn a_missing_log_is_not_an_error() { + let dir = tempfile::tempdir().expect("tempdir"); + + let usage = read(&dir.path().join("absent.jsonl"), date("2026-09-28")).expect("read"); + + assert!(usage.today.is_empty()); + } +} diff --git a/crates/switchyard-menubar/src/server.rs b/crates/switchyard-menubar/src/server.rs new file mode 100644 index 000000000..87db0dca1 --- /dev/null +++ b/crates/switchyard-menubar/src/server.rs @@ -0,0 +1,426 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +//! Controls the installed server: check a config with its `--dry-run`, save +//! it, and restart the LaunchAgent that runs it. + +use std::fs::OpenOptions; +use std::io::{ErrorKind, Write}; +use std::path::{Path, PathBuf}; +use std::process::Command; +use std::time::{Duration, Instant}; + +use chrono::Local; + +use crate::config::Config; +use crate::health::{ServerStatus, probe}; +use crate::models; +use crate::server_config::{Algorithm, Choice, ServerConfig}; + +/// How long to wait for the restarted server to answer `/health`. +const RESTART_WAIT: Duration = Duration::from_secs(10); + +/// How many backups one file can get within one second. +const BACKUPS_PER_SECOND: u32 = 100; + +/// Switches a route to new models, checks the result with the server's +/// `--dry-run`, saves it, and restarts the server. +/// +/// Returns what happened on success, one line each: the saved file and its +/// backup, the restart, notes about the edit, and the check. An error means +/// the config file was not changed. +pub fn apply( + settings: &Config, + route: &str, + algorithm: &Algorithm, + choices: &[Choice], +) -> Result { + let path = &settings.config_file; + // Read the file again so an edit made while the window was open is kept. + let text = std::fs::read_to_string(path).map_err(|error| { + format!( + "Could not read {}: {error}. Check config_file in the menu bar settings.", + path.display() + ) + })?; + let config = ServerConfig::parse(&text)?; + let edited = config.edit(route, algorithm, choices)?; + if edited.text == text { + return Ok( + "Nothing to save. The route already uses this algorithm and these models.".to_string(), + ); + } + + let binary = server_binary()?; + let backup = save_checked(&binary, path, &text, &edited.text).map_err(explain)?; + let label = &settings.launchd_label; + let health = format!("{}/health", settings.server_url); + let mut lines = vec![format!( + "Saved {}. The old file is at {}.", + path.display(), + backup.display() + )]; + lines.push(match restart(label) { + Err(error) => format!( + "Could not restart {label}: {error}. The server uses the new config the next time \ + it starts. Click Restart server in the menu to try again." + ), + Ok(()) if wait_for_health(&settings.server_url) => { + format!("Restarted {label}, and the server answers {health}.") + } + Ok(()) => format!( + "Restarted {label}, but the server did not answer {health} within {} seconds. \ + Check the server log, by default in ~/.switchyard/logs/. To undo, copy {} over {} \ + and click Restart server.", + RESTART_WAIT.as_secs(), + backup.display(), + path.display() + ), + }); + lines.extend(edited.notes); + let routes = config.routes().len(); + let plural = if routes == 1 { "" } else { "s" }; + lines.push(format!( + "The switchyard-server --dry-run check passed for {routes} route{plural}." + )); + Ok(lines.join("\n")) +} + +/// Adds advice to a check error that comes from a missing `api_key_env` +/// variable. The check runs with this app's environment, which is not the +/// user's shell environment. +fn explain(error: String) -> String { + let variable = error + .split_once("could not read api_key_env ") + .and_then(|(_, rest)| rest.split([':', ' ', '\n']).next()) + .filter(|variable| !variable.is_empty()) + .map(str::to_string); + match variable { + Some(variable) => format!("{error}\n{}", models::missing_env_note(&variable)), + None => error, + } +} + +/// Returns the `switchyard-server` installed next to this app, which is the +/// binary that the server's LaunchAgent runs. +fn server_binary() -> Result { + std::env::current_exe() + .map(|exe| exe.with_file_name("switchyard-server")) + .map_err(|error| { + format!( + "Could not find switchyard-server next to the menu bar app: {error}. Run make \ + install-macos from the Switchyard repository to install it." + ) + }) +} + +/// Checks `text` with `switchyard-server --dry-run`, then replaces `path` +/// with it, keeping `original`, the text that `path` held, as a timestamped +/// backup. Returns the backup's path. +/// +/// The real file is `path`, or the file it points to when `path` is a +/// symlink. The new text goes to a temporary file next to the real file. The +/// temporary file is synced to disk and then renamed over the real file, so a +/// reader never sees a half-written config. When the check fails, or when +/// the real file no longer holds `original`, the temporary file is removed, +/// `path` does not change, and the error is returned. +fn save_checked(binary: &Path, path: &Path, original: &str, text: &str) -> Result { + // Resolve a symlink and replace the file it points to, so the link stays. + let file = std::fs::canonicalize(path) + .map_err(|error| format!("Could not find {}: {error}", path.display()))?; + let dir = file.parent().unwrap_or(Path::new("/")); + let mut candidate = tempfile::Builder::new() + .prefix(".switchyard-picker-") + .suffix(".toml") + .tempfile_in(dir) + .map_err(|error| format!("Could not create a file in {}: {error}", dir.display()))?; + candidate + .write_all(text.as_bytes()) + .and_then(|()| candidate.as_file().sync_all()) + .map_err(|error| format!("Could not write {}: {error}", candidate.path().display()))?; + if let Ok(metadata) = std::fs::metadata(&file) { + candidate + .as_file() + .set_permissions(metadata.permissions()) + .map_err(|error| { + format!( + "Could not copy the permissions of {}: {error}", + path.display() + ) + })?; + } + + // Name the real file in errors, not the temporary one. + check(binary, candidate.path()).map_err(|error| { + error.replace( + &candidate.path().display().to_string(), + &path.display().to_string(), + ) + })?; + + // The check takes a moment. Keep an edit that another program saved + // in the meantime. + if std::fs::read_to_string(&file).ok().as_deref() != Some(original) { + return Err(format!( + "Could not replace {}, because it changed on disk while Apply was checking it. \ + Click Apply again to apply your choices to the new file.", + path.display() + )); + } + let backup = back_up(&file, original) + .map_err(|error| format!("Could not back up {}: {error}", path.display()))?; + candidate + .persist(&file) + .map_err(|error| format!("Could not replace {}: {}", path.display(), error.error))?; + Ok(backup) +} + +/// Writes `text` to a new backup of `file` and returns its path. The backup +/// is `.switchyard-backup.`, the name the installer uses, +/// with `-2`, `-3`, and so on added when that name is taken, so a backup +/// never replaces another one. The backup gets the file's permissions before +/// it gets the text. +fn back_up(file: &Path, text: &str) -> Result { + let permissions = std::fs::metadata(file)?.permissions(); + let stamp = Local::now().format("%Y%m%d%H%M%S"); + for number in 1..=BACKUPS_PER_SECOND { + let suffix = if number == 1 { + String::new() + } else { + format!("-{number}") + }; + let backup = PathBuf::from(format!( + "{}.switchyard-backup.{stamp}{suffix}", + file.display() + )); + let mut out = match OpenOptions::new() + .write(true) + .create_new(true) + .open(&backup) + { + Ok(out) => out, + Err(error) if error.kind() == ErrorKind::AlreadyExists => continue, + Err(error) => return Err(error), + }; + let written = out + .set_permissions(permissions.clone()) + .and_then(|()| out.write_all(text.as_bytes())) + .and_then(|()| out.sync_all()); + if let Err(error) = written { + let _ = std::fs::remove_file(&backup); + return Err(error); + } + return Ok(backup); + } + Err(std::io::Error::new( + ErrorKind::AlreadyExists, + format!("{BACKUPS_PER_SECOND} backups from this second already exist"), + )) +} + +/// Runs `switchyard-server --config --dry-run`, and returns its +/// error output when the config is invalid. +fn check(binary: &Path, config: &Path) -> Result<(), String> { + let output = Command::new(binary) + .arg("--config") + .arg(config) + .arg("--dry-run") + .output() + .map_err(|error| { + format!( + "Could not run {}: {error}. Apply needs switchyard-server next to the menu bar \ + app. Run make install-macos from the Switchyard repository to install it.", + binary.display() + ) + })?; + if output.status.success() { + return Ok(()); + } + let stdout = String::from_utf8_lossy(&output.stdout).trim().to_string(); + let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string(); + Err(if stderr.is_empty() { stdout } else { stderr }) +} + +/// Restarts the server's LaunchAgent. +pub fn restart(launchd_label: &str) -> Result<(), String> { + let uid = command("id", &["-u"])?; + let target = format!("gui/{}/{launchd_label}", uid.trim()); + command("launchctl", &["kickstart", "-k", &target]).map(|_| ()) +} + +fn wait_for_health(server_url: &str) -> bool { + let deadline = Instant::now() + RESTART_WAIT; + loop { + if probe(server_url) == ServerStatus::Running { + return true; + } + if Instant::now() >= deadline { + return false; + } + std::thread::sleep(Duration::from_millis(250)); + } +} + +/// Runs a command, returning its stdout or a message naming what failed. +pub fn command(program: &str, args: &[&str]) -> Result { + let output = Command::new(program) + .args(args) + .output() + .map_err(|error| format!("Could not run {program}: {error}"))?; + if !output.status.success() { + return Err(format!( + "{program} {} failed: {}", + args.join(" "), + String::from_utf8_lossy(&output.stderr).trim() + )); + } + Ok(String::from_utf8_lossy(&output.stdout).into_owned()) +} + +#[cfg(test)] +mod tests { + use super::*; + use std::os::unix::fs::PermissionsExt; + + /// Writes a stand-in for `switchyard-server` that runs `body`. + fn fake_server(dir: &Path, body: &str) -> PathBuf { + let path = dir.join("switchyard-server"); + std::fs::write(&path, format!("#!/bin/sh\n{body}\n")).expect("write script"); + std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o755)) + .expect("make script executable"); + path + } + + fn backups(dir: &Path) -> Vec { + std::fs::read_dir(dir) + .expect("read dir") + .map(|entry| entry.expect("entry").path()) + .filter(|path| path.display().to_string().contains(".switchyard-backup.")) + .collect() + } + + #[test] + fn a_config_that_fails_the_check_is_not_saved() { + let dir = tempfile::tempdir().expect("tempdir"); + let config = dir.path().join("composite.toml"); + std::fs::write(&config, "old").expect("write config"); + // The real server names the file it was given; the error must name + // the user's file instead of the temporary copy. + let binary = fake_server( + dir.path(), + r#"echo "invalid server config $2: route gateway cannot forward both" >&2; exit 1"#, + ); + + let error = save_checked(&binary, &config, "old", "new").expect_err("check fails"); + + assert_eq!( + error, + format!( + "invalid server config {}: route gateway cannot forward both", + config.display() + ) + ); + assert_eq!(std::fs::read_to_string(&config).expect("read"), "old"); + assert!(backups(dir.path()).is_empty()); + assert_eq!( + std::fs::read_dir(dir.path()).expect("read dir").count(), + 2, + "the temporary file is removed" + ); + } + + #[test] + fn a_config_that_passes_the_check_replaces_the_file_and_keeps_a_backup() { + let dir = tempfile::tempdir().expect("tempdir"); + let config = dir.path().join("composite.toml"); + std::fs::write(&config, "old").expect("write config"); + std::fs::set_permissions(&config, std::fs::Permissions::from_mode(0o640)) + .expect("set permissions"); + let binary = fake_server( + dir.path(), + r#"grep -q new "$2" && echo "server OK: gateway""#, + ); + + let backup = save_checked(&binary, &config, "old", "new").expect("saved"); + + assert_eq!(std::fs::read_to_string(&config).expect("read"), "new"); + let mode = |path: &Path| { + std::fs::metadata(path) + .expect("metadata") + .permissions() + .mode() + & 0o777 + }; + assert_eq!(mode(&config), 0o640, "the file keeps its permissions"); + assert_eq!(backups(dir.path()).len(), 1); + assert_eq!(std::fs::read_to_string(&backup).expect("read"), "old"); + assert_eq!(mode(&backup), 0o640, "the backup gets the same permissions"); + } + + #[test] + fn a_second_save_keeps_the_first_backup() { + let dir = tempfile::tempdir().expect("tempdir"); + let config = dir.path().join("composite.toml"); + std::fs::write(&config, "old").expect("write config"); + let binary = fake_server(dir.path(), "exit 0"); + + // Both saves usually fall in the same second, so their backups get + // the same timestamp. + save_checked(&binary, &config, "old", "new").expect("saved"); + save_checked(&binary, &config, "new", "newer").expect("saved"); + + let mut kept: Vec = backups(dir.path()) + .iter() + .map(|backup| std::fs::read_to_string(backup).expect("read")) + .collect(); + kept.sort(); + assert_eq!(kept, ["new", "old"]); + assert_eq!(std::fs::read_to_string(&config).expect("read"), "newer"); + } + + #[test] + fn a_file_that_changes_during_the_check_is_not_replaced() { + let dir = tempfile::tempdir().expect("tempdir"); + let config = dir.path().join("composite.toml"); + std::fs::write(&config, "old").expect("write config"); + // Another program saves the file while the check runs. + let binary = fake_server( + dir.path(), + &format!("echo edited-elsewhere > '{}'", config.display()), + ); + + let error = save_checked(&binary, &config, "old", "new").expect_err("file changed"); + + assert!(error.contains(&config.display().to_string()), "{error}"); + assert_eq!( + std::fs::read_to_string(&config).expect("read"), + "edited-elsewhere\n" + ); + assert!(backups(dir.path()).is_empty()); + } + + #[test] + fn saving_through_a_symlink_replaces_the_linked_file() { + let dir = tempfile::tempdir().expect("tempdir"); + let real = dir.path().join("dotfiles.toml"); + let link = dir.path().join("composite.toml"); + std::fs::write(&real, "old").expect("write config"); + std::os::unix::fs::symlink(&real, &link).expect("symlink"); + let binary = fake_server(dir.path(), r#"echo "server OK: gateway""#); + + save_checked(&binary, &link, "old", "new").expect("saved"); + + let link_type = std::fs::symlink_metadata(&link) + .expect("metadata") + .file_type(); + assert!(link_type.is_symlink(), "the link is kept"); + assert_eq!(std::fs::read_to_string(&real).expect("read"), "new"); + } + + #[test] + fn reports_which_command_failed() { + let error = command("switchyard-does-not-exist", &[]).expect_err("missing program"); + + assert!(error.contains("switchyard-does-not-exist")); + } +} diff --git a/crates/switchyard-menubar/src/server_config.rs b/crates/switchyard-menubar/src/server_config.rs new file mode 100644 index 000000000..50896ff74 --- /dev/null +++ b/crates/switchyard-menubar/src/server_config.rs @@ -0,0 +1,1229 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +//! Reads the server config and rewrites one route's algorithm and models. +//! +//! Edits go through `toml_edit`, so comments, formatting, and every table the +//! edit does not touch stay as the user wrote them. The server's own +//! `--dry-run` decides whether the result is valid; this module only builds it. + +use std::collections::HashMap; + +use toml_edit::{DocumentMut, Item, Table, TableLike, Value}; + +/// The part a model plays in a route. A choice made for one algorithm carries +/// over to the role with the same tier when the user switches algorithms. +#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)] +pub enum Tier { + Judge, + Capable, + Efficient, +} + +/// A route `type` the picker can write. +#[derive(Debug)] +pub struct Algorithm { + pub kind: &'static str, + pub summary: &'static str, + /// Each role's label, tier, and the key path under the route that names + /// the role's target. The list is empty for `random`, whose roles are the + /// entries of its `targets` list. + roles: &'static [(&'static str, Tier, &'static [&'static str])], + /// Required settings written when a route switches to this type. They + /// match the values the routing docs use in their examples. + settings: &'static [(&'static [&'static str], Setting)], + /// Whether the type accepts a nested `subagents` policy. + subagents: bool, +} + +#[derive(Debug)] +enum Setting { + Text(&'static str), + Number(f64), +} + +impl Setting { + fn value(&self) -> Value { + match self { + Self::Text(text) => Value::from(*text), + Self::Number(number) => Value::from(*number), + } + } +} + +const RANDOM: &str = "random"; + +/// The route types from the routing docs, in the order the picker lists them. +/// `noop` and the experimental `prefill_router` are left out: one calls no +/// model, and the other needs a checkpoint file rather than models. +pub const ALGORITHMS: &[Algorithm] = &[ + Algorithm { + kind: "passthrough", + summary: "Sends every request to one model.", + roles: &[("Model", Tier::Capable, &["target"])], + settings: &[], + subagents: true, + }, + Algorithm { + kind: RANDOM, + summary: "Splits requests at random between the models, for A/B tests and baselines.", + roles: &[], + settings: &[], + subagents: false, + }, + Algorithm { + kind: "llm_classifier", + summary: "A judge model reads each request and picks the capable or the efficient model.", + roles: &[ + ("Judge", Tier::Judge, &["classifier_target"]), + ("Capable", Tier::Capable, &["strong_target"]), + ("Efficient", Tier::Efficient, &["weak_target"]), + ], + settings: &[ + (&["mode"], Setting::Text("capability")), + (&["base_threshold"], Setting::Number(0.5)), + ], + subagents: false, + }, + Algorithm { + kind: "composite", + summary: "A judge picks the default model on each user turn, and tool results move \ + requests between the capable and the efficient model.", + roles: &[ + ("Judge", Tier::Judge, &["classifier", "target"]), + ("Capable", Tier::Capable, &["stage", "capable_target"]), + ("Efficient", Tier::Efficient, &["stage", "efficient_target"]), + ], + settings: &[ + (&["classifier", "base_threshold"], Setting::Number(0.5)), + ( + &["classifier", "classify_trigger"], + Setting::Text("user_turn"), + ), + (&["stage", "confidence_threshold"], Setting::Number(0.5)), + ], + subagents: true, + }, + Algorithm { + kind: "stage_router", + summary: "Tool results and agent progress move each request between the capable and \ + the efficient model.", + roles: &[ + ("Capable", Tier::Capable, &["capable_target"]), + ("Efficient", Tier::Efficient, &["efficient_target"]), + ], + settings: &[ + (&["picker"], Setting::Text("efficient_first")), + (&["confidence_threshold"], Setting::Number(0.5)), + ], + subagents: true, + }, + Algorithm { + kind: "advisor", + summary: "The executor answers every turn, and a stronger advisor reviews its final \ + answers.", + roles: &[ + ("Executor", Tier::Efficient, &["executor_target"]), + ("Advisor", Tier::Capable, &["advisor_target"]), + ], + settings: &[], + subagents: false, + }, + Algorithm { + kind: "plan_execute", + summary: "The capable model inspects and plans. The efficient model takes over after \ + the first file edit.", + roles: &[ + ("Capable", Tier::Capable, &["capable_target"]), + ("Efficient", Tier::Efficient, &["efficient_target"]), + ], + settings: &[], + subagents: false, + }, + Algorithm { + kind: "auto", + summary: "Switchyard's recommended preset: a stage router that starts on the efficient \ + model.", + roles: &[ + ("Capable", Tier::Capable, &["capable_target"]), + ("Efficient", Tier::Efficient, &["efficient_target"]), + ], + settings: &[], + subagents: false, + }, +]; + +/// Route keys every type accepts. Switching types keeps these and removes +/// the rest, so settings of the old type cannot fail the new type's checks. +const COMMON_ROUTE_KEYS: [&str; 6] = [ + "id", + "type", + "context_window", + "tool_calling", + "reasoning", + "vision", +]; + +/// Target settings that change the request body. The server keeps one +/// target per model on a client, so it rejects two targets that name the +/// same model on the same client with different values for these. +const REQUEST_SETTINGS: [&str; 3] = ["omit_body_fields", "reasoning_effort", "extra_body"]; + +/// Target settings that a route takes on when it uses the target. +const TARGET_SETTINGS: [&str; 4] = [ + "system_prompt", + "reasoning_effort", + "extra_body", + "omit_body_fields", +]; + +/// One entry under `[routes]`. +#[derive(Clone, Debug, PartialEq)] +pub struct Route { + /// The route's table name in this file. Callers never send it. + pub key: String, + /// The public model ID that callers send. + pub id: String, + pub kind: String, +} + +/// One entry under `[llm_clients]`. +#[derive(Clone, Debug, PartialEq)] +pub struct Client { + pub name: String, + pub format: String, + pub base_url: String, + pub api_key_env: Option, + pub forward_auth: bool, +} + +/// A model on an LLM client, as a target names it. +#[derive(Clone, Debug, Default, Eq, PartialEq)] +pub struct Choice { + pub client: String, + pub model: String, +} + +/// A model the algorithm needs, such as the judge. +#[derive(Clone, Debug, PartialEq)] +pub struct Role { + pub label: String, + pub tier: Option, + slot: Slot, +} + +/// Where a route names a role's target. +#[derive(Clone, Debug, PartialEq)] +enum Slot { + /// A key path, such as `stage.capable_target`. + Key(&'static [&'static str]), + /// An entry of a random route's `targets` list. + Listed(usize), +} + +impl Slot { + /// Returns the slot's key path under the route, such as + /// `stage.capable_target`. + fn path(&self) -> String { + match self { + Self::Key(path) => path.join("."), + Self::Listed(_) => "targets".to_string(), + } + } +} + +/// The result of [`ServerConfig::edit`]. +#[derive(Debug)] +pub struct Edited { + /// The new config text. + pub text: String, + /// What the user should know about the edit, one sentence each. + pub notes: Vec, +} + +/// A parsed server config that keeps the original text's layout. +#[derive(Clone, Debug)] +pub struct ServerConfig { + doc: DocumentMut, +} + +impl ServerConfig { + pub fn parse(text: &str) -> Result { + text.parse::() + .map(|doc| Self { doc }) + .map_err(|error| { + format!( + "Could not parse the server config. Fix this TOML error and try again:\n\ + {error}" + ) + }) + } + + pub fn routes(&self) -> Vec { + entries(self.table("routes")) + .map(|(key, route)| Route { + key: key.to_string(), + id: text(route, "id").unwrap_or(key).to_string(), + kind: text(route, "type").unwrap_or_default().to_string(), + }) + .collect() + } + + pub fn clients(&self) -> Vec { + entries(self.table("llm_clients")) + .map(|(name, client)| Client { + name: name.to_string(), + format: text(client, "format").unwrap_or_default().to_string(), + base_url: text(client, "base_url").unwrap_or_default().to_string(), + api_key_env: text(client, "api_key_env").map(str::to_string), + forward_auth: client + .get("forward_auth") + .and_then(Item::as_bool) + .unwrap_or(false), + }) + .collect() + } + + /// Returns the algorithm a route uses now, or `None` when the picker + /// cannot show it, such as a custom-mode classifier whose groups are + /// free-form. + pub fn algorithm(&self, route: &str) -> Option<&'static Algorithm> { + let route = self.route(route)?; + if text(route, "mode") == Some("custom") { + return None; + } + let kind = text(route, "type")?; + ALGORITHMS.iter().find(|algorithm| algorithm.kind == kind) + } + + /// Returns the roles that `algorithm` needs on `route`. A random route + /// keeps its number of targets, and has at least two. + pub fn roles(&self, route: &str, algorithm: &Algorithm) -> Vec { + if algorithm.kind != RANDOM { + return algorithm + .roles + .iter() + .map(|(label, tier, path)| Role { + label: (*label).to_string(), + tier: Some(*tier), + slot: Slot::Key(path), + }) + .collect(); + } + let listed = self + .route(route) + .filter(|route| text(*route, "type") == Some(RANDOM)) + .and_then(|route| { + route + .get("targets")? + .as_array() + .map(|targets| targets.len()) + }) + .unwrap_or(0); + (0..listed.max(2)) + .map(|index| Role { + label: format!("Model {}", index + 1), + tier: [Tier::Capable, Tier::Efficient].get(index).copied(), + slot: Slot::Listed(index), + }) + .collect() + } + + /// Returns the model that each role of the route's current algorithm + /// uses. A role whose target is missing gets an empty choice. + pub fn choices(&self, route: &str) -> Vec { + let Some(algorithm) = self.algorithm(route) else { + return Vec::new(); + }; + self.roles(route, algorithm) + .iter() + .map(|role| { + self.target_at(route, &role.slot) + .and_then(|name| self.target(name)) + .unwrap_or_default() + }) + .collect() + } + + /// Returns the config text with `route` switched to `algorithm`, using + /// one choice per role, and notes about the edit. + /// + /// For each role, in order of preference: keep the route's target when it + /// already names the model; use another target that names exactly this + /// model on this client when the route would get the same target + /// settings from it, or when the server would reject a second target for + /// that model; change the route's target in place when no other route or + /// role uses it, including a role earlier in this edit; otherwise add a + /// new target that copies it. A changed or copied target keeps settings + /// such as `extra_body` and `omit_body_fields`, including settings meant + /// for the old model. Targets no route uses any more are left in the file. + pub fn edit( + &self, + route: &str, + algorithm: &Algorithm, + choices: &[Choice], + ) -> Result { + if self.route(route).is_none() { + return Err(format!("The config has no [routes.{route}] table.")); + } + let roles = self.roles(route, algorithm); + if roles.len() != choices.len() { + return Err(format!( + "{} needs {} models, not {}.", + algorithm.kind, + roles.len(), + choices.len() + )); + } + let clients = self.clients(); + for (role, choice) in roles.iter().zip(choices) { + if choice.model.trim().is_empty() { + return Err(format!("Pick a model for {}.", role.label)); + } + if choice.client.is_empty() { + return Err(format!("Pick an LLM client for {}.", role.label)); + } + if !clients.iter().any(|client| client.name == choice.client) { + return Err(format!( + "The config has no LLM client named \"{}\".", + choice.client + )); + } + } + + let current = self.algorithm(route); + let same_type = current.is_some_and(|current| current.kind == algorithm.kind); + let old_roles = current + .map(|current| self.roles(route, current)) + .unwrap_or_default(); + // When the type changes, each new role starts from the old role with + // the same tier, so a composite's capable target stays the capable one. + let by_tier: Vec<(Option, &str)> = old_roles + .iter() + .filter_map(|role| Some((role.tier, self.target_at(route, &role.slot)?))) + .collect(); + let mut users = self.target_users(); + + let mut doc = self.doc.clone(); + let mut names = Vec::with_capacity(roles.len()); + let mut notes = Vec::new(); + for (role, choice) in roles.iter().zip(choices) { + let existing = if same_type { + self.target_at(route, &role.slot) + } else { + role.tier.and_then(|tier| { + by_tier + .iter() + .find(|(old, _)| *old == Some(tier)) + .map(|(_, name)| *name) + }) + }; + let choice = Choice { + client: choice.client.clone(), + model: choice.model.trim().to_string(), + }; + let (name, moved_from) = + choose_target(&mut doc, &mut users, route, role, &choice, existing)?; + if let Some(old) = moved_from + && let Some(note) = kept_settings_note(&doc, &clients, &name, &old, &choice) + { + notes.push(note); + } + let mut others: Vec<&str> = users + .get(name.as_str()) + .into_iter() + .flatten() + .copied() + .filter(|other| *other != route) + .collect(); + others.dedup(); + if Some(name.as_str()) != existing && !others.is_empty() { + let plural = if others.len() == 1 { "" } else { "s" }; + notes.push(format!( + "{} now shares [targets.{name}] with route{plural} {}.", + role.label, + others.join(", ") + )); + } + names.push(name); + } + + let table = doc + .get_mut("routes") + .and_then(Item::as_table_like_mut) + .and_then(|routes| routes.get_mut(route)) + .and_then(Item::as_table_like_mut) + .ok_or_else(|| format!("The config has no [routes.{route}] table."))?; + if !same_type { + let old_slots: Vec = old_roles.iter().map(|role| role.slot.path()).collect(); + let removed = reset_route(table, algorithm, &old_slots); + if !removed.is_empty() { + notes.push(format!( + "Switching to {} removed these settings from [routes.{route}]: {}. The \ + backup still has them.", + algorithm.kind, + removed.join(", ") + )); + } + } + for (role, name) in roles.iter().zip(&names) { + set_slot(table, &role.slot, name); + } + if !same_type { + for (path, setting) in algorithm.settings { + set_at(table, path, setting.value()); + } + } + Ok(Edited { + text: doc.to_string(), + notes, + }) + } + + fn table(&self, name: &str) -> Option<&dyn TableLike> { + self.doc.get(name)?.as_table_like() + } + + fn route(&self, route: &str) -> Option<&dyn TableLike> { + self.table("routes")?.get(route)?.as_table_like() + } + + fn target(&self, name: &str) -> Option { + let target = self.table("targets")?.get(name)?.as_table_like()?; + Some(Choice { + client: text(target, "llm_client")?.to_string(), + model: text(target, "id")?.to_string(), + }) + } + + fn target_at(&self, route: &str, slot: &Slot) -> Option<&str> { + let route = self.route(route)?; + match slot { + Slot::Key(path) => { + let (last, parents) = path.split_last()?; + let mut table = route; + for key in parents { + table = table.get(key)?.as_table_like()?; + } + text(table, last) + } + Slot::Listed(index) => route.get("targets")?.as_array()?.get(*index)?.as_str(), + } + } + + /// Returns, for each target name, the routes that name it: one entry per + /// mention in any route, nested policies included. A target with one + /// entry belongs to one role. + fn target_users(&self) -> HashMap<&str, Vec<&str>> { + let mut users: HashMap<&str, Vec<&str>> = entries(self.table("targets")) + .map(|(name, _)| (name, Vec::new())) + .collect(); + for (route, item) in self + .table("routes") + .into_iter() + .flat_map(|routes| routes.iter()) + { + visit_strings(item, &mut |text| { + if let Some(routes) = users.get_mut(text) { + routes.push(route); + } + }); + } + users + } +} + +/// Picks or writes the target that serves `choice` for one role. Returns its +/// name, and the model that the target named before when the edit changed +/// the target in place or copied it. Reusing a target adds the route to the +/// target's users, so a later role in the same edit copies that target +/// instead of changing it in place. +fn choose_target<'a>( + doc: &mut DocumentMut, + users: &mut HashMap<&'a str, Vec<&'a str>>, + route: &'a str, + role: &Role, + choice: &Choice, + existing: Option<&str>, +) -> Result<(String, Option), String> { + let targets = doc + .get_mut("targets") + .and_then(Item::as_table_like_mut) + .ok_or("The config has no [targets] table.")?; + + let current = existing + .and_then(|name| targets.get(name)) + .and_then(Item::as_table_like); + if let Some(name) = existing + && current.is_some_and(|target| names_choice(target, choice)) + { + return Ok((name.to_string(), None)); + } + let old = current.and_then(|target| { + Some(Choice { + client: text(target, "llm_client")?.to_string(), + model: text(target, "id")?.to_string(), + }) + }); + if let Some(name) = reusable(&*targets, current, choice).map(str::to_string) { + if let Some(routes) = users.get_mut(name.as_str()) { + routes.push(route); + } + return Ok((name, None)); + } + if let Some(name) = existing + && users.get(name).is_some_and(|routes| routes.len() == 1) + && let Some(target) = targets.get_mut(name).and_then(Item::as_table_like_mut) + { + set_value(target, "id", Value::from(choice.model.as_str())); + set_value(target, "llm_client", Value::from(choice.client.as_str())); + return Ok((name.to_string(), old)); + } + + let mut table = Table::new(); + if let Some(source) = existing + .and_then(|name| targets.get(name)) + .and_then(Item::as_table_like) + { + for (key, item) in source.iter() { + let mut item = item.clone(); + unplace(&mut item); + table.insert(key, item); + } + } + set_value(&mut table, "id", Value::from(choice.model.as_str())); + set_value( + &mut table, + "llm_client", + Value::from(choice.client.as_str()), + ); + let base = format!("{route}_{}", role.label.to_lowercase().replace(' ', "_")); + let mut name = base.clone(); + let mut suffix = 1; + while targets.contains_key(&name) { + suffix += 1; + name = format!("{base}_{suffix}"); + } + targets.insert(&name, Item::Table(table)); + Ok((name, old)) +} + +/// Returns a note about the request settings that a target kept when the +/// edit moved it from `old` to `new`, a model of another family or on a +/// client with another request format. A setting meant for the old model can +/// make the provider reject the new model's requests, and `--dry-run` does +/// not catch that. So the note lists the kept settings, or says that the +/// target has none, and the user decides. +fn kept_settings_note( + doc: &DocumentMut, + clients: &[Client], + name: &str, + old: &Choice, + new: &Choice, +) -> Option { + let format = |name: &str| { + clients + .iter() + .find(|client| client.name == name) + .map(|client| client.format.as_str()) + }; + if family(&old.model) == family(&new.model) && format(&old.client) == format(&new.client) { + return None; + } + let target = doc + .get("targets")? + .as_table_like()? + .get(name)? + .as_table_like()?; + let kept: Vec = REQUEST_SETTINGS + .iter() + .filter_map(|key| Some(format!("{key} = {}", setting(target, key)?))) + .collect(); + Some(if kept.is_empty() { + format!( + "[targets.{name}] now names {} and has no omit_body_fields, reasoning_effort, or \ + extra_body. Check whether the new model needs one of them.", + new.model + ) + } else { + format!( + "[targets.{name}] now names {} and kept {} from {}. Check that the new model \ + accepts these settings.", + new.model, + kept.join(", "), + old.model + ) + }) +} + +/// Returns a model's family: the first word of the last part of its ID, +/// such as `gpt` for `openai/gpt-5.6-sol`. +fn family(model: &str) -> String { + let name = model.rsplit('/').next().unwrap_or(model); + name.split(['-', '.', '_', ':']) + .next() + .unwrap_or(name) + .to_lowercase() +} + +/// Returns another target that names `choice` and that a role whose target +/// is `current` may use. The role takes on that target's settings. So it +/// uses a target with the same settings as `current`, or else a target whose +/// request settings differ from `current`, because the server would reject a +/// second target for this model with other request settings. +fn reusable<'t>( + targets: &'t dyn TableLike, + current: Option<&dyn TableLike>, + choice: &Choice, +) -> Option<&'t str> { + let same = |target: &dyn TableLike, keys: &[&str]| { + keys.iter() + .all(|key| setting(target, key) == current.and_then(|current| setting(current, key))) + }; + let candidates: Vec<(&str, &dyn TableLike)> = targets + .iter() + .filter_map(|(name, target)| Some((name, target.as_table_like()?))) + .filter(|(_, target)| names_choice(*target, choice)) + .collect(); + candidates + .iter() + .find(|(_, target)| same(*target, &TARGET_SETTINGS)) + .or_else(|| { + candidates + .iter() + .find(|(_, target)| !same(*target, &REQUEST_SETTINGS)) + }) + .map(|(name, _)| *name) +} + +/// Returns a target's setting as plain data, so the same value written in +/// another layout compares equal. +fn setting(target: &dyn TableLike, key: &str) -> Option { + let mut table = Table::new(); + table.insert(key, target.get(key)?.clone()); + DocumentMut::from(table) + .to_string() + .parse::() + .ok()? + .remove(key) +} + +/// Clears the file position of every table in `item`. A copied sub-table +/// then follows the header of the table it is copied into, not the header +/// of the table it came from. +fn unplace(item: &mut Item) { + if let Some(table) = item.as_table_mut() { + table.set_position(None); + for (_, child) in table.iter_mut() { + unplace(child); + } + } +} + +/// Returns whether a target names exactly this model on this client. +fn names_choice(target: &dyn TableLike, choice: &Choice) -> bool { + text(target, "llm_client") == Some(choice.client.as_str()) + && text(target, "id") == Some(choice.model.as_str()) +} + +/// Removes the old type's settings and sets the new `type`. Returns the +/// removed settings as key paths, such as `classifier.base_threshold`. The +/// list leaves out `old_slots`, the old roles' targets, which the new roles +/// replace. +fn reset_route( + route: &mut dyn TableLike, + algorithm: &Algorithm, + old_slots: &[String], +) -> Vec { + let kept = + |key: &str| COMMON_ROUTE_KEYS.contains(&key) || (algorithm.subagents && key == "subagents"); + let stale: Vec = route + .iter() + .map(|(key, _)| key) + .filter(|key| !kept(key)) + .map(str::to_string) + .collect(); + let mut removed = Vec::new(); + for key in stale { + if let Some(item) = route.remove(&key) { + key_paths(&key, &item, &mut removed); + } + } + removed.retain(|path| !old_slots.contains(path)); + set_value(route, "type", Value::from(algorithm.kind)); + removed +} + +/// Adds the key path of every value in `item` to `paths`. +fn key_paths(path: &str, item: &Item, paths: &mut Vec) { + match item.as_table_like() { + Some(table) => { + for (key, child) in table.iter() { + key_paths(&format!("{path}.{key}"), child, paths); + } + } + None => paths.push(path.to_string()), + } +} + +fn set_slot(route: &mut dyn TableLike, slot: &Slot, name: &str) { + match slot { + Slot::Key(path) => set_at(route, path, Value::from(name)), + Slot::Listed(index) => { + if !route.get("targets").is_some_and(Item::is_array) { + route.insert("targets", toml_edit::value(toml_edit::Array::new())); + } + if let Some(targets) = route.get_mut("targets").and_then(Item::as_array_mut) { + if *index < targets.len() { + targets.replace(*index, name); + } else { + targets.push(name); + } + } + } + } +} + +/// Sets a value at a key path, creating missing tables on the way. +fn set_at(table: &mut dyn TableLike, path: &[&str], value: Value) { + match path { + [] => {} + [key] => set_value(table, key, value), + [key, rest @ ..] => { + if !table.get(key).is_some_and(Item::is_table_like) { + table.insert(key, Item::Table(Table::new())); + } + if let Some(child) = table.get_mut(key).and_then(Item::as_table_like_mut) { + set_at(child, rest, value); + } + } + } +} + +/// Replaces a value, keeping the spacing and trailing comment around it. +fn set_value(table: &mut dyn TableLike, key: &str, mut value: Value) { + if let Some(old) = table.get_mut(key).and_then(Item::as_value_mut) { + *value.decor_mut() = old.decor().clone(); + *old = value; + } else { + table.insert(key, Item::Value(value)); + } +} + +fn entries(table: Option<&dyn TableLike>) -> impl Iterator { + table + .into_iter() + .flat_map(|table| table.iter()) + .filter_map(|(key, item)| Some((key, item.as_table_like()?))) +} + +fn text<'a>(table: &'a dyn TableLike, key: &str) -> Option<&'a str> { + table.get(key)?.as_str() +} + +fn visit_strings(item: &Item, visit: &mut dyn FnMut(&str)) { + match item { + Item::Value(value) => visit_value(value, visit), + Item::Table(table) => table + .iter() + .for_each(|(_, item)| visit_strings(item, visit)), + Item::ArrayOfTables(tables) => tables + .iter() + .flat_map(Table::iter) + .for_each(|(_, item)| visit_strings(item, visit)), + Item::None => {} + } +} + +fn visit_value(value: &Value, visit: &mut dyn FnMut(&str)) { + match value { + Value::String(text) => visit(text.value()), + Value::Array(array) => array.iter().for_each(|value| visit_value(value, visit)), + Value::InlineTable(table) => table + .iter() + .for_each(|(_, value)| visit_value(value, visit)), + _ => {} + } +} + +#[cfg(test)] +mod tests { + use super::*; + + /// A composite route with a GPT judge on a Responses client and Claude on + /// a Chat Completions client, both on one gateway. + const GATEWAY: &str = r#"schema_version = 1 + +[llm_clients.gateway] +format = "openai_responses" +base_url = "https://gateway.example.com/v1" +forward_auth = true + +# Claude caches prompts on the chat endpoint. +[llm_clients.gateway_chat] +format = "openai_chat" +base_url = "https://gateway.example.com/v1" +forward_auth = true + +# The gateway rejects any thinking setting for these models. +[targets.gateway_capable] +id = "claude-opus-5-5" +llm_client = "gateway_chat" # chat, for caching +omit_body_fields = ["reasoning_effort"] + +[targets.gateway_efficient] +id = "claude-sonnet-5" +llm_client = "gateway_chat" +omit_body_fields = ["reasoning_effort"] + +[targets.gateway_judge] +id = "gpt-5.6-terra" +llm_client = "gateway" + +[routes.gateway] +id = "switchyard-gateway" +type = "composite" +context_window = 200000 + +[routes.gateway.classifier] +target = "gateway_judge" +base_threshold = 0.5 +classify_trigger = "user_turn" +message_hash_fallback = true + +[routes.gateway.stage] +capable_target = "gateway_capable" +efficient_target = "gateway_efficient" +confidence_threshold = 0.5 +"#; + + fn choice(client: &str, model: &str) -> Choice { + Choice { + client: client.to_string(), + model: model.to_string(), + } + } + + fn algorithm(kind: &str) -> &'static Algorithm { + ALGORITHMS + .iter() + .find(|algorithm| algorithm.kind == kind) + .expect("known algorithm") + } + + /// Edits the config and checks the result with the server's parser. + fn edit(text: &str, route: &str, kind: &str, choices: &[Choice]) -> Edited { + let edited = ServerConfig::parse(text) + .expect("parse") + .edit(route, algorithm(kind), choices) + .expect("edit"); + if let Err(error) = switchyard_runner::Runner::from_toml(&edited.text) { + panic!( + "the server rejects the edited config: {error}\n{}", + edited.text + ); + } + edited + } + + fn capable_target(config: &ServerConfig) -> Option<&str> { + config.target_at("gateway", &Slot::Key(&["stage", "capable_target"])) + } + + #[test] + fn every_algorithm_writes_a_config_the_server_accepts() { + let gateway = ServerConfig::parse(GATEWAY).expect("parse"); + let models = [ + choice("gateway", "gpt-5.6-terra"), + choice("gateway", "gpt-5.6-sol"), + choice("gateway_chat", "claude-sonnet-5"), + ]; + for algorithm in ALGORITHMS { + let roles = gateway.roles("gateway", algorithm).len(); + + let edited = edit(GATEWAY, "gateway", algorithm.kind, &models[3 - roles..]); + + let config = ServerConfig::parse(&edited.text).expect("parse"); + assert_eq!( + config.algorithm("gateway").map(|a| a.kind), + Some(algorithm.kind) + ); + assert_eq!(config.choices("gateway"), models[3 - roles..]); + } + } + + #[test] + fn a_target_one_role_reuses_is_not_changed_for_another_role() { + // Capable moves to the efficient model, and Efficient to a new one. + let chosen = [ + choice("gateway", "gpt-5.6-terra"), + choice("gateway_chat", "claude-sonnet-5"), + choice("gateway", "gpt-5.6-luna"), + ]; + + let edited = edit(GATEWAY, "gateway", "composite", &chosen); + + assert_eq!( + ServerConfig::parse(&edited.text) + .expect("parse") + .choices("gateway"), + chosen + ); + } + + #[test] + fn switching_models_and_back_restores_the_file() { + let gpt = edit( + GATEWAY, + "gateway", + "composite", + &[ + choice("gateway", "gpt-5.6-terra"), + choice("gateway", "gpt-5.6-sol"), + choice("gateway", "gpt-5.6-luna"), + ], + ) + .text; + + let config = ServerConfig::parse(&gpt).expect("parse"); + assert_eq!( + config.choices("gateway"), + [ + choice("gateway", "gpt-5.6-terra"), + choice("gateway", "gpt-5.6-sol"), + choice("gateway", "gpt-5.6-luna"), + ] + ); + assert!( + gpt.contains( + "[targets.gateway_capable]\nid = \"gpt-5.6-sol\"\nllm_client = \"gateway\" # chat, for caching\nomit_body_fields = [\"reasoning_effort\"]" + ), + "the target keeps its other settings and comments:\n{gpt}" + ); + + let claude = edit( + &gpt, + "gateway", + "composite", + &[ + choice("gateway", "gpt-5.6-terra"), + choice("gateway_chat", "claude-opus-5-5"), + choice("gateway_chat", "claude-sonnet-5"), + ], + ); + assert_eq!(claude.text, GATEWAY); + } + + #[test] + fn changing_the_algorithm_replaces_only_the_route_settings() { + let edited = edit( + GATEWAY, + "gateway", + "stage_router", + &[ + choice("gateway_chat", "claude-opus-5-5"), + choice("gateway_chat", "claude-sonnet-5"), + ], + ); + + let config = ServerConfig::parse(&edited.text).expect("parse"); + assert_eq!( + config.algorithm("gateway").map(|a| a.kind), + Some("stage_router") + ); + let route = config.route("gateway").expect("route"); + assert_eq!(text(route, "id"), Some("switchyard-gateway")); + assert_eq!( + route.get("context_window").and_then(Item::as_integer), + Some(200000) + ); + assert_eq!(text(route, "capable_target"), Some("gateway_capable")); + assert_eq!(text(route, "efficient_target"), Some("gateway_efficient")); + assert_eq!(text(route, "picker"), Some("efficient_first")); + assert!(route.get("classifier").is_none() && route.get("stage").is_none()); + assert!( + edited.text.contains("[targets.gateway_judge]"), + "unused targets stay in the file" + ); + let removed = edited.notes.join("\n"); + assert!( + removed.contains("classifier.message_hash_fallback") + && !removed.contains("stage.capable_target"), + "the result lists the removed settings, not the replaced targets: {removed}" + ); + } + + #[test] + fn a_target_another_route_uses_is_copied_not_changed() { + // The shared target has a sub-table, which the copy must keep under + // its own header. + let shared = GATEWAY.replace( + "omit_body_fields = [\"reasoning_effort\"]\n\n[targets.gateway_efficient]", + "omit_body_fields = [\"reasoning_effort\"]\n\n[targets.gateway_capable.extra_body]\n\ + top_k = 5\n\n[targets.gateway_efficient]", + ) + "\n[routes.direct]\nid = \"direct\"\ntype = \"passthrough\"\ntarget = \"gateway_capable\"\n"; + + let edited = edit( + &shared, + "gateway", + "composite", + &[ + choice("gateway", "gpt-5.6-terra"), + choice("gateway", "gpt-5.6-sol"), + choice("gateway_chat", "claude-sonnet-5"), + ], + ); + + let config = ServerConfig::parse(&edited.text).expect("parse"); + assert_eq!( + config.choices("direct"), + [choice("gateway_chat", "claude-opus-5-5")] + ); + assert_eq!( + config.choices("gateway")[1], + choice("gateway", "gpt-5.6-sol") + ); + let copy = capable_target(&config).expect("capable target"); + assert_ne!(copy, "gateway_capable"); + let targets = config.table("targets").expect("targets"); + let (source, copied) = ( + targets.get("gateway_capable").and_then(Item::as_table_like), + targets.get(copy).and_then(Item::as_table_like), + ); + for key in TARGET_SETTINGS { + assert_eq!( + source.and_then(|target| setting(target, key)), + copied.and_then(|target| setting(target, key)), + "the copy keeps {key}" + ); + } + let header = |name: &str| edited.text.find(&format!("[{name}]")).expect(name); + assert!( + header(&format!("targets.{copy}")) < header(&format!("targets.{copy}.extra_body")), + "the copied sub-table follows the copy's header:\n{}", + edited.text + ); + } + + #[test] + fn uses_another_routes_target_only_when_the_route_keeps_its_settings_or_must_share() { + // `prompted` names the model that Capable moves to, and has a + // system prompt that the gateway route does not have. + for (omit, shared) in [ + // Same request settings: the server accepts a second target for + // the model, so Capable keeps its own target. + ("omit_body_fields = [\"reasoning_effort\"]\n", false), + // Other request settings: the server would reject a second + // target, so Capable shares `prompted`, and the result says so. + ("", true), + ] { + let text = format!( + "{GATEWAY}\n[targets.prompted]\nid = \"gpt-5.6-sol\"\nllm_client = \"gateway\"\n\ + system_prompt = \"You answer for route other.\"\n{omit}\n[routes.other]\n\ + id = \"other\"\ntype = \"passthrough\"\ntarget = \"prompted\"\n" + ); + + let edited = edit( + &text, + "gateway", + "composite", + &[ + choice("gateway", "gpt-5.6-terra"), + choice("gateway", "gpt-5.6-sol"), + choice("gateway_chat", "claude-sonnet-5"), + ], + ); + + let config = ServerConfig::parse(&edited.text).expect("parse"); + assert_eq!( + config.choices("gateway")[1], + choice("gateway", "gpt-5.6-sol") + ); + assert_eq!( + capable_target(&config) == Some("prompted"), + shared, + "{omit}" + ); + assert_eq!( + edited + .notes + .iter() + .any(|note| note.contains("prompted") && note.contains("other")), + shared, + "{:?}", + edited.notes + ); + } + } + + #[test] + fn a_move_to_another_model_family_reports_the_settings_the_target_kept() { + let gpt = edit( + GATEWAY, + "gateway", + "composite", + &[ + choice("gateway", "gpt-5.6-terra"), + choice("gateway", "gpt-5.6-sol"), + choice("gateway", "gpt-5.6-luna"), + ], + ) + .text; + + // The judge target has no request settings, and the capable target + // keeps omit_body_fields from its GPT model. Efficient stays. + let edited = edit( + &gpt, + "gateway", + "composite", + &[ + choice("gateway_chat", "claude-haiku-4-5"), + choice("gateway_chat", "claude-opus-5-5"), + choice("gateway", "gpt-5.6-luna"), + ], + ); + + let note = |target: &str| { + edited + .notes + .iter() + .find(|note| note.contains(&format!("[targets.{target}]"))) + .cloned() + }; + let capable = note("gateway_capable").expect("a note for the capable target"); + assert!( + capable.contains("claude-opus-5-5") + && capable.contains(r#"omit_body_fields = ["reasoning_effort"]"#), + "{capable}" + ); + let judge = note("gateway_judge").expect("a note for the judge target"); + assert!( + judge.contains("claude-haiku-4-5") && !judge.contains(" = "), + "{judge}" + ); + assert_eq!(note("gateway_efficient"), None); + } + + #[test] + fn rejects_a_missing_model_or_an_unknown_client() { + let config = ServerConfig::parse(GATEWAY).expect("parse"); + for (capable, named) in [ + (choice("gateway", " "), "Capable"), + (choice("typo", "gpt-5.6-sol"), "typo"), + ] { + let error = config + .edit( + "gateway", + algorithm("composite"), + &[ + choice("gateway", "gpt-5.6-terra"), + capable, + choice("gateway", "gpt-5.6-luna"), + ], + ) + .expect_err("invalid choice"); + + assert!(error.contains(named), "{error}"); + } + } +} diff --git a/crates/switchyard-menubar/src/summary.rs b/crates/switchyard-menubar/src/summary.rs new file mode 100644 index 000000000..ca5896122 --- /dev/null +++ b/crates/switchyard-menubar/src/summary.rs @@ -0,0 +1,278 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +//! Turns totals and prices into the rows the menu shows. + +use crate::config::Config; +use crate::health::ServerStatus; +use crate::pricing::{Savings, estimate}; +use crate::rollup::{Totals, Usage}; + +/// Most model rows shown, so the menu stays short. +const MAX_MODEL_ROWS: usize = 5; + +/// One line of the menu. Rows are informational; the actions are fixed. +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum Row { + Separator, + Label(String), +} + +fn label(text: impl Into) -> Row { + Row::Label(text.into()) +} + +/// Builds the menu body for the current day and trailing week. +pub fn build(status: ServerStatus, usage: &Usage, config: &Config) -> Vec { + let state = match status { + ServerStatus::Running => "running", + ServerStatus::Stopped => "not responding", + }; + let mut rows = vec![ + label(format!("Server: {state} · {}", config.server_url)), + Row::Separator, + ]; + + if usage.week.is_empty() { + rows.push(label("No requests recorded yet")); + return rows; + } + + rows.extend(period("Today", &usage.today, config)); + rows.push(Row::Separator); + rows.extend(period("This week", &usage.week, config)); + + let models = model_shares(&usage.week); + if !models.is_empty() { + rows.push(Row::Separator); + rows.push(label("This week by model")); + rows.extend(models); + } + + if config.prices.is_empty() { + rows.push(Row::Separator); + rows.push(label("Add prices to menubar.toml to see savings")); + } + rows +} + +fn period(name: &str, totals: &Totals, config: &Config) -> Vec { + let requests = totals.requests(); + let mut rows = vec![label(format!( + "{name} — {requests} request{} · {} tokens", + if requests == 1 { "" } else { "s" }, + tokens(totals.tokens()) + ))]; + if let Some(savings) = estimate(totals, &config.prices, &config.baseline_model) { + rows.push(label(format!(" Saved {}", saved(&savings)))); + } + rows +} + +fn saved(savings: &Savings) -> String { + let amount = money(savings.saved()); + match savings.percent() { + Some(percent) => format!("{amount} ({percent:.0}% of {})", money(savings.baseline)), + None => amount, + } +} + +fn model_shares(week: &Totals) -> Vec { + let total: u64 = week.routed.values().map(|tokens| tokens.total()).sum(); + if total == 0 { + return Vec::new(); + } + let mut models: Vec<_> = week + .routed + .iter() + .map(|(model, tokens)| (model, tokens.total())) + .collect(); + // Largest share first; model id breaks ties so the order is stable. + models.sort_by(|left, right| right.1.cmp(&left.1).then_with(|| left.0.cmp(right.0))); + models + .into_iter() + .take(MAX_MODEL_ROWS) + .map(|(model, count)| { + label(format!( + " {model} — {:.0}%", + count as f64 / total as f64 * 100.0 + )) + }) + .collect() +} + +/// Formats a token count compactly, since exact totals are not useful here. +fn tokens(value: u64) -> String { + match value { + 0..=9_999 => value.to_string(), + 10_000..=999_999 => format!("{:.0}K", value as f64 / 1_000.0), + 1_000_000..=999_999_999 => format!("{:.1}M", value as f64 / 1_000_000.0), + _ => format!("{:.1}B", value as f64 / 1_000_000_000.0), + } +} + +/// Formats dollars, keeping the sign readable when routing cost more than it saved. +fn money(value: f64) -> String { + if value < 0.0 { + format!("-${:.2}", -value) + } else { + format!("${value:.2}") + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::pricing::{ModelPrice, PriceTable}; + use crate::rollup::ModelTokens; + + fn counted(input: u64, output: u64, requests: u64) -> ModelTokens { + ModelTokens { + requests, + input, + cached_input: 0, + output, + } + } + + fn config(prices: PriceTable) -> Config { + Config { + baseline_model: "sol".to_string(), + prices, + ..Config::default() + } + } + + fn priced() -> PriceTable { + PriceTable::from([ + ( + "sol".to_string(), + ModelPrice { + input_per_mtok: 1.25, + cached_input_per_mtok: None, + output_per_mtok: 10.0, + }, + ), + ( + "luna".to_string(), + ModelPrice { + input_per_mtok: 0.25, + cached_input_per_mtok: None, + output_per_mtok: 2.0, + }, + ), + ]) + } + + fn labels(rows: &[Row]) -> Vec { + rows.iter() + .filter_map(|row| match row { + Row::Label(text) => Some(text.clone()), + Row::Separator => None, + }) + .collect() + } + + fn usage_of(today: Totals, week: Totals) -> Usage { + Usage { today, week } + } + + #[test] + fn shows_requests_tokens_and_savings_for_both_periods() { + let mut today = Totals::default(); + today + .routed + .insert("luna".to_string(), counted(1_000_000, 100_000, 3)); + let mut week = Totals::default(); + week.routed + .insert("luna".to_string(), counted(4_000_000, 400_000, 12)); + + let rows = build( + ServerStatus::Running, + &usage_of(today, week), + &config(priced()), + ); + let labels = labels(&rows); + + assert_eq!(labels[0], "Server: running · http://127.0.0.1:4123"); + assert_eq!(labels[1], "Today — 3 requests · 1.1M tokens"); + assert_eq!(labels[2], " Saved $1.80 (80% of $2.25)"); + assert_eq!(labels[3], "This week — 12 requests · 4.4M tokens"); + assert_eq!(labels[4], " Saved $7.20 (80% of $9.00)"); + } + + #[test] + fn hides_savings_and_explains_why_when_prices_are_missing() { + let mut week = Totals::default(); + week.routed + .insert("luna".to_string(), counted(1_000, 100, 1)); + + let rows = build( + ServerStatus::Running, + &usage_of(week.clone(), week), + &config(PriceTable::new()), + ); + let labels = labels(&rows); + + assert!(labels.iter().all(|label| !label.contains("Saved"))); + assert!(labels.iter().any(|label| label.contains("menubar.toml"))); + } + + #[test] + fn ranks_models_by_token_share() { + let mut week = Totals::default(); + week.routed + .insert("luna".to_string(), counted(700_000, 0, 7)); + week.routed + .insert("sol".to_string(), counted(300_000, 0, 3)); + + let rows = build( + ServerStatus::Running, + &usage_of(Totals::default(), week), + &config(priced()), + ); + let models: Vec<_> = labels(&rows) + .into_iter() + .filter(|label| label.starts_with(" ") && label.ends_with('%')) + .collect(); + + assert_eq!(models, vec![" luna — 70%", " sol — 30%"]); + } + + #[test] + fn reports_a_stopped_server_with_no_traffic() { + let rows = build(ServerStatus::Stopped, &Usage::default(), &config(priced())); + let labels = labels(&rows); + + assert!(labels[0].starts_with("Server: not responding")); + assert_eq!(labels[1], "No requests recorded yet"); + } + + #[test] + fn shows_a_loss_when_routing_overhead_exceeds_the_saving() { + let mut today = Totals::default(); + today.routed.insert("sol".to_string(), counted(1_000, 0, 1)); + today + .classifier + .insert("sol".to_string(), counted(1_000_000, 0, 1)); + + let rows = build( + ServerStatus::Running, + &usage_of(today.clone(), today), + &config(priced()), + ); + + assert!(labels(&rows)[2].starts_with(" Saved -$1.25")); + } + + #[test] + fn formats_tokens_and_money_for_reading() { + assert_eq!(tokens(412), "412"); + assert_eq!(tokens(12_345), "12K"); + assert_eq!(tokens(1_400_000), "1.4M"); + assert_eq!(tokens(9_700_000_000), "9.7B"); + + assert_eq!(money(2.181), "$2.18"); + assert_eq!(money(-0.125), "-$0.12"); + } +} diff --git a/crates/switchyard-menubar/src/tray.rs b/crates/switchyard-menubar/src/tray.rs new file mode 100644 index 000000000..5de887944 --- /dev/null +++ b/crates/switchyard-menubar/src/tray.rs @@ -0,0 +1,169 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +//! The macOS status item: event loop, menu, and the menu's actions. +//! +//! Everything platform-specific lives here and in the picker window, so the +//! rest of the crate builds and is tested on any target. + +use std::path::Path; +use std::time::{Duration, Instant}; + +use objc2_app_kit::{NSApplication, NSApplicationActivationPolicy, NSEventMask}; +use objc2_foundation::{MainThreadMarker, NSDate, NSDefaultRunLoopMode}; +use tray_icon::menu::{IsMenuItem, Menu, MenuEvent, MenuItem, PredefinedMenuItem, Submenu}; +use tray_icon::{Icon, TrayIconBuilder}; + +use crate::app::refresh; +use crate::config::Config; +use crate::icon; +use crate::models::CACHE_FILE; +use crate::picker::Picker; +use crate::server::{command, restart}; +use crate::summary::Row; + +const CHANGE_ROUTING: &str = "change-routing"; +const RESTART: &str = "restart-server"; +const OPEN_CONFIG: &str = "open-config"; +const OPEN_SETTINGS: &str = "open-settings"; +const QUIT: &str = "quit"; + +/// How long the loop blocks waiting for a UI event before checking the clock. +const EVENT_POLL: f64 = 0.1; + +/// Runs the status item until the user quits. `settings` is the settings +/// file that `config` came from; the model list cache sits next to it. +pub fn run(config: Config, settings: &Path) -> Result<(), String> { + let mtm = MainThreadMarker::new().ok_or("the menu bar must run on the main thread")?; + let ns_app = NSApplication::sharedApplication(mtm); + // Accessory keeps the process out of the Dock and the app switcher. + ns_app.setActivationPolicy(NSApplicationActivationPolicy::Accessory); + // An accessory app shows no menu bar, but AppKit still finds keyboard + // shortcuts, such as Cmd-C in a text field and Cmd-W in the picker + // window, through the app's main menu. + let main_menu = main_menu()?; + main_menu.init_for_nsapp(); + + let glyph = Icon::from_rgba(icon::glyph(), icon::SIZE, icon::SIZE) + .map_err(|error| format!("build icon: {error}"))?; + let tray = TrayIconBuilder::new() + .with_icon(glyph) + .with_icon_as_template(true) + .with_tooltip("Switchyard") + .with_menu(Box::new(menu(&refresh(&config))?)) + .build() + .map_err(|error| format!("create status item: {error}"))?; + + ns_app.finishLaunching(); + + let mut picker: Option = None; + let interval = Duration::from_secs(config.refresh_seconds.max(5)); + let mut next = Instant::now() + interval; + loop { + pump_events(&ns_app); + if let Some(picker) = picker.as_mut() { + picker.poll(); + } + + let mut redraw = false; + while let Ok(event) = MenuEvent::receiver().try_recv() { + match event.id.as_ref() { + QUIT => return Ok(()), + CHANGE_ROUTING => picker + .get_or_insert_with(|| { + Picker::new(mtm, &config, settings.with_file_name(CACHE_FILE)) + }) + .show(), + RESTART => report(restart(&config.launchd_label)), + OPEN_CONFIG => report(open(&config.config_file)), + OPEN_SETTINGS => report(open(&Config::default_path())), + // Informational rows are disabled, so nothing else fires. + _ => continue, + } + redraw = true; + } + + if redraw || Instant::now() >= next { + tray.set_menu(Some(Box::new(menu(&refresh(&config))?))); + next = Instant::now() + interval; + } + } +} + +/// Opens a config file in the user's editor. +fn open(path: &std::path::Path) -> Result<(), String> { + command("open", &["-t", &path.display().to_string()]).map(|_| ()) +} + +fn report(result: Result<(), String>) { + if let Err(error) = result { + eprintln!("switchyard-menubar: {error}"); + } +} + +/// Builds the whole menu: the summary rows, then the fixed actions. +fn menu(rows: &[Row]) -> Result { + let menu = Menu::new(); + let append = |item: &dyn IsMenuItem| { + menu.append(item) + .map_err(|error| format!("build menu: {error}")) + }; + + for row in rows { + match row { + Row::Separator => append(&PredefinedMenuItem::separator())?, + // Rows are labels, not commands, so they are disabled. + Row::Label(text) => append(&MenuItem::new(text, false, None))?, + } + } + append(&PredefinedMenuItem::separator())?; + for (id, text) in [ + (CHANGE_ROUTING, "Change routing…"), + (RESTART, "Restart server"), + (OPEN_CONFIG, "Open server config…"), + (OPEN_SETTINGS, "Open menu bar settings…"), + (QUIT, "Quit"), + ] { + append(&MenuItem::with_id(id, text, true, None))?; + } + Ok(menu) +} + +/// Builds the app's hidden main menu, which gives text fields their Edit +/// shortcuts and windows their Cmd-W shortcut. +fn main_menu() -> Result { + let edit = Submenu::with_items( + "Edit", + true, + &[ + &PredefinedMenuItem::undo(None), + &PredefinedMenuItem::redo(None), + &PredefinedMenuItem::separator(), + &PredefinedMenuItem::cut(None), + &PredefinedMenuItem::copy(None), + &PredefinedMenuItem::paste(None), + &PredefinedMenuItem::select_all(None), + ], + ) + .map_err(|error| format!("build the Edit menu: {error}"))?; + let window = Submenu::with_items("Window", true, &[&PredefinedMenuItem::close_window(None)]) + .map_err(|error| format!("build the Window menu: {error}"))?; + Menu::with_items(&[&edit, &window]).map_err(|error| format!("build the main menu: {error}")) +} + +/// Drains pending AppKit events, blocking briefly when there are none. +fn pump_events(ns_app: &NSApplication) { + let mut expiration = Some(NSDate::dateWithTimeIntervalSinceNow(EVENT_POLL)); + while let Some(event) = unsafe { + ns_app.nextEventMatchingMask_untilDate_inMode_dequeue( + NSEventMask::Any, + expiration.as_deref(), + NSDefaultRunLoopMode, + true, + ) + } { + ns_app.sendEvent(&event); + // Only the first wait blocks; the rest drain what is already queued. + expiration = Some(NSDate::distantPast()); + } +} diff --git a/scripts/macos/common.sh b/scripts/macos/common.sh new file mode 100644 index 000000000..3d5e265bd --- /dev/null +++ b/scripts/macos/common.sh @@ -0,0 +1,50 @@ +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 +# +# Paths, markers, and helpers shared by install.sh and uninstall.sh. +# The markers must match between the two, which is why they live here. + +SY_HOME="${SY_HOME:-$HOME/.switchyard}" +SY_PORT="${SY_PORT:-4123}" +SERVER_LABEL="com.nvidia.switchyard.server" +MENUBAR_LABEL="com.nvidia.switchyard.menubar" +LAUNCH_AGENTS="$HOME/Library/LaunchAgents" +CODEX_DIR="${CODEX_HOME:-$HOME/.codex}" +CODEX_CONFIG="$CODEX_DIR/config.toml" +# Codex reads `--profile sy` from its own file next to config.toml. A +# [profiles.sy] table in config.toml is rejected outright as legacy config. +CODEX_PROFILE_CONFIG="$CODEX_DIR/sy.config.toml" +# Codex.app ignores profiles entirely, so it gets a whole config.toml with the +# routing inlined, plus a snapshot of the unrouted one to swap back to. +CODEX_SWITCHYARD_CONFIG="$CODEX_DIR/config.toml.sy" +CODEX_DIRECT_CONFIG="$CODEX_DIR/config.toml.direct" +ALIAS_START="# >>> switchyard codex alias >>>" +ALIAS_END="# <<< switchyard codex alias <<<" +PROFILE_START="# >>> switchyard sy profile >>>" +PROFILE_END="# <<< switchyard sy profile <<<" + +say() { printf '%s\n' "$*"; } +step() { printf '\n==> %s\n' "$*"; } + +# Deletes the marked block, inclusive, leaving the rest of the file alone. +strip_block() { + local path="$1" start="$2" end="$3" label="${4:-the switchyard block}" + if [[ ! -f "$path" ]] || ! grep -qF "$start" "$path"; then + return 1 + fi + if (( DRY_RUN )); then + say " would remove $label from $path" + return 0 + fi + local temp + temp="$(mktemp)" + awk -v start="$start" -v end="$end" ' + index($0, start) { skipping = 1 } + !skipping { print } + index($0, end) { skipping = 0 } + ' "$path" > "$temp" + cat "$temp" > "$path" + rm -f "$temp" + say " removed $label from $path" + return 0 +} diff --git a/scripts/macos/install.sh b/scripts/macos/install.sh new file mode 100755 index 000000000..873f853bb --- /dev/null +++ b/scripts/macos/install.sh @@ -0,0 +1,370 @@ +#!/usr/bin/env bash +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 +# +# Installs the Switchyard background server and its menu bar companion as +# per-user LaunchAgents, sets up a `sy` Codex profile, and aliases `codex` to +# use it. +# +# Every step is idempotent and never overwrites a file you have edited. +# Run with --dry-run to print what would happen. + +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +REPO_ROOT="$(cd "$SCRIPT_DIR/../.." && pwd)" + +DRY_RUN=0 +[[ "${1:-}" == "--dry-run" ]] && DRY_RUN=1 + +# shellcheck source=scripts/macos/common.sh +source "$SCRIPT_DIR/common.sh" + +# Runs a command, or prints it when dry running. +run() { + if (( DRY_RUN )); then + say " would run: $*" + else + "$@" + fi +} + +# Writes stdin to a file, leaving an existing file untouched. +write_once() { + local path="$1" + if [[ -f "$path" ]]; then + say " keeping existing $path" + cat >/dev/null + return + fi + if (( DRY_RUN )); then + say " would create $path" + cat >/dev/null + else + mkdir -p "$(dirname "$path")" + cat > "$path" + say " created $path" + fi +} + +# Keeps a timestamped copy of a file this script is about to replace. +back_up() { + local path="$1" + [[ -f "$path" ]] || return 0 + if (( DRY_RUN )); then + say " would back up $path" + else + cp "$path" "$path.switchyard-backup.$(date +%Y%m%d%H%M%S)" + say " backed up $path" + fi +} + +# Builds a whole Codex config that routes through Switchyard, derived from the +# config you use now. Codex.app cannot use the sy profile, so it gets a second +# config.toml to swap in instead. See the crate README for why. +# +# Regenerating from an already-swapped config.toml yields the same file, since +# the keys this adds are stripped before they are written back. +write_codex_switchyard_config() { + local source="$1" target="$2" + if [[ ! -f "$source" ]]; then + say " no $source to derive from, skipping" + return + fi + if (( DRY_RUN )); then + say " would write $target" + return + fi + + local body + body="$(mktemp)" + # Drop what this file replaces: the top-level model selection, any + # model_provider, and an earlier copy of the provider table. + awk ' + BEGIN { preamble = 1; skip_table = 0 } + /^[[:space:]]*\[/ { + preamble = 0 + skip_table = ($0 ~ /^[[:space:]]*\[model_providers\.sy\][[:space:]]*\]?[[:space:]]*$/) + } + preamble && /^[[:space:]]*(model|model_provider)[[:space:]]*=/ { next } + !skip_table { print } + ' "$source" > "$body" + + { + echo "# Generated by the Switchyard installer. Do not edit; edit config.toml" + echo "# and reinstall, and this file is rebuilt from it." + echo "#" + echo "# This is config.toml with Switchyard routing inlined, for Codex.app," + echo "# which cannot use the sy profile. Swap it in and out with:" + echo "# cp ~/.codex/config.toml.sy ~/.codex/config.toml # route" + echo "# cp ~/.codex/config.toml.direct ~/.codex/config.toml # stop routing" + echo "model = \"switchyard\"" + echo "model_provider = \"sy\"" + cat "$body" + echo + echo "[model_providers.sy]" + echo "name = \"Switchyard\"" + echo "base_url = \"http://127.0.0.1:$SY_PORT/v1\"" + echo "wire_api = \"responses\"" + echo "requires_openai_auth = true" + } > "$target" + rm -f "$body" + say " wrote $target" +} + +# Writes stdin to a file, replacing it. Used only for files this script owns. +write_file() { + local path="$1" + if (( DRY_RUN )); then + say " would write $path" + cat >/dev/null + else + mkdir -p "$(dirname "$path")" + cat > "$path" + say " wrote $path" + fi +} + +if [[ "$(uname -s)" != "Darwin" ]]; then + say "This installer is for macOS only." >&2 + exit 1 +fi + +step "Building release binaries" +run cargo build --release --manifest-path "$REPO_ROOT/Cargo.toml" \ + -p switchyard-server -p switchyard-menubar + +step "Installing binaries into $SY_HOME/bin" +run mkdir -p "$SY_HOME/bin" "$SY_HOME/logs" +for binary in switchyard-server switchyard-menubar; do + run install -m 755 "$REPO_ROOT/target/release/$binary" "$SY_HOME/bin/$binary" +done + +step "Writing server config" +# The composite router from examples/run_codex.sh: Terra classifies each user +# turn and sets the tier, Stage drives the tool loop underneath it. +write_once "$SY_HOME/composite.toml" <<'EOF' +schema_version = 1 + +[llm_clients.chatgpt_backend] +format = "openai_responses" +base_url = "https://chatgpt.com/backend-api/codex" +forward_auth = true + +[targets.capable] +id = "gpt-5.6-sol" +llm_client = "chatgpt_backend" + +[targets.efficient] +id = "gpt-5.6-luna" +llm_client = "chatgpt_backend" + +# chatgpt.com/backend-api/codex is Codex CLI's own private endpoint, not the +# public OpenAI Responses API. It 400s unless store=false and stream=true are +# set explicitly, and it rejects max_output_tokens outright, so the +# classifier's own token cap has to be dropped before the request goes out. +[targets.terra] +id = "gpt-5.6-terra" +llm_client = "chatgpt_backend" +extra_body = { store = false, stream = true } +omit_body_fields = ["max_output_tokens"] + +[routes.switchyard] +id = "switchyard" +type = "composite" + +[routes.switchyard.classifier] +target = "terra" +base_threshold = 0.5 +classify_trigger = "user_turn" + +[routes.switchyard.stage] +capable_target = "capable" +efficient_target = "efficient" +confidence_threshold = 0.5 +EOF + +step "Writing menu bar settings" +# Rates are list prices per million tokens and are only used to estimate +# savings. Edit them to match what you actually pay. +write_once "$SY_HOME/menubar.toml" < + + + + Label + $SERVER_LABEL + ProgramArguments + + $SY_HOME/bin/switchyard-server + --config + $SY_HOME/composite.toml + --host + 127.0.0.1 + --port + $SY_PORT + --routing-log-file + $SY_HOME/routing.jsonl + + RunAtLoad + + KeepAlive + + ProcessType + Background + StandardOutPath + $SY_HOME/logs/server.log + StandardErrorPath + $SY_HOME/logs/server.err.log + EnvironmentVariables + + RUST_LOG + info + + + +EOF + +# SuccessfulExit false means the Quit menu item stays quit, while a crash is +# still restarted. Aqua-only, since there is no menu bar without a login session. +write_file "$LAUNCH_AGENTS/$MENUBAR_LABEL.plist" < + + + + Label + $MENUBAR_LABEL + ProgramArguments + + $SY_HOME/bin/switchyard-menubar + $SY_HOME/menubar.toml + + RunAtLoad + + KeepAlive + + SuccessfulExit + + + LimitLoadToSessionType + Aqua + StandardOutPath + $SY_HOME/logs/menubar.log + StandardErrorPath + $SY_HOME/logs/menubar.err.log + + +EOF + +step "Loading LaunchAgents" +for label in "$SERVER_LABEL" "$MENUBAR_LABEL"; do + if (( DRY_RUN )); then + say " would reload gui/$UID/$label" + continue + fi + launchctl bootout "gui/$UID/$label" 2>/dev/null || true + # bootout returns before the job leaves the domain, and bootstrapping a + # service that is still shutting down fails with an I/O error. + for _ in $(seq 50); do + launchctl print "gui/$UID/$label" >/dev/null 2>&1 || break + sleep 0.1 + done + launchctl bootstrap "gui/$UID" "$LAUNCH_AGENTS/$label.plist" + say " loaded $label" +done + +step "Adding the sy Codex profile" +# `codex --profile sy` reads ~/.codex/sy.config.toml. A [profiles.sy] table in +# config.toml is legacy config that Codex now refuses to start with, so an +# earlier install of this script has to be cleaned up first. +strip_block "$CODEX_CONFIG" "$PROFILE_START" "$PROFILE_END" "the legacy sy profile" || true + +# This profile only changes which router answers. Approval and sandbox +# settings are deliberately left out, so the profile cannot loosen how Codex +# asks before it acts. Set those yourself if you want them. +back_up "$CODEX_PROFILE_CONFIG" +write_file "$CODEX_PROFILE_CONFIG" <> "$rc" + say " added the codex alias to $rc" + fi +done + +step "Done" +say "Server: http://127.0.0.1:$SY_PORT (logs in $SY_HOME/logs)" +say "Settings: $SY_HOME/menubar.toml" +say "Look for the Switchyard glyph in the menu bar." +say "Open a new shell, or run: alias codex=\"codex --profile sy\"" +say "" +say "To route Codex.app too, swap its config:" +say " cp $CODEX_SWITCHYARD_CONFIG $CODEX_CONFIG" +say " cp $CODEX_DIRECT_CONFIG $CODEX_CONFIG # to stop routing" +say "Restart Codex.app after swapping." diff --git a/scripts/macos/uninstall.sh b/scripts/macos/uninstall.sh new file mode 100755 index 000000000..6532d7839 --- /dev/null +++ b/scripts/macos/uninstall.sh @@ -0,0 +1,79 @@ +#!/usr/bin/env bash +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 +# +# Removes what install.sh added: the two LaunchAgents, the `sy` Codex profile, +# and the codex alias. Your config, routing log, and binaries stay put; the +# paths are printed so you can delete them yourself. +# +# Run with --dry-run to print what would happen. + +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" + +DRY_RUN=0 +[[ "${1:-}" == "--dry-run" ]] && DRY_RUN=1 + +# shellcheck source=scripts/macos/common.sh +source "$SCRIPT_DIR/common.sh" + +# Deletes a file this script owns. +remove_file() { + local path="$1" + if [[ ! -e "$path" ]]; then + say " nothing to remove at $path" + elif (( DRY_RUN )); then + say " would delete $path" + else + rm -f "$path" + say " deleted $path" + fi +} + +step "Unloading LaunchAgents" +for label in "$SERVER_LABEL" "$MENUBAR_LABEL"; do + if (( DRY_RUN )); then + say " would unload gui/$UID/$label and delete its plist" + else + launchctl bootout "gui/$UID/$label" 2>/dev/null || true + rm -f "$LAUNCH_AGENTS/$label.plist" + say " unloaded $label" + fi +done + +step "Removing the sy Codex profile" +remove_file "$CODEX_PROFILE_CONFIG" +remove_file "$CODEX_SWITCHYARD_CONFIG" +# Older installs of this script put the profile in config.toml instead. +strip_block "$CODEX_CONFIG" "$PROFILE_START" "$PROFILE_END" "the legacy sy profile" || + say " no legacy profile in $CODEX_CONFIG" + +step "Unrouting Codex.app" +# Leaving a routed config.toml behind would point Codex.app at a server that +# is no longer running, so put the snapshot back before the agents go away. +if [[ -f "$CODEX_CONFIG" ]] && grep -qE '^[[:space:]]*model_provider[[:space:]]*=[[:space:]]*"sy"' "$CODEX_CONFIG"; then + if [[ ! -f "$CODEX_DIRECT_CONFIG" ]]; then + say " config.toml is routed but $CODEX_DIRECT_CONFIG is missing; edit it by hand" + elif (( DRY_RUN )); then + say " would restore $CODEX_DIRECT_CONFIG over config.toml" + else + cp "$CODEX_DIRECT_CONFIG" "$CODEX_CONFIG" + rm -f "$CODEX_DIRECT_CONFIG" + say " restored the unrouted config.toml" + fi +else + remove_file "$CODEX_DIRECT_CONFIG" +fi + +step "Removing the codex alias" +for rc in "$HOME/.zshrc" "$HOME/.bashrc"; do + strip_block "$rc" "$ALIAS_START" "$ALIAS_END" "the codex alias" || + say " no alias in $rc" +done + +step "Done" +say "Left in place, delete them if you want:" +say " $SY_HOME (binaries, config, routing log, model lists, logs)" +say " $CODEX_CONFIG.switchyard-backup.* (backups taken at install time)" +say " Keychain items named \"Switchyard model list\" (keys saved from Change routing…; delete them in Keychain Access)"