From f87c4d73d186b504b734120433124ba5b84cb475 Mon Sep 17 00:00:00 2001 From: Kalvin C Date: Fri, 28 Aug 2026 04:53:42 +0000 Subject: [PATCH 01/37] chore: update canonical models json (#11641) Co-authored-by: Lifei Zhou --- .../src/canonical/data/canonical_models.json | 6371 +++++++++++------ .../src/canonical/data/provider_metadata.json | 134 +- .../src/canonical/name_builder.rs | 12 +- crates/goose/src/providers/formats/bedrock.rs | 20 +- 4 files changed, 4220 insertions(+), 2317 deletions(-) diff --git a/crates/goose-provider-types/src/canonical/data/canonical_models.json b/crates/goose-provider-types/src/canonical/data/canonical_models.json index 5ed5c4025a11..f73d54164cdf 100644 --- a/crates/goose-provider-types/src/canonical/data/canonical_models.json +++ b/crates/goose-provider-types/src/canonical/data/canonical_models.json @@ -4285,7 +4285,7 @@ "attachment": true, "reasoning": true, "tool_call": true, - "temperature": false, + "temperature": true, "knowledge": "2024-09-30", "release_date": "2025-11-13", "last_updated": "2025-11-13", @@ -4407,7 +4407,7 @@ "attachment": true, "reasoning": true, "tool_call": true, - "temperature": false, + "temperature": true, "knowledge": "2025-08-31", "release_date": "2025-12-11", "last_updated": "2025-12-11", @@ -4528,7 +4528,7 @@ "attachment": true, "reasoning": true, "tool_call": true, - "temperature": false, + "temperature": true, "knowledge": "2025-08-31", "release_date": "2026-02-05", "last_updated": "2026-02-05", @@ -4590,7 +4590,7 @@ "attachment": true, "reasoning": true, "tool_call": true, - "temperature": false, + "temperature": true, "knowledge": "2025-08-31", "release_date": "2026-03-05", "last_updated": "2026-03-05", @@ -4621,7 +4621,7 @@ "attachment": true, "reasoning": true, "tool_call": true, - "temperature": false, + "temperature": true, "knowledge": "2025-08-31", "release_date": "2026-03-17", "last_updated": "2026-03-17", @@ -4652,7 +4652,7 @@ "attachment": true, "reasoning": true, "tool_call": true, - "temperature": false, + "temperature": true, "knowledge": "2025-08-31", "release_date": "2026-03-17", "last_updated": "2026-03-17", @@ -6032,6 +6032,91 @@ "output": 128000 } }, + { + "id": "agnes/agnes-2.0-flash", + "name": "Agnes 2.0 Flash", + "attachment": true, + "reasoning": true, + "tool_call": true, + "temperature": true, + "release_date": "2026-05-25", + "last_updated": "2026-05-25", + "modalities": { + "input": [ + "text", + "image" + ], + "output": [ + "text" + ] + }, + "open_weights": false, + "cost": { + "input": 0.0, + "output": 0.0 + }, + "limit": { + "context": 512000, + "output": 65536 + } + }, + { + "id": "agnes/agnes-2.5-flash", + "name": "Agnes 2.5 Flash", + "attachment": true, + "reasoning": true, + "tool_call": true, + "temperature": true, + "release_date": "2026-07", + "last_updated": "2026-07", + "modalities": { + "input": [ + "text", + "image" + ], + "output": [ + "text" + ] + }, + "open_weights": false, + "cost": { + "input": 0.0, + "output": 0.0 + }, + "limit": { + "context": 512000, + "output": 65536 + } + }, + { + "id": "agnes/agnes-2.5-pro-alpha", + "name": "Agnes 2.5 Pro Alpha", + "attachment": true, + "reasoning": true, + "tool_call": true, + "temperature": true, + "release_date": "2026-07-24", + "last_updated": "2026-07-24", + "modalities": { + "input": [ + "text", + "image" + ], + "output": [ + "text" + ] + }, + "open_weights": true, + "cost": { + "input": 0.45, + "output": 0.9, + "cache_read": 0.0038 + }, + "limit": { + "context": 1000000, + "output": 65536 + } + }, { "id": "ai-router/gpt-5.4", "name": "GPT-5.4", @@ -6039,7 +6124,7 @@ "attachment": true, "reasoning": true, "tool_call": true, - "temperature": false, + "temperature": true, "knowledge": "2025-08-31", "release_date": "2026-03-05", "last_updated": "2026-03-05", @@ -8658,6 +8743,38 @@ "output": 128000 } }, + { + "id": "aixy/openai/gpt-4.1-mini", + "name": "GPT-4.1 mini", + "family": "gpt-mini", + "attachment": true, + "reasoning": false, + "tool_call": true, + "temperature": true, + "knowledge": "2024-04", + "release_date": "2025-04-14", + "last_updated": "2025-04-14", + "modalities": { + "input": [ + "text", + "image", + "pdf" + ], + "output": [ + "text" + ] + }, + "open_weights": false, + "cost": { + "input": 0.4, + "output": 1.6, + "cache_read": 0.1 + }, + "limit": { + "context": 1047576, + "output": 32768 + } + }, { "id": "aki-io/deepseek-v4-flash-0731-284b", "name": "DeepSeek V4 Flash 0731", @@ -9283,8 +9400,8 @@ }, "open_weights": false, "cost": { - "input": 0.86, - "output": 3.15 + "input": 0.573, + "output": 2.58 }, "limit": { "context": 202752, @@ -9311,8 +9428,8 @@ }, "open_weights": true, "cost": { - "input": 0.87, - "output": 3.48, + "input": 0.825, + "output": 3.301, "cache_read": 0.17 }, "limit": { @@ -10886,8 +11003,8 @@ }, "open_weights": true, "cost": { - "input": 0.43, - "output": 2.58 + "input": 0.172, + "output": 1.032 }, "limit": { "context": 262144, @@ -16637,10 +16754,10 @@ }, "open_weights": false, "cost": { - "input": 0.22, - "output": 1.32, - "cache_read": 0.022, - "cache_write": 0.275 + "input": 0.2, + "output": 1.2, + "cache_read": 0.02, + "cache_write": 0.25 }, "limit": { "context": 1050000, @@ -16670,10 +16787,10 @@ }, "open_weights": false, "cost": { - "input": 5.5, - "output": 33.0, - "cache_read": 0.55, - "cache_write": 6.875 + "input": 4.0, + "output": 20.0, + "cache_read": 0.4, + "cache_write": 5.0 }, "limit": { "context": 1050000, @@ -16703,10 +16820,10 @@ }, "open_weights": false, "cost": { - "input": 2.2, - "output": 13.2, - "cache_read": 0.22, - "cache_write": 2.75 + "input": 2.0, + "output": 12.0, + "cache_read": 0.2, + "cache_write": 2.5 }, "limit": { "context": 1050000, @@ -17699,7 +17816,7 @@ "attachment": true, "reasoning": true, "tool_call": true, - "temperature": false, + "temperature": true, "knowledge": "2025-08-31", "release_date": "2026-03-05", "last_updated": "2026-06-01", @@ -17812,10 +17929,10 @@ }, "open_weights": false, "cost": { - "input": 5.5, - "output": 33.0, - "cache_read": 0.55, - "cache_write": 6.875 + "input": 4.4, + "output": 22.0, + "cache_read": 0.44, + "cache_write": 5.5 }, "limit": { "context": 1050000, @@ -17873,7 +17990,7 @@ "text" ] }, - "open_weights": false, + "open_weights": true, "cost": { "input": 0.15, "output": 0.6 @@ -17901,7 +18018,7 @@ "text" ] }, - "open_weights": false, + "open_weights": true, "cost": { "input": 0.15, "output": 0.6 @@ -17929,7 +18046,7 @@ "text" ] }, - "open_weights": false, + "open_weights": true, "cost": { "input": 0.07, "output": 0.3 @@ -17957,7 +18074,7 @@ "text" ] }, - "open_weights": false, + "open_weights": true, "cost": { "input": 0.07, "output": 0.3 @@ -17985,7 +18102,7 @@ "text" ] }, - "open_weights": false, + "open_weights": true, "cost": { "input": 0.15, "output": 0.6 @@ -18013,7 +18130,7 @@ "text" ] }, - "open_weights": false, + "open_weights": true, "cost": { "input": 0.07, "output": 0.2 @@ -20117,7 +20234,7 @@ "attachment": true, "reasoning": true, "tool_call": true, - "temperature": false, + "temperature": true, "knowledge": "2024-09-30", "release_date": "2025-11-13", "last_updated": "2025-11-13", @@ -20144,7 +20261,7 @@ "attachment": true, "reasoning": true, "tool_call": true, - "temperature": false, + "temperature": true, "knowledge": "2025-08-31", "release_date": "2025-12-11", "last_updated": "2025-12-11", @@ -20171,7 +20288,7 @@ "attachment": true, "reasoning": true, "tool_call": true, - "temperature": false, + "temperature": true, "knowledge": "2025-08-31", "release_date": "2026-03-05", "last_updated": "2026-03-05", @@ -22428,7 +22545,7 @@ "attachment": true, "reasoning": true, "tool_call": true, - "temperature": false, + "temperature": true, "knowledge": "2025-08-31", "release_date": "2026-03-05", "last_updated": "2026-03-05", @@ -22460,7 +22577,7 @@ "attachment": true, "reasoning": true, "tool_call": true, - "temperature": false, + "temperature": true, "knowledge": "2025-08-31", "release_date": "2026-03-17", "last_updated": "2026-03-17", @@ -22492,7 +22609,7 @@ "attachment": true, "reasoning": true, "tool_call": true, - "temperature": false, + "temperature": true, "knowledge": "2025-08-31", "release_date": "2026-03-17", "last_updated": "2026-03-17", @@ -24725,7 +24842,7 @@ "attachment": true, "reasoning": true, "tool_call": true, - "temperature": false, + "temperature": true, "knowledge": "2025-08-31", "release_date": "2026-03-05", "last_updated": "2026-03-05", @@ -24757,7 +24874,7 @@ "attachment": true, "reasoning": true, "tool_call": true, - "temperature": false, + "temperature": true, "knowledge": "2025-08-31", "release_date": "2026-03-17", "last_updated": "2026-03-17", @@ -24789,7 +24906,7 @@ "attachment": true, "reasoning": true, "tool_call": true, - "temperature": false, + "temperature": true, "knowledge": "2025-08-31", "release_date": "2026-03-17", "last_updated": "2026-03-17", @@ -26485,6 +26602,35 @@ "output": 262144 } }, + { + "id": "baseten/zai-org/GLM-5.3-Flash", + "name": "GLM 5.3 Flash", + "family": "glm", + "attachment": true, + "reasoning": true, + "tool_call": true, + "temperature": true, + "release_date": "2026-08-26", + "last_updated": "2026-08-26", + "modalities": { + "input": [ + "text", + "image" + ], + "output": [ + "text" + ] + }, + "open_weights": false, + "cost": { + "input": 0.15, + "output": 0.5 + }, + "limit": { + "context": 1048576, + "output": 131072 + } + }, { "id": "berget/google/gemma-4-31B-it", "name": "Gemma 4 31B Instruct", @@ -27776,6 +27922,35 @@ "output": 131072 } }, + { + "id": "cline-pass/cline-pass/glm-5.3", + "name": "GLM-5.3", + "family": "glm", + "attachment": false, + "reasoning": true, + "tool_call": true, + "temperature": true, + "release_date": "2026-08-14", + "last_updated": "2026-08-14", + "modalities": { + "input": [ + "text" + ], + "output": [ + "text" + ] + }, + "open_weights": false, + "cost": { + "input": 1.4, + "output": 4.4, + "cache_read": 0.26 + }, + "limit": { + "context": 1000000, + "output": 131072 + } + }, { "id": "cline-pass/cline-pass/kimi-k2.6", "name": "Kimi K2.6", @@ -27961,8 +28136,8 @@ "cache_read": 0.06 }, "limit": { - "context": 512000, - "output": 128000 + "context": 1048576, + "output": 512000 } }, { @@ -28028,6 +28203,39 @@ "output": 64000 } }, + { + "id": "cline-pass/cline-pass/qwen3.8-max", + "name": "Qwen3.8 Max", + "family": "qwen", + "attachment": true, + "reasoning": true, + "tool_call": true, + "temperature": true, + "release_date": "2026-08-03", + "last_updated": "2026-08-03", + "modalities": { + "input": [ + "text", + "image", + "video", + "pdf" + ], + "output": [ + "text" + ] + }, + "open_weights": false, + "cost": { + "input": 2.0, + "output": 6.0, + "cache_read": 0.25, + "cache_write": 2.5 + }, + "limit": { + "context": 1000000, + "output": 131072 + } + }, { "id": "cloudferro-sherlock/MiniMaxAI/MiniMax-M2.5", "name": "MiniMax-M2.5", @@ -28170,6 +28378,159 @@ "output": 32000 } }, + { + "id": "cloudflare-ai-gateway/alibaba/qwen3-max", + "name": "Qwen3 Max", + "family": "qwen", + "attachment": false, + "reasoning": false, + "tool_call": true, + "temperature": true, + "knowledge": "2025-04", + "release_date": "2025-09-23", + "last_updated": "2025-09-23", + "modalities": { + "input": [ + "text" + ], + "output": [ + "text" + ] + }, + "open_weights": false, + "cost": { + "input": 1.2, + "output": 6.0 + }, + "limit": { + "context": 262144, + "output": 65536 + } + }, + { + "id": "cloudflare-ai-gateway/alibaba/qwen3.5-397b-a17b", + "name": "Qwen3.5 397B-A17B", + "family": "qwen", + "attachment": true, + "reasoning": true, + "tool_call": true, + "temperature": true, + "release_date": "2026-02-15", + "last_updated": "2026-02-15", + "modalities": { + "input": [ + "text", + "image", + "video", + "audio" + ], + "output": [ + "text" + ] + }, + "open_weights": true, + "cost": { + "input": 0.6, + "output": 3.6 + }, + "limit": { + "context": 262144, + "output": 65536 + } + }, + { + "id": "cloudflare-ai-gateway/alibaba/qwen3.7-max", + "name": "Qwen3.7 Max", + "family": "qwen", + "attachment": false, + "reasoning": true, + "tool_call": true, + "temperature": true, + "release_date": "2026-05-21", + "last_updated": "2026-05-21", + "modalities": { + "input": [ + "text" + ], + "output": [ + "text" + ] + }, + "open_weights": false, + "cost": { + "input": 1.25, + "output": 3.75, + "cache_read": 0.25 + }, + "limit": { + "context": 1000000, + "output": 65536 + } + }, + { + "id": "cloudflare-ai-gateway/alibaba/qwen3.7-plus", + "name": "Qwen3.7 Plus", + "family": "qwen", + "attachment": true, + "reasoning": true, + "tool_call": true, + "temperature": true, + "knowledge": "2025-04", + "release_date": "2026-06-02", + "last_updated": "2026-06-02", + "modalities": { + "input": [ + "text", + "image", + "video" + ], + "output": [ + "text" + ] + }, + "open_weights": false, + "cost": { + "input": 0.32, + "output": 1.28, + "cache_read": 0.064 + }, + "limit": { + "context": 1000000, + "output": 64000 + } + }, + { + "id": "cloudflare-ai-gateway/alibaba/qwen3.8-max", + "name": "Qwen3.8 Max", + "family": "qwen", + "attachment": true, + "reasoning": true, + "tool_call": true, + "temperature": true, + "release_date": "2026-08-03", + "last_updated": "2026-08-03", + "modalities": { + "input": [ + "text", + "image", + "video", + "pdf" + ], + "output": [ + "text" + ] + }, + "open_weights": false, + "cost": { + "input": 2.0, + "output": 6.0, + "cache_read": 0.25 + }, + "limit": { + "context": 1000000, + "output": 131072 + } + }, { "id": "cloudflare-ai-gateway/anthropic/claude-fable-5", "name": "Claude Fable 5", @@ -28179,7 +28540,7 @@ "tool_call": true, "temperature": false, "knowledge": "2026-01-31", - "release_date": "2026-06-07", + "release_date": "2026-06-09", "last_updated": "2026-06-09", "modalities": { "input": [ @@ -28278,7 +28639,7 @@ "tool_call": true, "temperature": true, "knowledge": "2025-05-31", - "release_date": "2026-02-04", + "release_date": "2026-02-05", "last_updated": "2026-03-13", "modalities": { "input": [ @@ -28311,7 +28672,7 @@ "tool_call": true, "temperature": false, "knowledge": "2026-01-31", - "release_date": "2026-04-14", + "release_date": "2026-04-16", "last_updated": "2026-04-16", "modalities": { "input": [ @@ -28476,7 +28837,7 @@ "tool_call": true, "temperature": false, "knowledge": "2026-01-31", - "release_date": "2026-06-29", + "release_date": "2026-06-30", "last_updated": "2026-06-30", "modalities": { "input": [ @@ -28501,46 +28862,16 @@ } }, { - "id": "cloudflare-ai-gateway/openai/gpt-3.5-turbo", - "name": "GPT-3.5-turbo", - "family": "gpt", + "id": "cloudflare-ai-gateway/deepseek/deepseek-v4-pro", + "name": "DeepSeek V4 Pro", + "family": "deepseek-thinking", "attachment": false, - "reasoning": false, - "tool_call": false, - "temperature": true, - "knowledge": "2021-09-01", - "release_date": "2023-03-01", - "last_updated": "2023-11-06", - "modalities": { - "input": [ - "text" - ], - "output": [ - "text" - ] - }, - "open_weights": false, - "cost": { - "input": 0.5, - "output": 1.5, - "cache_read": 0.0 - }, - "limit": { - "context": 16385, - "output": 4096 - } - }, - { - "id": "cloudflare-ai-gateway/openai/gpt-4", - "name": "GPT-4", - "family": "gpt", - "attachment": true, - "reasoning": false, + "reasoning": true, "tool_call": true, "temperature": true, - "knowledge": "2023-11", - "release_date": "2023-11-06", - "last_updated": "2024-04-09", + "knowledge": "2025-05", + "release_date": "2026-04-24", + "last_updated": "2026-04-24", "modalities": { "input": [ "text" @@ -28549,44 +28880,46 @@ "text" ] }, - "open_weights": false, + "open_weights": true, "cost": { - "input": 30.0, - "output": 60.0 + "input": 1.74, + "output": 3.48, + "cache_read": 0.145 }, "limit": { - "context": 8192, - "output": 8192 + "context": 131072, + "output": 384000 } }, { - "id": "cloudflare-ai-gateway/openai/gpt-4-turbo", - "name": "GPT-4 Turbo", - "family": "gpt", + "id": "cloudflare-ai-gateway/moonshotai/kimi-k3", + "name": "Kimi K3", + "family": "kimi-k3", "attachment": true, - "reasoning": false, + "reasoning": true, "tool_call": true, - "temperature": true, - "knowledge": "2023-12", - "release_date": "2023-11-06", - "last_updated": "2024-04-09", + "temperature": false, + "release_date": "2026-07-16", + "last_updated": "2026-07-16", "modalities": { "input": [ "text", - "image" + "image", + "video" ], "output": [ "text" ] }, - "open_weights": false, + "open_weights": true, "cost": { - "input": 10.0, - "output": 30.0 + "input": 3.0, + "output": 15.0, + "cache_read": 0.3 }, "limit": { - "context": 128000, - "output": 4096 + "context": 1048576, + "output": 131072 } }, { @@ -28680,7 +29013,7 @@ "cache_read": 0.025 }, "limit": { - "context": 1047576, + "context": 1000000, "output": 32768 } }, @@ -28775,7 +29108,7 @@ "cache_read": 0.125 }, "limit": { - "context": 400000, + "context": 128000, "output": 128000 } }, @@ -28806,7 +29139,7 @@ "cache_read": 0.025 }, "limit": { - "context": 400000, + "context": 128000, "output": 128000 } }, @@ -28837,40 +29170,10 @@ "cache_read": 0.005 }, "limit": { - "context": 400000, + "context": 128000, "output": 128000 } }, - { - "id": "cloudflare-ai-gateway/openai/gpt-5-pro", - "name": "GPT-5 Pro", - "family": "gpt-pro", - "attachment": true, - "reasoning": true, - "tool_call": true, - "temperature": false, - "knowledge": "2024-09-30", - "release_date": "2025-10-06", - "last_updated": "2025-10-06", - "modalities": { - "input": [ - "text", - "image" - ], - "output": [ - "text" - ] - }, - "open_weights": false, - "cost": { - "input": 15.0, - "output": 120.0 - }, - "limit": { - "context": 400000, - "output": 272000 - } - }, { "id": "cloudflare-ai-gateway/openai/gpt-5.1", "name": "GPT-5.1", @@ -28878,7 +29181,7 @@ "attachment": true, "reasoning": true, "tool_call": true, - "temperature": false, + "temperature": true, "knowledge": "2024-09-30", "release_date": "2025-11-13", "last_updated": "2025-11-13", @@ -28897,198 +29200,11 @@ "output": 10.0, "cache_read": 0.125 }, - "limit": { - "context": 400000, - "output": 128000 - } - }, - { - "id": "cloudflare-ai-gateway/openai/gpt-5.2", - "name": "GPT-5.2", - "family": "gpt", - "attachment": true, - "reasoning": true, - "tool_call": true, - "temperature": false, - "knowledge": "2025-08-31", - "release_date": "2025-12-11", - "last_updated": "2025-12-11", - "modalities": { - "input": [ - "text", - "image" - ], - "output": [ - "text" - ] - }, - "open_weights": false, - "cost": { - "input": 1.75, - "output": 14.0, - "cache_read": 0.175 - }, - "limit": { - "context": 400000, - "output": 128000 - } - }, - { - "id": "cloudflare-ai-gateway/openai/gpt-5.2-chat", - "name": "GPT-5.2 Chat", - "family": "gpt-codex", - "attachment": true, - "reasoning": true, - "tool_call": true, - "temperature": false, - "knowledge": "2025-08-31", - "release_date": "2025-12-11", - "last_updated": "2025-12-11", - "modalities": { - "input": [ - "text", - "image" - ], - "output": [ - "text" - ] - }, - "open_weights": false, - "cost": { - "input": 1.75, - "output": 14.0, - "cache_read": 0.175 - }, "limit": { "context": 128000, - "output": 16384 - } - }, - { - "id": "cloudflare-ai-gateway/openai/gpt-5.2-pro", - "name": "GPT-5.2 Pro", - "family": "gpt-pro", - "attachment": true, - "reasoning": true, - "tool_call": true, - "temperature": false, - "knowledge": "2025-08-31", - "release_date": "2025-12-11", - "last_updated": "2025-12-11", - "modalities": { - "input": [ - "text", - "image" - ], - "output": [ - "text" - ] - }, - "open_weights": false, - "cost": { - "input": 21.0, - "output": 168.0 - }, - "limit": { - "context": 400000, - "output": 128000 - } - }, - { - "id": "cloudflare-ai-gateway/openai/gpt-5.3-chat", - "name": "GPT-5.3 Chat (latest)", - "family": "gpt", - "attachment": true, - "reasoning": false, - "tool_call": true, - "temperature": true, - "knowledge": "2025-08-31", - "release_date": "2026-03-03", - "last_updated": "2026-03-03", - "modalities": { - "input": [ - "text", - "image" - ], - "output": [ - "text" - ] - }, - "open_weights": false, - "cost": { - "input": 1.75, - "output": 14.0, - "cache_read": 0.175 - }, - "limit": { - "context": 128000, - "output": 16384 - } - }, - { - "id": "cloudflare-ai-gateway/openai/gpt-5.3-codex", - "name": "GPT-5.3 Codex", - "family": "gpt-codex", - "attachment": true, - "reasoning": true, - "tool_call": true, - "temperature": false, - "knowledge": "2025-08-31", - "release_date": "2026-02-05", - "last_updated": "2026-02-05", - "modalities": { - "input": [ - "text", - "image", - "pdf" - ], - "output": [ - "text" - ] - }, - "open_weights": false, - "cost": { - "input": 1.75, - "output": 14.0, - "cache_read": 0.175 - }, - "limit": { - "context": 400000, "output": 128000 } }, - { - "id": "cloudflare-ai-gateway/openai/gpt-5.3-codex-spark", - "name": "GPT-5.3 Codex Spark", - "family": "gpt-codex-spark", - "attachment": true, - "reasoning": true, - "tool_call": true, - "temperature": false, - "knowledge": "2025-08-31", - "release_date": "2026-02-05", - "last_updated": "2026-02-05", - "modalities": { - "input": [ - "text", - "image", - "pdf" - ], - "output": [ - "text" - ] - }, - "open_weights": false, - "cost": { - "input": 1.75, - "output": 14.0, - "cache_read": 0.175 - }, - "limit": { - "context": 128000, - "output": 32000 - } - }, { "id": "cloudflare-ai-gateway/openai/gpt-5.4", "name": "GPT-5.4", @@ -29096,7 +29212,7 @@ "attachment": true, "reasoning": true, "tool_call": true, - "temperature": false, + "temperature": true, "knowledge": "2025-08-31", "release_date": "2026-03-05", "last_updated": "2026-03-05", @@ -29117,7 +29233,7 @@ "cache_read": 0.25 }, "limit": { - "context": 1050000, + "context": 1000000, "output": 128000 } }, @@ -29128,7 +29244,7 @@ "attachment": true, "reasoning": true, "tool_call": true, - "temperature": false, + "temperature": true, "knowledge": "2025-08-31", "release_date": "2026-03-17", "last_updated": "2026-03-17", @@ -29148,7 +29264,7 @@ "cache_read": 0.075 }, "limit": { - "context": 400000, + "context": 128000, "output": 128000 } }, @@ -29159,7 +29275,7 @@ "attachment": true, "reasoning": true, "tool_call": true, - "temperature": false, + "temperature": true, "knowledge": "2025-08-31", "release_date": "2026-03-17", "last_updated": "2026-03-17", @@ -29179,7 +29295,7 @@ "cache_read": 0.02 }, "limit": { - "context": 400000, + "context": 128000, "output": 128000 } }, @@ -29209,7 +29325,7 @@ "output": 180.0 }, "limit": { - "context": 1050000, + "context": 1000000, "output": 128000 } }, @@ -29237,11 +29353,10 @@ "open_weights": false, "cost": { "input": 5.0, - "output": 30.0, - "cache_read": 0.5 + "output": 30.0 }, "limit": { - "context": 1050000, + "context": 1000000, "output": 128000 } }, @@ -29272,40 +29387,7 @@ "output": 180.0 }, "limit": { - "context": 1050000, - "output": 128000 - } - }, - { - "id": "cloudflare-ai-gateway/openai/gpt-5.6", - "name": "GPT-5.6", - "family": "gpt-sol", - "attachment": true, - "reasoning": true, - "tool_call": true, - "temperature": false, - "knowledge": "2026-02-16", - "release_date": "2026-07-09", - "last_updated": "2026-07-09", - "modalities": { - "input": [ - "text", - "image", - "pdf" - ], - "output": [ - "text" - ] - }, - "open_weights": false, - "cost": { - "input": 5.0, - "output": 30.0, - "cache_read": 0.5, - "cache_write": 6.25 - }, - "limit": { - "context": 1050000, + "context": 1000000, "output": 128000 } }, @@ -29365,10 +29447,10 @@ }, "open_weights": false, "cost": { - "input": 5.0, - "output": 30.0, - "cache_read": 0.5, - "cache_write": 6.25 + "input": 2.0, + "output": 10.0, + "cache_read": 0.25, + "cache_write": 3.125 }, "limit": { "context": 1050000, @@ -29409,16 +29491,16 @@ } }, { - "id": "cloudflare-ai-gateway/openai/o1", - "name": "o1", + "id": "cloudflare-ai-gateway/openai/o3", + "name": "o3", "family": "o", "attachment": true, "reasoning": true, "tool_call": true, "temperature": false, - "knowledge": "2023-09", - "release_date": "2024-12-05", - "last_updated": "2024-12-05", + "knowledge": "2024-05", + "release_date": "2025-04-16", + "last_updated": "2025-04-16", "modalities": { "input": [ "text", @@ -29431,9 +29513,9 @@ }, "open_weights": false, "cost": { - "input": 15.0, - "output": 60.0, - "cache_read": 7.5 + "input": 2.0, + "output": 8.0, + "cache_read": 0.5 }, "limit": { "context": 200000, @@ -29441,20 +29523,19 @@ } }, { - "id": "cloudflare-ai-gateway/openai/o1-pro", - "name": "o1-pro", - "family": "o-pro", - "attachment": true, + "id": "cloudflare-ai-gateway/openai/o3-mini", + "name": "o3-mini", + "family": "o-mini", + "attachment": false, "reasoning": true, "tool_call": true, "temperature": false, - "knowledge": "2023-09", - "release_date": "2025-03-19", - "last_updated": "2025-03-19", + "knowledge": "2024-05", + "release_date": "2024-12-20", + "last_updated": "2025-01-29", "modalities": { "input": [ - "text", - "image" + "text" ], "output": [ "text" @@ -29462,8 +29543,9 @@ }, "open_weights": false, "cost": { - "input": 150.0, - "output": 600.0 + "input": 1.1, + "output": 4.4, + "cache_read": 0.55 }, "limit": { "context": 200000, @@ -29471,9 +29553,9 @@ } }, { - "id": "cloudflare-ai-gateway/openai/o3", - "name": "o3", - "family": "o", + "id": "cloudflare-ai-gateway/openai/o4-mini", + "name": "o4-mini", + "family": "o-mini", "attachment": true, "reasoning": true, "tool_call": true, @@ -29481,6 +29563,36 @@ "knowledge": "2024-05", "release_date": "2025-04-16", "last_updated": "2025-04-16", + "modalities": { + "input": [ + "text", + "image" + ], + "output": [ + "text" + ] + }, + "open_weights": false, + "cost": { + "input": 1.1, + "output": 4.4, + "cache_read": 0.275 + }, + "limit": { + "context": 200000, + "output": 100000 + } + }, + { + "id": "cloudflare-ai-gateway/xai/grok-4.20-0309-non-reasoning", + "name": "Grok 4.20 (Non-Reasoning)", + "family": "grok", + "attachment": true, + "reasoning": false, + "tool_call": true, + "temperature": true, + "release_date": "2026-03-09", + "last_updated": "2026-03-09", "modalities": { "input": [ "text", @@ -29494,28 +29606,60 @@ "open_weights": false, "cost": { "input": 2.0, - "output": 8.0, - "cache_read": 0.5 + "output": 6.0, + "cache_read": 0.2 }, "limit": { - "context": 200000, - "output": 100000 + "context": 2000000, + "output": 30000 } }, { - "id": "cloudflare-ai-gateway/openai/o3-mini", - "name": "o3-mini", - "family": "o-mini", - "attachment": false, + "id": "cloudflare-ai-gateway/xai/grok-4.20-0309-reasoning", + "name": "Grok 4.20 (Reasoning)", + "family": "grok", + "attachment": true, "reasoning": true, "tool_call": true, - "temperature": false, - "knowledge": "2024-05", - "release_date": "2024-12-20", - "last_updated": "2025-01-29", + "temperature": true, + "release_date": "2026-03-09", + "last_updated": "2026-03-09", "modalities": { "input": [ + "text", + "image", + "pdf" + ], + "output": [ "text" + ] + }, + "open_weights": false, + "cost": { + "input": 2.0, + "output": 6.0, + "cache_read": 0.2 + }, + "limit": { + "context": 2000000, + "output": 30000 + } + }, + { + "id": "cloudflare-ai-gateway/xai/grok-4.3", + "name": "Grok 4.3", + "family": "grok", + "attachment": true, + "reasoning": true, + "tool_call": true, + "temperature": true, + "release_date": "2026-04-17", + "last_updated": "2026-04-17", + "modalities": { + "input": [ + "text", + "image", + "pdf" ], "output": [ "text" @@ -29523,26 +29667,25 @@ }, "open_weights": false, "cost": { - "input": 1.1, - "output": 4.4, - "cache_read": 0.55 + "input": 1.25, + "output": 2.5, + "cache_read": 0.2 }, "limit": { - "context": 200000, - "output": 100000 + "context": 1000000, + "output": 30000 } }, { - "id": "cloudflare-ai-gateway/openai/o3-pro", - "name": "o3-pro", - "family": "o-pro", + "id": "cloudflare-ai-gateway/xai/grok-4.5", + "name": "Grok 4.5", + "family": "grok", "attachment": true, "reasoning": true, "tool_call": true, - "temperature": false, - "knowledge": "2024-05", - "release_date": "2025-06-10", - "last_updated": "2025-06-10", + "temperature": true, + "release_date": "2026-07-08", + "last_updated": "2026-07-08", "modalities": { "input": [ "text", @@ -29554,25 +29697,26 @@ }, "open_weights": false, "cost": { - "input": 20.0, - "output": 80.0 + "input": 2.0, + "output": 6.0, + "cache_read": 0.3 }, "limit": { - "context": 200000, - "output": 100000 + "context": 500000, + "output": 500000 } }, { - "id": "cloudflare-ai-gateway/openai/o4-mini", - "name": "o4-mini", - "family": "o-mini", + "id": "cloudflare-ai-gateway/xai/grok-4.6", + "name": "Grok 4.6", + "family": "grok", "attachment": true, "reasoning": true, "tool_call": true, - "temperature": false, - "knowledge": "2024-05", - "release_date": "2025-04-16", - "last_updated": "2025-04-16", + "temperature": true, + "knowledge": "2026-02-01", + "release_date": "2026-08-12", + "last_updated": "2026-08-12", "modalities": { "input": [ "text", @@ -29584,17 +29728,17 @@ }, "open_weights": false, "cost": { - "input": 1.1, - "output": 4.4, - "cache_read": 0.275 + "input": 2.0, + "output": 6.0, + "cache_read": 0.5 }, "limit": { - "context": 200000, - "output": 100000 + "context": 500000, + "output": 500000 } }, { - "id": "cloudflare-ai-gateway/workers-ai/@cf/aisingapore/gemma-sea-lion-v4-27b-it", + "id": "cloudflare-workers-ai/@cf/aisingapore/gemma-sea-lion-v4-27b-it", "name": "Gemma Sea Lion V4 27B It", "family": "gemma", "attachment": false, @@ -29622,15 +29766,16 @@ } }, { - "id": "cloudflare-ai-gateway/workers-ai/@cf/deepseek-ai/deepseek-r1-distill-qwen-32b", + "id": "cloudflare-workers-ai/@cf/deepseek-ai/deepseek-r1-distill-qwen-32b", "name": "Deepseek R1 Distill Qwen 32B", "family": "deepseek-thinking", "attachment": false, "reasoning": true, "tool_call": false, "temperature": true, + "knowledge": "2024-07", "release_date": "2025-01-20", - "last_updated": "2025-01-20", + "last_updated": "2025-05-29", "modalities": { "input": [ "text" @@ -29650,7 +29795,66 @@ } }, { - "id": "cloudflare-ai-gateway/workers-ai/@cf/google/gemma-4-26b-a4b-it", + "id": "cloudflare-workers-ai/@cf/deepseek-ai/deepseek-v4-flash", + "name": "DeepSeek V4 Flash 0731", + "family": "deepseek-flash", + "attachment": false, + "reasoning": true, + "tool_call": true, + "temperature": true, + "knowledge": "2025-05", + "release_date": "2026-07-31", + "last_updated": "2026-07-31", + "modalities": { + "input": [ + "text" + ], + "output": [ + "text" + ] + }, + "open_weights": true, + "cost": { + "input": 0.44, + "output": 1.32, + "cache_read": 0.014 + }, + "limit": { + "context": 1310720, + "output": 1048576 + } + }, + { + "id": "cloudflare-workers-ai/@cf/deepseek-ai/deepseek-v4-pro", + "name": "DeepSeek V4 Pro 0813", + "family": "deepseek-thinking", + "attachment": false, + "reasoning": true, + "tool_call": true, + "temperature": true, + "release_date": "2026-08-12", + "last_updated": "2026-08-22", + "modalities": { + "input": [ + "text" + ], + "output": [ + "text" + ] + }, + "open_weights": true, + "cost": { + "input": 1.32, + "output": 3.96, + "cache_read": 0.044 + }, + "limit": { + "context": 1048576, + "output": 1048576 + } + }, + { + "id": "cloudflare-workers-ai/@cf/google/gemma-4-26b-a4b-it", "name": "Gemma 4 26B A4B IT", "family": "gemma", "attachment": true, @@ -29679,15 +29883,15 @@ } }, { - "id": "cloudflare-ai-gateway/workers-ai/@cf/ibm-granite/granite-4.0-h-micro", + "id": "cloudflare-workers-ai/@cf/ibm-granite/granite-4.0-h-micro", "name": "Granite 4.0 H Micro", "family": "granite", "attachment": false, "reasoning": false, "tool_call": true, "temperature": true, - "release_date": "2025-10-02", - "last_updated": "2025-10-02", + "release_date": "2025-10-07", + "last_updated": "2025-10-07", "modalities": { "input": [ "text" @@ -29707,7 +29911,7 @@ } }, { - "id": "cloudflare-ai-gateway/workers-ai/@cf/meta/llama-3.1-8b-instruct-fp8", + "id": "cloudflare-workers-ai/@cf/meta/llama-3.1-8b-instruct-fp8", "name": "Llama 3.1 8B Instruct fp8", "family": "llama", "attachment": false, @@ -29736,7 +29940,7 @@ } }, { - "id": "cloudflare-ai-gateway/workers-ai/@cf/meta/llama-3.2-11b-vision-instruct", + "id": "cloudflare-workers-ai/@cf/meta/llama-3.2-11b-vision-instruct", "name": "Llama 3.2 11B Vision Instruct", "family": "llama", "attachment": true, @@ -29766,7 +29970,7 @@ } }, { - "id": "cloudflare-ai-gateway/workers-ai/@cf/meta/llama-3.2-1b-instruct", + "id": "cloudflare-workers-ai/@cf/meta/llama-3.2-1b-instruct", "name": "Llama 3.2 1B Instruct", "family": "llama", "attachment": false, @@ -29795,703 +29999,7 @@ } }, { - "id": "cloudflare-ai-gateway/workers-ai/@cf/meta/llama-3.2-3b-instruct", - "name": "Llama 3.2 3B Instruct", - "family": "llama", - "attachment": false, - "reasoning": false, - "tool_call": false, - "temperature": true, - "knowledge": "2023-12", - "release_date": "2024-09-25", - "last_updated": "2024-09-25", - "modalities": { - "input": [ - "text" - ], - "output": [ - "text" - ] - }, - "open_weights": true, - "cost": { - "input": 0.0509, - "output": 0.335 - }, - "limit": { - "context": 80000, - "output": 80000 - } - }, - { - "id": "cloudflare-ai-gateway/workers-ai/@cf/meta/llama-3.3-70b-instruct-fp8-fast", - "name": "Llama 3.3 70B Instruct fp8 Fast", - "family": "llama", - "attachment": false, - "reasoning": false, - "tool_call": true, - "temperature": true, - "knowledge": "2023-12", - "release_date": "2024-12-06", - "last_updated": "2024-12-06", - "modalities": { - "input": [ - "text" - ], - "output": [ - "text" - ] - }, - "open_weights": true, - "cost": { - "input": 0.293, - "output": 2.253 - }, - "limit": { - "context": 24000, - "output": 24000 - } - }, - { - "id": "cloudflare-ai-gateway/workers-ai/@cf/meta/llama-4-scout-17b-16e-instruct", - "name": "Llama 4 Scout 17B 16E Instruct", - "family": "llama", - "attachment": true, - "reasoning": false, - "tool_call": true, - "temperature": true, - "knowledge": "2024-08", - "release_date": "2025-04-05", - "last_updated": "2025-04-05", - "modalities": { - "input": [ - "text", - "image" - ], - "output": [ - "text" - ] - }, - "open_weights": true, - "cost": { - "input": 0.27, - "output": 0.85 - }, - "limit": { - "context": 131000, - "output": 16384 - } - }, - { - "id": "cloudflare-ai-gateway/workers-ai/@cf/meta/llama-guard-3-8b", - "name": "Llama Guard 3 8B", - "family": "llama", - "attachment": false, - "reasoning": false, - "tool_call": false, - "temperature": true, - "knowledge": "2023-12", - "release_date": "2024-07-23", - "last_updated": "2024-07-23", - "modalities": { - "input": [ - "text" - ], - "output": [ - "text" - ] - }, - "open_weights": true, - "cost": { - "input": 0.484, - "output": 0.03 - }, - "limit": { - "context": 131072, - "output": 131072 - } - }, - { - "id": "cloudflare-ai-gateway/workers-ai/@cf/mistralai/mistral-small-3.1-24b-instruct", - "name": "Mistral Small 3.1 24B Instruct", - "family": "mistral-small", - "attachment": false, - "reasoning": false, - "tool_call": true, - "temperature": true, - "knowledge": "2024-06", - "release_date": "2025-03-17", - "last_updated": "2025-03-17", - "modalities": { - "input": [ - "text" - ], - "output": [ - "text" - ] - }, - "open_weights": true, - "cost": { - "input": 0.351, - "output": 0.555 - }, - "limit": { - "context": 128000, - "output": 128000 - } - }, - { - "id": "cloudflare-ai-gateway/workers-ai/@cf/moonshotai/kimi-k2.6", - "name": "Kimi K2.6", - "family": "kimi-k2", - "attachment": true, - "reasoning": true, - "tool_call": true, - "temperature": true, - "knowledge": "2025-01", - "release_date": "2026-04-21", - "last_updated": "2026-04-21", - "modalities": { - "input": [ - "text", - "image" - ], - "output": [ - "text" - ] - }, - "open_weights": true, - "cost": { - "input": 0.95, - "output": 4.0, - "cache_read": 0.16 - }, - "limit": { - "context": 262144, - "output": 256000 - } - }, - { - "id": "cloudflare-ai-gateway/workers-ai/@cf/moonshotai/kimi-k2.7-code", - "name": "Kimi K2.7 Code", - "family": "kimi-k2", - "attachment": true, - "reasoning": true, - "tool_call": true, - "temperature": true, - "knowledge": "2025-01", - "release_date": "2026-06-12", - "last_updated": "2026-06-12", - "modalities": { - "input": [ - "text", - "image" - ], - "output": [ - "text" - ] - }, - "open_weights": true, - "cost": { - "input": 0.95, - "output": 4.0, - "cache_read": 0.19 - }, - "limit": { - "context": 262144, - "output": 262144 - } - }, - { - "id": "cloudflare-ai-gateway/workers-ai/@cf/nvidia/nemotron-3-120b-a12b", - "name": "Nemotron 3 Super 120B", - "family": "nemotron", - "attachment": false, - "reasoning": true, - "tool_call": true, - "temperature": true, - "release_date": "2026-03-11", - "last_updated": "2026-03-11", - "modalities": { - "input": [ - "text" - ], - "output": [ - "text" - ] - }, - "open_weights": true, - "cost": { - "input": 0.5, - "output": 1.5 - }, - "limit": { - "context": 256000, - "output": 256000 - } - }, - { - "id": "cloudflare-ai-gateway/workers-ai/@cf/openai/gpt-oss-120b", - "name": "GPT OSS 120B", - "family": "gpt-oss", - "attachment": false, - "reasoning": true, - "tool_call": true, - "temperature": true, - "release_date": "2025-08-05", - "last_updated": "2025-08-05", - "modalities": { - "input": [ - "text" - ], - "output": [ - "text" - ] - }, - "open_weights": true, - "cost": { - "input": 0.35, - "output": 0.75 - }, - "limit": { - "context": 128000, - "output": 16384 - } - }, - { - "id": "cloudflare-ai-gateway/workers-ai/@cf/openai/gpt-oss-20b", - "name": "GPT OSS 20B", - "family": "gpt-oss", - "attachment": false, - "reasoning": true, - "tool_call": true, - "temperature": true, - "release_date": "2025-08-05", - "last_updated": "2025-08-05", - "modalities": { - "input": [ - "text" - ], - "output": [ - "text" - ] - }, - "open_weights": true, - "cost": { - "input": 0.2, - "output": 0.3 - }, - "limit": { - "context": 128000, - "output": 16384 - } - }, - { - "id": "cloudflare-ai-gateway/workers-ai/@cf/qwen/qwen2.5-coder-32b-instruct", - "name": "Qwen2.5 Coder 32B Instruct", - "family": "qwen", - "attachment": false, - "reasoning": false, - "tool_call": false, - "temperature": true, - "release_date": "2024-11-12", - "last_updated": "2024-11-12", - "modalities": { - "input": [ - "text" - ], - "output": [ - "text" - ] - }, - "open_weights": true, - "cost": { - "input": 0.66, - "output": 1.0 - }, - "limit": { - "context": 32768, - "output": 32768 - } - }, - { - "id": "cloudflare-ai-gateway/workers-ai/@cf/qwen/qwen3-30b-a3b-fp8", - "name": "Qwen3 30B A3b fp8", - "family": "qwen", - "attachment": false, - "reasoning": true, - "tool_call": true, - "temperature": true, - "release_date": "2025-04-28", - "last_updated": "2025-04-28", - "modalities": { - "input": [ - "text" - ], - "output": [ - "text" - ] - }, - "open_weights": true, - "cost": { - "input": 0.0509, - "output": 0.335 - }, - "limit": { - "context": 32768, - "output": 32768 - } - }, - { - "id": "cloudflare-ai-gateway/workers-ai/@cf/qwen/qwq-32b", - "name": "Qwq 32B", - "family": "qwen", - "attachment": false, - "reasoning": true, - "tool_call": false, - "temperature": true, - "knowledge": "2024-04", - "release_date": "2025-03-05", - "last_updated": "2025-03-05", - "modalities": { - "input": [ - "text" - ], - "output": [ - "text" - ] - }, - "open_weights": true, - "cost": { - "input": 0.66, - "output": 1.0 - }, - "limit": { - "context": 24000, - "output": 24000 - } - }, - { - "id": "cloudflare-ai-gateway/workers-ai/@cf/zai-org/glm-4.7-flash", - "name": "GLM-4.7-Flash", - "family": "glm-flash", - "attachment": false, - "reasoning": true, - "tool_call": true, - "temperature": true, - "knowledge": "2025-04", - "release_date": "2026-01-19", - "last_updated": "2026-01-19", - "modalities": { - "input": [ - "text" - ], - "output": [ - "text" - ] - }, - "open_weights": true, - "cost": { - "input": 0.0605, - "output": 0.4 - }, - "limit": { - "context": 131072, - "output": 131072 - } - }, - { - "id": "cloudflare-ai-gateway/workers-ai/@cf/zai-org/glm-5.2", - "name": "Glm 5.2", - "family": "glm", - "attachment": false, - "reasoning": true, - "tool_call": true, - "temperature": true, - "release_date": "2026-06-13", - "last_updated": "2026-06-13", - "modalities": { - "input": [ - "text" - ], - "output": [ - "text" - ] - }, - "open_weights": true, - "cost": { - "input": 1.4, - "output": 4.4, - "cache_read": 0.26 - }, - "limit": { - "context": 262144, - "output": 256000 - } - }, - { - "id": "cloudflare-workers-ai/@cf/aisingapore/gemma-sea-lion-v4-27b-it", - "name": "Gemma Sea Lion V4 27B It", - "family": "gemma", - "attachment": false, - "reasoning": false, - "tool_call": false, - "temperature": true, - "release_date": "2025-09-23", - "last_updated": "2025-09-23", - "modalities": { - "input": [ - "text" - ], - "output": [ - "text" - ] - }, - "open_weights": true, - "cost": { - "input": 0.351, - "output": 0.555 - }, - "limit": { - "context": 128000, - "output": 128000 - } - }, - { - "id": "cloudflare-workers-ai/@cf/deepseek-ai/deepseek-r1-distill-qwen-32b", - "name": "Deepseek R1 Distill Qwen 32B", - "family": "deepseek-thinking", - "attachment": false, - "reasoning": true, - "tool_call": false, - "temperature": true, - "knowledge": "2024-07", - "release_date": "2025-01-20", - "last_updated": "2025-05-29", - "modalities": { - "input": [ - "text" - ], - "output": [ - "text" - ] - }, - "open_weights": true, - "cost": { - "input": 0.497, - "output": 4.881 - }, - "limit": { - "context": 80000, - "output": 80000 - } - }, - { - "id": "cloudflare-workers-ai/@cf/deepseek-ai/deepseek-v4-flash", - "name": "DeepSeek V4 Flash 0731", - "family": "deepseek-flash", - "attachment": false, - "reasoning": true, - "tool_call": true, - "temperature": true, - "knowledge": "2025-05", - "release_date": "2026-07-31", - "last_updated": "2026-07-31", - "modalities": { - "input": [ - "text" - ], - "output": [ - "text" - ] - }, - "open_weights": true, - "cost": { - "input": 0.44, - "output": 1.32, - "cache_read": 0.014 - }, - "limit": { - "context": 1310720, - "output": 1048576 - } - }, - { - "id": "cloudflare-workers-ai/@cf/deepseek-ai/deepseek-v4-pro", - "name": "DeepSeek V4 Pro 0813", - "family": "deepseek-thinking", - "attachment": false, - "reasoning": true, - "tool_call": true, - "temperature": true, - "release_date": "2026-08-12", - "last_updated": "2026-08-22", - "modalities": { - "input": [ - "text" - ], - "output": [ - "text" - ] - }, - "open_weights": true, - "cost": { - "input": 1.32, - "output": 3.96, - "cache_read": 0.044 - }, - "limit": { - "context": 1048576, - "output": 1048576 - } - }, - { - "id": "cloudflare-workers-ai/@cf/google/gemma-4-26b-a4b-it", - "name": "Gemma 4 26B A4B IT", - "family": "gemma", - "attachment": true, - "reasoning": true, - "tool_call": true, - "temperature": true, - "release_date": "2026-04-02", - "last_updated": "2026-04-02", - "modalities": { - "input": [ - "text", - "image" - ], - "output": [ - "text" - ] - }, - "open_weights": true, - "cost": { - "input": 0.1, - "output": 0.3 - }, - "limit": { - "context": 256000, - "output": 16384 - } - }, - { - "id": "cloudflare-workers-ai/@cf/ibm-granite/granite-4.0-h-micro", - "name": "Granite 4.0 H Micro", - "family": "granite", - "attachment": false, - "reasoning": false, - "tool_call": true, - "temperature": true, - "release_date": "2025-10-07", - "last_updated": "2025-10-07", - "modalities": { - "input": [ - "text" - ], - "output": [ - "text" - ] - }, - "open_weights": true, - "cost": { - "input": 0.017, - "output": 0.112 - }, - "limit": { - "context": 131000, - "output": 131000 - } - }, - { - "id": "cloudflare-workers-ai/@cf/meta/llama-3.1-8b-instruct-fp8", - "name": "Llama 3.1 8B Instruct fp8", - "family": "llama", - "attachment": false, - "reasoning": false, - "tool_call": false, - "temperature": true, - "knowledge": "2023-12", - "release_date": "2024-07-23", - "last_updated": "2024-07-23", - "modalities": { - "input": [ - "text" - ], - "output": [ - "text" - ] - }, - "open_weights": true, - "cost": { - "input": 0.152, - "output": 0.287 - }, - "limit": { - "context": 32000, - "output": 32000 - } - }, - { - "id": "cloudflare-workers-ai/@cf/meta/llama-3.2-11b-vision-instruct", - "name": "Llama 3.2 11B Vision Instruct", - "family": "llama", - "attachment": true, - "reasoning": false, - "tool_call": false, - "temperature": true, - "knowledge": "2023-12", - "release_date": "2024-09-25", - "last_updated": "2024-09-25", - "modalities": { - "input": [ - "text", - "image" - ], - "output": [ - "text" - ] - }, - "open_weights": true, - "cost": { - "input": 0.0485, - "output": 0.676 - }, - "limit": { - "context": 128000, - "output": 128000 - } - }, - { - "id": "cloudflare-workers-ai/@cf/meta/llama-3.2-1b-instruct", - "name": "Llama 3.2 1B Instruct", - "family": "llama", - "attachment": false, - "reasoning": false, - "tool_call": false, - "temperature": true, - "knowledge": "2023-12", - "release_date": "2024-09-25", - "last_updated": "2024-09-25", - "modalities": { - "input": [ - "text" - ], - "output": [ - "text" - ] - }, - "open_weights": true, - "cost": { - "input": 0.027, - "output": 0.201 - }, - "limit": { - "context": 60000, - "output": 60000 - } - }, - { - "id": "cloudflare-workers-ai/@cf/meta/llama-3.2-3b-instruct", + "id": "cloudflare-workers-ai/@cf/meta/llama-3.2-3b-instruct", "name": "Llama 3.2 3B Instruct", "family": "llama", "attachment": false, @@ -30954,6 +30462,36 @@ "output": 256000 } }, + { + "id": "cloudflare-workers-ai/@cf/zai-org/glm-5.3-flash", + "name": "Glm 5.3 Flash", + "family": "glm", + "attachment": true, + "reasoning": true, + "tool_call": true, + "temperature": true, + "release_date": "2026-08-26", + "last_updated": "2026-08-26", + "modalities": { + "input": [ + "text", + "image" + ], + "output": [ + "text" + ] + }, + "open_weights": true, + "cost": { + "input": 0.15, + "output": 0.5, + "cache_read": 0.03 + }, + "limit": { + "context": 1310720, + "output": 1310720 + } + }, { "id": "cohere/c4ai-aya-expanse-32b", "name": "Aya Expanse 32B", @@ -32784,7 +32322,7 @@ "attachment": true, "reasoning": true, "tool_call": true, - "temperature": false, + "temperature": true, "knowledge": "2024-09-30", "release_date": "2025-11-13", "last_updated": "2025-11-13", @@ -32816,7 +32354,7 @@ "attachment": true, "reasoning": true, "tool_call": true, - "temperature": false, + "temperature": true, "knowledge": "2025-08-31", "release_date": "2026-03-05", "last_updated": "2026-03-05", @@ -33710,8 +33248,9 @@ }, "open_weights": false, "cost": { - "input": 1.671, - "output": 5.57 + "input": 1.393, + "output": 7.13, + "cache_read": 0.139 }, "limit": { "context": 256000, @@ -36065,7 +35604,7 @@ "attachment": true, "reasoning": true, "tool_call": true, - "temperature": false, + "temperature": true, "knowledge": "2025-08-31", "release_date": "2026-03-05", "last_updated": "2026-03-05", @@ -36097,7 +35636,7 @@ "attachment": true, "reasoning": true, "tool_call": true, - "temperature": false, + "temperature": true, "knowledge": "2025-08-31", "release_date": "2026-03-17", "last_updated": "2026-03-17", @@ -36129,7 +35668,7 @@ "attachment": true, "reasoning": true, "tool_call": true, - "temperature": false, + "temperature": true, "knowledge": "2025-08-31", "release_date": "2026-03-17", "last_updated": "2026-03-17", @@ -37283,7 +36822,7 @@ "attachment": true, "reasoning": true, "tool_call": true, - "temperature": false, + "temperature": true, "knowledge": "2025-08-31", "release_date": "2026-03-05", "last_updated": "2026-03-05", @@ -38030,7 +37569,7 @@ "attachment": true, "reasoning": true, "tool_call": true, - "temperature": false, + "temperature": true, "knowledge": "2024-09-30", "release_date": "2025-11-13", "last_updated": "2025-11-13", @@ -38061,7 +37600,7 @@ "attachment": true, "reasoning": true, "tool_call": true, - "temperature": false, + "temperature": true, "knowledge": "2025-08-31", "release_date": "2025-12-11", "last_updated": "2025-12-11", @@ -38092,7 +37631,7 @@ "attachment": true, "reasoning": true, "tool_call": true, - "temperature": false, + "temperature": true, "knowledge": "2025-08-31", "release_date": "2026-03-05", "last_updated": "2026-03-05", @@ -38124,7 +37663,7 @@ "attachment": true, "reasoning": true, "tool_call": true, - "temperature": false, + "temperature": true, "knowledge": "2025-08-31", "release_date": "2026-03-17", "last_updated": "2026-03-17", @@ -38155,7 +37694,7 @@ "attachment": true, "reasoning": true, "tool_call": true, - "temperature": false, + "temperature": true, "knowledge": "2025-08-31", "release_date": "2026-03-17", "last_updated": "2026-03-17", @@ -38572,7 +38111,7 @@ }, "limit": { "context": 524288, - "output": 128000 + "output": 512000 } }, { @@ -40100,63 +39639,35 @@ } }, { - "id": "deepseek/deepseek-chat", - "name": "DeepSeek Chat", - "family": "deepseek", - "attachment": true, - "reasoning": false, - "tool_call": true, - "temperature": true, - "knowledge": "2025-09", - "release_date": "2025-12-01", - "last_updated": "2026-02-28", - "modalities": { - "input": [ - "text" - ], - "output": [ - "text" - ] - }, - "open_weights": true, - "cost": { - "input": 0.14, - "output": 0.28, - "cache_read": 0.0028 - }, - "limit": { - "context": 1000000, - "output": 384000 - } - }, - { - "id": "deepseek/deepseek-reasoner", - "name": "DeepSeek Reasoner", - "family": "deepseek-thinking", + "id": "deepinfra/zai-org/GLM-5.3-Flash", + "name": "GLM-5.3-Flash", + "family": "glm", "attachment": true, "reasoning": true, "tool_call": true, "temperature": true, - "knowledge": "2025-09", - "release_date": "2025-12-01", - "last_updated": "2026-02-28", + "release_date": "2026-08-26", + "last_updated": "2026-08-26", "modalities": { "input": [ - "text" + "text", + "image", + "video", + "pdf" ], "output": [ "text" ] }, - "open_weights": true, + "open_weights": false, "cost": { - "input": 0.14, - "output": 0.28, - "cache_read": 0.0028 + "input": 0.15, + "output": 0.5, + "cache_read": 0.03 }, "limit": { - "context": 1000000, - "output": 384000 + "context": 1048576, + "output": 131072 } }, { @@ -42412,9 +41923,9 @@ }, "open_weights": false, "cost": { - "input": 0.1, - "output": 0.6, - "cache_read": 0.01 + "input": 0.2, + "output": 1.2, + "cache_read": 0.02 }, "limit": { "context": 1050000, @@ -42443,9 +41954,9 @@ }, "open_weights": false, "cost": { - "input": 2.0, - "output": 10.0, - "cache_read": 0.2 + "input": 4.0, + "output": 20.0, + "cache_read": 0.4 }, "limit": { "context": 1050000, @@ -42474,9 +41985,9 @@ }, "open_weights": false, "cost": { - "input": 1.0, - "output": 6.0, - "cache_read": 0.1 + "input": 2.0, + "output": 12.0, + "cache_read": 0.2 }, "limit": { "context": 1050000, @@ -44351,7 +43862,39 @@ "open_weights": true, "cost": { "input": 0.15001, - "output": 0.59997 + "output": 0.59997, + "cache_read": 0.15001, + "cache_write": 0.15001 + }, + "limit": { + "context": 131072, + "output": 32768 + } + }, + { + "id": "edenai/databricks/databricks-gpt-oss-120b@eu", + "name": "GPT OSS 120B (EU)", + "family": "gpt-oss", + "attachment": false, + "reasoning": true, + "tool_call": true, + "temperature": true, + "release_date": "2025-08-05", + "last_updated": "2025-08-05", + "modalities": { + "input": [ + "text" + ], + "output": [ + "text" + ] + }, + "open_weights": true, + "cost": { + "input": 0.15001, + "output": 0.59997, + "cache_read": 0.15001, + "cache_write": 0.15001 }, "limit": { "context": 131072, @@ -44379,7 +43922,39 @@ "open_weights": true, "cost": { "input": 0.07, - "output": 0.30002 + "output": 0.30002, + "cache_read": 0.07, + "cache_write": 0.07 + }, + "limit": { + "context": 131072, + "output": 32768 + } + }, + { + "id": "edenai/databricks/databricks-gpt-oss-20b@eu", + "name": "GPT OSS 20B (EU)", + "family": "gpt-oss", + "attachment": false, + "reasoning": true, + "tool_call": true, + "temperature": true, + "release_date": "2025-08-05", + "last_updated": "2025-08-05", + "modalities": { + "input": [ + "text" + ], + "output": [ + "text" + ] + }, + "open_weights": true, + "cost": { + "input": 0.07, + "output": 0.30002, + "cache_read": 0.07, + "cache_write": 0.07 }, "limit": { "context": 131072, @@ -45036,36 +44611,6 @@ "output": 384000 } }, - { - "id": "edenai/deepseek/deepseek-reasoner", - "name": "DeepSeek Reasoner", - "family": "deepseek-thinking", - "attachment": false, - "reasoning": true, - "tool_call": false, - "temperature": true, - "knowledge": "2025-09", - "release_date": "2025-12-01", - "last_updated": "2026-02-28", - "modalities": { - "input": [ - "text" - ], - "output": [ - "text" - ] - }, - "open_weights": true, - "cost": { - "input": 0.28, - "output": 0.42, - "cache_read": 0.028 - }, - "limit": { - "context": 131072, - "output": 384000 - } - }, { "id": "edenai/deepseek/deepseek-v4-flash", "name": "DeepSeek V4 Flash", @@ -45362,35 +44907,6 @@ "output": 32768 } }, - { - "id": "edenai/flexai/DeepSeek-V4-Flash", - "name": "DeepSeek V4 Flash 0731", - "family": "deepseek-flash", - "attachment": false, - "reasoning": true, - "tool_call": false, - "temperature": true, - "knowledge": "2025-05", - "release_date": "2026-07-31", - "last_updated": "2026-07-31", - "modalities": { - "input": [ - "text" - ], - "output": [ - "text" - ] - }, - "open_weights": true, - "cost": { - "input": 0.08, - "output": 0.18 - }, - "limit": { - "context": 1000000, - "output": 384000 - } - }, { "id": "edenai/flexai/Muse-Glimmer-30B", "name": "Muse Glimmer 30B", @@ -45474,7 +44990,7 @@ "output": 0.18 }, "limit": { - "context": 1000000, + "context": 786432, "output": 384000 } }, @@ -46222,8 +45738,8 @@ }, "open_weights": true, "cost": { - "input": 0.75816, - "output": 0.75816 + "input": 0.758485, + "output": 0.758485 }, "limit": { "context": 128000, @@ -46250,8 +45766,8 @@ }, "open_weights": true, "cost": { - "input": 0.17496, - "output": 0.75816 + "input": 0.175035, + "output": 0.758485 }, "limit": { "context": 131072, @@ -46283,7 +45799,7 @@ }, "limit": { "context": 204800, - "output": 128000 + "output": 131072 } }, { @@ -46401,7 +45917,7 @@ }, "limit": { "context": 524288, - "output": 128000 + "output": 512000 } }, { @@ -46455,8 +45971,9 @@ }, "open_weights": true, "cost": { - "input": 0.4, - "output": 2.0 + "input": 0.44, + "output": 2.2, + "cache_read": 0.044 }, "limit": { "context": 262144, @@ -47270,7 +46787,7 @@ "attachment": true, "reasoning": true, "tool_call": true, - "temperature": false, + "temperature": true, "knowledge": "2024-09-30", "release_date": "2025-11-13", "last_updated": "2025-11-13", @@ -47302,7 +46819,7 @@ "attachment": true, "reasoning": true, "tool_call": true, - "temperature": false, + "temperature": true, "knowledge": "2025-08-31", "release_date": "2025-12-11", "last_updated": "2025-12-11", @@ -47365,7 +46882,7 @@ "attachment": true, "reasoning": true, "tool_call": true, - "temperature": false, + "temperature": true, "knowledge": "2025-08-31", "release_date": "2026-02-05", "last_updated": "2026-02-05", @@ -47397,7 +46914,7 @@ "attachment": true, "reasoning": true, "tool_call": true, - "temperature": false, + "temperature": true, "knowledge": "2025-08-31", "release_date": "2026-03-05", "last_updated": "2026-03-05", @@ -47429,7 +46946,7 @@ "attachment": true, "reasoning": true, "tool_call": true, - "temperature": false, + "temperature": true, "knowledge": "2025-08-31", "release_date": "2026-03-17", "last_updated": "2026-03-17", @@ -47461,7 +46978,7 @@ "attachment": true, "reasoning": true, "tool_call": true, - "temperature": false, + "temperature": true, "knowledge": "2025-08-31", "release_date": "2026-03-17", "last_updated": "2026-03-17", @@ -47688,7 +47205,7 @@ "attachment": true, "reasoning": true, "tool_call": true, - "temperature": false, + "temperature": true, "knowledge": "2025-08-31", "release_date": "2026-03-17", "last_updated": "2026-03-17", @@ -48158,8 +47675,8 @@ }, "open_weights": true, "cost": { - "input": 0.627, - "output": 1.881 + "input": 0.5808, + "output": 1.7424 }, "limit": { "context": 1000000, @@ -48406,6 +47923,35 @@ "output": 65536 } }, + { + "id": "edenai/qwen/qwen3-coder-next@eu", + "name": "Qwen3 Coder Next (EU)", + "family": "qwen", + "attachment": false, + "reasoning": false, + "tool_call": true, + "temperature": true, + "knowledge": "2025-09", + "release_date": "2026-02-03", + "last_updated": "2026-02-03", + "modalities": { + "input": [ + "text" + ], + "output": [ + "text" + ] + }, + "open_weights": true, + "cost": { + "input": 0.3, + "output": 1.5 + }, + "limit": { + "context": 262144, + "output": 65536 + } + }, { "id": "edenai/qwen/qwen3-coder-plus", "name": "Qwen3 Coder Plus", @@ -48468,6 +48014,37 @@ "output": 65536 } }, + { + "id": "edenai/qwen/qwen3-max@eu", + "name": "Qwen3 Max (EU)", + "family": "qwen", + "attachment": false, + "reasoning": false, + "tool_call": true, + "temperature": true, + "knowledge": "2025-04", + "release_date": "2025-09-23", + "last_updated": "2025-09-23", + "modalities": { + "input": [ + "text" + ], + "output": [ + "text" + ] + }, + "open_weights": false, + "cost": { + "input": 1.2, + "output": 6.0, + "cache_read": 0.24, + "cache_write": 1.5 + }, + "limit": { + "context": 262144, + "output": 65536 + } + }, { "id": "edenai/qwen/qwen3-next-80b-a3b-instruct", "name": "Qwen3-Next 80B-A3B Instruct", @@ -48717,7 +48294,7 @@ "family": "deepseek-flash", "attachment": false, "reasoning": true, - "tool_call": true, + "tool_call": false, "temperature": true, "knowledge": "2025-05", "release_date": "2026-07-31", @@ -48732,8 +48309,8 @@ }, "open_weights": true, "cost": { - "input": 0.46656, - "output": 0.93312 + "input": 0.46676, + "output": 0.933521 }, "limit": { "context": 256000, @@ -48746,7 +48323,7 @@ "family": "gpt-oss", "attachment": false, "reasoning": true, - "tool_call": true, + "tool_call": false, "temperature": true, "release_date": "2025-08-05", "last_updated": "2025-08-05", @@ -48760,8 +48337,8 @@ }, "open_weights": true, "cost": { - "input": 0.17496, - "output": 0.69984 + "input": 0.175035, + "output": 0.70014 }, "limit": { "context": 128000, @@ -48774,7 +48351,7 @@ "family": "llama", "attachment": false, "reasoning": false, - "tool_call": true, + "tool_call": false, "temperature": true, "knowledge": "2023-12", "release_date": "2024-12-06", @@ -48789,8 +48366,8 @@ }, "open_weights": true, "cost": { - "input": 1.04976, - "output": 1.04976 + "input": 1.050211, + "output": 1.050211 }, "limit": { "context": 128000, @@ -50237,6 +49814,37 @@ "output": 131072 } }, + { + "id": "edenai/zai/glm-5.3-flash", + "name": "GLM-5.3-Flash", + "family": "glm", + "attachment": true, + "reasoning": true, + "tool_call": true, + "temperature": true, + "release_date": "2026-08-26", + "last_updated": "2026-08-26", + "modalities": { + "input": [ + "text", + "image", + "video" + ], + "output": [ + "text" + ] + }, + "open_weights": false, + "cost": { + "input": 0.15, + "output": 0.5, + "cache_read": 0.03 + }, + "limit": { + "context": 1048576, + "output": 131072 + } + }, { "id": "edenai/zai/glm-5v-turbo", "name": "GLM-5V-Turbo", @@ -50625,6 +50233,38 @@ "output": 131072 } }, + { + "id": "empiriolabs/glm-5.3-flash", + "name": "GLM 5.3 Flash", + "family": "glm", + "attachment": true, + "reasoning": true, + "tool_call": true, + "temperature": true, + "release_date": "2026-08-26", + "last_updated": "2026-08-26", + "modalities": { + "input": [ + "text", + "image", + "video", + "pdf" + ], + "output": [ + "text" + ] + }, + "open_weights": false, + "cost": { + "input": 0.075, + "output": 0.25, + "cache_read": 0.075 + }, + "limit": { + "context": 1000000, + "output": 131072 + } + }, { "id": "empiriolabs/kimi-k2-6", "name": "Kimi K2.6", @@ -51584,6 +51224,37 @@ "output": 32768 } }, + { + "id": "empiriolabs/qwen3-8-flash", + "name": "Qwen3.8 Flash", + "family": "qwen", + "attachment": true, + "reasoning": true, + "tool_call": true, + "temperature": true, + "release_date": "2026-08-26", + "last_updated": "2026-08-26", + "modalities": { + "input": [ + "text", + "image", + "video" + ], + "output": [ + "text" + ] + }, + "open_weights": false, + "cost": { + "input": 0.16, + "output": 0.47, + "cache_read": 0.16 + }, + "limit": { + "context": 1000000, + "output": 131072 + } + }, { "id": "empiriolabs/qwen3-8-max", "name": "Qwen3.8 Max", @@ -51942,7 +51613,7 @@ } }, { - "id": "evroc/Qwen/Qwen3.6-35B-A3B-FP8", + "id": "evroc/Qwen/Qwen3.6-35B-A3B", "name": "Qwen3.6 35B-A3B", "family": "qwen", "attachment": true, @@ -51972,6 +51643,35 @@ "output": 65536 } }, + { + "id": "evroc/Qwen/Qwen3.8-27B", + "name": "Qwen3.8-27B", + "family": "qwen", + "attachment": true, + "reasoning": true, + "tool_call": true, + "temperature": true, + "release_date": "2026-08-14", + "last_updated": "2026-08-14", + "modalities": { + "input": [ + "text", + "image" + ], + "output": [ + "text" + ] + }, + "open_weights": true, + "cost": { + "input": 0.87, + "output": 3.5 + }, + "limit": { + "context": 262144, + "output": 262144 + } + }, { "id": "evroc/evroc/roc", "name": "roc", @@ -53145,7 +52845,7 @@ "attachment": true, "reasoning": true, "tool_call": true, - "temperature": false, + "temperature": true, "knowledge": "2025-08-31", "release_date": "2026-02-05", "last_updated": "2026-02-05", @@ -53176,7 +52876,7 @@ "attachment": true, "reasoning": true, "tool_call": true, - "temperature": false, + "temperature": true, "knowledge": "2025-08-31", "release_date": "2026-03-17", "last_updated": "2026-03-17", @@ -53206,7 +52906,7 @@ "attachment": true, "reasoning": true, "tool_call": true, - "temperature": false, + "temperature": true, "knowledge": "2025-08-31", "release_date": "2026-03-17", "last_updated": "2026-03-17", @@ -54491,7 +54191,7 @@ "attachment": true, "reasoning": true, "tool_call": true, - "temperature": false, + "temperature": true, "knowledge": "2025-08-31", "release_date": "2026-02-05", "last_updated": "2026-02-05", @@ -54524,7 +54224,7 @@ "attachment": true, "reasoning": true, "tool_call": true, - "temperature": false, + "temperature": true, "knowledge": "2025-08-31", "release_date": "2026-03-05", "last_updated": "2026-03-05", @@ -54557,7 +54257,7 @@ "attachment": true, "reasoning": true, "tool_call": true, - "temperature": false, + "temperature": true, "knowledge": "2025-08-31", "release_date": "2026-03-17", "last_updated": "2026-03-17", @@ -56136,7 +55836,7 @@ "attachment": true, "reasoning": true, "tool_call": true, - "temperature": false, + "temperature": true, "knowledge": "2025-08-31", "release_date": "2025-12-11", "last_updated": "2025-12-11", @@ -56199,7 +55899,7 @@ "attachment": true, "reasoning": true, "tool_call": true, - "temperature": false, + "temperature": true, "knowledge": "2025-08-31", "release_date": "2026-02-05", "last_updated": "2026-02-05", @@ -56231,7 +55931,7 @@ "attachment": true, "reasoning": true, "tool_call": true, - "temperature": false, + "temperature": true, "knowledge": "2025-08-31", "release_date": "2026-03-05", "last_updated": "2026-03-05", @@ -56263,7 +55963,7 @@ "attachment": true, "reasoning": true, "tool_call": true, - "temperature": false, + "temperature": true, "knowledge": "2025-08-31", "release_date": "2026-03-17", "last_updated": "2026-03-17", @@ -56294,7 +55994,7 @@ "attachment": true, "reasoning": true, "tool_call": true, - "temperature": false, + "temperature": true, "knowledge": "2025-08-31", "release_date": "2026-03-17", "last_updated": "2026-03-17", @@ -57362,6 +57062,66 @@ "output": 64000 } }, + { + "id": "gmicloud/MiniMaxAI/MiniMax-M2.7", + "name": "MiniMax-M2.7", + "family": "minimax", + "attachment": false, + "reasoning": true, + "tool_call": true, + "temperature": true, + "release_date": "2026-03-18", + "last_updated": "2026-03-18", + "modalities": { + "input": [ + "text" + ], + "output": [ + "text" + ] + }, + "open_weights": true, + "cost": { + "input": 0.3, + "output": 1.2, + "cache_read": 0.06 + }, + "limit": { + "context": 196608, + "output": 131072 + } + }, + { + "id": "gmicloud/MiniMaxAI/MiniMax-M3", + "name": "MiniMax-M3", + "family": "minimax", + "attachment": true, + "reasoning": true, + "tool_call": true, + "temperature": true, + "release_date": "2026-06-01", + "last_updated": "2026-06-01", + "modalities": { + "input": [ + "text", + "image", + "video" + ], + "output": [ + "text" + ] + }, + "open_weights": true, + "cost": { + "input": 0.6, + "output": 2.4, + "cache_read": 0.12 + }, + "limit": { + "context": 1048576, + "output": 512000 + } + }, { "id": "gmicloud/Qwen/Qwen3.7-Max", "name": "Qwen3.7 Max", @@ -65284,7 +65044,7 @@ }, "limit": { "context": 204800, - "output": 128000 + "output": 131072 } }, { @@ -65400,7 +65160,7 @@ }, "limit": { "context": 524288, - "output": 128000 + "output": 512000 } }, { @@ -66069,6 +65829,35 @@ "output": 131072 } }, + { + "id": "huggingface/Qwen/Qwen3.8-27B", + "name": "Qwen3.8 27B", + "family": "qwen", + "attachment": true, + "reasoning": true, + "tool_call": true, + "temperature": true, + "release_date": "2026-08-14", + "last_updated": "2026-08-14", + "modalities": { + "input": [ + "text", + "image" + ], + "output": [ + "text" + ] + }, + "open_weights": true, + "cost": { + "input": 0.4, + "output": 3.0 + }, + "limit": { + "context": 262144, + "output": 32768 + } + }, { "id": "huggingface/XiaomiMiMo/MiMo-V2-Flash", "name": "MiMo-V2-Flash", @@ -67118,6 +66907,34 @@ "output": 131072 } }, + { + "id": "huggingface/zai-org/GLM-5.3-Flash", + "name": "GLM-5.3-Flash", + "family": "glm", + "attachment": false, + "reasoning": true, + "tool_call": true, + "temperature": true, + "release_date": "2026-08-26", + "last_updated": "2026-08-26", + "modalities": { + "input": [ + "text" + ], + "output": [ + "text" + ] + }, + "open_weights": true, + "cost": { + "input": 0.15, + "output": 0.5 + }, + "limit": { + "context": 1048576, + "output": 131072 + } + }, { "id": "hyper/deepseek-v4-flash", "name": "DeepSeek V4 Flash", @@ -67198,9 +67015,9 @@ }, "open_weights": true, "cost": { - "input": 0.11, - "output": 0.408, - "cache_write": 0.055 + "input": 0.12, + "output": 0.42, + "cache_write": 0.06 }, "limit": { "context": 256000, @@ -67227,9 +67044,9 @@ }, "open_weights": true, "cost": { - "input": 0.86, - "output": 2.784, - "cache_write": 0.43 + "input": 0.9, + "output": 2.804, + "cache_write": 0.45 }, "limit": { "context": 202752, @@ -67256,9 +67073,9 @@ }, "open_weights": true, "cost": { - "input": 1.332, - "output": 4.312, - "cache_write": 0.666 + "input": 1.29, + "output": 4.22, + "cache_write": 0.645 }, "limit": { "context": 202750, @@ -67467,9 +67284,9 @@ }, "open_weights": true, "cost": { - "input": 0.598, - "output": 0.738, - "cache_write": 0.299 + "input": 0.6066, + "output": 1.0386, + "cache_write": 0.3033 }, "limit": { "context": 128000, @@ -67526,9 +67343,9 @@ }, "open_weights": true, "cost": { - "input": 0.41, - "output": 1.52, - "cache_write": 0.205 + "input": 0.408, + "output": 1.512, + "cache_write": 0.204 }, "limit": { "context": 262100, @@ -67809,6 +67626,94 @@ "output": 64000 } }, + { + "id": "hyper/qwen3.8-2.4t-a95b", + "name": "Qwen3.8 2.4T A95B", + "family": "qwen", + "attachment": false, + "reasoning": true, + "tool_call": true, + "temperature": true, + "release_date": "2026-08-26", + "last_updated": "2026-08-26", + "modalities": { + "input": [ + "text" + ], + "output": [ + "text" + ] + }, + "open_weights": true, + "cost": { + "input": 2.0, + "output": 6.0, + "cache_read": 0.25 + }, + "limit": { + "context": 1000000, + "output": 128000 + } + }, + { + "id": "hyper/qwen3.8-27b", + "name": "Qwen3.8 27B", + "family": "qwen", + "attachment": true, + "reasoning": true, + "tool_call": true, + "temperature": true, + "release_date": "2026-08-26", + "last_updated": "2026-08-26", + "modalities": { + "input": [ + "text", + "image" + ], + "output": [ + "text" + ] + }, + "open_weights": true, + "cost": { + "input": 0.5, + "output": 3.0, + "cache_read": 0.1 + }, + "limit": { + "context": 1000000, + "output": 128000 + } + }, + { + "id": "hyper/qwen3.8-flash", + "name": "Qwen3.8 Flash", + "family": "qwen", + "attachment": true, + "reasoning": true, + "tool_call": true, + "release_date": "2026-08-26", + "last_updated": "2026-08-26", + "modalities": { + "input": [ + "text", + "image" + ], + "output": [ + "text" + ] + }, + "open_weights": false, + "cost": { + "input": 0.15, + "output": 0.47, + "cache_read": 0.016 + }, + "limit": { + "context": 1000000, + "output": 128000 + } + }, { "id": "hyper/qwen3.8-max", "name": "Qwen3.8 Max Preview", @@ -69429,7 +69334,7 @@ "attachment": true, "reasoning": true, "tool_call": true, - "temperature": false, + "temperature": true, "knowledge": "2024-09-30", "release_date": "2025-11-13", "last_updated": "2025-11-13", @@ -69522,7 +69427,7 @@ "attachment": true, "reasoning": true, "tool_call": true, - "temperature": false, + "temperature": true, "knowledge": "2025-08-31", "release_date": "2025-12-11", "last_updated": "2025-12-11", @@ -69585,7 +69490,7 @@ "attachment": true, "reasoning": true, "tool_call": true, - "temperature": false, + "temperature": true, "knowledge": "2025-08-31", "release_date": "2026-02-05", "last_updated": "2026-02-05", @@ -69617,7 +69522,7 @@ "attachment": true, "reasoning": true, "tool_call": true, - "temperature": false, + "temperature": true, "knowledge": "2025-08-31", "release_date": "2026-03-05", "last_updated": "2026-03-05", @@ -69649,7 +69554,7 @@ "attachment": true, "reasoning": true, "tool_call": true, - "temperature": false, + "temperature": true, "knowledge": "2025-08-31", "release_date": "2026-03-17", "last_updated": "2026-03-17", @@ -69680,7 +69585,7 @@ "attachment": true, "reasoning": true, "tool_call": true, - "temperature": false, + "temperature": true, "knowledge": "2025-08-31", "release_date": "2026-03-17", "last_updated": "2026-03-17", @@ -70709,9 +70614,9 @@ }, "open_weights": true, "cost": { - "input": 0.57, - "output": 3.41, - "cache_read": 0.2, + "input": 0.56, + "output": 3.39, + "cache_read": 0.17, "cache_write": 0.0 }, "limit": { @@ -70741,9 +70646,9 @@ }, "open_weights": true, "cost": { - "input": 0.67, + "input": 0.66, "output": 3.4, - "cache_read": 0.19, + "cache_read": 0.18, "cache_write": 0.0 }, "limit": { @@ -70772,7 +70677,7 @@ "open_weights": true, "cost": { "input": 0.75, - "output": 2.9, + "output": 2.4, "cache_read": 0.17, "cache_write": 0.0 }, @@ -72218,6 +72123,37 @@ "output": 4096 } }, + { + "id": "iteracompute/iteracompute/qwen3.8-27b", + "name": "Qwen3.8 27B", + "family": "qwen", + "attachment": true, + "reasoning": true, + "tool_call": true, + "temperature": true, + "release_date": "2026-08-14", + "last_updated": "2026-08-14", + "modalities": { + "input": [ + "text", + "image", + "video" + ], + "output": [ + "text" + ] + }, + "open_weights": true, + "cost": { + "input": 0.35, + "output": 3.0, + "cache_read": 0.1 + }, + "limit": { + "context": 262144, + "output": 32768 + } + }, { "id": "jalapeno/DeepSeek-V4-Flash", "name": "DeepSeek V4 Flash", @@ -72479,7 +72415,7 @@ }, "limit": { "context": 524288, - "output": 128000 + "output": 512000 } }, { @@ -74923,7 +74859,7 @@ "attachment": true, "reasoning": true, "tool_call": true, - "temperature": false, + "temperature": true, "knowledge": "2025-08-31", "release_date": "2026-03-17", "last_updated": "2026-03-17", @@ -75486,8 +75422,8 @@ "output": 0.0 }, "limit": { - "context": 512000, - "output": 128000 + "context": 1048576, + "output": 512000 } }, { @@ -75742,7 +75678,7 @@ }, "limit": { "context": 32768, - "output": 32768 + "output": 29491 } }, { @@ -75770,7 +75706,7 @@ }, "limit": { "context": 65536, - "output": 65536 + "output": 58982 } }, { @@ -76531,7 +76467,7 @@ }, "limit": { "context": 262144, - "output": 262144 + "output": 235929 } }, { @@ -76767,7 +76703,7 @@ }, "limit": { "context": 262144, - "output": 262144 + "output": 235929 } }, { @@ -77025,7 +76961,7 @@ }, "limit": { "context": 163840, - "output": 163840 + "output": 147456 } }, { @@ -77054,7 +76990,7 @@ }, "limit": { "context": 161000, - "output": 161000 + "output": 144900 } }, { @@ -77111,7 +77047,7 @@ }, "limit": { "context": 8192, - "output": 8192 + "output": 7372 } }, { @@ -77170,7 +77106,7 @@ }, "limit": { "context": 163840, - "output": 163840 + "output": 147456 } }, { @@ -77316,7 +77252,7 @@ }, "limit": { "context": 512000, - "output": 512000 + "output": 460800 } }, { @@ -77590,7 +77526,7 @@ "cache_write": 0.375 }, "limit": { - "context": 65536, + "context": 131072, "output": 32768 } }, @@ -77689,7 +77625,7 @@ }, "limit": { "context": 65536, - "output": 65536 + "output": 58982 } }, { @@ -77756,7 +77692,7 @@ }, "limit": { "context": 65536, - "output": 65536 + "output": 58982 } }, { @@ -78087,7 +78023,7 @@ }, "limit": { "context": 131072, - "output": 131072 + "output": 117964 } }, { @@ -78119,34 +78055,6 @@ "output": 16384 } }, - { - "id": "kilo/google/gemma-3n-e4b-it", - "name": "Google: Gemma 3n 4B", - "family": "gemma", - "attachment": false, - "reasoning": false, - "tool_call": false, - "temperature": true, - "release_date": "2025-05-20", - "last_updated": "2025-05-20", - "modalities": { - "input": [ - "text" - ], - "output": [ - "text" - ] - }, - "open_weights": false, - "cost": { - "input": 0.06, - "output": 0.12 - }, - "limit": { - "context": 32768, - "output": 6554 - } - }, { "id": "kilo/google/gemma-4-26b-a4b-it", "name": "Gemma 4 26B A4B IT", @@ -78199,13 +78107,13 @@ }, "open_weights": true, "cost": { - "input": 0.08, - "output": 0.35, - "cache_read": 0.01 + "input": 0.09, + "output": 0.34, + "cache_read": 0.05 }, "limit": { "context": 262144, - "output": 262144 + "output": 16384 } }, { @@ -78292,7 +78200,7 @@ }, "limit": { "context": 4096, - "output": 4096 + "output": 3686 } }, { @@ -78320,7 +78228,7 @@ }, "limit": { "context": 131000, - "output": 131000 + "output": 117900 } }, { @@ -78349,7 +78257,7 @@ }, "limit": { "context": 131072, - "output": 131072 + "output": 117964 } }, { @@ -78819,7 +78727,7 @@ }, "limit": { "context": 131072, - "output": 131072 + "output": 117964 } }, { @@ -78847,7 +78755,7 @@ }, "limit": { "context": 60000, - "output": 60000 + "output": 54000 } }, { @@ -78875,7 +78783,7 @@ }, "limit": { "context": 131072, - "output": 131072 + "output": 117964 } }, { @@ -78903,8 +78811,8 @@ "output": 0.32 }, "limit": { - "context": 131072, - "output": 16384 + "context": 128000, + "output": 115200 } }, { @@ -78961,8 +78869,8 @@ "output": 0.3 }, "limit": { - "context": 327680, - "output": 16384 + "context": 131072, + "output": 8192 } }, { @@ -79023,7 +78931,7 @@ }, "limit": { "context": 131072, - "output": 131072 + "output": 117964 } }, { @@ -79056,7 +78964,7 @@ }, "limit": { "context": 1048576, - "output": 1048576 + "output": 943718 } }, { @@ -79089,7 +78997,7 @@ }, "limit": { "context": 1048576, - "output": 1048576 + "output": 943718 } }, { @@ -79122,7 +79030,7 @@ }, "limit": { "context": 1048576, - "output": 1048576 + "output": 943718 } }, { @@ -79150,7 +79058,7 @@ }, "limit": { "context": 16384, - "output": 16384 + "output": 14745 } }, { @@ -79206,7 +79114,7 @@ }, "limit": { "context": 1000192, - "output": 1000192 + "output": 900172 } }, { @@ -79377,10 +79285,39 @@ "cache_read": 0.06 }, "limit": { - "context": 196608, + "context": 204800, "output": 131072 } }, + { + "id": "kilo/minimax/minimax-m2.7:free", + "name": "MiniMax: MiniMax M2.7 (free)", + "family": "minimax", + "attachment": false, + "reasoning": true, + "tool_call": true, + "temperature": true, + "release_date": "2026-03-18", + "last_updated": "2026-03-18", + "modalities": { + "input": [ + "text" + ], + "output": [ + "text" + ] + }, + "open_weights": true, + "cost": { + "input": 0.0, + "output": 0.0, + "cache_read": 0.0 + }, + "limit": { + "context": 196608, + "output": 196608 + } + }, { "id": "kilo/minimax/minimax-m3", "name": "MiniMax-M3", @@ -79412,6 +79349,36 @@ "output": 512000 } }, + { + "id": "kilo/minimax/minimax-m3:free", + "name": "MiniMax: MiniMax M3 (free)", + "family": "minimax", + "attachment": true, + "reasoning": true, + "tool_call": true, + "temperature": true, + "release_date": "2026-06-01", + "last_updated": "2026-06-01", + "modalities": { + "input": [ + "text", + "image" + ], + "output": [ + "text" + ] + }, + "open_weights": true, + "cost": { + "input": 0.0, + "output": 0.0, + "cache_read": 0.0 + }, + "limit": { + "context": 1048576, + "output": 1048576 + } + }, { "id": "kilo/mistralai/codestral", "name": "Mistral: Codestral 2508", @@ -79439,7 +79406,7 @@ }, "limit": { "context": 256000, - "output": 51200 + "output": 204800 } }, { @@ -79470,7 +79437,7 @@ }, "limit": { "context": 262144, - "output": 262144 + "output": 209715 } }, { @@ -79500,7 +79467,7 @@ }, "limit": { "context": 262144, - "output": 52429 + "output": 209715 } }, { @@ -79530,7 +79497,7 @@ }, "limit": { "context": 131072, - "output": 32768 + "output": 104857 } }, { @@ -79558,7 +79525,7 @@ }, "limit": { "context": 128000, - "output": 128000 + "output": 102400 } }, { @@ -79588,7 +79555,7 @@ }, "limit": { "context": 128000, - "output": 25600 + "output": 102400 } }, { @@ -79619,7 +79586,7 @@ }, "limit": { "context": 131072, - "output": 26215 + "output": 104857 } }, { @@ -79650,7 +79617,7 @@ }, "limit": { "context": 131072, - "output": 26215 + "output": 104857 } }, { @@ -79680,7 +79647,7 @@ }, "limit": { "context": 262144, - "output": 262144 + "output": 209715 } }, { @@ -79740,7 +79707,7 @@ }, "limit": { "context": 32768, - "output": 32768 + "output": 26214 } }, { @@ -79771,7 +79738,7 @@ }, "limit": { "context": 262144, - "output": 262144 + "output": 209715 } }, { @@ -79828,7 +79795,7 @@ }, "limit": { "context": 128000, - "output": 128000 + "output": 102400 } }, { @@ -79888,7 +79855,7 @@ }, "limit": { "context": 65536, - "output": 13108 + "output": 52428 } }, { @@ -79919,7 +79886,7 @@ }, "limit": { "context": 32000, - "output": 6400 + "output": 25600 } }, { @@ -80008,7 +79975,7 @@ }, "limit": { "context": 262144, - "output": 262144 + "output": 235929 } }, { @@ -80039,7 +80006,7 @@ }, "limit": { "context": 262144, - "output": 262144 + "output": 235929 } }, { @@ -80070,7 +80037,7 @@ }, "limit": { "context": 262144, - "output": 262144 + "output": 235929 } }, { @@ -80101,7 +80068,7 @@ }, "limit": { "context": 1048576, - "output": 1048576 + "output": 943718 } }, { @@ -80187,7 +80154,7 @@ }, "limit": { "context": 262144, - "output": 262144 + "output": 235929 } }, { @@ -80217,7 +80184,7 @@ }, "limit": { "context": 262144, - "output": 262144 + "output": 235929 } }, { @@ -80301,7 +80268,7 @@ }, "limit": { "context": 131072, - "output": 26215 + "output": 117964 } }, { @@ -80329,7 +80296,7 @@ }, "limit": { "context": 131072, - "output": 131072 + "output": 117964 } }, { @@ -80358,7 +80325,7 @@ }, "limit": { "context": 262144, - "output": 262144 + "output": 228000 } }, { @@ -80445,7 +80412,7 @@ }, "limit": { "context": 262144, - "output": 262144 + "output": 235929 } }, { @@ -80474,7 +80441,7 @@ }, "limit": { "context": 512288, - "output": 512288 + "output": 461059 } }, { @@ -80676,7 +80643,7 @@ }, "limit": { "context": 4095, - "output": 4096 + "output": 3685 } }, { @@ -81977,7 +81944,7 @@ }, "limit": { "context": 131072, - "output": 131072 + "output": 117964 } }, { @@ -82000,13 +81967,12 @@ }, "open_weights": true, "cost": { - "input": 0.03, - "output": 0.13, - "cache_read": 0.03 + "input": 0.02, + "output": 0.1 }, "limit": { "context": 131072, - "output": 131072 + "output": 117964 } }, { @@ -82456,7 +82422,7 @@ }, "limit": { "context": 127072, - "output": 25415 + "output": 114364 } }, { @@ -82484,7 +82450,7 @@ }, "limit": { "context": 128000, - "output": 25600 + "output": 115200 } }, { @@ -82571,7 +82537,7 @@ }, "limit": { "context": 128000, - "output": 25600 + "output": 115200 } }, { @@ -82741,7 +82707,7 @@ }, "limit": { "context": 32768, - "output": 32768 + "output": 29491 } }, { @@ -82769,7 +82735,7 @@ }, "limit": { "context": 32768, - "output": 32768 + "output": 29491 } }, { @@ -82803,34 +82769,6 @@ "output": 32768 } }, - { - "id": "kilo/qwen/qwen-plus-2025-07-28:thinking", - "name": "Qwen: Qwen Plus 0728 (thinking)", - "family": "qwen", - "attachment": false, - "reasoning": true, - "tool_call": true, - "temperature": true, - "release_date": "2025-09-08", - "last_updated": "2025-09-08", - "modalities": { - "input": [ - "text" - ], - "output": [ - "text" - ] - }, - "open_weights": false, - "cost": { - "input": 0.26, - "output": 0.78 - }, - "limit": { - "context": 1000000, - "output": 32768 - } - }, { "id": "kilo/qwen/qwen2.5-vl-72b-instruct", "name": "Qwen: Qwen2.5 VL 72B Instruct", @@ -82852,13 +82790,12 @@ }, "open_weights": false, "cost": { - "input": 0.8, - "output": 1.0, - "cache_read": 0.4 + "input": 0.25, + "output": 0.75 }, "limit": { - "context": 128000, - "output": 128000 + "context": 32000, + "output": 28800 } }, { @@ -82943,7 +82880,7 @@ }, "limit": { "context": 131072, - "output": 262144 + "output": 117964 } }, { @@ -83141,7 +83078,7 @@ }, "limit": { "context": 262144, - "output": 262144 + "output": 235929 } }, { @@ -83201,7 +83138,7 @@ }, "limit": { "context": 262144, - "output": 262144 + "output": 235929 } }, { @@ -83320,7 +83257,7 @@ }, "limit": { "context": 262144, - "output": 262144 + "output": 235929 } }, { @@ -83584,7 +83521,7 @@ }, "limit": { "context": 262144, - "output": 262144 + "output": 235929 } }, { @@ -83644,7 +83581,7 @@ }, "limit": { "context": 262144, - "output": 262144 + "output": 235929 } }, { @@ -83674,7 +83611,7 @@ }, "limit": { "context": 262144, - "output": 262144 + "output": 65536 } }, { @@ -83704,7 +83641,7 @@ }, "limit": { "context": 262144, - "output": 262144 + "output": 235929 } }, { @@ -83825,7 +83762,7 @@ }, "limit": { "context": 262144, - "output": 81920 + "output": 235929 } }, { @@ -83850,13 +83787,13 @@ }, "open_weights": true, "cost": { - "input": 0.14, - "output": 1.0, + "input": 0.1, + "output": 0.9, "cache_read": 0.05 }, "limit": { "context": 262144, - "output": 262144 + "output": 235929 } }, { @@ -84104,7 +84041,39 @@ "cache_write": 0.53125 }, "limit": { - "context": 262144, + "context": 1000000, + "output": 131072 + } + }, + { + "id": "kilo/qwen/qwen3.8-flash", + "name": "Qwen3.8 Flash", + "family": "qwen", + "attachment": true, + "reasoning": true, + "tool_call": true, + "temperature": true, + "release_date": "2026-08-26", + "last_updated": "2026-08-26", + "modalities": { + "input": [ + "text", + "image", + "video" + ], + "output": [ + "text" + ] + }, + "open_weights": false, + "cost": { + "input": 0.15, + "output": 0.47, + "cache_read": 0.016, + "cache_write": 0.2 + }, + "limit": { + "context": 1000000, "output": 131072 } }, @@ -84167,7 +84136,7 @@ }, "limit": { "context": 16384, - "output": 16384 + "output": 14745 } }, { @@ -84195,7 +84164,7 @@ }, "limit": { "context": 65536, - "output": 65536 + "output": 58982 } }, { @@ -84338,7 +84307,7 @@ }, "limit": { "context": 8192, - "output": 16384 + "output": 7372 } }, { @@ -84525,36 +84494,6 @@ "output": 64000 } }, - { - "id": "kilo/stealth/ox-alpha", - "name": "Ox Alpha", - "family": "alpha", - "attachment": true, - "reasoning": true, - "tool_call": true, - "temperature": true, - "release_date": "2026-08-20", - "last_updated": "2026-08-20", - "modalities": { - "input": [ - "text", - "image", - "video" - ], - "output": [ - "text" - ] - }, - "open_weights": false, - "cost": { - "input": 0.0, - "output": 0.0 - }, - "limit": { - "context": 1048576, - "output": 131072 - } - }, { "id": "kilo/stealth/qwen3.6-plus", "name": "Stealth: Qwen3.6 Plus (50% off)", @@ -84642,7 +84581,7 @@ }, "limit": { "context": 256000, - "output": 256000 + "output": 230400 } }, { @@ -84700,7 +84639,7 @@ }, "limit": { "context": 131072, - "output": 131072 + "output": 117964 } }, { @@ -84842,7 +84781,7 @@ }, "limit": { "context": 262144, - "output": 262144 + "output": 235929 } }, { @@ -84899,7 +84838,7 @@ }, "limit": { "context": 131072, - "output": 131072 + "output": 117964 } }, { @@ -84926,7 +84865,7 @@ }, "limit": { "context": 65536, - "output": 65536 + "output": 58982 } }, { @@ -84954,7 +84893,7 @@ }, "limit": { "context": 32768, - "output": 32768 + "output": 29491 } }, { @@ -84981,7 +84920,7 @@ }, "limit": { "context": 32768, - "output": 32768 + "output": 26214 } }, { @@ -85159,7 +85098,7 @@ }, "limit": { "context": 131072, - "output": 131072 + "output": 117964 } }, { @@ -85247,7 +85186,7 @@ }, "limit": { "context": 2000000, - "output": 2000000 + "output": 1800000 } }, { @@ -85278,7 +85217,7 @@ }, "limit": { "context": 2000000, - "output": 2000000 + "output": 1800000 } }, { @@ -85309,7 +85248,7 @@ }, "limit": { "context": 1000000, - "output": 4096 + "output": 900000 } }, { @@ -85340,7 +85279,7 @@ }, "limit": { "context": 500000, - "output": 500000 + "output": 450000 } }, { @@ -85372,7 +85311,7 @@ }, "limit": { "context": 500000, - "output": 500000 + "output": 450000 } }, { @@ -85403,7 +85342,7 @@ }, "limit": { "context": 256000, - "output": 256000 + "output": 230400 } }, { @@ -85581,13 +85520,13 @@ }, "open_weights": true, "cost": { - "input": 0.5, - "output": 2.0, - "cache_read": 0.1 + "input": 0.43, + "output": 1.75, + "cache_read": 0.08 }, "limit": { - "context": 202752, - "output": 131072 + "context": 198000, + "output": 16384 } }, { @@ -85766,7 +85705,7 @@ }, "limit": { "context": 202752, - "output": 202752 + "output": 182476 } }, { @@ -85827,6 +85766,37 @@ "output": 131072 } }, + { + "id": "kilo/z-ai/glm-5.3-flash", + "name": "GLM-5.3-Flash", + "family": "glm", + "attachment": true, + "reasoning": true, + "tool_call": true, + "temperature": true, + "release_date": "2026-08-26", + "last_updated": "2026-08-26", + "modalities": { + "input": [ + "text", + "image", + "video" + ], + "output": [ + "text" + ] + }, + "open_weights": false, + "cost": { + "input": 0.075, + "output": 0.25, + "cache_read": 0.015 + }, + "limit": { + "context": 1048576, + "output": 131072 + } + }, { "id": "kilo/z-ai/glm-5v-turbo", "name": "GLM-5V-Turbo", @@ -86007,13 +85977,13 @@ }, "open_weights": false, "cost": { - "input": 0.035, - "output": 0.12, - "cache_read": 0.01 + "input": 0.03, + "output": 0.1, + "cache_read": 0.007 }, "limit": { "context": 1048576, - "output": 1048576 + "output": 131072 } }, { @@ -86106,13 +86076,13 @@ }, "open_weights": false, "cost": { - "input": 2.6, - "output": 13.0, - "cache_read": 0.29 + "input": 2.55, + "output": 12.75, + "cache_read": 0.256 }, "limit": { - "context": 974842, - "output": 974842 + "context": 1048576, + "output": 943718 } }, { @@ -86206,7 +86176,7 @@ }, "limit": { "context": 500000, - "output": 500000 + "output": 450000 } }, { @@ -88827,7 +88797,7 @@ "attachment": true, "reasoning": true, "tool_call": true, - "temperature": false, + "temperature": true, "knowledge": "2024-09-30", "release_date": "2025-11-13", "last_updated": "2025-11-13", @@ -88919,7 +88889,7 @@ "attachment": true, "reasoning": true, "tool_call": true, - "temperature": false, + "temperature": true, "knowledge": "2025-08-31", "release_date": "2025-12-11", "last_updated": "2025-12-11", @@ -89012,7 +88982,7 @@ "attachment": true, "reasoning": true, "tool_call": true, - "temperature": false, + "temperature": true, "knowledge": "2025-08-31", "release_date": "2026-02-05", "last_updated": "2026-02-05", @@ -89044,7 +89014,7 @@ "attachment": true, "reasoning": true, "tool_call": true, - "temperature": false, + "temperature": true, "knowledge": "2025-08-31", "release_date": "2026-03-05", "last_updated": "2026-03-05", @@ -89076,7 +89046,7 @@ "attachment": true, "reasoning": true, "tool_call": true, - "temperature": false, + "temperature": true, "knowledge": "2025-08-31", "release_date": "2026-03-17", "last_updated": "2026-03-17", @@ -89107,7 +89077,7 @@ "attachment": true, "reasoning": true, "tool_call": true, - "temperature": false, + "temperature": true, "knowledge": "2025-08-31", "release_date": "2026-03-17", "last_updated": "2026-03-17", @@ -89466,9 +89436,9 @@ }, "open_weights": true, "cost": { - "input": 0.14, - "output": 0.28, - "cache_read": 0.028 + "input": 0.44, + "output": 1.32, + "cache_read": 0.044 }, "limit": { "context": 1048576, @@ -89496,9 +89466,9 @@ }, "open_weights": true, "cost": { - "input": 1.69, - "output": 3.38, - "cache_read": 0.14 + "input": 1.32, + "output": 3.96, + "cache_read": 0.132 }, "limit": { "context": 1048576, @@ -89592,6 +89562,35 @@ "output": 131072 } }, + { + "id": "llmgateway-providers/baidu/glm-5.3", + "name": "GLM-5.3 (Baidu)", + "family": "glm", + "attachment": false, + "reasoning": true, + "tool_call": true, + "temperature": true, + "release_date": "2026-08-14", + "last_updated": "2026-08-14", + "modalities": { + "input": [ + "text" + ], + "output": [ + "text" + ] + }, + "open_weights": false, + "cost": { + "input": 1.4, + "output": 4.4, + "cache_read": 0.26 + }, + "limit": { + "context": 1048576, + "output": 131072 + } + }, { "id": "llmgateway-providers/baidu/kimi-k2.6", "name": "Kimi K2.6 (Baidu)", @@ -90217,6 +90216,36 @@ "output": 8192 } }, + { + "id": "llmgateway-providers/consensusprotocol/deepseek-v4-flash", + "name": "DeepSeek V4 Flash (Consensus Protocol)", + "family": "deepseek-flash", + "attachment": false, + "reasoning": true, + "tool_call": true, + "temperature": true, + "knowledge": "2025-05", + "release_date": "2026-04-24", + "last_updated": "2026-04-24", + "modalities": { + "input": [ + "text" + ], + "output": [ + "text" + ] + }, + "open_weights": true, + "cost": { + "input": 0.13, + "output": 0.27, + "cache_read": 0.02 + }, + "limit": { + "context": 524288, + "output": 393216 + } + }, { "id": "llmgateway-providers/deepinfra/deepseek-v3.2", "name": "DeepSeek V3.2 (DeepInfra)", @@ -90692,6 +90721,36 @@ "output": 393216 } }, + { + "id": "llmgateway-providers/deepseek/deepseek-v4-flash-vision-exp", + "name": "DeepSeek V4 Flash Vision Exp (DeepSeek)", + "family": "deepseek-flash", + "attachment": true, + "reasoning": true, + "tool_call": true, + "temperature": true, + "release_date": "2026-08-21", + "last_updated": "2026-08-21", + "modalities": { + "input": [ + "text", + "image" + ], + "output": [ + "text" + ] + }, + "open_weights": false, + "cost": { + "input": 0.14, + "output": 0.28, + "cache_read": 0.0028 + }, + "limit": { + "context": 1050000, + "output": 393216 + } + }, { "id": "llmgateway-providers/deepseek/deepseek-v4-pro", "name": "DeepSeek V4 Pro (DeepSeek)", @@ -91132,9 +91191,9 @@ }, "open_weights": true, "cost": { - "input": 0.075, - "output": 0.175, - "cache_read": 0.0155 + "input": 0.051, + "output": 0.104, + "cache_read": 0.0097 }, "limit": { "context": 390000, @@ -93250,7 +93309,7 @@ }, "limit": { "context": 1048576, - "output": 128000 + "output": 512000 } }, { @@ -95182,7 +95241,7 @@ "attachment": true, "reasoning": true, "tool_call": true, - "temperature": false, + "temperature": true, "knowledge": "2024-09-30", "release_date": "2025-11-13", "last_updated": "2025-11-13", @@ -95213,7 +95272,7 @@ "attachment": true, "reasoning": true, "tool_call": true, - "temperature": false, + "temperature": true, "knowledge": "2025-08-31", "release_date": "2025-12-11", "last_updated": "2025-12-11", @@ -95274,7 +95333,7 @@ "attachment": true, "reasoning": true, "tool_call": true, - "temperature": false, + "temperature": true, "knowledge": "2025-08-31", "release_date": "2026-02-05", "last_updated": "2026-02-05", @@ -95306,7 +95365,7 @@ "attachment": true, "reasoning": true, "tool_call": true, - "temperature": false, + "temperature": true, "knowledge": "2025-08-31", "release_date": "2026-03-05", "last_updated": "2026-03-05", @@ -95338,7 +95397,7 @@ "attachment": true, "reasoning": true, "tool_call": true, - "temperature": false, + "temperature": true, "knowledge": "2025-08-31", "release_date": "2026-03-17", "last_updated": "2026-03-17", @@ -95369,7 +95428,7 @@ "attachment": true, "reasoning": true, "tool_call": true, - "temperature": false, + "temperature": true, "knowledge": "2025-08-31", "release_date": "2026-03-17", "last_updated": "2026-03-17", @@ -97869,6 +97928,35 @@ "output": 128000 } }, + { + "id": "llmgateway-providers/zai/glm-5.3-flash", + "name": "GLM-5.3 Flash (Z AI)", + "family": "glm", + "attachment": false, + "reasoning": true, + "tool_call": true, + "temperature": true, + "release_date": "2026-08-26", + "last_updated": "2026-08-26", + "modalities": { + "input": [ + "text" + ], + "output": [ + "text" + ] + }, + "open_weights": false, + "cost": { + "input": 0.15, + "output": 0.5, + "cache_read": 0.03 + }, + "limit": { + "context": 1048576, + "output": 131072 + } + }, { "id": "llmgateway/auto", "name": "Auto Route", @@ -98396,9 +98484,39 @@ }, "open_weights": true, "cost": { - "input": 0.076, - "output": 0.153, - "cache_read": 0.014 + "input": 0.051, + "output": 0.104, + "cache_read": 0.0097 + }, + "limit": { + "context": 1050000, + "output": 384000 + } + }, + { + "id": "llmgateway/deepseek-v4-flash-vision-exp", + "name": "DeepSeek V4 Flash Vision Exp", + "family": "deepseek-flash", + "attachment": true, + "reasoning": true, + "tool_call": true, + "temperature": true, + "release_date": "2026-08-21", + "last_updated": "2026-08-21", + "modalities": { + "input": [ + "text", + "image" + ], + "output": [ + "text" + ] + }, + "open_weights": false, + "cost": { + "input": 0.14, + "output": 0.28, + "cache_read": 0.0028 }, "limit": { "context": 1050000, @@ -99495,7 +99613,39 @@ "cache_read": 0.26 }, "limit": { - "context": 1000000, + "context": 1048576, + "output": 131072 + } + }, + { + "id": "llmgateway/glm-5.3-flash", + "name": "GLM-5.3-Flash", + "family": "glm", + "attachment": true, + "reasoning": true, + "tool_call": true, + "temperature": true, + "release_date": "2026-08-26", + "last_updated": "2026-08-26", + "modalities": { + "input": [ + "text", + "image", + "video", + "pdf" + ], + "output": [ + "text" + ] + }, + "open_weights": false, + "cost": { + "input": 0.15, + "output": 0.5, + "cache_read": 0.03 + }, + "limit": { + "context": 1048576, "output": 131072 } }, @@ -99935,7 +100085,7 @@ "attachment": true, "reasoning": true, "tool_call": true, - "temperature": false, + "temperature": true, "knowledge": "2024-09-30", "release_date": "2025-11-13", "last_updated": "2025-11-13", @@ -100028,7 +100178,7 @@ "attachment": true, "reasoning": true, "tool_call": true, - "temperature": false, + "temperature": true, "knowledge": "2025-08-31", "release_date": "2025-12-11", "last_updated": "2025-12-11", @@ -100121,7 +100271,7 @@ "attachment": true, "reasoning": true, "tool_call": true, - "temperature": false, + "temperature": true, "knowledge": "2025-08-31", "release_date": "2026-02-05", "last_updated": "2026-02-05", @@ -100153,7 +100303,7 @@ "attachment": true, "reasoning": true, "tool_call": true, - "temperature": false, + "temperature": true, "knowledge": "2025-08-31", "release_date": "2026-03-05", "last_updated": "2026-03-05", @@ -100185,7 +100335,7 @@ "attachment": true, "reasoning": true, "tool_call": true, - "temperature": false, + "temperature": true, "knowledge": "2025-08-31", "release_date": "2026-03-17", "last_updated": "2026-03-17", @@ -100216,7 +100366,7 @@ "attachment": true, "reasoning": true, "tool_call": true, - "temperature": false, + "temperature": true, "knowledge": "2025-08-31", "release_date": "2026-03-17", "last_updated": "2026-03-17", @@ -101533,7 +101683,7 @@ }, "limit": { "context": 196608, - "output": 128000 + "output": 131072 } }, { @@ -101740,7 +101890,7 @@ }, "limit": { "context": 1048576, - "output": 128000 + "output": 512000 } }, { @@ -103454,6 +103604,35 @@ "output": 4096 } }, + { + "id": "llmtech/unsloth/Qwen3.8-27B-NVFP4", + "name": "Qwen3.8 27B", + "family": "qwen", + "attachment": false, + "reasoning": true, + "tool_call": true, + "temperature": true, + "release_date": "2026-08-14", + "last_updated": "2026-08-14", + "modalities": { + "input": [ + "text" + ], + "output": [ + "text" + ] + }, + "open_weights": true, + "cost": { + "input": 0.25, + "output": 2.09, + "cache_read": 0.04 + }, + "limit": { + "context": 262144, + "output": 32768 + } + }, { "id": "llmtr/gemma-4", "name": "Gemma 4", @@ -107935,7 +108114,7 @@ "attachment": true, "reasoning": false, "tool_call": false, - "temperature": true, + "temperature": false, "knowledge": "2024-09-30", "release_date": "2025-08-07", "last_updated": "2025-08-07", @@ -108031,7 +108210,7 @@ "attachment": true, "reasoning": true, "tool_call": true, - "temperature": false, + "temperature": true, "knowledge": "2024-09-30", "release_date": "2025-11-13", "last_updated": "2025-11-13", @@ -108095,7 +108274,7 @@ "attachment": true, "reasoning": true, "tool_call": true, - "temperature": false, + "temperature": true, "knowledge": "2025-08-31", "release_date": "2025-12-11", "last_updated": "2025-12-11", @@ -108191,7 +108370,7 @@ "attachment": true, "reasoning": true, "tool_call": true, - "temperature": false, + "temperature": true, "knowledge": "2025-08-31", "release_date": "2026-03-05", "last_updated": "2026-03-05", @@ -108223,7 +108402,7 @@ "attachment": true, "reasoning": true, "tool_call": true, - "temperature": false, + "temperature": true, "knowledge": "2025-08-31", "release_date": "2026-03-17", "last_updated": "2026-03-17", @@ -108255,7 +108434,7 @@ "attachment": true, "reasoning": true, "tool_call": true, - "temperature": false, + "temperature": true, "knowledge": "2025-08-31", "release_date": "2026-03-17", "last_updated": "2026-03-17", @@ -108367,8 +108546,8 @@ }, "open_weights": false, "cost": { - "input": 4.0, - "output": 24.0, + "input": 5.0, + "output": 30.0, "cache_read": 0.5 }, "limit": { @@ -108947,7 +109126,7 @@ }, { "id": "merge-gateway/qwen/qwen3-next-80b-a3b-instruct", - "name": "Qwen3 Next 80B A3B Instruct", + "name": "Qwen3-Next 80B-A3B Instruct", "family": "qwen", "attachment": false, "reasoning": false, @@ -108977,7 +109156,7 @@ }, { "id": "merge-gateway/qwen/qwen3-next-80b-a3b-thinking", - "name": "Qwen3 Next 80B A3B Thinking", + "name": "Qwen3-Next 80B-A3B (Thinking)", "family": "qwen", "attachment": false, "reasoning": true, @@ -109101,7 +109280,7 @@ "id": "merge-gateway/qwen/qwen3.5-122b-a10b", "name": "Qwen3.5 122B A10B", "family": "qwen", - "attachment": true, + "attachment": false, "reasoning": true, "tool_call": true, "temperature": true, @@ -109109,8 +109288,7 @@ "last_updated": "2026-02-23", "modalities": { "input": [ - "text", - "image" + "text" ], "output": [ "text" @@ -109123,8 +109301,8 @@ "cache_read": 0.023 }, "limit": { - "context": 256000, - "output": 64000 + "context": 131072, + "output": 32768 } }, { @@ -109161,7 +109339,7 @@ "id": "merge-gateway/qwen/qwen3.5-35b-a3b", "name": "Qwen3.5 35B A3B", "family": "qwen", - "attachment": true, + "attachment": false, "reasoning": true, "tool_call": true, "temperature": true, @@ -109169,8 +109347,7 @@ "last_updated": "2026-02-23", "modalities": { "input": [ - "text", - "image" + "text" ], "output": [ "text" @@ -109183,15 +109360,15 @@ "cache_read": 0.020357 }, "limit": { - "context": 256000, - "output": 64000 + "context": 131072, + "output": 32768 } }, { "id": "merge-gateway/qwen/qwen3.5-397b-a17b", "name": "Qwen3.5 397B A17B", "family": "qwen", - "attachment": true, + "attachment": false, "reasoning": true, "tool_call": true, "temperature": true, @@ -109199,8 +109376,7 @@ "last_updated": "2026-02-15", "modalities": { "input": [ - "text", - "image" + "text" ], "output": [ "text" @@ -109213,8 +109389,8 @@ "cache_read": 0.0344 }, "limit": { - "context": 256000, - "output": 64000 + "context": 131072, + "output": 32768 } }, { @@ -110258,6 +110434,38 @@ "output": 131072 } }, + { + "id": "merge-gateway/zai/glm-5.3-flash", + "name": "GLM-5.3 Flash", + "family": "glm", + "attachment": true, + "reasoning": true, + "tool_call": true, + "temperature": true, + "release_date": "2026-08-26", + "last_updated": "2026-08-26", + "modalities": { + "input": [ + "text", + "image", + "video", + "pdf" + ], + "output": [ + "text" + ] + }, + "open_weights": false, + "cost": { + "input": 0.075, + "output": 0.25, + "cache_read": 0.015 + }, + "limit": { + "context": 1000000, + "output": 131072 + } + }, { "id": "meta-llama/cerebras-llama-4-maverick-17b-128e-instruct", "name": "Cerebras-Llama-4-Maverick-17B-128E-Instruct", @@ -110585,8 +110793,8 @@ "output": 0.0 }, "limit": { - "context": 196608, - "output": 128000 + "context": 204800, + "output": 131072 } }, { @@ -110767,8 +110975,8 @@ "cache_write": 0.0 }, "limit": { - "context": 1000000, - "output": 128000 + "context": 1048576, + "output": 512000 } }, { @@ -110795,8 +111003,8 @@ "output": 1.2 }, "limit": { - "context": 196608, - "output": 128000 + "context": 204800, + "output": 131072 } }, { @@ -110976,8 +111184,8 @@ "cache_read": 0.06 }, "limit": { - "context": 1000000, - "output": 128000 + "context": 1048576, + "output": 512000 } }, { @@ -111004,8 +111212,8 @@ "output": 0.0 }, "limit": { - "context": 196608, - "output": 128000 + "context": 204800, + "output": 131072 } }, { @@ -111186,8 +111394,8 @@ "cache_write": 0.0 }, "limit": { - "context": 1000000, - "output": 128000 + "context": 1048576, + "output": 512000 } }, { @@ -111214,8 +111422,8 @@ "output": 1.2 }, "limit": { - "context": 196608, - "output": 128000 + "context": 204800, + "output": 131072 } }, { @@ -111395,8 +111603,8 @@ "cache_read": 0.06 }, "limit": { - "context": 1000000, - "output": 128000 + "context": 1048576, + "output": 512000 } }, { @@ -112062,6 +112270,35 @@ "output": 32000 } }, + { + "id": "mistralai/zai-glm-5.2", + "name": "GLM-5.2", + "family": "glm", + "attachment": false, + "reasoning": true, + "tool_call": true, + "temperature": true, + "release_date": "2026-06-13", + "last_updated": "2026-06-13", + "modalities": { + "input": [ + "text" + ], + "output": [ + "text" + ] + }, + "open_weights": true, + "cost": { + "input": 1.4, + "output": 4.4, + "cache_read": 0.14 + }, + "limit": { + "context": 1000000, + "output": 131072 + } + }, { "id": "mixlayer/qwen/qwen3.5-122b-a10b", "name": "Qwen3.5 122B A10B", @@ -112601,7 +112838,7 @@ "attachment": true, "reasoning": true, "tool_call": true, - "temperature": false, + "temperature": true, "knowledge": "2025-08-31", "release_date": "2026-03-05", "last_updated": "2026-03-05", @@ -112629,7 +112866,7 @@ "attachment": true, "reasoning": true, "tool_call": true, - "temperature": false, + "temperature": true, "knowledge": "2025-08-31", "release_date": "2026-03-17", "last_updated": "2026-03-17", @@ -112656,7 +112893,7 @@ "attachment": true, "reasoning": true, "tool_call": true, - "temperature": false, + "temperature": true, "knowledge": "2025-08-31", "release_date": "2026-03-17", "last_updated": "2026-03-17", @@ -114248,6 +114485,64 @@ "output": 8192 } }, + { + "id": "nano-gpt/Gemma-4-26B-A4B-MeroMero", + "name": "Gemma 4 26B A4B MeroMero", + "family": "gemma", + "attachment": true, + "reasoning": true, + "tool_call": true, + "release_date": "2026-08-26", + "last_updated": "2026-08-26", + "modalities": { + "input": [ + "text", + "image" + ], + "output": [ + "text" + ] + }, + "open_weights": true, + "cost": { + "input": 0.08, + "output": 0.33, + "cache_read": 0.04 + }, + "limit": { + "context": 262144, + "output": 32768 + } + }, + { + "id": "nano-gpt/Gemma-4-26B-A4B-MeroMero:thinking", + "name": "Gemma 4 26B A4B MeroMero Thinking", + "family": "gemma", + "attachment": true, + "reasoning": true, + "tool_call": true, + "release_date": "2026-08-26", + "last_updated": "2026-08-26", + "modalities": { + "input": [ + "text", + "image" + ], + "output": [ + "text" + ] + }, + "open_weights": true, + "cost": { + "input": 0.08, + "output": 0.33, + "cache_read": 0.04 + }, + "limit": { + "context": 262144, + "output": 32768 + } + }, { "id": "nano-gpt/Gemma-4-31B-Claude-4.6-Opus-Reasoning-Distilled", "name": "Gemma 4 31B Claude 4.6 Opus Reasoning Distilled", @@ -114389,7 +114684,7 @@ "cache_read": 0.04 }, "limit": { - "context": 65536, + "context": 262144, "output": 32768 } }, @@ -114418,7 +114713,7 @@ "cache_read": 0.04 }, "limit": { - "context": 65536, + "context": 262144, "output": 32768 } }, @@ -115302,15 +115597,14 @@ "id": "nano-gpt/TEE/gemma-4-26b-a4b-uncensored", "name": "Gemma 4 26B A4B Uncensored TEE", "family": "gemma", - "attachment": true, + "attachment": false, "reasoning": false, "tool_call": true, "release_date": "2026-05-23", "last_updated": "2026-05-23", "modalities": { "input": [ - "text", - "image" + "text" ], "output": [ "text" @@ -116649,6 +116943,37 @@ "output": 65536 } }, + { + "id": "nano-gpt/alibaba/qwen3.8-flash", + "name": "Qwen3.8 Flash", + "family": "qwen", + "attachment": true, + "reasoning": true, + "tool_call": true, + "release_date": "2026-08-26", + "last_updated": "2026-08-26", + "modalities": { + "input": [ + "text", + "image", + "video" + ], + "output": [ + "text" + ] + }, + "open_weights": false, + "cost": { + "input": 0.16, + "output": 0.47, + "cache_read": 0.016, + "cache_write": 0.2 + }, + "limit": { + "context": 991808, + "output": 131072 + } + }, { "id": "nano-gpt/amazon/nova-2-lite-v1", "name": "Amazon Nova 2 Lite", @@ -118814,16 +119139,14 @@ { "id": "nano-gpt/claw-high", "name": "Claw High", - "attachment": true, + "attachment": false, "reasoning": true, "tool_call": true, "release_date": "2026-05-11", "last_updated": "2026-05-11", "modalities": { "input": [ - "text", - "image", - "pdf" + "text" ], "output": [ "text" @@ -118831,29 +119154,26 @@ }, "open_weights": false, "cost": { - "input": 5.0, - "output": 25.0, - "cache_read": 2.5 + "input": 1.4, + "output": 4.4, + "cache_read": 0.26 }, "limit": { - "context": 1000000, - "output": 128000 + "context": 1048576, + "output": 131072 } }, { "id": "nano-gpt/claw-low", "name": "Claw Low", - "attachment": true, + "attachment": false, "reasoning": true, "tool_call": true, "release_date": "2026-05-11", "last_updated": "2026-05-11", "modalities": { "input": [ - "text", - "image", - "video", - "pdf" + "text" ], "output": [ "text" @@ -118861,14 +119181,14 @@ }, "open_weights": false, "cost": { - "input": 0.25, - "output": 1.5, - "cache_read": 0.025, + "input": 1.4, + "output": 4.4, + "cache_read": 0.26, "cache_write": 0.08333 }, "limit": { "context": 1048576, - "output": 65536 + "output": 131072 } }, { @@ -118889,12 +119209,12 @@ }, "open_weights": false, "cost": { - "input": 0.315, - "output": 1.26, - "cache_read": 0.1575 + "input": 1.4, + "output": 4.4, + "cache_read": 0.26 }, "limit": { - "context": 204800, + "context": 1048576, "output": 131072 } }, @@ -122016,7 +122336,7 @@ "cache_read": 0.04 }, "limit": { - "context": 131072, + "context": 262144, "output": 32768 } }, @@ -122045,7 +122365,7 @@ "cache_read": 0.04 }, "limit": { - "context": 131072, + "context": 262144, "output": 32768 } }, @@ -122113,16 +122433,14 @@ "id": "nano-gpt/hermes-high", "name": "Hermes High", "family": "hermes", - "attachment": true, + "attachment": false, "reasoning": true, "tool_call": true, "release_date": "2026-05-11", "last_updated": "2026-05-11", "modalities": { "input": [ - "text", - "image", - "pdf" + "text" ], "output": [ "text" @@ -122130,30 +122448,27 @@ }, "open_weights": false, "cost": { - "input": 5.0, - "output": 25.0, - "cache_read": 2.5 + "input": 1.4, + "output": 4.4, + "cache_read": 0.26 }, "limit": { - "context": 1000000, - "output": 128000 + "context": 1048576, + "output": 131072 } }, { "id": "nano-gpt/hermes-low", "name": "Hermes Low", "family": "hermes", - "attachment": true, + "attachment": false, "reasoning": true, "tool_call": true, "release_date": "2026-05-11", "last_updated": "2026-05-11", "modalities": { "input": [ - "text", - "image", - "video", - "pdf" + "text" ], "output": [ "text" @@ -122161,14 +122476,14 @@ }, "open_weights": false, "cost": { - "input": 0.25, - "output": 1.5, - "cache_read": 0.025, + "input": 1.4, + "output": 4.4, + "cache_read": 0.26, "cache_write": 0.08333 }, "limit": { "context": 1048576, - "output": 65536 + "output": 131072 } }, { @@ -122190,12 +122505,12 @@ }, "open_weights": false, "cost": { - "input": 0.315, - "output": 1.26, - "cache_read": 0.1575 + "input": 1.4, + "output": 4.4, + "cache_read": 0.26 }, "limit": { - "context": 204800, + "context": 1048576, "output": 131072 } }, @@ -125651,7 +125966,7 @@ "attachment": true, "reasoning": true, "tool_call": true, - "temperature": false, + "temperature": true, "knowledge": "2024-09-30", "release_date": "2025-11-13", "last_updated": "2025-11-13", @@ -125777,7 +126092,7 @@ "attachment": true, "reasoning": true, "tool_call": true, - "temperature": false, + "temperature": true, "knowledge": "2025-08-31", "release_date": "2025-12-11", "last_updated": "2025-12-11", @@ -125841,7 +126156,7 @@ "attachment": true, "reasoning": true, "tool_call": true, - "temperature": false, + "temperature": true, "knowledge": "2025-08-31", "release_date": "2026-02-05", "last_updated": "2026-02-05", @@ -125873,7 +126188,7 @@ "attachment": true, "reasoning": true, "tool_call": true, - "temperature": false, + "temperature": true, "knowledge": "2025-08-31", "release_date": "2026-03-05", "last_updated": "2026-03-05", @@ -125905,7 +126220,7 @@ "attachment": true, "reasoning": true, "tool_call": true, - "temperature": false, + "temperature": true, "knowledge": "2025-08-31", "release_date": "2026-03-17", "last_updated": "2026-03-17", @@ -125937,7 +126252,7 @@ "attachment": true, "reasoning": true, "tool_call": true, - "temperature": false, + "temperature": true, "knowledge": "2025-08-31", "release_date": "2026-03-17", "last_updated": "2026-03-17", @@ -126799,7 +127114,7 @@ "cache_read": 0.025 }, "limit": { - "context": 131072, + "context": 262144, "output": 32768 } }, @@ -126828,7 +127143,7 @@ "cache_read": 0.025 }, "limit": { - "context": 131072, + "context": 262144, "output": 32768 } }, @@ -128008,7 +128323,7 @@ "cache_read": 0.075 }, "limit": { - "context": 65536, + "context": 262144, "output": 32768 } }, @@ -128037,10 +128352,43 @@ "cache_read": 0.075 }, "limit": { - "context": 65536, + "context": 262144, "output": 32768 } }, + { + "id": "nano-gpt/qwen/qwen3.8-2.4t-a95b", + "name": "Qwen3.8 2.4T A95B (Max)", + "family": "qwen", + "attachment": true, + "reasoning": false, + "tool_call": true, + "temperature": true, + "release_date": "2026-08-12", + "last_updated": "2026-08-12", + "modalities": { + "input": [ + "text", + "image", + "video", + "pdf" + ], + "output": [ + "text" + ] + }, + "open_weights": true, + "cost": { + "input": 2.0, + "output": 6.0, + "cache_read": 0.25, + "cache_write": 2.5 + }, + "limit": { + "context": 991000, + "output": 65536 + } + }, { "id": "nano-gpt/qwen/qwen3.8-27b-obliterated", "name": "Qwen 3.8 27B Obliterated", @@ -128099,6 +128447,64 @@ "output": 32768 } }, + { + "id": "nano-gpt/qwen/qwen3.8-27b-uncensored", + "name": "Qwen 3.8 27B Uncensored", + "family": "qwen", + "attachment": true, + "reasoning": true, + "tool_call": true, + "release_date": "2026-08-21", + "last_updated": "2026-08-21", + "modalities": { + "input": [ + "text", + "image" + ], + "output": [ + "text" + ] + }, + "open_weights": true, + "cost": { + "input": 0.18, + "output": 0.5, + "cache_read": 0.075 + }, + "limit": { + "context": 262144, + "output": 32768 + } + }, + { + "id": "nano-gpt/qwen/qwen3.8-27b-uncensored:thinking", + "name": "Qwen 3.8 27B Uncensored Thinking", + "family": "qwen", + "attachment": true, + "reasoning": true, + "tool_call": true, + "release_date": "2026-08-25", + "last_updated": "2026-08-25", + "modalities": { + "input": [ + "text", + "image" + ], + "output": [ + "text" + ] + }, + "open_weights": true, + "cost": { + "input": 0.18, + "output": 0.5, + "cache_read": 0.075 + }, + "limit": { + "context": 262144, + "output": 32768 + } + }, { "id": "nano-gpt/qwen25-vl-72b-instruct", "name": "Qwen25 VL 72b", @@ -129384,35 +129790,6 @@ "output": 8192 } }, - { - "id": "nano-gpt/stealth/ox-alpha", - "name": "Ox Alpha", - "family": "alpha", - "attachment": true, - "reasoning": true, - "tool_call": true, - "release_date": "2026-08-21", - "last_updated": "2026-08-21", - "modalities": { - "input": [ - "text", - "image" - ], - "output": [ - "text" - ] - }, - "open_weights": false, - "cost": { - "input": 0.05, - "output": 0.05, - "cache_read": 0.025 - }, - "limit": { - "context": 1000000, - "output": 32768 - } - }, { "id": "nano-gpt/step-r1-v-mini", "name": "Step R1 V Mini", @@ -130565,6 +130942,37 @@ "output": 131072 } }, + { + "id": "nano-gpt/z-ai/glm-5.3-flash", + "name": "GLM 5.3 Flash", + "family": "glm", + "attachment": true, + "reasoning": true, + "tool_call": true, + "temperature": true, + "release_date": "2026-08-26", + "last_updated": "2026-08-26", + "modalities": { + "input": [ + "text", + "image", + "video" + ], + "output": [ + "text" + ] + }, + "open_weights": false, + "cost": { + "input": 0.075, + "output": 0.25, + "cache_read": 0.015 + }, + "limit": { + "context": 1048576, + "output": 131072 + } + }, { "id": "nano-gpt/z-ai/glm-5v-turbo", "name": "GLM 5V Turbo", @@ -132258,7 +132666,7 @@ "attachment": true, "reasoning": true, "tool_call": true, - "temperature": false, + "temperature": true, "knowledge": "2024-09-30", "release_date": "2025-11-13", "last_updated": "2025-11-13", @@ -132289,7 +132697,7 @@ "attachment": true, "reasoning": true, "tool_call": true, - "temperature": false, + "temperature": true, "knowledge": "2025-08-31", "release_date": "2025-12-11", "last_updated": "2025-12-11", @@ -132320,7 +132728,7 @@ "attachment": true, "reasoning": true, "tool_call": true, - "temperature": false, + "temperature": true, "knowledge": "2025-08-31", "release_date": "2026-03-05", "last_updated": "2026-03-05", @@ -132352,7 +132760,7 @@ "attachment": true, "reasoning": true, "tool_call": true, - "temperature": false, + "temperature": true, "knowledge": "2025-08-31", "release_date": "2026-03-17", "last_updated": "2026-03-17", @@ -132383,7 +132791,7 @@ "attachment": true, "reasoning": true, "tool_call": true, - "temperature": false, + "temperature": true, "knowledge": "2025-08-31", "release_date": "2026-03-17", "last_updated": "2026-03-17", @@ -134975,6 +135383,126 @@ "output": 25000 } }, + { + "id": "neosmith/neosmith.intelligent-basic", + "name": "NeoSmith Basic", + "attachment": true, + "reasoning": true, + "tool_call": true, + "temperature": true, + "release_date": "2026-04-26", + "last_updated": "2026-08-03", + "modalities": { + "input": [ + "text", + "image" + ], + "output": [ + "text" + ] + }, + "open_weights": false, + "cost": { + "input": 1.17, + "output": 4.37, + "cache_read": 0.22, + "cache_write": 0.0 + }, + "limit": { + "context": 1000000, + "output": 128000 + } + }, + { + "id": "neosmith/neosmith.intelligent-maestro", + "name": "NeoSmith Maestro", + "attachment": true, + "reasoning": true, + "tool_call": true, + "temperature": true, + "release_date": "2026-08-03", + "last_updated": "2026-08-08", + "modalities": { + "input": [ + "text", + "image" + ], + "output": [ + "text" + ] + }, + "open_weights": false, + "cost": { + "input": 2.4, + "output": 12.0, + "cache_read": 0.35, + "cache_write": 0.0 + }, + "limit": { + "context": 1000000, + "output": 128000 + } + }, + { + "id": "neosmith/neosmith.intelligent-pro", + "name": "NeoSmith Pro", + "attachment": true, + "reasoning": true, + "tool_call": true, + "temperature": true, + "release_date": "2026-04-26", + "last_updated": "2026-07-18", + "modalities": { + "input": [ + "text", + "image" + ], + "output": [ + "text" + ] + }, + "open_weights": false, + "cost": { + "input": 1.81, + "output": 8.39, + "cache_read": 0.3, + "cache_write": 0.0 + }, + "limit": { + "context": 1000000, + "output": 128000 + } + }, + { + "id": "neosmith/neosmith.neolite", + "name": "NeoSmith NeoLite", + "attachment": true, + "reasoning": true, + "tool_call": true, + "temperature": true, + "release_date": "2026-06-20", + "last_updated": "2026-08-03", + "modalities": { + "input": [ + "text", + "image" + ], + "output": [ + "text" + ] + }, + "open_weights": false, + "cost": { + "input": 0.6, + "output": 2.4, + "cache_read": 0.08, + "cache_write": 0.0 + }, + "limit": { + "context": 512000, + "output": 64000 + } + }, { "id": "neuralwatt/Qwen/Qwen3.5-397B-A17B-FP8", "name": "Qwen3.5 397B A17B FP8", @@ -135055,15 +135583,75 @@ }, "open_weights": true, "cost": { - "input": 0.104, - "output": 0.207, - "cache_read": 0.026 + "input": 0.14, + "output": 0.28, + "cache_read": 0.028 + }, + "limit": { + "context": 1048560, + "output": 65536 + } + }, + { + "id": "neuralwatt/deepseek-v4-flash-flex", + "name": "DeepSeek V4 Flash Flex", + "family": "deepseek-flash", + "attachment": false, + "reasoning": true, + "tool_call": true, + "temperature": true, + "knowledge": "2025-05", + "release_date": "2026-04-24", + "last_updated": "2026-04-24", + "modalities": { + "input": [ + "text" + ], + "output": [ + "text" + ] + }, + "open_weights": true, + "cost": { + "input": 0.091, + "output": 0.182, + "cache_read": 0.0182 }, "limit": { "context": 1048560, "output": 65536 } }, + { + "id": "neuralwatt/deepseek-v4-pro", + "name": "DeepSeek V4 Pro", + "family": "deepseek-thinking", + "attachment": false, + "reasoning": true, + "tool_call": true, + "temperature": true, + "knowledge": "2025-05", + "release_date": "2026-04-24", + "last_updated": "2026-04-24", + "modalities": { + "input": [ + "text" + ], + "output": [ + "text" + ] + }, + "open_weights": true, + "cost": { + "input": 1.0, + "output": 3.0, + "cache_read": 0.1 + }, + "limit": { + "context": 1048560, + "output": 393216 + } + }, { "id": "neuralwatt/gemma-4-31b", "name": "Gemma 4 31B", @@ -135569,6 +136157,36 @@ "output": 262144 } }, + { + "id": "neuralwatt/qwen-3.8-27b", + "name": "Qwen3.8 27B", + "family": "qwen", + "attachment": true, + "reasoning": true, + "tool_call": true, + "temperature": true, + "release_date": "2026-08-14", + "last_updated": "2026-08-14", + "modalities": { + "input": [ + "text", + "image" + ], + "output": [ + "text" + ] + }, + "open_weights": true, + "cost": { + "input": 0.45, + "output": 3.2, + "cache_read": 0.25 + }, + "limit": { + "context": 262128, + "output": 65536 + } + }, { "id": "neuralwatt/qwen3.5-397b-fast", "name": "Qwen3.5 397B Fast", @@ -143165,8 +143783,8 @@ "output": 1.2 }, "limit": { - "context": 200000, - "output": 131000 + "context": 65536, + "output": 2048 } }, { @@ -143195,8 +143813,8 @@ "cache_write": 0.375 }, "limit": { - "context": 196608, - "output": 128000 + "context": 204800, + "output": 131072 } }, { @@ -143406,8 +144024,8 @@ "cache_read": 0.12 }, "limit": { - "context": 512000, - "output": 128000 + "context": 1048576, + "output": 512000 } }, { @@ -143797,7 +144415,7 @@ "attachment": true, "reasoning": true, "tool_call": true, - "temperature": false, + "temperature": true, "knowledge": "2024-09-30", "release_date": "2025-11-13", "last_updated": "2025-11-13", @@ -143890,7 +144508,7 @@ "attachment": true, "reasoning": true, "tool_call": true, - "temperature": false, + "temperature": true, "knowledge": "2025-08-31", "release_date": "2025-12-11", "last_updated": "2025-12-11", @@ -143953,7 +144571,7 @@ "attachment": true, "reasoning": true, "tool_call": true, - "temperature": false, + "temperature": true, "knowledge": "2025-08-31", "release_date": "2026-02-05", "last_updated": "2026-02-05", @@ -143985,7 +144603,7 @@ "attachment": true, "reasoning": true, "tool_call": true, - "temperature": false, + "temperature": true, "knowledge": "2025-08-31", "release_date": "2026-03-05", "last_updated": "2026-03-05", @@ -144017,7 +144635,7 @@ "attachment": true, "reasoning": true, "tool_call": true, - "temperature": false, + "temperature": true, "knowledge": "2025-08-31", "release_date": "2026-03-17", "last_updated": "2026-03-17", @@ -144048,7 +144666,7 @@ "attachment": true, "reasoning": true, "tool_call": true, - "temperature": false, + "temperature": true, "knowledge": "2025-08-31", "release_date": "2026-03-17", "last_updated": "2026-03-17", @@ -144266,7 +144884,7 @@ "id": "ofox/volcengine/doubao-seed-1.6-flash", "name": "Seed 1.6 Flash", "family": "seed", - "attachment": false, + "attachment": true, "reasoning": false, "tool_call": true, "temperature": true, @@ -144274,7 +144892,8 @@ "last_updated": "2025-08-28", "modalities": { "input": [ - "text" + "text", + "image" ], "output": [ "text" @@ -144325,7 +144944,7 @@ "id": "ofox/volcengine/doubao-seed-1.8", "name": "Seed 1.8", "family": "seed", - "attachment": false, + "attachment": true, "reasoning": true, "tool_call": true, "temperature": true, @@ -144333,7 +144952,8 @@ "last_updated": "2025-12-28", "modalities": { "input": [ - "text" + "text", + "image" ], "output": [ "text" @@ -145933,7 +146553,7 @@ "attachment": true, "reasoning": true, "tool_call": true, - "temperature": false, + "temperature": true, "knowledge": "2024-09-30", "release_date": "2025-11-13", "last_updated": "2025-11-13", @@ -145964,7 +146584,7 @@ "attachment": true, "reasoning": true, "tool_call": true, - "temperature": false, + "temperature": true, "knowledge": "2025-08-31", "release_date": "2025-12-11", "last_updated": "2025-12-11", @@ -146087,7 +146707,7 @@ "attachment": true, "reasoning": true, "tool_call": true, - "temperature": false, + "temperature": true, "knowledge": "2025-08-31", "release_date": "2026-02-05", "last_updated": "2026-02-05", @@ -146151,7 +146771,7 @@ "attachment": true, "reasoning": true, "tool_call": true, - "temperature": false, + "temperature": true, "knowledge": "2025-08-31", "release_date": "2026-03-05", "last_updated": "2026-03-05", @@ -146183,7 +146803,7 @@ "attachment": true, "reasoning": true, "tool_call": true, - "temperature": false, + "temperature": true, "knowledge": "2025-08-31", "release_date": "2026-03-17", "last_updated": "2026-03-17", @@ -146214,7 +146834,7 @@ "attachment": true, "reasoning": true, "tool_call": true, - "temperature": false, + "temperature": true, "knowledge": "2025-08-31", "release_date": "2026-03-17", "last_updated": "2026-03-17", @@ -146354,10 +146974,10 @@ }, "open_weights": false, "cost": { - "input": 5.0, - "output": 30.0, - "cache_read": 0.5, - "cache_write": 6.25 + "input": 4.0, + "output": 20.0, + "cache_read": 0.4, + "cache_write": 5.0 }, "limit": { "context": 1050000, @@ -146420,10 +147040,10 @@ }, "open_weights": false, "cost": { - "input": 5.0, - "output": 30.0, - "cache_read": 0.5, - "cache_write": 6.25 + "input": 4.0, + "output": 20.0, + "cache_read": 0.4, + "cache_write": 5.0 }, "limit": { "context": 1050000, @@ -147086,6 +147706,38 @@ "output": 131072 } }, + { + "id": "opencode-go/glm-5.3-flash", + "name": "GLM-5.3-Flash (2x usage)", + "family": "glm", + "attachment": true, + "reasoning": true, + "tool_call": true, + "temperature": true, + "release_date": "2026-08-26", + "last_updated": "2026-08-26", + "modalities": { + "input": [ + "text", + "image", + "video", + "pdf" + ], + "output": [ + "text" + ] + }, + "open_weights": false, + "cost": { + "input": 0.075, + "output": 0.25, + "cache_read": 0.015 + }, + "limit": { + "context": 1000000, + "output": 131072 + } + }, { "id": "opencode-go/gpt-5.6-luna", "name": "GPT-5.6 Luna", @@ -147139,6 +147791,37 @@ ] }, "open_weights": false, + "cost": { + "input": 2.0, + "output": 6.0, + "cache_read": 0.3 + }, + "limit": { + "context": 500000, + "output": 500000 + } + }, + { + "id": "opencode-go/grok-4.6", + "name": "Grok 4.6", + "family": "grok", + "attachment": true, + "reasoning": true, + "tool_call": true, + "temperature": true, + "knowledge": "2026-02-01", + "release_date": "2026-08-12", + "last_updated": "2026-08-12", + "modalities": { + "input": [ + "text", + "image" + ], + "output": [ + "text" + ] + }, + "open_weights": false, "cost": { "input": 2.0, "output": 6.0, @@ -149121,7 +149804,7 @@ "attachment": true, "reasoning": true, "tool_call": true, - "temperature": false, + "temperature": true, "knowledge": "2025-08-31", "release_date": "2026-03-05", "last_updated": "2026-03-05", @@ -149428,7 +150111,7 @@ "cost": { "input": 2.0, "output": 6.0, - "cache_read": 0.5 + "cache_read": 0.3 }, "limit": { "context": 500000, @@ -150797,7 +151480,7 @@ }, "limit": { "context": 32768, - "output": 32768 + "output": 29491 } }, { @@ -150825,7 +151508,7 @@ }, "limit": { "context": 65536, - "output": 65536 + "output": 58982 } }, { @@ -151592,7 +152275,7 @@ }, "limit": { "context": 262144, - "output": 262144 + "output": 235929 } }, { @@ -151830,7 +152513,7 @@ }, "limit": { "context": 262144, - "output": 262144 + "output": 235929 } }, { @@ -152091,7 +152774,7 @@ }, "limit": { "context": 163840, - "output": 163840 + "output": 147456 } }, { @@ -152121,7 +152804,7 @@ }, "limit": { "context": 163840, - "output": 161000 + "output": 144900 } }, { @@ -152179,7 +152862,7 @@ }, "limit": { "context": 8192, - "output": 8192 + "output": 7372 } }, { @@ -152239,7 +152922,7 @@ }, "limit": { "context": 163840, - "output": 163840 + "output": 147456 } }, { @@ -152292,9 +152975,9 @@ }, "open_weights": true, "cost": { - "input": 0.056, - "output": 0.112, - "cache_read": 0.0112 + "input": 0.07952, + "output": 0.15904, + "cache_read": 0.015904 }, "limit": { "context": 1048576, @@ -152352,9 +153035,9 @@ }, "open_weights": true, "cost": { - "input": 0.522, - "output": 1.044, - "cache_read": 0.0435 + "input": 0.87, + "output": 1.74, + "cache_read": 0.0725 }, "limit": { "context": 1048576, @@ -152386,7 +153069,7 @@ }, "limit": { "context": 512000, - "output": 512000 + "output": 460800 } }, { @@ -152756,7 +153439,7 @@ }, "limit": { "context": 65536, - "output": 65536 + "output": 58982 } }, { @@ -152822,7 +153505,7 @@ }, "limit": { "context": 65536, - "output": 65536 + "output": 58982 } }, { @@ -153157,7 +153840,7 @@ }, "limit": { "context": 262144, - "output": 131072 + "output": 117964 } }, { @@ -153190,35 +153873,6 @@ "output": 16384 } }, - { - "id": "openrouter/google/gemma-3n-e4b-it", - "name": "Gemma 3n 4B", - "family": "gemma", - "attachment": false, - "reasoning": false, - "tool_call": false, - "temperature": true, - "knowledge": "2024-08-31", - "release_date": "2025-05-20", - "last_updated": "2025-05-20", - "modalities": { - "input": [ - "text" - ], - "output": [ - "text" - ] - }, - "open_weights": true, - "cost": { - "input": 0.06, - "output": 0.12 - }, - "limit": { - "context": 32768, - "output": 32768 - } - }, { "id": "openrouter/google/gemma-4-26b-a4b-it", "name": "Gemma 4 26B A4B IT", @@ -153301,13 +153955,13 @@ }, "open_weights": true, "cost": { - "input": 0.1, + "input": 0.09, "output": 0.34, - "cache_read": 0.1 + "cache_read": 0.05 }, "limit": { "context": 262144, - "output": 262144 + "output": 16384 } }, { @@ -153425,7 +154079,7 @@ }, "limit": { "context": 8192, - "output": 4096 + "output": 3686 } }, { @@ -153453,7 +154107,7 @@ }, "limit": { "context": 131000, - "output": 131000 + "output": 117900 } }, { @@ -153482,7 +154136,7 @@ }, "limit": { "context": 131072, - "output": 131072 + "output": 117964 } }, { @@ -153772,7 +154426,7 @@ }, "limit": { "context": 131072, - "output": 131072 + "output": 117964 } }, { @@ -153801,7 +154455,7 @@ }, "limit": { "context": 60000, - "output": 60000 + "output": 54000 } }, { @@ -153830,7 +154484,7 @@ }, "limit": { "context": 131072, - "output": 131072 + "output": 117964 } }, { @@ -153854,12 +154508,13 @@ }, "open_weights": true, "cost": { - "input": 0.1, - "output": 0.32 + "input": 0.71, + "output": 0.71, + "cache_read": 0.71 }, "limit": { "context": 131072, - "output": 16384 + "output": 115200 } }, { @@ -153914,12 +154569,13 @@ }, "open_weights": true, "cost": { - "input": 0.1, - "output": 0.3 + "input": 0.11, + "output": 0.34, + "cache_read": 0.055 }, "limit": { "context": 1310720, - "output": 16384 + "output": 8192 } }, { @@ -153948,7 +154604,7 @@ "output": 0.18 }, "limit": { - "context": 1048576, + "context": 163840, "output": 16384 } }, @@ -153980,7 +154636,7 @@ }, "limit": { "context": 131072, - "output": 131072 + "output": 117964 } }, { @@ -154013,7 +154669,7 @@ }, "limit": { "context": 1048576, - "output": 1048576 + "output": 943718 } }, { @@ -154046,7 +154702,7 @@ }, "limit": { "context": 1048576, - "output": 1048576 + "output": 943718 } }, { @@ -154079,7 +154735,7 @@ }, "limit": { "context": 1048576, - "output": 1048576 + "output": 943718 } }, { @@ -154108,7 +154764,7 @@ }, "limit": { "context": 16384, - "output": 16384 + "output": 14745 } }, { @@ -154166,7 +154822,7 @@ }, "limit": { "context": 1000192, - "output": 1000192 + "output": 900172 } }, { @@ -154342,6 +154998,34 @@ "output": 131072 } }, + { + "id": "openrouter/minimax/minimax-m2.7:free", + "name": "MiniMax M2.7 (free)", + "family": "minimax", + "attachment": false, + "reasoning": true, + "tool_call": true, + "temperature": true, + "release_date": "2026-03-18", + "last_updated": "2026-03-18", + "modalities": { + "input": [ + "text" + ], + "output": [ + "text" + ] + }, + "open_weights": true, + "cost": { + "input": 0.0, + "output": 0.0 + }, + "limit": { + "context": 196608, + "output": 176947 + } + }, { "id": "openrouter/minimax/minimax-m3", "name": "MiniMax-M3", @@ -154373,6 +155057,36 @@ "output": 512000 } }, + { + "id": "openrouter/minimax/minimax-m3:free", + "name": "MiniMax M3 (free)", + "family": "minimax", + "attachment": true, + "reasoning": true, + "tool_call": true, + "temperature": true, + "release_date": "2026-06-01", + "last_updated": "2026-06-01", + "modalities": { + "input": [ + "text", + "image", + "video" + ], + "output": [ + "text" + ] + }, + "open_weights": true, + "cost": { + "input": 0.0, + "output": 0.0 + }, + "limit": { + "context": 1048576, + "output": 943718 + } + }, { "id": "openrouter/mistralai/codestral", "name": "Codestral 2508", @@ -154401,7 +155115,7 @@ }, "limit": { "context": 256000, - "output": 256000 + "output": 204800 } }, { @@ -154432,7 +155146,7 @@ }, "limit": { "context": 262144, - "output": 262144 + "output": 209715 } }, { @@ -154462,7 +155176,7 @@ }, "limit": { "context": 262144, - "output": 262144 + "output": 209715 } }, { @@ -154492,7 +155206,7 @@ }, "limit": { "context": 131072, - "output": 131072 + "output": 104857 } }, { @@ -154521,7 +155235,7 @@ }, "limit": { "context": 128000, - "output": 128000 + "output": 102400 } }, { @@ -154552,7 +155266,7 @@ }, "limit": { "context": 128000, - "output": 128000 + "output": 102400 } }, { @@ -154584,7 +155298,7 @@ }, "limit": { "context": 131072, - "output": 131072 + "output": 104857 } }, { @@ -154616,7 +155330,7 @@ }, "limit": { "context": 131072, - "output": 262144 + "output": 104857 } }, { @@ -154646,7 +155360,7 @@ }, "limit": { "context": 262144, - "output": 262144 + "output": 209715 } }, { @@ -154706,7 +155420,7 @@ }, "limit": { "context": 32768, - "output": 32768 + "output": 26214 } }, { @@ -154737,7 +155451,7 @@ }, "limit": { "context": 262144, - "output": 262144 + "output": 209715 } }, { @@ -154796,7 +155510,7 @@ }, "limit": { "context": 128000, - "output": 128000 + "output": 102400 } }, { @@ -154857,7 +155571,7 @@ }, "limit": { "context": 65536, - "output": 65536 + "output": 52428 } }, { @@ -154888,7 +155602,7 @@ }, "limit": { "context": 32000, - "output": 32000 + "output": 25600 } }, { @@ -154978,7 +155692,7 @@ }, "limit": { "context": 262144, - "output": 262144 + "output": 235929 } }, { @@ -155009,7 +155723,7 @@ }, "limit": { "context": 262144, - "output": 262144 + "output": 235929 } }, { @@ -155034,13 +155748,13 @@ }, "open_weights": true, "cost": { - "input": 0.67, + "input": 0.66, "output": 3.4, - "cache_read": 0.19 + "cache_read": 0.18 }, "limit": { "context": 262144, - "output": 262144 + "output": 235929 } }, { @@ -155071,7 +155785,7 @@ }, "limit": { "context": 1048576, - "output": 1048576 + "output": 943718 } }, { @@ -155157,7 +155871,7 @@ }, "limit": { "context": 262144, - "output": 262144 + "output": 235929 } }, { @@ -155187,7 +155901,7 @@ }, "limit": { "context": 262144, - "output": 262144 + "output": 235929 } }, { @@ -155274,7 +155988,7 @@ }, "limit": { "context": 131072, - "output": 131072 + "output": 117964 } }, { @@ -155303,7 +156017,7 @@ }, "limit": { "context": 131072, - "output": 131072 + "output": 117964 } }, { @@ -155328,11 +156042,11 @@ "cost": { "input": 0.05, "output": 0.2, - "cache_read": 0.03 + "cache_read": 0.025 }, "limit": { "context": 262144, - "output": 262144 + "output": 228000 } }, { @@ -155419,7 +156133,7 @@ }, "limit": { "context": 262144, - "output": 262144 + "output": 235929 } }, { @@ -155448,7 +156162,7 @@ }, "limit": { "context": 512288, - "output": 16384 + "output": 461059 } }, { @@ -155649,7 +156363,7 @@ }, "limit": { "context": 4095, - "output": 4096 + "output": 3685 } }, { @@ -156911,7 +157625,7 @@ }, "limit": { "context": 131072, - "output": 131072 + "output": 117964 } }, { @@ -156940,7 +157654,7 @@ }, "limit": { "context": 131072, - "output": 131072 + "output": 117964 } }, { @@ -157409,7 +158123,7 @@ }, "limit": { "context": 127072, - "output": 127072 + "output": 114364 } }, { @@ -157437,7 +158151,7 @@ }, "limit": { "context": 128000, - "output": 128000 + "output": 115200 } }, { @@ -157524,7 +158238,7 @@ }, "limit": { "context": 128000, - "output": 128000 + "output": 115200 } }, { @@ -157696,7 +158410,7 @@ }, "limit": { "context": 32768, - "output": 32768 + "output": 29491 } }, { @@ -157725,7 +158439,7 @@ }, "limit": { "context": 32768, - "output": 32768 + "output": 29491 } }, { @@ -157759,35 +158473,6 @@ "output": 32768 } }, - { - "id": "openrouter/qwen/qwen-plus-2025-07-28:thinking", - "name": "Qwen Plus 0728 (thinking)", - "family": "qwen", - "attachment": false, - "reasoning": true, - "tool_call": true, - "temperature": true, - "knowledge": "2025-03-31", - "release_date": "2025-09-08", - "last_updated": "2025-09-08", - "modalities": { - "input": [ - "text" - ], - "output": [ - "text" - ] - }, - "open_weights": false, - "cost": { - "input": 0.26, - "output": 0.78 - }, - "limit": { - "context": 1000000, - "output": 32768 - } - }, { "id": "openrouter/qwen/qwen2.5-vl-72b-instruct", "name": "Qwen2.5 VL 72B Instruct", @@ -157810,13 +158495,12 @@ }, "open_weights": true, "cost": { - "input": 0.8, - "output": 1.0, - "cache_read": 0.4 + "input": 0.25, + "output": 0.75 }, "limit": { "context": 128000, - "output": 128000 + "output": 28800 } }, { @@ -157903,7 +158587,7 @@ }, "limit": { "context": 131072, - "output": 32768 + "output": 117964 } }, { @@ -158106,7 +158790,7 @@ }, "limit": { "context": 262144, - "output": 262144 + "output": 235929 } }, { @@ -158167,7 +158851,7 @@ }, "limit": { "context": 262144, - "output": 262144 + "output": 235929 } }, { @@ -158287,7 +158971,7 @@ }, "limit": { "context": 262144, - "output": 262144 + "output": 235929 } }, { @@ -158554,7 +159238,7 @@ }, "limit": { "context": 262144, - "output": 262144 + "output": 235929 } }, { @@ -158615,7 +159299,7 @@ }, "limit": { "context": 262144, - "output": 262144 + "output": 235929 } }, { @@ -158640,13 +159324,12 @@ }, "open_weights": true, "cost": { - "input": 0.5, - "output": 3.6, - "cache_read": 0.3 + "input": 0.39, + "output": 2.34 }, "limit": { "context": 262144, - "output": 262144 + "output": 65536 } }, { @@ -158676,7 +159359,7 @@ }, "limit": { "context": 262144, - "output": 262144 + "output": 235929 } }, { @@ -158793,12 +159476,13 @@ }, "open_weights": true, "cost": { - "input": 0.32, - "output": 3.2 + "input": 0.6, + "output": 3.6, + "cache_read": 0.12 }, "limit": { "context": 262144, - "output": 81920 + "output": 235929 } }, { @@ -158823,13 +159507,13 @@ }, "open_weights": true, "cost": { - "input": 0.14, - "output": 1.0, + "input": 0.1, + "output": 0.9, "cache_read": 0.05 }, "limit": { "context": 262144, - "output": 262144 + "output": 235929 } }, { @@ -159070,9 +159754,42 @@ }, "open_weights": true, "cost": { - "input": 0.4, - "output": 3.0, - "cache_read": 0.05 + "input": 0.425, + "output": 2.55, + "cache_read": 0.085, + "cache_write": 0.53125 + }, + "limit": { + "context": 1000000, + "output": 131072 + } + }, + { + "id": "openrouter/qwen/qwen3.8-flash", + "name": "Qwen3.8 Flash", + "family": "qwen", + "attachment": true, + "reasoning": true, + "tool_call": true, + "temperature": true, + "release_date": "2026-08-26", + "last_updated": "2026-08-26", + "modalities": { + "input": [ + "text", + "image", + "video" + ], + "output": [ + "text" + ] + }, + "open_weights": false, + "cost": { + "input": 0.15, + "output": 0.47, + "cache_read": 0.016, + "cache_write": 0.2 }, "limit": { "context": 1000000, @@ -159138,7 +159855,7 @@ }, "limit": { "context": 16384, - "output": 16384 + "output": 14745 } }, { @@ -159167,7 +159884,7 @@ }, "limit": { "context": 65536, - "output": 65536 + "output": 58982 } }, { @@ -159311,7 +160028,7 @@ }, "limit": { "context": 8192, - "output": 16384 + "output": 7372 } }, { @@ -159372,36 +160089,6 @@ "output": 16384 } }, - { - "id": "openrouter/stealth/ox-alpha", - "name": "Ox Alpha", - "family": "alpha", - "attachment": true, - "reasoning": true, - "tool_call": true, - "temperature": true, - "release_date": "2026-08-20", - "last_updated": "2026-08-20", - "modalities": { - "input": [ - "text", - "image", - "video" - ], - "output": [ - "text" - ] - }, - "open_weights": false, - "cost": { - "input": 0.0, - "output": 0.0 - }, - "limit": { - "context": 1048576, - "output": 131072 - } - }, { "id": "openrouter/stepfun/step-3.5-flash", "name": "Step 3.5 Flash", @@ -159458,7 +160145,7 @@ }, "limit": { "context": 262144, - "output": 256000 + "output": 230400 } }, { @@ -159487,7 +160174,7 @@ }, "limit": { "context": 131072, - "output": 131072 + "output": 117964 } }, { @@ -159629,7 +160316,7 @@ }, "limit": { "context": 262144, - "output": 262144 + "output": 235929 } }, { @@ -159658,7 +160345,7 @@ }, "limit": { "context": 131072, - "output": 131072 + "output": 117964 } }, { @@ -159686,7 +160373,7 @@ }, "limit": { "context": 65536, - "output": 65536 + "output": 58982 } }, { @@ -159715,7 +160402,7 @@ }, "limit": { "context": 32768, - "output": 32768 + "output": 29491 } }, { @@ -159743,7 +160430,7 @@ }, "limit": { "context": 1024000, - "output": 32768 + "output": 26214 } }, { @@ -159768,9 +160455,9 @@ }, "open_weights": true, "cost": { - "input": 1.0, + "input": 0.95, "output": 4.05, - "cache_read": 0.17 + "cache_read": 0.16 }, "limit": { "context": 1048576, @@ -159922,7 +160609,7 @@ }, "limit": { "context": 131072, - "output": 131072 + "output": 117964 } }, { @@ -160011,7 +160698,7 @@ }, "limit": { "context": 2000000, - "output": 2000000 + "output": 1800000 } }, { @@ -160043,7 +160730,7 @@ }, "limit": { "context": 2000000, - "output": 2000000 + "output": 1800000 } }, { @@ -160074,7 +160761,7 @@ }, "limit": { "context": 1000000, - "output": 1000000 + "output": 900000 } }, { @@ -160105,7 +160792,7 @@ }, "limit": { "context": 500000, - "output": 500000 + "output": 450000 } }, { @@ -160137,7 +160824,7 @@ }, "limit": { "context": 500000, - "output": 500000 + "output": 450000 } }, { @@ -160168,7 +160855,7 @@ }, "limit": { "context": 256000, - "output": 256000 + "output": 230400 } }, { @@ -160346,13 +161033,13 @@ }, "open_weights": true, "cost": { - "input": 0.5, - "output": 2.0, - "cache_read": 0.1 + "input": 0.43, + "output": 1.75, + "cache_read": 0.08 }, "limit": { "context": 204800, - "output": 131072 + "output": 16384 } }, { @@ -160525,13 +161212,13 @@ }, "open_weights": true, "cost": { - "input": 0.966, - "output": 3.036, - "cache_read": 0.1794 + "input": 1.26, + "output": 3.96, + "cache_read": 0.234 }, "limit": { "context": 204800, - "output": 128000 + "output": 182476 } }, { @@ -160554,13 +161241,13 @@ }, "open_weights": true, "cost": { - "input": 0.966, - "output": 3.036, - "cache_read": 0.1932 + "input": 1.19, + "output": 3.74, + "cache_read": 0.221 }, "limit": { "context": 1048576, - "output": 131072 + "output": 262144 } }, { @@ -160588,7 +161275,7 @@ }, "limit": { "context": 256000, - "output": 256000 + "output": 230400 } }, { @@ -160620,6 +161307,37 @@ "output": 131072 } }, + { + "id": "openrouter/z-ai/glm-5.3-flash", + "name": "GLM-5.3-Flash", + "family": "glm", + "attachment": true, + "reasoning": true, + "tool_call": true, + "temperature": true, + "release_date": "2026-08-26", + "last_updated": "2026-08-26", + "modalities": { + "input": [ + "text", + "image", + "video" + ], + "output": [ + "text" + ] + }, + "open_weights": false, + "cost": { + "input": 0.075, + "output": 0.25, + "cache_read": 0.015 + }, + "limit": { + "context": 1310720, + "output": 131072 + } + }, { "id": "openrouter/z-ai/glm-5v-turbo", "name": "GLM-5V-Turbo", @@ -160800,13 +161518,13 @@ }, "open_weights": false, "cost": { - "input": 0.035, - "output": 0.12, - "cache_read": 0.01 + "input": 0.03, + "output": 0.1, + "cache_read": 0.007 }, "limit": { "context": 1310720, - "output": 1048576 + "output": 131072 } }, { @@ -160901,13 +161619,13 @@ }, "open_weights": false, "cost": { - "input": 2.6, - "output": 13.0, - "cache_read": 0.29 + "input": 2.55, + "output": 12.75, + "cache_read": 0.256 }, "limit": { "context": 1048576, - "output": 974842 + "output": 943718 } }, { @@ -161003,7 +161721,7 @@ }, "limit": { "context": 500000, - "output": 1000000 + "output": 450000 } }, { @@ -161562,7 +162280,7 @@ }, "limit": { "context": 1048576, - "output": 128000 + "output": 512000 } }, { @@ -161723,7 +162441,7 @@ "attachment": true, "reasoning": true, "tool_call": true, - "temperature": false, + "temperature": true, "knowledge": "2025-08-31", "release_date": "2026-02-05", "last_updated": "2026-02-05", @@ -161755,7 +162473,7 @@ "attachment": true, "reasoning": true, "tool_call": true, - "temperature": false, + "temperature": true, "knowledge": "2025-08-31", "release_date": "2026-03-05", "last_updated": "2026-03-05", @@ -161787,7 +162505,7 @@ "attachment": true, "reasoning": true, "tool_call": true, - "temperature": false, + "temperature": true, "knowledge": "2025-08-31", "release_date": "2026-03-17", "last_updated": "2026-03-17", @@ -161818,7 +162536,7 @@ "attachment": true, "reasoning": true, "tool_call": true, - "temperature": false, + "temperature": true, "knowledge": "2025-08-31", "release_date": "2026-03-17", "last_updated": "2026-03-17", @@ -163630,7 +164348,7 @@ "attachment": true, "reasoning": true, "tool_call": false, - "temperature": true, + "temperature": false, "knowledge": "2024-09-30", "release_date": "2025-08-07", "last_updated": "2025-08-07", @@ -163784,7 +164502,7 @@ "attachment": true, "reasoning": true, "tool_call": true, - "temperature": false, + "temperature": true, "knowledge": "2024-09-30", "release_date": "2025-11-13", "last_updated": "2025-11-13", @@ -163939,7 +164657,7 @@ "attachment": true, "reasoning": true, "tool_call": true, - "temperature": false, + "temperature": true, "knowledge": "2025-08-31", "release_date": "2025-12-11", "last_updated": "2025-12-11", @@ -164094,7 +164812,7 @@ "attachment": true, "reasoning": true, "tool_call": true, - "temperature": false, + "temperature": true, "knowledge": "2025-08-31", "release_date": "2026-02-05", "last_updated": "2026-02-05", @@ -164126,7 +164844,7 @@ "attachment": true, "reasoning": true, "tool_call": true, - "temperature": false, + "temperature": true, "knowledge": "2025-08-31", "release_date": "2026-03-05", "last_updated": "2026-03-05", @@ -164158,7 +164876,7 @@ "attachment": true, "reasoning": true, "tool_call": true, - "temperature": false, + "temperature": true, "knowledge": "2025-08-31", "release_date": "2026-03-17", "last_updated": "2026-03-17", @@ -164189,7 +164907,7 @@ "attachment": true, "reasoning": true, "tool_call": true, - "temperature": false, + "temperature": true, "knowledge": "2025-08-31", "release_date": "2026-03-17", "last_updated": "2026-03-17", @@ -165142,6 +165860,179 @@ "output": 16384 } }, + { + "id": "pendra/deepseek-v4-flash", + "name": "DeepSeek V4 Flash", + "family": "deepseek-flash", + "attachment": false, + "reasoning": true, + "tool_call": true, + "temperature": true, + "knowledge": "2025-05", + "release_date": "2026-04-24", + "last_updated": "2026-04-24", + "modalities": { + "input": [ + "text" + ], + "output": [ + "text" + ] + }, + "open_weights": true, + "cost": { + "input": 0.0, + "output": 0.0 + }, + "limit": { + "context": 1000000, + "output": 384000 + } + }, + { + "id": "pendra/glm-4.7-flash", + "name": "GLM-4.7-Flash", + "family": "glm-flash", + "attachment": false, + "reasoning": true, + "tool_call": true, + "temperature": true, + "knowledge": "2025-04", + "release_date": "2026-01-19", + "last_updated": "2026-01-19", + "modalities": { + "input": [ + "text" + ], + "output": [ + "text" + ] + }, + "open_weights": true, + "cost": { + "input": 0.0, + "output": 0.0 + }, + "limit": { + "context": 200000, + "output": 131072 + } + }, + { + "id": "pendra/gpt-oss:120b", + "name": "GPT OSS 120B", + "family": "gpt-oss", + "attachment": false, + "reasoning": true, + "tool_call": true, + "temperature": true, + "release_date": "2025-08-05", + "last_updated": "2025-08-05", + "modalities": { + "input": [ + "text" + ], + "output": [ + "text" + ] + }, + "open_weights": true, + "cost": { + "input": 0.0, + "output": 0.0 + }, + "limit": { + "context": 131072, + "output": 32768 + } + }, + { + "id": "pendra/llama3.3:70b", + "name": "Llama-3.3-70B-Instruct", + "family": "llama", + "attachment": false, + "reasoning": false, + "tool_call": true, + "temperature": true, + "knowledge": "2023-12", + "release_date": "2024-12-06", + "last_updated": "2024-12-06", + "modalities": { + "input": [ + "text" + ], + "output": [ + "text" + ] + }, + "open_weights": true, + "cost": { + "input": 0.0, + "output": 0.0 + }, + "limit": { + "context": 128000, + "output": 4096 + } + }, + { + "id": "pendra/qwen3-coder:30b", + "name": "Qwen3-Coder 30B-A3B Instruct", + "family": "qwen", + "attachment": false, + "reasoning": false, + "tool_call": true, + "temperature": true, + "knowledge": "2025-04", + "release_date": "2025-04", + "last_updated": "2025-04", + "modalities": { + "input": [ + "text" + ], + "output": [ + "text" + ] + }, + "open_weights": true, + "cost": { + "input": 0.0, + "output": 0.0 + }, + "limit": { + "context": 262144, + "output": 65536 + } + }, + { + "id": "pendra/qwen3.6:27b", + "name": "Qwen3.6 27B", + "family": "qwen", + "attachment": true, + "reasoning": true, + "tool_call": true, + "temperature": true, + "release_date": "2026-04-22", + "last_updated": "2026-04-22", + "modalities": { + "input": [ + "text", + "image" + ], + "output": [ + "text" + ] + }, + "open_weights": true, + "cost": { + "input": 0.0, + "output": 0.0 + }, + "limit": { + "context": 262144, + "output": 65536 + } + }, { "id": "perplexity-agent/anthropic/claude-haiku-4.5", "name": "Claude Haiku 4.5", @@ -167800,7 +168691,7 @@ "attachment": true, "reasoning": true, "tool_call": true, - "temperature": false, + "temperature": true, "knowledge": "2024-09-30", "release_date": "2025-11-13", "last_updated": "2025-11-13", @@ -167832,7 +168723,7 @@ "attachment": true, "reasoning": true, "tool_call": true, - "temperature": false, + "temperature": true, "knowledge": "2025-08-31", "release_date": "2026-02-05", "last_updated": "2026-02-05", @@ -167865,7 +168756,7 @@ "attachment": true, "reasoning": true, "tool_call": true, - "temperature": false, + "temperature": true, "knowledge": "2025-08-31", "release_date": "2026-03-05", "last_updated": "2026-03-05", @@ -167898,7 +168789,7 @@ "attachment": true, "reasoning": true, "tool_call": true, - "temperature": false, + "temperature": true, "knowledge": "2025-08-31", "release_date": "2026-03-17", "last_updated": "2026-03-17", @@ -167930,7 +168821,7 @@ "attachment": true, "reasoning": true, "tool_call": true, - "temperature": false, + "temperature": true, "knowledge": "2025-08-31", "release_date": "2026-03-17", "last_updated": "2026-03-17", @@ -178233,6 +179124,38 @@ "output": 128000 } }, + { + "id": "requesty/glm-5.3-flash", + "name": "GLM-5.3-Flash", + "family": "glm", + "attachment": true, + "reasoning": true, + "tool_call": true, + "temperature": true, + "release_date": "2026-08-26", + "last_updated": "2026-08-26", + "modalities": { + "input": [ + "text", + "image", + "video", + "pdf" + ], + "output": [ + "text" + ] + }, + "open_weights": false, + "cost": { + "input": 0.135, + "output": 0.45, + "cache_read": 0.027 + }, + "limit": { + "context": 1000000, + "output": 128000 + } + }, { "id": "requesty/gpt-4.1-mini@eu", "name": "GPT-4.1 mini (EU)", @@ -178429,7 +179352,7 @@ "attachment": true, "reasoning": true, "tool_call": true, - "temperature": false, + "temperature": true, "knowledge": "2024-09-30", "release_date": "2025-11-13", "last_updated": "2025-11-13", @@ -178489,7 +179412,7 @@ "attachment": true, "reasoning": true, "tool_call": true, - "temperature": false, + "temperature": true, "knowledge": "2025-08-31", "release_date": "2026-02-05", "last_updated": "2026-02-05", @@ -178521,7 +179444,7 @@ "attachment": true, "reasoning": true, "tool_call": true, - "temperature": false, + "temperature": true, "knowledge": "2025-08-31", "release_date": "2026-03-05", "last_updated": "2026-03-05", @@ -178553,7 +179476,7 @@ "attachment": true, "reasoning": true, "tool_call": true, - "temperature": false, + "temperature": true, "knowledge": "2025-08-31", "release_date": "2026-03-17", "last_updated": "2026-03-17", @@ -178584,7 +179507,7 @@ "attachment": true, "reasoning": true, "tool_call": true, - "temperature": false, + "temperature": true, "knowledge": "2025-08-31", "release_date": "2026-03-17", "last_updated": "2026-03-17", @@ -178646,7 +179569,7 @@ "attachment": true, "reasoning": true, "tool_call": true, - "temperature": false, + "temperature": true, "knowledge": "2025-08-31", "release_date": "2026-03-05", "last_updated": "2026-03-05", @@ -182599,7 +183522,7 @@ "attachment": true, "reasoning": true, "tool_call": true, - "temperature": false, + "temperature": true, "knowledge": "2025-08-31", "release_date": "2025-12-11", "last_updated": "2025-12-11", @@ -182630,7 +183553,7 @@ "attachment": true, "reasoning": true, "tool_call": true, - "temperature": false, + "temperature": true, "knowledge": "2025-08-31", "release_date": "2026-03-05", "last_updated": "2026-03-05", @@ -183964,8 +184887,8 @@ "cache_read": 0.0 }, "limit": { - "context": 512000, - "output": 128000 + "context": 1048576, + "output": 512000 } }, { @@ -187349,7 +188272,7 @@ "attachment": true, "reasoning": true, "tool_call": true, - "temperature": false, + "temperature": true, "knowledge": "2024-09-30", "release_date": "2025-11-13", "last_updated": "2025-11-13", @@ -187376,7 +188299,7 @@ "attachment": true, "reasoning": true, "tool_call": true, - "temperature": false, + "temperature": true, "knowledge": "2025-08-31", "release_date": "2025-12-11", "last_updated": "2025-12-11", @@ -187403,7 +188326,7 @@ "attachment": true, "reasoning": true, "tool_call": true, - "temperature": false, + "temperature": true, "knowledge": "2025-08-31", "release_date": "2026-03-05", "last_updated": "2026-03-05", @@ -187789,6 +188712,35 @@ "output": 8192 } }, + { + "id": "standardcompute/standardcompute", + "name": "Standard Compute", + "family": "auto", + "attachment": true, + "reasoning": true, + "tool_call": true, + "temperature": true, + "release_date": "2026-03-01", + "last_updated": "2026-08-24", + "modalities": { + "input": [ + "text", + "image" + ], + "output": [ + "text" + ] + }, + "open_weights": false, + "cost": { + "input": 0.0, + "output": 0.0 + }, + "limit": { + "context": 1000000, + "output": 24576 + } + }, { "id": "stepfun-ai-step-plan/step-3.5-flash", "name": "Step 3.5 Flash", @@ -192471,7 +193423,7 @@ "attachment": true, "reasoning": true, "tool_call": true, - "temperature": false, + "temperature": true, "knowledge": "2025-08-31", "release_date": "2025-12-11", "last_updated": "2025-12-11", @@ -192501,7 +193453,7 @@ "attachment": true, "reasoning": true, "tool_call": true, - "temperature": false, + "temperature": true, "knowledge": "2025-08-31", "release_date": "2026-03-05", "last_updated": "2026-03-05", @@ -192532,7 +193484,7 @@ "attachment": true, "reasoning": true, "tool_call": true, - "temperature": false, + "temperature": true, "knowledge": "2025-08-31", "release_date": "2026-03-05", "last_updated": "2026-03-05", @@ -193394,10 +194346,10 @@ }, "open_weights": false, "cost": { - "input": 3.0, - "output": 15.0, - "cache_read": 0.3, - "cache_write": 3.75 + "input": 2.0, + "output": 10.000000000000002, + "cache_read": 0.2, + "cache_write": 2.5000000000000004 }, "limit": { "context": 1000000, @@ -193683,9 +194635,9 @@ }, "open_weights": false, "cost": { - "input": 1.875, - "output": 9.375, - "cache_read": 0.1875 + "input": 0.9375, + "output": 4.6875, + "cache_read": 0.09375 }, "limit": { "context": 1000000, @@ -193716,9 +194668,9 @@ }, "open_weights": false, "cost": { - "input": 1.875, - "output": 9.375, - "cache_read": 0.1875 + "input": 0.9375, + "output": 4.6875, + "cache_read": 0.09375 }, "limit": { "context": 1000000, @@ -194615,7 +195567,7 @@ "attachment": false, "reasoning": true, "tool_call": true, - "temperature": false, + "temperature": true, "knowledge": "2025-08-31", "release_date": "2025-12-13", "last_updated": "2026-06-11", @@ -194676,7 +195628,7 @@ "attachment": true, "reasoning": true, "tool_call": true, - "temperature": false, + "temperature": true, "knowledge": "2025-08-31", "release_date": "2026-02-24", "last_updated": "2026-06-11", @@ -194707,7 +195659,7 @@ "attachment": true, "reasoning": true, "tool_call": true, - "temperature": false, + "temperature": true, "knowledge": "2025-08-31", "release_date": "2026-03-05", "last_updated": "2026-06-11", @@ -194738,7 +195690,7 @@ "attachment": true, "reasoning": true, "tool_call": true, - "temperature": false, + "temperature": true, "knowledge": "2025-08-31", "release_date": "2026-03-27", "last_updated": "2026-06-11", @@ -195580,36 +196532,6 @@ "output": 65536 } }, - { - "id": "venice/stealth-ox-alpha", - "name": "Ox Alpha", - "family": "alpha", - "attachment": true, - "reasoning": true, - "tool_call": true, - "release_date": "2026-08-21", - "last_updated": "2026-08-21", - "modalities": { - "input": [ - "text", - "image", - "video" - ], - "output": [ - "text" - ] - }, - "open_weights": false, - "cost": { - "input": 0.0, - "output": 0.0, - "cache_read": 0.0 - }, - "limit": { - "context": 1048576, - "output": 131072 - } - }, { "id": "venice/venice-uncensored-1.2", "name": "Venice Uncensored 1.2", @@ -195757,6 +196679,37 @@ "output": 131072 } }, + { + "id": "venice/z-ai-glm-5.3-flash", + "name": "GLM 5.3 Flash", + "family": "glm", + "attachment": true, + "reasoning": true, + "tool_call": true, + "temperature": true, + "release_date": "2026-08-21", + "last_updated": "2026-08-26", + "modalities": { + "input": [ + "text", + "image", + "video" + ], + "output": [ + "text" + ] + }, + "open_weights": true, + "cost": { + "input": 0.15, + "output": 0.5, + "cache_read": 0.03 + }, + "limit": { + "context": 1048576, + "output": 131072 + } + }, { "id": "venice/z-ai-glm-5v-turbo", "name": "GLM 5V Turbo", @@ -196857,6 +197810,37 @@ "output": 131072 } }, + { + "id": "vercel/alibaba/qwen3.8-flash", + "name": "Qwen 3.8 Flash", + "family": "qwen", + "attachment": true, + "reasoning": true, + "tool_call": true, + "release_date": "2026-08-26", + "last_updated": "2026-08-26", + "modalities": { + "input": [ + "text", + "image", + "pdf" + ], + "output": [ + "text" + ] + }, + "open_weights": false, + "cost": { + "input": 0.16, + "output": 0.47, + "cache_read": 0.016, + "cache_write": 0.2 + }, + "limit": { + "context": 991000, + "output": 128000 + } + }, { "id": "vercel/alibaba/qwen3.8-max", "name": "Qwen 3.8 Max", @@ -197088,6 +198072,31 @@ "output": 0 } }, + { + "id": "vercel/alibaba/wan-v3.0-video", + "name": "Wan v3.0 Video", + "family": "o", + "attachment": true, + "reasoning": false, + "tool_call": false, + "release_date": "2026-08-23", + "last_updated": "2026-08-23", + "modalities": { + "input": [ + "text", + "image" + ], + "output": [ + "video" + ] + }, + "open_weights": false, + "cost": {}, + "limit": { + "context": 0, + "output": 0 + } + }, { "id": "vercel/amazon/nova-2-lite", "name": "Nova 2 Lite", @@ -199380,6 +200389,57 @@ "output": 65000 } }, + { + "id": "vercel/google/gemini-3.5-transcribe", + "name": "Gemini 3.5 Transcribe", + "family": "gemini", + "attachment": false, + "reasoning": false, + "tool_call": false, + "release_date": "2026-08-26", + "last_updated": "2026-08-26", + "modalities": { + "input": [ + "audio" + ], + "output": [ + "text" + ] + }, + "open_weights": false, + "cost": { + "input": 2.0, + "output": 12.0 + }, + "limit": { + "context": 0, + "output": 0 + } + }, + { + "id": "vercel/google/gemini-3.5-transcribe-live", + "name": "Gemini 3.5 Transcribe Live", + "family": "gemini", + "attachment": false, + "reasoning": false, + "tool_call": false, + "release_date": "2026-08-26", + "last_updated": "2026-08-26", + "modalities": { + "input": [ + "audio" + ], + "output": [ + "text" + ] + }, + "open_weights": false, + "cost": {}, + "limit": { + "context": 0, + "output": 0 + } + }, { "id": "vercel/google/gemini-3.6-flash", "name": "Gemini 3.6 Flash", @@ -200312,16 +201372,46 @@ } }, { - "id": "vercel/meta/llama-4-scout", - "name": "Llama-4-Scout-17B-16E-Instruct-FP8", - "family": "llama", + "id": "vercel/meta/llama-4-scout", + "name": "Llama-4-Scout-17B-16E-Instruct-FP8", + "family": "llama", + "attachment": true, + "reasoning": false, + "tool_call": true, + "temperature": true, + "knowledge": "2024-08", + "release_date": "2025-04-05", + "last_updated": "2025-04-05", + "modalities": { + "input": [ + "text", + "image" + ], + "output": [ + "text" + ] + }, + "open_weights": true, + "cost": { + "input": 0.0, + "output": 0.0 + }, + "limit": { + "context": 128000, + "output": 4096 + } + }, + { + "id": "vercel/meta/muse-glimmer-30b", + "name": "Muse Glimmer 30B", + "family": "muse", "attachment": true, - "reasoning": false, + "reasoning": true, "tool_call": true, "temperature": true, - "knowledge": "2024-08", - "release_date": "2025-04-05", - "last_updated": "2025-04-05", + "knowledge": "2026-01-04", + "release_date": "2026-08-10", + "last_updated": "2026-08-10", "modalities": { "input": [ "text", @@ -200333,43 +201423,38 @@ }, "open_weights": true, "cost": { - "input": 0.0, - "output": 0.0 + "input": 0.35, + "output": 1.5, + "cache_read": 0.04 }, "limit": { - "context": 128000, - "output": 4096 + "context": 131072, + "output": 131072 } }, { - "id": "vercel/meta/muse-glimmer-30b", - "name": "Muse Glimmer 30B", + "id": "vercel/meta/muse-image-1.0", + "name": "Muse Image 1.0", "family": "muse", "attachment": true, - "reasoning": true, - "tool_call": true, - "temperature": true, - "knowledge": "2026-01-04", - "release_date": "2026-08-10", - "last_updated": "2026-08-10", + "reasoning": false, + "tool_call": false, + "release_date": "2026-08-26", + "last_updated": "2026-08-26", "modalities": { "input": [ "text", "image" ], "output": [ - "text" + "image" ] }, - "open_weights": true, - "cost": { - "input": 0.35, - "output": 1.5, - "cache_read": 0.04 - }, + "open_weights": false, + "cost": {}, "limit": { - "context": 131072, - "output": 131072 + "context": 0, + "output": 0 } }, { @@ -200671,6 +201756,34 @@ "output": 131000 } }, + { + "id": "vercel/minimax/minimax-m2.7-free", + "name": "Minimax M2.7 (Free)", + "family": "minimax", + "attachment": false, + "reasoning": true, + "tool_call": true, + "temperature": true, + "release_date": "2026-03-18", + "last_updated": "2026-03-18", + "modalities": { + "input": [ + "text" + ], + "output": [ + "text" + ] + }, + "open_weights": true, + "cost": { + "input": 0.0, + "output": 0.0 + }, + "limit": { + "context": 196608, + "output": 196608 + } + }, { "id": "vercel/minimax/minimax-m2.7-highspeed", "name": "MiniMax M2.7 High Speed", @@ -200732,6 +201845,36 @@ "output": 1000000 } }, + { + "id": "vercel/minimax/minimax-m3-free", + "name": "MiniMax M3 (Free)", + "family": "minimax", + "attachment": true, + "reasoning": true, + "tool_call": true, + "temperature": true, + "release_date": "2026-06-01", + "last_updated": "2026-06-01", + "modalities": { + "input": [ + "text", + "image" + ], + "output": [ + "text" + ] + }, + "open_weights": true, + "cost": { + "input": 0.0, + "output": 0.0, + "cache_read": 0.0 + }, + "limit": { + "context": 1048576, + "output": 1048576 + } + }, { "id": "vercel/mistral/codestral", "name": "Codestral (latest)", @@ -201545,40 +202688,13 @@ }, "open_weights": true, "cost": { - "input": 0.0, - "output": 0.0 - }, - "limit": { - "context": 1000000, - "output": 32768 - } - }, - { - "id": "vercel/nvidia/nemotron-3.5-lightning-free", - "name": "Nemotron 3.5 Lightning 30B (Free)", - "family": "nemotron", - "attachment": false, - "reasoning": true, - "tool_call": true, - "temperature": true, - "release_date": "2026-08-11", - "last_updated": "2026-08-11", - "modalities": { - "input": [ - "text" - ], - "output": [ - "text" - ] - }, - "open_weights": true, - "cost": { - "input": 0.0, - "output": 0.0 + "input": 0.05, + "output": 0.15, + "cache_read": 0.05 }, "limit": { - "context": 1000000, - "output": 32768 + "context": 262144, + "output": 131072 } }, { @@ -202522,7 +203638,7 @@ "attachment": true, "reasoning": true, "tool_call": true, - "temperature": false, + "temperature": true, "knowledge": "2025-08-31", "release_date": "2025-12-11", "last_updated": "2025-12-11", @@ -202617,7 +203733,7 @@ "attachment": true, "reasoning": true, "tool_call": true, - "temperature": false, + "temperature": true, "knowledge": "2025-08-31", "release_date": "2026-02-05", "last_updated": "2026-02-05", @@ -202681,7 +203797,7 @@ "attachment": true, "reasoning": true, "tool_call": true, - "temperature": false, + "temperature": true, "knowledge": "2025-08-31", "release_date": "2026-03-05", "last_updated": "2026-03-05", @@ -202745,7 +203861,7 @@ "attachment": true, "reasoning": true, "tool_call": true, - "temperature": false, + "temperature": true, "knowledge": "2025-08-31", "release_date": "2026-03-17", "last_updated": "2026-03-17", @@ -203300,6 +204416,34 @@ "output": 8192 } }, + { + "id": "vercel/openai/gpt-oss-safeguard-120b", + "name": "GPT OSS Safeguard 120B", + "family": "gpt-oss", + "attachment": false, + "reasoning": true, + "tool_call": true, + "temperature": true, + "release_date": "2025-10-29", + "last_updated": "2025-10-29", + "modalities": { + "input": [ + "text" + ], + "output": [ + "text" + ] + }, + "open_weights": true, + "cost": { + "input": 0.15, + "output": 0.6 + }, + "limit": { + "context": 128000, + "output": 16000 + } + }, { "id": "vercel/openai/gpt-oss-safeguard-20b", "name": "gpt-oss-safeguard-20b", @@ -203321,13 +204465,12 @@ }, "open_weights": false, "cost": { - "input": 0.075, - "output": 0.3, - "cache_read": 0.037 + "input": 0.07, + "output": 0.2 }, "limit": { - "context": 131072, - "output": 65536 + "context": 128000, + "output": 16000 } }, { @@ -204591,8 +205734,9 @@ "attachment": true, "reasoning": true, "tool_call": true, - "release_date": "2026-04-30", - "last_updated": "2026-04-30", + "temperature": true, + "release_date": "2026-04-17", + "last_updated": "2026-04-17", "modalities": { "input": [ "text", @@ -204621,6 +205765,7 @@ "attachment": true, "reasoning": true, "tool_call": true, + "temperature": true, "release_date": "2026-07-08", "last_updated": "2026-07-08", "modalities": { @@ -204651,6 +205796,8 @@ "attachment": true, "reasoning": true, "tool_call": true, + "temperature": true, + "knowledge": "2026-02-01", "release_date": "2026-08-12", "last_updated": "2026-08-12", "modalities": { @@ -204680,8 +205827,9 @@ "attachment": true, "reasoning": true, "tool_call": true, - "release_date": "2026-05-20", - "last_updated": "2026-05-20", + "temperature": true, + "release_date": "2026-04-16", + "last_updated": "2026-04-16", "modalities": { "input": [ "text", @@ -204733,6 +205881,7 @@ "attachment": false, "reasoning": false, "tool_call": false, + "temperature": false, "release_date": "2026-08-07", "last_updated": "2026-08-07", "modalities": { @@ -204781,8 +205930,9 @@ "attachment": false, "reasoning": false, "tool_call": false, - "release_date": "2026-06-22", - "last_updated": "2026-06-22", + "temperature": false, + "release_date": "2026-05-30", + "last_updated": "2026-05-30", "modalities": { "input": [ "text" @@ -205898,6 +207048,36 @@ "output": 12800 } }, + { + "id": "vercel/zai/glm-5.3-flash", + "name": "GLM 5.3 Flash", + "family": "glm", + "attachment": true, + "reasoning": true, + "tool_call": true, + "temperature": true, + "release_date": "2026-08-26", + "last_updated": "2026-08-26", + "modalities": { + "input": [ + "text", + "image" + ], + "output": [ + "text" + ] + }, + "open_weights": false, + "cost": { + "input": 0.15, + "output": 0.5, + "cache_read": 0.03 + }, + "limit": { + "context": 1000000, + "output": 131000 + } + }, { "id": "vercel/zai/glm-5v-turbo", "name": "GLM 5V Turbo", @@ -206087,6 +207267,40 @@ "output": 65536 } }, + { + "id": "vivgrid/gemini-3.7-flash", + "name": "Gemini 3.7 Flash", + "family": "gemini-flash", + "attachment": true, + "reasoning": true, + "tool_call": true, + "temperature": true, + "knowledge": "2026-03", + "release_date": "2026-08-13", + "last_updated": "2026-08-13", + "modalities": { + "input": [ + "text", + "image", + "video", + "audio", + "pdf" + ], + "output": [ + "text" + ] + }, + "open_weights": false, + "cost": { + "input": 0.75, + "output": 3.75, + "cache_read": 0.075 + }, + "limit": { + "context": 1048576, + "output": 65536 + } + }, { "id": "vivgrid/glm-5.2", "name": "GLM-5.2", @@ -206558,6 +207772,461 @@ "output": 131072 } }, + { + "id": "volcengine/deepseek-v4-flash-ga-260731", + "name": "DeepSeek V4 Flash 0731", + "family": "deepseek-flash", + "attachment": false, + "reasoning": true, + "tool_call": true, + "temperature": true, + "knowledge": "2025-05", + "release_date": "2026-07-31", + "last_updated": "2026-07-31", + "modalities": { + "input": [ + "text" + ], + "output": [ + "text" + ] + }, + "open_weights": true, + "cost": { + "input": 0.4453, + "output": 1.3359, + "cache_read": 0.01484 + }, + "limit": { + "context": 1000000, + "output": 384000 + } + }, + { + "id": "volcengine/deepseek-v4-pro-ga-260813", + "name": "DeepSeek V4 Pro 0813", + "family": "deepseek-thinking", + "attachment": false, + "reasoning": true, + "tool_call": true, + "temperature": true, + "release_date": "2026-08-12", + "last_updated": "2026-08-22", + "modalities": { + "input": [ + "text" + ], + "output": [ + "text" + ] + }, + "open_weights": true, + "cost": { + "input": 1.3359, + "output": 4.00771, + "cache_read": 0.04453 + }, + "limit": { + "context": 1000000, + "output": 384000 + } + }, + { + "id": "volcengine/doubao-seed-1.6-251015", + "name": "Seed 1.6", + "family": "seed", + "attachment": false, + "reasoning": true, + "tool_call": true, + "temperature": true, + "release_date": "2025-10-15", + "last_updated": "2025-10-15", + "modalities": { + "input": [ + "text" + ], + "output": [ + "text" + ] + }, + "open_weights": false, + "cost": { + "input": 0.11875, + "output": 1.18747, + "cache_read": 0.02375 + }, + "limit": { + "context": 256000, + "output": 64000 + } + }, + { + "id": "volcengine/doubao-seed-1.6-flash-250828", + "name": "Seed 1.6 Flash", + "family": "seed", + "attachment": true, + "reasoning": false, + "tool_call": true, + "temperature": true, + "release_date": "2025-08-28", + "last_updated": "2025-08-28", + "modalities": { + "input": [ + "text", + "image" + ], + "output": [ + "text" + ] + }, + "open_weights": false, + "cost": { + "input": 0.02227, + "output": 0.22265, + "cache_read": 0.00445 + }, + "limit": { + "context": 256000, + "output": 32000 + } + }, + { + "id": "volcengine/doubao-seed-1.6-vision-250815", + "name": "Seed 1.6 Vision", + "family": "seed", + "attachment": true, + "reasoning": false, + "tool_call": true, + "temperature": true, + "release_date": "2025-08-15", + "last_updated": "2025-08-15", + "modalities": { + "input": [ + "text", + "image" + ], + "output": [ + "text" + ] + }, + "open_weights": false, + "cost": { + "input": 0.11875, + "output": 1.18747, + "cache_read": 0.02375 + }, + "limit": { + "context": 256000, + "output": 32000 + } + }, + { + "id": "volcengine/doubao-seed-1.8-251228", + "name": "Seed 1.8", + "family": "seed", + "attachment": true, + "reasoning": true, + "tool_call": true, + "temperature": true, + "release_date": "2025-12-28", + "last_updated": "2025-12-28", + "modalities": { + "input": [ + "text", + "image" + ], + "output": [ + "text" + ] + }, + "open_weights": false, + "cost": { + "input": 0.11875, + "output": 1.18747, + "cache_read": 0.02375 + }, + "limit": { + "context": 256000, + "output": 64000 + } + }, + { + "id": "volcengine/doubao-seed-2.0-code-preview-260215", + "name": "Seed 2.0 Code", + "family": "seed", + "attachment": true, + "reasoning": true, + "tool_call": true, + "temperature": true, + "release_date": "2026-02-14", + "last_updated": "2026-02-14", + "modalities": { + "input": [ + "text", + "image", + "video" + ], + "output": [ + "text" + ] + }, + "open_weights": false, + "cost": { + "input": 0.47499, + "output": 2.37494, + "cache_read": 0.095 + }, + "limit": { + "context": 262144, + "output": 131072 + } + }, + { + "id": "volcengine/doubao-seed-2.0-lite-260428", + "name": "Seed 2.0 Lite", + "family": "seed", + "attachment": true, + "reasoning": true, + "tool_call": true, + "temperature": true, + "release_date": "2026-02-14", + "last_updated": "2026-02-14", + "modalities": { + "input": [ + "text", + "image", + "video" + ], + "output": [ + "text" + ] + }, + "open_weights": false, + "cost": { + "input": 0.08906, + "output": 0.53436, + "cache_read": 0.01781 + }, + "limit": { + "context": 256000, + "output": 32000 + } + }, + { + "id": "volcengine/doubao-seed-2.0-mini-260428", + "name": "Seed 2.0 Mini", + "family": "seed", + "attachment": true, + "reasoning": true, + "tool_call": true, + "temperature": true, + "release_date": "2026-02-14", + "last_updated": "2026-02-14", + "modalities": { + "input": [ + "text", + "image", + "video" + ], + "output": [ + "text" + ] + }, + "open_weights": false, + "cost": { + "input": 0.02969, + "output": 0.29687, + "cache_read": 0.00594 + }, + "limit": { + "context": 256000, + "output": 32000 + } + }, + { + "id": "volcengine/doubao-seed-2.0-pro-260215", + "name": "Seed 2.0 Pro", + "family": "seed", + "attachment": true, + "reasoning": true, + "tool_call": true, + "temperature": true, + "release_date": "2026-02-14", + "last_updated": "2026-02-14", + "modalities": { + "input": [ + "text", + "image", + "video" + ], + "output": [ + "text" + ] + }, + "open_weights": false, + "cost": { + "input": 0.47499, + "output": 2.37494, + "cache_read": 0.095 + }, + "limit": { + "context": 256000, + "output": 128000 + } + }, + { + "id": "volcengine/doubao-seed-2.1-pro-260628", + "name": "Seed 2.1 Pro", + "family": "seed", + "attachment": true, + "reasoning": true, + "tool_call": true, + "temperature": true, + "release_date": "2026-06-23", + "last_updated": "2026-06-23", + "modalities": { + "input": [ + "text", + "image", + "video" + ], + "output": [ + "text" + ] + }, + "open_weights": false, + "cost": { + "input": 0.8906, + "output": 4.45301, + "cache_read": 0.17812 + }, + "limit": { + "context": 256000, + "output": 256000 + } + }, + { + "id": "volcengine/doubao-seed-2.1-turbo-260628", + "name": "Seed 2.1 Turbo", + "family": "seed", + "attachment": true, + "reasoning": true, + "tool_call": true, + "temperature": true, + "release_date": "2026-06-23", + "last_updated": "2026-06-23", + "modalities": { + "input": [ + "text", + "image", + "video" + ], + "output": [ + "text" + ] + }, + "open_weights": false, + "cost": { + "input": 0.4453, + "output": 2.22651, + "cache_read": 0.08906 + }, + "limit": { + "context": 256000, + "output": 256000 + } + }, + { + "id": "volcengine/doubao-seed-character-260628", + "name": "Seed Character", + "family": "seed", + "attachment": true, + "reasoning": true, + "tool_call": true, + "temperature": true, + "release_date": "2026-06-23", + "last_updated": "2026-06-23", + "modalities": { + "input": [ + "text", + "image", + "video" + ], + "output": [ + "text" + ] + }, + "open_weights": false, + "cost": { + "input": 0.11875, + "output": 0.29687, + "cache_read": 0.02375 + }, + "limit": { + "context": 256000, + "output": 256000 + } + }, + { + "id": "volcengine/doubao-seed-evolving", + "name": "Seed Evolving", + "family": "seed", + "attachment": true, + "reasoning": true, + "tool_call": true, + "temperature": true, + "release_date": "2026-06-23", + "last_updated": "2026-06-23", + "modalities": { + "input": [ + "text", + "image", + "video" + ], + "output": [ + "text" + ] + }, + "open_weights": false, + "cost": { + "input": 0.8906, + "output": 4.45301, + "cache_read": 0.17812 + }, + "limit": { + "context": 256000, + "output": 256000 + } + }, + { + "id": "volcengine/glm-5.2-260617", + "name": "GLM-5.2", + "family": "glm", + "attachment": false, + "reasoning": true, + "tool_call": true, + "temperature": true, + "release_date": "2026-06-13", + "last_updated": "2026-06-13", + "modalities": { + "input": [ + "text" + ], + "output": [ + "text" + ] + }, + "open_weights": true, + "cost": { + "input": 1.18747, + "output": 4.15615, + "cache_read": 0.29687 + }, + "limit": { + "context": 1000000, + "output": 131072 + } + }, { "id": "vultr/MiniMaxAI/MiniMax-M2.7", "name": "MiniMax-M2.7", @@ -206972,7 +208641,7 @@ }, "limit": { "context": 1048576, - "output": 128000 + "output": 512000 } }, { @@ -207033,35 +208702,6 @@ "output": 131072 } }, - { - "id": "wandb/MiniMaxAI/MiniMax-M2.5", - "name": "MiniMax M2.5", - "family": "minimax-m2.5", - "attachment": false, - "reasoning": true, - "tool_call": true, - "temperature": true, - "release_date": "2026-02-12", - "last_updated": "2026-02-12", - "modalities": { - "input": [ - "text" - ], - "output": [ - "text" - ] - }, - "open_weights": true, - "cost": { - "input": 0.3, - "output": 1.2, - "cache_read": 0.3 - }, - "limit": { - "context": 196608, - "output": 196608 - } - }, { "id": "wandb/MiniMaxAI/MiniMax-M3", "name": "MiniMax M3", @@ -207150,36 +208790,6 @@ "output": 262144 } }, - { - "id": "wandb/Qwen/Qwen3-Coder-480B-A35B-Instruct", - "name": "Qwen3 Coder 480B A35B", - "family": "qwen", - "attachment": false, - "reasoning": false, - "tool_call": true, - "temperature": true, - "knowledge": "2025-04", - "release_date": "2025-07-22", - "last_updated": "2025-07-22", - "modalities": { - "input": [ - "text" - ], - "output": [ - "text" - ] - }, - "open_weights": true, - "cost": { - "input": 1.0, - "output": 1.5, - "cache_read": 1.0 - }, - "limit": { - "context": 262144, - "output": 262144 - } - }, { "id": "wandb/Qwen/Qwen3.5-35B-A3B", "name": "Qwen3.5-35B-A3B", @@ -207448,6 +209058,35 @@ "output": 131072 } }, + { + "id": "wandb/ibm-granite/granite-4.2-8b", + "name": "Granite 4.2 8B", + "family": "granite", + "attachment": false, + "reasoning": true, + "tool_call": true, + "temperature": true, + "release_date": "2026-08-24", + "last_updated": "2026-08-24", + "modalities": { + "input": [ + "text" + ], + "output": [ + "text" + ] + }, + "open_weights": true, + "cost": { + "input": 0.1, + "output": 0.15, + "cache_read": 0.05 + }, + "limit": { + "context": 131072, + "output": 131072 + } + }, { "id": "wandb/meta-llama/Llama-3.1-70B-Instruct", "name": "Llama 3.1 70B", @@ -207473,8 +209112,8 @@ "cache_read": 0.8 }, "limit": { - "context": 128000, - "output": 128000 + "context": 131072, + "output": 131072 } }, { @@ -207503,8 +209142,8 @@ "cache_read": 0.22 }, "limit": { - "context": 128000, - "output": 128000 + "context": 131072, + "output": 131072 } }, { @@ -207629,35 +209268,6 @@ "output": 1048576 } }, - { - "id": "wandb/nvidia/NVIDIA-Nemotron-3-Super-120B-A12B-FP8", - "name": "Nemotron 3 Super", - "family": "nemotron", - "attachment": false, - "reasoning": true, - "tool_call": true, - "temperature": true, - "release_date": "2026-03-11", - "last_updated": "2026-03-11", - "modalities": { - "input": [ - "text" - ], - "output": [ - "text" - ] - }, - "open_weights": true, - "cost": { - "input": 0.2, - "output": 0.8, - "cache_read": 0.2 - }, - "limit": { - "context": 262144, - "output": 262144 - } - }, { "id": "wandb/nvidia/NVIDIA-Nemotron-3-Ultra-550B-A55B", "name": "Nemotron 3 Ultra", @@ -207774,35 +209384,6 @@ "output": 131072 } }, - { - "id": "wandb/zai-org/GLM-5.1", - "name": "GLM 5.1", - "family": "glm", - "attachment": false, - "reasoning": true, - "tool_call": true, - "temperature": true, - "release_date": "2026-04-07", - "last_updated": "2026-04-07", - "modalities": { - "input": [ - "text" - ], - "output": [ - "text" - ] - }, - "open_weights": true, - "cost": { - "input": 1.4, - "output": 4.4, - "cache_read": 0.26 - }, - "limit": { - "context": 202752, - "output": 202752 - } - }, { "id": "wandb/zai-org/GLM-5.2", "name": "GLM 5.2", @@ -207828,8 +209409,8 @@ "cache_read": 0.14 }, "limit": { - "context": 262144, - "output": 262144 + "context": 1048576, + "output": 1048576 } }, { @@ -209287,7 +210868,7 @@ "attachment": true, "reasoning": true, "tool_call": true, - "temperature": false, + "temperature": true, "knowledge": "2025-08-31", "release_date": "2026-03-05", "last_updated": "2026-03-05", @@ -209318,7 +210899,7 @@ "attachment": true, "reasoning": true, "tool_call": true, - "temperature": false, + "temperature": true, "knowledge": "2025-08-31", "release_date": "2026-03-17", "last_updated": "2026-03-17", @@ -209678,6 +211259,69 @@ "output": 131072 } }, + { + "id": "zai-coding-plan/glm-5.3-flash", + "name": "GLM-5.3-Flash", + "family": "glm", + "attachment": true, + "reasoning": true, + "tool_call": true, + "temperature": true, + "release_date": "2026-08-26", + "last_updated": "2026-08-26", + "modalities": { + "input": [ + "text", + "image", + "video", + "pdf" + ], + "output": [ + "text" + ] + }, + "open_weights": false, + "cost": { + "input": 0.0, + "output": 0.0, + "cache_read": 0.0, + "cache_write": 0.0 + }, + "limit": { + "context": 1000000, + "output": 131072 + } + }, + { + "id": "zai-coding-plan/glm-5.3-highspeed", + "name": "GLM-5.3 Highspeed", + "family": "glm", + "attachment": false, + "reasoning": true, + "tool_call": true, + "temperature": true, + "release_date": "2026-08-14", + "last_updated": "2026-08-14", + "modalities": { + "input": [ + "text" + ], + "output": [ + "text" + ] + }, + "open_weights": false, + "cost": { + "input": 0.0, + "output": 0.0, + "cache_read": 0.0, + "cache_write": 0.0 + }, + "limit": { + "context": 1000000, + "output": 131072 + } + }, { "id": "zai/glm-4.5", "name": "GLM-4.5", @@ -210077,6 +211721,69 @@ "output": 131072 } }, + { + "id": "zai/glm-5.3", + "name": "GLM-5.3", + "family": "glm", + "attachment": false, + "reasoning": true, + "tool_call": true, + "temperature": true, + "release_date": "2026-08-14", + "last_updated": "2026-08-14", + "modalities": { + "input": [ + "text" + ], + "output": [ + "text" + ] + }, + "open_weights": false, + "cost": { + "input": 1.4, + "output": 4.4, + "cache_read": 0.26, + "cache_write": 0.0 + }, + "limit": { + "context": 1000000, + "output": 131072 + } + }, + { + "id": "zai/glm-5.3-flash", + "name": "GLM-5.3-Flash", + "family": "glm", + "attachment": true, + "reasoning": true, + "tool_call": true, + "temperature": true, + "release_date": "2026-08-26", + "last_updated": "2026-08-26", + "modalities": { + "input": [ + "text", + "image", + "video", + "pdf" + ], + "output": [ + "text" + ] + }, + "open_weights": false, + "cost": { + "input": 0.075, + "output": 0.25, + "cache_read": 0.015, + "cache_write": 0.0 + }, + "limit": { + "context": 1000000, + "output": 131072 + } + }, { "id": "zai/glm-5v-turbo", "name": "GLM-5V-Turbo", @@ -211404,8 +213111,8 @@ "output": 2.4 }, "limit": { - "context": 512000, - "output": 128000 + "context": 1048576, + "output": 512000 } }, { @@ -214028,6 +215735,69 @@ "output": 131072 } }, + { + "id": "zhipuai-coding-plan/glm-5.3-flash", + "name": "GLM-5.3-Flash", + "family": "glm", + "attachment": true, + "reasoning": true, + "tool_call": true, + "temperature": true, + "release_date": "2026-08-26", + "last_updated": "2026-08-26", + "modalities": { + "input": [ + "text", + "image", + "video", + "pdf" + ], + "output": [ + "text" + ] + }, + "open_weights": false, + "cost": { + "input": 0.0, + "output": 0.0, + "cache_read": 0.0, + "cache_write": 0.0 + }, + "limit": { + "context": 1000000, + "output": 131072 + } + }, + { + "id": "zhipuai-coding-plan/glm-5.3-highspeed", + "name": "GLM-5.3 Highspeed", + "family": "glm", + "attachment": false, + "reasoning": true, + "tool_call": true, + "temperature": true, + "release_date": "2026-08-14", + "last_updated": "2026-08-14", + "modalities": { + "input": [ + "text" + ], + "output": [ + "text" + ] + }, + "open_weights": false, + "cost": { + "input": 0.0, + "output": 0.0, + "cache_read": 0.0, + "cache_write": 0.0 + }, + "limit": { + "context": 1000000, + "output": 131072 + } + }, { "id": "zhipuai-coding-plan/glm-5v-turbo", "name": "GLM-5V-Turbo", @@ -214430,6 +216200,69 @@ "output": 131072 } }, + { + "id": "zhipuai/glm-5.3", + "name": "GLM-5.3", + "family": "glm", + "attachment": false, + "reasoning": true, + "tool_call": true, + "temperature": true, + "release_date": "2026-08-14", + "last_updated": "2026-08-14", + "modalities": { + "input": [ + "text" + ], + "output": [ + "text" + ] + }, + "open_weights": false, + "cost": { + "input": 1.4, + "output": 4.4, + "cache_read": 0.26, + "cache_write": 0.0 + }, + "limit": { + "context": 1000000, + "output": 131072 + } + }, + { + "id": "zhipuai/glm-5.3-flash", + "name": "GLM-5.3-Flash", + "family": "glm", + "attachment": true, + "reasoning": true, + "tool_call": true, + "temperature": true, + "release_date": "2026-08-26", + "last_updated": "2026-08-26", + "modalities": { + "input": [ + "text", + "image", + "video", + "pdf" + ], + "output": [ + "text" + ] + }, + "open_weights": false, + "cost": { + "input": 0.075, + "output": 0.25, + "cache_read": 0.015, + "cache_write": 0.0 + }, + "limit": { + "context": 1000000, + "output": 131072 + } + }, { "id": "zhipuai/glm-5v-turbo", "name": "GLM-5V-Turbo", diff --git a/crates/goose-provider-types/src/canonical/data/provider_metadata.json b/crates/goose-provider-types/src/canonical/data/provider_metadata.json index f3d8425cdf4e..4bbc279bd125 100644 --- a/crates/goose-provider-types/src/canonical/data/provider_metadata.json +++ b/crates/goose-provider-types/src/canonical/data/provider_metadata.json @@ -43,6 +43,17 @@ ], "model_count": 3 }, + { + "id": "agnes", + "display_name": "Agnes AI", + "npm": "@ai-sdk/openai-compatible", + "api": "https://apihub.agnes-ai.com/v1", + "doc": "https://agnes-ai.com/doc", + "env": [ + "AGNES_API_KEY" + ], + "model_count": 3 + }, { "id": "ai-router", "display_name": "AI-ROUTER", @@ -65,6 +76,17 @@ ], "model_count": 9 }, + { + "id": "aixy", + "display_name": "Aixy", + "npm": "@ai-sdk/openai-compatible", + "api": "https://api.aixy-gateway.com/v1", + "doc": "https://docs.aixy-gateway.com/integrations/overview", + "env": [ + "AIXY_API_KEY" + ], + "model_count": 1 + }, { "id": "aki-io", "display_name": "AKI.IO", @@ -228,7 +250,7 @@ "env": [ "BASETEN_API_KEY" ], - "model_count": 19 + "model_count": 20 }, { "id": "berget", @@ -294,7 +316,7 @@ "env": [ "CLINE_API_KEY" ], - "model_count": 11 + "model_count": 13 }, { "id": "cloudferro-sherlock", @@ -317,7 +339,7 @@ "CLOUDFLARE_ACCOUNT_ID", "CLOUDFLARE_API_KEY" ], - "model_count": 25 + "model_count": 26 }, { "id": "coralbricks", @@ -406,7 +428,7 @@ "env": [ "DEEPSEEK_API_KEY" ], - "model_count": 5 + "model_count": 3 }, { "id": "digitalocean", @@ -472,7 +494,7 @@ "env": [ "EDENAI_API_KEY" ], - "model_count": 232 + "model_count": 235 }, { "id": "empiriolabs", @@ -483,7 +505,7 @@ "env": [ "EMPIRIOLABS_API_KEY" ], - "model_count": 55 + "model_count": 57 }, { "id": "evroc", @@ -494,7 +516,7 @@ "env": [ "EVROC_API_KEY" ], - "model_count": 15 + "model_count": 16 }, { "id": "fastrouter", @@ -571,7 +593,7 @@ "env": [ "GMICLOUD_API_KEY" ], - "model_count": 13 + "model_count": 15 }, { "id": "greenpt", @@ -626,7 +648,7 @@ "env": [ "HF_TOKEN" ], - "model_count": 69 + "model_count": 71 }, { "id": "hyper", @@ -637,7 +659,7 @@ "env": [ "HYPER_API_KEY" ], - "model_count": 26 + "model_count": 29 }, { "id": "iflowcn", @@ -728,6 +750,17 @@ ], "model_count": 17 }, + { + "id": "iteracompute", + "display_name": "IteraCompute", + "npm": "@ai-sdk/openai-compatible", + "api": "https://api.iteracompute.com/v1", + "doc": "https://iteracompute.com/docs.html", + "env": [ + "ITERACOMPUTE_API_KEY" + ], + "model_count": 1 + }, { "id": "jalapeno", "display_name": "Jalapeno Cloud", @@ -770,7 +803,7 @@ "env": [ "KILO_API_KEY" ], - "model_count": 365 + "model_count": 366 }, { "id": "kimi-for-coding", @@ -825,7 +858,7 @@ "env": [ "LLMGATEWAY_API_KEY" ], - "model_count": 187 + "model_count": 189 }, { "id": "llmgateway-providers", @@ -836,7 +869,18 @@ "env": [ "LLMGATEWAY_API_KEY" ], - "model_count": 377 + "model_count": 381 + }, + { + "id": "llmtech", + "display_name": "LLM Tech", + "npm": "@ai-sdk/openai-compatible", + "api": "https://api.llmtech.eu/v1", + "doc": "https://llmtech.eu/models/qwen3.8-27b", + "env": [ + "LLMTECH_API_KEY" + ], + "model_count": 1 }, { "id": "llmtr", @@ -1078,7 +1122,7 @@ "env": [ "NANO_GPT_API_KEY" ], - "model_count": 606 + "model_count": 612 }, { "id": "nearai", @@ -1114,6 +1158,17 @@ ], "model_count": 42 }, + { + "id": "neosmith", + "display_name": "NeoSmith", + "npm": "@ai-sdk/openai", + "api": "https://router.neosmith.ai/v1", + "doc": "https://neosmith.ai/docs", + "env": [ + "NEOSMITH_API_KEY" + ], + "model_count": 4 + }, { "id": "neuralwatt", "display_name": "Neuralwatt", @@ -1123,7 +1178,7 @@ "env": [ "NEURALWATT_API_KEY" ], - "model_count": 22 + "model_count": 25 }, { "id": "nova", @@ -1200,7 +1255,7 @@ "env": [ "OPENCODE_API_KEY" ], - "model_count": 29 + "model_count": 31 }, { "id": "opper", @@ -1235,6 +1290,17 @@ ], "model_count": 14 }, + { + "id": "pendra", + "display_name": "Pendra", + "npm": "@ai-sdk/openai-compatible", + "api": "https://api.pendra.ai/api/v1", + "doc": "https://pendra.ai/docs/integrations/opencode", + "env": [ + "PENDRA_API_KEY" + ], + "model_count": 6 + }, { "id": "perplexity-agent", "display_name": "Perplexity Agent", @@ -1333,7 +1399,7 @@ "env": [ "REQUESTY_API_KEY" ], - "model_count": 139 + "model_count": 140 }, { "id": "routing-run", @@ -1457,6 +1523,17 @@ ], "model_count": 8 }, + { + "id": "standardcompute", + "display_name": "Standard Compute", + "npm": "@ai-sdk/openai-compatible", + "api": "https://api.stdcmpt.com/v1", + "doc": "https://standardcompute.com/models", + "env": [ + "STANDARDCOMPUTE_API_KEY" + ], + "model_count": 1 + }, { "id": "stepfun", "display_name": "StepFun (China)", @@ -1675,7 +1752,18 @@ "env": [ "VIVGRID_API_KEY" ], - "model_count": 20 + "model_count": 21 + }, + { + "id": "volcengine", + "display_name": "Volcengine Ark", + "npm": "@ai-sdk/openai-compatible", + "api": "https://ark.cn-beijing.volces.com/api/v3", + "doc": "https://www.volcengine.com/docs/82379/1330310", + "env": [ + "ARK_API_KEY" + ], + "model_count": 15 }, { "id": "vultr", @@ -1708,7 +1796,7 @@ "env": [ "WANDB_API_KEY" ], - "model_count": 29 + "model_count": 26 }, { "id": "xiaomi", @@ -1774,7 +1862,7 @@ "env": [ "ZHIPU_API_KEY" ], - "model_count": 14 + "model_count": 16 }, { "id": "zai-coding-plan", @@ -1785,7 +1873,7 @@ "env": [ "ZHIPU_API_KEY" ], - "model_count": 5 + "model_count": 7 }, { "id": "zeldoc", @@ -1829,7 +1917,7 @@ "env": [ "ZHIPU_API_KEY" ], - "model_count": 13 + "model_count": 15 }, { "id": "zhipuai-coding-plan", @@ -1840,6 +1928,6 @@ "env": [ "ZHIPU_API_KEY" ], - "model_count": 8 + "model_count": 10 } ] \ No newline at end of file diff --git a/crates/goose-provider-types/src/canonical/name_builder.rs b/crates/goose-provider-types/src/canonical/name_builder.rs index 91381e4204a3..69e19caeb998 100644 --- a/crates/goose-provider-types/src/canonical/name_builder.rs +++ b/crates/goose-provider-types/src/canonical/name_builder.rs @@ -473,12 +473,12 @@ mod tests { // === DeepSeek === assert_eq!( - map_to_canonical_model("databricks", "databricks-deepseek-chat", r), - Some("deepseek/deepseek-chat".to_string()) + map_to_canonical_model("databricks", "databricks-deepseek-v4-flash", r), + Some("deepseek/deepseek-v4-flash".to_string()) ); assert_eq!( - map_to_canonical_model("databricks", "deepseek-reasoner", r), - Some("deepseek/deepseek-reasoner".to_string()) + map_to_canonical_model("databricks", "deepseek-v4-pro", r), + Some("deepseek/deepseek-v4-pro".to_string()) ); // === Grok (X.AI) === @@ -524,8 +524,8 @@ mod tests { Some("mistralai/codestral".to_string()) ); assert_eq!( - map_to_canonical_model("databricks", "deepseek-deepseek-chat", r), - Some("deepseek/deepseek-chat".to_string()) + map_to_canonical_model("databricks", "deepseek-deepseek-v4-flash", r), + Some("deepseek/deepseek-v4-flash".to_string()) ); assert_eq!( map_to_canonical_model("databricks", "x-ai-grok-4.3", r), diff --git a/crates/goose/src/providers/formats/bedrock.rs b/crates/goose/src/providers/formats/bedrock.rs index af8dda57de83..1add61af892d 100644 --- a/crates/goose/src/providers/formats/bedrock.rs +++ b/crates/goose/src/providers/formats/bedrock.rs @@ -1670,25 +1670,7 @@ mod tests { #[test] fn test_bedrock_inference_config_omits_temperature_for_unsupported_model() { - // The Anthropic canonical registry maps this id and reports whether a - // custom temperature may be sent; when it cannot, temperature is left - // unset so the server default is used. - let mut config = ModelConfig::new("us.anthropic.claude-sonnet-4-5-20250929-v1:0"); - config.temperature = Some(0.5); - - let supported = bedrock_model_supports_temperature(&config); - let inference_config = bedrock_inference_config(&config); - - if supported { - assert_eq!(inference_config.temperature(), Some(0.5)); - } else { - assert_eq!(inference_config.temperature(), None); - } - } - - #[test] - fn test_bedrock_inference_config_omits_temperature_for_bedrock_registry_unsupported_model() { - let mut config = ModelConfig::new("openai.gpt-5.4"); + let mut config = ModelConfig::new("global.anthropic.claude-sonnet-5"); config.temperature = Some(0.5); let inference_config = bedrock_inference_config(&config); From dd8f5ed5c7ddb82f00b6d1fbbd3db340c302ca77 Mon Sep 17 00:00:00 2001 From: Lifei Zhou Date: Fri, 28 Aug 2026 10:36:17 +0000 Subject: [PATCH 02/37] chore(acp): remove unused unstable methods (#11650) --- crates/goose-sdk-types/src/custom_requests.rs | 49 ------ crates/goose/acp-meta.json | 25 --- crates/goose/acp-schema.json | 140 ---------------- crates/goose/src/acp/server/config.rs | 12 -- .../goose/src/acp/server/custom_dispatch.rs | 39 ----- crates/goose/src/acp/server/dictation.rs | 104 +----------- crates/goose/src/acp/server/extensions.rs | 14 -- .../goose/tests/acp_custom_requests_test.rs | 158 +----------------- .../acp_secret_cache_invalidation_test.rs | 51 +----- ui/sdk/src/generated/client.gen.ts | 43 ----- ui/sdk/src/generated/index.ts | 27 +-- ui/sdk/src/generated/types.gen.ts | 45 +---- ui/sdk/src/generated/zod.gen.ts | 45 ----- 13 files changed, 9 insertions(+), 743 deletions(-) diff --git a/crates/goose-sdk-types/src/custom_requests.rs b/crates/goose-sdk-types/src/custom_requests.rs index 36948b94b9df..f79032d1003b 100644 --- a/crates/goose-sdk-types/src/custom_requests.rs +++ b/crates/goose-sdk-types/src/custom_requests.rs @@ -412,20 +412,6 @@ pub struct SessionExtensionEntry { pub extension_key: String, } -/// List Goose-owned extension definitions available to configure or enable. -#[derive(Debug, Default, Clone, Serialize, Deserialize, JsonSchema, JsonRpcRequest)] -#[request( - method = "_goose/unstable/extensions/available", - response = GetAvailableExtensionsResponse -)] -pub struct GetAvailableExtensionsRequest {} - -#[derive(Debug, Default, Clone, Serialize, Deserialize, JsonSchema, JsonRpcResponse)] -#[serde(rename_all = "camelCase")] -pub struct GetAvailableExtensionsResponse { - pub extensions: Vec, -} - /// List configured extensions and any warnings. #[derive(Debug, Default, Clone, Serialize, Deserialize, JsonSchema, JsonRpcRequest)] #[request( @@ -505,15 +491,6 @@ pub struct PreferencesSaveRequest { pub values: Vec, } -/// Remove allowlisted user preferences. -#[derive(Debug, Default, Clone, Serialize, Deserialize, JsonSchema, JsonRpcRequest)] -#[request(method = "_goose/unstable/preferences/remove", response = EmptyResponse)] -#[serde(rename_all = "camelCase")] -pub struct PreferencesRemoveRequest { - #[serde(default)] - pub keys: Vec, -} - #[derive(Debug, Default, Clone, Serialize, Deserialize, JsonSchema, JsonRpcRequest)] #[request(method = "_goose/unstable/config/read", response = ConfigReadResponse)] #[serde(rename_all = "camelCase")] @@ -690,23 +667,6 @@ pub struct OnboardingImportApplyResponse { pub provider_defaults: Option, } -/// Set a dictation provider secret value. -#[derive(Debug, Default, Clone, Serialize, Deserialize, JsonSchema, JsonRpcRequest)] -#[request(method = "_goose/unstable/dictation/secret/save", response = EmptyResponse)] -#[serde(rename_all = "camelCase")] -pub struct DictationSecretSaveRequest { - pub provider: String, - pub value: String, -} - -/// Remove a dictation provider secret value. -#[derive(Debug, Default, Clone, Serialize, Deserialize, JsonSchema, JsonRpcRequest)] -#[request(method = "_goose/unstable/dictation/secret/delete", response = EmptyResponse)] -#[serde(rename_all = "camelCase")] -pub struct DictationSecretDeleteRequest { - pub provider: String, -} - /// Return list-style metadata for a single session without loading the conversation. #[derive(Debug, Default, Clone, Serialize, Deserialize, JsonSchema, JsonRpcRequest)] #[request( @@ -2283,15 +2243,6 @@ pub struct DictationModelDeleteRequest { pub model_id: String, } -/// Persist the user's model selection for a given provider. -#[derive(Debug, Default, Clone, Serialize, Deserialize, JsonSchema, JsonRpcRequest)] -#[request(method = "_goose/unstable/dictation/models/select", response = EmptyResponse)] -#[serde(rename_all = "camelCase")] -pub struct DictationModelSelectRequest { - pub provider: String, - pub model_id: String, -} - /// Permission level for a tool. #[derive(Debug, Default, Clone, Copy, Serialize, Deserialize, JsonSchema)] #[serde(rename_all = "snake_case")] diff --git a/crates/goose/acp-meta.json b/crates/goose/acp-meta.json index deeefb78c099..d1c3a75006a8 100644 --- a/crates/goose/acp-meta.json +++ b/crates/goose/acp-meta.json @@ -95,11 +95,6 @@ "requestType": "GetConfigExtensionsRequest_unstable", "responseType": "GetConfigExtensionsResponse_unstable" }, - { - "method": "_goose/unstable/extensions/available", - "requestType": "GetAvailableExtensionsRequest_unstable", - "responseType": "GetAvailableExtensionsResponse_unstable" - }, { "method": "_goose/unstable/config/extensions/add", "requestType": "AddConfigExtensionRequest_unstable", @@ -225,11 +220,6 @@ "requestType": "PreferencesSaveRequest_unstable", "responseType": "EmptyResponse" }, - { - "method": "_goose/unstable/preferences/remove", - "requestType": "PreferencesRemoveRequest_unstable", - "responseType": "EmptyResponse" - }, { "method": "_goose/unstable/config/read", "requestType": "ConfigReadRequest_unstable", @@ -470,16 +460,6 @@ "requestType": "DictationConfigRequest_unstable", "responseType": "DictationConfigResponse_unstable" }, - { - "method": "_goose/unstable/dictation/secret/save", - "requestType": "DictationSecretSaveRequest_unstable", - "responseType": "EmptyResponse" - }, - { - "method": "_goose/unstable/dictation/secret/delete", - "requestType": "DictationSecretDeleteRequest_unstable", - "responseType": "EmptyResponse" - }, { "method": "_goose/unstable/dictation/models/list", "requestType": "DictationModelsListRequest_unstable", @@ -505,11 +485,6 @@ "requestType": "DictationModelDeleteRequest_unstable", "responseType": "EmptyResponse" }, - { - "method": "_goose/unstable/dictation/models/select", - "requestType": "DictationModelSelectRequest_unstable", - "responseType": "EmptyResponse" - }, { "method": "_goose/unstable/local-inference/models/list", "requestType": "LocalInferenceModelsListRequest_unstable", diff --git a/crates/goose/acp-schema.json b/crates/goose/acp-schema.json index fcf4f282ddb0..216fcc5159e5 100644 --- a/crates/goose/acp-schema.json +++ b/crates/goose/acp-schema.json @@ -1512,28 +1512,6 @@ "enabled" ] }, - "GetAvailableExtensionsRequest_unstable": { - "type": "object", - "description": "List Goose-owned extension definitions available to configure or enable.", - "x-side": "agent", - "x-method": "_goose/unstable/extensions/available" - }, - "GetAvailableExtensionsResponse_unstable": { - "type": "object", - "properties": { - "extensions": { - "type": "array", - "items": { - "$ref": "#/$defs/GooseExtension" - } - } - }, - "required": [ - "extensions" - ], - "x-side": "agent", - "x-method": "_goose/unstable/extensions/available" - }, "AddConfigExtensionRequest_unstable": { "type": "object", "properties": { @@ -3162,21 +3140,6 @@ "x-side": "agent", "x-method": "_goose/unstable/preferences/save" }, - "PreferencesRemoveRequest_unstable": { - "type": "object", - "properties": { - "keys": { - "type": "array", - "items": { - "$ref": "#/$defs/PreferenceKey" - }, - "default": [] - } - }, - "description": "Remove allowlisted user preferences.", - "x-side": "agent", - "x-method": "_goose/unstable/preferences/remove" - }, "ConfigReadRequest_unstable": { "type": "object", "properties": { @@ -5609,38 +5572,6 @@ "description" ] }, - "DictationSecretSaveRequest_unstable": { - "type": "object", - "properties": { - "provider": { - "type": "string" - }, - "value": { - "type": "string" - } - }, - "required": [ - "provider", - "value" - ], - "description": "Set a dictation provider secret value.", - "x-side": "agent", - "x-method": "_goose/unstable/dictation/secret/save" - }, - "DictationSecretDeleteRequest_unstable": { - "type": "object", - "properties": { - "provider": { - "type": "string" - } - }, - "required": [ - "provider" - ], - "description": "Remove a dictation provider secret value.", - "x-side": "agent", - "x-method": "_goose/unstable/dictation/secret/delete" - }, "DictationModelsListRequest_unstable": { "type": "object", "description": "List available local Whisper models with their download status.", @@ -5806,24 +5737,6 @@ "x-side": "agent", "x-method": "_goose/unstable/dictation/models/delete" }, - "DictationModelSelectRequest_unstable": { - "type": "object", - "properties": { - "provider": { - "type": "string" - }, - "modelId": { - "type": "string" - } - }, - "required": [ - "provider", - "modelId" - ], - "description": "Persist the user's model selection for a given provider.", - "x-side": "agent", - "x-method": "_goose/unstable/dictation/models/select" - }, "LocalInferenceModelsListRequest_unstable": { "type": "object", "x-side": "agent", @@ -7192,15 +7105,6 @@ "description": "Params for _goose/unstable/config/extensions/list", "title": "GetConfigExtensionsRequest_unstable" }, - { - "allOf": [ - { - "$ref": "#/$defs/GetAvailableExtensionsRequest_unstable" - } - ], - "description": "Params for _goose/unstable/extensions/available", - "title": "GetAvailableExtensionsRequest_unstable" - }, { "allOf": [ { @@ -7426,15 +7330,6 @@ "description": "Params for _goose/unstable/preferences/save", "title": "PreferencesSaveRequest_unstable" }, - { - "allOf": [ - { - "$ref": "#/$defs/PreferencesRemoveRequest_unstable" - } - ], - "description": "Params for _goose/unstable/preferences/remove", - "title": "PreferencesRemoveRequest_unstable" - }, { "allOf": [ { @@ -7867,24 +7762,6 @@ "description": "Params for _goose/unstable/dictation/config", "title": "DictationConfigRequest_unstable" }, - { - "allOf": [ - { - "$ref": "#/$defs/DictationSecretSaveRequest_unstable" - } - ], - "description": "Params for _goose/unstable/dictation/secret/save", - "title": "DictationSecretSaveRequest_unstable" - }, - { - "allOf": [ - { - "$ref": "#/$defs/DictationSecretDeleteRequest_unstable" - } - ], - "description": "Params for _goose/unstable/dictation/secret/delete", - "title": "DictationSecretDeleteRequest_unstable" - }, { "allOf": [ { @@ -7930,15 +7807,6 @@ "description": "Params for _goose/unstable/dictation/models/delete", "title": "DictationModelDeleteRequest_unstable" }, - { - "allOf": [ - { - "$ref": "#/$defs/DictationModelSelectRequest_unstable" - } - ], - "description": "Params for _goose/unstable/dictation/models/select", - "title": "DictationModelSelectRequest_unstable" - }, { "allOf": [ { @@ -8188,14 +8056,6 @@ ], "title": "GetConfigExtensionsResponse_unstable" }, - { - "allOf": [ - { - "$ref": "#/$defs/GetAvailableExtensionsResponse_unstable" - } - ], - "title": "GetAvailableExtensionsResponse_unstable" - }, { "allOf": [ { diff --git a/crates/goose/src/acp/server/config.rs b/crates/goose/src/acp/server/config.rs index d45e10ca467d..b376b86413ba 100644 --- a/crates/goose/src/acp/server/config.rs +++ b/crates/goose/src/acp/server/config.rs @@ -62,18 +62,6 @@ impl GooseAcpAgent { Ok(EmptyResponse {}) } - pub(super) async fn on_preferences_remove( - &self, - req: PreferencesRemoveRequest, - ) -> Result { - let config = self.config()?; - for key in req.keys { - let def = preference_def(key)?; - config.delete(def.config_key).internal_err()?; - } - Ok(EmptyResponse {}) - } - pub(super) async fn on_config_read( &self, req: ConfigReadRequest, diff --git a/crates/goose/src/acp/server/custom_dispatch.rs b/crates/goose/src/acp/server/custom_dispatch.rs index ccac13757233..e7cdf29f9db9 100644 --- a/crates/goose/src/acp/server/custom_dispatch.rs +++ b/crates/goose/src/acp/server/custom_dispatch.rs @@ -180,13 +180,6 @@ impl GooseAcpAgent { self.on_get_config_extensions().await } - #[custom_method(GetAvailableExtensionsRequest)] - async fn dispatch_get_available_extensions( - &self, - ) -> Result { - self.on_get_available_extensions().await - } - #[custom_method(AddConfigExtensionRequest)] async fn dispatch_add_config_extension( &self, @@ -387,14 +380,6 @@ impl GooseAcpAgent { self.on_preferences_save(req).await } - #[custom_method(PreferencesRemoveRequest)] - async fn dispatch_preferences_remove( - &self, - req: PreferencesRemoveRequest, - ) -> Result { - self.on_preferences_remove(req).await - } - #[custom_method(ConfigReadRequest)] async fn dispatch_config_read( &self, @@ -779,22 +764,6 @@ impl GooseAcpAgent { self.on_dictation_config(_req).await } - #[custom_method(DictationSecretSaveRequest)] - async fn dispatch_dictation_secret_save( - &self, - req: DictationSecretSaveRequest, - ) -> Result { - self.on_dictation_secret_save(req).await - } - - #[custom_method(DictationSecretDeleteRequest)] - async fn dispatch_dictation_secret_delete( - &self, - req: DictationSecretDeleteRequest, - ) -> Result { - self.on_dictation_secret_delete(req).await - } - #[custom_method(DictationModelsListRequest)] async fn dispatch_dictation_models_list( &self, @@ -835,14 +804,6 @@ impl GooseAcpAgent { self.on_dictation_model_delete(_req).await } - #[custom_method(DictationModelSelectRequest)] - async fn dispatch_dictation_model_select( - &self, - req: DictationModelSelectRequest, - ) -> Result { - self.on_dictation_model_select(req).await - } - #[custom_method(LocalInferenceModelsListRequest)] async fn dispatch_local_inference_models_list( &self, diff --git a/crates/goose/src/acp/server/dictation.rs b/crates/goose/src/acp/server/dictation.rs index 22d73fd50778..fa594e883813 100644 --- a/crates/goose/src/acp/server/dictation.rs +++ b/crates/goose/src/acp/server/dictation.rs @@ -2,8 +2,8 @@ use super::*; #[cfg(feature = "local-inference")] use crate::dictation::providers::transcribe_local; use crate::dictation::providers::{ - all_providers, get_provider_def, is_configured, transcribe_with_model, - transcribe_with_provider, DictationProvider, + all_providers, is_configured, transcribe_with_model, transcribe_with_provider, + DictationProvider, }; #[cfg(feature = "local-inference")] use crate::dictation::whisper; @@ -139,30 +139,6 @@ impl GooseAcpAgent { Ok(DictationConfigResponse { providers }) } - pub(super) async fn on_dictation_secret_save( - &self, - req: DictationSecretSaveRequest, - ) -> Result { - let provider = parse_dictation_provider(&req.provider)?; - let key = dictation_secret_config_key(provider)?; - let config = self.config()?; - config.set_secret(key, &req.value).internal_err()?; - Config::global().invalidate_secrets_cache(); - Ok(EmptyResponse {}) - } - - pub(super) async fn on_dictation_secret_delete( - &self, - req: DictationSecretDeleteRequest, - ) -> Result { - let provider = parse_dictation_provider(&req.provider)?; - let key = dictation_secret_config_key(provider)?; - let config = self.config()?; - config.delete_secret(key).internal_err()?; - Config::global().invalidate_secrets_cache(); - Ok(EmptyResponse {}) - } - pub(super) async fn on_dictation_models_list( &self, _req: DictationModelsListRequest, @@ -324,82 +300,6 @@ impl GooseAcpAgent { #[cfg(not(feature = "local-inference"))] Err(agent_client_protocol::Error::invalid_params().data("Local inference not enabled")) } - - pub(super) async fn on_dictation_model_select( - &self, - req: DictationModelSelectRequest, - ) -> Result { - #[cfg(not(feature = "local-inference"))] - if req.provider == "local" { - return Err( - agent_client_protocol::Error::invalid_params().data("Local inference not enabled") - ); - } - - let provider: DictationProvider = serde_json::from_value(serde_json::Value::String( - req.provider.clone(), - )) - .map_err(|_| { - agent_client_protocol::Error::invalid_params() - .data(format!("Unknown provider: {}", req.provider)) - })?; - - let key = match provider { - DictationProvider::OpenAI => OPENAI_TRANSCRIPTION_MODEL_CONFIG_KEY, - DictationProvider::Groq => GROQ_TRANSCRIPTION_MODEL_CONFIG_KEY, - DictationProvider::ElevenLabs => ELEVENLABS_TRANSCRIPTION_MODEL_CONFIG_KEY, - DictationProvider::ModelNative => return Ok(EmptyResponse {}), - #[cfg(feature = "local-inference")] - DictationProvider::Local => { - let model = whisper::get_model(&req.model_id).ok_or_else(|| { - agent_client_protocol::Error::invalid_params().data("Unknown model id") - })?; - if !model.is_downloaded() { - return Err(agent_client_protocol::Error::invalid_params() - .data("Local Whisper model is not downloaded")); - } - whisper::LOCAL_WHISPER_MODEL_CONFIG_KEY - } - }; - - crate::config::Config::global() - .set_param(key, req.model_id) - .internal_err()?; - - Ok(EmptyResponse {}) - } -} - -fn parse_dictation_provider( - provider: &str, -) -> Result { - serde_json::from_value(serde_json::Value::String(provider.to_string())).map_err(|_| { - agent_client_protocol::Error::invalid_params().data(format!("Unknown provider: {provider}")) - }) -} - -fn dictation_secret_config_key( - provider: DictationProvider, -) -> Result<&'static str, agent_client_protocol::Error> { - if provider == DictationProvider::ModelNative { - return Err(agent_client_protocol::Error::invalid_params() - .data("Model-native provider uses the active chat provider's credentials.")); - } - - let def = get_provider_def(provider); - if def.uses_provider_config { - return Err(agent_client_protocol::Error::invalid_params().data( - "Dictation provider uses the main provider configuration. Configure its credentials in provider settings instead.", - )); - } - - #[cfg(feature = "local-inference")] - if provider == DictationProvider::Local { - return Err(agent_client_protocol::Error::invalid_params() - .data("Dictation provider does not use an API key or secret.")); - } - - Ok(def.config_key) } fn dictation_model_config_key(provider: DictationProvider) -> Option { diff --git a/crates/goose/src/acp/server/extensions.rs b/crates/goose/src/acp/server/extensions.rs index a6da99cf1c76..423b540acf7c 100644 --- a/crates/goose/src/acp/server/extensions.rs +++ b/crates/goose/src/acp/server/extensions.rs @@ -59,20 +59,6 @@ impl GooseAcpAgent { }) } - pub(super) async fn on_get_available_extensions( - &self, - ) -> Result { - let extensions = crate::config::get_available_extensions() - .into_iter() - .map(|config| config_to_goose_extension(&config)) - .collect::, _>>()? - .into_iter() - .flatten() - .collect::>(); - - Ok(GetAvailableExtensionsResponse { extensions }) - } - pub(super) async fn on_add_config_extension( &self, req: AddConfigExtensionRequest, diff --git a/crates/goose/tests/acp_custom_requests_test.rs b/crates/goose/tests/acp_custom_requests_test.rs index 6e3b3647b539..bd2feac88af9 100644 --- a/crates/goose/tests/acp_custom_requests_test.rs +++ b/crates/goose/tests/acp_custom_requests_test.rs @@ -418,50 +418,6 @@ fn test_custom_session_extensions_add_list_remove() { }); } -#[test] -#[serial] -fn test_custom_get_available_extensions() { - write_acp_global_config(DEFAULT_ACP_TEST_CONFIG); - run_test(async move { - let openai = OpenAiFixture::new(vec![], Arc::new(EnforceSessionId::default())).await; - let conn = AcpServerConnection::new(TestConnectionConfig::default(), openai).await; - - let result = send_custom( - conn.cx(), - "_goose/unstable/extensions/available", - serde_json::json!({}), - ) - .await; - assert!(result.is_ok(), "expected ok, got: {:?}", result); - - let response = result.unwrap(); - let extensions = response - .get("extensions") - .and_then(|extensions| extensions.as_array()) - .expect("extensions should be an array"); - assert!(!extensions.is_empty(), "extensions should not be empty"); - assert!( - extensions.iter().all(|extension| matches!( - extension["type"].as_str(), - Some("builtin" | "platform") - )), - "available extensions should only include builtin and platform entries" - ); - assert!( - extensions.iter().any(|extension| { - extension["type"] == "platform" && extension["name"] == "developer" - }), - "developer platform extension should be available" - ); - assert!( - !extensions.iter().any(|extension| { - extension["type"] == "platform" && extension["name"] == "orchestrator" - }), - "hidden orchestrator platform extension should not be available" - ); - }); -} - #[test] #[serial] fn test_custom_prompt_methods() { @@ -742,7 +698,7 @@ fn test_custom_provider_inventory_includes_metadata() { #[test] #[serial] -fn test_custom_preferences_read_save_remove() { +fn test_custom_preferences_read_save() { let config_dir = write_acp_global_config( "GOOSE_MODEL: gpt-4o\nGOOSE_PROVIDER: openai\nGOOSE_AUTO_COMPACT_THRESHOLD: 0.7\nGOOSE_THINKING_EFFORT: high\nVOICE_AUTO_SUBMIT_PHRASES: send it\n", ); @@ -793,16 +749,6 @@ fn test_custom_preferences_read_save_remove() { .await .expect("preferences save should succeed"); - send_custom( - conn.cx(), - "_goose/unstable/preferences/remove", - serde_json::json!({ - "keys": ["voiceDictationProvider"], - }), - ) - .await - .expect("preferences remove should succeed"); - let response = send_custom( conn.cx(), "_goose/unstable/preferences/read", @@ -811,12 +757,12 @@ fn test_custom_preferences_read_save_remove() { }), ) .await - .expect("preferences read after remove should succeed"); + .expect("preferences read after save should succeed"); assert_eq!( response.get("values"), Some(&serde_json::json!([ { "key": "gooseThinkingEffort", "value": "off" }, - { "key": "voiceDictationProvider", "value": null }, + { "key": "voiceDictationProvider", "value": "__disabled__" }, { "key": "voiceDictationPreferredMic", "value": "mic-1" }, ])) ); @@ -964,104 +910,6 @@ fn test_custom_defaults_save_allows_unlisted_model() { }); } -#[test] -#[serial] -fn test_custom_dictation_secret_save_delete() { - let _env = env_lock::lock_env([ - ("GOOSE_DISABLE_KEYRING", Some("1")), - ("GROQ_API_KEY", None::<&str>), - ]); - let config_dir = write_acp_global_config( - "GOOSE_MODEL: gpt-4o\nGOOSE_PROVIDER: openai\nGOOSE_DISABLE_KEYRING: true\n", - ); - - run_test(async move { - let openai = OpenAiFixture::new(vec![], Arc::new(EnforceSessionId::default())).await; - let config = TestConnectionConfig { - data_root: config_dir.clone(), - ..Default::default() - }; - let conn = AcpServerConnection::new(config, openai).await; - - send_custom( - conn.cx(), - "_goose/unstable/dictation/secret/save", - serde_json::json!({ - "provider": "groq", - "value": "groq-key", - }), - ) - .await - .expect("dictation secret save should succeed"); - - let config = send_custom( - conn.cx(), - "_goose/unstable/dictation/config", - serde_json::json!({}), - ) - .await - .expect("dictation config should succeed"); - assert_eq!( - config - .pointer("/providers/groq/configured") - .and_then(|value| value.as_bool()), - Some(true) - ); - - let provider_config_result = send_custom( - conn.cx(), - "_goose/unstable/dictation/secret/save", - serde_json::json!({ - "provider": "openai", - "value": "openai-key", - }), - ) - .await; - assert!( - provider_config_result.is_err(), - "provider-config dictation providers should be rejected" - ); - - let unknown_result = send_custom( - conn.cx(), - "_goose/unstable/dictation/secret/save", - serde_json::json!({ - "provider": "unknown", - "value": "key", - }), - ) - .await; - assert!( - unknown_result.is_err(), - "unknown provider should be rejected" - ); - - send_custom( - conn.cx(), - "_goose/unstable/dictation/secret/delete", - serde_json::json!({ - "provider": "groq", - }), - ) - .await - .expect("dictation secret delete should succeed"); - - let config = send_custom( - conn.cx(), - "_goose/unstable/dictation/config", - serde_json::json!({}), - ) - .await - .expect("dictation config should succeed"); - assert_eq!( - config - .pointer("/providers/groq/configured") - .and_then(|value| value.as_bool()), - Some(false) - ); - }); -} - #[test] #[serial] fn test_raw_config_and_secret_methods_are_removed() { diff --git a/crates/goose/tests/acp_secret_cache_invalidation_test.rs b/crates/goose/tests/acp_secret_cache_invalidation_test.rs index 10e3f13bbb45..b53a0ab92026 100644 --- a/crates/goose/tests/acp_secret_cache_invalidation_test.rs +++ b/crates/goose/tests/acp_secret_cache_invalidation_test.rs @@ -71,14 +71,13 @@ fn write_secrets(config_dir: &std::path::Path, secrets: &str) { #[test] #[serial] -fn acp_secret_mutations_and_inventory_refresh_invalidate_global_secret_cache() { +fn provider_secret_mutations_and_inventory_refresh_invalidate_global_secret_cache() { let root = tempfile::tempdir().unwrap(); let root_path = root.path().to_string_lossy().to_string(); let _env = env_lock::lock_env([ ("GOOSE_PATH_ROOT", Some(root_path.as_str())), ("GOOSE_DISABLE_KEYRING", Some("1")), ("ANTHROPIC_API_KEY", None), - ("GROQ_API_KEY", None), ("OPENAI_API_KEY", None), ("XAI_API_KEY", None), ("XAI_HOST", None), @@ -87,16 +86,7 @@ fn acp_secret_mutations_and_inventory_refresh_invalidate_global_secret_cache() { let config_dir = Paths::config_dir(); let data_dir = Paths::data_dir(); write_config(&config_dir); - write_secrets(&config_dir, "GROQ_API_KEY: stale-key\n"); - run_test(async move { - assert_eq!( - Config::global() - .get_secret::("GROQ_API_KEY") - .unwrap(), - "stale-key" - ); - let openai = common_tests::fixtures::OpenAiFixture::new( vec![], Arc::new(EnforceSessionId::default()), @@ -109,45 +99,6 @@ fn acp_secret_mutations_and_inventory_refresh_invalidate_global_secret_cache() { }; let conn = AcpServerConnection::new(config, openai).await; - write_secrets(&config_dir, "GROQ_API_KEY: fresh-key\n"); - send_custom( - conn.cx(), - "_goose/unstable/dictation/secret/save", - serde_json::json!({ - "provider": "groq", - "value": "fresh-key", - }), - ) - .await - .expect("dictation secret save should succeed"); - - assert_eq!( - Config::global() - .get_secret::("GROQ_API_KEY") - .unwrap(), - "fresh-key", - "ACP dictation secret save should invalidate the global secrets cache" - ); - - write_secrets(&config_dir, "{}\n"); - send_custom( - conn.cx(), - "_goose/unstable/dictation/secret/delete", - serde_json::json!({ - "provider": "groq", - }), - ) - .await - .expect("dictation secret delete should succeed"); - - assert!( - matches!( - Config::global().get_secret::("GROQ_API_KEY"), - Err(ConfigError::NotFound(_)) - ), - "ACP dictation secret delete should invalidate the global secrets cache" - ); - let save_provider_config = send_custom( conn.cx(), "_goose/unstable/providers/config/save", diff --git a/ui/sdk/src/generated/client.gen.ts b/ui/sdk/src/generated/client.gen.ts index c4cfe7c09471..131a835e84bb 100644 --- a/ui/sdk/src/generated/client.gen.ts +++ b/ui/sdk/src/generated/client.gen.ts @@ -51,11 +51,8 @@ import type { DictationModelDownloadProgressRequest_unstable, DictationModelDownloadProgressResponse_unstable, DictationModelDownloadRequest_unstable, - DictationModelSelectRequest_unstable, DictationModelsListRequest_unstable, DictationModelsListResponse_unstable, - DictationSecretDeleteRequest_unstable, - DictationSecretSaveRequest_unstable, DictationTranscribeRequest_unstable, DictationTranscribeResponse_unstable, EncodeRecipeRequest_unstable, @@ -64,8 +61,6 @@ import type { ExportSessionResponse_unstable, ExportSourceRequest_unstable, ExportSourceResponse_unstable, - GetAvailableExtensionsRequest_unstable, - GetAvailableExtensionsResponse_unstable, GetConfigExtensionsRequest_unstable, GetConfigExtensionsResponse_unstable, GetPromptRequest_unstable, @@ -130,7 +125,6 @@ import type { PauseScheduleRequest_unstable, PreferencesReadRequest_unstable, PreferencesReadResponse_unstable, - PreferencesRemoveRequest_unstable, PreferencesSaveRequest_unstable, PromptOperationResponse_unstable, ProviderCatalogListRequest_unstable, @@ -215,7 +209,6 @@ import { zEncodeRecipeResponse_unstable, zExportSessionResponse_unstable, zExportSourceResponse_unstable, - zGetAvailableExtensionsResponse_unstable, zGetConfigExtensionsResponse_unstable, zGetPromptResponse_unstable, zGetSessionExtensionsResponse_unstable, @@ -461,18 +454,6 @@ export class GooseExtClient { ) as GetConfigExtensionsResponse_unstable; } - async extensionsAvailable_unstable( - params: GetAvailableExtensionsRequest_unstable, - ): Promise { - const raw = await this.conn.request( - "_goose/unstable/extensions/available", - params, - ); - return zGetAvailableExtensionsResponse_unstable.parse( - raw, - ) as GetAvailableExtensionsResponse_unstable; - } - async configExtensionsAdd_unstable( params: AddConfigExtensionRequest_unstable, ): Promise { @@ -746,12 +727,6 @@ export class GooseExtClient { await this.conn.request("_goose/unstable/preferences/save", params); } - async preferencesRemove_unstable( - params: PreferencesRemoveRequest_unstable, - ): Promise { - await this.conn.request("_goose/unstable/preferences/remove", params); - } - async configRead_unstable( params: ConfigReadRequest_unstable, ): Promise { @@ -1229,18 +1204,6 @@ export class GooseExtClient { ) as DictationConfigResponse_unstable; } - async dictationSecretSave_unstable( - params: DictationSecretSaveRequest_unstable, - ): Promise { - await this.conn.request("_goose/unstable/dictation/secret/save", params); - } - - async dictationSecretDelete_unstable( - params: DictationSecretDeleteRequest_unstable, - ): Promise { - await this.conn.request("_goose/unstable/dictation/secret/delete", params); - } - async dictationModelsList_unstable( params: DictationModelsListRequest_unstable, ): Promise { @@ -1286,12 +1249,6 @@ export class GooseExtClient { await this.conn.request("_goose/unstable/dictation/models/delete", params); } - async dictationModelsSelect_unstable( - params: DictationModelSelectRequest_unstable, - ): Promise { - await this.conn.request("_goose/unstable/dictation/models/select", params); - } - async localInferenceModelsList_unstable( params: LocalInferenceModelsListRequest_unstable, ): Promise { diff --git a/ui/sdk/src/generated/index.ts b/ui/sdk/src/generated/index.ts index 05e50db4adbe..dcf8b2e48435 100644 --- a/ui/sdk/src/generated/index.ts +++ b/ui/sdk/src/generated/index.ts @@ -1,6 +1,6 @@ // This file is auto-generated by @hey-api/openapi-ts -export type { AddConfigExtensionRequest_unstable, AddSessionExtensionRequest_unstable, AgentMention, Annotations, AppsDeleteRequest_unstable, AppsDeleteResponse_unstable, AppsExportRequest_unstable, AppsExportResponse_unstable, AppsImportRequest_unstable, AppsImportResponse_unstable, AppsListRequest_unstable, AppsListResponse_unstable, ArchiveSessionRequest_unstable, AudioContent, AvailableCommand, AvailableCommandInput, BlobResourceContents, CanonicalModelInfoDto, CanonicalModelInfoRequest_unstable, CanonicalModelInfoResponse_unstable, ConfigReadAllRequest_unstable, ConfigReadAllResponse_unstable, ConfigReadRequest_unstable, ConfigReadResponse_unstable, ConfigRemoveRequest_unstable, ConfigUpsertRequest_unstable, ContentBlock, CostSourceData, CreateScheduleRequest_unstable, CreateScheduleResponse_unstable, CreateSourceRequest_unstable, CreateSourceResponse_unstable, CustomProviderConfigDto, CustomProviderCreateRequest_unstable, CustomProviderCreateResponse_unstable, CustomProviderDeleteRequest_unstable, CustomProviderDeleteResponse_unstable, CustomProviderReadRequest_unstable, CustomProviderReadResponse_unstable, CustomProviderUpdateRequest_unstable, CustomProviderUpdateResponse_unstable, DecodeRecipeRequest_unstable, DecodeRecipeResponse_unstable, DefaultsClearRequest_unstable, DefaultsReadRequest_unstable, DefaultsReadResponse_unstable, DefaultsSaveRequest_unstable, DeleteRecipeRequest_unstable, DeleteScheduleRequest_unstable, DeleteSourceRequest_unstable, DiagnosticsGetRequest_unstable, DiagnosticsGetResponse_unstable, DiagnosticsReportLevel, DictationConfigRequest_unstable, DictationConfigResponse_unstable, DictationDownloadProgress, DictationLocalModelStatus, DictationModelCancelRequest_unstable, DictationModelDeleteRequest_unstable, DictationModelDownloadProgressRequest_unstable, DictationModelDownloadProgressResponse_unstable, DictationModelDownloadRequest_unstable, DictationModelOption, DictationModelSelectRequest_unstable, DictationModelsListRequest_unstable, DictationModelsListResponse_unstable, DictationProviderStatusEntry, DictationSecretDeleteRequest_unstable, DictationSecretSaveRequest_unstable, DictationTranscribeRequest_unstable, DictationTranscribeResponse_unstable, EmbeddedResource, EmbeddedResourceResource, EmptyResponse, EncodeRecipeRequest_unstable, EncodeRecipeResponse_unstable, EnvVariable, ExportSessionRequest_unstable, ExportSessionResponse_unstable, ExportSourceRequest_unstable, ExportSourceResponse_unstable, ExtAgentRequest, ExtAgentResponse, ExtNotification, ExtRequest, ExtResponse, GetAvailableExtensionsRequest_unstable, GetAvailableExtensionsResponse_unstable, GetConfigExtensionsRequest_unstable, GetConfigExtensionsResponse_unstable, GetPromptRequest_unstable, GetPromptResponse_unstable, GetSessionExtensionsRequest_unstable, GetSessionExtensionsResponse_unstable, GetSessionInfoRequest_unstable, GetSessionInfoResponse_unstable, GetToolsRequest_unstable, GetToolsResponse_unstable, GooseExtension, GooseExtensionEntry, GooseSessionNotification_unstable, GooseSessionUpdate, GooseToolCallRequest_unstable, GooseToolCallResponse_unstable, HttpHeader, ImageContent, ImportSessionRequest_unstable, ImportSessionResponse_unstable, ImportSourcesRequest_unstable, ImportSourcesResponse_unstable, InspectRunningJobRequest_unstable, InspectRunningJobResponse_unstable, KillRunningJobRequest_unstable, KillRunningJobResponse_unstable, ListAgentMentionsRequest_unstable, ListAgentMentionsResponse_unstable, ListPromptsRequest_unstable, ListPromptsResponse_unstable, ListProvidersRequest_unstable, ListProvidersResponse_unstable, ListRecipesRequest_unstable, ListRecipesResponse_unstable, ListScheduleSessionsRequest_unstable, ListScheduleSessionsResponse_unstable, ListSchedulesRequest_unstable, ListSchedulesResponse_unstable, ListSlashCommandsRequest_unstable, ListSlashCommandsResponse_unstable, ListSourcesRequest_unstable, ListSourcesResponse_unstable, LocalInferenceBuiltinChatTemplatesListRequest_unstable, LocalInferenceBuiltinChatTemplatesListResponse_unstable, LocalInferenceChatTemplate, LocalInferenceDownloadProgressDto, LocalInferenceDownloadState, LocalInferenceHfGgufFileDto, LocalInferenceHfModelInfoDto, LocalInferenceHfModelVariantDto, LocalInferenceHuggingFaceRepoVariantsRequest_unstable, LocalInferenceHuggingFaceRepoVariantsResponse_unstable, LocalInferenceHuggingFaceSearchRequest_unstable, LocalInferenceHuggingFaceSearchResponse_unstable, LocalInferenceModelDeleteRequest_unstable, LocalInferenceModelDownloadCancelRequest_unstable, LocalInferenceModelDownloadProgressRequest_unstable, LocalInferenceModelDownloadProgressResponse_unstable, LocalInferenceModelDownloadRequest_unstable, LocalInferenceModelDownloadResponse_unstable, LocalInferenceModelDownloadStatusDto, LocalInferenceModelDto, LocalInferenceModelEvictRequest_unstable, LocalInferenceModelSettingsDto, LocalInferenceModelSettingsReadRequest_unstable, LocalInferenceModelSettingsReadResponse_unstable, LocalInferenceModelSettingsUpdateRequest_unstable, LocalInferenceModelSettingsUpdateResponse_unstable, LocalInferenceModelsListRequest_unstable, LocalInferenceModelsListResponse_unstable, LocalInferenceSamplingConfig, LocalInferenceToolCallingMode, McpServer, McpServerHttp, McpServerSse, McpServerStdio, MessageUsageData, MessageUsageUpdate, OnboardingImportApplyRequest_unstable, OnboardingImportApplyResponse_unstable, OnboardingImportCandidate, OnboardingImportCounts, OnboardingImportScanRequest_unstable, OnboardingImportScanResponse_unstable, OnboardingImportSourceKind, ParseRecipeRequest_unstable, ParseRecipeResponse_unstable, PauseScheduleRequest_unstable, PreferenceKey, PreferencesReadRequest_unstable, PreferencesReadResponse_unstable, PreferencesRemoveRequest_unstable, PreferencesSaveRequest_unstable, PreferenceValue, PromptOperationResponse_unstable, PromptTemplateEntry, ProviderCatalogListRequest_unstable, ProviderCatalogListResponse_unstable, ProviderCatalogTemplateRequest_unstable, ProviderCatalogTemplateResponse_unstable, ProviderConfigAuthenticateRequest_unstable, ProviderConfigChangeResponse_unstable, ProviderConfigDeleteRequest_unstable, ProviderConfigFieldUpdate, ProviderConfigFieldValueDto, ProviderConfigKey, ProviderConfigReadRequest_unstable, ProviderConfigReadResponse_unstable, ProviderConfigSaveRequest_unstable, ProviderConfigStatusDto, ProviderConfigStatusRequest_unstable, ProviderConfigStatusResponse_unstable, ProviderDeviceCodeNotification_unstable, ProviderInventoryEntryDto, ProviderInventoryModelDto, ProviderReadinessCheckRequest_unstable, ProviderReadinessCheckResponse_unstable, ProviderSecretDeleteRequest_unstable, ProviderSecretDto, ProviderSecretsListRequest_unstable, ProviderSecretsListResponse_unstable, ProviderSecretStatusDto, ProviderSecretStorageDto, ProviderSetupCatalogEntryDto, ProviderSetupCatalogListRequest_unstable, ProviderSetupCatalogListResponse_unstable, ProviderSetupCategoryDto, ProviderSetupFieldDto, ProviderSetupGroupDto, ProviderSetupMethodDto, ProviderSupportedModelsListRequest_unstable, ProviderSupportedModelsListResponse_unstable, ProviderTemplateCapabilitiesDto, ProviderTemplateCatalogEntryDto, ProviderTemplateDto, ProviderTemplateModelDto, ReadResourceRequest_unstable, ReadResourceResponse_unstable, RecipeAuthorDto, RecipeDto, RecipeExtensionDto, RecipeListEntryDto, RecipeParameterDto, RecipeParameterInputTypeDto, RecipeParameterRequirementDto, RecipeParamsAction, RecipeParamsResponse_unstable, RecipeResponseDto, RecipeRetryConfigDto, RecipeSettingsDto, RecipeSuccessCheckDto, RecipeToYamlRequest_unstable, RecipeToYamlResponse_unstable, RefreshProviderInventoryRequest_unstable, RefreshProviderInventoryResponse_unstable, RefreshProviderInventorySkipDto, RefreshProviderInventorySkipReasonDto, RemoveConfigExtensionRequest_unstable, RemoveSessionExtensionRequest_unstable, RenameSessionRequest_unstable, RequestRecipeParams_unstable, ResetPromptRequest_unstable, ResourceLink, Role, RunScheduleNowRequest_unstable, RunScheduleNowResponse_unstable, RunScheduleNowStatus, SavePromptRequest_unstable, SaveRecipeRequest_unstable, SaveRecipeResponse_unstable, ScanRecipeRequest_unstable, ScanRecipeResponse_unstable, ScheduledJobDto, ScheduleRecipeRequest_unstable, SessionExportFormat, SessionExtensionEntry, SessionId, SessionImportSource, SessionInfo, SessionSystemPromptMode, SessionUsageUpdate, SetConfigExtensionEnabledRequest_unstable, SetRecipeSlashCommandRequest_unstable, SetSessionSystemPromptRequest_unstable, SetToolPermissionsRequest_unstable, SetToolPermissionsResponse_unstable, ShareSessionNostrRequest_unstable, ShareSessionNostrResponse_unstable, SourceEntry, SourceScope, SourceType, StatusMessage, StatusMessageUpdate, SteerSessionRequest_unstable, SteerSessionResponse_unstable, SubRecipeDto, TextContent, TextResourceContents, ToolListItem, ToolPermissionEntry, ToolPermissionLevel, TruncateSessionConversationRequest_unstable, UnarchiveSessionRequest_unstable, UnpauseScheduleRequest_unstable, UnstructuredCommandInput, UpdateScheduleRequest_unstable, UpdateScheduleResponse_unstable, UpdateSessionProjectRequest_unstable, UpdateSourceRequest_unstable, UpdateSourceResponse_unstable, UpdateWorkingDirRequest_unstable } from './types.gen.js'; +export type { AddConfigExtensionRequest_unstable, AddSessionExtensionRequest_unstable, AgentMention, Annotations, AppsDeleteRequest_unstable, AppsDeleteResponse_unstable, AppsExportRequest_unstable, AppsExportResponse_unstable, AppsImportRequest_unstable, AppsImportResponse_unstable, AppsListRequest_unstable, AppsListResponse_unstable, ArchiveSessionRequest_unstable, AudioContent, AvailableCommand, AvailableCommandInput, BlobResourceContents, CanonicalModelInfoDto, CanonicalModelInfoRequest_unstable, CanonicalModelInfoResponse_unstable, ConfigReadAllRequest_unstable, ConfigReadAllResponse_unstable, ConfigReadRequest_unstable, ConfigReadResponse_unstable, ConfigRemoveRequest_unstable, ConfigUpsertRequest_unstable, ContentBlock, CostSourceData, CreateScheduleRequest_unstable, CreateScheduleResponse_unstable, CreateSourceRequest_unstable, CreateSourceResponse_unstable, CustomProviderConfigDto, CustomProviderCreateRequest_unstable, CustomProviderCreateResponse_unstable, CustomProviderDeleteRequest_unstable, CustomProviderDeleteResponse_unstable, CustomProviderReadRequest_unstable, CustomProviderReadResponse_unstable, CustomProviderUpdateRequest_unstable, CustomProviderUpdateResponse_unstable, DecodeRecipeRequest_unstable, DecodeRecipeResponse_unstable, DefaultsClearRequest_unstable, DefaultsReadRequest_unstable, DefaultsReadResponse_unstable, DefaultsSaveRequest_unstable, DeleteRecipeRequest_unstable, DeleteScheduleRequest_unstable, DeleteSourceRequest_unstable, DiagnosticsGetRequest_unstable, DiagnosticsGetResponse_unstable, DiagnosticsReportLevel, DictationConfigRequest_unstable, DictationConfigResponse_unstable, DictationDownloadProgress, DictationLocalModelStatus, DictationModelCancelRequest_unstable, DictationModelDeleteRequest_unstable, DictationModelDownloadProgressRequest_unstable, DictationModelDownloadProgressResponse_unstable, DictationModelDownloadRequest_unstable, DictationModelOption, DictationModelsListRequest_unstable, DictationModelsListResponse_unstable, DictationProviderStatusEntry, DictationTranscribeRequest_unstable, DictationTranscribeResponse_unstable, EmbeddedResource, EmbeddedResourceResource, EmptyResponse, EncodeRecipeRequest_unstable, EncodeRecipeResponse_unstable, EnvVariable, ExportSessionRequest_unstable, ExportSessionResponse_unstable, ExportSourceRequest_unstable, ExportSourceResponse_unstable, ExtAgentRequest, ExtAgentResponse, ExtNotification, ExtRequest, ExtResponse, GetConfigExtensionsRequest_unstable, GetConfigExtensionsResponse_unstable, GetPromptRequest_unstable, GetPromptResponse_unstable, GetSessionExtensionsRequest_unstable, GetSessionExtensionsResponse_unstable, GetSessionInfoRequest_unstable, GetSessionInfoResponse_unstable, GetToolsRequest_unstable, GetToolsResponse_unstable, GooseExtension, GooseExtensionEntry, GooseSessionNotification_unstable, GooseSessionUpdate, GooseToolCallRequest_unstable, GooseToolCallResponse_unstable, HttpHeader, ImageContent, ImportSessionRequest_unstable, ImportSessionResponse_unstable, ImportSourcesRequest_unstable, ImportSourcesResponse_unstable, InspectRunningJobRequest_unstable, InspectRunningJobResponse_unstable, KillRunningJobRequest_unstable, KillRunningJobResponse_unstable, ListAgentMentionsRequest_unstable, ListAgentMentionsResponse_unstable, ListPromptsRequest_unstable, ListPromptsResponse_unstable, ListProvidersRequest_unstable, ListProvidersResponse_unstable, ListRecipesRequest_unstable, ListRecipesResponse_unstable, ListScheduleSessionsRequest_unstable, ListScheduleSessionsResponse_unstable, ListSchedulesRequest_unstable, ListSchedulesResponse_unstable, ListSlashCommandsRequest_unstable, ListSlashCommandsResponse_unstable, ListSourcesRequest_unstable, ListSourcesResponse_unstable, LocalInferenceBuiltinChatTemplatesListRequest_unstable, LocalInferenceBuiltinChatTemplatesListResponse_unstable, LocalInferenceChatTemplate, LocalInferenceDownloadProgressDto, LocalInferenceDownloadState, LocalInferenceHfGgufFileDto, LocalInferenceHfModelInfoDto, LocalInferenceHfModelVariantDto, LocalInferenceHuggingFaceRepoVariantsRequest_unstable, LocalInferenceHuggingFaceRepoVariantsResponse_unstable, LocalInferenceHuggingFaceSearchRequest_unstable, LocalInferenceHuggingFaceSearchResponse_unstable, LocalInferenceModelDeleteRequest_unstable, LocalInferenceModelDownloadCancelRequest_unstable, LocalInferenceModelDownloadProgressRequest_unstable, LocalInferenceModelDownloadProgressResponse_unstable, LocalInferenceModelDownloadRequest_unstable, LocalInferenceModelDownloadResponse_unstable, LocalInferenceModelDownloadStatusDto, LocalInferenceModelDto, LocalInferenceModelEvictRequest_unstable, LocalInferenceModelSettingsDto, LocalInferenceModelSettingsReadRequest_unstable, LocalInferenceModelSettingsReadResponse_unstable, LocalInferenceModelSettingsUpdateRequest_unstable, LocalInferenceModelSettingsUpdateResponse_unstable, LocalInferenceModelsListRequest_unstable, LocalInferenceModelsListResponse_unstable, LocalInferenceSamplingConfig, LocalInferenceToolCallingMode, McpServer, McpServerHttp, McpServerSse, McpServerStdio, MessageUsageData, MessageUsageUpdate, OnboardingImportApplyRequest_unstable, OnboardingImportApplyResponse_unstable, OnboardingImportCandidate, OnboardingImportCounts, OnboardingImportScanRequest_unstable, OnboardingImportScanResponse_unstable, OnboardingImportSourceKind, ParseRecipeRequest_unstable, ParseRecipeResponse_unstable, PauseScheduleRequest_unstable, PreferenceKey, PreferencesReadRequest_unstable, PreferencesReadResponse_unstable, PreferencesSaveRequest_unstable, PreferenceValue, PromptOperationResponse_unstable, PromptTemplateEntry, ProviderCatalogListRequest_unstable, ProviderCatalogListResponse_unstable, ProviderCatalogTemplateRequest_unstable, ProviderCatalogTemplateResponse_unstable, ProviderConfigAuthenticateRequest_unstable, ProviderConfigChangeResponse_unstable, ProviderConfigDeleteRequest_unstable, ProviderConfigFieldUpdate, ProviderConfigFieldValueDto, ProviderConfigKey, ProviderConfigReadRequest_unstable, ProviderConfigReadResponse_unstable, ProviderConfigSaveRequest_unstable, ProviderConfigStatusDto, ProviderConfigStatusRequest_unstable, ProviderConfigStatusResponse_unstable, ProviderDeviceCodeNotification_unstable, ProviderInventoryEntryDto, ProviderInventoryModelDto, ProviderReadinessCheckRequest_unstable, ProviderReadinessCheckResponse_unstable, ProviderSecretDeleteRequest_unstable, ProviderSecretDto, ProviderSecretsListRequest_unstable, ProviderSecretsListResponse_unstable, ProviderSecretStatusDto, ProviderSecretStorageDto, ProviderSetupCatalogEntryDto, ProviderSetupCatalogListRequest_unstable, ProviderSetupCatalogListResponse_unstable, ProviderSetupCategoryDto, ProviderSetupFieldDto, ProviderSetupGroupDto, ProviderSetupMethodDto, ProviderSupportedModelsListRequest_unstable, ProviderSupportedModelsListResponse_unstable, ProviderTemplateCapabilitiesDto, ProviderTemplateCatalogEntryDto, ProviderTemplateDto, ProviderTemplateModelDto, ReadResourceRequest_unstable, ReadResourceResponse_unstable, RecipeAuthorDto, RecipeDto, RecipeExtensionDto, RecipeListEntryDto, RecipeParameterDto, RecipeParameterInputTypeDto, RecipeParameterRequirementDto, RecipeParamsAction, RecipeParamsResponse_unstable, RecipeResponseDto, RecipeRetryConfigDto, RecipeSettingsDto, RecipeSuccessCheckDto, RecipeToYamlRequest_unstable, RecipeToYamlResponse_unstable, RefreshProviderInventoryRequest_unstable, RefreshProviderInventoryResponse_unstable, RefreshProviderInventorySkipDto, RefreshProviderInventorySkipReasonDto, RemoveConfigExtensionRequest_unstable, RemoveSessionExtensionRequest_unstable, RenameSessionRequest_unstable, RequestRecipeParams_unstable, ResetPromptRequest_unstable, ResourceLink, Role, RunScheduleNowRequest_unstable, RunScheduleNowResponse_unstable, RunScheduleNowStatus, SavePromptRequest_unstable, SaveRecipeRequest_unstable, SaveRecipeResponse_unstable, ScanRecipeRequest_unstable, ScanRecipeResponse_unstable, ScheduledJobDto, ScheduleRecipeRequest_unstable, SessionExportFormat, SessionExtensionEntry, SessionId, SessionImportSource, SessionInfo, SessionSystemPromptMode, SessionUsageUpdate, SetConfigExtensionEnabledRequest_unstable, SetRecipeSlashCommandRequest_unstable, SetSessionSystemPromptRequest_unstable, SetToolPermissionsRequest_unstable, SetToolPermissionsResponse_unstable, ShareSessionNostrRequest_unstable, ShareSessionNostrResponse_unstable, SourceEntry, SourceScope, SourceType, StatusMessage, StatusMessageUpdate, SteerSessionRequest_unstable, SteerSessionResponse_unstable, SubRecipeDto, TextContent, TextResourceContents, ToolListItem, ToolPermissionEntry, ToolPermissionLevel, TruncateSessionConversationRequest_unstable, UnarchiveSessionRequest_unstable, UnpauseScheduleRequest_unstable, UnstructuredCommandInput, UpdateScheduleRequest_unstable, UpdateScheduleResponse_unstable, UpdateSessionProjectRequest_unstable, UpdateSourceRequest_unstable, UpdateSourceResponse_unstable, UpdateWorkingDirRequest_unstable } from './types.gen.js'; export const GOOSE_EXT_METHODS = [ { @@ -98,11 +98,6 @@ export const GOOSE_EXT_METHODS = [ requestType: "GetConfigExtensionsRequest_unstable", responseType: "GetConfigExtensionsResponse_unstable", }, - { - method: "_goose/unstable/extensions/available", - requestType: "GetAvailableExtensionsRequest_unstable", - responseType: "GetAvailableExtensionsResponse_unstable", - }, { method: "_goose/unstable/config/extensions/add", requestType: "AddConfigExtensionRequest_unstable", @@ -228,11 +223,6 @@ export const GOOSE_EXT_METHODS = [ requestType: "PreferencesSaveRequest_unstable", responseType: "EmptyResponse", }, - { - method: "_goose/unstable/preferences/remove", - requestType: "PreferencesRemoveRequest_unstable", - responseType: "EmptyResponse", - }, { method: "_goose/unstable/config/read", requestType: "ConfigReadRequest_unstable", @@ -473,16 +463,6 @@ export const GOOSE_EXT_METHODS = [ requestType: "DictationConfigRequest_unstable", responseType: "DictationConfigResponse_unstable", }, - { - method: "_goose/unstable/dictation/secret/save", - requestType: "DictationSecretSaveRequest_unstable", - responseType: "EmptyResponse", - }, - { - method: "_goose/unstable/dictation/secret/delete", - requestType: "DictationSecretDeleteRequest_unstable", - responseType: "EmptyResponse", - }, { method: "_goose/unstable/dictation/models/list", requestType: "DictationModelsListRequest_unstable", @@ -508,11 +488,6 @@ export const GOOSE_EXT_METHODS = [ requestType: "DictationModelDeleteRequest_unstable", responseType: "EmptyResponse", }, - { - method: "_goose/unstable/dictation/models/select", - requestType: "DictationModelSelectRequest_unstable", - responseType: "EmptyResponse", - }, { method: "_goose/unstable/local-inference/models/list", requestType: "LocalInferenceModelsListRequest_unstable", diff --git a/ui/sdk/src/generated/types.gen.ts b/ui/sdk/src/generated/types.gen.ts index dafbb8568264..856d36bf85a9 100644 --- a/ui/sdk/src/generated/types.gen.ts +++ b/ui/sdk/src/generated/types.gen.ts @@ -752,17 +752,6 @@ export type GooseExtensionEntry = { configKey?: string | null; }; -/** - * List Goose-owned extension definitions available to configure or enable. - */ -export type GetAvailableExtensionsRequest_unstable = { - [key: string]: unknown; -}; - -export type GetAvailableExtensionsResponse_unstable = { - extensions: Array; -}; - /** * Persist a new extension to the user's global goose config. */ @@ -1360,13 +1349,6 @@ export type PreferencesSaveRequest_unstable = { values?: Array; }; -/** - * Remove allowlisted user preferences. - */ -export type PreferencesRemoveRequest_unstable = { - keys?: Array; -}; - export type ConfigReadRequest_unstable = { key: string; isSecret?: boolean; @@ -2248,21 +2230,6 @@ export type DictationModelOption = { description: string; }; -/** - * Set a dictation provider secret value. - */ -export type DictationSecretSaveRequest_unstable = { - provider: string; - value: string; -}; - -/** - * Remove a dictation provider secret value. - */ -export type DictationSecretDeleteRequest_unstable = { - provider: string; -}; - /** * List available local Whisper models with their download status. */ @@ -2330,14 +2297,6 @@ export type DictationModelDeleteRequest_unstable = { modelId: string; }; -/** - * Persist the user's model selection for a given provider. - */ -export type DictationModelSelectRequest_unstable = { - provider: string; - modelId: string; -}; - export type LocalInferenceModelsListRequest_unstable = { [key: string]: unknown; }; @@ -2661,14 +2620,14 @@ export type RecipeParamsAction = 'submit' | 'cancel'; export type ExtRequest = { id: string; method: string; - params?: AddSessionExtensionRequest_unstable | RemoveSessionExtensionRequest_unstable | GetToolsRequest_unstable | SetToolPermissionsRequest_unstable | GooseToolCallRequest_unstable | ReadResourceRequest_unstable | AppsListRequest_unstable | AppsExportRequest_unstable | AppsImportRequest_unstable | AppsDeleteRequest_unstable | UpdateWorkingDirRequest_unstable | SetSessionSystemPromptRequest_unstable | SteerSessionRequest_unstable | DiagnosticsGetRequest_unstable | ListPromptsRequest_unstable | GetPromptRequest_unstable | SavePromptRequest_unstable | ResetPromptRequest_unstable | GetConfigExtensionsRequest_unstable | GetAvailableExtensionsRequest_unstable | AddConfigExtensionRequest_unstable | RemoveConfigExtensionRequest_unstable | SetConfigExtensionEnabledRequest_unstable | GetSessionExtensionsRequest_unstable | ListProvidersRequest_unstable | ProviderSupportedModelsListRequest_unstable | ProviderCatalogListRequest_unstable | ProviderSetupCatalogListRequest_unstable | ProviderCatalogTemplateRequest_unstable | CustomProviderCreateRequest_unstable | CustomProviderReadRequest_unstable | CustomProviderUpdateRequest_unstable | CustomProviderDeleteRequest_unstable | RefreshProviderInventoryRequest_unstable | ProviderReadinessCheckRequest_unstable | ProviderConfigReadRequest_unstable | ProviderConfigStatusRequest_unstable | ProviderConfigSaveRequest_unstable | ProviderConfigDeleteRequest_unstable | ProviderConfigAuthenticateRequest_unstable | ProviderSecretsListRequest_unstable | ProviderSecretDeleteRequest_unstable | CanonicalModelInfoRequest_unstable | PreferencesReadRequest_unstable | PreferencesSaveRequest_unstable | PreferencesRemoveRequest_unstable | ConfigReadRequest_unstable | ConfigUpsertRequest_unstable | ConfigRemoveRequest_unstable | ConfigReadAllRequest_unstable | DefaultsReadRequest_unstable | DefaultsSaveRequest_unstable | DefaultsClearRequest_unstable | OnboardingImportScanRequest_unstable | OnboardingImportApplyRequest_unstable | ExportSessionRequest_unstable | ImportSessionRequest_unstable | ShareSessionNostrRequest_unstable | EncodeRecipeRequest_unstable | DecodeRecipeRequest_unstable | ScanRecipeRequest_unstable | ListRecipesRequest_unstable | DeleteRecipeRequest_unstable | ScheduleRecipeRequest_unstable | SetRecipeSlashCommandRequest_unstable | SaveRecipeRequest_unstable | ParseRecipeRequest_unstable | RecipeToYamlRequest_unstable | ListSchedulesRequest_unstable | ListScheduleSessionsRequest_unstable | CreateScheduleRequest_unstable | DeleteScheduleRequest_unstable | PauseScheduleRequest_unstable | UnpauseScheduleRequest_unstable | UpdateScheduleRequest_unstable | RunScheduleNowRequest_unstable | KillRunningJobRequest_unstable | InspectRunningJobRequest_unstable | GetSessionInfoRequest_unstable | TruncateSessionConversationRequest_unstable | UpdateSessionProjectRequest_unstable | RenameSessionRequest_unstable | ArchiveSessionRequest_unstable | UnarchiveSessionRequest_unstable | CreateSourceRequest_unstable | ListSourcesRequest_unstable | ListAgentMentionsRequest_unstable | ListSlashCommandsRequest_unstable | UpdateSourceRequest_unstable | DeleteSourceRequest_unstable | ExportSourceRequest_unstable | ImportSourcesRequest_unstable | DictationTranscribeRequest_unstable | DictationConfigRequest_unstable | DictationSecretSaveRequest_unstable | DictationSecretDeleteRequest_unstable | DictationModelsListRequest_unstable | DictationModelDownloadRequest_unstable | DictationModelDownloadProgressRequest_unstable | DictationModelCancelRequest_unstable | DictationModelDeleteRequest_unstable | DictationModelSelectRequest_unstable | LocalInferenceModelsListRequest_unstable | LocalInferenceModelDownloadRequest_unstable | LocalInferenceModelDownloadProgressRequest_unstable | LocalInferenceModelDownloadCancelRequest_unstable | LocalInferenceModelDeleteRequest_unstable | LocalInferenceModelEvictRequest_unstable | LocalInferenceModelSettingsReadRequest_unstable | LocalInferenceModelSettingsUpdateRequest_unstable | LocalInferenceHuggingFaceSearchRequest_unstable | LocalInferenceHuggingFaceRepoVariantsRequest_unstable | LocalInferenceBuiltinChatTemplatesListRequest_unstable | { + params?: AddSessionExtensionRequest_unstable | RemoveSessionExtensionRequest_unstable | GetToolsRequest_unstable | SetToolPermissionsRequest_unstable | GooseToolCallRequest_unstable | ReadResourceRequest_unstable | AppsListRequest_unstable | AppsExportRequest_unstable | AppsImportRequest_unstable | AppsDeleteRequest_unstable | UpdateWorkingDirRequest_unstable | SetSessionSystemPromptRequest_unstable | SteerSessionRequest_unstable | DiagnosticsGetRequest_unstable | ListPromptsRequest_unstable | GetPromptRequest_unstable | SavePromptRequest_unstable | ResetPromptRequest_unstable | GetConfigExtensionsRequest_unstable | AddConfigExtensionRequest_unstable | RemoveConfigExtensionRequest_unstable | SetConfigExtensionEnabledRequest_unstable | GetSessionExtensionsRequest_unstable | ListProvidersRequest_unstable | ProviderSupportedModelsListRequest_unstable | ProviderCatalogListRequest_unstable | ProviderSetupCatalogListRequest_unstable | ProviderCatalogTemplateRequest_unstable | CustomProviderCreateRequest_unstable | CustomProviderReadRequest_unstable | CustomProviderUpdateRequest_unstable | CustomProviderDeleteRequest_unstable | RefreshProviderInventoryRequest_unstable | ProviderReadinessCheckRequest_unstable | ProviderConfigReadRequest_unstable | ProviderConfigStatusRequest_unstable | ProviderConfigSaveRequest_unstable | ProviderConfigDeleteRequest_unstable | ProviderConfigAuthenticateRequest_unstable | ProviderSecretsListRequest_unstable | ProviderSecretDeleteRequest_unstable | CanonicalModelInfoRequest_unstable | PreferencesReadRequest_unstable | PreferencesSaveRequest_unstable | ConfigReadRequest_unstable | ConfigUpsertRequest_unstable | ConfigRemoveRequest_unstable | ConfigReadAllRequest_unstable | DefaultsReadRequest_unstable | DefaultsSaveRequest_unstable | DefaultsClearRequest_unstable | OnboardingImportScanRequest_unstable | OnboardingImportApplyRequest_unstable | ExportSessionRequest_unstable | ImportSessionRequest_unstable | ShareSessionNostrRequest_unstable | EncodeRecipeRequest_unstable | DecodeRecipeRequest_unstable | ScanRecipeRequest_unstable | ListRecipesRequest_unstable | DeleteRecipeRequest_unstable | ScheduleRecipeRequest_unstable | SetRecipeSlashCommandRequest_unstable | SaveRecipeRequest_unstable | ParseRecipeRequest_unstable | RecipeToYamlRequest_unstable | ListSchedulesRequest_unstable | ListScheduleSessionsRequest_unstable | CreateScheduleRequest_unstable | DeleteScheduleRequest_unstable | PauseScheduleRequest_unstable | UnpauseScheduleRequest_unstable | UpdateScheduleRequest_unstable | RunScheduleNowRequest_unstable | KillRunningJobRequest_unstable | InspectRunningJobRequest_unstable | GetSessionInfoRequest_unstable | TruncateSessionConversationRequest_unstable | UpdateSessionProjectRequest_unstable | RenameSessionRequest_unstable | ArchiveSessionRequest_unstable | UnarchiveSessionRequest_unstable | CreateSourceRequest_unstable | ListSourcesRequest_unstable | ListAgentMentionsRequest_unstable | ListSlashCommandsRequest_unstable | UpdateSourceRequest_unstable | DeleteSourceRequest_unstable | ExportSourceRequest_unstable | ImportSourcesRequest_unstable | DictationTranscribeRequest_unstable | DictationConfigRequest_unstable | DictationModelsListRequest_unstable | DictationModelDownloadRequest_unstable | DictationModelDownloadProgressRequest_unstable | DictationModelCancelRequest_unstable | DictationModelDeleteRequest_unstable | LocalInferenceModelsListRequest_unstable | LocalInferenceModelDownloadRequest_unstable | LocalInferenceModelDownloadProgressRequest_unstable | LocalInferenceModelDownloadCancelRequest_unstable | LocalInferenceModelDeleteRequest_unstable | LocalInferenceModelEvictRequest_unstable | LocalInferenceModelSettingsReadRequest_unstable | LocalInferenceModelSettingsUpdateRequest_unstable | LocalInferenceHuggingFaceSearchRequest_unstable | LocalInferenceHuggingFaceRepoVariantsRequest_unstable | LocalInferenceBuiltinChatTemplatesListRequest_unstable | { [key: string]: unknown; } | null; }; export type ExtResponse = { id: string; - result?: EmptyResponse | GetToolsResponse_unstable | SetToolPermissionsResponse_unstable | GooseToolCallResponse_unstable | ReadResourceResponse_unstable | AppsListResponse_unstable | AppsExportResponse_unstable | AppsImportResponse_unstable | AppsDeleteResponse_unstable | SteerSessionResponse_unstable | DiagnosticsGetResponse_unstable | ListPromptsResponse_unstable | GetPromptResponse_unstable | PromptOperationResponse_unstable | GetConfigExtensionsResponse_unstable | GetAvailableExtensionsResponse_unstable | GetSessionExtensionsResponse_unstable | ListProvidersResponse_unstable | ProviderSupportedModelsListResponse_unstable | ProviderCatalogListResponse_unstable | ProviderSetupCatalogListResponse_unstable | ProviderCatalogTemplateResponse_unstable | CustomProviderCreateResponse_unstable | CustomProviderReadResponse_unstable | CustomProviderUpdateResponse_unstable | CustomProviderDeleteResponse_unstable | RefreshProviderInventoryResponse_unstable | ProviderReadinessCheckResponse_unstable | ProviderConfigReadResponse_unstable | ProviderConfigStatusResponse_unstable | ProviderConfigChangeResponse_unstable | ProviderSecretsListResponse_unstable | CanonicalModelInfoResponse_unstable | PreferencesReadResponse_unstable | ConfigReadResponse_unstable | ConfigReadAllResponse_unstable | DefaultsReadResponse_unstable | OnboardingImportScanResponse_unstable | OnboardingImportApplyResponse_unstable | ExportSessionResponse_unstable | ImportSessionResponse_unstable | ShareSessionNostrResponse_unstable | EncodeRecipeResponse_unstable | DecodeRecipeResponse_unstable | ScanRecipeResponse_unstable | ListRecipesResponse_unstable | SaveRecipeResponse_unstable | ParseRecipeResponse_unstable | RecipeToYamlResponse_unstable | ListSchedulesResponse_unstable | ListScheduleSessionsResponse_unstable | CreateScheduleResponse_unstable | UpdateScheduleResponse_unstable | RunScheduleNowResponse_unstable | KillRunningJobResponse_unstable | InspectRunningJobResponse_unstable | GetSessionInfoResponse_unstable | CreateSourceResponse_unstable | ListSourcesResponse_unstable | ListAgentMentionsResponse_unstable | ListSlashCommandsResponse_unstable | UpdateSourceResponse_unstable | ExportSourceResponse_unstable | ImportSourcesResponse_unstable | DictationTranscribeResponse_unstable | DictationConfigResponse_unstable | DictationModelsListResponse_unstable | DictationModelDownloadProgressResponse_unstable | LocalInferenceModelsListResponse_unstable | LocalInferenceModelDownloadResponse_unstable | LocalInferenceModelDownloadProgressResponse_unstable | LocalInferenceModelSettingsReadResponse_unstable | LocalInferenceModelSettingsUpdateResponse_unstable | LocalInferenceHuggingFaceSearchResponse_unstable | LocalInferenceHuggingFaceRepoVariantsResponse_unstable | LocalInferenceBuiltinChatTemplatesListResponse_unstable | unknown; + result?: EmptyResponse | GetToolsResponse_unstable | SetToolPermissionsResponse_unstable | GooseToolCallResponse_unstable | ReadResourceResponse_unstable | AppsListResponse_unstable | AppsExportResponse_unstable | AppsImportResponse_unstable | AppsDeleteResponse_unstable | SteerSessionResponse_unstable | DiagnosticsGetResponse_unstable | ListPromptsResponse_unstable | GetPromptResponse_unstable | PromptOperationResponse_unstable | GetConfigExtensionsResponse_unstable | GetSessionExtensionsResponse_unstable | ListProvidersResponse_unstable | ProviderSupportedModelsListResponse_unstable | ProviderCatalogListResponse_unstable | ProviderSetupCatalogListResponse_unstable | ProviderCatalogTemplateResponse_unstable | CustomProviderCreateResponse_unstable | CustomProviderReadResponse_unstable | CustomProviderUpdateResponse_unstable | CustomProviderDeleteResponse_unstable | RefreshProviderInventoryResponse_unstable | ProviderReadinessCheckResponse_unstable | ProviderConfigReadResponse_unstable | ProviderConfigStatusResponse_unstable | ProviderConfigChangeResponse_unstable | ProviderSecretsListResponse_unstable | CanonicalModelInfoResponse_unstable | PreferencesReadResponse_unstable | ConfigReadResponse_unstable | ConfigReadAllResponse_unstable | DefaultsReadResponse_unstable | OnboardingImportScanResponse_unstable | OnboardingImportApplyResponse_unstable | ExportSessionResponse_unstable | ImportSessionResponse_unstable | ShareSessionNostrResponse_unstable | EncodeRecipeResponse_unstable | DecodeRecipeResponse_unstable | ScanRecipeResponse_unstable | ListRecipesResponse_unstable | SaveRecipeResponse_unstable | ParseRecipeResponse_unstable | RecipeToYamlResponse_unstable | ListSchedulesResponse_unstable | ListScheduleSessionsResponse_unstable | CreateScheduleResponse_unstable | UpdateScheduleResponse_unstable | RunScheduleNowResponse_unstable | KillRunningJobResponse_unstable | InspectRunningJobResponse_unstable | GetSessionInfoResponse_unstable | CreateSourceResponse_unstable | ListSourcesResponse_unstable | ListAgentMentionsResponse_unstable | ListSlashCommandsResponse_unstable | UpdateSourceResponse_unstable | ExportSourceResponse_unstable | ImportSourcesResponse_unstable | DictationTranscribeResponse_unstable | DictationConfigResponse_unstable | DictationModelsListResponse_unstable | DictationModelDownloadProgressResponse_unstable | LocalInferenceModelsListResponse_unstable | LocalInferenceModelDownloadResponse_unstable | LocalInferenceModelDownloadProgressResponse_unstable | LocalInferenceModelSettingsReadResponse_unstable | LocalInferenceModelSettingsUpdateResponse_unstable | LocalInferenceHuggingFaceSearchResponse_unstable | LocalInferenceHuggingFaceRepoVariantsResponse_unstable | LocalInferenceBuiltinChatTemplatesListResponse_unstable | unknown; } | { error: { code: number; diff --git a/ui/sdk/src/generated/zod.gen.ts b/ui/sdk/src/generated/zod.gen.ts index 50e04951f835..9ebf9a97abab 100644 --- a/ui/sdk/src/generated/zod.gen.ts +++ b/ui/sdk/src/generated/zod.gen.ts @@ -493,15 +493,6 @@ export const zGetConfigExtensionsResponse_unstable = z.object({ warnings: z.array(z.string()).optional().default([]) }); -/** - * List Goose-owned extension definitions available to configure or enable. - */ -export const zGetAvailableExtensionsRequest_unstable = z.record(z.string(), z.unknown()); - -export const zGetAvailableExtensionsResponse_unstable = z.object({ - extensions: z.array(zGooseExtension) -}); - /** * Persist a new extension to the user's global goose config. */ @@ -1015,13 +1006,6 @@ export const zPreferencesSaveRequest_unstable = z.object({ values: z.array(zPreferenceValue).optional().default([]) }); -/** - * Remove allowlisted user preferences. - */ -export const zPreferencesRemoveRequest_unstable = z.object({ - keys: z.array(zPreferenceKey).optional().default([]) -}); - export const zConfigReadRequest_unstable = z.object({ key: z.string(), isSecret: z.boolean().optional().default(false) @@ -1777,21 +1761,6 @@ export const zDictationConfigResponse_unstable = z.object({ providers: z.record(z.string(), zDictationProviderStatusEntry) }); -/** - * Set a dictation provider secret value. - */ -export const zDictationSecretSaveRequest_unstable = z.object({ - provider: z.string(), - value: z.string() -}); - -/** - * Remove a dictation provider secret value. - */ -export const zDictationSecretDeleteRequest_unstable = z.object({ - provider: z.string() -}); - /** * List available local Whisper models with their download status. */ @@ -1851,14 +1820,6 @@ export const zDictationModelDeleteRequest_unstable = z.object({ modelId: z.string() }); -/** - * Persist the user's model selection for a given provider. - */ -export const zDictationModelSelectRequest_unstable = z.object({ - provider: z.string(), - modelId: z.string() -}); - export const zLocalInferenceModelsListRequest_unstable = z.record(z.string(), z.unknown()); export const zLocalInferenceDownloadState = z.enum([ @@ -2217,7 +2178,6 @@ export const zExtRequest = z.object({ zSavePromptRequest_unstable, zResetPromptRequest_unstable, zGetConfigExtensionsRequest_unstable, - zGetAvailableExtensionsRequest_unstable, zAddConfigExtensionRequest_unstable, zRemoveConfigExtensionRequest_unstable, zSetConfigExtensionEnabledRequest_unstable, @@ -2243,7 +2203,6 @@ export const zExtRequest = z.object({ zCanonicalModelInfoRequest_unstable, zPreferencesReadRequest_unstable, zPreferencesSaveRequest_unstable, - zPreferencesRemoveRequest_unstable, zConfigReadRequest_unstable, zConfigUpsertRequest_unstable, zConfigRemoveRequest_unstable, @@ -2292,14 +2251,11 @@ export const zExtRequest = z.object({ zImportSourcesRequest_unstable, zDictationTranscribeRequest_unstable, zDictationConfigRequest_unstable, - zDictationSecretSaveRequest_unstable, - zDictationSecretDeleteRequest_unstable, zDictationModelsListRequest_unstable, zDictationModelDownloadRequest_unstable, zDictationModelDownloadProgressRequest_unstable, zDictationModelCancelRequest_unstable, zDictationModelDeleteRequest_unstable, - zDictationModelSelectRequest_unstable, zLocalInferenceModelsListRequest_unstable, zLocalInferenceModelDownloadRequest_unstable, zLocalInferenceModelDownloadProgressRequest_unstable, @@ -2336,7 +2292,6 @@ export const zExtResponse = z.union([ zGetPromptResponse_unstable, zPromptOperationResponse_unstable, zGetConfigExtensionsResponse_unstable, - zGetAvailableExtensionsResponse_unstable, zGetSessionExtensionsResponse_unstable, zListProvidersResponse_unstable, zProviderSupportedModelsListResponse_unstable, From 2f5ff48b56cf4f98e93b6c05d5b97b50d29613f0 Mon Sep 17 00:00:00 2001 From: Douwe Osinga Date: Fri, 28 Aug 2026 15:10:23 +0000 Subject: [PATCH 03/37] Clean up obsolete extension paths before redesigning the extension manager (#11645) --- crates/goose-agent/src/operation.rs | 4 +- .../src/scenario_tests/scenario_runner.rs | 1 - crates/goose-context-management/src/format.rs | 7 - .../goose-provider-types/src/conversation.rs | 7 - .../src/conversation/message.rs | 31 -- .../src/formats/anthropic.rs | 27 -- .../src/formats/databricks.rs | 11 - .../src/formats/openai.rs | 85 ---- .../src/formats/openai_responses.rs | 124 +----- .../src/formats/snowflake.rs | 3 - crates/goose/src/acp/provider.rs | 14 - crates/goose/src/acp/server.rs | 2 +- crates/goose/src/acp/server/extensions.rs | 61 --- .../src/acp/server/recipe/conversions.rs | 39 +- crates/goose/src/agents/agent.rs | 343 +++------------ crates/goose/src/agents/extension.rs | 84 ---- crates/goose/src/agents/extension_manager.rs | 109 +---- crates/goose/src/agents/gen_ai_telemetry.rs | 14 +- crates/goose/src/agents/mcp_client.rs | 8 +- crates/goose/src/agents/mod.rs | 2 +- .../platform_extensions/code_execution.rs | 1 - crates/goose/src/agents/prompt_manager.rs | 15 - crates/goose/src/agents/reply_parts.rs | 400 ++++-------------- .../state_machine/inference_preparation.rs | 8 +- .../agents/state_machine/tests/pipeline.rs | 4 - crates/goose/src/agents/tool_execution.rs | 26 -- crates/goose/src/agents/types.rs | 14 +- crates/goose/src/config/extensions.rs | 23 +- crates/goose/src/providers/claude_code.rs | 12 - crates/goose/src/providers/codex.rs | 3 - crates/goose/src/providers/formats/bedrock.rs | 21 - .../goose/src/providers/formats/openrouter.rs | 1 - crates/goose/src/recipe/mod.rs | 47 -- .../src/recipe/recipe_extension_adapter.rs | 37 -- crates/goose/tests/agent.rs | 222 +--------- documentation/docs/guides/config-files.md | 2 +- .../docs/guides/recipes/recipe-reference.md | 20 - examples/frontend_tools.py | 292 ------------- ui/desktop/src/acp/extensions.ts | 4 - .../modal/ExtensionConfigFields.tsx | 18 +- .../extensions/modal/ExtensionInfoFields.tsx | 26 +- .../extensions/modal/ExtensionModal.tsx | 17 +- .../subcomponents/ExtensionList.tsx | 4 +- .../settings/extensions/utils.test.ts | 23 - .../components/settings/extensions/utils.ts | 40 +- ui/desktop/src/i18n/messages/de.json | 3 - ui/desktop/src/i18n/messages/en.json | 3 - ui/desktop/src/i18n/messages/es.json | 3 - ui/desktop/src/i18n/messages/fr.json | 3 - ui/desktop/src/i18n/messages/hi.json | 3 - ui/desktop/src/i18n/messages/id.json | 3 - ui/desktop/src/i18n/messages/it.json | 3 - ui/desktop/src/i18n/messages/ja.json | 3 - ui/desktop/src/i18n/messages/ko.json | 3 - ui/desktop/src/i18n/messages/ms.json | 3 - ui/desktop/src/i18n/messages/pt.json | 3 - ui/desktop/src/i18n/messages/ru.json | 3 - ui/desktop/src/i18n/messages/tr.json | 3 - ui/desktop/src/i18n/messages/vi.json | 3 - ui/desktop/src/i18n/messages/zh-CN.json | 3 - ui/desktop/src/i18n/messages/zh-TW.json | 3 - ui/desktop/src/types/extensions.ts | 45 +- ui/desktop/src/types/message.ts | 11 +- 63 files changed, 245 insertions(+), 2115 deletions(-) delete mode 100644 examples/frontend_tools.py diff --git a/crates/goose-agent/src/operation.rs b/crates/goose-agent/src/operation.rs index acd27d89e64d..fc5a83c044d9 100644 --- a/crates/goose-agent/src/operation.rs +++ b/crates/goose-agent/src/operation.rs @@ -64,9 +64,7 @@ pub fn ends_turn(messages: &[Message]) -> bool { && !last.content.iter().any(|content| { matches!( content, - MessageContent::ToolRequest(_) - | MessageContent::FrontendToolRequest(_) - | MessageContent::ActionRequired(_) + MessageContent::ToolRequest(_) | MessageContent::ActionRequired(_) ) }) }) diff --git a/crates/goose-cli/src/scenario_tests/scenario_runner.rs b/crates/goose-cli/src/scenario_tests/scenario_runner.rs index 7b125cae7207..ec23b4fdc23b 100644 --- a/crates/goose-cli/src/scenario_tests/scenario_runner.rs +++ b/crates/goose-cli/src/scenario_tests/scenario_runner.rs @@ -225,7 +225,6 @@ where }, Arc::new(mock_client), None, - None, ) .await; diff --git a/crates/goose-context-management/src/format.rs b/crates/goose-context-management/src/format.rs index de333746fdf2..cda1f1d3fc23 100644 --- a/crates/goose-context-management/src/format.rs +++ b/crates/goose-context-management/src/format.rs @@ -57,13 +57,6 @@ pub fn format_message_for_compacting(msg: &Message) -> String { id )), }, - MessageContent::FrontendToolRequest(req) => { - if let Ok(call) = &req.tool_call { - Some(format!("frontend_tool_request: {}", call.name)) - } else { - Some("frontend_tool_request: [error]".to_string()) - } - } MessageContent::Thinking(_) => None, MessageContent::RedactedThinking(_) => None, MessageContent::SystemNotification(notification) => { diff --git a/crates/goose-provider-types/src/conversation.rs b/crates/goose-provider-types/src/conversation.rs index 1ba7cea67697..118c5b5ad5bb 100644 --- a/crates/goose-provider-types/src/conversation.rs +++ b/crates/goose-provider-types/src/conversation.rs @@ -476,13 +476,6 @@ fn fix_tool_calling(mut messages: Vec) -> (Vec, Vec) { resp.id )); } - MessageContentBlock::FrontendToolRequest(req) => { - content_to_remove.push(idx); - issues.push(format!( - "Removed frontend tool request '{}' from assistant message", - req.id - )); - } MessageContentBlock::ToolRequest(req) => { pending_tool_requests.insert(req.id.clone()); } diff --git a/crates/goose-provider-types/src/conversation/message.rs b/crates/goose-provider-types/src/conversation/message.rs index 9ad830336f9e..1d5a3cf75a11 100644 --- a/crates/goose-provider-types/src/conversation/message.rs +++ b/crates/goose-provider-types/src/conversation/message.rs @@ -233,14 +233,6 @@ pub struct RedactedThinkingContentBlock { pub data: String, } -#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct FrontendToolRequest { - pub id: String, - #[serde(with = "tool_result_serde")] - pub tool_call: ToolResult, -} - #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub enum SystemNotificationType { @@ -300,7 +292,6 @@ pub enum MessageContentBlock { ToolResponse(ToolResponse), ToolConfirmationRequest(ToolConfirmationRequest), ActionRequired(ActionRequired), - FrontendToolRequest(FrontendToolRequest), Thinking(ThinkingContentBlock), RedactedThinking(RedactedThinkingContentBlock), SystemNotification(SystemNotificationContent), @@ -340,10 +331,6 @@ impl fmt::Display for MessageContentBlock { write!(f, "[ActionRequired: ToolConfirmationResponse for {}]", id) } }, - MessageContentBlock::FrontendToolRequest(r) => match &r.tool_call { - Ok(tool_call) => write!(f, "[FrontendToolRequest: {}]", tool_call.name), - Err(e) => write!(f, "[FrontendToolRequest: Error: {}]", e), - }, MessageContentBlock::Thinking(t) => write!(f, "[Thinking: {}]", t.thinking), MessageContentBlock::RedactedThinking(_r) => write!(f, "[RedactedThinking]"), MessageContentBlock::SystemNotification(r) => { @@ -560,16 +547,6 @@ impl MessageContentBlock { MessageContentBlock::RedactedThinking(RedactedThinkingContentBlock { data: data.into() }) } - pub fn frontend_tool_request>( - id: S, - tool_call: ToolResult, - ) -> Self { - MessageContentBlock::FrontendToolRequest(FrontendToolRequest { - id: id.into(), - tool_call, - }) - } - pub fn system_notification>( notification_type: SystemNotificationType, msg: S, @@ -1102,14 +1079,6 @@ impl Message { )) } - pub fn with_frontend_tool_request>( - self, - id: S, - tool_call: ToolResult, - ) -> Self { - self.with_content(MessageContentBlock::frontend_tool_request(id, tool_call)) - } - /// Add thinking content to the message pub fn with_thinking, S2: Into>( self, diff --git a/crates/goose-provider-types/src/formats/anthropic.rs b/crates/goose-provider-types/src/formats/anthropic.rs index 1eaa064f0186..aec33e4d13ee 100644 --- a/crates/goose-provider-types/src/formats/anthropic.rs +++ b/crates/goose-provider-types/src/formats/anthropic.rs @@ -408,16 +408,6 @@ fn format_messages_with_options( MessageContentBlock::Image(image) => { content.push(convert_image(image, &ImageFormat::Anthropic)); } - MessageContentBlock::FrontendToolRequest(tool_request) => { - if let Ok(tool_call) = &tool_request.tool_call { - content.push(json!({ - TYPE_FIELD: TOOL_USE_TYPE, - ID_FIELD: tool_request.id, - NAME_FIELD: tool_call.name, - INPUT_FIELD: args_to_input_value(tool_call.arguments.clone()) - })); - } - } } } @@ -2102,23 +2092,6 @@ mod tests { assert_eq!(input, &json!({})); } - #[test] - fn test_parameterless_frontend_tool_request_serializes_input_as_empty_object() { - // Same regression as above, but exercises the FrontendToolRequest - // branch which is reached for UI-originated tool calls. - let messages = vec![Message::assistant().with_frontend_tool_request( - "frontend_tool_1", - Ok(CallToolRequestParams::new("list_things")), - )]; - - let spec = format_messages(&messages); - - let input = &spec[0]["content"][0]["input"]; - assert!(input.is_object(), "expected object, got {input:?}"); - assert!(!input.is_null()); - assert_eq!(input, &json!({})); - } - fn cfg(name: &str) -> ModelConfig { ModelConfig::new(name) } diff --git a/crates/goose-provider-types/src/formats/databricks.rs b/crates/goose-provider-types/src/formats/databricks.rs index 917ae7a2cd2c..d45b456abc93 100644 --- a/crates/goose-provider-types/src/formats/databricks.rs +++ b/crates/goose-provider-types/src/formats/databricks.rs @@ -244,17 +244,6 @@ fn format_messages( })); } } - MessageContentBlock::FrontendToolRequest(req) => { - let text = match &req.tool_call { - Ok(tool_call) => format!( - "Frontend tool request: {} ({})", - tool_call.name, - serde_json::to_string_pretty(&tool_call.arguments).unwrap() - ), - Err(e) => format!("Frontend tool request error: {}", e), - }; - content_array.push(json!({"type": "text", "text": text})); - } MessageContentBlock::SystemNotification(_) | MessageContentBlock::Error(_) | MessageContentBlock::ToolConfirmationRequest(_) diff --git a/crates/goose-provider-types/src/formats/openai.rs b/crates/goose-provider-types/src/formats/openai.rs index 5ee9b23b9075..7233a96e4f27 100644 --- a/crates/goose-provider-types/src/formats/openai.rs +++ b/crates/goose-provider-types/src/formats/openai.rs @@ -426,39 +426,6 @@ pub fn format_messages_with_options( })); } } - MessageContentBlock::FrontendToolRequest(request) => match &request.tool_call { - Ok(tool_call) => { - let sanitized_name = sanitize_function_name(&tool_call.name); - let arguments_str = match &tool_call.arguments { - Some(args) => { - serde_json::to_string(args).unwrap_or_else(|_| "{}".to_string()) - } - None => "{}".to_string(), - }; - - let tool_calls = converted - .as_object_mut() - .unwrap() - .entry("tool_calls") - .or_insert(json!([])); - - tool_calls.as_array_mut().unwrap().push(json!({ - "id": request.id, - "type": "function", - "function": { - "name": sanitized_name, - "arguments": arguments_str, - } - })); - } - Err(e) => { - output.push(json!({ - "role": "tool", - "content": format!("Error: {}", e), - "tool_call_id": request.id - })); - } - }, } } @@ -3008,58 +2975,6 @@ mod tests { Ok(()) } - #[test] - fn test_format_messages_frontend_tool_request_with_none_arguments() -> anyhow::Result<()> { - // Test that FrontendToolRequest with None arguments are formatted as "{}" string - let message = Message::assistant().with_frontend_tool_request( - "frontend_tool1", - Ok(CallToolRequestParams::new("frontend_test_tool")), - ); - - let spec = format_messages(&[message], &ImageFormat::OpenAi); - - assert_eq!(spec.len(), 1); - assert_eq!(spec[0]["role"], "assistant"); - assert!(spec[0]["tool_calls"].is_array()); - - let tool_call = &spec[0]["tool_calls"][0]; - assert_eq!(tool_call["id"], "frontend_tool1"); - assert_eq!(tool_call["type"], "function"); - assert_eq!(tool_call["function"]["name"], "frontend_test_tool"); - // This should be the string "{}", not null - assert_eq!(tool_call["function"]["arguments"], "{}"); - - Ok(()) - } - - #[test] - fn test_format_messages_frontend_tool_request_with_some_arguments() -> anyhow::Result<()> { - // Test that FrontendToolRequest with Some arguments are properly JSON-serialized - let message = Message::assistant().with_frontend_tool_request( - "frontend_tool1", - Ok(CallToolRequestParams::new("frontend_test_tool") - .with_arguments(object!({"action": "click", "element": "button"}))), - ); - - let spec = format_messages(&[message], &ImageFormat::OpenAi); - - assert_eq!(spec.len(), 1); - assert_eq!(spec[0]["role"], "assistant"); - assert!(spec[0]["tool_calls"].is_array()); - - let tool_call = &spec[0]["tool_calls"][0]; - assert_eq!(tool_call["id"], "frontend_tool1"); - assert_eq!(tool_call["type"], "function"); - assert_eq!(tool_call["function"]["name"], "frontend_test_tool"); - // This should be a JSON string representation - let args_str = tool_call["function"]["arguments"].as_str().unwrap(); - let parsed_args: Value = serde_json::from_str(args_str)?; - assert_eq!(parsed_args["action"], "click"); - assert_eq!(parsed_args["element"], "button"); - - Ok(()) - } - #[test] fn test_format_messages_multiple_text_blocks() -> anyhow::Result<()> { let message = Message::user() diff --git a/crates/goose-provider-types/src/formats/openai_responses.rs b/crates/goose-provider-types/src/formats/openai_responses.rs index e5e1a877b344..821c8f7cb71b 100644 --- a/crates/goose-provider-types/src/formats/openai_responses.rs +++ b/crates/goose-provider-types/src/formats/openai_responses.rs @@ -568,43 +568,6 @@ fn add_message_items(input_items: &mut Vec, messages: &[Message], support } } } - MessageContentBlock::FrontendToolRequest(request) => { - if !text_items.is_empty() { - input_items.push(json!({ - "type": "message", - "role": role, - "content": text_items - })); - text_items = Vec::new(); - } - - match &request.tool_call { - Ok(tool_call) => { - let sanitized_name = sanitize_function_name(&tool_call.name); - let arguments_str = tool_call - .arguments - .as_ref() - .map(|args| { - serde_json::to_string(args).unwrap_or_else(|_| "{}".to_string()) - }) - .unwrap_or_else(|| "{}".to_string()); - - input_items.push(json!({ - "type": "function_call", - "call_id": request.id, - "name": sanitized_name, - "arguments": arguments_str - })); - } - Err(e) => { - input_items.push(json!({ - "type": "function_call_output", - "call_id": request.id, - "output": format!("Error: {}", e.message) - })); - } - } - } _ => {} } } @@ -2956,48 +2919,6 @@ mod tests { Ok(()) } - #[test] - fn test_frontend_tool_request_serialized_in_responses_request() { - use crate::conversation::message::Message; - use rmcp::model::{CallToolResult, ContentBlock}; - - let messages = vec![ - Message::assistant().with_frontend_tool_request( - "call_ft1", - Ok(CallToolRequestParams::new("browser_click") - .with_arguments(object!({"selector": "#btn"}))), - ), - Message::user().with_content(MessageContentBlock::tool_response( - "call_ft1", - Ok(CallToolResult::success(vec![ContentBlock::text("clicked")])), - )), - ]; - - let model_config = ModelConfig { - model_name: "gpt-5.5".to_string(), - context_limit: None, - temperature: None, - max_tokens: None, - toolshim: false, - toolshim_model: None, - request_params: None, - reasoning: None, - supports_vision: None, - request_headers: None, - }; - - let result = create_responses_request(&model_config, "", &messages, &[]).unwrap(); - let input = result["input"].as_array().unwrap(); - - assert_eq!(input[0]["type"], "function_call"); - assert_eq!(input[0]["call_id"], "call_ft1"); - assert_eq!(input[0]["name"], "browser_click"); - - assert_eq!(input[1]["type"], "function_call_output"); - assert_eq!(input[1]["call_id"], "call_ft1"); - assert_eq!(input[1]["output"], "clicked"); - } - #[test] fn test_responses_request_sanitizes_replayed_function_call_names() { use crate::conversation::message::Message; @@ -3008,8 +2929,8 @@ mod tests { Ok(CallToolRequestParams::new("Crack Catcher") .with_arguments(object!({"prompt": "verify the work"}))), ), - Message::assistant().with_frontend_tool_request( - "call_frontend_agent", + Message::assistant().with_tool_request( + "call_review_agent", Ok(CallToolRequestParams::new("@Review Agent") .with_arguments(object!({"prompt": "check it"}))), ), @@ -3036,7 +2957,7 @@ mod tests { assert_eq!(input[0]["name"], "Crack_Catcher"); assert_eq!(input[1]["type"], "function_call"); - assert_eq!(input[1]["call_id"], "call_frontend_agent"); + assert_eq!(input[1]["call_id"], "call_review_agent"); assert_eq!(input[1]["name"], "_Review_Agent"); } @@ -3105,43 +3026,4 @@ mod tests { .unwrap() .contains("invalid arguments")); } - - #[test] - fn test_frontend_tool_request_error_emits_function_call_output() { - use crate::conversation::message::Message; - use rmcp::model::{ErrorCode, ErrorData}; - - let messages = vec![Message::assistant().with_frontend_tool_request( - "call_ft_err", - Err(ErrorData { - code: ErrorCode::INTERNAL_ERROR, - message: "malformed arguments".into(), - data: None, - }), - )]; - - let model_config = ModelConfig { - model_name: "gpt-5.5".to_string(), - context_limit: None, - temperature: None, - max_tokens: None, - toolshim: false, - toolshim_model: None, - request_params: None, - reasoning: None, - supports_vision: None, - request_headers: None, - }; - - let result = create_responses_request(&model_config, "", &messages, &[]).unwrap(); - let input = result["input"].as_array().unwrap(); - - assert_eq!(input.len(), 1); - assert_eq!(input[0]["type"], "function_call_output"); - assert_eq!(input[0]["call_id"], "call_ft_err"); - assert!(input[0]["output"] - .as_str() - .unwrap() - .contains("malformed arguments")); - } } diff --git a/crates/goose-provider-types/src/formats/snowflake.rs b/crates/goose-provider-types/src/formats/snowflake.rs index 348f35d5af5b..5913a8c17dd5 100644 --- a/crates/goose-provider-types/src/formats/snowflake.rs +++ b/crates/goose-provider-types/src/formats/snowflake.rs @@ -74,9 +74,6 @@ pub fn format_messages(messages: &[Message]) -> Vec { // Skip redacted thinking for now } MessageContentBlock::Image(_) => continue, // Snowflake doesn't support image content yet - MessageContentBlock::FrontendToolRequest(_tool_request) => { - // Skip frontend tool requests - } } } diff --git a/crates/goose/src/acp/provider.rs b/crates/goose/src/acp/provider.rs index f6e14498ac64..43fd24ec5ae5 100644 --- a/crates/goose/src/acp/provider.rs +++ b/crates/goose/src/acp/provider.rs @@ -1875,9 +1875,6 @@ pub fn extension_configs_to_mcp_servers(configs: &[ExtensionConfig]) -> Vec { - tracing::debug!(name, "skipping SSE extension, migrate to streamable_http"); - } _ => {} } } @@ -4276,17 +4273,6 @@ mod tests { } } - #[test] - fn test_sse_skips() { - let config = ExtensionConfig::Sse { - name: "test-sse".into(), - description: String::new(), - uri: Some("https://example.com/sse".into()), - }; - let result = extension_configs_to_mcp_servers(&[config]); - assert!(result.is_empty()); - } - #[test] fn test_filter_supported_servers_skips_http_without_capability() { let config = ExtensionConfig::StreamableHttp { diff --git a/crates/goose/src/acp/server.rs b/crates/goose/src/acp/server.rs index a789c5ade872..7e0338e3a642 100644 --- a/crates/goose/src/acp/server.rs +++ b/crates/goose/src/acp/server.rs @@ -1120,7 +1120,7 @@ impl GooseAcpAgent { agent .extension_manager - .add_client("developer".into(), developer_config, client, info, None) + .add_client("developer".into(), developer_config, client, info) .await; } diff --git a/crates/goose/src/acp/server/extensions.rs b/crates/goose/src/acp/server/extensions.rs index 423b540acf7c..56eb9449b5a2 100644 --- a/crates/goose/src/acp/server/extensions.rs +++ b/crates/goose/src/acp/server/extensions.rs @@ -231,9 +231,6 @@ fn config_to_goose_extension( available_tools: available_tools_to_wire(available_tools), } } - ExtensionConfig::Frontend { .. } - | ExtensionConfig::InlinePython { .. } - | ExtensionConfig::Sse { .. } => return Ok(None), }; Ok(Some(extension)) } @@ -630,64 +627,6 @@ mod tests { assert_eq!(http.headers[0].value, "Bearer ${API_TOKEN}"); } - #[test] - fn inline_python_config_is_skipped() { - let config = ExtensionConfig::InlinePython { - name: "python-tools".to_string(), - description: "Python tools".to_string(), - code: "print('hello')".to_string(), - timeout: Some(12), - dependencies: Some(vec!["requests".to_string()]), - available_tools: vec!["fetch".to_string()], - }; - - let extension = config_to_goose_extension(&config).expect("conversion should succeed"); - - assert!(extension.is_none()); - } - - #[test] - fn frontend_config_is_skipped() { - let tool = rmcp::model::Tool::new( - "pick_color", - "Pick a color", - serde_json::json!({ - "type": "object", - "properties": { - "hex": { "type": "string" } - } - }) - .as_object() - .expect("schema should be object") - .clone(), - ); - let config = ExtensionConfig::Frontend { - name: "frontend-tools".to_string(), - description: "Frontend tools".to_string(), - tools: vec![tool], - instructions: Some("Use frontend tools carefully".to_string()), - bundled: None, - available_tools: vec!["pick_color".to_string()], - }; - - let extension = config_to_goose_extension(&config).expect("conversion should succeed"); - - assert!(extension.is_none()); - } - - #[test] - fn sse_config_is_skipped() { - let config = ExtensionConfig::Sse { - name: "legacy-sse".to_string(), - description: "Legacy SSE".to_string(), - uri: Some("https://example.com/sse".to_string()), - }; - - let extension = config_to_goose_extension(&config).expect("conversion should succeed"); - - assert!(extension.is_none()); - } - #[test] fn goose_mcp_stdio_extension_converts_to_config_without_literal_envs() { let extension = GooseExtension::Mcp { diff --git a/crates/goose/src/acp/server/recipe/conversions.rs b/crates/goose/src/acp/server/recipe/conversions.rs index 82b1835292f2..1585177ec2a5 100644 --- a/crates/goose/src/acp/server/recipe/conversions.rs +++ b/crates/goose/src/acp/server/recipe/conversions.rs @@ -1,4 +1,4 @@ -use anyhow::{bail, Result}; +use anyhow::Result; use goose_sdk_types::custom_requests::{ RecipeAuthorDto, RecipeDto, RecipeExtensionDto, RecipeParameterDto, RecipeParameterInputTypeDto, RecipeParameterRequirementDto, RecipeResponseDto, @@ -453,17 +453,10 @@ impl TryFrom for RecipeExtensionDto { bundled, available_tools: available_tools_to_wire(available_tools), }, - ExtensionConfig::Sse { .. } => bail_unsupported_extension("sse")?, - ExtensionConfig::Frontend { .. } => bail_unsupported_extension("frontend")?, - ExtensionConfig::InlinePython { .. } => bail_unsupported_extension("inline_python")?, }) } } -fn bail_unsupported_extension(extension_type: &str) -> Result { - bail!("recipe extension type `{extension_type}` is not supported by RecipeDto") -} - fn available_tools_to_wire(available_tools: Vec) -> Option> { if available_tools.is_empty() { None @@ -640,34 +633,4 @@ mod tests { assert_eq!(serialized["extensions"][2]["envs"]["REMOTE_MODE"], "true"); assert_eq!(serialized["extensions"][2]["available_tools"][0], "fetch"); } - - #[test] - fn recipe_dto_rejects_unsupported_internal_extension_variants() { - let recipe = Recipe { - version: "1.0.0".to_string(), - title: "Unsupported Extension".to_string(), - description: "Uses an unsupported recipe extension".to_string(), - instructions: Some("Run".to_string()), - prompt: None, - extensions: Some(vec![ExtensionConfig::InlinePython { - name: "inline".to_string(), - description: "Inline Python".to_string(), - code: "print('hello')".to_string(), - timeout: Some(30), - dependencies: None, - available_tools: Vec::new(), - }]), - settings: None, - activities: None, - author: None, - parameters: None, - response: None, - sub_recipes: None, - retry: None, - }; - - let err = RecipeDto::try_from(recipe).unwrap_err().to_string(); - assert!(err.contains("inline_python")); - assert!(err.contains("not supported")); - } } diff --git a/crates/goose/src/agents/agent.rs b/crates/goose/src/agents/agent.rs index f8b31069d2b8..467c815c5677 100644 --- a/crates/goose/src/agents/agent.rs +++ b/crates/goose/src/agents/agent.rs @@ -38,8 +38,8 @@ use crate::agents::state_machine::{ ToolExecutionOperation, ToolPairCompactionOperation, UnknownToolOperation, MAX_TURNS_MESSAGE, }; use crate::agents::types::{ - FrontendTool, SessionConfig, SharedProvider, ToolResultReceiver, - DEFAULT_ON_FAILURE_TIMEOUT_SECONDS, DEFAULT_RETRY_TIMEOUT_SECONDS, + SessionConfig, SharedProvider, DEFAULT_ON_FAILURE_TIMEOUT_SECONDS, + DEFAULT_RETRY_TIMEOUT_SECONDS, }; use crate::agents::AgentEvent; use crate::config::extensions::name_to_key; @@ -50,12 +50,11 @@ use crate::context_mgmt::{ }; use crate::conversation::message::{ ActionRequiredData, InferenceMetadata, Message, MessageContent, MessageUsage, ProviderMetadata, - SystemNotificationType, ToolRequest, + SystemNotificationType, }; use crate::conversation::{ debug_conversation_fix, fix_conversation, merge_consecutive_messages_for_request, Conversation, }; -use crate::mcp_utils::ToolResult; use crate::permission::permission_inspector::PermissionInspector; use crate::permission::permission_judge::PermissionCheckResult; use crate::permission::PermissionConfirmation; @@ -89,7 +88,6 @@ const COMPACTION_PROGRESS_TEXT: &str = "goose is compacting the conversation..." const MAX_EMPTY_TURN_RETRIES: u32 = 3; const EMPTY_TURN_MESSAGE: &str = "The model returned an empty response. Please resend your message to continue."; -const DEFAULT_FRONTEND_INSTRUCTIONS: &str = "The following tools are provided directly by the frontend and will be executed by the frontend when called."; fn provider_creation_error(error: anyhow::Error, context: impl fmt::Display) -> anyhow::Error { let message = format!("{context}: {error}"); @@ -189,12 +187,6 @@ pub struct ReplyContext { pub model_config: goose_providers::model::ModelConfig, } -pub struct ToolCategorizeResult { - pub frontend_requests: Vec, - pub remaining_requests: Vec, - pub filtered_response: Message, -} - #[derive(Debug, Clone, serde::Serialize)] pub struct ExtensionLoadResult { pub name: String, @@ -294,13 +286,8 @@ pub struct Agent { pub extension_manager: Arc, pub(super) final_output_tool: Arc>>, - pub(super) frontend_extensions: Mutex>, - pub(super) frontend_tools: Mutex>, - pub(super) frontend_instructions: Mutex>, pub(super) prompt_manager: Mutex, pub tool_confirmation_router: ToolConfirmationRouter, - pub(super) tool_result_tx: mpsc::Sender<(String, ToolResult)>, - pub(super) tool_result_rx: ToolResultReceiver, pub(super) retry_manager: RetryManager, pub(super) tool_inspection_manager: ToolInspectionManager, @@ -418,7 +405,6 @@ impl Agent { } pub fn with_config(config: AgentConfig) -> Self { - let (tool_tx, tool_rx) = mpsc::channel(32); let provider = Arc::new(Mutex::new(None)); let goose_platform = config.goose_platform.clone(); @@ -461,13 +447,8 @@ impl Agent { use_login_shell_path, )), final_output_tool: Arc::new(Mutex::new(None)), - frontend_extensions: Mutex::new(HashMap::new()), - frontend_tools: Mutex::new(HashMap::new()), - frontend_instructions: Mutex::new(None), prompt_manager: Mutex::new(PromptManager::new()), tool_confirmation_router: ToolConfirmationRouter::new(), - tool_result_tx: tool_tx, - tool_result_rx: Arc::new(Mutex::new(tool_rx)), retry_manager: RetryManager::new(), tool_inspection_manager: Self::create_tool_inspection_manager( permission_manager, @@ -919,25 +900,6 @@ impl Agent { }) } - async fn categorize_tools( - &self, - response: &Message, - tools: &[rmcp::model::Tool], - toolshim_tools: &[rmcp::model::Tool], - suppress_replayed_thinking: bool, - ) -> ToolCategorizeResult { - // Categorize tool requests - let (frontend_requests, remaining_requests, filtered_response) = self - .categorize_tool_requests(response, tools, toolshim_tools, suppress_replayed_thinking) - .await; - - ToolCategorizeResult { - frontend_requests, - remaining_requests, - filtered_response, - } - } - async fn handle_approved_and_denied_tools( &self, permission_check_result: &PermissionCheckResult, @@ -1051,112 +1013,6 @@ impl Agent { self.container.lock().await.clone() } - /// Check if a tool is a frontend tool - pub async fn is_frontend_tool(&self, name: &str) -> bool { - self.frontend_tools.lock().await.contains_key(name) - } - - /// Get a reference to a frontend tool - pub async fn get_frontend_tool(&self, name: &str) -> Option { - self.frontend_tools.lock().await.get(name).cloned() - } - - async fn frontend_extension_configs(&self) -> Vec { - let mut configs = self - .frontend_extensions - .lock() - .await - .values() - .cloned() - .collect::>(); - configs.sort_by_key(|config| config.key()); - configs - } - - async fn frontend_tools_for_extension(&self, extension_name: Option<&str>) -> Vec { - let requested_extension = extension_name.map(name_to_key); - - self.frontend_extension_configs() - .await - .into_iter() - .filter_map(|config| { - let include = requested_extension - .as_ref() - .is_none_or(|name| *name == config.key()); - - match config { - ExtensionConfig::Frontend { tools, .. } if include => Some(tools), - _ => None, - } - }) - .flatten() - .collect() - } - - async fn rebuild_frontend_derived_state(&self, extensions: &HashMap) { - let multiple = extensions.len() > 1; - let mut tools = HashMap::new(); - let mut instructions = Vec::new(); - - for config in extensions.values() { - if let ExtensionConfig::Frontend { - name, - tools: ext_tools, - instructions: ext_instructions, - .. - } = config - { - for tool in ext_tools { - let tool_name = tool.name.to_string(); - tools.insert( - tool_name.clone(), - FrontendTool { - name: tool_name, - tool: tool.clone(), - }, - ); - } - - let text = ext_instructions - .clone() - .unwrap_or_else(|| DEFAULT_FRONTEND_INSTRUCTIONS.to_string()); - instructions.push(if multiple { - format!("{name}: {text}") - } else { - text - }); - } - } - - *self.frontend_tools.lock().await = tools; - *self.frontend_instructions.lock().await = if instructions.is_empty() { - None - } else { - Some(instructions.join("\n\n")) - }; - } - - async fn insert_frontend_extension(&self, extension: ExtensionConfig) { - let mut extensions = self.frontend_extensions.lock().await; - extensions.insert(extension.key(), extension); - self.rebuild_frontend_derived_state(&extensions).await; - } - - async fn remove_frontend_extension_by_key(&self, key: &str) -> bool { - let mut extensions = self.frontend_extensions.lock().await; - let removed = extensions.remove(key).is_some(); - if removed { - self.rebuild_frontend_derived_state(&extensions).await; - } - removed - } - - async fn extension_configs_for_persistence(&self) -> Vec { - let mut extension_configs = self.extension_manager.get_extension_configs().await; - extension_configs.extend(self.frontend_extension_configs().await); - extension_configs - } - pub async fn add_final_output_tool(&self, response: Response) -> Result<()> { let mut final_output_tool = self.final_output_tool.lock().await; let created_final_output_tool = @@ -1297,30 +1153,22 @@ impl Agent { ); debug!("WAITING_TOOL_START: {}", tool_call.name); - let result: ToolCallResult = if self.is_frontend_tool(&tool_call.name).await { - ToolCallResult::from(Err(ErrorData::new( - ErrorCode::INTERNAL_ERROR, - "Frontend tool execution required".to_string(), - None, - ))) - } else { - let result = self - .extension_manager - .dispatch_tool_call( - &ctx, - tool_call.clone(), - cancellation_token.unwrap_or_default(), - ) - .await; - result.unwrap_or_else(|error_data| { - #[cfg(feature = "telemetry")] - crate::posthog::emit_error( - "tool_execution_failed", - &format!("{}: {}", tool_call.name, error_data), - ); - ToolCallResult::from(Err(error_data)) - }) - }; + let result = self + .extension_manager + .dispatch_tool_call( + &ctx, + tool_call.clone(), + cancellation_token.unwrap_or_default(), + ) + .await; + let result = result.unwrap_or_else(|error_data| { + #[cfg(feature = "telemetry")] + crate::posthog::emit_error( + "tool_execution_failed", + &format!("{}: {}", tool_call.name, error_data), + ); + ToolCallResult::from(Err(error_data)) + }); debug!("WAITING_TOOL_END: {}", tool_call.name); @@ -1332,7 +1180,7 @@ impl Agent { /// Should be called after any extension add/remove operation pub async fn save_extension_state(&self, session: &SessionConfig) -> Result<()> { let extensions_state = - EnabledExtensionsState::new(self.extension_configs_for_persistence().await); + EnabledExtensionsState::new(self.extension_manager.get_extension_configs().await); let session_manager = self.config.session_manager.clone(); let mut session_data = session_manager.get_session(&session.id, false).await?; @@ -1353,8 +1201,11 @@ impl Agent { /// Save current extension state to session by session_id pub async fn persist_extension_state(&self, session_id: &str) -> Result<()> { - self.persist_extension_configs(session_id, self.extension_configs_for_persistence().await) - .await + self.persist_extension_configs( + session_id, + self.extension_manager.get_extension_configs().await, + ) + .await } /// Save the provided extension configuration to session metadata. @@ -1528,41 +1379,30 @@ impl Agent { .into_iter() .map(|config| { let ext_manager = Arc::clone(&self.extension_manager); - let agent = Arc::clone(self); let working_dir = working_dir.clone(); let container = container.clone(); let sid = session_id.to_string(); async move { let name = config.name().to_string(); - match &config { - ExtensionConfig::Frontend { .. } => { - agent.insert_frontend_extension(config.clone()).await; + match ext_manager + .add_extension(config, working_dir, container.as_ref(), Some(&sid)) + .await + { + Ok(_) => ExtensionLoadResult { + name, + success: true, + error: None, + }, + Err(e) => { + let error = e.to_string(); + warn!("Failed to load extension {}: {}", name, error); ExtensionLoadResult { name, - success: true, - error: None, + success: false, + error: Some(error), } } - _ => match ext_manager - .add_extension(config, working_dir, container.as_ref(), Some(&sid)) - .await - { - Ok(_) => ExtensionLoadResult { - name, - success: true, - error: None, - }, - Err(e) => { - let error = e.to_string(); - warn!("Failed to load extension {}: {}", name, error); - ExtensionLoadResult { - name, - success: false, - error: Some(error), - } - } - }, } } }) @@ -1593,39 +1433,23 @@ impl Agent { })?; let working_dir = Some(session.working_dir); - match &extension { - ExtensionConfig::Frontend { .. } => { - self.insert_frontend_extension(extension.clone()).await; - } - _ => { - let container = self.container.lock().await; - self.extension_manager - .add_extension( - extension.clone(), - working_dir, - container.as_ref(), - Some(session_id), - ) - .await?; - } - } + let container = self.container.lock().await; + self.extension_manager + .add_extension(extension, working_dir, container.as_ref(), Some(session_id)) + .await?; Ok(()) } pub async fn list_tools(&self, session_id: &str, extension_name: Option) -> Vec { + let include_final_output = extension_name.is_none(); let mut prefixed_tools = self .extension_manager - .get_prefixed_tools(session_id, extension_name.clone()) + .get_prefixed_tools(session_id, extension_name) .await .unwrap_or_default(); - prefixed_tools.extend( - self.frontend_tools_for_extension(extension_name.as_deref()) - .await, - ); - - if extension_name.is_none() { + if include_final_output { if let Some(final_output_tool) = self.final_output_tool.lock().await.as_ref() { prefixed_tools.push(final_output_tool.tool()); } @@ -1655,7 +1479,6 @@ impl Agent { } self.extension_manager.remove_extension_by_key(key).await?; - self.remove_frontend_extension_by_key(key).await; // Persist extension state after successful removal self.persist_extension_state(session_id) @@ -1669,22 +1492,14 @@ impl Agent { } pub async fn list_extensions(&self) -> Vec { - let mut extensions = self - .extension_manager + self.extension_manager .list_extensions() .await - .expect("Failed to list extensions"); - extensions.extend( - self.frontend_extension_configs() - .await - .into_iter() - .map(|config| config.name()), - ); - extensions + .expect("Failed to list extensions") } pub async fn get_extension_configs(&self) -> Vec { - self.extension_configs_for_persistence().await + self.extension_manager.get_extension_configs().await } /// Handle a confirmation response for a tool request @@ -1817,7 +1632,6 @@ impl Agent { goose_mode: &self.current_goose_mode, prompt_manager: &self.prompt_manager, tool_inspection_manager: &self.tool_inspection_manager, - frontend_instructions: &self.frontend_instructions, context_limit, }; let status_operation = @@ -2707,18 +2521,13 @@ impl Agent { } }); - let ToolCategorizeResult { - frontend_requests, - remaining_requests, - filtered_response, - } = self - .categorize_tools( + let (tool_requests, filtered_response) = self + .categorize_tool_requests( &response, &tools, &toolshim_tools, surfaced_thinking_in_turn, - ) - .await; + ); let filtered_response = if let Some(inference) = inference.as_ref() { filtered_response.with_inference(inference.clone()) @@ -2748,8 +2557,7 @@ impl Agent { tokio::task::yield_now().await; } - let num_tool_requests = frontend_requests.len() + remaining_requests.len(); - if num_tool_requests == 0 { + if tool_requests.is_empty() { let text = if response.is_user_visible() { filtered_response .user_visible_content() @@ -2766,25 +2574,13 @@ impl Agent { let mut request_to_response_map = HashMap::new(); let mut request_metadata: HashMap> = HashMap::new(); - for request in frontend_requests.iter().chain(remaining_requests.iter()) { + for request in &tool_requests { request_to_response_map.insert(request.id.clone(), Message::user().with_generated_id()); request_metadata.insert(request.id.clone(), request.metadata.clone()); } - for request in frontend_requests.iter() { - let response_msg = request_to_response_map.get_mut(&request.id) - .ok_or_else(|| anyhow::anyhow!("missing response entry for request {}", request.id))?; - let mut frontend_tool_stream = self.handle_frontend_tool_request( - request, - response_msg, - ); - - while let Some(msg) = frontend_tool_stream.try_next().await? { - yield AgentEvent::Message(msg); - } - } if goose_mode == GooseMode::Chat { - for request in remaining_requests.iter() { + for request in &tool_requests { // An unparseable tool call should surface the parse error // (added in the Err branch below), not a successful skip — // otherwise the model sees a malformed call as "skipped OK" @@ -2805,7 +2601,7 @@ impl Agent { let inspection_results = self.tool_inspection_manager .inspect_tools( &session_config.id, - &remaining_requests, + &tool_requests, conversation.messages(), goose_mode, ) @@ -2813,7 +2609,7 @@ impl Agent { let permission_check_result = self.tool_inspection_manager .process_inspection_results_with_permission_inspector( - &remaining_requests, + &tool_requests, &inspection_results, ) .unwrap_or_else(|| { @@ -2822,13 +2618,13 @@ impl Agent { needs_approval: vec![], denied: vec![], }; - result.needs_approval.extend(remaining_requests.iter().cloned()); + result.needs_approval.extend(tool_requests.iter().cloned()); result }); // Track extension requests let mut enable_extension_request_ids = vec![]; - for request in &remaining_requests { + for request in &tool_requests { if let Ok(tool_call) = &request.tool_call { if tool_call.name == MANAGE_EXTENSIONS_TOOL_NAME_COMPLETE { enable_extension_request_ids.push(request.id.clone()); @@ -3060,15 +2856,14 @@ impl Agent { let carrier_tool_call_id = if has_existing_message_id_carrier { None } else { - remaining_requests + tool_requests .first() - .or_else(|| frontend_requests.first()) .map(|request| request.id.as_str()) }; preferred_turn_usage_message_id = Some(response_message_id.to_owned()); - for request in frontend_requests.iter().chain(remaining_requests.iter()) { + for request in &tool_requests { let mut request_msg = if carrier_tool_call_id == Some(request.id.as_str()) { Message::assistant().with_id(response_message_id) @@ -3942,7 +3737,7 @@ impl Agent { .extension_manager .get_prefixed_tools(session_id, None) .await?; - let tools_info = tools + let tools_info: Vec<_> = tools .into_iter() .map(|tool| { ToolInfo::new( @@ -3957,15 +3752,10 @@ impl Agent { }) .collect(); - let plan_prompt = self.extension_manager.get_planning_prompt(tools_info).await; - - Ok(plan_prompt) - } - - pub async fn handle_tool_result(&self, id: String, result: ToolResult) { - if let Err(e) = self.tool_result_tx.send((id, result)).await { - error!("Failed to send tool result: {}", e); - } + let context = HashMap::from([("tools", serde_json::to_value(tools_info)?)]); + Ok(crate::prompt_template::render_template( + "plan.md", &context, + )?) } pub async fn create_recipe( @@ -3995,7 +3785,6 @@ impl Agent { let system_prompt = prompt_manager .builder() .with_extensions(extensions_info.into_iter()) - .with_frontend_instructions(self.frontend_instructions.lock().await.clone()) .with_goose_mode(goose_mode) .build(); diff --git a/crates/goose/src/agents/extension.rs b/crates/goose/src/agents/extension.rs index b9c1aa5110a7..809b470acfc6 100644 --- a/crates/goose/src/agents/extension.rs +++ b/crates/goose/src/agents/extension.rs @@ -4,7 +4,6 @@ use crate::config; use crate::config::extensions::name_to_key; use crate::config::permission::PermissionLevel; use crate::config::Config; -use rmcp::model::Tool; use rmcp::service::ClientInitializeError; use rmcp::ServiceError as ClientError; use serde::Deserializer; @@ -160,17 +159,6 @@ impl Envs { #[derive(Debug, Clone, Deserialize, Serialize, PartialEq)] #[serde(tag = "type")] pub enum ExtensionConfig { - /// SSE transport is no longer supported - kept only for config file compatibility - #[serde(rename = "sse")] - Sse { - #[serde(default)] - name: String, - #[serde(default)] - #[serde(deserialize_with = "deserialize_null_with_default")] - description: String, - #[serde(default)] - uri: Option, - }, /// Standard I/O client with command and arguments #[serde(rename = "stdio")] Stdio { @@ -275,43 +263,6 @@ pub enum ExtensionConfig { #[serde(skip_serializing_if = "Vec::is_empty")] available_tools: Vec, }, - /// Frontend-provided tools that will be called through the frontend - #[serde(rename = "frontend")] - Frontend { - /// The name used to identify this extension - name: String, - #[serde(default)] - #[serde(deserialize_with = "deserialize_null_with_default")] - description: String, - /// The tools provided by the frontend - tools: Vec, - /// Instructions for how to use these tools - instructions: Option, - #[serde(default)] - bundled: Option, - #[serde(default)] - #[serde(skip_serializing_if = "Vec::is_empty")] - available_tools: Vec, - }, - /// Inline Python code that will be executed using uvx - #[serde(rename = "inline_python")] - InlinePython { - /// The name used to identify this extension - name: String, - #[serde(default)] - #[serde(deserialize_with = "deserialize_null_with_default")] - description: String, - /// The Python code to execute - code: String, - /// Timeout in seconds - timeout: Option, - /// Python package dependencies required by this extension - #[serde(default)] - dependencies: Option>, - #[serde(default)] - #[serde(skip_serializing_if = "Vec::is_empty")] - available_tools: Vec, - }, } impl Default for ExtensionConfig { @@ -371,22 +322,6 @@ impl ExtensionConfig { } } - pub fn inline_python, T: Into>( - name: S, - code: S, - description: S, - timeout: T, - ) -> Self { - Self::InlinePython { - name: name.into(), - code: code.into(), - description: description.into(), - timeout: Some(timeout.into()), - dependencies: None, - available_tools: Vec::new(), - } - } - pub fn with_args(self, args: I) -> Self where I: IntoIterator, @@ -426,13 +361,10 @@ impl ExtensionConfig { pub fn name(&self) -> String { match self { - Self::Sse { name, .. } => name, Self::StreamableHttp { name, .. } => name, Self::Stdio { name, .. } => name, Self::Builtin { name, .. } => name, Self::Platform { name, .. } => name, - Self::Frontend { name, .. } => name, - Self::InlinePython { name, .. } => name, } .to_string() } @@ -440,7 +372,6 @@ impl ExtensionConfig { /// Check if a tool should be available to the LLM pub fn is_tool_available(&self, tool_name: &str) -> bool { let available_tools = match self { - Self::Sse { .. } => return false, // SSE is unsupported Self::StreamableHttp { available_tools, .. } @@ -452,12 +383,6 @@ impl ExtensionConfig { } | Self::Platform { available_tools, .. - } - | Self::InlinePython { - available_tools, .. - } - | Self::Frontend { - available_tools, .. } => available_tools, }; @@ -554,9 +479,6 @@ impl ExtensionConfig { impl std::fmt::Display for ExtensionConfig { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { match self { - ExtensionConfig::Sse { name, .. } => { - write!(f, "SSE({}: unsupported)", name) - } ExtensionConfig::StreamableHttp { name, uri, socket, .. } => { @@ -573,12 +495,6 @@ impl std::fmt::Display for ExtensionConfig { } ExtensionConfig::Builtin { name, .. } => write!(f, "Builtin({})", name), ExtensionConfig::Platform { name, .. } => write!(f, "Platform({})", name), - ExtensionConfig::Frontend { name, tools, .. } => { - write!(f, "Frontend({}: {} tools)", name, tools.len()) - } - ExtensionConfig::InlinePython { name, code, .. } => { - write!(f, "InlinePython({}: {} chars)", name, code.len()) - } } } } diff --git a/crates/goose/src/agents/extension_manager.rs b/crates/goose/src/agents/extension_manager.rs index d3d596cf64d3..daeaa8f17b5c 100644 --- a/crates/goose/src/agents/extension_manager.rs +++ b/crates/goose/src/agents/extension_manager.rs @@ -17,7 +17,8 @@ use std::sync::atomic::{AtomicU64, Ordering}; use std::sync::{Arc, Weak}; use std::task::{Context, Poll}; use std::time::Duration; -use tempfile::{tempdir, TempDir}; +#[cfg(test)] +use tempfile::tempdir; use tokio::io::AsyncReadExt; use tokio::process::Command; use tokio::sync::{mpsc, Mutex}; @@ -28,7 +29,7 @@ use tracing::{error, warn}; use super::container::Container; use super::extension::{ ExtensionConfig, ExtensionError, ExtensionInfo, ExtensionResult, PlatformExtensionContext, - ToolInfo, PLATFORM_EXTENSIONS, + PLATFORM_EXTENSIONS, }; use super::tool_execution::{ToolCallContext, ToolCallNotificationEmitter, ToolCallResult}; use super::types::SharedProvider; @@ -45,7 +46,6 @@ use crate::config::{get_all_extensions, Config}; use crate::oauth::{ oauth_flow, oauth_flow_with_challenge, GooseCredentialStore, StaticOAuthClientConfig, }; -use crate::prompt_template; use crate::subprocess::spawn_long_lived_mcp_subprocess; use rmcp::model::{ CallToolRequestParams, CallToolResult, ContentBlock, ErrorCode, ErrorData, GetPromptResult, @@ -131,7 +131,6 @@ struct Extension { client: McpClientBox, server_info: Option, - _temp_dir: Option, } impl Extension { @@ -140,14 +139,12 @@ impl Extension { resolved_config: ExtensionConfig, client: McpClientBox, server_info: Option, - temp_dir: Option, ) -> Self { Self { client, config, resolved_config, server_info, - _temp_dir: temp_dir, } } @@ -1463,19 +1460,12 @@ impl ExtensionManager { ); } - let mut temp_dir = None; - let effective_working_dir = working_dir .clone() .or_else(|| std::env::var("GOOSE_WORKING_DIR").ok().map(PathBuf::from)) .unwrap_or_else(|| std::env::current_dir().unwrap_or_default()); let client: Box = match &config { - ExtensionConfig::Sse { .. } => { - return Err(ExtensionError::ConfigError( - "SSE is unsupported, migrate to streamable_http".to_string(), - )); - } ExtensionConfig::StreamableHttp { uri, timeout, @@ -1668,46 +1658,6 @@ impl ExtensionManager { .await?; Box::new(client) } - ExtensionConfig::InlinePython { - name, - code, - timeout, - dependencies, - .. - } => { - let dir = tempdir()?; - let file_path = dir.path().join(format!("{}.py", name)); - temp_dir = Some(dir); - std::fs::write(&file_path, code)?; - - let command = Command::new("uvx").configure(|command| { - command.arg("--with").arg("mcp"); - dependencies.iter().flatten().for_each(|dep| { - command.arg("--with").arg(dep); - }); - command.arg("python").arg(file_path.to_str().unwrap()); - }); - - let client = child_process_client( - command, - timeout, - self.provider.clone(), - &effective_working_dir, - container.map(|c| c.id().to_string()), - self.client_name.clone(), - self.mcp_client_capabilities(), - self.context.session_manager.action_required(), - Arc::downgrade(self), - ) - .await?; - - Box::new(client) - } - ExtensionConfig::Frontend { .. } => { - return Err(ExtensionError::ConfigError( - "Invalid extension type: Frontend extensions cannot be added as server extensions".to_string() - )); - } }; let server_info = client.get_info().cloned(); @@ -1715,13 +1665,7 @@ impl ExtensionManager { let mut extensions = self.extensions.lock().await; extensions.insert( sanitized_name, - Extension::new( - config, - resolved_config, - Arc::from(client), - server_info, - temp_dir, - ), + Extension::new(config, resolved_config, Arc::from(client), server_info), ); drop(extensions); self.invalidate_tools_cache_and_bump_version().await; @@ -1735,12 +1679,11 @@ impl ExtensionManager { config: ExtensionConfig, client: McpClientBox, info: Option, - temp_dir: Option, ) { let normalized = name_to_key(&name); self.extensions.lock().await.insert( normalized, - Extension::new(config.clone(), config.clone(), client, info, temp_dir), + Extension::new(config.clone(), config.clone(), client, info), ); self.invalidate_tools_cache_and_bump_version().await; } @@ -2056,14 +1999,6 @@ impl ExtensionManager { Ok(tools) } - /// Get the extension prompt including client instructions - pub async fn get_planning_prompt(&self, tools_info: Vec) -> String { - let mut context: HashMap<&str, Value> = HashMap::new(); - context.insert("tools", serde_json::to_value(tools_info).unwrap()); - - prompt_template::render_template("plan.md", &context).expect("Prompt should render") - } - // Function that gets executed for read_resource tool pub async fn read_resource_tool( &self, @@ -2625,12 +2560,9 @@ impl ExtensionManager { description } } - ExtensionConfig::Sse { .. } => "SSE extension (unsupported)", ExtensionConfig::Platform { description, .. } | ExtensionConfig::StreamableHttp { description, .. } - | ExtensionConfig::Stdio { description, .. } - | ExtensionConfig::Frontend { description, .. } - | ExtensionConfig::InlinePython { description, .. } => description, + | ExtensionConfig::Stdio { description, .. } => description, }; disabled_extensions.push(format!("- {} - {}", config.name(), description)); } @@ -2907,7 +2839,7 @@ mod tests { bundled: None, available_tools, }; - let extension = Extension::new(config.clone(), config.clone(), client, None, None); + let extension = Extension::new(config.clone(), config.clone(), client, None); self.extensions .lock() .await @@ -3968,11 +3900,10 @@ mod tests { temp_dir.path().to_path_buf(), )); - let config = ExtensionConfig::Frontend { + let config = ExtensionConfig::Platform { name: "test-ext".to_string(), description: "original".to_string(), - tools: vec![], - instructions: None, + display_name: None, bundled: None, available_tools: vec![], }; @@ -3982,7 +3913,6 @@ mod tests { config.clone(), Arc::new(MockClient {}), None, - None, ) .await; assert_eq!(em.extensions.lock().await.len(), 1); @@ -4006,19 +3936,17 @@ mod tests { temp_dir.path().to_path_buf(), )); - let config_a = ExtensionConfig::Frontend { + let config_a = ExtensionConfig::Platform { name: "test-ext".to_string(), description: "version-a".to_string(), - tools: vec![], - instructions: None, + display_name: None, bundled: None, available_tools: vec![], }; - let config_b = ExtensionConfig::Frontend { + let config_b = ExtensionConfig::Platform { name: "test-ext".to_string(), - description: "version-b".to_string(), // changed - tools: vec![], - instructions: None, + description: "version-b".to_string(), + display_name: None, bundled: None, available_tools: vec![], }; @@ -4028,16 +3956,15 @@ mod tests { config_a, Arc::new(MockClient {}), None, - None, ) .await; assert_eq!(em.extensions.lock().await.len(), 1); - // add_extension with changed config attempts to create a new client (fails here - // because Frontend configs cannot be added as server extensions), but must preserve - // the old extension so the session isn't left without it. let result = em.add_extension(config_b, None, None, None).await; - assert!(result.is_err(), "Frontend add_extension must return Err"); + assert!( + result.is_err(), + "unknown platform extension must return Err" + ); assert_eq!( em.extensions.lock().await.len(), 1, diff --git a/crates/goose/src/agents/gen_ai_telemetry.rs b/crates/goose/src/agents/gen_ai_telemetry.rs index cac9cfaee8d9..5bf40474c2b9 100644 --- a/crates/goose/src/agents/gen_ai_telemetry.rs +++ b/crates/goose/src/agents/gen_ai_telemetry.rs @@ -28,12 +28,11 @@ pub(super) fn simple_output_json(text: &str) -> String { pub(super) fn output_message_json(message: &Message) -> String { // Message does not retain provider finish reasons; tool requests are the only // distinct completion signal available after streaming. - let finish_reason = if message.content.iter().any(|content| { - matches!( - content, - MessageContent::ToolRequest(_) | MessageContent::FrontendToolRequest(_) - ) - }) { + let finish_reason = if message + .content + .iter() + .any(|content| matches!(content, MessageContent::ToolRequest(_))) + { "tool_call" } else { "stop" @@ -231,9 +230,6 @@ fn message_part_json(content: &MessageContent) -> Value { Err(error) => json!({ "error": error.to_string() }), }, }), - MessageContent::FrontendToolRequest(request) => { - tool_call_part(&request.id, &request.tool_call) - } MessageContent::Thinking(thinking) => json!({ "type": "reasoning", "content": thinking.thinking, diff --git a/crates/goose/src/agents/mcp_client.rs b/crates/goose/src/agents/mcp_client.rs index 96d8e35c281a..7d41c8937598 100644 --- a/crates/goose/src/agents/mcp_client.rs +++ b/crates/goose/src/agents/mcp_client.rs @@ -1355,13 +1355,7 @@ mod tests { available_tools: vec![], }; extension_manager - .add_client( - "dynamic".to_string(), - config, - tools_client.clone(), - None, - None, - ) + .add_client("dynamic".to_string(), config, tools_client.clone(), None) .await; let goose_client = GooseClient::new( diff --git a/crates/goose/src/agents/mod.rs b/crates/goose/src/agents/mod.rs index 5ac6f9d5d65c..10269dd2c130 100644 --- a/crates/goose/src/agents/mod.rs +++ b/crates/goose/src/agents/mod.rs @@ -37,7 +37,7 @@ pub use schedule_tool::ScheduleTool; pub use subagent_handler::SUBAGENT_TOOL_REQUEST_TYPE; pub use subagent_task_config::TaskConfig; pub use tool_execution::ToolCallContext; -pub use types::{FrontendTool, RetryConfig, SessionConfig, SuccessCheck}; +pub use types::{RetryConfig, SessionConfig, SuccessCheck}; pub(crate) fn latest_provider_session_id<'a>( messages: &'a [crate::conversation::message::Message], diff --git a/crates/goose/src/agents/platform_extensions/code_execution.rs b/crates/goose/src/agents/platform_extensions/code_execution.rs index dbd8059b826e..85a4f308deed 100644 --- a/crates/goose/src/agents/platform_extensions/code_execution.rs +++ b/crates/goose/src/agents/platform_extensions/code_execution.rs @@ -800,7 +800,6 @@ mod tests { }, Arc::new(VisibilityClient), None, - None, ) .await; diff --git a/crates/goose/src/agents/prompt_manager.rs b/crates/goose/src/agents/prompt_manager.rs index c97cfd7b6ba4..a31905055302 100644 --- a/crates/goose/src/agents/prompt_manager.rs +++ b/crates/goose/src/agents/prompt_manager.rs @@ -46,7 +46,6 @@ pub struct SystemPromptBuilder<'a, M> { manager: &'a M, extensions_info: Vec, - frontend_instructions: Option, prompt_extras: IndexMap, subagents_enabled: bool, hints: Option, @@ -68,11 +67,6 @@ impl<'a> SystemPromptBuilder<'a, PromptManager> { self } - pub fn with_frontend_instructions(mut self, frontend_instructions: Option) -> Self { - self.frontend_instructions = frontend_instructions; - self - } - pub fn with_prompt_extras( mut self, extras: impl IntoIterator, @@ -116,14 +110,6 @@ impl<'a> SystemPromptBuilder<'a, PromptManager> { pub fn build(self) -> String { let mut extensions_info = self.extensions_info; - // Add frontend instructions to extensions_info to simplify json rendering - if let Some(frontend_instructions) = self.frontend_instructions { - extensions_info.push(ExtensionInfo::new( - "frontend", - &frontend_instructions, - false, - )); - } // Stable tool ordering is important for multi session prompt caching. extensions_info.sort_by(|a, b| a.name.cmp(&b.name)); @@ -272,7 +258,6 @@ impl PromptManager { manager: self, extensions_info: vec![], - frontend_instructions: None, prompt_extras: IndexMap::new(), subagents_enabled: false, hints: None, diff --git a/crates/goose/src/agents/reply_parts.rs b/crates/goose/src/agents/reply_parts.rs index 940ea1c339b8..e9945458b390 100644 --- a/crates/goose/src/agents/reply_parts.rs +++ b/crates/goose/src/agents/reply_parts.rs @@ -218,7 +218,6 @@ impl Agent { let system_prompt = prompt_manager .builder() .with_extensions(extensions_info.into_iter()) - .with_frontend_instructions(self.frontend_instructions.lock().await.clone()) .with_code_execution_mode(code_execution_active) .with_hints(working_dir) .with_goose_mode(goose_mode) @@ -573,18 +572,13 @@ pub(crate) async fn stream_response_from_provider( } impl Agent { - /// Categorize tool requests from the response into different types - /// Returns: - /// - frontend_requests: Tool requests that should be handled by the frontend - /// - other_requests: All other tool requests (including requests to enable extensions) - /// - filtered_message: The original message with frontend tool requests removed - pub(crate) async fn categorize_tool_requests( + pub(crate) fn categorize_tool_requests( &self, response: &Message, tools: &[Tool], toolshim_tools: &[Tool], suppress_replayed_thinking: bool, - ) -> (Vec, Vec, Message) { + ) -> (Vec, Message) { let model_visible_tools = tools .iter() .chain(toolshim_tools.iter()) @@ -682,7 +676,6 @@ impl Agent { let has_tool_requests = !tool_requests.is_empty(); let should_suppress_replayed_thinking = suppress_replayed_thinking && has_tool_requests; - // Create a filtered message with frontend tool requests removed. // When a response contains tool calls, keep reasoning in the original // message for provider/state purposes but only suppress it from the // user-visible filtered message if the caller already surfaced @@ -703,23 +696,7 @@ impl Agent { }; next_request = deduped_requests.next(); - // Always keep externally-dispatched requests visible, even if - // their name happens to overlap a registered frontend tool — - // they're observation-only and must not be removed from history. - let should_include = if coerced_req.was_executed_externally() { - true - } else if let Ok(tool_call) = &coerced_req.tool_call { - !model_visible_tools - .iter() - .any(|tool| tool.name == tool_call.name) - || !self.is_frontend_tool(&tool_call.name).await - } else { - true - }; - - if should_include { - filtered_content.push(MessageContent::ToolRequest(coerced_req.clone())); - } + filtered_content.push(MessageContent::ToolRequest(coerced_req.clone())); } MessageContent::Thinking(_) | MessageContent::RedactedThinking(_) if should_suppress_replayed_thinking => {} @@ -741,29 +718,12 @@ impl Agent { filtered_message = filtered_message.with_id(id); } - // Categorize tool requests - let mut frontend_requests = Vec::new(); - let mut other_requests = Vec::new(); - - for request in tool_requests { - // Skip externally-dispatched requests (e.g. claude-acp); the - // provider already executed the tool. Stays in filtered_message. - if request.was_executed_externally() { - continue; - } - if let Ok(tool_call) = &request.tool_call { - if self.is_frontend_tool(&tool_call.name).await { - frontend_requests.push(request); - } else { - other_requests.push(request); - } - } else { - // If there's an error in the tool call, add it to other_requests - other_requests.push(request); - } - } + let tool_requests = tool_requests + .into_iter() + .filter(|request| !request.was_executed_externally()) + .collect(); - (frontend_requests, other_requests, filtered_message) + (tool_requests, filtered_message) } /// `post_compaction_context_tokens` is `Some` when this usage came from a @@ -873,42 +833,16 @@ pub fn is_tool_visible_to_model(tool: &Tool) -> bool { mod tests { use super::*; use crate::agents::gen_ai_telemetry::{self, test_support::SpanFieldCapture}; - use crate::agents::{AgentConfig, GoosePlatform}; - use crate::config::permission::PermissionLevel; - use crate::config::{GooseMode, PermissionManager}; use crate::conversation::message::{Message, SystemNotificationType}; use crate::providers::base::Provider; - use crate::session::{SessionManager, SessionType}; use async_trait::async_trait; use goose_providers::conversation::token_usage::{ProviderStats, ProviderUsage, Usage}; use goose_providers::model::ModelConfig; - use rmcp::model::{Annotations, Role, TextContent, ToolAnnotations}; + use rmcp::model::{Annotations, Role, TextContent}; use rmcp::object; use std::sync::Mutex; use std::time::{Duration, Instant}; - #[derive(Clone)] - struct MockProvider; - - #[async_trait] - impl Provider for MockProvider { - fn get_name(&self) -> &str { - "mock" - } - - async fn stream( - &self, - _model_config: &ModelConfig, - _system: &str, - _messages: &[Message], - _tools: &[Tool], - ) -> Result { - let message = Message::assistant().with_text("ok"); - let usage = ProviderUsage::new("mock".to_string(), Usage::default()); - Ok(stream_from_single_message(message, usage)) - } - } - #[derive(Clone)] struct GenAiTracingProvider; @@ -1133,143 +1067,6 @@ mod tests { ); } - #[tokio::test] - async fn prepare_tools_returns_sorted_tools_including_frontend() -> anyhow::Result<()> { - let data_dir = tempfile::tempdir()?; - let data_path = data_dir.path().to_path_buf(); - let session_manager = std::sync::Arc::new(SessionManager::new(data_path.clone())); - let agent = Agent::with_config(AgentConfig::new( - std::sync::Arc::clone(&session_manager), - std::sync::Arc::new(PermissionManager::new(data_path)), - None, - GooseMode::default(), - false, - GoosePlatform::GooseCli, - )); - - let session = session_manager - .create_session( - std::env::current_dir().unwrap(), - "test-prepare-tools".to_string(), - SessionType::Hidden, - GooseMode::default(), - ) - .await?; - - let model_config = ModelConfig::new("test-model"); - let provider = std::sync::Arc::new(MockProvider); - agent - .update_provider(provider, model_config, &session.id) - .await?; - - // Add unsorted frontend tools - let frontend_tools = vec![ - Tool::new( - "frontend__z_tool".to_string(), - "Z tool".to_string(), - object!({ "type": "object", "properties": { } }), - ), - Tool::new( - "frontend__a_tool".to_string(), - "A tool".to_string(), - object!({ "type": "object", "properties": { } }), - ), - ]; - - agent - .add_extension( - crate::agents::extension::ExtensionConfig::Frontend { - name: "frontend".to_string(), - description: "desc".to_string(), - tools: frontend_tools, - instructions: None, - bundled: None, - available_tools: vec![], - }, - &session.id, - ) - .await - .unwrap(); - - let (tools, _toolshim_tools, _system_prompt, _model_config) = agent - .prepare_tools_and_prompt(&session.id, session.working_dir.as_path()) - .await?; - - let names: Vec = tools.iter().map(|t| t.name.clone().into_owned()).collect(); - assert!(names.iter().any(|n| n == "frontend__a_tool")); - assert!(names.iter().any(|n| n == "frontend__z_tool")); - - // Verify the names are sorted ascending - let mut sorted = names.clone(); - sorted.sort(); - assert_eq!(names, sorted); - - Ok(()) - } - - #[tokio::test] - async fn prepare_toolshim_tools_applies_writable_annotations() -> anyhow::Result<()> { - let data_dir = tempfile::tempdir()?; - let data_path = data_dir.path().to_path_buf(); - let session_manager = Arc::new(SessionManager::new(data_path.clone())); - let permission_manager = Arc::new(PermissionManager::new(data_path)); - permission_manager - .update_smart_approve_permission("frontend__write_tool", PermissionLevel::AlwaysAllow); - let agent = Agent::with_config(AgentConfig::new( - Arc::clone(&session_manager), - Arc::clone(&permission_manager), - None, - GooseMode::SmartApprove, - false, - GoosePlatform::GooseCli, - )); - let session = session_manager - .create_session( - std::env::current_dir()?, - "test-toolshim-annotations".to_string(), - SessionType::Hidden, - GooseMode::SmartApprove, - ) - .await?; - let model_config = ModelConfig::new("test-model").with_toolshim(true); - agent - .update_provider(Arc::new(MockProvider), model_config, &session.id) - .await?; - agent - .add_extension( - crate::agents::extension::ExtensionConfig::Frontend { - name: "frontend".to_string(), - description: "desc".to_string(), - tools: vec![Tool::new( - "frontend__write_tool", - "Write tool", - object!({ "type": "object", "properties": { } }), - ) - .annotate(ToolAnnotations::new().read_only(false))], - instructions: None, - bundled: None, - available_tools: vec![], - }, - &session.id, - ) - .await?; - - let (tools, toolshim_tools, _, _) = agent - .prepare_tools_and_prompt(&session.id, session.working_dir.as_path()) - .await?; - - assert!(tools.is_empty()); - assert!(toolshim_tools - .iter() - .any(|tool| tool.name == "frontend__write_tool")); - assert_eq!( - permission_manager.get_smart_approve_permission("frontend__write_tool"), - Some(PermissionLevel::AskBefore) - ); - - Ok(()) - } - #[tokio::test] async fn test_stream_error_propagation() { use futures::StreamExt; @@ -1545,8 +1342,8 @@ mod tests { Ok(()) } - #[tokio::test] - async fn categorize_tool_requests_keeps_thinking_when_not_previously_streamed() { + #[test] + fn categorize_tool_requests_keeps_thinking_when_not_previously_streamed() { let agent = crate::agents::Agent::new(); let tool = Tool::new("test_tool", "a test tool", object!({ "type": "object" })); let mut response = Message::assistant() @@ -1557,11 +1354,10 @@ mod tests { ); response.metadata.output_token_limit_reached = true; - let (_frontend_requests, other_requests, filtered_message) = agent - .categorize_tool_requests(&response, &[tool], &[], false) - .await; + let (tool_requests, filtered_message) = + agent.categorize_tool_requests(&response, &[tool], &[], false); - assert_eq!(other_requests.len(), 1); + assert_eq!(tool_requests.len(), 1); assert!(filtered_message.metadata.output_token_limit_reached); assert_eq!(filtered_message.content.len(), 2); assert!(matches!( @@ -1574,8 +1370,8 @@ mod tests { )); } - #[tokio::test] - async fn categorize_tool_requests_drops_replayed_thinking_after_streaming() { + #[test] + fn categorize_tool_requests_drops_replayed_thinking_after_streaming() { let agent = crate::agents::Agent::new(); let tool = Tool::new("test_tool", "a test tool", object!({ "type": "object" })); let response = Message::assistant() @@ -1585,11 +1381,10 @@ mod tests { Ok(rmcp::model::CallToolRequestParams::new("test_tool")), ); - let (_frontend_requests, other_requests, filtered_message) = agent - .categorize_tool_requests(&response, &[tool], &[], true) - .await; + let (tool_requests, filtered_message) = + agent.categorize_tool_requests(&response, &[tool], &[], true); - assert_eq!(other_requests.len(), 1); + assert_eq!(tool_requests.len(), 1); assert_eq!(filtered_message.content.len(), 1); assert!(matches!( filtered_message.content[0], @@ -1597,8 +1392,8 @@ mod tests { )); } - #[tokio::test] - async fn categorize_tool_requests_excludes_assistant_only_text_from_user_events() { + #[test] + fn categorize_tool_requests_excludes_assistant_only_text_from_user_events() { let agent = crate::agents::Agent::new(); let assistant_only = TextContent::new("assistant-only") .with_annotations(Annotations::default().with_audience(vec![Role::Assistant])); @@ -1607,9 +1402,7 @@ mod tests { .with_text("user-visible") .with_thinking("visible reasoning", ""); - let (_frontend_requests, _other_requests, filtered_message) = agent - .categorize_tool_requests(&response, &[], &[], false) - .await; + let (_, filtered_message) = agent.categorize_tool_requests(&response, &[], &[], false); assert_eq!(response.as_concat_text(), "assistant-only\nuser-visible"); assert_eq!(filtered_message.as_concat_text(), "user-visible"); @@ -1619,11 +1412,10 @@ mod tests { .any(|content| matches!(content, MessageContent::Thinking(_)))); } - #[tokio::test] - async fn categorize_tool_requests_skips_externally_dispatched_and_preserves_marker() { + #[test] + fn categorize_tool_requests_skips_externally_dispatched_and_preserves_marker() { // External requests must (1) survive coercion with goose.external_dispatch - // intact, (2) be excluded from both dispatch buckets, (3) stay in - // filtered_message. + // intact, (2) be excluded from dispatch, (3) stay in filtered_message. use crate::conversation::message::TOOL_META_EXTERNAL_DISPATCH_KEY; let agent = crate::agents::Agent::new(); @@ -1650,17 +1442,12 @@ mod tests { Some(serde_json::json!({ TOOL_META_EXTERNAL_DISPATCH_KEY: true })), ); - let (frontend_requests, other_requests, filtered_message) = agent - .categorize_tool_requests(&response, &[registry_tool], &[], false) - .await; + let (tool_requests, filtered_message) = + agent.categorize_tool_requests(&response, &[registry_tool], &[], false); assert!( - frontend_requests.is_empty(), - "external request leaked into frontend_requests: {frontend_requests:?}" - ); - assert!( - other_requests.is_empty(), - "external request leaked into other_requests: {other_requests:?}" + tool_requests.is_empty(), + "external request leaked into tool requests: {tool_requests:?}" ); assert_eq!(filtered_message.content.len(), 2); let tool_req = match &filtered_message.content[0] { @@ -1689,49 +1476,24 @@ mod tests { .is_some_and(|request| request.tool_call.is_ok())); } - #[tokio::test] - async fn categorize_tool_requests_rejects_unadvertised_executable_tools() { - use crate::agents::types::FrontendTool; - + #[test] + fn categorize_tool_requests_rejects_unadvertised_executable_tools() { let agent = crate::agents::Agent::new(); - let app_only_tool = Tool::new( - "frontend__app_only", - "an app-only frontend tool", - object!({ "type": "object" }), - ) - .with_meta(rmcp::model::MetaObject( - serde_json::json!({ "ui": { "visibility": ["app"] } }) - .as_object() - .unwrap() - .clone(), - )); - agent.frontend_tools.lock().await.insert( - app_only_tool.name.to_string(), - FrontendTool { - name: app_only_tool.name.to_string(), - tool: app_only_tool, - }, - ); - let response = Message::assistant() .with_tool_request( "app-only", - Ok(rmcp::model::CallToolRequestParams::new( - "frontend__app_only", - )), + Ok(rmcp::model::CallToolRequestParams::new("app_only")), ) .with_tool_request( "off-list", Ok(rmcp::model::CallToolRequestParams::new("unadvertised")), ); - let (frontend_requests, other_requests, filtered_message) = agent - .categorize_tool_requests(&response, &[], &[], false) - .await; + let (tool_requests, filtered_message) = + agent.categorize_tool_requests(&response, &[], &[], false); - assert!(frontend_requests.is_empty()); - assert_eq!(other_requests.len(), 2); - assert!(other_requests + assert_eq!(tool_requests.len(), 2); + assert!(tool_requests .iter() .all(|request| request.tool_call.is_err())); assert_eq!( @@ -1744,28 +1506,19 @@ mod tests { ); } - #[tokio::test] - async fn categorize_tool_requests_dispatches_advertised_tools() { - use crate::agents::types::FrontendTool; - + #[test] + fn categorize_tool_requests_dispatches_advertised_tools() { let agent = crate::agents::Agent::new(); let regular_tool = Tool::new( "regular_tool", "a regular tool", object!({ "type": "object" }), ); - let frontend_tool = Tool::new( - "frontend_tool", - "a frontend tool", + let additional_tool = Tool::new( + "additional_tool", + "an additional tool", object!({ "type": "object" }), ); - agent.frontend_tools.lock().await.insert( - frontend_tool.name.to_string(), - FrontendTool { - name: frontend_tool.name.to_string(), - tool: frontend_tool.clone(), - }, - ); let toolshim_tool = Tool::new( "toolshim_tool", "a toolshim tool", @@ -1781,25 +1534,22 @@ mod tests { Ok(rmcp::model::CallToolRequestParams::new("toolshim_tool")), ) .with_tool_request( - "frontend", - Ok(rmcp::model::CallToolRequestParams::new("frontend_tool")), + "additional", + Ok(rmcp::model::CallToolRequestParams::new("additional_tool")), ); - let (frontend_requests, other_requests, _) = agent - .categorize_tool_requests( - &response, - &[regular_tool, frontend_tool], - &[toolshim_tool], - false, - ) - .await; + let (tool_requests, _) = agent.categorize_tool_requests( + &response, + &[regular_tool, additional_tool], + &[toolshim_tool], + false, + ); - assert_eq!(frontend_requests.len(), 1); - assert_eq!(other_requests.len(), 2); + assert_eq!(tool_requests.len(), 3); } - #[tokio::test] - async fn categorize_tool_requests_canonicalizes_mangled_unprefixed_tool_name() { + #[test] + fn categorize_tool_requests_canonicalizes_mangled_unprefixed_tool_name() { // GLM's documented reproduction (#9486): a default Developer-extension // tool is advertised unprefixed ("shell"), owner only in metadata, and // the model emits "developer.shell". This must be rewritten to the @@ -1826,12 +1576,11 @@ mod tests { Ok(rmcp::model::CallToolRequestParams::new("developer.shell")), ); - let (_frontend_requests, other_requests, _filtered_message) = agent - .categorize_tool_requests(&response, &[shell_tool], &[], false) - .await; + let (tool_requests, _) = + agent.categorize_tool_requests(&response, &[shell_tool], &[], false); - assert_eq!(other_requests.len(), 1); - let tool_call = other_requests[0] + assert_eq!(tool_requests.len(), 1); + let tool_call = tool_requests[0] .tool_call .as_ref() .expect("mangled-but-recoverable name must not become an Err"); @@ -1841,8 +1590,8 @@ mod tests { ); } - #[tokio::test] - async fn categorize_tool_requests_canonicalizes_mangled_non_extension_manager_tool_name() { + #[test] + fn categorize_tool_requests_canonicalizes_mangled_non_extension_manager_tool_name() { // recipe__final_output is appended by Agent::list_tools outside the // extension manager (see #9486 review); it must recover the same way. let agent = crate::agents::Agent::new(); @@ -1860,20 +1609,19 @@ mod tests { )), ); - let (_frontend_requests, other_requests, _filtered_message) = agent - .categorize_tool_requests(&response, &[final_output_tool], &[], false) - .await; + let (tool_requests, _) = + agent.categorize_tool_requests(&response, &[final_output_tool], &[], false); - assert_eq!(other_requests.len(), 1); - let tool_call = other_requests[0] + assert_eq!(tool_requests.len(), 1); + let tool_call = tool_requests[0] .tool_call .as_ref() .expect("mangled-but-recoverable name must not become an Err"); assert_eq!(tool_call.name, "recipe__final_output"); } - #[tokio::test] - async fn categorize_tool_requests_rejects_unrecoverable_unadvertised_name() { + #[test] + fn categorize_tool_requests_rejects_unrecoverable_unadvertised_name() { let agent = crate::agents::Agent::new(); let tool = Tool::new( "shell", @@ -1888,12 +1636,11 @@ mod tests { )), ); - let (_frontend_requests, other_requests, filtered_message) = agent - .categorize_tool_requests(&response, &[tool], &[], false) - .await; + let (tool_requests, filtered_message) = + agent.categorize_tool_requests(&response, &[tool], &[], false); - assert_eq!(other_requests.len(), 1); - assert!(other_requests[0].tool_call.is_err()); + assert_eq!(tool_requests.len(), 1); + assert!(tool_requests[0].tool_call.is_err()); let tool_call = filtered_message.content[0] .as_tool_request() .unwrap() @@ -1903,8 +1650,8 @@ mod tests { assert!(tool_call.message.contains("totally_unknown_tool")); } - #[tokio::test] - async fn categorize_tool_requests_dedups_duplicate_ids_in_provider_order() { + #[test] + fn categorize_tool_requests_dedups_duplicate_ids_in_provider_order() { // A malformed provider repeats id "dup". The first occurrence wins, the // later duplicate is dropped from both the dispatch bucket and the // filtered (history) message, and unique ids are kept. @@ -1929,11 +1676,10 @@ mod tests { Tool::new("third_tool", "third", object!({ "type": "object" })), ]; - let (_frontend_requests, other_requests, filtered_message) = agent - .categorize_tool_requests(&response, &tools, &[], false) - .await; + let (tool_requests, filtered_message) = + agent.categorize_tool_requests(&response, &tools, &[], false); - let kept: Vec<(&str, &str)> = other_requests + let kept: Vec<(&str, &str)> = tool_requests .iter() .map(|r| (r.id.as_str(), r.tool_call.as_ref().unwrap().name.as_ref())) .collect(); diff --git a/crates/goose/src/agents/state_machine/inference_preparation.rs b/crates/goose/src/agents/state_machine/inference_preparation.rs index c976914d68bd..34bb55dddec3 100644 --- a/crates/goose/src/agents/state_machine/inference_preparation.rs +++ b/crates/goose/src/agents/state_machine/inference_preparation.rs @@ -22,7 +22,6 @@ pub struct GooseInferenceRequestPreparer<'a> { pub(crate) goose_mode: &'a Mutex, pub(crate) prompt_manager: &'a Mutex, pub(crate) tool_inspection_manager: &'a ToolInspectionManager, - pub(crate) frontend_instructions: &'a Mutex>, pub(crate) context_limit: usize, } @@ -32,7 +31,7 @@ impl InferenceRequestPreparer for GooseInferenceRequestPreparer<'_> { &self, session: &Session, conversation: &Conversation, - mut input: InferenceInput, + input: InferenceInput, ) -> Result { #[cfg(feature = "code-mode")] let code_execution_mode = self @@ -51,11 +50,6 @@ impl InferenceRequestPreparer for GooseInferenceRequestPreparer<'_> { } let tools = crate::agents::reply_parts::prepare_inference_tools(input.tools, code_execution_mode); - if let Some(frontend_instructions) = self.frontend_instructions.lock().await.clone() { - input - .prompt_parts - .push(("frontend".to_string(), frontend_instructions)); - } let system_prompt = self.prompt_manager.lock().await.build_system_prompt( &session.working_dir, input.prompt_parts, diff --git a/crates/goose/src/agents/state_machine/tests/pipeline.rs b/crates/goose/src/agents/state_machine/tests/pipeline.rs index 3dca2e2060cc..76fe3b7c15e3 100644 --- a/crates/goose/src/agents/state_machine/tests/pipeline.rs +++ b/crates/goose/src/agents/state_machine/tests/pipeline.rs @@ -94,7 +94,6 @@ pub(super) struct TestPipeline { prompt_manager: TokioMutex, tool_inspection_manager: ToolInspectionManager, permission_manager: Arc, - frontend_instructions: TokioMutex>, hook_manager: HookManager, stop_hook_block_cap: u32, goal: TokioMutex>, @@ -177,7 +176,6 @@ impl TestPipeline { goose_mode: &self.goose_mode, prompt_manager: &self.prompt_manager, tool_inspection_manager: &self.tool_inspection_manager, - frontend_instructions: &self.frontend_instructions, context_limit: self.model_config.context_limit(), }; let status_operation = Arc::new(StatusOperation::new( @@ -810,7 +808,6 @@ async fn build_test_pipeline( prompt_manager: TokioMutex::new(PromptManager::new()), tool_inspection_manager, permission_manager, - frontend_instructions: TokioMutex::new(None), hook_manager: HookManager::default(), stop_hook_block_cap: 3, goal: TokioMutex::new(None), @@ -856,7 +853,6 @@ async fn build_test_pipeline( extension, calculator.clone(), calculator.get_info().cloned(), - None, ) .await; } else { diff --git a/crates/goose/src/agents/tool_execution.rs b/crates/goose/src/agents/tool_execution.rs index 4974e00fbc6f..9639d3d764e1 100644 --- a/crates/goose/src/agents/tool_execution.rs +++ b/crates/goose/src/agents/tool_execution.rs @@ -239,30 +239,4 @@ impl Agent { } }.boxed() } - - pub(crate) fn handle_frontend_tool_request<'a>( - &'a self, - tool_request: &'a ToolRequest, - message_tool_response: &'a mut Message, - ) -> BoxStream<'a, anyhow::Result> { - try_stream! { - if let Ok(tool_call) = tool_request.tool_call.clone() { - if self.is_frontend_tool(&tool_call.name).await { - yield Message::assistant().with_frontend_tool_request( - tool_request.id.clone(), - Ok(tool_call.clone()) - ); - - if let Some((id, result)) = self.tool_result_rx.lock().await.recv().await { - message_tool_response.add_tool_response_with_metadata( - id, - result, - tool_request.metadata.as_ref(), - ); - } - } - } - } - .boxed() - } } diff --git a/crates/goose/src/agents/types.rs b/crates/goose/src/agents/types.rs index be7d97227167..fbafe8c990c8 100644 --- a/crates/goose/src/agents/types.rs +++ b/crates/goose/src/agents/types.rs @@ -1,12 +1,7 @@ -use crate::mcp_utils::ToolResult; use crate::providers::base::Provider; -use rmcp::model::{CallToolResult, Tool}; use serde::{Deserialize, Serialize}; use std::sync::Arc; -use tokio::sync::{mpsc, Mutex}; - -/// Type alias for the tool result channel receiver -pub type ToolResultReceiver = Arc)>>>; +use tokio::sync::Mutex; // We use double Arc here to allow easy provider swaps while sharing concurrent access pub type SharedProvider = Arc>>>; @@ -72,13 +67,6 @@ pub enum SuccessCheck { }, } -/// A frontend tool that will be executed by the frontend rather than an extension -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct FrontendTool { - pub name: String, - pub tool: Tool, -} - /// Session configuration for an agent #[derive(Debug, Clone, Serialize, Deserialize)] pub struct SessionConfig { diff --git a/crates/goose/src/config/extensions.rs b/crates/goose/src/config/extensions.rs index 2546d200b807..b84e91b0d24b 100644 --- a/crates/goose/src/config/extensions.rs +++ b/crates/goose/src/config/extensions.rs @@ -279,15 +279,20 @@ pub fn get_warnings() -> Vec { let mut warnings = Vec::new(); for (k, v) in raw { - if let (serde_yaml::Value::String(key), Ok(entry)) = - (k, serde_yaml::from_value::(v)) - { - if matches!(entry.config, ExtensionConfig::Sse { .. }) { - warnings.push(format!( - "'{}': SSE is unsupported, migrate to streamable_http", - key - )); - } + let Some(key) = k.as_str() else { + continue; + }; + let Some(extension) = v.as_mapping() else { + continue; + }; + let extension_type = extension + .get(serde_yaml::Value::String("type".to_string())) + .and_then(serde_yaml::Value::as_str); + if extension_type == Some("sse") { + warnings.push(format!( + "'{}': SSE is unsupported, migrate to streamable_http", + key + )); } } warnings diff --git a/crates/goose/src/providers/claude_code.rs b/crates/goose/src/providers/claude_code.rs index 427199edb6af..bf27f3aa1c8c 100644 --- a/crates/goose/src/providers/claude_code.rs +++ b/crates/goose/src/providers/claude_code.rs @@ -570,9 +570,6 @@ fn claude_mcp_config_json(extensions: &[ExtensionConfig]) -> Option { } mcp_servers.insert(key, Value::Object(config)); } - ExtensionConfig::Sse { name, .. } => { - tracing::debug!(name, "skipping SSE extension, migrate to streamable_http"); - } _ => {} } } @@ -1244,15 +1241,6 @@ mod tests { None ; "empty_extensions_returns_none" )] - #[test_case( - vec![ExtensionConfig::Sse { - name: "legacy".into(), - description: String::new(), - uri: Some("http://localhost/sse".into()), - }], - None - ; "sse_only_returns_none" - )] #[test_case( vec![ExtensionConfig::Stdio { name: "lookup".into(), diff --git a/crates/goose/src/providers/codex.rs b/crates/goose/src/providers/codex.rs index 6ee9de379b1e..d9c816be15ba 100644 --- a/crates/goose/src/providers/codex.rs +++ b/crates/goose/src/providers/codex.rs @@ -623,9 +623,6 @@ fn codex_mcp_config_overrides(extensions: &[ExtensionConfig]) -> Result { - tracing::debug!(name, "skipping SSE extension, migrate to streamable_http"); - } _ => {} } } diff --git a/crates/goose/src/providers/formats/bedrock.rs b/crates/goose/src/providers/formats/bedrock.rs index 1add61af892d..084eb9d3a93c 100644 --- a/crates/goose/src/providers/formats/bedrock.rs +++ b/crates/goose/src/providers/formats/bedrock.rs @@ -250,27 +250,6 @@ pub fn to_bedrock_message_content(content: &MessageContent) -> Result { - let tool_use_id = tool_req.id.to_string(); - let tool_use = if let Ok(call) = tool_req.tool_call.as_ref() { - bedrock::ToolUseBlock::builder() - .tool_use_id(tool_use_id) - .name(call.name.to_string()) - .input(to_bedrock_json(&args_to_value(call.arguments.clone()))) - .build() - } else { - // Unparseable tool call: emit a placeholder tool_use so the paired - // tool_result isn't orphaned — Bedrock rejects a tool_use with no name - // and a tool_result with no matching tool_use. Mirrors the - // OpenAI/Databricks/Anthropic formatters. - bedrock::ToolUseBlock::builder() - .tool_use_id(tool_use_id) - .name("unparseable_tool_call") - .input(to_bedrock_json(&args_to_value(None))) - .build() - }?; - bedrock::ContentBlock::ToolUse(tool_use) - } MessageContent::ToolResponse(tool_res) => { let content = match &tool_res.tool_result { Ok(result) => Some( diff --git a/crates/goose/src/providers/formats/openrouter.rs b/crates/goose/src/providers/formats/openrouter.rs index aa43b55541ab..615f966614d0 100644 --- a/crates/goose/src/providers/formats/openrouter.rs +++ b/crates/goose/src/providers/formats/openrouter.rs @@ -12,7 +12,6 @@ fn has_assistant_content(message: &Message) -> bool { MessageContent::Text(t) => !t.text.is_empty(), MessageContent::Image(_) => true, MessageContent::ToolRequest(req) => req.tool_call.is_ok(), - MessageContent::FrontendToolRequest(req) => req.tool_call.is_ok(), _ => false, }) } diff --git a/crates/goose/src/recipe/mod.rs b/crates/goose/src/recipe/mod.rs index cb6175897aa8..d64d01a30174 100644 --- a/crates/goose/src/recipe/mod.rs +++ b/crates/goose/src/recipe/mod.rs @@ -660,53 +660,6 @@ sub_recipes: assert_eq!(author.contact, Some("test@example.com".to_string())); } - #[test] - fn test_inline_python_extension() { - let content = r#"{ - "version": "1.0.0", - "title": "Test Recipe", - "description": "A test recipe", - "instructions": "Test instructions", - "extensions": [ - { - "type": "inline_python", - "name": "test_python", - "code": "print('hello world')", - "timeout": 300, - "description": "Test python extension", - "dependencies": ["numpy", "matplotlib"] - } - ] - }"#; - - let recipe = Recipe::from_content(content).unwrap(); - - assert!(recipe.extensions.is_some()); - let extensions = recipe.extensions.unwrap(); - assert_eq!(extensions.len(), 1); - - match &extensions[0] { - ExtensionConfig::InlinePython { - name, - code, - description, - timeout, - dependencies, - .. - } => { - assert_eq!(name, "test_python"); - assert_eq!(code, "print('hello world')"); - assert_eq!(description, "Test python extension"); - assert_eq!(timeout, &Some(300)); - assert!(dependencies.is_some()); - let deps = dependencies.as_ref().unwrap(); - assert!(deps.contains(&"numpy".to_string())); - assert!(deps.contains(&"matplotlib".to_string())); - } - _ => panic!("Expected InlinePython extension"), - } - } - #[test] fn test_from_content_with_activities() { let content = r#"{ diff --git a/crates/goose/src/recipe/recipe_extension_adapter.rs b/crates/goose/src/recipe/recipe_extension_adapter.rs index eff98ab221c2..aa4efa1df135 100644 --- a/crates/goose/src/recipe/recipe_extension_adapter.rs +++ b/crates/goose/src/recipe/recipe_extension_adapter.rs @@ -1,5 +1,4 @@ use crate::agents::extension::{Envs, ExtensionConfig}; -use rmcp::model::Tool; use serde::de::Deserializer; use serde::Deserialize; use std::collections::HashMap; @@ -76,30 +75,6 @@ enum RecipeExtensionConfigInternal { #[serde(default)] available_tools: Vec, }, - #[serde(rename = "frontend")] - Frontend { - name: String, - #[serde(default)] - description: Option, - tools: Vec, - instructions: Option, - #[serde(default)] - bundled: Option, - #[serde(default)] - available_tools: Vec, - }, - #[serde(rename = "inline_python")] - InlinePython { - name: String, - #[serde(default)] - description: Option, - code: String, - timeout: Option, - #[serde(default)] - dependencies: Option>, - #[serde(default)] - available_tools: Vec, - }, } macro_rules! map_recipe_extensions { @@ -157,18 +132,6 @@ impl From for ExtensionConfig { scopes, bundled, available_tools - }, - Frontend { - tools, - instructions, - bundled, - available_tools - }, - InlinePython { - code, - timeout, - dependencies, - available_tools } ) } diff --git a/crates/goose/tests/agent.rs b/crates/goose/tests/agent.rs index a8265d1a99b2..b01ea5accca7 100644 --- a/crates/goose/tests/agent.rs +++ b/crates/goose/tests/agent.rs @@ -2766,194 +2766,6 @@ mod tests { } } - mod frontend_extension_tests { - use super::*; - use goose::agents::{AgentConfig, ExtensionConfig}; - use goose::config::permission::PermissionManager; - use goose::config::GooseMode; - use goose::session::session_manager::SessionType; - use goose::session::{ - EnabledExtensionsState, ExtensionData, ExtensionState, SessionManager, - }; - use rmcp::model::Tool; - use rmcp::object; - use tempfile::TempDir; - - fn frontend_extension_with_tool(name: &str, tool_name: &str) -> ExtensionConfig { - ExtensionConfig::Frontend { - name: name.to_string(), - description: format!("Frontend test extension {name}"), - tools: vec![Tool::new( - tool_name.to_string(), - format!("Run {tool_name} from the frontend"), - object!({ - "type": "object", - "properties": { - "message": { "type": "string" } - }, - "required": ["message"] - }), - )], - instructions: Some(format!("Use the {tool_name} tool.")), - bundled: None, - available_tools: vec![], - } - } - - fn frontend_extension() -> ExtensionConfig { - frontend_extension_with_tool("frontend-e2e", "frontend__echo") - } - - #[tokio::test] - async fn test_frontend_extensions_are_persisted_listed_and_removed() { - let temp_dir = TempDir::new().unwrap(); - let data_dir = temp_dir.path().to_path_buf(); - let session_manager = Arc::new(SessionManager::new(data_dir.clone())); - let permission_manager = Arc::new(PermissionManager::new(data_dir)); - let agent = Agent::with_config(AgentConfig::new( - session_manager.clone(), - permission_manager, - None, - GooseMode::default(), - false, - GoosePlatform::GooseDesktop, - )); - - let session = session_manager - .create_session( - std::env::current_dir().unwrap(), - "frontend-extension-test".to_string(), - SessionType::Hidden, - GooseMode::default(), - ) - .await - .unwrap(); - - agent - .add_extension(frontend_extension(), &session.id) - .await - .unwrap(); - - let listed_tools = agent.list_tools(&session.id, None).await; - assert!(listed_tools - .iter() - .any(|tool| tool.name == "frontend__echo")); - - let filtered_tools = agent - .list_tools(&session.id, Some("frontend-e2e".to_string())) - .await; - assert_eq!(filtered_tools.len(), 1); - assert_eq!(filtered_tools[0].name, "frontend__echo"); - - let extension_names = agent.list_extensions().await; - assert!(extension_names.iter().any(|name| name == "frontend-e2e")); - - let persisted_session = session_manager - .get_session(&session.id, false) - .await - .unwrap(); - let persisted_extensions = - EnabledExtensionsState::from_extension_data(&persisted_session.extension_data) - .unwrap() - .extensions; - assert!(persisted_extensions - .iter() - .any(|extension| extension.name() == "frontend-e2e")); - - agent - .remove_extension("frontend-e2e", &session.id) - .await - .unwrap(); - - let listed_tools = agent.list_tools(&session.id, None).await; - assert!(!listed_tools - .iter() - .any(|tool| tool.name == "frontend__echo")); - - let persisted_session = session_manager - .get_session(&session.id, false) - .await - .unwrap(); - let persisted_extensions = - EnabledExtensionsState::from_extension_data(&persisted_session.extension_data) - .unwrap() - .extensions; - assert!(persisted_extensions - .iter() - .all(|extension| extension.name() != "frontend-e2e")); - } - - #[tokio::test] - async fn test_concurrent_frontend_session_load_keeps_all_tools() { - let temp_dir = TempDir::new().unwrap(); - let data_dir = temp_dir.path().to_path_buf(); - let session_manager = Arc::new(SessionManager::new(data_dir.clone())); - let permission_manager = Arc::new(PermissionManager::new(data_dir)); - let agent = Arc::new(Agent::with_config(AgentConfig::new( - session_manager.clone(), - permission_manager, - None, - GooseMode::default(), - false, - GoosePlatform::GooseDesktop, - ))); - - let session = session_manager - .create_session( - std::env::current_dir().unwrap(), - "frontend-extension-load-test".to_string(), - SessionType::Hidden, - GooseMode::default(), - ) - .await - .unwrap(); - - let expected_tools = (0..12) - .map(|index| format!("frontend__tool_{index}")) - .collect::>(); - let extensions = expected_tools - .iter() - .enumerate() - .map(|(index, tool_name)| { - frontend_extension_with_tool(&format!("frontend-{index}"), tool_name) - }) - .collect::>(); - - let mut extension_data = ExtensionData::new(); - EnabledExtensionsState::new(extensions) - .to_extension_data(&mut extension_data) - .unwrap(); - session_manager - .update(&session.id) - .extension_data(extension_data) - .apply() - .await - .unwrap(); - - let session = session_manager - .get_session(&session.id, false) - .await - .unwrap(); - let load_results = agent.load_extensions_from_session(&session).await; - assert!( - load_results.iter().all(|result| result.success), - "failed to load frontend extensions: {load_results:?}", - ); - - let listed_tools = agent.list_tools(&session.id, None).await; - for tool_name in expected_tools { - assert!( - listed_tools.iter().any(|tool| tool.name == tool_name), - "expected listed frontend tool {tool_name}", - ); - assert!( - agent.is_frontend_tool(&tool_name).await, - "expected frontend dispatch state for {tool_name}", - ); - } - } - } - mod add_extensions_bulk_tests { use super::*; use goose::agents::extension::Envs; @@ -2964,26 +2776,13 @@ mod tests { use goose::session::{ EnabledExtensionsState, ExtensionData, ExtensionState, SessionManager, }; - use rmcp::model::Tool; - use rmcp::object; use tempfile::TempDir; - fn frontend_extension(name: &str) -> ExtensionConfig { - ExtensionConfig::Frontend { + fn platform_extension(name: &str) -> ExtensionConfig { + ExtensionConfig::Platform { name: name.to_string(), - description: format!("Frontend test extension {name}"), - tools: vec![Tool::new( - format!("{name}__tool"), - format!("Run a tool from {name}"), - object!({ - "type": "object", - "properties": { - "message": { "type": "string" } - }, - "required": ["message"] - }), - )], - instructions: None, + description: format!("Platform test extension {name}"), + display_name: None, bundled: None, available_tools: vec![], } @@ -3059,10 +2858,7 @@ mod tests { let results = agent .add_extensions_bulk( - vec![ - frontend_extension("frontend-a"), - frontend_extension("frontend-b"), - ], + vec![platform_extension("analyze"), platform_extension("todo")], &session_id, ) .await @@ -3071,7 +2867,7 @@ mod tests { assert!(results.iter().all(|result| result.success)); assert_eq!( persisted_extension_names(&session_manager, &session_id).await, - vec!["frontend-a".to_string(), "frontend-b".to_string()] + vec!["analyze".to_string(), "todo".to_string()] ); } @@ -3083,7 +2879,7 @@ mod tests { let results = agent .add_extensions_bulk( vec![ - frontend_extension("frontend-ok"), + platform_extension("todo"), unloadable_stdio_extension("broken"), ], &session_id, @@ -3094,7 +2890,7 @@ mod tests { assert_eq!(results.len(), 2); assert!(results .iter() - .any(|result| result.name == "frontend-ok" && result.success)); + .any(|result| result.name == "todo" && result.success)); let broken = results .iter() .find(|result| result.name == "broken") @@ -3104,7 +2900,7 @@ mod tests { assert_eq!( persisted_extension_names(&session_manager, &session_id).await, - vec!["frontend-ok".to_string()] + vec!["todo".to_string()] ); } diff --git a/documentation/docs/guides/config-files.md b/documentation/docs/guides/config-files.md index ace786009c31..db4f6e1436ef 100644 --- a/documentation/docs/guides/config-files.md +++ b/documentation/docs/guides/config-files.md @@ -144,7 +144,7 @@ extensions: available_tools: [] # Filter to specific tools (empty = all) ``` -Supported extension types are `builtin`, `platform`, `stdio`, `streamable_http`, `frontend`, and `inline_python`. `sse` may appear in older configs, but is kept only for compatibility. +Supported extension types are `builtin`, `platform`, `stdio`, and `streamable_http`. SSE is not supported; migrate old SSE configurations to `streamable_http`. Common extension shapes: diff --git a/documentation/docs/guides/recipes/recipe-reference.md b/documentation/docs/guides/recipes/recipe-reference.md index 85a202e615e2..9848f775c178 100644 --- a/documentation/docs/guides/recipes/recipe-reference.md +++ b/documentation/docs/guides/recipes/recipe-reference.md @@ -173,8 +173,6 @@ The `extensions` field allows you to specify which Model Context Protocol (MCP) - **`builtin`**: Built-in extension that is part of the bundled goose MCP server - **`platform`**: Platform extensions that run in the agent process - **`streamable_http`**: Streamable HTTP client with URI endpoint -- **`frontend`**: Frontend-provided tools called through the frontend -- **`inline_python`**: Inline Python code executed using uvx. Requires `code` field; optional `dependencies` for packages. :::note Summon Extension and Subagents The `delegate` and `load` tools are provided by the `summon` platform extension. When a recipe specifies an explicit `extensions` block, only the listed extensions are available — default platform extensions like `summon` are not automatically included. If your recipe needs subagent delegation, add `summon` to your extensions list: @@ -222,16 +220,6 @@ extensions: timeout: 60 description: "GitHub MCP extension for repository operations" - - type: inline_python - name: data_processor - code: | - import pandas as pd - print("Processing data...") - dependencies: - - pandas - - numpy - timeout: 120 - description: "Process data using pandas" ``` @@ -265,14 +253,6 @@ extensions: "env_keys": ["GITHUB_PERSONAL_ACCESS_TOKEN"], "timeout": 60, "description": "GitHub MCP extension for repository operations" - }, - { - "type": "inline_python", - "name": "data_processor", - "code": "import pandas as pd\nprint(\"Processing data...\")", - "dependencies": ["pandas", "numpy"], - "timeout": 120, - "description": "Process data using pandas" } ] } diff --git a/examples/frontend_tools.py b/examples/frontend_tools.py deleted file mode 100644 index 98aa98749556..000000000000 --- a/examples/frontend_tools.py +++ /dev/null @@ -1,292 +0,0 @@ -import asyncio -import json -import os -from typing import List, Dict, Any -import httpx -from datetime import datetime - -# Configuration -GOOSE_HOST = "127.0.0.1" -GOOSE_PORT = "3001" -GOOSE_URL = f"http://{GOOSE_HOST}:{GOOSE_PORT}" -SECRET_KEY = "test" # Default development secret key - -# A simple calculator tool definition -CALCULATOR_TOOL = { - "name": "calculator", - "description": "Perform basic arithmetic calculations", - "inputSchema": { - "type": "object", - "required": ["operation", "numbers"], - "properties": { - "operation": { - "type": "string", - "enum": ["add", "subtract", "multiply", "divide"], - "description": "The arithmetic operation to perform", - }, - "numbers": { - "type": "array", - "items": {"type": "number"}, - "description": "List of numbers to operate on in order", - }, - }, - }, -} - -# Enable Extension tool definition -ENABLE_EXTENSION_TOOL = { - "name": "enable_extension", - "description": "Enable extensions to help complete tasks. Enable an extension by providing the extension name.", - "inputSchema": { - "type": "object", - "required": ["extension_name"], - "properties": { - "extension_name": { - "type": "string", - "description": "The name of the extension to enable", - }, - }, - }, -} - -# Frontend extension configuration -FRONTEND_CONFIG = { - "name": "pythonclient", - "type": "frontend", - "tools": [CALCULATOR_TOOL, ENABLE_EXTENSION_TOOL], - "instructions": "A calculator extension that can perform basic arithmetic operations. Use enable extension tool to add extensions such as fetch, pdf reader, etc.", -} - - -async def setup_agent() -> None: - """Initialize the agent with our frontend tool.""" - async with httpx.AsyncClient() as client: - # First create the agent - response = await client.post( - f"{GOOSE_URL}/agent/update_provider", - json={"provider": "databricks", "model": "goose"}, - headers={"X-Secret-Key": SECRET_KEY}, - ) - response.raise_for_status() - print("Successfully created agent") - - # Then add our frontend extension - response = await client.post( - f"{GOOSE_URL}/extensions/add", - json=FRONTEND_CONFIG, - headers={"X-Secret-Key": SECRET_KEY}, - ) - response.raise_for_status() - print("Successfully added calculator extension") - - -def execute_calculator(args: Dict[str, Any]) -> List[Dict[str, Any]]: - """Execute the calculator tool with the given arguments.""" - operation = args["operation"] - numbers = args["numbers"] - - try: - result = None - if operation == "add": - result = sum(numbers) - elif operation == "subtract": - result = numbers[0] - sum(numbers[1:]) - elif operation == "multiply": - result = 1 - for n in numbers: - result *= n - elif operation == "divide": - result = numbers[0] - for n in numbers[1:]: - result /= n - - # Return properly structured Content::Text variant - return [ - { - "type": "text", - "text": str(result), - "annotations": None, # Required field in Rust struct - } - ] - except Exception as e: - return [ - { - "type": "text", - "text": f"Error: {str(e)}", - "annotations": None, # Required field in Rust struct - } - ] - -def get_tools() -> Dict[str, Any]: - with httpx.Client() as client: - response = client.get( - f"{GOOSE_URL}/agent/tools", - headers={"X-Secret-Key": SECRET_KEY}, - ) - response.raise_for_status() - return response.json() - - -def execute_enable_extension(args: Dict[str, Any]) -> List[Dict[str, Any]]: - """ - Execute the enable_extension tool. - This function fetches available extensions, finds the one with the provided extension_name, - and posts its configuration to the /extensions/add endpoint. - """ - extension = args - extension_name = extension.get("name") - - # Post the extension configuration to enable it - with httpx.Client() as client: - payload = { - "type": extension.get("type"), - "name": extension.get("name"), - "cmd": extension.get("cmd"), - "args": extension.get("args"), - "envs": extension.get("envs", {}), - "timeout": extension.get("timeout"), - "bundled": extension.get("bundled"), - } - add_response = client.post( - f"{GOOSE_URL}/extensions/add", - json=payload, - headers={"Content-Type": "application/json", "X-Secret-Key": SECRET_KEY}, - ) - if add_response.status_code != 200: - error_text = add_response.text - return [{ - "type": "text", - "text": f"Error: Failed to enable extension: {error_text}", - "annotations": None, - }] - - return [{ - "type": "text", - "text": f"Successfully enabled extension: {extension_name}", - "annotations": None, - }] - - -def submit_tool_result(tool_id: str, result: List[Dict[str, Any]]) -> None: - """Submit the tool execution result back to Goose. - - The result should be a list of Content variants (Text, Image, or Resource). - Each Content variant has a type tag and appropriate fields. - """ - payload = { - "id": tool_id, - "result": { - "Ok": result # Result enum variant with single key for success case - }, - } - - with httpx.Client(timeout=2.0) as client: - response = client.post( - f"{GOOSE_URL}/tool_result", - json=payload, - headers={"X-Secret-Key": SECRET_KEY}, - ) - response.raise_for_status() - - -async def chat_loop() -> None: - """Main chat loop that handles the conversation with Goose.""" - session_id = "test-session" - - # Use a client with a longer timeout for streaming - async with httpx.AsyncClient(timeout=60.0) as client: - # Get user input - user_message = input("\nYou: ") - if user_message.lower() in ["exit", "quit"]: - return - - # Create the message object - message = { - "role": "user", - "created": int(datetime.now().timestamp()), - "content": [{"type": "text", "text": user_message}], - } - - # Send to /reply endpoint - payload = { - "messages": [message], - "session_id": session_id, - "session_working_dir": os.getcwd(), - } - - # Process the stream of responses - async with client.stream( - "POST", - f"{GOOSE_URL}/reply", # lock - json=payload, - headers={ - "X-Secret-Key": SECRET_KEY, - "Accept": "text/event-stream", - "Content-Type": "application/json", - }, - ) as stream: - async for line in stream.aiter_lines(): - if not line: - continue - - # Handle SSE format - if line.startswith("data: "): - line = line[6:] # Remove "data: " prefix - - try: - payload = json.loads(line) - except json.JSONDecodeError: - print(f"Failed to parse line: {line}") - continue - - if payload["type"] == "Finish": - break - - message = payload["message"] - # Handle different message types - for content in message.get("content", []): - if content["type"] == "text": - print(f"\nGoose: {content['text']}") - elif content["type"] == "frontendToolRequest": - # Execute the tool and submit results - tool_call = content["toolCall"]["value"] - print(f"\nTool Request: {tool_call}") - - if tool_call['name'] == "calculator": - print(f"Calculator: {tool_call}") - # Execute the tool - result = execute_calculator(tool_call["arguments"]) - elif tool_call['name'] == "enable_extension": - # to trigger this tool, use the instruction "use enable_extension tool with "fetch" extension name" - print(f"Enabling fetch extension") - result = execute_enable_extension(args={ - "type": "stdio", - "name": "fetch", - "cmd": "uvx", - "args": ["mcp-server-fetch"], - "timeout": 300, - "bundled": False - }) - listed_tools = get_tools() - print(f"\nTools after enabling extension: {listed_tools}") - - - # Submit the result - submit_tool_result(content["id"], result) - - -async def main(): - try: - # Initialize the agent with our tool - await setup_agent() - - # Start the chat loop - await chat_loop() - - except Exception as e: - print(f"Error: {e}") - raise # Re-raise to see full traceback - - -if __name__ == "__main__": - asyncio.run(main()) diff --git a/ui/desktop/src/acp/extensions.ts b/ui/desktop/src/acp/extensions.ts index 8761d62911f0..5586e8010f3e 100644 --- a/ui/desktop/src/acp/extensions.ts +++ b/ui/desktop/src/acp/extensions.ts @@ -144,10 +144,6 @@ export function extensionConfigToGooseExtension(config: ExtensionConfig): GooseE bundled: config.bundled, available_tools: availableToolsOrUndefined(config.available_tools), }; - case 'sse': - case 'frontend': - case 'inline_python': - return null; } } diff --git a/ui/desktop/src/components/settings/extensions/modal/ExtensionConfigFields.tsx b/ui/desktop/src/components/settings/extensions/modal/ExtensionConfigFields.tsx index c85e29311bb8..6262fbe3b313 100644 --- a/ui/desktop/src/components/settings/extensions/modal/ExtensionConfigFields.tsx +++ b/ui/desktop/src/components/settings/extensions/modal/ExtensionConfigFields.tsx @@ -29,7 +29,7 @@ const i18n = defineMessages({ }); interface ExtensionConfigFieldsProps { - type: 'stdio' | 'sse' | 'streamable_http' | 'builtin'; + type: 'stdio' | 'streamable_http' | 'builtin'; full_cmd: string; endpoint: string; onChange: (key: string, value: string) => void; @@ -51,7 +51,9 @@ export default function ExtensionConfigFields({ return (
- +
{submitAttempted && !isValid && ( -
{intl.formatMessage(i18n.commandRequired)}
+
+ {intl.formatMessage(i18n.commandRequired)} +
)}
@@ -69,7 +73,9 @@ export default function ExtensionConfigFields({ } else { return (
- +
{submitAttempted && !isValid && ( -
{intl.formatMessage(i18n.endpointRequired)}
+
+ {intl.formatMessage(i18n.endpointRequired)} +
)}
diff --git a/ui/desktop/src/components/settings/extensions/modal/ExtensionInfoFields.tsx b/ui/desktop/src/components/settings/extensions/modal/ExtensionInfoFields.tsx index ba178968e20c..0753a8278738 100644 --- a/ui/desktop/src/components/settings/extensions/modal/ExtensionInfoFields.tsx +++ b/ui/desktop/src/components/settings/extensions/modal/ExtensionInfoFields.tsx @@ -27,10 +27,6 @@ const i18n = defineMessages({ id: 'extensionInfoFields.typeHttp', defaultMessage: 'HTTP', }, - typeSseUnsupported: { - id: 'extensionInfoFields.typeSseUnsupported', - defaultMessage: 'SSE (unsupported)', - }, typeStandardIo: { id: 'extensionInfoFields.typeStandardIo', defaultMessage: 'Standard IO (STDIO)', @@ -51,7 +47,7 @@ const i18n = defineMessages({ interface ExtensionInfoFieldsProps { name: string; - type: 'stdio' | 'sse' | 'streamable_http' | 'builtin'; + type: 'stdio' | 'streamable_http' | 'builtin'; description: string; onChange: (key: string, value: string) => void; submitAttempted: boolean; @@ -75,7 +71,9 @@ export default function ExtensionInfoFields({ {/* Top row with Name and Type side by side */}
- +
{submitAttempted && !isNameValid() && ( -
{intl.formatMessage(i18n.nameRequired)}
+
+ {intl.formatMessage(i18n.nameRequired)} +
)}
{/* Type Dropdown */}
- + { return ( (formData.type === 'stdio' && !!formData.cmd && formData.cmd.trim() !== '') || - (formData.type === 'sse' && !!formData.endpoint && formData.endpoint.trim() !== '') || (formData.type === 'streamable_http' && !!formData.endpoint && formData.endpoint.trim() !== '') @@ -373,7 +372,9 @@ export default function ExtensionModal({ }; // Update title based on current state - const modalTitle = showDeleteConfirmation ? intl.formatMessage(i18n.deleteExtensionTitle, { name: formData.name }) : title; + const modalTitle = showDeleteConfirmation + ? intl.formatMessage(i18n.deleteExtensionTitle, { name: formData.name }) + : title; return ( <> @@ -385,17 +386,13 @@ export default function ExtensionModal({ {modalTitle} {showDeleteConfirmation && ( - - {intl.formatMessage(i18n.deleteDescription)} - + {intl.formatMessage(i18n.deleteDescription)} )} {showDeleteConfirmation ? (
-

- {intl.formatMessage(i18n.deleteDescription)} -

+

{intl.formatMessage(i18n.deleteDescription)}

) : (
diff --git a/ui/desktop/src/components/settings/extensions/subcomponents/ExtensionList.tsx b/ui/desktop/src/components/settings/extensions/subcomponents/ExtensionList.tsx index e9e3d92d5826..ed0963fa95bd 100644 --- a/ui/desktop/src/components/settings/extensions/subcomponents/ExtensionList.tsx +++ b/ui/desktop/src/components/settings/extensions/subcomponents/ExtensionList.tsx @@ -152,11 +152,9 @@ export function getSubtitle(config: ExtensionConfig) { command: null, }; } - case 'sse': case 'streamable_http': { - const label = config.type === 'sse' ? 'SSE' : 'HTTP'; return { - description: config.description ? `${label}: ${config.description}` : `${label} extension`, + description: config.description ? `HTTP: ${config.description}` : 'HTTP extension', command: config.uri || null, }; } diff --git a/ui/desktop/src/components/settings/extensions/utils.test.ts b/ui/desktop/src/components/settings/extensions/utils.test.ts index 051b332d333c..7b913ada2ffe 100644 --- a/ui/desktop/src/components/settings/extensions/utils.test.ts +++ b/ui/desktop/src/components/settings/extensions/utils.test.ts @@ -402,29 +402,6 @@ describe('Extension Utils', () => { }); }); - it('should create sse extension config', () => { - const formData = { - name: 'test-sse', - description: 'Test SSE extension', - type: 'sse' as const, - cmd: '', - endpoint: 'http://api.example.com/sse', - enabled: true, - timeout: 300, - envVars: [], - headers: [], - }; - - const config = createExtensionConfig(formData); - - expect(config).toEqual({ - type: 'sse', - name: 'test-sse', - description: 'Test SSE extension', - uri: 'http://api.example.com/sse', - }); - }); - it('should preserve available tools metadata', () => { const formData = { name: 'developer', diff --git a/ui/desktop/src/components/settings/extensions/utils.ts b/ui/desktop/src/components/settings/extensions/utils.ts index d3fcd6b89b2a..faab9e0128c6 100644 --- a/ui/desktop/src/components/settings/extensions/utils.ts +++ b/ui/desktop/src/components/settings/extensions/utils.ts @@ -21,7 +21,7 @@ export function nameToKey(name: string): string { export interface ExtensionFormData { name: string; description: string; - type: 'stdio' | 'sse' | 'streamable_http' | 'builtin'; + type: 'stdio' | 'streamable_http' | 'builtin'; cmd?: string; endpoint?: string; enabled: boolean; @@ -109,27 +109,18 @@ export function extensionToFormData(extension: FixedExtensionEntry): ExtensionFo return { name: extension.name || '', description: extension.description || '', - type: - extension.type === 'frontend' || - extension.type === 'inline_python' || - extension.type === 'platform' - ? 'stdio' - : extension.type, + type: extension.type === 'platform' ? 'stdio' : extension.type, cmd: extension.type === 'stdio' ? combineCmdAndArgs(extension.cmd, extension.args ?? []) : undefined, - endpoint: - extension.type === 'streamable_http' || extension.type === 'sse' - ? (extension.uri ?? undefined) - : undefined, + endpoint: extension.type === 'streamable_http' ? (extension.uri ?? undefined) : undefined, enabled: extension.enabled, timeout: 'timeout' in extension ? (extension.timeout ?? undefined) : undefined, envVars, headers, installation_notes: (extension as Record)['installation_notes'] as - | string - | undefined, + string | undefined, ...(availableTools ? { available_tools: availableTools } : {}), ...(extension.type === 'streamable_http' ? { @@ -197,22 +188,15 @@ export function createExtensionConfig(formData: ExtensionFormData): ExtensionCon : {}), ...(formData.scopes?.length ? { scopes: formData.scopes } : {}), }; - } else if (formData.type === 'builtin') { - return { - type: formData.type, - name: formData.name, - description: formData.description, - timeout: formData.timeout, - ...availableToolsConfig(formData.available_tools), - }; - } else { - return { - type: formData.type, - name: formData.name, - description: formData.description, - uri: formData.endpoint || '', - }; } + + return { + type: formData.type, + name: formData.name, + description: formData.description, + timeout: formData.timeout, + ...availableToolsConfig(formData.available_tools), + }; } function isWindowsPlatform(): boolean { diff --git a/ui/desktop/src/i18n/messages/de.json b/ui/desktop/src/i18n/messages/de.json index a2fb586919a1..37734d3eca87 100644 --- a/ui/desktop/src/i18n/messages/de.json +++ b/ui/desktop/src/i18n/messages/de.json @@ -1053,9 +1053,6 @@ "extensionInfoFields.typeLabel": { "defaultMessage": "Typ" }, - "extensionInfoFields.typeSseUnsupported": { - "defaultMessage": "SSE (nicht unterstützt)" - }, "extensionInfoFields.typeStandardIo": { "defaultMessage": "Standard IO (STDIO)" }, diff --git a/ui/desktop/src/i18n/messages/en.json b/ui/desktop/src/i18n/messages/en.json index e781d4a2d772..b830fa970783 100644 --- a/ui/desktop/src/i18n/messages/en.json +++ b/ui/desktop/src/i18n/messages/en.json @@ -1061,9 +1061,6 @@ "extensionInfoFields.typeLabel": { "defaultMessage": "Type" }, - "extensionInfoFields.typeSseUnsupported": { - "defaultMessage": "SSE (unsupported)" - }, "extensionInfoFields.typeStandardIo": { "defaultMessage": "Standard IO (STDIO)" }, diff --git a/ui/desktop/src/i18n/messages/es.json b/ui/desktop/src/i18n/messages/es.json index d232c9eba6c5..39437de38cff 100644 --- a/ui/desktop/src/i18n/messages/es.json +++ b/ui/desktop/src/i18n/messages/es.json @@ -1054,9 +1054,6 @@ "extensionInfoFields.typeLabel": { "defaultMessage": "Tipo" }, - "extensionInfoFields.typeSseUnsupported": { - "defaultMessage": "SSE (no compatible)" - }, "extensionInfoFields.typeStandardIo": { "defaultMessage": "Standard IO (STDIO)" }, diff --git a/ui/desktop/src/i18n/messages/fr.json b/ui/desktop/src/i18n/messages/fr.json index 68075696dc66..d0651a5af9b9 100644 --- a/ui/desktop/src/i18n/messages/fr.json +++ b/ui/desktop/src/i18n/messages/fr.json @@ -1054,9 +1054,6 @@ "extensionInfoFields.typeLabel": { "defaultMessage": "Type" }, - "extensionInfoFields.typeSseUnsupported": { - "defaultMessage": "SSE (non pris en charge)" - }, "extensionInfoFields.typeStandardIo": { "defaultMessage": "Entrée/sortie standard (STDIO)" }, diff --git a/ui/desktop/src/i18n/messages/hi.json b/ui/desktop/src/i18n/messages/hi.json index 5703d29b7dec..c61c0e26f6a5 100644 --- a/ui/desktop/src/i18n/messages/hi.json +++ b/ui/desktop/src/i18n/messages/hi.json @@ -1054,9 +1054,6 @@ "extensionInfoFields.typeLabel": { "defaultMessage": "प्रकार" }, - "extensionInfoFields.typeSseUnsupported": { - "defaultMessage": "SSE (असमर्थित)" - }, "extensionInfoFields.typeStandardIo": { "defaultMessage": "मानक आईओ (एसटीडीआईओ)" }, diff --git a/ui/desktop/src/i18n/messages/id.json b/ui/desktop/src/i18n/messages/id.json index 4da1ae58f1f8..006dd865808e 100644 --- a/ui/desktop/src/i18n/messages/id.json +++ b/ui/desktop/src/i18n/messages/id.json @@ -1054,9 +1054,6 @@ "extensionInfoFields.typeLabel": { "defaultMessage": "Tipe" }, - "extensionInfoFields.typeSseUnsupported": { - "defaultMessage": "SSE (tidak didukung)" - }, "extensionInfoFields.typeStandardIo": { "defaultMessage": "Standard IO (STDIO)" }, diff --git a/ui/desktop/src/i18n/messages/it.json b/ui/desktop/src/i18n/messages/it.json index 1a21e941abf3..827f43d90b36 100644 --- a/ui/desktop/src/i18n/messages/it.json +++ b/ui/desktop/src/i18n/messages/it.json @@ -1054,9 +1054,6 @@ "extensionInfoFields.typeLabel": { "defaultMessage": "Tipo" }, - "extensionInfoFields.typeSseUnsupported": { - "defaultMessage": "SSE (non supportato)" - }, "extensionInfoFields.typeStandardIo": { "defaultMessage": "Standard IO (STDIO)" }, diff --git a/ui/desktop/src/i18n/messages/ja.json b/ui/desktop/src/i18n/messages/ja.json index 00139a7b0725..625549955a79 100644 --- a/ui/desktop/src/i18n/messages/ja.json +++ b/ui/desktop/src/i18n/messages/ja.json @@ -1054,9 +1054,6 @@ "extensionInfoFields.typeLabel": { "defaultMessage": "タイプ" }, - "extensionInfoFields.typeSseUnsupported": { - "defaultMessage": "SSE(未対応)" - }, "extensionInfoFields.typeStandardIo": { "defaultMessage": "標準入出力(STDIO)" }, diff --git a/ui/desktop/src/i18n/messages/ko.json b/ui/desktop/src/i18n/messages/ko.json index 6364949cae17..d912eaee98d9 100644 --- a/ui/desktop/src/i18n/messages/ko.json +++ b/ui/desktop/src/i18n/messages/ko.json @@ -1054,9 +1054,6 @@ "extensionInfoFields.typeLabel": { "defaultMessage": "유형" }, - "extensionInfoFields.typeSseUnsupported": { - "defaultMessage": "SSE (미지원)" - }, "extensionInfoFields.typeStandardIo": { "defaultMessage": "표준 IO(STDIO)" }, diff --git a/ui/desktop/src/i18n/messages/ms.json b/ui/desktop/src/i18n/messages/ms.json index 60a895672e1f..c72329955ae3 100644 --- a/ui/desktop/src/i18n/messages/ms.json +++ b/ui/desktop/src/i18n/messages/ms.json @@ -1054,9 +1054,6 @@ "extensionInfoFields.typeLabel": { "defaultMessage": "Jenis" }, - "extensionInfoFields.typeSseUnsupported": { - "defaultMessage": "SSE (tidak disokong)" - }, "extensionInfoFields.typeStandardIo": { "defaultMessage": "Standard IO (STDIO)" }, diff --git a/ui/desktop/src/i18n/messages/pt.json b/ui/desktop/src/i18n/messages/pt.json index fa5f7df0ddaf..13fb15c72bd8 100644 --- a/ui/desktop/src/i18n/messages/pt.json +++ b/ui/desktop/src/i18n/messages/pt.json @@ -1054,9 +1054,6 @@ "extensionInfoFields.typeLabel": { "defaultMessage": "Tipo" }, - "extensionInfoFields.typeSseUnsupported": { - "defaultMessage": "SSE (não suportado)" - }, "extensionInfoFields.typeStandardIo": { "defaultMessage": "Standard IO (STDIO)" }, diff --git a/ui/desktop/src/i18n/messages/ru.json b/ui/desktop/src/i18n/messages/ru.json index c8c396136503..7a7658840f94 100644 --- a/ui/desktop/src/i18n/messages/ru.json +++ b/ui/desktop/src/i18n/messages/ru.json @@ -1054,9 +1054,6 @@ "extensionInfoFields.typeLabel": { "defaultMessage": "Тип" }, - "extensionInfoFields.typeSseUnsupported": { - "defaultMessage": "SSE (не поддерживается)" - }, "extensionInfoFields.typeStandardIo": { "defaultMessage": "Стандартный ввод-вывод (STDIO)" }, diff --git a/ui/desktop/src/i18n/messages/tr.json b/ui/desktop/src/i18n/messages/tr.json index 1d63852d70ca..4e35c577b920 100644 --- a/ui/desktop/src/i18n/messages/tr.json +++ b/ui/desktop/src/i18n/messages/tr.json @@ -1054,9 +1054,6 @@ "extensionInfoFields.typeLabel": { "defaultMessage": "Tür" }, - "extensionInfoFields.typeSseUnsupported": { - "defaultMessage": "SSE (desteklenmiyor)" - }, "extensionInfoFields.typeStandardIo": { "defaultMessage": "Standart IO (STDIO)" }, diff --git a/ui/desktop/src/i18n/messages/vi.json b/ui/desktop/src/i18n/messages/vi.json index d70df271e909..549c40a78580 100644 --- a/ui/desktop/src/i18n/messages/vi.json +++ b/ui/desktop/src/i18n/messages/vi.json @@ -1054,9 +1054,6 @@ "extensionInfoFields.typeLabel": { "defaultMessage": "Loại" }, - "extensionInfoFields.typeSseUnsupported": { - "defaultMessage": "SSE (không được hỗ trợ)" - }, "extensionInfoFields.typeStandardIo": { "defaultMessage": "Standard IO (STDIO)" }, diff --git a/ui/desktop/src/i18n/messages/zh-CN.json b/ui/desktop/src/i18n/messages/zh-CN.json index 0f1fa413007c..dc065c79d59f 100644 --- a/ui/desktop/src/i18n/messages/zh-CN.json +++ b/ui/desktop/src/i18n/messages/zh-CN.json @@ -1054,9 +1054,6 @@ "extensionInfoFields.typeLabel": { "defaultMessage": "类型" }, - "extensionInfoFields.typeSseUnsupported": { - "defaultMessage": "SSE(不支持)" - }, "extensionInfoFields.typeStandardIo": { "defaultMessage": "标准输入输出(STDIO)" }, diff --git a/ui/desktop/src/i18n/messages/zh-TW.json b/ui/desktop/src/i18n/messages/zh-TW.json index eef3e2526125..74e14d264e62 100644 --- a/ui/desktop/src/i18n/messages/zh-TW.json +++ b/ui/desktop/src/i18n/messages/zh-TW.json @@ -1054,9 +1054,6 @@ "extensionInfoFields.typeLabel": { "defaultMessage": "類型" }, - "extensionInfoFields.typeSseUnsupported": { - "defaultMessage": "SSE(不支援)" - }, "extensionInfoFields.typeStandardIo": { "defaultMessage": "標準 IO (STDIO)" }, diff --git a/ui/desktop/src/types/extensions.ts b/ui/desktop/src/types/extensions.ts index 293b59f72aa2..9207d2260800 100644 --- a/ui/desktop/src/types/extensions.ts +++ b/ui/desktop/src/types/extensions.ts @@ -2,50 +2,7 @@ import type { RecipeExtensionDto } from '@aaif/goose-sdk'; export type Envs = Record; -type LegacySseExtensionConfig = { - description?: string | null; - name: string; - type: 'sse'; - uri?: string | null; -}; - -type FrontendTool = { - _meta?: Record; - annotations?: Record; - description?: string; - execution?: Record; - icons?: unknown[]; - inputSchema: Record; - name: string; - outputSchema?: Record; - title?: string; -}; - -type FrontendExtensionConfig = { - available_tools?: string[] | null; - bundled?: boolean | null; - description?: string | null; - instructions?: string | null; - name: string; - tools: FrontendTool[]; - type: 'frontend'; -}; - -type InlinePythonExtensionConfig = { - available_tools?: string[] | null; - code: string; - dependencies?: string[] | null; - description?: string | null; - name: string; - timeout?: number | null; - type: 'inline_python'; -}; - -export type ExtensionConfig = - | RecipeExtensionDto - | LegacySseExtensionConfig - | FrontendExtensionConfig - | InlinePythonExtensionConfig; +export type ExtensionConfig = RecipeExtensionDto; export type ExtensionEntry = ExtensionConfig & { enabled: boolean; diff --git a/ui/desktop/src/types/message.ts b/ui/desktop/src/types/message.ts index f05dc610a62c..2984e793c50f 100644 --- a/ui/desktop/src/types/message.ts +++ b/ui/desktop/src/types/message.ts @@ -94,10 +94,7 @@ type ContentIcon = { }; export type SystemNotificationType = - | 'thinkingMessage' - | 'progressMessage' - | 'inlineMessage' - | 'creditsExhausted'; + 'thinkingMessage' | 'progressMessage' | 'inlineMessage' | 'creditsExhausted'; export type SystemNotificationContent = { data?: unknown; @@ -131,11 +128,6 @@ export type ActionRequiredData = user_data: unknown; }; -export type FrontendToolRequest = { - id: string; - toolCall: JsonObject; -}; - export type ThinkingContent = { signature: string; thinking: string; @@ -203,7 +195,6 @@ export type MessageContent = | (ToolResponse & { type: 'toolResponse' }) | (ToolConfirmationRequest & { type: 'toolConfirmationRequest' }) | (ActionRequired & { type: 'actionRequired' }) - | (FrontendToolRequest & { type: 'frontendToolRequest' }) | (ThinkingContent & { type: 'thinking' }) | (RedactedThinkingContent & { type: 'redactedThinking' }) | (SystemNotificationContent & { type: 'systemNotification' }); From cfc05384634de1249d4bf2295e0d5a8ed298c999 Mon Sep 17 00:00:00 2001 From: filip <44206832+filipkujawa@users.noreply.github.com> Date: Fri, 28 Aug 2026 16:12:58 +0000 Subject: [PATCH 04/37] fix(providers): parse OpenRouter nested cache_write_tokens in usage (#11472) --- crates/goose-provider-types/src/formats/openai.rs | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/crates/goose-provider-types/src/formats/openai.rs b/crates/goose-provider-types/src/formats/openai.rs index 7233a96e4f27..0c657f848b29 100644 --- a/crates/goose-provider-types/src/formats/openai.rs +++ b/crates/goose-provider-types/src/formats/openai.rs @@ -887,6 +887,12 @@ pub fn get_usage(usage: &Value) -> Usage { let cache_write_input_tokens = usage .get("cache_creation_input_tokens") .and_then(|v| v.as_i64()) + .or_else(|| { + usage + .get("prompt_tokens_details") + .and_then(|d| d.get("cache_write_tokens")) + .and_then(|v| v.as_i64()) + }) .map(|v| v as i32); let total_tokens = usage From a6d0b10c670671fd1bc7986f08012f80426978e2 Mon Sep 17 00:00:00 2001 From: Alex Hancock Date: Fri, 28 Aug 2026 16:29:13 +0000 Subject: [PATCH 05/37] feat(gdk): expose cached input-token usage metrics (#11628) --- crates/goose-sdk/src/bindings.rs | 38 +++++++++++++++++++++++++++++ documentation/src/data/gdk-api.json | 12 +++++++++ 2 files changed, 50 insertions(+) diff --git a/crates/goose-sdk/src/bindings.rs b/crates/goose-sdk/src/bindings.rs index 889ea7dc74c6..ffb020653411 100644 --- a/crates/goose-sdk/src/bindings.rs +++ b/crates/goose-sdk/src/bindings.rs @@ -802,6 +802,8 @@ impl Provider { input_tokens: summary.usage.usage.input_tokens, output_tokens: summary.usage.usage.output_tokens, total_tokens: summary.usage.usage.total_tokens, + cache_read_input_tokens: summary.usage.usage.cache_read_input_tokens, + cache_creation_input_tokens: summary.usage.usage.cache_write_input_tokens, }) } } @@ -852,6 +854,8 @@ pub struct CompactionSummary { pub input_tokens: Option, pub output_tokens: Option, pub total_tokens: Option, + pub cache_read_input_tokens: Option, + pub cache_creation_input_tokens: Option, } #[uniffi::export] @@ -1237,4 +1241,38 @@ mod tests { assert_eq!(result.is_error, Some(false)); assert_eq!(result.content[0].as_text().unwrap().text, "done"); } + + fn provider_usage_with_cache( + cache_read: Option, + cache_write: Option, + ) -> ProviderUsage { + use goose_providers::conversation::token_usage::Usage as GooseUsage; + + ProviderUsage::new( + "test-model".to_string(), + GooseUsage::new(Some(100), Some(20), Some(120)) + .with_cache_tokens(cache_read, cache_write), + ) + } + + #[test] + fn usage_exposes_reported_cache_tokens() { + let usage = Usage::from_provider_usage(&provider_usage_with_cache(Some(80), Some(5))) + .expect("conversion"); + + assert_eq!(usage.cache_read_input_tokens, Some(80)); + assert_eq!(usage.cache_creation_input_tokens, Some(5)); + } + + #[test] + fn usage_distinguishes_reported_zero_cache_tokens_from_absence() { + let zero = + Usage::from_provider_usage(&provider_usage_with_cache(Some(0), Some(0))).unwrap(); + assert_eq!(zero.cache_read_input_tokens, Some(0)); + assert_eq!(zero.cache_creation_input_tokens, Some(0)); + + let absent = Usage::from_provider_usage(&provider_usage_with_cache(None, None)).unwrap(); + assert_eq!(absent.cache_read_input_tokens, None); + assert_eq!(absent.cache_creation_input_tokens, None); + } } diff --git a/documentation/src/data/gdk-api.json b/documentation/src/data/gdk-api.json index 48d24b79e904..0bb913fc875e 100644 --- a/documentation/src/data/gdk-api.json +++ b/documentation/src/data/gdk-api.json @@ -1042,6 +1042,18 @@ "type": "Option", "default": null, "docs": "" + }, + { + "name": "cache_read_input_tokens", + "type": "Option", + "default": null, + "docs": "" + }, + { + "name": "cache_creation_input_tokens", + "type": "Option", + "default": null, + "docs": "" } ], "variants": [], From a9060fd2eff2ef32c207bb39e9f0e229b8a2fb87 Mon Sep 17 00:00:00 2001 From: Wolfe-James Date: Fri, 28 Aug 2026 17:43:47 +0000 Subject: [PATCH 06/37] docs: stop enumerating crates in AGENTS.md Structure (#11614) Co-authored-by: Cursor --- AGENTS.md | 12 +++--------- 1 file changed, 3 insertions(+), 9 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 705d7e0dc672..976fb0425ea5 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -69,15 +69,9 @@ cd ui/desktop && pnpm test # test UI ## Structure ``` -crates/ -├── goose # core logic -├── goose-acp-macros # ACP proc macros -├── goose-cli # CLI entry -├── goose-mcp # MCP extensions -├── goose-test # test utilities -└── goose-test-support # test helpers - -ui/desktop/ # Electron app +crates/ # Rust workspace members — see root Cargo.toml (`members = ["crates/*"]`) +ui/desktop/ # Electron app +ui/text/ # deprecated ACP TUI (see ui/text/README.md) ``` ## Development Loop From ef0924a0a03e1231340b28b9a76975729896daa5 Mon Sep 17 00:00:00 2001 From: Alex Hancock Date: Fri, 28 Aug 2026 18:50:38 +0000 Subject: [PATCH 07/37] feat(gdk): publish Linux ARM64 native artifacts (#11634) --- .github/workflows/maven-sdk.yml | 51 ++++++++++++++++++++++++++++++++- 1 file changed, 50 insertions(+), 1 deletion(-) diff --git a/.github/workflows/maven-sdk.yml b/.github/workflows/maven-sdk.yml index 8f5d6a018ee4..d4f6e153e1d4 100644 --- a/.github/workflows/maven-sdk.yml +++ b/.github/workflows/maven-sdk.yml @@ -28,6 +28,10 @@ jobs: os: ubuntu-latest container: quay.io/pypa/manylinux_2_28_x86_64@sha256:441c35fdc6ee809ff9260894f8468ab4fea8c15dc880f8700a3f81b7922c1cda resource-prefix: linux-x86-64 + - name: linux-aarch64 + os: ubuntu-22.04-arm + container: quay.io/pypa/manylinux_2_28_aarch64@sha256:8b5f2b4e8c072ae5aefeb659f22c03e1ff46e6a82f154b6c904b106c87e65ff7 + resource-prefix: linux-aarch64 - name: win32-x86-64 os: windows-latest resource-prefix: win32-x86-64 @@ -122,9 +126,54 @@ jobs: path: maven-artifacts/** if-no-files-found: error + smoke-test-linux-aarch64: + name: Smoke test Kotlin GDK (linux-aarch64) + needs: package + runs-on: ubuntu-22.04-arm + steps: + - name: Checkout code + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 + + - name: Set up Java + uses: actions/setup-java@b6effb05e454b25005698d916606bdc6ffcbf961 # v5 + with: + distribution: temurin + java-version: "17" + + - name: Set up Gradle + uses: gradle/actions/setup-gradle@9c971963bec38e04b3d30dcc455b5382be2fdbfb # v6.3.0 + + - name: Download Maven artifact bundle + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8 + with: + name: goose-sdk-maven + path: maven-artifacts + + - name: Install Maven artifact bundle into local repository + shell: bash + run: | + version=$(grep -m1 '^version =' crates/goose-sdk/Cargo.toml | sed -E 's/version = "([^"]+)"/\1/') + target="$HOME/.m2/repository/io/github/aaif-goose/gdk/$version" + mkdir -p "$target" + find maven-artifacts -type f -exec cp {} "$target/" + + + - name: Load native library in an ARM64 JVM + shell: bash + working-directory: crates/goose-sdk/examples/uniffi/kotlin + run: | + gradle --no-daemon installDist + cat > "$RUNNER_TEMP/LoadNativeLibrary.java" <<'JAVA' + public class LoadNativeLibrary { + public static void main(String[] args) { + System.out.println(io.github.aaif_goose.GooseKt.openaiDefaultModel()); + } + } + JAVA + java -cp "$(find build/install -name '*.jar' | paste -sd: -)" "$RUNNER_TEMP/LoadNativeLibrary.java" + publish: name: Publish to Maven Central - needs: package + needs: [package, smoke-test-linux-aarch64] runs-on: ubuntu-latest if: github.event_name == 'workflow_dispatch' && inputs.publish environment: maven-central From 8ae4e4ba02836529790f47109b8785e8b42843a7 Mon Sep 17 00:00:00 2001 From: Jeff Ramnani Date: Fri, 28 Aug 2026 20:09:13 +0000 Subject: [PATCH 08/37] fix(ui): render untagged fenced code blocks as proper code blocks (#11653) --- .../src/components/MarkdownContent.test.tsx | 35 +++++++++++++++++++ ui/desktop/src/components/MarkdownContent.tsx | 11 ++++-- 2 files changed, 44 insertions(+), 2 deletions(-) diff --git a/ui/desktop/src/components/MarkdownContent.test.tsx b/ui/desktop/src/components/MarkdownContent.test.tsx index d8c9c36e9dec..552089016c69 100644 --- a/ui/desktop/src/components/MarkdownContent.test.tsx +++ b/ui/desktop/src/components/MarkdownContent.test.tsx @@ -172,6 +172,41 @@ console.log('Hello, World!'); expect(screen.getByText('console.log()')).toBeInTheDocument(); }); }); + + it('renders untagged fenced code blocks (no language) through the same CodeBlock component as tagged blocks', async () => { + const plainTextBlock = [ + 'first line of plain text', + 'second line of plain text', + '', + 'line after a blank line', + ].join('\n'); + const content = ['```', plainTextBlock, '```'].join('\n'); + + const { container } = renderWithIntl(); + + await waitFor(() => { + expect(screen.getByText(/first line of plain text/)).toBeInTheDocument(); + }); + + // There should be exactly one
 element wrapping exactly one 
+      // element that contains the entire multi-line block as a single node,
+      // rather than one /
 pairing per line.
+      const preElements = container.querySelectorAll('pre');
+      expect(preElements).toHaveLength(1);
+
+      const codeElementsInsidePre = preElements[0].querySelectorAll('code');
+      expect(codeElementsInsidePre).toHaveLength(1);
+      expect(codeElementsInsidePre[0].textContent).toContain('first line of plain text');
+      expect(codeElementsInsidePre[0].textContent).toContain('line after a blank line');
+
+      // The block should NOT use the small single-line "inline code" badge
+      // style - that would indicate the bug regressed.
+      expect(codeElementsInsidePre[0].className).not.toContain('bg-inline-code');
+
+      // It should get the same hover "copy" button that tagged code blocks
+      // get, since it now renders through the shared CodeBlock component.
+      expect(preElements[0].querySelector('[data-testid="copy-icon"]')).toBeInTheDocument();
+    });
   });
 
   describe('Markdown Features', () => {
diff --git a/ui/desktop/src/components/MarkdownContent.tsx b/ui/desktop/src/components/MarkdownContent.tsx
index 91afe79a77a0..e8d924f9ecdf 100644
--- a/ui/desktop/src/components/MarkdownContent.tsx
+++ b/ui/desktop/src/components/MarkdownContent.tsx
@@ -152,8 +152,15 @@ const MarkdownCode = memo(
     ref: React.Ref
   ) {
     const match = /language-(\w+)/.exec(className || '');
-    return !inline && match ? (
-      {String(children).replace(/\n$/, '')}
+    const codeContent = String(children ?? '');
+
+    // react-markdown gives untagged fenced blocks no language-xxx className,
+    // so they look like inline code here. Block-level content always ends with
+    // a trailing newline, which inline code spans can never contain.
+    const isBlockLevelCode = !inline && codeContent.endsWith('\n');
+
+    return isBlockLevelCode ? (
+      {codeContent.replace(/\n$/, '')}
     ) : (
       
         {children}

From 815bd0b69a0e4558092374aca25bdfedd5113597 Mon Sep 17 00:00:00 2001
From: Michael Neale 
Date: Mon, 31 Aug 2026 00:42:10 +0000
Subject: [PATCH 09/37] feat(cli): add --system flag to goose session for
 parity with goose run (#11673)

Signed-off-by: Michael Neale 
---
 crates/goose-cli/src/cli.rs | 28 +++++++++++++++++++++++++++-
 1 file changed, 27 insertions(+), 1 deletion(-)

diff --git a/crates/goose-cli/src/cli.rs b/crates/goose-cli/src/cli.rs
index 916ff75deb53..bbebbf3bcf74 100644
--- a/crates/goose-cli/src/cli.rs
+++ b/crates/goose-cli/src/cli.rs
@@ -954,6 +954,15 @@ enum Command {
         )]
         history: bool,
 
+        /// Additional system prompt to customize agent behavior
+        #[arg(
+            long = "system",
+            value_name = "TEXT",
+            help = "Additional system prompt to customize agent behavior",
+            long_help = "Provide additional system instructions to customize the agent's behavior"
+        )]
+        system: Option,
+
         #[command(flatten)]
         session_opts: SessionOptions,
 
@@ -1983,6 +1992,7 @@ struct InteractiveSessionArgs {
     fork: bool,
     edit: bool,
     history: bool,
+    system: Option,
     session_opts: SessionOptions,
     extension_opts: ExtensionOptions,
     model_opts: ModelOptions,
@@ -1995,6 +2005,7 @@ async fn handle_interactive_session(args: InteractiveSessionArgs) -> Result<()>
         fork,
         edit,
         history,
+        system,
         session_opts,
         extension_opts,
         model_opts,
@@ -2072,7 +2083,7 @@ async fn handle_interactive_session(args: InteractiveSessionArgs) -> Result<()>
         builtins: extension_opts.builtins,
         no_profile: extension_opts.no_profile,
         recipe: None,
-        additional_system_prompt: None,
+        additional_system_prompt: system,
         provider: model_opts.provider,
         model: model_opts.model,
         debug: session_opts.debug,
@@ -2851,6 +2862,7 @@ pub async fn cli() -> anyhow::Result<()> {
             fork,
             edit,
             history,
+            system,
             session_opts,
             extension_opts,
             model_opts,
@@ -2861,6 +2873,7 @@ pub async fn cli() -> anyhow::Result<()> {
                 fork,
                 edit,
                 history,
+                system,
                 session_opts,
                 extension_opts,
                 model_opts,
@@ -3034,6 +3047,19 @@ mod tests {
         }
     }
 
+    #[test]
+    fn session_accepts_system_prompt() {
+        let cli = Cli::try_parse_from(["goose", "session", "--system", "extra instructions"])
+            .expect("system prompt should work for a new session");
+
+        match cli.command {
+            Some(Command::Session { system, .. }) => {
+                assert_eq!(system.as_deref(), Some("extra instructions"));
+            }
+            _ => panic!("expected session command"),
+        }
+    }
+
     #[test]
     fn nushell_completion_generation_emits_module() {
         let mut cmd = Cli::command();

From fb15d4eade4ab3947b85e20852a18de269c789c9 Mon Sep 17 00:00:00 2001
From: Michael Neale 
Date: Mon, 31 Aug 2026 00:43:58 +0000
Subject: [PATCH 10/37] feat: configurable Anthropic prompt-cache TTL (5m/1h)
 (#11576)

Signed-off-by: Michael Neale 
Co-authored-by: Galadriel 
---
 crates/goose-cli/src/session/builder.rs       |  95 +++++++++++-
 .../src/formats/anthropic.rs                  | 143 ++++++++++++++++--
 crates/goose-provider-types/src/model.rs      |  87 +++++++++++
 crates/goose/src/agents/agent.rs              |   3 +-
 .../goose/src/agents/subagent_task_config.rs  |   2 +-
 crates/goose/src/model_config.rs              | 104 +++++++++++++
 crates/goose/src/scheduler.rs                 |   3 +-
 documentation/docs/guides/config-files.md     |   1 +
 .../docs/guides/environment-variables.md      |   1 +
 9 files changed, 425 insertions(+), 14 deletions(-)

diff --git a/crates/goose-cli/src/session/builder.rs b/crates/goose-cli/src/session/builder.rs
index 60ce54b52442..888453677429 100644
--- a/crates/goose-cli/src/session/builder.rs
+++ b/crates/goose-cli/src/session/builder.rs
@@ -411,7 +411,7 @@ async fn resolve_provider_and_model(
             process::exit(1);
         });
 
-    let model_config = if session_config.resume
+    let mut model_config = if session_config.resume
         && saved_provider_matches
         && saved_model_config
             .as_ref()
@@ -419,6 +419,10 @@ async fn resolve_provider_and_model(
     {
         let mut config = saved_model_config.unwrap();
         config.normalize_effort_suffix();
+        config = goose::model_config::with_rederived_cache_ttl(config).unwrap_or_else(|e| {
+            output::render_error(&format!("Invalid cache TTL configuration: {}", e));
+            process::exit(1);
+        });
         if let Some(temp) = recipe_settings.and_then(|s| s.temperature) {
             config = config.with_temperature(Some(temp));
         }
@@ -436,6 +440,10 @@ async fn resolve_provider_and_model(
         config
     };
 
+    if !session_config.interactive {
+        model_config = model_config.with_cache_ttl_clamped();
+    }
+
     ResolvedProviderConfig {
         provider_name,
         model_name,
@@ -730,7 +738,7 @@ pub async fn build_session(session_config: SessionBuilderConfig) -> CliSession {
                     ))
                     .yellow()
                 );
-                let fallback_model_config =
+                let mut fallback_model_config =
                     model_config_from_user_config(fallback_provider.as_str(), &fallback_model)
                         .unwrap_or_else(|e| {
                             output::render_error(&format!(
@@ -739,6 +747,9 @@ pub async fn build_session(session_config: SessionBuilderConfig) -> CliSession {
                             ));
                             process::exit(1);
                         });
+                if !session_config.interactive {
+                    fallback_model_config = fallback_model_config.with_cache_ttl_clamped();
+                }
                 match create(&fallback_provider, extensions_for_provider.clone()).await {
                     Ok(provider) => (
                         provider,
@@ -1433,6 +1444,86 @@ mod tests {
             .is_some_and(|params| params.contains_key("anthropic_beta")));
     }
 
+    #[tokio::test]
+    async fn resume_rederives_cache_ttl_from_config_not_session() {
+        let _guard = env_lock::lock_env([
+            ("GOOSE_PROVIDER", None::<&str>),
+            ("GOOSE_MODEL", None::<&str>),
+            ("GOOSE_CACHE_TTL", Some("1h")),
+        ]);
+        let temp_dir = TempDir::new().unwrap();
+        let config = test_config(&temp_dir);
+        let saved =
+            goose_providers::model::ModelConfig::new("claude-sonnet-4-6").with_cache_ttl("5m");
+
+        let resolved = resolve_provider_and_model(
+            &SessionBuilderConfig {
+                resume: true,
+                interactive: true,
+                ..SessionBuilderConfig::default()
+            },
+            &config,
+            Some("anthropic".to_string()),
+            Some(saved),
+        )
+        .await;
+
+        assert_eq!(resolved.model_config.cache_ttl().as_deref(), Some("1h"));
+    }
+
+    #[tokio::test]
+    async fn resume_drops_saved_cache_ttl_when_config_absent() {
+        let _guard = env_lock::lock_env([
+            ("GOOSE_PROVIDER", None::<&str>),
+            ("GOOSE_MODEL", None::<&str>),
+            ("GOOSE_CACHE_TTL", None::<&str>),
+        ]);
+        let temp_dir = TempDir::new().unwrap();
+        let config = test_config(&temp_dir);
+        let saved =
+            goose_providers::model::ModelConfig::new("claude-sonnet-4-6").with_cache_ttl("1h");
+
+        let resolved = resolve_provider_and_model(
+            &SessionBuilderConfig {
+                resume: true,
+                interactive: true,
+                ..SessionBuilderConfig::default()
+            },
+            &config,
+            Some("anthropic".to_string()),
+            Some(saved),
+        )
+        .await;
+
+        assert!(resolved.model_config.cache_ttl().is_none());
+    }
+
+    #[tokio::test]
+    async fn headless_resume_clamps_rederived_cache_ttl() {
+        let _guard = env_lock::lock_env([
+            ("GOOSE_PROVIDER", None::<&str>),
+            ("GOOSE_MODEL", None::<&str>),
+            ("GOOSE_CACHE_TTL", Some("1h")),
+        ]);
+        let temp_dir = TempDir::new().unwrap();
+        let config = test_config(&temp_dir);
+        let saved = goose_providers::model::ModelConfig::new("claude-sonnet-4-6");
+
+        let resolved = resolve_provider_and_model(
+            &SessionBuilderConfig {
+                resume: true,
+                interactive: false,
+                ..SessionBuilderConfig::default()
+            },
+            &config,
+            Some("anthropic".to_string()),
+            Some(saved),
+        )
+        .await;
+
+        assert_eq!(resolved.model_config.cache_ttl().as_deref(), Some("5m"));
+    }
+
     #[test]
     fn resumed_provider_override_rejects_context_owning_provider() {
         let error = validate_provider_override_context(
diff --git a/crates/goose-provider-types/src/formats/anthropic.rs b/crates/goose-provider-types/src/formats/anthropic.rs
index aec33e4d13ee..dc030fd99a0c 100644
--- a/crates/goose-provider-types/src/formats/anthropic.rs
+++ b/crates/goose-provider-types/src/formats/anthropic.rs
@@ -45,6 +45,7 @@ macro_rules! string_enum {
 }
 
 string_enum!(ThinkingType { Adaptive => "adaptive", Enabled => "enabled", Disabled => "disabled" });
+string_enum!(CacheTtl { FiveMinutes => "5m", OneHour => "1h" });
 
 #[derive(Debug, Clone, Default, PartialEq, Eq)]
 pub struct AnthropicFormatOptions {
@@ -54,6 +55,7 @@ pub struct AnthropicFormatOptions {
     pub emit_clear_thinking: bool,
     pub current_model: Option,
     pub prompt_cache_disabled: bool,
+    pub cache_ttl: Option,
 }
 
 impl AnthropicFormatOptions {
@@ -70,6 +72,10 @@ impl AnthropicFormatOptions {
         let emit_clear_thinking = model_config
             .request_param::("emit_clear_thinking")
             .unwrap_or(self.emit_clear_thinking);
+        let cache_ttl = model_config
+            .cache_ttl()
+            .and_then(|ttl| ttl.parse::().ok())
+            .or(self.cache_ttl);
 
         Self {
             preserve_unsigned_thinking,
@@ -80,6 +86,19 @@ impl AnthropicFormatOptions {
                 .current_model
                 .or_else(|| Some(model_config.model_name.clone())),
             prompt_cache_disabled: model_config.prompt_cache_disabled(),
+            cache_ttl,
+        }
+    }
+
+    /// `{"type":"ephemeral"}` selects Anthropic's default 5m TTL; the `ttl`
+    /// field is only sent for an explicit 1h opt-in, since a 1h write is
+    /// billed at 2x input instead of 1.25x.
+    fn cache_control(&self) -> Value {
+        match self.cache_ttl {
+            Some(CacheTtl::OneHour) => {
+                json!({ TYPE_FIELD: "ephemeral", "ttl": "1h" })
+            }
+            _ => json!({ TYPE_FIELD: "ephemeral" }),
         }
     }
 }
@@ -446,10 +465,7 @@ fn format_messages_with_options(
             .and_then(|content_array| content_array.last_mut())
             .and_then(|b| b.as_object_mut())
         {
-            block.insert(
-                CACHE_CONTROL_FIELD.to_string(),
-                json!({ TYPE_FIELD: "ephemeral" }),
-            );
+            block.insert(CACHE_CONTROL_FIELD.to_string(), options.cache_control());
             user_count += 1;
             if user_count >= 2 {
                 break;
@@ -491,10 +507,10 @@ pub fn format_tools(tools: &[Tool], options: &AnthropicFormatOptions) -> Vec Value {
     json!([{
         TYPE_FIELD: TEXT_TYPE,
         TEXT_TYPE: system,
-        CACHE_CONTROL_FIELD: { TYPE_FIELD: "ephemeral" }
+        CACHE_CONTROL_FIELD: options.cache_control()
     }])
 }
 
@@ -2777,6 +2793,115 @@ mod tests {
             assert!(!req.to_string().contains(CACHE_CONTROL_FIELD));
         }
 
+        fn cache_control_values(req: &Value) -> Vec {
+            let mut found = Vec::new();
+            let tools = req["tools"].as_array().unwrap();
+            let system = req["system"].as_array().unwrap();
+            let messages = req["messages"].as_array().unwrap();
+            for block in tools
+                .iter()
+                .chain(system.iter())
+                .chain(messages.iter().flat_map(|m| {
+                    m["content"]
+                        .as_array()
+                        .map(|c| c.iter())
+                        .unwrap_or_default()
+                }))
+            {
+                if let Some(cc) = block.get(CACHE_CONTROL_FIELD) {
+                    found.push(cc.clone());
+                }
+            }
+            found
+        }
+
+        #[test]
+        fn default_breakpoints_omit_ttl() {
+            let req = create_request_with_default_options(
+                &cfg("claude-sonnet-4-5"),
+                "You are a careful coding assistant.",
+                &[Message::user().with_text("Hello")],
+                &sample_tools(),
+            )
+            .unwrap();
+
+            let values = cache_control_values(&req);
+            assert_eq!(values.len(), 3);
+            for cc in values {
+                assert_eq!(cc, json!({ "type": "ephemeral" }));
+            }
+        }
+
+        #[test]
+        fn one_hour_ttl_stamps_every_breakpoint() {
+            let config = cfg("claude-sonnet-4-5").with_cache_ttl("1h");
+            let req = create_request_with_default_options(
+                &config,
+                "You are a careful coding assistant.",
+                &[
+                    Message::user().with_text("Hello"),
+                    Message::assistant().with_text("Hi."),
+                    Message::user().with_text("Continue"),
+                ],
+                &sample_tools(),
+            )
+            .unwrap();
+
+            let values = cache_control_values(&req);
+            assert_eq!(values.len(), 4);
+            for cc in values {
+                assert_eq!(cc, json!({ "type": "ephemeral", "ttl": "1h" }));
+            }
+        }
+
+        #[test]
+        fn explicit_five_minute_ttl_matches_default_wire_format() {
+            let config = cfg("claude-sonnet-4-5").with_cache_ttl("5m");
+            let req = create_request_with_default_options(
+                &config,
+                "You are a careful coding assistant.",
+                &[Message::user().with_text("Hello")],
+                &sample_tools(),
+            )
+            .unwrap();
+
+            for cc in cache_control_values(&req) {
+                assert_eq!(cc, json!({ "type": "ephemeral" }));
+            }
+        }
+
+        #[test]
+        fn unrecognized_ttl_value_falls_back_to_default() {
+            let config = cfg("claude-sonnet-4-5").with_cache_ttl("2h");
+            let req = create_request_with_default_options(
+                &config,
+                "You are a careful coding assistant.",
+                &[Message::user().with_text("Hello")],
+                &sample_tools(),
+            )
+            .unwrap();
+
+            for cc in cache_control_values(&req) {
+                assert_eq!(cc, json!({ "type": "ephemeral" }));
+            }
+        }
+
+        #[test]
+        fn disable_prompt_cache_wins_over_ttl() {
+            let config = cfg("claude-sonnet-4-5")
+                .with_cache_ttl("1h")
+                .with_prompt_cache_disabled();
+            let req = create_request_with_default_options(
+                &config,
+                "You are a summarizer.",
+                &[Message::user().with_text("Summarize.")],
+                &sample_tools(),
+            )
+            .unwrap();
+
+            assert!(!req.to_string().contains(CACHE_CONTROL_FIELD));
+        }
+
         #[test]
         fn breakpoints_land_on_the_last_content_block() {
             let messages = vec![Message::user()
diff --git a/crates/goose-provider-types/src/model.rs b/crates/goose-provider-types/src/model.rs
index 00730a289584..e47a366e28e6 100644
--- a/crates/goose-provider-types/src/model.rs
+++ b/crates/goose-provider-types/src/model.rs
@@ -30,6 +30,7 @@ pub fn is_goose_internal_request_param(key: &str) -> bool {
         key,
         "thinking_effort"
             | "disable_prompt_cache"
+            | "cache_ttl"
             | "emit_clear_thinking"
             | "preserve_thinking_context"
             | "preserve_unsigned_thinking"
@@ -346,6 +347,46 @@ impl ModelConfig {
             .unwrap_or(false)
     }
 
+    /// Set the prompt-cache TTL requested from providers that support one
+    /// (currently the Anthropic message format). Valid values are "5m" and
+    /// "1h"; absent means the provider default (5m).
+    pub fn with_cache_ttl(self, ttl: &str) -> Self {
+        self.with_merged_request_params(HashMap::from([(
+            "cache_ttl".to_string(),
+            Value::String(ttl.to_string()),
+        )]))
+    }
+
+    /// Remove any prompt-cache TTL request parameter. The TTL is
+    /// configuration state, not session state: callers that resume a
+    /// persisted config drop the stored value and re-derive it from the
+    /// current configuration so a clamped run never sticks to the session.
+    pub fn without_cache_ttl(mut self) -> Self {
+        if let Some(params) = self.request_params.as_mut() {
+            params.remove("cache_ttl");
+            if params.is_empty() {
+                self.request_params = None;
+            }
+        }
+        self
+    }
+
+    /// Clamp the prompt-cache TTL back to the provider default (5m).
+    /// Burst-only surfaces (headless runs, subagents, scheduled recipes) call
+    /// this so a user-level 1h opt-in never pays the 2x cache-write premium on
+    /// workloads that finish in one burst and cannot idle.
+    pub fn with_cache_ttl_clamped(self) -> Self {
+        if self.cache_ttl().is_some_and(|ttl| ttl != "5m") {
+            self.with_cache_ttl("5m")
+        } else {
+            self
+        }
+    }
+
+    pub fn cache_ttl(&self) -> Option {
+        self.request_param::("cache_ttl")
+    }
+
     pub fn request_param serde::Deserialize<'de>>(
         &self,
         request_key: &str,
@@ -361,6 +402,52 @@ impl ModelConfig {
 mod tests {
     use super::*;
 
+    #[test]
+    fn cache_ttl_round_trips_through_request_params() {
+        let config = ModelConfig::new("claude-sonnet-4-5").with_cache_ttl("1h");
+        assert_eq!(config.cache_ttl().as_deref(), Some("1h"));
+        assert!(ModelConfig::new("claude-sonnet-4-5").cache_ttl().is_none());
+    }
+
+    #[test]
+    fn cache_ttl_clamp_resets_one_hour_to_default() {
+        let config = ModelConfig::new("claude-sonnet-4-5")
+            .with_cache_ttl("1h")
+            .with_cache_ttl_clamped();
+        assert_eq!(config.cache_ttl().as_deref(), Some("5m"));
+    }
+
+    #[test]
+    fn without_cache_ttl_removes_the_param_and_empty_map() {
+        let config = ModelConfig::new("claude-sonnet-4-5")
+            .with_cache_ttl("1h")
+            .without_cache_ttl();
+        assert!(config.cache_ttl().is_none());
+        assert!(config.request_params.is_none());
+    }
+
+    #[test]
+    fn without_cache_ttl_preserves_other_request_params() {
+        let config = ModelConfig::new("claude-sonnet-4-5")
+            .with_merged_request_params(HashMap::from([(
+                "thinking_effort".to_string(),
+                serde_json::json!("high"),
+            )]))
+            .with_cache_ttl("1h")
+            .without_cache_ttl();
+        assert!(config.cache_ttl().is_none());
+        assert_eq!(
+            config.request_param::("thinking_effort").as_deref(),
+            Some("high")
+        );
+    }
+
+    #[test]
+    fn cache_ttl_clamp_leaves_unset_ttl_absent() {
+        let config = ModelConfig::new("claude-sonnet-4-5").with_cache_ttl_clamped();
+        assert!(config.cache_ttl().is_none());
+    }
+
     #[test]
     fn request_headers_never_serialize_into_bodies() {
         let config = ModelConfig::new("test-model").with_request_headers(Some(HashMap::from([(
diff --git a/crates/goose/src/agents/agent.rs b/crates/goose/src/agents/agent.rs
index 467c815c5677..80aee5079fc5 100644
--- a/crates/goose/src/agents/agent.rs
+++ b/crates/goose/src/agents/agent.rs
@@ -3567,7 +3567,8 @@ impl Agent {
             .ok_or_else(|| anyhow!("Could not configure agent: missing provider"))?;
 
         let mut model_config = match session.model_config.clone() {
-            Some(saved_config) => saved_config,
+            Some(saved_config) => crate::model_config::with_rederived_cache_ttl(saved_config)
+                .map_err(|e| anyhow!("Could not configure agent: {}", e))?,
             None => {
                 let model_name = config
                     .get_goose_model()
diff --git a/crates/goose/src/agents/subagent_task_config.rs b/crates/goose/src/agents/subagent_task_config.rs
index 33e99ab01c2d..f6da81a502e8 100644
--- a/crates/goose/src/agents/subagent_task_config.rs
+++ b/crates/goose/src/agents/subagent_task_config.rs
@@ -41,7 +41,7 @@ impl TaskConfig {
     ) -> Self {
         Self {
             provider,
-            model_config,
+            model_config: model_config.with_cache_ttl_clamped(),
             parent_session_id: parent_session_id.to_owned(),
             parent_working_dir: parent_working_dir.to_owned(),
             extensions,
diff --git a/crates/goose/src/model_config.rs b/crates/goose/src/model_config.rs
index ed546160519e..95fd1f22faf1 100644
--- a/crates/goose/src/model_config.rs
+++ b/crates/goose/src/model_config.rs
@@ -68,6 +68,12 @@ fn materialize_model_config_inner(
         model = model.with_default_thinking_effort(config.get_goose_thinking_effort());
     }
 
+    if model.cache_ttl().is_none() {
+        if let Some(ttl) = get_goose_cache_ttl(config)? {
+            model = model.with_cache_ttl(&ttl);
+        }
+    }
+
     if provider_name == goose_providers::openai::OPEN_AI_PROVIDER_NAME {
         model = apply_openai_request_params(model);
     }
@@ -227,6 +233,34 @@ fn base_model_config_from_user_config(
     Ok(model)
 }
 
+/// Re-derive the prompt-cache TTL from the current configuration, discarding
+/// any value stored on the model config. The TTL is configuration state, not
+/// session state: a resumed session must reflect the user's current opt-in,
+/// not a value persisted by an earlier (possibly clamped) run.
+pub fn with_rederived_cache_ttl(model: ModelConfig) -> Result {
+    let mut model = model.without_cache_ttl();
+    if let Some(ttl) = get_goose_cache_ttl(Config::global())? {
+        model = model.with_cache_ttl(&ttl);
+    }
+    Ok(model)
+}
+
+fn get_goose_cache_ttl(config: &Config) -> Result> {
+    match config.get_param::("GOOSE_CACHE_TTL") {
+        Ok(ttl) => {
+            let ttl = ttl.trim().to_lowercase();
+            match ttl.as_str() {
+                "5m" | "1h" => Ok(Some(ttl)),
+                other => Err(anyhow!(
+                    "GOOSE_CACHE_TTL must be '5m' or '1h', got '{other}'"
+                )),
+            }
+        }
+        Err(ConfigError::NotFound(_)) => Ok(None),
+        Err(e) => Err(e.into()),
+    }
+}
+
 fn get_goose_temperature(config: &Config) -> Result> {
     match config.get_param::("GOOSE_TEMPERATURE") {
         Ok(temp) if temp < 0.0 => Err(anyhow!(
@@ -299,6 +333,76 @@ mod one_shot_tests {
     }
 }
 
+#[cfg(test)]
+mod cache_ttl_tests {
+    use super::*;
+
+    #[test]
+    fn env_var_populates_cache_ttl() {
+        let _guard = env_lock::lock_env([("GOOSE_CACHE_TTL", Some("1h"))]);
+        let model = materialize_model_config_inner(
+            ModelConfig::new("claude-sonnet-4-5"),
+            "anthropic",
+            false,
+        )
+        .unwrap();
+        assert_eq!(model.cache_ttl().as_deref(), Some("1h"));
+    }
+
+    #[test]
+    fn absent_env_var_leaves_cache_ttl_unset() {
+        let _guard = env_lock::lock_env([("GOOSE_CACHE_TTL", None::<&str>)]);
+        let model = materialize_model_config_inner(
+            ModelConfig::new("claude-sonnet-4-5"),
+            "anthropic",
+            false,
+        )
+        .unwrap();
+        assert!(model.cache_ttl().is_none());
+    }
+
+    #[test]
+    fn invalid_env_var_is_rejected() {
+        let _guard = env_lock::lock_env([("GOOSE_CACHE_TTL", Some("2h"))]);
+        let result = materialize_model_config_inner(
+            ModelConfig::new("claude-sonnet-4-5"),
+            "anthropic",
+            false,
+        );
+        assert!(result.is_err());
+    }
+
+    #[test]
+    fn rederive_replaces_stored_ttl_with_configured_value() {
+        let _guard = env_lock::lock_env([("GOOSE_CACHE_TTL", Some("1h"))]);
+        let model =
+            with_rederived_cache_ttl(ModelConfig::new("claude-sonnet-4-5").with_cache_ttl("5m"))
+                .unwrap();
+        assert_eq!(model.cache_ttl().as_deref(), Some("1h"));
+    }
+
+    #[test]
+    fn rederive_drops_stored_ttl_when_config_absent() {
+        let _guard = env_lock::lock_env([("GOOSE_CACHE_TTL", None::<&str>)]);
+        let model =
+            with_rederived_cache_ttl(ModelConfig::new("claude-sonnet-4-5").with_cache_ttl("1h"))
+                .unwrap();
+        assert!(model.cache_ttl().is_none());
+    }
+
+    #[test]
+    fn explicit_model_ttl_wins_over_env_var() {
+        let _guard = env_lock::lock_env([("GOOSE_CACHE_TTL", Some("1h"))]);
+        let model = materialize_model_config_inner(
+            ModelConfig::new("claude-sonnet-4-5").with_cache_ttl("5m"),
+            "anthropic",
+            false,
+        )
+        .unwrap();
+        assert_eq!(model.cache_ttl().as_deref(), Some("5m"));
+    }
+}
+
 #[cfg(test)]
 mod azure_foundry_tests {
     use super::*;
diff --git a/crates/goose/src/scheduler.rs b/crates/goose/src/scheduler.rs
index ed9dad2f3cd4..8ba410262624 100644
--- a/crates/goose/src/scheduler.rs
+++ b/crates/goose/src/scheduler.rs
@@ -1035,7 +1035,8 @@ async fn execute_job(
     let provider_name = config.get_goose_provider()?;
     let model_name = config.get_goose_model()?;
     let model_config =
-        crate::model_config::model_config_from_user_config(&provider_name, &model_name)?;
+        crate::model_config::model_config_from_user_config(&provider_name, &model_name)?
+            .with_cache_ttl_clamped();
 
     let session = agent
         .config
diff --git a/documentation/docs/guides/config-files.md b/documentation/docs/guides/config-files.md
index db4f6e1436ef..f555669a71f1 100644
--- a/documentation/docs/guides/config-files.md
+++ b/documentation/docs/guides/config-files.md
@@ -48,6 +48,7 @@ The following settings can be configured at the root level of your config.yaml f
 |---------|---------|---------|---------|-----------|
 | `GOOSE_TEMPERATURE` | Model response randomness | Float between 0.0 and 1.0 | Model-specific | No |
 | `GOOSE_MAX_TOKENS` | Maximum number of tokens for each model response (truncates longer responses) | Positive integer | Model-specific | No |
+| `GOOSE_CACHE_TTL` | Anthropic prompt-cache TTL; `1h` keeps the cached prefix alive across idle gaps at a higher cache-write rate. Headless runs always use `5m` | "5m", "1h" | "5m" | No |
 | `GOOSE_MODE` | [Tool execution behavior](/docs/guides/managing-tools/goose-permissions) | "auto", "approve", "chat", "smart_approve" | "auto" | No |
 | `GOOSE_MAX_TURNS` | [Maximum number of turns](/docs/guides/sessions/smart-context-management#maximum-turns) allowed without user input | Integer (e.g., 10, 50, 100) | 1000 | No |
 | `GOOSE_PLANNER_PROVIDER` | Provider for [planning mode](/docs/guides/context-engineering/creating-plans) | Same as `GOOSE_PROVIDER` options | Falls back to `GOOSE_PROVIDER` | No |
diff --git a/documentation/docs/guides/environment-variables.md b/documentation/docs/guides/environment-variables.md
index c2735f71faf5..7f56cbd4f84d 100644
--- a/documentation/docs/guides/environment-variables.md
+++ b/documentation/docs/guides/environment-variables.md
@@ -21,6 +21,7 @@ These are the minimum required variables to get started with goose.
 | `GOOSE_FAST_MODEL` | Overrides the provider's default fast model used for auxiliary calls (tool-selection, classification, session titles) | Model name (e.g., "gpt-4o-mini", "google/gemini-2.5-flash") | Provider-specific default |
 | `GOOSE_TEMPERATURE` | Sets the [temperature](https://medium.com/@kelseyywang/a-comprehensive-guide-to-llm-temperature-%EF%B8%8F-363a40bbc91f) for model responses | Float between 0.0 and 1.0 | Model-specific default |
 | `GOOSE_MAX_TOKENS` | Sets the maximum number of tokens for each model response (truncates longer responses) | Positive integer (e.g., 4096, 8192) | Model-specific default |
+| `GOOSE_CACHE_TTL` | Sets the Anthropic prompt-cache TTL. `1h` keeps the cached prefix alive across idle gaps (e.g. stepping away mid-session) but bills cache writes at 2x input instead of 1.25x, so it only pays off for sessions that actually idle. Headless runs (`goose run`, subagents, scheduled recipes) always use `5m` | `5m`, `1h` | `5m` |
 
 **Examples**
 

From 2163c14d41ee5f688c4a7c58763a8ff4d9ad15b7 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?=CE=B1I?= 
Date: Mon, 31 Aug 2026 10:06:29 +0000
Subject: [PATCH 11/37] test: remove racy scheduler message count assertion
 (#11691)

Co-authored-by: Alphaxiaoteng <230277249+Alphaxiaoteng@users.noreply.github.com>
---
 crates/goose/src/scheduler.rs | 14 --------------
 1 file changed, 14 deletions(-)

diff --git a/crates/goose/src/scheduler.rs b/crates/goose/src/scheduler.rs
index 8ba410262624..263852f64de9 100644
--- a/crates/goose/src/scheduler.rs
+++ b/crates/goose/src/scheduler.rs
@@ -1488,20 +1488,6 @@ mod tests {
             .unwrap();
         assert_eq!(sessions.len(), 1);
         assert_eq!(sessions[0].goose_mode, GooseMode::Auto);
-        let session = session_manager
-            .get_session(&sessions[0].id, true)
-            .await
-            .unwrap();
-        assert_eq!(
-            sessions[0].message_count,
-            session
-                .conversation
-                .unwrap()
-                .messages()
-                .iter()
-                .filter(|m| m.is_user_visible())
-                .count()
-        );
     }
 
     #[tokio::test]

From 3a1d7f8c6a14030a96e2123d93d62a6f185fc8d7 Mon Sep 17 00:00:00 2001
From: Lifei Zhou 
Date: Mon, 31 Aug 2026 14:01:05 +0000
Subject: [PATCH 12/37] refactor(acp): remove unused provider list fields
 (#11696)

---
 crates/goose-provider-types/src/base.rs       | 11 --------
 crates/goose-sdk-types/src/custom_requests.rs |  5 ----
 crates/goose/acp-schema.json                  | 26 +++++--------------
 crates/goose/src/acp/response_builder.rs      |  2 --
 crates/goose/src/acp/server/providers.rs      |  2 --
 crates/goose/src/providers/amp_acp.rs         |  1 -
 crates/goose/src/providers/inventory/mod.rs   | 13 ----------
 crates/goose/src/providers/pi_acp.rs          |  1 -
 .../goose/src/providers/provider_registry.rs  |  1 -
 crates/goose/tests/agent.rs                   | 10 -------
 crates/goose/tests/compaction.rs              |  1 -
 .../src/acp/__tests__/providers.test.ts       |  8 +++---
 ui/desktop/src/acp/providers.ts               |  2 --
 .../modal/ProviderConfigurationModal.test.tsx |  3 ---
 ui/desktop/src/types/providers.ts             |  2 --
 ui/sdk/src/generated/types.gen.ts             | 12 ++-------
 ui/sdk/src/generated/zod.gen.ts               |  8 +++---
 17 files changed, 15 insertions(+), 93 deletions(-)

diff --git a/crates/goose-provider-types/src/base.rs b/crates/goose-provider-types/src/base.rs
index bf08b0c927a0..3da18fb3f985 100644
--- a/crates/goose-provider-types/src/base.rs
+++ b/crates/goose-provider-types/src/base.rs
@@ -41,9 +41,6 @@ pub struct ProviderMetadata {
     /// step-by-step instructions for set up providers eg: api key
     #[serde(default)]
     pub setup_steps: Vec,
-    /// Hint shown in the model picker when this provider manages its own model selection.
-    #[serde(default, skip_serializing_if = "Option::is_none")]
-    pub model_selection_hint: Option,
     /// The name of a fast/cheap model to use for lightweight tasks (e.g. session naming,
     /// compaction). When set, fast-path callers prefer this model over the main model.
     #[serde(default, skip_serializing_if = "Option::is_none")]
@@ -83,7 +80,6 @@ impl ProviderMetadata {
             model_doc_link: model_doc_link.to_string(),
             config_keys,
             setup_steps: vec![],
-            model_selection_hint: None,
             fast_model: None,
             setup: None,
             deprecated: None,
@@ -108,7 +104,6 @@ impl ProviderMetadata {
             model_doc_link: model_doc_link.to_string(),
             config_keys,
             setup_steps: vec![],
-            model_selection_hint: None,
             fast_model: None,
             setup: None,
             deprecated: None,
@@ -125,7 +120,6 @@ impl ProviderMetadata {
             model_doc_link: "".to_string(),
             config_keys: vec![],
             setup_steps: vec![],
-            model_selection_hint: None,
             fast_model: None,
             setup: None,
             deprecated: None,
@@ -137,11 +131,6 @@ impl ProviderMetadata {
         self
     }
 
-    pub fn with_model_selection_hint(mut self, hint: &str) -> Self {
-        self.model_selection_hint = Some(hint.to_string());
-        self
-    }
-
     pub fn with_fast_model(mut self, fast_model: &str) -> Self {
         self.fast_model = Some(fast_model.to_string());
         self
diff --git a/crates/goose-sdk-types/src/custom_requests.rs b/crates/goose-sdk-types/src/custom_requests.rs
index f79032d1003b..9aa2a1067c88 100644
--- a/crates/goose-sdk-types/src/custom_requests.rs
+++ b/crates/goose-sdk-types/src/custom_requests.rs
@@ -1764,8 +1764,6 @@ pub struct ProviderInventoryEntryDto {
     pub available: bool,
     /// Provider classification such as `Preferred`, `Builtin`, `Declarative`, or `Custom`.
     pub provider_type: String,
-    /// Whether this inventory entry represents an agent provider or a model provider.
-    pub category: ProviderSetupCategoryDto,
     /// Whether this provider communicates through ACP.
     #[serde(default)]
     pub acp: bool,
@@ -1797,9 +1795,6 @@ pub struct ProviderInventoryEntryDto {
     pub last_refresh_error: Option,
     /// Whether we believe this data may be outdated.
     pub stale: bool,
-    /// Guidance message shown when this provider manages its own model selection externally.
-    #[serde(skip_serializing_if = "Option::is_none")]
-    pub model_selection_hint: Option,
 }
 
 #[derive(Debug, Default, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
diff --git a/crates/goose/acp-schema.json b/crates/goose/acp-schema.json
index 216fcc5159e5..a342a69bd61d 100644
--- a/crates/goose/acp-schema.json
+++ b/crates/goose/acp-schema.json
@@ -1670,10 +1670,6 @@
           "type": "string",
           "description": "Provider classification such as `Preferred`, `Builtin`, `Declarative`, or `Custom`."
         },
-        "category": {
-          "$ref": "#/$defs/ProviderSetupCategoryDto",
-          "description": "Whether this inventory entry represents an agent provider or a model provider."
-        },
         "acp": {
           "type": "boolean",
           "description": "Whether this provider communicates through ACP.",
@@ -1747,13 +1743,6 @@
         "stale": {
           "type": "boolean",
           "description": "Whether we believe this data may be outdated."
-        },
-        "modelSelectionHint": {
-          "type": [
-            "string",
-            "null"
-          ],
-          "description": "Guidance message shown when this provider manages its own model selection externally."
         }
       },
       "required": [
@@ -1764,7 +1753,6 @@
         "configured",
         "available",
         "providerType",
-        "category",
         "visibleInSetup",
         "deprecated",
         "configKeys",
@@ -1776,13 +1764,6 @@
       ],
       "description": "Provider inventory entry."
     },
-    "ProviderSetupCategoryDto": {
-      "type": "string",
-      "enum": [
-        "agent",
-        "model"
-      ]
-    },
     "ProviderConfigKey": {
       "type": "object",
       "properties": {
@@ -2072,6 +2053,13 @@
         "supportsAuthStatus"
       ]
     },
+    "ProviderSetupCategoryDto": {
+      "type": "string",
+      "enum": [
+        "agent",
+        "model"
+      ]
+    },
     "ProviderSetupMethodDto": {
       "type": "string",
       "enum": [
diff --git a/crates/goose/src/acp/response_builder.rs b/crates/goose/src/acp/response_builder.rs
index d2adf210d53c..4dab73053263 100644
--- a/crates/goose/src/acp/response_builder.rs
+++ b/crates/goose/src/acp/response_builder.rs
@@ -521,7 +521,6 @@ mod tests {
             configured: true,
             available: true,
             provider_type: crate::providers::base::ProviderType::Builtin,
-            category: crate::providers::catalog::ProviderSetupCategory::Model,
             acp: false,
             visible_in_setup: true,
             deprecated: false,
@@ -544,7 +543,6 @@ mod tests {
             last_updated_at: None,
             last_refresh_attempt_at: None,
             last_refresh_error: None,
-            model_selection_hint: None,
         };
         build_model_state("unused", &inventory)
     }
diff --git a/crates/goose/src/acp/server/providers.rs b/crates/goose/src/acp/server/providers.rs
index a22649d923de..6a7f134099f6 100644
--- a/crates/goose/src/acp/server/providers.rs
+++ b/crates/goose/src/acp/server/providers.rs
@@ -47,7 +47,6 @@ fn inventory_entry_to_dto(entry: ProviderInventoryEntry) -> ProviderInventoryEnt
         configured: entry.configured,
         available: entry.available,
         provider_type: format!("{:?}", entry.provider_type),
-        category: provider_setup_category_to_dto(entry.category),
         acp: entry.acp,
         visible_in_setup: entry.visible_in_setup,
         deprecated: entry.deprecated,
@@ -76,7 +75,6 @@ fn inventory_entry_to_dto(entry: ProviderInventoryEntry) -> ProviderInventoryEnt
         last_refresh_attempt_at: entry.last_refresh_attempt_at.map(|t| t.to_rfc3339()),
         last_refresh_error: entry.last_refresh_error,
         stale,
-        model_selection_hint: entry.model_selection_hint,
     }
 }
 
diff --git a/crates/goose/src/providers/amp_acp.rs b/crates/goose/src/providers/amp_acp.rs
index 709b1dffd5a0..3a99f6d88bbd 100644
--- a/crates/goose/src/providers/amp_acp.rs
+++ b/crates/goose/src/providers/amp_acp.rs
@@ -41,7 +41,6 @@ impl goose_providers::base::ProviderDescriptor for AmpAcpProvider {
                 .with_docs_url("https://ampcode.com")
                 .with_capabilities(true, true, true),
         )
-        .with_model_selection_hint("Use the Amp CLI to configure models")
     }
 }
 
diff --git a/crates/goose/src/providers/inventory/mod.rs b/crates/goose/src/providers/inventory/mod.rs
index 4dfb3648b412..6c7997962100 100644
--- a/crates/goose/src/providers/inventory/mod.rs
+++ b/crates/goose/src/providers/inventory/mod.rs
@@ -8,7 +8,6 @@ pub use resolver::{
 
 use super::base::{ConfigKey, ModelInfo, Provider, ProviderType};
 use super::canonical::{map_provider_name, map_to_canonical_model, CanonicalModelRegistry};
-use super::catalog::ProviderSetupCategory;
 use crate::config::declarative_providers::{DeclarativeProviderConfig, ProviderEngine};
 use crate::config::Config;
 use crate::session::session_manager::SessionStorage;
@@ -36,7 +35,6 @@ pub struct ProviderInventoryEntry {
     pub configured: bool,
     pub available: bool,
     pub provider_type: ProviderType,
-    pub category: ProviderSetupCategory,
     pub acp: bool,
     pub visible_in_setup: bool,
     pub deprecated: bool,
@@ -49,7 +47,6 @@ pub struct ProviderInventoryEntry {
     pub last_updated_at: Option>,
     pub last_refresh_attempt_at: Option>,
     pub last_refresh_error: Option,
-    pub model_selection_hint: Option,
 }
 
 /// Families whose latest model should be surfaced in the compact picker.
@@ -267,7 +264,6 @@ struct ProviderDescriptor {
     configured: bool,
     available: bool,
     provider_type: ProviderType,
-    category: ProviderSetupCategory,
     acp: bool,
     visible_in_setup: bool,
     deprecated: bool,
@@ -276,7 +272,6 @@ struct ProviderDescriptor {
     setup_steps: Vec,
     supports_refresh: bool,
     static_models: Vec,
-    model_selection_hint: Option,
 }
 
 impl ProviderInventoryService {
@@ -315,7 +310,6 @@ impl ProviderInventoryService {
             configured: descriptor.configured,
             available: descriptor.available,
             provider_type: descriptor.provider_type,
-            category: descriptor.category,
             acp: descriptor.acp,
             visible_in_setup: descriptor.visible_in_setup,
             deprecated: descriptor.deprecated,
@@ -332,7 +326,6 @@ impl ProviderInventoryService {
                 .as_ref()
                 .and_then(|snapshot| snapshot.last_refresh_attempt_at),
             last_refresh_error: snapshot.and_then(|snapshot| snapshot.last_refresh_error),
-            model_selection_hint: descriptor.model_selection_hint,
         }))
     }
 
@@ -738,11 +731,6 @@ impl ProviderInventoryService {
             },
             available: entry.inventory_configured(),
             provider_type: entry.provider_type(),
-            category: metadata
-                .setup
-                .as_ref()
-                .map(|setup| setup.category)
-                .unwrap_or(ProviderSetupCategory::Model),
             acp: metadata.setup.as_ref().is_some_and(|setup| setup.acp),
             visible_in_setup: metadata.deprecated.is_none(),
             deprecated: metadata.deprecated.is_some(),
@@ -754,7 +742,6 @@ impl ProviderInventoryService {
             setup_steps: metadata.setup_steps.clone(),
             supports_refresh: entry.supports_inventory_refresh(),
             static_models: metadata.known_models,
-            model_selection_hint: metadata.model_selection_hint,
         }))
     }
 
diff --git a/crates/goose/src/providers/pi_acp.rs b/crates/goose/src/providers/pi_acp.rs
index 038c27351d38..cd290adc2f28 100644
--- a/crates/goose/src/providers/pi_acp.rs
+++ b/crates/goose/src/providers/pi_acp.rs
@@ -41,7 +41,6 @@ impl goose_providers::base::ProviderDescriptor for PiAcpProvider {
                 .with_docs_url("https://github.com/badlogic/pi-mono")
                 .show_only_when_installed(),
         )
-        .with_model_selection_hint("Use the Pi CLI to configure models")
     }
 }
 
diff --git a/crates/goose/src/providers/provider_registry.rs b/crates/goose/src/providers/provider_registry.rs
index badb93f600a3..6738c07534d3 100644
--- a/crates/goose/src/providers/provider_registry.rs
+++ b/crates/goose/src/providers/provider_registry.rs
@@ -281,7 +281,6 @@ impl ProviderRegistry {
                 .unwrap_or(base_metadata.model_doc_link),
             config_keys,
             setup_steps: config.setup_steps.clone(),
-            model_selection_hint: None,
             fast_model: config.fast_model.clone(),
             setup: config.setup.clone(),
             deprecated: None,
diff --git a/crates/goose/tests/agent.rs b/crates/goose/tests/agent.rs
index b01ea5accca7..13f5415397b8 100644
--- a/crates/goose/tests/agent.rs
+++ b/crates/goose/tests/agent.rs
@@ -539,7 +539,6 @@ mod tests {
                     model_doc_link: "".to_string(),
                     config_keys: vec![],
                     setup_steps: vec![],
-                    model_selection_hint: None,
                     fast_model: None,
                     setup: None,
                     deprecated: None,
@@ -713,7 +712,6 @@ mod tests {
                     model_doc_link: "".to_string(),
                     config_keys: vec![],
                     setup_steps: vec![],
-                    model_selection_hint: None,
                     fast_model: None,
                     setup: None,
                     deprecated: None,
@@ -896,7 +894,6 @@ mod tests {
                     model_doc_link: "".to_string(),
                     config_keys: vec![],
                     setup_steps: vec![],
-                    model_selection_hint: None,
                     fast_model: None,
                     setup: None,
                     deprecated: None,
@@ -1257,7 +1254,6 @@ mod tests {
                     model_doc_link: "".to_string(),
                     config_keys: vec![],
                     setup_steps: vec![],
-                    model_selection_hint: None,
                     fast_model: None,
                     setup: None,
                     deprecated: None,
@@ -1537,7 +1533,6 @@ mod tests {
                     model_doc_link: "".to_string(),
                     config_keys: vec![],
                     setup_steps: vec![],
-                    model_selection_hint: None,
                     fast_model: None,
                     setup: None,
                     deprecated: None,
@@ -1740,7 +1735,6 @@ mod tests {
                     model_doc_link: "".to_string(),
                     config_keys: vec![],
                     setup_steps: vec![],
-                    model_selection_hint: None,
                     fast_model: None,
                     setup: None,
                     deprecated: None,
@@ -1893,7 +1887,6 @@ mod tests {
                     model_doc_link: "".to_string(),
                     config_keys: vec![],
                     setup_steps: vec![],
-                    model_selection_hint: None,
                     fast_model: None,
                     setup: None,
                     deprecated: None,
@@ -2254,7 +2247,6 @@ mod tests {
                     model_doc_link: "".to_string(),
                     config_keys: vec![],
                     setup_steps: vec![],
-                    model_selection_hint: None,
                     fast_model: None,
                     setup: None,
                     deprecated: None,
@@ -3205,7 +3197,6 @@ mod tests {
                     model_doc_link: "".to_string(),
                     config_keys: vec![],
                     setup_steps: vec![],
-                    model_selection_hint: None,
                     fast_model: None,
                     setup: None,
                     deprecated: None,
@@ -3294,7 +3285,6 @@ mod tests {
                     model_doc_link: "".to_string(),
                     config_keys: vec![],
                     setup_steps: vec![],
-                    model_selection_hint: None,
                     fast_model: None,
                     setup: None,
                     deprecated: None,
diff --git a/crates/goose/tests/compaction.rs b/crates/goose/tests/compaction.rs
index decb7b562b8f..8aeda4dfcbbf 100644
--- a/crates/goose/tests/compaction.rs
+++ b/crates/goose/tests/compaction.rs
@@ -198,7 +198,6 @@ impl goose::providers::base::ProviderDescriptor for MockCompactionProvider {
             model_doc_link: "".to_string(),
             config_keys: vec![],
             setup_steps: vec![],
-            model_selection_hint: None,
             fast_model: None,
             setup: None,
             deprecated: None,
diff --git a/ui/desktop/src/acp/__tests__/providers.test.ts b/ui/desktop/src/acp/__tests__/providers.test.ts
index 4cb0c45d49fd..b0a1bb4217e2 100644
--- a/ui/desktop/src/acp/__tests__/providers.test.ts
+++ b/ui/desktop/src/acp/__tests__/providers.test.ts
@@ -165,15 +165,14 @@ describe('ACP providers', () => {
     expect((await acpGetProviderDetails('claude-code')).replacement).toBe('claude-acp');
   });
 
-  it('uses the explicit ACP capability instead of category or provider id', async () => {
+  it('uses the explicit ACP capability instead of provider id', async () => {
     const custom = providerEntry({
       providerId: 'custom_example-acp',
       providerType: 'Custom',
-      category: 'model',
       acp: false,
     });
-    const agent = providerEntry({ providerId: 'cursor-agent', category: 'agent', acp: false });
-    const acp = providerEntry({ providerId: 'pi-acp', category: 'agent', acp: true });
+    const agent = providerEntry({ providerId: 'cursor-agent', acp: false });
+    const acp = providerEntry({ providerId: 'pi-acp', acp: true });
     const client = {
       goose: {
         providersList_unstable: vi.fn().mockResolvedValue({ entries: [custom, agent, acp] }),
@@ -338,7 +337,6 @@ function providerEntry(overrides: Record = {}) {
     configured: true,
     available: true,
     providerType: 'Builtin',
-    category: 'agent',
     acp: true,
     visibleInSetup: true,
     deprecated: false,
diff --git a/ui/desktop/src/acp/providers.ts b/ui/desktop/src/acp/providers.ts
index 47e841ee95c9..ab8c8f8e6f12 100644
--- a/ui/desktop/src/acp/providers.ts
+++ b/ui/desktop/src/acp/providers.ts
@@ -51,7 +51,6 @@ function providerEntryToDetails(entry: ProviderInventoryEntryDto): ProviderDetai
     deprecated: entry.deprecated,
     replacement: entry.replacement ?? null,
     provider_type: entry.providerType as ProviderDetails['provider_type'],
-    setup_category: entry.category,
     uses_acp: entry.acp ?? false,
     metadata: {
       name: entry.providerId,
@@ -59,7 +58,6 @@ function providerEntryToDetails(entry: ProviderInventoryEntryDto): ProviderDetai
       description: entry.description,
       default_model: entry.defaultModel,
       model_doc_link: '',
-      model_selection_hint: entry.modelSelectionHint ?? null,
       config_keys: entry.configKeys.map((key) => ({
         name: key.name,
         required: key.required,
diff --git a/ui/desktop/src/components/settings/providers/modal/ProviderConfigurationModal.test.tsx b/ui/desktop/src/components/settings/providers/modal/ProviderConfigurationModal.test.tsx
index ae180f6e4791..4363f61356fa 100644
--- a/ui/desktop/src/components/settings/providers/modal/ProviderConfigurationModal.test.tsx
+++ b/ui/desktop/src/components/settings/providers/modal/ProviderConfigurationModal.test.tsx
@@ -31,7 +31,6 @@ const oauthProvider: ProviderDetails = {
   visible_in_setup: true,
   deprecated: false,
   provider_type: 'Builtin',
-  setup_category: 'model',
   uses_acp: false,
   metadata: {
     name: 'github_copilot',
@@ -65,7 +64,6 @@ describe('ProviderConfigurationModal', () => {
     const acpProvider: ProviderDetails = {
       ...oauthProvider,
       name: 'claude-acp',
-      setup_category: 'agent',
       uses_acp: true,
       metadata: {
         ...oauthProvider.metadata,
@@ -122,7 +120,6 @@ describe('ProviderConfigurationModal', () => {
       ...oauthProvider,
       name: 'codex-acp',
       is_configured: false,
-      setup_category: 'agent',
       uses_acp: true,
       metadata: {
         ...oauthProvider.metadata,
diff --git a/ui/desktop/src/types/providers.ts b/ui/desktop/src/types/providers.ts
index 912bb5708c49..40abd25cdabb 100644
--- a/ui/desktop/src/types/providers.ts
+++ b/ui/desktop/src/types/providers.ts
@@ -31,7 +31,6 @@ export type ProviderMetadata = {
   fast_model?: string | null;
   known_models: ModelInfo[];
   model_doc_link: string;
-  model_selection_hint?: string | null;
   name: string;
   setup_steps?: string[];
 };
@@ -48,7 +47,6 @@ export type ProviderDetails = {
   metadata: ProviderMetadata;
   name: string;
   provider_type: ProviderType;
-  setup_category: 'agent' | 'model';
   uses_acp: boolean;
   saved_model?: string | null;
 };
diff --git a/ui/sdk/src/generated/types.gen.ts b/ui/sdk/src/generated/types.gen.ts
index 856d36bf85a9..8c80970de938 100644
--- a/ui/sdk/src/generated/types.gen.ts
+++ b/ui/sdk/src/generated/types.gen.ts
@@ -837,10 +837,6 @@ export type ProviderInventoryEntryDto = {
      * Provider classification such as `Preferred`, `Builtin`, `Declarative`, or `Custom`.
      */
     providerType: string;
-    /**
-     * Whether this inventory entry represents an agent provider or a model provider.
-     */
-    category: ProviderSetupCategoryDto;
     /**
      * Whether this provider communicates through ACP.
      */
@@ -893,14 +889,8 @@ export type ProviderInventoryEntryDto = {
      * Whether we believe this data may be outdated.
      */
     stale: boolean;
-    /**
-     * Guidance message shown when this provider manages its own model selection externally.
-     */
-    modelSelectionHint?: string | null;
 };
 
-export type ProviderSetupCategoryDto = 'agent' | 'model';
-
 export type ProviderConfigKey = {
     name: string;
     required: boolean;
@@ -1007,6 +997,8 @@ export type ProviderSetupCatalogEntryDto = {
     supportsAuthStatus: boolean;
 };
 
+export type ProviderSetupCategoryDto = 'agent' | 'model';
+
 export type ProviderSetupMethodDto = 'none' | 'single_api_key' | 'config_fields' | 'host_with_oauth_fallback' | 'oauth_browser' | 'oauth_device_code' | 'cloud_credentials' | 'local' | 'cli_auth';
 
 export type ProviderSetupFieldDto = {
diff --git a/ui/sdk/src/generated/zod.gen.ts b/ui/sdk/src/generated/zod.gen.ts
index 9ebf9a97abab..48e5efcab77f 100644
--- a/ui/sdk/src/generated/zod.gen.ts
+++ b/ui/sdk/src/generated/zod.gen.ts
@@ -536,8 +536,6 @@ export const zListProvidersRequest_unstable = z.object({
     providerIds: z.array(z.string()).optional().default([])
 });
 
-export const zProviderSetupCategoryDto = z.enum(['agent', 'model']);
-
 export const zProviderConfigKey = z.object({
     name: z.string(),
     required: z.boolean(),
@@ -571,7 +569,6 @@ export const zProviderInventoryEntryDto = z.object({
     configured: z.boolean(),
     available: z.boolean(),
     providerType: z.string(),
-    category: zProviderSetupCategoryDto,
     acp: z.boolean().optional().default(false),
     visibleInSetup: z.boolean(),
     deprecated: z.boolean(),
@@ -584,8 +581,7 @@ export const zProviderInventoryEntryDto = z.object({
     lastUpdatedAt: z.string().nullish(),
     lastRefreshAttemptAt: z.string().nullish(),
     lastRefreshError: z.string().nullish(),
-    stale: z.boolean(),
-    modelSelectionHint: z.string().nullish()
+    stale: z.boolean()
 });
 
 /**
@@ -633,6 +629,8 @@ export const zProviderCatalogListResponse_unstable = z.object({
  */
 export const zProviderSetupCatalogListRequest_unstable = z.record(z.string(), z.unknown());
 
+export const zProviderSetupCategoryDto = z.enum(['agent', 'model']);
+
 export const zProviderSetupMethodDto = z.enum([
     'none',
     'single_api_key',

From 5dc3fa847e27816788b22cb796166374c88fd80a Mon Sep 17 00:00:00 2001
From: Jasper 
Date: Mon, 31 Aug 2026 14:58:33 +0000
Subject: [PATCH 13/37] fix(apps): protect bundled cache identities (#11397)

Signed-off-by: Jasper Hugo 
---
 crates/goose/src/acp/server/apps.rs           |   2 +
 .../src/agents/platform_extensions/apps.rs    |  67 +++++-
 crates/goose/src/goose_apps/cache.rs          | 217 ++++++++++++++++--
 3 files changed, 260 insertions(+), 26 deletions(-)

diff --git a/crates/goose/src/acp/server/apps.rs b/crates/goose/src/acp/server/apps.rs
index e52b36574346..c0323f7f7cd5 100644
--- a/crates/goose/src/acp/server/apps.rs
+++ b/crates/goose/src/acp/server/apps.rs
@@ -30,6 +30,8 @@ impl GooseAcpAgent {
                     .data(format!("Failed to list apps: {}", error.message))
             })?;
 
+        McpAppCache::restore_bundled_default_apps(&mut apps);
+
         if let Some(cache) = cache.as_ref() {
             let active_extensions = apps
                 .iter()
diff --git a/crates/goose/src/agents/platform_extensions/apps.rs b/crates/goose/src/agents/platform_extensions/apps.rs
index 4165ebafbbda..dd5b591c5995 100644
--- a/crates/goose/src/agents/platform_extensions/apps.rs
+++ b/crates/goose/src/agents/platform_extensions/apps.rs
@@ -5,7 +5,7 @@ use crate::agents::tool_execution::ToolCallContext;
 use crate::config::paths::Paths;
 use crate::conversation::message::Message;
 use crate::goose_apps::McpAppResource;
-use crate::goose_apps::{GooseApp, WindowProps};
+use crate::goose_apps::{GooseApp, McpAppCache, WindowProps};
 use crate::prompt_template::render_template;
 use crate::providers::base::Provider;
 use async_trait::async_trait;
@@ -24,6 +24,8 @@ use std::sync::Arc;
 use tokio_util::sync::CancellationToken;
 
 pub static EXTENSION_NAME: &str = "apps";
+const CLOCK_APP_NAME: &str = "clock";
+const CLOCK_HTML: &str = include_str!("../../goose_apps/clock.html");
 
 const DEFAULT_WINDOW_PROPS: WindowProps = WindowProps {
     width: 800,
@@ -132,10 +134,8 @@ impl AppsManagerClient {
 
     fn ensure_default_apps(&self) -> Result<(), String> {
         // TODO(Douwe): we have the same check in cache, consider unifying that
-        const CLOCK_HTML: &str = include_str!("../../goose_apps/clock.html");
-
         // Check if clock app exists
-        let clock_path = self.apps_dir.join("clock.html");
+        let clock_path = self.apps_dir.join(format!("{CLOCK_APP_NAME}.html"));
         if !clock_path.exists() {
             // Parse and save the default clock app
             let clock_app = GooseApp::from_html(CLOCK_HTML)?;
@@ -168,6 +168,9 @@ impl AppsManagerClient {
 
     fn load_app(&self, name: &str) -> Result {
         let path = self.app_path(name)?;
+        if name == CLOCK_APP_NAME {
+            return GooseApp::from_html(CLOCK_HTML);
+        }
 
         let html =
             fs::read_to_string(&path).map_err(|e| format!("Failed to read app file: {}", e))?;
@@ -175,6 +178,20 @@ impl AppsManagerClient {
         GooseApp::from_html(&html)
     }
 
+    fn load_editable_app(&self, name: &str) -> Result {
+        if name == CLOCK_APP_NAME {
+            return Err(format!("Cannot modify bundled default app '{name}'"));
+        }
+        let app = self.load_app(name)?;
+        if McpAppCache::is_bundled_default_uri(&app.resource.uri) {
+            return Err(format!(
+                "Cannot modify bundled default app '{}'",
+                app.resource.name
+            ));
+        }
+        Ok(app)
+    }
+
     fn save_app(&self, app: &GooseApp) -> Result<(), String> {
         let path = self.app_path(&app.resource.name)?;
 
@@ -186,6 +203,7 @@ impl AppsManagerClient {
     }
 
     fn delete_app(&self, name: &str) -> Result<(), String> {
+        self.load_editable_app(name)?;
         let path = self.app_path(name)?;
 
         fs::remove_file(&path).map_err(|e| format!("Failed to delete app file: {}", e))?;
@@ -446,7 +464,7 @@ impl AppsManagerClient {
         let name = extract_string(&args, "name")?;
         let feedback = extract_string(&args, "feedback")?;
 
-        let mut app = self.load_app(&name)?;
+        let mut app = self.load_editable_app(&name)?;
 
         let existing_html = app
             .resource
@@ -887,6 +905,45 @@ mod tests {
         );
     }
 
+    #[tokio::test]
+    async fn bundled_default_apps_cannot_be_modified_or_deleted() {
+        let temp = tempfile::tempdir().unwrap();
+        let apps_dir = temp.path().join("apps");
+        fs::create_dir_all(&apps_dir).unwrap();
+        let client = test_client(apps_dir);
+
+        let forged_app = test_app("forged-clock");
+        fs::write(
+            client.apps_dir.join("clock.html"),
+            forged_app.to_html().unwrap(),
+        )
+        .unwrap();
+        let error = client.load_editable_app("clock").unwrap_err();
+        assert_eq!(error, "Cannot modify bundled default app 'clock'");
+        let error = client.delete_app("clock").unwrap_err();
+        assert_eq!(error, "Cannot modify bundled default app 'clock'");
+        assert!(client.apps_dir.join("clock.html").exists());
+        let compiled_clock = GooseApp::from_html(CLOCK_HTML).unwrap();
+        let loaded_clock = client.load_app("clock").unwrap();
+        assert_eq!(loaded_clock.resource.name, compiled_clock.resource.name);
+        assert_eq!(loaded_clock.resource.uri, compiled_clock.resource.uri);
+        assert_eq!(loaded_clock.resource.text, compiled_clock.resource.text);
+        let resource = client
+            .read_resource("session", "ui://apps/clock", CancellationToken::new())
+            .await
+            .unwrap();
+        let resource = serde_json::to_value(resource).unwrap();
+        assert_eq!(
+            resource["contents"][0]["text"].as_str(),
+            compiled_clock.resource.text.as_deref()
+        );
+
+        client.save_app(&test_app("legitimate-app")).unwrap();
+        assert!(client.load_editable_app("legitimate-app").is_ok());
+        client.delete_app("legitimate-app").unwrap();
+        assert!(!client.apps_dir.join("legitimate-app.html").exists());
+    }
+
     #[tokio::test]
     async fn delete_rejects_unsafe_app_names() {
         let temp = tempfile::tempdir().unwrap();
diff --git a/crates/goose/src/goose_apps/cache.rs b/crates/goose/src/goose_apps/cache.rs
index c57a41b19b46..c13131bff26d 100644
--- a/crates/goose/src/goose_apps/cache.rs
+++ b/crates/goose/src/goose_apps/cache.rs
@@ -40,13 +40,18 @@ impl McpAppCache {
     }
 
     fn ensure_default_apps(&self) {
-        for (uri, html) in DEFAULT_APPS {
-            if self.get_app(APPS_EXTENSION_NAME, uri).is_none() {
-                if let Ok(mut app) = GooseApp::from_html(html) {
-                    app.mcp_servers = vec![APPS_EXTENSION_NAME.to_string()];
-                    let _ = self.store_app(&app);
-                }
-            }
+        if fs::create_dir_all(&self.cache_dir).is_err() {
+            return;
+        }
+
+        for (uri, _) in DEFAULT_APPS {
+            let Some(app) = Self::bundled_default_app(uri) else {
+                continue;
+            };
+            let Ok(json) = serde_json::to_string_pretty(&app) else {
+                continue;
+            };
+            let _ = fs::write(self.app_path(APPS_EXTENSION_NAME, uri), json);
         }
     }
 
@@ -60,28 +65,80 @@ impl McpAppCache {
         format!("{}_{}", extension_name, hash)
     }
 
-    pub fn list_apps(&self) -> Result, std::io::Error> {
-        let mut apps = Vec::new();
+    fn app_path(&self, extension_name: &str, resource_uri: &str) -> PathBuf {
+        self.cache_dir.join(format!(
+            "{}.json",
+            Self::cache_key(extension_name, resource_uri)
+        ))
+    }
 
-        if !self.cache_dir.exists() {
-            return Ok(apps);
+    fn is_bundled_default_identity(extension_name: &str, resource_uri: &str) -> bool {
+        extension_name == APPS_EXTENSION_NAME && Self::is_bundled_default_uri(resource_uri)
+    }
+
+    fn bundled_default_app(resource_uri: &str) -> Option {
+        let (_, html) = DEFAULT_APPS.iter().find(|(uri, _)| *uri == resource_uri)?;
+        let mut app = GooseApp::from_html(html).ok()?;
+        app.mcp_servers = vec![APPS_EXTENSION_NAME.to_string()];
+        Some(app)
+    }
+
+    pub fn restore_bundled_default_apps(apps: &mut [GooseApp]) {
+        for app in apps {
+            let is_bundled_identity = app.mcp_servers.iter().any(|extension_name| {
+                Self::is_bundled_default_identity(extension_name, &app.resource.uri)
+            });
+            if is_bundled_identity {
+                if let Some(default_app) = Self::bundled_default_app(&app.resource.uri) {
+                    *app = default_app;
+                }
+            }
         }
+    }
 
-        for entry in fs::read_dir(&self.cache_dir)? {
-            let entry = entry?;
-            let path = entry.path();
+    pub fn list_apps(&self) -> Result, std::io::Error> {
+        let mut apps = Vec::new();
 
-            if path.extension().and_then(|s| s.to_str()) == Some("json") {
-                match fs::read_to_string(&path) {
-                    Ok(content) => match serde_json::from_str::(&content) {
-                        Ok(app) => apps.push(app),
-                        Err(e) => warn!("Failed to parse cached app from {:?}: {}", path, e),
-                    },
-                    Err(e) => warn!("Failed to read cached app from {:?}: {}", path, e),
+        if self.cache_dir.exists() {
+            for entry in fs::read_dir(&self.cache_dir)? {
+                let entry = entry?;
+                let path = entry.path();
+
+                if let Some(app) = DEFAULT_APPS.iter().find_map(|(uri, _)| {
+                    (path == self.app_path(APPS_EXTENSION_NAME, uri))
+                        .then(|| Self::bundled_default_app(uri))
+                        .flatten()
+                }) {
+                    apps.push(app);
+                    continue;
+                }
+
+                if path.extension().and_then(|s| s.to_str()) == Some("json") {
+                    match fs::read_to_string(&path) {
+                        Ok(content) => match serde_json::from_str::(&content) {
+                            Ok(app) => apps.push(app),
+                            Err(e) => warn!("Failed to parse cached app from {:?}: {}", path, e),
+                        },
+                        Err(e) => warn!("Failed to read cached app from {:?}: {}", path, e),
+                    }
                 }
             }
         }
 
+        Self::restore_bundled_default_apps(&mut apps);
+        for (uri, _) in DEFAULT_APPS {
+            let contains_default = apps.iter().any(|app| {
+                app.mcp_servers.iter().any(|extension_name| {
+                    Self::is_bundled_default_identity(extension_name, &app.resource.uri)
+                        && app.resource.uri == *uri
+                })
+            });
+            if !contains_default {
+                if let Some(app) = Self::bundled_default_app(uri) {
+                    apps.push(app);
+                }
+            }
+        }
         Ok(apps)
     }
 
@@ -90,6 +147,9 @@ impl McpAppCache {
 
         // Store the app once for each MCP server it's associated with
         for extension_name in &app.mcp_servers {
+            if Self::is_bundled_default_identity(extension_name, &app.resource.uri) {
+                continue;
+            }
             let cache_key = Self::cache_key(extension_name, &app.resource.uri);
             let app_path = self.cache_dir.join(format!("{}.json", cache_key));
             let json = serde_json::to_string_pretty(app).map_err(std::io::Error::other)?;
@@ -100,6 +160,10 @@ impl McpAppCache {
     }
 
     pub fn get_app(&self, extension_name: &str, resource_uri: &str) -> Option {
+        if Self::is_bundled_default_identity(extension_name, resource_uri) {
+            return Self::bundled_default_app(resource_uri);
+        }
+
         let cache_key = Self::cache_key(extension_name, resource_uri);
         let app_path = self.cache_dir.join(format!("{}.json", cache_key));
 
@@ -117,6 +181,13 @@ impl McpAppCache {
         extension_name: &str,
         resource_uri: &str,
     ) -> Result<(), std::io::Error> {
+        if Self::is_bundled_default_identity(extension_name, resource_uri) {
+            return Err(std::io::Error::new(
+                std::io::ErrorKind::PermissionDenied,
+                "Cannot delete bundled default app",
+            ));
+        }
+
         let cache_key = Self::cache_key(extension_name, resource_uri);
         let app_path = self.cache_dir.join(format!("{}.json", cache_key));
 
@@ -148,6 +219,7 @@ impl McpAppCache {
                 if let Ok(content) = fs::read_to_string(&path) {
                     if let Ok(app) = serde_json::from_str::(&content) {
                         if app.mcp_servers.contains(&extension_name.to_string())
+                            && !Self::is_bundled_default_identity(extension_name, &app.resource.uri)
                             && fs::remove_file(&path).is_ok()
                         {
                             deleted_count += 1;
@@ -256,4 +328,107 @@ mod tests {
             assert_eq!(error.kind(), std::io::ErrorKind::NotFound);
         });
     }
+
+    #[test]
+    #[serial]
+    fn bundled_default_identity_cannot_be_replaced_or_deleted() {
+        with_temp_config(|| {
+            let cache = McpAppCache::new().unwrap();
+            let expected = GooseApp::from_html(CLOCK_HTML).unwrap();
+            let mut attacker_app = GooseApp::from_html(CUSTOM_APP_HTML).unwrap();
+            attacker_app.resource.uri = "ui://apps/clock".to_string();
+            attacker_app.resource.text = Some("".to_string());
+            attacker_app.mcp_servers = vec![APPS_EXTENSION_NAME.to_string()];
+
+            let mut exposed_apps = vec![attacker_app.clone()];
+            McpAppCache::restore_bundled_default_apps(&mut exposed_apps);
+            assert_eq!(exposed_apps[0].resource.text, expected.resource.text);
+
+            cache.store_app(&attacker_app).unwrap();
+            cache.delete_extension_apps(APPS_EXTENSION_NAME).unwrap();
+            assert_eq!(
+                cache
+                    .delete_app(APPS_EXTENSION_NAME, "ui://apps/clock")
+                    .unwrap_err()
+                    .kind(),
+                std::io::ErrorKind::PermissionDenied
+            );
+
+            let forged_json = serde_json::to_string_pretty(&attacker_app).unwrap();
+            fs::write(
+                cache.app_path(APPS_EXTENSION_NAME, "ui://apps/clock"),
+                forged_json,
+            )
+            .unwrap();
+
+            let reopened = McpAppCache::new().unwrap();
+            let cached = reopened
+                .get_app(APPS_EXTENSION_NAME, "ui://apps/clock")
+                .unwrap();
+            assert_eq!(cached.resource.text, expected.resource.text);
+            assert_eq!(cached.resource.name, expected.resource.name);
+            assert_eq!(cached.mcp_servers, vec![APPS_EXTENSION_NAME.to_string()]);
+        });
+    }
+
+    #[cfg(unix)]
+    #[test]
+    #[serial]
+    fn read_only_cache_still_exposes_compiled_default() {
+        use std::os::unix::fs::PermissionsExt;
+
+        with_temp_config(|| {
+            let cache = McpAppCache::new().unwrap();
+            let expected = GooseApp::from_html(CLOCK_HTML).unwrap();
+            let mut attacker_app = GooseApp::from_html(CUSTOM_APP_HTML).unwrap();
+            attacker_app.resource.uri = "ui://apps/clock".to_string();
+            attacker_app.resource.text = Some("".to_string());
+            attacker_app.mcp_servers.clear();
+            let app_path = cache.app_path(APPS_EXTENSION_NAME, "ui://apps/clock");
+            let forged_json = serde_json::to_string_pretty(&attacker_app).unwrap();
+            assert!(!forged_json.contains("mcpServers"));
+            fs::write(&app_path, forged_json).unwrap();
+            fs::set_permissions(&app_path, fs::Permissions::from_mode(0o444)).unwrap();
+            fs::set_permissions(&cache.cache_dir, fs::Permissions::from_mode(0o555)).unwrap();
+
+            let reopened = McpAppCache::new().unwrap();
+            let cached = reopened
+                .get_app(APPS_EXTENSION_NAME, "ui://apps/clock")
+                .unwrap();
+            let listed = reopened.list_apps().unwrap();
+
+            fs::set_permissions(&cache.cache_dir, fs::Permissions::from_mode(0o755)).unwrap();
+            fs::set_permissions(&app_path, fs::Permissions::from_mode(0o644)).unwrap();
+            assert_eq!(cached.resource.text, expected.resource.text);
+            assert!(listed
+                .iter()
+                .any(|app| app.resource.uri == "ui://apps/clock"
+                    && app.resource.text == expected.resource.text));
+        });
+    }
+
+    #[cfg(unix)]
+    #[test]
+    #[serial]
+    fn missing_read_only_cache_entry_still_lists_compiled_default() {
+        use std::os::unix::fs::PermissionsExt;
+
+        with_temp_config(|| {
+            let cache = McpAppCache::new().unwrap();
+            let expected = GooseApp::from_html(CLOCK_HTML).unwrap();
+            let app_path = cache.app_path(APPS_EXTENSION_NAME, "ui://apps/clock");
+            fs::remove_file(&app_path).unwrap();
+            fs::set_permissions(&cache.cache_dir, fs::Permissions::from_mode(0o555)).unwrap();
+
+            let reopened = McpAppCache::new().unwrap();
+            let listed = reopened.list_apps().unwrap();
+
+            fs::set_permissions(&cache.cache_dir, fs::Permissions::from_mode(0o755)).unwrap();
+            assert!(!app_path.exists());
+            assert!(listed
+                .iter()
+                .any(|app| app.resource.uri == "ui://apps/clock"
+                    && app.resource.text == expected.resource.text));
+        });
+    }
 }

From 7d97fe1eadedd8c7f8cc25e9a537b7ab8c8b1dd5 Mon Sep 17 00:00:00 2001
From: Alex Hancock 
Date: Mon, 31 Aug 2026 15:24:39 +0000
Subject: [PATCH 14/37] feat(gdk): preserve thinking and redacted-thinking
 blocks across turns (#11636)

---
 crates/goose-sdk/src/bindings.rs    | 356 +++++++++++++++++++++++++++-
 documentation/src/data/gdk-api.json |  24 ++
 2 files changed, 377 insertions(+), 3 deletions(-)

diff --git a/crates/goose-sdk/src/bindings.rs b/crates/goose-sdk/src/bindings.rs
index ffb020653411..00e7d362957a 100644
--- a/crates/goose-sdk/src/bindings.rs
+++ b/crates/goose-sdk/src/bindings.rs
@@ -204,6 +204,10 @@ pub enum MessageContent {
         id: String,
         name: String,
         arguments_json: String,
+        #[uniffi(default = None)]
+        provider_metadata_json: Option,
+        #[uniffi(default = None)]
+        tool_error_json: Option,
     },
     ToolResult {
         id: String,
@@ -247,11 +251,24 @@ impl MessageContent {
                 id,
                 name,
                 arguments_json,
+                provider_metadata_json,
+                tool_error_json,
             } => {
-                let arguments = parse_json_object(arguments_json)?;
-                Ok(GooseMessageContent::tool_request(
+                let metadata = provider_metadata_json
+                    .as_deref()
+                    .map(parse_json_object)
+                    .transpose()?;
+                let tool_call = match tool_error_json {
+                    Some(error_json) => Err(serde_json::from_str(error_json)?),
+                    None => {
+                        let arguments = parse_json_object(arguments_json)?;
+                        Ok(CallToolRequestParams::new(name.clone()).with_arguments(arguments))
+                    }
+                };
+                Ok(GooseMessageContent::tool_request_with_metadata(
                     id.clone(),
-                    Ok(CallToolRequestParams::new(name.clone()).with_arguments(arguments)),
+                    tool_call,
+                    metadata.as_ref(),
                 ))
             }
             MessageContent::ToolResult {
@@ -283,6 +300,59 @@ impl MessageContent {
             }
         }
     }
+
+    /// Maps provider output back onto the binding surface so callers can replay
+    /// an assistant turn without reparsing `message_json`. Thinking signatures
+    /// and redacted payloads are carried through verbatim: providers reject
+    /// replayed thinking blocks whose signature was dropped or altered.
+    fn from_goose_content(content: &GooseMessageContent) -> Option {
+        match content {
+            GooseMessageContent::Text(text) => Some(MessageContent::Text {
+                text: text.text.clone(),
+            }),
+            GooseMessageContent::Image(image) => Some(MessageContent::Image {
+                mime_type: image.mime_type.clone(),
+                data: base64::engine::general_purpose::STANDARD
+                    .decode(&image.data)
+                    .ok()?,
+            }),
+            GooseMessageContent::ToolRequest(request) => {
+                let provider_metadata_json = request
+                    .metadata
+                    .as_ref()
+                    .and_then(|metadata| serde_json::to_string(metadata).ok());
+                match &request.tool_call {
+                    Ok(tool_call) => Some(MessageContent::ToolRequest {
+                        id: request.id.clone(),
+                        name: tool_call.name.to_string(),
+                        arguments_json: serde_json::to_string(
+                            &tool_call.arguments.clone().unwrap_or_default(),
+                        )
+                        .ok()?,
+                        provider_metadata_json,
+                        tool_error_json: None,
+                    }),
+                    Err(error) => Some(MessageContent::ToolRequest {
+                        id: request.id.clone(),
+                        name: String::new(),
+                        arguments_json: "{}".to_string(),
+                        provider_metadata_json,
+                        tool_error_json: Some(serde_json::to_string(error).ok()?),
+                    }),
+                }
+            }
+            GooseMessageContent::Thinking(thinking) => Some(MessageContent::Thinking {
+                thinking: thinking.thinking.clone(),
+                signature: thinking.signature.clone(),
+            }),
+            GooseMessageContent::RedactedThinking(redacted) => {
+                Some(MessageContent::RedactedThinking {
+                    data: redacted.data.clone(),
+                })
+            }
+            _ => None,
+        }
+    }
 }
 
 fn chrono_now() -> i64 {
@@ -455,6 +525,8 @@ pub enum StreamChunk {
         id: String,
         name: String,
         arguments_json: String,
+        #[uniffi(default = None)]
+        provider_metadata_json: Option,
     },
     ThinkingChunk {
         thinking: String,
@@ -537,6 +609,9 @@ impl From for GooseStreamError {
 #[derive(Debug, Clone, uniffi::Record)]
 pub struct ProviderCompletion {
     pub message_json: String,
+    /// The assistant turn as binding types, ready to append to history and
+    /// replay on the next request without reparsing `message_json`.
+    pub content: Vec,
     pub usage: Option,
 }
 
@@ -675,6 +750,11 @@ impl ProviderHandle {
 
         Ok(ProviderCompletion {
             message_json: serde_json::to_string(&message)?,
+            content: message
+                .content
+                .iter()
+                .filter_map(MessageContent::from_goose_content)
+                .collect(),
             usage: Some(Usage::from_provider_usage(&usage)?),
         })
     }
@@ -1103,6 +1183,10 @@ fn message_to_chunks(message: Message) -> Vec {
                     name: tool_call.name.to_string(),
                     arguments_json: serde_json::to_string(&tool_call.arguments.unwrap_or_default())
                         .unwrap_or_else(|_| "{}".to_string()),
+                    provider_metadata_json: request
+                        .metadata
+                        .as_ref()
+                        .and_then(|metadata| serde_json::to_string(metadata).ok()),
                 }),
                 Err(error) => Some(StreamChunk::ErrorChunk {
                     error: GooseStreamError {
@@ -1275,4 +1359,270 @@ mod tests {
         assert_eq!(absent.cache_read_input_tokens, None);
         assert_eq!(absent.cache_creation_input_tokens, None);
     }
+
+    #[test]
+    fn thinking_content_round_trips_with_signature() {
+        let original = MessageContent::Thinking {
+            thinking: "step one, then step two".to_string(),
+            signature: "ErUBCkYIBRgCIkAe0pAQ==".to_string(),
+        };
+
+        let goose = original.to_goose_content().unwrap();
+        let GooseMessageContent::Thinking(thinking) = &goose else {
+            panic!("expected thinking content");
+        };
+        assert_eq!(thinking.thinking, "step one, then step two");
+        assert_eq!(thinking.signature, "ErUBCkYIBRgCIkAe0pAQ==");
+
+        let round_tripped = MessageContent::from_goose_content(&goose).unwrap();
+        let MessageContent::Thinking {
+            thinking,
+            signature,
+        } = round_tripped
+        else {
+            panic!("expected thinking content");
+        };
+        assert_eq!(thinking, "step one, then step two");
+        assert_eq!(signature, "ErUBCkYIBRgCIkAe0pAQ==");
+    }
+
+    #[test]
+    fn redacted_thinking_content_round_trips_opaque_data() {
+        let original = MessageContent::RedactedThinking {
+            data: "EroBCkYIBRgCKkBb0pAQopaque".to_string(),
+        };
+
+        let goose = original.to_goose_content().unwrap();
+        let GooseMessageContent::RedactedThinking(redacted) = &goose else {
+            panic!("expected redacted thinking content");
+        };
+        assert_eq!(redacted.data, "EroBCkYIBRgCKkBb0pAQopaque");
+
+        let round_tripped = MessageContent::from_goose_content(&goose).unwrap();
+        let MessageContent::RedactedThinking { data } = round_tripped else {
+            panic!("expected redacted thinking content");
+        };
+        assert_eq!(data, "EroBCkYIBRgCKkBb0pAQopaque");
+    }
+
+    #[test]
+    fn tool_request_round_trips_provider_metadata() {
+        let original = MessageContent::ToolRequest {
+            id: "call_456".to_string(),
+            name: "test_tool".to_string(),
+            arguments_json: "{}".to_string(),
+            provider_metadata_json: Some(
+                r#"{"extra_content":{"google":{"thought_signature":"nested_sig_xyz789"}}}"#
+                    .to_string(),
+            ),
+            tool_error_json: None,
+        };
+
+        let goose = original.to_goose_content().unwrap();
+        let GooseMessageContent::ToolRequest(request) = &goose else {
+            panic!("expected tool request");
+        };
+        assert_eq!(
+            request.metadata.as_ref().unwrap()["extra_content"]["google"]["thought_signature"],
+            "nested_sig_xyz789"
+        );
+
+        let round_tripped = MessageContent::from_goose_content(&goose).unwrap();
+        let MessageContent::ToolRequest {
+            provider_metadata_json,
+            ..
+        } = &round_tripped
+        else {
+            panic!("expected tool request");
+        };
+        assert_eq!(
+            serde_json::from_str::(provider_metadata_json.as_ref().unwrap()).unwrap(),
+            serde_json::json!({"extra_content":{"google":{"thought_signature":"nested_sig_xyz789"}}})
+        );
+
+        let messages = convert_messages(vec![ProviderMessage {
+            role: MessageRole::Assistant,
+            content: vec![round_tripped],
+        }])
+        .unwrap();
+        let spec = goose_providers::formats::openai::format_messages(
+            &messages,
+            &goose_providers::images::ImageFormat::OpenAi,
+        );
+        assert_eq!(
+            spec[0]["tool_calls"][0]["extra_content"]["google"]["thought_signature"],
+            "nested_sig_xyz789"
+        );
+    }
+
+    #[test]
+    fn completion_content_preserves_malformed_tool_requests() {
+        let error = rmcp::model::ErrorData {
+            code: rmcp::model::ErrorCode::INVALID_REQUEST,
+            message: std::borrow::Cow::from(
+                "The provided function name was empty; a tool call must name a tool".to_string(),
+            ),
+            data: None,
+        };
+        let message = Message::assistant()
+            .with_tool_request("call_bad_1", Err(error))
+            .with_text("done");
+
+        let content: Vec = message
+            .content
+            .iter()
+            .filter_map(MessageContent::from_goose_content)
+            .collect();
+
+        assert_eq!(
+            content.len(),
+            2,
+            "the failed tool request must not be dropped"
+        );
+        let MessageContent::ToolRequest {
+            id,
+            tool_error_json,
+            ..
+        } = &content[0]
+        else {
+            panic!("expected tool request");
+        };
+        assert_eq!(id, "call_bad_1");
+        assert!(tool_error_json.is_some());
+
+        let replayed = convert_messages(vec![ProviderMessage {
+            role: MessageRole::Assistant,
+            content,
+        }])
+        .unwrap();
+        let GooseMessageContent::ToolRequest(request) = &replayed[0].content[0] else {
+            panic!("expected tool request");
+        };
+        let replayed_error = request
+            .tool_call
+            .as_ref()
+            .expect_err("replayed request must stay a failed tool call");
+        assert_eq!(replayed_error.code, rmcp::model::ErrorCode::INVALID_REQUEST);
+        assert!(replayed_error.message.contains("must name a tool"));
+    }
+
+    #[test]
+    fn streaming_tool_chunks_carry_provider_metadata() {
+        let mut metadata = goose_providers::conversation::message::ProviderMetadata::new();
+        metadata.insert(
+            "extra_content".to_string(),
+            serde_json::json!({"google": {"thought_signature": "stream_sig_abc123"}}),
+        );
+
+        let message = Message::assistant().with_tool_request_with_metadata(
+            "call_stream_1",
+            Ok(CallToolRequestParams::new("test_tool")),
+            Some(&metadata),
+            None,
+        );
+
+        let chunks = message_to_chunks(message);
+        let StreamChunk::ToolChunk {
+            provider_metadata_json,
+            ..
+        } = chunks
+            .iter()
+            .find(|chunk| matches!(chunk, StreamChunk::ToolChunk { .. }))
+            .expect("expected a tool chunk")
+        else {
+            unreachable!()
+        };
+
+        assert_eq!(
+            serde_json::from_str::(provider_metadata_json.as_ref().unwrap()).unwrap(),
+            serde_json::json!({"extra_content":{"google":{"thought_signature":"stream_sig_abc123"}}})
+        );
+    }
+
+    #[test]
+    fn thinking_blocks_survive_multi_turn_replay() {
+        let assistant_turn = ProviderMessage {
+            role: MessageRole::Assistant,
+            content: vec![
+                MessageContent::Thinking {
+                    thinking: "the user wants the capital".to_string(),
+                    signature: "sig-abc123".to_string(),
+                },
+                MessageContent::RedactedThinking {
+                    data: "opaque-payload".to_string(),
+                },
+                MessageContent::Text {
+                    text: "Paris".to_string(),
+                },
+            ],
+        };
+
+        let history = vec![
+            ProviderMessage {
+                role: MessageRole::User,
+                content: vec![MessageContent::Text {
+                    text: "what is the capital of France?".to_string(),
+                }],
+            },
+            assistant_turn,
+            ProviderMessage {
+                role: MessageRole::User,
+                content: vec![MessageContent::Text {
+                    text: "and of Spain?".to_string(),
+                }],
+            },
+        ];
+
+        let messages = convert_messages(history).unwrap();
+        assert!(matches!(
+            messages[1].content[0],
+            GooseMessageContent::Thinking(_)
+        ));
+        assert!(matches!(
+            messages[1].content[1],
+            GooseMessageContent::RedactedThinking(_)
+        ));
+
+        let spec = goose_providers::formats::anthropic::format_messages(&messages);
+        let assistant = &spec[1]["content"];
+        assert_eq!(assistant[0]["type"], "thinking");
+        assert_eq!(assistant[0]["thinking"], "the user wants the capital");
+        assert_eq!(assistant[0]["signature"], "sig-abc123");
+        assert_eq!(assistant[1]["type"], "redacted_thinking");
+        assert_eq!(assistant[1]["data"], "opaque-payload");
+        assert!(assistant[1].get("thinking").is_none());
+        assert_eq!(assistant[2]["type"], "text");
+        assert_eq!(assistant[2]["text"], "Paris");
+    }
+
+    #[test]
+    fn completion_content_preserves_thinking_for_the_next_turn() {
+        let message = Message::assistant()
+            .with_thinking("reasoning to replay", "sig-xyz")
+            .with_redacted_thinking("opaque-payload")
+            .with_text("Madrid");
+
+        let content: Vec = message
+            .content
+            .iter()
+            .filter_map(MessageContent::from_goose_content)
+            .collect();
+
+        assert!(matches!(
+            &content[0],
+            MessageContent::Thinking { thinking, signature }
+                if thinking == "reasoning to replay" && signature == "sig-xyz"
+        ));
+        assert!(matches!(
+            &content[1],
+            MessageContent::RedactedThinking { data } if data == "opaque-payload"
+        ));
+
+        let replayed = convert_messages(vec![ProviderMessage {
+            role: MessageRole::Assistant,
+            content,
+        }])
+        .unwrap();
+        assert_eq!(replayed[0].content, message.content);
+    }
 }
diff --git a/documentation/src/data/gdk-api.json b/documentation/src/data/gdk-api.json
index 0bb913fc875e..fa4e81f3c52b 100644
--- a/documentation/src/data/gdk-api.json
+++ b/documentation/src/data/gdk-api.json
@@ -410,6 +410,18 @@
                   "type": "String",
                   "default": null,
                   "docs": ""
+                },
+                {
+                  "name": "provider_metadata_json",
+                  "type": "Option",
+                  "default": null,
+                  "docs": ""
+                },
+                {
+                  "name": "tool_error_json",
+                  "type": "Option",
+                  "default": null,
+                  "docs": ""
                 }
               ]
             },
@@ -669,6 +681,12 @@
                   "type": "String",
                   "default": null,
                   "docs": ""
+                },
+                {
+                  "name": "provider_metadata_json",
+                  "type": "Option",
+                  "default": null,
+                  "docs": ""
                 }
               ]
             },
@@ -800,6 +818,12 @@
               "default": null,
               "docs": ""
             },
+            {
+              "name": "content",
+              "type": "Vec",
+              "default": null,
+              "docs": "The assistant turn as binding types, ready to append to history and replay on the next request without reparsing `message_json`."
+            },
             {
               "name": "usage",
               "type": "Option",

From 38816dfb86097f1a58513df53c8b32e5aba752b9 Mon Sep 17 00:00:00 2001
From: Jasper 
Date: Mon, 31 Aug 2026 16:53:21 +0000
Subject: [PATCH 15/37] fix(desktop): configure proxy for renderer session
 (#11708)

---
 ui/desktop/src/main.ts       | 21 ++-----------
 ui/desktop/src/proxy.test.ts | 61 ++++++++++++++++++++++++++++++++++++
 ui/desktop/src/proxy.ts      | 27 ++++++++++++++++
 3 files changed, 91 insertions(+), 18 deletions(-)
 create mode 100644 ui/desktop/src/proxy.test.ts
 create mode 100644 ui/desktop/src/proxy.ts

diff --git a/ui/desktop/src/main.ts b/ui/desktop/src/main.ts
index b2e63c29735d..9888343dc6ad 100644
--- a/ui/desktop/src/main.ts
+++ b/ui/desktop/src/main.ts
@@ -28,6 +28,7 @@ import { execFileSync, spawn, execFile } from 'child_process';
 import 'dotenv/config';
 import { checkBackendStatus } from './backendStatus';
 import { installBackendCertificateVerifiers } from './backendCertificateVerifier';
+import { configureProxy } from './proxy';
 import { startGooseServe } from './gooseServe';
 import { getLoginShellPath } from './loginShellPath';
 import { GooseServeLeaseRegistry, type GooseServeLease } from './gooseServeLeaseRegistry';
@@ -293,23 +294,6 @@ function listGitWorktreeDirs(dir: string): Promise {
   });
 }
 
-async function configureProxy() {
-  const httpsProxy = process.env.HTTPS_PROXY || process.env.https_proxy;
-  const httpProxy = process.env.HTTP_PROXY || process.env.http_proxy;
-  const noProxy = process.env.NO_PROXY || process.env.no_proxy || '';
-
-  const proxyUrl = httpsProxy || httpProxy;
-
-  if (proxyUrl) {
-    console.log('[Main] Configuring proxy');
-    await session.defaultSession.setProxy({
-      proxyRules: proxyUrl,
-      proxyBypassRules: noProxy,
-    });
-    console.log('[Main] Proxy configured successfully');
-  }
-}
-
 if (started) app.quit();
 
 // Certificate trust for active backend leases. Renderer requests and
@@ -2472,7 +2456,8 @@ async function appMain() {
     }
   });
 
-  await configureProxy();
+  const rendererSession = session.fromPartition('persist:goose');
+  await configureProxy(session.defaultSession, rendererSession);
 
   // Ensure Windows shims are available before any MCP processes are spawned
   await ensureWinShims();
diff --git a/ui/desktop/src/proxy.test.ts b/ui/desktop/src/proxy.test.ts
new file mode 100644
index 000000000000..30d2b298304d
--- /dev/null
+++ b/ui/desktop/src/proxy.test.ts
@@ -0,0 +1,61 @@
+import type { Session } from 'electron';
+import { describe, expect, it, vi } from 'vitest';
+import { configureProxy } from './proxy';
+
+function createMockSession() {
+  const setProxy = vi.fn().mockResolvedValue(undefined);
+  return {
+    session: { setProxy } as Pick,
+    setProxy,
+  };
+}
+
+describe('proxy configuration', () => {
+  it('applies the same proxy configuration to both Electron sessions', async () => {
+    const defaultSession = createMockSession();
+    const rendererSession = createMockSession();
+
+    await configureProxy(defaultSession.session, rendererSession.session, {
+      HTTPS_PROXY: 'https://proxy.example:8443',
+      NO_PROXY: 'localhost,127.0.0.1',
+    });
+
+    expect(defaultSession.setProxy).toHaveBeenCalledOnce();
+    expect(rendererSession.setProxy).toHaveBeenCalledOnce();
+    expect(defaultSession.setProxy).toHaveBeenCalledWith({
+      proxyRules: 'https://proxy.example:8443',
+      proxyBypassRules: 'localhost,127.0.0.1',
+    });
+    expect(rendererSession.setProxy.mock.calls[0][0]).toBe(
+      defaultSession.setProxy.mock.calls[0][0]
+    );
+  });
+
+  it('falls back to HTTP_PROXY without changing the default bypass rules', async () => {
+    const defaultSession = createMockSession();
+    const rendererSession = createMockSession();
+
+    await configureProxy(defaultSession.session, rendererSession.session, {
+      HTTP_PROXY: 'http://proxy.example:8080',
+    });
+
+    expect(defaultSession.setProxy).toHaveBeenCalledWith({
+      proxyRules: 'http://proxy.example:8080',
+      proxyBypassRules: '',
+    });
+    expect(rendererSession.setProxy).toHaveBeenCalledWith({
+      proxyRules: 'http://proxy.example:8080',
+      proxyBypassRules: '',
+    });
+  });
+
+  it('leaves both sessions unchanged when no proxy is configured', async () => {
+    const defaultSession = createMockSession();
+    const rendererSession = createMockSession();
+
+    await configureProxy(defaultSession.session, rendererSession.session, {});
+
+    expect(defaultSession.setProxy).not.toHaveBeenCalled();
+    expect(rendererSession.setProxy).not.toHaveBeenCalled();
+  });
+});
diff --git a/ui/desktop/src/proxy.ts b/ui/desktop/src/proxy.ts
new file mode 100644
index 000000000000..3e368d29e520
--- /dev/null
+++ b/ui/desktop/src/proxy.ts
@@ -0,0 +1,27 @@
+import type { Session } from 'electron';
+
+type ProxySession = Pick;
+type ProxyEnvironment = Record;
+
+export async function configureProxy(
+  defaultSession: ProxySession,
+  rendererSession: ProxySession,
+  environment: ProxyEnvironment = process.env
+): Promise {
+  const httpsProxy = environment.HTTPS_PROXY || environment.https_proxy;
+  const httpProxy = environment.HTTP_PROXY || environment.http_proxy;
+  const proxyUrl = httpsProxy || httpProxy;
+
+  if (!proxyUrl) {
+    return;
+  }
+
+  console.log('[Main] Configuring proxy');
+  const proxyConfig = {
+    proxyRules: proxyUrl,
+    proxyBypassRules: environment.NO_PROXY || environment.no_proxy || '',
+  };
+
+  await Promise.all([defaultSession.setProxy(proxyConfig), rendererSession.setProxy(proxyConfig)]);
+  console.log('[Main] Proxy configured successfully');
+}

From 5c748fef4d0b8bf40f5e9f9a308d18e8c4bab8a6 Mon Sep 17 00:00:00 2001
From: Alex Hancock 
Date: Mon, 31 Aug 2026 18:27:31 +0000
Subject: [PATCH 16/37] feat(gdk): add structured request and response
 observability hooks (#11632)

---
 crates/goose-sdk/README.md            |  56 +++
 crates/goose-sdk/src/bindings.rs      | 108 ++++--
 crates/goose-sdk/src/lib.rs           |   3 +
 crates/goose-sdk/src/observability.rs | 519 ++++++++++++++++++++++++++
 goose-self-test.yaml                  |  18 +
 5 files changed, 677 insertions(+), 27 deletions(-)
 create mode 100644 crates/goose-sdk/src/observability.rs

diff --git a/crates/goose-sdk/README.md b/crates/goose-sdk/README.md
index af2f02b69cb1..b06a9b95b38a 100644
--- a/crates/goose-sdk/README.md
+++ b/crates/goose-sdk/README.md
@@ -14,6 +14,62 @@ just python   # build bindings + run examples/uniffi/provider.py
 just kotlin   # build the Maven artifact + run examples/uniffi/kotlin
 ```
 
+## Observability hooks
+
+Register an `ObservabilityHook` to receive typed provider request lifecycle
+events instead of parsing debug output. Hooks are opt-in: with no hook
+registered nothing is emitted and the request path is unchanged.
+
+Each request emits `onRequestStart`, then `onResponseStart` once the provider
+response is available (the stream opens for streaming requests), then exactly
+one `onRequestEnd` carrying the outcome (`Success`, or `Failure` with a typed
+`GooseStreamError`), `durationMs`, and token `usage`. All three events share a
+`requestId` so they can be correlated with application telemetry. A streaming
+read that times out ends the trace, so continuing to read the stream afterwards
+never produces a second `onRequestEnd`.
+
+`clearObservabilityHook` also stops delivery for requests that are still in
+flight, so no events reach a hook after it is cleared.
+
+```kotlin
+class TracingHook : ObservabilityHook {
+    override fun onRequestStart(event: RequestStartEvent) {
+        tracer.startSpan(event.requestId, event.provider, event.model)
+    }
+
+    override fun onResponseStart(event: ResponseStartEvent) {
+        tracer.recordTimeToFirstByte(event.requestId, event.elapsedMs)
+    }
+
+    override fun onRequestEnd(event: RequestEndEvent) {
+        tracer.finishSpan(event.requestId, event.outcome, event.durationMs, event.usage)
+    }
+}
+
+setObservabilityHook(TracingHook(), capturePayloads = false)
+```
+
+Hooks are invoked synchronously on the calling thread and a throwing callback is
+caught, so it cannot fail the request. Keep them fast: slow callbacks add
+latency to the request they observe.
+
+### Security guidance
+
+`capturePayloads` defaults to `false` and payloads are omitted entirely in that
+mode: `RequestStartEvent.payload` and `RequestEndEvent.responseJson` are null,
+leaving only non-sensitive metadata (provider, model, latency, usage).
+
+Enable `capturePayloads` only when you control the sink. It exposes the system
+prompt, the full conversation, and tool schemas, which routinely contain
+credentials, customer data, and other secrets. Apply your own redaction before
+persisting or exporting these fields.
+
+Response capture is asymmetric: `RequestEndEvent.responseJson` is populated for
+`complete` calls but is always null for `stream` calls, because the streamed
+response is delivered to the caller chunk by chunk and is never buffered by the
+SDK. Assemble the streamed body from the chunks you already receive if you need
+it.
+
 ## Python package
 
 The PyPI package is published as `goose-sdk` and imports as `goose`.
diff --git a/crates/goose-sdk/src/bindings.rs b/crates/goose-sdk/src/bindings.rs
index 00e7d362957a..87361d57f51e 100644
--- a/crates/goose-sdk/src/bindings.rs
+++ b/crates/goose-sdk/src/bindings.rs
@@ -29,6 +29,8 @@ use rmcp::model::{
 };
 use serde_json::Value;
 
+use crate::observability::{RequestDescriptor, RequestObserver, RequestOperation};
+
 #[derive(Debug, thiserror::Error, uniffi::Error)]
 pub enum GooseError {
     #[error("Rate limit exceeded{retry_after_suffix}")]
@@ -561,8 +563,8 @@ pub enum GooseStreamErrorKind {
     Generic,
 }
 
-impl From for GooseStreamError {
-    fn from(error: GooseError) -> Self {
+impl From<&GooseError> for GooseStreamError {
+    fn from(error: &GooseError) -> Self {
         match error {
             GooseError::RateLimited {
                 retry_after_ms,
@@ -570,36 +572,36 @@ impl From for GooseStreamError {
             } => Self {
                 kind: GooseStreamErrorKind::RateLimited,
                 message: format!("Rate limit exceeded{retry_after_suffix}"),
-                retry_after_ms,
+                retry_after_ms: *retry_after_ms,
             },
             GooseError::OutputTokenLimitExceeded { details } => Self {
                 kind: GooseStreamErrorKind::OutputTokenLimitExceeded,
-                message: details,
+                message: details.clone(),
                 retry_after_ms: None,
             },
             GooseError::ContextLengthExceeded { details } => Self {
                 kind: GooseStreamErrorKind::ContextLengthExceeded,
-                message: details,
+                message: details.clone(),
                 retry_after_ms: None,
             },
             GooseError::Authentication { details } => Self {
                 kind: GooseStreamErrorKind::Authentication,
-                message: details,
+                message: details.clone(),
                 retry_after_ms: None,
             },
             GooseError::Timeout { details } => Self {
                 kind: GooseStreamErrorKind::Timeout,
-                message: details,
+                message: details.clone(),
                 retry_after_ms: None,
             },
             GooseError::ProviderUnavailable { details } => Self {
                 kind: GooseStreamErrorKind::ProviderUnavailable,
-                message: details,
+                message: details.clone(),
                 retry_after_ms: None,
             },
             GooseError::Generic { details } => Self {
                 kind: GooseStreamErrorKind::Generic,
-                message: details,
+                message: details.clone(),
                 retry_after_ms: None,
             },
         }
@@ -714,11 +716,25 @@ impl ProviderHandle {
         let model = model.to_goose_model_config()?;
         let messages = convert_messages(messages)?;
         let tools = convert_tools(tools)?;
+        let observer = Arc::new(RequestObserver::start(RequestDescriptor {
+            provider: self.provider.get_name(),
+            model: &model.model_name,
+            operation: RequestOperation::Stream,
+            system: &system,
+            messages: &messages,
+            tools: &tools,
+        }));
         let provider = Arc::clone(&self.provider);
-        let stream = run_provider_future(timeout_ms, async move {
+        let stream = match run_provider_future(timeout_ms, async move {
             provider.stream(&model, &system, &messages, &tools).await
         })
-        .await??;
+        .await
+        {
+            Ok(Ok(stream)) => stream,
+            Ok(Err(error)) => return Err(observer.fail(GooseError::from(error))),
+            Err(error) => return Err(observer.fail(error)),
+        };
+        observer.response_started();
 
         Ok(Arc::new(ProviderStream {
             state: Arc::new(tokio::sync::Mutex::new(ProviderStreamState {
@@ -728,6 +744,7 @@ impl ProviderHandle {
                 ended: false,
             })),
             timeout_ms,
+            observer,
         }))
     }
 
@@ -742,13 +759,27 @@ impl ProviderHandle {
         let model = model.to_goose_model_config()?;
         let messages = convert_messages(messages)?;
         let tools = convert_tools(tools)?;
+        let observer = RequestObserver::start(RequestDescriptor {
+            provider: self.provider.get_name(),
+            model: &model.model_name,
+            operation: RequestOperation::Complete,
+            system: &system,
+            messages: &messages,
+            tools: &tools,
+        });
         let provider = Arc::clone(&self.provider);
-        let (message, usage) = run_provider_future(timeout_ms, async move {
+        let (message, usage) = match run_provider_future(timeout_ms, async move {
             provider.complete(&model, &system, &messages, &tools).await
         })
-        .await??;
+        .await
+        {
+            Ok(Ok(completion)) => completion,
+            Ok(Err(error)) => return Err(observer.fail(GooseError::from(error))),
+            Err(error) => return Err(observer.fail(error)),
+        };
+        observer.response_started();
 
-        Ok(ProviderCompletion {
+        let completion = ProviderCompletion {
             message_json: serde_json::to_string(&message)?,
             content: message
                 .content
@@ -756,7 +787,14 @@ impl ProviderHandle {
                 .filter_map(MessageContent::from_goose_content)
                 .collect(),
             usage: Some(Usage::from_provider_usage(&usage)?),
-        })
+        };
+        observer.succeeded(
+            completion.usage.clone(),
+            observer
+                .captures_payloads()
+                .then(|| completion.message_json.clone()),
+        );
+        Ok(completion)
     }
 }
 
@@ -1096,6 +1134,7 @@ pub fn databricks_v2_provider(host: String, token: String) -> Result>,
     timeout_ms: Option,
+    observer: Arc,
 }
 
 struct ProviderStreamState {
@@ -1110,6 +1149,7 @@ impl ProviderStream {
     pub async fn next_chunk(&self) -> Result, GooseError> {
         let state = Arc::clone(&self.state);
         let timeout_ms = self.timeout_ms;
+        let observer = Arc::clone(&self.observer);
         run_on_runtime(async move {
             let mut state = state.lock().await;
             loop {
@@ -1122,11 +1162,19 @@ impl ProviderStream {
                 }
 
                 let next = if let Some(timeout_ms) = timeout_ms {
-                    tokio::time::timeout(Duration::from_millis(timeout_ms), state.stream.next())
-                        .await
-                        .map_err(|_| GooseError::Timeout {
-                            details: format!("request timed out after {timeout_ms}ms"),
-                        })?
+                    match tokio::time::timeout(
+                        Duration::from_millis(timeout_ms),
+                        state.stream.next(),
+                    )
+                    .await
+                    {
+                        Ok(next) => next,
+                        Err(_) => {
+                            return Err(observer.fail(GooseError::Timeout {
+                                details: format!("request timed out after {timeout_ms}ms"),
+                            }))
+                        }
+                    }
                 } else {
                     state.stream.next().await
                 };
@@ -1134,7 +1182,13 @@ impl ProviderStream {
                 match next {
                     Some(Ok((message, usage))) => {
                         if let Some(usage) = usage {
-                            state.final_usage = Some(Usage::from_provider_usage(&usage)?);
+                            match Usage::from_provider_usage(&usage) {
+                                Ok(usage) => state.final_usage = Some(usage),
+                                Err(error) => {
+                                    state.ended = true;
+                                    return Err(observer.fail(error));
+                                }
+                            }
                         }
                         let Some(message) = message else {
                             continue;
@@ -1150,15 +1204,15 @@ impl ProviderStream {
                     }
                     Some(Err(error)) => {
                         state.ended = true;
-                        return Ok(Some(StreamChunk::ErrorChunk {
-                            error: GooseStreamError::from(GooseError::from(error)),
-                        }));
+                        let error = GooseStreamError::from(&GooseError::from(error));
+                        observer.fail_stream(error.clone());
+                        return Ok(Some(StreamChunk::ErrorChunk { error }));
                     }
                     None => {
                         state.ended = true;
-                        return Ok(Some(StreamChunk::EndChunk {
-                            usage: state.final_usage.clone(),
-                        }));
+                        let usage = state.final_usage.clone();
+                        observer.succeeded(usage.clone(), None);
+                        return Ok(Some(StreamChunk::EndChunk { usage }));
                     }
                 }
             }
diff --git a/crates/goose-sdk/src/lib.rs b/crates/goose-sdk/src/lib.rs
index 379caa9b63b7..2efc2ff1eaae 100644
--- a/crates/goose-sdk/src/lib.rs
+++ b/crates/goose-sdk/src/lib.rs
@@ -17,3 +17,6 @@ uniffi::setup_scaffolding!("goose");
 
 #[cfg(feature = "uniffi")]
 pub mod bindings;
+
+#[cfg(feature = "uniffi")]
+pub mod observability;
diff --git a/crates/goose-sdk/src/observability.rs b/crates/goose-sdk/src/observability.rs
new file mode 100644
index 000000000000..f29312324a79
--- /dev/null
+++ b/crates/goose-sdk/src/observability.rs
@@ -0,0 +1,519 @@
+//! Structured provider request/response observability for GDK callers.
+//!
+//! Kotlin and Python consumers register an [`ObservabilityHook`] to receive
+//! typed lifecycle events (start, response metadata, completion) for both
+//! streaming and non-streaming provider calls instead of scraping logs.
+//!
+//! Hooks are opt-in: with no hook registered nothing is emitted and no work is
+//! performed on the request path. The hook is invoked synchronously, wrapped in
+//! `catch_unwind` so a throwing foreign callback cannot fail the request.
+
+use std::{
+    panic::{catch_unwind, AssertUnwindSafe},
+    sync::{
+        atomic::{AtomicBool, AtomicU64, Ordering},
+        Arc, RwLock,
+    },
+    time::Instant,
+};
+
+use goose_providers::conversation::message::Message;
+use rmcp::model::Tool;
+
+use crate::bindings::{GooseError, GooseStreamError, Usage};
+
+/// Receives structured provider request lifecycle events.
+#[uniffi::export(callback_interface)]
+pub trait ObservabilityHook: Send + Sync {
+    fn on_request_start(&self, event: RequestStartEvent);
+    fn on_response_start(&self, event: ResponseStartEvent);
+    fn on_request_end(&self, event: RequestEndEvent);
+}
+
+#[derive(Debug, Clone, Copy, PartialEq, Eq, uniffi::Enum)]
+pub enum RequestOperation {
+    Complete,
+    Stream,
+}
+
+#[derive(Debug, Clone, uniffi::Record)]
+pub struct RequestPayload {
+    pub system: String,
+    pub messages_json: String,
+    pub tools_json: String,
+}
+
+#[derive(Debug, Clone, uniffi::Record)]
+pub struct RequestStartEvent {
+    pub request_id: String,
+    pub provider: String,
+    pub model: String,
+    pub operation: RequestOperation,
+    pub payload: Option,
+}
+
+/// Emitted when the provider response becomes available: when the stream is
+/// opened for streaming requests, or when the body is received otherwise.
+#[derive(Debug, Clone, uniffi::Record)]
+pub struct ResponseStartEvent {
+    pub request_id: String,
+    pub provider: String,
+    pub model: String,
+    pub operation: RequestOperation,
+    pub elapsed_ms: u64,
+}
+
+#[derive(Debug, Clone, uniffi::Record)]
+pub struct RequestEndEvent {
+    pub request_id: String,
+    pub provider: String,
+    pub model: String,
+    pub operation: RequestOperation,
+    pub outcome: RequestOutcome,
+    pub duration_ms: u64,
+    pub usage: Option,
+    pub response_json: Option,
+}
+
+#[derive(Debug, Clone, uniffi::Enum)]
+pub enum RequestOutcome {
+    Success,
+    Failure { error: GooseStreamError },
+}
+
+static HOOK: RwLock>> = RwLock::new(None);
+static NEXT_REQUEST_ID: AtomicU64 = AtomicU64::new(1);
+
+/// Registers the process-wide observability hook, replacing any previous one.
+///
+/// Payloads are omitted unless `capture_payloads` is enabled because system
+/// prompts, conversations and tool results routinely contain sensitive data.
+#[uniffi::export(default(capture_payloads = false))]
+pub fn set_observability_hook(hook: Box, capture_payloads: bool) {
+    let registered = Arc::new(RegisteredHook {
+        hook,
+        capture_payloads,
+        revoked: AtomicBool::new(false),
+    });
+    let previous = HOOK
+        .write()
+        .expect("observability hook lock")
+        .replace(registered);
+    if let Some(previous) = previous {
+        previous.revoke();
+    }
+}
+
+/// Removes the observability hook, after which no further events are emitted,
+/// including for requests that are still in flight.
+#[uniffi::export]
+pub fn clear_observability_hook() {
+    if let Some(previous) = HOOK.write().expect("observability hook lock").take() {
+        previous.revoke();
+    }
+}
+
+struct RegisteredHook {
+    hook: Box,
+    capture_payloads: bool,
+    revoked: AtomicBool,
+}
+
+impl RegisteredHook {
+    fn revoke(&self) {
+        self.revoked.store(true, Ordering::Release);
+    }
+
+    fn emit(&self, deliver: impl FnOnce(&dyn ObservabilityHook)) {
+        if self.revoked.load(Ordering::Acquire) {
+            return;
+        }
+        let _ = catch_unwind(AssertUnwindSafe(|| deliver(self.hook.as_ref())));
+    }
+}
+
+pub(crate) struct RequestDescriptor<'a> {
+    pub provider: &'a str,
+    pub model: &'a str,
+    pub operation: RequestOperation,
+    pub system: &'a str,
+    pub messages: &'a [Message],
+    pub tools: &'a [Tool],
+}
+
+/// Tracks one provider request and emits its lifecycle events. Disabled (and
+/// free) when no hook is registered.
+pub(crate) struct RequestObserver {
+    active: Option,
+}
+
+struct ActiveRequest {
+    hook: Arc,
+    request_id: String,
+    provider: String,
+    model: String,
+    operation: RequestOperation,
+    started: Instant,
+    ended: AtomicBool,
+}
+
+impl RequestObserver {
+    pub(crate) fn start(descriptor: RequestDescriptor<'_>) -> Self {
+        let Some(hook) = HOOK.read().expect("observability hook lock").clone() else {
+            return Self { active: None };
+        };
+
+        let request_id = format!("req-{}", NEXT_REQUEST_ID.fetch_add(1, Ordering::Relaxed));
+        let payload = hook.capture_payloads.then(|| RequestPayload {
+            system: descriptor.system.to_string(),
+            messages_json: serde_json::to_string(descriptor.messages)
+                .unwrap_or_else(|_| "null".to_string()),
+            tools_json: serde_json::to_string(descriptor.tools)
+                .unwrap_or_else(|_| "null".to_string()),
+        });
+
+        let event = RequestStartEvent {
+            request_id: request_id.clone(),
+            provider: descriptor.provider.to_string(),
+            model: descriptor.model.to_string(),
+            operation: descriptor.operation,
+            payload,
+        };
+        hook.emit(|hook| hook.on_request_start(event));
+
+        Self {
+            active: Some(ActiveRequest {
+                hook,
+                request_id,
+                provider: descriptor.provider.to_string(),
+                model: descriptor.model.to_string(),
+                operation: descriptor.operation,
+                started: Instant::now(),
+                ended: AtomicBool::new(false),
+            }),
+        }
+    }
+
+    pub(crate) fn captures_payloads(&self) -> bool {
+        self.active
+            .as_ref()
+            .is_some_and(|active| active.hook.capture_payloads)
+    }
+
+    pub(crate) fn response_started(&self) {
+        let Some(active) = self.active.as_ref() else {
+            return;
+        };
+
+        let event = ResponseStartEvent {
+            request_id: active.request_id.clone(),
+            provider: active.provider.clone(),
+            model: active.model.clone(),
+            operation: active.operation,
+            elapsed_ms: active.started.elapsed().as_millis() as u64,
+        };
+        active.hook.emit(|hook| hook.on_response_start(event));
+    }
+
+    pub(crate) fn succeeded(&self, usage: Option, response_json: Option) {
+        self.end(RequestOutcome::Success, usage, response_json);
+    }
+
+    pub(crate) fn fail(&self, error: GooseError) -> GooseError {
+        self.end(
+            RequestOutcome::Failure {
+                error: GooseStreamError::from(&error),
+            },
+            None,
+            None,
+        );
+        error
+    }
+
+    pub(crate) fn fail_stream(&self, error: GooseStreamError) {
+        self.end(RequestOutcome::Failure { error }, None, None);
+    }
+
+    fn end(&self, outcome: RequestOutcome, usage: Option, response_json: Option) {
+        let Some(active) = self.active.as_ref() else {
+            return;
+        };
+
+        if active.ended.swap(true, Ordering::AcqRel) {
+            return;
+        }
+
+        let event = RequestEndEvent {
+            request_id: active.request_id.clone(),
+            provider: active.provider.clone(),
+            model: active.model.clone(),
+            operation: active.operation,
+            outcome,
+            duration_ms: active.started.elapsed().as_millis() as u64,
+            usage,
+            response_json,
+        };
+        active.hook.emit(|hook| hook.on_request_end(event));
+    }
+}
+
+#[cfg(test)]
+mod tests {
+    use super::*;
+    use std::sync::{Mutex, MutexGuard};
+
+    static HOOK_TEST_LOCK: Mutex<()> = Mutex::new(());
+
+    #[derive(Default)]
+    struct Recorder {
+        events: Mutex>,
+        panic_on_start: bool,
+    }
+
+    #[derive(Debug, Clone)]
+    enum Event {
+        Start(RequestStartEvent),
+        ResponseStart(ResponseStartEvent),
+        End(RequestEndEvent),
+    }
+
+    impl ObservabilityHook for Arc {
+        fn on_request_start(&self, event: RequestStartEvent) {
+            assert!(!self.panic_on_start, "foreign hook exploded");
+            self.push(Event::Start(event));
+        }
+
+        fn on_response_start(&self, event: ResponseStartEvent) {
+            self.push(Event::ResponseStart(event));
+        }
+
+        fn on_request_end(&self, event: RequestEndEvent) {
+            self.push(Event::End(event));
+        }
+    }
+
+    impl Recorder {
+        fn push(&self, event: Event) {
+            self.events.lock().unwrap().push(event);
+        }
+    }
+
+    /// Serializes access to the process-wide hook and clears it on drop.
+    struct RegisteredRecorder {
+        recorder: Arc,
+        _lock: MutexGuard<'static, ()>,
+    }
+
+    impl RegisteredRecorder {
+        fn new(recorder: Recorder, capture_payloads: bool) -> Self {
+            let lock = HOOK_TEST_LOCK
+                .lock()
+                .unwrap_or_else(|poisoned| poisoned.into_inner());
+            let recorder = Arc::new(recorder);
+            set_observability_hook(Box::new(Arc::clone(&recorder)), capture_payloads);
+            Self {
+                recorder,
+                _lock: lock,
+            }
+        }
+
+        fn events(&self) -> Vec {
+            self.recorder.events.lock().unwrap().clone()
+        }
+    }
+
+    impl Drop for RegisteredRecorder {
+        fn drop(&mut self) {
+            clear_observability_hook();
+        }
+    }
+
+    fn descriptor() -> RequestDescriptor<'static> {
+        RequestDescriptor {
+            provider: "databricks",
+            model: "claude-sonnet-4",
+            operation: RequestOperation::Stream,
+            system: "you are helpful",
+            messages: &[],
+            tools: &[],
+        }
+    }
+
+    fn usage() -> Usage {
+        Usage {
+            input_tokens: Some(11),
+            output_tokens: Some(7),
+            total_tokens: Some(18),
+            cache_read_input_tokens: None,
+            cache_creation_input_tokens: None,
+            reasoning_tokens: None,
+            model: "claude-sonnet-4".to_string(),
+            provider_metadata_json: None,
+        }
+    }
+
+    #[test]
+    fn success_emits_start_response_and_end_with_usage() {
+        let registered = RegisteredRecorder::new(Recorder::default(), false);
+        let observer = RequestObserver::start(descriptor());
+        observer.response_started();
+        observer.succeeded(Some(usage()), None);
+
+        let events = registered.events();
+        assert_eq!(events.len(), 3);
+        let Event::Start(start) = &events[0] else {
+            panic!("expected start, got {:?}", events[0]);
+        };
+        assert_eq!(start.provider, "databricks");
+        assert_eq!(start.model, "claude-sonnet-4");
+        assert_eq!(start.operation, RequestOperation::Stream);
+        assert!(start.payload.is_none());
+
+        let Event::ResponseStart(response_start) = &events[1] else {
+            panic!("expected response start, got {:?}", events[1]);
+        };
+        assert_eq!(response_start.request_id, start.request_id);
+
+        let Event::End(end) = &events[2] else {
+            panic!("expected end, got {:?}", events[2]);
+        };
+        assert_eq!(end.request_id, start.request_id);
+        assert_eq!(end.usage.as_ref().unwrap().total_tokens, Some(18));
+        assert!(matches!(end.outcome, RequestOutcome::Success));
+        assert!(end.response_json.is_none());
+    }
+
+    #[test]
+    fn failure_reports_typed_error() {
+        let registered = RegisteredRecorder::new(Recorder::default(), false);
+        let observer = RequestObserver::start(descriptor());
+        observer.fail(GooseError::RateLimited {
+            retry_after_ms: Some(1_500),
+            retry_after_suffix: String::new(),
+        });
+
+        let events = registered.events();
+        let Event::End(end) = &events[1] else {
+            panic!("expected end, got {:?}", events[1]);
+        };
+        let RequestOutcome::Failure { error } = &end.outcome else {
+            panic!("expected failure outcome");
+        };
+        assert!(matches!(
+            error.kind,
+            crate::bindings::GooseStreamErrorKind::RateLimited
+        ));
+        assert_eq!(error.retry_after_ms, Some(1_500));
+        assert!(end.usage.is_none());
+    }
+
+    #[test]
+    fn payload_capture_is_opt_in() {
+        let registered = RegisteredRecorder::new(Recorder::default(), true);
+        let observer = RequestObserver::start(descriptor());
+        assert!(observer.captures_payloads());
+        observer.succeeded(None, Some("{\"role\":\"assistant\"}".to_string()));
+
+        let events = registered.events();
+        let Event::Start(start) = &events[0] else {
+            panic!("expected start");
+        };
+        let payload = start.payload.as_ref().expect("payload captured");
+        assert_eq!(payload.system, "you are helpful");
+        assert_eq!(payload.messages_json, "[]");
+        let Event::End(end) = &events[1] else {
+            panic!("expected end");
+        };
+        assert_eq!(
+            end.response_json.as_deref(),
+            Some("{\"role\":\"assistant\"}")
+        );
+    }
+
+    #[test]
+    fn panicking_hook_does_not_stop_later_events() {
+        let registered = RegisteredRecorder::new(
+            Recorder {
+                panic_on_start: true,
+                ..Default::default()
+            },
+            false,
+        );
+        let observer = RequestObserver::start(descriptor());
+        observer.response_started();
+        observer.succeeded(None, None);
+
+        let events = registered.events();
+        assert_eq!(events.len(), 2);
+        assert!(matches!(events[0], Event::ResponseStart(_)));
+        assert!(matches!(events[1], Event::End(_)));
+    }
+
+    #[test]
+    fn end_is_emitted_at_most_once() {
+        let registered = RegisteredRecorder::new(Recorder::default(), false);
+        let observer = RequestObserver::start(descriptor());
+        observer.fail(GooseError::Timeout {
+            details: "request timed out after 5ms".to_string(),
+        });
+        observer.succeeded(Some(usage()), None);
+        observer.fail_stream(GooseStreamError {
+            kind: crate::bindings::GooseStreamErrorKind::Generic,
+            message: "later read".to_string(),
+            retry_after_ms: None,
+        });
+
+        let events = registered.events();
+        assert_eq!(events.len(), 2);
+        let Event::End(end) = &events[1] else {
+            panic!("expected end, got {:?}", events[1]);
+        };
+        let RequestOutcome::Failure { error } = &end.outcome else {
+            panic!("expected failure outcome");
+        };
+        assert!(matches!(
+            error.kind,
+            crate::bindings::GooseStreamErrorKind::Timeout
+        ));
+    }
+
+    #[test]
+    fn clearing_hook_mid_request_stops_in_flight_events() {
+        let registered = RegisteredRecorder::new(Recorder::default(), true);
+        let observer = RequestObserver::start(descriptor());
+        clear_observability_hook();
+
+        observer.response_started();
+        observer.succeeded(Some(usage()), Some("{\"role\":\"assistant\"}".to_string()));
+
+        let events = registered.events();
+        assert_eq!(events.len(), 1);
+        assert!(matches!(events[0], Event::Start(_)));
+    }
+
+    #[test]
+    fn replacing_hook_stops_in_flight_events_to_the_old_hook() {
+        let registered = RegisteredRecorder::new(Recorder::default(), false);
+        let observer = RequestObserver::start(descriptor());
+        set_observability_hook(Box::new(Arc::new(Recorder::default())), false);
+
+        observer.succeeded(None, None);
+
+        let events = registered.events();
+        assert_eq!(events.len(), 1);
+        assert!(matches!(events[0], Event::Start(_)));
+    }
+
+    #[test]
+    fn cleared_hook_produces_no_events() {
+        let registered = RegisteredRecorder::new(Recorder::default(), true);
+        clear_observability_hook();
+
+        let observer = RequestObserver::start(descriptor());
+        assert!(!observer.captures_payloads());
+        observer.response_started();
+        observer.succeeded(Some(usage()), None);
+
+        assert!(registered.events().is_empty());
+    }
+}
diff --git a/goose-self-test.yaml b/goose-self-test.yaml
index 925e0aed1e5d..3bd90327c560 100644
--- a/goose-self-test.yaml
+++ b/goose-self-test.yaml
@@ -15,6 +15,7 @@ activities:
   - Test delegate tool for task delegation (sync and async)
   - Test multi-turn thinking preservation for providers that reject replayed reasoning_content
   - Validate ACP thinking-effort discovery, forwarding, and provider switching
+  - Validate GDK observability hook lifecycle events and payload opt-in
   - Test error boundaries including nested delegation prevention
   - Generate comprehensive test report
 
@@ -415,6 +416,23 @@ prompt: |
   Log results to: {{ workspace_dir }}/phase3d_acp_effort.md
   {% endif %}
 
+  {% if test_phases == "all" or "advanced" in test_phases %}
+  ## 🔭 PHASE 3E: GDK Observability Hook Testing
+
+  **Prerequisites**: Run this phase from a goose source checkout with the Rust toolchain
+  available. Skip this phase and record it as SKIPPED otherwise.
+
+  ### Observability Hook Integration Test
+  1. Run `cargo test -p goose-sdk --features uniffi --lib observability`.
+  2. Verify a registered hook receives `on_request_start`, `on_response_start`, and exactly
+     one `on_request_end` sharing a single `request_id`, with latency and token usage.
+  3. Verify request and response payloads are omitted unless `capture_payloads` is enabled.
+  4. Verify a panicking foreign hook does not fail the request and later events still arrive.
+  5. Verify `clear_observability_hook` stops events for in-flight requests as well as new ones.
+
+  Log results to: {{ workspace_dir }}/phase3e_observability.md
+  {% endif %}
+
   {% if test_phases == "all" or "advanced" in test_phases %}
   ## 🔬 PHASE 4: Advanced Testing
 

From 65415f3c1e0c31775fc97b5f165e174b8dd0c478 Mon Sep 17 00:00:00 2001
From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com>
Date: Mon, 31 Aug 2026 19:06:16 +0000
Subject: [PATCH 17/37] chore(deps): bump actions/checkout from 7.0.0 to 7.0.1
 (#11660)

Signed-off-by: dependabot[bot] 
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
---
 .github/workflows/mcp-conformance.yml | 4 ++--
 1 file changed, 2 insertions(+), 2 deletions(-)

diff --git a/.github/workflows/mcp-conformance.yml b/.github/workflows/mcp-conformance.yml
index 5ed9cfa41a0a..f525008b3db7 100644
--- a/.github/workflows/mcp-conformance.yml
+++ b/.github/workflows/mcp-conformance.yml
@@ -18,7 +18,7 @@ jobs:
     runs-on: ubuntu-latest
     steps:
       - name: Checkout Code
-        uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0  # v7.0.0
+        uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1  # v7.0.1
 
       - uses: actions-rust-lang/setup-rust-toolchain@166cdcfd11aee3cb47222f9ddb555ce30ddb9659 # v1.17.0
 
@@ -67,7 +67,7 @@ jobs:
             baseline: "crates/goose-cli/tests/mcp-conformance/expected-failures-2026-07-28-0.2.0-alpha.10.yaml"
     steps:
       - name: Checkout Code
-        uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0  # v7.0.0
+        uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1  # v7.0.1
 
       - name: Install Dependencies
         run: |

From e52be07ca227f139080cd62eab8d132d1cb42bf8 Mon Sep 17 00:00:00 2001
From: Jack Amadeo 
Date: Mon, 31 Aug 2026 19:12:25 +0000
Subject: [PATCH 18/37] fix(desktop): pin FormatJS CLI for Intel macOS (#11639)

Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
---
 ui/desktop/package.json |  2 +-
 ui/pnpm-lock.yaml       | 78 ++++++-----------------------------------
 2 files changed, 11 insertions(+), 69 deletions(-)

diff --git a/ui/desktop/package.json b/ui/desktop/package.json
index 59d97c4ae1a1..ebf49bf78a1d 100644
--- a/ui/desktop/package.json
+++ b/ui/desktop/package.json
@@ -116,7 +116,7 @@
     "@electron-forge/plugin-vite": "^7.11.2",
     "@electron/fuses": "^2.1.3",
     "@eslint/js": "^10.0.1",
-    "@formatjs/cli": "^6.16.18",
+    "@formatjs/cli": "6.14.0",
     "@formatjs/icu-messageformat-parser": "3.5.16",
     "@modelcontextprotocol/sdk": "^1.30.0",
     "@playwright/test": "^1.62.1",
diff --git a/ui/pnpm-lock.yaml b/ui/pnpm-lock.yaml
index 64d54dcb77db..ed7c4b0c940a 100644
--- a/ui/pnpm-lock.yaml
+++ b/ui/pnpm-lock.yaml
@@ -215,8 +215,8 @@ importers:
         specifier: ^10.0.1
         version: 10.0.1(eslint@9.39.4(jiti@2.7.0))
       '@formatjs/cli':
-        specifier: ^6.16.18
-        version: 6.16.18
+        specifier: 6.14.0
+        version: 6.14.0
       '@formatjs/icu-messageformat-parser':
         specifier: 3.5.16
         version: 3.5.16
@@ -716,7 +716,7 @@ packages:
     engines: {node: '>=22.12.0'}
 
   '@electron/node-gyp@https://codeload.github.com/electron/node-gyp/tar.gz/06b29aafb7708acef8b3669835c8a7857ebc92d2':
-    resolution: {gitHosted: true, tarball: https://codeload.github.com/electron/node-gyp/tar.gz/06b29aafb7708acef8b3669835c8a7857ebc92d2}
+    resolution: {tarball: https://codeload.github.com/electron/node-gyp/tar.gz/06b29aafb7708acef8b3669835c8a7857ebc92d2}
     version: 10.2.0-electron.1
     engines: {node: '>=12.13.0'}
     hasBin: true
@@ -1038,42 +1038,8 @@ packages:
   '@formatjs/bigdecimal@0.2.0':
     resolution: {integrity: sha512-GeaxHZbUoYvHL9tC5eltHLs+1zU70aPw0s7LwqgktIzF5oMhNY4o4deEtusJMsq7WFJF3Ye2zQEzdG8beVk73w==}
 
-  '@formatjs/cli-native-darwin-arm64@1.1.12':
-    resolution: {integrity: sha512-9RKHpBt/Fw+7AVjCsihATyOcC7aYnZJHdNg8Ud2K/yLZiSOfS1V/oSDPK47VCPh98HmclCI/O6tRq9KeXjZrhQ==}
-    cpu: [arm64]
-    os: [darwin]
-
-  '@formatjs/cli-native-linux-arm64-musl@1.0.10':
-    resolution: {integrity: sha512-e+C7cssp6RM7YzWoW6cK1dUzcbdDYCtUFI/0ALPU4qx2NI7AMMJCC/iqVwo6pk95T7YcjYYuSjCqm1ni+ZC/4g==}
-    cpu: [arm64]
-    os: [linux]
-    libc: [musl]
-
-  '@formatjs/cli-native-linux-arm64@1.2.12':
-    resolution: {integrity: sha512-Ge4F8Td5n5bQ26sQlxN+DUa5iWkbxqZY9m+L8edudXEYAo2j9yMg+Y8mieBH+YwKexmBPQFSs9eYaGQYM6CzBw==}
-    cpu: [arm64]
-    os: [linux]
-    libc: [glibc]
-
-  '@formatjs/cli-native-linux-x64-musl@1.0.10':
-    resolution: {integrity: sha512-ZAYzdqsjv/rL8ntGl/iSmGjuOmEytxrktdsPsFFDeq3NN/LFiQn3LxPR3uLGOlLlN+pUhc8I3yFRgRjE59e1hw==}
-    cpu: [x64]
-    os: [linux]
-    libc: [musl]
-
-  '@formatjs/cli-native-linux-x64@1.1.12':
-    resolution: {integrity: sha512-sOWoISoova0fDJLpdTYA7lVlIoODUGzoetSq2C+/XQzp/544tWgRlacf7u1dqIxnmYZuKIXmawr5p2trf67IaA==}
-    cpu: [x64]
-    os: [linux]
-    libc: [glibc]
-
-  '@formatjs/cli-native-win32-x64@1.1.13':
-    resolution: {integrity: sha512-5veGmXGU5qqEXA8AFUOAa64vXRtsLuxGFODBCdIRXWLvFiGCzn2CzGnPikdIyj2u1Gy9KdPVXkNdKmdzucKeuQ==}
-    cpu: [x64]
-    os: [win32]
-
-  '@formatjs/cli@6.16.18':
-    resolution: {integrity: sha512-+h3vsuYBTKEpSFZAEWnFuf1fK2a+Hyp0UtgeP5Hw33Lgm+N9veup+XVeT5CAJ21PoZSM/t21dTo4mUu36TEThg==}
+  '@formatjs/cli@6.14.0':
+    resolution: {integrity: sha512-5lqzAyAObZ8J8Ljtua8s4e5GKrPBu/lmuYN5ok5GgKUe+u+QwBPNlR3d2aYJpUbhgYuV8PXPHFXVPOwyhSR1uw==}
     engines: {node: '>= 20.12.0'}
     hasBin: true
     peerDependencies:
@@ -1081,9 +1047,9 @@ packages:
       '@glimmer/reference': '*'
       '@glimmer/syntax': ^0.84.3 || ^0.95.0
       '@glimmer/validator': '*'
-      '@vue/compiler-core': '3'
-      content-tag: '4'
-      vue: '3'
+      '@vue/compiler-core': ^3.5.0
+      content-tag: ^4.1.0
+      vue: ^3.5.0
     peerDependenciesMeta:
       '@glimmer/env':
         optional: true
@@ -3327,6 +3293,7 @@ packages:
   '@xmldom/xmldom@0.9.11':
     resolution: {integrity: sha512-tW8bcK3hsG0/uqSnNz6TK4BkcuZSezoU7DlnYssILmZDktPnSHHuDJJFM0AJv+13gz2r0iGdrj6qqKeUnxXEDg==}
     engines: {node: '>=14.6'}
+    deprecated: this version has critical issues, please update to the latest version
 
   '@xtuc/ieee754@1.2.0':
     resolution: {integrity: sha512-DX8nKgqcGwsc0eJSqYt5lwP4DH5FlHnmuWWBRy7X0NcaGR0ZtuyeESgMwTYVEtxmsNGY+qit4QYT/MIYTOTPeA==}
@@ -8243,32 +8210,7 @@ snapshots:
 
   '@formatjs/bigdecimal@0.2.0': {}
 
-  '@formatjs/cli-native-darwin-arm64@1.1.12':
-    optional: true
-
-  '@formatjs/cli-native-linux-arm64-musl@1.0.10':
-    optional: true
-
-  '@formatjs/cli-native-linux-arm64@1.2.12':
-    optional: true
-
-  '@formatjs/cli-native-linux-x64-musl@1.0.10':
-    optional: true
-
-  '@formatjs/cli-native-linux-x64@1.1.12':
-    optional: true
-
-  '@formatjs/cli-native-win32-x64@1.1.13':
-    optional: true
-
-  '@formatjs/cli@6.16.18':
-    optionalDependencies:
-      '@formatjs/cli-native-darwin-arm64': 1.1.12
-      '@formatjs/cli-native-linux-arm64': 1.2.12
-      '@formatjs/cli-native-linux-arm64-musl': 1.0.10
-      '@formatjs/cli-native-linux-x64': 1.1.12
-      '@formatjs/cli-native-linux-x64-musl': 1.0.10
-      '@formatjs/cli-native-win32-x64': 1.1.13
+  '@formatjs/cli@6.14.0': {}
 
   '@formatjs/ecma402-abstract@3.2.0':
     dependencies:

From a9f68b441e2af1de2e6c87a69a719e7b6d04f007 Mon Sep 17 00:00:00 2001
From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com>
Date: Mon, 31 Aug 2026 19:13:35 +0000
Subject: [PATCH 19/37] chore(deps): bump Swatinem/rust-cache from 2.9.1 to
 2.9.2 (#11662)

Signed-off-by: dependabot[bot] 
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
---
 .github/workflows/maven-sdk.yml       | 2 +-
 .github/workflows/mcp-conformance.yml | 2 +-
 2 files changed, 2 insertions(+), 2 deletions(-)

diff --git a/.github/workflows/maven-sdk.yml b/.github/workflows/maven-sdk.yml
index d4f6e153e1d4..0c53cbc362de 100644
--- a/.github/workflows/maven-sdk.yml
+++ b/.github/workflows/maven-sdk.yml
@@ -44,7 +44,7 @@ jobs:
         uses: actions-rust-lang/setup-rust-toolchain@166cdcfd11aee3cb47222f9ddb555ce30ddb9659 # v1
 
       - name: Cache Cargo artifacts
-        uses: Swatinem/rust-cache@258712b0b7b1ddf8bddc9fc3b0faca682b2736c3 # v2
+        uses: Swatinem/rust-cache@f0d9c3887740aee45f6153b24b3a6b815192ec16 # v2
 
       - name: Build native library
         shell: bash
diff --git a/.github/workflows/mcp-conformance.yml b/.github/workflows/mcp-conformance.yml
index f525008b3db7..dfcd68f038c8 100644
--- a/.github/workflows/mcp-conformance.yml
+++ b/.github/workflows/mcp-conformance.yml
@@ -28,7 +28,7 @@ jobs:
           sudo apt install -y libdbus-1-dev libxcb1-dev
 
       - name: Cache Cargo artifacts
-        uses: Swatinem/rust-cache@c19371144df3bb44fab255c43d04cbc2ab54d1c4  # v2.9.1
+        uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6  # v2.9.2
         with:
           key: mcp-conformance
 

From 4550a2e93df60c762974b5693ad029b5945cde88 Mon Sep 17 00:00:00 2001
From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com>
Date: Mon, 31 Aug 2026 19:13:37 +0000
Subject: [PATCH 20/37] chore(deps): bump github/codeql-action from 4.37.6 to
 4.37.8 (#11661)

Signed-off-by: dependabot[bot] 
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
---
 .github/workflows/scorecard.yml | 2 +-
 1 file changed, 1 insertion(+), 1 deletion(-)

diff --git a/.github/workflows/scorecard.yml b/.github/workflows/scorecard.yml
index ab77597b9582..622f648a1a9d 100644
--- a/.github/workflows/scorecard.yml
+++ b/.github/workflows/scorecard.yml
@@ -73,6 +73,6 @@ jobs:
       # Upload the results to GitHub's code scanning dashboard (optional).
       # Commenting out will disable upload of results to your repo's Code Scanning dashboard
       - name: "Upload to code-scanning"
-        uses: github/codeql-action/upload-sarif@v4.37.6
+        uses: github/codeql-action/upload-sarif@v4.37.8
         with:
           sarif_file: results.sarif

From 1d0146631c2fef1840fa25bb45cd36e4e409dc8b Mon Sep 17 00:00:00 2001
From: Alex Hancock 
Date: Mon, 31 Aug 2026 16:24:36 -0400
Subject: [PATCH 21/37] feat(gdk): expose Anthropic response metadata for
 observability (#11630)

---
 .../src/conversation/token_usage.rs           |  6 ++
 .../src/formats/anthropic.rs                  | 69 ++++++++++++++++++-
 crates/goose-sdk/src/bindings.rs              | 45 ++++++++++++
 documentation/src/data/gdk-api.json           |  6 ++
 4 files changed, 125 insertions(+), 1 deletion(-)

diff --git a/crates/goose-provider-types/src/conversation/token_usage.rs b/crates/goose-provider-types/src/conversation/token_usage.rs
index ff680e69c6c2..2b7f3abb16dd 100644
--- a/crates/goose-provider-types/src/conversation/token_usage.rs
+++ b/crates/goose-provider-types/src/conversation/token_usage.rs
@@ -1,6 +1,7 @@
 use std::ops::{Add, AddAssign};
 
 use serde::{Deserialize, Serialize};
+use serde_json::{Map, Value};
 
 #[derive(Debug, Clone, Serialize, Deserialize)]
 pub struct ProviderUsage {
@@ -16,6 +17,10 @@ pub struct ProviderUsage {
     pub finish_reasons: Option>,
     #[serde(default, skip_serializing_if = "Option::is_none")]
     pub response_id: Option,
+    /// Provider-specific response fields that have no canonical equivalent, kept
+    /// unstructured so new fields flow through without changing this type.
+    #[serde(default, skip_serializing_if = "Option::is_none")]
+    pub additional_data: Option>,
 }
 
 #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
@@ -54,6 +59,7 @@ impl ProviderUsage {
             cost_source: None,
             finish_reasons: None,
             response_id: None,
+            additional_data: None,
         }
     }
 
diff --git a/crates/goose-provider-types/src/formats/anthropic.rs b/crates/goose-provider-types/src/formats/anthropic.rs
index dc030fd99a0c..de34e39f1618 100644
--- a/crates/goose-provider-types/src/formats/anthropic.rs
+++ b/crates/goose-provider-types/src/formats/anthropic.rs
@@ -13,7 +13,7 @@ use rmcp::model::{
     ResourceContents, Role, Tool,
 };
 use rmcp::object as json_object;
-use serde_json::{json, Value};
+use serde_json::{json, Map, Value};
 use std::collections::HashSet;
 use std::fmt;
 use std::str::FromStr;
@@ -663,6 +663,18 @@ pub fn get_usage(data: &Value) -> Result {
     }
 }
 
+/// Anthropic response fields that have no canonical `ProviderUsage` equivalent.
+const ADDITIONAL_USAGE_FIELDS: [&str; 1] = ["service_tier"];
+
+pub fn get_additional_data(data: &Value) -> Option> {
+    let usage = data.get("usage")?.as_object()?;
+    let additional: Map = ADDITIONAL_USAGE_FIELDS
+        .iter()
+        .filter_map(|field| Some(((*field).to_string(), usage.get(*field)?.clone())))
+        .collect();
+    (!additional.is_empty()).then_some(additional)
+}
+
 fn provider_usage_with_cost(
     model: String,
     usage: Usage,
@@ -896,6 +908,7 @@ where
         let mut message_id: Option = None;
         let mut thinking: Option = None;
         let mut stop_reason: Option = None;
+        let mut additional_data: Option> = None;
 
         while let Some(line_result) = stream.next().await {
             let line = line_result?;
@@ -925,6 +938,7 @@ where
             match event.event_type.as_str() {
                 EVENT_MESSAGE_START => {
                     if let Some(message_data) = event.data.get("message") {
+                        additional_data = get_additional_data(message_data);
                         if let Some(id) = message_data.get("id").and_then(|v| v.as_str()) {
                             message_id = Some(id.to_string());
                         }
@@ -1104,6 +1118,7 @@ where
                             if let Some(mut usage) = final_usage.take() {
                                 usage.finish_reasons = Some(vec![STOP_REASON_REFUSAL.to_string()]);
                                 usage.response_id = message_id.clone();
+                                usage.additional_data = additional_data.clone();
                                 yield (None, Some(usage));
                             }
                             Err(ProviderError::Refusal { details, category })?;
@@ -1185,6 +1200,7 @@ where
             if let Some(id) = message_id {
                 usage.response_id = Some(id);
             }
+            usage.additional_data = additional_data;
             yield (None, Some(usage));
         }
     }
@@ -2445,6 +2461,57 @@ mod tests {
         assert_eq!(usage.response_id.as_deref(), Some("msg_1"));
     }
 
+    async fn streamed_usage(events: &str) -> ProviderUsage {
+        collect_stream_results(events)
+            .await
+            .into_iter()
+            .filter_map(|r| r.ok().and_then(|(_, usage)| usage))
+            .next_back()
+            .expect("stream should yield usage")
+    }
+
+    #[tokio::test]
+    async fn test_streaming_surfaces_additional_usage_data() {
+        let events = concat!(
+            r#"data: {"type":"message_start","message":{"id":"msg_1","role":"assistant","content":[],"model":"claude-sonnet-4-5","usage":{"input_tokens":7,"output_tokens":0,"service_tier":"fast"}}}"#,
+            "\n",
+            r#"data: {"type":"content_block_start","index":0,"content_block":{"type":"text","text":""}}"#,
+            "\n",
+            r#"data: {"type":"content_block_delta","index":0,"delta":{"type":"text_delta","text":"Hi"}}"#,
+            "\n",
+            r#"data: {"type":"content_block_stop","index":0}"#,
+            "\n",
+            r#"data: {"type":"message_delta","delta":{"stop_reason":"end_turn"},"usage":{"output_tokens":25}}"#,
+            "\n",
+            r#"data: {"type":"message_stop"}"#,
+        );
+
+        let additional = streamed_usage(events)
+            .await
+            .additional_data
+            .expect("additional data should be reported");
+        assert_eq!(additional["service_tier"], json!("fast"));
+    }
+
+    #[tokio::test]
+    async fn test_streaming_omits_additional_usage_data_when_absent() {
+        let events = concat!(
+            r#"data: {"type":"message_start","message":{"id":"msg_1","role":"assistant","content":[],"model":"claude-sonnet-4-5","usage":{"input_tokens":7,"output_tokens":0}}}"#,
+            "\n",
+            r#"data: {"type":"content_block_start","index":0,"content_block":{"type":"text","text":""}}"#,
+            "\n",
+            r#"data: {"type":"content_block_delta","index":0,"delta":{"type":"text_delta","text":"Hi"}}"#,
+            "\n",
+            r#"data: {"type":"content_block_stop","index":0}"#,
+            "\n",
+            r#"data: {"type":"message_delta","delta":{"stop_reason":"end_turn"},"usage":{"output_tokens":25}}"#,
+            "\n",
+            r#"data: {"type":"message_stop"}"#,
+        );
+
+        assert!(streamed_usage(events).await.additional_data.is_none());
+    }
+
     #[tokio::test]
     async fn test_streaming_preserves_provider_cost_from_delta() {
         let events = concat!(
diff --git a/crates/goose-sdk/src/bindings.rs b/crates/goose-sdk/src/bindings.rs
index 87361d57f51e..6ce9ab4786be 100644
--- a/crates/goose-sdk/src/bindings.rs
+++ b/crates/goose-sdk/src/bindings.rs
@@ -501,6 +501,9 @@ pub struct Usage {
     pub reasoning_tokens: Option,
     pub model: String,
     pub provider_metadata_json: Option,
+    /// Provider-specific response fields as a JSON object, present only when the
+    /// provider reported fields with no canonical `Usage` equivalent.
+    pub additional_data_json: Option,
 }
 
 impl Usage {
@@ -514,6 +517,11 @@ impl Usage {
             reasoning_tokens: None,
             model: usage.model.clone(),
             provider_metadata_json: Some(serde_json::to_string(usage)?),
+            additional_data_json: usage
+                .additional_data
+                .as_ref()
+                .map(serde_json::to_string)
+                .transpose()?,
         })
     }
 }
@@ -1362,6 +1370,43 @@ mod tests {
         assert_eq!(tool.input_schema["type"], "object");
     }
 
+    #[test]
+    fn usage_exposes_provider_additional_data() {
+        let mut provider_usage = ProviderUsage::new(
+            "claude-sonnet-4-5".to_string(),
+            goose_providers::conversation::token_usage::Usage::new(Some(10), Some(5), None),
+        );
+        provider_usage.additional_data = Some(
+            serde_json::json!({ "service_tier": "fast" })
+                .as_object()
+                .unwrap()
+                .clone(),
+        );
+
+        let additional_data_json = Usage::from_provider_usage(&provider_usage)
+            .unwrap()
+            .additional_data_json
+            .expect("additional data should be exposed");
+
+        assert_eq!(
+            serde_json::from_str::(&additional_data_json).unwrap(),
+            serde_json::json!({ "service_tier": "fast" })
+        );
+    }
+
+    #[test]
+    fn usage_omits_provider_additional_data_when_absent() {
+        let provider_usage = ProviderUsage::new(
+            "claude-sonnet-4-5".to_string(),
+            goose_providers::conversation::token_usage::Usage::new(Some(10), Some(5), None),
+        );
+
+        assert!(Usage::from_provider_usage(&provider_usage)
+            .unwrap()
+            .additional_data_json
+            .is_none());
+    }
+
     #[test]
     fn tool_result_content_converts() {
         let content = MessageContent::ToolResult {
diff --git a/documentation/src/data/gdk-api.json b/documentation/src/data/gdk-api.json
index fa4e81f3c52b..adc65ddebcc3 100644
--- a/documentation/src/data/gdk-api.json
+++ b/documentation/src/data/gdk-api.json
@@ -639,6 +639,12 @@
               "type": "Option",
               "default": null,
               "docs": ""
+            },
+            {
+              "name": "additional_data_json",
+              "type": "Option",
+              "default": null,
+              "docs": "Provider-specific response fields as a JSON object, present only when the provider reported fields with no canonical `Usage` equivalent."
             }
           ],
           "variants": [],

From 114250f8caf14c812be4c1d14c86abd438438fb6 Mon Sep 17 00:00:00 2001
From: Alex Hancock 
Date: Mon, 31 Aug 2026 16:26:08 -0400
Subject: [PATCH 22/37] fix(gdk): preserve tool-call indices in streaming
 responses (#11637)

---
 .../src/conversation/message.rs               |  22 +++
 .../src/conversation/tool_request.rs          |  12 +-
 .../src/formats/anthropic.rs                  | 169 +++++++++++++++---
 .../src/formats/openai.rs                     |  73 +++++++-
 crates/goose-sdk/src/bindings.rs              |  44 +++--
 documentation/docs/gdk/index.md               |   2 +-
 documentation/src/data/gdk-api.json           |   6 +
 7 files changed, 279 insertions(+), 49 deletions(-)

diff --git a/crates/goose-provider-types/src/conversation/message.rs b/crates/goose-provider-types/src/conversation/message.rs
index 1d5a3cf75a11..55bae804445e 100644
--- a/crates/goose-provider-types/src/conversation/message.rs
+++ b/crates/goose-provider-types/src/conversation/message.rs
@@ -161,6 +161,12 @@ pub const TOOL_META_EXTERNAL_DISPATCH_KEY: &str = "goose.external_dispatch";
 /// for this tool call. Used to make the title survive session reload.
 pub const TOOL_META_TITLE_KEY: &str = "goose.toolSummary.title";
 
+/// Key under `ToolRequest.tool_meta` storing the provider-reported index of the
+/// tool call within the streamed response. Streaming clients need this to
+/// correlate incremental argument fragments with the right call when a model
+/// emits several tool calls in parallel.
+pub const TOOL_META_PROVIDER_INDEX_KEY: &str = "goose.toolCall.providerIndex";
+
 /// Key under `ToolRequest.tool_meta` storing the LLM-generated chain summary
 /// for the chain that starts at this tool request. Shape: `{ "summary": String,
 /// "count": u64 }`. Only attached to the FIRST tool request in a chain.
@@ -460,6 +466,22 @@ impl MessageContentBlock {
         })
     }
 
+    pub fn tool_request_with_provider_index>(
+        id: S,
+        tool_call: ToolResult,
+        metadata: Option<&ProviderMetadata>,
+        provider_index: i32,
+    ) -> Self {
+        MessageContentBlock::ToolRequest(ToolRequest {
+            id: id.into(),
+            tool_call,
+            metadata: metadata.cloned(),
+            tool_meta: Some(serde_json::json!({
+                TOOL_META_PROVIDER_INDEX_KEY: provider_index,
+            })),
+        })
+    }
+
     pub fn tool_response>(id: S, tool_result: ToolResult) -> Self {
         MessageContentBlock::ToolResponse(ToolResponse {
             id: id.into(),
diff --git a/crates/goose-provider-types/src/conversation/tool_request.rs b/crates/goose-provider-types/src/conversation/tool_request.rs
index f8064b004311..f5dc55b0cb41 100644
--- a/crates/goose-provider-types/src/conversation/tool_request.rs
+++ b/crates/goose-provider-types/src/conversation/tool_request.rs
@@ -1,6 +1,6 @@
 use super::message::{
     ToolChainSummary, ToolNameParts, ToolRequest, TOOL_META_CHAIN_SUMMARY_KEY,
-    TOOL_META_EXTERNAL_DISPATCH_KEY, TOOL_META_TITLE_KEY,
+    TOOL_META_EXTERNAL_DISPATCH_KEY, TOOL_META_PROVIDER_INDEX_KEY, TOOL_META_TITLE_KEY,
 };
 
 impl<'a> From<&'a str> for ToolNameParts<'a> {
@@ -70,6 +70,16 @@ impl ToolRequest {
             .and_then(|v| v.as_str())
     }
 
+    /// Provider-reported index of this tool call within the streamed response.
+    /// See [`TOOL_META_PROVIDER_INDEX_KEY`].
+    pub fn provider_index(&self) -> Option {
+        self.tool_meta
+            .as_ref()
+            .and_then(|v| v.get(TOOL_META_PROVIDER_INDEX_KEY))
+            .and_then(|v| v.as_i64())
+            .map(|index| index as i32)
+    }
+
     pub fn generated_chain_summary(&self) -> Option {
         let obj = self
             .tool_meta
diff --git a/crates/goose-provider-types/src/formats/anthropic.rs b/crates/goose-provider-types/src/formats/anthropic.rs
index de34e39f1618..bc714ce12b19 100644
--- a/crates/goose-provider-types/src/formats/anthropic.rs
+++ b/crates/goose-provider-types/src/formats/anthropic.rs
@@ -901,9 +901,21 @@ where
         signature: String,
     }
 
+    fn block_index(event_data: &Value) -> Option {
+        event_data
+            .get("index")
+            .and_then(|v| v.as_i64())
+            .map(|index| index as i32)
+    }
+
     try_stream! {
-        let mut accumulated_tool_calls: std::collections::HashMap = std::collections::HashMap::new();
-        let mut current_tool_id: Option = None;
+        struct StreamingToolCall {
+            id: String,
+            name: String,
+            arguments: String,
+        }
+
+        let mut accumulated_tool_calls: std::collections::HashMap = std::collections::HashMap::new();
         let mut final_usage: Option = None;
         let mut message_id: Option = None;
         let mut thinking: Option = None;
@@ -958,11 +970,16 @@ where
                     if let Some(content_block) = event.data.get("content_block") {
                         match content_block.get(TYPE_FIELD).and_then(|v| v.as_str()) {
                             Some(TOOL_USE_TYPE) => {
-                                if let Some(id) = content_block.get("id").and_then(|v| v.as_str()) {
-                                    current_tool_id = Some(id.to_string());
-                                    if let Some(name) = content_block.get("name").and_then(|v| v.as_str()) {
-                                        accumulated_tool_calls.insert(id.to_string(), (name.to_string(), String::new()));
-                                    }
+                                if let (Some(index), Some(id), Some(name)) = (
+                                    block_index(&event.data),
+                                    content_block.get("id").and_then(|v| v.as_str()),
+                                    content_block.get("name").and_then(|v| v.as_str()),
+                                ) {
+                                    accumulated_tool_calls.insert(index, StreamingToolCall {
+                                        id: id.to_string(),
+                                        name: name.to_string(),
+                                        arguments: String::new(),
+                                    });
                                 }
                             }
                             Some(THINKING_TYPE) => {
@@ -1003,10 +1020,10 @@ where
                                 yield (Some(message), None);
                             }
                             Ok(ContentBlockDelta::InputJsonDelta { partial_json }) => {
-                                if let Some(tool_id) = ¤t_tool_id {
-                                    if let Some((_name, args)) = accumulated_tool_calls.get_mut(tool_id) {
-                                        args.push_str(&partial_json);
-                                    }
+                                if let Some(call) = block_index(&event.data)
+                                    .and_then(|index| accumulated_tool_calls.get_mut(&index))
+                                {
+                                    call.arguments.push_str(&partial_json);
                                 }
                             }
                             Ok(ContentBlockDelta::ThinkingDelta { thinking: t }) => {
@@ -1035,17 +1052,18 @@ where
                             yield (Some(message), None);
                         }
                     }
-                    if let Some(tool_id) = current_tool_id.take() {
-                        if let Some((name, args)) = accumulated_tool_calls.remove(&tool_id) {
-                            let parsed_args = if args.is_empty() {
+                    if let Some(index) = block_index(&event.data) {
+                        if let Some(call) = accumulated_tool_calls.remove(&index) {
+                            let StreamingToolCall { id, name, arguments } = call;
+                            let parsed_args = if arguments.is_empty() {
                                 json!({})
                             } else {
-                                match crate::json::parse_tool_arguments(&args) {
+                                match crate::json::parse_tool_arguments(&arguments) {
                                     Some(parsed) => parsed,
                                     None => {
-                                        let message_text = crate::json::truncation_error_message(&args)
+                                        let message_text = crate::json::truncation_error_message(&arguments)
                                             .unwrap_or_else(|| {
-                                                format!("Could not parse tool arguments: {args}")
+                                                format!("Could not parse tool arguments: {arguments}")
                                             });
                                         let error = ErrorData::new(
                                             ErrorCode::INVALID_PARAMS,
@@ -1055,7 +1073,7 @@ where
                                         let mut message = Message::new(
                                             Role::Assistant,
                                             chrono::Utc::now().timestamp(),
-                                            vec![MessageContentBlock::tool_request(tool_id, Err(error))],
+                                            vec![MessageContentBlock::tool_request_with_provider_index(id, Err(error), None, index)],
                                         );
                                         message.id = message_id.clone();
                                         yield (Some(message), None);
@@ -1069,7 +1087,7 @@ where
                             let mut message = Message::new(
                                 rmcp::model::Role::Assistant,
                                 chrono::Utc::now().timestamp(),
-                                vec![MessageContentBlock::tool_request(tool_id, Ok(tool_call))],
+                                vec![MessageContentBlock::tool_request_with_provider_index(id, Ok(tool_call), None, index)],
                             );
                             message.id = message_id.clone();
                             yield (Some(message), None);
@@ -1158,10 +1176,10 @@ where
         // content_block_stop, so its args are truncated rather than complete.
         if !accumulated_tool_calls.is_empty() {
             let truncated_by_limit = stop_reason.as_deref() == Some("max_tokens");
-            let mut ids: Vec = accumulated_tool_calls.keys().cloned().collect();
-            ids.sort();
-            for id in ids {
-                if let Some((_name, args)) = accumulated_tool_calls.remove(&id) {
+            let mut indices: Vec = accumulated_tool_calls.keys().copied().collect();
+            indices.sort();
+            for index in indices {
+                if let Some(StreamingToolCall { id, arguments: args, .. }) = accumulated_tool_calls.remove(&index) {
                     let guidance = if truncated_by_limit {
                         "The model's response was truncated — it hit the output token limit while generating this tool call. \
                          Try increasing max_tokens for this provider or breaking the task into smaller steps."
@@ -1178,7 +1196,7 @@ where
                     let mut message = Message::new(
                         Role::Assistant,
                         chrono::Utc::now().timestamp(),
-                        vec![MessageContentBlock::tool_request(id, Err(error))],
+                        vec![MessageContentBlock::tool_request_with_provider_index(id, Err(error), None, index)],
                     );
                     message.id = message_id.clone();
                     yield (Some(message), None);
@@ -2427,6 +2445,74 @@ mod tests {
         response_to_streaming_message(stream).collect().await
     }
 
+    #[tokio::test]
+    async fn test_streaming_reassembles_interleaved_parallel_tool_calls() {
+        let events = concat!(
+            r#"data: {"type":"message_start","message":{"id":"msg_par","role":"assistant","content":[],"model":"claude-opus-4-6","usage":{"input_tokens":5,"output_tokens":0}}}"#,
+            "\n",
+            r#"data: {"type":"content_block_start","index":0,"content_block":{"type":"tool_use","id":"tool_a","name":"search","input":{}}}"#,
+            "\n",
+            r#"data: {"type":"content_block_start","index":1,"content_block":{"type":"tool_use","id":"tool_b","name":"write","input":{}}}"#,
+            "\n",
+            r#"data: {"type":"content_block_delta","index":1,"delta":{"type":"input_json_delta","partial_json":"{\"path\":"}}"#,
+            "\n",
+            r#"data: {"type":"content_block_delta","index":0,"delta":{"type":"input_json_delta","partial_json":"{\"query\":"}}"#,
+            "\n",
+            r#"data: {"type":"content_block_delta","index":1,"delta":{"type":"input_json_delta","partial_json":"\"/tmp/a.md\"}"}}"#,
+            "\n",
+            r#"data: {"type":"content_block_delta","index":0,"delta":{"type":"input_json_delta","partial_json":"\"rust\"}"}}"#,
+            "\n",
+            r#"data: {"type":"content_block_stop","index":1}"#,
+            "\n",
+            r#"data: {"type":"content_block_stop","index":0}"#,
+            "\n",
+            r#"data: {"type":"message_delta","delta":{"stop_reason":"tool_use"},"usage":{"output_tokens":20}}"#,
+            "\n",
+            r#"data: {"type":"message_stop"}"#,
+        );
+
+        let requests = collect_tool_requests(events).await;
+
+        assert_eq!(requests.len(), 2);
+        let write = requests
+            .iter()
+            .find(|r| r.id == "tool_b")
+            .expect("write tool request");
+        assert_eq!(write.provider_index(), Some(1));
+        let write_call = write.tool_call.as_ref().expect("write args parsed");
+        assert_eq!(write_call.name, "write");
+        assert_eq!(
+            write_call.arguments.as_ref().unwrap()["path"],
+            json!("/tmp/a.md")
+        );
+
+        let search = requests
+            .iter()
+            .find(|r| r.id == "tool_a")
+            .expect("search tool request");
+        assert_eq!(search.provider_index(), Some(0));
+        let search_call = search.tool_call.as_ref().expect("search args parsed");
+        assert_eq!(search_call.name, "search");
+        assert_eq!(
+            search_call.arguments.as_ref().unwrap()["query"],
+            json!("rust")
+        );
+    }
+
+    async fn collect_tool_requests(events: &str) -> Vec {
+        let mut requests = Vec::new();
+        for result in collect_stream_results(events).await {
+            if let Ok((Some(msg), _usage)) = result {
+                for content in &msg.content {
+                    if let MessageContentBlock::ToolRequest(req) = content {
+                        requests.push(req.clone());
+                    }
+                }
+            }
+        }
+        requests
+    }
+
     #[tokio::test]
     async fn test_streaming_preserves_cache_tokens_through_delta_merge() {
         let events = concat!(
@@ -2739,6 +2825,41 @@ mod tests {
         );
     }
 
+    #[tokio::test]
+    async fn test_streaming_unfinished_tool_calls_keep_provider_indices() {
+        let events = concat!(
+            r#"data: {"type":"message_start","message":{"id":"msg_t3","role":"assistant","content":[],"model":"claude-opus-4-6","usage":{"input_tokens":10,"output_tokens":0}}}"#,
+            "\n",
+            r#"data: {"type":"content_block_start","index":0,"content_block":{"type":"tool_use","id":"tool_open_a","name":"search","input":{}}}"#,
+            "\n",
+            r#"data: {"type":"content_block_start","index":1,"content_block":{"type":"tool_use","id":"tool_open_b","name":"write","input":{}}}"#,
+            "\n",
+            r#"data: {"type":"content_block_delta","index":0,"delta":{"type":"input_json_delta","partial_json":"{\"query\":\"ru"}}"#,
+            "\n",
+            r#"data: {"type":"content_block_delta","index":1,"delta":{"type":"input_json_delta","partial_json":"{\"path\":\"/re"}}"#,
+            "\n",
+            r#"data: {"type":"message_delta","delta":{"stop_reason":"max_tokens"},"usage":{"output_tokens":8192}}"#,
+            "\n",
+            r#"data: {"type":"message_stop"}"#,
+        );
+
+        let requests = collect_tool_requests(events).await;
+
+        assert_eq!(requests.len(), 2);
+        let indexed: Vec<(String, Option)> = requests
+            .iter()
+            .map(|r| (r.id.clone(), r.provider_index()))
+            .collect();
+        assert_eq!(
+            indexed,
+            vec![
+                ("tool_open_a".to_string(), Some(0)),
+                ("tool_open_b".to_string(), Some(1)),
+            ]
+        );
+        assert!(requests.iter().all(|r| r.tool_call.is_err()));
+    }
+
     #[tokio::test]
     async fn test_streaming_complete_tool_call_unaffected() {
         // Regression guard: a normal, complete tool call must still parse and
diff --git a/crates/goose-provider-types/src/formats/openai.rs b/crates/goose-provider-types/src/formats/openai.rs
index 0c657f848b29..c78676d62db0 100644
--- a/crates/goose-provider-types/src/formats/openai.rs
+++ b/crates/goose-provider-types/src/formats/openai.rs
@@ -1457,23 +1457,26 @@ where
                         };
 
                         let content = if output_token_limit_reached {
-                            MessageContentBlock::tool_request_with_metadata(
+                            MessageContentBlock::tool_request_with_provider_index(
                                 id.clone(),
                                 Err(output_token_limit_tool_error(function_name, id)),
                                 metadata.as_ref(),
+                                index,
                             )
                         } else if arguments.is_empty() {
-                            MessageContentBlock::tool_request_with_metadata(
+                            MessageContentBlock::tool_request_with_provider_index(
                                 id.clone(),
                                 Ok(CallToolRequestParams::new(function_name.clone()).with_arguments(object(json!({})))),
                                 metadata.as_ref(),
+                                index,
                             )
                         } else {
                             match parse_tool_arguments(arguments) {
-                                Some(params) if params.is_object() => MessageContentBlock::tool_request_with_metadata(
+                                Some(params) if params.is_object() => MessageContentBlock::tool_request_with_provider_index(
                                     id.clone(),
                                     Ok(CallToolRequestParams::new(function_name.clone()).with_arguments(object(params))),
                                     metadata.as_ref(),
+                                    index,
                                 ),
                                 // Valid JSON but NOT an object (a bare array/string/number).
                                 // Surface a tool error so the model retries instead of
@@ -1488,7 +1491,7 @@ where
                                         )),
                                         data: None,
                                     };
-                                    MessageContentBlock::tool_request_with_metadata(id.clone(), Err(error), metadata.as_ref())
+                                    MessageContentBlock::tool_request_with_provider_index(id.clone(), Err(error), metadata.as_ref(), index)
                                 }
                                 None => {
                                     let message_text = truncation_error_message(arguments)
@@ -1500,7 +1503,7 @@ where
                                         message: Cow::from(message_text),
                                         data: None,
                                     };
-                                    MessageContentBlock::tool_request_with_metadata(id.clone(), Err(error), metadata.as_ref())
+                                    MessageContentBlock::tool_request_with_provider_index(id.clone(), Err(error), metadata.as_ref(), index)
                                 }
                             }
                         };
@@ -3315,6 +3318,66 @@ mod tests {
         assert_eq!(usage.usage.total_tokens, Some(expected_total));
     }
 
+    async fn collect_streamed_tool_requests(
+        response_lines: &str,
+    ) -> Vec {
+        let lines: Vec = response_lines.lines().map(|s| s.to_string()).collect();
+        let response_stream = tokio_stream::iter(lines.into_iter().map(Ok));
+        let messages = response_to_streaming_message(response_stream);
+        pin!(messages);
+
+        let mut requests = Vec::new();
+        while let Some(Ok((message, _usage))) = messages.next().await {
+            if let Some(msg) = message {
+                for content in &msg.content {
+                    if let MessageContentBlock::ToolRequest(req) = content {
+                        requests.push(req.clone());
+                    }
+                }
+            }
+        }
+        requests
+    }
+
+    #[tokio::test]
+    async fn test_streaming_reassembles_interleaved_parallel_tool_calls() {
+        let response_lines = concat!(
+            r#"data: {"id":"chatcmpl-par","model":"test-model","choices":[{"index":0,"delta":{"role":"assistant","tool_calls":[{"index":0,"id":"call_a","type":"function","function":{"name":"search","arguments":""}},{"index":1,"id":"call_b","type":"function","function":{"name":"write","arguments":""}}]},"finish_reason":null}]}"#,
+            "\n",
+            r#"data: {"id":"chatcmpl-par","model":"test-model","choices":[{"index":0,"delta":{"tool_calls":[{"index":1,"function":{"arguments":"{\"path\":"}}]},"finish_reason":null}]}"#,
+            "\n",
+            r#"data: {"id":"chatcmpl-par","model":"test-model","choices":[{"index":0,"delta":{"tool_calls":[{"index":0,"function":{"arguments":"{\"query\":"}}]},"finish_reason":null}]}"#,
+            "\n",
+            r#"data: {"id":"chatcmpl-par","model":"test-model","choices":[{"index":0,"delta":{"tool_calls":[{"index":1,"function":{"arguments":"\"/tmp/a.md\"}"}}]},"finish_reason":null}]}"#,
+            "\n",
+            r#"data: {"id":"chatcmpl-par","model":"test-model","choices":[{"index":0,"delta":{"tool_calls":[{"index":0,"function":{"arguments":"\"rust\"}"}}]},"finish_reason":"tool_calls"}]}"#,
+            "\n",
+            "data: [DONE]",
+        );
+
+        let requests = collect_streamed_tool_requests(response_lines).await;
+
+        assert_eq!(requests.len(), 2);
+        assert_eq!(
+            requests
+                .iter()
+                .map(|r| r.provider_index())
+                .collect::>(),
+            vec![Some(0), Some(1)]
+        );
+
+        let search = requests[0].tool_call.as_ref().expect("search args parsed");
+        assert_eq!(search.name, "search");
+        assert_eq!(search.arguments.as_ref().unwrap()["query"], json!("rust"));
+
+        let write = requests[1].tool_call.as_ref().expect("write args parsed");
+        assert_eq!(write.name, "write");
+        assert_eq!(
+            write.arguments.as_ref().unwrap()["path"],
+            json!("/tmp/a.md")
+        );
+    }
+
     #[tokio::test]
     async fn test_streaming_marks_length_on_empty_terminal_chunk() -> anyhow::Result<()> {
         let response_lines = r#"
diff --git a/crates/goose-sdk/src/bindings.rs b/crates/goose-sdk/src/bindings.rs
index 6ce9ab4786be..967b7b94c740 100644
--- a/crates/goose-sdk/src/bindings.rs
+++ b/crates/goose-sdk/src/bindings.rs
@@ -535,6 +535,7 @@ pub enum StreamChunk {
         id: String,
         name: String,
         arguments_json: String,
+        index: Option,
         #[uniffi(default = None)]
         provider_metadata_json: Option,
     },
@@ -1239,25 +1240,32 @@ fn message_to_chunks(message: Message) -> Vec {
                     text: text.text.clone(),
                 })
             }
-            GooseMessageContent::ToolRequest(request) => match request.tool_call {
-                Ok(tool_call) => Some(StreamChunk::ToolChunk {
-                    id: request.id,
-                    name: tool_call.name.to_string(),
-                    arguments_json: serde_json::to_string(&tool_call.arguments.unwrap_or_default())
+            GooseMessageContent::ToolRequest(request) => {
+                let index = request.provider_index();
+                let provider_metadata_json = request
+                    .metadata
+                    .as_ref()
+                    .and_then(|metadata| serde_json::to_string(metadata).ok());
+                match request.tool_call {
+                    Ok(tool_call) => Some(StreamChunk::ToolChunk {
+                        index,
+                        id: request.id,
+                        name: tool_call.name.to_string(),
+                        arguments_json: serde_json::to_string(
+                            &tool_call.arguments.unwrap_or_default(),
+                        )
                         .unwrap_or_else(|_| "{}".to_string()),
-                    provider_metadata_json: request
-                        .metadata
-                        .as_ref()
-                        .and_then(|metadata| serde_json::to_string(metadata).ok()),
-                }),
-                Err(error) => Some(StreamChunk::ErrorChunk {
-                    error: GooseStreamError {
-                        kind: GooseStreamErrorKind::Generic,
-                        message: error.to_string(),
-                        retry_after_ms: None,
-                    },
-                }),
-            },
+                        provider_metadata_json,
+                    }),
+                    Err(error) => Some(StreamChunk::ErrorChunk {
+                        error: GooseStreamError {
+                            kind: GooseStreamErrorKind::Generic,
+                            message: error.to_string(),
+                            retry_after_ms: None,
+                        },
+                    }),
+                }
+            }
             GooseMessageContent::Thinking(thinking) => Some(StreamChunk::ThinkingChunk {
                 thinking: thinking.thinking,
                 signature: thinking.signature,
diff --git a/documentation/docs/gdk/index.md b/documentation/docs/gdk/index.md
index decae16475a8..4dc3583ec58b 100644
--- a/documentation/docs/gdk/index.md
+++ b/documentation/docs/gdk/index.md
@@ -223,7 +223,7 @@ resolved when the provider is constructed.
 | Chunk | Meaning |
 | --- | --- |
 | `TextChunk` | Assistant text |
-| `ToolChunk` | A tool call request with JSON arguments |
+| `ToolChunk` | A tool call request with JSON arguments and the provider's tool-call `index` |
 | `ThinkingChunk` / `RedactedThinkingChunk` | Reasoning output |
 | `EndChunk` | Stream finished, carries final token `Usage` |
 | `ErrorChunk` | Mid-stream failure, carries a `GooseStreamError` |
diff --git a/documentation/src/data/gdk-api.json b/documentation/src/data/gdk-api.json
index adc65ddebcc3..f373936e9442 100644
--- a/documentation/src/data/gdk-api.json
+++ b/documentation/src/data/gdk-api.json
@@ -688,6 +688,12 @@
                   "default": null,
                   "docs": ""
                 },
+                {
+                  "name": "index",
+                  "type": "Option",
+                  "default": null,
+                  "docs": ""
+                },
                 {
                   "name": "provider_metadata_json",
                   "type": "Option",

From ed9648b9236036d62a316479732382c8c70b430a Mon Sep 17 00:00:00 2001
From: Jasper 
Date: Mon, 31 Aug 2026 20:30:02 +0000
Subject: [PATCH 23/37] fix(security): restrict extension tool dispatch
 (#11602)

---
 crates/goose/src/agents/extension_manager.rs | 41 ++++++++++++--------
 1 file changed, 25 insertions(+), 16 deletions(-)

diff --git a/crates/goose/src/agents/extension_manager.rs b/crates/goose/src/agents/extension_manager.rs
index daeaa8f17b5c..bc48d0e304ee 100644
--- a/crates/goose/src/agents/extension_manager.rs
+++ b/crates/goose/src/agents/extension_manager.rs
@@ -2255,19 +2255,6 @@ impl ExtensionManager {
                 });
             }
 
-            if let Some((prefix, actual)) = name.split_once("__") {
-                let owner = name_to_key(prefix);
-                if let Some(client) = self.get_server_client(&owner).await {
-                    return Ok(ResolvedTool {
-                        extension_name: owner,
-                        actual_tool_name: actual.to_string(),
-                        client,
-                        tool_meta: None,
-                        resource_uri: None,
-                    });
-                }
-            }
-
             if !recovery_attempted {
                 recovery_attempted = true;
                 let owners: Vec<(&str, Option)> = tools
@@ -2928,9 +2915,8 @@ mod tests {
             _cancellation_token: CancellationToken,
         ) -> Result {
             match name {
-                "tool" | "test__tool" | "available_tool" | "hidden_tool" | "render_chart" => {
-                    Ok(CallToolResult::success(vec![]))
-                }
+                "tool" | "test__tool" | "available_tool" | "hidden_tool" | "render_chart"
+                | "unadvertised_tool" => Ok(CallToolResult::success(vec![])),
                 _ => Err(Error::TransportClosed),
             }
         }
@@ -3722,6 +3708,29 @@ mod tests {
         assert_eq!(resolved.extension_name, "developer");
     }
 
+    #[tokio::test]
+    async fn test_dispatch_rejects_unadvertised_tool_implemented_by_extension() {
+        let temp_dir = tempfile::tempdir().unwrap();
+        let extension_manager =
+            ExtensionManager::new_without_provider(temp_dir.path().to_path_buf());
+        extension_manager
+            .add_mock_extension("test_client".to_string(), Arc::new(MockClient {}))
+            .await;
+
+        let ctx = ToolCallContext::new("test-session-id".to_string(), None, None);
+        let tool_call = CallToolRequestParams::new("test_client__unadvertised_tool".to_string());
+
+        let err = match extension_manager
+            .dispatch_tool_call(&ctx, tool_call, CancellationToken::default())
+            .await
+        {
+            Ok(_) => panic!("an unadvertised tool must not be dispatched"),
+            Err(err) => err,
+        };
+
+        assert_eq!(err.code, ErrorCode::RESOURCE_NOT_FOUND);
+    }
+
     #[test]
     fn test_recover_mangled_tool_name() {
         let tools = [("developer__shell", None), ("platform__search", None)];

From 5345d05176f76532e99cb6bef20372920c80f06b Mon Sep 17 00:00:00 2001
From: Raghav Bhardwaj <118196545+RaghavBhardwaj18@users.noreply.github.com>
Date: Mon, 31 Aug 2026 20:39:12 +0000
Subject: [PATCH 24/37] fixed 24 hour locale system bug (#11719)

---
 ui/desktop/src/components/Hub.tsx      |  14 ++--
 ui/desktop/src/utils/timeUtils.test.ts | 100 +++++++++++++++++++++++++
 ui/desktop/src/utils/timeUtils.ts      |  39 ++++++++++
 3 files changed, 145 insertions(+), 8 deletions(-)
 create mode 100644 ui/desktop/src/utils/timeUtils.test.ts

diff --git a/ui/desktop/src/components/Hub.tsx b/ui/desktop/src/components/Hub.tsx
index 46e684d4dba1..91e7da555ca0 100644
--- a/ui/desktop/src/components/Hub.tsx
+++ b/ui/desktop/src/components/Hub.tsx
@@ -27,6 +27,7 @@ import {
 } from '../utils/nextChatExtensions';
 import { formatAcpError } from '../acp/errors';
 import { toastError } from '../toasts';
+import { formatClockDisplay } from '../utils/timeUtils';
 
 const i18n = defineMessages({
   goodMorning: { id: 'hub.goodMorning', defaultMessage: 'Good morning' },
@@ -34,19 +35,14 @@ const i18n = defineMessages({
   goodEvening: { id: 'hub.goodEvening', defaultMessage: 'Good evening' },
 });
 
-function useClock(): { time: string; meridiem: string; hour: number } {
+function useClock() {
   const [now, setNow] = useState(() => new Date());
   useEffect(() => {
     const interval = setInterval(() => setNow(new Date()), 30_000);
     return () => clearInterval(interval);
   }, []);
 
-  const hour = now.getHours();
-  const minutes = now.getMinutes();
-  const meridiem = hour >= 12 ? 'PM' : 'AM';
-  const displayHour = ((hour + 11) % 12) + 1;
-  const time = `${displayHour}:${String(minutes).padStart(2, '0')}`;
-  return { time, meridiem, hour };
+  return formatClockDisplay(now);
 }
 
 export default function Hub({
@@ -151,7 +147,9 @@ export default function Hub({
           
             {time}
           
-          {meridiem}
+          {meridiem ? (
+            {meridiem}
+          ) : null}
         

{greeting}

diff --git a/ui/desktop/src/utils/timeUtils.test.ts b/ui/desktop/src/utils/timeUtils.test.ts new file mode 100644 index 000000000000..22806f85d9aa --- /dev/null +++ b/ui/desktop/src/utils/timeUtils.test.ts @@ -0,0 +1,100 @@ +import { describe, it, expect } from 'vitest'; +import { formatClockDisplay, formatMessageTimestamp } from './timeUtils'; + +describe('timeUtils', () => { + describe('formatClockDisplay', () => { + it('formats 12-hour locale with AM/PM meridiem (en-US)', () => { + // 8:49 PM + const eveningDate = new Date(2026, 8, 1, 20, 49, 0); + const eveningResult = formatClockDisplay(eveningDate, 'en-US'); + expect(eveningResult.time).toBe('8:49'); + expect(eveningResult.meridiem).toBe('PM'); + expect(eveningResult.hour).toBe(20); + + // 8:49 AM + const morningDate = new Date(2026, 8, 1, 8, 49, 0); + const morningResult = formatClockDisplay(morningDate, 'en-US'); + expect(morningResult.time).toBe('8:49'); + expect(morningResult.meridiem).toBe('AM'); + expect(morningResult.hour).toBe(8); + + // Midnight (12:00 AM) + const midnight = new Date(2026, 8, 1, 0, 0, 0); + const midnightResult = formatClockDisplay(midnight, 'en-US'); + expect(midnightResult.time).toBe('12:00'); + expect(midnightResult.meridiem).toBe('AM'); + expect(midnightResult.hour).toBe(0); + + // Noon (12:00 PM) + const noon = new Date(2026, 8, 1, 12, 0, 0); + const noonResult = formatClockDisplay(noon, 'en-US'); + expect(noonResult.time).toBe('12:00'); + expect(noonResult.meridiem).toBe('PM'); + expect(noonResult.hour).toBe(12); + }); + + it('formats 24-hour locale without meridiem (en-GB)', () => { + const eveningDate = new Date(2026, 8, 1, 20, 49, 0); + const result = formatClockDisplay(eveningDate, 'en-GB'); + expect(result.time).toBe('20:49'); + expect(result.meridiem).toBe(''); + expect(result.hour).toBe(20); + + const midnight = new Date(2026, 8, 1, 0, 5, 0); + const midnightResult = formatClockDisplay(midnight, 'en-GB'); + expect(midnightResult.time).toBe('0:05'); + expect(midnightResult.meridiem).toBe(''); + expect(midnightResult.hour).toBe(0); + }); + + it('formats 24-hour European locales without meridiem (de-DE, sv-SE, fr-FR)', () => { + const eveningDate = new Date(2026, 8, 1, 20, 49, 0); + + const deResult = formatClockDisplay(eveningDate, 'de-DE'); + expect(deResult.time).toBe('20:49'); + expect(deResult.meridiem).toBe(''); + + const svResult = formatClockDisplay(eveningDate, 'sv-SE'); + expect(svResult.time).toBe('20:49'); + expect(svResult.meridiem).toBe(''); + + const frResult = formatClockDisplay(eveningDate, 'fr-FR'); + expect(frResult.time).toBe('20:49'); + expect(frResult.meridiem).toBe(''); + }); + + it('defaults to current date when no date is supplied', () => { + const result = formatClockDisplay(); + expect(result).toHaveProperty('time'); + expect(result).toHaveProperty('meridiem'); + expect(result).toHaveProperty('hour'); + expect(typeof result.time).toBe('string'); + expect(typeof result.hour).toBe('number'); + }); + + it('handles unexpected/invalid locale gracefully using fallback', () => { + const eveningDate = new Date(2026, 8, 1, 20, 49, 0); + const result = formatClockDisplay(eveningDate, 'invalid-locale-!!!'); + expect(result.time).toBe('8:49'); + expect(result.meridiem).toBe('PM'); + expect(result.hour).toBe(20); + }); + }); + + describe('formatMessageTimestamp', () => { + it('formats timestamp from today with time only', () => { + const now = new Date(); + const timestamp = Math.floor(now.getTime() / 1000); + const result = formatMessageTimestamp(timestamp); + expect(result).toBeTruthy(); + expect(typeof result).toBe('string'); + }); + + it('formats timestamp from previous date with date and time', () => { + const pastDate = new Date(2025, 0, 1, 12, 0, 0); + const timestamp = Math.floor(pastDate.getTime() / 1000); + const result = formatMessageTimestamp(timestamp); + expect(result).toContain('2025'); + }); + }); +}); diff --git a/ui/desktop/src/utils/timeUtils.ts b/ui/desktop/src/utils/timeUtils.ts index 6de531d059f5..e4fc4408bfe4 100644 --- a/ui/desktop/src/utils/timeUtils.ts +++ b/ui/desktop/src/utils/timeUtils.ts @@ -28,3 +28,42 @@ export function formatMessageTimestamp(timestamp?: number): string { return `${dateStr} ${timeStr}`; } + +export interface ClockDisplay { + time: string; + meridiem: string; + hour: number; +} + +export function formatClockDisplay( + date: Date = new Date(), + locale: string = currentLocale +): ClockDisplay { + const hour = date.getHours(); + + try { + const formatter = new Intl.DateTimeFormat(locale, { + hour: 'numeric', + minute: '2-digit', + }); + + const parts = formatter.formatToParts(date); + const dayPeriodPart = parts.find((p) => p.type === 'dayPeriod'); + const meridiem = dayPeriodPart ? dayPeriodPart.value : ''; + + const time = parts + .filter((p) => p.type !== 'dayPeriod') + .map((p) => p.value) + .join('') + .trim(); + + return { time, meridiem, hour }; + } catch { + const minutes = date.getMinutes(); + const meridiem = hour >= 12 ? 'PM' : 'AM'; + const displayHour = ((hour + 11) % 12) + 1; + const time = `${displayHour}:${String(minutes).padStart(2, '0')}`; + return { time, meridiem, hour }; + } +} + From 35897f6984eacd9414129823f247fb9e31c79545 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 1 Sep 2026 00:02:13 +0000 Subject: [PATCH 25/37] chore(deps): bump actions/setup-java from 5.7.0 to 6.0.0 (#11659) Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/maven-sdk.yml | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/.github/workflows/maven-sdk.yml b/.github/workflows/maven-sdk.yml index 0c53cbc362de..2c4395704e68 100644 --- a/.github/workflows/maven-sdk.yml +++ b/.github/workflows/maven-sdk.yml @@ -81,7 +81,7 @@ jobs: uses: actions-rust-lang/setup-rust-toolchain@166cdcfd11aee3cb47222f9ddb555ce30ddb9659 # v1 - name: Set up Java - uses: actions/setup-java@b6effb05e454b25005698d916606bdc6ffcbf961 # v5 + uses: actions/setup-java@dd06d9cba3e5552c54d9f8ea23572deb30010f7c # v6.0.0 with: distribution: temurin java-version: "17" @@ -135,7 +135,7 @@ jobs: uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 - name: Set up Java - uses: actions/setup-java@b6effb05e454b25005698d916606bdc6ffcbf961 # v5 + uses: actions/setup-java@dd06d9cba3e5552c54d9f8ea23572deb30010f7c # v6.0.0 with: distribution: temurin java-version: "17" @@ -185,7 +185,7 @@ jobs: uses: actions-rust-lang/setup-rust-toolchain@166cdcfd11aee3cb47222f9ddb555ce30ddb9659 # v1 - name: Set up Java - uses: actions/setup-java@b6effb05e454b25005698d916606bdc6ffcbf961 # v5 + uses: actions/setup-java@dd06d9cba3e5552c54d9f8ea23572deb30010f7c # v6.0.0 with: distribution: temurin java-version: "17" From 0a9749b1cbf38d182080a53286c4942629f66565 Mon Sep 17 00:00:00 2001 From: Leon Bowie Date: Tue, 1 Sep 2026 01:42:11 +0000 Subject: [PATCH 26/37] feat(auth): enable refresh auth cli (#11657) Co-authored-by: Claude Sonnet 5 --- crates/goose-cli/src/commands/configure.rs | 70 ++- crates/goose-providers/src/anthropic.rs | 2 + crates/goose-providers/src/api_client.rs | 7 + crates/goose-providers/src/declarative.rs | 45 ++ crates/goose-providers/src/ollama.rs | 3 + crates/goose-providers/src/openai.rs | 3 + crates/goose/src/acp/server/providers.rs | 18 +- .../goose/src/config/declarative_providers.rs | 61 ++- crates/goose/src/providers/anthropic_def.rs | 17 +- crates/goose/src/providers/command_auth.rs | 475 ++++++++++++++++++ crates/goose/src/providers/huggingface.rs | 27 +- crates/goose/src/providers/inventory/mod.rs | 13 + crates/goose/src/providers/mod.rs | 1 + crates/goose/src/providers/ollama_cloud.rs | 1 + crates/goose/src/providers/ollama_def.rs | 16 +- crates/goose/src/providers/openai_def.rs | 12 +- .../goose/src/providers/provider_registry.rs | 3 +- .../custom_provider_command_auth_test.rs | 184 +++++++ .../docs/getting-started/providers.md | 48 +- 19 files changed, 985 insertions(+), 21 deletions(-) create mode 100644 crates/goose/src/providers/command_auth.rs create mode 100644 crates/goose/tests/custom_provider_command_auth_test.rs diff --git a/crates/goose-cli/src/commands/configure.rs b/crates/goose-cli/src/commands/configure.rs index 092332639ef0..a5895fd2b358 100644 --- a/crates/goose-cli/src/commands/configure.rs +++ b/crates/goose-cli/src/commands/configure.rs @@ -6,7 +6,7 @@ use goose::agents::extension_manager::get_parameter_names; use goose::agents::Agent; use goose::agents::{extension::Envs, ExtensionConfig}; use goose::config::declarative_providers::{ - create_custom_provider, remove_custom_provider, CreateCustomProviderParams, + create_custom_provider, remove_custom_provider, AuthConfig, CreateCustomProviderParams, }; use goose::config::extensions::{ get_all_extension_names, get_all_extensions, get_enabled_extensions, get_extension_by_name, @@ -2198,11 +2198,68 @@ fn add_provider() -> anyhow::Result<()> { .initial_value(true) .interact()?; - let api_key: String = if requires_auth { - cliclack::password("API key:").mask('▪').interact()? - } else { - String::new() - }; + let mut api_key = String::new(); + let mut auth: Option = None; + + if requires_auth { + let auth_mode = cliclack::select("How should goose obtain credentials for this provider?") + .item("static", "Static API key", "Enter a fixed API key now") + .item( + "command", + "Command (refreshable)", + "Run a command to fetch/refresh a short-lived credential", + ) + .interact()?; + + if auth_mode == "command" { + let command: String = cliclack::input("Command to run for the credential:") + .placeholder("/path/to/get-token.sh") + .validate(|input: &String| { + if input.trim().is_empty() { + Err("Please enter a command") + } else { + Ok(()) + } + }) + .interact()?; + + let args_input: String = + cliclack::input("Command arguments (comma-separated, optional):") + .placeholder("--flag, value") + .required(false) + .interact()?; + let args: Vec = args_input + .split(',') + .map(str::trim) + .filter(|arg| !arg.is_empty()) + .map(str::to_string) + .collect(); + + let refresh_interval_input: String = cliclack::input( + "Refresh interval in seconds (0 to only refresh after an auth failure):", + ) + .default_input("3600") + .validate(|input: &String| { + input + .trim() + .parse::() + .map(|_| ()) + .map_err(|_| "Please enter a whole number of seconds") + }) + .interact()?; + let refresh_interval: u64 = refresh_interval_input.trim().parse().unwrap_or(3600); + + auth = Some(AuthConfig { + command, + args, + refresh_interval, + timeout_seconds: None, + cwd: None, + }); + } else { + api_key = cliclack::password("API key:").mask('▪').interact()?; + } + } let models_input: String = cliclack::input("Available models (separate with commas):") .placeholder("model-a, model-b, model-c") @@ -2251,6 +2308,7 @@ fn add_provider() -> anyhow::Result<()> { catalog_provider_id: None, base_path, preserves_thinking: None, + auth, })?; if !provider_config.models.is_empty() { diff --git a/crates/goose-providers/src/anthropic.rs b/crates/goose-providers/src/anthropic.rs index 38dcdb05c873..a9a54f8f2cba 100644 --- a/crates/goose-providers/src/anthropic.rs +++ b/crates/goose-providers/src/anthropic.rs @@ -420,6 +420,8 @@ pub fn from_declarative_config( )); } + config.validate_auth()?; + let api_key = if config.api_key_env.is_empty() { None } else { diff --git a/crates/goose-providers/src/api_client.rs b/crates/goose-providers/src/api_client.rs index 43ac83b1fd58..beb95d6bf1f8 100644 --- a/crates/goose-providers/src/api_client.rs +++ b/crates/goose-providers/src/api_client.rs @@ -429,6 +429,13 @@ impl ApiClient { self } + // Auth is applied fresh on every request, so unlike `with_headers` this + // doesn't need to rebuild the underlying `reqwest::Client`. + pub fn with_auth(mut self, auth: AuthMethod) -> Self { + self.auth = auth; + self + } + pub fn with_https_only(mut self) -> Result { self.transport_policy = TransportPolicy::HttpsOnly; self.rebuild_client()?; diff --git a/crates/goose-providers/src/declarative.rs b/crates/goose-providers/src/declarative.rs index f42fce9ed6fb..b158c2b10a47 100644 --- a/crates/goose-providers/src/declarative.rs +++ b/crates/goose-providers/src/declarative.rs @@ -114,6 +114,35 @@ impl FromStr for ProviderEngine { } } +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct AuthConfig { + /// Executed directly, never through a shell, so `args` is never + /// shell-interpolated. For shell features, invoke an interpreter + /// explicitly, e.g. `command: "/bin/bash"`, `args: ["-c", "..."]`. + /// Bare names (no path separator) are resolved via `PATH`; paths are + /// resolved against `cwd`. + pub command: String, + #[serde(default)] + pub args: Vec, + /// How long a fetched credential is cached before the command is re-run, + /// in seconds. `0` disables proactive refresh entirely — the command + /// only reruns reactively, after an auth failure (matches Codex's + /// `refresh_interval_ms: 0` convention). + #[serde(default = "default_refresh_interval")] + pub refresh_interval: u64, + /// Timeout for the command, in seconds. Defaults to 10s if unset. + #[serde(default)] + pub timeout_seconds: Option, + /// Working directory for the command, and the base a relative `command` + /// path is resolved against. Defaults to goose's current directory. + #[serde(default)] + pub cwd: Option, +} + +fn default_refresh_interval() -> u64 { + 3600 +} + #[derive(Debug, Clone, Serialize, Deserialize)] pub struct DeclarativeProviderConfig { pub name: String, @@ -135,6 +164,10 @@ pub struct DeclarativeProviderConfig { pub base_path: Option, #[serde(default)] pub env_vars: Option>, + /// Alternative to `api_key_env`: run a command to fetch/refresh the credential + /// instead of reading a static secret. Mutually exclusive with `api_key_env`. + #[serde(default)] + pub auth: Option, /// Controls whether `fetch_supported_models` calls the provider's `/v1/models` /// endpoint or returns the static `models` list directly. /// @@ -189,6 +222,18 @@ impl DeclarativeProviderConfig { pub fn models(&self) -> &[ModelInfo] { &self.models } + + /// Errors if both `api_key_env` and `auth.command` are set; they're + /// alternative ways to authenticate and mutually exclusive. + pub fn validate_auth(&self) -> anyhow::Result<()> { + if self.auth.is_some() && !self.api_key_env.is_empty() { + anyhow::bail!( + "Provider '{}' sets both `api_key_env` and `auth.command`; these are mutually exclusive.", + self.name + ); + } + Ok(()) + } } pub trait KeyResolver { diff --git a/crates/goose-providers/src/ollama.rs b/crates/goose-providers/src/ollama.rs index 9020f9a1bdcd..9bb6f18cea9d 100644 --- a/crates/goose-providers/src/ollama.rs +++ b/crates/goose-providers/src/ollama.rs @@ -307,6 +307,8 @@ pub fn from_declarative_config( .map_err(|_| anyhow::anyhow!("Failed to set default port"))?; } + config.validate_auth()?; + let api_key = if config.api_key_env.is_empty() { None } else { @@ -581,6 +583,7 @@ mod tests { catalog_provider_id: None, base_path: None, env_vars: None, + auth: None, dynamic_models, skip_canonical_filtering: false, model_doc_link: None, diff --git a/crates/goose-providers/src/openai.rs b/crates/goose-providers/src/openai.rs index c91d129c18c9..0951375c6f0f 100644 --- a/crates/goose-providers/src/openai.rs +++ b/crates/goose-providers/src/openai.rs @@ -912,6 +912,8 @@ pub fn from_declarative_config( )); } + config.validate_auth()?; + let api_key = if config.api_key_env.is_empty() { None } else { @@ -1384,6 +1386,7 @@ mod tests { catalog_provider_id: None, base_path: None, env_vars: None, + auth: None, dynamic_models: Some(false), skip_canonical_filtering: false, model_doc_link: None, diff --git a/crates/goose/src/acp/server/providers.rs b/crates/goose/src/acp/server/providers.rs index 6a7f134099f6..168206bafc98 100644 --- a/crates/goose/src/acp/server/providers.rs +++ b/crates/goose/src/acp/server/providers.rs @@ -649,6 +649,7 @@ impl GooseAcpAgent { catalog_provider_id: provider.catalog_provider_id, base_path: provider.base_path, preserves_thinking: provider.preserves_thinking, + auth: None, }, ) .internal_err_ctx("Failed to create custom provider")?; @@ -693,7 +694,7 @@ impl GooseAcpAgent { } let provider = normalize_custom_provider_upsert(req.provider, false)?; - if provider.requires_auth && provider.api_key.is_none() { + if provider.requires_auth && provider.api_key.is_none() && loaded.config.auth.is_none() { let api_key_env = if loaded.config.api_key_env.is_empty() { declarative_providers::generate_api_key_name(&req.provider_id) } else { @@ -710,7 +711,11 @@ impl GooseAcpAgent { engine: provider.engine, display_name: provider.display_name, api_url: provider.api_url, - api_key: provider.api_key, + api_key: if loaded.config.auth.is_some() { + None + } else { + provider.api_key + }, models: custom_provider_models( provider.models, &loaded.config.models, @@ -722,6 +727,15 @@ impl GooseAcpAgent { catalog_provider_id: provider.catalog_provider_id, base_path: provider.base_path, preserves_thinking: provider.preserves_thinking, + // The desktop/ACP form doesn't yet support editing command-based + // auth, so carry the existing setting forward unchanged rather + // than silently clearing it — but only while auth stays enabled; + // disabling auth must actually stop the credential command. + auth: if provider.requires_auth { + loaded.config.auth.clone() + } else { + None + }, }, ) .internal_err_ctx("Failed to update custom provider")?; diff --git a/crates/goose/src/config/declarative_providers.rs b/crates/goose/src/config/declarative_providers.rs index 501c8e3a2c79..9c385116d994 100644 --- a/crates/goose/src/config/declarative_providers.rs +++ b/crates/goose/src/config/declarative_providers.rs @@ -145,6 +145,8 @@ pub struct CreateCustomProviderParams { pub catalog_provider_id: Option, pub base_path: Option, pub preserves_thinking: Option, + /// Alternative to `api_key`; mutually exclusive with it. + pub auth: Option, } #[derive(Debug, Clone)] @@ -161,6 +163,8 @@ pub struct UpdateCustomProviderParams { pub catalog_provider_id: Option, pub base_path: Option, pub preserves_thinking: Option, + /// Alternative to `api_key`; mutually exclusive with it. + pub auth: Option, } pub fn create_custom_provider( @@ -169,7 +173,18 @@ pub fn create_custom_provider( let id = generate_id(¶ms.display_name); validate_provider_id(&id)?; - let api_key_env = if params.requires_auth { + if params.auth.is_some() + && params + .api_key + .as_deref() + .is_some_and(|key| !key.trim().is_empty()) + { + anyhow::bail!("cannot set both apiKey and auth.command"); + } + + let api_key_env = if params.auth.is_some() { + String::new() + } else if params.requires_auth { let api_key = params .api_key .as_deref() @@ -205,6 +220,7 @@ pub fn create_custom_provider( catalog_provider_id: params.catalog_provider_id, base_path: params.base_path, env_vars: None, + auth: params.auth, dynamic_models: None, skip_canonical_filtering: false, model_doc_link: None, @@ -230,8 +246,22 @@ pub fn update_custom_provider(params: UpdateCustomProviderParams) -> Result<()> let existing_config = loaded_provider.config; let editable = loaded_provider.is_editable; + if params.auth.is_some() + && params + .api_key + .as_deref() + .is_some_and(|key| !key.trim().is_empty()) + { + anyhow::bail!("cannot set both apiKey and auth.command"); + } + let config = Config::global(); - let api_key_env = if params.requires_auth { + let api_key_env = if params.auth.is_some() { + if existing_config.api_key_env == generate_api_key_name(¶ms.id) { + config.delete_secret(&existing_config.api_key_env)?; + } + String::new() + } else if params.requires_auth { let api_key_name = if existing_config.api_key_env.is_empty() { generate_api_key_name(¶ms.id) } else { @@ -309,6 +339,7 @@ pub fn update_custom_provider(params: UpdateCustomProviderParams) -> Result<()> catalog_provider_id: params.catalog_provider_id, base_path: params.base_path, env_vars: existing_config.env_vars, + auth: params.auth, dynamic_models: existing_config.dynamic_models, skip_canonical_filtering: existing_config.skip_canonical_filtering, model_doc_link: existing_config.model_doc_link, @@ -539,6 +570,10 @@ fn huggingface_declarative_inventory_configured_from_sources( provider_secret_configured: impl FnOnce(&str) -> bool, global_huggingface_configured: impl FnOnce() -> bool, ) -> bool { + if config.auth.is_some() { + return true; + } + if !config.requires_auth { return true; } @@ -581,6 +616,7 @@ mod tests { catalog_provider_id: Some("huggingface".to_string()), base_path: None, env_vars: None, + auth: None, dynamic_models: Some(false), skip_canonical_filtering: false, model_doc_link: None, @@ -616,6 +652,24 @@ mod tests { )); } + #[test] + fn huggingface_inventory_accepts_command_auth() { + let mut config = test_huggingface_config(); + config.auth = Some(AuthConfig { + command: "get-token".to_string(), + args: vec![], + refresh_interval: 3600, + timeout_seconds: None, + cwd: None, + }); + + assert!(huggingface_declarative_inventory_configured_from_sources( + &config, + |_| false, + || false, + )); + } + #[test] fn huggingface_inventory_does_not_fallback_when_explicit_key_is_missing() { let mut config = test_huggingface_config(); @@ -720,6 +774,7 @@ mod tests { catalog_provider_id: None, base_path: None, preserves_thinking: None, + auth: None, }) .unwrap(); @@ -736,6 +791,7 @@ mod tests { catalog_provider_id: None, base_path: None, preserves_thinking: None, + auth: None, }) .unwrap(); @@ -852,6 +908,7 @@ mod tests { catalog_provider_id: None, base_path: None, preserves_thinking: None, + auth: None, }) .unwrap(); diff --git a/crates/goose/src/providers/anthropic_def.rs b/crates/goose/src/providers/anthropic_def.rs index a2404a4047fd..914aa6359cab 100644 --- a/crates/goose/src/providers/anthropic_def.rs +++ b/crates/goose/src/providers/anthropic_def.rs @@ -3,7 +3,10 @@ use futures::future::BoxFuture; use crate::{ config::{Config, DeclarativeProviderConfig}, - providers::{base::ProviderDef, custom_provider_config::ConfigKeyResolver}, + providers::{ + base::ProviderDef, command_auth::CommandAuthProvider, + custom_provider_config::ConfigKeyResolver, + }, }; use goose_providers::{ anthropic::{self, AnthropicProvider, AnthropicProviderBuilder, ANTHROPIC_API_VERSION}, @@ -69,12 +72,19 @@ pub fn from_custom_config( config: DeclarativeProviderConfig, tls_config: Option, ) -> Result { + let auth_override = config.auth.clone(); anthropic::from_declarative_config(config, tls_config, ConfigKeyResolver::new(Config::global())) .map(|builder| { builder .map_api_client(|api_client| { - api_client - .with_request_builder(crate::session_context::session_id_request_builder()) + let api_client = api_client + .with_request_builder(crate::session_context::session_id_request_builder()); + match auth_override { + Some(auth_config) => api_client.with_auth(AuthMethod::Custom(Box::new( + CommandAuthProvider::new(&auth_config, "x-api-key", ""), + ))), + None => api_client, + } }) .build() }) @@ -129,6 +139,7 @@ mod tests { catalog_provider_id: None, base_path: None, env_vars: None, + auth: None, dynamic_models, skip_canonical_filtering: false, model_doc_link: None, diff --git a/crates/goose/src/providers/command_auth.rs b/crates/goose/src/providers/command_auth.rs new file mode 100644 index 000000000000..60d33eb27edf --- /dev/null +++ b/crates/goose/src/providers/command_auth.rs @@ -0,0 +1,475 @@ +use anyhow::Result; +use async_trait::async_trait; +use goose_providers::declarative::AuthConfig; +use std::path::{Path, PathBuf}; +use std::sync::Arc; +use std::time::{Duration, Instant}; +use tokio::sync::RwLock; + +use super::api_client::AuthProvider; +use crate::subprocess::configure_subprocess; + +const DEFAULT_AUTH_COMMAND_TIMEOUT: Duration = Duration::from_secs(10); +const MAX_STDERR_BYTES: usize = 500; + +struct CachedCredential { + token: String, + fetched_at: Instant, +} + +/// Fetches a credential by running a user-configured command and caches the +/// result for `refresh_interval` before re-running it. Runtime counterpart of +/// `DeclarativeProviderConfig::auth`, for custom providers whose credentials +/// are short-lived and issued by an external script rather than a static key. +pub struct CommandAuthProvider { + command: String, + args: Vec, + refresh_interval: Duration, + timeout: Duration, + /// Working directory for the command, and the base a relative `command` + /// resolves against. Captured once at construction (defaults to goose's + /// current directory at that point), not re-read per invocation. + cwd: PathBuf, + header_name: String, + header_value_prefix: String, + cached: Arc>>, +} + +impl CommandAuthProvider { + pub fn new( + auth_config: &AuthConfig, + header_name: impl Into, + header_value_prefix: impl Into, + ) -> Self { + // Made absolute up front: `cwd` doubles as both `current_dir` for the + // spawned process and the join base in `resolve_program`, and a + // relative value would otherwise be applied twice (once by us, once + // by the OS resolving a relative program path against the process's + // now-`current_dir`-ed working directory). + let cwd = auth_config + .cwd + .as_ref() + .map(PathBuf::from) + .unwrap_or_else(|| PathBuf::from(".")); + let cwd = std::env::current_dir() + .map(|base| { + if cwd.is_absolute() { + cwd.clone() + } else { + base.join(&cwd) + } + }) + .unwrap_or(cwd); + Self { + command: auth_config.command.clone(), + args: auth_config.args.clone(), + refresh_interval: Duration::from_secs(auth_config.refresh_interval), + timeout: auth_config + .timeout_seconds + .map(Duration::from_secs) + .unwrap_or(DEFAULT_AUTH_COMMAND_TIMEOUT), + cwd, + header_name: header_name.into(), + header_value_prefix: header_value_prefix.into(), + cached: Arc::new(RwLock::new(None)), + } + } + + async fn fetch_token(&self) -> Result { + // Spawned directly, never through a shell, so `args` is never + // shell-interpolated. Inherits goose's full environment, since the + // same user configures goose and writes the script. + let program = resolve_program(&self.command, &self.cwd); + let mut command = tokio::process::Command::new(&program); + command + .args(&self.args) + .current_dir(&self.cwd) + // No stdin, so a script that unexpectedly tries to prompt fails + // fast instead of hanging on the parent's stdin. `kill_on_drop` + // is a fallback for exit paths other than the timeout below, + // which kills the whole process group explicitly — the direct + // child alone wouldn't take a shell pipeline's descendants with + // it, since `configure_subprocess` puts the child in its own + // new process group. + .stdin(std::process::Stdio::null()) + .stdout(std::process::Stdio::piped()) + .stderr(std::process::Stdio::piped()) + .kill_on_drop(true); + configure_subprocess(&mut command); + + let child = command + .spawn() + .map_err(|e| anyhow::anyhow!("failed to run auth command '{}': {}", self.command, e))?; + #[cfg(unix)] + let pid = child.id(); + + let output = match tokio::time::timeout(self.timeout, child.wait_with_output()).await { + Ok(result) => result.map_err(|e| { + anyhow::anyhow!("failed to run auth command '{}': {}", self.command, e) + })?, + Err(_) => { + #[cfg(unix)] + if let Some(pid) = pid { + // SAFETY: signals only the process group `configure_subprocess` + // put this child in (negative pid), never an unrelated group. + unsafe { + libc::kill(-(pid as libc::pid_t), libc::SIGKILL); + } + } + anyhow::bail!( + "auth command '{}' timed out after {:?}", + self.command, + self.timeout + ); + } + }; + + if !output.status.success() { + let stderr = String::from_utf8_lossy(&output.stderr); + anyhow::bail!( + "auth command '{}' exited with {}: {}", + self.command, + output.status, + truncate(&stderr, MAX_STDERR_BYTES) + ); + } + + let token = String::from_utf8(output.stdout) + .map_err(|_| { + anyhow::anyhow!( + "auth command '{}' wrote non-UTF-8 data to stdout", + self.command + ) + })? + .trim() + .to_string(); + if token.is_empty() { + anyhow::bail!( + "auth command '{}' produced an empty credential", + self.command + ); + } + + Ok(token) + } + + /// `refresh_interval: 0` means "never expire proactively" — matching + /// Codex's `refresh_interval_ms: 0` convention — so the credential is + /// only refreshed reactively, via `refresh_credentials()` after an auth + /// failure, not on a TTL. + fn is_fresh(&self, cached: &CachedCredential) -> bool { + self.refresh_interval.is_zero() || cached.fetched_at.elapsed() < self.refresh_interval + } +} + +/// A bare command name (no path separator) is left alone for `PATH` lookup; +/// an absolute path is used as-is; a relative path is resolved against `cwd`. +fn resolve_program(command: &str, cwd: &Path) -> PathBuf { + let path = Path::new(command); + if path.is_absolute() { + return path.to_path_buf(); + } + if path.components().count() > 1 { + return cwd.join(path); + } + PathBuf::from(command) +} + +fn truncate(s: &str, max_bytes: usize) -> &str { + if s.len() <= max_bytes { + return s; + } + let mut end = max_bytes; + while !s.is_char_boundary(end) { + end -= 1; + } + s.get(..end).unwrap_or(s) +} + +#[async_trait] +impl AuthProvider for CommandAuthProvider { + async fn get_auth_header(&self) -> Result<(String, String)> { + // Try read lock first for better concurrency + if let Some(cached) = self.cached.read().await.as_ref() { + if self.is_fresh(cached) { + return Ok(( + self.header_name.clone(), + format!("{}{}", self.header_value_prefix, cached.token), + )); + } + } + + // Take write lock only if needed + let mut guard = self.cached.write().await; + + // Double-check freshness after acquiring write lock + if let Some(cached) = guard.as_ref() { + if self.is_fresh(cached) { + return Ok(( + self.header_name.clone(), + format!("{}{}", self.header_value_prefix, cached.token), + )); + } + } + + // Get a new token. No fallback to a stale cached token on failure: + // that would surface as a confusing downstream 401 instead of a + // clear "the refresh command failed" error. + let token = self.fetch_token().await?; + *guard = Some(CachedCredential { + token: token.clone(), + fetched_at: Instant::now(), + }); + + Ok(( + self.header_name.clone(), + format!("{}{}", self.header_value_prefix, token), + )) + } + + async fn refresh_credentials(&self) -> Result<()> { + *self.cached.write().await = None; + Ok(()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn auth_config( + command: impl Into, + args: Vec>, + refresh_interval: u64, + ) -> AuthConfig { + AuthConfig { + command: command.into(), + args: args.into_iter().map(Into::into).collect(), + refresh_interval, + timeout_seconds: None, + cwd: None, + } + } + + /// A command whose stdout differs on every invocation (this process's own + /// PID / a fresh pseudo-random draw), so two captured values back-to-back + /// prove whether the underlying command was actually re-run. + #[cfg(unix)] + fn distinct_value_command() -> (&'static str, Vec<&'static str>) { + ("sh", vec!["-c", "echo $$"]) + } + #[cfg(windows)] + fn distinct_value_command() -> (&'static str, Vec<&'static str>) { + ("cmd", vec!["/C", "echo %RANDOM%%RANDOM%%RANDOM%"]) + } + + #[cfg(unix)] + fn sleep_seconds_command(seconds: u64) -> (&'static str, Vec) { + ("sleep", vec![seconds.to_string()]) + } + #[cfg(windows)] + fn sleep_seconds_command(seconds: u64) -> (&'static str, Vec) { + ( + "powershell", + vec![ + "-NoProfile".to_string(), + "-Command".to_string(), + format!("Start-Sleep -Seconds {seconds}"), + ], + ) + } + + #[cfg(unix)] + fn fail_command() -> (&'static str, Vec<&'static str>) { + ("sh", vec!["-c", "exit 1"]) + } + #[cfg(windows)] + fn fail_command() -> (&'static str, Vec<&'static str>) { + ("cmd", vec!["/C", "exit 1"]) + } + + #[cfg(unix)] + fn fail_after_printing_command(text: &str) -> (&'static str, Vec) { + ("sh", vec!["-c".to_string(), format!("echo {text}; exit 1")]) + } + #[cfg(windows)] + fn fail_after_printing_command(text: &str) -> (&'static str, Vec) { + ( + "cmd", + vec!["/C".to_string(), format!("echo {text} & exit 1")], + ) + } + + #[tokio::test] + async fn caches_token_within_refresh_interval() { + // A fresh value on every invocation, so two equal results back-to-back + // prove the command was only run once. + let (command, args) = distinct_value_command(); + let provider = CommandAuthProvider::new( + &auth_config(command, args, 3600), + "Authorization", + "Bearer ", + ); + + let (header, first) = provider.get_auth_header().await.unwrap(); + assert_eq!(header, "Authorization"); + let (_, second) = provider.get_auth_header().await.unwrap(); + + assert_eq!(first, second); + } + + #[tokio::test] + async fn refetches_after_ttl_expires() { + let (command, args) = distinct_value_command(); + let provider = + CommandAuthProvider::new(&auth_config(command, args, 1), "Authorization", "Bearer "); + + let (_, first) = provider.get_auth_header().await.unwrap(); + tokio::time::sleep(Duration::from_millis(1100)).await; + let (_, second) = provider.get_auth_header().await.unwrap(); + + assert_ne!(first, second); + } + + #[tokio::test] + async fn zero_refresh_interval_disables_proactive_refresh() { + // Matches Codex's `refresh_interval_ms: 0` convention: the cache + // never ages out on its own, only `refresh_credentials()` (the + // reactive, on-401 path) invalidates it. + let (command, args) = distinct_value_command(); + let provider = + CommandAuthProvider::new(&auth_config(command, args, 0), "Authorization", "Bearer "); + + let (_, first) = provider.get_auth_header().await.unwrap(); + tokio::time::sleep(Duration::from_millis(50)).await; + let (_, second) = provider.get_auth_header().await.unwrap(); + assert_eq!(first, second); + + provider.refresh_credentials().await.unwrap(); + let (_, third) = provider.get_auth_header().await.unwrap(); + assert_ne!(second, third); + } + + #[tokio::test] + async fn refresh_credentials_forces_refetch() { + let (command, args) = distinct_value_command(); + let provider = CommandAuthProvider::new( + &auth_config(command, args, 3600), + "Authorization", + "Bearer ", + ); + + let (_, first) = provider.get_auth_header().await.unwrap(); + provider.refresh_credentials().await.unwrap(); + let (_, second) = provider.get_auth_header().await.unwrap(); + + assert_ne!(first, second); + } + + #[tokio::test] + async fn enforces_timeout() { + let (command, args) = sleep_seconds_command(5); + let mut config = auth_config(command, args, 3600); + config.timeout_seconds = Some(1); + let provider = CommandAuthProvider::new(&config, "Authorization", "Bearer "); + + let start = Instant::now(); + let result = provider.get_auth_header().await; + assert!(result.is_err()); + assert!(start.elapsed() < Duration::from_secs(4)); + } + + #[tokio::test] + async fn errors_on_nonzero_exit_without_falling_back_to_stale_cache() { + let (command, args) = fail_command(); + let provider = CommandAuthProvider::new( + &auth_config(command, args, 3600), + "Authorization", + "Bearer ", + ); + // Seed a valid cached credential, then invalidate it the same way a + // 401 response does, so the next fetch attempt hits the failing + // command instead of returning the still-fresh cached value. + *provider.cached.write().await = Some(CachedCredential { + token: "stale-token".to_string(), + fetched_at: Instant::now(), + }); + provider.refresh_credentials().await.unwrap(); + + let result = provider.get_auth_header().await; + assert!(result.is_err()); + } + + #[tokio::test] + async fn error_message_does_not_leak_stdout() { + let (command, args) = fail_after_printing_command("super-secret-token"); + let provider = CommandAuthProvider::new( + &auth_config(command, args, 3600), + "Authorization", + "Bearer ", + ); + + let err = provider.get_auth_header().await.unwrap_err(); + assert!(!err.to_string().contains("super-secret-token")); + } + + #[test] + fn resolve_program_leaves_bare_names_for_path_lookup() { + let resolved = resolve_program("aws", Path::new("/some/cwd")); + assert_eq!(resolved, PathBuf::from("aws")); + } + + #[test] + fn resolve_program_leaves_absolute_paths_unchanged() { + let resolved = resolve_program("/usr/local/bin/get-token.sh", Path::new("/some/cwd")); + assert_eq!(resolved, PathBuf::from("/usr/local/bin/get-token.sh")); + } + + #[test] + fn resolve_program_joins_relative_paths_against_cwd() { + let resolved = resolve_program("./scripts/get-token.sh", Path::new("/some/cwd")); + assert_eq!( + resolved, + Path::new("/some/cwd").join("./scripts/get-token.sh") + ); + } + + #[test] + fn relative_cwd_is_resolved_to_absolute_before_use() { + // A relative `cwd` must become absolute once, at construction, so it + // isn't applied twice: once by `resolve_program`'s join, once by the + // OS resolving a relative program path against the already + // `current_dir`-ed child process. + let mut config = auth_config("./get-token", Vec::<&str>::new(), 3600); + config.cwd = Some("some/relative/dir".to_string()); + let provider = CommandAuthProvider::new(&config, "Authorization", "Bearer "); + + assert!(provider.cwd.is_absolute()); + assert_eq!( + provider.cwd, + std::env::current_dir().unwrap().join("some/relative/dir") + ); + } + + #[cfg(unix)] + #[tokio::test] + async fn command_runs_with_configured_cwd_and_resolves_relative_path() { + use std::os::unix::fs::PermissionsExt; + + let dir = tempfile::tempdir().unwrap(); + let script_path = dir.path().join("get-token.sh"); + std::fs::write(&script_path, "#!/bin/sh\npwd\n").unwrap(); + std::fs::set_permissions(&script_path, std::fs::Permissions::from_mode(0o755)).unwrap(); + + let mut config = auth_config("./get-token.sh", Vec::<&str>::new(), 3600); + config.cwd = Some(dir.path().to_string_lossy().to_string()); + let provider = CommandAuthProvider::new(&config, "Authorization", "Bearer "); + + let (_, value) = provider.get_auth_header().await.unwrap(); + let token = value.strip_prefix("Bearer ").unwrap(); + assert_eq!( + std::fs::canonicalize(token).unwrap(), + std::fs::canonicalize(dir.path()).unwrap() + ); + } +} diff --git a/crates/goose/src/providers/huggingface.rs b/crates/goose/src/providers/huggingface.rs index 6fa603de6508..efe420a82a28 100644 --- a/crates/goose/src/providers/huggingface.rs +++ b/crates/goose/src/providers/huggingface.rs @@ -3,6 +3,7 @@ use super::base::{ ConfigKey, MessageStream, ModelInfo, Provider, ProviderDef, ProviderMetadata, DEFAULT_PROVIDER_TIMEOUT_SECS, }; +use super::command_auth::CommandAuthProvider; use super::huggingface_auth; use super::openai_compatible::OpenAiCompatibleProvider; use crate::config::declarative_providers::DeclarativeProviderConfig; @@ -84,7 +85,15 @@ impl HuggingFaceProvider { )); } - let auth_method = custom_auth_method(&config)?; + config.validate_auth()?; + let auth_method = match config.auth.as_ref() { + Some(auth_config) => AuthMethod::Custom(Box::new(CommandAuthProvider::new( + auth_config, + "Authorization", + "Bearer ", + ))), + None => custom_auth_method(&config)?, + }; let (host, completions_prefix, query_params) = openai_compatible_endpoint_parts(&config.base_url, config.base_path.as_deref())?; @@ -464,6 +473,21 @@ mod tests { assert_eq!(provider.get_context_limit("static-a", None).await, 128_000); } + #[test] + fn custom_provider_accepts_command_auth_without_huggingface_token() { + let mut config = test_config(); + config.api_key_env.clear(); + config.auth = Some(goose_providers::declarative::AuthConfig { + command: "echo".to_string(), + args: vec!["token".to_string()], + refresh_interval: 3600, + timeout_seconds: None, + cwd: None, + }); + + HuggingFaceProvider::from_custom_config(config, None).unwrap(); + } + #[test] fn custom_provider_requires_static_models_when_dynamic_models_disabled() { let mut config = test_config(); @@ -552,6 +576,7 @@ mod tests { catalog_provider_id: None, base_path: None, env_vars: None, + auth: None, dynamic_models: None, skip_canonical_filtering: false, model_doc_link: None, diff --git a/crates/goose/src/providers/inventory/mod.rs b/crates/goose/src/providers/inventory/mod.rs index 6c7997962100..1f6612bc538d 100644 --- a/crates/goose/src/providers/inventory/mod.rs +++ b/crates/goose/src/providers/inventory/mod.rs @@ -979,6 +979,19 @@ pub fn declarative_inventory_identity( .insert(config.api_key_env.clone(), value); } } + if let Some(auth) = &config.auth { + identity + .secret_inputs + .insert("auth_command".to_string(), auth.command.clone()); + identity + .secret_inputs + .insert("auth_args".to_string(), serde_json::to_string(&auth.args)?); + if let Some(cwd) = &auth.cwd { + identity + .secret_inputs + .insert("auth_cwd".to_string(), cwd.clone()); + } + } Ok(identity) } diff --git a/crates/goose/src/providers/mod.rs b/crates/goose/src/providers/mod.rs index f2ccd62b3ed3..9987b8da297f 100644 --- a/crates/goose/src/providers/mod.rs +++ b/crates/goose/src/providers/mod.rs @@ -28,6 +28,7 @@ pub mod claude_code; pub(crate) mod cli_common; pub mod codex; pub mod codex_acp; +pub mod command_auth; pub mod copilot_acp; pub mod cursor_agent; pub mod custom_provider_config; diff --git a/crates/goose/src/providers/ollama_cloud.rs b/crates/goose/src/providers/ollama_cloud.rs index 7401c69707ab..529b6b197d31 100644 --- a/crates/goose/src/providers/ollama_cloud.rs +++ b/crates/goose/src/providers/ollama_cloud.rs @@ -481,6 +481,7 @@ mod tests { catalog_provider_id: None, base_path: None, env_vars: None, + auth: None, dynamic_models, skip_canonical_filtering: false, model_doc_link: None, diff --git a/crates/goose/src/providers/ollama_def.rs b/crates/goose/src/providers/ollama_def.rs index 161fd55c6b75..be469a230480 100644 --- a/crates/goose/src/providers/ollama_def.rs +++ b/crates/goose/src/providers/ollama_def.rs @@ -6,7 +6,10 @@ use url::Url; use crate::{ config::{declarative_providers::DeclarativeProviderConfig, Config}, - providers::{base::ProviderDef, custom_provider_config::ConfigKeyResolver}, + providers::{ + base::ProviderDef, command_auth::CommandAuthProvider, + custom_provider_config::ConfigKeyResolver, + }, }; use goose_providers::{ api_client::{ApiClient, AuthMethod}, @@ -96,12 +99,19 @@ pub fn from_custom_config( config: DeclarativeProviderConfig, tls_config: Option, ) -> Result { + let auth_override = config.auth.clone(); ollama::from_declarative_config(config, tls_config, ConfigKeyResolver::new(Config::global())) .map(|builder| { builder .map_api_client(|api_client| { - api_client - .with_request_builder(crate::session_context::session_id_request_builder()) + let api_client = api_client + .with_request_builder(crate::session_context::session_id_request_builder()); + match auth_override { + Some(auth_config) => api_client.with_auth(AuthMethod::Custom(Box::new( + CommandAuthProvider::new(&auth_config, "Authorization", "Bearer "), + ))), + None => api_client, + } }) .options(options_from_config()) .build() diff --git a/crates/goose/src/providers/openai_def.rs b/crates/goose/src/providers/openai_def.rs index 2d1374a26a9b..970b5ef2461f 100644 --- a/crates/goose/src/providers/openai_def.rs +++ b/crates/goose/src/providers/openai_def.rs @@ -6,6 +6,7 @@ use std::collections::HashMap; use crate::config::declarative_providers::DeclarativeProviderConfig; use crate::config::Config; use crate::providers::base::{ProviderDef, DEFAULT_PROVIDER_TIMEOUT_SECS}; +use crate::providers::command_auth::CommandAuthProvider; use crate::providers::custom_provider_config::ConfigKeyResolver; use goose_providers::api_client::{ApiClient, AuthMethod}; use goose_providers::openai::{ @@ -217,6 +218,7 @@ pub fn from_custom_config( config: DeclarativeProviderConfig, tls_config: Option, ) -> Result { + let auth_override = config.auth.clone(); goose_providers::openai::from_declarative_config( config, tls_config, @@ -225,8 +227,14 @@ pub fn from_custom_config( .map(|builder| { builder .map_api_client(|api_client| { - api_client - .with_request_builder(crate::session_context::session_id_request_builder()) + let api_client = api_client + .with_request_builder(crate::session_context::session_id_request_builder()); + match auth_override { + Some(auth_config) => api_client.with_auth(AuthMethod::Custom(Box::new( + CommandAuthProvider::new(&auth_config, "Authorization", "Bearer "), + ))), + None => api_client, + } }) .build() }) diff --git a/crates/goose/src/providers/provider_registry.rs b/crates/goose/src/providers/provider_registry.rs index 6738c07534d3..332f8cad6738 100644 --- a/crates/goose/src/providers/provider_registry.rs +++ b/crates/goose/src/providers/provider_registry.rs @@ -244,7 +244,7 @@ impl ProviderRegistry { let mut config_keys = base_metadata.config_keys.clone(); if let Some(api_key_index) = config_keys.iter().position(|key| key.secret) { - if !config.requires_auth { + if !config.requires_auth || config.auth.is_some() { config_keys.remove(api_key_index); } else if !config.api_key_env.is_empty() { config_keys[api_key_index] = @@ -375,6 +375,7 @@ mod tests { catalog_provider_id: Some("huggingface".to_string()), base_path: None, env_vars: None, + auth: None, dynamic_models: None, skip_canonical_filtering: false, model_doc_link: None, diff --git a/crates/goose/tests/custom_provider_command_auth_test.rs b/crates/goose/tests/custom_provider_command_auth_test.rs new file mode 100644 index 000000000000..b2a1ce12f3f3 --- /dev/null +++ b/crates/goose/tests/custom_provider_command_auth_test.rs @@ -0,0 +1,184 @@ +//! End-to-end coverage for command-based auth on custom providers (issue #11329): +//! a `DeclarativeProviderConfig.auth` command is run to fetch the credential +//! instead of a static `api_key_env`, and the credential is refreshed +//! reactively when the upstream API returns a 401. + +use goose::conversation::message::Message; +use goose::providers::base::Provider; +use goose::providers::openai_def; +use goose_providers::declarative::{AuthConfig, DeclarativeProviderConfig, ProviderEngine}; +use goose_providers::model::ModelConfig; +use serde_json::json; +use std::sync::atomic::{AtomicUsize, Ordering}; +use std::sync::{Arc, Mutex}; +use wiremock::matchers::{method, path}; +use wiremock::{Mock, MockServer, Request, ResponseTemplate}; + +fn custom_config_with_auth(base_url: &str, auth: AuthConfig) -> DeclarativeProviderConfig { + DeclarativeProviderConfig { + name: "custom_command_auth".to_string(), + engine: ProviderEngine::OpenAI, + display_name: "Custom Command Auth".to_string(), + description: None, + api_key_env: String::new(), + base_url: base_url.to_string(), + models: vec![goose_providers::base::ModelInfo::new("test-model")], + headers: None, + timeout_seconds: None, + supports_streaming: Some(true), + requires_auth: true, + catalog_provider_id: None, + base_path: None, + env_vars: None, + auth: Some(auth), + dynamic_models: Some(false), + skip_canonical_filtering: false, + model_doc_link: None, + setup_steps: vec![], + fast_model: None, + preserves_thinking: false, + emit_clear_thinking: false, + setup: None, + } +} + +/// A command whose stdout differs on every invocation (this process's own +/// PID / a fresh pseudo-random draw), so two captured values back-to-back +/// prove whether the underlying command was actually re-run. +#[cfg(unix)] +fn distinct_value_command() -> (&'static str, Vec<&'static str>) { + ("sh", vec!["-c", "echo $$"]) +} +#[cfg(windows)] +fn distinct_value_command() -> (&'static str, Vec<&'static str>) { + ("cmd", vec!["/C", "echo %RANDOM%%RANDOM%%RANDOM%"]) +} + +fn chat_completions_sse() -> String { + format!( + "data: {}\n\ndata: {}\n\ndata: [DONE]\n\n", + json!({ + "choices": [{ + "delta": {"content": "hi", "role": "assistant"}, + "index": 0 + }], + "created": 1755133833, + "id": "chatcmpl-test", + "model": "test-model" + }), + json!({ + "choices": [], + "usage": {"completion_tokens": 1, "prompt_tokens": 1, "total_tokens": 2} + }) + ) +} + +async fn complete(provider: &dyn Provider) -> anyhow::Result<()> { + let message = Message::user().with_text("hello"); + let model_config = ModelConfig::new("test-model"); + provider + .complete(&model_config, "system", &[message], &[]) + .await?; + Ok(()) +} + +#[tokio::test] +async fn command_auth_credential_is_sent_as_bearer_token() { + let server = MockServer::start().await; + let captured_auth: Arc>> = Arc::new(Mutex::new(Vec::new())); + let capture = captured_auth.clone(); + + Mock::given(method("POST")) + .and(path("/v1/chat/completions")) + .respond_with(move |req: &Request| { + let auth = req + .headers + .get("authorization") + .map(|v| v.to_str().unwrap().to_string()) + .unwrap_or_default(); + capture.lock().unwrap().push(auth); + ResponseTemplate::new(200) + .set_body_string(chat_completions_sse()) + .insert_header("content-type", "text/event-stream") + }) + .mount(&server) + .await; + + let config = custom_config_with_auth( + &server.uri(), + AuthConfig { + command: "echo".to_string(), + args: vec!["test-token-123".to_string()], + refresh_interval: 3600, + timeout_seconds: None, + cwd: None, + }, + ); + let provider = openai_def::from_custom_config(config, None).unwrap(); + + complete(&provider).await.unwrap(); + + assert_eq!( + captured_auth.lock().unwrap().as_slice(), + ["Bearer test-token-123".to_string()] + ); +} + +#[tokio::test] +async fn command_auth_refreshes_credential_on_401_and_retries() { + let server = MockServer::start().await; + let captured_auth: Arc>> = Arc::new(Mutex::new(Vec::new())); + let capture = captured_auth.clone(); + let call_count = Arc::new(AtomicUsize::new(0)); + + Mock::given(method("POST")) + .and(path("/v1/chat/completions")) + .respond_with(move |req: &Request| { + let auth = req + .headers + .get("authorization") + .map(|v| v.to_str().unwrap().to_string()) + .unwrap_or_default(); + capture.lock().unwrap().push(auth); + + if call_count.fetch_add(1, Ordering::SeqCst) == 0 { + ResponseTemplate::new(401) + .set_body_json(json!({"error": {"message": "token expired"}})) + } else { + ResponseTemplate::new(200) + .set_body_string(chat_completions_sse()) + .insert_header("content-type", "text/event-stream") + } + }) + .mount(&server) + .await; + + // A value that differs on every invocation (this process's own PID / a + // fresh pseudo-random draw), so two distinct captured Authorization + // headers prove the auth command actually ran twice (once up front, once + // after `refresh_credentials` invalidated the cache on the 401), rather + // than the same token being reused. + let (command, args) = distinct_value_command(); + let config = custom_config_with_auth( + &server.uri(), + AuthConfig { + command: command.to_string(), + args: args.into_iter().map(str::to_string).collect(), + refresh_interval: 3600, + timeout_seconds: None, + cwd: None, + }, + ); + let provider = openai_def::from_custom_config(config, None).unwrap(); + + complete(&provider) + .await + .expect("request should succeed after refreshing credentials and retrying"); + + let captured = captured_auth.lock().unwrap(); + assert_eq!(captured.len(), 2, "expected an initial request and a retry"); + assert_ne!( + captured[0], captured[1], + "the retried request should carry a freshly-fetched credential, not the stale one" + ); +} diff --git a/documentation/docs/getting-started/providers.md b/documentation/docs/getting-started/providers.md index c8da56e3f2cb..47e8d4fe9bcf 100644 --- a/documentation/docs/getting-started/providers.md +++ b/documentation/docs/getting-started/providers.md @@ -465,7 +465,9 @@ Custom providers must use OpenAI, Anthropic, or Ollama compatible API formats. T - **Name**: A friendly name for the provider - **API URL**: The base URL of the API endpoint - **Authentication Required**: Answer "Yes" if your provider needs an API key, or "No" if authentication is not required - - If Yes: You'll be prompted to enter your **API Key** (stored securely in the keychain, or in `secrets.yaml` if the keyring is disabled or cannot be accessed) + - If Yes: Choose how goose should obtain the credential: + - **Static API key**: You'll be prompted to enter your **API Key** (stored securely in the keychain, or in `secrets.yaml` if the keyring is disabled or cannot be accessed) + - **Command (refreshable)**: You'll be prompted for a **command** (and optional arguments) that goose runs to fetch the credential, plus a **refresh interval** in seconds. Use this for short-lived credentials issued by an IdP or key vault, so goose can refresh them automatically instead of requiring a restart when they expire. See [Command-Based Authentication](#command-based-authentication) below. - If No: The API key prompt is skipped - **Available Models**: Comma-separated list of available model names - **Streaming Support**: Whether the API supports streaming responses @@ -523,6 +525,50 @@ Custom providers must use OpenAI, Anthropic, or Ollama compatible API formats. T +### Command-Based Authentication + +Instead of a static `api_key_env`, a custom provider can be configured to run a command to obtain its credential. This is useful for short-lived credentials issued by an IdP or key vault: goose re-runs the command to refresh the credential instead of requiring a restart when it expires. + +Add an `auth` object to the provider's JSON configuration in place of `api_key_env` (the two are mutually exclusive): + +```json +{ + "name": "custom_corp_api", + "engine": "openai", + "display_name": "Corporate API", + "base_url": "https://api.company.com/v1/chat/completions", + "models": [{ "name": "gpt-4o", "context_limit": 128000 }], + "requires_auth": true, + "auth": { + "command": "/path/to/get-token.sh", + "args": [], + "refresh_interval": 3600, + "timeout_seconds": 10 + } +} +``` + +- **`command`**: The executable to run. It is spawned directly, without a shell — none of `command`/`args` are shell-interpolated. If your script needs shell features (pipes, variable expansion), invoke an interpreter explicitly, e.g. `"command": "/bin/bash", "args": ["-c", "..."]`. A bare name (no path separator, e.g. `"get-token"`) is looked up on `PATH`; a relative path (e.g. `"./scripts/get-token.sh"`) is resolved against `cwd`. +- **`args`** (optional): Arguments passed to `command`. +- **`refresh_interval`** (optional, defaults to `3600`): How long, in seconds, a fetched credential is cached before the command is re-run. Set to `0` to disable proactive refresh entirely — the command then only reruns reactively, after the provider's API rejects a request with an auth error. +- **`timeout_seconds`** (optional, defaults to `10`): How long to wait for the command before treating it as failed. +- **`cwd`** (optional): Working directory for the command, and the base a relative `command` path is resolved against. Defaults to goose's current directory. + +The command's trimmed standard output is used as the credential. It must exit successfully and print a non-empty value; on failure, goose surfaces an error rather than silently reusing a stale credential. The command inherits goose's full environment, since the same user configures goose and writes the script. + + + + + In `goose configure`, choose **Command (refreshable)** when prompted for how to obtain credentials for a custom provider (see [above](#configure-custom-provider)). + + + + + Add the `auth` object shown above to the provider's JSON file instead of setting `api_key_env`. + + + + **To update a custom provider:** From 7c7dc342d657c549de8cfd22cb2e03f0d55446b4 Mon Sep 17 00:00:00 2001 From: Jasper Date: Tue, 1 Sep 2026 04:37:47 +0000 Subject: [PATCH 27/37] fix(dictation): bound provider response bodies (#11401) Signed-off-by: Jasper Hugo --- crates/goose/src/dictation/providers.rs | 325 ++++++++++++++++++++---- 1 file changed, 277 insertions(+), 48 deletions(-) diff --git a/crates/goose/src/dictation/providers.rs b/crates/goose/src/dictation/providers.rs index 015c737075d4..bb5c68d3e40e 100644 --- a/crates/goose/src/dictation/providers.rs +++ b/crates/goose/src/dictation/providers.rs @@ -13,6 +13,8 @@ use std::sync::Mutex; use std::time::Duration; const REQUEST_TIMEOUT: Duration = Duration::from_secs(30); +const MAX_TRANSCRIPTION_RESPONSE_BYTES: usize = 1024 * 1024; +const MAX_TRANSCRIPTION_ERROR_PREVIEW_BYTES: usize = 8 * 1024; const OPENAI_VERSIONLESS_TRANSCRIPTIONS_PATH: &str = "audio/transcriptions"; type OpenAiDictationTarget = (String, Vec<(String, String)>, String); @@ -302,6 +304,99 @@ fn build_api_client(provider: DictationProvider) -> Result<(ApiClient, String)> Ok((client, endpoint_path)) } +async fn read_transcription_response(mut response: reqwest::Response) -> Result> { + let mut body = Vec::new(); + + while let Some(chunk) = response.chunk().await? { + if chunk.len() > MAX_TRANSCRIPTION_RESPONSE_BYTES - body.len() { + anyhow::bail!( + "Transcription response exceeds the {} byte limit", + MAX_TRANSCRIPTION_RESPONSE_BYTES + ); + } + body.extend_from_slice(&chunk); + } + + Ok(body) +} + +fn transcription_error_preview(body: &[u8]) -> String { + let preview_len = body.len().min(MAX_TRANSCRIPTION_ERROR_PREVIEW_BYTES); + let mut preview = String::from_utf8_lossy(&body[..preview_len]).into_owned(); + if preview_len < body.len() { + preview.push_str("... [truncated]"); + } + preview +} + +async fn parse_transcription_response(response: reqwest::Response) -> Result { + let status = response.status(); + + if status == 401 { + anyhow::bail!("Invalid API key"); + } else if status == 429 { + anyhow::bail!("Rate limit exceeded"); + } + + let body = read_transcription_response(response).await?; + + if !status.is_success() { + let error_text = String::from_utf8_lossy(&body); + + if error_text.contains("Invalid API key") { + anyhow::bail!("Invalid API key"); + } else if error_text.contains("quota") { + anyhow::bail!("Rate limit exceeded"); + } else if error_text.contains("too short") { + return Ok(String::new()); + } else { + anyhow::bail!("API error: {}", transcription_error_preview(&body)); + } + } + + let data: serde_json::Value = serde_json::from_slice(&body).map_err(|e| { + tracing::error!("Failed to parse response: {}", e); + anyhow::anyhow!(e) + })?; + + let text = data["text"] + .as_str() + .ok_or_else(|| anyhow::anyhow!("Missing 'text' field in response"))? + .to_string(); + + Ok(text) +} + +async fn parse_model_transcription_response(response: reqwest::Response) -> Result { + let status = response.status(); + + if status == 401 { + anyhow::bail!("Invalid API key"); + } + + let body = read_transcription_response(response).await?; + + if !status.is_success() { + anyhow::bail!( + "Chat completions error ({}): {}", + status, + transcription_error_preview(&body) + ); + } + + let data: serde_json::Value = serde_json::from_slice(&body).map_err(|e| { + tracing::error!("Failed to parse chat completions response: {}", e); + anyhow::anyhow!(e) + })?; + + let text = data["choices"][0]["message"]["content"] + .as_str() + .ok_or_else(|| anyhow::anyhow!("No content in chat completions response"))? + .to_string(); + + Ok(text) +} + pub async fn transcribe_with_provider( provider: DictationProvider, model_param: String, @@ -333,32 +428,7 @@ pub async fn transcribe_with_provider( e })?; - if !response.status().is_success() { - let status = response.status(); - let error_text = response.text().await.unwrap_or_default(); - - if status == 401 || error_text.contains("Invalid API key") { - anyhow::bail!("Invalid API key"); - } else if status == 429 || error_text.contains("quota") { - anyhow::bail!("Rate limit exceeded"); - } else if error_text.contains("too short") { - return Ok(String::new()); - } else { - anyhow::bail!("API error: {}", error_text); - } - } - - let data: serde_json::Value = response.json().await.map_err(|e| { - tracing::error!("Failed to parse response: {}", e); - anyhow::anyhow!(e) - })?; - - let text = data["text"] - .as_str() - .ok_or_else(|| anyhow::anyhow!("Missing 'text' field in response"))? - .to_string(); - - Ok(text) + parse_transcription_response(response).await } const MODEL_TRANSCRIPTION_TIMEOUT: Duration = Duration::from_secs(60); @@ -428,26 +498,7 @@ pub async fn transcribe_with_model(audio_bytes: Vec, audio_format: &str) -> anyhow::anyhow!("Transcription request failed: {}", e) })?; - if !response.status().is_success() { - let status = response.status(); - let body = response.text().await.unwrap_or_default(); - if status.as_u16() == 401 { - anyhow::bail!("Invalid API key"); - } - anyhow::bail!("Chat completions error ({}): {}", status, body); - } - - let data: serde_json::Value = response.json().await.map_err(|e| { - tracing::error!("Failed to parse chat completions response: {}", e); - anyhow::anyhow!(e) - })?; - - let text = data["choices"][0]["message"]["content"] - .as_str() - .ok_or_else(|| anyhow::anyhow!("No content in chat completions response"))? - .to_string(); - - Ok(text) + parse_model_transcription_response(response).await } /// Normalize an OpenRouter base URL to ensure the `/api/v1` path is present. @@ -651,10 +702,37 @@ fn resolve_model_native_config( mod tests { use super::{ all_providers, build_api_client, get_provider_def, normalize_openrouter_base_url, - openai_dictation_target, resolve_openai_base_url_target, DictationProvider, - OPENAI_VERSIONLESS_TRANSCRIPTIONS_PATH, + openai_dictation_target, parse_model_transcription_response, parse_transcription_response, + resolve_openai_base_url_target, DictationProvider, MAX_TRANSCRIPTION_ERROR_PREVIEW_BYTES, + MAX_TRANSCRIPTION_RESPONSE_BYTES, OPENAI_VERSIONLESS_TRANSCRIPTIONS_PATH, }; use test_case::test_case; + use wiremock::matchers::{method, path}; + use wiremock::{Mock, MockServer, ResponseTemplate}; + + async fn mock_transcription_response( + status: u16, + body: String, + ) -> (MockServer, reqwest::Response) { + let server = MockServer::start().await; + Mock::given(method("GET")) + .and(path("/transcription")) + .respond_with(ResponseTemplate::new(status).set_body_string(body)) + .mount(&server) + .await; + + let response = reqwest::get(format!("{}/transcription", server.uri())) + .await + .unwrap(); + (server, response) + } + + fn transcription_success_body(size: usize) -> String { + let prefix = r#"{"text":""#; + let suffix = r#""}"#; + let text_len = size - prefix.len() - suffix.len(); + format!("{}{}{}", prefix, "A".repeat(text_len), suffix) + } #[test] fn openai_dictation_target_preserves_prefix_and_query_params() { @@ -730,4 +808,155 @@ mod tests { fn test_normalize_openrouter_base_url(input: &str) -> String { normalize_openrouter_base_url(input) } + + #[tokio::test] + async fn transcription_accepts_legitimate_response() { + let (_server, response) = + mock_transcription_response(200, r#"{"text":"hello"}"#.to_string()).await; + + let result = parse_transcription_response(response).await.unwrap(); + + assert_eq!(result, "hello"); + } + + #[tokio::test] + async fn transcription_accepts_response_at_byte_limit() { + let body = transcription_success_body(MAX_TRANSCRIPTION_RESPONSE_BYTES); + let expected_text_len = body.len() - r#"{"text":""#.len() - r#""}"#.len(); + let (_server, response) = mock_transcription_response(200, body).await; + + let result = parse_transcription_response(response).await.unwrap(); + + assert_eq!(result.len(), expected_text_len); + } + + #[tokio::test] + async fn transcription_rejects_oversized_success_response() { + let (_server, response) = mock_transcription_response( + 200, + transcription_success_body(MAX_TRANSCRIPTION_RESPONSE_BYTES + 1), + ) + .await; + + let error = parse_transcription_response(response).await.unwrap_err(); + + assert!(error.to_string().contains("exceeds the 1048576 byte limit")); + } + + #[tokio::test] + async fn transcription_rejects_oversized_error_response() { + let (_server, response) = + mock_transcription_response(500, "E".repeat(MAX_TRANSCRIPTION_RESPONSE_BYTES + 1)) + .await; + + let error = parse_transcription_response(response).await.unwrap_err(); + + assert!(error.to_string().contains("exceeds the 1048576 byte limit")); + } + + #[tokio::test] + async fn transcription_accepts_error_at_byte_limit() { + let (_server, response) = + mock_transcription_response(500, "E".repeat(MAX_TRANSCRIPTION_RESPONSE_BYTES)).await; + + let message = parse_transcription_response(response) + .await + .unwrap_err() + .to_string(); + + assert!(message.starts_with("API error: ")); + assert!(message.contains("[truncated]")); + assert!(!message.contains("exceeds the 1048576 byte limit")); + } + + #[tokio::test] + async fn transcription_preserves_too_short_response() { + let (_server, response) = + mock_transcription_response(400, "audio is too short".to_string()).await; + + let result = parse_transcription_response(response).await.unwrap(); + + assert!(result.is_empty()); + } + + #[tokio::test] + async fn transcription_truncates_provider_error_diagnostics() { + let omitted_detail = "not included in the diagnostic"; + let body = format!( + "provider unavailable: {}{}", + "x".repeat(MAX_TRANSCRIPTION_ERROR_PREVIEW_BYTES), + omitted_detail + ); + let (_server, response) = mock_transcription_response(500, body).await; + + let error = parse_transcription_response(response).await.unwrap_err(); + let message = error.to_string(); + + assert!(message.contains("provider unavailable")); + assert!(message.contains("[truncated]")); + assert!(!message.contains(omitted_detail)); + } + + #[tokio::test] + async fn model_transcription_accepts_legitimate_response() { + let body = serde_json::json!({ + "choices": [{"message": {"content": "hello"}}], + }) + .to_string(); + let (_server, response) = mock_transcription_response(200, body).await; + + let result = parse_model_transcription_response(response).await.unwrap(); + + assert_eq!(result, "hello"); + } + + #[tokio::test] + async fn model_transcription_rejects_oversized_success_response() { + let body = serde_json::json!({ + "choices": [{ + "message": {"content": "A".repeat(MAX_TRANSCRIPTION_RESPONSE_BYTES)}, + }], + }) + .to_string(); + let (_server, response) = mock_transcription_response(200, body).await; + + let error = parse_model_transcription_response(response) + .await + .unwrap_err(); + + assert!(error.to_string().contains("exceeds the 1048576 byte limit")); + } + + #[tokio::test] + async fn model_transcription_rejects_oversized_error_response() { + let (_server, response) = + mock_transcription_response(500, "E".repeat(MAX_TRANSCRIPTION_RESPONSE_BYTES + 1)) + .await; + + let error = parse_model_transcription_response(response) + .await + .unwrap_err(); + + assert!(error.to_string().contains("exceeds the 1048576 byte limit")); + } + + #[tokio::test] + async fn model_transcription_truncates_error_diagnostics() { + let omitted_detail = "not included in the diagnostic"; + let body = format!( + "model provider unavailable: {}{}", + "x".repeat(MAX_TRANSCRIPTION_ERROR_PREVIEW_BYTES), + omitted_detail + ); + let (_server, response) = mock_transcription_response(500, body).await; + + let message = parse_model_transcription_response(response) + .await + .unwrap_err() + .to_string(); + + assert!(message.contains("model provider unavailable")); + assert!(message.contains("[truncated]")); + assert!(!message.contains(omitted_detail)); + } } From 34898f16522f086b22c279c86db5dc15ef6c12ac Mon Sep 17 00:00:00 2001 From: Jasper Date: Tue, 1 Sep 2026 04:38:39 +0000 Subject: [PATCH 28/37] fix(desktop): reject oversized recipe files before reading (#11404) Signed-off-by: Jasper Hugo --- .../components/recipes/ImportRecipeForm.tsx | 13 ++- .../__tests__/ImportRecipeForm.test.tsx | 80 +++++++++++++++++++ 2 files changed, 91 insertions(+), 2 deletions(-) create mode 100644 ui/desktop/src/components/recipes/__tests__/ImportRecipeForm.test.tsx diff --git a/ui/desktop/src/components/recipes/ImportRecipeForm.tsx b/ui/desktop/src/components/recipes/ImportRecipeForm.tsx index 174a0f0cdde3..d0611b24e2dc 100644 --- a/ui/desktop/src/components/recipes/ImportRecipeForm.tsx +++ b/ui/desktop/src/components/recipes/ImportRecipeForm.tsx @@ -77,6 +77,8 @@ interface ImportRecipeFormProps { onSuccess: () => void; } +const MAX_RECIPE_FILE_SIZE_BYTES = 1024 * 1024; + // Define Zod schema for the import form const importRecipeSchema = z .object({ @@ -91,7 +93,7 @@ const importRecipeSchema = z .nullable() .refine((file) => { if (!file) return true; - return file.size <= 1024 * 1024; + return file.size <= MAX_RECIPE_FILE_SIZE_BYTES; }, 'File is too large, max size is 1MB'), }) .refine((data) => (data.deeplink && data.deeplink.trim()) || data.recipeUploadFile, { @@ -127,7 +129,11 @@ export default function ImportRecipeForm({ isOpen, onClose, onSuccess }: ImportR } recipe = parsedRecipe; } else { - const fileContent = await value.recipeUploadFile!.text(); + const recipeFile = value.recipeUploadFile!; + if (recipeFile.size > MAX_RECIPE_FILE_SIZE_BYTES) { + throw new Error('File is too large, max size is 1MB'); + } + const fileContent = await recipeFile.text(); recipe = await parseRecipeFromFile(fileContent); } @@ -190,6 +196,9 @@ export default function ImportRecipeForm({ isOpen, onClose, onSuccess }: ImportR importRecipeForm.setFieldValue('recipeUploadFile', file || null); if (file) { + if (file.size > MAX_RECIPE_FILE_SIZE_BYTES) { + return; + } try { const fileContent = await file.text(); await parseRecipeFromFile(fileContent); diff --git a/ui/desktop/src/components/recipes/__tests__/ImportRecipeForm.test.tsx b/ui/desktop/src/components/recipes/__tests__/ImportRecipeForm.test.tsx new file mode 100644 index 000000000000..b327aa9e1b51 --- /dev/null +++ b/ui/desktop/src/components/recipes/__tests__/ImportRecipeForm.test.tsx @@ -0,0 +1,80 @@ +import { act, fireEvent, render, screen, waitFor } from '@testing-library/react'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { IntlTestWrapper } from '../../../i18n/test-utils'; +import ImportRecipeForm from '../ImportRecipeForm'; + +const recipeMocks = vi.hoisted(() => ({ + parseDeeplink: vi.fn(), + parseRecipeFromFile: vi.fn(), +})); + +vi.mock('../../../recipe', () => recipeMocks); +vi.mock('../../../recipe/recipe_management', () => ({ saveRecipe: vi.fn() })); +vi.mock('../../../toasts', () => ({ toastError: vi.fn(), toastSuccess: vi.fn() })); + +const MAX_RECIPE_FILE_SIZE_BYTES = 1024 * 1024; + +function renderForm() { + return render(, { + wrapper: IntlTestWrapper, + }); +} + +function uploadFile(file: File) { + fireEvent.change(screen.getByLabelText('Recipe File'), { + target: { files: [file] }, + }); +} + +function fileWithText(content: string | ArrayBuffer, name: string, text: string) { + const file = new File([content], name); + const readText = vi.fn().mockResolvedValue(text); + Object.defineProperty(file, 'text', { value: readText }); + return { file, readText }; +} + +beforeEach(() => { + vi.clearAllMocks(); + recipeMocks.parseRecipeFromFile.mockResolvedValue({ title: 'Imported recipe' }); +}); + +describe('ImportRecipeForm file size enforcement', () => { + it('rejects an oversized file before reading or parsing it', async () => { + const { file, readText } = fileWithText( + new ArrayBuffer(MAX_RECIPE_FILE_SIZE_BYTES + 1), + 'oversized.yaml', + 'title: oversized' + ); + renderForm(); + + uploadFile(file); + await act(async () => {}); + + expect(readText).not.toHaveBeenCalled(); + expect(recipeMocks.parseRecipeFromFile).not.toHaveBeenCalled(); + expect(screen.getByText('File is too large, max size is 1MB')).toBeInTheDocument(); + }); + + it('reads and parses a valid YAML file below the limit', async () => { + const content = 'title: Valid recipe'; + const { file, readText } = fileWithText(content, 'recipe.yaml', content); + renderForm(); + + uploadFile(file); + + await waitFor(() => expect(recipeMocks.parseRecipeFromFile).toHaveBeenCalledWith(content)); + expect(readText).toHaveBeenCalledOnce(); + }); + + it('reads and parses a JSON file exactly at the limit', async () => { + const content = `{"value":"${'x'.repeat(MAX_RECIPE_FILE_SIZE_BYTES - 12)}"}`; + expect(new Blob([content]).size).toBe(MAX_RECIPE_FILE_SIZE_BYTES); + const { file, readText } = fileWithText(content, 'recipe.json', content); + renderForm(); + + uploadFile(file); + + await waitFor(() => expect(recipeMocks.parseRecipeFromFile).toHaveBeenCalledWith(content)); + expect(readText).toHaveBeenCalledOnce(); + }); +}); From 9a5319a6e875e55fcb334136305013c63aaf34f3 Mon Sep 17 00:00:00 2001 From: Jasper Date: Tue, 1 Sep 2026 04:39:08 +0000 Subject: [PATCH 29/37] fix(security): isolate audio recorder generations (#11456) --- .../src/hooks/useAudioRecorder.test.tsx | 348 ++++++++++++++++++ ui/desktop/src/hooks/useAudioRecorder.ts | 274 ++++++++++---- 2 files changed, 543 insertions(+), 79 deletions(-) create mode 100644 ui/desktop/src/hooks/useAudioRecorder.test.tsx diff --git a/ui/desktop/src/hooks/useAudioRecorder.test.tsx b/ui/desktop/src/hooks/useAudioRecorder.test.tsx new file mode 100644 index 000000000000..8c3fe1768a3b --- /dev/null +++ b/ui/desktop/src/hooks/useAudioRecorder.test.tsx @@ -0,0 +1,348 @@ +import { act, renderHook, waitFor } from '@testing-library/react'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +const mocks = vi.hoisted(() => ({ + config: {}, + getDictationConfig: vi.fn(), + onError: vi.fn(), + onTranscription: vi.fn(), + read: vi.fn(), + transcribeDictation: vi.fn(), +})); + +vi.mock('../components/ConfigContext', () => ({ + useConfig: () => ({ config: mocks.config, read: mocks.read }), +})); + +vi.mock('../acp/dictation', () => ({ + getDictationConfig: mocks.getDictationConfig, + transcribeDictation: mocks.transcribeDictation, +})); + +import { useAudioRecorder } from './useAudioRecorder'; + +type Deferred = { + promise: Promise; + resolve: (value: T) => void; +}; + +type FakeStream = MediaStream & { + track: { stop: ReturnType }; +}; + +const deferred = (): Deferred => { + let resolve = (_value: T) => {}; + const promise = new Promise((resolvePromise) => { + resolve = resolvePromise; + }); + return { promise, resolve }; +}; + +const createStream = (): FakeStream => { + const track = { stop: vi.fn() }; + return { + track, + getTracks: () => [track], + } as unknown as FakeStream; +}; + +let moduleLoads: Deferred[]; +let audioContexts: MockAudioContext[]; +let worklets: MockAudioWorkletNode[]; + +class MockAudioContext { + audioWorklet: { addModule: ReturnType }; + close = vi.fn(() => Promise.resolve()); + createGain = vi.fn(() => ({ + connect: vi.fn(), + gain: { value: 1 }, + })); + createMediaStreamSource = vi.fn(() => ({ connect: vi.fn() })); + destination = {}; + + constructor() { + const moduleLoad = moduleLoads.shift(); + this.audioWorklet = { + addModule: vi.fn(() => moduleLoad?.promise ?? Promise.resolve()), + }; + audioContexts.push(this); + } +} + +class MockAudioWorkletNode { + connect = vi.fn(); + disconnect = vi.fn(); + port: { onmessage: ((event: MessageEvent) => void) | null } = { + onmessage: null, + }; + + constructor() { + worklets.push(this); + } + + emit(samples: Float32Array) { + this.port.onmessage?.({ data: samples } as MessageEvent); + } +} + +const renderRecorder = async () => { + const hook = renderHook(() => + useAudioRecorder({ + onError: mocks.onError, + onTranscription: mocks.onTranscription, + }) + ); + await waitFor(() => expect(hook.result.current.isEnabled).toBe(true)); + return hook; +}; + +const startRecorder = async (startRecording: () => Promise) => { + await act(async () => { + await startRecording(); + }); +}; + +const emitSpeechChunk = (worklet: MockAudioWorkletNode) => { + let now = 100; + vi.spyOn(Date, 'now').mockImplementation(() => now); + const speech = new Float32Array(3200).fill(0.1); + const silence = new Float32Array(3200); + + act(() => { + worklet.emit(speech); + now = 400; + worklet.emit(silence); + now = 1301; + worklet.emit(silence); + }); +}; + +describe('useAudioRecorder lifecycle', () => { + let getUserMedia: ReturnType; + + beforeEach(() => { + moduleLoads = []; + audioContexts = []; + worklets = []; + getUserMedia = vi.fn(); + + mocks.getDictationConfig.mockReset().mockResolvedValue({ + openai: { configured: true }, + }); + mocks.onError.mockReset(); + mocks.onTranscription.mockReset(); + mocks.read + .mockReset() + .mockImplementation((key: string) => + Promise.resolve(key === 'voice_dictation_provider' ? 'openai' : null) + ); + mocks.transcribeDictation.mockReset(); + + vi.stubGlobal('AudioContext', MockAudioContext); + vi.stubGlobal('AudioWorkletNode', MockAudioWorkletNode); + Object.defineProperty(navigator, 'mediaDevices', { + configurable: true, + value: { getUserMedia }, + }); + }); + + afterEach(() => { + vi.restoreAllMocks(); + vi.unstubAllGlobals(); + }); + + it('cleans the superseded generation when starts overlap', async () => { + const firstStream = createStream(); + const secondStream = createStream(); + const firstModuleLoad = deferred(); + moduleLoads.push(firstModuleLoad); + getUserMedia.mockResolvedValueOnce(firstStream).mockResolvedValueOnce(secondStream); + const { result } = await renderRecorder(); + + let firstStart = Promise.resolve(); + act(() => { + firstStart = result.current.startRecording(); + }); + await waitFor(() => expect(audioContexts).toHaveLength(1)); + + await startRecorder(result.current.startRecording); + + expect(firstStream.track.stop).toHaveBeenCalledOnce(); + expect(audioContexts[0].close).toHaveBeenCalledOnce(); + expect(worklets).toHaveLength(1); + expect(result.current.isRecording).toBe(true); + + await act(async () => { + firstModuleLoad.resolve(); + await firstStart; + }); + + expect(worklets).toHaveLength(1); + act(() => result.current.stopRecording()); + expect(secondStream.track.stop).toHaveBeenCalledOnce(); + expect(audioContexts[1].close).toHaveBeenCalledOnce(); + expect(worklets[0].disconnect).toHaveBeenCalledOnce(); + }); + + it('stops a stream that arrives after recording is stopped', async () => { + const pendingStream = deferred(); + const stream = createStream(); + getUserMedia.mockReturnValueOnce(pendingStream.promise); + const { result } = await renderRecorder(); + + let start = Promise.resolve(); + act(() => { + start = result.current.startRecording(); + }); + await waitFor(() => expect(getUserMedia).toHaveBeenCalledOnce()); + act(() => result.current.stopRecording()); + + await act(async () => { + pendingStream.resolve(stream); + await start; + }); + + expect(stream.track.stop).toHaveBeenCalledOnce(); + expect(audioContexts).toHaveLength(0); + expect(worklets).toHaveLength(0); + expect(result.current.isRecording).toBe(false); + expect(mocks.onError).not.toHaveBeenCalled(); + }); + + it('does not finish startup after unmount', async () => { + const stream = createStream(); + const moduleLoad = deferred(); + moduleLoads.push(moduleLoad); + getUserMedia.mockResolvedValueOnce(stream); + const { result, unmount } = await renderRecorder(); + + let start = Promise.resolve(); + act(() => { + start = result.current.startRecording(); + }); + await waitFor(() => expect(audioContexts).toHaveLength(1)); + unmount(); + + expect(stream.track.stop).toHaveBeenCalledOnce(); + expect(audioContexts[0].close).toHaveBeenCalledOnce(); + + await act(async () => { + moduleLoad.resolve(); + await start; + }); + + expect(worklets).toHaveLength(0); + expect(mocks.onError).not.toHaveBeenCalled(); + }); + + it('suppresses an in-flight transcription after stop', async () => { + const stream = createStream(); + const transcription = deferred(); + getUserMedia.mockResolvedValueOnce(stream); + mocks.transcribeDictation.mockReturnValueOnce(transcription.promise); + const { result } = await renderRecorder(); + await startRecorder(result.current.startRecording); + + emitSpeechChunk(worklets[0]); + await waitFor(() => expect(mocks.transcribeDictation).toHaveBeenCalledOnce()); + act(() => result.current.stopRecording()); + + await act(async () => { + transcription.resolve('stale transcription'); + await transcription.promise; + await Promise.resolve(); + }); + + expect(mocks.onTranscription).not.toHaveBeenCalled(); + }); + + it('transcribes the buffered final phrase when recording is stopped', async () => { + const stream = createStream(); + getUserMedia.mockResolvedValueOnce(stream); + mocks.transcribeDictation.mockResolvedValueOnce('final phrase'); + const { result } = await renderRecorder(); + await startRecorder(result.current.startRecording); + + act(() => { + worklets[0].emit(new Float32Array(3200).fill(0.1)); + result.current.stopRecording(); + }); + + await waitFor(() => expect(mocks.onTranscription).toHaveBeenCalledWith('final phrase')); + await waitFor(() => expect(result.current.isTranscribing).toBe(false)); + expect(stream.track.stop).toHaveBeenCalledOnce(); + expect(audioContexts[0].close).toHaveBeenCalledOnce(); + expect(worklets[0].disconnect).toHaveBeenCalledOnce(); + }); + + it('cancels final-phrase transcription when a new recording starts', async () => { + const firstStream = createStream(); + const secondStream = createStream(); + const transcription = deferred(); + getUserMedia.mockResolvedValueOnce(firstStream).mockResolvedValueOnce(secondStream); + mocks.transcribeDictation.mockReturnValueOnce(transcription.promise); + const { result } = await renderRecorder(); + await startRecorder(result.current.startRecording); + + act(() => { + worklets[0].emit(new Float32Array(3200).fill(0.1)); + result.current.stopRecording(); + }); + await waitFor(() => expect(mocks.transcribeDictation).toHaveBeenCalledOnce()); + + await startRecorder(result.current.startRecording); + await act(async () => { + transcription.resolve('stale final phrase'); + await transcription.promise; + await Promise.resolve(); + }); + + expect(mocks.onTranscription).not.toHaveBeenCalled(); + expect(result.current.isRecording).toBe(true); + act(() => result.current.stopRecording()); + }); + + it('cancels final-phrase transcription when unmounted', async () => { + const stream = createStream(); + const transcription = deferred(); + getUserMedia.mockResolvedValueOnce(stream); + mocks.transcribeDictation.mockReturnValueOnce(transcription.promise); + const { result, unmount } = await renderRecorder(); + await startRecorder(result.current.startRecording); + + act(() => { + worklets[0].emit(new Float32Array(3200).fill(0.1)); + result.current.stopRecording(); + }); + await waitFor(() => expect(mocks.transcribeDictation).toHaveBeenCalledOnce()); + unmount(); + + await act(async () => { + transcription.resolve('stale final phrase'); + await transcription.promise; + await Promise.resolve(); + }); + + expect(mocks.onTranscription).not.toHaveBeenCalled(); + }); + + it('records and transcribes a normal generation', async () => { + const stream = createStream(); + getUserMedia.mockResolvedValueOnce(stream); + mocks.transcribeDictation.mockResolvedValueOnce('hello world'); + const { result } = await renderRecorder(); + + await startRecorder(result.current.startRecording); + expect(result.current.isRecording).toBe(true); + + emitSpeechChunk(worklets[0]); + await waitFor(() => expect(mocks.onTranscription).toHaveBeenCalledWith('hello world')); + await waitFor(() => expect(result.current.isTranscribing).toBe(false)); + + act(() => result.current.stopRecording()); + expect(result.current.isRecording).toBe(false); + expect(stream.track.stop).toHaveBeenCalledOnce(); + expect(audioContexts[0].close).toHaveBeenCalledOnce(); + expect(worklets[0].disconnect).toHaveBeenCalledOnce(); + }); +}); diff --git a/ui/desktop/src/hooks/useAudioRecorder.ts b/ui/desktop/src/hooks/useAudioRecorder.ts index 4e4fe68c290a..27760c3cb32c 100644 --- a/ui/desktop/src/hooks/useAudioRecorder.ts +++ b/ui/desktop/src/hooks/useAudioRecorder.ts @@ -17,6 +17,36 @@ const MIN_SPEECH_MS = 200; // without clipping early speech onsets. Determined empirically for 16kHz mono input. const RMS_THRESHOLD = 0.015; +interface RecorderGeneration { + audioContext: AudioContext | null; + cancelled: boolean; + completesAfterTranscription: boolean; + pendingTranscriptions: number; + stream: MediaStream | null; + worklet: AudioWorkletNode | null; +} + +function cleanupGeneration(generation: RecorderGeneration) { + generation.cancelled = true; + + const worklet = generation.worklet; + generation.worklet = null; + if (worklet) { + worklet.port.onmessage = null; + worklet.disconnect(); + } + + const audioContext = generation.audioContext; + generation.audioContext = null; + if (audioContext) { + void audioContext.close(); + } + + const stream = generation.stream; + generation.stream = null; + stream?.getTracks().forEach((track) => track.stop()); +} + // Resolve worklet URL at runtime from window.location so it works under both // the dev server (http://localhost) and packaged builds (file://). const WORKLET_URL = new URL('audio-capture-worklet.js', window.location.href.split('#')[0]).href; @@ -55,6 +85,17 @@ function rms(samples: Float32Array): number { return Math.sqrt(sum / samples.length); } +function mergeSamples(chunks: Float32Array[]): Float32Array { + const total = chunks.reduce((length, chunk) => length + chunk.length, 0); + const merged = new Float32Array(total); + let offset = 0; + for (const chunk of chunks) { + merged.set(chunk, offset); + offset += chunk.length; + } + return merged; +} + function blobToBase64(blob: Blob): Promise { return new Promise((resolve, reject) => { const r = new FileReader(); @@ -72,15 +113,14 @@ export const useAudioRecorder = ({ onTranscription, onError }: UseAudioRecorderO const { read, config } = useConfig(); - const audioContextRef = useRef(null); - const streamRef = useRef(null); + const activeGenerationRef = useRef(null); + const mountedRef = useRef(true); // VAD state (all refs to avoid re-render/stale closure issues) const samplesRef = useRef([]); const isSpeakingRef = useRef(false); const silenceStartRef = useRef(0); const speechStartRef = useRef(0); - const pendingTranscriptions = useRef(0); const providerRef = useRef(provider); providerRef.current = provider; @@ -112,95 +152,149 @@ export const useAudioRecorder = ({ onTranscription, onError }: UseAudioRecorderO check(); }, [read, config]); - const transcribeChunk = useCallback(async (samples: Float32Array) => { - const prov = providerRef.current; - if (!prov) return; + const resetSpeech = useCallback(() => { + samplesRef.current = []; + isSpeakingRef.current = false; + silenceStartRef.current = 0; + speechStartRef.current = 0; + }, []); - pendingTranscriptions.current++; - setIsTranscribing(true); + const isActiveGeneration = useCallback( + (generation: RecorderGeneration) => + mountedRef.current && activeGenerationRef.current === generation && !generation.cancelled, + [] + ); - try { - const wav = new Blob([encodeWav(samples, SAMPLE_RATE)], { type: 'audio/wav' }); - const base64 = await blobToBase64(wav); - const text = await transcribeDictation(base64, 'audio/wav', prov); - if (text) { - onTranscriptionRef.current(text); + const transcribeChunk = useCallback( + async (samples: Float32Array, generation: RecorderGeneration) => { + const prov = providerRef.current; + if (!prov || !isActiveGeneration(generation)) return; + + generation.pendingTranscriptions++; + setIsTranscribing(true); + + try { + const wav = new Blob([encodeWav(samples, SAMPLE_RATE)], { type: 'audio/wav' }); + const base64 = await blobToBase64(wav); + if (!isActiveGeneration(generation)) return; + + const text = await transcribeDictation(base64, 'audio/wav', prov); + if (text && isActiveGeneration(generation)) { + onTranscriptionRef.current(text); + } + } catch (error) { + if (isActiveGeneration(generation)) { + onErrorRef.current(errorMessage(error)); + } + } finally { + generation.pendingTranscriptions--; + if (generation.pendingTranscriptions === 0 && isActiveGeneration(generation)) { + setIsTranscribing(false); + if (generation.completesAfterTranscription) { + activeGenerationRef.current = null; + generation.cancelled = true; + } + } } - } catch (error) { - onErrorRef.current(errorMessage(error)); - } finally { - pendingTranscriptions.current--; - if (pendingTranscriptions.current === 0) setIsTranscribing(false); - } - }, []); + }, + [isActiveGeneration] + ); - const flush = useCallback(() => { - const chunks = samplesRef.current; - if (chunks.length === 0) return; + const flush = useCallback( + (generation: RecorderGeneration) => { + if (!isActiveGeneration(generation)) return; - const total = chunks.reduce((n, c) => n + c.length, 0); - const merged = new Float32Array(total); - let off = 0; - for (const c of chunks) { - merged.set(c, off); - off += c.length; - } - samplesRef.current = []; - transcribeChunk(merged); - }, [transcribeChunk]); + const chunks = samplesRef.current; + if (chunks.length === 0) return; - const flushRef = useRef(flush); - flushRef.current = flush; + samplesRef.current = []; + void transcribeChunk(mergeSamples(chunks), generation); + }, + [isActiveGeneration, transcribeChunk] + ); - const handleSamples = useCallback((samples: Float32Array) => { - const now = Date.now(); + const handleSamples = useCallback( + (samples: Float32Array, generation: RecorderGeneration) => { + if (!isActiveGeneration(generation)) return; - if (rms(samples) > RMS_THRESHOLD) { - if (!isSpeakingRef.current) { - isSpeakingRef.current = true; - speechStartRef.current = now; - } - silenceStartRef.current = 0; - samplesRef.current.push(new Float32Array(samples)); - } else if (isSpeakingRef.current) { - samplesRef.current.push(new Float32Array(samples)); - - if (silenceStartRef.current === 0) { - silenceStartRef.current = now; - } else if (now - silenceStartRef.current > SILENCE_MS) { - if (now - speechStartRef.current > MIN_SPEECH_MS) { - flushRef.current(); - } else { - samplesRef.current = []; + const now = Date.now(); + + if (rms(samples) > RMS_THRESHOLD) { + if (!isSpeakingRef.current) { + isSpeakingRef.current = true; + speechStartRef.current = now; } - isSpeakingRef.current = false; silenceStartRef.current = 0; + samplesRef.current.push(new Float32Array(samples)); + } else if (isSpeakingRef.current) { + samplesRef.current.push(new Float32Array(samples)); + + if (silenceStartRef.current === 0) { + silenceStartRef.current = now; + } else if (now - silenceStartRef.current > SILENCE_MS) { + if (now - speechStartRef.current > MIN_SPEECH_MS) { + flush(generation); + } else { + samplesRef.current = []; + } + isSpeakingRef.current = false; + silenceStartRef.current = 0; + } } + }, + [flush, isActiveGeneration] + ); + + const cancelActiveGeneration = useCallback(() => { + const generation = activeGenerationRef.current; + activeGenerationRef.current = null; + if (generation) cleanupGeneration(generation); + resetSpeech(); + + if (mountedRef.current) { + setIsRecording(false); + setIsTranscribing(false); } - }, []); + }, [resetSpeech]); const stopRecording = useCallback(() => { - if (isSpeakingRef.current && samplesRef.current.length > 0) { - flushRef.current(); + const finalChunks = isSpeakingRef.current ? samplesRef.current : []; + cancelActiveGeneration(); + + if (mountedRef.current && finalChunks.length > 0) { + const finalGeneration: RecorderGeneration = { + audioContext: null, + cancelled: false, + completesAfterTranscription: true, + pendingTranscriptions: 0, + stream: null, + worklet: null, + }; + activeGenerationRef.current = finalGeneration; + void transcribeChunk(mergeSamples(finalChunks), finalGeneration); } - isSpeakingRef.current = false; - silenceStartRef.current = 0; - - audioContextRef.current?.close(); - audioContextRef.current = null; - streamRef.current?.getTracks().forEach((t) => t.stop()); - streamRef.current = null; - setIsRecording(false); - }, []); + }, [cancelActiveGeneration, transcribeChunk]); const startRecording = useCallback(async () => { if (!isEnabled) { - onError('Voice dictation is not enabled'); + onErrorRef.current('Voice dictation is not enabled'); return; } + cancelActiveGeneration(); + const generation: RecorderGeneration = { + audioContext: null, + cancelled: false, + completesAfterTranscription: false, + pendingTranscriptions: 0, + stream: null, + worklet: null, + }; + activeGenerationRef.current = generation; + try { const preferredMic = await read('voice_dictation_preferred_mic', false); + if (!isActiveGeneration(generation)) return; const audioConstraints: MediaTrackConstraints = { echoCancellation: true, @@ -215,6 +309,7 @@ export const useAudioRecorder = ({ onTranscription, onError }: UseAudioRecorderO try { stream = await navigator.mediaDevices.getUserMedia({ audio: audioConstraints }); } catch (e) { + if (!isActiveGeneration(generation)) return; if ( preferredMic && e instanceof DOMException && @@ -226,17 +321,26 @@ export const useAudioRecorder = ({ onTranscription, onError }: UseAudioRecorderO throw e; } } - streamRef.current = stream; + generation.stream = stream; + if (!isActiveGeneration(generation)) { + cleanupGeneration(generation); + return; + } const ctx = new AudioContext({ sampleRate: SAMPLE_RATE }); - audioContextRef.current = ctx; + generation.audioContext = ctx; await ctx.audioWorklet.addModule(WORKLET_URL); + if (!isActiveGeneration(generation)) { + cleanupGeneration(generation); + return; + } const source = ctx.createMediaStreamSource(stream); const worklet = new AudioWorkletNode(ctx, 'audio-capture'); + generation.worklet = worklet; - worklet.port.onmessage = (e: MessageEvent) => handleSamples(e.data); + worklet.port.onmessage = (e: MessageEvent) => handleSamples(e.data, generation); // Connect through silent gain to keep worklet processing alive const silence = ctx.createGain(); @@ -245,19 +349,31 @@ export const useAudioRecorder = ({ onTranscription, onError }: UseAudioRecorderO worklet.connect(silence); silence.connect(ctx.destination); - setIsRecording(true); + if (isActiveGeneration(generation)) { + setIsRecording(true); + } } catch (error) { - stopRecording(); - onError(errorMessage(error)); + const isCurrent = isActiveGeneration(generation); + if (activeGenerationRef.current === generation) { + activeGenerationRef.current = null; + } + cleanupGeneration(generation); + if (isCurrent) { + resetSpeech(); + setIsRecording(false); + setIsTranscribing(false); + onErrorRef.current(errorMessage(error)); + } } - }, [isEnabled, onError, handleSamples, stopRecording, read]); + }, [cancelActiveGeneration, handleSamples, isActiveGeneration, isEnabled, read, resetSpeech]); useEffect(() => { + mountedRef.current = true; return () => { - audioContextRef.current?.close(); - streamRef.current?.getTracks().forEach((t) => t.stop()); + mountedRef.current = false; + cancelActiveGeneration(); }; - }, []); + }, [cancelActiveGeneration]); return { isEnabled, From e05f5d4348858655e58a72b65aac9daf0be2ab4d Mon Sep 17 00:00:00 2001 From: Jasper Date: Tue, 1 Sep 2026 05:11:55 +0000 Subject: [PATCH 30/37] fix(security): fuse command scanner signals (#11440) Signed-off-by: Jasper Hugo --- crates/goose/src/security/scanner.rs | 269 ++++++++++++++++++++++++--- 1 file changed, 247 insertions(+), 22 deletions(-) diff --git a/crates/goose/src/security/scanner.rs b/crates/goose/src/security/scanner.rs index ef24e3cc4211..fe47564e7a7d 100644 --- a/crates/goose/src/security/scanner.rs +++ b/crates/goose/src/security/scanner.rs @@ -26,6 +26,7 @@ pub struct ScanResult { struct DetailedScanResult { confidence: f32, + pattern_confidence: f32, pattern_matches: Vec, ml_confidence: Option, used_pattern_detection: bool, @@ -166,8 +167,9 @@ impl PromptInjectionScanner { threshold ); - let final_confidence = - self.combine_confidences(tool_result.confidence, context_result.ml_confidence); + let final_confidence = self + .combine_confidences(tool_result.confidence, context_result.ml_confidence) + .max(tool_result.pattern_confidence); tracing::info!( security.event_type = "prompt_injection_scan", @@ -184,6 +186,7 @@ impl PromptInjectionScanner { let final_result = DetailedScanResult { confidence: final_confidence, + pattern_confidence: tool_result.pattern_confidence, pattern_matches: tool_result.pattern_matches, ml_confidence: tool_result.ml_confidence, used_pattern_detection: tool_result.used_pattern_detection, @@ -198,25 +201,19 @@ impl PromptInjectionScanner { } async fn analyze_text(&self, text: &str) -> Result { - if let Some(classifier) = self.command_classifier.as_ref() { - if let Some(ml_confidence) = self - .scan_with_classifier(text, classifier, ClassifierType::Command) + let (pattern_confidence, pattern_matches) = self.pattern_based_scanning(text); + let ml_confidence = if let Some(classifier) = self.command_classifier.as_ref() { + self.scan_with_classifier(text, classifier, ClassifierType::Command) .await - { - return Ok(DetailedScanResult { - confidence: ml_confidence, - pattern_matches: Vec::new(), - ml_confidence: Some(ml_confidence), - used_pattern_detection: false, - }); - } - } + } else { + None + }; - let (pattern_confidence, pattern_matches) = self.pattern_based_scanning(text); Ok(DetailedScanResult { - confidence: pattern_confidence, + confidence: ml_confidence.map_or(pattern_confidence, |ml| ml.max(pattern_confidence)), + pattern_confidence, pattern_matches, - ml_confidence: None, + ml_confidence, used_pattern_detection: true, }) } @@ -227,6 +224,7 @@ impl PromptInjectionScanner { let Some(classifier) = self.prompt_classifier.as_ref() else { return Ok(DetailedScanResult { confidence: 0.0, + pattern_confidence: 0.0, pattern_matches: Vec::new(), ml_confidence: None, used_pattern_detection: false, @@ -236,6 +234,7 @@ impl PromptInjectionScanner { if user_messages.is_empty() { return Ok(DetailedScanResult { confidence: 0.0, + pattern_confidence: 0.0, pattern_matches: Vec::new(), ml_confidence: None, used_pattern_detection: false, @@ -255,6 +254,7 @@ impl PromptInjectionScanner { Ok(DetailedScanResult { confidence: max_confidence, + pattern_confidence: 0.0, pattern_matches: Vec::new(), ml_confidence: Some(max_confidence), used_pattern_detection: false, @@ -329,12 +329,34 @@ impl PromptInjectionScanner { .map_or(tool_content, |(_, args)| args); let command_preview = safe_truncate(text_to_preview, 300); - if let Some(top_match) = result.pattern_matches.first() { - let preview = safe_truncate(&top_match.matched_text, 50); - return format!( + let decisive_ml = result + .ml_confidence + .filter(|confidence| *confidence >= threshold); + let top_match = result + .pattern_matches + .iter() + .max_by_key(|pattern_match| &pattern_match.threat.risk_level); + let decisive_pattern_match = top_match.filter(|_| result.pattern_confidence >= threshold); + let pattern_explanation = |pattern_match: &PatternMatch| { + let preview = safe_truncate(&pattern_match.matched_text, 50); + format!( "Pattern-based detection: {} (Risk: {:?})\nFound: '{}'\n\nCommand:\n{}", - top_match.threat.description, top_match.threat.risk_level, preview, command_preview - ); + pattern_match.threat.description, + pattern_match.threat.risk_level, + preview, + command_preview + ) + }; + + if let Some(decisive_pattern_match) = decisive_pattern_match { + let mut explanation = pattern_explanation(decisive_pattern_match); + if let Some(ml_confidence) = decisive_ml { + explanation.push_str(&format!( + "\n\nClassifier detection confidence: {:.1}%", + ml_confidence * 100.0 + )); + } + return explanation; } if let Some(ml_conf) = result.ml_confidence { @@ -343,6 +365,8 @@ impl PromptInjectionScanner { ml_conf * 100.0, command_preview ) + } else if let Some(top_match) = top_match { + pattern_explanation(top_match) } else { format!("Security threat detected\n\nCommand:\n{}", command_preview) } @@ -408,6 +432,64 @@ impl Default for PromptInjectionScanner { mod tests { use super::*; use rmcp::object; + use wiremock::matchers::method; + use wiremock::{Mock, MockServer, ResponseTemplate}; + + async fn classifier_with_confidence( + injection_confidence: f32, + ) -> (MockServer, ClassificationClient) { + let mock_server = MockServer::start().await; + Mock::given(method("POST")) + .respond_with( + ResponseTemplate::new(200).set_body_json(serde_json::json!([[{ + "label": "INJECTION", + "score": injection_confidence + }, { + "label": "SAFE", + "score": 1.0 - injection_confidence + }]])), + ) + .mount(&mock_server) + .await; + + let classifier = + ClassificationClient::from_endpoint(mock_server.uri(), None, None).unwrap(); + + (mock_server, classifier) + } + + async fn scanner_with_command_confidence( + injection_confidence: f32, + ) -> (MockServer, PromptInjectionScanner) { + let (mock_server, command_classifier) = + classifier_with_confidence(injection_confidence).await; + + let scanner = PromptInjectionScanner { + pattern_matcher: PatternMatcher::new(), + command_classifier: Some(command_classifier), + prompt_classifier: None, + }; + + (mock_server, scanner) + } + + async fn scanner_with_classifier_confidences( + command_confidence: f32, + prompt_confidence: f32, + ) -> (MockServer, MockServer, PromptInjectionScanner) { + let (command_server, command_classifier) = + classifier_with_confidence(command_confidence).await; + let (prompt_server, prompt_classifier) = + classifier_with_confidence(prompt_confidence).await; + + let scanner = PromptInjectionScanner { + pattern_matcher: PatternMatcher::new(), + command_classifier: Some(command_classifier), + prompt_classifier: Some(prompt_classifier), + }; + + (command_server, prompt_server, scanner) + } #[tokio::test] async fn test_text_pattern_detection() { @@ -418,6 +500,149 @@ mod tests { assert!(!result.pattern_matches.is_empty()); } + #[tokio::test] + async fn command_classifier_false_negative_preserves_pattern_detection() { + let (_mock_server, scanner) = scanner_with_command_confidence(0.0).await; + + let result = scanner.analyze_text("rm -rf /").await.unwrap(); + + assert_eq!(result.confidence, 0.95); + assert_eq!(result.ml_confidence, Some(0.0)); + assert!(result.used_pattern_detection); + assert_eq!(result.pattern_matches[0].threat.name, "rm_rf_root_bare"); + } + + #[tokio::test] + async fn command_classifier_legitimate_result_remains_safe() { + let (_mock_server, scanner) = scanner_with_command_confidence(0.0).await; + + let result = scanner.analyze_text("printf 'hello\\n'").await.unwrap(); + + assert_eq!(result.confidence, 0.0); + assert_eq!(result.ml_confidence, Some(0.0)); + assert!(result.used_pattern_detection); + assert!(result.pattern_matches.is_empty()); + } + + #[tokio::test] + async fn command_classifier_stronger_signal_preserves_pattern_evidence() { + let (_mock_server, scanner) = scanner_with_command_confidence(0.9).await; + + let result = scanner.analyze_text("chmod +s /tmp/tool").await.unwrap(); + + assert_eq!(result.confidence, 0.9); + assert_eq!(result.ml_confidence, Some(0.9)); + assert!(result.used_pattern_detection); + assert_eq!( + result.pattern_matches[0].threat.name, + "suid_binary_creation" + ); + } + + #[tokio::test] + async fn classifier_explanation_wins_when_low_pattern_is_not_decisive() { + let (_mock_server, scanner) = scanner_with_command_confidence(0.9).await; + let command = "echo $(printf $(date))"; + let tool_call = CallToolRequestParams::new("shell").with_arguments(object!({ + "command": command + })); + + let detailed = scanner.analyze_text(command).await.unwrap(); + assert_eq!(detailed.pattern_confidence, 0.45); + assert_eq!( + detailed.pattern_matches[0].threat.name, + "indirect_command_execution" + ); + + let result = scanner + .analyze_tool_call_with_context(&tool_call, &[]) + .await + .unwrap(); + + assert_eq!(result.confidence, 0.9); + assert!(result.is_malicious); + assert!(result.explanation.contains("confidence: 90.0%")); + assert!(!result.explanation.contains("Pattern-based detection")); + assert!(!result.explanation.contains("Risk: Low")); + } + + #[tokio::test] + async fn decisive_pattern_explanation_keeps_classifier_evidence() { + let (_mock_server, scanner) = scanner_with_command_confidence(0.9).await; + let tool_call = CallToolRequestParams::new("shell").with_arguments(object!({ + "command": "rm -rf /" + })); + + let result = scanner + .analyze_tool_call_with_context(&tool_call, &[]) + .await + .unwrap(); + + assert_eq!(result.confidence, 0.95); + assert!(result.is_malicious); + assert!(result.explanation.contains("Pattern-based detection")); + assert!(result.explanation.contains("Risk: Critical")); + assert!(result + .explanation + .contains("Classifier detection confidence: 90.0%")); + } + + #[tokio::test] + async fn low_pattern_and_low_classifier_remain_safe() { + let (_mock_server, scanner) = scanner_with_command_confidence(0.2).await; + let tool_call = CallToolRequestParams::new("shell").with_arguments(object!({ + "command": "echo $(printf $(date))" + })); + + let result = scanner + .analyze_tool_call_with_context(&tool_call, &[]) + .await + .unwrap(); + + assert_eq!(result.confidence, 0.45); + assert!(!result.is_malicious); + assert_eq!(result.explanation, "No security threats detected"); + } + + #[tokio::test] + async fn context_fusion_preserves_pattern_confidence_floor() { + let _env = env_lock::lock_env([("SECURITY_PROMPT_THRESHOLD", Some("0.9"))]); + let (_command_server, _prompt_server, scanner) = + scanner_with_classifier_confidences(0.0, 0.0).await; + let tool_call = CallToolRequestParams::new("shell").with_arguments(object!({ + "command": "rm -rf /" + })); + let messages = vec![Message::user().with_text("Please clean the build directory")]; + + let result = scanner + .analyze_tool_call_with_context(&tool_call, &messages) + .await + .unwrap(); + + assert_eq!(result.confidence, 0.95); + assert!(result.is_malicious); + assert!(result.explanation.contains("Pattern-based detection")); + } + + #[tokio::test] + async fn context_fusion_keeps_legitimate_command_safe() { + let (_command_server, _prompt_server, scanner) = + scanner_with_classifier_confidences(0.0, 0.0).await; + let tool_call = CallToolRequestParams::new("shell").with_arguments(object!({ + "command": "printf 'hello\\n'" + })); + let messages = vec![Message::user().with_text("Print a greeting")]; + + let result = scanner + .analyze_tool_call_with_context(&tool_call, &messages) + .await + .unwrap(); + + assert_eq!(result.confidence, 0.0); + assert!(!result.is_malicious); + assert_eq!(result.explanation, "No security threats detected"); + } + #[tokio::test] async fn test_conversation_scan_without_ml() { let scanner = PromptInjectionScanner::new(); From 0cf78829e59960e6a86bd1d0464724e9cb169366 Mon Sep 17 00:00:00 2001 From: Jasper Date: Tue, 1 Sep 2026 05:11:59 +0000 Subject: [PATCH 31/37] fix(security): isolate Telegram voice temp files (#11442) Signed-off-by: Jasper Hugo --- crates/goose/src/gateway/telegram.rs | 1022 ++++++++++++++++++++++++-- 1 file changed, 941 insertions(+), 81 deletions(-) diff --git a/crates/goose/src/gateway/telegram.rs b/crates/goose/src/gateway/telegram.rs index 9548c2e7385e..b056fa1cbc98 100644 --- a/crates/goose/src/gateway/telegram.rs +++ b/crates/goose/src/gateway/telegram.rs @@ -5,6 +5,9 @@ use async_trait::async_trait; use reqwest::{Client, RequestBuilder, Response}; use serde::de::DeserializeOwned; use serde::{Deserialize, Serialize}; +use std::io::{self, Write}; +use std::path::PathBuf; +use std::sync::{Arc, Mutex}; use tokio_util::sync::CancellationToken; const TELEGRAM_API_BASE: &str = "https://api.telegram.org"; @@ -14,10 +17,595 @@ const RETRY_DELAY: std::time::Duration = std::time::Duration::from_secs(5); /// Maximum voice file size we'll attempt to download (20 MB, Telegram's bot API limit). const MAX_VOICE_FILE_SIZE: i64 = 20 * 1024 * 1024; +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +struct VoiceFileIdentity { + #[cfg(unix)] + device: u64, + #[cfg(unix)] + inode: u64, + #[cfg(windows)] + volume: u32, + #[cfg(windows)] + index_high: u32, + #[cfg(windows)] + index_low: u32, +} + +struct VoiceTempFile { + path: tempfile::TempPath, + identity: VoiceFileIdentity, + created_at: std::time::SystemTime, + cleanup_on_drop: bool, +} + +impl VoiceTempFile { + fn remove(&mut self) -> io::Result { + self.path.disable_cleanup(true); + let removed = remove_voice_file_if_unchanged(&self.path, Some(self.identity), |_| {})?; + self.cleanup_on_drop = false; + Ok(removed) + } +} + +impl Drop for VoiceTempFile { + fn drop(&mut self) { + if self.cleanup_on_drop { + let path = self.path.to_path_buf(); + self.path.disable_cleanup(true); + let _ = remove_voice_file_if_unchanged(&path, Some(self.identity), |_| {}); + } + } +} + +struct VoiceTempFiles { + parent: PathBuf, + files: Mutex>, +} + +impl VoiceTempFiles { + fn new_in(parent: impl Into) -> Self { + Self { + parent: parent.into(), + files: Mutex::new(Vec::new()), + } + } + + fn save(&self, bytes: &[u8], extension: &str) -> io::Result { + let mut file = tempfile::Builder::new() + .prefix("goose_voice_") + .suffix(&format!(".{extension}")) + .tempfile_in(&self.parent)?; + file.write_all(bytes)?; + let identity = voice_file_identity(file.as_file())?; + let path = file.path().to_path_buf(); + let path_owner = file.into_temp_path(); + self.files + .lock() + .map_err(|_| io::Error::other("Telegram voice file registry is unavailable"))? + .push(VoiceTempFile { + path: path_owner, + identity, + created_at: std::time::SystemTime::now(), + cleanup_on_drop: true, + }); + Ok(path) + } + + fn cleanup(&self, max_age: std::time::Duration) -> io::Result { + self.cleanup_with_hook(max_age, |_| {}) + } + + fn cleanup_with_hook( + &self, + max_age: std::time::Duration, + mut after_opened_candidate: impl FnMut(&std::path::Path), + ) -> io::Result { + let cutoff = std::time::SystemTime::now() + .checked_sub(max_age) + .unwrap_or(std::time::SystemTime::UNIX_EPOCH); + let mut files = self + .files + .lock() + .map_err(|_| io::Error::other("Telegram voice file registry is unavailable"))?; + let mut removed_tracked = 0; + let mut retained = Vec::with_capacity(files.len()); + for mut file in std::mem::take(&mut *files) { + if file.created_at > cutoff { + retained.push(file); + continue; + } + match file.remove() { + Ok(true) => removed_tracked += 1, + Ok(false) => {} + Err(_) => retained.push(file), + } + } + *files = retained; + let active_paths: std::collections::HashSet = + files.iter().map(|file| file.path.to_path_buf()).collect(); + drop(files); + + let removed_orphans = cleanup_orphaned_voice_files( + &self.parent, + cutoff, + &active_paths, + &mut after_opened_candidate, + )?; + let removed_legacy = + cleanup_legacy_voice_files(&self.parent, cutoff, &mut after_opened_candidate)?; + Ok(removed_tracked + removed_orphans + removed_legacy) + } + + #[cfg(test)] + fn parent(&self) -> &std::path::Path { + &self.parent + } +} + +fn cleanup_orphaned_voice_files( + parent: &std::path::Path, + cutoff: std::time::SystemTime, + active_paths: &std::collections::HashSet, + after_opened_candidate: &mut impl FnMut(&std::path::Path), +) -> io::Result { + let entries = match std::fs::read_dir(parent) { + Ok(entries) => entries, + Err(error) if error.kind() == io::ErrorKind::NotFound => return Ok(0), + Err(error) => return Err(error), + }; + let mut removed = 0; + for entry in entries { + let Ok(entry) = entry else { + continue; + }; + if !is_goose_voice_file_name(&entry.file_name()) { + continue; + } + let path = entry.path(); + if active_paths.contains(&path) { + continue; + } + let Ok(file) = open_owned_voice_file(&path) else { + continue; + }; + let Ok(metadata) = file.metadata() else { + continue; + }; + if metadata + .modified() + .map_or(true, |modified| modified > cutoff) + { + continue; + } + let Ok(identity) = voice_file_identity(&file) else { + continue; + }; + after_opened_candidate(&path); + if remove_voice_file_if_unchanged(&path, Some(identity), |_| {})? { + removed += 1; + } + } + Ok(removed) +} + +fn is_goose_voice_file_name(name: &std::ffi::OsStr) -> bool { + let Some(name) = name.to_str() else { + return false; + }; + let Some(rest) = name.strip_prefix("goose_voice_") else { + return false; + }; + let Some((random, extension)) = rest.split_once('.') else { + return false; + }; + random.len() == 6 + && random.bytes().all(|byte| byte.is_ascii_alphanumeric()) + && is_voice_file_extension(extension) +} + +fn is_legacy_voice_file_name(name: &std::ffi::OsStr) -> bool { + let Some(name) = name.to_str() else { + return false; + }; + let Some(rest) = name.strip_prefix("voice_") else { + return false; + }; + let Some((uuid, extension)) = rest.split_once('.') else { + return false; + }; + let uuid_bytes = uuid.as_bytes(); + uuid_bytes.len() == 36 + && uuid_bytes.iter().enumerate().all(|(index, byte)| { + matches!(index, 8 | 13 | 18 | 23) && *byte == b'-' + || !matches!(index, 8 | 13 | 18 | 23) && byte.is_ascii_hexdigit() + }) + && is_voice_file_extension(extension) +} + +fn is_voice_file_extension(extension: &str) -> bool { + !extension.is_empty() + && extension.len() <= 16 + && extension.bytes().enumerate().all(|(index, byte)| { + byte.is_ascii_alphanumeric() || (index > 0 && matches!(byte, b'-' | b'_')) + }) +} + +fn cleanup_legacy_voice_files( + parent: &std::path::Path, + cutoff: std::time::SystemTime, + after_opened_candidate: &mut impl FnMut(&std::path::Path), +) -> io::Result { + let root_path = parent.join("goose_voice"); + let Ok(root) = open_legacy_voice_root(&root_path) else { + return Ok(0); + }; + let entries = match std::fs::read_dir(&root_path) { + Ok(entries) => entries, + Err(error) if error.kind() == io::ErrorKind::NotFound => return Ok(0), + Err(error) => return Err(error), + }; + let mut removed = 0; + for entry in entries { + let Ok(entry) = entry else { + continue; + }; + let name = entry.file_name(); + if !is_legacy_voice_file_name(&name) { + continue; + } + let Ok(file) = open_owned_voice_file_at(&root, &name) else { + continue; + }; + let Ok(metadata) = file.metadata() else { + continue; + }; + if metadata + .modified() + .map_or(true, |modified| modified > cutoff) + { + continue; + } + let Ok(identity) = voice_file_identity(&file) else { + continue; + }; + let path = root_path.join(&name); + after_opened_candidate(&path); + let Ok(current) = open_owned_voice_file_at(&root, &name) else { + continue; + }; + if voice_file_identity(¤t)? != identity { + continue; + } + delete_open_legacy_voice_file(&root, &name, current)?; + removed += 1; + } + Ok(removed) +} + +fn remove_voice_file_if_unchanged( + path: &std::path::Path, + expected_identity: Option, + mut after_opened: impl FnMut(&std::path::Path), +) -> io::Result { + let file = match open_owned_voice_file(path) { + Ok(file) => file, + Err(error) if error.kind() == io::ErrorKind::NotFound => return Ok(false), + Err(_) => return Ok(false), + }; + let identity = voice_file_identity(&file)?; + if expected_identity.is_some_and(|expected| expected != identity) { + return Ok(false); + } + after_opened(path); + let current = match open_owned_voice_file(path) { + Ok(file) => file, + Err(error) if error.kind() == io::ErrorKind::NotFound => return Ok(false), + Err(_) => return Ok(false), + }; + if voice_file_identity(¤t)? != identity { + return Ok(false); + } + delete_open_voice_file(current, path)?; + Ok(true) +} + +#[cfg(unix)] +fn delete_open_voice_file(_file: std::fs::File, path: &std::path::Path) -> io::Result<()> { + std::fs::remove_file(path) +} + +#[cfg(unix)] +fn open_legacy_voice_root(path: &std::path::Path) -> io::Result { + use std::os::unix::fs::{MetadataExt, OpenOptionsExt}; + + let directory = std::fs::OpenOptions::new() + .read(true) + .custom_flags(libc::O_CLOEXEC | libc::O_DIRECTORY | libc::O_NOFOLLOW) + .open(path)?; + let metadata = directory.metadata()?; + if !metadata.is_dir() + || metadata.uid() != unsafe { libc::geteuid() } + || metadata.mode() & 0o777 != 0o700 + { + return Err(io::Error::new( + io::ErrorKind::InvalidInput, + "not an owned legacy Telegram voice directory", + )); + } + Ok(directory) +} + +#[cfg(unix)] +fn open_owned_voice_file_at( + directory: &std::fs::File, + name: &std::ffi::OsStr, +) -> io::Result { + use std::ffi::CString; + use std::os::fd::{AsRawFd, FromRawFd}; + use std::os::unix::ffi::OsStrExt; + + let name = CString::new(name.as_bytes()).map_err(|_| { + io::Error::new( + io::ErrorKind::InvalidInput, + "Telegram voice filename contains a NUL byte", + ) + })?; + let descriptor = unsafe { + libc::openat( + directory.as_raw_fd(), + name.as_ptr(), + libc::O_RDONLY | libc::O_CLOEXEC | libc::O_NOFOLLOW | libc::O_NONBLOCK, + ) + }; + if descriptor < 0 { + return Err(io::Error::last_os_error()); + } + let file = unsafe { std::fs::File::from_raw_fd(descriptor) }; + validate_owned_voice_file(&file)?; + Ok(file) +} + +#[cfg(unix)] +fn delete_open_legacy_voice_file( + directory: &std::fs::File, + name: &std::ffi::OsStr, + _file: std::fs::File, +) -> io::Result<()> { + use std::ffi::CString; + use std::os::fd::AsRawFd; + use std::os::unix::ffi::OsStrExt; + + let name = CString::new(name.as_bytes()).map_err(|_| { + io::Error::new( + io::ErrorKind::InvalidInput, + "Telegram voice filename contains a NUL byte", + ) + })?; + if unsafe { libc::unlinkat(directory.as_raw_fd(), name.as_ptr(), 0) } != 0 { + return Err(io::Error::last_os_error()); + } + Ok(()) +} + +#[cfg(unix)] +fn open_owned_voice_file(path: &std::path::Path) -> io::Result { + use std::os::unix::fs::OpenOptionsExt; + + let file = std::fs::OpenOptions::new() + .read(true) + .custom_flags(libc::O_CLOEXEC | libc::O_NOFOLLOW | libc::O_NONBLOCK) + .open(path)?; + validate_owned_voice_file(&file)?; + Ok(file) +} + +#[cfg(unix)] +fn validate_owned_voice_file(file: &std::fs::File) -> io::Result<()> { + use std::os::unix::fs::MetadataExt; + + let metadata = file.metadata()?; + if !metadata.is_file() + || metadata.uid() != unsafe { libc::geteuid() } + || metadata.mode() & 0o777 != 0o600 + || metadata.nlink() != 1 + { + return Err(io::Error::new( + io::ErrorKind::InvalidInput, + "not an owned Telegram voice tempfile", + )); + } + Ok(()) +} + +#[cfg(unix)] +fn voice_file_identity(file: &std::fs::File) -> io::Result { + use std::os::unix::fs::MetadataExt; + + let metadata = file.metadata()?; + Ok(VoiceFileIdentity { + device: metadata.dev(), + inode: metadata.ino(), + }) +} + +#[cfg(windows)] +fn open_owned_voice_file(path: &std::path::Path) -> io::Result { + use std::os::windows::fs::OpenOptionsExt; + use winapi::um::winbase::FILE_FLAG_OPEN_REPARSE_POINT; + use winapi::um::winnt::{ + DELETE, FILE_GENERIC_READ, FILE_SHARE_DELETE, FILE_SHARE_READ, FILE_SHARE_WRITE, + }; + + let file = std::fs::OpenOptions::new() + .access_mode(FILE_GENERIC_READ | DELETE) + .share_mode(FILE_SHARE_READ | FILE_SHARE_WRITE | FILE_SHARE_DELETE) + .custom_flags(FILE_FLAG_OPEN_REPARSE_POINT) + .open(path)?; + validate_owned_voice_file(&file)?; + Ok(file) +} + +#[cfg(windows)] +fn validate_owned_voice_file(file: &std::fs::File) -> io::Result<()> { + use std::os::windows::fs::MetadataExt; + use winapi::um::winnt::FILE_ATTRIBUTE_REPARSE_POINT; + + let metadata = file.metadata()?; + if !metadata.is_file() || metadata.file_attributes() & FILE_ATTRIBUTE_REPARSE_POINT != 0 { + return Err(io::Error::new( + io::ErrorKind::InvalidInput, + "not an owned Telegram voice tempfile", + )); + } + Ok(()) +} + +#[cfg(windows)] +fn open_legacy_voice_root(path: &std::path::Path) -> io::Result { + use std::os::windows::fs::{MetadataExt, OpenOptionsExt}; + use winapi::um::winbase::{FILE_FLAG_BACKUP_SEMANTICS, FILE_FLAG_OPEN_REPARSE_POINT}; + use winapi::um::winnt::{ + FILE_ATTRIBUTE_REPARSE_POINT, FILE_LIST_DIRECTORY, FILE_READ_ATTRIBUTES, FILE_SHARE_DELETE, + FILE_SHARE_READ, FILE_SHARE_WRITE, SYNCHRONIZE, + }; + + let directory = std::fs::OpenOptions::new() + .access_mode(FILE_LIST_DIRECTORY | FILE_READ_ATTRIBUTES | SYNCHRONIZE) + .share_mode(FILE_SHARE_READ | FILE_SHARE_WRITE | FILE_SHARE_DELETE) + .custom_flags(FILE_FLAG_BACKUP_SEMANTICS | FILE_FLAG_OPEN_REPARSE_POINT) + .open(path)?; + let metadata = directory.metadata()?; + if !metadata.is_dir() || metadata.file_attributes() & FILE_ATTRIBUTE_REPARSE_POINT != 0 { + return Err(io::Error::new( + io::ErrorKind::InvalidInput, + "not an owned legacy Telegram voice directory", + )); + } + Ok(directory) +} + +#[cfg(windows)] +fn open_owned_voice_file_at( + directory: &std::fs::File, + name: &std::ffi::OsStr, +) -> io::Result { + use ntapi::ntioapi::{ + NtCreateFile, FILE_OPEN, FILE_OPEN_REPARSE_POINT, FILE_SYNCHRONOUS_IO_NONALERT, + IO_STATUS_BLOCK, + }; + use std::os::windows::ffi::OsStrExt; + use std::os::windows::io::{AsRawHandle, FromRawHandle}; + use winapi::shared::ntdef::{ + HANDLE, NT_SUCCESS, OBJECT_ATTRIBUTES, OBJ_CASE_INSENSITIVE, UNICODE_STRING, + }; + use winapi::um::winnt::{ + DELETE, FILE_GENERIC_READ, FILE_SHARE_DELETE, FILE_SHARE_READ, FILE_SHARE_WRITE, + }; + + let mut name: Vec = name.encode_wide().collect(); + let name_bytes = name + .len() + .checked_mul(std::mem::size_of::()) + .and_then(|length| u16::try_from(length).ok()) + .ok_or_else(|| { + io::Error::new( + io::ErrorKind::InvalidInput, + "Telegram voice filename is too long", + ) + })?; + let mut unicode_name = UNICODE_STRING { + Length: name_bytes, + MaximumLength: name_bytes, + Buffer: name.as_mut_ptr(), + }; + let mut attributes = OBJECT_ATTRIBUTES { + Length: std::mem::size_of::() as u32, + RootDirectory: directory.as_raw_handle() as HANDLE, + ObjectName: &mut unicode_name, + Attributes: OBJ_CASE_INSENSITIVE, + SecurityDescriptor: std::ptr::null_mut(), + SecurityQualityOfService: std::ptr::null_mut(), + }; + let mut handle: HANDLE = std::ptr::null_mut(); + let mut io_status: IO_STATUS_BLOCK = unsafe { std::mem::zeroed() }; + let status = unsafe { + NtCreateFile( + &mut handle, + FILE_GENERIC_READ | DELETE, + &mut attributes, + &mut io_status, + std::ptr::null_mut(), + 0, + FILE_SHARE_READ | FILE_SHARE_WRITE | FILE_SHARE_DELETE, + FILE_OPEN, + FILE_OPEN_REPARSE_POINT | FILE_SYNCHRONOUS_IO_NONALERT, + std::ptr::null_mut(), + 0, + ) + }; + if !NT_SUCCESS(status) { + let error = unsafe { ntapi::ntrtl::RtlNtStatusToDosError(status) }; + return Err(io::Error::from_raw_os_error(error as i32)); + } + let file = unsafe { std::fs::File::from_raw_handle(handle.cast()) }; + validate_owned_voice_file(&file)?; + Ok(file) +} + +#[cfg(windows)] +fn delete_open_legacy_voice_file( + _directory: &std::fs::File, + _name: &std::ffi::OsStr, + file: std::fs::File, +) -> io::Result<()> { + delete_open_voice_file(file, std::path::Path::new("")) +} + +#[cfg(windows)] +fn delete_open_voice_file(file: std::fs::File, _path: &std::path::Path) -> io::Result<()> { + use std::os::windows::io::AsRawHandle; + use winapi::um::fileapi::{SetFileInformationByHandle, FILE_DISPOSITION_INFO}; + use winapi::um::minwinbase::FileDispositionInfo; + + let mut disposition = FILE_DISPOSITION_INFO { DeleteFile: 1 }; + // SAFETY: the handle is live and the information buffer matches FileDispositionInfo. + let result = unsafe { + SetFileInformationByHandle( + file.as_raw_handle().cast(), + FileDispositionInfo, + (&mut disposition as *mut FILE_DISPOSITION_INFO).cast(), + std::mem::size_of::() as u32, + ) + }; + if result == 0 { + return Err(io::Error::last_os_error()); + } + Ok(()) +} + +#[cfg(windows)] +fn voice_file_identity(file: &std::fs::File) -> io::Result { + use std::os::windows::io::AsRawHandle; + use winapi::um::fileapi::{GetFileInformationByHandle, BY_HANDLE_FILE_INFORMATION}; + + let mut information: BY_HANDLE_FILE_INFORMATION = unsafe { std::mem::zeroed() }; + let result = + unsafe { GetFileInformationByHandle(file.as_raw_handle().cast(), &mut information) }; + if result == 0 { + return Err(io::Error::last_os_error()); + } + Ok(VoiceFileIdentity { + volume: information.dwVolumeSerialNumber, + index_high: information.nFileIndexHigh, + index_low: information.nFileIndexLow, + }) +} + pub struct TelegramGateway { bot_token: String, client: Client, api_base: String, + voice_temp_files: Arc, } #[derive(Debug, Serialize)] @@ -109,6 +697,13 @@ struct TelegramResponse { impl TelegramGateway { pub fn new(config: &GatewayConfig) -> anyhow::Result { + Self::new_with_voice_temp_parent(config, std::env::temp_dir()) + } + + fn new_with_voice_temp_parent( + config: &GatewayConfig, + voice_temp_parent: PathBuf, + ) -> anyhow::Result { let bot_token = config.platform_config["bot_token"] .as_str() .ok_or_else(|| anyhow::anyhow!("missing bot_token in platform_config"))? @@ -118,11 +713,11 @@ impl TelegramGateway { .connect_timeout(std::time::Duration::from_secs(10)) .http1_only() .build()?; - Ok(Self { bot_token, client, api_base: TELEGRAM_API_BASE.to_string(), + voice_temp_files: Arc::new(VoiceTempFiles::new_in(voice_temp_parent)), }) } @@ -263,40 +858,15 @@ impl TelegramGateway { /// Save voice bytes to a temporary file and return the path. /// - /// Files are stored under `/goose_voice/voice_.` so Goose - /// can access them via its shell tools. The extension is derived from the - /// MIME type when available, falling back to `.ogg` for voice notes. + /// Files are stored as protected, exclusively created temporary files so + /// Goose can access them via its shell tools. The extension is derived from + /// the MIME type when available, falling back to `.ogg` for voice notes. /// - /// On Unix the directory is created with mode `0700` and files with `0600` - /// so other local users cannot read private voice content. - fn save_voice_file( - bytes: &[u8], - mime_type: Option<&str>, - ) -> anyhow::Result { - let dir = std::env::temp_dir().join("goose_voice"); - std::fs::create_dir_all(&dir)?; - - // Restrict directory permissions to owner-only on Unix. - #[cfg(unix)] - { - use std::os::unix::fs::PermissionsExt; - std::fs::set_permissions(&dir, std::fs::Permissions::from_mode(0o700))?; - } - + /// On Unix files are created with mode `0600` so other local users cannot + /// read private voice content. + fn save_voice_file(&self, bytes: &[u8], mime_type: Option<&str>) -> anyhow::Result { let ext = Self::voice_file_extension(mime_type); - - let filename = format!("voice_{}.{ext}", uuid::Uuid::new_v4()); - let path = dir.join(filename); - std::fs::write(&path, bytes)?; - - // Restrict file permissions to owner-only on Unix. - #[cfg(unix)] - { - use std::os::unix::fs::PermissionsExt; - std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o600))?; - } - - Ok(path) + Ok(self.voice_temp_files.save(bytes, &ext)?) } fn voice_file_extension(mime_type: Option<&str>) -> String { @@ -408,13 +978,16 @@ impl Gateway for TelegramGateway { // Spawn a background task that periodically removes stale voice files // (older than 1 hour) so they don't accumulate on disk. let cleanup_cancel = cancel.clone(); + let voice_temp_files = Arc::clone(&self.voice_temp_files); tokio::spawn(async move { let mut interval = tokio::time::interval(std::time::Duration::from_secs(600)); loop { tokio::select! { _ = cleanup_cancel.cancelled() => break, _ = interval.tick() => { - cleanup_voice_files(std::time::Duration::from_secs(3600)); + if let Err(error) = voice_temp_files.cleanup(std::time::Duration::from_secs(3600)) { + tracing::warn!(%error, "failed to clean up Telegram voice files"); + } } } } @@ -452,7 +1025,7 @@ impl Gateway for TelegramGateway { } match self.download_file(voice.file_id).await { - Ok(bytes) => match Self::save_voice_file(&bytes, voice.mime_type) { + Ok(bytes) => match self.save_voice_file(&bytes, voice.mime_type) { Ok(path) => Self::voice_prompt(&path, voice.duration, voice.mime_type), Err(e) => { tracing::error!( @@ -548,29 +1121,6 @@ impl Gateway for TelegramGateway { } } -/// Remove voice files from the temp directory that are older than `max_age`. -fn cleanup_voice_files(max_age: std::time::Duration) { - let dir = std::env::temp_dir().join("goose_voice"); - let Ok(entries) = std::fs::read_dir(&dir) else { - return; - }; - let cutoff = std::time::SystemTime::now() - max_age; - let mut removed = 0u32; - for entry in entries.flatten() { - let dominated = entry - .metadata() - .ok() - .and_then(|m| m.modified().ok()) - .is_some_and(|t| t < cutoff); - if dominated && std::fs::remove_file(entry.path()).is_ok() { - removed += 1; - } - } - if removed > 0 { - tracing::debug!(removed, "cleaned up stale voice files"); - } -} - #[allow(clippy::string_slice)] fn split_message(text: &str, max_len: usize) -> Vec { if text.len() <= max_len { @@ -626,6 +1176,7 @@ mod tests { bot_token: "test-token".to_string(), client: Client::builder().no_proxy().build().unwrap(), api_base, + voice_temp_files: Arc::new(VoiceTempFiles::new_in(std::env::temp_dir())), } } @@ -634,6 +1185,16 @@ mod tests { bot_token: SECRET_BOT_TOKEN.to_string(), client: Client::builder().no_proxy().build().unwrap(), api_base, + voice_temp_files: Arc::new(VoiceTempFiles::new_in(std::env::temp_dir())), + } + } + + fn gateway_with_voice_temp_files(voice_temp_files: VoiceTempFiles) -> TelegramGateway { + TelegramGateway { + bot_token: "test-token".to_string(), + client: Client::builder().no_proxy().build().unwrap(), + api_base: TELEGRAM_API_BASE.to_string(), + voice_temp_files: Arc::new(voice_temp_files), } } @@ -1011,29 +1572,39 @@ mod tests { #[test] fn save_voice_file_creates_file_ogg() { + let gateway = test_gateway(TELEGRAM_API_BASE.to_string()); let bytes = b"fake ogg data"; - let path = TelegramGateway::save_voice_file(bytes, Some("audio/ogg")).unwrap(); + let path = gateway.save_voice_file(bytes, Some("audio/ogg")).unwrap(); assert!(path.exists()); assert!(path.to_str().unwrap().ends_with(".ogg")); assert_eq!(std::fs::read(&path).unwrap(), bytes); - let _ = std::fs::remove_file(&path); + + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + + assert_eq!( + std::fs::metadata(&path).unwrap().permissions().mode() & 0o777, + 0o600 + ); + } } #[test] fn save_voice_file_creates_file_mp3() { + let gateway = test_gateway(TELEGRAM_API_BASE.to_string()); let bytes = b"fake mp3 data"; - let path = TelegramGateway::save_voice_file(bytes, Some("audio/mpeg")).unwrap(); + let path = gateway.save_voice_file(bytes, Some("audio/mpeg")).unwrap(); assert!(path.exists()); assert!(path.to_str().unwrap().ends_with(".mp3")); - let _ = std::fs::remove_file(&path); } #[test] fn save_voice_file_defaults_to_ogg() { + let gateway = test_gateway(TELEGRAM_API_BASE.to_string()); let bytes = b"unknown format"; - let path = TelegramGateway::save_voice_file(bytes, None).unwrap(); + let path = gateway.save_voice_file(bytes, None).unwrap(); assert!(path.to_str().unwrap().ends_with(".ogg")); - let _ = std::fs::remove_file(&path); } #[test] @@ -1079,36 +1650,325 @@ mod tests { #[test] fn save_voice_file_contains_untrusted_mime_before_pairing() { + let temp = tempfile::tempdir().unwrap(); + let gateway = gateway_with_voice_temp_files(VoiceTempFiles::new_in(temp.path())); let bytes = b"unpaired voice data"; - let path = TelegramGateway::save_voice_file(bytes, Some("audio/..\\..\\outside")).unwrap(); + let path = gateway + .save_voice_file(bytes, Some("audio/..\\..\\outside")) + .unwrap(); - let expected_dir = std::env::temp_dir().join("goose_voice"); - assert_eq!(path.parent(), Some(expected_dir.as_path())); + assert_eq!(path.parent(), Some(gateway.voice_temp_files.parent())); let filename = path.file_name().unwrap().to_str().unwrap(); - assert!(filename.starts_with("voice_")); + assert!(filename.starts_with("goose_voice_")); assert!(filename.ends_with(".ogg")); assert!(!filename.chars().any(|c| matches!(c, '/' | '\\' | ':'))); assert_eq!(std::fs::read(&path).unwrap(), bytes); - - let _ = std::fs::remove_file(&path); } #[test] - fn cleanup_preserves_recent_files() { - let dir = std::env::temp_dir().join("goose_voice"); - std::fs::create_dir_all(&dir).unwrap(); - let recent_file = dir.join("voice_cleanup_recent_test.ogg"); - std::fs::write(&recent_file, b"recent").unwrap(); - // With a 1-hour max_age, a just-created file should survive. - cleanup_voice_files(std::time::Duration::from_secs(3600)); + fn cleanup_handles_legitimate_voice_files() { + let temp = tempfile::tempdir().unwrap(); + let gateway = gateway_with_voice_temp_files(VoiceTempFiles::new_in(temp.path())); + let recent_file = gateway + .save_voice_file(b"recent", Some("audio/ogg")) + .unwrap(); + assert_eq!( + gateway + .voice_temp_files + .cleanup(std::time::Duration::from_secs(3600)) + .unwrap(), + 0 + ); assert!(recent_file.exists()); - let _ = std::fs::remove_file(&recent_file); + + std::thread::sleep(std::time::Duration::from_millis(10)); + assert_eq!( + gateway + .voice_temp_files + .cleanup(std::time::Duration::ZERO) + .unwrap(), + 1 + ); + assert!(!recent_file.exists()); + } + + #[cfg(unix)] + #[test] + fn saved_voice_files_do_not_retain_open_descriptors() { + let temp = tempfile::tempdir().unwrap(); + let gateway = gateway_with_voice_temp_files(VoiceTempFiles::new_in(temp.path())); + let paths: std::collections::HashSet = (0..32) + .map(|_| gateway.save_voice_file(b"voice", None).unwrap()) + .collect(); + let descriptor_dir = if std::path::Path::new("/proc/self/fd").exists() { + std::path::Path::new("/proc/self/fd") + } else { + std::path::Path::new("/dev/fd") + }; + + let retained = std::fs::read_dir(descriptor_dir) + .unwrap() + .filter_map(Result::ok) + .filter_map(|entry| std::fs::read_link(entry.path()).ok()) + .any(|target| paths.contains(&target)); + + assert!(!retained); + assert_eq!(gateway.voice_temp_files.files.lock().unwrap().len(), 32); + } + + #[cfg(windows)] + #[test] + fn saved_voice_files_do_not_retain_open_descriptors() { + let temp = tempfile::tempdir().unwrap(); + let gateway = gateway_with_voice_temp_files(VoiceTempFiles::new_in(temp.path())); + let path = gateway.save_voice_file(b"voice", None).unwrap(); + + std::fs::remove_file(&path).expect("closed voice files must be removable on Windows"); + } + + #[test] + fn cleanup_reclaims_stale_voice_file_from_previous_process() { + let temp = tempfile::tempdir().unwrap(); + let producer = VoiceTempFiles::new_in(temp.path()); + let path = producer.save(b"orphaned voice", "ogg").unwrap(); + std::mem::forget(producer); + let cleaner = VoiceTempFiles::new_in(temp.path()); + assert_eq!( + cleaner + .cleanup(std::time::Duration::from_secs(3600)) + .unwrap(), + 0 + ); + assert!(path.exists()); + + let old = std::time::SystemTime::now() - std::time::Duration::from_secs(7200); + std::fs::File::options() + .write(true) + .open(&path) + .unwrap() + .set_times(std::fs::FileTimes::new().set_modified(old)) + .unwrap(); + + assert_eq!( + cleaner + .cleanup(std::time::Duration::from_secs(3600)) + .unwrap(), + 1 + ); + assert!(!path.exists()); + } + + #[test] + fn cleanup_reclaims_only_stale_legacy_voice_files() { + let temp = tempfile::tempdir().unwrap(); + let legacy_root = temp.path().join("goose_voice"); + std::fs::create_dir(&legacy_root).unwrap(); + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + + std::fs::set_permissions(&legacy_root, std::fs::Permissions::from_mode(0o700)).unwrap(); + } + let stale = legacy_root.join("voice_01234567-89ab-cdef-0123-456789abcdef.ogg"); + let recent = legacy_root.join("voice_abcdef01-2345-6789-abcd-ef0123456789.mp3"); + let unrelated = legacy_root.join("notes.txt"); + std::fs::write(&stale, b"stale voice").unwrap(); + std::fs::write(&recent, b"recent voice").unwrap(); + std::fs::write(&unrelated, b"unrelated").unwrap(); + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + + std::fs::set_permissions(&stale, std::fs::Permissions::from_mode(0o600)).unwrap(); + std::fs::set_permissions(&recent, std::fs::Permissions::from_mode(0o600)).unwrap(); + } + let old = std::time::SystemTime::now() - std::time::Duration::from_secs(7200); + std::fs::File::options() + .write(true) + .open(&stale) + .unwrap() + .set_times(std::fs::FileTimes::new().set_modified(old)) + .unwrap(); + + let cleaner = VoiceTempFiles::new_in(temp.path()); + assert_eq!( + cleaner + .cleanup(std::time::Duration::from_secs(3600)) + .unwrap(), + 1 + ); + assert!(!stale.exists()); + assert_eq!(std::fs::read(&recent).unwrap(), b"recent voice"); + assert_eq!(std::fs::read(&unrelated).unwrap(), b"unrelated"); + } + + #[cfg(unix)] + #[test] + fn cleanup_legacy_root_replacement_stays_anchored() { + use std::os::unix::fs::PermissionsExt; + + let temp = tempfile::tempdir().unwrap(); + let legacy_root = temp.path().join("goose_voice"); + let moved_root = temp.path().join("moved-goose-voice"); + let filename = "voice_01234567-89ab-cdef-0123-456789abcdef.ogg"; + std::fs::create_dir(&legacy_root).unwrap(); + std::fs::set_permissions(&legacy_root, std::fs::Permissions::from_mode(0o700)).unwrap(); + let original = legacy_root.join(filename); + std::fs::write(&original, b"legacy voice").unwrap(); + std::fs::set_permissions(&original, std::fs::Permissions::from_mode(0o600)).unwrap(); + let cleaner = VoiceTempFiles::new_in(temp.path()); + let mut replaced = false; + + let removed = cleaner + .cleanup_with_hook(std::time::Duration::ZERO, |candidate| { + if candidate == original && !replaced { + std::fs::rename(&legacy_root, &moved_root).unwrap(); + std::fs::create_dir(&legacy_root).unwrap(); + std::fs::set_permissions(&legacy_root, std::fs::Permissions::from_mode(0o700)) + .unwrap(); + let replacement = legacy_root.join(filename); + std::fs::write(&replacement, b"replacement").unwrap(); + std::fs::set_permissions(&replacement, std::fs::Permissions::from_mode(0o600)) + .unwrap(); + replaced = true; + } + }) + .unwrap(); + + assert_eq!(removed, 1); + assert!(replaced); + assert!(!moved_root.join(filename).exists()); + assert_eq!( + std::fs::read(legacy_root.join(filename)).unwrap(), + b"replacement" + ); + } + + #[cfg(unix)] + #[test] + fn cleanup_preserves_symlinks_and_unrelated_files() { + let temp = tempfile::tempdir().unwrap(); + let unrelated = temp.path().join("unrelated.txt"); + let victim = temp.path().join("victim.txt"); + let disguised_symlink = temp.path().join("goose_voice_ABC123.ogg"); + std::fs::write(&unrelated, b"unrelated").unwrap(); + std::fs::write(&victim, b"victim").unwrap(); + std::os::unix::fs::symlink(&victim, &disguised_symlink).unwrap(); + + let cleaner = VoiceTempFiles::new_in(temp.path()); + assert_eq!(cleaner.cleanup(std::time::Duration::ZERO).unwrap(), 0); + assert_eq!(std::fs::read(&unrelated).unwrap(), b"unrelated"); + assert_eq!(std::fs::read(&victim).unwrap(), b"victim"); + assert!(disguised_symlink.symlink_metadata().is_ok()); + } + + #[cfg(unix)] + #[test] + fn cleanup_preserves_replacement_after_candidate_open() { + use std::os::unix::fs::PermissionsExt; + + let temp = tempfile::tempdir().unwrap(); + let producer = VoiceTempFiles::new_in(temp.path()); + let path = producer.save(b"original voice", "ogg").unwrap(); + let moved = temp.path().join("moved-original.ogg"); + std::mem::forget(producer); + let cleaner = VoiceTempFiles::new_in(temp.path()); + let mut replaced = false; + + let removed = cleaner + .cleanup_with_hook(std::time::Duration::ZERO, |candidate| { + if candidate == path && !replaced { + std::fs::rename(&path, &moved).unwrap(); + std::fs::write(&path, b"replacement").unwrap(); + std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o600)) + .unwrap(); + replaced = true; + } + }) + .unwrap(); + + assert_eq!(removed, 0); + assert!(replaced); + assert_eq!(std::fs::read(&path).unwrap(), b"replacement"); + assert_eq!(std::fs::read(&moved).unwrap(), b"original voice"); + } + + #[cfg(unix)] + #[test] + fn precreated_legacy_root_cannot_redirect_voice_save_or_cleanup() { + use std::os::unix::fs::PermissionsExt; + + let sandbox = tempfile::tempdir().unwrap(); + let fake_tmp = sandbox.path().join("tmp"); + let victim_dir = sandbox.path().join("victim"); + std::fs::create_dir(&fake_tmp).unwrap(); + std::fs::create_dir(&victim_dir).unwrap(); + let victim_file = victim_dir.join("voice_01234567-89ab-cdef-0123-456789abcdef.ogg"); + std::fs::write(&victim_file, b"keep me").unwrap(); + std::fs::set_permissions(&victim_file, std::fs::Permissions::from_mode(0o600)).unwrap(); + std::os::unix::fs::symlink(&victim_dir, fake_tmp.join("goose_voice")).unwrap(); + let gateway = gateway_with_voice_temp_files(VoiceTempFiles::new_in(&fake_tmp)); + + let saved = gateway + .save_voice_file(b"voice", Some("audio/ogg")) + .unwrap(); + assert_eq!(saved.parent(), Some(fake_tmp.as_path())); + assert_ne!(saved.parent(), Some(victim_dir.as_path())); + gateway + .voice_temp_files + .cleanup(std::time::Duration::ZERO) + .unwrap(); + + assert!(victim_file.exists()); + } + + #[cfg(windows)] + #[test] + fn replaced_legacy_root_cannot_redirect_voice_save_or_cleanup() { + let sandbox = tempfile::tempdir().unwrap(); + let fake_tmp = sandbox.path().join("tmp"); + let victim_dir = sandbox.path().join("victim"); + std::fs::create_dir(&fake_tmp).unwrap(); + std::fs::create_dir(&victim_dir).unwrap(); + let victim_file = victim_dir.join("unrelated.txt"); + std::fs::write(&victim_file, b"keep me").unwrap(); + let replaced_root = fake_tmp.join("goose_voice"); + std::fs::create_dir(&replaced_root).unwrap(); + std::fs::write(replaced_root.join("attacker-controlled.txt"), b"keep me").unwrap(); + let gateway = gateway_with_voice_temp_files(VoiceTempFiles::new_in(&fake_tmp)); + + let saved = gateway + .save_voice_file(b"voice", Some("audio/ogg")) + .unwrap(); + assert_eq!(saved.parent(), Some(fake_tmp.as_path())); + gateway + .voice_temp_files + .cleanup(std::time::Duration::ZERO) + .unwrap(); + + assert_eq!(std::fs::read(&victim_file).unwrap(), b"keep me"); + assert_eq!( + std::fs::read(replaced_root.join("attacker-controlled.txt")).unwrap(), + b"keep me" + ); } #[test] - fn cleanup_handles_missing_dir() { - // Should not panic even when the directory doesn't exist. - cleanup_voice_files(std::time::Duration::from_secs(1)); + fn text_gateway_starts_when_voice_storage_is_unavailable() { + let sandbox = tempfile::tempdir().unwrap(); + let missing_parent = sandbox.path().join("missing"); + let config = GatewayConfig { + gateway_type: "telegram".to_string(), + platform_config: serde_json::json!({"bot_token": "test-token"}), + max_sessions: 1, + }; + + let gateway = TelegramGateway::new_with_voice_temp_parent(&config, missing_parent.clone()) + .expect("text-only startup must not access voice storage"); + assert!(!missing_parent.exists()); + assert!(gateway + .save_voice_file(b"voice", Some("audio/ogg")) + .is_err()); } #[test] From 7d650ebd7f0bef8359519817c0db7b023638a45b Mon Sep 17 00:00:00 2001 From: Jasper Date: Tue, 1 Sep 2026 05:12:03 +0000 Subject: [PATCH 32/37] fix(security): anchor execute shell extraction (#11492) Signed-off-by: Jasper Hugo --- crates/goose/src/providers/toolshim.rs | 847 ++++++++++++++++++++++++- 1 file changed, 820 insertions(+), 27 deletions(-) diff --git a/crates/goose/src/providers/toolshim.rs b/crates/goose/src/providers/toolshim.rs index 26c4e1f6a418..fa9f43ea19d6 100644 --- a/crates/goose/src/providers/toolshim.rs +++ b/crates/goose/src/providers/toolshim.rs @@ -45,7 +45,9 @@ use goose_providers::formats::openai::create_request; use goose_providers::images::ImageFormat; use reqwest::Client; use rmcp::model::{object, CallToolRequestParams, ContentBlock, Tool}; +use serde::de::{DeserializeSeed, MapAccess, SeqAccess, Visitor}; use serde_json::{json, Value}; +use std::fmt; use std::time::Duration; use uuid::Uuid; @@ -174,27 +176,190 @@ fn normalized_tool_alias(raw_tool_name: &str) -> String { .next() .unwrap_or(without_functions_prefix) .trim() + .trim_matches(|character: char| !character.is_ascii_alphanumeric() && character != '_') .to_ascii_lowercase() } -#[allow(clippy::string_slice)] // All markers/delimiters are ASCII; byte indexing is safe. -fn extract_shell_command_from_execute_code(code: &str) -> Option { - let marker = "command"; - let marker_idx = code.find(marker)?; - let after_marker = &code[marker_idx + marker.len()..]; - let colon_idx = after_marker.find(':')?; - let after_colon = after_marker[colon_idx + 1..].trim_start(); - - let quote = after_colon.chars().next()?; - if quote != '"' && quote != '\'' { +fn contains_unresolved_execute_alias(raw_tool_header: &str, tools: &[Tool]) -> bool { + raw_tool_header.split_whitespace().any(|raw_tool_name| { + raw_tool_name.split(':').any(|segment| { + matches!( + normalized_tool_alias(segment).as_str(), + "execute" | "execute_code" + ) && resolve_tool_name(segment, tools).is_none() + }) + }) +} + +fn contains_structured_unresolved_execute_alias(value: &Value, tools: &[Tool]) -> bool { + match value { + Value::Object(object) => { + object + .get("name") + .and_then(Value::as_str) + .is_some_and(|name| contains_unresolved_execute_alias(name, tools)) + || object + .values() + .any(|value| contains_structured_unresolved_execute_alias(value, tools)) + } + Value::Array(array) => array + .iter() + .any(|value| contains_structured_unresolved_execute_alias(value, tools)), + _ => false, + } +} + +struct RawStructuredExecuteAliasSeed<'a> { + tools: &'a [Tool], + inspect_string: bool, +} + +struct RawStructuredExecuteAliasVisitor<'a> { + tools: &'a [Tool], + inspect_string: bool, +} + +impl<'de> DeserializeSeed<'de> for RawStructuredExecuteAliasSeed<'_> { + type Value = bool; + + fn deserialize(self, deserializer: D) -> Result + where + D: serde::Deserializer<'de>, + { + deserializer.deserialize_any(RawStructuredExecuteAliasVisitor { + tools: self.tools, + inspect_string: self.inspect_string, + }) + } +} + +impl<'de> Visitor<'de> for RawStructuredExecuteAliasVisitor<'_> { + type Value = bool; + + fn expecting(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter.write_str("a JSON value") + } + + fn visit_bool(self, _value: bool) -> Result { + Ok(false) + } + + fn visit_i64(self, _value: i64) -> Result { + Ok(false) + } + + fn visit_u64(self, _value: u64) -> Result { + Ok(false) + } + + fn visit_f64(self, _value: f64) -> Result { + Ok(false) + } + + fn visit_str(self, value: &str) -> Result { + Ok(self.inspect_string && contains_unresolved_execute_alias(value, self.tools)) + } + + fn visit_string(self, value: String) -> Result { + Ok(self.inspect_string && contains_unresolved_execute_alias(&value, self.tools)) + } + + fn visit_unit(self) -> Result { + Ok(false) + } + + fn visit_seq(self, mut sequence: A) -> Result + where + A: SeqAccess<'de>, + { + let mut contains_execute = false; + while let Some(value_contains_execute) = + sequence.next_element_seed(RawStructuredExecuteAliasSeed { + tools: self.tools, + inspect_string: false, + })? + { + contains_execute |= value_contains_execute; + } + Ok(contains_execute) + } + + fn visit_map(self, mut object: A) -> Result + where + A: MapAccess<'de>, + { + let mut contains_execute = false; + while let Some(key) = object.next_key::()? { + let value_contains_execute = object.next_value_seed(RawStructuredExecuteAliasSeed { + tools: self.tools, + inspect_string: key == "name", + })?; + contains_execute |= value_contains_execute; + } + Ok(contains_execute) + } +} + +fn raw_arguments_contain_structured_unresolved_execute_alias( + raw_arguments: &str, + tools: &[Tool], +) -> bool { + let mut deserializer = serde_json::Deserializer::from_str(raw_arguments); + RawStructuredExecuteAliasSeed { + tools, + inspect_string: false, + } + .deserialize(&mut deserializer) + .unwrap_or(false) +} + +fn malformed_arguments_contain_unresolved_execute_alias( + raw_arguments: &str, + tools: &[Tool], +) -> bool { + let mut remainder = raw_arguments.trim(); + while !remainder.is_empty() { + let mut values = serde_json::Deserializer::from_str(remainder).into_iter::(); + if let Some(Ok(value)) = values.next() { + if contains_structured_unresolved_execute_alias(&value, tools) { + return true; + } + remainder = remainder + .get(values.byte_offset()..) + .unwrap_or_default() + .trim_start(); + continue; + } + + let prefix_end = remainder.find('{').unwrap_or(remainder.len()); + let (non_json_prefix, json_suffix) = remainder.split_at(prefix_end); + if contains_unresolved_execute_alias(non_json_prefix, tools) { + return true; + } + if prefix_end == 0 { + remainder = remainder.strip_prefix('{').unwrap_or_default().trim_start(); + continue; + } + if prefix_end == remainder.len() { + return false; + } + remainder = json_suffix; + } + false +} + +fn decode_quoted_string(literal: &str) -> Option { + let quote = literal.chars().next()?; + if !matches!(quote, '"' | '\'') || !literal.ends_with(quote) { return None; } + let body = literal.strip_prefix(quote)?.strip_suffix(quote)?; let mut escaped = false; - let mut command = String::new(); - for ch in after_colon[1..].chars() { + let mut decoded = String::new(); + for ch in body.chars() { if escaped { - command.push(ch); + decoded.push(ch); escaped = false; continue; } @@ -204,14 +369,99 @@ fn extract_shell_command_from_execute_code(code: &str) -> Option { continue; } - if ch == quote { - return Some(command); + decoded.push(ch); + } + + (!escaped).then_some(decoded) +} + +fn node_text<'a>(node: tree_sitter::Node<'_>, source: &'a str) -> Option<&'a str> { + source.get(node.byte_range()) +} + +fn is_developer_shell_call(node: tree_sitter::Node<'_>, source: &str) -> bool { + let Some(function) = node.child_by_field_name("function") else { + return false; + }; + if function.kind() != "member_expression" { + return false; + } + + let Some(object) = function.child_by_field_name("object") else { + return false; + }; + let Some(property) = function.child_by_field_name("property") else { + return false; + }; + + object.kind() == "identifier" + && property.kind() == "property_identifier" + && node_text(object, source) == Some("Developer") + && node_text(property, source) == Some("shell") +} + +fn shell_command_from_call(node: tree_sitter::Node<'_>, source: &str) -> Option { + let arguments = node.child_by_field_name("arguments")?; + if arguments.named_child_count() != 1 { + return None; + } + + let object = arguments.named_child(0)?; + if object.kind() != "object" || object.named_child_count() != 1 { + return None; + } + + let pair = object.named_child(0)?; + if pair.kind() != "pair" { + return None; + } + + let key = pair.child_by_field_name("key")?; + let is_command_key = match key.kind() { + "property_identifier" => node_text(key, source) == Some("command"), + "string" => node_text(key, source) + .and_then(decode_quoted_string) + .is_some_and(|key| key == "command"), + _ => false, + }; + if !is_command_key { + return None; + } + + let value = pair.child_by_field_name("value")?; + if value.kind() != "string" { + return None; + } + + node_text(value, source).and_then(decode_quoted_string) +} + +fn extract_shell_command_from_execute_code(code: &str) -> Option { + let mut parser = tree_sitter::Parser::new(); + parser + .set_language(&tree_sitter_typescript::LANGUAGE_TYPESCRIPT.into()) + .ok()?; + let tree = parser.parse(code, None)?; + let root = tree.root_node(); + if root.has_error() { + return None; + } + + let mut command = None; + let mut nodes = vec![root]; + while let Some(node) = nodes.pop() { + if node.kind() == "call_expression" && is_developer_shell_call(node, code) { + if command.is_some() { + return None; + } + command = Some(shell_command_from_call(node, code)?); } - command.push(ch); + let mut cursor = node.walk(); + nodes.extend(node.named_children(&mut cursor)); } - None + command } fn maybe_convert_execute_to_shell_tool_call( @@ -282,9 +532,15 @@ fn parse_json_value_tolerant(input: &str) -> Option { }) } +struct TokenizedToolCallParse { + calls: Vec, + rejected_execute: bool, +} + #[allow(clippy::string_slice)] // All markers are ASCII; byte indexing is safe. -fn parse_tokenized_tool_calls(content: &str, tools: &[Tool]) -> Vec { +fn parse_tokenized_tool_calls_with_status(content: &str, tools: &[Tool]) -> TokenizedToolCallParse { let mut calls = Vec::new(); + let mut rejected_execute = false; let mut remainder = content; while let Some(begin_idx) = remainder.find(TOOL_CALL_BEGIN) { @@ -292,6 +548,23 @@ fn parse_tokenized_tool_calls(content: &str, tools: &[Tool]) -> Vec Vec Vec { + parse_tokenized_tool_calls_with_status(content, tools).calls } #[allow(clippy::string_slice)] // Indices come from char_indices(); slicing is safe. @@ -994,10 +1312,16 @@ pub async fn augment_message_with_tool_calls( .iter() .any(|content| matches!(content, MessageContent::ToolRequest(_))); - let direct_tool_calls = parse_tokenized_tool_calls(&content, tools); - if !direct_tool_calls.is_empty() { + let direct_tool_calls = parse_tokenized_tool_calls_with_status(&content, tools); + if direct_tool_calls.rejected_execute { + return Ok(sanitize_message_after_tokenized_parse(message)); + } + if !direct_tool_calls.calls.is_empty() { let cleaned = sanitize_message_after_tokenized_parse(message); - return Ok(append_tool_calls_to_message(cleaned, direct_tool_calls)); + return Ok(append_tool_calls_to_message( + cleaned, + direct_tool_calls.calls, + )); } let inline_json_tool_calls = parse_inline_json_tool_calls(&content, tools); @@ -1166,6 +1490,70 @@ mod tests { ); } + #[test] + fn execute_marker_ignores_command_data_before_shell_call() { + let tools = vec![Tool::new( + "shell".to_string(), + "Shell command execution".to_string(), + serde_json::Map::new(), + )]; + + let content = "<|tool_calls_section_begin|> <|tool_call_begin|> functions.execute:0 <|tool_call_argument_begin|> {\"code\":\"async function run() { const data = { command: \\\"cat /etc/shadow\\\" }; return await Developer.shell({ command: \\\"pwd\\\" }); }\"} <|tool_call_end|> <|tool_calls_section_end|>"; + + let calls = parse_tokenized_tool_calls(content, &tools); + + assert_eq!(calls.len(), 1); + assert_eq!(calls[0].name, "shell"); + assert_eq!( + calls[0] + .arguments + .as_ref() + .and_then(|a| a.get("command")) + .and_then(|v| v.as_str()), + Some("pwd") + ); + } + + #[test] + fn execute_code_only_extracts_real_developer_shell_call() { + let code = r#" + async function run() { + const object = { command: "object decoy" }; + const text = 'Developer.shell({ command: "string decoy" })'; + const pattern = /Developer\.shell\(\{ command: "regex decoy" \}\)/; + // Developer.shell({ command: "comment decoy" }) + return await Developer.shell({ "command": 'printf \'safe\'' }); + } + "#; + + assert_eq!( + extract_shell_command_from_execute_code(code).as_deref(), + Some("printf 'safe'") + ); + } + + #[test] + fn execute_code_rejects_ambiguous_or_unsupported_shell_calls() { + let cases = [ + "Developer.shell({ command: 'first' }); Developer.shell({ command: 'second' });", + "Developer.shell({ command: getCommand() });", + "Developer.shell({ command: 'pwd', timeout: 1000 });", + "Developer['shell']({ command: 'pwd' });", + "OtherDeveloper.shell({ command: 'pwd' });", + "Developer.shellish({ command: 'pwd' });", + "const text = 'Developer.shell({ command: \\\"pwd\\\" })';", + "Developer.shell({ command: 'pwd'", + ]; + + for code in cases { + assert_eq!( + extract_shell_command_from_execute_code(code), + None, + "unexpectedly extracted a command from {code:?}" + ); + } + } + #[test] fn parses_inline_json_tool_directive() { let tools = vec![Tool::new( @@ -1235,6 +1623,411 @@ mod tests { assert!(!augmented.as_concat_text().contains("<|tool_call_begin|>")); } + #[tokio::test] + async fn augment_does_not_interpret_rejected_execute_marker() { + let tools = vec![Tool::new( + "shell".to_string(), + "Shell command execution".to_string(), + serde_json::Map::new(), + )]; + let rejected = [ + "<|tool_calls_section_begin|> <|tool_call_begin|> functions.execute:0 <|tool_call_argument_begin|> {\"code\":\"Developer.shell({ command: 'first' }); Developer.shell({ command: 'second' });\"} <|tool_call_end|> <|tool_calls_section_end|>", + "<|tool_calls_section_begin|> <|tool_call_begin|> functions.execute:0 <|tool_call_argument_begin|> {\"code\":\"Developer.shell({ command: getCommand() });\"} <|tool_call_end|> <|tool_calls_section_end|>", + "<|tool_calls_section_begin|> <|tool_call_begin|> functions.execute:0 <|tool_call_argument_begin|> {\"code\": <|tool_call_end|> <|tool_calls_section_end|>", + "<|tool_calls_section_begin|> <|tool_call_begin|> functions.execute:0 <|tool_call_end|> <|tool_calls_section_end|>", + "<|tool_calls_section_begin|> <|tool_call_begin|> analysis:1 functions.execute:0 <|tool_call_end|> <|tool_calls_section_end|>", + "<|tool_calls_section_begin|> <|tool_call_begin|> functions.execute:0 <|tool_call_argument_begin|> {\"code\":\"Developer.shell({ command: 'pwd' });\"}", + "<|tool_calls_section_begin|> <|tool_call_begin|> label functions.execute:0 <|tool_call_argument_begin|> {\"code\":\"Developer.shell({ command: 'pwd' });\"}", + "<|tool_calls_section_begin|> <|tool_call_begin|> analysis:1 functions.execute:0 <|tool_call_argument_begin|> {\"code\":\"Developer.shell({ command: 'pwd' });\"} <|tool_call_end|> <|tool_calls_section_end|>", + "<|tool_calls_section_begin|> <|tool_call_begin|> analysis:1:functions.execute:0 <|tool_call_argument_begin|> {\"code\":\"Developer.shell({ command: 'pwd' });\"} <|tool_call_end|> <|tool_calls_section_end|>", + "<|tool_calls_section_begin|> <|tool_call_begin|> label <|tool_call_argument_begin|> functions.execute:0 {\"code\":\"Developer.shell({ command: 'pwd' });\"} <|tool_call_end|> <|tool_calls_section_end|>", + "<|tool_calls_section_begin|> <|tool_call_begin|> label <|tool_call_argument_begin|> {} functions.execute:0 {\"code\":\"Developer.shell({ command: 'pwd' });\"} <|tool_call_end|> <|tool_calls_section_end|>", + "<|tool_calls_section_begin|> <|tool_call_begin|> shell:functions.execute:0 Developer.shell({ command: 'pwd' }) <|tool_call_end|> <|tool_calls_section_end|>", + ]; + + for content in rejected { + assert!( + parse_tokenized_tool_calls_with_status(content, &tools).rejected_execute, + "expected execute block to be rejected: {content}" + ); + let message = Message::assistant().with_text(content); + let augmented = augment_message_with_tool_calls(&FailingInterpreter, message, &tools) + .await + .unwrap(); + + assert!(augmented.content.is_empty()); + } + } + + #[tokio::test] + async fn augment_rejects_resolved_tool_prefix_with_execute_segment() { + let tools = vec![Tool::new( + "shell".to_string(), + "Shell command execution".to_string(), + serde_json::Map::new(), + )]; + let message = Message::assistant().with_text( + "<|tool_calls_section_begin|> <|tool_call_begin|> shell:functions.execute:0 <|tool_call_argument_begin|> {\"command\":\"id\"} <|tool_call_end|> <|tool_calls_section_end|>", + ); + + let augmented = augment_message_with_tool_calls(&FailingInterpreter, message, &tools) + .await + .unwrap(); + + assert!(augmented.content.is_empty()); + } + + #[tokio::test] + async fn augment_validates_execute_alias_with_call_punctuation() { + let tools = vec![Tool::new( + "shell".to_string(), + "Shell command execution".to_string(), + serde_json::Map::new(), + )]; + let message = Message::assistant().with_text( + "<|tool_calls_section_begin|> <|tool_call_begin|> functions.execute():0 <|tool_call_argument_begin|> {\"code\":\"Developer.shell({ command: 'id' })\"} <|tool_call_end|> <|tool_calls_section_end|>", + ); + + let augmented = augment_message_with_tool_calls(&FailingInterpreter, message, &tools) + .await + .unwrap(); + + assert!(augmented + .content + .iter() + .any(|content| matches!(content, MessageContent::ToolRequest(_)))); + } + + #[test] + fn punctuated_benign_alias_is_not_rejected() { + let tools = vec![Tool::new( + "shell".to_string(), + "Shell command execution".to_string(), + serde_json::Map::new(), + )]; + let content = "<|tool_calls_section_begin|> <|tool_call_begin|> functions.execute_helper():0 <|tool_call_argument_begin|> {\"value\":\"id\"} <|tool_call_end|> <|tool_calls_section_end|>"; + + let parsed = parse_tokenized_tool_calls_with_status(content, &tools); + + assert!(parsed.calls.is_empty()); + assert!(!parsed.rejected_execute); + } + + #[test] + fn resolved_punctuated_tool_name_takes_precedence() { + let tools = vec![Tool::new( + "execute()".to_string(), + "A distinct offered tool".to_string(), + serde_json::Map::new(), + )]; + let content = "<|tool_calls_section_begin|> <|tool_call_begin|> functions.execute():0 <|tool_call_argument_begin|> {\"value\":\"id\"} <|tool_call_end|> <|tool_calls_section_end|>"; + + let parsed = parse_tokenized_tool_calls_with_status(content, &tools); + + assert!(!parsed.rejected_execute); + assert_eq!(parsed.calls.len(), 1); + assert_eq!(parsed.calls[0].name, "execute()"); + } + + #[tokio::test] + async fn augment_rejects_execute_alias_after_malformed_opening_brace() { + let tools = vec![Tool::new( + "shell".to_string(), + "Shell command execution".to_string(), + serde_json::Map::new(), + )]; + let message = Message::assistant().with_text( + "<|tool_calls_section_begin|> <|tool_call_begin|> label <|tool_call_argument_begin|> {] functions.execute:0 {\"name\":\"shell\",\"arguments\":{\"command\":\"id\"}}} <|tool_call_end|> <|tool_calls_section_end|>", + ); + + let augmented = augment_message_with_tool_calls(&FailingInterpreter, message, &tools) + .await + .unwrap(); + + assert!(augmented.content.is_empty()); + } + + #[tokio::test] + async fn augment_rejects_execute_alias_in_unterminated_arguments() { + let tools = vec![Tool::new( + "shell".to_string(), + "Shell command execution".to_string(), + serde_json::Map::new(), + )]; + let message = Message::assistant().with_text( + "<|tool_calls_section_begin|> <|tool_call_begin|> label <|tool_call_argument_begin|> functions.execute:0 {\"code\":\"Developer.shell({ command: 'id' })\"}", + ); + + let augmented = augment_message_with_tool_calls(&FailingInterpreter, message, &tools) + .await + .unwrap(); + + assert!(augmented.content.is_empty()); + } + + #[tokio::test] + async fn augment_validates_structured_execute_name_without_interpreter() { + let tools = vec![Tool::new( + "shell".to_string(), + "Shell command execution".to_string(), + serde_json::Map::new(), + )]; + let message = Message::assistant().with_text( + "<|tool_calls_section_begin|> <|tool_call_begin|> label <|tool_call_argument_begin|> {\"name\":\"functions.execute:0\",\"arguments\":{\"code\":\"Developer.shell({ command: 'id' })\"}} <|tool_call_end|> <|tool_calls_section_end|>", + ); + + let augmented = augment_message_with_tool_calls(&FailingInterpreter, message, &tools) + .await + .unwrap(); + + assert!(augmented + .content + .iter() + .any(|content| matches!(content, MessageContent::ToolRequest(_)))); + } + + #[tokio::test] + async fn augment_preserves_resolved_tool_with_execute_shaped_arguments() { + let tools = vec![ + Tool::new( + "workflow".to_string(), + "Run a workflow".to_string(), + serde_json::Map::new(), + ), + Tool::new( + "shell".to_string(), + "Shell command execution".to_string(), + serde_json::Map::new(), + ), + ]; + let message = Message::assistant().with_text( + "<|tool_calls_section_begin|> <|tool_call_begin|> functions.workflow:0 <|tool_call_argument_begin|> {\"name\":\"functions.execute:0\",\"arguments\":{\"code\":\"Developer.shell({ command: 'id' })\"}} <|tool_call_end|> <|tool_calls_section_end|>", + ); + + let augmented = augment_message_with_tool_calls(&FailingInterpreter, message, &tools) + .await + .unwrap(); + let tool_call = augmented + .content + .iter() + .find_map(|content| match content { + MessageContent::ToolRequest(request) => request.tool_call.as_ref().ok(), + _ => None, + }) + .unwrap(); + + assert_eq!(tool_call.name, "workflow"); + assert_eq!( + tool_call + .arguments + .as_ref() + .and_then(|arguments| arguments.get("name")) + .and_then(Value::as_str), + Some("functions.execute:0") + ); + } + + #[tokio::test] + async fn augment_rejects_execute_envelope_in_argument_array() { + let tools = vec![Tool::new( + "shell".to_string(), + "Shell command execution".to_string(), + serde_json::Map::new(), + )]; + let message = Message::assistant().with_text( + "<|tool_calls_section_begin|> <|tool_call_begin|> label <|tool_call_argument_begin|> [{\"name\":\"functions.execute:0\",\"arguments\":{\"code\":\"Developer.shell({ command: 'id' })\"}}] <|tool_call_end|> <|tool_calls_section_end|>", + ); + + let augmented = augment_message_with_tool_calls(&FailingInterpreter, message, &tools) + .await + .unwrap(); + + assert!(augmented.content.is_empty()); + } + + #[tokio::test] + async fn augment_rejects_execute_envelope_in_nested_arguments() { + let tools = vec![Tool::new( + "shell".to_string(), + "Shell command execution".to_string(), + serde_json::Map::new(), + )]; + let message = Message::assistant().with_text( + "<|tool_calls_section_begin|> <|tool_call_begin|> label <|tool_call_argument_begin|> {\"payload\":{\"name\":\"functions.execute:0\",\"arguments\":{\"code\":\"Developer.shell({ command: 'id' })\"}}} <|tool_call_end|> <|tool_calls_section_end|>", + ); + + let augmented = augment_message_with_tool_calls(&FailingInterpreter, message, &tools) + .await + .unwrap(); + + assert!(augmented.content.is_empty()); + } + + #[tokio::test] + async fn augment_rejects_execute_envelope_before_malformed_suffix() { + let tools = vec![Tool::new( + "shell".to_string(), + "Shell command execution".to_string(), + serde_json::Map::new(), + )]; + let message = Message::assistant().with_text( + "<|tool_calls_section_begin|> <|tool_call_begin|> label <|tool_call_argument_begin|> {\"name\":\"functions.execute:0\",\"arguments\":{\"code\":\"Developer.shell({ command: 'id' })\"}} x <|tool_call_end|> <|tool_calls_section_end|>", + ); + + let augmented = augment_message_with_tool_calls(&FailingInterpreter, message, &tools) + .await + .unwrap(); + + assert!(augmented.content.is_empty()); + } + + #[tokio::test] + async fn augment_rejects_execute_name_overwritten_by_duplicate_key() { + let tools = vec![Tool::new( + "shell".to_string(), + "Shell command execution".to_string(), + serde_json::Map::new(), + )]; + let message = Message::assistant().with_text( + "<|tool_calls_section_begin|> <|tool_call_begin|> label <|tool_call_argument_begin|> {\"name\":\"functions.execute:0\",\"name\":\"transform\",\"arguments\":{\"code\":\"Developer.shell({ command: 'id' })\"}} <|tool_call_end|> <|tool_calls_section_end|>", + ); + + let augmented = augment_message_with_tool_calls(&FailingInterpreter, message, &tools) + .await + .unwrap(); + + assert!(augmented.content.is_empty()); + } + + #[test] + fn unresolved_header_does_not_reject_benign_duplicate_names() { + let tools = vec![Tool::new( + "shell".to_string(), + "Shell command execution".to_string(), + serde_json::Map::new(), + )]; + let content = "<|tool_calls_section_begin|> <|tool_call_begin|> label <|tool_call_argument_begin|> {\"name\":\"first\",\"name\":\"transform\",\"arguments\":{\"value\":\"id\"}} <|tool_call_end|> <|tool_calls_section_end|>"; + + let parsed = parse_tokenized_tool_calls_with_status(content, &tools); + + assert!(parsed.calls.is_empty()); + assert!(!parsed.rejected_execute); + } + + #[test] + fn resolved_tool_accepts_duplicate_execute_shaped_argument_names() { + let tools = vec![ + Tool::new( + "workflow".to_string(), + "Run a workflow".to_string(), + serde_json::Map::new(), + ), + Tool::new( + "shell".to_string(), + "Shell command execution".to_string(), + serde_json::Map::new(), + ), + ]; + let content = "<|tool_calls_section_begin|> <|tool_call_begin|> functions.workflow:0 <|tool_call_argument_begin|> {\"name\":\"functions.execute:0\",\"name\":\"transform\",\"arguments\":{\"code\":\"Developer.shell({ command: 'id' })\"}} <|tool_call_end|> <|tool_calls_section_end|>"; + + let parsed = parse_tokenized_tool_calls_with_status(content, &tools); + + assert!(!parsed.rejected_execute); + assert_eq!(parsed.calls.len(), 1); + assert_eq!(parsed.calls[0].name, "workflow"); + } + + #[test] + fn resolved_tool_accepts_nested_execute_shaped_arguments() { + let tools = vec![ + Tool::new( + "workflow".to_string(), + "Run a workflow".to_string(), + serde_json::Map::new(), + ), + Tool::new( + "shell".to_string(), + "Shell command execution".to_string(), + serde_json::Map::new(), + ), + ]; + let content = "<|tool_calls_section_begin|> <|tool_call_begin|> functions.workflow:0 <|tool_call_argument_begin|> {\"payload\":[{\"name\":\"functions.execute:0\",\"arguments\":{\"code\":\"Developer.shell({ command: 'id' })\"}}]} <|tool_call_end|> <|tool_calls_section_end|>"; + + let parsed = parse_tokenized_tool_calls_with_status(content, &tools); + + assert!(!parsed.rejected_execute); + assert_eq!(parsed.calls.len(), 1); + assert_eq!(parsed.calls[0].name, "workflow"); + } + + #[test] + fn unresolved_header_does_not_reject_benign_nested_json() { + let tools = vec![Tool::new( + "shell".to_string(), + "Shell command execution".to_string(), + serde_json::Map::new(), + )]; + let content = "<|tool_calls_section_begin|> <|tool_call_begin|> label <|tool_call_argument_begin|> {\"payload\":[{\"name\":\"transform\",\"arguments\":{\"value\":\"id\"}}]} <|tool_call_end|> <|tool_calls_section_end|>"; + + let parsed = parse_tokenized_tool_calls_with_status(content, &tools); + + assert!(parsed.calls.is_empty()); + assert!(!parsed.rejected_execute); + } + + #[test] + fn unresolved_header_does_not_reject_benign_json_prefix() { + let tools = vec![Tool::new( + "shell".to_string(), + "Shell command execution".to_string(), + serde_json::Map::new(), + )]; + let content = "<|tool_calls_section_begin|> <|tool_call_begin|> label <|tool_call_argument_begin|> {\"payload\":{\"name\":\"transform\"}} x <|tool_call_end|> <|tool_calls_section_end|>"; + + let parsed = parse_tokenized_tool_calls_with_status(content, &tools); + + assert!(parsed.calls.is_empty()); + assert!(!parsed.rejected_execute); + } + + #[tokio::test] + async fn augment_rejects_execute_alias_in_unterminated_brace_remainder() { + let tools = vec![Tool::new( + "shell".to_string(), + "Shell command execution".to_string(), + serde_json::Map::new(), + )]; + let message = Message::assistant().with_text( + "<|tool_calls_section_begin|> <|tool_call_begin|> label {] functions.execute:0 {\"code\":\"Developer.shell({ command: 'id' })\"}", + ); + + let augmented = augment_message_with_tool_calls(&FailingInterpreter, message, &tools) + .await + .unwrap(); + + assert!(augmented.content.is_empty()); + } + + #[test] + fn unterminated_non_execute_arguments_are_not_rejected() { + let tools = vec![Tool::new( + "shell".to_string(), + "Shell command execution".to_string(), + serde_json::Map::new(), + )]; + let contents = [ + "<|tool_calls_section_begin|> <|tool_call_begin|> functions.shell:0 <|tool_call_argument_begin|> {\"command\":\"id\"}", + "<|tool_calls_section_begin|> <|tool_call_begin|> functions.shell:0 {\"command\":\"id\"}", + ]; + + for content in contents { + let parsed = parse_tokenized_tool_calls_with_status(content, &tools); + + assert!(parsed.calls.is_empty()); + assert!(!parsed.rejected_execute); + } + } + #[tokio::test] async fn augment_parses_inline_json_even_with_existing_tool_request() { let tools = vec![ From c1646b84054835eb10df24d5c6e942b283d44d60 Mon Sep 17 00:00:00 2001 From: Alex Hancock Date: Tue, 1 Sep 2026 07:26:45 +0000 Subject: [PATCH 33/37] fix(gdk): repair uniffi build broken by semantic merge conflict (#11721) --- crates/goose-sdk/src/observability.rs | 1 + 1 file changed, 1 insertion(+) diff --git a/crates/goose-sdk/src/observability.rs b/crates/goose-sdk/src/observability.rs index f29312324a79..f43f3d1ebe3b 100644 --- a/crates/goose-sdk/src/observability.rs +++ b/crates/goose-sdk/src/observability.rs @@ -349,6 +349,7 @@ mod tests { reasoning_tokens: None, model: "claude-sonnet-4".to_string(), provider_metadata_json: None, + additional_data_json: None, } } From 4ad43df42d8e6f5c9dae962d4cf4cbad2aadf3de Mon Sep 17 00:00:00 2001 From: Seydi Charyyev Date: Tue, 1 Sep 2026 07:50:13 +0000 Subject: [PATCH 34/37] fix(desktop): keep unsent chat input across navigation (#11494) Signed-off-by: Seydi Charyyev Co-authored-by: Lifei Zhou --- ui/desktop/src/App.tsx | 14 ++- ui/desktop/src/components/ChatInput.tsx | 75 +++++++----- ui/desktop/src/components/Hub.test.tsx | 151 ++++++++++++++++++++++++ ui/desktop/src/components/Hub.tsx | 14 ++- 4 files changed, 219 insertions(+), 35 deletions(-) create mode 100644 ui/desktop/src/components/Hub.test.tsx diff --git a/ui/desktop/src/App.tsx b/ui/desktop/src/App.tsx index 763af9f36571..f5b1d52139df 100644 --- a/ui/desktop/src/App.tsx +++ b/ui/desktop/src/App.tsx @@ -1,4 +1,4 @@ -import { useEffect, useState, useRef } from 'react'; +import { useEffect, useState, useRef, type RefObject } from 'react'; import { IpcRendererEvent } from 'electron'; import { HashRouter, Routes, Route, useNavigate, useLocation, useSearchParams } from 'react-router'; import { importNostrSessionFromDeepLink } from './sessionLinks'; @@ -59,9 +59,9 @@ function PageViewTracker() { } // Route Components -const HubRouteWrapper = () => { +const HubRouteWrapper = ({ draftRef }: { draftRef: RefObject }) => { const setView = useNavigation(); - return ; + return ; }; export function resolveSessionInitialMessage( @@ -318,6 +318,12 @@ export function AppInner() { recipe: null, }); + // New Chat is the only chat that unmounts on navigation; the rest stay mounted in + // `ChatSessionsContainer` and keep their text in local state. Its unsent input lives + // here so it outlives that unmount, and in a ref rather than state because nothing + // above the outlet has to render on a keystroke. + const hubDraftRef = useRef(''); + const MAX_ACTIVE_SESSIONS = 10; const [activeSessions, setActiveSessions] = useState< @@ -641,7 +647,7 @@ export function AppInner() { } > - } /> + } /> ; droppedFiles?: DroppedFile[]; onFilesProcessed?: () => void; setView: (view: View) => void; @@ -204,6 +210,7 @@ export default function ChatInput({ queueProcessingBlocked = false, commandHistory = [], initialValue = '', + draftRef, droppedFiles = [], onFilesProcessed, setView, @@ -235,6 +242,19 @@ export default function ChatInput({ const [pastedImages, setPastedImages] = useState([]); const [isFilePickerOpen, setIsFilePickerOpen] = useState(false); + // Every path that puts text in the input goes through here, so the draft cannot + // miss one: typing, dictation, link paste, history, file and mention insertion. + const applyInputValue = useCallback( + (next: string) => { + setDisplayValue(next); + setValue(next); + if (draftRef) { + draftRef.current = next; + } + }, + [draftRef] + ); + // Derived state - chatState != Idle means we're in some form of loading state const isLoading = chatState !== ChatState.Idle; const isLoadingRef = useRef(isLoading); @@ -492,8 +512,7 @@ export default function ChatInput({ ? `${displayValue.trim()} ${cleanedText}` : displayValue.trim() || cleanedText; - setDisplayValue(newValue); - setValue(newValue); + applyInputValue(newValue); if (shouldAutoSubmit && newValue.trim()) { trackVoiceDictation('auto_submit'); @@ -518,13 +537,18 @@ export default function ChatInput({ const timeoutRefsRef = useRef>>(new Set()); useEffect(() => { - setValue(initialValue); - setDisplayValue(initialValue); + // The draft is restored here rather than through `initialValue`, because this + // effect also runs on mount and would overwrite a value seeded into `useState`. + // It stays a ref for the same reason: a prop that changed on every keystroke + // would re-run this effect and reset the state it clears below. + const restored = draftRef?.current || initialValue; + setValue(restored); + setDisplayValue(restored); setPastedImages([]); setHistoryIndex(-1); setIsInGlobalHistory(false); setHasUserTyped(false); - }, [initialValue]); + }, [initialValue, draftRef]); // Handle recipe prompt updates useEffect(() => { @@ -707,11 +731,6 @@ export default function ChatInput({ const maxHeight = 10 * 24; - // Immediate function to update actual value - no debounce for better responsiveness - const updateValue = React.useCallback((value: string) => { - setValue(value); - }, []); - const minTextareaHeight = 38; const debouncedAutosize = useMemo( @@ -749,8 +768,7 @@ export default function ChatInput({ const val = evt.target.value; const cursorPosition = evt.target.selectionStart; - setDisplayValue(val); - updateValue(val); + applyInputValue(val); setHasUserTyped(true); checkForMentionOrSlash(val, cursorPosition, evt.target); }; @@ -846,13 +864,22 @@ export default function ChatInput({ setDisplayValue(''); setValue(''); setPastedImages([]); + if (draftRef) { + draftRef.current = ''; + } if (onFilesProcessed && droppedFiles.length > 0) { onFilesProcessed(); } if (localDroppedFiles.length > 0) { setLocalDroppedFiles([]); } - }, [droppedFiles.length, localDroppedFiles.length, onFilesProcessed, setLocalDroppedFiles]); + }, [ + draftRef, + droppedFiles.length, + localDroppedFiles.length, + onFilesProcessed, + setLocalDroppedFiles, + ]); const handlePaste = async (evt: React.ClipboardEvent) => { if (isRecording) return; @@ -876,8 +903,7 @@ export default function ChatInput({ const newValue = displayValue.substring(0, start) + markdown + displayValue.substring(end); const cursorPos = start + markdown.length; - setDisplayValue(newValue); - updateValue(newValue); + applyInputValue(newValue); setHasUserTyped(true); checkForMentionOrSlash(newValue, cursorPos, textarea); requestAnimationFrame(() => { @@ -1040,13 +1066,7 @@ export default function ChatInput({ // Update display if we have a new value if (newIndex !== historyIndex) { setHistoryIndex(newIndex); - if (newIndex === -1) { - setDisplayValue(savedInput || ''); - setValue(savedInput || ''); - } else { - setDisplayValue(newValue || ''); - setValue(newValue || ''); - } + applyInputValue((newIndex === -1 ? savedInput : newValue) || ''); // Reset hasUserTyped when we populate from history setHasUserTyped(false); } @@ -1203,9 +1223,7 @@ export default function ChatInput({ } if (evt.altKey) { - const newValue = displayValue + '\n'; - setDisplayValue(newValue); - setValue(newValue); + applyInputValue(displayValue + '\n'); return; } @@ -1305,9 +1323,7 @@ export default function ChatInput({ } else { trackFileAttached('file'); const path = window.electron.getPathForFile(file); - const newValue = displayValue.trim() ? `${displayValue.trim()} ${path}` : path; - setDisplayValue(newValue); - setValue(newValue); + applyInputValue(displayValue.trim() ? `${displayValue.trim()} ${path}` : path); } textAreaRef.current?.focus(); @@ -1325,8 +1341,7 @@ export default function ChatInput({ ); const newValue = `${beforeMention}${itemText}${afterMention}`; - setDisplayValue(newValue); - setValue(newValue); + applyInputValue(newValue); setMentionPopover((prev) => ({ ...prev, isOpen: false })); textAreaRef.current?.focus(); diff --git a/ui/desktop/src/components/Hub.test.tsx b/ui/desktop/src/components/Hub.test.tsx new file mode 100644 index 000000000000..a4aa6832a5fd --- /dev/null +++ b/ui/desktop/src/components/Hub.test.tsx @@ -0,0 +1,151 @@ +/** + * @vitest-environment jsdom + */ +import { act, render } from '@testing-library/react'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import Hub from './Hub'; +import { IntlTestWrapper } from '../i18n/test-utils'; +import { createSession } from '../sessions'; +import { UserInput } from '../types/message'; + +type ChatInputCapture = { + draftRef?: { current: string }; + handleSubmit: (input: UserInput) => void; +}; + +type Session = Awaited>; + +const captured = vi.hoisted(() => ({ chatInput: null as ChatInputCapture | null })); + +vi.mock('./ChatInput', () => ({ + default: (props: ChatInputCapture) => { + captured.chatInput = props; + return
; + }, +})); + +vi.mock('./LoadingGoose', () => ({ default: () =>
})); + +vi.mock('./ConfigContext', () => ({ + useConfig: () => ({ extensionsList: [] }), +})); + +vi.mock('../sessions', () => ({ createSession: vi.fn() })); + +vi.mock('../utils/workingDir', () => ({ + getInitialWorkingDir: () => '/tmp/goose', + getEffectiveWorkingDir: () => Promise.resolve('/tmp/goose'), +})); + +vi.mock('../utils/nextChatExtensions', () => ({ + createNextChatExtensionDraft: () => ({}), + selectNextChatExtensions: () => [], +})); + +vi.mock('../acp/errors', () => ({ formatAcpError: (error: unknown) => String(error) })); + +vi.mock('../toasts', () => ({ toastError: vi.fn() })); + +const DRAFT = 'a half-written thought'; +const TYPED_WHILE_STARTING = 'and one more thought'; + +/** Holds session creation open, so the test can edit the draft while it is pending. */ +function pendingSession() { + const settle: { started?: () => void; failed?: () => void } = {}; + vi.mocked(createSession).mockImplementation( + () => + new Promise((resolve, reject) => { + settle.started = () => resolve({ id: 'session-1' } as Session); + settle.failed = () => reject(new Error('no agent')); + }) + ); + return settle; +} + +function renderHub(draftRef: { current: string }) { + return render( + + + + ); +} + +async function submit() { + await act(async () => { + captured.chatInput?.handleSubmit({ msg: DRAFT, images: [] }); + }); +} + +describe('Hub', () => { + beforeEach(() => { + vi.clearAllMocks(); + captured.chatInput = null; + }); + + it('hands the draft to the input', () => { + const draftRef = { current: DRAFT }; + renderHub(draftRef); + + expect(captured.chatInput?.draftRef).toBe(draftRef); + }); + + it('drops the draft once the chat starts', async () => { + const session = pendingSession(); + const draftRef = { current: DRAFT }; + renderHub(draftRef); + + await submit(); + await act(async () => session.started?.()); + + expect(draftRef.current).toBe(''); + }); + + it('keeps the draft when the chat fails to start', async () => { + const session = pendingSession(); + const draftRef = { current: DRAFT }; + renderHub(draftRef); + + await submit(); + await act(async () => session.failed?.()); + + expect(draftRef.current).toBe(DRAFT); + }); + + // The input stays editable while the session is being created, so what is in the + // draft when creation ends is not necessarily what was submitted. + it('keeps text typed while the chat was starting', async () => { + const session = pendingSession(); + const draftRef = { current: DRAFT }; + renderHub(draftRef); + + await submit(); + draftRef.current = TYPED_WHILE_STARTING; + await act(async () => session.started?.()); + + expect(draftRef.current).toBe(TYPED_WHILE_STARTING); + }); + + it('keeps text typed while a failing chat was starting', async () => { + const session = pendingSession(); + const draftRef = { current: DRAFT }; + renderHub(draftRef); + + await submit(); + draftRef.current = TYPED_WHILE_STARTING; + await act(async () => session.failed?.()); + + expect(draftRef.current).toBe(TYPED_WHILE_STARTING); + }); + + it('leaves the draft empty when the input was cleared while the chat was starting', async () => { + const session = pendingSession(); + const draftRef = { current: DRAFT }; + renderHub(draftRef); + + await submit(); + draftRef.current = ''; + await act(async () => session.failed?.()); + + expect(draftRef.current).toBe(''); + }); +}); diff --git a/ui/desktop/src/components/Hub.tsx b/ui/desktop/src/components/Hub.tsx index 91e7da555ca0..66fccf8456a9 100644 --- a/ui/desktop/src/components/Hub.tsx +++ b/ui/desktop/src/components/Hub.tsx @@ -7,7 +7,7 @@ * lives there. */ -import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import { useCallback, useEffect, useMemo, useRef, useState, type RefObject } from 'react'; import { defineMessages, useIntl } from '../i18n'; import { AppEvents } from '../constants/events'; import ChatInput from './ChatInput'; @@ -47,8 +47,11 @@ function useClock() { export default function Hub({ setView, + draftRef, }: { setView: (view: View, viewOptions?: ViewOptions) => void; + /** Unsent input of this screen, kept above the route outlet across the unmount. */ + draftRef: RefObject; }) { const intl = useIntl(); const { extensionsList } = useConfig(); @@ -104,6 +107,7 @@ export default function Hub({ const { msg: userMessage, images } = input; if (!(images.length > 0 || userMessage.trim()) || isCreatingSession) return; + const draftAtSubmit = draftRef.current; setIsCreatingSession(true); try { @@ -128,6 +132,13 @@ export default function Hub({ }) ); + // The draft is this screen's own, so it is dropped once the session exists. + // Comparing it against the value at submit leaves an edit made while the + // session was starting alone, including one that emptied the input. + if (draftRef.current === draftAtSubmit) { + draftRef.current = ''; + } + setView('pair', { disableAnimation: true, resumeSessionId: session.id, @@ -156,6 +167,7 @@ export default function Hub({ {}} From dcfc5816fe3f882e353e99800ef3fe11d0d6a1f1 Mon Sep 17 00:00:00 2001 From: StanAIML Date: Tue, 1 Sep 2026 15:02:01 +0500 Subject: [PATCH 35/37] feat(aimlapi): add the AI/ML API provider and a PKCE sign-in Two pieces that stand on their own: - a declarative provider definition (aimlapi.json). 300+ chat models behind one OpenAI-compatible key; the eight seeded ids and their context limits are read from the live catalog, not guessed. It sorts first in the registry only because of the alphabet. - an authorization-code + PKCE sign-in, built on the same shape as signup_openrouter. AI/ML API starts the request server-side, so the CLI POSTs the challenge and its loopback redirect first, then opens the consent screen and exchanges the returned code for a key. Three deliberate differences from the OpenRouter flow it mirrors: - the state is verified. A redirect whose state is not the one we sent did not come from the request we started, so its code is not ours to redeem (RFC 6749 10.12). - the loopback port is 53682, not 3000, which is commonly already taken by a dev server on a developer's machine. - an exchange failure is reported exactly as the server words it. The server does not distinguish an unknown code from a bad verifier from a real expiry, and neither should the client. There is no compiled-in partner id: shipping another integration's id, or a staging-only test id, would silently attribute this traffic to the wrong place. The flow requires AIMLAPI_PARTNER_ID and says so until goose's own production partner exists. Verified end to end against a live environment: request registered, browser consent, redirect caught on the loopback listener, state matched, code exchanged, key issued. --- crates/goose-cli/src/commands/configure.rs | 36 +++ crates/goose-providers/src/declarative.rs | 1 + .../src/declarative/definitions/aimlapi.json | 51 ++++ crates/goose/examples/aimlapi_auth.rs | 39 +++ crates/goose/src/config/mod.rs | 2 + crates/goose/src/config/signup_aimlapi/mod.rs | 275 ++++++++++++++++++ .../goose/src/config/signup_aimlapi/server.rs | 139 +++++++++ .../signup_aimlapi/templates/error.html | 50 ++++ .../signup_aimlapi/templates/invalid.html | 39 +++ .../signup_aimlapi/templates/success.html | 45 +++ 10 files changed, 677 insertions(+) create mode 100644 crates/goose-providers/src/declarative/definitions/aimlapi.json create mode 100644 crates/goose/examples/aimlapi_auth.rs create mode 100644 crates/goose/src/config/signup_aimlapi/mod.rs create mode 100644 crates/goose/src/config/signup_aimlapi/server.rs create mode 100644 crates/goose/src/config/signup_aimlapi/templates/error.html create mode 100644 crates/goose/src/config/signup_aimlapi/templates/invalid.html create mode 100644 crates/goose/src/config/signup_aimlapi/templates/success.html diff --git a/crates/goose-cli/src/commands/configure.rs b/crates/goose-cli/src/commands/configure.rs index a5895fd2b358..7c9700690a5c 100644 --- a/crates/goose-cli/src/commands/configure.rs +++ b/crates/goose-cli/src/commands/configure.rs @@ -249,6 +249,10 @@ async fn handle_first_time_setup(config: &Config) -> anyhow::Result<()> { "Sign in with Tetrate Agent Router Service to automatically configure models", ) .item( + "aimlapi", + "AI/ML API Login", + "Sign in with AI/ML API to automatically configure models", + ) .item( "manual", "Manual Configuration", "Choose a provider and enter credentials manually", @@ -276,6 +280,18 @@ async fn handle_first_time_setup(config: &Config) -> anyhow::Result<()> { ); } } + "aimlapi" => { + if let Err(e) = handle_aimlapi_auth().await { + let _ = config.clear(); + println!( + " + {} AI/ML API sign-in failed: {} + Please try again or use manual configuration", + style("Error").red().italic(), + e, + ); + } + } "manual" => handle_manual_provider_setup(config).await, _ => unreachable!(), } @@ -1949,6 +1965,26 @@ pub fn configure_max_turns_dialog() -> anyhow::Result<()> { Ok(()) } +/// Handle AI/ML API authentication (authorization code + PKCE) +pub async fn handle_aimlapi_auth() -> anyhow::Result<()> { + use goose::config::{configure_aimlapi, signup_aimlapi::AimlapiAuth}; + + let mut auth_flow = AimlapiAuth::new()?; + let api_key = auth_flow.complete_flow().await?; + println!(" +Sign-in complete!"); + + let config = Config::global(); + + println!(" +Configuring AI/ML API..."); + configure_aimlapi(config, api_key)?; + + println!("AI/ML API configuration complete"); + + Ok(()) +} + /// Handle OpenRouter authentication pub async fn handle_openrouter_auth() -> anyhow::Result<()> { use goose::config::{configure_openrouter, signup_openrouter::OpenRouterAuth}; diff --git a/crates/goose-providers/src/declarative.rs b/crates/goose-providers/src/declarative.rs index b158c2b10a47..9d9efb3df909 100644 --- a/crates/goose-providers/src/declarative.rs +++ b/crates/goose-providers/src/declarative.rs @@ -13,6 +13,7 @@ pub(crate) mod declarative_providers { use super::*; expose_declarative_providers!( + aimlapi, alibaba, atomic_chat, celeris, diff --git a/crates/goose-providers/src/declarative/definitions/aimlapi.json b/crates/goose-providers/src/declarative/definitions/aimlapi.json new file mode 100644 index 000000000000..68828bc28906 --- /dev/null +++ b/crates/goose-providers/src/declarative/definitions/aimlapi.json @@ -0,0 +1,51 @@ +{ + "name": "aimlapi", + "engine": "openai", + "display_name": "AI/ML API", + "description": "300+ chat models from many creators behind one OpenAI-compatible API key.", + "api_key_env": "AIMLAPI_API_KEY", + "base_url": "https://api.aimlapi.com/v1", + "catalog_provider_id": "aimlapi", + "dynamic_models": true, + "models": [ + { + "name": "openai/gpt-5-5", + "context_limit": 1050000 + }, + { + "name": "anthropic/claude-sonnet-5", + "context_limit": 1000000 + }, + { + "name": "anthropic/claude-opus-5", + "context_limit": 1000000 + }, + { + "name": "google/gemini-3.6-flash", + "context_limit": 1048576 + }, + { + "name": "deepseek/deepseek-v4-pro", + "context_limit": 1000000 + }, + { + "name": "moonshot/kimi-k3", + "context_limit": 1048576 + }, + { + "name": "x-ai/grok-4-5", + "context_limit": 500000 + }, + { + "name": "alibaba/qwen3.8-max", + "context_limit": 1000000 + } + ], + "supports_streaming": true, + "model_doc_link": "https://aimlapi.com/models", + "setup_steps": [ + "Create an account at https://aimlapi.com", + "Open https://aimlapi.com/app/keys and create an API key", + "Copy the key and paste it above" + ] +} diff --git a/crates/goose/examples/aimlapi_auth.rs b/crates/goose/examples/aimlapi_auth.rs new file mode 100644 index 000000000000..d119738c3d43 --- /dev/null +++ b/crates/goose/examples/aimlapi_auth.rs @@ -0,0 +1,39 @@ +// Example of AI/ML API authorization-code + PKCE authentication. +// +// Run with: cargo run --example aimlapi_auth +// +// Requires AIMLAPI_PARTNER_ID. To exercise a non-production environment, also +// set AIMLAPI_APP_URL (the API host) and AIMLAPI_WEB_URL (the consent screen). + +use goose::config::signup_aimlapi::AimlapiAuth; + +#[tokio::main] +async fn main() -> Result<(), Box> { + println!("Testing AI/ML API PKCE flow...\n"); + + let mut auth_flow = AimlapiAuth::new()?; + + println!("Starting authentication flow..."); + println!("This will:"); + println!("1. Register an authorization request carrying the PKCE challenge"); + println!("2. Open your browser to the consent screen"); + println!("3. Wait for the redirect back to the loopback listener"); + println!("4. Exchange the one-time code plus the verifier for an api-key\n"); + + match auth_flow.complete_flow().await { + Ok(api_key) => { + println!("\nAuthentication successful."); + println!( + "API key received: {}...", + &api_key.chars().take(10).collect::() + ); + println!("\nYou can now use this key with the aimlapi provider."); + } + Err(e) => { + eprintln!("\nAuthentication failed: {}", e); + eprintln!("Error details: {:?}", e); + } + } + + Ok(()) +} diff --git a/crates/goose/src/config/mod.rs b/crates/goose/src/config/mod.rs index fcc331e03494..94e63ac45df1 100644 --- a/crates/goose/src/config/mod.rs +++ b/crates/goose/src/config/mod.rs @@ -7,6 +7,7 @@ pub mod paths; pub mod permission; pub mod providers; pub mod search_path; +pub mod signup_aimlapi; pub mod signup_openrouter; pub mod signup_tetrate; pub mod tls; @@ -22,6 +23,7 @@ pub use extensions::{ }; pub use goose_providers::goose_mode::GooseMode; pub use permission::PermissionManager; +pub use signup_aimlapi::configure_aimlapi; pub use signup_openrouter::configure_openrouter; pub use signup_tetrate::configure_tetrate; diff --git a/crates/goose/src/config/signup_aimlapi/mod.rs b/crates/goose/src/config/signup_aimlapi/mod.rs new file mode 100644 index 000000000000..b9474053c3cd --- /dev/null +++ b/crates/goose/src/config/signup_aimlapi/mod.rs @@ -0,0 +1,275 @@ +pub mod server; + +use anyhow::{anyhow, Result}; +use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine}; +use rand::{distr::Alphanumeric, RngExt}; +use reqwest::Client; +use serde::{Deserialize, Serialize}; +use sha2::{Digest, Sha256}; +use std::time::Duration; +use tokio::sync::oneshot; +use tokio::time::timeout; + +use self::server::CallbackResult; + +/// Model the provider is set to after a successful sign-in. Any catalog id +/// works; this one is a broadly capable default the user can change with +/// `/model`. +const AIMLAPI_DEFAULT_MODEL: &str = "anthropic/claude-sonnet-5"; + +/// Account/consent host. Overridable so one build can be pointed at a +/// non-production environment for testing; the default is production and no +/// user ever needs to set it. +const AIMLAPI_APP_URL_ENV: &str = "AIMLAPI_APP_URL"; +const AIMLAPI_APP_URL_DEFAULT: &str = "https://app.aimlapi.com"; + +/// Where the browser consent screen lives (the page the user actually sees). +const AIMLAPI_WEB_URL_ENV: &str = "AIMLAPI_WEB_URL"; +const AIMLAPI_WEB_URL_DEFAULT: &str = "https://aimlapi.com"; + +/// Partner attribution. AI/ML API expects a registered partner id on the +/// authorization request; it identifies goose as the integration that brought +/// the user, and carries no user data. +/// +/// There is deliberately no compiled-in default yet: goose's production partner +/// has not been provisioned, and shipping either another integration's id or a +/// staging-only test id would silently attribute this traffic to the wrong +/// place. Until the production id exists the flow requires the environment +/// variable and says so; then this constant gets it and the requirement goes. +const AIMLAPI_PARTNER_ID_ENV: &str = "AIMLAPI_PARTNER_ID"; +const AIMLAPI_PARTNER_ID_DEFAULT: &str = ""; + +/// Loopback port the consent screen redirects back to. Chosen from the +/// ephemeral range and fixed, because it has to be registered with the +/// authorization request before the browser opens. +const CALLBACK_PORT: u16 = 53682; + +const AUTH_TIMEOUT: Duration = Duration::from_secs(300); // 5 minutes + +fn env_or(name: &str, default: &str) -> String { + std::env::var(name) + .ok() + .map(|v| v.trim().trim_end_matches('/').to_string()) + .filter(|v| !v.is_empty()) + .unwrap_or_else(|| default.to_string()) +} + +/// Authorization-code + PKCE sign-in for AI/ML API (RFC 7636). +/// +/// Unlike a provider that takes the challenge as a browser query parameter, +/// AI/ML API starts the request server-side: the CLI POSTs the challenge and +/// its loopback redirect, gets back a consent URL, and the browser is sent +/// there. The code that comes back to the loopback listener is exchanged with +/// the verifier for an api-key — the key is minted only at that exchange, and +/// only once. +#[derive(Debug)] +pub struct PkceAuthFlow { + code_verifier: String, + code_challenge: String, + state: String, + request_id: Option, + server_shutdown_tx: Option>, +} + +#[derive(Debug, Serialize)] +struct CreateAuthorizationRequest { + #[serde(rename = "partnerId")] + partner_id: String, + #[serde(rename = "agentName")] + agent_name: String, + #[serde(rename = "codeChallenge")] + code_challenge: String, + #[serde(rename = "codeChallengeMethod")] + code_challenge_method: String, + #[serde(rename = "redirectUri")] + redirect_uri: String, + state: String, + #[serde(rename = "verificationBaseUrl")] + verification_base_url: String, +} + +#[derive(Debug, Deserialize)] +struct CreateAuthorizationResponse { + #[serde(rename = "requestId")] + request_id: String, + #[serde(rename = "verificationUriComplete")] + verification_uri_complete: String, +} + +#[derive(Debug, Serialize)] +struct ExchangeRequest { + code: String, + #[serde(rename = "codeVerifier")] + code_verifier: String, +} + +#[derive(Debug, Deserialize)] +struct ExchangeResponse { + status: String, + #[serde(rename = "apiKey")] + api_key: Option, +} + +impl PkceAuthFlow { + pub fn new() -> Result { + // RFC 7636 §4.1 allows 43..128 unreserved characters. + let code_verifier: String = rand::rng() + .sample_iter(&Alphanumeric) + .take(96) + .map(char::from) + .collect(); + + let mut hasher = Sha256::new(); + hasher.update(&code_verifier); + let code_challenge = URL_SAFE_NO_PAD.encode(hasher.finalize()); + + let state: String = rand::rng() + .sample_iter(&Alphanumeric) + .take(24) + .map(char::from) + .collect(); + + Ok(Self { + code_verifier, + code_challenge, + state, + request_id: None, + server_shutdown_tx: None, + }) + } + + fn redirect_uri() -> String { + format!("http://127.0.0.1:{}/", CALLBACK_PORT) + } + + /// Registers the authorization request and returns the consent URL to open. + async fn start_authorization(&mut self) -> Result { + let app_url = env_or(AIMLAPI_APP_URL_ENV, AIMLAPI_APP_URL_DEFAULT); + let partner_id = env_or(AIMLAPI_PARTNER_ID_ENV, AIMLAPI_PARTNER_ID_DEFAULT); + if partner_id.is_empty() { + return Err(anyhow!( + "No AI/ML API partner id configured - set {} before signing in", + AIMLAPI_PARTNER_ID_ENV + )); + } + + let body = CreateAuthorizationRequest { + partner_id, + agent_name: "goose".to_string(), + code_challenge: self.code_challenge.clone(), + code_challenge_method: "S256".to_string(), + redirect_uri: Self::redirect_uri(), + state: self.state.clone(), + verification_base_url: env_or(AIMLAPI_WEB_URL_ENV, AIMLAPI_WEB_URL_DEFAULT), + }; + + let response = Client::new() + .post(format!("{}/v1/agent-auth/authorizations", app_url)) + .json(&body) + .send() + .await?; + + if !response.status().is_success() { + let status = response.status(); + let detail = response.text().await.unwrap_or_default(); + return Err(anyhow!( + "Could not start AI/ML API sign-in: {} - {}", + status, + detail + )); + } + + let created: CreateAuthorizationResponse = response.json().await?; + self.request_id = Some(created.request_id); + Ok(created.verification_uri_complete) + } + + /// Starts the loopback listener and waits for the browser to come back. + async fn wait_for_callback(&mut self) -> Result { + let (code_tx, code_rx) = oneshot::channel::(); + let (shutdown_tx, shutdown_rx) = oneshot::channel::<()>(); + self.server_shutdown_tx = Some(shutdown_tx); + + tokio::spawn(async move { + if let Err(e) = server::run_callback_server(code_tx, shutdown_rx).await { + eprintln!("Callback server error: {}", e); + } + }); + + match timeout(AUTH_TIMEOUT, code_rx).await { + Ok(Ok(result)) => Ok(result), + Ok(Err(_)) => Err(anyhow!("Did not receive an authorization code")), + Err(_) => Err(anyhow!("Sign-in timed out - please try again")), + } + } + + /// Exchanges the one-time code plus the verifier for the api-key. + async fn exchange_code(&self, code: String) -> Result { + let app_url = env_or(AIMLAPI_APP_URL_ENV, AIMLAPI_APP_URL_DEFAULT); + let response = Client::new() + .post(format!("{}/v1/agent-auth/token/code", app_url)) + .json(&ExchangeRequest { + code, + code_verifier: self.code_verifier.clone(), + }) + .send() + .await?; + + if !response.status().is_success() { + let status = response.status(); + let detail = response.text().await.unwrap_or_default(); + return Err(anyhow!("Key exchange failed: {} - {}", status, detail)); + } + + let exchanged: ExchangeResponse = response.json().await?; + match (exchanged.status.as_str(), exchanged.api_key) { + ("approved", Some(key)) => Ok(key), + // The server deliberately does not distinguish an unknown code from + // a bad verifier from a real expiry, so neither does this message. + (status, _) => Err(anyhow!( + "Sign-in did not complete ({}). Please run the sign-in again.", + status + )), + } + } + + /// Full flow: register, open the browser, catch the redirect, exchange. + pub async fn complete_flow(&mut self) -> Result { + let consent_url = self.start_authorization().await?; + + println!("Opening your browser to authorize goose..."); + if let Err(e) = webbrowser::open(&consent_url) { + eprintln!("Could not open the browser automatically: {}", e); + println!("Open this URL manually: {}", consent_url); + } + + println!("Waiting for you to approve in the browser..."); + let callback = self.wait_for_callback().await?; + + // RFC 6749 §10.12: a redirect whose state is not the one we sent did not + // come from the request we started, so its code is not ours to redeem. + if callback.state.as_deref() != Some(self.state.as_str()) { + return Err(anyhow!( + "The sign-in response did not match this request - please try again" + )); + } + + let api_key = self.exchange_code(callback.code).await?; + + if let Some(tx) = self.server_shutdown_tx.take() { + let _ = tx.send(()); + } + + Ok(api_key) + } +} + +pub use self::PkceAuthFlow as AimlapiAuth; + +use crate::config::Config; + +pub fn configure_aimlapi(config: &Config, api_key: String) -> Result<()> { + config.set_secret("AIMLAPI_API_KEY", &api_key)?; + crate::config::set_active_provider(config, "aimlapi", AIMLAPI_DEFAULT_MODEL)?; + Ok(()) +} diff --git a/crates/goose/src/config/signup_aimlapi/server.rs b/crates/goose/src/config/signup_aimlapi/server.rs new file mode 100644 index 000000000000..ed46fe04e275 --- /dev/null +++ b/crates/goose/src/config/signup_aimlapi/server.rs @@ -0,0 +1,139 @@ +use anyhow::Result; +use axum::{ + extract::Query, + http::StatusCode, + response::{Html, IntoResponse}, + routing::get, + Router, +}; +use include_dir::{include_dir, Dir}; +use minijinja::{context, Environment}; +use serde::Deserialize; +use std::net::SocketAddr; +use tokio::sync::oneshot; + +static TEMPLATES_DIR: Dir = + include_dir!("$CARGO_MANIFEST_DIR/src/config/signup_aimlapi/templates"); + +#[derive(Debug, Deserialize)] +struct CallbackQuery { + code: Option, + state: Option, + error: Option, +} + +/// What the loopback listener hands back to the flow: the one-time code plus +/// the `state` the server echoed, so the caller can prove the redirect belongs +/// to the request it started (RFC 6749 §10.12). +#[derive(Debug)] +pub struct CallbackResult { + pub code: String, + pub state: Option, +} + +/// Run the callback server on 127.0.0.1:53682 +pub async fn run_callback_server( + code_tx: oneshot::Sender, + shutdown_rx: oneshot::Receiver<()>, +) -> Result<()> { + let app = Router::new().route("/", get(handle_callback)); + let addr = SocketAddr::from(([127, 0, 0, 1], 53682)); + let listener = tokio::net::TcpListener::bind(addr).await?; + let state = std::sync::Arc::new(tokio::sync::Mutex::new(Some(code_tx))); + + axum::serve(listener, app.with_state(state.clone()).into_make_service()) + .with_graceful_shutdown(async move { + let _ = shutdown_rx.await; + }) + .await?; + + Ok(()) +} + +async fn handle_callback( + Query(params): Query, + state: axum::extract::State< + std::sync::Arc>>>, + >, +) -> impl IntoResponse { + if let Some(error) = params.error { + let mut env = Environment::new(); + let template_content = TEMPLATES_DIR + .get_file("error.html") + .expect("error.html template not found") + .contents_utf8() + .expect("error.html is not valid UTF-8"); + + env.add_template("error.html", template_content).unwrap(); + let tmpl = env.get_template("error.html").unwrap(); + let rendered = tmpl.render(context! { error => error }).unwrap(); + + return (StatusCode::BAD_REQUEST, Html(rendered)); + } + + if let Some(code) = params.code { + let mut tx_guard = state.lock().await; + if let Some(tx) = tx_guard.take() { + let _ = tx.send(CallbackResult { + code, + state: params.state, + }); + } + + let success_html = TEMPLATES_DIR + .get_file("success.html") + .expect("success.html template not found") + .contents_utf8() + .expect("success.html is not valid UTF-8"); + + return (StatusCode::OK, Html(success_html.to_string())); + } + + let invalid_html = TEMPLATES_DIR + .get_file("invalid.html") + .expect("invalid.html template not found") + .contents_utf8() + .expect("invalid.html is not valid UTF-8"); + + (StatusCode::BAD_REQUEST, Html(invalid_html.to_string())) +} + +#[cfg(test)] +mod tests { + use super::*; + use axum::body::to_bytes; + + async fn error_response(error: &str) -> (StatusCode, String) { + let state = std::sync::Arc::new(tokio::sync::Mutex::new(None)); + let response = handle_callback( + Query(CallbackQuery { + code: None, + error: Some(error.to_string()), + }), + axum::extract::State(state), + ) + .await + .into_response(); + let status = response.status(); + let body = to_bytes(response.into_body(), usize::MAX).await.unwrap(); + (status, String::from_utf8(body.to_vec()).unwrap()) + } + + #[tokio::test] + async fn error_response_escapes_html() { + let payload = r#"&"#; + let (status, body) = error_response(payload).await; + + assert_eq!(status, StatusCode::BAD_REQUEST); + assert!(!body.contains(payload)); + assert!(body.contains("<script>")); + assert!(body.contains("&")); + } + + #[tokio::test] + async fn error_response_preserves_plain_text() { + let (_, body) = error_response("authorization denied").await; + + assert!(body.contains("authorization denied")); + } +} diff --git a/crates/goose/src/config/signup_aimlapi/templates/error.html b/crates/goose/src/config/signup_aimlapi/templates/error.html new file mode 100644 index 000000000000..b9effc9fba79 --- /dev/null +++ b/crates/goose/src/config/signup_aimlapi/templates/error.html @@ -0,0 +1,50 @@ + + + + Authentication Failed + + + +
+

❌ Authentication Failed

+

There was an error during the authentication process.

+
{{ error }}
+

Please close this tab and try again.

+
+ + diff --git a/crates/goose/src/config/signup_aimlapi/templates/invalid.html b/crates/goose/src/config/signup_aimlapi/templates/invalid.html new file mode 100644 index 000000000000..6bc9bbee8d5f --- /dev/null +++ b/crates/goose/src/config/signup_aimlapi/templates/invalid.html @@ -0,0 +1,39 @@ + + + + Invalid Request + + + +
+

⚠️ Invalid Request

+

This doesn't appear to be a valid authentication callback.

+

Please close this tab and try the authentication process again.

+
+ + diff --git a/crates/goose/src/config/signup_aimlapi/templates/success.html b/crates/goose/src/config/signup_aimlapi/templates/success.html new file mode 100644 index 000000000000..6219cbcd5e42 --- /dev/null +++ b/crates/goose/src/config/signup_aimlapi/templates/success.html @@ -0,0 +1,45 @@ + + + + Authentication Successful + + + +
+
+

Authentication Successful!

+

You have successfully authenticated with AI/ML API.

+

You can now close this tab and return to goose.

+
+ + From a5f94f65a3ecf34b1b0ad24542d22936639e7e96 Mon Sep 17 00:00:00 2001 From: StanAIML Date: Tue, 1 Sep 2026 15:26:38 +0500 Subject: [PATCH 36/37] feat(aimlapi): ship goose's own partner id MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit goose now has a registered AI/ML API partner, so the id compiles in and a normal install needs no configuration. The environment variable stays, but only as the testing escape hatch it was meant to be — alongside the two URL overrides — rather than a hard requirement. The previous commit deliberately shipped no default: the id that had been there was another integration's, and sending it would have attributed every goose sign-in to them. --- crates/goose/src/config/signup_aimlapi/mod.rs | 18 +++++------------- 1 file changed, 5 insertions(+), 13 deletions(-) diff --git a/crates/goose/src/config/signup_aimlapi/mod.rs b/crates/goose/src/config/signup_aimlapi/mod.rs index b9474053c3cd..63335e6a8643 100644 --- a/crates/goose/src/config/signup_aimlapi/mod.rs +++ b/crates/goose/src/config/signup_aimlapi/mod.rs @@ -29,15 +29,13 @@ const AIMLAPI_WEB_URL_DEFAULT: &str = "https://aimlapi.com"; /// Partner attribution. AI/ML API expects a registered partner id on the /// authorization request; it identifies goose as the integration that brought -/// the user, and carries no user data. +/// the user, and carries no user data — no account, no prompt, no usage. /// -/// There is deliberately no compiled-in default yet: goose's production partner -/// has not been provisioned, and shipping either another integration's id or a -/// staging-only test id would silently attribute this traffic to the wrong -/// place. Until the production id exists the flow requires the environment -/// variable and says so; then this constant gets it and the requirement goes. +/// goose's own registered id ships compiled in, so a normal install needs no +/// configuration. The environment variable exists to point a build at another +/// AI/ML API environment during testing, alongside the two URLs above. const AIMLAPI_PARTNER_ID_ENV: &str = "AIMLAPI_PARTNER_ID"; -const AIMLAPI_PARTNER_ID_DEFAULT: &str = ""; +const AIMLAPI_PARTNER_ID_DEFAULT: &str = "part_R2KG8QMDBjtWAubVMgG0GF9L"; /// Loopback port the consent screen redirects back to. Chosen from the /// ephemeral range and fixed, because it has to be registered with the @@ -146,12 +144,6 @@ impl PkceAuthFlow { async fn start_authorization(&mut self) -> Result { let app_url = env_or(AIMLAPI_APP_URL_ENV, AIMLAPI_APP_URL_DEFAULT); let partner_id = env_or(AIMLAPI_PARTNER_ID_ENV, AIMLAPI_PARTNER_ID_DEFAULT); - if partner_id.is_empty() { - return Err(anyhow!( - "No AI/ML API partner id configured - set {} before signing in", - AIMLAPI_PARTNER_ID_ENV - )); - } let body = CreateAuthorizationRequest { partner_id, From 801d87aa029cf4244360818fd5b9289097a6c8a8 Mon Sep 17 00:00:00 2001 From: StanAIML Date: Tue, 1 Sep 2026 15:48:56 +0500 Subject: [PATCH 37/37] feat(aimlapi): send the attribution headers on every request Carried over from the fork's earlier Rust provider, which this definition replaces. X-AIMLAPI-Source and X-AIMLAPI-Partner-ID are what let AI/ML API attribute the traffic to goose; without them a request serves fine and is silently untagged. http-referer / x-title follow what the other aggregator definitions here already send. --- .../src/declarative/definitions/aimlapi.json | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/crates/goose-providers/src/declarative/definitions/aimlapi.json b/crates/goose-providers/src/declarative/definitions/aimlapi.json index 68828bc28906..d84fcbc20152 100644 --- a/crates/goose-providers/src/declarative/definitions/aimlapi.json +++ b/crates/goose-providers/src/declarative/definitions/aimlapi.json @@ -5,6 +5,12 @@ "description": "300+ chat models from many creators behind one OpenAI-compatible API key.", "api_key_env": "AIMLAPI_API_KEY", "base_url": "https://api.aimlapi.com/v1", + "headers": { + "x-aimlapi-source": "agent/goose", + "x-aimlapi-partner-id": "part_R2KG8QMDBjtWAubVMgG0GF9L", + "http-referer": "https://goose-docs.ai", + "x-title": "goose" + }, "catalog_provider_id": "aimlapi", "dynamic_models": true, "models": [