diff --git a/Cargo.lock b/Cargo.lock index b57fd5c9..69efd41c 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1745,7 +1745,7 @@ dependencies = [ [[package]] name = "collections" version = "0.1.0" -source = "git+https://github.com/zed-industries/zed?rev=1a246efd7e1b83ab568ec5e3e6c1a43a42e1abba#1a246efd7e1b83ab568ec5e3e6c1a43a42e1abba" +source = "git+https://github.com/zed-industries/zed?rev=cc053a4a6fa2fd0e8793201ed9099466af1be0b1#cc053a4a6fa2fd0e8793201ed9099466af1be0b1" dependencies = [ "gpui_util", "indexmap 2.14.0", @@ -2456,7 +2456,7 @@ dependencies = [ [[package]] name = "derive_refineable" version = "0.1.0" -source = "git+https://github.com/zed-industries/zed?rev=1a246efd7e1b83ab568ec5e3e6c1a43a42e1abba#1a246efd7e1b83ab568ec5e3e6c1a43a42e1abba" +source = "git+https://github.com/zed-industries/zed?rev=cc053a4a6fa2fd0e8793201ed9099466af1be0b1#cc053a4a6fa2fd0e8793201ed9099466af1be0b1" dependencies = [ "proc-macro2", "quote", @@ -2536,7 +2536,7 @@ dependencies = [ "libc", "option-ext", "redox_users 0.5.2", - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] @@ -2816,7 +2816,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]] @@ -4444,7 +4444,7 @@ dependencies = [ [[package]] name = "gpui" version = "0.2.2" -source = "git+https://github.com/zed-industries/zed?rev=1a246efd7e1b83ab568ec5e3e6c1a43a42e1abba#1a246efd7e1b83ab568ec5e3e6c1a43a42e1abba" +source = "git+https://github.com/zed-industries/zed?rev=cc053a4a6fa2fd0e8793201ed9099466af1be0b1#cc053a4a6fa2fd0e8793201ed9099466af1be0b1" dependencies = [ "accesskit", "anyhow", @@ -4513,6 +4513,7 @@ dependencies = [ "sum_tree", "taffy", "thiserror 2.0.18", + "tracing", "ttf-parser", "url", "usvg 0.46.0", @@ -4523,12 +4524,13 @@ dependencies = [ "windows 0.61.3", "zed-font-kit", "zed-scap", + "ztracing", ] [[package]] name = "gpui-component" version = "0.5.2" -source = "git+https://github.com/stippi/gpui-component?rev=1a66dc72424ae70300aae2ee02b04c4ce4e71d5a#1a66dc72424ae70300aae2ee02b04c4ce4e71d5a" +source = "git+https://github.com/stippi/gpui-component?rev=abeab54c#abeab54cb264742aea8ab57705109fa49389e27b" dependencies = [ "aho-corasick", "anyhow", @@ -4576,7 +4578,7 @@ dependencies = [ [[package]] name = "gpui-component-assets" version = "0.5.1" -source = "git+https://github.com/stippi/gpui-component?rev=1a66dc72424ae70300aae2ee02b04c4ce4e71d5a#1a66dc72424ae70300aae2ee02b04c4ce4e71d5a" +source = "git+https://github.com/stippi/gpui-component?rev=abeab54c#abeab54cb264742aea8ab57705109fa49389e27b" dependencies = [ "anyhow", "gpui", @@ -4590,7 +4592,7 @@ dependencies = [ [[package]] name = "gpui-component-macros" version = "0.5.1" -source = "git+https://github.com/stippi/gpui-component?rev=1a66dc72424ae70300aae2ee02b04c4ce4e71d5a#1a66dc72424ae70300aae2ee02b04c4ce4e71d5a" +source = "git+https://github.com/stippi/gpui-component?rev=abeab54c#abeab54cb264742aea8ab57705109fa49389e27b" dependencies = [ "proc-macro2", "quote", @@ -4600,7 +4602,7 @@ dependencies = [ [[package]] name = "gpui_linux" version = "0.1.0" -source = "git+https://github.com/zed-industries/zed?rev=1a246efd7e1b83ab568ec5e3e6c1a43a42e1abba#1a246efd7e1b83ab568ec5e3e6c1a43a42e1abba" +source = "git+https://github.com/zed-industries/zed?rev=cc053a4a6fa2fd0e8793201ed9099466af1be0b1#cc053a4a6fa2fd0e8793201ed9099466af1be0b1" dependencies = [ "accesskit", "accesskit_unix", @@ -4622,6 +4624,7 @@ dependencies = [ "itertools 0.14.0", "libc", "log", + "notify-rust", "oo7", "open", "parking_lot", @@ -4651,13 +4654,14 @@ dependencies = [ [[package]] name = "gpui_macos" version = "0.1.0" -source = "git+https://github.com/zed-industries/zed?rev=1a246efd7e1b83ab568ec5e3e6c1a43a42e1abba#1a246efd7e1b83ab568ec5e3e6c1a43a42e1abba" +source = "git+https://github.com/zed-industries/zed?rev=cc053a4a6fa2fd0e8793201ed9099466af1be0b1#cc053a4a6fa2fd0e8793201ed9099466af1be0b1" dependencies = [ "accesskit", "accesskit_macos", "anyhow", "async-task", "block", + "block2 0.6.2", "cbindgen", "cocoa 0.26.0", "collections", @@ -4685,6 +4689,7 @@ dependencies = [ "objc2 0.6.4", "objc2-app-kit 0.3.2", "objc2-foundation 0.3.2", + "objc2-user-notifications", "parking_lot", "pathfinder_geometry", "raw-window-handle", @@ -4698,7 +4703,7 @@ dependencies = [ [[package]] name = "gpui_macros" version = "0.1.0" -source = "git+https://github.com/zed-industries/zed?rev=1a246efd7e1b83ab568ec5e3e6c1a43a42e1abba#1a246efd7e1b83ab568ec5e3e6c1a43a42e1abba" +source = "git+https://github.com/zed-industries/zed?rev=cc053a4a6fa2fd0e8793201ed9099466af1be0b1#cc053a4a6fa2fd0e8793201ed9099466af1be0b1" dependencies = [ "heck 0.5.0", "proc-macro2", @@ -4709,7 +4714,7 @@ dependencies = [ [[package]] name = "gpui_platform" version = "0.1.0" -source = "git+https://github.com/zed-industries/zed?rev=1a246efd7e1b83ab568ec5e3e6c1a43a42e1abba#1a246efd7e1b83ab568ec5e3e6c1a43a42e1abba" +source = "git+https://github.com/zed-industries/zed?rev=cc053a4a6fa2fd0e8793201ed9099466af1be0b1#cc053a4a6fa2fd0e8793201ed9099466af1be0b1" dependencies = [ "console_error_panic_hook", "gpui", @@ -4722,7 +4727,7 @@ dependencies = [ [[package]] name = "gpui_shared_string" version = "0.1.0" -source = "git+https://github.com/zed-industries/zed?rev=1a246efd7e1b83ab568ec5e3e6c1a43a42e1abba#1a246efd7e1b83ab568ec5e3e6c1a43a42e1abba" +source = "git+https://github.com/zed-industries/zed?rev=cc053a4a6fa2fd0e8793201ed9099466af1be0b1#cc053a4a6fa2fd0e8793201ed9099466af1be0b1" dependencies = [ "schemars 1.2.1", "serde", @@ -4732,7 +4737,7 @@ dependencies = [ [[package]] name = "gpui_util" version = "0.1.0" -source = "git+https://github.com/zed-industries/zed?rev=1a246efd7e1b83ab568ec5e3e6c1a43a42e1abba#1a246efd7e1b83ab568ec5e3e6c1a43a42e1abba" +source = "git+https://github.com/zed-industries/zed?rev=cc053a4a6fa2fd0e8793201ed9099466af1be0b1#cc053a4a6fa2fd0e8793201ed9099466af1be0b1" dependencies = [ "anyhow", "log", @@ -4742,7 +4747,7 @@ dependencies = [ [[package]] name = "gpui_web" version = "0.1.0" -source = "git+https://github.com/zed-industries/zed?rev=1a246efd7e1b83ab568ec5e3e6c1a43a42e1abba#1a246efd7e1b83ab568ec5e3e6c1a43a42e1abba" +source = "git+https://github.com/zed-industries/zed?rev=cc053a4a6fa2fd0e8793201ed9099466af1be0b1#cc053a4a6fa2fd0e8793201ed9099466af1be0b1" dependencies = [ "anyhow", "console_error_panic_hook", @@ -4754,7 +4759,6 @@ dependencies = [ "log", "parking_lot", "raw-window-handle", - "smallvec", "uuid", "wasm-bindgen", "wasm-bindgen-futures", @@ -4766,7 +4770,7 @@ dependencies = [ [[package]] name = "gpui_wgpu" version = "0.1.0" -source = "git+https://github.com/zed-industries/zed?rev=1a246efd7e1b83ab568ec5e3e6c1a43a42e1abba#1a246efd7e1b83ab568ec5e3e6c1a43a42e1abba" +source = "git+https://github.com/zed-industries/zed?rev=cc053a4a6fa2fd0e8793201ed9099466af1be0b1#cc053a4a6fa2fd0e8793201ed9099466af1be0b1" dependencies = [ "anyhow", "bytemuck", @@ -4784,6 +4788,7 @@ dependencies = [ "raw-window-handle", "smallvec", "swash", + "unicode-bidi", "unicode-segmentation", "wasm-bindgen", "wasm-bindgen-futures", @@ -4795,7 +4800,7 @@ dependencies = [ [[package]] name = "gpui_windows" version = "0.1.0" -source = "git+https://github.com/zed-industries/zed?rev=1a246efd7e1b83ab568ec5e3e6c1a43a42e1abba#1a246efd7e1b83ab568ec5e3e6c1a43a42e1abba" +source = "git+https://github.com/zed-industries/zed?rev=cc053a4a6fa2fd0e8793201ed9099466af1be0b1#cc053a4a6fa2fd0e8793201ed9099466af1be0b1" dependencies = [ "accesskit", "accesskit_windows", @@ -5154,7 +5159,7 @@ dependencies = [ [[package]] name = "http_client" version = "0.1.0" -source = "git+https://github.com/zed-industries/zed?rev=1a246efd7e1b83ab568ec5e3e6c1a43a42e1abba#1a246efd7e1b83ab568ec5e3e6c1a43a42e1abba" +source = "git+https://github.com/zed-industries/zed?rev=cc053a4a6fa2fd0e8793201ed9099466af1be0b1#cc053a4a6fa2fd0e8793201ed9099466af1be0b1" dependencies = [ "anyhow", "async-compression", @@ -5313,7 +5318,7 @@ dependencies = [ "js-sys", "log", "wasm-bindgen", - "windows-core 0.57.0", + "windows-core 0.62.2", ] [[package]] @@ -5751,7 +5756,7 @@ dependencies = [ "portable-atomic", "portable-atomic-util", "serde_core", - "windows-sys 0.52.0", + "windows-sys 0.61.2", ] [[package]] @@ -6288,6 +6293,20 @@ version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c41e0c4fef86961ac6d6f8a82609f55f31b05e4fce149ac5710e439df7619ba4" +[[package]] +name = "mac-notification-sys" +version = "0.6.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fd604973958ddcc11b561193c0fb96ba146506ef2f231ef2e7c35fd2cbc9beca" +dependencies = [ + "cc", + "log", + "objc2 0.6.4", + "objc2-foundation 0.3.2", + "time", + "uuid", +] + [[package]] name = "mach2" version = "0.5.0" @@ -6477,7 +6496,7 @@ checksum = "490cc448043f947bae3cbee9c203358d62dbee0db12107a74be5c30ccfd09771" [[package]] name = "media" version = "0.1.0" -source = "git+https://github.com/zed-industries/zed?rev=1a246efd7e1b83ab568ec5e3e6c1a43a42e1abba#1a246efd7e1b83ab568ec5e3e6c1a43a42e1abba" +source = "git+https://github.com/zed-industries/zed?rev=cc053a4a6fa2fd0e8793201ed9099466af1be0b1#cc053a4a6fa2fd0e8793201ed9099466af1be0b1" dependencies = [ "anyhow", "bindgen", @@ -6794,6 +6813,20 @@ dependencies = [ "windows-sys 0.52.0", ] +[[package]] +name = "notify-rust" +version = "4.18.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c5b4c1b4f2aa9f25f63a7a49d3dd0ed567b3670da15330a66b29434be899b891" +dependencies = [ + "futures-lite 2.6.1", + "log", + "mac-notification-sys", + "serde", + "tauri-winrt-notification", + "zbus 5.15.0", +] + [[package]] name = "notify-types" version = "1.0.1" @@ -6818,7 +6851,7 @@ version = "0.50.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5" dependencies = [ - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] @@ -7124,6 +7157,16 @@ dependencies = [ "objc2-foundation 0.3.2", ] +[[package]] +name = "objc2-core-location" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ca347214e24bc973fc025fd0d36ebb179ff30536ed1f80252706db19ee452009" +dependencies = [ + "objc2 0.6.4", + "objc2-foundation 0.3.2", +] + [[package]] name = "objc2-core-text" version = "0.3.2" @@ -7255,6 +7298,19 @@ dependencies = [ "objc2-metal 0.3.2", ] +[[package]] +name = "objc2-user-notifications" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9df9128cbbfef73cda168416ccf7f837b62737d748333bfe9ab71c245d76613e" +dependencies = [ + "bitflags 2.11.1", + "block2 0.6.2", + "objc2 0.6.4", + "objc2-core-location", + "objc2-foundation 0.3.2", +] + [[package]] name = "objc_exception" version = "0.1.2" @@ -7552,7 +7608,7 @@ checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" [[package]] name = "perf" version = "0.1.0" -source = "git+https://github.com/zed-industries/zed?rev=1a246efd7e1b83ab568ec5e3e6c1a43a42e1abba#1a246efd7e1b83ab568ec5e3e6c1a43a42e1abba" +source = "git+https://github.com/zed-industries/zed?rev=cc053a4a6fa2fd0e8793201ed9099466af1be0b1#cc053a4a6fa2fd0e8793201ed9099466af1be0b1" dependencies = [ "collections", "serde", @@ -8311,7 +8367,7 @@ dependencies = [ "once_cell", "socket2 0.6.3", "tracing", - "windows-sys 0.52.0", + "windows-sys 0.61.2", ] [[package]] @@ -8630,7 +8686,7 @@ dependencies = [ [[package]] name = "refineable" version = "0.1.0" -source = "git+https://github.com/zed-industries/zed?rev=1a246efd7e1b83ab568ec5e3e6c1a43a42e1abba#1a246efd7e1b83ab568ec5e3e6c1a43a42e1abba" +source = "git+https://github.com/zed-industries/zed?rev=cc053a4a6fa2fd0e8793201ed9099466af1be0b1#cc053a4a6fa2fd0e8793201ed9099466af1be0b1" dependencies = [ "derive_refineable", ] @@ -9002,7 +9058,7 @@ dependencies = [ "errno", "libc", "linux-raw-sys 0.12.1", - "windows-sys 0.52.0", + "windows-sys 0.61.2", ] [[package]] @@ -9184,7 +9240,7 @@ dependencies = [ [[package]] name = "scheduler" version = "0.1.0" -source = "git+https://github.com/zed-industries/zed?rev=1a246efd7e1b83ab568ec5e3e6c1a43a42e1abba#1a246efd7e1b83ab568ec5e3e6c1a43a42e1abba" +source = "git+https://github.com/zed-industries/zed?rev=cc053a4a6fa2fd0e8793201ed9099466af1be0b1#cc053a4a6fa2fd0e8793201ed9099466af1be0b1" dependencies = [ "async-task", "backtrace", @@ -10122,7 +10178,7 @@ checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" [[package]] name = "sum_tree" version = "0.1.0" -source = "git+https://github.com/zed-industries/zed?rev=1a246efd7e1b83ab568ec5e3e6c1a43a42e1abba#1a246efd7e1b83ab568ec5e3e6c1a43a42e1abba" +source = "git+https://github.com/zed-industries/zed?rev=cc053a4a6fa2fd0e8793201ed9099466af1be0b1#cc053a4a6fa2fd0e8793201ed9099466af1be0b1" dependencies = [ "heapless 0.9.3", "log", @@ -10382,9 +10438,9 @@ dependencies = [ [[package]] name = "taffy" -version = "0.12.1" +version = "0.12.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "73afc801dd6bd47529eaa7c7e90557f107527d1b7c9c7ed7d7803c7b8d0c357f" +checksum = "340a09581f29809fc0df82a3955501dc7f2a21f887e5d1c13dbe288fe1c0bef4" dependencies = [ "arrayvec", "grid", @@ -10404,6 +10460,17 @@ dependencies = [ "objc", ] +[[package]] +name = "tauri-winrt-notification" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ed071c670382e85fc2f48ae706492d8c338f4f89bf72520d32f8abfe880aade" +dependencies = [ + "thiserror 2.0.18", + "windows 0.61.3", + "windows-version", +] + [[package]] name = "temp-env" version = "0.3.6" @@ -10424,7 +10491,7 @@ dependencies = [ "getrandom 0.4.2", "once_cell", "rustix 1.1.4", - "windows-sys 0.52.0", + "windows-sys 0.61.2", ] [[package]] @@ -11517,7 +11584,7 @@ checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" [[package]] name = "util_macros" version = "0.1.0" -source = "git+https://github.com/zed-industries/zed?rev=1a246efd7e1b83ab568ec5e3e6c1a43a42e1abba#1a246efd7e1b83ab568ec5e3e6c1a43a42e1abba" +source = "git+https://github.com/zed-industries/zed?rev=cc053a4a6fa2fd0e8793201ed9099466af1be0b1#cc053a4a6fa2fd0e8793201ed9099466af1be0b1" dependencies = [ "perf", "quote", @@ -11796,8 +11863,7 @@ dependencies = [ [[package]] name = "wasm_thread" version = "0.3.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b7516db7f32decdadb1c3b8deb1b7d78b9df7606c5cc2f6241737c2ab3a0258e" +source = "git+https://github.com/zed-industries/wasm_thread?rev=0cf96c7708dfb97ccf3da50347e25edcf75d6937#0cf96c7708dfb97ccf3da50347e25edcf75d6937" dependencies = [ "futures", "js-sys", @@ -12045,6 +12111,7 @@ dependencies = [ "thiserror 2.0.18", "wgpu-core-deps-apple", "wgpu-core-deps-emscripten", + "wgpu-core-deps-wasm", "wgpu-core-deps-windows-linux-android", "wgpu-hal", "wgpu-naga-bridge", @@ -12069,6 +12136,15 @@ dependencies = [ "wgpu-hal", ] +[[package]] +name = "wgpu-core-deps-wasm" +version = "29.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "af1fb1798be2a912497d4c224f72d39bb0cb34af50e8bcc29865bc339c943059" +dependencies = [ + "wgpu-hal", +] + [[package]] name = "wgpu-core-deps-windows-linux-android" version = "29.0.4" @@ -12224,7 +12300,7 @@ 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.48.0", ] [[package]] @@ -12677,6 +12753,15 @@ dependencies = [ "windows-link 0.2.1", ] +[[package]] +name = "windows-version" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e4060a1da109b9d0326b7262c8e12c84df67cc0dbc9e33cf49e01ccc2eb63631" +dependencies = [ + "windows-link 0.2.1", +] + [[package]] name = "windows_aarch64_gnullvm" version = "0.48.5" @@ -13635,7 +13720,7 @@ checksum = "3be3d40e40a133f9c916ee3f9f4fa2d9d63435b5fbe1bfc6d9dae0aa0ada1513" [[package]] name = "zlog" version = "0.1.0" -source = "git+https://github.com/zed-industries/zed?rev=1a246efd7e1b83ab568ec5e3e6c1a43a42e1abba#1a246efd7e1b83ab568ec5e3e6c1a43a42e1abba" +source = "git+https://github.com/zed-industries/zed?rev=cc053a4a6fa2fd0e8793201ed9099466af1be0b1#cc053a4a6fa2fd0e8793201ed9099466af1be0b1" dependencies = [ "anyhow", "chrono", @@ -13692,7 +13777,7 @@ dependencies = [ [[package]] name = "ztracing" version = "0.1.0" -source = "git+https://github.com/zed-industries/zed?rev=1a246efd7e1b83ab568ec5e3e6c1a43a42e1abba#1a246efd7e1b83ab568ec5e3e6c1a43a42e1abba" +source = "git+https://github.com/zed-industries/zed?rev=cc053a4a6fa2fd0e8793201ed9099466af1be0b1#cc053a4a6fa2fd0e8793201ed9099466af1be0b1" dependencies = [ "tracing", "tracing-subscriber", @@ -13703,7 +13788,7 @@ dependencies = [ [[package]] name = "ztracing_macro" version = "0.1.0" -source = "git+https://github.com/zed-industries/zed?rev=1a246efd7e1b83ab568ec5e3e6c1a43a42e1abba#1a246efd7e1b83ab568ec5e3e6c1a43a42e1abba" +source = "git+https://github.com/zed-industries/zed?rev=cc053a4a6fa2fd0e8793201ed9099466af1be0b1#cc053a4a6fa2fd0e8793201ed9099466af1be0b1" [[package]] name = "zune-core" diff --git a/Cargo.toml b/Cargo.toml index 31a432a6..3f25b03e 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -11,18 +11,25 @@ resolver = "2" transmutation = { git = "https://github.com/stippi/transmutation", rev = "87f48d18ce3ff61659d7182c1b733eae8ec6f2c9" } crossterm = { git = "https://github.com/nornagon/crossterm", branch = "nornagon/color-query" } ratatui = { git = "https://github.com/nornagon/ratatui", branch = "nornagon-v0.29.0-patch" } -# gpui-component depends on gpui via an unpinned `git = ".../zed"` dependency. -# The workspace must resolve to the *same* zed commit that gpui-component -# resolves to, otherwise two copies of gpui end up in the graph and cause -# E0053/E0277/E0599 type mismatches. We pin the zed crates to the exact commit -# that the pinned gpui-component revision locks to (see gpui-component's own -# Cargo.lock). At this commit the zed workspace exposes two crates literally -# named `gpui` (a `0.0.0` internal placeholder and the real `0.2.2`), so the -# `gpui` patch must carry `version = "=0.2.2"` to disambiguate. -# When bumping gpui-component, update the `rev` to match gpui-component's -# Cargo.lock, then run `cargo update -p gpui-component -p gpui`. +# gpui-component depends on gpui via a `git = ".../zed"` dependency. On its +# upstream `main` that dependency is UNPINNED, so it floats to zed's branch +# tip. If we pinned our workspace `gpui` to a fixed `?rev=`, that would be a +# different Cargo source than gpui-component's floating one, and two copies of +# gpui would end up in the graph -> E0053/E0277/E0599 type mismatches. +# To get a single copy we consume gpui-component from the `stippi` fork on a +# thin "pin-zed" branch: it is upstream `main` plus one commit that pins the +# zed git deps (gpui/gpui_platform/gpui_web/gpui_macros/reqwest_client) to the +# exact rev below, so both gpui-component and this workspace resolve to the +# identical `git+.../zed?rev=` source. +# At this zed commit the workspace exposes two crates literally named `gpui` +# (a `0.0.0` internal placeholder and the real `0.2.2`), so the `gpui` patch +# must carry `version = "=0.2.2"` to disambiguate. +# When bumping: rebase the pin-zed commit onto the new gpui-component main, +# set the zed `rev` to whatever that main's Cargo.lock locks gpui to, push the +# branch, then update both `rev`s here and run +# `cargo update -p gpui-component -p gpui`. # CI builds with `--locked`. See .github/workflows/release.yml. -gpui = { git = "https://github.com/zed-industries/zed", rev = "1a246efd7e1b83ab568ec5e3e6c1a43a42e1abba", version = "=0.2.2" } -gpui_platform = { git = "https://github.com/zed-industries/zed", rev = "1a246efd7e1b83ab568ec5e3e6c1a43a42e1abba" } -gpui_macros = { git = "https://github.com/zed-industries/zed", rev = "1a246efd7e1b83ab568ec5e3e6c1a43a42e1abba" } -gpui-component = { git = "https://github.com/stippi/gpui-component", rev = "1a66dc72424ae70300aae2ee02b04c4ce4e71d5a" } +gpui = { git = "https://github.com/zed-industries/zed", rev = "cc053a4a6fa2fd0e8793201ed9099466af1be0b1", version = "=0.2.2" } +gpui_platform = { git = "https://github.com/zed-industries/zed", rev = "cc053a4a6fa2fd0e8793201ed9099466af1be0b1" } +gpui_macros = { git = "https://github.com/zed-industries/zed", rev = "cc053a4a6fa2fd0e8793201ed9099466af1be0b1" } +gpui-component = { git = "https://github.com/stippi/gpui-component", rev = "abeab54c" } diff --git a/crates/code_assistant_core/src/tools/impls/execute_command.rs b/crates/code_assistant_core/src/tools/impls/execute_command.rs index 5e42ce46..e110ab8e 100644 --- a/crates/code_assistant_core/src/tools/impls/execute_command.rs +++ b/crates/code_assistant_core/src/tools/impls/execute_command.rs @@ -852,6 +852,25 @@ mod tests { } } + /// Poll the mock UI's live terminal text until it contains `needle` or the + /// timeout elapses, returning the last observed text either way. Session + /// mode spawns a login shell, whose startup latency on a loaded CI runner + /// makes exact wall-clock timing of the first output unreliable. + async fn wait_for_terminal_text( + fixture: &ToolTestFixture, + needle: &str, + timeout: std::time::Duration, + ) -> String { + let deadline = tokio::time::Instant::now() + timeout; + loop { + let text = fixture.ui().unwrap().get_terminal_output_text(); + if text.contains(needle) || tokio::time::Instant::now() >= deadline { + return text; + } + tokio::time::sleep(std::time::Duration::from_millis(50)).await; + } + } + #[tokio::test(flavor = "multi_thread")] async fn session_mode_returns_session_id_while_running() -> Result<()> { let dir = tempfile::tempdir()?; @@ -970,11 +989,14 @@ mod tests { .with_tool_id("tool-bg-1".to_string()); // Short yield: the tool returns while the process is still running, - // before it prints the delayed "LATE" marker. + // before it prints the delayed "LATE" marker. The gap between the + // markers is generous so that login-shell startup latency (session + // mode spawns `$SHELL -l -c ...`) on a loaded CI runner cannot blur + // EARLY into LATE. let session_id = { let mut context = fixture.context(); let mut input = session_mode_input( - "printf 'EARLY\\n'; sleep 1; printf 'LATE\\n'; sleep 30", + "printf 'EARLY\\n'; sleep 3; printf 'LATE\\n'; sleep 30", 500, ); let result = ExecuteCommandTool.execute(&mut context, &mut input).await?; @@ -984,20 +1006,24 @@ mod tests { // The tool call is over (context dropped). The agent would now be // doing other work — no tool is polling the session. - let streamed_at_return = fixture.ui().unwrap().get_terminal_output_text(); + // The login shell may still be starting up when the tool returns, so + // don't demand EARLY at that exact instant; poll for it (comfortably + // within the 3s gap before LATE). This is the "output keeps streaming + // with no tool call polling the session" guarantee. + let streamed_early = + wait_for_terminal_text(&fixture, "EARLY", std::time::Duration::from_secs(2)).await; assert!( - streamed_at_return.contains("EARLY"), - "early output should have streamed: {streamed_at_return:?}" + streamed_early.contains("EARLY"), + "early output should have streamed: {streamed_early:?}" ); assert!( - !streamed_at_return.contains("LATE"), - "the delayed output cannot have streamed yet: {streamed_at_return:?}" + !streamed_early.contains("LATE"), + "the delayed output cannot have streamed yet: {streamed_early:?}" ); // Wait past the delay without any tool call touching the session. - tokio::time::sleep(std::time::Duration::from_millis(1500)).await; - - let streamed_later = fixture.ui().unwrap().get_terminal_output_text(); + let streamed_later = + wait_for_terminal_text(&fixture, "LATE", std::time::Duration::from_secs(5)).await; assert!( streamed_later.contains("LATE"), "output produced between turns should keep streaming to the card: {streamed_later:?}" diff --git a/crates/llm/src/anthropic.rs b/crates/llm/src/anthropic.rs index 8a04f60e..f1eb0154 100644 --- a/crates/llm/src/anthropic.rs +++ b/crates/llm/src/anthropic.rs @@ -81,39 +81,9 @@ impl DefaultMessageConverter { Self } - /// Get cache marker positions based on the stable prefix length. - /// - /// Messages at and after the first volatile message are excluded because they - /// may change or disappear between requests, which would invalidate the - /// provider-side cached prefix. - /// - /// 0-4 messages: no cache markers - /// 5-9 messages: marker at index 4 - /// 10-14 messages: markers at indices 4 and 9 - /// 15-19 messages: markers at indices 9 and 14 - /// 20-24 messages: markers at indices 14 and 19 - /// etc. - fn get_cache_marker_positions(&self, messages: &[Message]) -> Vec { - let stable_len = messages - .iter() - .position(|message| message.volatile) - .unwrap_or(messages.len()); - - if stable_len < 5 { - return vec![]; - } - let remainder = stable_len % 5; - let last_marker = stable_len - remainder; - if last_marker > 5 { - vec![last_marker - 6, last_marker - 1] - } else { - vec![last_marker - 1] - } - } - /// Convert generic messages to Anthropic-specific format with cache control fn convert_messages_with_cache(&self, messages: Vec) -> Vec { - let cache_positions = self.get_cache_marker_positions(&messages); + let cache_positions = crate::prompt_caching::per_request_marker_positions(&messages); messages .into_iter() @@ -1432,99 +1402,55 @@ mod tests { use serde_json::json; - /// Test cache marker placement based on message count (stateless) + /// Test per-request cache marker placement (stateless): leading marker on + /// the last message, trailing marker directly before the last assistant + /// message — i.e. on the previous request's leading marker position. #[test] - fn test_message_count_based_cache_markers() { + fn test_per_request_cache_markers() { let converter = DefaultMessageConverter::new(); // Helper to count cache markers in messages fn count_message_cache_markers(result: &[AnthropicMessage]) -> Vec { - let mut marker_positions = Vec::new(); - for (msg_idx, msg) in result.iter().enumerate() { - if msg - .content - .iter() - .any(|block| block.cache_control.is_some()) - { - marker_positions.push(msg_idx); - } - } - marker_positions - } - - // Test 0-4 messages: No cache markers - for msg_count in 0..=4 { - let messages: Vec = (0..msg_count) - .map(|i| Message::new_user(format!("Message {i}"))) - .collect(); - - let result = converter.convert_messages_with_cache(messages); - let markers = count_message_cache_markers(&result); - assert!( - markers.is_empty(), - "{msg_count} messages: Should have no cache markers" - ); - } - - // Test 5-9 messages: Cache marker at index 4 - for msg_count in 5..=9 { - let messages: Vec = (0..msg_count) - .map(|i| Message::new_user(format!("Message {i}"))) - .collect(); - - let result = converter.convert_messages_with_cache(messages); - let markers = count_message_cache_markers(&result); - assert_eq!( - markers, - vec![4], - "{msg_count} messages: Should have cache marker at index 4" - ); - } - - // Test 10-14 messages: Cache markers at indices 4 and 9 - for msg_count in 10..=14 { - let messages: Vec = (0..msg_count) - .map(|i| Message::new_user(format!("Message {i}"))) - .collect(); - - let result = converter.convert_messages_with_cache(messages); - let markers = count_message_cache_markers(&result); - assert_eq!( - markers, - vec![4, 9], - "{msg_count} messages: Should have cache markers at indices 4 and 9" - ); - } - - // Test 15-19 messages: Cache markers at indices 9 and 14 - for msg_count in 15..=19 { - let messages: Vec = (0..msg_count) - .map(|i| Message::new_user(format!("Message {i}"))) - .collect(); - - let result = converter.convert_messages_with_cache(messages); - let markers = count_message_cache_markers(&result); - assert_eq!( - markers, - vec![9, 14], - "{msg_count} messages: Should have cache markers at indices 9 and 14" - ); + result + .iter() + .enumerate() + .filter_map(|(idx, msg)| { + msg.content + .iter() + .any(|block| block.cache_control.is_some()) + .then_some(idx) + }) + .collect() } - // Test 20-24 messages: Cache markers at indices 14 and 19 - for msg_count in 20..=24 { - let messages: Vec = (0..msg_count) - .map(|i| Message::new_user(format!("Message {i}"))) - .collect(); + // No history → no markers + let result = converter.convert_messages_with_cache(vec![]); + assert!(count_message_cache_markers(&result).is_empty()); - let result = converter.convert_messages_with_cache(messages); - let markers = count_message_cache_markers(&result); - assert_eq!( - markers, - vec![14, 19], - "{msg_count} messages: Should have cache markers at indices 14 and 19" - ); - } + // User-only history → single marker on the last message + let messages: Vec = (0..3) + .map(|i| Message::new_user(format!("Message {i}"))) + .collect(); + let result = converter.convert_messages_with_cache(messages); + assert_eq!(count_message_cache_markers(&result), vec![2]); + + // Growing conversation: each request's trailing marker must land on + // the previous request's leading marker position + let mut messages = vec![Message::new_user("u0")]; + let result = converter.convert_messages_with_cache(messages.clone()); + assert_eq!(count_message_cache_markers(&result), vec![0]); + + messages.push(Message::new_assistant("a1")); + messages.push(Message::new_user("u2")); + let result = converter.convert_messages_with_cache(messages.clone()); + assert_eq!(count_message_cache_markers(&result), vec![0, 2]); + + // Pending user message appended on top of the regular turn + messages.push(Message::new_assistant("a3")); + messages.push(Message::new_user("u4")); + messages.push(Message::new_user("u5 (pending)")); + let result = converter.convert_messages_with_cache(messages); + assert_eq!(count_message_cache_markers(&result), vec![2, 5]); } /// Test that volatile messages cap the cacheable history prefix. @@ -1545,25 +1471,33 @@ mod tests { .collect() } - let mut messages: Vec = (0..18) - .map(|i| Message::new_user(format!("Message {i}"))) - .collect(); + fn alternating_messages(count: usize) -> Vec { + (0..count) + .map(|i| { + if i % 2 == 0 { + Message::new_user(format!("u{i}")) + } else { + Message::new_assistant(format!("a{i}")) + } + }) + .collect() + } + + let mut messages = alternating_messages(18); messages[12].volatile = true; let result = converter.convert_messages_with_cache(messages); assert_eq!( count_message_cache_markers(&result), - vec![4, 9], - "18 messages with first volatile at 12 should use markers for the 12-message stable prefix" + vec![10, 11], + "First volatile at 12 caps markers to the 12-message stable prefix" ); - let mut messages: Vec = (0..18) - .map(|i| Message::new_user(format!("Message {i}"))) - .collect(); - messages[4].volatile = true; + let mut messages = alternating_messages(18); + messages[0].volatile = true; let result = converter.convert_messages_with_cache(messages); assert!( count_message_cache_markers(&result).is_empty(), - "A volatile message before index 5 should suppress message-history cache markers" + "A fully volatile history should carry no cache markers" ); } @@ -1599,7 +1533,8 @@ mod tests { let result = converter.convert_messages_with_cache(messages); - // Should have cache markers at indices 9 and 14 + // Leading marker on the last message (14), trailing marker directly + // before the last assistant message (13) → index 12 let mut cache_markers = Vec::new(); for (idx, msg) in result.iter().enumerate() { if msg @@ -1612,8 +1547,8 @@ mod tests { } assert_eq!( cache_markers, - vec![9, 14], - "15 messages should have cache markers at indices 9 and 14" + vec![12, 14], + "15 messages should have cache markers at indices 12 and 14" ); // Verify structured content is preserved @@ -1693,7 +1628,7 @@ mod tests { }) .collect(); - // Both should have identical cache marker placement (indices 9, 14) + // Both should have identical cache marker placement (indices 12, 14) let result_a = converter.convert_messages_with_cache(messages_a); let result_b = converter.convert_messages_with_cache(messages_b); @@ -1721,13 +1656,13 @@ mod tests { assert_eq!( markers_a, - vec![9, 14], - "Message set A should have markers at indices 9, 14" + vec![12, 14], + "Message set A should have markers at indices 12, 14" ); assert_eq!( markers_b, - vec![9, 14], - "Message set B should have markers at indices 9, 14" + vec![12, 14], + "Message set B should have markers at indices 12, 14" ); assert_eq!( markers_a, markers_b, @@ -1743,8 +1678,8 @@ mod tests { let markers_short = get_markers(&result_short); assert_eq!( markers_short, - vec![4], - "7 messages should have marker at index 4" + vec![6], + "7 user-only messages should have a single marker on the last one" ); } @@ -1825,13 +1760,14 @@ mod tests { .collect() }; - // Test with the full 30-message conversation + // Test with the full 30-message conversation: leading marker on the + // last message (29), trailing before the last assistant message (28) let result30 = converter.convert_messages_with_cache(messages.clone()); let markers30 = get_markers(&result30); assert_eq!( markers30, - vec![24, 29], - "30 messages: Should have cache markers at indices 24 and 29, found: {markers30:?}" + vec![27, 29], + "30 messages: Should have cache markers at indices 27 and 29, found: {markers30:?}" ); // Test different message counts to demonstrate stateless behavior @@ -1843,8 +1779,8 @@ mod tests { let markers_short = get_markers(&result_short); assert_eq!( markers_short, - vec![4], - "7 messages should have marker at index 4" + vec![3, 6], + "7 messages should have markers at indices 3 and 6" ); // Agent 2: Medium conversation (12 messages) @@ -1853,8 +1789,8 @@ mod tests { let markers_medium = get_markers(&result_medium); assert_eq!( markers_medium, - vec![4, 9], - "12 messages should have markers at indices 4, 9" + vec![9, 11], + "12 messages should have markers at indices 9, 11" ); // Agent 3: Long conversation (18 messages) @@ -1863,8 +1799,8 @@ mod tests { let markers_long = get_markers(&result_long); assert_eq!( markers_long, - vec![9, 14], - "18 messages should have markers at indices 9, 14" + vec![15, 17], + "18 messages should have markers at indices 15, 17" ); // Agent 4: Very long conversation (25 messages) @@ -1873,8 +1809,8 @@ mod tests { let markers_very_long = get_markers(&result_very_long); assert_eq!( markers_very_long, - vec![19, 24], - "25 messages should have markers at indices 19, 24" + vec![21, 24], + "25 messages should have markers at indices 21, 24" ); // Verify that cache markers are only placed on first content block of structured messages diff --git a/crates/llm/src/lib.rs b/crates/llm/src/lib.rs index fdb61ea4..eb3675ca 100644 --- a/crates/llm/src/lib.rs +++ b/crates/llm/src/lib.rs @@ -30,6 +30,7 @@ pub mod openai; pub mod openai_responses; pub mod openai_responses_ws; pub mod openrouter; +pub mod prompt_caching; pub mod provider_config; pub mod recording; pub mod streaming; diff --git a/crates/llm/src/openai_responses.rs b/crates/llm/src/openai_responses.rs index 711fd0f4..13d02b62 100644 --- a/crates/llm/src/openai_responses.rs +++ b/crates/llm/src/openai_responses.rs @@ -204,12 +204,38 @@ struct ModelCapabilities { supports_verbosity: bool, /// Default verbosity for the model default_verbosity: Option, + /// Whether the model supports explicit prompt cache breakpoints + /// (`prompt_cache_breakpoint`, GPT-5.6 and later; older models reject the field) + supports_explicit_cache: bool, +} + +/// Whether a model supports explicit prompt cache breakpoints. +/// +/// Per OpenAI docs, explicit prompt caching is supported by "GPT-5.6 and later +/// model families". Older models reject requests containing +/// `prompt_cache_breakpoint`, so this must stay conservative. +pub(crate) fn model_supports_explicit_cache(model_lower: &str) -> bool { + let Some(pos) = model_lower.find("gpt-") else { + return false; + }; + let version: String = model_lower[pos + 4..] + .chars() + .take_while(|c| c.is_ascii_digit() || *c == '.') + .collect(); + let mut parts = version.split('.'); + let major: u32 = match parts.next().and_then(|p| p.parse().ok()) { + Some(m) => m, + None => return false, + }; + let minor: u32 = parts.next().and_then(|p| p.parse().ok()).unwrap_or(0); + major > 5 || (major == 5 && minor >= 6) } impl ModelCapabilities { /// Get model capabilities based on model ID fn for_model(model: &str) -> Self { let model_lower = model.to_lowercase(); + let supports_explicit_cache = model_supports_explicit_cache(&model_lower); // GPT-5 and variants - full reasoning and verbosity support if model_lower.contains("gpt-5") || model_lower.starts_with("gpt5") { @@ -219,6 +245,7 @@ impl ModelCapabilities { default_summary: Some("auto".to_string()), supports_verbosity: true, default_verbosity: Some(Verbosity::Medium), + supports_explicit_cache, }; } @@ -230,6 +257,7 @@ impl ModelCapabilities { default_summary: Some("auto".to_string()), supports_verbosity: false, default_verbosity: None, + supports_explicit_cache, }; } @@ -241,6 +269,7 @@ impl ModelCapabilities { default_summary: Some("auto".to_string()), supports_verbosity: false, default_verbosity: None, + supports_explicit_cache, }; } @@ -252,6 +281,7 @@ impl ModelCapabilities { default_summary: None, supports_verbosity: false, default_verbosity: None, + supports_explicit_cache, }; } @@ -263,6 +293,7 @@ impl ModelCapabilities { default_summary: None, supports_verbosity: false, default_verbosity: None, + supports_explicit_cache, }; } @@ -274,6 +305,28 @@ impl ModelCapabilities { default_summary: Some("auto".to_string()), supports_verbosity: false, default_verbosity: None, + supports_explicit_cache, + } + } +} + +/// Place an explicit cache breakpoint on the last breakpoint-eligible content +/// block within `items[..end]`. +/// +/// A breakpoint marks the end of the cached prefix including everything +/// rendered before it, but is only valid on input blocks (`input_text`, +/// `input_image`). Function calls, tool outputs and assistant text cannot +/// carry one, so the marker is shifted backwards to the nearest eligible +/// block. The scan only depends on content before the anchor, which is stable +/// across requests, so placement stays deterministic. +fn apply_cache_breakpoint(items: &mut [ResponseInputItem], end: usize) { + for item in items[..end].iter_mut().rev() { + if let ResponseInputItem::Message { content, .. } = item { + for content_item in content.iter_mut().rev() { + if content_item.try_mark_cache_breakpoint() { + return; + } + } } } } @@ -302,15 +355,37 @@ enum ResponseInputItem { }, } +/// Explicit cache breakpoint marker on an input content block (GPT-5.6+). +/// +/// Marks the end of a cacheable prompt prefix, including the marked block and +/// all prompt content rendered before it. Older models reject this field, so +/// it must only be sent when `ModelCapabilities::supports_explicit_cache`. +#[derive(Debug, Clone, Serialize)] +pub(crate) struct PromptCacheBreakpoint { + mode: String, +} + +impl PromptCacheBreakpoint { + pub(crate) fn explicit() -> Self { + Self { + mode: "explicit".to_string(), + } + } +} + /// Content item within messages #[derive(Debug, Serialize)] #[serde(tag = "type", rename_all = "snake_case")] enum ResponseContentItem { InputText { text: String, + #[serde(skip_serializing_if = "Option::is_none")] + prompt_cache_breakpoint: Option, }, InputImage { image_url: String, + #[serde(skip_serializing_if = "Option::is_none")] + prompt_cache_breakpoint: Option, }, OutputText { text: String, @@ -319,6 +394,41 @@ enum ResponseContentItem { }, } +impl ResponseContentItem { + fn input_text(text: String) -> Self { + Self::InputText { + text, + prompt_cache_breakpoint: None, + } + } + + fn input_image(image_url: String) -> Self { + Self::InputImage { + image_url, + prompt_cache_breakpoint: None, + } + } + + /// Set the explicit cache breakpoint if this is an input block that + /// supports one. Returns whether the marker could be applied. + fn try_mark_cache_breakpoint(&mut self) -> bool { + match self { + Self::InputText { + prompt_cache_breakpoint, + .. + } + | Self::InputImage { + prompt_cache_breakpoint, + .. + } => { + *prompt_cache_breakpoint = Some(PromptCacheBreakpoint::explicit()); + true + } + Self::OutputText { .. } => false, + } + } +} + /// Response structure from the Responses API #[derive(Debug, Deserialize)] struct ResponsesResponse { @@ -399,6 +509,32 @@ struct ResponsesUsage { #[derive(Debug, Deserialize)] struct InputTokensDetails { cached_tokens: u32, + /// Tokens written to the prompt cache (explicit caching, GPT-5.6+). + #[serde(default)] + cache_write_tokens: u32, +} + +impl ResponsesUsage { + /// Convert to the provider-independent `Usage`. + /// + /// OpenAI reports `input_tokens` inclusive of cached (and cache-write) + /// tokens, while the internal `Usage` follows Anthropic semantics where + /// `input_tokens` counts only uncached input. + fn to_usage(&self) -> Usage { + let (cache_read, cache_write) = self + .input_tokens_details + .as_ref() + .map(|d| (d.cached_tokens, d.cache_write_tokens)) + .unwrap_or((0, 0)); + Usage { + input_tokens: self + .input_tokens + .saturating_sub(cache_read.saturating_add(cache_write)), + output_tokens: self.output_tokens, + cache_creation_input_tokens: cache_write, + cache_read_input_tokens: cache_read, + } + } } #[derive(Debug, Deserialize)] @@ -607,15 +743,29 @@ impl OpenAIResponsesClient { .customize_url(&self.base_url, streaming) } - /// Convert internal messages to Responses API format - fn convert_messages(&self, messages: Vec) -> Vec { + /// Convert internal messages to Responses API format, optionally placing + /// explicit cache breakpoints at stable history positions. + fn convert_messages_with_cache( + &self, + messages: Vec, + explicit_cache: bool, + ) -> Vec { + let cache_positions = if explicit_cache { + crate::prompt_caching::cache_marker_positions(&messages) + } else { + Vec::new() + }; + let mut result = Vec::new(); + // Number of converted items after each message, so marker positions + // (message indices) can be mapped back to converted input items. + let mut items_after_message = Vec::with_capacity(messages.len()); for message in messages { match message.content { MessageContent::Text(text) => { let content_item = match message.role { - MessageRole::User => ResponseContentItem::InputText { text }, + MessageRole::User => ResponseContentItem::input_text(text), MessageRole::Assistant => ResponseContentItem::OutputText { text, phase: Some("final_answer".to_string()), @@ -633,6 +783,11 @@ impl OpenAIResponsesClient { self.convert_structured_message(message.role, blocks, &mut result); } } + items_after_message.push(result.len()); + } + + for &position in &cache_positions { + apply_cache_breakpoint(&mut result, items_after_message[position]); } result @@ -685,7 +840,7 @@ impl OpenAIResponsesClient { match block { ContentBlock::Text { text, .. } => match role { MessageRole::User => { - current_message_content.push(ResponseContentItem::InputText { text }); + current_message_content.push(ResponseContentItem::input_text(text)); } MessageRole::Assistant => { current_message_content.push(ResponseContentItem::OutputText { @@ -698,12 +853,11 @@ impl OpenAIResponsesClient { media_type, data, .. } => { let image_url = format!("data:{media_type};base64,{data}"); - current_message_content.push(ResponseContentItem::InputImage { image_url }); + current_message_content.push(ResponseContentItem::input_image(image_url)); } ContentBlock::Thinking { thinking, .. } => match role { MessageRole::User => { - current_message_content - .push(ResponseContentItem::InputText { text: thinking }); + current_message_content.push(ResponseContentItem::input_text(thinking)); } MessageRole::Assistant => { current_message_content.push(ResponseContentItem::OutputText { @@ -932,15 +1086,7 @@ impl OpenAIResponsesClient { let content = self.convert_output(responses_response.output); let usage = responses_response .usage - .map_or_else(Usage::zero, |u| Usage { - input_tokens: u.input_tokens, - output_tokens: u.output_tokens, - cache_creation_input_tokens: 0, - cache_read_input_tokens: u - .input_tokens_details - .map(|d| d.cached_tokens) - .unwrap_or(0), - }); + .map_or_else(Usage::zero, |u| u.to_usage()); Ok(LLMResponse { content, @@ -1044,15 +1190,7 @@ impl OpenAIResponsesClient { let content = self.convert_output(responses_response.output); let usage = responses_response .usage - .map_or_else(Usage::zero, |u| Usage { - input_tokens: u.input_tokens, - output_tokens: u.output_tokens, - cache_creation_input_tokens: 0, - cache_read_input_tokens: u - .input_tokens_details - .map(|d| d.cached_tokens) - .unwrap_or(0), - }); + .map_or_else(Usage::zero, |u| u.to_usage()); Ok(( LLMResponse { @@ -1452,12 +1590,7 @@ impl<'a> StreamProcessor<'a> { .clone(), ) { - self.usage.input_tokens = usage_data.input_tokens; - self.usage.output_tokens = usage_data.output_tokens; - self.usage.cache_read_input_tokens = usage_data - .input_tokens_details - .map(|d| d.cached_tokens) - .unwrap_or(0); + *self.usage = usage_data.to_usage(); } // Create a RedactedThinking block from collected reasoning items if any @@ -1489,17 +1622,26 @@ impl LLMProvider for OpenAIResponsesClient { request: LLMRequest, streaming_callback: Option<&StreamingCallback>, ) -> Result { - let mut input = self.convert_messages(request.messages); + // Get model capabilities; explicit cache breakpoints are only sent to + // models that support them (older models reject the field). + let capabilities = ModelCapabilities::for_model(&self.model); + + let mut input = self + .convert_messages_with_cache(request.messages, capabilities.supports_explicit_cache); // Add system prompt as developer message at the beginning if !request.system_prompt.is_empty() { + // The system prompt (plus tools rendered before it) is the largest + // stable prefix — always mark it, mirroring the Anthropic client. + let mut system_item = ResponseContentItem::input_text(request.system_prompt); + if capabilities.supports_explicit_cache { + system_item.try_mark_cache_breakpoint(); + } input.insert( 0, ResponseInputItem::Message { role: "developer".to_string(), - content: vec![ResponseContentItem::InputText { - text: request.system_prompt, - }], + content: vec![system_item], }, ); } @@ -1521,8 +1663,7 @@ impl LLMProvider for OpenAIResponsesClient { // Configure for stateless mode with encrypted reasoning let store = false; - // Get model capabilities and build reasoning config - let capabilities = ModelCapabilities::for_model(&self.model); + // Build reasoning config from model capabilities let reasoning = if capabilities.supports_reasoning { Some(ReasoningConfig { effort: capabilities.default_effort, @@ -1594,7 +1735,7 @@ mod tests { let messages = vec![Message::new_user("Hello")]; - let converted = client.convert_messages(messages); + let converted = client.convert_messages_with_cache(messages, false); assert_eq!(converted.len(), 1); match &converted[0] { @@ -1602,7 +1743,7 @@ mod tests { assert_eq!(role, "user"); assert_eq!(content.len(), 1); match &content[0] { - ResponseContentItem::InputText { text } => { + ResponseContentItem::InputText { text, .. } => { assert_eq!(text, "Hello"); } _ => panic!("Expected InputText"), @@ -1623,7 +1764,7 @@ mod tests { // Test that assistant messages with simple text use OutputText, not InputText let messages = vec![Message::new_assistant("Hello from assistant")]; - let converted = client.convert_messages(messages); + let converted = client.convert_messages_with_cache(messages, false); assert_eq!(converted.len(), 1); match &converted[0] { @@ -1653,7 +1794,7 @@ mod tests { ContentBlock::new_tool_result("test_id", "Tool output"), ])]; - let converted = client.convert_messages(messages); + let converted = client.convert_messages_with_cache(messages, false); assert_eq!(converted.len(), 1); match &converted[0] { @@ -1780,7 +1921,7 @@ mod tests { Message::new_user("What about 3+3?"), ]; - let converted = client.convert_messages(conversation); + let converted = client.convert_messages_with_cache(conversation, false); assert_eq!(converted.len(), 4); // First: User question @@ -1832,7 +1973,7 @@ mod tests { assert_eq!(role, "user"); assert_eq!(content.len(), 1); match &content[0] { - ResponseContentItem::InputText { text } => { + ResponseContentItem::InputText { text, .. } => { assert_eq!(text, "What about 3+3?"); } _ => panic!("Expected InputText"), @@ -1862,7 +2003,7 @@ mod tests { ContentBlock::new_text("Third text"), ])]; - let converted = client.convert_messages(messages); + let converted = client.convert_messages_with_cache(messages, false); assert_eq!(converted.len(), 5); // First: First text @@ -1945,7 +2086,7 @@ mod tests { ContentBlock::new_text("Based on my reasoning, here's the answer."), ])]; - let converted = client.convert_messages(messages); + let converted = client.convert_messages_with_cache(messages, false); assert_eq!(converted.len(), 2); // First should be the encrypted reasoning (maintains original order) @@ -2167,7 +2308,7 @@ mod tests { // Simulate converting messages with a system prompt let messages = vec![Message::new_user("Hello")]; - let mut input = client.convert_messages(messages); + let mut input = client.convert_messages_with_cache(messages, false); // Add system prompt as developer message (simulating what send_message does) let system_prompt = "You are a helpful assistant."; @@ -2176,9 +2317,7 @@ mod tests { 0, ResponseInputItem::Message { role: "developer".to_string(), - content: vec![ResponseContentItem::InputText { - text: system_prompt.to_string(), - }], + content: vec![ResponseContentItem::input_text(system_prompt.to_string())], }, ); } @@ -2191,7 +2330,7 @@ mod tests { assert_eq!(role, "developer"); assert_eq!(content.len(), 1); match &content[0] { - ResponseContentItem::InputText { text } => { + ResponseContentItem::InputText { text, .. } => { assert_eq!(text, "You are a helpful assistant."); } _ => panic!("Expected InputText"), @@ -2206,7 +2345,7 @@ mod tests { assert_eq!(role, "user"); assert_eq!(content.len(), 1); match &content[0] { - ResponseContentItem::InputText { text } => { + ResponseContentItem::InputText { text, .. } => { assert_eq!(text, "Hello"); } _ => panic!("Expected InputText"), @@ -2341,7 +2480,7 @@ mod tests { ); let messages = vec![Message::new_assistant("Hello from assistant")]; - let converted = client.convert_messages(messages); + let converted = client.convert_messages_with_cache(messages, false); assert_eq!(converted.len(), 1); match &converted[0] { @@ -2375,7 +2514,7 @@ mod tests { ContentBlock::new_tool_use("call_1", "search", serde_json::json!({"query": "weather"})), ])]; - let converted = client.convert_messages(messages); + let converted = client.convert_messages_with_cache(messages, false); // Should produce: Message(OutputText), FunctionCall assert_eq!(converted.len(), 2); @@ -2419,7 +2558,7 @@ mod tests { ContentBlock::new_text("Second thought."), ])]; - let converted = client.convert_messages(messages); + let converted = client.convert_messages_with_cache(messages, false); // Message("First thought."), FunctionCall, Message("Second thought.") assert_eq!(converted.len(), 3); @@ -2447,7 +2586,7 @@ mod tests { ); let messages = vec![Message::new_user("Hello")]; - let converted = client.convert_messages(messages); + let converted = client.convert_messages_with_cache(messages, false); assert_eq!(converted.len(), 1); match &converted[0] { @@ -2483,9 +2622,7 @@ mod tests { assert_eq!(json["phase"], "commentary"); // InputText must not carry a phase key - let input_item = ResponseContentItem::InputText { - text: "hello".to_string(), - }; + let input_item = ResponseContentItem::input_text("hello".to_string()); let json = serde_json::to_value(&input_item).unwrap(); assert!(json.get("phase").is_none()); } @@ -2576,4 +2713,123 @@ mod tests { .expect("error should be retryable"); assert!(matches!(ctx.error, ApiError::RateLimit(_))); } + + #[test] + fn test_model_supports_explicit_cache_version_gating() { + // GPT-5.6 and later model families support explicit cache breakpoints + assert!(model_supports_explicit_cache("gpt-5.6")); + assert!(model_supports_explicit_cache("gpt-5.6-codex")); + assert!(model_supports_explicit_cache("gpt-5.7-mini")); + assert!(model_supports_explicit_cache("gpt-6")); + + // Older models reject the field and must not receive it + assert!(!model_supports_explicit_cache("gpt-5")); + assert!(!model_supports_explicit_cache("gpt-5-codex")); + assert!(!model_supports_explicit_cache("gpt-5.1")); + assert!(!model_supports_explicit_cache("gpt-4o")); + assert!(!model_supports_explicit_cache("o3-mini")); + assert!(!model_supports_explicit_cache("o1")); + } + + fn content_items_with_breakpoint(items: &[ResponseInputItem]) -> Vec { + items + .iter() + .enumerate() + .filter_map(|(idx, item)| match item { + ResponseInputItem::Message { content, .. } => content + .iter() + .any(|c| { + matches!( + c, + ResponseContentItem::InputText { + prompt_cache_breakpoint: Some(_), + .. + } | ResponseContentItem::InputImage { + prompt_cache_breakpoint: Some(_), + .. + } + ) + }) + .then_some(idx), + _ => None, + }) + .collect() + } + + #[test] + fn test_explicit_cache_breakpoints_on_user_messages() { + let client = OpenAIResponsesClient::new( + "test_key".to_string(), + "gpt-5.6".to_string(), + "https://api.openai.com/v1".to_string(), + ); + + // 10 user messages → markers at message indices 4 and 9 (1:1 items) + let messages: Vec = (0..10) + .map(|i| Message::new_user(format!("Message {i}"))) + .collect(); + let items = client.convert_messages_with_cache(messages, true); + assert_eq!(content_items_with_breakpoint(&items), vec![4, 9]); + + // Without explicit caching no markers are placed + let messages: Vec = (0..10) + .map(|i| Message::new_user(format!("Message {i}"))) + .collect(); + let items = client.convert_messages_with_cache(messages, false); + assert!(content_items_with_breakpoint(&items).is_empty()); + } + + #[test] + fn test_explicit_cache_breakpoint_shifts_to_eligible_block() { + let client = OpenAIResponsesClient::new( + "test_key".to_string(), + "gpt-5.6".to_string(), + "https://api.openai.com/v1".to_string(), + ); + + // Message index 4 is a tool result (function_call_output, no + // breakpoint support) — the marker must shift back to the nearest + // input block, the user message at index 3. + let messages = vec![ + Message::new_user("Message 0"), + Message::new_assistant("Answer 0"), + Message::new_user("Message 1"), + Message::new_user("Message 2"), + Message::new_user_content(vec![ContentBlock::new_tool_result("call_1", "result")]), + ]; + + let items = client.convert_messages_with_cache(messages, true); + assert_eq!(content_items_with_breakpoint(&items), vec![3]); + } + + #[test] + fn test_prompt_cache_breakpoint_serialization() { + let mut item = ResponseContentItem::input_text("hello".to_string()); + let json = serde_json::to_value(&item).unwrap(); + assert!(json.get("prompt_cache_breakpoint").is_none()); + + assert!(item.try_mark_cache_breakpoint()); + let json = serde_json::to_value(&item).unwrap(); + assert_eq!(json["prompt_cache_breakpoint"]["mode"], "explicit"); + } + + #[test] + fn test_usage_normalization_subtracts_cached_tokens() { + let usage: ResponsesUsage = serde_json::from_value(serde_json::json!({ + "input_tokens": 1000, + "output_tokens": 50, + "total_tokens": 1050, + "input_tokens_details": { + "cached_tokens": 700, + "cache_write_tokens": 200 + } + })) + .unwrap(); + + let converted = usage.to_usage(); + assert_eq!(converted.input_tokens, 100); + assert_eq!(converted.cache_read_input_tokens, 700); + assert_eq!(converted.cache_creation_input_tokens, 200); + assert_eq!(converted.output_tokens, 50); + } } diff --git a/crates/llm/src/openai_responses_ws.rs b/crates/llm/src/openai_responses_ws.rs index 6d4b1a7b..9f7f4b7c 100644 --- a/crates/llm/src/openai_responses_ws.rs +++ b/crates/llm/src/openai_responses_ws.rs @@ -52,7 +52,7 @@ use tokio_tungstenite::{ use tracing::{debug, info, warn}; // Re-export types shared with the HTTP provider -use crate::openai_responses::Verbosity; +use crate::openai_responses::{PromptCacheBreakpoint, Verbosity, model_supports_explicit_cache}; // ============================================================================ // Request / Response types (WebSocket-specific envelope) @@ -145,9 +145,13 @@ enum WsInputItem { enum WsContentItem { InputText { text: String, + #[serde(skip_serializing_if = "Option::is_none")] + prompt_cache_breakpoint: Option, }, InputImage { image_url: String, + #[serde(skip_serializing_if = "Option::is_none")] + prompt_cache_breakpoint: Option, }, OutputText { text: String, @@ -156,6 +160,66 @@ enum WsContentItem { }, } +impl WsContentItem { + fn input_text(text: String) -> Self { + Self::InputText { + text, + prompt_cache_breakpoint: None, + } + } + + fn input_image(image_url: String) -> Self { + Self::InputImage { + image_url, + prompt_cache_breakpoint: None, + } + } + + /// Set the explicit cache breakpoint if this is an input block that + /// supports one. Returns whether the marker could be applied. + fn try_mark_cache_breakpoint(&mut self) -> bool { + match self { + Self::InputText { + prompt_cache_breakpoint, + .. + } + | Self::InputImage { + prompt_cache_breakpoint, + .. + } => { + *prompt_cache_breakpoint = Some(PromptCacheBreakpoint::explicit()); + true + } + Self::OutputText { .. } => false, + } + } +} + +/// Place an explicit cache breakpoint on the last breakpoint-eligible content +/// block within `items[..end]`. +/// +/// A breakpoint marks the end of the cached prefix including everything +/// rendered before it, but is only valid on input blocks (`input_text`, +/// `input_image`). Function calls, tool outputs and assistant text cannot +/// carry one, so the marker is shifted backwards to the nearest eligible +/// block. The scan only depends on content before the anchor, which is stable +/// across requests, so placement stays deterministic. +/// +/// Markers serialize as part of the input items, so `compute_delta` naturally +/// forces a full resend whenever marker positions move — the previously +/// cached token prefix stays valid because markers are not prompt content. +fn apply_cache_breakpoint(items: &mut [WsInputItem], end: usize) { + for item in items[..end].iter_mut().rev() { + if let WsInputItem::Message { content, .. } = item { + for content_item in content.iter_mut().rev() { + if content_item.try_mark_cache_breakpoint() { + return; + } + } + } + } +} + // --------------------------------------------------------------------------- // Output / event types (received from the API) // --------------------------------------------------------------------------- @@ -249,6 +313,32 @@ struct WsResponsesUsage { #[derive(Debug, Deserialize)] struct WsInputTokensDetails { cached_tokens: u32, + /// Tokens written to the prompt cache (explicit caching, GPT-5.6+). + #[serde(default)] + cache_write_tokens: u32, +} + +impl WsResponsesUsage { + /// Convert to the provider-independent `Usage`. + /// + /// OpenAI reports `input_tokens` inclusive of cached (and cache-write) + /// tokens, while the internal `Usage` follows Anthropic semantics where + /// `input_tokens` counts only uncached input. + fn to_usage(&self) -> Usage { + let (cache_read, cache_write) = self + .input_tokens_details + .as_ref() + .map(|d| (d.cached_tokens, d.cache_write_tokens)) + .unwrap_or((0, 0)); + Usage { + input_tokens: self + .input_tokens + .saturating_sub(cache_read.saturating_add(cache_write)), + output_tokens: self.output_tokens, + cache_creation_input_tokens: cache_write, + cache_read_input_tokens: cache_read, + } + } } // ============================================================================ @@ -262,11 +352,15 @@ struct ModelCapabilities { default_summary: Option, supports_verbosity: bool, default_verbosity: Option, + /// Whether the model supports explicit prompt cache breakpoints + /// (`prompt_cache_breakpoint`, GPT-5.6 and later; older models reject the field) + supports_explicit_cache: bool, } impl ModelCapabilities { fn for_model(model: &str) -> Self { let m = model.to_lowercase(); + let supports_explicit_cache = model_supports_explicit_cache(&m); if m.contains("gpt-5") || m.starts_with("gpt5") { return Self { @@ -275,6 +369,7 @@ impl ModelCapabilities { default_summary: Some("auto".into()), supports_verbosity: true, default_verbosity: Some(Verbosity::Medium), + supports_explicit_cache, }; } if m.starts_with("o3") || m.starts_with("o4") { @@ -284,6 +379,7 @@ impl ModelCapabilities { default_summary: Some("auto".into()), supports_verbosity: false, default_verbosity: None, + supports_explicit_cache, }; } if m.starts_with("o1") { @@ -293,6 +389,7 @@ impl ModelCapabilities { default_summary: Some("auto".into()), supports_verbosity: false, default_verbosity: None, + supports_explicit_cache, }; } if m.contains("gpt-4o") || m.contains("gpt4o") || m.contains("gpt-4") || m.contains("gpt4") @@ -303,6 +400,7 @@ impl ModelCapabilities { default_summary: None, supports_verbosity: false, default_verbosity: None, + supports_explicit_cache, }; } // Default: assume reasoning support with conservative defaults @@ -312,6 +410,7 @@ impl ModelCapabilities { default_summary: Some("auto".into()), supports_verbosity: false, default_verbosity: None, + supports_explicit_cache, } } } @@ -587,8 +686,24 @@ impl OpenAIResponsesWsClient { // Message conversion (internal types → WS input items) // ----------------------------------------------------------------------- - fn convert_messages(&self, messages: Vec) -> Vec { + /// Convert internal messages to WS input items, optionally placing + /// explicit cache breakpoints at stable history positions. + fn convert_messages_with_cache( + &self, + messages: Vec, + explicit_cache: bool, + ) -> Vec { + let cache_positions = if explicit_cache { + crate::prompt_caching::cache_marker_positions(&messages) + } else { + Vec::new() + }; + let mut items = Vec::new(); + // Number of converted items after each message, so marker positions + // (message indices) can be mapped back to converted input items. + let mut items_after_message = Vec::with_capacity(messages.len()); + for message in messages { match &message.content { MessageContent::Text(text) => { @@ -605,7 +720,7 @@ impl OpenAIResponsesWsClient { phase: Some("final_answer".to_string()), } } else { - WsContentItem::InputText { text: text.clone() } + WsContentItem::input_text(text.clone()) }; items.push(WsInputItem::Message { role: role.to_string(), @@ -616,7 +731,13 @@ impl OpenAIResponsesWsClient { self.convert_structured_message(&message.role, blocks, &mut items); } } + items_after_message.push(items.len()); } + + for &position in &cache_positions { + apply_cache_breakpoint(&mut items, items_after_message[position]); + } + items } @@ -652,7 +773,7 @@ impl OpenAIResponsesWsClient { match block { ContentBlock::Text { text, .. } => { let item = if *role == MessageRole::User { - WsContentItem::InputText { text: text.clone() } + WsContentItem::input_text(text.clone()) } else { WsContentItem::OutputText { text: text.clone(), @@ -662,9 +783,10 @@ impl OpenAIResponsesWsClient { current_content.push(item); } ContentBlock::Image { data, .. } => { - current_content.push(WsContentItem::InputImage { - image_url: format!("data:image/png;base64,{}", data), - }); + current_content.push(WsContentItem::input_image(format!( + "data:image/png;base64,{}", + data + ))); } ContentBlock::Thinking { thinking, @@ -906,7 +1028,15 @@ impl OpenAIResponsesWsClient { request: LLMRequest, streaming_callback: Option<&StreamingCallback>, ) -> Result { - let input = self.convert_messages(request.messages); + let capabilities = ModelCapabilities::for_model(&self.model); + + // Explicit cache breakpoints are only sent to models that support + // them (older models reject the field). The system prompt travels as + // `instructions` (a plain string, no breakpoint possible); a marker on + // a history message still caches instructions and tools, since a + // breakpoint covers everything rendered before it. + let input = self + .convert_messages_with_cache(request.messages, capabilities.supports_explicit_cache); // Add system prompt as the top-level `instructions` field (WebSocket style) // but also keep it as a developer message in `input` for compatibility. @@ -930,8 +1060,6 @@ impl OpenAIResponsesWsClient { .collect() }); - let capabilities = ModelCapabilities::for_model(&self.model); - let reasoning = if capabilities.supports_reasoning { Some(ReasoningConfig { effort: capabilities.default_effort, @@ -1277,12 +1405,7 @@ impl OpenAIResponsesWsClient { usage_val.clone(), ) { - usage.input_tokens = u.input_tokens; - usage.output_tokens = u.output_tokens; - usage.cache_read_input_tokens = u - .input_tokens_details - .map(|d| d.cached_tokens) - .unwrap_or(0); + usage = u.to_usage(); } } @@ -1632,9 +1755,7 @@ mod tests { previous_response_id: None, input: vec![WsInputItem::Message { role: "user".to_string(), - content: vec![WsContentItem::InputText { - text: "Hello".to_string(), - }], + content: vec![WsContentItem::input_text("Hello".to_string())], }], tools: None, tool_choice: Some("auto".to_string()), @@ -1663,9 +1784,7 @@ mod tests { previous_response_id: Some("resp_abc123".to_string()), input: vec![WsInputItem::Message { role: "user".to_string(), - content: vec![WsContentItem::InputText { - text: "Follow-up".to_string(), - }], + content: vec![WsContentItem::input_text("Follow-up".to_string())], }], tools: None, tool_choice: None, @@ -1709,9 +1828,7 @@ mod tests { // No previous state => no delta let input = vec![WsInputItem::Message { role: "user".to_string(), - content: vec![WsContentItem::InputText { - text: "Hello".to_string(), - }], + content: vec![WsContentItem::input_text("Hello".to_string())], }]; assert!(client.compute_delta(&input).is_none()); @@ -1730,9 +1847,7 @@ mod tests { }); extended.push(WsInputItem::Message { role: "user".to_string(), - content: vec![WsContentItem::InputText { - text: "How are you?".to_string(), - }], + content: vec![WsContentItem::input_text("How are you?".to_string())], }); let result = client.compute_delta(&extended); @@ -1832,7 +1947,7 @@ mod tests { ); let messages = vec![Message::new_assistant("Hello from assistant")]; - let converted = client.convert_messages(messages); + let converted = client.convert_messages_with_cache(messages, false); assert_eq!(converted.len(), 1); match &converted[0] { @@ -1866,7 +1981,7 @@ mod tests { ContentBlock::new_tool_use("call_1", "search", serde_json::json!({"query": "weather"})), ])]; - let converted = client.convert_messages(messages); + let converted = client.convert_messages_with_cache(messages, false); // Should produce: Message(OutputText), FunctionCall assert_eq!(converted.len(), 2); @@ -1897,7 +2012,7 @@ mod tests { ); let messages = vec![Message::new_user("Hello")]; - let converted = client.convert_messages(messages); + let converted = client.convert_messages_with_cache(messages, false); assert_eq!(converted.len(), 1); match &converted[0] { @@ -1931,10 +2046,92 @@ mod tests { let json = serde_json::to_value(&commentary_item).unwrap(); assert_eq!(json["phase"], "commentary"); - let input_item = WsContentItem::InputText { - text: "hello".to_string(), - }; + let input_item = WsContentItem::input_text("hello".to_string()); let json = serde_json::to_value(&input_item).unwrap(); assert!(json.get("phase").is_none()); } + + fn ws_items_with_breakpoint(items: &[WsInputItem]) -> Vec { + items + .iter() + .enumerate() + .filter_map(|(idx, item)| match item { + WsInputItem::Message { content, .. } => content + .iter() + .any(|c| { + matches!( + c, + WsContentItem::InputText { + prompt_cache_breakpoint: Some(_), + .. + } | WsContentItem::InputImage { + prompt_cache_breakpoint: Some(_), + .. + } + ) + }) + .then_some(idx), + _ => None, + }) + .collect() + } + + #[test] + fn test_explicit_cache_breakpoints_on_user_messages() { + let client = OpenAIResponsesWsClient::new( + "test_key".to_string(), + "gpt-5.6".to_string(), + "https://api.openai.com/v1".to_string(), + ); + + // 10 user messages → markers at message indices 4 and 9 (1:1 items) + let messages: Vec = (0..10) + .map(|i| Message::new_user(format!("Message {i}"))) + .collect(); + let items = client.convert_messages_with_cache(messages, true); + assert_eq!(ws_items_with_breakpoint(&items), vec![4, 9]); + + // Without explicit caching no markers are placed + let messages: Vec = (0..10) + .map(|i| Message::new_user(format!("Message {i}"))) + .collect(); + let items = client.convert_messages_with_cache(messages, false); + assert!(ws_items_with_breakpoint(&items).is_empty()); + } + + /// Moving cache markers must invalidate the incremental-input delta so + /// the full input (with markers at their new positions) is resent. + #[test] + fn test_moved_cache_marker_invalidates_delta() { + let mut client = OpenAIResponsesWsClient::new( + "test_key".to_string(), + "gpt-5.6".to_string(), + "https://api.openai.com/v1".to_string(), + ); + + let messages: Vec = (0..10) + .map(|i| Message::new_user(format!("Message {i}"))) + .collect(); + let prev_items = client.convert_messages_with_cache(messages.clone(), true); + client.last_response_id = Some("resp_1".to_string()); + client.last_input_items = prev_items; + + // Growing to 12 messages keeps markers at 4 and 9 → strict extension + let mut extended = messages.clone(); + extended.push(Message::new_user("Message 10")); + extended.push(Message::new_user("Message 11")); + let items = client.convert_messages_with_cache(extended, true); + let delta = client.compute_delta(&items); + assert!(delta.is_some()); + assert_eq!(delta.unwrap().1.len(), 2); + + // Growing to 15 messages moves markers to 9 and 14 → full resend + let mut extended = messages; + for i in 10..15 { + extended.push(Message::new_user(format!("Message {i}"))); + } + let items = client.convert_messages_with_cache(extended, true); + assert_eq!(ws_items_with_breakpoint(&items), vec![9, 14]); + assert!(client.compute_delta(&items).is_none()); + } } diff --git a/crates/llm/src/prompt_caching.rs b/crates/llm/src/prompt_caching.rs new file mode 100644 index 00000000..c7f63d9e --- /dev/null +++ b/crates/llm/src/prompt_caching.rs @@ -0,0 +1,143 @@ +//! Shared cache-marker placement logic for providers with explicit prompt caching. +//! +//! Both the Anthropic client and the OpenAI Responses clients place explicit +//! cache breakpoints into the message history. The placement strategy is +//! provider-independent: markers anchor at message indices derived from the +//! stable (non-volatile) history prefix. + +use crate::types::{Message, MessageRole}; + +/// Length of the stable history prefix. +/// +/// Messages at and after the first volatile message are excluded because they +/// may change or disappear between requests, which would invalidate the +/// provider-side cached prefix. +fn stable_prefix_len(messages: &[Message]) -> usize { + messages + .iter() + .position(|message| message.volatile) + .unwrap_or(messages.len()) +} + +/// Get cache marker positions that move on every request. Used by the +/// Anthropic client, which resends the full message history each request. +/// +/// Two markers are placed inside the stable prefix: +/// +/// - the last stable message, so the entire prompt built in this request is +/// cached as early as possible (a write pays for itself as soon as the +/// prefix is reused once: 1.25x + 0.1x < 1x + 1x) +/// - the message directly before the last stable assistant message +/// +/// Every request ends on a user message and the response is appended right +/// behind it as an assistant message, so the message before the last +/// assistant message is exactly where the previous request placed its leading +/// marker. The lookup there is guaranteed to hit and only the messages +/// appended since then are written — without tracking any state, and +/// regardless of how many messages arrived in between (tool results, pending +/// user messages). +pub fn per_request_marker_positions(messages: &[Message]) -> Vec { + let stable_len = stable_prefix_len(messages); + if stable_len == 0 { + return vec![]; + } + + let leading = stable_len - 1; + let trailing = messages[..stable_len] + .iter() + .rposition(|message| message.role == MessageRole::Assistant) + .and_then(|assistant_index| assistant_index.checked_sub(1)); + + match trailing { + // `trailing < leading` always holds: the assistant message itself + // sits between the two positions. + Some(trailing) => vec![trailing, leading], + None => vec![leading], + } +} + +/// Get cache marker positions quantized to blocks of five messages. Used by +/// the OpenAI Responses clients. +/// +/// There the markers are stable *anchors* rather than the primary caching +/// mechanism: OpenAI's implicit mode already caches up to the latest message +/// on every request, and rarely-moving markers keep the WebSocket client's +/// incremental input intact (moving a marker changes an already-sent item and +/// forces a full resend). +/// +/// 0-4 messages: no cache markers +/// 5-9 messages: marker at index 4 +/// 10-14 messages: markers at indices 4 and 9 +/// 15-19 messages: markers at indices 9 and 14 +/// 20-24 messages: markers at indices 14 and 19 +/// etc. +pub fn cache_marker_positions(messages: &[Message]) -> Vec { + let stable_len = stable_prefix_len(messages); + + if stable_len < 5 { + return vec![]; + } + let remainder = stable_len % 5; + let last_marker = stable_len - remainder; + if last_marker > 5 { + vec![last_marker - 6, last_marker - 1] + } else { + vec![last_marker - 1] + } +} + +#[cfg(test)] +mod tests { + use super::*; + + /// The trailing marker of each request must land on the leading marker + /// position of the previous request, so that cache lookups always hit. + #[test] + fn per_request_markers_chain_across_requests() { + // Request 1: a single user message → one marker + let mut messages = vec![Message::new_user("u0")]; + assert_eq!(per_request_marker_positions(&messages), vec![0]); + + // Response appended, tool results form the next request + messages.push(Message::new_assistant("a1")); + messages.push(Message::new_user("u2")); + assert_eq!(per_request_marker_positions(&messages), vec![0, 2]); + + // Next turn adds a pending user message on top — the trailing marker + // still lands on the previous request's leading position (index 2) + messages.push(Message::new_assistant("a3")); + messages.push(Message::new_user("u4")); + messages.push(Message::new_user("u5 (pending)")); + assert_eq!(per_request_marker_positions(&messages), vec![2, 5]); + } + + #[test] + fn per_request_markers_respect_volatile_prefix() { + let mut messages: Vec = (0..18) + .map(|i| { + if i % 2 == 0 { + Message::new_user(format!("u{i}")) + } else { + Message::new_assistant(format!("a{i}")) + } + }) + .collect(); + assert_eq!(per_request_marker_positions(&messages), vec![16, 17]); + + // Volatile tail caps the markable prefix + messages[12].volatile = true; + assert_eq!(per_request_marker_positions(&messages), vec![10, 11]); + + // Everything volatile → no markers + messages[0].volatile = true; + assert!(per_request_marker_positions(&messages).is_empty()); + } + + #[test] + fn per_request_markers_without_assistant_history() { + assert!(per_request_marker_positions(&[]).is_empty()); + + let messages: Vec = (0..3).map(|i| Message::new_user(format!("u{i}"))).collect(); + assert_eq!(per_request_marker_positions(&messages), vec![2]); + } +} diff --git a/crates/ui_gpui/src/blocks/mod.rs b/crates/ui_gpui/src/blocks/mod.rs index 8c6cf968..abfdd38b 100644 --- a/crates/ui_gpui/src/blocks/mod.rs +++ b/crates/ui_gpui/src/blocks/mod.rs @@ -7,7 +7,7 @@ pub use data::*; use gpui::prelude::*; use gpui::{Context, Entity, Pixels, Task, px}; -use gpui_component::text::{TextView, TextViewState}; +use gpui_component::text::{SelectionFormat, TextView, TextViewState}; use std::cell::Cell; use std::rc::Rc; @@ -237,9 +237,14 @@ impl BlockView { // selection (e.g. `**bold**`, list markers, code fences) rather than the // rendered plain text. The hover copy button already copies full-block // source; this keeps partial-selection copy consistent. + let selection_format = if selectable { + SelectionFormat::Source + } else { + SelectionFormat::Plain + }; TextView::new(&state) .selectable(selectable) - .selectable_source(selectable) + .selection_format(selection_format) } /// Whether the copy button should currently render its "copied" checkmark.