From 482b13d38abc528b0f1440e7eb652710494328c9 Mon Sep 17 00:00:00 2001 From: Saoud Rizwan <7799382+saoudrizwan@users.noreply.github.com> Date: Tue, 13 Oct 2026 00:29:19 -0700 Subject: [PATCH 01/21] chore(llms): refresh model catalog and follow v0 / OpenCode Go upstream changes (#15047) * test(llms): follow models.dev catalog changes for v0 and OpenCode Go models.dev dropped the v0 provider and moved OpenCode Go from /zen/go/v1 to /inference/go/v1 (both endpoints serve the same model list). * chore(llms): refresh generated model catalog --- .../llms/src/catalog/catalog.generated.ts | 16317 ++++++++++++---- .../catalog/cline-recommended.generated.ts | 27 +- sdk/packages/llms/src/providers/ids.test.ts | 9 +- .../llms/src/providers/opencode-go.test.ts | 4 +- .../src/providers/provider-ids.generated.ts | 4 +- .../llms/src/providers/providers.generated.ts | 119 +- 6 files changed, 12860 insertions(+), 3620 deletions(-) diff --git a/sdk/packages/llms/src/catalog/catalog.generated.ts b/sdk/packages/llms/src/catalog/catalog.generated.ts index 6deee86595..e76b0b2de6 100644 --- a/sdk/packages/llms/src/catalog/catalog.generated.ts +++ b/sdk/packages/llms/src/catalog/catalog.generated.ts @@ -14,9 +14,82 @@ export const GENERATED_PROVIDER_MODELS: { version: number providers: Record> } = { - version: 1791434846083, + version: 1791870297371, providers: { "302ai": { + "claude-haiku-5-5": { + "id": "claude-haiku-5-5", + "name": "Claude Haiku 5.5", + "contextWindow": 1000000, + "maxInputTokens": 1000000, + "maxTokens": 128000, + "capabilities": [ + "images", + "files", + "tools", + "reasoning", + "structured_output", + "prompt-cache" + ], + "reasoningOptions": [ + { + "type": "toggle" + }, + { + "type": "effort", + "values": [ + "low", + "medium", + "high", + "xhigh", + "max" + ] + } + ], + "pricing": { + "input": 0.1, + "output": 0.5, + "cacheRead": 0.01, + "cacheWrite": 0.125 + }, + "releaseDate": "2026-10-07", + "family": "claude-haiku" + }, + "gpt-6.1-sol": { + "id": "gpt-6.1-sol", + "name": "GPT-6.1 Sol", + "contextWindow": 1050000, + "maxInputTokens": 922000, + "maxTokens": 128000, + "capabilities": [ + "images", + "files", + "tools", + "reasoning", + "structured_output", + "prompt-cache" + ], + "reasoningOptions": [ + { + "type": "effort", + "values": [ + "low", + "medium", + "high", + "xhigh", + "max" + ] + } + ], + "pricing": { + "input": 2, + "output": 10, + "cacheRead": 0.1, + "cacheWrite": 2.5 + }, + "releaseDate": "2026-09-29", + "family": "gpt-sol" + }, "claude-sonnet-5-5": { "id": "claude-sonnet-5-5", "name": "Claude Sonnet 5.5", @@ -7170,6 +7243,113 @@ export const GENERATED_PROVIDER_MODELS: { } }, "aihubmix": { + "claude-haiku-5-5": { + "id": "claude-haiku-5-5", + "name": "Claude Haiku 5.5", + "contextWindow": 1000000, + "maxInputTokens": 1000000, + "maxTokens": 128000, + "capabilities": [ + "images", + "files", + "tools", + "reasoning", + "structured_output", + "prompt-cache" + ], + "reasoningOptions": [ + { + "type": "toggle" + }, + { + "type": "effort", + "values": [ + "low", + "medium", + "high", + "xhigh", + "max" + ] + } + ], + "pricing": { + "input": 0.1, + "output": 0.5, + "cacheRead": 0.01, + "cacheWrite": 0.125 + }, + "releaseDate": "2026-10-07", + "family": "claude-haiku" + }, + "gpt-6.1-sol": { + "id": "gpt-6.1-sol", + "name": "GPT-6.1 Sol", + "contextWindow": 1050000, + "maxInputTokens": 922000, + "maxTokens": 128000, + "capabilities": [ + "images", + "files", + "tools", + "reasoning", + "structured_output", + "prompt-cache" + ], + "reasoningOptions": [ + { + "type": "effort", + "values": [ + "low", + "medium", + "high", + "xhigh", + "max" + ] + } + ], + "pricing": { + "input": 2, + "output": 10, + "cacheRead": 0.1, + "cacheWrite": 2.5 + }, + "releaseDate": "2026-09-29", + "family": "gpt-sol" + }, + "claude-sonnet-5-5": { + "id": "claude-sonnet-5-5", + "name": "Claude Sonnet 5.5", + "contextWindow": 1000000, + "maxInputTokens": 1000000, + "maxTokens": 128000, + "capabilities": [ + "images", + "files", + "tools", + "reasoning", + "prompt-cache" + ], + "reasoningOptions": [ + { + "type": "effort", + "values": [ + "low", + "medium", + "high", + "xhigh", + "max" + ] + } + ], + "pricing": { + "input": 2, + "output": 10, + "cacheRead": 0.1, + "cacheWrite": 2.5 + }, + "releaseDate": "2026-09-28", + "family": "claude-sonnet" + }, "claude-opus-5-5": { "id": "claude-opus-5-5", "name": "Claude Opus 5.5", @@ -7749,6 +7929,40 @@ export const GENERATED_PROVIDER_MODELS: { "releaseDate": "2026-08-28", "family": "Hy" }, + "coding-glm-5.3-flash-free": { + "id": "coding-glm-5.3-flash-free", + "name": "GLM-5.3-Flash (Coding, free)", + "contextWindow": 1000000, + "maxInputTokens": 1000000, + "maxTokens": 131072, + "capabilities": [ + "images", + "video", + "files", + "tools", + "reasoning", + "structured_output", + "temperature" + ], + "reasoningOptions": [ + { + "type": "effort", + "values": [ + "low", + "high", + "max" + ] + } + ], + "pricing": { + "input": 0, + "output": 0, + "cacheRead": 0, + "cacheWrite": 0 + }, + "releaseDate": "2026-08-26", + "family": "glm-flash" + }, "glm-5.3-flash": { "id": "glm-5.3-flash", "name": "GLM-5.3-Flash", @@ -7895,6 +8109,37 @@ export const GENERATED_PROVIDER_MODELS: { "releaseDate": "2026-08-21", "family": "deepseek-flash" }, + "coding-glm-5.3-free": { + "id": "coding-glm-5.3-free", + "name": "GLM-5.3 (Coding, free)", + "contextWindow": 1000000, + "maxInputTokens": 1000000, + "maxTokens": 131072, + "capabilities": [ + "tools", + "reasoning", + "structured_output", + "temperature" + ], + "reasoningOptions": [ + { + "type": "effort", + "values": [ + "low", + "high", + "max" + ] + } + ], + "pricing": { + "input": 0, + "output": 0, + "cacheRead": 0, + "cacheWrite": 0 + }, + "releaseDate": "2026-08-14", + "family": "glm" + }, "glm-5.3": { "id": "glm-5.3", "name": "GLM-5.3", @@ -8075,6 +8320,32 @@ export const GENERATED_PROVIDER_MODELS: { "releaseDate": "2026-08-12", "family": "qwen" }, + "nemotron-3.5-lightning-free": { + "id": "nemotron-3.5-lightning-free", + "name": "Nemotron 3.5 Lightning 30B A3B (free)", + "contextWindow": 262144, + "maxInputTokens": 262144, + "maxTokens": 262144, + "capabilities": [ + "tools", + "reasoning", + "structured_output", + "temperature" + ], + "reasoningOptions": [ + { + "type": "budget_tokens" + } + ], + "pricing": { + "input": 0, + "output": 0, + "cacheRead": 0, + "cacheWrite": 0 + }, + "releaseDate": "2026-08-11", + "family": "nemotron" + }, "muse-spark-1.2": { "id": "muse-spark-1.2", "name": "Muse Spark 1.2", @@ -8265,6 +8536,31 @@ export const GENERATED_PROVIDER_MODELS: { "releaseDate": "2026-07-24", "family": "claude-opus" }, + "ling-3.0-flash-free": { + "id": "ling-3.0-flash-free", + "name": "Ling 3.0 Flash (free)", + "contextWindow": 262144, + "maxInputTokens": 262144, + "maxTokens": 32768, + "capabilities": [ + "tools", + "reasoning", + "temperature" + ], + "reasoningOptions": [ + { + "type": "toggle" + } + ], + "pricing": { + "input": 0, + "output": 0, + "cacheRead": 0, + "cacheWrite": 0 + }, + "releaseDate": "2026-07-23", + "family": "ling" + }, "gemini-3.5-flash-lite": { "id": "gemini-3.5-flash-lite", "name": "Gemini 3.5 Flash Lite", @@ -8701,6 +8997,36 @@ export const GENERATED_PROVIDER_MODELS: { "releaseDate": "2026-06-30", "family": "longcat" }, + "coding-glm-5.2-free": { + "id": "coding-glm-5.2-free", + "name": "GLM-5.2 (Coding, free)", + "contextWindow": 1000000, + "maxInputTokens": 1000000, + "maxTokens": 131072, + "capabilities": [ + "tools", + "reasoning", + "structured_output", + "temperature" + ], + "reasoningOptions": [ + { + "type": "effort", + "values": [ + "high", + "max" + ] + } + ], + "pricing": { + "input": 0, + "output": 0, + "cacheRead": 0, + "cacheWrite": 0 + }, + "releaseDate": "2026-06-13", + "family": "glm" + }, "glm-5.2": { "id": "glm-5.2", "name": "GLM-5.2", @@ -8825,6 +9151,27 @@ export const GENERATED_PROVIDER_MODELS: { "releaseDate": "2026-06-09", "family": "claude-fable" }, + "nemotron-3-ultra-550b-a55b-free": { + "id": "nemotron-3-ultra-550b-a55b-free", + "name": "Nemotron 3 Ultra 550B A55B (free)", + "contextWindow": 1000000, + "maxInputTokens": 1000000, + "maxTokens": 128000, + "capabilities": [ + "tools", + "reasoning", + "temperature" + ], + "reasoningOptions": [], + "pricing": { + "input": 0, + "output": 0, + "cacheRead": 0, + "cacheWrite": 0 + }, + "releaseDate": "2026-06-04", + "family": "nemotron" + }, "qwen3.7-plus": { "id": "qwen3.7-plus", "name": "Qwen3.7 Plus", @@ -9308,6 +9655,44 @@ export const GENERATED_PROVIDER_MODELS: { "releaseDate": "2026-04-28", "family": "seed" }, + "nemotron-3-nano-omni-30b-a3b-reasoning-free": { + "id": "nemotron-3-nano-omni-30b-a3b-reasoning-free", + "name": "Nemotron 3 Nano Omni 30B A3B Reasoning (free)", + "contextWindow": 256000, + "maxInputTokens": 256000, + "maxTokens": 65536, + "capabilities": [ + "images", + "video", + "tools", + "reasoning", + "temperature" + ], + "reasoningOptions": [ + { + "type": "budget_tokens" + } + ], + "pricing": { + "input": 0, + "output": 0, + "cacheRead": 0, + "cacheWrite": 0 + }, + "releaseDate": "2026-04-28", + "family": "nemotron", + "modalities": { + "input": [ + "text", + "image", + "video", + "audio" + ], + "output": [ + "text" + ] + } + }, "alicloud-deepseek-v4-flash": { "id": "alicloud-deepseek-v4-flash", "name": "DeepSeek V4 Flash (Alibaba Cloud)", @@ -10285,6 +10670,31 @@ export const GENERATED_PROVIDER_MODELS: { "releaseDate": "2026-03-17", "family": "gpt-nano" }, + "nemotron-3-super-120b-a12b-free": { + "id": "nemotron-3-super-120b-a12b-free", + "name": "Nemotron 3 Super 120B A12B (free)", + "contextWindow": 262144, + "maxInputTokens": 262144, + "maxTokens": 262144, + "capabilities": [ + "tools", + "reasoning", + "temperature" + ], + "reasoningOptions": [ + { + "type": "budget_tokens" + } + ], + "pricing": { + "input": 0, + "output": 0, + "cacheRead": 0, + "cacheWrite": 0 + }, + "releaseDate": "2026-03-11", + "family": "nemotron" + }, "gpt-5.4": { "id": "gpt-5.4", "name": "GPT-5.4", @@ -10923,6 +11333,31 @@ export const GENERATED_PROVIDER_MODELS: { "releaseDate": "2026-02-05", "family": "gpt-codex" }, + "glm-4.7-flash-free": { + "id": "glm-4.7-flash-free", + "name": "GLM-4.7-Flash (free)", + "contextWindow": 200000, + "maxInputTokens": 200000, + "maxTokens": 131072, + "capabilities": [ + "tools", + "reasoning", + "temperature" + ], + "reasoningOptions": [ + { + "type": "toggle" + } + ], + "pricing": { + "input": 0, + "output": 0, + "cacheRead": 0, + "cacheWrite": 0 + }, + "releaseDate": "2026-01-19", + "family": "glm-flash" + }, "kimi-k2.5": { "id": "kimi-k2.5", "name": "Kimi K2.5", @@ -11161,6 +11596,46 @@ export const GENERATED_PROVIDER_MODELS: { "releaseDate": "2025-11-24", "family": "claude-opus" }, + "claude-opus-4-5-think": { + "id": "claude-opus-4-5-think", + "name": "Claude Opus 4.5 Thinking", + "contextWindow": 200000, + "maxInputTokens": 200000, + "maxTokens": 64000, + "capabilities": [ + "images", + "files", + "tools", + "reasoning", + "temperature", + "prompt-cache" + ], + "reasoningOptions": [ + { + "type": "toggle" + }, + { + "type": "effort", + "values": [ + "low", + "medium", + "high" + ] + }, + { + "type": "budget_tokens", + "min": 1024 + } + ], + "pricing": { + "input": 5, + "output": 25, + "cacheRead": 0.5, + "cacheWrite": 6.25 + }, + "releaseDate": "2025-11-24", + "family": "claude-opus" + }, "gpt-5.1": { "id": "gpt-5.1", "name": "GPT-5.1", @@ -11347,6 +11822,38 @@ export const GENERATED_PROVIDER_MODELS: { "releaseDate": "2025-09-29", "family": "claude-sonnet" }, + "claude-sonnet-4-5-think": { + "id": "claude-sonnet-4-5-think", + "name": "Claude Sonnet 4.5 Thinking", + "contextWindow": 1000000, + "maxInputTokens": 1000000, + "maxTokens": 64000, + "capabilities": [ + "images", + "files", + "tools", + "reasoning", + "temperature", + "prompt-cache" + ], + "reasoningOptions": [ + { + "type": "toggle" + }, + { + "type": "budget_tokens", + "min": 1024 + } + ], + "pricing": { + "input": 3.3, + "output": 16.5, + "cacheRead": 0.33, + "cacheWrite": 4.125 + }, + "releaseDate": "2025-09-29", + "family": "claude-sonnet" + }, "qwen3-max": { "id": "qwen3-max", "name": "Qwen3 Max", @@ -11671,6 +12178,4425 @@ export const GENERATED_PROVIDER_MODELS: { } } }, + "aimlapi": { + "anthropic/claude-haiku-5.5": { + "id": "anthropic/claude-haiku-5.5", + "name": "Claude Haiku 5.5", + "contextWindow": 1000000, + "maxInputTokens": 1000000, + "maxTokens": 128000, + "capabilities": [ + "images", + "files", + "tools", + "reasoning", + "structured_output", + "prompt-cache" + ], + "reasoningOptions": [ + { + "type": "effort", + "values": [ + "low", + "medium", + "high", + "xhigh", + "max" + ] + } + ], + "pricing": { + "input": 0.13, + "output": 0.65, + "cacheRead": 0.013, + "cacheWrite": 0 + }, + "releaseDate": "2026-10-07", + "family": "claude-haiku" + }, + "openai/gpt-6.1-sol": { + "id": "openai/gpt-6.1-sol", + "name": "GPT-6.1 Sol", + "contextWindow": 1050000, + "maxInputTokens": 922000, + "maxTokens": 128000, + "capabilities": [ + "images", + "files", + "tools", + "reasoning", + "structured_output", + "prompt-cache" + ], + "reasoningOptions": [ + { + "type": "effort", + "values": [ + "low", + "medium", + "high" + ] + } + ], + "pricing": { + "input": 2.6, + "output": 13, + "cacheRead": 0.13, + "cacheWrite": 0 + }, + "releaseDate": "2026-09-29", + "family": "gpt-sol" + }, + "anthropic/claude-sonnet-5.5": { + "id": "anthropic/claude-sonnet-5.5", + "name": "Claude Sonnet 5.5", + "contextWindow": 1000000, + "maxInputTokens": 1000000, + "maxTokens": 128000, + "capabilities": [ + "images", + "files", + "tools", + "reasoning", + "prompt-cache" + ], + "reasoningOptions": [ + { + "type": "effort", + "values": [ + "low", + "medium", + "high", + "xhigh", + "max" + ] + } + ], + "pricing": { + "input": 2.6, + "output": 13, + "cacheRead": 0.26, + "cacheWrite": 0 + }, + "releaseDate": "2026-09-28", + "family": "claude-sonnet" + }, + "alibaba/qwen3.8-max-prime": { + "id": "alibaba/qwen3.8-max-prime", + "name": "Qwen3.8 Max Prime", + "contextWindow": 1000000, + "maxInputTokens": 1000000, + "maxTokens": 131072, + "capabilities": [ + "images", + "video", + "tools", + "reasoning", + "structured_output", + "temperature", + "prompt-cache" + ], + "reasoningOptions": [ + { + "type": "effort", + "values": [ + "minimal", + "low", + "medium", + "high", + "xhigh" + ] + } + ], + "pricing": { + "input": 5.5016, + "output": 16.5048, + "cacheRead": 0.6877, + "cacheWrite": 0 + }, + "releaseDate": "2026-09-23", + "family": "qwen3.8-max" + }, + "anthropic/claude-opus-5.5": { + "id": "anthropic/claude-opus-5.5", + "name": "Claude Opus 5.5", + "contextWindow": 1000000, + "maxInputTokens": 1000000, + "maxTokens": 128000, + "capabilities": [ + "images", + "files", + "tools", + "reasoning", + "prompt-cache" + ], + "reasoningOptions": [ + { + "type": "effort", + "values": [ + "low", + "medium", + "high", + "xhigh", + "max" + ] + } + ], + "pricing": { + "input": 5.2, + "output": 26, + "cacheRead": 0.26, + "cacheWrite": 0 + }, + "releaseDate": "2026-09-22", + "family": "claude-opus" + }, + "openai/gpt-6-luna": { + "id": "openai/gpt-6-luna", + "name": "GPT-6 Luna", + "contextWindow": 1050000, + "maxInputTokens": 922000, + "maxTokens": 128000, + "capabilities": [ + "images", + "files", + "tools", + "reasoning", + "structured_output", + "prompt-cache" + ], + "reasoningOptions": [ + { + "type": "effort", + "values": [ + "none", + "low", + "medium", + "high" + ] + } + ], + "pricing": { + "input": 0.13, + "output": 0.65, + "cacheRead": 0.013, + "cacheWrite": 0 + }, + "releaseDate": "2026-09-22", + "family": "gpt-luna" + }, + "openai/gpt-6-sol": { + "id": "openai/gpt-6-sol", + "name": "GPT-6 Sol", + "contextWindow": 1050000, + "maxInputTokens": 922000, + "maxTokens": 128000, + "capabilities": [ + "images", + "files", + "tools", + "reasoning", + "structured_output", + "prompt-cache" + ], + "reasoningOptions": [ + { + "type": "effort", + "values": [ + "none", + "low", + "medium", + "high" + ] + } + ], + "pricing": { + "input": 2.6, + "output": 13, + "cacheRead": 0.26, + "cacheWrite": 0 + }, + "releaseDate": "2026-09-22", + "family": "gpt-sol" + }, + "unbiased/pareto": { + "id": "unbiased/pareto", + "name": "Pareto", + "contextWindow": 262144, + "maxInputTokens": 262144, + "maxTokens": 131072, + "capabilities": [ + "images", + "tools", + "temperature", + "prompt-cache" + ], + "pricing": { + "input": 3.4385, + "output": 10.3155, + "cacheRead": 0.34385, + "cacheWrite": 0 + }, + "releaseDate": "2026-09-17" + }, + "stepfun/step-5-preview": { + "id": "stepfun/step-5-preview", + "name": "Step 5 Preview", + "contextWindow": 1000000, + "maxInputTokens": 1000000, + "maxTokens": 64000, + "capabilities": [ + "images", + "video", + "tools", + "reasoning", + "structured_output", + "prompt-cache" + ], + "reasoningOptions": [ + { + "type": "effort", + "values": [ + "low", + "medium", + "high" + ] + } + ], + "pricing": { + "input": 1.3754, + "output": 3.71358, + "cacheRead": 0.06877, + "cacheWrite": 0 + }, + "releaseDate": "2026-09-16" + }, + "sakana/fugu-max": { + "id": "sakana/fugu-max", + "name": "Fugu Max", + "contextWindow": 1000000, + "maxInputTokens": 1000000, + "maxTokens": 128000, + "capabilities": [ + "images", + "files", + "tools", + "reasoning", + "structured_output", + "prompt-cache" + ], + "reasoningOptions": [ + { + "type": "effort", + "values": [ + "high" + ] + } + ], + "pricing": { + "input": 2.7508, + "output": 8.2524, + "cacheRead": 0.34385, + "cacheWrite": 0 + }, + "releaseDate": "2026-09-11", + "family": "fugu" + }, + "sakana/fugu-ultra-v2": { + "id": "sakana/fugu-ultra-v2", + "name": "Fugu Ultra v2", + "contextWindow": 1000000, + "maxInputTokens": 1000000, + "maxTokens": 128000, + "capabilities": [ + "images", + "files", + "tools", + "reasoning", + "structured_output", + "prompt-cache" + ], + "reasoningOptions": [ + { + "type": "effort", + "values": [ + "high" + ] + } + ], + "pricing": { + "input": 6.877, + "output": 41.262, + "cacheRead": 0.6877, + "cacheWrite": 0 + }, + "releaseDate": "2026-09-11", + "family": "fugu" + }, + "deepseek/deepseek-v4.1-flash": { + "id": "deepseek/deepseek-v4.1-flash", + "name": "DeepSeek V4.1 Flash", + "contextWindow": 1048576, + "maxInputTokens": 1048576, + "maxTokens": 384000, + "capabilities": [ + "images", + "tools", + "reasoning", + "structured_output", + "temperature", + "prompt-cache" + ], + "reasoningOptions": [ + { + "type": "effort", + "values": [ + "low", + "high" + ] + } + ], + "pricing": { + "input": 0.39, + "output": 1.56, + "cacheRead": 0.0078, + "cacheWrite": 0 + }, + "releaseDate": "2026-09-10", + "family": "deepseek-flash" + }, + "inception/mercury-2.5": { + "id": "inception/mercury-2.5", + "name": "Mercury 2.5", + "contextWindow": 260000, + "maxInputTokens": 260000, + "maxTokens": 65536, + "capabilities": [ + "tools", + "reasoning", + "structured_output", + "temperature", + "prompt-cache" + ], + "reasoningOptions": [ + { + "type": "effort", + "values": [ + "low", + "medium", + "high" + ] + } + ], + "pricing": { + "input": 0.27508, + "output": 1.03155, + "cacheRead": 0.027508, + "cacheWrite": 0 + }, + "releaseDate": "2026-09-08", + "family": "mercury" + }, + "openai/gpt-6-astra": { + "id": "openai/gpt-6-astra", + "name": "GPT-6 Astra", + "contextWindow": 1050000, + "maxInputTokens": 922000, + "maxTokens": 128000, + "capabilities": [ + "images", + "files", + "tools", + "reasoning", + "structured_output", + "prompt-cache" + ], + "reasoningOptions": [ + { + "type": "effort", + "values": [ + "low", + "medium", + "high", + "xhigh", + "max" + ] + } + ], + "pricing": { + "input": 13, + "output": 65, + "cacheRead": 1.3, + "cacheWrite": 0 + }, + "releaseDate": "2026-09-04", + "family": "gpt-astra" + }, + "google/gemini-3-8-flash": { + "id": "google/gemini-3-8-flash", + "name": "Gemini 3.8 Flash", + "contextWindow": 1048576, + "maxInputTokens": 1048576, + "maxTokens": 65536, + "capabilities": [ + "images", + "video", + "files", + "tools", + "reasoning", + "structured_output", + "temperature", + "prompt-cache" + ], + "reasoningOptions": [ + { + "type": "effort", + "values": [ + "low", + "medium", + "high" + ] + } + ], + "pricing": { + "input": 0.975, + "output": 4.875, + "cacheRead": 0.0975, + "cacheWrite": 0 + }, + "releaseDate": "2026-09-02", + "family": "gemini-flash", + "modalities": { + "input": [ + "text", + "image", + "video", + "audio", + "pdf" + ], + "output": [ + "text" + ] + } + }, + "google/gemini-3.8-flash": { + "id": "google/gemini-3.8-flash", + "name": "Gemini 3.8 Flash", + "contextWindow": 1048576, + "maxInputTokens": 1048576, + "maxTokens": 65536, + "capabilities": [ + "images", + "video", + "files", + "tools", + "reasoning", + "structured_output", + "temperature", + "prompt-cache" + ], + "reasoningOptions": [ + { + "type": "effort", + "values": [ + "low", + "medium", + "high" + ] + } + ], + "pricing": { + "input": 0.975, + "output": 4.875, + "cacheRead": 0.0975, + "cacheWrite": 0 + }, + "releaseDate": "2026-09-02", + "family": "gemini-flash", + "modalities": { + "input": [ + "text", + "image", + "video", + "audio", + "pdf" + ], + "output": [ + "text" + ] + } + }, + "meta/muse-spark-1.3": { + "id": "meta/muse-spark-1.3", + "name": "Muse Spark 1.3", + "contextWindow": 1048576, + "maxInputTokens": 1048576, + "maxTokens": 131072, + "capabilities": [ + "images", + "video", + "files", + "tools", + "reasoning", + "structured_output", + "temperature", + "prompt-cache" + ], + "reasoningOptions": [ + { + "type": "effort", + "values": [ + "low", + "medium", + "high" + ] + } + ], + "pricing": { + "input": 1.71925, + "output": 5.84545, + "cacheRead": 0.20631, + "cacheWrite": 0 + }, + "releaseDate": "2026-09-02", + "family": "muse", + "modalities": { + "input": [ + "text", + "image", + "video", + "pdf", + "audio" + ], + "output": [ + "text" + ] + } + }, + "anthropic/claude-fable-5.1": { + "id": "anthropic/claude-fable-5.1", + "name": "Claude Fable 5.1", + "contextWindow": 1000000, + "maxInputTokens": 1000000, + "maxTokens": 128000, + "capabilities": [ + "images", + "files", + "tools", + "reasoning", + "prompt-cache" + ], + "reasoningOptions": [ + { + "type": "effort", + "values": [ + "low", + "medium", + "high", + "xhigh", + "max" + ] + } + ], + "pricing": { + "input": 13, + "output": 65, + "cacheRead": 0.325, + "cacheWrite": 0 + }, + "releaseDate": "2026-09-01", + "family": "claude-fable" + }, + "tencent/hy4-preview": { + "id": "tencent/hy4-preview", + "name": "Hy4 preview", + "contextWindow": 1048576, + "maxInputTokens": 1048576, + "maxTokens": 64000, + "capabilities": [ + "tools", + "reasoning", + "temperature", + "prompt-cache" + ], + "reasoningOptions": [ + { + "type": "effort", + "values": [ + "low", + "high" + ] + } + ], + "pricing": { + "input": 1.147084, + "output": 3.439875, + "cacheRead": 0.057767, + "cacheWrite": 0 + }, + "releaseDate": "2026-08-28", + "family": "Hy" + }, + "alibaba/qwen3.8-flash": { + "id": "alibaba/qwen3.8-flash", + "name": "Qwen3.8 Flash", + "contextWindow": 1000000, + "maxInputTokens": 1000000, + "maxTokens": 131072, + "capabilities": [ + "images", + "video", + "tools", + "reasoning", + "structured_output", + "prompt-cache" + ], + "reasoningOptions": [ + { + "type": "effort", + "values": [ + "low", + "medium" + ] + } + ], + "pricing": { + "input": 0.208, + "output": 0.611, + "cacheRead": 0.0208, + "cacheWrite": 0 + }, + "releaseDate": "2026-08-26", + "family": "qwen" + }, + "z-ai/glm-5.3-flash": { + "id": "z-ai/glm-5.3-flash", + "name": "GLM 5.3 Flash", + "contextWindow": 1048576, + "maxInputTokens": 1048576, + "maxTokens": 131072, + "capabilities": [ + "images", + "video", + "files", + "tools", + "reasoning", + "structured_output", + "temperature", + "prompt-cache" + ], + "reasoningOptions": [ + { + "type": "effort", + "values": [ + "low", + "high", + "max" + ] + } + ], + "pricing": { + "input": 0.195, + "output": 0.65, + "cacheRead": 0.039, + "cacheWrite": 0 + }, + "releaseDate": "2026-08-26", + "family": "glm-flash" + }, + "deepseek/deepseek-v4-flash-vision-exp": { + "id": "deepseek/deepseek-v4-flash-vision-exp", + "name": "DeepSeek V4 Flash Vision Exp", + "contextWindow": 1048576, + "maxInputTokens": 1048576, + "maxTokens": 384000, + "capabilities": [ + "images", + "tools", + "reasoning", + "structured_output", + "temperature", + "prompt-cache" + ], + "reasoningOptions": [ + { + "type": "effort", + "values": [ + "none", + "low", + "high" + ] + } + ], + "pricing": { + "input": 0.572, + "output": 1.716, + "cacheRead": 0.0182, + "cacheWrite": 0 + }, + "releaseDate": "2026-08-21", + "family": "deepseek-flash" + }, + "alibaba/qwen3.8-27b": { + "id": "alibaba/qwen3.8-27b", + "name": "Qwen3.8 27B", + "contextWindow": 262144, + "maxInputTokens": 262144, + "maxTokens": 131072, + "capabilities": [ + "images", + "video", + "tools", + "reasoning", + "structured_output", + "temperature" + ], + "reasoningOptions": [ + { + "type": "effort", + "values": [ + "low", + "medium" + ] + } + ], + "pricing": { + "input": 0.61893, + "output": 4.40128, + "cacheRead": 0, + "cacheWrite": 0 + }, + "releaseDate": "2026-08-14", + "family": "qwen" + }, + "zhipu/glm-5.3": { + "id": "zhipu/glm-5.3", + "name": "GLM 5.3", + "contextWindow": 1024000, + "maxInputTokens": 1024000, + "maxTokens": 131072, + "capabilities": [ + "tools", + "reasoning", + "structured_output", + "temperature", + "prompt-cache" + ], + "reasoningOptions": [ + { + "type": "effort", + "values": [ + "low", + "high" + ] + } + ], + "pricing": { + "input": 1.82, + "output": 5.72, + "cacheRead": 0.338, + "cacheWrite": 0 + }, + "releaseDate": "2026-08-14", + "family": "glm" + }, + "google/gemini-3.7-flash": { + "id": "google/gemini-3.7-flash", + "name": "Gemini 3.7 Flash", + "contextWindow": 1048576, + "maxInputTokens": 1048576, + "maxTokens": 65536, + "capabilities": [ + "images", + "video", + "files", + "tools", + "reasoning", + "structured_output", + "temperature", + "prompt-cache" + ], + "reasoningOptions": [ + { + "type": "effort", + "values": [ + "low", + "medium", + "high" + ] + } + ], + "pricing": { + "input": 0.975, + "output": 4.875, + "cacheRead": 0.0975, + "cacheWrite": 0 + }, + "releaseDate": "2026-08-13", + "family": "gemini-flash", + "modalities": { + "input": [ + "text", + "image", + "video", + "audio", + "pdf" + ], + "output": [ + "text" + ] + } + }, + "google/gemini-flash-latest": { + "id": "google/gemini-flash-latest", + "name": "Gemini Flash Latest", + "contextWindow": 1048576, + "maxInputTokens": 1048576, + "maxTokens": 65536, + "capabilities": [ + "images", + "video", + "files", + "tools", + "reasoning", + "structured_output", + "temperature", + "prompt-cache" + ], + "reasoningOptions": [ + { + "type": "effort", + "values": [ + "low", + "medium", + "high" + ] + } + ], + "pricing": { + "input": 2.0631, + "output": 12.3786, + "cacheRead": 0.20631, + "cacheWrite": 0 + }, + "releaseDate": "2026-08-13", + "family": "gemini-flash", + "modalities": { + "input": [ + "text", + "image", + "video", + "audio", + "pdf" + ], + "output": [ + "text" + ] + } + }, + "alibaba/qwen3.8-2.4t-a95b": { + "id": "alibaba/qwen3.8-2.4t-a95b", + "name": "Qwen3.8 2.4T A95B", + "contextWindow": 262144, + "maxInputTokens": 262144, + "maxTokens": 52429, + "capabilities": [ + "tools", + "reasoning", + "structured_output", + "temperature", + "prompt-cache" + ], + "reasoningOptions": [ + { + "type": "effort", + "values": [ + "low", + "medium" + ] + } + ], + "pricing": { + "input": 3.4385, + "output": 8.59625, + "cacheRead": 0.6877, + "cacheWrite": 0 + }, + "releaseDate": "2026-08-12", + "family": "qwen" + }, + "deepseek/deepseek-v4-pro-0813": { + "id": "deepseek/deepseek-v4-pro-0813", + "name": "DeepSeek V4 Pro 0813", + "contextWindow": 1048576, + "maxInputTokens": 1048576, + "maxTokens": 384000, + "capabilities": [ + "tools", + "reasoning", + "structured_output", + "temperature", + "prompt-cache" + ], + "reasoningOptions": [ + { + "type": "effort", + "values": [ + "none", + "low", + "high" + ] + } + ], + "pricing": { + "input": 2.393196, + "output": 4.786392, + "cacheRead": 0.199433, + "cacheWrite": 0 + }, + "releaseDate": "2026-08-12", + "family": "deepseek-thinking" + }, + "meta/muse-glimmer-30b": { + "id": "meta/muse-glimmer-30b", + "name": "Muse Glimmer 30B", + "contextWindow": 131072, + "maxInputTokens": 131072, + "maxTokens": 131072, + "capabilities": [ + "images", + "tools", + "reasoning", + "structured_output", + "temperature", + "prompt-cache" + ], + "reasoningOptions": [ + { + "type": "effort", + "values": [ + "low", + "medium", + "high" + ] + } + ], + "pricing": { + "input": 0.48139, + "output": 2.0631, + "cacheRead": 0.055016, + "cacheWrite": 0 + }, + "releaseDate": "2026-08-10", + "family": "muse" + }, + "upstage/solar-pro4": { + "id": "upstage/solar-pro4", + "name": "Solar Pro 4", + "contextWindow": 524288, + "maxInputTokens": 524288, + "maxTokens": 131072, + "capabilities": [ + "tools", + "reasoning", + "structured_output", + "temperature", + "prompt-cache" + ], + "reasoningOptions": [ + { + "type": "effort", + "values": [ + "none", + "low", + "medium", + "high" + ] + } + ], + "pricing": { + "input": 0.041262, + "output": 0.165048, + "cacheRead": 0.008252, + "cacheWrite": 0 + }, + "releaseDate": "2026-08-06", + "family": "solar-pro" + }, + "meta/muse-spark-1.2": { + "id": "meta/muse-spark-1.2", + "name": "Muse Spark 1.2", + "contextWindow": 1048576, + "maxInputTokens": 1048576, + "maxTokens": 131072, + "capabilities": [ + "images", + "video", + "files", + "tools", + "reasoning", + "structured_output", + "temperature", + "prompt-cache" + ], + "reasoningOptions": [ + { + "type": "effort", + "values": [ + "low", + "medium", + "high" + ] + } + ], + "pricing": { + "input": 1.71925, + "output": 5.84545, + "cacheRead": 0.20631, + "cacheWrite": 0 + }, + "releaseDate": "2026-08-05", + "family": "muse", + "modalities": { + "input": [ + "text", + "image", + "video", + "pdf", + "audio" + ], + "output": [ + "text" + ] + } + }, + "alibaba/qwen3.8-max": { + "id": "alibaba/qwen3.8-max", + "name": "Qwen3.8 Max", + "contextWindow": 1000000, + "maxInputTokens": 1000000, + "maxTokens": 131072, + "capabilities": [ + "images", + "video", + "files", + "tools", + "reasoning", + "temperature", + "prompt-cache" + ], + "reasoningOptions": [ + { + "type": "effort", + "values": [ + "low", + "medium" + ] + } + ], + "pricing": { + "input": 2.6, + "output": 7.8, + "cacheRead": 0.325, + "cacheWrite": 0 + }, + "releaseDate": "2026-08-03", + "family": "qwen" + }, + "thinkingmachines/inkling-small": { + "id": "thinkingmachines/inkling-small", + "name": "Inkling Small", + "contextWindow": 524288, + "maxInputTokens": 524288, + "maxTokens": 262144, + "capabilities": [ + "images", + "tools", + "reasoning", + "temperature", + "prompt-cache" + ], + "reasoningOptions": [ + { + "type": "effort", + "values": [ + "low", + "medium", + "high" + ] + } + ], + "pricing": { + "input": 0.797732, + "output": 1.980576, + "cacheRead": 0.159546, + "cacheWrite": 0 + }, + "releaseDate": "2026-07-30", + "family": "ling", + "modalities": { + "input": [ + "text", + "image", + "audio" + ], + "output": [ + "text" + ] + } + }, + "anthropic/claude-opus-5": { + "id": "anthropic/claude-opus-5", + "name": "Claude Opus 5", + "contextWindow": 1000000, + "maxInputTokens": 1000000, + "maxTokens": 128000, + "capabilities": [ + "images", + "files", + "tools", + "reasoning", + "prompt-cache" + ], + "reasoningOptions": [ + { + "type": "effort", + "values": [ + "low", + "medium", + "high", + "xhigh", + "max" + ] + } + ], + "pricing": { + "input": 6.5, + "output": 32.5, + "cacheRead": 0.65, + "cacheWrite": 0 + }, + "releaseDate": "2026-07-24", + "family": "claude-opus" + }, + "google/gemini-3.6-flash": { + "id": "google/gemini-3.6-flash", + "name": "Gemini 3.6 Flash", + "contextWindow": 1048576, + "maxInputTokens": 1048576, + "maxTokens": 65536, + "capabilities": [ + "images", + "video", + "files", + "tools", + "reasoning", + "structured_output", + "temperature", + "prompt-cache" + ], + "reasoningOptions": [ + { + "type": "effort", + "values": [ + "minimal", + "low", + "medium", + "high" + ] + } + ], + "pricing": { + "input": 1.95, + "output": 9.75, + "cacheRead": 0.195, + "cacheWrite": 0 + }, + "releaseDate": "2026-07-21", + "family": "gemini-flash", + "modalities": { + "input": [ + "text", + "image", + "video", + "audio", + "pdf" + ], + "output": [ + "text" + ] + } + }, + "moonshot/kimi-k3": { + "id": "moonshot/kimi-k3", + "name": "Kimi K3", + "contextWindow": 1048576, + "maxInputTokens": 1048576, + "maxTokens": 131072, + "capabilities": [ + "images", + "video", + "tools", + "reasoning", + "structured_output", + "prompt-cache" + ], + "reasoningOptions": [ + { + "type": "effort", + "values": [ + "low", + "high", + "max" + ] + } + ], + "pricing": { + "input": 3.9, + "output": 19.5, + "cacheRead": 0.39, + "cacheWrite": 0 + }, + "releaseDate": "2026-07-16", + "family": "kimi-k3" + }, + "thinkingmachines/inkling": { + "id": "thinkingmachines/inkling", + "name": "Inkling", + "contextWindow": 1048576, + "maxInputTokens": 1048576, + "maxTokens": 1048576, + "capabilities": [ + "images", + "tools", + "reasoning", + "temperature", + "prompt-cache" + ], + "reasoningOptions": [ + { + "type": "effort", + "values": [ + "low", + "medium", + "high" + ] + } + ], + "pricing": { + "input": 1.3754, + "output": 5.57037, + "cacheRead": 0.233818, + "cacheWrite": 0 + }, + "releaseDate": "2026-07-15", + "family": "ling", + "modalities": { + "input": [ + "text", + "image", + "audio" + ], + "output": [ + "text" + ] + } + }, + "openai/gpt-5.6-luna": { + "id": "openai/gpt-5.6-luna", + "name": "GPT-5.6 Luna", + "contextWindow": 1050000, + "maxInputTokens": 922000, + "maxTokens": 128000, + "capabilities": [ + "images", + "files", + "tools", + "reasoning", + "structured_output", + "prompt-cache" + ], + "reasoningOptions": [ + { + "type": "effort", + "values": [ + "none", + "low", + "medium", + "high" + ] + } + ], + "pricing": { + "input": 0.26, + "output": 1.56, + "cacheRead": 0.026, + "cacheWrite": 0 + }, + "releaseDate": "2026-07-09", + "family": "gpt-luna" + }, + "openai/gpt-5.6-luna-pro": { + "id": "openai/gpt-5.6-luna-pro", + "name": "GPT-5.6 Luna Pro", + "contextWindow": 1050000, + "maxInputTokens": 922000, + "maxTokens": 128000, + "capabilities": [ + "images", + "files", + "tools", + "reasoning", + "structured_output", + "prompt-cache" + ], + "reasoningOptions": [ + { + "type": "effort", + "values": [ + "none", + "low", + "medium", + "high" + ] + } + ], + "pricing": { + "input": 0.27508, + "output": 1.65048, + "cacheRead": 0.027508, + "cacheWrite": 0 + }, + "releaseDate": "2026-07-09", + "family": "gpt-luna" + }, + "openai/gpt-5.6-sol": { + "id": "openai/gpt-5.6-sol", + "name": "GPT-5.6 Sol", + "contextWindow": 1050000, + "maxInputTokens": 922000, + "maxTokens": 128000, + "capabilities": [ + "images", + "files", + "tools", + "reasoning", + "structured_output", + "prompt-cache" + ], + "reasoningOptions": [ + { + "type": "effort", + "values": [ + "none", + "low", + "medium", + "high" + ] + } + ], + "pricing": { + "input": 5.2, + "output": 26, + "cacheRead": 0.52, + "cacheWrite": 0 + }, + "releaseDate": "2026-07-09", + "family": "gpt-sol" + }, + "openai/gpt-5.6-sol-pro": { + "id": "openai/gpt-5.6-sol-pro", + "name": "GPT-5.6 Sol Pro", + "contextWindow": 1050000, + "maxInputTokens": 922000, + "maxTokens": 128000, + "capabilities": [ + "images", + "files", + "tools", + "reasoning", + "structured_output", + "prompt-cache" + ], + "reasoningOptions": [ + { + "type": "effort", + "values": [ + "none", + "low", + "medium", + "high" + ] + } + ], + "pricing": { + "input": 5.5016, + "output": 27.508, + "cacheRead": 0.55016, + "cacheWrite": 0 + }, + "releaseDate": "2026-07-09", + "family": "gpt-sol" + }, + "openai/gpt-5.6-terra": { + "id": "openai/gpt-5.6-terra", + "name": "GPT-5.6 Terra", + "contextWindow": 1050000, + "maxInputTokens": 922000, + "maxTokens": 128000, + "capabilities": [ + "images", + "files", + "tools", + "reasoning", + "structured_output", + "prompt-cache" + ], + "reasoningOptions": [ + { + "type": "effort", + "values": [ + "none", + "low", + "medium", + "high" + ] + } + ], + "pricing": { + "input": 2.6, + "output": 15.6, + "cacheRead": 0.26, + "cacheWrite": 0 + }, + "releaseDate": "2026-07-09", + "family": "gpt-terra" + }, + "openai/gpt-5.6-terra-pro": { + "id": "openai/gpt-5.6-terra-pro", + "name": "GPT-5.6 Terra Pro", + "contextWindow": 1050000, + "maxInputTokens": 922000, + "maxTokens": 128000, + "capabilities": [ + "images", + "files", + "tools", + "reasoning", + "structured_output", + "prompt-cache" + ], + "reasoningOptions": [ + { + "type": "effort", + "values": [ + "none", + "low", + "medium", + "high" + ] + } + ], + "pricing": { + "input": 2.7508, + "output": 16.5048, + "cacheRead": 0.27508, + "cacheWrite": 0 + }, + "releaseDate": "2026-07-09", + "family": "gpt-terra" + }, + "tencent/hy3": { + "id": "tencent/hy3", + "name": "Hy3", + "contextWindow": 262144, + "maxInputTokens": 262144, + "maxTokens": 131072, + "capabilities": [ + "tools", + "reasoning", + "temperature", + "prompt-cache" + ], + "reasoningOptions": [ + { + "type": "effort", + "values": [ + "low", + "high" + ] + } + ], + "pricing": { + "input": 0.27508, + "output": 1.10032, + "cacheRead": 0.06877, + "cacheWrite": 0 + }, + "releaseDate": "2026-07-06", + "family": "Hy" + }, + "anthropic/claude-sonnet-5": { + "id": "anthropic/claude-sonnet-5", + "name": "Claude Sonnet 5", + "contextWindow": 1000000, + "maxInputTokens": 1000000, + "maxTokens": 128000, + "capabilities": [ + "images", + "files", + "tools", + "reasoning", + "prompt-cache" + ], + "reasoningOptions": [ + { + "type": "effort", + "values": [ + "low", + "medium", + "high", + "xhigh", + "max" + ] + } + ], + "pricing": { + "input": 2.6, + "output": 13, + "cacheRead": 0.26, + "cacheWrite": 0 + }, + "releaseDate": "2026-06-30", + "family": "claude-sonnet" + }, + "sakana/fugu-ultra": { + "id": "sakana/fugu-ultra", + "name": "Fugu Ultra", + "contextWindow": 1000000, + "maxInputTokens": 1000000, + "maxTokens": 128000, + "capabilities": [ + "images", + "tools", + "reasoning", + "structured_output", + "prompt-cache" + ], + "reasoningOptions": [ + { + "type": "effort", + "values": [ + "high" + ] + } + ], + "pricing": { + "input": 6.877, + "output": 41.262, + "cacheRead": 0.6877, + "cacheWrite": 0 + }, + "releaseDate": "2026-06-15", + "family": "fugu" + }, + "zhipu/glm-5.2": { + "id": "zhipu/glm-5.2", + "name": "GLM 5.2", + "contextWindow": 1000000, + "maxInputTokens": 1000000, + "maxTokens": 131072, + "capabilities": [ + "tools", + "reasoning", + "structured_output", + "temperature", + "prompt-cache" + ], + "reasoningOptions": [ + { + "type": "effort", + "values": [ + "high" + ] + } + ], + "pricing": { + "input": 1.82, + "output": 5.72, + "cacheRead": 0.338, + "cacheWrite": 0 + }, + "releaseDate": "2026-06-13", + "family": "glm" + }, + "anthropic/claude-fable-5": { + "id": "anthropic/claude-fable-5", + "name": "Claude Fable 5", + "contextWindow": 1000000, + "maxInputTokens": 1000000, + "maxTokens": 128000, + "capabilities": [ + "images", + "files", + "tools", + "reasoning", + "prompt-cache" + ], + "reasoningOptions": [ + { + "type": "effort", + "values": [ + "low", + "medium", + "high", + "xhigh", + "max" + ] + } + ], + "pricing": { + "input": 13, + "output": 65, + "cacheRead": 1.3, + "cacheWrite": 0 + }, + "releaseDate": "2026-06-09", + "family": "claude-fable" + }, + "anthropic/claude-opus-4.8": { + "id": "anthropic/claude-opus-4.8", + "name": "Claude 4.8 Opus", + "contextWindow": 1000000, + "maxInputTokens": 1000000, + "maxTokens": 128000, + "capabilities": [ + "images", + "files", + "tools", + "reasoning", + "prompt-cache" + ], + "reasoningOptions": [ + { + "type": "effort", + "values": [ + "none", + "low", + "medium", + "high", + "xhigh", + "max" + ] + } + ], + "pricing": { + "input": 6.877, + "output": 34.385, + "cacheRead": 0.6877, + "cacheWrite": 0 + }, + "releaseDate": "2026-05-28", + "family": "claude-opus" + }, + "mistralai/mistral-medium-3-5": { + "id": "mistralai/mistral-medium-3-5", + "name": "Medium 3.5", + "contextWindow": 262144, + "maxInputTokens": 262144, + "maxTokens": 209715, + "capabilities": [ + "images", + "files", + "tools", + "reasoning", + "structured_output", + "temperature" + ], + "reasoningOptions": [ + { + "type": "effort", + "values": [ + "high" + ] + } + ], + "pricing": { + "input": 2.0631, + "output": 10.3155, + "cacheRead": 0, + "cacheWrite": 0 + }, + "releaseDate": "2026-04-30", + "family": "mistral-medium" + }, + "deepseek/deepseek-chat": { + "id": "deepseek/deepseek-chat", + "name": "DeepSeek V4 Flash", + "contextWindow": 1000000, + "maxInputTokens": 1000000, + "maxTokens": 384000, + "capabilities": [ + "tools", + "reasoning", + "structured_output", + "temperature", + "prompt-cache" + ], + "reasoningOptions": [ + { + "type": "effort", + "values": [ + "none", + "low", + "high" + ] + } + ], + "pricing": { + "input": 0.39, + "output": 1.56, + "cacheRead": 0.0078, + "cacheWrite": 0 + }, + "releaseDate": "2026-04-24", + "family": "deepseek-flash" + }, + "deepseek/deepseek-v4-flash": { + "id": "deepseek/deepseek-v4-flash", + "name": "DeepSeek V4 Flash", + "contextWindow": 1000000, + "maxInputTokens": 1000000, + "maxTokens": 384000, + "capabilities": [ + "tools", + "reasoning", + "structured_output", + "temperature", + "prompt-cache" + ], + "reasoningOptions": [ + { + "type": "effort", + "values": [ + "none", + "low", + "high" + ] + } + ], + "pricing": { + "input": 0.39, + "output": 1.56, + "cacheRead": 0.0078, + "cacheWrite": 0 + }, + "releaseDate": "2026-04-24", + "family": "deepseek-flash" + }, + "deepseek/deepseek-v4-pro": { + "id": "deepseek/deepseek-v4-pro", + "name": "DeepSeek V4 Pro", + "contextWindow": 1000000, + "maxInputTokens": 1000000, + "maxTokens": 384000, + "capabilities": [ + "tools", + "reasoning", + "structured_output", + "temperature", + "prompt-cache" + ], + "reasoningOptions": [ + { + "type": "effort", + "values": [ + "none", + "low", + "high" + ] + } + ], + "pricing": { + "input": 1.17, + "output": 3.51, + "cacheRead": 0.039, + "cacheWrite": 0 + }, + "releaseDate": "2026-04-24", + "family": "deepseek-thinking" + }, + "openai/gpt-5-5": { + "id": "openai/gpt-5-5", + "name": "GPT-5.5", + "contextWindow": 1050000, + "maxInputTokens": 922000, + "maxTokens": 128000, + "capabilities": [ + "images", + "files", + "tools", + "reasoning", + "structured_output", + "prompt-cache" + ], + "reasoningOptions": [ + { + "type": "effort", + "values": [ + "none", + "low", + "medium", + "high" + ] + } + ], + "pricing": { + "input": 6.5, + "output": 39, + "cacheRead": 0.65, + "cacheWrite": 0 + }, + "releaseDate": "2026-04-23", + "family": "gpt" + }, + "openai/gpt-5.5-pro": { + "id": "openai/gpt-5.5-pro", + "name": "GPT-5.5 Pro", + "contextWindow": 1050000, + "maxInputTokens": 922000, + "maxTokens": 128000, + "capabilities": [ + "images", + "files", + "tools", + "reasoning", + "structured_output" + ], + "reasoningOptions": [ + { + "type": "effort", + "values": [ + "medium", + "high" + ] + } + ], + "pricing": { + "input": 41.262, + "output": 247.572, + "cacheRead": 0, + "cacheWrite": 0 + }, + "releaseDate": "2026-04-23", + "family": "gpt-pro" + }, + "xiaomi/mimo-v2.5": { + "id": "xiaomi/mimo-v2.5", + "name": "MiMo V2.5", + "contextWindow": 1000000, + "maxInputTokens": 1000000, + "maxTokens": 128000, + "capabilities": [ + "images", + "video", + "tools", + "reasoning", + "temperature", + "prompt-cache" + ], + "reasoningOptions": [], + "pricing": { + "input": 0.192556, + "output": 0.385112, + "cacheRead": 0.003851, + "cacheWrite": 0 + }, + "releaseDate": "2026-04-22", + "family": "mimo", + "modalities": { + "input": [ + "text", + "image", + "audio", + "video" + ], + "output": [ + "text" + ] + } + }, + "xiaomi/mimo-v2.5-pro": { + "id": "xiaomi/mimo-v2.5-pro", + "name": "MiMo V2.5 Pro", + "contextWindow": 1000000, + "maxInputTokens": 1000000, + "maxTokens": 128000, + "capabilities": [ + "tools", + "reasoning", + "temperature", + "prompt-cache" + ], + "reasoningOptions": [], + "pricing": { + "input": 0.598299, + "output": 1.196598, + "cacheRead": 0.004951, + "cacheWrite": 0 + }, + "releaseDate": "2026-04-22", + "family": "mimo" + }, + "alibaba/qwen3.6-max-preview": { + "id": "alibaba/qwen3.6-max-preview", + "name": "Qwen3.6 Max Preview", + "contextWindow": 262144, + "maxInputTokens": 262144, + "maxTokens": 65536, + "capabilities": [ + "tools", + "reasoning", + "temperature" + ], + "reasoningOptions": [], + "pricing": { + "input": 0.65, + "output": 3.9, + "cacheRead": 0, + "cacheWrite": 0 + }, + "releaseDate": "2026-04-20", + "family": "qwen" + }, + "anthropic/claude-opus-4.7": { + "id": "anthropic/claude-opus-4.7", + "name": "Claude 4.7 Opus", + "contextWindow": 1000000, + "maxInputTokens": 1000000, + "maxTokens": 128000, + "capabilities": [ + "images", + "files", + "tools", + "reasoning", + "prompt-cache" + ], + "reasoningOptions": [ + { + "type": "effort", + "values": [ + "none", + "low", + "medium", + "high", + "xhigh", + "max" + ] + } + ], + "pricing": { + "input": 6.877, + "output": 34.385, + "cacheRead": 0.6877, + "cacheWrite": 0 + }, + "releaseDate": "2026-04-16", + "family": "claude-opus" + }, + "meta/muse-spark-1.1": { + "id": "meta/muse-spark-1.1", + "name": "Muse Spark 1.1", + "contextWindow": 1048576, + "maxInputTokens": 1048576, + "maxTokens": 131072, + "capabilities": [ + "images", + "video", + "files", + "tools", + "reasoning", + "structured_output", + "temperature", + "prompt-cache" + ], + "reasoningOptions": [ + { + "type": "effort", + "values": [ + "low", + "medium", + "high" + ] + } + ], + "pricing": { + "input": 1.71925, + "output": 5.84545, + "cacheRead": 0.20631, + "cacheWrite": 0 + }, + "releaseDate": "2026-04-08", + "family": "muse" + }, + "google/gemma-4-26b-a4b-it": { + "id": "google/gemma-4-26b-a4b-it", + "name": "Gemma 4 26B A4B IT", + "contextWindow": 262144, + "maxInputTokens": 262144, + "maxTokens": 128000, + "capabilities": [ + "images", + "tools", + "reasoning", + "structured_output", + "temperature", + "prompt-cache" + ], + "reasoningOptions": [], + "pricing": { + "input": 0.20631, + "output": 0.82524, + "cacheRead": 0.20631, + "cacheWrite": 0 + }, + "releaseDate": "2026-04-02", + "family": "gemma" + }, + "google/gemma-4-31b-it": { + "id": "google/gemma-4-31b-it", + "name": "Gemma 4 31B Instruct", + "contextWindow": 256000, + "maxInputTokens": 256000, + "maxTokens": 16384, + "capabilities": [ + "images", + "tools", + "reasoning", + "structured_output", + "temperature", + "prompt-cache" + ], + "reasoningOptions": [], + "pricing": { + "input": 1.361646, + "output": 2.049346, + "cacheRead": 1.361646, + "cacheWrite": 0 + }, + "releaseDate": "2026-04-02", + "family": "gemma" + }, + "z-ai/glm-5v-turbo": { + "id": "z-ai/glm-5v-turbo", + "name": "GLM 5V Turbo", + "contextWindow": 202752, + "maxInputTokens": 202752, + "maxTokens": 131072, + "capabilities": [ + "images", + "video", + "files", + "tools", + "reasoning", + "temperature", + "prompt-cache" + ], + "reasoningOptions": [], + "pricing": { + "input": 1.56, + "output": 5.2, + "cacheRead": 0.312, + "cacheWrite": 0 + }, + "releaseDate": "2026-04-01", + "family": "glm" + }, + "rekaai/reka-edge": { + "id": "rekaai/reka-edge", + "name": "Reka Edge", + "contextWindow": 16384, + "maxInputTokens": 16384, + "maxTokens": 16384, + "capabilities": [ + "images", + "video", + "tools", + "structured_output", + "temperature" + ], + "pricing": { + "input": 0.13754, + "output": 0.13754, + "cacheRead": 0, + "cacheWrite": 0 + }, + "releaseDate": "2026-03-20", + "family": "reka" + }, + "openai/gpt-5.4-mini": { + "id": "openai/gpt-5.4-mini", + "name": "GPT-5.4 Mini", + "contextWindow": 400000, + "maxInputTokens": 272000, + "maxTokens": 128000, + "capabilities": [ + "images", + "tools", + "reasoning", + "structured_output", + "temperature", + "prompt-cache" + ], + "reasoningOptions": [ + { + "type": "effort", + "values": [ + "none", + "low", + "medium", + "high" + ] + } + ], + "pricing": { + "input": 2.0631, + "output": 12.3786, + "cacheRead": 0.20631, + "cacheWrite": 0 + }, + "releaseDate": "2026-03-17", + "family": "gpt-mini" + }, + "openai/gpt-5.4-nano": { + "id": "openai/gpt-5.4-nano", + "name": "GPT-5.4 Nano", + "contextWindow": 400000, + "maxInputTokens": 272000, + "maxTokens": 128000, + "capabilities": [ + "images", + "tools", + "reasoning", + "structured_output", + "temperature", + "prompt-cache" + ], + "reasoningOptions": [ + { + "type": "effort", + "values": [ + "none", + "low", + "medium", + "high" + ] + } + ], + "pricing": { + "input": 0.27508, + "output": 1.71925, + "cacheRead": 0.027508, + "cacheWrite": 0 + }, + "releaseDate": "2026-03-17", + "family": "gpt-nano" + }, + "mistralai/mistral-small-2603": { + "id": "mistralai/mistral-small-2603", + "name": "Small 4", + "contextWindow": 262144, + "maxInputTokens": 262144, + "maxTokens": 256000, + "capabilities": [ + "images", + "tools", + "reasoning", + "temperature", + "prompt-cache" + ], + "reasoningOptions": [ + { + "type": "effort", + "values": [ + "none", + "high" + ] + } + ], + "pricing": { + "input": 0.257888, + "output": 1.03155, + "cacheRead": 0.020631, + "cacheWrite": 0 + }, + "releaseDate": "2026-03-16", + "family": "mistral-small" + }, + "z-ai/glm-5-turbo": { + "id": "z-ai/glm-5-turbo", + "name": "GLM 5 Turbo", + "contextWindow": 262144, + "maxInputTokens": 262144, + "maxTokens": 131072, + "capabilities": [ + "tools", + "reasoning", + "structured_output", + "temperature", + "prompt-cache" + ], + "reasoningOptions": [], + "pricing": { + "input": 1.56, + "output": 5.2, + "cacheRead": 0.312, + "cacheWrite": 0 + }, + "releaseDate": "2026-03-16", + "family": "glm" + }, + "x-ai/grok-4-20-0309-non-reasoning": { + "id": "x-ai/grok-4-20-0309-non-reasoning", + "name": "Grok 4.20 Non-Reasoning", + "contextWindow": 2000000, + "maxInputTokens": 2000000, + "maxTokens": 1999000, + "capabilities": [ + "images", + "files", + "tools", + "structured_output", + "temperature", + "prompt-cache" + ], + "pricing": { + "input": 1.625, + "output": 3.25, + "cacheRead": 0.26, + "cacheWrite": 0 + }, + "releaseDate": "2026-03-09", + "family": "grok" + }, + "openai/gpt-5-4": { + "id": "openai/gpt-5-4", + "name": "GPT-5.4", + "contextWindow": 1050000, + "maxInputTokens": 922000, + "maxTokens": 128000, + "capabilities": [ + "images", + "files", + "tools", + "reasoning", + "structured_output", + "temperature", + "prompt-cache" + ], + "reasoningOptions": [ + { + "type": "effort", + "values": [ + "none", + "low", + "medium", + "high" + ] + } + ], + "pricing": { + "input": 3.25, + "output": 19.5, + "cacheRead": 0.325, + "cacheWrite": 0 + }, + "releaseDate": "2026-03-05", + "family": "gpt" + }, + "openai/gpt-5.4-pro": { + "id": "openai/gpt-5.4-pro", + "name": "GPT-5.4 Pro", + "contextWindow": 1050000, + "maxInputTokens": 922000, + "maxTokens": 128000, + "capabilities": [ + "images", + "tools", + "reasoning" + ], + "reasoningOptions": [ + { + "type": "effort", + "values": [ + "medium", + "high" + ] + } + ], + "pricing": { + "input": 41.262, + "output": 247.572, + "cacheRead": 0, + "cacheWrite": 0 + }, + "releaseDate": "2026-03-05", + "family": "gpt-pro" + }, + "inception/mercury-2": { + "id": "inception/mercury-2", + "name": "Mercury 2", + "contextWindow": 128000, + "maxInputTokens": 128000, + "maxTokens": 50000, + "capabilities": [ + "tools", + "reasoning", + "structured_output", + "temperature", + "prompt-cache" + ], + "reasoningOptions": [ + { + "type": "effort", + "values": [ + "low", + "medium", + "high" + ] + } + ], + "pricing": { + "input": 0.34385, + "output": 1.03155, + "cacheRead": 0.034385, + "cacheWrite": 0 + }, + "releaseDate": "2026-03-04", + "family": "mercury" + }, + "google/gemini-3.1-flash-lite-preview": { + "id": "google/gemini-3.1-flash-lite-preview", + "name": "Gemini 3.1 Flash Lite Preview", + "contextWindow": 1000000, + "maxInputTokens": 1000000, + "maxTokens": 65536, + "capabilities": [ + "images", + "video", + "files", + "tools", + "reasoning", + "structured_output", + "temperature", + "prompt-cache" + ], + "reasoningOptions": [ + { + "type": "effort", + "values": [ + "low", + "medium", + "high" + ] + } + ], + "pricing": { + "input": 0.34385, + "output": 2.0631, + "cacheRead": 0.034385, + "cacheWrite": 0 + }, + "releaseDate": "2026-03-03", + "family": "gemini-flash-lite", + "modalities": { + "input": [ + "text", + "image", + "video", + "audio", + "pdf" + ], + "output": [ + "text" + ] + } + }, + "google/gemini-3.1-pro-preview": { + "id": "google/gemini-3.1-pro-preview", + "name": "Gemini 3.1 Pro Preview", + "contextWindow": 1000000, + "maxInputTokens": 1000000, + "maxTokens": 65536, + "capabilities": [ + "images", + "video", + "files", + "tools", + "reasoning", + "structured_output", + "temperature", + "prompt-cache" + ], + "reasoningOptions": [], + "pricing": { + "input": 2.6, + "output": 15.6, + "cacheRead": 0.65, + "cacheWrite": 0 + }, + "releaseDate": "2026-02-19", + "family": "gemini-pro", + "modalities": { + "input": [ + "text", + "image", + "video", + "audio", + "pdf" + ], + "output": [ + "text" + ] + } + }, + "google/gemini-3.1-pro-preview-customtools": { + "id": "google/gemini-3.1-pro-preview-customtools", + "name": "Gemini 3.1 Pro Preview Custom Tools", + "contextWindow": 1048756, + "maxInputTokens": 1048756, + "maxTokens": 65536, + "capabilities": [ + "images", + "video", + "files", + "tools", + "reasoning", + "structured_output", + "temperature", + "prompt-cache" + ], + "reasoningOptions": [ + { + "type": "effort", + "values": [ + "low", + "medium", + "high" + ] + } + ], + "pricing": { + "input": 2.7508, + "output": 16.5048, + "cacheRead": 0.27508, + "cacheWrite": 0 + }, + "releaseDate": "2026-02-19", + "family": "gemini-pro", + "modalities": { + "input": [ + "text", + "image", + "video", + "audio", + "pdf" + ], + "output": [ + "text" + ] + } + }, + "anthropic/claude-sonnet-4.6": { + "id": "anthropic/claude-sonnet-4.6", + "name": "Claude 4.6 Sonnet", + "contextWindow": 200000, + "maxInputTokens": 200000, + "maxTokens": 64000, + "capabilities": [ + "images", + "files", + "tools", + "reasoning", + "temperature", + "prompt-cache" + ], + "reasoningOptions": [ + { + "type": "effort", + "values": [ + "low", + "medium", + "high", + "max" + ] + } + ], + "pricing": { + "input": 4.1262, + "output": 20.631, + "cacheRead": 0.41262, + "cacheWrite": 0 + }, + "releaseDate": "2026-02-17", + "family": "claude-sonnet" + }, + "bytedance-seed/seed-2.0-code": { + "id": "bytedance-seed/seed-2.0-code", + "name": "Seed-2.0-Code", + "contextWindow": 262144, + "maxInputTokens": 262144, + "maxTokens": 131072, + "capabilities": [ + "images", + "video", + "tools", + "reasoning", + "structured_output", + "temperature" + ], + "reasoningOptions": [ + { + "type": "effort", + "values": [ + "low", + "medium", + "high" + ] + } + ], + "pricing": { + "input": 0.6877, + "output": 4.1262, + "cacheRead": 0, + "cacheWrite": 0 + }, + "releaseDate": "2026-02-14", + "family": "seed" + }, + "bytedance-seed/seed-2.0-lite": { + "id": "bytedance-seed/seed-2.0-lite", + "name": "Seed-2.0-Lite", + "contextWindow": 262144, + "maxInputTokens": 262144, + "maxTokens": 131072, + "capabilities": [ + "images", + "video", + "tools", + "reasoning", + "structured_output", + "temperature" + ], + "reasoningOptions": [ + { + "type": "effort", + "values": [ + "low", + "medium", + "high" + ] + } + ], + "pricing": { + "input": 0.34385, + "output": 2.7508, + "cacheRead": 0, + "cacheWrite": 0 + }, + "releaseDate": "2026-02-14", + "family": "seed" + }, + "bytedance-seed/seed-2.0-mini": { + "id": "bytedance-seed/seed-2.0-mini", + "name": "Seed-2.0-Mini", + "contextWindow": 262144, + "maxInputTokens": 262144, + "maxTokens": 131072, + "capabilities": [ + "images", + "video", + "tools", + "reasoning", + "structured_output", + "temperature" + ], + "reasoningOptions": [ + { + "type": "effort", + "values": [ + "low", + "medium", + "high" + ] + } + ], + "pricing": { + "input": 0.13754, + "output": 0.55016, + "cacheRead": 0, + "cacheWrite": 0 + }, + "releaseDate": "2026-02-14", + "family": "seed" + }, + "openai/gpt-5.3-codex": { + "id": "openai/gpt-5.3-codex", + "name": "GPT-5.3 Codex", + "contextWindow": 400000, + "maxInputTokens": 272000, + "maxTokens": 128000, + "capabilities": [ + "images", + "files", + "tools", + "reasoning", + "structured_output", + "temperature", + "prompt-cache" + ], + "reasoningOptions": [ + { + "type": "effort", + "values": [ + "none", + "low", + "medium", + "high" + ] + } + ], + "pricing": { + "input": 4.8139, + "output": 38.5112, + "cacheRead": 0.48139, + "cacheWrite": 0 + }, + "releaseDate": "2026-02-05", + "family": "gpt-codex" + }, + "z-ai/glm-4.7-flash": { + "id": "z-ai/glm-4.7-flash", + "name": "GLM 4.7 Flash", + "contextWindow": 202752, + "maxInputTokens": 202752, + "maxTokens": 16384, + "capabilities": [ + "tools", + "reasoning", + "temperature", + "prompt-cache" + ], + "reasoningOptions": [], + "pricing": { + "input": 0.171925, + "output": 0.6877, + "cacheRead": 0.013754, + "cacheWrite": 0 + }, + "releaseDate": "2026-01-19", + "family": "glm-flash" + }, + "upstage/solar-pro-3": { + "id": "upstage/solar-pro-3", + "name": "Solar Pro 3", + "contextWindow": 128000, + "maxInputTokens": 128000, + "maxTokens": 8192, + "capabilities": [ + "tools", + "reasoning", + "temperature", + "prompt-cache" + ], + "reasoningOptions": [ + { + "type": "effort", + "values": [ + "low", + "medium", + "high" + ] + } + ], + "pricing": { + "input": 0.20631, + "output": 0.82524, + "cacheRead": 0.020631, + "cacheWrite": 0 + }, + "releaseDate": "2026-01", + "family": "solar-pro" + }, + "x-ai/grok-4-fast-non-reasoning": { + "id": "x-ai/grok-4-fast-non-reasoning", + "name": "Grok 4 Fast Non-Reasoning", + "contextWindow": 2000000, + "maxInputTokens": 2000000, + "maxTokens": 1999000, + "capabilities": [ + "images", + "video", + "tools", + "temperature", + "prompt-cache" + ], + "pricing": { + "input": 1.625, + "output": 3.25, + "cacheRead": 0.26, + "cacheWrite": 0 + }, + "releaseDate": "2025-12-18", + "modalities": { + "input": [ + "text", + "image", + "audio", + "video" + ], + "output": [ + "text" + ] + } + }, + "openai/gpt-5-2": { + "id": "openai/gpt-5-2", + "name": "GPT-5.2", + "contextWindow": 400000, + "maxInputTokens": 272000, + "maxTokens": 128000, + "capabilities": [ + "images", + "tools", + "reasoning", + "structured_output", + "temperature", + "prompt-cache" + ], + "reasoningOptions": [ + { + "type": "effort", + "values": [ + "none", + "low", + "medium", + "high" + ] + } + ], + "pricing": { + "input": 2.275, + "output": 18.2, + "cacheRead": 0.2275, + "cacheWrite": 0 + }, + "releaseDate": "2025-12-11", + "family": "gpt" + }, + "openai/gpt-5.2-pro": { + "id": "openai/gpt-5.2-pro", + "name": "GPT-5.2 Pro", + "contextWindow": 400000, + "maxInputTokens": 272000, + "maxTokens": 128000, + "capabilities": [ + "images", + "tools", + "reasoning" + ], + "reasoningOptions": [ + { + "type": "effort", + "values": [ + "medium", + "high" + ] + } + ], + "pricing": { + "input": 28.8834, + "output": 231.0672, + "cacheRead": 0, + "cacheWrite": 0 + }, + "releaseDate": "2025-12-11", + "family": "gpt-pro" + }, + "mistralai/devstral-2512": { + "id": "mistralai/devstral-2512", + "name": "Devstral 2 2512", + "contextWindow": 262144, + "maxInputTokens": 262144, + "maxTokens": 262144, + "capabilities": [ + "tools", + "temperature", + "prompt-cache" + ], + "pricing": { + "input": 0.605176, + "output": 3.02588, + "cacheRead": 0.060518, + "cacheWrite": 0 + }, + "releaseDate": "2025-12-09", + "family": "devstral" + }, + "relace/relace-search": { + "id": "relace/relace-search", + "name": "Search", + "contextWindow": 256000, + "maxInputTokens": 256000, + "maxTokens": 128000, + "capabilities": [ + "tools", + "temperature" + ], + "pricing": { + "input": 1.3754, + "output": 4.1262, + "cacheRead": 0, + "cacheWrite": 0 + }, + "releaseDate": "2025-12-08" + }, + "z-ai/glm-4.6v": { + "id": "z-ai/glm-4.6v", + "name": "GLM 4.6V", + "contextWindow": 131072, + "maxInputTokens": 131072, + "maxTokens": 32768, + "capabilities": [ + "images", + "video", + "tools", + "reasoning", + "temperature", + "prompt-cache" + ], + "reasoningOptions": [], + "pricing": { + "input": 0.41262, + "output": 1.23786, + "cacheRead": 0.075647, + "cacheWrite": 0 + }, + "releaseDate": "2025-12-08", + "family": "glm" + }, + "mistralai/ministral-14b-2512": { + "id": "mistralai/ministral-14b-2512", + "name": "Ministral 3 14B 2512", + "contextWindow": 262144, + "maxInputTokens": 262144, + "maxTokens": 209715, + "capabilities": [ + "images", + "tools", + "structured_output", + "temperature", + "prompt-cache" + ], + "pricing": { + "input": 0.48139, + "output": 0.48139, + "cacheRead": 0.027508, + "cacheWrite": 0 + }, + "releaseDate": "2025-12-02", + "family": "ministral" + }, + "mistralai/ministral-3b-2512": { + "id": "mistralai/ministral-3b-2512", + "name": "Ministral 3 3B 2512", + "contextWindow": 131072, + "maxInputTokens": 131072, + "maxTokens": 104857, + "capabilities": [ + "images", + "tools", + "structured_output", + "temperature", + "prompt-cache" + ], + "pricing": { + "input": 0.20631, + "output": 0.20631, + "cacheRead": 0.013754, + "cacheWrite": 0 + }, + "releaseDate": "2025-12-02", + "family": "ministral" + }, + "mistralai/ministral-8b-2512": { + "id": "mistralai/ministral-8b-2512", + "name": "Ministral 3 8B 2512", + "contextWindow": 262144, + "maxInputTokens": 262144, + "maxTokens": 209715, + "capabilities": [ + "images", + "tools", + "structured_output", + "temperature", + "prompt-cache" + ], + "pricing": { + "input": 0.41262, + "output": 0.41262, + "cacheRead": 0.020631, + "cacheWrite": 0 + }, + "releaseDate": "2025-12-02", + "family": "ministral" + }, + "anthropic/claude-opus-4.5": { + "id": "anthropic/claude-opus-4.5", + "name": "Claude 4.5 Opus", + "contextWindow": 200000, + "maxInputTokens": 200000, + "maxTokens": 64000, + "capabilities": [ + "images", + "files", + "tools", + "reasoning", + "temperature", + "prompt-cache" + ], + "reasoningOptions": [ + { + "type": "effort", + "values": [ + "low", + "medium", + "high" + ] + } + ], + "pricing": { + "input": 6.877, + "output": 34.385, + "cacheRead": 0.6877, + "cacheWrite": 0 + }, + "releaseDate": "2025-11-24", + "family": "claude-opus" + }, + "openai/gpt-5-1": { + "id": "openai/gpt-5-1", + "name": "GPT-5.1", + "contextWindow": 400000, + "maxInputTokens": 272000, + "maxTokens": 128000, + "capabilities": [ + "images", + "tools", + "reasoning", + "structured_output", + "temperature", + "prompt-cache" + ], + "reasoningOptions": [ + { + "type": "effort", + "values": [ + "none", + "low", + "medium", + "high" + ] + } + ], + "pricing": { + "input": 1.625, + "output": 13, + "cacheRead": 0.1625, + "cacheWrite": 0 + }, + "releaseDate": "2025-11-13", + "family": "gpt" + }, + "openai/gpt-5.1-codex-max": { + "id": "openai/gpt-5.1-codex-max", + "name": "GPT-5.1-Codex-Max", + "contextWindow": 400000, + "maxInputTokens": 272000, + "maxTokens": 128000, + "capabilities": [ + "images", + "tools", + "reasoning", + "structured_output", + "prompt-cache" + ], + "reasoningOptions": [ + { + "type": "effort", + "values": [ + "low", + "medium", + "high" + ] + } + ], + "pricing": { + "input": 3.4385, + "output": 27.508, + "cacheRead": 0.34385, + "cacheWrite": 0 + }, + "releaseDate": "2025-11-13", + "family": "gpt-codex" + }, + "moonshotai/kimi-k2-thinking": { + "id": "moonshotai/kimi-k2-thinking", + "name": "Kimi K2 Thinking", + "contextWindow": 262144, + "maxInputTokens": 262144, + "maxTokens": 98304, + "capabilities": [ + "tools", + "reasoning", + "temperature", + "prompt-cache" + ], + "reasoningOptions": [], + "pricing": { + "input": 0.82524, + "output": 3.4385, + "cacheRead": 0.20631, + "cacheWrite": 0 + }, + "releaseDate": "2025-11-06", + "family": "kimi-thinking" + }, + "openai/gpt-oss-safeguard-20b": { + "id": "openai/gpt-oss-safeguard-20b", + "name": "gpt-oss-safeguard-20b", + "contextWindow": 131072, + "maxInputTokens": 131072, + "maxTokens": 65536, + "capabilities": [ + "tools", + "reasoning", + "structured_output", + "temperature", + "prompt-cache" + ], + "reasoningOptions": [ + { + "type": "effort", + "values": [ + "low", + "medium", + "high" + ] + } + ], + "pricing": { + "input": 0.103155, + "output": 0.41262, + "cacheRead": 0.051578, + "cacheWrite": 0 + }, + "releaseDate": "2025-10-29", + "family": "gpt-oss" + }, + "alibaba/qwen3-vl-flash": { + "id": "alibaba/qwen3-vl-flash", + "name": "Qwen3 VL Flash", + "contextWindow": 262144, + "maxInputTokens": 262144, + "maxTokens": 32768, + "capabilities": [ + "images", + "tools", + "temperature", + "prompt-cache" + ], + "pricing": { + "input": 0.065, + "output": 0.52, + "cacheRead": 0.065, + "cacheWrite": 0 + }, + "releaseDate": "2025-10-15" + }, + "anthropic/claude-haiku-4.5": { + "id": "anthropic/claude-haiku-4.5", + "name": "Claude 4.5 Haiku", + "contextWindow": 200000, + "maxInputTokens": 200000, + "maxTokens": 64000, + "capabilities": [ + "images", + "files", + "tools", + "reasoning", + "temperature", + "prompt-cache" + ], + "reasoningOptions": [], + "pricing": { + "input": 1.3754, + "output": 6.877, + "cacheRead": 0.13754, + "cacheWrite": 0 + }, + "releaseDate": "2025-10-15", + "family": "claude-haiku" + }, + "openai/gpt-5-pro": { + "id": "openai/gpt-5-pro", + "name": "GPT-5 Pro", + "contextWindow": 400000, + "maxInputTokens": 272000, + "maxTokens": 272000, + "capabilities": [ + "images", + "tools", + "reasoning", + "structured_output" + ], + "reasoningOptions": [ + { + "type": "effort", + "values": [ + "high" + ] + } + ], + "pricing": { + "input": 20.631, + "output": 165.048, + "cacheRead": 0, + "cacheWrite": 0 + }, + "releaseDate": "2025-10-06", + "family": "gpt-pro" + }, + "ibm-granite/granite-4.0-h-micro": { + "id": "ibm-granite/granite-4.0-h-micro", + "name": "Granite 4.0 Micro", + "contextWindow": 131000, + "maxInputTokens": 131000, + "maxTokens": 131000, + "capabilities": [ + "tools", + "structured_output", + "temperature" + ], + "pricing": { + "input": 0.023382, + "output": 0.154045, + "cacheRead": 0, + "cacheWrite": 0 + }, + "releaseDate": "2025-10-02", + "family": "granite" + }, + "anthropic/claude-sonnet-4.5": { + "id": "anthropic/claude-sonnet-4.5", + "name": "Claude 4.5 Sonnet", + "contextWindow": 200000, + "maxInputTokens": 200000, + "maxTokens": 64000, + "capabilities": [ + "images", + "files", + "tools", + "reasoning", + "temperature", + "prompt-cache" + ], + "reasoningOptions": [], + "pricing": { + "input": 4.1262, + "output": 20.631, + "cacheRead": 0.41262, + "cacheWrite": 0 + }, + "releaseDate": "2025-09-29", + "family": "claude-sonnet" + }, + "alibaba/qwen3-max": { + "id": "alibaba/qwen3-max", + "name": "Qwen3 Max Instruct", + "contextWindow": 262144, + "maxInputTokens": 262144, + "maxTokens": 65536, + "capabilities": [ + "tools", + "temperature", + "prompt-cache" + ], + "pricing": { + "input": 1.56, + "output": 7.8, + "cacheRead": 0.312, + "cacheWrite": 0 + }, + "releaseDate": "2025-09-23", + "family": "qwen" + }, + "alibaba/qwen3-max-preview": { + "id": "alibaba/qwen3-max-preview", + "name": "Qwen3 Max Preview", + "contextWindow": 252000, + "maxInputTokens": 252000, + "maxTokens": 32768, + "capabilities": [ + "tools", + "temperature", + "prompt-cache" + ], + "pricing": { + "input": 1.56, + "output": 7.8, + "cacheRead": 0.312, + "cacheWrite": 0 + }, + "releaseDate": "2025-09-05", + "family": "qwen" + }, + "moonshotai/kimi-k2-0905": { + "id": "moonshotai/kimi-k2-0905", + "name": "Kimi K2 0905", + "contextWindow": 262144, + "maxInputTokens": 262144, + "maxTokens": 98304, + "capabilities": [ + "tools", + "temperature" + ], + "pricing": { + "input": 1.3754, + "output": 4.1262, + "cacheRead": 0, + "cacheWrite": 0 + }, + "releaseDate": "2025-09-05", + "family": "kimi-k2" + }, + "alibaba/qwen3-next-80b-a3b-instruct": { + "id": "alibaba/qwen3-next-80b-a3b-instruct", + "name": "Qwen3 Next 80B A3B Instruct", + "contextWindow": 129024, + "maxInputTokens": 129024, + "maxTokens": 16384, + "capabilities": [ + "tools", + "temperature" + ], + "pricing": { + "input": 0.195, + "output": 1.56, + "cacheRead": 0, + "cacheWrite": 0 + }, + "releaseDate": "2025-09", + "family": "qwen" + }, + "bytedance-seed/seed-1.6-flash": { + "id": "bytedance-seed/seed-1.6-flash", + "name": "Seed 1.6 Flash", + "contextWindow": 262144, + "maxInputTokens": 262144, + "maxTokens": 32768, + "capabilities": [ + "images", + "tools", + "structured_output", + "temperature" + ], + "pricing": { + "input": 0.103155, + "output": 0.41262, + "cacheRead": 0, + "cacheWrite": 0 + }, + "releaseDate": "2025-08-28", + "family": "seed" + }, + "mistralai/mistral-medium-3.1": { + "id": "mistralai/mistral-medium-3.1", + "name": "Medium 3.1", + "contextWindow": 131072, + "maxInputTokens": 131072, + "maxTokens": 104857, + "capabilities": [ + "images", + "files", + "tools", + "structured_output", + "temperature", + "prompt-cache" + ], + "pricing": { + "input": 0.55016, + "output": 2.7508, + "cacheRead": 0.055016, + "cacheWrite": 0 + }, + "releaseDate": "2025-08-13", + "family": "mistral-medium" + }, + "z-ai/glm-4.5v": { + "id": "z-ai/glm-4.5v", + "name": "GLM 4.5V", + "contextWindow": 65536, + "maxInputTokens": 65536, + "maxTokens": 16384, + "capabilities": [ + "images", + "video", + "tools", + "reasoning", + "temperature", + "prompt-cache" + ], + "reasoningOptions": [], + "pricing": { + "input": 0.82524, + "output": 2.47572, + "cacheRead": 0.151294, + "cacheWrite": 0 + }, + "releaseDate": "2025-08-11", + "family": "glm" + }, + "openai/gpt-5": { + "id": "openai/gpt-5", + "name": "GPT-5", + "contextWindow": 400000, + "maxInputTokens": 272000, + "maxTokens": 128000, + "capabilities": [ + "images", + "tools", + "reasoning", + "structured_output", + "prompt-cache" + ], + "reasoningOptions": [ + { + "type": "effort", + "values": [ + "minimal", + "low", + "medium", + "high" + ] + } + ], + "pricing": { + "input": 1.891175, + "output": 15.1294, + "cacheRead": 1.71925, + "cacheWrite": 0 + }, + "releaseDate": "2025-08-07", + "family": "gpt" + }, + "openai/gpt-5-mini": { + "id": "openai/gpt-5-mini", + "name": "GPT-5 Mini", + "contextWindow": 400000, + "maxInputTokens": 272000, + "maxTokens": 128000, + "capabilities": [ + "images", + "tools", + "reasoning", + "structured_output", + "prompt-cache" + ], + "reasoningOptions": [ + { + "type": "effort", + "values": [ + "minimal", + "low", + "medium", + "high" + ] + } + ], + "pricing": { + "input": 0.378235, + "output": 3.02588, + "cacheRead": 0.34385, + "cacheWrite": 0 + }, + "releaseDate": "2025-08-07", + "family": "gpt-mini" + }, + "openai/gpt-5-nano": { + "id": "openai/gpt-5-nano", + "name": "GPT-5 Nano", + "contextWindow": 400000, + "maxInputTokens": 272000, + "maxTokens": 128000, + "capabilities": [ + "images", + "tools", + "reasoning", + "structured_output", + "prompt-cache" + ], + "reasoningOptions": [ + { + "type": "effort", + "values": [ + "minimal", + "low", + "medium", + "high" + ] + } + ], + "pricing": { + "input": 0.075647, + "output": 0.605176, + "cacheRead": 0.06877, + "cacheWrite": 0 + }, + "releaseDate": "2025-08-07", + "family": "gpt-nano" + }, + "openai/gpt-oss-120b": { + "id": "openai/gpt-oss-120b", + "name": "GPT OSS 120B", + "contextWindow": 131000, + "maxInputTokens": 131000, + "maxTokens": 117964, + "capabilities": [ + "tools", + "reasoning", + "structured_output", + "temperature", + "prompt-cache" + ], + "reasoningOptions": [ + { + "type": "effort", + "values": [ + "low", + "medium", + "high" + ] + } + ], + "pricing": { + "input": 0.48139, + "output": 1.30663, + "cacheRead": 0.48139, + "cacheWrite": 0 + }, + "releaseDate": "2025-08-05", + "family": "gpt-oss" + }, + "openai/gpt-oss-20b": { + "id": "openai/gpt-oss-20b", + "name": "GPT OSS 20B", + "contextWindow": 131000, + "maxInputTokens": 131000, + "maxTokens": 117964, + "capabilities": [ + "tools", + "reasoning", + "structured_output", + "temperature", + "prompt-cache" + ], + "reasoningOptions": [ + { + "type": "effort", + "values": [ + "low", + "medium", + "high" + ] + } + ], + "pricing": { + "input": 0.103155, + "output": 0.41262, + "cacheRead": 0.051578, + "cacheWrite": 0 + }, + "releaseDate": "2025-08-05", + "family": "gpt-oss" + }, + "mistralai/codestral-2508": { + "id": "mistralai/codestral-2508", + "name": "Codestral 2508", + "contextWindow": 256000, + "maxInputTokens": 256000, + "maxTokens": 8192, + "capabilities": [ + "tools", + "structured_output", + "temperature", + "prompt-cache" + ], + "pricing": { + "input": 0.41262, + "output": 1.23786, + "cacheRead": 0.041262, + "cacheWrite": 0 + }, + "releaseDate": "2025-07-30", + "family": "codestral" + }, + "mistralai/voxtral-small-24b-2507": { + "id": "mistralai/voxtral-small-24b-2507", + "name": "Voxtral Small 24B 2507", + "contextWindow": 32000, + "maxInputTokens": 32000, + "maxTokens": 32768, + "capabilities": [ + "tools", + "temperature", + "prompt-cache" + ], + "pricing": { + "input": 0.13754, + "output": 0.41262, + "cacheRead": 0.013754, + "cacheWrite": 0 + }, + "releaseDate": "2025-07-15", + "family": "voxtral", + "modalities": { + "input": [ + "text", + "audio" + ], + "output": [ + "text" + ] + } + }, + "moonshotai/kimi-k2": { + "id": "moonshotai/kimi-k2", + "name": "Kimi K2 0711", + "contextWindow": 131072, + "maxInputTokens": 131072, + "maxTokens": 98304, + "capabilities": [ + "tools", + "temperature" + ], + "pricing": { + "input": 0.783978, + "output": 3.16342, + "cacheRead": 0, + "cacheWrite": 0 + }, + "releaseDate": "2025-07-11", + "family": "kimi-k2" + }, + "mistralai/mistral-small-3.2-24b-instruct": { + "id": "mistralai/mistral-small-3.2-24b-instruct", + "name": "Small 3.2 24B", + "contextWindow": 128000, + "maxInputTokens": 128000, + "maxTokens": 16384, + "capabilities": [ + "images", + "tools", + "structured_output", + "temperature" + ], + "pricing": { + "input": 0.103155, + "output": 0.27508, + "cacheRead": 0, + "cacheWrite": 0 + }, + "releaseDate": "2025-06-20", + "family": "mistral-small" + }, + "openai/o3-pro": { + "id": "openai/o3-pro", + "name": "o3 Pro", + "contextWindow": 200000, + "maxInputTokens": 200000, + "maxTokens": 100000, + "capabilities": [ + "images", + "tools", + "reasoning", + "structured_output" + ], + "reasoningOptions": [ + { + "type": "effort", + "values": [ + "low", + "medium", + "high" + ] + } + ], + "pricing": { + "input": 27.508, + "output": 110.032, + "cacheRead": 0, + "cacheWrite": 0 + }, + "releaseDate": "2025-06-10", + "family": "o-pro" + }, + "mistralai/mistral-medium-3": { + "id": "mistralai/mistral-medium-3", + "name": "Medium 3", + "contextWindow": 131072, + "maxInputTokens": 131072, + "maxTokens": 104857, + "capabilities": [ + "images", + "files", + "tools", + "structured_output", + "temperature", + "prompt-cache" + ], + "pricing": { + "input": 0.55016, + "output": 2.7508, + "cacheRead": 0.055016, + "cacheWrite": 0 + }, + "releaseDate": "2025-05-07", + "family": "mistral-medium" + }, + "openai/gpt-4.1": { + "id": "openai/gpt-4.1", + "name": "GPT-4.1", + "contextWindow": 1047576, + "maxInputTokens": 1047576, + "maxTokens": 32768, + "capabilities": [ + "images", + "files", + "tools", + "structured_output", + "temperature", + "prompt-cache" + ], + "pricing": { + "input": 2.6, + "output": 10.4, + "cacheRead": 0.65, + "cacheWrite": 0 + }, + "releaseDate": "2025-04-14", + "family": "gpt" + }, + "openai/gpt-4.1-mini": { + "id": "openai/gpt-4.1-mini", + "name": "GPT-4.1 Mini", + "contextWindow": 1000000, + "maxInputTokens": 1000000, + "maxTokens": 32768, + "capabilities": [ + "images", + "files", + "tools", + "structured_output", + "temperature", + "prompt-cache" + ], + "pricing": { + "input": 0.52, + "output": 2.08, + "cacheRead": 0.13, + "cacheWrite": 0 + }, + "releaseDate": "2025-04-14", + "family": "gpt-mini" + }, + "openai/gpt-4.1-nano": { + "id": "openai/gpt-4.1-nano", + "name": "GPT-4.1 Nano", + "contextWindow": 1000000, + "maxInputTokens": 1000000, + "maxTokens": 32768, + "capabilities": [ + "images", + "tools", + "structured_output", + "temperature", + "prompt-cache" + ], + "pricing": { + "input": 0.13, + "output": 0.52, + "cacheRead": 0.0325, + "cacheWrite": 0 + }, + "releaseDate": "2025-04-14", + "family": "gpt-nano" + }, + "alibaba/qwen3-coder-480b-a35b-instruct": { + "id": "alibaba/qwen3-coder-480b-a35b-instruct", + "name": "Qwen3 Coder", + "contextWindow": 262000, + "maxInputTokens": 262000, + "maxTokens": 65536, + "capabilities": [ + "tools", + "temperature" + ], + "pricing": { + "input": 1.95, + "output": 9.75, + "cacheRead": 0, + "cacheWrite": 0 + }, + "releaseDate": "2025-04", + "family": "qwen" + }, + "deepseek/deepseek-chat-v3-0324": { + "id": "deepseek/deepseek-chat-v3-0324", + "name": "DeepSeek V3", + "contextWindow": 128000, + "maxInputTokens": 128000, + "maxTokens": 124000, + "capabilities": [ + "tools", + "structured_output", + "temperature", + "prompt-cache" + ], + "pricing": { + "input": 0.398866, + "output": 1.567956, + "cacheRead": 0.192556, + "cacheWrite": 0 + }, + "releaseDate": "2025-03-24", + "family": "deepseek" + }, + "openai/o1-pro": { + "id": "openai/o1-pro", + "name": "o1-pro", + "contextWindow": 200000, + "maxInputTokens": 200000, + "maxTokens": 100000, + "capabilities": [ + "images", + "tools", + "reasoning", + "structured_output" + ], + "reasoningOptions": [ + { + "type": "effort", + "values": [ + "low", + "medium", + "high" + ] + } + ], + "pricing": { + "input": 206.31, + "output": 825.24, + "cacheRead": 0, + "cacheWrite": 0 + }, + "releaseDate": "2025-03-19", + "family": "o-pro" + }, + "google/gemma-3-12b-it": { + "id": "google/gemma-3-12b-it", + "name": "Gemma 3 12B", + "contextWindow": 131000, + "maxInputTokens": 131000, + "maxTokens": 96000, + "capabilities": [ + "images", + "tools", + "temperature", + "prompt-cache" + ], + "pricing": { + "input": 0.06877, + "output": 0.20631, + "cacheRead": 0.055016, + "cacheWrite": 0 + }, + "releaseDate": "2025-03-12", + "family": "gemma" + }, + "google/gemma-3-27b-it": { + "id": "google/gemma-3-27b-it", + "name": "Gemma 3 27B", + "contextWindow": 128000, + "maxInputTokens": 128000, + "maxTokens": 117964, + "capabilities": [ + "images", + "tools", + "temperature", + "prompt-cache" + ], + "pricing": { + "input": 0.20631, + "output": 0.632684, + "cacheRead": 0.103155, + "cacheWrite": 0 + }, + "releaseDate": "2025-03-12", + "family": "gemma" + }, + "google/gemma-3-4b-it": { + "id": "google/gemma-3-4b-it", + "name": "Gemma 3 4B", + "contextWindow": 131000, + "maxInputTokens": 131000, + "maxTokens": 32768, + "capabilities": [ + "images", + "tools", + "temperature", + "prompt-cache" + ], + "pricing": { + "input": 0.06877, + "output": 0.13754, + "cacheRead": 0.055016, + "cacheWrite": 0 + }, + "releaseDate": "2025-03-12", + "family": "gemma" + }, + "mistralai/mistral-saba": { + "id": "mistralai/mistral-saba", + "name": "Saba", + "contextWindow": 32768, + "maxInputTokens": 32768, + "maxTokens": 26214, + "capabilities": [ + "files", + "tools", + "structured_output", + "temperature", + "prompt-cache" + ], + "pricing": { + "input": 0.27508, + "output": 0.82524, + "cacheRead": 0.027508, + "cacheWrite": 0 + }, + "releaseDate": "2025-02-17", + "family": "mistral" + }, + "openai/o3-mini": { + "id": "openai/o3-mini", + "name": "o3 Mini", + "contextWindow": 200000, + "maxInputTokens": 200000, + "maxTokens": 100000, + "capabilities": [ + "tools", + "reasoning", + "structured_output", + "prompt-cache" + ], + "reasoningOptions": [ + { + "type": "effort", + "values": [ + "low", + "medium", + "high" + ] + } + ], + "pricing": { + "input": 1.43, + "output": 5.72, + "cacheRead": 0.715, + "cacheWrite": 0 + }, + "releaseDate": "2024-12-20", + "family": "o-mini" + }, + "meta-llama/Llama-3.3-70B-Instruct-Turbo": { + "id": "meta-llama/Llama-3.3-70B-Instruct-Turbo", + "name": "Llama 3.3 70B Instruct Turbo", + "contextWindow": 128000, + "maxInputTokens": 128000, + "maxTokens": 127000, + "capabilities": [ + "tools", + "temperature" + ], + "pricing": { + "input": 1.352, + "output": 1.352, + "cacheRead": 0, + "cacheWrite": 0 + }, + "releaseDate": "2024-12-06", + "family": "llama" + }, + "amazon/nova-lite-v1": { + "id": "amazon/nova-lite-v1", + "name": "Nova Lite 1.0", + "contextWindow": 300000, + "maxInputTokens": 300000, + "maxTokens": 5120, + "capabilities": [ + "images", + "tools", + "temperature" + ], + "pricing": { + "input": 0.082524, + "output": 0.330096, + "cacheRead": 0, + "cacheWrite": 0 + }, + "releaseDate": "2024-12-05", + "family": "nova-lite" + }, + "amazon/nova-micro-v1": { + "id": "amazon/nova-micro-v1", + "name": "Nova Micro 1.0", + "contextWindow": 128000, + "maxInputTokens": 128000, + "maxTokens": 5120, + "capabilities": [ + "tools", + "temperature" + ], + "pricing": { + "input": 0.048139, + "output": 0.192556, + "cacheRead": 0, + "cacheWrite": 0 + }, + "releaseDate": "2024-12-05", + "family": "nova-micro" + }, + "amazon/nova-pro-v1": { + "id": "amazon/nova-pro-v1", + "name": "Nova Pro 1.0", + "contextWindow": 300000, + "maxInputTokens": 300000, + "maxTokens": 5120, + "capabilities": [ + "images", + "tools", + "temperature" + ], + "pricing": { + "input": 1.10032, + "output": 4.40128, + "cacheRead": 0, + "cacheWrite": 0 + }, + "releaseDate": "2024-12-05", + "family": "nova-pro" + }, + "openai/o1": { + "id": "openai/o1", + "name": "o1", + "contextWindow": 128000, + "maxInputTokens": 128000, + "maxTokens": 100000, + "capabilities": [ + "images", + "files", + "tools", + "reasoning", + "structured_output", + "prompt-cache" + ], + "reasoningOptions": [ + { + "type": "effort", + "values": [ + "low", + "medium", + "high" + ] + } + ], + "pricing": { + "input": 19.5, + "output": 78, + "cacheRead": 9.75, + "cacheWrite": 0 + }, + "releaseDate": "2024-12-05", + "family": "o" + }, + "cohere/command-r7b-12-2024": { + "id": "cohere/command-r7b-12-2024", + "name": "Command R7B (12-2024)", + "contextWindow": 128000, + "maxInputTokens": 128000, + "maxTokens": 4000, + "capabilities": [ + "tools", + "temperature" + ], + "pricing": { + "input": 0.051578, + "output": 0.20631, + "cacheRead": 0, + "cacheWrite": 0 + }, + "releaseDate": "2024-12-02", + "family": "command-r" + }, + "openai/gpt-4o-2024-11-20": { + "id": "openai/gpt-4o-2024-11-20", + "name": "GPT-4o", + "contextWindow": 128000, + "maxInputTokens": 128000, + "maxTokens": 16384, + "capabilities": [ + "images", + "tools", + "structured_output", + "temperature", + "prompt-cache" + ], + "pricing": { + "input": 3.25, + "output": 13, + "cacheRead": 1.625, + "cacheWrite": 0 + }, + "releaseDate": "2024-11-20", + "family": "gpt" + }, + "mistralai/mistral-large-2407": { + "id": "mistralai/mistral-large-2407", + "name": "Large 2407", + "contextWindow": 131072, + "maxInputTokens": 131072, + "maxTokens": 104857, + "capabilities": [ + "files", + "tools", + "structured_output", + "temperature", + "prompt-cache" + ], + "pricing": { + "input": 2.7508, + "output": 8.2524, + "cacheRead": 0.27508, + "cacheWrite": 0 + }, + "releaseDate": "2024-11-19", + "family": "mistral-large" + }, + "cohere/command-r-08-2024": { + "id": "cohere/command-r-08-2024", + "name": "Command R (08-2024)", + "contextWindow": 128000, + "maxInputTokens": 128000, + "maxTokens": 4000, + "capabilities": [ + "tools", + "temperature" + ], + "pricing": { + "input": 0.20631, + "output": 0.82524, + "cacheRead": 0, + "cacheWrite": 0 + }, + "releaseDate": "2024-08-30", + "family": "command-r" + }, + "cohere/command-r-plus-08-2024": { + "id": "cohere/command-r-plus-08-2024", + "name": "Command R+ (08-2024)", + "contextWindow": 128000, + "maxInputTokens": 128000, + "maxTokens": 4000, + "capabilities": [ + "tools", + "temperature" + ], + "pricing": { + "input": 3.4385, + "output": 13.754, + "cacheRead": 0, + "cacheWrite": 0 + }, + "releaseDate": "2024-08-30", + "family": "command-r" + }, + "sao10k/l3.1-euryale-70b": { + "id": "sao10k/l3.1-euryale-70b", + "name": "Llama 3.1 Euryale 70B v2.2", + "contextWindow": 131072, + "maxInputTokens": 131072, + "maxTokens": 16384, + "capabilities": [ + "tools", + "structured_output", + "temperature" + ], + "pricing": { + "input": 1.99488, + "output": 1.99488, + "cacheRead": 0, + "cacheWrite": 0 + }, + "releaseDate": "2024-08-28", + "family": "llama" + }, + "openai/gpt-4o-2024-08-06": { + "id": "openai/gpt-4o-2024-08-06", + "name": "GPT-4o", + "contextWindow": 128000, + "maxInputTokens": 128000, + "maxTokens": 16384, + "capabilities": [ + "images", + "tools", + "structured_output", + "temperature", + "prompt-cache" + ], + "pricing": { + "input": 3.25, + "output": 13, + "cacheRead": 1.625, + "cacheWrite": 0 + }, + "releaseDate": "2024-08-06", + "family": "gpt" + }, + "openai/gpt-4o-mini": { + "id": "openai/gpt-4o-mini", + "name": "GPT-4o Mini", + "contextWindow": 128000, + "maxInputTokens": 128000, + "maxTokens": 16384, + "capabilities": [ + "images", + "files", + "tools", + "structured_output", + "temperature", + "prompt-cache" + ], + "pricing": { + "input": 0.195, + "output": 0.78, + "cacheRead": 0.0975, + "cacheWrite": 0 + }, + "releaseDate": "2024-07-18", + "family": "gpt-mini" + }, + "mistralai/mistral-nemo": { + "id": "mistralai/mistral-nemo", + "name": "Mistral NeMo", + "contextWindow": 128000, + "maxInputTokens": 128000, + "maxTokens": 16384, + "capabilities": [ + "tools", + "temperature", + "prompt-cache" + ], + "pricing": { + "input": 0.20631, + "output": 0.233818, + "cacheRead": 0.20631, + "cacheWrite": 0 + }, + "releaseDate": "2024-07-01", + "family": "mistral-nemo" + }, + "openai/gpt-4o": { + "id": "openai/gpt-4o", + "name": "GPT-4o", + "contextWindow": 128000, + "maxInputTokens": 128000, + "maxTokens": 16384, + "capabilities": [ + "images", + "files", + "tools", + "structured_output", + "temperature", + "prompt-cache" + ], + "pricing": { + "input": 3.25, + "output": 13, + "cacheRead": 1.625, + "cacheWrite": 0 + }, + "releaseDate": "2024-05-13", + "family": "gpt" + }, + "openai/gpt-4o-2024-05-13": { + "id": "openai/gpt-4o-2024-05-13", + "name": "GPT-4o", + "contextWindow": 128000, + "maxInputTokens": 128000, + "maxTokens": 16384, + "capabilities": [ + "images", + "tools", + "structured_output", + "temperature" + ], + "pricing": { + "input": 6.5, + "output": 19.5, + "cacheRead": 0, + "cacheWrite": 0 + }, + "releaseDate": "2024-05-13", + "family": "gpt" + }, + "mistralai/mixtral-8x22b-instruct": { + "id": "mistralai/mixtral-8x22b-instruct", + "name": "Mixtral 8x22B Instruct", + "contextWindow": 65536, + "maxInputTokens": 65536, + "maxTokens": 52428, + "capabilities": [ + "files", + "tools", + "structured_output", + "temperature", + "prompt-cache" + ], + "pricing": { + "input": 2.7508, + "output": 8.2524, + "cacheRead": 0.27508, + "cacheWrite": 0 + }, + "releaseDate": "2024-04-17", + "family": "mistral" + }, + "alibaba/qwen-max": { + "id": "alibaba/qwen-max", + "name": "Qwen Max", + "contextWindow": 32000, + "maxInputTokens": 32000, + "maxTokens": 8192, + "capabilities": [ + "tools", + "temperature", + "prompt-cache" + ], + "pricing": { + "input": 2.08, + "output": 8.32, + "cacheRead": 0.416, + "cacheWrite": 0 + }, + "releaseDate": "2024-04-03", + "family": "qwen" + }, + "mistralai/mistral-large": { + "id": "mistralai/mistral-large", + "name": "Large", + "contextWindow": 128000, + "maxInputTokens": 128000, + "maxTokens": 102400, + "capabilities": [ + "files", + "tools", + "structured_output", + "temperature", + "prompt-cache" + ], + "pricing": { + "input": 2.7508, + "output": 8.2524, + "cacheRead": 0.27508, + "cacheWrite": 0 + }, + "releaseDate": "2024-02-26", + "family": "mistral-large" + }, + "alibaba/qwen-plus": { + "id": "alibaba/qwen-plus", + "name": "Qwen Plus", + "contextWindow": 131000, + "maxInputTokens": 131000, + "maxTokens": 16384, + "capabilities": [ + "tools", + "reasoning", + "temperature", + "prompt-cache" + ], + "reasoningOptions": [], + "pricing": { + "input": 0.52, + "output": 1.56, + "cacheRead": 0.104, + "cacheWrite": 0 + }, + "releaseDate": "2024-01-25", + "family": "qwen" + }, + "openai/gpt-4": { + "id": "openai/gpt-4", + "name": "GPT-4", + "contextWindow": 8000, + "maxInputTokens": 8000, + "maxTokens": 8192, + "capabilities": [ + "tools", + "temperature", + "prompt-cache" + ], + "pricing": { + "input": 39, + "output": 78, + "cacheRead": 39, + "cacheWrite": 0 + }, + "releaseDate": "2023-11-06", + "family": "gpt" + }, + "openai/gpt-4-turbo": { + "id": "openai/gpt-4-turbo", + "name": "GPT-4 Turbo", + "contextWindow": 128000, + "maxInputTokens": 128000, + "maxTokens": 4096, + "capabilities": [ + "images", + "tools", + "temperature" + ], + "pricing": { + "input": 13, + "output": 39, + "cacheRead": 0, + "cacheWrite": 0 + }, + "releaseDate": "2023-11-06", + "family": "gpt" + } + }, "ainetcafe": { "Kimi-K3": { "id": "Kimi-K3", @@ -11706,28 +16632,276 @@ export const GENERATED_PROVIDER_MODELS: { } }, "aixy": { - "openai/gpt-4.1-mini": { - "id": "openai/gpt-4.1-mini", - "name": "GPT-4.1 mini", - "contextWindow": 1047576, - "maxInputTokens": 1047576, - "maxTokens": 32768, + "openai/gpt-6.1-sol": { + "id": "openai/gpt-6.1-sol", + "name": "GPT-6.1 Sol", + "contextWindow": 1050000, + "maxInputTokens": 922000, + "maxTokens": 128000, "capabilities": [ "images", "files", "tools", + "reasoning", "structured_output", - "temperature", "prompt-cache" ], + "reasoningOptions": [ + { + "type": "effort", + "values": [ + "low", + "medium", + "high", + "xhigh", + "max" + ] + } + ], "pricing": { - "input": 0.4, - "output": 1.6, + "input": 2, + "output": 10, "cacheRead": 0.1, - "cacheWrite": 0 + "cacheWrite": 2.5 }, - "releaseDate": "2025-04-14", - "family": "gpt-mini" + "releaseDate": "2026-09-29", + "family": "gpt-sol", + "metadata": { + "apiProtocol": "openai-responses" + } + }, + "openai/gpt-6-luna": { + "id": "openai/gpt-6-luna", + "name": "GPT-6 Luna", + "contextWindow": 1050000, + "maxInputTokens": 922000, + "maxTokens": 128000, + "capabilities": [ + "images", + "files", + "tools", + "reasoning", + "structured_output", + "prompt-cache" + ], + "reasoningOptions": [ + { + "type": "effort", + "values": [ + "none", + "low", + "medium", + "high", + "xhigh", + "max" + ] + } + ], + "pricing": { + "input": 0.1, + "output": 0.5, + "cacheRead": 0.01, + "cacheWrite": 0.125 + }, + "releaseDate": "2026-09-22", + "family": "gpt-luna", + "metadata": { + "apiProtocol": "openai-responses" + } + }, + "openai/gpt-6-sol": { + "id": "openai/gpt-6-sol", + "name": "GPT-6 Sol", + "contextWindow": 1050000, + "maxInputTokens": 922000, + "maxTokens": 128000, + "capabilities": [ + "images", + "files", + "tools", + "reasoning", + "structured_output", + "prompt-cache" + ], + "reasoningOptions": [ + { + "type": "effort", + "values": [ + "none", + "low", + "medium", + "high", + "xhigh", + "max" + ] + } + ], + "pricing": { + "input": 2, + "output": 10, + "cacheRead": 0.2, + "cacheWrite": 2.5 + }, + "releaseDate": "2026-09-22", + "family": "gpt-sol", + "metadata": { + "apiProtocol": "openai-responses" + } + }, + "openai/gpt-6-astra": { + "id": "openai/gpt-6-astra", + "name": "GPT-6 Astra", + "contextWindow": 1050000, + "maxInputTokens": 922000, + "maxTokens": 128000, + "capabilities": [ + "images", + "files", + "tools", + "reasoning", + "structured_output", + "prompt-cache" + ], + "reasoningOptions": [ + { + "type": "effort", + "values": [ + "low", + "medium", + "high", + "xhigh", + "max" + ] + } + ], + "pricing": { + "input": 10, + "output": 50, + "cacheRead": 1, + "cacheWrite": 12.5 + }, + "releaseDate": "2026-09-04", + "family": "gpt-astra", + "metadata": { + "apiProtocol": "openai-responses" + } + }, + "openai/gpt-5.6-luna": { + "id": "openai/gpt-5.6-luna", + "name": "GPT-5.6 Luna", + "contextWindow": 1050000, + "maxInputTokens": 922000, + "maxTokens": 128000, + "capabilities": [ + "images", + "files", + "tools", + "reasoning", + "structured_output", + "prompt-cache" + ], + "reasoningOptions": [ + { + "type": "effort", + "values": [ + "none", + "low", + "medium", + "high", + "xhigh", + "max" + ] + } + ], + "pricing": { + "input": 0.2, + "output": 1.2, + "cacheRead": 0.02, + "cacheWrite": 0.25 + }, + "releaseDate": "2026-07-09", + "family": "gpt-luna", + "metadata": { + "apiProtocol": "openai-responses" + } + }, + "openai/gpt-5.6-sol": { + "id": "openai/gpt-5.6-sol", + "name": "GPT-5.6 Sol", + "contextWindow": 1050000, + "maxInputTokens": 922000, + "maxTokens": 128000, + "capabilities": [ + "images", + "files", + "tools", + "reasoning", + "structured_output", + "prompt-cache" + ], + "reasoningOptions": [ + { + "type": "effort", + "values": [ + "none", + "low", + "medium", + "high", + "xhigh", + "max" + ] + } + ], + "pricing": { + "input": 4, + "output": 20, + "cacheRead": 0.4, + "cacheWrite": 5 + }, + "releaseDate": "2026-07-09", + "family": "gpt-sol", + "metadata": { + "apiProtocol": "openai-responses" + } + }, + "openai/gpt-5.6-terra": { + "id": "openai/gpt-5.6-terra", + "name": "GPT-5.6 Terra", + "contextWindow": 1050000, + "maxInputTokens": 922000, + "maxTokens": 128000, + "capabilities": [ + "images", + "files", + "tools", + "reasoning", + "structured_output", + "prompt-cache" + ], + "reasoningOptions": [ + { + "type": "effort", + "values": [ + "none", + "low", + "medium", + "high", + "xhigh", + "max" + ] + } + ], + "pricing": { + "input": 2, + "output": 12, + "cacheRead": 0.2, + "cacheWrite": 2.5 + }, + "releaseDate": "2026-07-09", + "family": "gpt-terra", + "metadata": { + "apiProtocol": "openai-responses" + } } }, "aki-io": { @@ -17089,6 +22263,27 @@ export const GENERATED_PROVIDER_MODELS: { } }, "ambient": { + "qwen/qwen3-30b-a3b-instruct-2507": { + "id": "qwen/qwen3-30b-a3b-instruct-2507", + "name": "Qwen3 30B A3B Instruct 2507", + "contextWindow": 131072, + "maxInputTokens": 131072, + "maxTokens": 32768, + "capabilities": [ + "tools", + "structured_output", + "temperature", + "prompt-cache" + ], + "pricing": { + "input": 0.09, + "output": 0.3, + "cacheRead": 0.09, + "cacheWrite": 0 + }, + "releaseDate": "2026-10-09", + "family": "qwen" + }, "qwen/qwen3.8-27b": { "id": "qwen/qwen3.8-27b", "name": "Qwen3.8 27B", @@ -17351,7 +22546,6 @@ export const GENERATED_PROVIDER_MODELS: { "maxInputTokens": 32768, "maxTokens": 8192, "capabilities": [ - "images", "tools", "reasoning", "structured_output", @@ -17436,6 +22630,29 @@ export const GENERATED_PROVIDER_MODELS: { "releaseDate": "2026-04-21", "family": "kimi-k2" }, + "qwen/qwen3.6-35b-a3b": { + "id": "qwen/qwen3.6-35b-a3b", + "name": "Qwen3.6 35B-A3B", + "contextWindow": 262144, + "maxInputTokens": 262144, + "maxTokens": 262144, + "capabilities": [ + "tools", + "reasoning", + "structured_output", + "temperature", + "prompt-cache" + ], + "reasoningOptions": [], + "pricing": { + "input": 0.15, + "output": 1, + "cacheRead": 0.15, + "cacheWrite": 0 + }, + "releaseDate": "2026-04-17", + "family": "qwen" + }, "zai-org/GLM-5.1-FP8": { "id": "zai-org/GLM-5.1-FP8", "name": "GLM 5.1", @@ -19936,7 +25153,7 @@ export const GENERATED_PROVIDER_MODELS: { }, "deepseek-ai/DeepSeek-V4.1-Flash-Fast": { "id": "deepseek-ai/DeepSeek-V4.1-Flash-Fast", - "name": "deepseek-ai/DeepSeek-V4.1-Flash-Fast", + "name": "DeepSeek V4.1 Flash Fast", "contextWindow": 1048576, "maxInputTokens": 1048576, "maxTokens": 32768, @@ -27167,9 +32384,9 @@ export const GENERATED_PROVIDER_MODELS: { ], "reasoningOptions": [], "pricing": { - "input": 0.24, - "output": 2.2, - "cacheRead": 0.023999999999999994, + "input": 0.039, + "output": 1.25, + "cacheRead": 0.003899999999999999, "cacheWrite": 0 }, "releaseDate": "2026-08-16", @@ -27223,9 +32440,9 @@ export const GENERATED_PROVIDER_MODELS: { } ], "pricing": { - "input": 3, - "output": 15, - "cacheRead": 0.29999999999999993, + "input": 1.85, + "output": 9.5, + "cacheRead": 0.18499999999999997, "cacheWrite": 0 }, "releaseDate": "2026-07-29", @@ -27278,9 +32495,9 @@ export const GENERATED_PROVIDER_MODELS: { } ], "pricing": { - "input": 0.3, + "input": 0.25, "output": 2, - "cacheRead": 0.029999999999999992, + "cacheRead": 0.024999999999999994, "cacheWrite": 0 }, "releaseDate": "2026-04-22", @@ -27307,9 +32524,9 @@ export const GENERATED_PROVIDER_MODELS: { } ], "pricing": { - "input": 0.5, - "output": 2.85, - "cacheRead": 0.04999999999999999, + "input": 0.45, + "output": 1.95, + "cacheRead": 0.04499999999999999, "cacheWrite": 0 }, "releaseDate": "2026-04-21", @@ -27777,13 +32994,15 @@ export const GENERATED_PROVIDER_MODELS: { } }, "cline": { - "cline-free/solar-mini4": { - "id": "cline-free/solar-mini4", - "name": "Solar Mini 4 (free)", - "contextWindow": 524288, - "maxInputTokens": 524288, - "maxTokens": 131072, + "cline-free/step-5-preview": { + "id": "cline-free/step-5-preview", + "name": "Step 5 Preview (free)", + "contextWindow": 1000000, + "maxInputTokens": 1000000, + "maxTokens": 64000, "capabilities": [ + "images", + "video", "tools", "reasoning", "structured_output", @@ -27794,13 +33013,9 @@ export const GENERATED_PROVIDER_MODELS: { { "type": "effort", "values": [ - "none", - "minimal", "low", "medium", - "high", - "xhigh", - "max" + "high" ] } ], @@ -27810,9 +33025,8 @@ export const GENERATED_PROVIDER_MODELS: { "cacheRead": 0, "cacheWrite": 0 }, - "releaseDate": "2026-09-23", - "family": "solar", - "description": "Compact 35-billion parameter mixture-of-experts" + "releaseDate": "2026-09-16", + "description": "StepFun's flagship model for agentic work, built on a sparse Mixture-of-Experts" }, "cline-free/mimo-v2.6-flash": { "id": "cline-free/mimo-v2.6-flash", @@ -27893,6 +33107,43 @@ export const GENERATED_PROVIDER_MODELS: { "releaseDate": "2026-09-02", "family": "muse", "description": "Meta’s multimodal reasoning model for experimentation, learning, and early-stage agentic, multi-agent, and coding workflows." + }, + "cline-free/solar-mini4": { + "id": "cline-free/solar-mini4", + "name": "Solar Mini 4 (free)", + "contextWindow": 524288, + "maxInputTokens": 524288, + "maxTokens": 131072, + "capabilities": [ + "tools", + "reasoning", + "structured_output", + "temperature", + "prompt-cache" + ], + "reasoningOptions": [ + { + "type": "effort", + "values": [ + "none", + "minimal", + "low", + "medium", + "high", + "xhigh", + "max" + ] + } + ], + "pricing": { + "input": 0, + "output": 0, + "cacheRead": 0, + "cacheWrite": 0 + }, + "releaseDate": "2026-09-23", + "family": "solar", + "description": "Compact 35-billion parameter mixture-of-experts" } }, "cline-pass": { @@ -28184,7 +33435,7 @@ export const GENERATED_PROVIDER_MODELS: { "id": "cline-pass/glm-5.3-flash", "contextWindow": 1048576, "maxInputTokens": 1048576, - "maxTokens": 943717, + "maxTokens": 943718, "capabilities": [ "images", "video", @@ -28214,34 +33465,6 @@ export const GENERATED_PROVIDER_MODELS: { "family": "glm-flash", "description": "Latest natively multimodal model in the GLM-5 series" }, - "cline-pass/qwen3.7-max": { - "name": "Qwen3.7 Max", - "id": "cline-pass/qwen3.7-max", - "contextWindow": 1000000, - "maxInputTokens": 1000000, - "maxTokens": 131072, - "capabilities": [ - "tools", - "reasoning", - "structured_output", - "temperature", - "prompt-cache" - ], - "reasoningOptions": [ - { - "type": "toggle" - } - ], - "pricing": { - "input": 0, - "output": 0, - "cacheRead": 0, - "cacheWrite": 0 - }, - "releaseDate": "2026-05-21", - "family": "qwen", - "description": "Flagship agent model with 1M context window" - }, "cline-pass/minimax-m3": { "name": "MiniMax-M3", "id": "cline-pass/minimax-m3", @@ -28272,6 +33495,34 @@ export const GENERATED_PROVIDER_MODELS: { "family": "minimax", "description": "Frontier coding and agent model with 1M context window" }, + "cline-pass/qwen3.7-max": { + "name": "Qwen3.7 Max", + "id": "cline-pass/qwen3.7-max", + "contextWindow": 1000000, + "maxInputTokens": 1000000, + "maxTokens": 131072, + "capabilities": [ + "tools", + "reasoning", + "structured_output", + "temperature", + "prompt-cache" + ], + "reasoningOptions": [ + { + "type": "toggle" + } + ], + "pricing": { + "input": 0, + "output": 0, + "cacheRead": 0, + "cacheWrite": 0 + }, + "releaseDate": "2026-05-21", + "family": "qwen", + "description": "Flagship agent model with 1M context window" + }, "cline-pass/qwen3.7-plus": { "name": "Qwen3.7 Plus", "id": "cline-pass/qwen3.7-plus", @@ -28370,13 +33621,15 @@ export const GENERATED_PROVIDER_MODELS: { }, "description": "Fast and efficient MiMo for everyday coding" }, - "cline-free/solar-mini4": { - "id": "cline-free/solar-mini4", - "name": "Solar Mini 4 (free)", - "contextWindow": 524288, - "maxInputTokens": 524288, - "maxTokens": 131072, + "cline-free/step-5-preview": { + "id": "cline-free/step-5-preview", + "name": "Step 5 Preview (free)", + "contextWindow": 1000000, + "maxInputTokens": 1000000, + "maxTokens": 64000, "capabilities": [ + "images", + "video", "tools", "reasoning", "structured_output", @@ -28387,13 +33640,9 @@ export const GENERATED_PROVIDER_MODELS: { { "type": "effort", "values": [ - "none", - "minimal", "low", "medium", - "high", - "xhigh", - "max" + "high" ] } ], @@ -28403,9 +33652,8 @@ export const GENERATED_PROVIDER_MODELS: { "cacheRead": 0, "cacheWrite": 0 }, - "releaseDate": "2026-09-23", - "family": "solar", - "description": "Compact 35-billion parameter mixture-of-experts" + "releaseDate": "2026-09-16", + "description": "StepFun's flagship model for agentic work, built on a sparse Mixture-of-Experts" }, "cline-free/mimo-v2.6-flash": { "id": "cline-free/mimo-v2.6-flash", @@ -28486,9 +33734,76 @@ export const GENERATED_PROVIDER_MODELS: { "releaseDate": "2026-09-02", "family": "muse", "description": "Meta’s multimodal reasoning model for experimentation, learning, and early-stage agentic, multi-agent, and coding workflows." + }, + "cline-free/solar-mini4": { + "id": "cline-free/solar-mini4", + "name": "Solar Mini 4 (free)", + "contextWindow": 524288, + "maxInputTokens": 524288, + "maxTokens": 131072, + "capabilities": [ + "tools", + "reasoning", + "structured_output", + "temperature", + "prompt-cache" + ], + "reasoningOptions": [ + { + "type": "effort", + "values": [ + "none", + "minimal", + "low", + "medium", + "high", + "xhigh", + "max" + ] + } + ], + "pricing": { + "input": 0, + "output": 0, + "cacheRead": 0, + "cacheWrite": 0 + }, + "releaseDate": "2026-09-23", + "family": "solar", + "description": "Compact 35-billion parameter mixture-of-experts" } }, "cloudferro-sherlock": { + "zai-org/GLM-5.3": { + "id": "zai-org/GLM-5.3", + "name": "GLM-5.3", + "contextWindow": 262144, + "maxInputTokens": 262144, + "maxTokens": 131072, + "capabilities": [ + "tools", + "reasoning", + "structured_output", + "temperature" + ], + "reasoningOptions": [ + { + "type": "effort", + "values": [ + "low", + "high" + ] + } + ], + "pricing": { + "input": 0.34, + "output": 4.48, + "cacheRead": 0, + "cacheWrite": 0 + }, + "releaseDate": "2026-08-14", + "family": "glm" + }, "MiniMaxAI/MiniMax-M2.5": { "id": "MiniMaxAI/MiniMax-M2.5", "name": "MiniMax-M2.5", @@ -28542,25 +33857,6 @@ export const GENERATED_PROVIDER_MODELS: { "releaseDate": "2025-08-28", "family": "gpt-oss" }, - "speakleash/Bielik-11B-v2.6-Instruct": { - "id": "speakleash/Bielik-11B-v2.6-Instruct", - "name": "Bielik 11B v2.6 Instruct", - "contextWindow": 32000, - "maxInputTokens": 32000, - "maxTokens": 32000, - "capabilities": [ - "tools", - "structured_output", - "temperature" - ], - "pricing": { - "input": 0.67, - "output": 0.67, - "cacheRead": 0, - "cacheWrite": 0 - }, - "releaseDate": "2025-03-13" - }, "speakleash/Bielik-11B-v3.0-Instruct": { "id": "speakleash/Bielik-11B-v3.0-Instruct", "name": "Bielik 11B v3.0 Instruct", @@ -29077,6 +34373,7 @@ export const GENERATED_PROVIDER_MODELS: { "maxInputTokens": 128000, "maxTokens": 128000, "capabilities": [ + "images", "tools", "temperature" ], @@ -29136,7 +34433,7 @@ export const GENERATED_PROVIDER_MODELS: { } ], "pricing": { - "input": 0.3, + "input": 0.01, "output": 1.2, "cacheRead": 0, "cacheWrite": 0.09 @@ -29170,7 +34467,7 @@ export const GENERATED_PROVIDER_MODELS: { } ], "pricing": { - "input": 0.3, + "input": 0.01, "output": 1.2, "cacheRead": 0, "cacheWrite": 0.09 @@ -29244,6 +34541,39 @@ export const GENERATED_PROVIDER_MODELS: { } }, "cortecs": { + "claude-haiku-5.5": { + "id": "claude-haiku-5.5", + "name": "Claude Haiku 5.5", + "contextWindow": 1000000, + "maxInputTokens": 1000000, + "maxTokens": 128000, + "capabilities": [ + "images", + "files", + "tools", + "reasoning", + "structured_output", + "prompt-cache" + ], + "reasoningOptions": [ + { + "type": "effort", + "values": [ + "low", + "medium", + "high" + ] + } + ], + "pricing": { + "input": 0.113, + "output": 0.563, + "cacheRead": 0.011, + "cacheWrite": 0.14 + }, + "releaseDate": "2026-10-07", + "family": "claude-haiku" + }, "mistral-large-4-0": { "id": "mistral-large-4-0", "name": "Mistral Large 4", @@ -31814,8 +37144,8 @@ export const GENERATED_PROVIDER_MODELS: { "prompt-cache" ], "pricing": { - "input": 0.067, - "output": 0.245, + "input": 0.179, + "output": 0.697, "cacheRead": 0.014, "cacheWrite": 0 }, @@ -32763,6 +38093,43 @@ export const GENERATED_PROVIDER_MODELS: { } }, "crossmodel": { + "anthropic/claude-haiku-5-5": { + "id": "anthropic/claude-haiku-5-5", + "name": "Claude Haiku 5.5", + "contextWindow": 1000000, + "maxInputTokens": 1000000, + "maxTokens": 128000, + "capabilities": [ + "images", + "tools", + "reasoning", + "structured_output", + "prompt-cache" + ], + "reasoningOptions": [ + { + "type": "toggle" + }, + { + "type": "effort", + "values": [ + "low", + "medium", + "high", + "xhigh", + "max" + ] + } + ], + "pricing": { + "input": 0.1, + "output": 0.5, + "cacheRead": 0.01, + "cacheWrite": 0.125 + }, + "releaseDate": "2026-10-07", + "family": "claude-haiku" + }, "openai/gpt-6.1-sol": { "id": "openai/gpt-6.1-sol", "name": "GPT-6.1 Sol", @@ -32824,7 +38191,7 @@ export const GENERATED_PROVIDER_MODELS: { "pricing": { "input": 2, "output": 10, - "cacheRead": 0.2, + "cacheRead": 0.1, "cacheWrite": 2.5 }, "releaseDate": "2026-09-28", @@ -36872,6 +42239,40 @@ export const GENERATED_PROVIDER_MODELS: { } }, "digitalocean": { + "anthropic-claude-5.5-haiku": { + "id": "anthropic-claude-5.5-haiku", + "name": "Anthropic Claude 5.5 Haiku", + "contextWindow": 1000000, + "maxInputTokens": 1000000, + "maxTokens": 128000, + "capabilities": [ + "images", + "tools", + "reasoning", + "structured_output", + "prompt-cache" + ], + "reasoningOptions": [ + { + "type": "effort", + "values": [ + "low", + "medium", + "high", + "xhigh", + "max" + ] + } + ], + "pricing": { + "input": 0.1, + "output": 0.5, + "cacheRead": 0.01, + "cacheWrite": 0.125 + }, + "releaseDate": "2026-10-07", + "family": "claude-haiku" + }, "openai-gpt-6-1-sol": { "id": "openai-gpt-6-1-sol", "name": "OpenAI GPT-6.1 Sol", @@ -38173,7 +43574,6 @@ export const GENERATED_PROVIDER_MODELS: { "maxInputTokens": 65536, "maxTokens": 65536, "capabilities": [ - "images", "tools", "reasoning", "temperature", @@ -40234,9 +45634,9 @@ export const GENERATED_PROVIDER_MODELS: { } ], "pricing": { - "input": 0.14, - "output": 0.42, - "cacheRead": 0.021, + "input": 0.09, + "output": 0.18, + "cacheRead": 0.0135, "cacheWrite": 0 }, "releaseDate": "2026-09-10", @@ -40275,6 +45675,39 @@ export const GENERATED_PROVIDER_MODELS: { "releaseDate": "2026-09-10", "family": "deepseek-flash" }, + "greenpt/deepseek-v4.1-flash": { + "id": "greenpt/deepseek-v4.1-flash", + "name": "DeepSeek V4.1 Flash (Greenpt)", + "contextWindow": 1000000, + "maxInputTokens": 1000000, + "maxTokens": 384000, + "capabilities": [ + "images", + "tools", + "reasoning", + "temperature", + "prompt-cache" + ], + "reasoningOptions": [ + { + "type": "effort", + "values": [ + "none", + "low", + "high", + "max" + ] + } + ], + "pricing": { + "input": 0.245982, + "output": 1.229911, + "cacheRead": 0.012299, + "cacheWrite": 0 + }, + "releaseDate": "2026-09-10", + "family": "deepseek-flash" + }, "nebius/deepseek-ai/DeepSeek-V4.1-Flash": { "id": "nebius/deepseek-ai/DeepSeek-V4.1-Flash", "name": "DeepSeek V4.1 Flash (Nebius)", @@ -40342,6 +45775,38 @@ export const GENERATED_PROVIDER_MODELS: { "releaseDate": "2026-09-10", "family": "deepseek-flash" }, + "scaleway/deepseek-v4.1-flash": { + "id": "scaleway/deepseek-v4.1-flash", + "name": "DeepSeek V4.1 Flash (Scaleway)", + "contextWindow": 256000, + "maxInputTokens": 256000, + "maxTokens": 384000, + "capabilities": [ + "tools", + "reasoning", + "temperature", + "prompt-cache" + ], + "reasoningOptions": [ + { + "type": "effort", + "values": [ + "none", + "low", + "high", + "max" + ] + } + ], + "pricing": { + "input": 0.67086, + "output": 2.012581, + "cacheRead": 0.134172, + "cacheWrite": 0 + }, + "releaseDate": "2026-09-10", + "family": "deepseek-flash" + }, "tensorx/deepseek/deepseek-v4.1-flash": { "id": "tensorx/deepseek/deepseek-v4.1-flash", "name": "DeepSeek V4.1 Flash (TensorX)", @@ -41586,6 +47051,39 @@ export const GENERATED_PROVIDER_MODELS: { "releaseDate": "2026-08-10", "family": "muse" }, + "flexai/Muse-Glimmer-30B": { + "id": "flexai/Muse-Glimmer-30B", + "name": "Muse Glimmer 30B (FlexAI)", + "contextWindow": 131072, + "maxInputTokens": 131072, + "maxTokens": 131072, + "capabilities": [ + "images", + "tools", + "reasoning", + "temperature", + "prompt-cache" + ], + "reasoningOptions": [ + { + "type": "effort", + "values": [ + "low", + "medium", + "high", + "xhigh" + ] + } + ], + "pricing": { + "input": 0.35, + "output": 1.5, + "cacheRead": 0.0525, + "cacheWrite": 0 + }, + "releaseDate": "2026-08-10", + "family": "muse" + }, "together_ai/meta-models/Muse-Glimmer-30B": { "id": "together_ai/meta-models/Muse-Glimmer-30B", "name": "Muse Glimmer 30B (Together AI)", @@ -41778,9 +47276,42 @@ export const GENERATED_PROVIDER_MODELS: { } ], "pricing": { - "input": 0.06, - "output": 0.18, - "cacheRead": 0.009, + "input": 0.0192, + "output": 0.27, + "cacheRead": 0.0029, + "cacheWrite": 0 + }, + "releaseDate": "2026-07-31", + "family": "deepseek-flash" + }, + "greenpt/deepseek-v4-flash-0731": { + "id": "greenpt/deepseek-v4-flash-0731", + "name": "DeepSeek V4 Flash 0731 (Greenpt)", + "contextWindow": 1000000, + "maxInputTokens": 1000000, + "maxTokens": 384000, + "capabilities": [ + "tools", + "reasoning", + "structured_output", + "temperature", + "prompt-cache" + ], + "reasoningOptions": [ + { + "type": "effort", + "values": [ + "none", + "low", + "high", + "max" + ] + } + ], + "pricing": { + "input": 0.156534, + "output": 0.391335, + "cacheRead": 0.044724, "cacheWrite": 0 }, "releaseDate": "2026-07-31", @@ -41876,9 +47407,9 @@ export const GENERATED_PROVIDER_MODELS: { } ], "pricing": { - "input": 0.44708, - "output": 0.89416, - "cacheRead": 0.089416, + "input": 0.44724, + "output": 0.89448, + "cacheRead": 0.089448, "cacheWrite": 0 }, "releaseDate": "2026-07-31", @@ -43482,6 +49013,39 @@ export const GENERATED_PROVIDER_MODELS: { ] } }, + "mistral/mistral-medium-3-5": { + "id": "mistral/mistral-medium-3-5", + "name": "Mistral Medium 3.5", + "contextWindow": 262144, + "maxInputTokens": 262144, + "maxTokens": 209715, + "capabilities": [ + "images", + "files", + "tools", + "reasoning", + "structured_output", + "temperature", + "prompt-cache" + ], + "reasoningOptions": [ + { + "type": "effort", + "values": [ + "none", + "high" + ] + } + ], + "pricing": { + "input": 1.5, + "output": 7.5, + "cacheRead": 0.15, + "cacheWrite": 0 + }, + "releaseDate": "2026-04-30", + "family": "mistral-medium" + }, "mistral/mistral-medium-2604": { "id": "mistral/mistral-medium-2604", "name": "Mistral Medium 3.5", @@ -44746,9 +50310,9 @@ export const GENERATED_PROVIDER_MODELS: { "releaseDate": "2026-02-05", "family": "gpt-codex" }, - "qwen/qwen3-coder-next": { - "id": "qwen/qwen3-coder-next", - "name": "Qwen3 Coder Next", + "amazon/qwen.qwen3-coder-next": { + "id": "amazon/qwen.qwen3-coder-next", + "name": "Qwen3 Coder Next (Amazon Bedrock)", "contextWindow": 262144, "maxInputTokens": 262144, "maxTokens": 65536, @@ -44758,28 +50322,8 @@ export const GENERATED_PROVIDER_MODELS: { "temperature" ], "pricing": { - "input": 0.3, - "output": 1.5, - "cacheRead": 0, - "cacheWrite": 0 - }, - "releaseDate": "2026-02-03", - "family": "qwen" - }, - "qwen/qwen3-coder-next@eu": { - "id": "qwen/qwen3-coder-next@eu", - "name": "Qwen3 Coder Next (EU)", - "contextWindow": 262144, - "maxInputTokens": 262144, - "maxTokens": 65536, - "capabilities": [ - "tools", - "structured_output", - "temperature" - ], - "pricing": { - "input": 0.3, - "output": 1.5, + "input": 0.6, + "output": 1.44, "cacheRead": 0, "cacheWrite": 0 }, @@ -45019,6 +50563,25 @@ export const GENERATED_PROVIDER_MODELS: { "releaseDate": "2025-12-22", "family": "glm" }, + "xai/grok-4-fast-non-reasoning": { + "id": "xai/grok-4-fast-non-reasoning", + "name": "X-Ai/Grok-4-Fast-Non-Reasoning", + "contextWindow": 2000000, + "maxInputTokens": 2000000, + "maxTokens": 2000000, + "capabilities": [ + "tools", + "temperature", + "prompt-cache" + ], + "pricing": { + "input": 0.2, + "output": 0.5, + "cacheRead": 0.05, + "cacheWrite": 0 + }, + "releaseDate": "2025-12-18" + }, "google/gemini-3-flash-preview": { "id": "google/gemini-3-flash-preview", "name": "Gemini 3 Flash Preview", @@ -45306,6 +50869,72 @@ export const GENERATED_PROVIDER_MODELS: { "releaseDate": "2025-12-02", "family": "devstral" }, + "mistral/ministral-14b-2512": { + "id": "mistral/ministral-14b-2512", + "name": "Ministral 3 14B 2512", + "contextWindow": 262144, + "maxInputTokens": 262144, + "maxTokens": 209715, + "capabilities": [ + "images", + "tools", + "structured_output", + "temperature", + "prompt-cache" + ], + "pricing": { + "input": 0.2, + "output": 0.2, + "cacheRead": 0.02, + "cacheWrite": 0 + }, + "releaseDate": "2025-12-02", + "family": "ministral" + }, + "mistral/ministral-3b-2512": { + "id": "mistral/ministral-3b-2512", + "name": "Ministral 3 3B 2512", + "contextWindow": 131072, + "maxInputTokens": 131072, + "maxTokens": 104857, + "capabilities": [ + "images", + "tools", + "structured_output", + "temperature", + "prompt-cache" + ], + "pricing": { + "input": 0.1, + "output": 0.1, + "cacheRead": 0.01, + "cacheWrite": 0 + }, + "releaseDate": "2025-12-02", + "family": "ministral" + }, + "mistral/ministral-8b-2512": { + "id": "mistral/ministral-8b-2512", + "name": "Ministral 3 8B 2512", + "contextWindow": 262144, + "maxInputTokens": 262144, + "maxTokens": 209715, + "capabilities": [ + "images", + "tools", + "structured_output", + "temperature", + "prompt-cache" + ], + "pricing": { + "input": 0.15, + "output": 0.15, + "cacheRead": 0.015, + "cacheWrite": 0 + }, + "releaseDate": "2025-12-02", + "family": "ministral" + }, "mistral/mistral-large-2512": { "id": "mistral/mistral-large-2512", "name": "Mistral Large 3", @@ -45721,6 +51350,27 @@ export const GENERATED_PROVIDER_MODELS: { "releaseDate": "2025-10-06", "family": "gpt-pro" }, + "deepinfra/Qwen/Qwen3-VL-30B-A3B-Instruct": { + "id": "deepinfra/Qwen/Qwen3-VL-30B-A3B-Instruct", + "name": "Qwen3 VL 30B A3B Instruct (Deep Infra)", + "contextWindow": 262144, + "maxInputTokens": 262144, + "maxTokens": 32768, + "capabilities": [ + "images", + "tools", + "structured_output", + "temperature" + ], + "pricing": { + "input": 0.15, + "output": 0.6, + "cacheRead": 0, + "cacheWrite": 0 + }, + "releaseDate": "2025-10-04", + "family": "qwen" + }, "zai/glm-4.6": { "id": "zai/glm-4.6", "name": "GLM-4.6", @@ -45743,11 +51393,11 @@ export const GENERATED_PROVIDER_MODELS: { "releaseDate": "2025-09-30", "family": "glm" }, - "qwen/qwen3-max": { - "id": "qwen/qwen3-max", - "name": "Qwen3 Max", - "contextWindow": 262144, - "maxInputTokens": 262144, + "deepinfra/Qwen/Qwen3-Max": { + "id": "deepinfra/Qwen/Qwen3-Max", + "name": "Qwen3 Max (Deep Infra)", + "contextWindow": 256000, + "maxInputTokens": 256000, "maxTokens": 65536, "capabilities": [ "tools", @@ -45759,114 +51409,87 @@ export const GENERATED_PROVIDER_MODELS: { "input": 1.2, "output": 6, "cacheRead": 0.24, - "cacheWrite": 1.5 + "cacheWrite": 0 }, "releaseDate": "2025-09-23", "family": "qwen" }, - "qwen/qwen3-max@eu": { - "id": "qwen/qwen3-max@eu", - "name": "Qwen3 Max (EU)", + "deepinfra/Qwen/Qwen3-VL-235B-A22B-Instruct": { + "id": "deepinfra/Qwen/Qwen3-VL-235B-A22B-Instruct", + "name": "Qwen3 VL 235B A22B Instruct (Deep Infra)", "contextWindow": 262144, "maxInputTokens": 262144, - "maxTokens": 65536, + "maxTokens": 32768, "capabilities": [ + "images", "tools", "structured_output", "temperature", "prompt-cache" ], "pricing": { - "input": 1.2, - "output": 6, - "cacheRead": 0.24, - "cacheWrite": 1.5 - }, - "releaseDate": "2025-09-23", - "family": "qwen" - }, - "qwen/qwen3-vl-235b-a22b-instruct": { - "id": "qwen/qwen3-vl-235b-a22b-instruct", - "name": "Qwen3 VL 235B A22B Instruct", - "contextWindow": 131072, - "maxInputTokens": 131072, - "maxTokens": 32768, - "capabilities": [ - "images", - "video", - "tools", - "structured_output", - "temperature" - ], - "pricing": { - "input": 0.4, - "output": 1.6, - "cacheRead": 0, + "input": 0.2, + "output": 0.88, + "cacheRead": 0.11, "cacheWrite": 0 }, "releaseDate": "2025-09-23", "family": "qwen" }, - "qwen/qwen3-vl-235b-a22b-thinking": { - "id": "qwen/qwen3-vl-235b-a22b-thinking", - "name": "Qwen3 VL 235B A22B Thinking", - "contextWindow": 131072, - "maxInputTokens": 131072, - "maxTokens": 32768, - "capabilities": [ - "images", - "video", - "tools", - "reasoning", - "structured_output", - "temperature" - ], - "reasoningOptions": [], - "pricing": { - "input": 0.4, - "output": 4, - "cacheRead": 0, - "cacheWrite": 0 - }, - "releaseDate": "2025-09-23", - "family": "qwen" - }, - "qwen/qwen3-next-80b-a3b-instruct": { - "id": "qwen/qwen3-next-80b-a3b-instruct", - "name": "Qwen3-Next 80B-A3B Instruct", + "databricks/databricks-qwen3-next-80b-a3b-instruct": { + "id": "databricks/databricks-qwen3-next-80b-a3b-instruct", + "name": "Qwen3-Next 80B-A3B Instruct (Databricks)", "contextWindow": 131072, "maxInputTokens": 131072, "maxTokens": 32768, "capabilities": [ "tools", - "structured_output", - "temperature" + "temperature", + "prompt-cache" ], "pricing": { - "input": 0.15, - "output": 1.2, - "cacheRead": 0, - "cacheWrite": 0 + "input": 0.15001, + "output": 1.20001, + "cacheRead": 0.015001, + "cacheWrite": 0.15001 }, "releaseDate": "2025-09", "family": "qwen" }, - "qwen/qwen3-next-80b-a3b-thinking": { - "id": "qwen/qwen3-next-80b-a3b-thinking", - "name": "Qwen3-Next 80B-A3B (Thinking)", + "databricks/databricks-qwen3-next-80b-a3b-instruct@eu": { + "id": "databricks/databricks-qwen3-next-80b-a3b-instruct@eu", + "name": "Qwen3-Next 80B-A3B Instruct (Databricks, EU)", "contextWindow": 131072, "maxInputTokens": 131072, "maxTokens": 32768, "capabilities": [ "tools", - "reasoning", + "temperature", + "prompt-cache" + ], + "pricing": { + "input": 0.15001, + "output": 1.20001, + "cacheRead": 0.015001, + "cacheWrite": 0.15001 + }, + "releaseDate": "2025-09", + "family": "qwen" + }, + "deepinfra/Qwen/Qwen3-Next-80B-A3B-Instruct": { + "id": "deepinfra/Qwen/Qwen3-Next-80B-A3B-Instruct", + "name": "Qwen3-Next 80B-A3B Instruct (Deep Infra)", + "contextWindow": 262144, + "maxInputTokens": 262144, + "maxTokens": 32768, + "capabilities": [ + "tools", "structured_output", "temperature" ], - "reasoningOptions": [], "pricing": { - "input": 0.15, - "output": 1.2, + "input": 0.09, + "output": 1.1, "cacheRead": 0, "cacheWrite": 0 }, @@ -46380,6 +52003,37 @@ export const GENERATED_PROVIDER_MODELS: { "releaseDate": "2025-08-05", "family": "gpt-oss" }, + "greenpt/gpt-oss-120b": { + "id": "greenpt/gpt-oss-120b", + "name": "GPT OSS 120B (Greenpt)", + "contextWindow": 128000, + "maxInputTokens": 128000, + "maxTokens": 32768, + "capabilities": [ + "tools", + "reasoning", + "structured_output", + "temperature" + ], + "reasoningOptions": [ + { + "type": "effort", + "values": [ + "low", + "medium", + "high" + ] + } + ], + "pricing": { + "input": 0.22362, + "output": 0.78267, + "cacheRead": 0, + "cacheWrite": 0 + }, + "releaseDate": "2025-08-05", + "family": "gpt-oss" + }, "groq/openai/gpt-oss-120b": { "id": "groq/openai/gpt-oss-120b", "name": "GPT OSS 120B (Groq)", @@ -46498,8 +52152,8 @@ export const GENERATED_PROVIDER_MODELS: { } ], "pricing": { - "input": 0.167655, - "output": 0.67062, + "input": 0.167715, + "output": 0.67086, "cacheRead": 0, "cacheWrite": 0 }, @@ -46580,12 +52234,51 @@ export const GENERATED_PROVIDER_MODELS: { "releaseDate": "2025-07-28", "family": "qwen" }, - "qwen/qwen3-coder-plus": { - "id": "qwen/qwen3-coder-plus", - "name": "Qwen3 Coder Plus", - "contextWindow": 1000000, - "maxInputTokens": 1000000, - "maxTokens": 65536, + "deepinfra/Qwen/Qwen3-235B-A22B-Instruct-2507": { + "id": "deepinfra/Qwen/Qwen3-235B-A22B-Instruct-2507", + "name": "Qwen3 235B-A22B Instruct 2507 (Deep Infra)", + "contextWindow": 262144, + "maxInputTokens": 262144, + "maxTokens": 16384, + "capabilities": [ + "tools", + "temperature" + ], + "pricing": { + "input": 0.09, + "output": 0.55, + "cacheRead": 0, + "cacheWrite": 0 + }, + "releaseDate": "2025-07-21", + "family": "qwen" + }, + "greenpt/qwen3-235b-a22b-instruct-2507": { + "id": "greenpt/qwen3-235b-a22b-instruct-2507", + "name": "Qwen3 235B-A22B Instruct 2507 (Greenpt)", + "contextWindow": 250000, + "maxInputTokens": 250000, + "maxTokens": 16384, + "capabilities": [ + "tools", + "structured_output", + "temperature" + ], + "pricing": { + "input": 1.00629, + "output": 3.018871, + "cacheRead": 0, + "cacheWrite": 0 + }, + "releaseDate": "2025-07-21", + "family": "qwen" + }, + "nebius/Qwen/Qwen3-235B-A22B-Instruct-2507": { + "id": "nebius/Qwen/Qwen3-235B-A22B-Instruct-2507", + "name": "Qwen3 235B-A22B Instruct 2507 (Nebius)", + "contextWindow": 262144, + "maxInputTokens": 262144, + "maxTokens": 16384, "capabilities": [ "tools", "structured_output", @@ -46593,12 +52286,31 @@ export const GENERATED_PROVIDER_MODELS: { "prompt-cache" ], "pricing": { - "input": 1, - "output": 5, + "input": 0.2, + "output": 0.6, "cacheRead": 0.2, - "cacheWrite": 1.25 + "cacheWrite": 0 }, - "releaseDate": "2025-07-23", + "releaseDate": "2025-07-21", + "family": "qwen" + }, + "scaleway/qwen3-235b-a22b-instruct-2507": { + "id": "scaleway/qwen3-235b-a22b-instruct-2507", + "name": "Qwen3 235B-A22B Instruct 2507 (Scaleway)", + "contextWindow": 250000, + "maxInputTokens": 250000, + "maxTokens": 16384, + "capabilities": [ + "tools", + "temperature" + ], + "pricing": { + "input": 0.838575, + "output": 2.515726, + "cacheRead": 0, + "cacheWrite": 0 + }, + "releaseDate": "2025-07-21", "family": "qwen" }, "mistral/voxtral-small-latest": { @@ -46631,6 +52343,25 @@ export const GENERATED_PROVIDER_MODELS: { ] } }, + "deepinfra/moonshotai/Kimi-K2-Instruct": { + "id": "deepinfra/moonshotai/Kimi-K2-Instruct", + "name": "Kimi K2 Instruct (Deep Infra)", + "contextWindow": 131072, + "maxInputTokens": 131072, + "maxTokens": 32768, + "capabilities": [ + "tools", + "temperature" + ], + "pricing": { + "input": 0.5, + "output": 2, + "cacheRead": 0, + "cacheWrite": 0 + }, + "releaseDate": "2025-07-11", + "family": "kimi-k2" + }, "openai/o3-pro": { "id": "openai/o3-pro", "name": "o3-pro", @@ -46663,6 +52394,52 @@ export const GENERATED_PROVIDER_MODELS: { "releaseDate": "2025-06-10", "family": "o-pro" }, + "deepinfra/deepseek-ai/DeepSeek-R1-0528": { + "id": "deepinfra/deepseek-ai/DeepSeek-R1-0528", + "name": "DeepSeek R1 0528 (Deep Infra)", + "contextWindow": 163840, + "maxInputTokens": 163840, + "maxTokens": 32768, + "capabilities": [ + "tools", + "reasoning", + "structured_output", + "temperature", + "prompt-cache" + ], + "reasoningOptions": [], + "pricing": { + "input": 0.5, + "output": 2.15, + "cacheRead": 0.35, + "cacheWrite": 0 + }, + "releaseDate": "2025-05-28", + "family": "deepseek-thinking" + }, + "tensorx/deepseek/deepseek-r1-0528": { + "id": "tensorx/deepseek/deepseek-r1-0528", + "name": "DeepSeek R1 0528 (TensorX)", + "contextWindow": 164000, + "maxInputTokens": 164000, + "maxTokens": 32768, + "capabilities": [ + "tools", + "reasoning", + "structured_output", + "temperature", + "prompt-cache" + ], + "reasoningOptions": [], + "pricing": { + "input": 0.66, + "output": 2.6, + "cacheRead": 0.165, + "cacheWrite": 0 + }, + "releaseDate": "2025-05-28", + "family": "deepseek-thinking" + }, "mistral/mistral-medium-2505": { "id": "mistral/mistral-medium-2505", "name": "Mistral Medium 3", @@ -46684,6 +52461,29 @@ export const GENERATED_PROVIDER_MODELS: { "releaseDate": "2025-05-07", "family": "mistral-medium" }, + "mistral/mistral-medium-3": { + "id": "mistral/mistral-medium-3", + "name": "Mistral Medium 3", + "contextWindow": 262144, + "maxInputTokens": 262144, + "maxTokens": 104857, + "capabilities": [ + "images", + "files", + "tools", + "structured_output", + "temperature", + "prompt-cache" + ], + "pricing": { + "input": 0.4, + "output": 2, + "cacheRead": 0.04, + "cacheWrite": 0 + }, + "releaseDate": "2025-05-07", + "family": "mistral-medium" + }, "openai/o3": { "id": "openai/o3", "name": "o3", @@ -46876,20 +52676,38 @@ export const GENERATED_PROVIDER_MODELS: { "releaseDate": "2025-04-08", "family": "pixtral" }, - "qwen/qwen3-coder-30b-a3b-instruct": { - "id": "qwen/qwen3-coder-30b-a3b-instruct", - "name": "Qwen3-Coder 30B-A3B Instruct", + "deepinfra/Qwen/Qwen3-Coder-480B-A35B-Instruct": { + "id": "deepinfra/Qwen/Qwen3-Coder-480B-A35B-Instruct", + "name": "Qwen3-Coder 480B-A35B Instruct (Deep Infra)", "contextWindow": 262144, "maxInputTokens": 262144, "maxTokens": 65536, "capabilities": [ "tools", - "structured_output", "temperature" ], "pricing": { - "input": 0.45, - "output": 2.25, + "input": 0.4, + "output": 1.6, + "cacheRead": 0, + "cacheWrite": 0 + }, + "releaseDate": "2025-04", + "family": "qwen" + }, + "scaleway/qwen3-coder-30b-a3b-instruct": { + "id": "scaleway/qwen3-coder-30b-a3b-instruct", + "name": "Qwen3-Coder 30B-A3B Instruct (Scaleway)", + "contextWindow": 128000, + "maxInputTokens": 128000, + "maxTokens": 65536, + "capabilities": [ + "tools", + "temperature" + ], + "pricing": { + "input": 0.22362, + "output": 0.89448, "cacheRead": 0, "cacheWrite": 0 }, @@ -46948,6 +52766,27 @@ export const GENERATED_PROVIDER_MODELS: { "releaseDate": "2025-03-19", "family": "o-pro" }, + "cloudflare/@cf/mistralai/mistral-small-3.1-24b-instruct": { + "id": "cloudflare/@cf/mistralai/mistral-small-3.1-24b-instruct", + "name": "Mistral Small 3.1 24B (Cloudflare)", + "contextWindow": 128000, + "maxInputTokens": 128000, + "maxTokens": 102400, + "capabilities": [ + "images", + "tools", + "structured_output", + "temperature" + ], + "pricing": { + "input": 0.351, + "output": 0.555, + "cacheRead": 0, + "cacheWrite": 0 + }, + "releaseDate": "2025-03-17", + "family": "mistral-small" + }, "mistral/magistral-medium-latest": { "id": "mistral/magistral-medium-latest", "name": "Magistral Medium (latest)", @@ -47209,6 +53048,26 @@ export const GENERATED_PROVIDER_MODELS: { "releaseDate": "2025-03-12", "family": "gemma" }, + "deepinfra/mistralai/Mistral-Small-24B-Instruct-2501": { + "id": "deepinfra/mistralai/Mistral-Small-24B-Instruct-2501", + "name": "Mistral Small 3 (Deep Infra)", + "contextWindow": 32768, + "maxInputTokens": 32768, + "maxTokens": 16384, + "capabilities": [ + "tools", + "structured_output", + "temperature" + ], + "pricing": { + "input": 0.05, + "output": 0.08, + "cacheRead": 0, + "cacheWrite": 0 + }, + "releaseDate": "2025-01-30", + "family": "mistral-small" + }, "deepinfra/deepseek-ai/DeepSeek-R1": { "id": "deepinfra/deepseek-ai/DeepSeek-R1", "name": "DeepSeek-R1 (Deep Infra)", @@ -47301,6 +53160,26 @@ export const GENERATED_PROVIDER_MODELS: { "releaseDate": "2024-12-06", "family": "llama" }, + "greenpt/llama-3.3-70b-instruct": { + "id": "greenpt/llama-3.3-70b-instruct", + "name": "Llama-3.3-70B-Instruct (Greenpt)", + "contextWindow": 128000, + "maxInputTokens": 128000, + "maxTokens": 4096, + "capabilities": [ + "tools", + "structured_output", + "temperature" + ], + "pricing": { + "input": 1.229911, + "output": 1.229911, + "cacheRead": 0, + "cacheWrite": 0 + }, + "releaseDate": "2024-12-06", + "family": "llama" + }, "scaleway/llama-3.3-70b-instruct": { "id": "scaleway/llama-3.3-70b-instruct", "name": "Llama-3.3-70B-Instruct (Scaleway)", @@ -47312,8 +53191,8 @@ export const GENERATED_PROVIDER_MODELS: { "temperature" ], "pricing": { - "input": 1.005931, - "output": 1.005931, + "input": 1.00629, + "output": 1.00629, "cacheRead": 0, "cacheWrite": 0 }, @@ -47530,6 +53409,28 @@ export const GENERATED_PROVIDER_MODELS: { "releaseDate": "2024-11-20", "family": "gpt" }, + "mistral/mistral-large-2407": { + "id": "mistral/mistral-large-2407", + "name": "Mistral Large 2407", + "contextWindow": 131072, + "maxInputTokens": 131072, + "maxTokens": 104857, + "capabilities": [ + "files", + "tools", + "structured_output", + "temperature", + "prompt-cache" + ], + "pricing": { + "input": 2, + "output": 6, + "cacheRead": 0.2, + "cacheWrite": 0 + }, + "releaseDate": "2024-11-19", + "family": "mistral-large" + }, "mistral/mistral-large-latest": { "id": "mistral/mistral-large-latest", "name": "Mistral Large (latest)", @@ -47593,6 +53494,26 @@ export const GENERATED_PROVIDER_MODELS: { "releaseDate": "2024-08-30", "family": "command-r" }, + "deepinfra/NousResearch/Hermes-3-Llama-3.1-405B": { + "id": "deepinfra/NousResearch/Hermes-3-Llama-3.1-405B", + "name": "Hermes 3 405B Instruct (Deep Infra)", + "contextWindow": 131072, + "maxInputTokens": 131072, + "maxTokens": 16384, + "capabilities": [ + "tools", + "structured_output", + "temperature" + ], + "pricing": { + "input": 1, + "output": 1, + "cacheRead": 0, + "cacheWrite": 0 + }, + "releaseDate": "2024-08-16", + "family": "nousresearch" + }, "openai/gpt-4o-2024-08-06": { "id": "openai/gpt-4o-2024-08-06", "name": "GPT-4o (2024-08-06)", @@ -47725,6 +53646,25 @@ export const GENERATED_PROVIDER_MODELS: { "releaseDate": "2023-11-06", "family": "gpt" }, + "deepinfra/Gryphe/MythoMax-L2-13b": { + "id": "deepinfra/Gryphe/MythoMax-L2-13b", + "name": "MythoMax L2 13B (Deep Infra)", + "contextWindow": 4096, + "maxInputTokens": 4096, + "maxTokens": 3200, + "capabilities": [ + "tools", + "structured_output", + "temperature" + ], + "pricing": { + "input": 0.4, + "output": 0.4, + "cacheRead": 0, + "cacheWrite": 0 + }, + "releaseDate": "2023-08-10" + }, "openai/gpt-3.5-turbo": { "id": "openai/gpt-3.5-turbo", "name": "GPT-3.5-turbo", @@ -47952,6 +53892,38 @@ export const GENERATED_PROVIDER_MODELS: { }, "releaseDate": "2026-09-16" }, + "fugu-max": { + "id": "fugu-max", + "name": "Fugu Max", + "contextWindow": 1000000, + "maxInputTokens": 1000000, + "maxTokens": 131072, + "capabilities": [ + "images", + "tools", + "reasoning", + "structured_output", + "prompt-cache" + ], + "reasoningOptions": [ + { + "type": "effort", + "values": [ + "high", + "xhigh", + "max" + ] + } + ], + "pricing": { + "input": 2, + "output": 6, + "cacheRead": 0.25, + "cacheWrite": 0 + }, + "releaseDate": "2026-09-11", + "family": "fugu" + }, "deepseek-v4-1-flash": { "id": "deepseek-v4-1-flash", "name": "DeepSeek V4.1 Flash", @@ -48887,45 +54859,6 @@ export const GENERATED_PROVIDER_MODELS: { "releaseDate": "2026-04-27", "family": "qwen3.6" }, - "deepseek-v4-flash": { - "id": "deepseek-v4-flash", - "name": "DeepSeek V4 Flash", - "contextWindow": 1000000, - "maxInputTokens": 1000000, - "maxTokens": 393216, - "capabilities": [ - "tools", - "reasoning", - "structured_output", - "temperature", - "prompt-cache" - ], - "reasoningOptions": [ - { - "type": "effort", - "values": [ - "none", - "low", - "medium", - "high", - "max" - ] - }, - { - "type": "budget_tokens", - "min": 1, - "max": 393216 - } - ], - "pricing": { - "input": 0.14, - "output": 0.28, - "cacheRead": 0.14, - "cacheWrite": 0 - }, - "releaseDate": "2026-04-24", - "family": "deepseek-flash" - }, "deepseek-v4-pro": { "id": "deepseek-v4-pro", "name": "DeepSeek V4 Pro", @@ -49114,45 +55047,6 @@ export const GENERATED_PROVIDER_MODELS: { "releaseDate": "2026-04-21", "family": "kimi-k2" }, - "qwen3-6-max-preview": { - "id": "qwen3-6-max-preview", - "name": "Qwen3.6 Max Preview", - "contextWindow": 256000, - "maxInputTokens": 256000, - "maxTokens": 65536, - "capabilities": [ - "tools", - "reasoning", - "structured_output", - "temperature", - "prompt-cache" - ], - "reasoningOptions": [ - { - "type": "effort", - "values": [ - "none", - "low", - "medium", - "high", - "max" - ] - }, - { - "type": "budget_tokens", - "min": 1, - "max": 393216 - } - ], - "pricing": { - "input": 1.31, - "output": 7.88, - "cacheRead": 1.31, - "cacheWrite": 0 - }, - "releaseDate": "2026-04-20", - "family": "qwen" - }, "qwen3-6-35b-a3b": { "id": "qwen3-6-35b-a3b", "name": "Qwen3.6 35B A3B", @@ -49663,6 +55557,11 @@ export const GENERATED_PROVIDER_MODELS: { "reasoningOptions": [ { "type": "toggle" + }, + { + "type": "budget_tokens", + "min": 1, + "max": 81920 } ], "pricing": { @@ -49692,6 +55591,11 @@ export const GENERATED_PROVIDER_MODELS: { "reasoningOptions": [ { "type": "toggle" + }, + { + "type": "budget_tokens", + "min": 1, + "max": 81920 } ], "pricing": { @@ -49969,68 +55873,6 @@ export const GENERATED_PROVIDER_MODELS: { "releaseDate": "2025-12-08", "family": "glm-flash" }, - "deepseek-v3-2": { - "id": "deepseek-v3-2", - "name": "DeepSeek V3.2", - "contextWindow": 128000, - "maxInputTokens": 128000, - "maxTokens": 32768, - "capabilities": [ - "tools", - "reasoning", - "structured_output", - "temperature", - "prompt-cache" - ], - "reasoningOptions": [ - { - "type": "effort", - "values": [ - "none", - "low", - "medium", - "high", - "max" - ] - }, - { - "type": "budget_tokens", - "min": 1, - "max": 393216 - } - ], - "pricing": { - "input": 0.57, - "output": 1.71, - "cacheRead": 0.57, - "cacheWrite": 0 - }, - "releaseDate": "2025-12-01", - "family": "deepseek" - }, - "qwen3-max": { - "id": "qwen3-max", - "name": "Qwen3 Max", - "contextWindow": 256000, - "maxInputTokens": 256000, - "maxTokens": 65536, - "capabilities": [ - "tools", - "reasoning", - "structured_output", - "temperature", - "prompt-cache" - ], - "reasoningOptions": [], - "pricing": { - "input": 1.08, - "output": 5.52, - "cacheRead": 1.08, - "cacheWrite": 0 - }, - "releaseDate": "2025-09-23", - "family": "qwen" - }, "glm-4-5-flash": { "id": "glm-4-5-flash", "name": "GLM 4.5 Flash", @@ -54780,7 +60622,10 @@ export const GENERATED_PROVIDER_MODELS: { "cacheWrite": 0.125 }, "releaseDate": "2026-10-07", - "family": "claude-haiku" + "family": "claude-haiku", + "metadata": { + "apiProtocol": "anthropic" + } }, "gpt-6.1-sol": { "id": "gpt-6.1-sol", @@ -54815,7 +60660,10 @@ export const GENERATED_PROVIDER_MODELS: { "cacheWrite": 2.5 }, "releaseDate": "2026-09-29", - "family": "gpt-sol" + "family": "gpt-sol", + "metadata": { + "apiProtocol": "openai-responses" + } }, "claude-sonnet-5.5": { "id": "claude-sonnet-5.5", @@ -54849,7 +60697,10 @@ export const GENERATED_PROVIDER_MODELS: { "cacheWrite": 2.5 }, "releaseDate": "2026-09-28", - "family": "claude-sonnet" + "family": "claude-sonnet", + "metadata": { + "apiProtocol": "anthropic" + } }, "claude-opus-5.5": { "id": "claude-opus-5.5", @@ -54883,7 +60734,10 @@ export const GENERATED_PROVIDER_MODELS: { "cacheWrite": 5 }, "releaseDate": "2026-09-22", - "family": "claude-opus" + "family": "claude-opus", + "metadata": { + "apiProtocol": "anthropic" + } }, "gpt-6-luna": { "id": "gpt-6-luna", @@ -54919,7 +60773,10 @@ export const GENERATED_PROVIDER_MODELS: { "cacheWrite": 0.125 }, "releaseDate": "2026-09-22", - "family": "gpt-luna" + "family": "gpt-luna", + "metadata": { + "apiProtocol": "openai-responses" + } }, "gpt-6-sol": { "id": "gpt-6-sol", @@ -54955,7 +60812,10 @@ export const GENERATED_PROVIDER_MODELS: { "cacheWrite": 2.5 }, "releaseDate": "2026-09-22", - "family": "gpt-sol" + "family": "gpt-sol", + "metadata": { + "apiProtocol": "openai-responses" + } }, "grok-4.7": { "id": "grok-4.7", @@ -54990,7 +60850,10 @@ export const GENERATED_PROVIDER_MODELS: { "cacheWrite": 0 }, "releaseDate": "2026-09-21", - "family": "grok" + "family": "grok", + "metadata": { + "apiProtocol": "openai-responses" + } }, "gpt-6-astra": { "id": "gpt-6-astra", @@ -55025,7 +60888,10 @@ export const GENERATED_PROVIDER_MODELS: { "cacheWrite": 12.5 }, "releaseDate": "2026-09-04", - "family": "gpt-astra" + "family": "gpt-astra", + "metadata": { + "apiProtocol": "openai-responses" + } }, "gemini-3.8-flash": { "id": "gemini-3.8-flash", @@ -55092,7 +60958,10 @@ export const GENERATED_PROVIDER_MODELS: { "cacheWrite": 12.5 }, "releaseDate": "2026-09-01", - "family": "claude-fable" + "family": "claude-fable", + "metadata": { + "apiProtocol": "anthropic" + } }, "gemini-3.7-flash": { "id": "gemini-3.7-flash", @@ -55159,7 +61028,10 @@ export const GENERATED_PROVIDER_MODELS: { "cacheWrite": 0 }, "releaseDate": "2026-08-12", - "family": "grok" + "family": "grok", + "metadata": { + "apiProtocol": "openai-responses" + } }, "mai-code-1.1-flash": { "id": "mai-code-1.1-flash", @@ -55192,7 +61064,10 @@ export const GENERATED_PROVIDER_MODELS: { "cacheWrite": 0 }, "releaseDate": "2026-08-11", - "family": "mai" + "family": "mai", + "metadata": { + "apiProtocol": "openai-responses" + } }, "claude-opus-5": { "id": "claude-opus-5", @@ -55227,7 +61102,10 @@ export const GENERATED_PROVIDER_MODELS: { "cacheWrite": 6.25 }, "releaseDate": "2026-07-24", - "family": "claude-opus" + "family": "claude-opus", + "metadata": { + "apiProtocol": "anthropic" + } }, "gemini-3.6-flash": { "id": "gemini-3.6-flash", @@ -55335,7 +61213,10 @@ export const GENERATED_PROVIDER_MODELS: { "cacheWrite": 0.25 }, "releaseDate": "2026-07-09", - "family": "gpt-luna" + "family": "gpt-luna", + "metadata": { + "apiProtocol": "openai-responses" + } }, "gpt-5.6-sol": { "id": "gpt-5.6-sol", @@ -55371,7 +61252,10 @@ export const GENERATED_PROVIDER_MODELS: { "cacheWrite": 5 }, "releaseDate": "2026-07-09", - "family": "gpt-sol" + "family": "gpt-sol", + "metadata": { + "apiProtocol": "openai-responses" + } }, "gpt-5.6-terra": { "id": "gpt-5.6-terra", @@ -55407,7 +61291,10 @@ export const GENERATED_PROVIDER_MODELS: { "cacheWrite": 2.5 }, "releaseDate": "2026-07-09", - "family": "gpt-terra" + "family": "gpt-terra", + "metadata": { + "apiProtocol": "openai-responses" + } }, "grok-4.5": { "id": "grok-4.5", @@ -55440,7 +61327,10 @@ export const GENERATED_PROVIDER_MODELS: { "cacheWrite": 0 }, "releaseDate": "2026-07-08", - "family": "grok" + "family": "grok", + "metadata": { + "apiProtocol": "openai-responses" + } }, "claude-sonnet-5": { "id": "claude-sonnet-5", @@ -55474,7 +61364,10 @@ export const GENERATED_PROVIDER_MODELS: { "cacheWrite": 2.5 }, "releaseDate": "2026-06-30", - "family": "claude-sonnet" + "family": "claude-sonnet", + "metadata": { + "apiProtocol": "anthropic" + } }, "kimi-k2.7-code": { "id": "kimi-k2.7-code", @@ -55531,7 +61424,10 @@ export const GENERATED_PROVIDER_MODELS: { "cacheWrite": 12.5 }, "releaseDate": "2026-06-09", - "family": "claude-fable" + "family": "claude-fable", + "metadata": { + "apiProtocol": "anthropic" + } }, "mai-code-1-flash-picker": { "id": "mai-code-1-flash-picker", @@ -55563,7 +61459,10 @@ export const GENERATED_PROVIDER_MODELS: { "cacheWrite": 0 }, "releaseDate": "2026-06-02", - "family": "mai" + "family": "mai", + "metadata": { + "apiProtocol": "openai-responses" + } }, "claude-opus-4.8": { "id": "claude-opus-4.8", @@ -55597,7 +61496,10 @@ export const GENERATED_PROVIDER_MODELS: { "cacheWrite": 6.25 }, "releaseDate": "2026-05-28", - "family": "claude-opus" + "family": "claude-opus", + "metadata": { + "apiProtocol": "anthropic" + } }, "gemini-3.5-flash": { "id": "gemini-3.5-flash", @@ -55685,7 +61587,10 @@ export const GENERATED_PROVIDER_MODELS: { "cacheWrite": 0 }, "releaseDate": "2026-04-23", - "family": "gpt" + "family": "gpt", + "metadata": { + "apiProtocol": "openai-responses" + } }, "claude-opus-4.7": { "id": "claude-opus-4.7", @@ -55719,7 +61624,10 @@ export const GENERATED_PROVIDER_MODELS: { "cacheWrite": 6.25 }, "releaseDate": "2026-04-16", - "family": "claude-opus" + "family": "claude-opus", + "metadata": { + "apiProtocol": "anthropic" + } }, "gpt-5.4-mini": { "id": "gpt-5.4-mini", @@ -55754,7 +61662,10 @@ export const GENERATED_PROVIDER_MODELS: { "cacheWrite": 0 }, "releaseDate": "2026-03-17", - "family": "gpt-mini" + "family": "gpt-mini", + "metadata": { + "apiProtocol": "openai-responses" + } }, "gpt-5.4-nano": { "id": "gpt-5.4-nano", @@ -55778,7 +61689,10 @@ export const GENERATED_PROVIDER_MODELS: { "cacheWrite": 0 }, "releaseDate": "2026-03-17", - "family": "gpt-nano" + "family": "gpt-nano", + "metadata": { + "apiProtocol": "openai-responses" + } }, "gpt-5.4": { "id": "gpt-5.4", @@ -55814,7 +61728,10 @@ export const GENERATED_PROVIDER_MODELS: { "cacheWrite": 0 }, "releaseDate": "2026-03-05", - "family": "gpt" + "family": "gpt", + "metadata": { + "apiProtocol": "openai-responses" + } }, "claude-sonnet-4.6": { "id": "claude-sonnet-4.6", @@ -55853,7 +61770,10 @@ export const GENERATED_PROVIDER_MODELS: { "cacheWrite": 3.75 }, "releaseDate": "2026-02-17", - "family": "claude-sonnet" + "family": "claude-sonnet", + "metadata": { + "apiProtocol": "anthropic" + } }, "gpt-5.3-codex": { "id": "gpt-5.3-codex", @@ -55888,7 +61808,10 @@ export const GENERATED_PROVIDER_MODELS: { "cacheWrite": 0 }, "releaseDate": "2026-02-05", - "family": "gpt-codex" + "family": "gpt-codex", + "metadata": { + "apiProtocol": "openai-responses" + } }, "claude-haiku-4.5": { "id": "claude-haiku-4.5", @@ -55918,7 +61841,10 @@ export const GENERATED_PROVIDER_MODELS: { "cacheWrite": 1.25 }, "releaseDate": "2025-10-15", - "family": "claude-haiku" + "family": "claude-haiku", + "metadata": { + "apiProtocol": "anthropic" + } }, "gpt-5-mini": { "id": "gpt-5-mini", @@ -60762,6 +66688,27 @@ export const GENERATED_PROVIDER_MODELS: { "releaseDate": "2025-10-27", "family": "minimax" }, + "Qwen/Qwen3-VL-30B-A3B-Instruct": { + "id": "Qwen/Qwen3-VL-30B-A3B-Instruct", + "name": "Qwen3 VL 30B A3B Instruct", + "contextWindow": 131072, + "maxInputTokens": 131072, + "maxTokens": 32768, + "capabilities": [ + "images", + "tools", + "structured_output", + "temperature" + ], + "pricing": { + "input": 0.2, + "output": 0.7, + "cacheRead": 0, + "cacheWrite": 0 + }, + "releaseDate": "2025-10-04", + "family": "qwen" + }, "zai-org/GLM-4.6": { "id": "zai-org/GLM-4.6", "name": "GLM-4.6", @@ -61141,6 +67088,27 @@ export const GENERATED_PROVIDER_MODELS: { "releaseDate": "2025-07-14", "family": "kimi-k2" }, + "MiniMaxAI/MiniMax-M1-80k": { + "id": "MiniMaxAI/MiniMax-M1-80k", + "name": "MiniMax M1 80K", + "contextWindow": 1000000, + "maxInputTokens": 1000000, + "maxTokens": 80000, + "capabilities": [ + "tools", + "reasoning", + "temperature" + ], + "reasoningOptions": [], + "pricing": { + "input": 0.55, + "output": 2.2, + "cacheRead": 0, + "cacheWrite": 0 + }, + "releaseDate": "2025-06-17", + "family": "minimax" + }, "deepseek-ai/DeepSeek-R1-0528": { "id": "deepseek-ai/DeepSeek-R1-0528", "name": "DeepSeek-R1-0528", @@ -61886,68 +67854,6 @@ export const GENERATED_PROVIDER_MODELS: { "releaseDate": "2026-07-19", "family": "qwen" }, - "deepseek-v4-flash": { - "id": "deepseek-v4-flash", - "name": "DeepSeek V4 Flash", - "contextWindow": 1000000, - "maxInputTokens": 1000000, - "maxTokens": 384000, - "capabilities": [ - "tools", - "reasoning", - "structured_output", - "temperature", - "prompt-cache" - ], - "reasoningOptions": [ - { - "type": "effort", - "values": [ - "high", - "xhigh" - ] - } - ], - "pricing": { - "input": 0.2, - "output": 0.4, - "cacheRead": 0.04, - "cacheWrite": 0 - }, - "releaseDate": "2026-07-06", - "family": "deepseek-flash" - }, - "deepseek-v4-pro": { - "id": "deepseek-v4-pro", - "name": "DeepSeek V4 Pro", - "contextWindow": 1000000, - "maxInputTokens": 1000000, - "maxTokens": 384000, - "capabilities": [ - "tools", - "reasoning", - "structured_output", - "temperature", - "prompt-cache" - ], - "reasoningOptions": [ - { - "type": "effort", - "values": [ - "high", - "xhigh" - ] - } - ], - "pricing": { - "input": 2.4, - "output": 4.8, - "cacheRead": 0.2, - "cacheWrite": 0 - }, - "releaseDate": "2026-07-06", - "family": "deepseek-thinking" - }, "kimi-k2.7-code": { "id": "kimi-k2.7-code", "name": "Kimi K2.7 Code", @@ -70203,6 +76109,56 @@ export const GENERATED_PROVIDER_MODELS: { } }, "kilo": { + "meta/muse-wonder": { + "id": "meta/muse-wonder", + "name": "Meta: Muse Wonder", + "contextWindow": 1048576, + "maxInputTokens": 1048576, + "maxTokens": 943718, + "capabilities": [ + "images", + "video", + "files", + "tools", + "reasoning", + "structured_output", + "temperature", + "prompt-cache" + ], + "reasoningOptions": [ + { + "type": "effort", + "values": [ + "minimal", + "low", + "medium", + "high", + "xhigh", + "max" + ] + } + ], + "pricing": { + "input": 4, + "output": 20, + "cacheRead": 0.2, + "cacheWrite": 0 + }, + "releaseDate": "2026-10-12", + "family": "muse", + "modalities": { + "input": [ + "text", + "image", + "video", + "pdf", + "audio" + ], + "output": [ + "text" + ] + } + }, "anthropic/claude-haiku-5.5": { "id": "anthropic/claude-haiku-5.5", "name": "Claude Haiku 5.5", @@ -70241,9 +76197,9 @@ export const GENERATED_PROVIDER_MODELS: { }, "mistralai/mistral-large-4-0": { "id": "mistralai/mistral-large-4-0", - "name": "Mistral: Mistral Large 4", - "contextWindow": 524288, - "maxInputTokens": 524288, + "name": "Mistral: Mistral Large 4 (50% off)", + "contextWindow": 1048576, + "maxInputTokens": 1048576, "maxTokens": 262144, "capabilities": [ "images", @@ -70263,9 +76219,9 @@ export const GENERATED_PROVIDER_MODELS: { } ], "pricing": { - "input": 1.36, - "output": 4.18, - "cacheRead": 0.14, + "input": 0.68, + "output": 2.09, + "cacheRead": 0.07, "cacheWrite": 0 }, "releaseDate": "2026-10-06", @@ -70273,10 +76229,10 @@ export const GENERATED_PROVIDER_MODELS: { }, "inclusionai/ling-3.1-flash": { "id": "inclusionai/ling-3.1-flash", - "name": "inclusionAI: Ling 3.1 Flash (new)", + "name": "inclusionAI: Ling 3.1 Flash", "contextWindow": 262144, "maxInputTokens": 262144, - "maxTokens": 32768, + "maxTokens": 235929, "capabilities": [ "tools", "reasoning", @@ -71173,6 +77129,39 @@ export const GENERATED_PROVIDER_MODELS: { }, "releaseDate": "2026-09-17" }, + "stepfun/step-5-preview": { + "id": "stepfun/step-5-preview", + "name": "Step 5 Preview", + "contextWindow": 1000000, + "maxInputTokens": 1000000, + "maxTokens": 64000, + "capabilities": [ + "images", + "video", + "tools", + "reasoning", + "structured_output", + "temperature", + "prompt-cache" + ], + "reasoningOptions": [ + { + "type": "effort", + "values": [ + "low", + "medium", + "high" + ] + } + ], + "pricing": { + "input": 1, + "output": 2.7, + "cacheRead": 0.05, + "cacheWrite": 0 + }, + "releaseDate": "2026-09-16" + }, "~deepseek/deepseek-flash-latest": { "id": "~deepseek/deepseek-flash-latest", "name": "DeepSeek: DeepSeek Flash Latest", @@ -71199,9 +77188,9 @@ export const GENERATED_PROVIDER_MODELS: { } ], "pricing": { - "input": 0.0184, - "output": 1, - "cacheRead": 0.01, + "input": 0.045, + "output": 0.891, + "cacheRead": 0.0054, "cacheWrite": 0 }, "releaseDate": "2026-09-14", @@ -71212,7 +77201,7 @@ export const GENERATED_PROVIDER_MODELS: { "name": "DeepSeek: DeepSeek Pro Latest", "contextWindow": 1048576, "maxInputTokens": 1048576, - "maxTokens": 393216, + "maxTokens": 943718, "capabilities": [ "tools", "reasoning", @@ -71232,9 +77221,9 @@ export const GENERATED_PROVIDER_MODELS: { } ], "pricing": { - "input": 0.1575, - "output": 4.2, - "cacheRead": 0.12, + "input": 0.25, + "output": 5, + "cacheRead": 0.2, "cacheWrite": 0 }, "releaseDate": "2026-09-14", @@ -71384,7 +77373,7 @@ export const GENERATED_PROVIDER_MODELS: { }, "sakana/fugu-max": { "id": "sakana/fugu-max", - "name": "Sakana: Fugu Max", + "name": "Fugu Max", "contextWindow": 1000000, "maxInputTokens": 1000000, "maxTokens": 128000, @@ -71417,7 +77406,7 @@ export const GENERATED_PROVIDER_MODELS: { }, "sakana/fugu-ultra-v2": { "id": "sakana/fugu-ultra-v2", - "name": "Sakana: Fugu Ultra v2", + "name": "Fugu Ultra v2", "contextWindow": 1000000, "maxInputTokens": 1000000, "maxTokens": 128000, @@ -71581,16 +77570,17 @@ export const GENERATED_PROVIDER_MODELS: { "releaseDate": "2026-09-08", "family": "agi" }, - "inclusionai/ling-3.0-flash-sante:free": { - "id": "inclusionai/ling-3.0-flash-sante:free", - "name": "inclusionAI: Ling 3.0 Flash Sante (free)", + "inclusionai/ling-3.0-flash-sante": { + "id": "inclusionai/ling-3.0-flash-sante", + "name": "inclusionAI: Ling 3.0 Flash Sante", "contextWindow": 262144, "maxInputTokens": 262144, "maxTokens": 32768, "capabilities": [ "tools", "reasoning", - "temperature" + "temperature", + "prompt-cache" ], "reasoningOptions": [ { @@ -71602,9 +77592,9 @@ export const GENERATED_PROVIDER_MODELS: { } ], "pricing": { - "input": 0, - "output": 0, - "cacheRead": 0, + "input": 0.075, + "output": 0.22, + "cacheRead": 0.015, "cacheWrite": 0 }, "releaseDate": "2026-09-04", @@ -71930,9 +77920,9 @@ export const GENERATED_PROVIDER_MODELS: { } ], "pricing": { - "input": 0.834, - "output": 2.501, - "cacheRead": 0.042, + "input": 0.7506, + "output": 2.2509, + "cacheRead": 0.0378, "cacheWrite": 0 }, "releaseDate": "2026-08-28", @@ -71964,9 +77954,9 @@ export const GENERATED_PROVIDER_MODELS: { } ], "pricing": { - "input": 0.032, - "output": 0.7986, - "cacheRead": 0.02, + "input": 0.0261, + "output": 0.9, + "cacheRead": 0.0108, "cacheWrite": 0 }, "releaseDate": "2026-08-27", @@ -71981,7 +77971,6 @@ export const GENERATED_PROVIDER_MODELS: { "capabilities": [ "tools", "reasoning", - "structured_output", "temperature", "prompt-cache" ], @@ -72039,9 +78028,9 @@ export const GENERATED_PROVIDER_MODELS: { "z-ai/glm-5.3-flash": { "id": "z-ai/glm-5.3-flash", "name": "GLM-5.3-Flash", - "contextWindow": 1048575, - "maxInputTokens": 1048575, - "maxTokens": 943717, + "contextWindow": 1048576, + "maxInputTokens": 1048576, + "maxTokens": 943718, "capabilities": [ "images", "video", @@ -72096,9 +78085,9 @@ export const GENERATED_PROVIDER_MODELS: { } ], "pricing": { - "input": 0.44, - "output": 1.32, - "cacheRead": 0.028, + "input": 0.2156, + "output": 0.6468, + "cacheRead": 0.00686, "cacheWrite": 0 }, "releaseDate": "2026-08-21", @@ -72146,7 +78135,7 @@ export const GENERATED_PROVIDER_MODELS: { "name": "Z.ai: GLM Latest", "contextWindow": 1048576, "maxInputTokens": 1048576, - "maxTokens": 131072, + "maxTokens": 943718, "capabilities": [ "tools", "reasoning", @@ -72165,9 +78154,9 @@ export const GENERATED_PROVIDER_MODELS: { } ], "pricing": { - "input": 0.0392, - "output": 12, - "cacheRead": 0.0365, + "input": 0.055, + "output": 5, + "cacheRead": 0.06, "cacheWrite": 0 }, "releaseDate": "2026-08-19", @@ -72206,9 +78195,9 @@ export const GENERATED_PROVIDER_MODELS: { "qwen/qwen3.8-27b": { "id": "qwen/qwen3.8-27b", "name": "Qwen3.8 27B", - "contextWindow": 1000000, - "maxInputTokens": 1000000, - "maxTokens": 131072, + "contextWindow": 262144, + "maxInputTokens": 262144, + "maxTokens": 235929, "capabilities": [ "images", "video", @@ -72483,7 +78472,7 @@ export const GENERATED_PROVIDER_MODELS: { "name": "Nemotron 3.5 Lightning 30B A3B", "contextWindow": 262144, "maxInputTokens": 262144, - "maxTokens": 131072, + "maxTokens": 235929, "capabilities": [ "tools", "reasoning", @@ -72501,9 +78490,9 @@ export const GENERATED_PROVIDER_MODELS: { } ], "pricing": { - "input": 0.039, - "output": 0.18, - "cacheRead": 0.0195, + "input": 0.025, + "output": 0.12, + "cacheRead": 0.0125, "cacheWrite": 0 }, "releaseDate": "2026-08-11", @@ -72566,7 +78555,7 @@ export const GENERATED_PROVIDER_MODELS: { ], "pricing": { "input": 0.3, - "output": 1.1, + "output": 1.2, "cacheRead": 0.04, "cacheWrite": 0 }, @@ -72703,9 +78692,9 @@ export const GENERATED_PROVIDER_MODELS: { } ], "pricing": { - "input": 0.00368, - "output": 1.24416, - "cacheRead": 0.00368, + "input": 0.0144, + "output": 0.576, + "cacheRead": 0.0108, "cacheWrite": 0 }, "releaseDate": "2026-08-01", @@ -73560,9 +79549,9 @@ export const GENERATED_PROVIDER_MODELS: { } ], "pricing": { - "input": 0.13, - "output": 0.53, - "cacheRead": 0.033, + "input": 0.0825, + "output": 0.33, + "cacheRead": 0.020625, "cacheWrite": 0 }, "releaseDate": "2026-07-06", @@ -73729,7 +79718,7 @@ export const GENERATED_PROVIDER_MODELS: { "name": "GLM-5.2", "contextWindow": 1048576, "maxInputTokens": 1048576, - "maxTokens": 131072, + "maxTokens": 943718, "capabilities": [ "tools", "reasoning", @@ -73860,9 +79849,9 @@ export const GENERATED_PROVIDER_MODELS: { "nvidia/nemotron-3-ultra-550b-a55b": { "id": "nvidia/nemotron-3-ultra-550b-a55b", "name": "Nemotron 3 Ultra 550B A55B", - "contextWindow": 262144, - "maxInputTokens": 262144, - "maxTokens": 16384, + "contextWindow": 202800, + "maxInputTokens": 202800, + "maxTokens": 182520, "capabilities": [ "tools", "reasoning", @@ -74017,36 +80006,6 @@ export const GENERATED_PROVIDER_MODELS: { }, "releaseDate": "2026-05-29" }, - "stepfun/step-3.7-flash:free": { - "id": "stepfun/step-3.7-flash:free", - "name": "StepFun: Step 3.7 Flash (free)", - "contextWindow": 262144, - "maxInputTokens": 262144, - "maxTokens": 262144, - "capabilities": [ - "images", - "tools", - "reasoning", - "temperature" - ], - "reasoningOptions": [ - { - "type": "effort", - "values": [ - "low", - "medium", - "high" - ] - } - ], - "pricing": { - "input": 0, - "output": 0, - "cacheRead": 0, - "cacheWrite": 0 - }, - "releaseDate": "2026-05-29" - }, "anthropic/claude-opus-4.8": { "id": "anthropic/claude-opus-4.8", "name": "Claude Opus 4.8", @@ -74235,7 +80194,7 @@ export const GENERATED_PROVIDER_MODELS: { }, "mistralai/mistral-medium-3-5": { "id": "mistralai/mistral-medium-3-5", - "name": "Mistral: Mistral Medium 3.5", + "name": "Mistral Medium 3.5", "contextWindow": 262144, "maxInputTokens": 262144, "maxTokens": 209715, @@ -74499,9 +80458,9 @@ export const GENERATED_PROVIDER_MODELS: { } ], "pricing": { - "input": 0.71, - "output": 13, - "cacheRead": 0.45, + "input": 0.73, + "output": 13.5, + "cacheRead": 0.55, "cacheWrite": 0 }, "releaseDate": "2026-04-27", @@ -74632,9 +80591,9 @@ export const GENERATED_PROVIDER_MODELS: { } ], "pricing": { - "input": 0.14, - "output": 0.28, - "cacheRead": 0.028, + "input": 0.05, + "output": 1.28, + "cacheRead": 0.05, "cacheWrite": 0 }, "releaseDate": "2026-04-24", @@ -74744,7 +80703,7 @@ export const GENERATED_PROVIDER_MODELS: { "name": "Qwen3.6 27B", "contextWindow": 262144, "maxInputTokens": 262144, - "maxTokens": 65536, + "maxTokens": 81920, "capabilities": [ "images", "video", @@ -74905,45 +80864,14 @@ export const GENERATED_PROVIDER_MODELS: { } ], "pricing": { - "input": 0.465, - "output": 2.45, - "cacheRead": 0.0975, + "input": 0.65, + "output": 3.41, + "cacheRead": 0.15, "cacheWrite": 0 }, "releaseDate": "2026-04-21", "family": "kimi-k2" }, - "qwen/qwen3.6-max-preview": { - "id": "qwen/qwen3.6-max-preview", - "name": "Qwen3.6 Max Preview", - "contextWindow": 262144, - "maxInputTokens": 262144, - "maxTokens": 65536, - "capabilities": [ - "tools", - "reasoning", - "structured_output", - "temperature", - "prompt-cache" - ], - "reasoningOptions": [ - { - "type": "effort", - "values": [ - "none", - "high" - ] - } - ], - "pricing": { - "input": 1.027, - "output": 6.162, - "cacheRead": 0, - "cacheWrite": 1.28375 - }, - "releaseDate": "2026-04-20", - "family": "qwen" - }, "tencent/hy3-preview": { "id": "tencent/hy3-preview", "name": "Hy3 preview", @@ -74980,7 +80908,7 @@ export const GENERATED_PROVIDER_MODELS: { "name": "Qwen3.6 35B-A3B", "contextWindow": 262144, "maxInputTokens": 262144, - "maxTokens": 235929, + "maxTokens": 65536, "capabilities": [ "images", "video", @@ -75000,9 +80928,9 @@ export const GENERATED_PROVIDER_MODELS: { } ], "pricing": { - "input": 0.15, - "output": 1, - "cacheRead": 0.05, + "input": 0.1, + "output": 0.95, + "cacheRead": 0.1, "cacheWrite": 0 }, "releaseDate": "2026-04-17", @@ -75397,9 +81325,9 @@ export const GENERATED_PROVIDER_MODELS: { "minimax/minimax-m2.7": { "id": "minimax/minimax-m2.7", "name": "MiniMax-M2.7", - "contextWindow": 196608, - "maxInputTokens": 196608, - "maxTokens": 176947, + "contextWindow": 204800, + "maxInputTokens": 204800, + "maxTokens": 131072, "capabilities": [ "tools", "reasoning", @@ -75560,7 +81488,7 @@ export const GENERATED_PROVIDER_MODELS: { "name": "Nemotron 3 Super 120B A12B", "contextWindow": 262144, "maxInputTokens": 262144, - "maxTokens": 16384, + "maxTokens": 235929, "capabilities": [ "tools", "reasoning", @@ -75895,7 +81823,7 @@ export const GENERATED_PROVIDER_MODELS: { "name": "Qwen3.5 35B-A3B", "contextWindow": 262144, "maxInputTokens": 262144, - "maxTokens": 235929, + "maxTokens": 32768, "capabilities": [ "images", "video", @@ -76121,7 +82049,7 @@ export const GENERATED_PROVIDER_MODELS: { "name": "Qwen3.5 397B-A17B", "contextWindow": 262144, "maxInputTokens": 262144, - "maxTokens": 81920, + "maxTokens": 235929, "capabilities": [ "images", "video", @@ -76313,36 +82241,6 @@ export const GENERATED_PROVIDER_MODELS: { "releaseDate": "2026-02-12", "family": "glm" }, - "qwen/qwen3-max-thinking": { - "id": "qwen/qwen3-max-thinking", - "name": "Qwen: Qwen3 Max Thinking (retires Oct 9)", - "contextWindow": 262144, - "maxInputTokens": 262144, - "maxTokens": 65536, - "capabilities": [ - "tools", - "reasoning", - "structured_output", - "temperature" - ], - "reasoningOptions": [ - { - "type": "effort", - "values": [ - "none", - "high" - ] - } - ], - "pricing": { - "input": 0.78, - "output": 3.9, - "cacheRead": 0, - "cacheWrite": 0 - }, - "releaseDate": "2026-02-09", - "family": "qwen" - }, "anthropic/claude-opus-4.6": { "id": "anthropic/claude-opus-4.6", "name": "Claude Opus 4.6", @@ -76423,12 +82321,13 @@ export const GENERATED_PROVIDER_MODELS: { "capabilities": [ "tools", "structured_output", - "temperature" + "temperature", + "prompt-cache" ], "pricing": { - "input": 0.3, - "output": 1.5, - "cacheRead": 0, + "input": 0.12, + "output": 0.8, + "cacheRead": 0.07, "cacheWrite": 0 }, "releaseDate": "2026-02-03", @@ -76580,7 +82479,7 @@ export const GENERATED_PROVIDER_MODELS: { }, "bytedance-seed/seed-1.6": { "id": "bytedance-seed/seed-1.6", - "name": "ByteDance Seed: Seed 1.6", + "name": "ByteDance Seed: Seed 1.6 (retires Nov 11)", "contextWindow": 262144, "maxInputTokens": 262144, "maxTokens": 32768, @@ -76612,7 +82511,7 @@ export const GENERATED_PROVIDER_MODELS: { }, "bytedance-seed/seed-1.6-flash": { "id": "bytedance-seed/seed-1.6-flash", - "name": "ByteDance Seed: Seed 1.6 Flash", + "name": "ByteDance Seed: Seed 1.6 Flash (retires Nov 11)", "contextWindow": 262144, "maxInputTokens": 262144, "maxTokens": 32768, @@ -77010,7 +82909,7 @@ export const GENERATED_PROVIDER_MODELS: { }, "mistralai/ministral-14b-2512": { "id": "mistralai/ministral-14b-2512", - "name": "Mistral: Ministral 3 14B 2512", + "name": "Ministral 3 14B 2512", "contextWindow": 262144, "maxInputTokens": 262144, "maxTokens": 209715, @@ -77032,7 +82931,7 @@ export const GENERATED_PROVIDER_MODELS: { }, "mistralai/ministral-3b-2512": { "id": "mistralai/ministral-3b-2512", - "name": "Mistral: Ministral 3 3B 2512", + "name": "Ministral 3 3B 2512", "contextWindow": 131072, "maxInputTokens": 131072, "maxTokens": 104857, @@ -77054,7 +82953,7 @@ export const GENERATED_PROVIDER_MODELS: { }, "mistralai/ministral-8b-2512": { "id": "mistralai/ministral-8b-2512", - "name": "Mistral: Ministral 3 8B 2512", + "name": "Ministral 3 8B 2512", "contextWindow": 262144, "maxInputTokens": 262144, "maxTokens": 209715, @@ -77100,17 +82999,17 @@ export const GENERATED_PROVIDER_MODELS: { "deepseek/deepseek-chat": { "id": "deepseek/deepseek-chat", "name": "DeepSeek Chat", - "contextWindow": 128000, - "maxInputTokens": 128000, - "maxTokens": 16000, + "contextWindow": 163840, + "maxInputTokens": 163840, + "maxTokens": 16384, "capabilities": [ "tools", "structured_output", "temperature" ], "pricing": { - "input": 0.2574, - "output": 1.0287, + "input": 0.32, + "output": 0.89, "cacheRead": 0, "cacheWrite": 0 }, @@ -77141,8 +83040,8 @@ export const GENERATED_PROVIDER_MODELS: { ], "pricing": { "input": 0.259, - "output": 0.42, - "cacheRead": 0.135, + "output": 0.8, + "cacheRead": 0.14, "cacheWrite": 0 }, "releaseDate": "2025-12-01", @@ -77321,12 +83220,13 @@ export const GENERATED_PROVIDER_MODELS: { "name": "Kimi K2 Thinking", "contextWindow": 262144, "maxInputTokens": 262144, - "maxTokens": 235929, + "maxTokens": 98304, "capabilities": [ "tools", "reasoning", "structured_output", - "temperature" + "temperature", + "prompt-cache" ], "reasoningOptions": [ { @@ -77339,7 +83239,7 @@ export const GENERATED_PROVIDER_MODELS: { "pricing": { "input": 0.6, "output": 2.5, - "cacheRead": 0, + "cacheRead": 0.15, "cacheWrite": 0 }, "releaseDate": "2025-11-06", @@ -77425,27 +83325,6 @@ export const GENERATED_PROVIDER_MODELS: { "releaseDate": "2025-10-27", "family": "minimax" }, - "qwen/qwen3-vl-32b-instruct": { - "id": "qwen/qwen3-vl-32b-instruct", - "name": "Qwen: Qwen3 VL 32B Instruct (retires Oct 9)", - "contextWindow": 131072, - "maxInputTokens": 131072, - "maxTokens": 32768, - "capabilities": [ - "images", - "tools", - "structured_output", - "temperature" - ], - "pricing": { - "input": 0.104, - "output": 0.416, - "cacheRead": 0, - "cacheWrite": 0 - }, - "releaseDate": "2025-10-23", - "family": "qwen" - }, "anthropic/claude-haiku-4.5": { "id": "anthropic/claude-haiku-4.5", "name": "Claude Haiku 4.5 (latest)", @@ -77481,50 +83360,21 @@ export const GENERATED_PROVIDER_MODELS: { }, "qwen/qwen3-vl-8b-instruct": { "id": "qwen/qwen3-vl-8b-instruct", - "name": "Qwen: Qwen3 VL 8B Instruct (retires Oct 9)", - "contextWindow": 131072, - "maxInputTokens": 131072, - "maxTokens": 32768, + "name": "Qwen: Qwen3 VL 8B Instruct", + "contextWindow": 262144, + "maxInputTokens": 262144, + "maxTokens": 235929, "capabilities": [ "images", "tools", "structured_output", - "temperature" + "temperature", + "prompt-cache" ], "pricing": { - "input": 0.117, - "output": 0.455, - "cacheRead": 0, - "cacheWrite": 0 - }, - "releaseDate": "2025-10-14", - "family": "qwen" - }, - "qwen/qwen3-vl-8b-thinking": { - "id": "qwen/qwen3-vl-8b-thinking", - "name": "Qwen: Qwen3 VL 8B Thinking (retires Oct 9)", - "contextWindow": 131072, - "maxInputTokens": 131072, - "maxTokens": 32768, - "capabilities": [ - "images", - "tools", - "reasoning", - "structured_output", - "temperature" - ], - "reasoningOptions": [ - { - "type": "effort", - "values": [ - "high" - ] - } - ], - "pricing": { - "input": 0.18, - "output": 2.1, - "cacheRead": 0, + "input": 0.25, + "output": 0.75, + "cacheRead": 0.12, "cacheWrite": 0 }, "releaseDate": "2025-10-14", @@ -77560,33 +83410,12 @@ export const GENERATED_PROVIDER_MODELS: { "releaseDate": "2025-10-06", "family": "gpt-pro" }, - "qwen/qwen3-vl-30b-a3b-instruct": { - "id": "qwen/qwen3-vl-30b-a3b-instruct", - "name": "Qwen: Qwen3 VL 30B A3B Instruct", - "contextWindow": 262144, - "maxInputTokens": 262144, - "maxTokens": 16384, - "capabilities": [ - "images", - "tools", - "structured_output", - "temperature" - ], - "pricing": { - "input": 0.13, - "output": 0.52, - "cacheRead": 0, - "cacheWrite": 0 - }, - "releaseDate": "2025-10-06", - "family": "qwen" - }, "qwen/qwen3-vl-30b-a3b-thinking": { "id": "qwen/qwen3-vl-30b-a3b-thinking", - "name": "Qwen: Qwen3 VL 30B A3B Thinking (retires Oct 9)", - "contextWindow": 131072, - "maxInputTokens": 131072, - "maxTokens": 32768, + "name": "Qwen: Qwen3 VL 30B A3B Thinking", + "contextWindow": 262144, + "maxInputTokens": 262144, + "maxTokens": 235929, "capabilities": [ "images", "tools", @@ -77603,20 +83432,41 @@ export const GENERATED_PROVIDER_MODELS: { } ], "pricing": { - "input": 0.2, - "output": 2.4, + "input": 0.29, + "output": 1, "cacheRead": 0, "cacheWrite": 0 }, "releaseDate": "2025-10-06", "family": "qwen" }, + "qwen/qwen3-vl-30b-a3b-instruct": { + "id": "qwen/qwen3-vl-30b-a3b-instruct", + "name": "Qwen3 VL 30B A3B Instruct", + "contextWindow": 262144, + "maxInputTokens": 262144, + "maxTokens": 16384, + "capabilities": [ + "images", + "tools", + "structured_output", + "temperature" + ], + "pricing": { + "input": 0.15, + "output": 0.6, + "cacheRead": 0, + "cacheWrite": 0 + }, + "releaseDate": "2025-10-04", + "family": "qwen" + }, "z-ai/glm-4.6": { "id": "z-ai/glm-4.6", "name": "GLM-4.6", - "contextWindow": 198000, - "maxInputTokens": 198000, - "maxTokens": 16384, + "contextWindow": 202752, + "maxInputTokens": 202752, + "maxTokens": 131072, "capabilities": [ "tools", "reasoning", @@ -77634,9 +83484,9 @@ export const GENERATED_PROVIDER_MODELS: { } ], "pricing": { - "input": 0.43, - "output": 1.75, - "cacheRead": 0.08, + "input": 0.5, + "output": 2, + "cacheRead": 0.1, "cacheWrite": 0 }, "releaseDate": "2025-09-30", @@ -77705,27 +83555,6 @@ export const GENERATED_PROVIDER_MODELS: { "releaseDate": "2025-09-29", "family": "deepseek" }, - "qwen/qwen3-max": { - "id": "qwen/qwen3-max", - "name": "Qwen3 Max", - "contextWindow": 262144, - "maxInputTokens": 262144, - "maxTokens": 65536, - "capabilities": [ - "tools", - "structured_output", - "temperature", - "prompt-cache" - ], - "pricing": { - "input": 0.78, - "output": 3.9, - "cacheRead": 0.156, - "cacheWrite": 0.975 - }, - "releaseDate": "2025-09-23", - "family": "qwen" - }, "qwen/qwen3-vl-235b-a22b-instruct": { "id": "qwen/qwen3-vl-235b-a22b-instruct", "name": "Qwen3 VL 235B A22B Instruct", @@ -77736,42 +83565,13 @@ export const GENERATED_PROVIDER_MODELS: { "images", "tools", "structured_output", - "temperature" + "temperature", + "prompt-cache" ], "pricing": { - "input": 0.26, - "output": 1.04, - "cacheRead": 0, - "cacheWrite": 0 - }, - "releaseDate": "2025-09-23", - "family": "qwen" - }, - "qwen/qwen3-vl-235b-a22b-thinking": { - "id": "qwen/qwen3-vl-235b-a22b-thinking", - "name": "Qwen3 VL 235B A22B Thinking", - "contextWindow": 131072, - "maxInputTokens": 131072, - "maxTokens": 32768, - "capabilities": [ - "images", - "tools", - "reasoning", - "structured_output", - "temperature" - ], - "reasoningOptions": [ - { - "type": "effort", - "values": [ - "high" - ] - } - ], - "pricing": { - "input": 0.4, - "output": 4, - "cacheRead": 0, + "input": 0.21, + "output": 1.9, + "cacheRead": 0.1, "cacheWrite": 0 }, "releaseDate": "2025-09-23", @@ -77807,29 +83607,9 @@ export const GENERATED_PROVIDER_MODELS: { "releaseDate": "2025-09-22", "family": "deepseek" }, - "qwen/qwen-plus-2025-07-28": { - "id": "qwen/qwen-plus-2025-07-28", - "name": "Qwen: Qwen Plus 0728 (retires Oct 9)", - "contextWindow": 1000000, - "maxInputTokens": 1000000, - "maxTokens": 32768, - "capabilities": [ - "tools", - "structured_output", - "temperature" - ], - "pricing": { - "input": 0.26, - "output": 0.78, - "cacheRead": 0, - "cacheWrite": 0 - }, - "releaseDate": "2025-09-08", - "family": "qwen" - }, "moonshotai/kimi-k2-0905": { "id": "moonshotai/kimi-k2-0905", - "name": "MoonshotAI: Kimi K2 0905", + "name": "Kimi K2 Instruct 0905", "contextWindow": 262144, "maxInputTokens": 262144, "maxTokens": 98304, @@ -77844,24 +83624,25 @@ export const GENERATED_PROVIDER_MODELS: { "cacheRead": 0, "cacheWrite": 0 }, - "releaseDate": "2025-09-04", + "releaseDate": "2025-09-05", "family": "kimi-k2" }, "qwen/qwen3-next-80b-a3b-instruct": { "id": "qwen/qwen3-next-80b-a3b-instruct", "name": "Qwen3-Next 80B-A3B Instruct", - "contextWindow": 131072, - "maxInputTokens": 131072, - "maxTokens": 32768, + "contextWindow": 262144, + "maxInputTokens": 262144, + "maxTokens": 235929, "capabilities": [ "tools", "structured_output", - "temperature" + "temperature", + "prompt-cache" ], "pricing": { - "input": 0.0975, - "output": 0.78, - "cacheRead": 0, + "input": 0.1, + "output": 1.1, + "cacheRead": 0.07, "cacheWrite": 0 }, "releaseDate": "2025-09", @@ -77870,9 +83651,9 @@ export const GENERATED_PROVIDER_MODELS: { "qwen/qwen3-next-80b-a3b-thinking": { "id": "qwen/qwen3-next-80b-a3b-thinking", "name": "Qwen3-Next 80B-A3B (Thinking)", - "contextWindow": 131072, - "maxInputTokens": 131072, - "maxTokens": 32768, + "contextWindow": 262144, + "maxInputTokens": 262144, + "maxTokens": 235929, "capabilities": [ "tools", "reasoning", @@ -77896,34 +83677,6 @@ export const GENERATED_PROVIDER_MODELS: { "releaseDate": "2025-09", "family": "qwen" }, - "qwen/qwen3-30b-a3b-thinking-2507": { - "id": "qwen/qwen3-30b-a3b-thinking-2507", - "name": "Qwen: Qwen3 30B A3B Thinking 2507 (retires Oct 9)", - "contextWindow": 81920, - "maxInputTokens": 81920, - "maxTokens": 32768, - "capabilities": [ - "tools", - "reasoning", - "temperature" - ], - "reasoningOptions": [ - { - "type": "effort", - "values": [ - "high" - ] - } - ], - "pricing": { - "input": 0.2, - "output": 2.4, - "cacheRead": 0, - "cacheWrite": 0 - }, - "releaseDate": "2025-08-28", - "family": "qwen" - }, "stealth/claude-opus-4.6": { "id": "stealth/claude-opus-4.6", "name": "Stealth: Claude Opus 4.6 (20% off)", @@ -78068,6 +83821,36 @@ export const GENERATED_PROVIDER_MODELS: { "releaseDate": "2025-08-26", "family": "claude-sonnet" }, + "stealth/glyph-cluster": { + "id": "stealth/glyph-cluster", + "name": "Stealth: Glyph Cluster (free)", + "contextWindow": 256000, + "maxInputTokens": 256000, + "maxTokens": 256000, + "capabilities": [ + "tools", + "reasoning", + "temperature" + ], + "reasoningOptions": [ + { + "type": "effort", + "values": [ + "low", + "medium", + "high", + "xhigh" + ] + } + ], + "pricing": { + "input": 0, + "output": 0, + "cacheRead": 0, + "cacheWrite": 0 + }, + "releaseDate": "2025-08-26" + }, "stealth/qwen3.6-plus": { "id": "stealth/qwen3.6-plus", "name": "Stealth: Qwen3.6 Plus (50% off)", @@ -78102,9 +83885,40 @@ export const GENERATED_PROVIDER_MODELS: { "releaseDate": "2025-08-26", "family": "qwen3.6" }, + "stepfun/step-5-preview-free": { + "id": "stepfun/step-5-preview-free", + "name": "StepFun: Step 5 Preview (free)", + "contextWindow": 1000000, + "maxInputTokens": 1000000, + "maxTokens": 64000, + "capabilities": [ + "images", + "tools", + "reasoning", + "temperature" + ], + "reasoningOptions": [ + { + "type": "effort", + "values": [ + "low", + "medium", + "high" + ] + } + ], + "pricing": { + "input": 0, + "output": 0, + "cacheRead": 0, + "cacheWrite": 0 + }, + "releaseDate": "2025-08-26", + "family": "step" + }, "deepseek/deepseek-chat-v3.1": { "id": "deepseek/deepseek-chat-v3.1", - "name": "DeepSeek: DeepSeek V3.1", + "name": "DeepSeek V3.1", "contextWindow": 163840, "maxInputTokens": 163840, "maxTokens": 32768, @@ -78135,7 +83949,7 @@ export const GENERATED_PROVIDER_MODELS: { }, "mistralai/mistral-medium-3.1": { "id": "mistralai/mistral-medium-3.1", - "name": "Mistral: Mistral Medium 3.1", + "name": "Mistral Medium 3.1", "contextWindow": 131072, "maxInputTokens": 131072, "maxTokens": 104857, @@ -78326,7 +84140,7 @@ export const GENERATED_PROVIDER_MODELS: { "name": "GPT OSS 120B", "contextWindow": 131072, "maxInputTokens": 131072, - "maxTokens": 117964, + "maxTokens": 65536, "capabilities": [ "tools", "reasoning", @@ -78346,7 +84160,7 @@ export const GENERATED_PROVIDER_MODELS: { ], "pricing": { "input": 0.03, - "output": 0.17, + "output": 0.15, "cacheRead": 0.03, "cacheWrite": 0 }, @@ -78410,17 +84224,17 @@ export const GENERATED_PROVIDER_MODELS: { "qwen/qwen3-30b-a3b-instruct-2507": { "id": "qwen/qwen3-30b-a3b-instruct-2507", "name": "Qwen: Qwen3 30B A3B Instruct 2507", - "contextWindow": 128000, - "maxInputTokens": 128000, - "maxTokens": 32000, + "contextWindow": 262144, + "maxInputTokens": 262144, + "maxTokens": 235929, "capabilities": [ "tools", "structured_output", "temperature" ], "pricing": { - "input": 0.13, - "output": 0.52, + "input": 0.1, + "output": 0.3, "cacheRead": 0, "cacheWrite": 0 }, @@ -78509,10 +84323,10 @@ export const GENERATED_PROVIDER_MODELS: { }, "qwen/qwen3-235b-a22b-thinking-2507": { "id": "qwen/qwen3-235b-a22b-thinking-2507", - "name": "Qwen: Qwen3 235B A22B Thinking 2507 (retires Oct 9)", - "contextWindow": 131072, - "maxInputTokens": 131072, - "maxTokens": 117964, + "name": "Qwen: Qwen3 235B A22B Thinking 2507", + "contextWindow": 128000, + "maxInputTokens": 128000, + "maxTokens": 16384, "capabilities": [ "tools", "reasoning", @@ -78527,8 +84341,8 @@ export const GENERATED_PROVIDER_MODELS: { } ], "pricing": { - "input": 0.23, - "output": 2.3, + "input": 0.45, + "output": 3.5, "cacheRead": 0, "cacheWrite": 0 }, @@ -78541,26 +84355,6 @@ export const GENERATED_PROVIDER_MODELS: { "contextWindow": 262144, "maxInputTokens": 262144, "maxTokens": 65536, - "capabilities": [ - "tools", - "structured_output", - "temperature" - ], - "pricing": { - "input": 0.975, - "output": 4.875, - "cacheRead": 0, - "cacheWrite": 0 - }, - "releaseDate": "2025-07-23", - "family": "qwen" - }, - "qwen/qwen3-coder-plus": { - "id": "qwen/qwen3-coder-plus", - "name": "Qwen3 Coder Plus", - "contextWindow": 1000000, - "maxInputTokens": 1000000, - "maxTokens": 65536, "capabilities": [ "tools", "structured_output", @@ -78568,10 +84362,10 @@ export const GENERATED_PROVIDER_MODELS: { "prompt-cache" ], "pricing": { - "input": 0.65, - "output": 3.25, - "cacheRead": 0.13, - "cacheWrite": 0.8125 + "input": 0.3, + "output": 1, + "cacheRead": 0.1, + "cacheWrite": 0 }, "releaseDate": "2025-07-23", "family": "qwen" @@ -78588,8 +84382,8 @@ export const GENERATED_PROVIDER_MODELS: { "temperature" ], "pricing": { - "input": 0.1495, - "output": 0.598, + "input": 0.09, + "output": 0.55, "cacheRead": 0, "cacheWrite": 0 }, @@ -78630,7 +84424,7 @@ export const GENERATED_PROVIDER_MODELS: { }, "moonshotai/kimi-k2": { "id": "moonshotai/kimi-k2", - "name": "MoonshotAI: Kimi K2 0711", + "name": "Kimi K2 0711", "contextWindow": 131072, "maxInputTokens": 131072, "maxTokens": 98304, @@ -78649,7 +84443,7 @@ export const GENERATED_PROVIDER_MODELS: { }, "mistralai/mistral-small-3.2-24b-instruct": { "id": "mistralai/mistral-small-3.2-24b-instruct", - "name": "Mistral: Mistral Small 3.2 24B", + "name": "Mistral Small 3.2 24B", "contextWindow": 256000, "maxInputTokens": 256000, "maxTokens": 16384, @@ -78868,7 +84662,7 @@ export const GENERATED_PROVIDER_MODELS: { }, "google/gemini-2.5-pro-preview": { "id": "google/gemini-2.5-pro-preview", - "name": "Google: Gemini 2.5 Pro Preview 06-05", + "name": "Gemini 2.5 Pro Preview 06-05", "contextWindow": 1048576, "maxInputTokens": 1048576, "maxTokens": 65536, @@ -78911,7 +84705,7 @@ export const GENERATED_PROVIDER_MODELS: { }, "deepseek/deepseek-r1-0528": { "id": "deepseek/deepseek-r1-0528", - "name": "DeepSeek: R1 0528", + "name": "DeepSeek R1 0528", "contextWindow": 163840, "maxInputTokens": 163840, "maxTokens": 32768, @@ -78937,7 +84731,7 @@ export const GENERATED_PROVIDER_MODELS: { "cacheWrite": 0 }, "releaseDate": "2025-05-28", - "family": "deepseek" + "family": "deepseek-thinking" }, "anthropic/claude-sonnet-4": { "id": "anthropic/claude-sonnet-4", @@ -78973,7 +84767,7 @@ export const GENERATED_PROVIDER_MODELS: { }, "mistralai/mistral-medium-3": { "id": "mistralai/mistral-medium-3", - "name": "Mistral: Mistral Medium 3", + "name": "Mistral Medium 3", "contextWindow": 131072, "maxInputTokens": 131072, "maxTokens": 104857, @@ -79016,8 +84810,8 @@ export const GENERATED_PROVIDER_MODELS: { } ], "pricing": { - "input": 0.2275, - "output": 0.91, + "input": 0.12, + "output": 0.24, "cacheRead": 0, "cacheWrite": 0 }, @@ -79046,37 +84840,8 @@ export const GENERATED_PROVIDER_MODELS: { } ], "pricing": { - "input": 0.13, - "output": 0.52, - "cacheRead": 0, - "cacheWrite": 0 - }, - "releaseDate": "2025-04-28", - "family": "qwen" - }, - "qwen/qwen3-8b": { - "id": "qwen/qwen3-8b", - "name": "Qwen: Qwen3 8B (retires Oct 9)", - "contextWindow": 131072, - "maxInputTokens": 131072, - "maxTokens": 8192, - "capabilities": [ - "tools", - "reasoning", - "temperature" - ], - "reasoningOptions": [ - { - "type": "effort", - "values": [ - "none", - "high" - ] - } - ], - "pricing": { - "input": 0.117, - "output": 0.455, + "input": 0.12, + "output": 0.5, "cacheRead": 0, "cacheWrite": 0 }, @@ -79272,9 +85037,9 @@ export const GENERATED_PROVIDER_MODELS: { "meta-llama/llama-4-scout": { "id": "meta-llama/llama-4-scout", "name": "Meta: Llama 4 Scout", - "contextWindow": 327680, - "maxInputTokens": 327680, - "maxTokens": 16384, + "contextWindow": 131072, + "maxInputTokens": 131072, + "maxTokens": 117964, "capabilities": [ "images", "tools", @@ -79290,35 +85055,6 @@ export const GENERATED_PROVIDER_MODELS: { "releaseDate": "2025-04-05", "family": "llama" }, - "qwen/qwen3-235b-a22b": { - "id": "qwen/qwen3-235b-a22b", - "name": "Qwen3 235B-A22B", - "contextWindow": 131072, - "maxInputTokens": 131072, - "maxTokens": 8192, - "capabilities": [ - "tools", - "reasoning", - "temperature" - ], - "reasoningOptions": [ - { - "type": "effort", - "values": [ - "none", - "high" - ] - } - ], - "pricing": { - "input": 0.455, - "output": 1.82, - "cacheRead": 0, - "cacheWrite": 0 - }, - "releaseDate": "2025-04", - "family": "qwen" - }, "qwen/qwen3-32b": { "id": "qwen/qwen3-32b", "name": "Qwen3 32B", @@ -79361,8 +85097,8 @@ export const GENERATED_PROVIDER_MODELS: { "temperature" ], "pricing": { - "input": 0.2925, - "output": 1.4625, + "input": 0.07, + "output": 0.28, "cacheRead": 0, "cacheWrite": 0 }, @@ -79371,7 +85107,7 @@ export const GENERATED_PROVIDER_MODELS: { }, "deepseek/deepseek-chat-v3-0324": { "id": "deepseek/deepseek-chat-v3-0324", - "name": "DeepSeek: DeepSeek V3 0324", + "name": "DeepSeek V3 0324", "contextWindow": 128000, "maxInputTokens": 128000, "maxTokens": 115200, @@ -79392,7 +85128,7 @@ export const GENERATED_PROVIDER_MODELS: { }, "mistralai/mistral-small-3.1-24b-instruct": { "id": "mistralai/mistral-small-3.1-24b-instruct", - "name": "Mistral: Mistral Small 3.1 24B", + "name": "Mistral Small 3.1 24B", "contextWindow": 128000, "maxInputTokens": 128000, "maxTokens": 102400, @@ -79455,7 +85191,7 @@ export const GENERATED_PROVIDER_MODELS: { }, "mistralai/mistral-saba": { "id": "mistralai/mistral-saba", - "name": "Mistral: Saba", + "name": "Saba", "contextWindow": 32768, "maxInputTokens": 32768, "maxTokens": 26214, @@ -79569,7 +85305,7 @@ export const GENERATED_PROVIDER_MODELS: { "name": "Llama-3.3-70B-Instruct", "contextWindow": 131072, "maxInputTokens": 131072, - "maxTokens": 16384, + "maxTokens": 32768, "capabilities": [ "tools", "structured_output", @@ -79800,26 +85536,6 @@ export const GENERATED_PROVIDER_MODELS: { "releaseDate": "2024-08-30", "family": "command-r" }, - "sao10k/l3.1-euryale-70b": { - "id": "sao10k/l3.1-euryale-70b", - "name": "Sao10K: Llama 3.1 Euryale 70B v2.2", - "contextWindow": 131072, - "maxInputTokens": 131072, - "maxTokens": 16384, - "capabilities": [ - "tools", - "structured_output", - "temperature" - ], - "pricing": { - "input": 0.85, - "output": 0.85, - "cacheRead": 0, - "cacheWrite": 0 - }, - "releaseDate": "2024-08-28", - "family": "llama" - }, "openai/gpt-4o-2024-08-06": { "id": "openai/gpt-4o-2024-08-06", "name": "GPT-4o (2024-08-06)", @@ -79997,7 +85713,7 @@ export const GENERATED_PROVIDER_MODELS: { }, "mistralai/mixtral-8x22b-instruct": { "id": "mistralai/mixtral-8x22b-instruct", - "name": "Mistral: Mixtral 8x22B Instruct", + "name": "Mixtral 8x22B Instruct", "contextWindow": 65536, "maxInputTokens": 65536, "maxTokens": 52428, @@ -80909,6 +86625,76 @@ export const GENERATED_PROVIDER_MODELS: { } }, "llmgateway": { + "muse-wonder-1.0": { + "id": "muse-wonder-1.0", + "name": "Muse Wonder", + "contextWindow": 1048576, + "maxInputTokens": 1048576, + "maxTokens": 131072, + "capabilities": [ + "images", + "video", + "files", + "tools", + "reasoning", + "structured_output", + "temperature", + "prompt-cache" + ], + "reasoningOptions": [ + { + "type": "effort", + "values": [ + "minimal", + "low", + "medium", + "high", + "xhigh" + ] + } + ], + "pricing": { + "input": 4, + "output": 20, + "cacheRead": 0.2, + "cacheWrite": 0 + }, + "releaseDate": "2026-10-12", + "family": "muse", + "modalities": { + "input": [ + "text", + "image", + "video", + "pdf", + "audio" + ], + "output": [ + "text" + ] + } + }, + "reka-edge-2603": { + "id": "reka-edge-2603", + "name": "Reka Edge 2603", + "contextWindow": 16384, + "maxInputTokens": 16384, + "maxTokens": 16384, + "capabilities": [ + "images", + "tools", + "structured_output", + "temperature" + ], + "pricing": { + "input": 0.1, + "output": 0.1, + "cacheRead": 0, + "cacheWrite": 0 + }, + "releaseDate": "2026-10-09", + "family": "reka" + }, "claude-haiku-5-5": { "id": "claude-haiku-5-5", "name": "Claude Haiku 5.5", @@ -81061,7 +86847,7 @@ export const GENERATED_PROVIDER_MODELS: { "pricing": { "input": 2, "output": 10, - "cacheRead": 0.2, + "cacheRead": 0.1, "cacheWrite": 2.5 }, "releaseDate": "2026-09-28", @@ -81858,7 +87644,9 @@ export const GENERATED_PROVIDER_MODELS: { "type": "effort", "values": [ "none", + "minimal", "low", + "medium", "high", "xhigh", "max" @@ -81866,9 +87654,9 @@ export const GENERATED_PROVIDER_MODELS: { } ], "pricing": { - "input": 0.9, - "output": 3, - "cacheRead": 0.15, + "input": 0.924, + "output": 2.904, + "cacheRead": 0.1716, "cacheWrite": 0 }, "releaseDate": "2026-08-14", @@ -82356,9 +88144,9 @@ export const GENERATED_PROVIDER_MODELS: { } ], "pricing": { - "input": 2, - "output": 6, - "cacheRead": 0.25, + "input": 1.54, + "output": 4.631, + "cacheRead": 0.198, "cacheWrite": 2.5 }, "releaseDate": "2026-07-19", @@ -82716,9 +88504,9 @@ export const GENERATED_PROVIDER_MODELS: { } ], "pricing": { - "input": 0.8, - "output": 2.55, - "cacheRead": 0.16, + "input": 0.605, + "output": 2.123, + "cacheRead": 0.154, "cacheWrite": 0 }, "releaseDate": "2026-06-13", @@ -82739,9 +88527,9 @@ export const GENERATED_PROVIDER_MODELS: { ], "reasoningOptions": [], "pricing": { - "input": 2.2, - "output": 6.5, - "cacheRead": 0.45, + "input": 2.1256, + "output": 6.776, + "cacheRead": 0.429, "cacheWrite": 0 }, "releaseDate": "2026-06-13", @@ -82776,9 +88564,9 @@ export const GENERATED_PROVIDER_MODELS: { } ], "pricing": { - "input": 0.95, - "output": 4, - "cacheRead": 0.19, + "input": 0.737, + "output": 3.08, + "cacheRead": 0.143, "cacheWrite": 0 }, "releaseDate": "2026-06-12", @@ -83468,9 +89256,9 @@ export const GENERATED_PROVIDER_MODELS: { } ], "pricing": { - "input": 0.14, - "output": 0.28, - "cacheRead": 0.0028, + "input": 0.168, + "output": 0.336, + "cacheRead": 0.0034, "cacheWrite": 0 }, "releaseDate": "2026-04-22", @@ -83560,28 +89348,6 @@ export const GENERATED_PROVIDER_MODELS: { "releaseDate": "2026-04-21", "family": "kimi-k2" }, - "qwen3.6-max-preview": { - "id": "qwen3.6-max-preview", - "name": "Qwen3.6 Max Preview", - "contextWindow": 262144, - "maxInputTokens": 262144, - "maxTokens": 65536, - "capabilities": [ - "tools", - "reasoning", - "temperature", - "prompt-cache" - ], - "reasoningOptions": [], - "pricing": { - "input": 1.3, - "output": 7.8, - "cacheRead": 0.13, - "cacheWrite": 1.625 - }, - "releaseDate": "2026-04-20", - "family": "qwen" - }, "grok-4-3": { "id": "grok-4-3", "name": "Grok 4.3", @@ -85627,28 +91393,6 @@ export const GENERATED_PROVIDER_MODELS: { "releaseDate": "2025-10-15", "family": "qwen" }, - "qwen3-vl-flash": { - "id": "qwen3-vl-flash", - "name": "Qwen3 VL Flash", - "contextWindow": 262144, - "maxInputTokens": 262144, - "maxTokens": 32000, - "capabilities": [ - "images", - "tools", - "structured_output", - "temperature", - "prompt-cache" - ], - "pricing": { - "input": 0.05, - "output": 0.4, - "cacheRead": 0.01, - "cacheWrite": 0 - }, - "releaseDate": "2025-10-09", - "family": "qwen" - }, "gpt-5-pro": { "id": "gpt-5-pro", "name": "GPT-5 Pro", @@ -86410,26 +92154,6 @@ export const GENERATED_PROVIDER_MODELS: { "releaseDate": "2025-07-26", "family": "seed" }, - "qwen3-coder-plus": { - "id": "qwen3-coder-plus", - "name": "Qwen3 Coder Plus", - "contextWindow": 1000000, - "maxInputTokens": 1000000, - "maxTokens": 65536, - "capabilities": [ - "tools", - "temperature", - "prompt-cache" - ], - "pricing": { - "input": 1, - "output": 5, - "cacheRead": 0.2, - "cacheWrite": 1.25 - }, - "releaseDate": "2025-07-23", - "family": "qwen" - }, "kimi-k2": { "id": "kimi-k2", "name": "Kimi K2", @@ -87116,26 +92840,6 @@ export const GENERATED_PROVIDER_MODELS: { "releaseDate": "2024-11-01", "family": "mistral-large" }, - "qwen-coder-plus": { - "id": "qwen-coder-plus", - "name": "Qwen Coder Plus", - "contextWindow": 131072, - "maxInputTokens": 131072, - "maxTokens": 8192, - "capabilities": [ - "tools", - "structured_output", - "temperature" - ], - "pricing": { - "input": 0.502, - "output": 1.004, - "cacheRead": 0, - "cacheWrite": 0 - }, - "releaseDate": "2024-09-18", - "family": "qwen" - }, "gpt-4o-mini": { "id": "gpt-4o-mini", "name": "GPT-4o mini", @@ -87306,6 +93010,76 @@ export const GENERATED_PROVIDER_MODELS: { } }, "llmgateway-providers": { + "meta/muse-wonder-1.0": { + "id": "meta/muse-wonder-1.0", + "name": "Muse Wonder 1.0 (Meta)", + "contextWindow": 1048576, + "maxInputTokens": 1048576, + "maxTokens": 1048576, + "capabilities": [ + "images", + "video", + "files", + "tools", + "reasoning", + "structured_output", + "temperature", + "prompt-cache" + ], + "reasoningOptions": [ + { + "type": "effort", + "values": [ + "minimal", + "low", + "medium", + "high", + "xhigh" + ] + } + ], + "pricing": { + "input": 4, + "output": 20, + "cacheRead": 0.2, + "cacheWrite": 0 + }, + "releaseDate": "2026-10-12", + "family": "muse", + "modalities": { + "input": [ + "text", + "image", + "video", + "pdf", + "audio" + ], + "output": [ + "text" + ] + } + }, + "reka-ai/reka-edge-2603": { + "id": "reka-ai/reka-edge-2603", + "name": "Reka Edge 2603 (reka-ai)", + "contextWindow": 16384, + "maxInputTokens": 16384, + "maxTokens": 16384, + "capabilities": [ + "images", + "tools", + "structured_output", + "temperature" + ], + "pricing": { + "input": 0.1, + "output": 0.1, + "cacheRead": 0, + "cacheWrite": 0 + }, + "releaseDate": "2026-10-09", + "family": "reka" + }, "anthropic/claude-haiku-5-5": { "id": "anthropic/claude-haiku-5-5", "name": "Claude Haiku 5.5 (Anthropic)", @@ -87526,7 +93300,7 @@ export const GENERATED_PROVIDER_MODELS: { "pricing": { "input": 2, "output": 10, - "cacheRead": 0.2, + "cacheRead": 0.1, "cacheWrite": 2.5 }, "releaseDate": "2026-09-28", @@ -87560,7 +93334,7 @@ export const GENERATED_PROVIDER_MODELS: { "pricing": { "input": 2, "output": 10, - "cacheRead": 0.2, + "cacheRead": 0.1, "cacheWrite": 2.5 }, "releaseDate": "2026-09-28", @@ -87595,7 +93369,7 @@ export const GENERATED_PROVIDER_MODELS: { "pricing": { "input": 2, "output": 10, - "cacheRead": 0.2, + "cacheRead": 0.1, "cacheWrite": 2.5 }, "releaseDate": "2026-09-28", @@ -89423,6 +95197,45 @@ export const GENERATED_PROVIDER_MODELS: { "releaseDate": "2026-08-26", "family": "qwen" }, + "reka-ai/glm-5.3-flash": { + "id": "reka-ai/glm-5.3-flash", + "name": "GLM-5.3 Flash (reka-ai)", + "contextWindow": 262144, + "maxInputTokens": 262144, + "maxTokens": 262144, + "capabilities": [ + "images", + "video", + "files", + "tools", + "reasoning", + "structured_output", + "temperature", + "prompt-cache" + ], + "reasoningOptions": [ + { + "type": "effort", + "values": [ + "none", + "minimal", + "low", + "medium", + "high", + "xhigh", + "max" + ] + } + ], + "pricing": { + "input": 0.15, + "output": 0.5, + "cacheRead": 0.02, + "cacheWrite": 0 + }, + "releaseDate": "2026-08-26", + "family": "glm-flash" + }, "runware/glm-5.3-flash": { "id": "runware/glm-5.3-flash", "name": "GLM-5.3 Flash (Runware)", @@ -89774,6 +95587,80 @@ export const GENERATED_PROVIDER_MODELS: { "releaseDate": "2026-08-14", "family": "qwen" }, + "reka-ai/glm-5.3": { + "id": "reka-ai/glm-5.3", + "name": "GLM-5.3 (reka-ai)", + "contextWindow": 1048576, + "maxInputTokens": 1048576, + "maxTokens": 262144, + "capabilities": [ + "tools", + "reasoning", + "structured_output", + "temperature", + "prompt-cache" + ], + "reasoningOptions": [ + { + "type": "effort", + "values": [ + "none", + "minimal", + "low", + "medium", + "high", + "xhigh", + "max" + ] + } + ], + "pricing": { + "input": 1.17, + "output": 3.96, + "cacheRead": 0.234, + "cacheWrite": 0 + }, + "releaseDate": "2026-08-14", + "family": "glm" + }, + "reka-ai/qwen3.8-27b": { + "id": "reka-ai/qwen3.8-27b", + "name": "Qwen3.8 27B (reka-ai)", + "contextWindow": 262144, + "maxInputTokens": 262144, + "maxTokens": 262144, + "capabilities": [ + "images", + "video", + "tools", + "reasoning", + "structured_output", + "temperature", + "prompt-cache" + ], + "reasoningOptions": [ + { + "type": "effort", + "values": [ + "none", + "minimal", + "low", + "medium", + "high", + "xhigh", + "max" + ] + } + ], + "pricing": { + "input": 0.41, + "output": 3, + "cacheRead": 0.085, + "cacheWrite": 0 + }, + "releaseDate": "2026-08-14", + "family": "qwen" + }, "runware/glm-5.3": { "id": "runware/glm-5.3", "name": "GLM-5.3 (Runware)", @@ -89829,9 +95716,9 @@ export const GENERATED_PROVIDER_MODELS: { } ], "pricing": { - "input": 1.4, - "output": 4.4, - "cacheRead": 0.26, + "input": 0.924, + "output": 2.904, + "cacheRead": 0.1716, "cacheWrite": 0 }, "releaseDate": "2026-08-14", @@ -90028,41 +95915,6 @@ export const GENERATED_PROVIDER_MODELS: { "releaseDate": "2026-08-12", "family": "grok" }, - "deepinfra/qwen3.8-2.4t-a95b": { - "id": "deepinfra/qwen3.8-2.4t-a95b", - "name": "Qwen3.8 2.4T A95B (DeepInfra)", - "contextWindow": 262144, - "maxInputTokens": 262144, - "maxTokens": 131072, - "capabilities": [ - "tools", - "reasoning", - "structured_output", - "temperature", - "prompt-cache" - ], - "reasoningOptions": [ - { - "type": "effort", - "values": [ - "minimal", - "low", - "medium", - "high", - "xhigh", - "max" - ] - } - ], - "pricing": { - "input": 2, - "output": 6, - "cacheRead": 0.2, - "cacheWrite": 0 - }, - "releaseDate": "2026-08-12", - "family": "qwen" - }, "novita/qwen3.8-2.4t-a95b": { "id": "novita/qwen3.8-2.4t-a95b", "name": "Qwen3.8 2.4T A95B (NovitaAI)", @@ -90459,9 +96311,9 @@ export const GENERATED_PROVIDER_MODELS: { } ], "pricing": { - "input": 2, - "output": 6, - "cacheRead": 0.25, + "input": 1.54, + "output": 4.631, + "cacheRead": 0.198, "cacheWrite": 2.5 }, "releaseDate": "2026-08-03", @@ -91165,9 +97017,9 @@ export const GENERATED_PROVIDER_MODELS: { } ], "pricing": { - "input": 3.5, - "output": 18, - "cacheRead": 0.35, + "input": 3.135, + "output": 15.675, + "cacheRead": 0.319, "cacheWrite": 0 }, "releaseDate": "2026-07-16", @@ -92139,9 +97991,9 @@ export const GENERATED_PROVIDER_MODELS: { } ], "pricing": { - "input": 0.88, - "output": 2.55, - "cacheRead": 0.16, + "input": 0.605, + "output": 2.123, + "cacheRead": 0.154, "cacheWrite": 0 }, "releaseDate": "2026-06-13", @@ -92161,9 +98013,9 @@ export const GENERATED_PROVIDER_MODELS: { ], "reasoningOptions": [], "pricing": { - "input": 2.2, - "output": 6.5, - "cacheRead": 0.45, + "input": 2.1256, + "output": 6.776, + "cacheRead": 0.429, "cacheWrite": 0 }, "releaseDate": "2026-06-13", @@ -92365,9 +98217,9 @@ export const GENERATED_PROVIDER_MODELS: { } ], "pricing": { - "input": 0.95, - "output": 4, - "cacheRead": 0.19, + "input": 0.737, + "output": 3.08, + "cacheRead": 0.143, "cacheWrite": 0 }, "releaseDate": "2026-06-12", @@ -93919,6 +99771,78 @@ export const GENERATED_PROVIDER_MODELS: { "releaseDate": "2026-04-24", "family": "deepseek-flash" }, + "reka-ai/deepseek-v4-flash": { + "id": "reka-ai/deepseek-v4-flash", + "name": "DeepSeek V4 Flash (reka-ai)", + "contextWindow": 262144, + "maxInputTokens": 262144, + "maxTokens": 131072, + "capabilities": [ + "tools", + "reasoning", + "structured_output", + "temperature", + "prompt-cache" + ], + "reasoningOptions": [ + { + "type": "effort", + "values": [ + "none", + "minimal", + "low", + "medium", + "high", + "xhigh", + "max" + ] + } + ], + "pricing": { + "input": 0.11, + "output": 0.66, + "cacheRead": 0.01, + "cacheWrite": 0 + }, + "releaseDate": "2026-04-24", + "family": "deepseek-flash" + }, + "reka-ai/deepseek-v4-pro": { + "id": "reka-ai/deepseek-v4-pro", + "name": "DeepSeek V4 Pro (reka-ai)", + "contextWindow": 1048576, + "maxInputTokens": 1048576, + "maxTokens": 393216, + "capabilities": [ + "tools", + "reasoning", + "structured_output", + "temperature", + "prompt-cache" + ], + "reasoningOptions": [ + { + "type": "effort", + "values": [ + "none", + "minimal", + "low", + "medium", + "high", + "xhigh", + "max" + ] + } + ], + "pricing": { + "input": 1.2, + "output": 3.3, + "cacheRead": 0.13, + "cacheWrite": 0 + }, + "releaseDate": "2026-04-24", + "family": "deepseek-thinking" + }, "runware/deepseek-v4-flash": { "id": "runware/deepseek-v4-flash", "name": "DeepSeek V4 Flash (Runware)", @@ -94630,28 +100554,6 @@ export const GENERATED_PROVIDER_MODELS: { "releaseDate": "2026-04-21", "family": "kimi-k2" }, - "alibaba/qwen3.6-max-preview": { - "id": "alibaba/qwen3.6-max-preview", - "name": "Qwen3.6 Max Preview (Alibaba Cloud)", - "contextWindow": 262144, - "maxInputTokens": 262144, - "maxTokens": 65536, - "capabilities": [ - "tools", - "reasoning", - "temperature", - "prompt-cache" - ], - "reasoningOptions": [], - "pricing": { - "input": 1.3, - "output": 7.8, - "cacheRead": 0.13, - "cacheWrite": 0 - }, - "releaseDate": "2026-04-20", - "family": "qwen" - }, "alibaba/qwen3.6-35b-a3b": { "id": "alibaba/qwen3.6-35b-a3b", "name": "Qwen3.6 35B A3B (Alibaba Cloud)", @@ -94880,7 +100782,7 @@ export const GENERATED_PROVIDER_MODELS: { "input": 5, "output": 25, "cacheRead": 0.5, - "cacheWrite": 0 + "cacheWrite": 6.25 }, "releaseDate": "2026-04-16", "family": "claude-opus" @@ -96259,8 +102161,11 @@ export const GENERATED_PROVIDER_MODELS: { "maxInputTokens": 262144, "maxTokens": 32768, "capabilities": [ + "images", + "video", "tools", "reasoning", + "structured_output", "temperature" ], "reasoningOptions": [], @@ -96579,7 +102484,7 @@ export const GENERATED_PROVIDER_MODELS: { "input": 3, "output": 15, "cacheRead": 0.3, - "cacheWrite": 0 + "cacheWrite": 3.75 }, "releaseDate": "2026-02-17", "family": "claude-sonnet" @@ -97202,7 +103107,7 @@ export const GENERATED_PROVIDER_MODELS: { "input": 5, "output": 25, "cacheRead": 0.5, - "cacheWrite": 0 + "cacheWrite": 6.25 }, "releaseDate": "2026-02-05", "family": "claude-opus" @@ -98375,7 +104280,7 @@ export const GENERATED_PROVIDER_MODELS: { "input": 5, "output": 25, "cacheRead": 0.5, - "cacheWrite": 0 + "cacheWrite": 6.25 }, "releaseDate": "2025-11-01", "family": "claude-opus" @@ -98413,26 +104318,6 @@ export const GENERATED_PROVIDER_MODELS: { "releaseDate": "2025-10-27", "family": "minimax" }, - "alibaba/qwen3-vl-flash": { - "id": "alibaba/qwen3-vl-flash", - "name": "Qwen3 VL Flash (Alibaba Cloud)", - "contextWindow": 262144, - "maxInputTokens": 262144, - "maxTokens": 32768, - "capabilities": [ - "images", - "tools", - "temperature", - "prompt-cache" - ], - "pricing": { - "input": 0.05, - "output": 0.4, - "cacheRead": 0.01, - "cacheWrite": 0 - }, - "releaseDate": "2025-10-15" - }, "anthropic/claude-haiku-4-5": { "id": "anthropic/claude-haiku-4-5", "name": "Claude Haiku 4.5 (Anthropic)", @@ -98608,7 +104493,7 @@ export const GENERATED_PROVIDER_MODELS: { "input": 1, "output": 5, "cacheRead": 0.1, - "cacheWrite": 0 + "cacheWrite": 1.25 }, "releaseDate": "2025-10-15", "family": "claude-haiku" @@ -98932,33 +104817,11 @@ export const GENERATED_PROVIDER_MODELS: { "input": 3, "output": 15, "cacheRead": 0.3, - "cacheWrite": 0 + "cacheWrite": 3.75 }, "releaseDate": "2025-09-29", "family": "claude-sonnet" }, - "alibaba/qwen3-max": { - "id": "alibaba/qwen3-max", - "name": "Qwen3 Max (Alibaba Cloud)", - "contextWindow": 256000, - "maxInputTokens": 256000, - "maxTokens": 32800, - "capabilities": [ - "tools", - "reasoning", - "temperature", - "prompt-cache" - ], - "reasoningOptions": [], - "pricing": { - "input": 1.2, - "output": 6, - "cacheRead": 0.24, - "cacheWrite": 1.5 - }, - "releaseDate": "2025-09-23", - "family": "qwen" - }, "deepinfra/qwen3-vl-235b-a22b-instruct": { "id": "deepinfra/qwen3-vl-235b-a22b-instruct", "name": "Qwen3 VL 235B A22B Instruct (DeepInfra)", @@ -99912,26 +105775,6 @@ export const GENERATED_PROVIDER_MODELS: { }, "releaseDate": "2025-07-25" }, - "alibaba/qwen3-coder-plus": { - "id": "alibaba/qwen3-coder-plus", - "name": "Qwen3 Coder Plus (Alibaba Cloud)", - "contextWindow": 1000000, - "maxInputTokens": 1000000, - "maxTokens": 66000, - "capabilities": [ - "tools", - "temperature", - "prompt-cache" - ], - "pricing": { - "input": 1, - "output": 5, - "cacheRead": 0.2, - "cacheWrite": 1.25 - }, - "releaseDate": "2025-07-23", - "family": "qwen" - }, "cerebras/qwen3-235b-a22b-instruct-2507": { "id": "cerebras/qwen3-235b-a22b-instruct-2507", "name": "Qwen3 235B A22B Instruct 2507 (Cerebras)", @@ -100694,25 +106537,6 @@ export const GENERATED_PROVIDER_MODELS: { "releaseDate": "2024-12-06", "family": "llama" }, - "alibaba/qwen-coder-plus": { - "id": "alibaba/qwen-coder-plus", - "name": "Qwen Coder Plus (Alibaba Cloud)", - "contextWindow": 131072, - "maxInputTokens": 131072, - "maxTokens": 8192, - "capabilities": [ - "tools", - "temperature" - ], - "pricing": { - "input": 0.502, - "output": 1.004, - "cacheRead": 0, - "cacheWrite": 0 - }, - "releaseDate": "2024-09-18", - "family": "qwen" - }, "alibaba/qwen-plus-latest": { "id": "alibaba/qwen-plus-latest", "name": "Qwen Plus Latest (Alibaba Cloud)", @@ -101845,6 +107669,317 @@ export const GENERATED_PROVIDER_MODELS: { "family": "longcat" } }, + "lowrouter": { + "auto/mistralai/mistral-large-4": { + "id": "auto/mistralai/mistral-large-4", + "name": "Mistral Large 4", + "contextWindow": 524288, + "maxInputTokens": 524288, + "maxTokens": 262144, + "capabilities": [ + "images", + "tools", + "reasoning", + "structured_output", + "temperature", + "prompt-cache" + ], + "reasoningOptions": [ + { + "type": "effort", + "values": [ + "none", + "high" + ] + } + ], + "pricing": { + "input": 0.7112, + "output": 2.1859, + "cacheRead": 0.0732, + "cacheWrite": 0 + }, + "releaseDate": "2026-10-06", + "family": "mistral-large" + }, + "auto/mistralai/mistral-medium-3.5": { + "id": "auto/mistralai/mistral-medium-3.5", + "name": "Mistral Medium 3.5", + "contextWindow": 262144, + "maxInputTokens": 262144, + "maxTokens": 262144, + "capabilities": [ + "images", + "tools", + "reasoning", + "structured_output", + "temperature", + "prompt-cache" + ], + "reasoningOptions": [ + { + "type": "effort", + "values": [ + "none", + "high" + ] + } + ], + "pricing": { + "input": 1.5688, + "output": 7.8442, + "cacheRead": 0.1569, + "cacheWrite": 0 + }, + "releaseDate": "2026-04-29", + "family": "mistral-medium" + }, + "auto/mistralai/mistral-small-2603": { + "id": "auto/mistralai/mistral-small-2603", + "name": "Mistral Small 4", + "contextWindow": 256000, + "maxInputTokens": 256000, + "maxTokens": 256000, + "capabilities": [ + "images", + "tools", + "reasoning", + "temperature", + "prompt-cache" + ], + "reasoningOptions": [ + { + "type": "effort", + "values": [ + "none", + "high" + ] + } + ], + "pricing": { + "input": 0.1569, + "output": 0.6275, + "cacheRead": 0.0157, + "cacheWrite": 0 + }, + "releaseDate": "2026-03-16", + "family": "mistral-small" + }, + "auto/mistralai/ministral-14b-2512": { + "id": "auto/mistralai/ministral-14b-2512", + "name": "Ministral 3 14B", + "contextWindow": 262144, + "maxInputTokens": 262144, + "maxTokens": 262144, + "capabilities": [ + "images", + "tools", + "structured_output", + "temperature", + "prompt-cache" + ], + "pricing": { + "input": 0.2092, + "output": 0.2092, + "cacheRead": 0.0209, + "cacheWrite": 0 + }, + "releaseDate": "2025-12-02", + "family": "ministral" + }, + "auto/mistralai/ministral-3b-2512": { + "id": "auto/mistralai/ministral-3b-2512", + "name": "Ministral 3 3B", + "contextWindow": 262144, + "maxInputTokens": 262144, + "maxTokens": 262144, + "capabilities": [ + "images", + "tools", + "structured_output", + "temperature", + "prompt-cache" + ], + "pricing": { + "input": 0.1046, + "output": 0.1046, + "cacheRead": 0.0105, + "cacheWrite": 0 + }, + "releaseDate": "2025-12-02", + "family": "ministral" + }, + "auto/mistralai/ministral-8b-2512": { + "id": "auto/mistralai/ministral-8b-2512", + "name": "Ministral 3 8B", + "contextWindow": 262144, + "maxInputTokens": 262144, + "maxTokens": 262144, + "capabilities": [ + "images", + "tools", + "structured_output", + "temperature", + "prompt-cache" + ], + "pricing": { + "input": 0.1569, + "output": 0.1569, + "cacheRead": 0.0157, + "cacheWrite": 0 + }, + "releaseDate": "2025-12-02", + "family": "ministral" + }, + "auto/mistralai/mistral-large-2512": { + "id": "auto/mistralai/mistral-large-2512", + "name": "Mistral Large 3", + "contextWindow": 262144, + "maxInputTokens": 262144, + "maxTokens": 262144, + "capabilities": [ + "images", + "tools", + "temperature", + "prompt-cache" + ], + "pricing": { + "input": 0.5229, + "output": 1.5688, + "cacheRead": 0.0523, + "cacheWrite": 0 + }, + "releaseDate": "2025-12-02", + "family": "mistral-large" + }, + "auto/openai/gpt-oss-safeguard-120b": { + "id": "auto/openai/gpt-oss-safeguard-120b", + "name": "GPT OSS Safeguard 120B", + "contextWindow": 131072, + "maxInputTokens": 131072, + "maxTokens": 32768, + "capabilities": [ + "tools", + "reasoning", + "structured_output", + "temperature" + ], + "reasoningOptions": [], + "pricing": { + "input": 0.18, + "output": 0.73, + "cacheRead": 0, + "cacheWrite": 0 + }, + "releaseDate": "2025-10-29", + "family": "gpt-oss" + }, + "auto/openai/gpt-oss-safeguard-20b": { + "id": "auto/openai/gpt-oss-safeguard-20b", + "name": "GPT OSS Safeguard 20B", + "contextWindow": 131072, + "maxInputTokens": 131072, + "maxTokens": 131072, + "capabilities": [ + "tools", + "reasoning", + "structured_output", + "temperature" + ], + "reasoningOptions": [], + "pricing": { + "input": 0.08, + "output": 0.24, + "cacheRead": 0, + "cacheWrite": 0 + }, + "releaseDate": "2025-10-29", + "family": "gpt-oss" + }, + "auto/openai/gpt-oss-120b": { + "id": "auto/openai/gpt-oss-120b", + "name": "GPT OSS 120B", + "contextWindow": 131072, + "maxInputTokens": 131072, + "maxTokens": 32768, + "capabilities": [ + "tools", + "reasoning", + "structured_output", + "temperature" + ], + "reasoningOptions": [ + { + "type": "effort", + "values": [ + "low", + "medium", + "high" + ] + } + ], + "pricing": { + "input": 0.1695, + "output": 0.6779, + "cacheRead": 0, + "cacheWrite": 0 + }, + "releaseDate": "2025-08-05", + "family": "gpt-oss" + }, + "auto/openai/gpt-oss-20b": { + "id": "auto/openai/gpt-oss-20b", + "name": "GPT OSS 20B", + "contextWindow": 131072, + "maxInputTokens": 131072, + "maxTokens": 32768, + "capabilities": [ + "tools", + "reasoning", + "structured_output", + "temperature" + ], + "reasoningOptions": [ + { + "type": "effort", + "values": [ + "low", + "medium", + "high" + ] + } + ], + "pricing": { + "input": 0.113, + "output": 0.4745, + "cacheRead": 0, + "cacheWrite": 0 + }, + "releaseDate": "2025-08-05", + "family": "gpt-oss" + }, + "auto/mistralai/codestral-2508": { + "id": "auto/mistralai/codestral-2508", + "name": "Codestral 25.08", + "contextWindow": 128000, + "maxInputTokens": 128000, + "maxTokens": 8192, + "capabilities": [ + "tools", + "structured_output", + "temperature", + "prompt-cache" + ], + "pricing": { + "input": 0.3138, + "output": 0.9413, + "cacheRead": 0.0314, + "cacheWrite": 0 + }, + "releaseDate": "2025-07-30", + "family": "codestral" + } + }, "lucidquery": { "lucidquery-agi-01-frontier": { "id": "lucidquery-agi-01-frontier", @@ -102686,6 +108821,55 @@ export const GENERATED_PROVIDER_MODELS: { } }, "meta": { + "muse-wonder-1.0": { + "id": "muse-wonder-1.0", + "name": "Muse Wonder", + "contextWindow": 1048576, + "maxInputTokens": 1048576, + "maxTokens": 131072, + "capabilities": [ + "images", + "video", + "files", + "tools", + "reasoning", + "structured_output", + "temperature" + ], + "reasoningOptions": [ + { + "type": "effort", + "values": [ + "minimal", + "low", + "medium", + "high", + "xhigh", + "max" + ] + } + ], + "pricing": { + "input": 0, + "output": 0, + "cacheRead": 0, + "cacheWrite": 0 + }, + "releaseDate": "2026-10-12", + "family": "muse", + "modalities": { + "input": [ + "text", + "image", + "video", + "pdf", + "audio" + ], + "output": [ + "text" + ] + } + }, "muse-spark-1.3": { "id": "muse-spark-1.3", "name": "Muse Spark 1.3", @@ -103625,8 +109809,8 @@ export const GENERATED_PROVIDER_MODELS: { "mistral-large-4": { "id": "mistral-large-4", "name": "Mistral Large 4", - "contextWindow": 524288, - "maxInputTokens": 524288, + "contextWindow": 1048576, + "maxInputTokens": 1048576, "maxTokens": 262144, "capabilities": [ "images", @@ -104214,6 +110398,186 @@ export const GENERATED_PROVIDER_MODELS: { } }, "moark": { + "GLM-5.3": { + "id": "GLM-5.3", + "name": "GLM-5.3", + "contextWindow": 1000000, + "maxInputTokens": 1000000, + "maxTokens": 131072, + "capabilities": [ + "tools", + "reasoning", + "structured_output", + "temperature" + ], + "reasoningOptions": [], + "pricing": { + "input": 1.2, + "output": 4.18, + "cacheRead": 0, + "cacheWrite": 0 + }, + "releaseDate": "2026-08-14", + "family": "glm" + }, + "deepseek-v4-flash-0731": { + "id": "deepseek-v4-flash-0731", + "name": "DeepSeek V4 Flash 0731", + "contextWindow": 1000000, + "maxInputTokens": 1000000, + "maxTokens": 384000, + "capabilities": [ + "tools", + "reasoning", + "structured_output", + "temperature" + ], + "reasoningOptions": [], + "pricing": { + "input": 0.15, + "output": 0.3, + "cacheRead": 0, + "cacheWrite": 0 + }, + "releaseDate": "2026-07-31", + "family": "deepseek-flash" + }, + "kimi-k3": { + "id": "kimi-k3", + "name": "Kimi K3", + "contextWindow": 1048576, + "maxInputTokens": 1048576, + "maxTokens": 131072, + "capabilities": [ + "images", + "video", + "tools", + "reasoning", + "structured_output" + ], + "reasoningOptions": [], + "pricing": { + "input": 3, + "output": 15, + "cacheRead": 0, + "cacheWrite": 0 + }, + "releaseDate": "2026-07-16", + "family": "kimi-k3" + }, + "Qwen3.6-Flash": { + "id": "Qwen3.6-Flash", + "name": "Qwen3.6 Flash", + "contextWindow": 1000000, + "maxInputTokens": 1000000, + "maxTokens": 65536, + "capabilities": [ + "images", + "video", + "tools", + "reasoning", + "structured_output", + "temperature" + ], + "reasoningOptions": [], + "pricing": { + "input": 0.18, + "output": 1.03, + "cacheRead": 0, + "cacheWrite": 0 + }, + "releaseDate": "2026-04-27", + "family": "qwen3.6" + }, + "DeepSeek-V4-Flash": { + "id": "DeepSeek-V4-Flash", + "name": "DeepSeek V4 Flash", + "contextWindow": 1000000, + "maxInputTokens": 1000000, + "maxTokens": 384000, + "capabilities": [ + "tools", + "reasoning", + "structured_output", + "temperature" + ], + "reasoningOptions": [], + "pricing": { + "input": 0.5, + "output": 0.5, + "cacheRead": 0, + "cacheWrite": 0 + }, + "releaseDate": "2026-04-24", + "family": "deepseek-flash" + }, + "DeepSeek-V4-Pro": { + "id": "DeepSeek-V4-Pro", + "name": "DeepSeek V4 Pro", + "contextWindow": 1000000, + "maxInputTokens": 1000000, + "maxTokens": 384000, + "capabilities": [ + "tools", + "reasoning", + "structured_output", + "temperature" + ], + "reasoningOptions": [], + "pricing": { + "input": 1.8, + "output": 3.6, + "cacheRead": 0, + "cacheWrite": 0 + }, + "releaseDate": "2026-04-24", + "family": "deepseek-thinking" + }, + "Kimi-K2.6": { + "id": "Kimi-K2.6", + "name": "Kimi K2.6", + "contextWindow": 262144, + "maxInputTokens": 262144, + "maxTokens": 262144, + "capabilities": [ + "images", + "video", + "tools", + "reasoning", + "structured_output", + "temperature" + ], + "reasoningOptions": [], + "pricing": { + "input": 0.98, + "output": 4, + "cacheRead": 0, + "cacheWrite": 0 + }, + "releaseDate": "2026-04-21", + "family": "kimi-k2" + }, + "MiniMax-M2.7": { + "id": "MiniMax-M2.7", + "name": "MiniMax-M2.7", + "contextWindow": 204800, + "maxInputTokens": 204800, + "maxTokens": 131072, + "capabilities": [ + "tools", + "reasoning", + "temperature" + ], + "reasoningOptions": [], + "pricing": { + "input": 0.29, + "output": 1.2, + "cacheRead": 0, + "cacheWrite": 0 + }, + "releaseDate": "2026-03-18", + "family": "minimax" + }, "MiniMax-M2.1": { "id": "MiniMax-M2.1", "name": "MiniMax-M2.1", @@ -104223,15 +110587,14 @@ export const GENERATED_PROVIDER_MODELS: { "capabilities": [ "tools", "reasoning", - "temperature", - "prompt-cache" + "temperature" ], "reasoningOptions": [], "pricing": { - "input": 2.1, - "output": 8.4, - "cacheRead": 2.1, - "cacheWrite": 8.4 + "input": 0.29, + "output": 1.2, + "cacheRead": 0, + "cacheWrite": 0 }, "releaseDate": "2025-12-23", "family": "minimax" @@ -104249,8 +110612,8 @@ export const GENERATED_PROVIDER_MODELS: { ], "reasoningOptions": [], "pricing": { - "input": 3.5, - "output": 14, + "input": 0.42, + "output": 2.2, "cacheRead": 0, "cacheWrite": 0 }, @@ -105776,6 +112139,50 @@ export const GENERATED_PROVIDER_MODELS: { } }, "nano-gpt": { + "gemma-4-26b-a4b-it-chimeraxheretic": { + "id": "gemma-4-26b-a4b-it-chimeraxheretic", + "name": "Gemma 4 26B A4B Chimera X Heretic", + "contextWindow": 262144, + "maxInputTokens": 262144, + "maxTokens": 32768, + "capabilities": [ + "images", + "tools", + "reasoning", + "prompt-cache" + ], + "reasoningOptions": [], + "pricing": { + "input": 0.12, + "output": 0.38, + "cacheRead": 0.06, + "cacheWrite": 0 + }, + "releaseDate": "2026-10-08", + "family": "gemma" + }, + "gemma-4-31b-it-scarlet": { + "id": "gemma-4-31b-it-scarlet", + "name": "Gemma 4 31B Scarlet", + "contextWindow": 262144, + "maxInputTokens": 262144, + "maxTokens": 32768, + "capabilities": [ + "images", + "tools", + "reasoning", + "prompt-cache" + ], + "reasoningOptions": [], + "pricing": { + "input": 0.1, + "output": 0.45, + "cacheRead": 0.05, + "cacheWrite": 0 + }, + "releaseDate": "2026-10-08", + "family": "gemma" + }, "anthropic/claude-haiku-5.5": { "id": "anthropic/claude-haiku-5.5", "name": "Claude Haiku 5.5", @@ -106206,7 +112613,7 @@ export const GENERATED_PROVIDER_MODELS: { "pricing": { "input": 2, "output": 10, - "cacheRead": 0.2, + "cacheRead": 0.1, "cacheWrite": 2.5 }, "releaseDate": "2026-09-28", @@ -106241,7 +112648,7 @@ export const GENERATED_PROVIDER_MODELS: { "pricing": { "input": 2, "output": 10, - "cacheRead": 0.2, + "cacheRead": 0.1, "cacheWrite": 2.5 }, "releaseDate": "2026-09-28", @@ -106906,9 +113313,9 @@ export const GENERATED_PROVIDER_MODELS: { } ], "pricing": { - "input": 1.6, - "output": 4.8, - "cacheRead": 0.4, + "input": 2, + "output": 6, + "cacheRead": 0.5, "cacheWrite": 0 }, "releaseDate": "2026-09-21", @@ -107420,6 +113827,40 @@ export const GENERATED_PROVIDER_MODELS: { "releaseDate": "2026-09-10", "family": "deepseek-flash" }, + "inclusionai/ling-3.0-flash-vl": { + "id": "inclusionai/ling-3.0-flash-vl", + "name": "Ling 3.0 Flash VL", + "contextWindow": 262144, + "maxInputTokens": 262144, + "maxTokens": 32768, + "capabilities": [ + "images", + "video", + "tools", + "reasoning", + "temperature", + "prompt-cache" + ], + "reasoningOptions": [ + { + "type": "effort", + "values": [ + "none", + "low", + "medium", + "high" + ] + } + ], + "pricing": { + "input": 0.06, + "output": 0.18, + "cacheRead": 0.012, + "cacheWrite": 0 + }, + "releaseDate": "2026-09-10", + "family": "ling" + }, "lightonai/LightOnOCR-2-1B": { "id": "lightonai/LightOnOCR-2-1B", "name": "LightOnOCR 2", @@ -107438,24 +113879,6 @@ export const GENERATED_PROVIDER_MODELS: { }, "releaseDate": "2026-09-10" }, - "mistralai/mistral-small-24b-instruct-2501": { - "id": "mistralai/mistral-small-24b-instruct-2501", - "name": "Mistral Small 24B", - "contextWindow": 32768, - "maxInputTokens": 32768, - "maxTokens": 8192, - "capabilities": [ - "tools" - ], - "pricing": { - "input": 0.1155, - "output": 0.3465, - "cacheRead": 0, - "cacheWrite": 0 - }, - "releaseDate": "2026-09-10", - "family": "mistral-small" - }, "TEE/deepseek-v4.1-flash": { "id": "TEE/deepseek-v4.1-flash", "name": "DeepSeek V4.1 Flash TEE", @@ -107556,39 +113979,6 @@ export const GENERATED_PROVIDER_MODELS: { "releaseDate": "2026-09-09", "family": "gemma" }, - "inclusionai/ling-3.0-flash-vl": { - "id": "inclusionai/ling-3.0-flash-vl", - "name": "Ling 3.0 Flash VL", - "contextWindow": 262144, - "maxInputTokens": 262144, - "maxTokens": 32768, - "capabilities": [ - "images", - "video", - "tools", - "reasoning", - "prompt-cache" - ], - "reasoningOptions": [ - { - "type": "effort", - "values": [ - "none", - "low", - "medium", - "high" - ] - } - ], - "pricing": { - "input": 0.06, - "output": 0.18, - "cacheRead": 0.012, - "cacheWrite": 0 - }, - "releaseDate": "2026-09-09", - "family": "ling" - }, "qwen/qwen3.8-27b-queen": { "id": "qwen/qwen3.8-27b-queen", "name": "Qwen 3.8 27B Queen", @@ -108214,8 +114604,8 @@ export const GENERATED_PROVIDER_MODELS: { "qwen/qwen3.8-27b": { "id": "qwen/qwen3.8-27b", "name": "Qwen3.8 27B", - "contextWindow": 262144, - "maxInputTokens": 262144, + "contextWindow": 1001536, + "maxInputTokens": 1001536, "maxTokens": 32768, "capabilities": [ "images", @@ -108239,8 +114629,8 @@ export const GENERATED_PROVIDER_MODELS: { "qwen/qwen3.8-27b:thinking": { "id": "qwen/qwen3.8-27b:thinking", "name": "Qwen3.8 27B Thinking", - "contextWindow": 262144, - "maxInputTokens": 262144, + "contextWindow": 1001536, + "maxInputTokens": 1001536, "maxTokens": 32768, "capabilities": [ "images", @@ -108342,8 +114732,8 @@ export const GENERATED_PROVIDER_MODELS: { } ], "pricing": { - "input": 1, - "output": 3.2, + "input": 0.6, + "output": 1.1, "cacheRead": 0.2, "cacheWrite": 0 }, @@ -108373,8 +114763,8 @@ export const GENERATED_PROVIDER_MODELS: { } ], "pricing": { - "input": 1, - "output": 3.2, + "input": 0.6, + "output": 1.1, "cacheRead": 0.2, "cacheWrite": 0 }, @@ -108526,9 +114916,9 @@ export const GENERATED_PROVIDER_MODELS: { } ], "pricing": { - "input": 1.1, - "output": 2.5, - "cacheRead": 0.04, + "input": 0.48, + "output": 1.98, + "cacheRead": 0.1, "cacheWrite": 0 }, "releaseDate": "2026-08-12", @@ -108559,9 +114949,9 @@ export const GENERATED_PROVIDER_MODELS: { } ], "pricing": { - "input": 1.1, - "output": 2.5, - "cacheRead": 0.04, + "input": 0.48, + "output": 1.98, + "cacheRead": 0.1, "cacheWrite": 0 }, "releaseDate": "2026-08-12", @@ -109064,8 +115454,8 @@ export const GENERATED_PROVIDER_MODELS: { "deepseek/deepseek-v4-flash-0731": { "id": "deepseek/deepseek-v4-flash-0731", "name": "DeepSeek V4 Flash 0731", - "contextWindow": 1000000, - "maxInputTokens": 1000000, + "contextWindow": 1048576, + "maxInputTokens": 1048576, "maxTokens": 131072, "capabilities": [ "tools", @@ -109097,8 +115487,8 @@ export const GENERATED_PROVIDER_MODELS: { "deepseek/deepseek-v4-flash-0731:thinking": { "id": "deepseek/deepseek-v4-flash-0731:thinking", "name": "DeepSeek V4 Flash 0731 (Thinking)", - "contextWindow": 1000000, - "maxInputTokens": 1000000, + "contextWindow": 1048576, + "maxInputTokens": 1048576, "maxTokens": 131072, "capabilities": [ "tools", @@ -109713,8 +116103,8 @@ export const GENERATED_PROVIDER_MODELS: { "qwen/qwen3.8-27b-uncensored": { "id": "qwen/qwen3.8-27b-uncensored", "name": "Qwen 3.8 27B Uncensored", - "contextWindow": 524288, - "maxInputTokens": 524288, + "contextWindow": 1010000, + "maxInputTokens": 1010000, "maxTokens": 32768, "capabilities": [ "images", @@ -109725,9 +116115,9 @@ export const GENERATED_PROVIDER_MODELS: { ], "reasoningOptions": [], "pricing": { - "input": 0.15, - "output": 1.2, - "cacheRead": 0.125, + "input": 1, + "output": 3, + "cacheRead": 0.22, "cacheWrite": 0 }, "releaseDate": "2026-07-29", @@ -109736,8 +116126,8 @@ export const GENERATED_PROVIDER_MODELS: { "qwen/qwen3.8-27b-uncensored:thinking": { "id": "qwen/qwen3.8-27b-uncensored:thinking", "name": "Qwen 3.8 27B Uncensored Thinking", - "contextWindow": 524288, - "maxInputTokens": 524288, + "contextWindow": 1010000, + "maxInputTokens": 1010000, "maxTokens": 32768, "capabilities": [ "images", @@ -109748,9 +116138,9 @@ export const GENERATED_PROVIDER_MODELS: { ], "reasoningOptions": [], "pricing": { - "input": 0.15, - "output": 1.2, - "cacheRead": 0.125, + "input": 1, + "output": 3, + "cacheRead": 0.22, "cacheWrite": 0 }, "releaseDate": "2026-07-29", @@ -109862,6 +116252,7 @@ export const GENERATED_PROVIDER_MODELS: { "maxTokens": 32768, "capabilities": [ "tools", + "temperature", "prompt-cache" ], "pricing": { @@ -109882,6 +116273,7 @@ export const GENERATED_PROVIDER_MODELS: { "capabilities": [ "tools", "reasoning", + "temperature", "prompt-cache" ], "reasoningOptions": [ @@ -110560,6 +116952,7 @@ export const GENERATED_PROVIDER_MODELS: { "capabilities": [ "tools", "reasoning", + "temperature", "prompt-cache" ], "reasoningOptions": [], @@ -110580,6 +116973,7 @@ export const GENERATED_PROVIDER_MODELS: { "capabilities": [ "tools", "reasoning", + "temperature", "prompt-cache" ], "reasoningOptions": [], @@ -110888,9 +117282,9 @@ export const GENERATED_PROVIDER_MODELS: { ], "reasoningOptions": [], "pricing": { - "input": 0.95, - "output": 4, - "cacheRead": 0.19, + "input": 0.6712, + "output": 3.35, + "cacheRead": 0.18, "cacheWrite": 0 }, "releaseDate": "2026-06-12", @@ -111596,8 +117990,8 @@ export const GENERATED_PROVIDER_MODELS: { } ], "pricing": { - "input": 1, - "output": 3.2, + "input": 0.6, + "output": 1.1, "cacheRead": 0.2, "cacheWrite": 0 }, @@ -111626,8 +118020,8 @@ export const GENERATED_PROVIDER_MODELS: { } ], "pricing": { - "input": 1, - "output": 3.2, + "input": 0.6, + "output": 1.1, "cacheRead": 0.2, "cacheWrite": 0.08333 }, @@ -111656,8 +118050,8 @@ export const GENERATED_PROVIDER_MODELS: { } ], "pricing": { - "input": 1, - "output": 3.2, + "input": 0.6, + "output": 1.1, "cacheRead": 0.2, "cacheWrite": 0 }, @@ -111686,8 +118080,8 @@ export const GENERATED_PROVIDER_MODELS: { } ], "pricing": { - "input": 1, - "output": 3.2, + "input": 0.6, + "output": 1.1, "cacheRead": 0.2, "cacheWrite": 0 }, @@ -111717,8 +118111,8 @@ export const GENERATED_PROVIDER_MODELS: { } ], "pricing": { - "input": 1, - "output": 3.2, + "input": 0.6, + "output": 1.1, "cacheRead": 0.2, "cacheWrite": 0.08333 }, @@ -111748,8 +118142,8 @@ export const GENERATED_PROVIDER_MODELS: { } ], "pricing": { - "input": 1, - "output": 3.2, + "input": 0.6, + "output": 1.1, "cacheRead": 0.2, "cacheWrite": 0 }, @@ -111828,9 +118222,9 @@ export const GENERATED_PROVIDER_MODELS: { } ], "pricing": { - "input": 1.1, - "output": 2.5, - "cacheRead": 0.04, + "input": 0.48, + "output": 1.98, + "cacheRead": 0.1, "cacheWrite": 0 }, "releaseDate": "2026-05-03", @@ -111957,9 +118351,9 @@ export const GENERATED_PROVIDER_MODELS: { } ], "pricing": { - "input": 1.6, - "output": 4.8, - "cacheRead": 0.4, + "input": 2, + "output": 6, + "cacheRead": 0.5, "cacheWrite": 0 }, "releaseDate": "2026-05-03", @@ -111979,8 +118373,8 @@ export const GENERATED_PROVIDER_MODELS: { ], "reasoningOptions": [], "pricing": { - "input": 1, - "output": 3.2, + "input": 0.6, + "output": 1.1, "cacheRead": 0.2, "cacheWrite": 0 }, @@ -112437,7 +118831,7 @@ export const GENERATED_PROVIDER_MODELS: { "pricing": { "input": 0.14, "output": 0.28, - "cacheRead": 0.03, + "cacheRead": 0.028, "cacheWrite": 0 }, "releaseDate": "2026-04-23", @@ -112470,7 +118864,7 @@ export const GENERATED_PROVIDER_MODELS: { "pricing": { "input": 0.14, "output": 0.28, - "cacheRead": 0.03, + "cacheRead": 0.028, "cacheWrite": 0 }, "releaseDate": "2026-04-23", @@ -112514,8 +118908,8 @@ export const GENERATED_PROVIDER_MODELS: { "qwen/qwen3.6-27b": { "id": "qwen/qwen3.6-27b", "name": "Qwen3.6 27B", - "contextWindow": 260096, - "maxInputTokens": 260096, + "contextWindow": 262144, + "maxInputTokens": 262144, "maxTokens": 65536, "capabilities": [ "images", @@ -112536,8 +118930,8 @@ export const GENERATED_PROVIDER_MODELS: { "qwen/qwen3.6-27b:thinking": { "id": "qwen/qwen3.6-27b:thinking", "name": "Qwen3.6 27B Thinking", - "contextWindow": 260096, - "maxInputTokens": 260096, + "contextWindow": 262144, + "maxInputTokens": 262144, "maxTokens": 65536, "capabilities": [ "images", @@ -112710,8 +119104,8 @@ export const GENERATED_PROVIDER_MODELS: { "moonshotai/kimi-k2.6": { "id": "moonshotai/kimi-k2.6", "name": "Kimi K2.6", - "contextWindow": 256000, - "maxInputTokens": 256000, + "contextWindow": 262144, + "maxInputTokens": 262144, "maxTokens": 65536, "capabilities": [ "images", @@ -112720,9 +119114,9 @@ export const GENERATED_PROVIDER_MODELS: { "prompt-cache" ], "pricing": { - "input": 0.5, - "output": 2.6, - "cacheRead": 0.125, + "input": 0.441, + "output": 2.45, + "cacheRead": 0.0878, "cacheWrite": 0 }, "releaseDate": "2026-04-21", @@ -112731,8 +119125,8 @@ export const GENERATED_PROVIDER_MODELS: { "moonshotai/kimi-k2.6:thinking": { "id": "moonshotai/kimi-k2.6:thinking", "name": "Kimi K2.6 Thinking", - "contextWindow": 256000, - "maxInputTokens": 256000, + "contextWindow": 262144, + "maxInputTokens": 262144, "maxTokens": 65536, "capabilities": [ "images", @@ -112743,9 +119137,9 @@ export const GENERATED_PROVIDER_MODELS: { ], "reasoningOptions": [], "pricing": { - "input": 0.5, - "output": 2.6, - "cacheRead": 0.125, + "input": 0.441, + "output": 2.45, + "cacheRead": 0.0878, "cacheWrite": 0 }, "releaseDate": "2026-04-21", @@ -112976,9 +119370,9 @@ export const GENERATED_PROVIDER_MODELS: { } ], "pricing": { - "input": 1.5, - "output": 5.25, - "cacheRead": 0.3, + "input": 0.7, + "output": 2.5, + "cacheRead": 0.18, "cacheWrite": 0 }, "releaseDate": "2026-04-07", @@ -113007,9 +119401,9 @@ export const GENERATED_PROVIDER_MODELS: { } ], "pricing": { - "input": 1.5, - "output": 5.25, - "cacheRead": 0.3, + "input": 0.7, + "output": 2.5, + "cacheRead": 0.18, "cacheWrite": 0 }, "releaseDate": "2026-04-07", @@ -113038,9 +119432,9 @@ export const GENERATED_PROVIDER_MODELS: { } ], "pricing": { - "input": 0.75, - "output": 2.6, - "cacheRead": 0.15, + "input": 0.7, + "output": 2.5, + "cacheRead": 0.18, "cacheWrite": 0 }, "releaseDate": "2026-04-07", @@ -113069,9 +119463,9 @@ export const GENERATED_PROVIDER_MODELS: { } ], "pricing": { - "input": 0.75, - "output": 2.6, - "cacheRead": 0.15, + "input": 0.7, + "output": 2.5, + "cacheRead": 0.18, "cacheWrite": 0 }, "releaseDate": "2026-04-07", @@ -113392,9 +119786,9 @@ export const GENERATED_PROVIDER_MODELS: { "anthropic/claude-haiku-latest": { "id": "anthropic/claude-haiku-latest", "name": "Claude Haiku Latest", - "contextWindow": 200000, - "maxInputTokens": 200000, - "maxTokens": 64000, + "contextWindow": 1000000, + "maxInputTokens": 1000000, + "maxTokens": 128000, "capabilities": [ "images", "files", @@ -113405,19 +119799,21 @@ export const GENERATED_PROVIDER_MODELS: { ], "reasoningOptions": [ { - "type": "toggle" - }, - { - "type": "budget_tokens", - "min": 1024, - "max": 63999 + "type": "effort", + "values": [ + "low", + "medium", + "high", + "xhigh", + "max" + ] } ], "pricing": { - "input": 1, - "output": 5, - "cacheRead": 0.1, - "cacheWrite": 0 + "input": 0.1, + "output": 0.5, + "cacheRead": 0.01, + "cacheWrite": 0.125 }, "releaseDate": "2026-03-29", "family": "claude-haiku" @@ -113788,6 +120184,29 @@ export const GENERATED_PROVIDER_MODELS: { "releaseDate": "2026-03-05", "family": "gpt" }, + "mercury-2": { + "id": "mercury-2", + "name": "Mercury 2", + "contextWindow": 128000, + "maxInputTokens": 128000, + "maxTokens": 50000, + "capabilities": [ + "tools", + "reasoning", + "structured_output", + "temperature", + "prompt-cache" + ], + "reasoningOptions": [], + "pricing": { + "input": 0.25, + "output": 0.75, + "cacheRead": 0.025, + "cacheWrite": 0 + }, + "releaseDate": "2026-03-04", + "family": "mercury" + }, "anthropic/claude-sonnet-latest": { "id": "anthropic/claude-sonnet-latest", "name": "Claude Sonnet Latest", @@ -113817,7 +120236,7 @@ export const GENERATED_PROVIDER_MODELS: { "pricing": { "input": 2, "output": 10, - "cacheRead": 0.2, + "cacheRead": 0.1, "cacheWrite": 2.5 }, "releaseDate": "2026-03-01", @@ -113831,6 +120250,7 @@ export const GENERATED_PROVIDER_MODELS: { "maxTokens": 32768, "capabilities": [ "tools", + "temperature", "prompt-cache" ], "pricing": { @@ -113844,8 +120264,8 @@ export const GENERATED_PROVIDER_MODELS: { "qwen/qwen3.5-122b-a10b": { "id": "qwen/qwen3.5-122b-a10b", "name": "Qwen3.5 122B A10B", - "contextWindow": 131072, - "maxInputTokens": 131072, + "contextWindow": 262144, + "maxInputTokens": 262144, "maxTokens": 32768, "capabilities": [ "tools", @@ -113865,8 +120285,8 @@ export const GENERATED_PROVIDER_MODELS: { "qwen/qwen3.5-122b-a10b:thinking": { "id": "qwen/qwen3.5-122b-a10b:thinking", "name": "Qwen3.5 122B A10B Thinking", - "contextWindow": 131072, - "maxInputTokens": 131072, + "contextWindow": 262144, + "maxInputTokens": 262144, "maxTokens": 32768, "capabilities": [ "tools", @@ -113980,8 +120400,8 @@ export const GENERATED_PROVIDER_MODELS: { "qwen/qwen3.5-9b": { "id": "qwen/qwen3.5-9b", "name": "Qwen3.5 9B", - "contextWindow": 256000, - "maxInputTokens": 256000, + "contextWindow": 262144, + "maxInputTokens": 262144, "maxTokens": 65536, "capabilities": [ "images", @@ -114243,8 +120663,8 @@ export const GENERATED_PROVIDER_MODELS: { "qwen/qwen3.5-397b-a17b": { "id": "qwen/qwen3.5-397b-a17b", "name": "Qwen3.5 397B A17B", - "contextWindow": 258048, - "maxInputTokens": 258048, + "contextWindow": 262144, + "maxInputTokens": 262144, "maxTokens": 65536, "capabilities": [ "images", @@ -114265,8 +120685,8 @@ export const GENERATED_PROVIDER_MODELS: { "qwen/qwen3.5-397b-a17b:thinking": { "id": "qwen/qwen3.5-397b-a17b:thinking", "name": "Qwen3.5 397B A17B Thinking", - "contextWindow": 258048, - "maxInputTokens": 258048, + "contextWindow": 262144, + "maxInputTokens": 262144, "maxTokens": 65536, "capabilities": [ "images", @@ -114359,9 +120779,9 @@ export const GENERATED_PROVIDER_MODELS: { ], "reasoningOptions": [], "pricing": { - "input": 0.5, - "output": 2.55, - "cacheRead": 0.13, + "input": 0.6, + "output": 1.9, + "cacheRead": 0.1, "cacheWrite": 0 }, "releaseDate": "2026-02-12", @@ -114382,9 +120802,9 @@ export const GENERATED_PROVIDER_MODELS: { ], "reasoningOptions": [], "pricing": { - "input": 0.5, - "output": 2.55, - "cacheRead": 0.13, + "input": 0.6, + "output": 1.9, + "cacheRead": 0.1, "cacheWrite": 0 }, "releaseDate": "2026-02-12", @@ -114404,9 +120824,9 @@ export const GENERATED_PROVIDER_MODELS: { ], "reasoningOptions": [], "pricing": { - "input": 1, - "output": 3.2, - "cacheRead": 0.2, + "input": 0.6, + "output": 1.9, + "cacheRead": 0.1, "cacheWrite": 0 }, "releaseDate": "2026-02-11", @@ -114426,9 +120846,9 @@ export const GENERATED_PROVIDER_MODELS: { ], "reasoningOptions": [], "pricing": { - "input": 1, - "output": 3.2, - "cacheRead": 0.2, + "input": 0.6, + "output": 1.9, + "cacheRead": 0.1, "cacheWrite": 0 }, "releaseDate": "2026-02-11", @@ -114629,9 +121049,9 @@ export const GENERATED_PROVIDER_MODELS: { "prompt-cache" ], "pricing": { - "input": 0.2, - "output": 1.5, - "cacheRead": 0.1, + "input": 0.250251, + "output": 0.80293, + "cacheRead": 0.079764, "cacheWrite": 0 }, "releaseDate": "2026-02-03", @@ -114713,30 +121133,11 @@ export const GENERATED_PROVIDER_MODELS: { "releaseDate": "2026-01-19", "family": "glm-flash" }, - "NousResearch/hermes-3-llama-3.1-70b": { - "id": "NousResearch/hermes-3-llama-3.1-70b", - "name": "Hermes 3 70B", - "contextWindow": 65536, - "maxInputTokens": 65536, - "maxTokens": 8192, - "capabilities": [ - "tools", - "prompt-cache" - ], - "pricing": { - "input": 0.408, - "output": 0.408, - "cacheRead": 0.204, - "cacheWrite": 0 - }, - "releaseDate": "2026-01-07", - "family": "nousresearch" - }, "moonshotai/kimi-k2.5": { "id": "moonshotai/kimi-k2.5", "name": "Kimi K2.5", - "contextWindow": 256000, - "maxInputTokens": 256000, + "contextWindow": 262144, + "maxInputTokens": 262144, "maxTokens": 65536, "capabilities": [ "images", @@ -114744,9 +121145,9 @@ export const GENERATED_PROVIDER_MODELS: { "prompt-cache" ], "pricing": { - "input": 0.3, - "output": 1.9, - "cacheRead": 0.15, + "input": 0.375, + "output": 2, + "cacheRead": 0.07, "cacheWrite": 0 }, "releaseDate": "2026-01", @@ -114755,8 +121156,8 @@ export const GENERATED_PROVIDER_MODELS: { "moonshotai/kimi-k2.5:thinking": { "id": "moonshotai/kimi-k2.5:thinking", "name": "Kimi K2.5 Thinking", - "contextWindow": 256000, - "maxInputTokens": 256000, + "contextWindow": 262144, + "maxInputTokens": 262144, "maxTokens": 65536, "capabilities": [ "images", @@ -114766,9 +121167,9 @@ export const GENERATED_PROVIDER_MODELS: { ], "reasoningOptions": [], "pricing": { - "input": 0.3, - "output": 1.9, - "cacheRead": 0.15, + "input": 0.375, + "output": 2, + "cacheRead": 0.07, "cacheWrite": 0 }, "releaseDate": "2026-01", @@ -115045,9 +121446,9 @@ export const GENERATED_PROVIDER_MODELS: { ], "reasoningOptions": [], "pricing": { - "input": 0.35, - "output": 1.4, - "cacheRead": 0.175, + "input": 0.5, + "output": 2, + "cacheRead": 0.1, "cacheWrite": 0 }, "releaseDate": "2025-12-11", @@ -115389,9 +121790,9 @@ export const GENERATED_PROVIDER_MODELS: { } ], "pricing": { - "input": 0.35, - "output": 1.4, - "cacheRead": 0.175, + "input": 0.5, + "output": 2, + "cacheRead": 0.1, "cacheWrite": 0 }, "releaseDate": "2025-09-30", @@ -115420,9 +121821,9 @@ export const GENERATED_PROVIDER_MODELS: { } ], "pricing": { - "input": 0.35, - "output": 1.4, - "cacheRead": 0.175, + "input": 0.5, + "output": 2, + "cacheRead": 0.1, "cacheWrite": 0 }, "releaseDate": "2025-09-30", @@ -115522,6 +121923,25 @@ export const GENERATED_PROVIDER_MODELS: { "releaseDate": "2025-09-29", "family": "claude-sonnet" }, + "TheDrummer/Cydonia-24B-v4.1": { + "id": "TheDrummer/Cydonia-24B-v4.1", + "name": "The Drummer Cydonia 24B v4.1", + "contextWindow": 131072, + "maxInputTokens": 131072, + "maxTokens": 117964, + "capabilities": [ + "tools", + "temperature", + "prompt-cache" + ], + "pricing": { + "input": 0.35, + "output": 0.55, + "cacheRead": 0.16, + "cacheWrite": 0 + }, + "releaseDate": "2025-09-27" + }, "gemini-2.5-flash-lite-preview-09-2025": { "id": "gemini-2.5-flash-lite-preview-09-2025", "name": "Gemini 2.5 Flash Lite Preview (09/2025)", @@ -115731,9 +122151,9 @@ export const GENERATED_PROVIDER_MODELS: { "prompt-cache" ], "pricing": { - "input": 0.15, - "output": 0.6, - "cacheRead": 0.075, + "input": 0.1875, + "output": 0.6525, + "cacheRead": 0.05, "cacheWrite": 0 }, "releaseDate": "2025-09-05", @@ -115773,8 +122193,8 @@ export const GENERATED_PROVIDER_MODELS: { "prompt-cache" ], "pricing": { - "input": 0.15, - "output": 0.65, + "input": 0.09, + "output": 1.1, "cacheRead": 0.075, "cacheWrite": 0 }, @@ -115812,24 +122232,6 @@ export const GENERATED_PROVIDER_MODELS: { "releaseDate": "2025-09-01", "family": "sarvam" }, - "TheDrummer/Cydonia-24B-v4.1": { - "id": "TheDrummer/Cydonia-24B-v4.1", - "name": "The Drummer Cydonia 24B v4.1", - "contextWindow": 131072, - "maxInputTokens": 131072, - "maxTokens": 117964, - "capabilities": [ - "tools", - "prompt-cache" - ], - "pricing": { - "input": 0.35, - "output": 0.55, - "cacheRead": 0.16, - "cacheWrite": 0 - }, - "releaseDate": "2025-08-19" - }, "openai/gpt-5": { "id": "openai/gpt-5", "name": "GPT 5", @@ -116021,8 +122423,8 @@ export const GENERATED_PROVIDER_MODELS: { "openai/gpt-oss-20b": { "id": "openai/gpt-oss-20b", "name": "GPT OSS 20B", - "contextWindow": 128000, - "maxInputTokens": 128000, + "contextWindow": 131072, + "maxInputTokens": 131072, "maxTokens": 16384, "capabilities": [ "tools", @@ -116153,6 +122555,27 @@ export const GENERATED_PROVIDER_MODELS: { "releaseDate": "2025-07-21", "family": "qwen" }, + "moonshotai/kimi-k2-instruct": { + "id": "moonshotai/kimi-k2-instruct", + "name": "Kimi K2 Instruct", + "contextWindow": 256000, + "maxInputTokens": 256000, + "maxTokens": 8192, + "capabilities": [ + "tools", + "structured_output", + "temperature", + "prompt-cache" + ], + "pricing": { + "input": 0.4, + "output": 1.8, + "cacheRead": 0.2, + "cacheWrite": 0 + }, + "releaseDate": "2025-07-11", + "family": "kimi-k2" + }, "qwen/qwen-2.5-72b-instruct": { "id": "qwen/qwen-2.5-72b-instruct", "name": "Qwen2.5 72B", @@ -116604,6 +123027,45 @@ export const GENERATED_PROVIDER_MODELS: { "releaseDate": "2025-02-24", "family": "claude-sonnet" }, + "aion-labs/aion-rp-llama-3.1-8b": { + "id": "aion-labs/aion-rp-llama-3.1-8b", + "name": "Llama 3.1 8b (uncensored)", + "contextWindow": 32768, + "maxInputTokens": 32768, + "maxTokens": 16384, + "capabilities": [ + "tools", + "temperature", + "prompt-cache" + ], + "pricing": { + "input": 0.8, + "output": 1.6, + "cacheRead": 0.4, + "cacheWrite": 0 + }, + "releaseDate": "2025-02-04", + "family": "llama" + }, + "mistralai/mistral-small-24b-instruct-2501": { + "id": "mistralai/mistral-small-24b-instruct-2501", + "name": "Mistral Small 24B", + "contextWindow": 32768, + "maxInputTokens": 32768, + "maxTokens": 8192, + "capabilities": [ + "tools", + "temperature" + ], + "pricing": { + "input": 0.1155, + "output": 0.3465, + "cacheRead": 0, + "cacheWrite": 0 + }, + "releaseDate": "2025-01-30", + "family": "mistral-small" + }, "deepseek-r1": { "id": "deepseek-r1", "name": "DeepSeek R1", @@ -116699,6 +123161,26 @@ export const GENERATED_PROVIDER_MODELS: { "releaseDate": "2024-11-20", "family": "gpt" }, + "NousResearch/hermes-3-llama-3.1-70b": { + "id": "NousResearch/hermes-3-llama-3.1-70b", + "name": "Hermes 3 70B", + "contextWindow": 65536, + "maxInputTokens": 65536, + "maxTokens": 8192, + "capabilities": [ + "tools", + "temperature", + "prompt-cache" + ], + "pricing": { + "input": 0.408, + "output": 0.408, + "cacheRead": 0.204, + "cacheWrite": 0 + }, + "releaseDate": "2024-08-18", + "family": "nousresearch" + }, "openai/gpt-4o-2024-08-06": { "id": "openai/gpt-4o-2024-08-06", "name": "GPT-4o (2024-08-06)", @@ -116761,25 +123243,6 @@ export const GENERATED_PROVIDER_MODELS: { "releaseDate": "2024-05-13", "family": "gpt" }, - "aion-labs/aion-rp-llama-3.1-8b": { - "id": "aion-labs/aion-rp-llama-3.1-8b", - "name": "Llama 3.1 8b (uncensored)", - "contextWindow": 32768, - "maxInputTokens": 32768, - "maxTokens": 16384, - "capabilities": [ - "tools", - "prompt-cache" - ], - "pricing": { - "input": 0.8, - "output": 1.6, - "cacheRead": 0.4, - "cacheWrite": 0 - }, - "releaseDate": "2024-01-01", - "family": "llama" - }, "anthropic/claude-sonnet-4:thinking:1024": { "id": "anthropic/claude-sonnet-4:thinking:1024", "name": "Claude 4 Sonnet Thinking (1K)", @@ -116963,48 +123426,6 @@ export const GENERATED_PROVIDER_MODELS: { }, "releaseDate": "2024-01-01" }, - "mercury-2": { - "id": "mercury-2", - "name": "Mercury 2", - "contextWindow": 128000, - "maxInputTokens": 128000, - "maxTokens": 50000, - "capabilities": [ - "tools", - "reasoning", - "structured_output", - "prompt-cache" - ], - "reasoningOptions": [], - "pricing": { - "input": 0.25, - "output": 0.75, - "cacheRead": 0.025, - "cacheWrite": 0 - }, - "releaseDate": "2024-01-01", - "family": "mercury" - }, - "moonshotai/kimi-k2-instruct": { - "id": "moonshotai/kimi-k2-instruct", - "name": "Kimi K2 Instruct", - "contextWindow": 256000, - "maxInputTokens": 256000, - "maxTokens": 8192, - "capabilities": [ - "tools", - "structured_output", - "prompt-cache" - ], - "pricing": { - "input": 0.4, - "output": 1.8, - "cacheRead": 0.2, - "cacheWrite": 0 - }, - "releaseDate": "2024-01-01", - "family": "kimi-k2" - }, "moonshotai/kimi-k2-instruct-0711": { "id": "moonshotai/kimi-k2-instruct-0711", "name": "Kimi K2 0711", @@ -117158,6 +123579,676 @@ export const GENERATED_PROVIDER_MODELS: { } }, "nearai": { + "anthropic/claude-haiku-5-5": { + "id": "anthropic/claude-haiku-5-5", + "name": "Claude Haiku 5.5", + "contextWindow": 1000000, + "maxInputTokens": 1000000, + "maxTokens": 128000, + "capabilities": [ + "images", + "tools", + "reasoning", + "structured_output", + "prompt-cache" + ], + "reasoningOptions": [ + { + "type": "toggle" + }, + { + "type": "effort", + "values": [ + "low", + "medium", + "high", + "xhigh", + "max" + ] + } + ], + "pricing": { + "input": 0.1, + "output": 0.5, + "cacheRead": 0.01, + "cacheWrite": 0.125 + }, + "releaseDate": "2026-10-07", + "family": "claude-haiku" + }, + "openai/gpt-6.1-sol": { + "id": "openai/gpt-6.1-sol", + "name": "GPT-6.1 Sol", + "contextWindow": 1050000, + "maxInputTokens": 922000, + "maxTokens": 128000, + "capabilities": [ + "images", + "tools", + "reasoning", + "structured_output", + "prompt-cache" + ], + "reasoningOptions": [ + { + "type": "effort", + "values": [ + "low", + "medium", + "high", + "xhigh", + "max" + ] + } + ], + "pricing": { + "input": 2, + "output": 10, + "cacheRead": 0.1, + "cacheWrite": 0 + }, + "releaseDate": "2026-09-29", + "family": "gpt-sol" + }, + "anthropic/claude-sonnet-5-5": { + "id": "anthropic/claude-sonnet-5-5", + "name": "Claude Sonnet 5.5", + "contextWindow": 1000000, + "maxInputTokens": 1000000, + "maxTokens": 128000, + "capabilities": [ + "images", + "files", + "tools", + "reasoning", + "prompt-cache" + ], + "reasoningOptions": [ + { + "type": "effort", + "values": [ + "low", + "medium", + "high", + "xhigh", + "max" + ] + } + ], + "pricing": { + "input": 2, + "output": 10, + "cacheRead": 0.1, + "cacheWrite": 0 + }, + "releaseDate": "2026-09-28", + "family": "claude-sonnet" + }, + "anthropic/claude-opus-5-5": { + "id": "anthropic/claude-opus-5-5", + "name": "Claude Opus 5.5", + "contextWindow": 1000000, + "maxInputTokens": 1000000, + "maxTokens": 128000, + "capabilities": [ + "images", + "files", + "tools", + "reasoning", + "prompt-cache" + ], + "reasoningOptions": [ + { + "type": "effort", + "values": [ + "low", + "medium", + "high", + "xhigh", + "max" + ] + } + ], + "pricing": { + "input": 4, + "output": 20, + "cacheRead": 0.2, + "cacheWrite": 0 + }, + "releaseDate": "2026-09-22", + "family": "claude-opus" + }, + "openai/gpt-6-luna": { + "id": "openai/gpt-6-luna", + "name": "GPT-6 Luna", + "contextWindow": 1050000, + "maxInputTokens": 922000, + "maxTokens": 128000, + "capabilities": [ + "images", + "tools", + "reasoning", + "structured_output", + "prompt-cache" + ], + "reasoningOptions": [ + { + "type": "effort", + "values": [ + "none", + "low", + "medium", + "high", + "xhigh", + "max" + ] + } + ], + "pricing": { + "input": 0.1, + "output": 0.5, + "cacheRead": 0.01, + "cacheWrite": 0 + }, + "releaseDate": "2026-09-22", + "family": "gpt-luna" + }, + "openai/gpt-6-sol": { + "id": "openai/gpt-6-sol", + "name": "GPT-6 Sol", + "contextWindow": 1050000, + "maxInputTokens": 922000, + "maxTokens": 128000, + "capabilities": [ + "images", + "tools", + "reasoning", + "structured_output", + "prompt-cache" + ], + "reasoningOptions": [ + { + "type": "effort", + "values": [ + "none", + "low", + "medium", + "high", + "xhigh", + "max" + ] + } + ], + "pricing": { + "input": 2, + "output": 10, + "cacheRead": 0.2, + "cacheWrite": 0 + }, + "releaseDate": "2026-09-22", + "family": "gpt-sol" + }, + "x-ai/grok-4.7": { + "id": "x-ai/grok-4.7", + "name": "Grok 4.7", + "contextWindow": 500000, + "maxInputTokens": 500000, + "maxTokens": 500000, + "capabilities": [ + "images", + "files", + "tools", + "reasoning", + "structured_output", + "temperature", + "prompt-cache" + ], + "reasoningOptions": [ + { + "type": "effort", + "values": [ + "low", + "medium", + "high", + "xhigh" + ] + } + ], + "pricing": { + "input": 2, + "output": 6, + "cacheRead": 0.5, + "cacheWrite": 0 + }, + "releaseDate": "2026-09-21", + "family": "grok" + }, + "deepseek/deepseek-v4.1-flash": { + "id": "deepseek/deepseek-v4.1-flash", + "name": "DeepSeek V4.1 Flash", + "contextWindow": 1000000, + "maxInputTokens": 1000000, + "maxTokens": 384000, + "capabilities": [ + "images", + "tools", + "reasoning", + "structured_output", + "temperature", + "prompt-cache" + ], + "reasoningOptions": [ + { + "type": "toggle" + }, + { + "type": "effort", + "values": [ + "low", + "high", + "max" + ] + } + ], + "pricing": { + "input": 0.3, + "output": 1.2, + "cacheRead": 0.006, + "cacheWrite": 0 + }, + "releaseDate": "2026-09-10", + "family": "deepseek-flash" + }, + "openai/gpt-6-astra": { + "id": "openai/gpt-6-astra", + "name": "GPT-6 Astra", + "contextWindow": 1050000, + "maxInputTokens": 922000, + "maxTokens": 128000, + "capabilities": [ + "images", + "tools", + "reasoning", + "structured_output", + "prompt-cache" + ], + "reasoningOptions": [ + { + "type": "effort", + "values": [ + "low", + "medium", + "high", + "xhigh", + "max" + ] + } + ], + "pricing": { + "input": 10, + "output": 50, + "cacheRead": 1, + "cacheWrite": 0 + }, + "releaseDate": "2026-09-04", + "family": "gpt-astra" + }, + "google/gemini-3.8-flash": { + "id": "google/gemini-3.8-flash", + "name": "Gemini 3.8 Flash", + "contextWindow": 1048576, + "maxInputTokens": 1048576, + "maxTokens": 65536, + "capabilities": [ + "images", + "tools", + "reasoning", + "structured_output", + "temperature", + "prompt-cache" + ], + "reasoningOptions": [], + "pricing": { + "input": 0.75, + "output": 3.75, + "cacheRead": 0.075, + "cacheWrite": 0 + }, + "releaseDate": "2026-09-02", + "family": "gemini-flash" + }, + "anthropic/claude-fable-5-1": { + "id": "anthropic/claude-fable-5-1", + "name": "Claude Fable 5.1", + "contextWindow": 1000000, + "maxInputTokens": 1000000, + "maxTokens": 128000, + "capabilities": [ + "images", + "tools", + "reasoning", + "prompt-cache" + ], + "reasoningOptions": [], + "pricing": { + "input": 10, + "output": 50, + "cacheRead": 0.25, + "cacheWrite": 0 + }, + "releaseDate": "2026-09-01", + "family": "claude-fable" + }, + "z-ai/glm-5.3-flash": { + "id": "z-ai/glm-5.3-flash", + "name": "GLM-5.3-Flash", + "contextWindow": 1000000, + "maxInputTokens": 1000000, + "maxTokens": 131072, + "capabilities": [ + "images", + "tools", + "reasoning", + "structured_output", + "temperature", + "prompt-cache" + ], + "reasoningOptions": [ + { + "type": "toggle" + }, + { + "type": "effort", + "values": [ + "low", + "high", + "max" + ] + } + ], + "pricing": { + "input": 0.15, + "output": 0.5, + "cacheRead": 0.035, + "cacheWrite": 0 + }, + "releaseDate": "2026-08-26", + "family": "glm-flash" + }, + "Qwen/Qwen3.8-27B": { + "id": "Qwen/Qwen3.8-27B", + "name": "Qwen3.8 27B", + "contextWindow": 262144, + "maxInputTokens": 262144, + "maxTokens": 32768, + "capabilities": [ + "images", + "tools", + "reasoning", + "structured_output", + "temperature", + "prompt-cache" + ], + "reasoningOptions": [ + { + "type": "toggle" + } + ], + "pricing": { + "input": 0.04, + "output": 1.35, + "cacheRead": 0.018, + "cacheWrite": 0 + }, + "releaseDate": "2026-08-14", + "family": "qwen" + }, + "x-ai/grok-4.6": { + "id": "x-ai/grok-4.6", + "name": "Grok 4.6", + "contextWindow": 500000, + "maxInputTokens": 500000, + "maxTokens": 500000, + "capabilities": [ + "tools", + "reasoning", + "structured_output", + "temperature", + "prompt-cache" + ], + "reasoningOptions": [ + { + "type": "effort", + "values": [ + "low", + "medium", + "high", + "xhigh" + ] + } + ], + "pricing": { + "input": 2, + "output": 6, + "cacheRead": 0.5, + "cacheWrite": 0 + }, + "releaseDate": "2026-08-12", + "family": "grok" + }, + "anthropic/claude-opus-5": { + "id": "anthropic/claude-opus-5", + "name": "Claude Opus 5", + "contextWindow": 1000000, + "maxInputTokens": 1000000, + "maxTokens": 128000, + "capabilities": [ + "images", + "tools", + "reasoning", + "prompt-cache" + ], + "reasoningOptions": [], + "pricing": { + "input": 5, + "output": 25, + "cacheRead": 0.5, + "cacheWrite": 0 + }, + "releaseDate": "2026-07-24", + "family": "claude-opus" + }, + "moonshotai/kimi-k3": { + "id": "moonshotai/kimi-k3", + "name": "Kimi K3", + "contextWindow": 1048576, + "maxInputTokens": 1048576, + "maxTokens": 131072, + "capabilities": [ + "tools", + "reasoning", + "structured_output", + "prompt-cache" + ], + "reasoningOptions": [ + { + "type": "toggle" + }, + { + "type": "effort", + "values": [ + "low", + "high", + "max" + ] + } + ], + "pricing": { + "input": 3.3, + "output": 16.5, + "cacheRead": 0.33, + "cacheWrite": 0 + }, + "releaseDate": "2026-07-16", + "family": "kimi-k3" + }, + "openai/gpt-5.6-luna": { + "id": "openai/gpt-5.6-luna", + "name": "GPT-5.6 Luna", + "contextWindow": 1050000, + "maxInputTokens": 922000, + "maxTokens": 128000, + "capabilities": [ + "images", + "tools", + "reasoning", + "structured_output", + "prompt-cache" + ], + "reasoningOptions": [ + { + "type": "effort", + "values": [ + "none", + "low", + "medium", + "high", + "xhigh", + "max" + ] + } + ], + "pricing": { + "input": 0.2, + "output": 1.2, + "cacheRead": 0.02, + "cacheWrite": 0 + }, + "releaseDate": "2026-07-09", + "family": "gpt-luna" + }, + "openai/gpt-5.6-sol": { + "id": "openai/gpt-5.6-sol", + "name": "GPT-5.6 Sol", + "contextWindow": 1050000, + "maxInputTokens": 922000, + "maxTokens": 128000, + "capabilities": [ + "images", + "tools", + "reasoning", + "structured_output", + "prompt-cache" + ], + "reasoningOptions": [ + { + "type": "effort", + "values": [ + "none", + "low", + "medium", + "high", + "xhigh", + "max" + ] + } + ], + "pricing": { + "input": 4, + "output": 20, + "cacheRead": 0.4, + "cacheWrite": 0 + }, + "releaseDate": "2026-07-09", + "family": "gpt-sol" + }, + "anthropic/claude-sonnet-5": { + "id": "anthropic/claude-sonnet-5", + "name": "Claude Sonnet 5", + "contextWindow": 1000000, + "maxInputTokens": 1000000, + "maxTokens": 128000, + "capabilities": [ + "images", + "tools", + "reasoning", + "prompt-cache" + ], + "reasoningOptions": [], + "pricing": { + "input": 2, + "output": 10, + "cacheRead": 0.2, + "cacheWrite": 0 + }, + "releaseDate": "2026-06-30", + "family": "claude-sonnet" + }, + "anthropic/claude-fable-5": { + "id": "anthropic/claude-fable-5", + "name": "Claude Fable 5", + "contextWindow": 1000000, + "maxInputTokens": 1000000, + "maxTokens": 128000, + "capabilities": [ + "images", + "tools", + "reasoning", + "prompt-cache" + ], + "reasoningOptions": [], + "pricing": { + "input": 10, + "output": 50, + "cacheRead": 1, + "cacheWrite": 0 + }, + "releaseDate": "2026-06-09", + "family": "claude-fable" + }, + "anthropic/claude-opus-4-8": { + "id": "anthropic/claude-opus-4-8", + "name": "Claude Opus 4.8", + "contextWindow": 1000000, + "maxInputTokens": 1000000, + "maxTokens": 128000, + "capabilities": [ + "images", + "tools", + "reasoning", + "prompt-cache" + ], + "reasoningOptions": [], + "pricing": { + "input": 5, + "output": 25, + "cacheRead": 0.5, + "cacheWrite": 0 + }, + "releaseDate": "2026-05-28", + "family": "claude-opus" + }, + "qwen/qwen3.7-max": { + "id": "qwen/qwen3.7-max", + "name": "Qwen3.7 Max", + "contextWindow": 1000000, + "maxInputTokens": 1000000, + "maxTokens": 65536, + "capabilities": [ + "tools", + "reasoning", + "temperature" + ], + "reasoningOptions": [], + "pricing": { + "input": 2.8, + "output": 7.5, + "cacheRead": 0, + "cacheWrite": 0 + }, + "releaseDate": "2026-05-21", + "family": "qwen" + }, "google/gemini-3.5-flash": { "id": "google/gemini-3.5-flash", "name": "Gemini 3.5 Flash", @@ -117269,6 +124360,33 @@ export const GENERATED_PROVIDER_MODELS: { "releaseDate": "2026-04-23", "family": "gpt" }, + "moonshotai/kimi-k2.6": { + "id": "moonshotai/kimi-k2.6", + "name": "Kimi K2.6", + "contextWindow": 262144, + "maxInputTokens": 262144, + "maxTokens": 262144, + "capabilities": [ + "tools", + "reasoning", + "structured_output", + "temperature", + "prompt-cache" + ], + "reasoningOptions": [ + { + "type": "toggle" + } + ], + "pricing": { + "input": 0.81, + "output": 3.85, + "cacheRead": 0.41, + "cacheWrite": 0 + }, + "releaseDate": "2026-04-21", + "family": "kimi-k2" + }, "Qwen/Qwen3.6-35B-A3B-FP8": { "id": "Qwen/Qwen3.6-35B-A3B-FP8", "name": "Qwen 3.6 35B A3B FP8", @@ -117323,32 +124441,6 @@ export const GENERATED_PROVIDER_MODELS: { "releaseDate": "2026-04-16", "family": "claude-opus" }, - "zai-org/GLM-5.1-FP8": { - "id": "zai-org/GLM-5.1-FP8", - "name": "GLM-5.1 FP8", - "contextWindow": 202752, - "maxInputTokens": 202752, - "maxTokens": 16384, - "capabilities": [ - "tools", - "reasoning", - "structured_output", - "temperature" - ], - "reasoningOptions": [ - { - "type": "toggle" - } - ], - "pricing": { - "input": 1.4, - "output": 4.4, - "cacheRead": 0, - "cacheWrite": 0 - }, - "releaseDate": "2026-03-27", - "family": "glm" - }, "openai/gpt-5.4-mini": { "id": "openai/gpt-5.4-mini", "name": "GPT-5.4 mini", @@ -117487,6 +124579,34 @@ export const GENERATED_PROVIDER_MODELS: { "releaseDate": "2026-02-17", "family": "claude-sonnet" }, + "qwen/qwen3.5-397b-a17b": { + "id": "qwen/qwen3.5-397b-a17b", + "name": "Qwen3.5 397B-A17B", + "contextWindow": 262144, + "maxInputTokens": 262144, + "maxTokens": 65536, + "capabilities": [ + "images", + "tools", + "reasoning", + "structured_output", + "temperature", + "prompt-cache" + ], + "reasoningOptions": [ + { + "type": "toggle" + } + ], + "pricing": { + "input": 0.5, + "output": 3.3, + "cacheRead": 0.25, + "cacheWrite": 0 + }, + "releaseDate": "2026-02-15", + "family": "qwen" + }, "anthropic/claude-opus-4-6": { "id": "anthropic/claude-opus-4-6", "name": "Claude Opus 4.6", @@ -117554,6 +124674,33 @@ export const GENERATED_PROVIDER_MODELS: { "releaseDate": "2025-12-11", "family": "gpt" }, + "deepseek/deepseek-v3.2": { + "id": "deepseek/deepseek-v3.2", + "name": "DeepSeek V3.2", + "contextWindow": 128000, + "maxInputTokens": 128000, + "maxTokens": 64000, + "capabilities": [ + "tools", + "reasoning", + "structured_output", + "temperature", + "prompt-cache" + ], + "reasoningOptions": [ + { + "type": "toggle" + } + ], + "pricing": { + "input": 1.1, + "output": 1.1, + "cacheRead": 0.55, + "cacheWrite": 0 + }, + "releaseDate": "2025-12-01", + "family": "deepseek" + }, "openai/gpt-5.1": { "id": "openai/gpt-5.1", "name": "GPT-5.1", @@ -117662,12 +124809,13 @@ export const GENERATED_PROVIDER_MODELS: { "images", "tools", "structured_output", - "temperature" + "temperature", + "prompt-cache" ], "pricing": { "input": 0.15, "output": 0.55, - "cacheRead": 0, + "cacheRead": 0.03, "cacheWrite": 0 }, "releaseDate": "2025-09-23", @@ -119869,6 +127017,94 @@ export const GENERATED_PROVIDER_MODELS: { } }, "neuralwatt": { + "mimo-v2.6-pro": { + "id": "mimo-v2.6-pro", + "name": "MiMo-V2.6-Pro", + "contextWindow": 1048560, + "maxInputTokens": 1048560, + "maxTokens": 393216, + "capabilities": [ + "images", + "tools", + "reasoning", + "temperature", + "prompt-cache" + ], + "reasoningOptions": [ + { + "type": "effort", + "values": [ + "none", + "high" + ] + }, + { + "type": "budget_tokens" + } + ], + "pricing": { + "input": 0.87, + "output": 1.74, + "cacheRead": 0.036, + "cacheWrite": 0 + }, + "releaseDate": "2026-09-22", + "family": "mimo", + "modalities": { + "input": [ + "text", + "image", + "audio" + ], + "output": [ + "text" + ] + } + }, + "mimo-v2.6-pro-flex": { + "id": "mimo-v2.6-pro-flex", + "name": "MiMo-V2.6-Pro Flex", + "contextWindow": 1048560, + "maxInputTokens": 1048560, + "maxTokens": 393216, + "capabilities": [ + "images", + "tools", + "reasoning", + "temperature", + "prompt-cache" + ], + "reasoningOptions": [ + { + "type": "effort", + "values": [ + "none", + "high" + ] + }, + { + "type": "budget_tokens" + } + ], + "pricing": { + "input": 0.5655, + "output": 1.131, + "cacheRead": 0.0234, + "cacheWrite": 0 + }, + "releaseDate": "2026-09-22", + "family": "mimo", + "modalities": { + "input": [ + "text", + "image", + "audio" + ], + "output": [ + "text" + ] + } + }, "deepseek-v4.1-flash": { "id": "deepseek-v4.1-flash", "name": "DeepSeek V4.1 Flash", @@ -120609,18 +127845,122 @@ export const GENERATED_PROVIDER_MODELS: { } }, "novita-ai": { - "moonshotai/kimi-k3": { - "id": "moonshotai/kimi-k3", - "name": "Kimi K3", + "inclusionai/ling-3.1-flash": { + "id": "inclusionai/ling-3.1-flash", + "name": "Ling 3.1 Flash", + "contextWindow": 262144, + "maxInputTokens": 262144, + "maxTokens": 262144, + "capabilities": [ + "tools", + "reasoning" + ], + "reasoningOptions": [ + { + "type": "toggle" + } + ], + "pricing": { + "input": 0, + "output": 0, + "cacheRead": 0, + "cacheWrite": 0 + }, + "releaseDate": "2026-09-29", + "family": "ling" + }, + "xiaomimimo/mimo-v2.6-flash": { + "id": "xiaomimimo/mimo-v2.6-flash", + "name": "MiMo-V2.6-Flash", "contextWindow": 1048576, "maxInputTokens": 1048576, - "maxTokens": 1048576, + "maxTokens": 131072, "capabilities": [ "images", "video", "tools", "reasoning", "structured_output", + "temperature", + "prompt-cache" + ], + "reasoningOptions": [ + { + "type": "toggle" + } + ], + "pricing": { + "input": 0.14, + "output": 0.28, + "cacheRead": 0.0028, + "cacheWrite": 0 + }, + "releaseDate": "2026-09-22", + "family": "mimo", + "modalities": { + "input": [ + "text", + "image", + "audio", + "video" + ], + "output": [ + "text" + ] + } + }, + "xiaomimimo/mimo-v2.6-pro": { + "id": "xiaomimimo/mimo-v2.6-pro", + "name": "MiMo-V2.6-Pro", + "contextWindow": 1048576, + "maxInputTokens": 1048576, + "maxTokens": 131072, + "capabilities": [ + "images", + "video", + "tools", + "reasoning", + "structured_output", + "temperature", + "prompt-cache" + ], + "reasoningOptions": [ + { + "type": "toggle" + } + ], + "pricing": { + "input": 0.435, + "output": 0.87, + "cacheRead": 0.0036, + "cacheWrite": 0 + }, + "releaseDate": "2026-09-22", + "family": "mimo", + "modalities": { + "input": [ + "text", + "image", + "audio", + "video" + ], + "output": [ + "text" + ] + } + }, + "deepseek/deepseek-v4.1-flash": { + "id": "deepseek/deepseek-v4.1-flash", + "name": "DeepSeek V4.1 Flash", + "contextWindow": 1048576, + "maxInputTokens": 1048576, + "maxTokens": 393216, + "capabilities": [ + "images", + "tools", + "reasoning", + "structured_output", + "temperature", "prompt-cache" ], "reasoningOptions": [ @@ -120636,6 +127976,548 @@ export const GENERATED_PROVIDER_MODELS: { ] } ], + "pricing": { + "input": 0.3, + "output": 1.2, + "cacheRead": 0.006, + "cacheWrite": 0 + }, + "releaseDate": "2026-09-10", + "family": "deepseek-flash" + }, + "inclusionai/ling-3.0-flash-vl": { + "id": "inclusionai/ling-3.0-flash-vl", + "name": "Ling 3.0 Flash VL", + "contextWindow": 262144, + "maxInputTokens": 262144, + "maxTokens": 32768, + "capabilities": [ + "images", + "video", + "tools", + "reasoning", + "temperature", + "prompt-cache" + ], + "reasoningOptions": [ + { + "type": "toggle" + } + ], + "pricing": { + "input": 0.075, + "output": 0.22, + "cacheRead": 0.015, + "cacheWrite": 0 + }, + "releaseDate": "2026-09-10", + "family": "ling" + }, + "inclusionai/ling-3.0-flash-sante": { + "id": "inclusionai/ling-3.0-flash-sante", + "name": "Ling 3.0 Flash Sante", + "contextWindow": 262144, + "maxInputTokens": 262144, + "maxTokens": 32768, + "capabilities": [ + "tools", + "reasoning", + "temperature", + "prompt-cache" + ], + "reasoningOptions": [ + { + "type": "toggle" + } + ], + "pricing": { + "input": 0.075, + "output": 0.22, + "cacheRead": 0.015, + "cacheWrite": 0 + }, + "releaseDate": "2026-09-04", + "family": "ling" + }, + "tencent/hy4-preview": { + "id": "tencent/hy4-preview", + "name": "Hy4 preview", + "contextWindow": 1000000, + "maxInputTokens": 1000000, + "maxTokens": 64000, + "capabilities": [ + "tools", + "reasoning", + "structured_output", + "temperature", + "prompt-cache" + ], + "reasoningOptions": [ + { + "type": "effort", + "values": [ + "none", + "high" + ] + } + ], + "pricing": { + "input": 0.834, + "output": 2.501, + "cacheRead": 0.042, + "cacheWrite": 0 + }, + "releaseDate": "2026-08-28", + "family": "Hy" + }, + "inclusionai/ling-3.0-flash-fin": { + "id": "inclusionai/ling-3.0-flash-fin", + "name": "Ling 3.0 Flash Fin", + "contextWindow": 262144, + "maxInputTokens": 262144, + "maxTokens": 32768, + "capabilities": [ + "tools", + "reasoning", + "prompt-cache" + ], + "reasoningOptions": [ + { + "type": "toggle" + } + ], + "pricing": { + "input": 0.075, + "output": 0.22, + "cacheRead": 0.015, + "cacheWrite": 0 + }, + "releaseDate": "2026-08-27", + "family": "ling" + }, + "qwen/qwen3.8-flash": { + "id": "qwen/qwen3.8-flash", + "name": "Qwen3.8 Flash", + "contextWindow": 1000000, + "maxInputTokens": 1000000, + "maxTokens": 131072, + "capabilities": [ + "images", + "video", + "tools", + "reasoning", + "structured_output", + "prompt-cache" + ], + "reasoningOptions": [ + { + "type": "toggle" + }, + { + "type": "effort", + "values": [ + "low", + "medium", + "xhigh" + ] + }, + { + "type": "budget_tokens" + } + ], + "pricing": { + "input": 0.15, + "output": 0.47, + "cacheRead": 0.016, + "cacheWrite": 0 + }, + "releaseDate": "2026-08-26", + "family": "qwen" + }, + "zai-org/glm-5.3-flash": { + "id": "zai-org/glm-5.3-flash", + "name": "GLM-5.3-Flash", + "contextWindow": 1048576, + "maxInputTokens": 1048576, + "maxTokens": 131072, + "capabilities": [ + "images", + "video", + "tools", + "reasoning", + "structured_output", + "temperature", + "prompt-cache" + ], + "reasoningOptions": [ + { + "type": "effort", + "values": [ + "low", + "high", + "max" + ] + } + ], + "pricing": { + "input": 0.15, + "output": 0.5, + "cacheRead": 0.03, + "cacheWrite": 0 + }, + "releaseDate": "2026-08-26", + "family": "glm-flash" + }, + "apodex/apodex-1.1-mini": { + "id": "apodex/apodex-1.1-mini", + "name": "Apodex 1.1 Mini", + "contextWindow": 262144, + "maxInputTokens": 262144, + "maxTokens": 262144, + "capabilities": [ + "tools", + "reasoning", + "structured_output", + "temperature" + ], + "reasoningOptions": [ + { + "type": "toggle" + } + ], + "pricing": { + "input": 0, + "output": 0, + "cacheRead": 0, + "cacheWrite": 0 + }, + "releaseDate": "2026-08-24" + }, + "qwen/qwen3.8-27b": { + "id": "qwen/qwen3.8-27b", + "name": "Qwen3.8 27B", + "contextWindow": 1000000, + "maxInputTokens": 1000000, + "maxTokens": 131072, + "capabilities": [ + "images", + "video", + "tools", + "reasoning", + "structured_output", + "temperature", + "prompt-cache" + ], + "reasoningOptions": [ + { + "type": "toggle" + }, + { + "type": "effort", + "values": [ + "low", + "medium", + "xhigh" + ] + }, + { + "type": "budget_tokens" + } + ], + "pricing": { + "input": 0.42, + "output": 3, + "cacheRead": 0.085, + "cacheWrite": 0 + }, + "releaseDate": "2026-08-14", + "family": "qwen" + }, + "zai-org/glm-5.3": { + "id": "zai-org/glm-5.3", + "name": "GLM-5.3", + "contextWindow": 1048576, + "maxInputTokens": 1048576, + "maxTokens": 131072, + "capabilities": [ + "tools", + "reasoning", + "structured_output", + "temperature", + "prompt-cache" + ], + "reasoningOptions": [ + { + "type": "effort", + "values": [ + "none", + "low", + "high", + "max" + ] + } + ], + "pricing": { + "input": 1.4, + "output": 4.4, + "cacheRead": 0.26, + "cacheWrite": 0 + }, + "releaseDate": "2026-08-14", + "family": "glm" + }, + "deepseek/deepseek-v4-pro-0813": { + "id": "deepseek/deepseek-v4-pro-0813", + "name": "DeepSeek V4 Pro 0813", + "contextWindow": 1048576, + "maxInputTokens": 1048576, + "maxTokens": 393216, + "capabilities": [ + "tools", + "reasoning", + "structured_output", + "temperature", + "prompt-cache" + ], + "reasoningOptions": [ + { + "type": "toggle" + }, + { + "type": "effort", + "values": [ + "low", + "high", + "max" + ] + } + ], + "pricing": { + "input": 1.32, + "output": 3.96, + "cacheRead": 0.044, + "cacheWrite": 0 + }, + "releaseDate": "2026-08-12", + "family": "deepseek-thinking" + }, + "qwen/qwen3.8-2.4t-a95b": { + "id": "qwen/qwen3.8-2.4t-a95b", + "name": "Qwen3.8 2.4T A95B", + "contextWindow": 1000000, + "maxInputTokens": 1000000, + "maxTokens": 131072, + "capabilities": [ + "tools", + "reasoning", + "structured_output", + "temperature", + "prompt-cache" + ], + "reasoningOptions": [ + { + "type": "effort", + "values": [ + "low", + "medium", + "xhigh" + ] + }, + { + "type": "budget_tokens" + } + ], + "pricing": { + "input": 2, + "output": 6, + "cacheRead": 0.25, + "cacheWrite": 0 + }, + "releaseDate": "2026-08-12", + "family": "qwen" + }, + "qwen/qwen3.8-max": { + "id": "qwen/qwen3.8-max", + "name": "Qwen3.8 Max", + "contextWindow": 1000000, + "maxInputTokens": 1000000, + "maxTokens": 131072, + "capabilities": [ + "images", + "video", + "tools", + "reasoning", + "structured_output", + "temperature", + "prompt-cache" + ], + "reasoningOptions": [ + { + "type": "toggle" + }, + { + "type": "effort", + "values": [ + "low", + "medium", + "xhigh" + ] + } + ], + "pricing": { + "input": 2, + "output": 6, + "cacheRead": 0.25, + "cacheWrite": 0 + }, + "releaseDate": "2026-08-03", + "family": "qwen" + }, + "deepseek/deepseek-v4-flash-0731": { + "id": "deepseek/deepseek-v4-flash-0731", + "name": "DeepSeek V4 Flash 0731", + "contextWindow": 1048576, + "maxInputTokens": 1048576, + "maxTokens": 393216, + "capabilities": [ + "tools", + "reasoning", + "structured_output", + "temperature", + "prompt-cache" + ], + "reasoningOptions": [ + { + "type": "toggle" + }, + { + "type": "effort", + "values": [ + "low", + "high", + "max" + ] + } + ], + "pricing": { + "input": 0.44, + "output": 1.32, + "cacheRead": 0.028, + "cacheWrite": 0 + }, + "releaseDate": "2026-07-31", + "family": "deepseek-flash" + }, + "inclusionai/ling-3.0-flash": { + "id": "inclusionai/ling-3.0-flash", + "name": "Ling 3.0 Flash", + "contextWindow": 262144, + "maxInputTokens": 262144, + "maxTokens": 32768, + "capabilities": [ + "tools", + "reasoning", + "temperature", + "prompt-cache" + ], + "reasoningOptions": [ + { + "type": "toggle" + } + ], + "pricing": { + "input": 0.06, + "output": 0.18, + "cacheRead": 0.012, + "cacheWrite": 0 + }, + "releaseDate": "2026-07-23", + "family": "ling" + }, + "mindai/macaron-v1-tall": { + "id": "mindai/macaron-v1-tall", + "name": "Macaron V1 Tall", + "contextWindow": 262144, + "maxInputTokens": 262144, + "maxTokens": 32768, + "capabilities": [ + "tools", + "reasoning", + "temperature", + "prompt-cache" + ], + "reasoningOptions": [ + { + "type": "effort", + "values": [ + "none", + "high" + ] + } + ], + "pricing": { + "input": 0.2475, + "output": 1.43, + "cacheRead": 0.044, + "cacheWrite": 0 + }, + "releaseDate": "2026-07-21" + }, + "mindai/macaron-v1-venti": { + "id": "mindai/macaron-v1-venti", + "name": "Macaron V1 Venti", + "contextWindow": 1048576, + "maxInputTokens": 1048576, + "maxTokens": 131072, + "capabilities": [ + "tools", + "reasoning", + "temperature", + "prompt-cache" + ], + "reasoningOptions": [ + { + "type": "effort", + "values": [ + "none", + "high", + "max" + ] + } + ], + "pricing": { + "input": 0.825, + "output": 2.475, + "cacheRead": 0.165, + "cacheWrite": 0 + }, + "releaseDate": "2026-07-21" + }, + "moonshotai/kimi-k3": { + "id": "moonshotai/kimi-k3", + "name": "Kimi K3", + "contextWindow": 1048576, + "maxInputTokens": 1048576, + "maxTokens": 1048576, + "capabilities": [ + "images", + "video", + "tools", + "reasoning", + "structured_output", + "prompt-cache" + ], + "reasoningOptions": [ + { + "type": "effort", + "values": [ + "none", + "low", + "high", + "max" + ] + } + ], "pricing": { "input": 3, "output": 15, @@ -120645,6 +128527,37 @@ export const GENERATED_PROVIDER_MODELS: { "releaseDate": "2026-07-16", "family": "kimi-k3" }, + "tencent/hy3": { + "id": "tencent/hy3", + "name": "Hy3", + "contextWindow": 262144, + "maxInputTokens": 192000, + "maxTokens": 262144, + "capabilities": [ + "tools", + "reasoning", + "structured_output", + "temperature", + "prompt-cache" + ], + "reasoningOptions": [ + { + "type": "effort", + "values": [ + "none", + "high" + ] + } + ], + "pricing": { + "input": 0.14, + "output": 0.58, + "cacheRead": 0.035, + "cacheWrite": 0 + }, + "releaseDate": "2026-07-06", + "family": "Hy" + }, "zai-org/glm-5.2": { "id": "zai-org/glm-5.2", "name": "GLM-5.2", @@ -120691,11 +128604,7 @@ export const GENERATED_PROVIDER_MODELS: { "structured_output", "prompt-cache" ], - "reasoningOptions": [ - { - "type": "toggle" - } - ], + "reasoningOptions": [], "pricing": { "input": 0.95, "output": 4, @@ -120705,9 +128614,71 @@ export const GENERATED_PROVIDER_MODELS: { "releaseDate": "2026-06-12", "family": "kimi-k2" }, + "minimax/minimax-m3": { + "id": "minimax/minimax-m3", + "name": "MiniMax-M3", + "contextWindow": 1000000, + "maxInputTokens": 1000000, + "maxTokens": 131072, + "capabilities": [ + "images", + "video", + "tools", + "reasoning", + "structured_output", + "temperature", + "prompt-cache" + ], + "reasoningOptions": [ + { + "type": "toggle" + } + ], + "pricing": { + "input": 0.3, + "output": 1.2, + "cacheRead": 0.06, + "cacheWrite": 0 + }, + "releaseDate": "2026-06-01", + "family": "minimax" + }, + "stepfun/step-3.7-flash": { + "id": "stepfun/step-3.7-flash", + "name": "Step 3.7 Flash", + "contextWindow": 262144, + "maxInputTokens": 256000, + "maxTokens": 256000, + "capabilities": [ + "images", + "video", + "tools", + "reasoning", + "structured_output", + "temperature", + "prompt-cache" + ], + "reasoningOptions": [ + { + "type": "effort", + "values": [ + "low", + "medium", + "high" + ] + } + ], + "pricing": { + "input": 0.2, + "output": 1.15, + "cacheRead": 0.04, + "cacheWrite": 0 + }, + "releaseDate": "2026-05-29" + }, "qwen/qwen3.7-max": { "id": "qwen/qwen3.7-max", - "name": "Qwen3.7-Max", + "name": "Qwen3.7 Max", "contextWindow": 1000000, "maxInputTokens": 1000000, "maxTokens": 65536, @@ -120721,76 +128692,44 @@ export const GENERATED_PROVIDER_MODELS: { "reasoningOptions": [ { "type": "toggle" + }, + { + "type": "budget_tokens" } ], "pricing": { "input": 1.25, "output": 3.75, "cacheRead": 0.25, - "cacheWrite": 1.5625 + "cacheWrite": 0 }, "releaseDate": "2026-05-21", "family": "qwen" }, - "inclusionai/ring-2.6-1t": { - "id": "inclusionai/ring-2.6-1t", - "name": "Ring-2.6-1T", - "contextWindow": 262144, - "maxInputTokens": 262144, + "baidu/cobuddy": { + "id": "baidu/cobuddy", + "name": "CoBuddy", + "contextWindow": 131072, + "maxInputTokens": 131072, "maxTokens": 65536, "capabilities": [ "tools", "reasoning", - "structured_output", - "temperature", - "prompt-cache" - ], - "reasoningOptions": [], - "pricing": { - "input": 0.3, - "output": 2.5, - "cacheRead": 0.06, - "cacheWrite": 0 - }, - "releaseDate": "2026-05-08", - "family": "ring" - }, - "deepseek/deepseek-v4-flash": { - "id": "deepseek/deepseek-v4-flash", - "name": "DeepSeek V4 Flash", - "contextWindow": 1048576, - "maxInputTokens": 1048576, - "maxTokens": 393216, - "capabilities": [ - "tools", - "reasoning", - "structured_output", "temperature", "prompt-cache" ], "reasoningOptions": [ { "type": "toggle" - }, - { - "type": "effort", - "values": [ - "minimal", - "low", - "medium", - "high", - "xhigh" - ] } ], "pricing": { - "input": 0.14, - "output": 0.28, - "cacheRead": 0.028, + "input": 0.28, + "output": 1.13, + "cacheRead": 0.07, "cacheWrite": 0 }, - "releaseDate": "2026-04-24", - "family": "deepseek-flash" + "releaseDate": "2026-05-06" }, "deepseek/deepseek-v4-pro": { "id": "deepseek/deepseek-v4-pro", @@ -120813,9 +128752,8 @@ export const GENERATED_PROVIDER_MODELS: { "type": "effort", "values": [ "low", - "medium", "high", - "xhigh" + "max" ] } ], @@ -120828,47 +128766,65 @@ export const GENERATED_PROVIDER_MODELS: { "releaseDate": "2026-04-24", "family": "deepseek-thinking" }, - "inclusionai/ling-2.6-flash": { - "id": "inclusionai/ling-2.6-flash", - "name": "Ling-2.6-flash", + "qwen/qwen3.6-27b": { + "id": "qwen/qwen3.6-27b", + "name": "Qwen3.6 27B", "contextWindow": 262144, "maxInputTokens": 262144, - "maxTokens": 32768, + "maxTokens": 65536, "capabilities": [ + "images", + "video", "tools", + "reasoning", "structured_output", - "temperature", - "prompt-cache" + "temperature" + ], + "reasoningOptions": [ + { + "type": "toggle" + }, + { + "type": "budget_tokens" + } ], "pricing": { - "input": 0.1, - "output": 0.3, - "cacheRead": 0.02, + "input": 0.6, + "output": 3.6, + "cacheRead": 0, "cacheWrite": 0 }, - "releaseDate": "2026-04-24", - "family": "ling" + "releaseDate": "2026-04-22", + "family": "qwen" }, - "inclusionai/ling-2.6-1t": { - "id": "inclusionai/ling-2.6-1t", - "name": "Ling-2.6-1T", - "contextWindow": 262144, - "maxInputTokens": 262144, - "maxTokens": 32768, + "xiaomimimo/mimo-v2.5": { + "id": "xiaomimimo/mimo-v2.5", + "name": "MiMo-V2.5", + "contextWindow": 1048576, + "maxInputTokens": 1048576, + "maxTokens": 131072, "capabilities": [ + "images", + "video", "tools", + "reasoning", "structured_output", "temperature", "prompt-cache" ], + "reasoningOptions": [ + { + "type": "toggle" + } + ], "pricing": { - "input": 0.3, - "output": 2.5, - "cacheRead": 0.06, + "input": 0.168, + "output": 0.336, + "cacheRead": 0.0034, "cacheWrite": 0 }, - "releaseDate": "2026-04-23", - "family": "ling" + "releaseDate": "2026-04-22", + "family": "mimo" }, "xiaomimimo/mimo-v2.5-pro": { "id": "xiaomimimo/mimo-v2.5-pro", @@ -120883,7 +128839,11 @@ export const GENERATED_PROVIDER_MODELS: { "temperature", "prompt-cache" ], - "reasoningOptions": [], + "reasoningOptions": [ + { + "type": "toggle" + } + ], "pricing": { "input": 0.522, "output": 1.044, @@ -120922,9 +128882,67 @@ export const GENERATED_PROVIDER_MODELS: { "releaseDate": "2026-04-21", "family": "kimi-k2" }, + "qwen/qwen3.6-35b-a3b": { + "id": "qwen/qwen3.6-35b-a3b", + "name": "Qwen3.6 35B-A3B", + "contextWindow": 262144, + "maxInputTokens": 262144, + "maxTokens": 65536, + "capabilities": [ + "images", + "video", + "tools", + "reasoning", + "structured_output", + "temperature" + ], + "reasoningOptions": [ + { + "type": "toggle" + }, + { + "type": "budget_tokens" + } + ], + "pricing": { + "input": 0.248, + "output": 1.485, + "cacheRead": 0, + "cacheWrite": 0 + }, + "releaseDate": "2026-04-17", + "family": "qwen" + }, + "zai-org/glm-5.1": { + "id": "zai-org/glm-5.1", + "name": "GLM-5.1", + "contextWindow": 204800, + "maxInputTokens": 204800, + "maxTokens": 131072, + "capabilities": [ + "tools", + "reasoning", + "structured_output", + "temperature", + "prompt-cache" + ], + "reasoningOptions": [ + { + "type": "toggle" + } + ], + "pricing": { + "input": 1.38, + "output": 4.4, + "cacheRead": 0.26, + "cacheWrite": 0 + }, + "releaseDate": "2026-04-07", + "family": "glm" + }, "google/gemma-4-26b-a4b-it": { "id": "google/gemma-4-26b-a4b-it", - "name": "Gemma 4 26B A4B", + "name": "Gemma 4 26B A4B IT", "contextWindow": 262144, "maxInputTokens": 262144, "maxTokens": 131072, @@ -120951,7 +128969,7 @@ export const GENERATED_PROVIDER_MODELS: { }, "google/gemma-4-31b-it": { "id": "google/gemma-4-31b-it", - "name": "Gemma 4 31B", + "name": "Gemma 4 31B IT", "contextWindow": 262144, "maxInputTokens": 262144, "maxTokens": 131072, @@ -120976,36 +128994,9 @@ export const GENERATED_PROVIDER_MODELS: { "releaseDate": "2026-04-02", "family": "gemma" }, - "zai-org/glm-5.1": { - "id": "zai-org/glm-5.1", - "name": "GLM-5.1", - "contextWindow": 204800, - "maxInputTokens": 204800, - "maxTokens": 131072, - "capabilities": [ - "tools", - "reasoning", - "structured_output", - "temperature", - "prompt-cache" - ], - "reasoningOptions": [ - { - "type": "toggle" - } - ], - "pricing": { - "input": 1.38, - "output": 4.4, - "cacheRead": 0.26, - "cacheWrite": 0 - }, - "releaseDate": "2026-03-27", - "family": "glm" - }, "minimax/minimax-m2.7": { "id": "minimax/minimax-m2.7", - "name": "MiniMax M2.7", + "name": "MiniMax-M2.7", "contextWindow": 204800, "maxInputTokens": 204800, "maxTokens": 131072, @@ -121024,7 +129015,7 @@ export const GENERATED_PROVIDER_MODELS: { "cacheWrite": 0 }, "releaseDate": "2026-03-18", - "family": "minimax-m2.7" + "family": "minimax" }, "minimax/minimax-m2.7-highspeed": { "id": "minimax/minimax-m2.7-highspeed", @@ -121049,32 +129040,9 @@ export const GENERATED_PROVIDER_MODELS: { "releaseDate": "2026-03-18", "family": "minimax" }, - "xiaomimimo/mimo-v2-pro": { - "id": "xiaomimimo/mimo-v2-pro", - "name": "MiMo-V2-Pro", - "contextWindow": 1048576, - "maxInputTokens": 1048576, - "maxTokens": 131072, - "capabilities": [ - "tools", - "reasoning", - "structured_output", - "temperature", - "prompt-cache" - ], - "reasoningOptions": [], - "pricing": { - "input": 2, - "output": 6, - "cacheRead": 0.4, - "cacheWrite": 0 - }, - "releaseDate": "2026-03-18", - "family": "mimo" - }, "qwen/qwen3.5-122b-a10b": { "id": "qwen/qwen3.5-122b-a10b", - "name": "Qwen3.5-122B-A10B", + "name": "Qwen3.5 122B-A10B", "contextWindow": 262144, "maxInputTokens": 262144, "maxTokens": 65536, @@ -121089,6 +129057,9 @@ export const GENERATED_PROVIDER_MODELS: { "reasoningOptions": [ { "type": "toggle" + }, + { + "type": "budget_tokens" } ], "pricing": { @@ -121097,12 +129068,12 @@ export const GENERATED_PROVIDER_MODELS: { "cacheRead": 0, "cacheWrite": 0 }, - "releaseDate": "2026-02-26", + "releaseDate": "2026-02-23", "family": "qwen" }, "qwen/qwen3.5-27b": { "id": "qwen/qwen3.5-27b", - "name": "Qwen3.5-27B", + "name": "Qwen3.5 27B", "contextWindow": 262144, "maxInputTokens": 262144, "maxTokens": 65536, @@ -121117,6 +129088,9 @@ export const GENERATED_PROVIDER_MODELS: { "reasoningOptions": [ { "type": "toggle" + }, + { + "type": "budget_tokens" } ], "pricing": { @@ -121125,12 +129099,12 @@ export const GENERATED_PROVIDER_MODELS: { "cacheRead": 0, "cacheWrite": 0 }, - "releaseDate": "2026-02-26", + "releaseDate": "2026-02-23", "family": "qwen" }, "qwen/qwen3.5-35b-a3b": { "id": "qwen/qwen3.5-35b-a3b", - "name": "Qwen3.5-35B-A3B", + "name": "Qwen3.5 35B-A3B", "contextWindow": 262144, "maxInputTokens": 262144, "maxTokens": 65536, @@ -121145,6 +129119,9 @@ export const GENERATED_PROVIDER_MODELS: { "reasoningOptions": [ { "type": "toggle" + }, + { + "type": "budget_tokens" } ], "pricing": { @@ -121153,15 +129130,15 @@ export const GENERATED_PROVIDER_MODELS: { "cacheRead": 0, "cacheWrite": 0 }, - "releaseDate": "2026-02-26", + "releaseDate": "2026-02-23", "family": "qwen" }, "qwen/qwen3.5-397b-a17b": { "id": "qwen/qwen3.5-397b-a17b", - "name": "Qwen3.5-397B-A17B", + "name": "Qwen3.5 397B-A17B", "contextWindow": 262144, "maxInputTokens": 262144, - "maxTokens": 64000, + "maxTokens": 65536, "capabilities": [ "images", "video", @@ -121173,6 +129150,9 @@ export const GENERATED_PROVIDER_MODELS: { "reasoningOptions": [ { "type": "toggle" + }, + { + "type": "budget_tokens" } ], "pricing": { @@ -121181,15 +129161,15 @@ export const GENERATED_PROVIDER_MODELS: { "cacheRead": 0, "cacheWrite": 0 }, - "releaseDate": "2026-02-17", + "releaseDate": "2026-02-15", "family": "qwen" }, "minimax/minimax-m2.5": { "id": "minimax/minimax-m2.5", - "name": "MiniMax M2.5", + "name": "MiniMax-M2.5", "contextWindow": 204800, "maxInputTokens": 204800, - "maxTokens": 131072, + "maxTokens": 131100, "capabilities": [ "tools", "reasoning", @@ -121207,29 +129187,6 @@ export const GENERATED_PROVIDER_MODELS: { "releaseDate": "2026-02-12", "family": "minimax" }, - "minimax/minimax-m2.5-highspeed": { - "id": "minimax/minimax-m2.5-highspeed", - "name": "MiniMax M2.5 Highspeed", - "contextWindow": 204800, - "maxInputTokens": 204800, - "maxTokens": 131072, - "capabilities": [ - "tools", - "reasoning", - "structured_output", - "temperature", - "prompt-cache" - ], - "reasoningOptions": [], - "pricing": { - "input": 0.6, - "output": 2.4, - "cacheRead": 0.03, - "cacheWrite": 0 - }, - "releaseDate": "2026-02-12", - "family": "minimax-m2.5" - }, "zai-org/glm-5": { "id": "zai-org/glm-5", "name": "GLM-5", @@ -121254,58 +129211,9 @@ export const GENERATED_PROVIDER_MODELS: { "cacheRead": 0.2, "cacheWrite": 0 }, - "releaseDate": "2026-02-11", + "releaseDate": "2026-02-12", "family": "glm" }, - "qwen/qwen3-coder-next": { - "id": "qwen/qwen3-coder-next", - "name": "Qwen3 Coder Next", - "contextWindow": 262144, - "maxInputTokens": 262144, - "maxTokens": 65536, - "capabilities": [ - "tools", - "structured_output", - "temperature" - ], - "pricing": { - "input": 0.2, - "output": 1.5, - "cacheRead": 0, - "cacheWrite": 0 - }, - "releaseDate": "2026-02-03", - "family": "qwen" - }, - "moonshotai/kimi-k2.5": { - "id": "moonshotai/kimi-k2.5", - "name": "Kimi K2.5", - "contextWindow": 262144, - "maxInputTokens": 262144, - "maxTokens": 262144, - "capabilities": [ - "images", - "video", - "tools", - "reasoning", - "structured_output", - "temperature", - "prompt-cache" - ], - "reasoningOptions": [ - { - "type": "toggle" - } - ], - "pricing": { - "input": 0.6, - "output": 3, - "cacheRead": 0.1, - "cacheWrite": 0 - }, - "releaseDate": "2026-01-27", - "family": "kimi-k2" - }, "zai-org/glm-4.7-flash": { "id": "zai-org/glm-4.7-flash", "name": "GLM-4.7-Flash", @@ -121333,46 +129241,33 @@ export const GENERATED_PROVIDER_MODELS: { "releaseDate": "2026-01-19", "family": "glm-flash" }, - "kwaipilot/kat-coder-pro": { - "id": "kwaipilot/kat-coder-pro", - "name": "Kat Coder Pro", - "contextWindow": 256000, - "maxInputTokens": 256000, - "maxTokens": 128000, + "moonshotai/kimi-k2.5": { + "id": "moonshotai/kimi-k2.5", + "name": "Kimi K2.5", + "contextWindow": 262144, + "maxInputTokens": 262144, + "maxTokens": 262144, "capabilities": [ + "images", + "video", "tools", + "reasoning", "structured_output", - "temperature", "prompt-cache" ], - "pricing": { - "input": 0.3, - "output": 1.2, - "cacheRead": 0.06, - "cacheWrite": 0 - }, - "releaseDate": "2026-01-05" - }, - "minimax/minimax-m2.1": { - "id": "minimax/minimax-m2.1", - "name": "Minimax M2.1", - "contextWindow": 204800, - "maxInputTokens": 204800, - "maxTokens": 131072, - "capabilities": [ - "tools", - "structured_output", - "temperature", - "prompt-cache" + "reasoningOptions": [ + { + "type": "toggle" + } ], "pricing": { - "input": 0.3, - "output": 1.2, - "cacheRead": 0.03, + "input": 0.6, + "output": 3, + "cacheRead": 0.1, "cacheWrite": 0 }, - "releaseDate": "2025-12-23", - "family": "minimax" + "releaseDate": "2026-01", + "family": "kimi-k2" }, "zai-org/glm-4.7": { "id": "zai-org/glm-4.7", @@ -121401,32 +129296,35 @@ export const GENERATED_PROVIDER_MODELS: { "releaseDate": "2025-12-22", "family": "glm" }, - "xiaomimimo/mimo-v2-flash": { - "id": "xiaomimimo/mimo-v2-flash", - "name": "XiaomiMiMo/MiMo-V2-Flash", + "nvidia/nemotron-3-nano-30b-a3b": { + "id": "nvidia/nemotron-3-nano-30b-a3b", + "name": "Nemotron 3 Nano 30B A3B", "contextWindow": 262144, "maxInputTokens": 262144, - "maxTokens": 32000, + "maxTokens": 32768, "capabilities": [ "tools", "reasoning", "structured_output", - "temperature", - "prompt-cache" + "temperature" + ], + "reasoningOptions": [ + { + "type": "toggle" + } ], - "reasoningOptions": [], "pricing": { - "input": 0.1, - "output": 0.3, - "cacheRead": 0.3, + "input": 0.05, + "output": 0.2, + "cacheRead": 0, "cacheWrite": 0 }, - "releaseDate": "2025-12-19", - "family": "mimo" + "releaseDate": "2025-12-15", + "family": "nemotron" }, "zai-org/glm-4.6v": { "id": "zai-org/glm-4.6v", - "name": "GLM 4.6V", + "name": "GLM-4.6V", "contextWindow": 131072, "maxInputTokens": 131072, "maxTokens": 32768, @@ -121451,57 +129349,7 @@ export const GENERATED_PROVIDER_MODELS: { "cacheWrite": 0 }, "releaseDate": "2025-12-08", - "family": "glmv" - }, - "deepseek/deepseek-v3.2": { - "id": "deepseek/deepseek-v3.2", - "name": "Deepseek V3.2", - "contextWindow": 163840, - "maxInputTokens": 163840, - "maxTokens": 65536, - "capabilities": [ - "tools", - "reasoning", - "structured_output", - "temperature", - "prompt-cache" - ], - "reasoningOptions": [ - { - "type": "toggle" - } - ], - "pricing": { - "input": 0.269, - "output": 0.4, - "cacheRead": 0.1345, - "cacheWrite": 0 - }, - "releaseDate": "2025-12-01", - "family": "deepseek" - }, - "baidu/ernie-4.5-vl-28b-a3b-thinking": { - "id": "baidu/ernie-4.5-vl-28b-a3b-thinking", - "name": "ERNIE-4.5-VL-28B-A3B-Thinking", - "contextWindow": 131072, - "maxInputTokens": 131072, - "maxTokens": 65536, - "capabilities": [ - "images", - "video", - "tools", - "reasoning", - "structured_output", - "temperature" - ], - "reasoningOptions": [], - "pricing": { - "input": 0.39, - "output": 0.39, - "cacheRead": 0, - "cacheWrite": 0 - }, - "releaseDate": "2025-11-26" + "family": "glm" }, "moonshotai/kimi-k2-thinking": { "id": "moonshotai/kimi-k2-thinking", @@ -121523,81 +129371,12 @@ export const GENERATED_PROVIDER_MODELS: { "cacheRead": 0.15, "cacheWrite": 0 }, - "releaseDate": "2025-11-07", + "releaseDate": "2025-11-06", "family": "kimi-thinking" }, - "minimax/minimax-m2": { - "id": "minimax/minimax-m2", - "name": "MiniMax-M2", - "contextWindow": 204800, - "maxInputTokens": 204800, - "maxTokens": 131072, - "capabilities": [ - "tools", - "reasoning", - "temperature", - "prompt-cache" - ], - "reasoningOptions": [], - "pricing": { - "input": 0.3, - "output": 1.2, - "cacheRead": 0.03, - "cacheWrite": 0 - }, - "releaseDate": "2025-10-27", - "family": "minimax" - }, - "qwen/qwen3-vl-8b-instruct": { - "id": "qwen/qwen3-vl-8b-instruct", - "name": "qwen/qwen3-vl-8b-instruct", - "contextWindow": 131072, - "maxInputTokens": 131072, - "maxTokens": 32768, - "capabilities": [ - "images", - "video", - "tools", - "structured_output", - "temperature" - ], - "pricing": { - "input": 0.08, - "output": 0.5, - "cacheRead": 0, - "cacheWrite": 0 - }, - "releaseDate": "2025-10-17" - }, - "zai-org/glm-4.5-air": { - "id": "zai-org/glm-4.5-air", - "name": "GLM 4.5 Air", - "contextWindow": 131072, - "maxInputTokens": 131072, - "maxTokens": 98304, - "capabilities": [ - "tools", - "reasoning", - "temperature", - "prompt-cache" - ], - "reasoningOptions": [ - { - "type": "toggle" - } - ], - "pricing": { - "input": 0.13, - "output": 0.85, - "cacheRead": 0.025, - "cacheWrite": 0 - }, - "releaseDate": "2025-10-13", - "family": "glm-air" - }, "qwen/qwen3-vl-30b-a3b-instruct": { "id": "qwen/qwen3-vl-30b-a3b-instruct", - "name": "qwen/qwen3-vl-30b-a3b-instruct", + "name": "Qwen3 VL 30B A3B Instruct", "contextWindow": 131072, "maxInputTokens": 131072, "maxTokens": 32768, @@ -121614,51 +129393,12 @@ export const GENERATED_PROVIDER_MODELS: { "cacheRead": 0, "cacheWrite": 0 }, - "releaseDate": "2025-10-11" - }, - "qwen/qwen3-vl-30b-a3b-thinking": { - "id": "qwen/qwen3-vl-30b-a3b-thinking", - "name": "qwen/qwen3-vl-30b-a3b-thinking", - "contextWindow": 131072, - "maxInputTokens": 131072, - "maxTokens": 32768, - "capabilities": [ - "images", - "video", - "tools", - "structured_output", - "temperature" - ], - "pricing": { - "input": 0.2, - "output": 1, - "cacheRead": 0, - "cacheWrite": 0 - }, - "releaseDate": "2025-10-11" - }, - "qwen/qwen3-coder-30b-a3b-instruct": { - "id": "qwen/qwen3-coder-30b-a3b-instruct", - "name": "Qwen3 Coder 30b A3B Instruct", - "contextWindow": 160000, - "maxInputTokens": 160000, - "maxTokens": 32768, - "capabilities": [ - "tools", - "structured_output", - "temperature" - ], - "pricing": { - "input": 0.07, - "output": 0.27, - "cacheRead": 0, - "cacheWrite": 0 - }, - "releaseDate": "2025-10-09" + "releaseDate": "2025-10-04", + "family": "qwen" }, "zai-org/glm-4.6": { "id": "zai-org/glm-4.6", - "name": "GLM 4.6", + "name": "GLM-4.6", "contextWindow": 204800, "maxInputTokens": 204800, "maxTokens": 131072, @@ -121683,51 +129423,6 @@ export const GENERATED_PROVIDER_MODELS: { "releaseDate": "2025-09-30", "family": "glm" }, - "deepseek/deepseek-v3.2-exp": { - "id": "deepseek/deepseek-v3.2-exp", - "name": "Deepseek V3.2 Exp", - "contextWindow": 163840, - "maxInputTokens": 163840, - "maxTokens": 65536, - "capabilities": [ - "tools", - "reasoning", - "structured_output", - "temperature" - ], - "reasoningOptions": [ - { - "type": "toggle" - } - ], - "pricing": { - "input": 0.27, - "output": 0.41, - "cacheRead": 0, - "cacheWrite": 0 - }, - "releaseDate": "2025-09-29" - }, - "qwen/qwen3-max": { - "id": "qwen/qwen3-max", - "name": "Qwen3 Max", - "contextWindow": 262144, - "maxInputTokens": 262144, - "maxTokens": 65536, - "capabilities": [ - "tools", - "structured_output", - "temperature" - ], - "pricing": { - "input": 2.11, - "output": 8.45, - "cacheRead": 0, - "cacheWrite": 0 - }, - "releaseDate": "2025-09-24", - "family": "qwen" - }, "qwen/qwen3-omni-30b-a3b-thinking": { "id": "qwen/qwen3-omni-30b-a3b-thinking", "name": "Qwen3 Omni 30B A3B Thinking", @@ -121749,110 +129444,23 @@ export const GENERATED_PROVIDER_MODELS: { "cacheRead": 0, "cacheWrite": 0 }, - "releaseDate": "2025-09-24", + "releaseDate": "2025-09-22", + "family": "qwen", "modalities": { "input": [ "text", - "audio", + "image", "video", - "image" + "audio" ], "output": [ "text" ] } }, - "qwen/qwen3-vl-235b-a22b-instruct": { - "id": "qwen/qwen3-vl-235b-a22b-instruct", - "name": "Qwen3 VL 235B A22B Instruct", - "contextWindow": 131072, - "maxInputTokens": 131072, - "maxTokens": 32768, - "capabilities": [ - "images", - "video", - "tools", - "structured_output", - "temperature" - ], - "pricing": { - "input": 0.3, - "output": 1.5, - "cacheRead": 0, - "cacheWrite": 0 - }, - "releaseDate": "2025-09-24" - }, - "deepseek/deepseek-v3.1-terminus": { - "id": "deepseek/deepseek-v3.1-terminus", - "name": "Deepseek V3.1 Terminus", - "contextWindow": 131072, - "maxInputTokens": 131072, - "maxTokens": 32768, - "capabilities": [ - "tools", - "reasoning", - "structured_output", - "temperature", - "prompt-cache" - ], - "reasoningOptions": [ - { - "type": "toggle" - } - ], - "pricing": { - "input": 0.27, - "output": 1, - "cacheRead": 0.135, - "cacheWrite": 0 - }, - "releaseDate": "2025-09-22", - "family": "deepseek" - }, - "qwen/qwen3-next-80b-a3b-instruct": { - "id": "qwen/qwen3-next-80b-a3b-instruct", - "name": "Qwen3 Next 80B A3B Instruct", - "contextWindow": 131072, - "maxInputTokens": 131072, - "maxTokens": 32768, - "capabilities": [ - "tools", - "structured_output", - "temperature" - ], - "pricing": { - "input": 0.15, - "output": 1.5, - "cacheRead": 0, - "cacheWrite": 0 - }, - "releaseDate": "2025-09-10" - }, - "qwen/qwen3-next-80b-a3b-thinking": { - "id": "qwen/qwen3-next-80b-a3b-thinking", - "name": "Qwen3 Next 80B A3B Thinking", - "contextWindow": 131072, - "maxInputTokens": 131072, - "maxTokens": 32768, - "capabilities": [ - "tools", - "reasoning", - "structured_output", - "temperature" - ], - "reasoningOptions": [], - "pricing": { - "input": 0.15, - "output": 1.5, - "cacheRead": 0, - "cacheWrite": 0 - }, - "releaseDate": "2025-09-10" - }, "moonshotai/kimi-k2-0905": { "id": "moonshotai/kimi-k2-0905", - "name": "Kimi K2 0905", + "name": "Kimi K2 Instruct 0905", "contextWindow": 262144, "maxInputTokens": 262144, "maxTokens": 98304, @@ -121870,36 +129478,9 @@ export const GENERATED_PROVIDER_MODELS: { "releaseDate": "2025-09-05", "family": "kimi-k2" }, - "deepseek/deepseek-v3.1": { - "id": "deepseek/deepseek-v3.1", - "name": "DeepSeek V3.1", - "contextWindow": 131072, - "maxInputTokens": 131072, - "maxTokens": 32768, - "capabilities": [ - "tools", - "reasoning", - "structured_output", - "temperature", - "prompt-cache" - ], - "reasoningOptions": [ - { - "type": "toggle" - } - ], - "pricing": { - "input": 0.27, - "output": 1, - "cacheRead": 0.135, - "cacheWrite": 0 - }, - "releaseDate": "2025-08-21", - "family": "deepseek" - }, "zai-org/glm-4.5v": { "id": "zai-org/glm-4.5v", - "name": "GLM 4.5V", + "name": "GLM-4.5V", "contextWindow": 65536, "maxInputTokens": 65536, "maxTokens": 16384, @@ -121924,16 +129505,15 @@ export const GENERATED_PROVIDER_MODELS: { "cacheWrite": 0 }, "releaseDate": "2025-08-11", - "family": "glmv" + "family": "glm" }, "openai/gpt-oss-120b": { "id": "openai/gpt-oss-120b", - "name": "OpenAI GPT OSS 120B", + "name": "GPT OSS 120B", "contextWindow": 131072, "maxInputTokens": 131072, "maxTokens": 32768, "capabilities": [ - "images", "tools", "reasoning", "structured_output", @@ -121955,11 +129535,12 @@ export const GENERATED_PROVIDER_MODELS: { "cacheRead": 0, "cacheWrite": 0 }, - "releaseDate": "2025-08-06" + "releaseDate": "2025-08-05", + "family": "gpt-oss" }, - "zai-org/glm-4.5": { - "id": "zai-org/glm-4.5", - "name": "GLM-4.5", + "zai-org/glm-4.5-air": { + "id": "zai-org/glm-4.5-air", + "name": "GLM-4.5-Air", "contextWindow": 131072, "maxInputTokens": 131072, "maxTokens": 98304, @@ -121975,81 +129556,20 @@ export const GENERATED_PROVIDER_MODELS: { } ], "pricing": { - "input": 0.6, - "output": 2.2, - "cacheRead": 0.11, + "input": 0.13, + "output": 0.85, + "cacheRead": 0.025, "cacheWrite": 0 }, "releaseDate": "2025-07-28", - "family": "glm" - }, - "qwen/qwen3-235b-a22b-thinking-2507": { - "id": "qwen/qwen3-235b-a22b-thinking-2507", - "name": "Qwen3 235B A22b Thinking 2507", - "contextWindow": 131072, - "maxInputTokens": 131072, - "maxTokens": 32768, - "capabilities": [ - "tools", - "reasoning", - "temperature" - ], - "reasoningOptions": [], - "pricing": { - "input": 0.3, - "output": 3, - "cacheRead": 0, - "cacheWrite": 0 - }, - "releaseDate": "2025-07-25", - "family": "qwen" - }, - "qwen/qwen3-coder-480b-a35b-instruct": { - "id": "qwen/qwen3-coder-480b-a35b-instruct", - "name": "Qwen3 Coder 480B A35B Instruct", - "contextWindow": 262144, - "maxInputTokens": 262144, - "maxTokens": 65536, - "capabilities": [ - "tools", - "structured_output", - "temperature" - ], - "pricing": { - "input": 0.38, - "output": 1.55, - "cacheRead": 0, - "cacheWrite": 0 - }, - "releaseDate": "2025-07-23", - "family": "qwen" - }, - "qwen/qwen3-235b-a22b-instruct-2507": { - "id": "qwen/qwen3-235b-a22b-instruct-2507", - "name": "Qwen3 235B A22B Instruct 2507", - "contextWindow": 131072, - "maxInputTokens": 131072, - "maxTokens": 16384, - "capabilities": [ - "tools", - "structured_output", - "temperature" - ], - "pricing": { - "input": 0.09, - "output": 0.58, - "cacheRead": 0, - "cacheWrite": 0 - }, - "releaseDate": "2025-07-22", - "family": "qwen" + "family": "glm-air" }, "moonshotai/kimi-k2-instruct": { "id": "moonshotai/kimi-k2-instruct", "name": "Kimi K2 Instruct", "contextWindow": 131072, "maxInputTokens": 131072, - "maxTokens": 32768, + "maxTokens": 98304, "capabilities": [ "tools", "temperature" @@ -122060,7 +129580,8 @@ export const GENERATED_PROVIDER_MODELS: { "cacheRead": 0, "cacheWrite": 0 }, - "releaseDate": "2025-07-11" + "releaseDate": "2025-07-11", + "family": "kimi-k2" }, "baidu/ernie-4.5-21B-a3b": { "id": "baidu/ernie-4.5-21B-a3b", @@ -122081,36 +129602,16 @@ export const GENERATED_PROVIDER_MODELS: { "releaseDate": "2025-06-30", "family": "ernie" }, - "baidu/ernie-4.5-vl-28b-a3b": { - "id": "baidu/ernie-4.5-vl-28b-a3b", - "name": "ERNIE 4.5 VL 28B A3B", - "contextWindow": 30000, - "maxInputTokens": 30000, - "maxTokens": 8000, - "capabilities": [ - "images", - "tools", - "reasoning", - "temperature" - ], - "reasoningOptions": [], - "pricing": { - "input": 0.14, - "output": 0.56, - "cacheRead": 0, - "cacheWrite": 0 - }, - "releaseDate": "2025-06-30" - }, "minimaxai/minimax-m1-80k": { "id": "minimaxai/minimax-m1-80k", - "name": "MiniMax M1", + "name": "MiniMax M1 80K", "contextWindow": 1000000, "maxInputTokens": 1000000, "maxTokens": 40000, "capabilities": [ "tools", "reasoning", + "structured_output", "temperature" ], "reasoningOptions": [], @@ -122146,55 +129647,97 @@ export const GENERATED_PROVIDER_MODELS: { "releaseDate": "2025-05-28", "family": "deepseek-thinking" }, - "qwen/qwen2.5-7b-instruct": { - "id": "qwen/qwen2.5-7b-instruct", - "name": "Qwen2.5 7B Instruct", - "contextWindow": 32000, - "maxInputTokens": 32000, - "maxTokens": 32000, + "meta-llama/llama-4-maverick-17b-128e-instruct-fp8": { + "id": "meta-llama/llama-4-maverick-17b-128e-instruct-fp8", + "name": "Llama 4 Maverick 17B Instruct", + "contextWindow": 1048576, + "maxInputTokens": 1048576, + "maxTokens": 8192, "capabilities": [ + "images", "tools", "structured_output", "temperature" ], "pricing": { - "input": 0.07, - "output": 0.07, + "input": 0.27, + "output": 0.85, "cacheRead": 0, "cacheWrite": 0 }, - "releaseDate": "2025-04-16" + "releaseDate": "2025-04-05", + "family": "llama" }, - "deepseek/deepseek-v3-0324": { - "id": "deepseek/deepseek-v3-0324", - "name": "DeepSeek V3 0324", - "contextWindow": 163840, - "maxInputTokens": 163840, - "maxTokens": 163840, + "meta-llama/llama-4-scout-17b-16e-instruct": { + "id": "meta-llama/llama-4-scout-17b-16e-instruct", + "name": "Llama 4 Scout 17B Instruct", + "contextWindow": 131072, + "maxInputTokens": 131072, + "maxTokens": 131072, "capabilities": [ + "images", "tools", - "structured_output", - "temperature", - "prompt-cache" + "temperature" ], "pricing": { - "input": 0.27, - "output": 1.12, - "cacheRead": 0.135, + "input": 0.18, + "output": 0.59, + "cacheRead": 0, "cacheWrite": 0 }, - "releaseDate": "2025-03-25", - "family": "deepseek" + "releaseDate": "2025-04-05", + "family": "llama" + }, + "google/gemma-3-12b-it": { + "id": "google/gemma-3-12b-it", + "name": "Gemma 3 12B IT", + "contextWindow": 131072, + "maxInputTokens": 131072, + "maxTokens": 8192, + "capabilities": [ + "images", + "tools", + "temperature" + ], + "pricing": { + "input": 0.05, + "output": 0.1, + "cacheRead": 0, + "cacheWrite": 0 + }, + "releaseDate": "2025-03-12", + "family": "gemma" + }, + "google/gemma-3-27b-it": { + "id": "google/gemma-3-27b-it", + "name": "Gemma 3 27B IT", + "contextWindow": 98304, + "maxInputTokens": 98304, + "maxTokens": 16384, + "capabilities": [ + "images", + "tools", + "temperature" + ], + "pricing": { + "input": 0.119, + "output": 0.2, + "cacheRead": 0, + "cacheWrite": 0 + }, + "releaseDate": "2025-03-12", + "family": "gemma" }, "deepseek/deepseek-r1-turbo": { "id": "deepseek/deepseek-r1-turbo", - "name": "DeepSeek R1 (Turbo)\t", + "name": "DeepSeek R1 (Turbo)", "contextWindow": 64000, "maxInputTokens": 64000, "maxTokens": 16000, "capabilities": [ "tools", "reasoning", + "structured_output", "temperature" ], "reasoningOptions": [], @@ -122204,11 +129747,12 @@ export const GENERATED_PROVIDER_MODELS: { "cacheRead": 0, "cacheWrite": 0 }, - "releaseDate": "2025-03-05" + "releaseDate": "2025-01-20", + "family": "deepseek-thinking" }, - "deepseek/deepseek-v3-turbo": { - "id": "deepseek/deepseek-v3-turbo", - "name": "DeepSeek V3 (Turbo)\t", + "deepseek/deepseek_v3": { + "id": "deepseek/deepseek_v3", + "name": "DeepSeek-V3", "contextWindow": 64000, "maxInputTokens": 64000, "maxTokens": 16000, @@ -122217,21 +129761,23 @@ export const GENERATED_PROVIDER_MODELS: { "temperature" ], "pricing": { - "input": 0.4, - "output": 1.3, + "input": 0.89, + "output": 0.89, "cacheRead": 0, "cacheWrite": 0 }, - "releaseDate": "2025-03-05" + "releaseDate": "2024-12-26", + "family": "deepseek" }, "meta-llama/llama-3.3-70b-instruct": { "id": "meta-llama/llama-3.3-70b-instruct", - "name": "Llama 3.3 70B Instruct", - "contextWindow": 131072, - "maxInputTokens": 131072, - "maxTokens": 120000, + "name": "Llama-3.3-70B-Instruct", + "contextWindow": 12288, + "maxInputTokens": 12288, + "maxTokens": 12288, "capabilities": [ "tools", + "structured_output", "temperature" ], "pricing": { @@ -122240,33 +129786,54 @@ export const GENERATED_PROVIDER_MODELS: { "cacheRead": 0, "cacheWrite": 0 }, - "releaseDate": "2024-12-07", + "releaseDate": "2024-12-06", "family": "llama" }, - "sao10K/L3-8B-stheno-v3.2": { - "id": "sao10K/L3-8B-stheno-v3.2", - "name": "L3 8B Stheno V3.2", - "contextWindow": 8192, - "maxInputTokens": 8192, + "meta-llama/llama-3.2-1b-instruct": { + "id": "meta-llama/llama-3.2-1b-instruct", + "name": "Llama 3.2 1B Instruct", + "contextWindow": 131000, + "maxInputTokens": 131000, "maxTokens": 32000, "capabilities": [ "tools", + "structured_output", "temperature" ], "pricing": { - "input": 0.05, + "input": 0.02, + "output": 0.02, + "cacheRead": 0, + "cacheWrite": 0 + }, + "releaseDate": "2024-09-25", + "family": "llama" + }, + "meta-llama/llama-3.1-8b-instruct": { + "id": "meta-llama/llama-3.1-8b-instruct", + "name": "Llama-3.1-8B-Instruct", + "contextWindow": 16384, + "maxInputTokens": 16384, + "maxTokens": 16384, + "capabilities": [ + "tools", + "structured_output", + "temperature" + ], + "pricing": { + "input": 0.02, "output": 0.05, "cacheRead": 0, "cacheWrite": 0 }, - "releaseDate": "2024-11-29", + "releaseDate": "2024-07-23", "family": "llama" }, - "qwen/qwen-2.5-72b-instruct": { - "id": "qwen/qwen-2.5-72b-instruct", - "name": "Qwen 2.5 72B Instruct", - "contextWindow": 32000, - "maxInputTokens": 32000, + "nousresearch/hermes-2-pro-llama-3-8b": { + "id": "nousresearch/hermes-2-pro-llama-3-8b", + "name": "Hermes 2 Pro Llama 3 8B", + "contextWindow": 8192, + "maxInputTokens": 8192, "maxTokens": 8192, "capabilities": [ "tools", @@ -122274,49 +129841,12 @@ export const GENERATED_PROVIDER_MODELS: { "temperature" ], "pricing": { - "input": 0.38, - "output": 0.4, + "input": 0.14, + "output": 0.14, "cacheRead": 0, "cacheWrite": 0 }, - "releaseDate": "2024-10-15", - "family": "qwen" - }, - "sao10K/l31-70b-euryale-v2.2": { - "id": "sao10K/l31-70b-euryale-v2.2", - "name": "L31 70B Euryale V2.2", - "contextWindow": 8192, - "maxInputTokens": 8192, - "maxTokens": 8192, - "capabilities": [ - "tools", - "temperature" - ], - "pricing": { - "input": 1.48, - "output": 1.48, - "cacheRead": 0, - "cacheWrite": 0 - }, - "releaseDate": "2024-09-19" - }, - "sao10K/l3-70b-euryale-v2.1": { - "id": "sao10K/l3-70b-euryale-v2.1", - "name": "L3 70B Euryale V2.1\t", - "contextWindow": 8192, - "maxInputTokens": 8192, - "maxTokens": 8192, - "capabilities": [ - "tools", - "temperature" - ], - "pricing": { - "input": 1.48, - "output": 1.48, - "cacheRead": 0, - "cacheWrite": 0 - }, - "releaseDate": "2024-06-18" + "releaseDate": "2024-04-30" } }, "nvidia": { @@ -123097,6 +130627,47 @@ export const GENERATED_PROVIDER_MODELS: { } }, "ofox": { + "anthropic/claude-haiku-5.5": { + "id": "anthropic/claude-haiku-5.5", + "name": "Claude Haiku 5.5", + "contextWindow": 1000000, + "maxInputTokens": 1000000, + "maxTokens": 128000, + "capabilities": [ + "images", + "files", + "tools", + "reasoning", + "structured_output", + "prompt-cache" + ], + "reasoningOptions": [ + { + "type": "toggle" + }, + { + "type": "effort", + "values": [ + "low", + "medium", + "high", + "xhigh", + "max" + ] + } + ], + "pricing": { + "input": 0.1, + "output": 0.5, + "cacheRead": 0.01, + "cacheWrite": 0.125 + }, + "releaseDate": "2026-10-07", + "family": "claude-haiku", + "metadata": { + "apiProtocol": "anthropic" + } + }, "openai/gpt-6.1-sol": { "id": "openai/gpt-6.1-sol", "name": "GPT-6.1 Sol", @@ -129842,6 +137413,38 @@ export const GENERATED_PROVIDER_MODELS: { } }, "opencode": { + "cat-jam-free": { + "id": "cat-jam-free", + "name": "Cat Jam Free", + "contextWindow": 262144, + "maxInputTokens": 262144, + "maxTokens": 131072, + "capabilities": [ + "tools", + "reasoning", + "structured_output", + "temperature" + ], + "reasoningOptions": [ + { + "type": "effort", + "values": [ + "low", + "medium", + "high", + "xhigh", + "max" + ] + } + ], + "pricing": { + "input": 0, + "output": 0, + "cacheRead": 0, + "cacheWrite": 0 + }, + "releaseDate": "2026-10-12" + }, "claude-haiku-5-5": { "id": "claude-haiku-5-5", "name": "Claude Haiku 5.5", @@ -129942,36 +137545,6 @@ export const GENERATED_PROVIDER_MODELS: { "releaseDate": "2026-10-06", "family": "mistral-large" }, - "fledge-alpha-free": { - "id": "fledge-alpha-free", - "name": "Fledge Alpha Free", - "contextWindow": 1048576, - "maxInputTokens": 1048576, - "maxTokens": 131072, - "capabilities": [ - "images", - "tools", - "reasoning", - "temperature" - ], - "reasoningOptions": [ - { - "type": "effort", - "values": [ - "low", - "high", - "max" - ] - } - ], - "pricing": { - "input": 0, - "output": 0, - "cacheRead": 0, - "cacheWrite": 0 - }, - "releaseDate": "2026-10-01" - }, "gpt-6.1-sol": { "id": "gpt-6.1-sol", "name": "GPT-6.1 Sol", @@ -130317,6 +137890,37 @@ export const GENERATED_PROVIDER_MODELS: { "apiProtocol": "openai-responses" } }, + "step-5-preview-free": { + "id": "step-5-preview-free", + "name": "Step 5 Preview Free", + "contextWindow": 1000000, + "maxInputTokens": 1000000, + "maxTokens": 65536, + "capabilities": [ + "images", + "video", + "tools", + "reasoning", + "structured_output" + ], + "reasoningOptions": [ + { + "type": "effort", + "values": [ + "low", + "medium", + "high" + ] + } + ], + "pricing": { + "input": 0, + "output": 0, + "cacheRead": 0, + "cacheWrite": 0 + }, + "releaseDate": "2026-09-16" + }, "deepseek-v4.1-flash": { "id": "deepseek-v4.1-flash", "name": "DeepSeek V4.1 Flash", @@ -133087,6 +140691,37 @@ export const GENERATED_PROVIDER_MODELS: { "apiProtocol": "openai-responses" } }, + "step-5-preview-free": { + "id": "step-5-preview-free", + "name": "Step 5 Preview Free", + "contextWindow": 1000000, + "maxInputTokens": 1000000, + "maxTokens": 65536, + "capabilities": [ + "images", + "video", + "tools", + "reasoning", + "structured_output" + ], + "reasoningOptions": [ + { + "type": "effort", + "values": [ + "low", + "medium", + "high" + ] + } + ], + "pricing": { + "input": 0, + "output": 0, + "cacheRead": 0, + "cacheWrite": 0 + }, + "releaseDate": "2026-09-16" + }, "deepseek-v4.1-flash": { "id": "deepseek-v4.1-flash", "name": "DeepSeek V4.1 Flash", @@ -133971,6 +141606,56 @@ export const GENERATED_PROVIDER_MODELS: { } }, "openrouter": { + "meta/muse-wonder": { + "id": "meta/muse-wonder", + "name": "Muse Wonder", + "contextWindow": 1048576, + "maxInputTokens": 1048576, + "maxTokens": 943718, + "capabilities": [ + "images", + "video", + "files", + "tools", + "reasoning", + "structured_output", + "temperature", + "prompt-cache" + ], + "reasoningOptions": [ + { + "type": "effort", + "values": [ + "minimal", + "low", + "medium", + "high", + "xhigh", + "max" + ] + } + ], + "pricing": { + "input": 4, + "output": 20, + "cacheRead": 0.2, + "cacheWrite": 0 + }, + "releaseDate": "2026-10-12", + "family": "muse", + "modalities": { + "input": [ + "text", + "image", + "video", + "pdf", + "audio" + ], + "output": [ + "text" + ] + } + }, "anthropic/claude-haiku-5.5": { "id": "anthropic/claude-haiku-5.5", "name": "Claude Haiku 5.5", @@ -134041,23 +141726,23 @@ export const GENERATED_PROVIDER_MODELS: { "cacheWrite": 0 }, "releaseDate": "2026-10-06", - "family": "nano-banana", + "family": "gemini-flash", "modalities": { "input": [ "image", "text" ], "output": [ - "image", - "text" + "text", + "image" ] } }, "mistralai/mistral-large-4-0": { "id": "mistralai/mistral-large-4-0", "name": "Mistral Large 4", - "contextWindow": 524288, - "maxInputTokens": 524288, + "contextWindow": 1048576, + "maxInputTokens": 1048576, "maxTokens": 262144, "capabilities": [ "images", @@ -134090,7 +141775,7 @@ export const GENERATED_PROVIDER_MODELS: { "name": "Ling 3.1 Flash", "contextWindow": 262144, "maxInputTokens": 262144, - "maxTokens": 32768, + "maxTokens": 235929, "capabilities": [ "tools", "reasoning", @@ -134995,6 +142680,39 @@ export const GENERATED_PROVIDER_MODELS: { }, "releaseDate": "2026-09-17" }, + "stepfun/step-5-preview": { + "id": "stepfun/step-5-preview", + "name": "Step 5 Preview", + "contextWindow": 1000000, + "maxInputTokens": 1000000, + "maxTokens": 64000, + "capabilities": [ + "images", + "video", + "tools", + "reasoning", + "structured_output", + "temperature", + "prompt-cache" + ], + "reasoningOptions": [ + { + "type": "effort", + "values": [ + "low", + "medium", + "high" + ] + } + ], + "pricing": { + "input": 1, + "output": 2.7, + "cacheRead": 0.05, + "cacheWrite": 0 + }, + "releaseDate": "2026-09-16" + }, "~deepseek/deepseek-flash-latest": { "id": "~deepseek/deepseek-flash-latest", "name": "DeepSeek Flash Latest", @@ -135023,9 +142741,9 @@ export const GENERATED_PROVIDER_MODELS: { } ], "pricing": { - "input": 0.0184, - "output": 1.2, - "cacheRead": 0.01, + "input": 0.045, + "output": 0.891, + "cacheRead": 0.0054, "cacheWrite": 0 }, "releaseDate": "2026-09-14", @@ -135036,7 +142754,7 @@ export const GENERATED_PROVIDER_MODELS: { "name": "DeepSeek Pro Latest", "contextWindow": 1048576, "maxInputTokens": 1048576, - "maxTokens": 393216, + "maxTokens": 943718, "capabilities": [ "tools", "reasoning", @@ -135058,9 +142776,9 @@ export const GENERATED_PROVIDER_MODELS: { } ], "pricing": { - "input": 0.1773, - "output": 4.2, - "cacheRead": 0.13, + "input": 0.25, + "output": 5, + "cacheRead": 0.2, "cacheWrite": 0 }, "releaseDate": "2026-09-14", @@ -135302,9 +143020,9 @@ export const GENERATED_PROVIDER_MODELS: { } ], "pricing": { - "input": 0.3, - "output": 1.2, - "cacheRead": 0.006, + "input": 0.045, + "output": 0.891, + "cacheRead": 0.0054, "cacheWrite": 0 }, "releaseDate": "2026-09-10", @@ -135405,16 +143123,17 @@ export const GENERATED_PROVIDER_MODELS: { "releaseDate": "2026-09-08", "family": "agi" }, - "inclusionai/ling-3.0-flash-sante:free": { - "id": "inclusionai/ling-3.0-flash-sante:free", - "name": "Ling 3.0 Flash Sante (free)", + "inclusionai/ling-3.0-flash-sante": { + "id": "inclusionai/ling-3.0-flash-sante", + "name": "Ling 3.0 Flash Sante", "contextWindow": 262144, "maxInputTokens": 262144, "maxTokens": 32768, "capabilities": [ "tools", "reasoning", - "temperature" + "temperature", + "prompt-cache" ], "reasoningOptions": [ { @@ -135422,9 +143141,9 @@ export const GENERATED_PROVIDER_MODELS: { } ], "pricing": { - "input": 0, - "output": 0, - "cacheRead": 0, + "input": 0.042, + "output": 0.1232, + "cacheRead": 0.0084, "cacheWrite": 0 }, "releaseDate": "2026-09-04", @@ -135654,7 +143373,7 @@ export const GENERATED_PROVIDER_MODELS: { "input": 2, "output": 6, "cacheRead": 0.25, - "cacheWrite": 2.5 + "cacheWrite": 0 }, "releaseDate": "2026-09-02", "family": "qwen" @@ -135784,9 +143503,9 @@ export const GENERATED_PROVIDER_MODELS: { } ], "pricing": { - "input": 0.032, - "output": 0.66, - "cacheRead": 0.02, + "input": 0.0261, + "output": 0.9, + "cacheRead": 0.0108, "cacheWrite": 0 }, "releaseDate": "2026-08-27", @@ -135801,7 +143520,6 @@ export const GENERATED_PROVIDER_MODELS: { "capabilities": [ "tools", "reasoning", - "structured_output", "temperature", "prompt-cache" ], @@ -135846,7 +143564,7 @@ export const GENERATED_PROVIDER_MODELS: { "input": 0.15, "output": 0.47, "cacheRead": 0.016, - "cacheWrite": 0.2 + "cacheWrite": 0 }, "releaseDate": "2026-08-26", "family": "qwen" @@ -135856,7 +143574,7 @@ export const GENERATED_PROVIDER_MODELS: { "name": "GLM-5.3-Flash", "contextWindow": 1048576, "maxInputTokens": 1048576, - "maxTokens": 943717, + "maxTokens": 943718, "capabilities": [ "images", "video", @@ -135877,9 +143595,9 @@ export const GENERATED_PROVIDER_MODELS: { } ], "pricing": { - "input": 0.15, - "output": 0.5, - "cacheRead": 0.03, + "input": 0.0261, + "output": 0.9, + "cacheRead": 0.0108, "cacheWrite": 0 }, "releaseDate": "2026-08-26", @@ -135963,7 +143681,7 @@ export const GENERATED_PROVIDER_MODELS: { "name": "GLM Latest", "contextWindow": 1048576, "maxInputTokens": 1048576, - "maxTokens": 131072, + "maxTokens": 943718, "capabilities": [ "tools", "reasoning", @@ -135982,9 +143700,9 @@ export const GENERATED_PROVIDER_MODELS: { } ], "pricing": { - "input": 0.0392, - "output": 12, - "cacheRead": 0.0365, + "input": 0.055, + "output": 5, + "cacheRead": 0.06, "cacheWrite": 0 }, "releaseDate": "2026-08-19", @@ -136021,7 +143739,7 @@ export const GENERATED_PROVIDER_MODELS: { "name": "Qwen3.8 27B", "contextWindow": 1000000, "maxInputTokens": 1000000, - "maxTokens": 131072, + "maxTokens": 235929, "capabilities": [ "images", "video", @@ -136045,10 +143763,10 @@ export const GENERATED_PROVIDER_MODELS: { } ], "pricing": { - "input": 0.425, - "output": 2.55, - "cacheRead": 0.085, - "cacheWrite": 0.53125 + "input": 0.04, + "output": 2.3, + "cacheRead": 0.0198, + "cacheWrite": 0 }, "releaseDate": "2026-08-14", "family": "qwen" @@ -136077,9 +143795,9 @@ export const GENERATED_PROVIDER_MODELS: { } ], "pricing": { - "input": 0.049, - "output": 3.39, - "cacheRead": 0.048, + "input": 0.079, + "output": 4.8, + "cacheRead": 0.059, "cacheWrite": 0 }, "releaseDate": "2026-08-14", @@ -136187,9 +143905,9 @@ export const GENERATED_PROVIDER_MODELS: { } ], "pricing": { - "input": 1.32, - "output": 3.96, - "cacheRead": 0.044, + "input": 0.66, + "output": 1.98, + "cacheRead": 0.022, "cacheWrite": 0 }, "releaseDate": "2026-08-12", @@ -136289,7 +144007,7 @@ export const GENERATED_PROVIDER_MODELS: { "name": "Nemotron 3.5 Lightning 30B A3B", "contextWindow": 262144, "maxInputTokens": 262144, - "maxTokens": 131072, + "maxTokens": 235929, "capabilities": [ "tools", "reasoning", @@ -136303,9 +144021,9 @@ export const GENERATED_PROVIDER_MODELS: { } ], "pricing": { - "input": 0.049, - "output": 0.14, - "cacheRead": 0.0245, + "input": 0.07, + "output": 0.2, + "cacheRead": 0.04, "cacheWrite": 0 }, "releaseDate": "2026-08-11", @@ -136502,9 +144220,9 @@ export const GENERATED_PROVIDER_MODELS: { } ], "pricing": { - "input": 0.00632, - "output": 1.536, - "cacheRead": 0.00632, + "input": 0.0144, + "output": 0.576, + "cacheRead": 0.0108, "cacheWrite": 0 }, "releaseDate": "2026-08-01", @@ -136537,9 +144255,9 @@ export const GENERATED_PROVIDER_MODELS: { } ], "pricing": { - "input": 0.0079, - "output": 1.28, - "cacheRead": 0.0079, + "input": 0.016, + "output": 0.64, + "cacheRead": 0.016, "cacheWrite": 0 }, "releaseDate": "2026-07-31", @@ -136903,9 +144621,9 @@ export const GENERATED_PROVIDER_MODELS: { } ], "pricing": { - "input": 0.65, - "output": 15, - "cacheRead": 0.43, + "input": 0.73, + "output": 13.5, + "cacheRead": 0.55, "cacheWrite": 0 }, "releaseDate": "2026-07-16", @@ -137583,7 +145301,7 @@ export const GENERATED_PROVIDER_MODELS: { "name": "GLM-5.2", "contextWindow": 1048576, "maxInputTokens": 1048576, - "maxTokens": 131072, + "maxTokens": 943718, "capabilities": [ "tools", "reasoning", @@ -137604,9 +145322,9 @@ export const GENERATED_PROVIDER_MODELS: { } ], "pricing": { - "input": 0.171, - "output": 12, - "cacheRead": 0.15, + "input": 0.06, + "output": 7, + "cacheRead": 0.059, "cacheWrite": 0 }, "releaseDate": "2026-06-13", @@ -137711,7 +145429,7 @@ export const GENERATED_PROVIDER_MODELS: { "name": "Nemotron 3 Ultra 550B A55B", "contextWindow": 262144, "maxInputTokens": 262144, - "maxTokens": 16384, + "maxTokens": 182520, "capabilities": [ "tools", "reasoning", @@ -137735,9 +145453,9 @@ export const GENERATED_PROVIDER_MODELS: { } ], "pricing": { - "input": 0.5, - "output": 2.2, - "cacheRead": 0.1, + "input": 0.6, + "output": 2.4, + "cacheRead": 0.12, "cacheWrite": 0 }, "releaseDate": "2026-06-04", @@ -138400,9 +146118,9 @@ export const GENERATED_PROVIDER_MODELS: { } ], "pricing": { - "input": 0.64, - "output": 13, - "cacheRead": 0.45, + "input": 0.73, + "output": 13.5, + "cacheRead": 0.55, "cacheWrite": 0 }, "releaseDate": "2026-04-27", @@ -138527,9 +146245,9 @@ export const GENERATED_PROVIDER_MODELS: { } ], "pricing": { - "input": 0.0128, + "input": 0.05, "output": 1.28, - "cacheRead": 0.0128, + "cacheRead": 0.05, "cacheWrite": 0 }, "releaseDate": "2026-04-24", @@ -138641,15 +146359,14 @@ export const GENERATED_PROVIDER_MODELS: { "name": "Qwen3.6 27B", "contextWindow": 262144, "maxInputTokens": 262144, - "maxTokens": 65536, + "maxTokens": 81920, "capabilities": [ "images", "video", "tools", "reasoning", "structured_output", - "temperature", - "prompt-cache" + "temperature" ], "reasoningOptions": [ { @@ -138657,9 +146374,9 @@ export const GENERATED_PROVIDER_MODELS: { } ], "pricing": { - "input": 0.3, - "output": 2, - "cacheRead": 0.03, + "input": 0.32, + "output": 3.2, + "cacheRead": 0, "cacheWrite": 0 }, "releaseDate": "2026-04-22", @@ -138787,9 +146504,9 @@ export const GENERATED_PROVIDER_MODELS: { } ], "pricing": { - "input": 0.465, - "output": 2.45, - "cacheRead": 0.0975, + "input": 0.65, + "output": 3.41, + "cacheRead": 0.15, "cacheWrite": 0 }, "releaseDate": "2026-04-21", @@ -138840,33 +146557,6 @@ export const GENERATED_PROVIDER_MODELS: { ] } }, - "qwen/qwen3.6-max-preview": { - "id": "qwen/qwen3.6-max-preview", - "name": "Qwen3.6 Max Preview", - "contextWindow": 262144, - "maxInputTokens": 262144, - "maxTokens": 65536, - "capabilities": [ - "tools", - "reasoning", - "structured_output", - "temperature", - "prompt-cache" - ], - "reasoningOptions": [ - { - "type": "toggle" - } - ], - "pricing": { - "input": 1.027, - "output": 6.162, - "cacheRead": 0, - "cacheWrite": 1.28375 - }, - "releaseDate": "2026-04-20", - "family": "qwen" - }, "tencent/hy3-preview": { "id": "tencent/hy3-preview", "name": "Hy3 preview", @@ -138903,7 +146593,7 @@ export const GENERATED_PROVIDER_MODELS: { "name": "Qwen3.6 35B-A3B", "contextWindow": 262144, "maxInputTokens": 262144, - "maxTokens": 235929, + "maxTokens": 65536, "capabilities": [ "images", "video", @@ -138919,9 +146609,9 @@ export const GENERATED_PROVIDER_MODELS: { } ], "pricing": { - "input": 0.15, - "output": 1, - "cacheRead": 0.05, + "input": 0.1, + "output": 0.95, + "cacheRead": 0.1, "cacheWrite": 0 }, "releaseDate": "2026-04-17", @@ -139336,7 +147026,7 @@ export const GENERATED_PROVIDER_MODELS: { "name": "MiniMax-M2.7", "contextWindow": 204800, "maxInputTokens": 204800, - "maxTokens": 176947, + "maxTokens": 131072, "capabilities": [ "tools", "reasoning", @@ -139345,9 +147035,9 @@ export const GENERATED_PROVIDER_MODELS: { ], "reasoningOptions": [], "pricing": { - "input": 0.21, - "output": 0.84, - "cacheRead": 0.042, + "input": 0.3, + "output": 1.2, + "cacheRead": 0.06, "cacheWrite": 0 }, "releaseDate": "2026-03-18", @@ -139486,7 +147176,7 @@ export const GENERATED_PROVIDER_MODELS: { "name": "Nemotron 3 Super 120B A12B", "contextWindow": 262144, "maxInputTokens": 262144, - "maxTokens": 16384, + "maxTokens": 235929, "capabilities": [ "tools", "reasoning", @@ -139509,8 +147199,8 @@ export const GENERATED_PROVIDER_MODELS: { } ], "pricing": { - "input": 0.085, - "output": 0.4, + "input": 0.08, + "output": 0.45, "cacheRead": 0, "cacheWrite": 0 }, @@ -139857,7 +147547,7 @@ export const GENERATED_PROVIDER_MODELS: { "name": "Qwen3.5 35B-A3B", "contextWindow": 262144, "maxInputTokens": 262144, - "maxTokens": 235929, + "maxTokens": 32768, "capabilities": [ "images", "video", @@ -139873,9 +147563,9 @@ export const GENERATED_PROVIDER_MODELS: { } ], "pricing": { - "input": 0.15, - "output": 1, - "cacheRead": 0.05, + "input": 0.08, + "output": 0.75, + "cacheRead": 0.04, "cacheWrite": 0 }, "releaseDate": "2026-02-23", @@ -140074,7 +147764,7 @@ export const GENERATED_PROVIDER_MODELS: { "name": "Qwen3.5 397B-A17B", "contextWindow": 262144, "maxInputTokens": 262144, - "maxTokens": 81920, + "maxTokens": 235929, "capabilities": [ "images", "video", @@ -140090,9 +147780,9 @@ export const GENERATED_PROVIDER_MODELS: { } ], "pricing": { - "input": 0.45, - "output": 3, - "cacheRead": 0.22, + "input": 0.55, + "output": 3.5, + "cacheRead": 0.225, "cacheWrite": 0 }, "releaseDate": "2026-02-15", @@ -140258,32 +147948,6 @@ export const GENERATED_PROVIDER_MODELS: { "releaseDate": "2026-02-12", "family": "glm" }, - "qwen/qwen3-max-thinking": { - "id": "qwen/qwen3-max-thinking", - "name": "Qwen3 Max Thinking", - "contextWindow": 262144, - "maxInputTokens": 262144, - "maxTokens": 65536, - "capabilities": [ - "tools", - "reasoning", - "structured_output", - "temperature" - ], - "reasoningOptions": [ - { - "type": "toggle" - } - ], - "pricing": { - "input": 0.78, - "output": 3.9, - "cacheRead": 0, - "cacheWrite": 0 - }, - "releaseDate": "2026-02-09", - "family": "qwen" - }, "anthropic/claude-opus-4.6": { "id": "anthropic/claude-opus-4.6", "name": "Claude Opus 4.6", @@ -140561,9 +148225,9 @@ export const GENERATED_PROVIDER_MODELS: { } ], "pricing": { - "input": 0.45, - "output": 2.25, - "cacheRead": 0.07, + "input": 0.5, + "output": 2.5, + "cacheRead": 0.15, "cacheWrite": 0 }, "releaseDate": "2026-01", @@ -141063,15 +148727,15 @@ export const GENERATED_PROVIDER_MODELS: { "name": "DeepSeek Chat", "contextWindow": 163840, "maxInputTokens": 163840, - "maxTokens": 16000, + "maxTokens": 16384, "capabilities": [ "tools", "structured_output", "temperature" ], "pricing": { - "input": 0.2574, - "output": 1.0287, + "input": 0.32, + "output": 0.89, "cacheRead": 0, "cacheWrite": 0 }, @@ -141098,8 +148762,8 @@ export const GENERATED_PROVIDER_MODELS: { ], "pricing": { "input": 0.259, - "output": 0.42, - "cacheRead": 0.135, + "output": 0.8, + "cacheRead": 0.14, "cacheWrite": 0 }, "releaseDate": "2025-12-01", @@ -141306,18 +148970,19 @@ export const GENERATED_PROVIDER_MODELS: { "name": "Kimi K2 Thinking", "contextWindow": 262144, "maxInputTokens": 262144, - "maxTokens": 235929, + "maxTokens": 98304, "capabilities": [ "tools", "reasoning", "structured_output", - "temperature" + "temperature", + "prompt-cache" ], "reasoningOptions": [], "pricing": { "input": 0.6, "output": 2.5, - "cacheRead": 0, + "cacheRead": 0.15, "cacheWrite": 0 }, "releaseDate": "2025-11-06", @@ -141398,27 +149063,6 @@ export const GENERATED_PROVIDER_MODELS: { "releaseDate": "2025-10-27", "family": "minimax" }, - "qwen/qwen3-vl-32b-instruct": { - "id": "qwen/qwen3-vl-32b-instruct", - "name": "Qwen3 VL 32B Instruct", - "contextWindow": 131072, - "maxInputTokens": 131072, - "maxTokens": 32768, - "capabilities": [ - "images", - "tools", - "structured_output", - "temperature" - ], - "pricing": { - "input": 0.104, - "output": 0.416, - "cacheRead": 0, - "cacheWrite": 0 - }, - "releaseDate": "2025-10-23", - "family": "qwen" - }, "openai/gpt-5-image-mini": { "id": "openai/gpt-5-image-mini", "name": "GPT-5 Image Mini", @@ -141523,40 +149167,18 @@ export const GENERATED_PROVIDER_MODELS: { "name": "Qwen3 VL 8B Instruct", "contextWindow": 262144, "maxInputTokens": 262144, - "maxTokens": 32768, + "maxTokens": 235929, "capabilities": [ "images", "tools", "structured_output", - "temperature" + "temperature", + "prompt-cache" ], "pricing": { - "input": 0.117, - "output": 0.455, - "cacheRead": 0, - "cacheWrite": 0 - }, - "releaseDate": "2025-10-14", - "family": "qwen" - }, - "qwen/qwen3-vl-8b-thinking": { - "id": "qwen/qwen3-vl-8b-thinking", - "name": "Qwen3 VL 8B Thinking", - "contextWindow": 131072, - "maxInputTokens": 131072, - "maxTokens": 32768, - "capabilities": [ - "images", - "tools", - "reasoning", - "structured_output", - "temperature" - ], - "reasoningOptions": [], - "pricing": { - "input": 0.18, - "output": 2.1, - "cacheRead": 0, + "input": 0.25, + "output": 0.75, + "cacheRead": 0.12, "cacheWrite": 0 }, "releaseDate": "2025-10-14", @@ -141592,6 +149214,29 @@ export const GENERATED_PROVIDER_MODELS: { "releaseDate": "2025-10-06", "family": "gpt-pro" }, + "qwen/qwen3-vl-30b-a3b-thinking": { + "id": "qwen/qwen3-vl-30b-a3b-thinking", + "name": "Qwen3 VL 30B A3B Thinking", + "contextWindow": 262144, + "maxInputTokens": 262144, + "maxTokens": 235929, + "capabilities": [ + "images", + "tools", + "reasoning", + "structured_output", + "temperature" + ], + "reasoningOptions": [], + "pricing": { + "input": 0.29, + "output": 1, + "cacheRead": 0, + "cacheWrite": 0 + }, + "releaseDate": "2025-10-06", + "family": "qwen" + }, "qwen/qwen3-vl-30b-a3b-instruct": { "id": "qwen/qwen3-vl-30b-a3b-instruct", "name": "Qwen3 VL 30B A3B Instruct", @@ -141610,30 +149255,7 @@ export const GENERATED_PROVIDER_MODELS: { "cacheRead": 0, "cacheWrite": 0 }, - "releaseDate": "2025-10-06", - "family": "qwen" - }, - "qwen/qwen3-vl-30b-a3b-thinking": { - "id": "qwen/qwen3-vl-30b-a3b-thinking", - "name": "Qwen3 VL 30B A3B Thinking", - "contextWindow": 262144, - "maxInputTokens": 262144, - "maxTokens": 32768, - "capabilities": [ - "images", - "tools", - "reasoning", - "structured_output", - "temperature" - ], - "reasoningOptions": [], - "pricing": { - "input": 0.2, - "output": 2.4, - "cacheRead": 0, - "cacheWrite": 0 - }, - "releaseDate": "2025-10-06", + "releaseDate": "2025-10-04", "family": "qwen" }, "z-ai/glm-4.6": { @@ -141641,7 +149263,7 @@ export const GENERATED_PROVIDER_MODELS: { "name": "GLM-4.6", "contextWindow": 204800, "maxInputTokens": 204800, - "maxTokens": 16384, + "maxTokens": 131072, "capabilities": [ "tools", "reasoning", @@ -141655,9 +149277,9 @@ export const GENERATED_PROVIDER_MODELS: { } ], "pricing": { - "input": 0.43, - "output": 1.75, - "cacheRead": 0.08, + "input": 0.5, + "output": 2, + "cacheRead": 0.1, "cacheWrite": 0 }, "releaseDate": "2025-09-30", @@ -141718,27 +149340,6 @@ export const GENERATED_PROVIDER_MODELS: { "releaseDate": "2025-09-29", "family": "deepseek" }, - "qwen/qwen3-max": { - "id": "qwen/qwen3-max", - "name": "Qwen3 Max", - "contextWindow": 262144, - "maxInputTokens": 262144, - "maxTokens": 65536, - "capabilities": [ - "tools", - "structured_output", - "temperature", - "prompt-cache" - ], - "pricing": { - "input": 0.78, - "output": 3.9, - "cacheRead": 0.156, - "cacheWrite": 0.975 - }, - "releaseDate": "2025-09-23", - "family": "qwen" - }, "qwen/qwen3-vl-235b-a22b-instruct": { "id": "qwen/qwen3-vl-235b-a22b-instruct", "name": "Qwen3 VL 235B A22B Instruct", @@ -141761,29 +149362,6 @@ export const GENERATED_PROVIDER_MODELS: { "releaseDate": "2025-09-23", "family": "qwen" }, - "qwen/qwen3-vl-235b-a22b-thinking": { - "id": "qwen/qwen3-vl-235b-a22b-thinking", - "name": "Qwen3 VL 235B A22B Thinking", - "contextWindow": 131072, - "maxInputTokens": 131072, - "maxTokens": 32768, - "capabilities": [ - "images", - "tools", - "reasoning", - "structured_output", - "temperature" - ], - "reasoningOptions": [], - "pricing": { - "input": 0.4, - "output": 4, - "cacheRead": 0, - "cacheWrite": 0 - }, - "releaseDate": "2025-09-23", - "family": "qwen" - }, "deepseek/deepseek-v3.1-terminus": { "id": "deepseek/deepseek-v3.1-terminus", "name": "DeepSeek V3.1 Terminus", @@ -141810,29 +149388,9 @@ export const GENERATED_PROVIDER_MODELS: { "releaseDate": "2025-09-22", "family": "deepseek" }, - "qwen/qwen-plus-2025-07-28": { - "id": "qwen/qwen-plus-2025-07-28", - "name": "Qwen Plus 0728", - "contextWindow": 1000000, - "maxInputTokens": 1000000, - "maxTokens": 32768, - "capabilities": [ - "tools", - "structured_output", - "temperature" - ], - "pricing": { - "input": 0.26, - "output": 0.78, - "cacheRead": 0, - "cacheWrite": 0 - }, - "releaseDate": "2025-09-08", - "family": "qwen" - }, "moonshotai/kimi-k2-0905": { "id": "moonshotai/kimi-k2-0905", - "name": "Kimi K2 0905", + "name": "Kimi K2 Instruct 0905", "contextWindow": 262144, "maxInputTokens": 262144, "maxTokens": 98304, @@ -141847,7 +149405,7 @@ export const GENERATED_PROVIDER_MODELS: { "cacheRead": 0, "cacheWrite": 0 }, - "releaseDate": "2025-09-04", + "releaseDate": "2025-09-05", "family": "kimi-k2" }, "qwen/qwen3-next-80b-a3b-instruct": { @@ -141855,16 +149413,17 @@ export const GENERATED_PROVIDER_MODELS: { "name": "Qwen3-Next 80B-A3B Instruct", "contextWindow": 262144, "maxInputTokens": 262144, - "maxTokens": 32768, + "maxTokens": 235929, "capabilities": [ "tools", "structured_output", - "temperature" + "temperature", + "prompt-cache" ], "pricing": { - "input": 0.15, - "output": 1.5, - "cacheRead": 0, + "input": 0.1, + "output": 1.1, + "cacheRead": 0.07, "cacheWrite": 0 }, "releaseDate": "2025-09", @@ -141875,7 +149434,7 @@ export const GENERATED_PROVIDER_MODELS: { "name": "Qwen3-Next 80B-A3B (Thinking)", "contextWindow": 262144, "maxInputTokens": 262144, - "maxTokens": 32768, + "maxTokens": 235929, "capabilities": [ "tools", "reasoning", @@ -141892,27 +149451,6 @@ export const GENERATED_PROVIDER_MODELS: { "releaseDate": "2025-09", "family": "qwen" }, - "qwen/qwen3-30b-a3b-thinking-2507": { - "id": "qwen/qwen3-30b-a3b-thinking-2507", - "name": "Qwen3 30B A3B Thinking 2507", - "contextWindow": 81920, - "maxInputTokens": 81920, - "maxTokens": 32768, - "capabilities": [ - "tools", - "reasoning", - "temperature" - ], - "reasoningOptions": [], - "pricing": { - "input": 0.2, - "output": 2.4, - "cacheRead": 0, - "cacheWrite": 0 - }, - "releaseDate": "2025-08-28", - "family": "qwen" - }, "google/gemini-2.5-flash-image": { "id": "google/gemini-2.5-flash-image", "name": "Nano Banana", @@ -142156,12 +149694,13 @@ export const GENERATED_PROVIDER_MODELS: { "name": "GPT OSS 120B", "contextWindow": 131072, "maxInputTokens": 131072, - "maxTokens": 117964, + "maxTokens": 65536, "capabilities": [ "tools", "reasoning", "structured_output", - "temperature" + "temperature", + "prompt-cache" ], "reasoningOptions": [ { @@ -142174,9 +149713,9 @@ export const GENERATED_PROVIDER_MODELS: { } ], "pricing": { - "input": 0.037, - "output": 0.17, - "cacheRead": 0, + "input": 0.15, + "output": 0.6, + "cacheRead": 0.075, "cacheWrite": 0 }, "releaseDate": "2025-08-05", @@ -142241,15 +149780,15 @@ export const GENERATED_PROVIDER_MODELS: { "name": "Qwen3 30B A3B Instruct 2507", "contextWindow": 262144, "maxInputTokens": 262144, - "maxTokens": 32000, + "maxTokens": 235929, "capabilities": [ "tools", "structured_output", "temperature" ], "pricing": { - "input": 0.04815, - "output": 0.19305, + "input": 0.1, + "output": 0.3, "cacheRead": 0, "cacheWrite": 0 }, @@ -142331,9 +149870,9 @@ export const GENERATED_PROVIDER_MODELS: { "qwen/qwen3-235b-a22b-thinking-2507": { "id": "qwen/qwen3-235b-a22b-thinking-2507", "name": "Qwen3 235B A22B Thinking 2507", - "contextWindow": 131072, - "maxInputTokens": 131072, - "maxTokens": 117964, + "contextWindow": 128000, + "maxInputTokens": 128000, + "maxTokens": 16384, "capabilities": [ "tools", "reasoning", @@ -142341,8 +149880,8 @@ export const GENERATED_PROVIDER_MODELS: { ], "reasoningOptions": [], "pricing": { - "input": 0.23, - "output": 2.3, + "input": 0.45, + "output": 3.5, "cacheRead": 0, "cacheWrite": 0 }, @@ -142370,27 +149909,6 @@ export const GENERATED_PROVIDER_MODELS: { "releaseDate": "2025-07-23", "family": "qwen" }, - "qwen/qwen3-coder-plus": { - "id": "qwen/qwen3-coder-plus", - "name": "Qwen3 Coder Plus", - "contextWindow": 1000000, - "maxInputTokens": 1000000, - "maxTokens": 65536, - "capabilities": [ - "tools", - "structured_output", - "temperature", - "prompt-cache" - ], - "pricing": { - "input": 0.65, - "output": 3.25, - "cacheRead": 0.13, - "cacheWrite": 0.8125 - }, - "releaseDate": "2025-07-23", - "family": "qwen" - }, "qwen/qwen3-235b-a22b-2507": { "id": "qwen/qwen3-235b-a22b-2507", "name": "Qwen3 235B A22B Instruct 2507", @@ -142707,7 +150225,7 @@ export const GENERATED_PROVIDER_MODELS: { }, "deepseek/deepseek-r1-0528": { "id": "deepseek/deepseek-r1-0528", - "name": "R1 0528", + "name": "DeepSeek R1 0528", "contextWindow": 163840, "maxInputTokens": 163840, "maxTokens": 32768, @@ -142726,7 +150244,7 @@ export const GENERATED_PROVIDER_MODELS: { "cacheWrite": 0 }, "releaseDate": "2025-05-28", - "family": "deepseek" + "family": "deepseek-thinking" }, "anthropic/claude-sonnet-4": { "id": "anthropic/claude-sonnet-4", @@ -142782,8 +150300,8 @@ export const GENERATED_PROVIDER_MODELS: { "qwen/qwen3-14b": { "id": "qwen/qwen3-14b", "name": "Qwen3 14B", - "contextWindow": 131072, - "maxInputTokens": 131072, + "contextWindow": 40960, + "maxInputTokens": 40960, "maxTokens": 16384, "capabilities": [ "tools", @@ -142808,8 +150326,8 @@ export const GENERATED_PROVIDER_MODELS: { "qwen/qwen3-30b-a3b": { "id": "qwen/qwen3-30b-a3b", "name": "Qwen3 30B A3B", - "contextWindow": 131072, - "maxInputTokens": 131072, + "contextWindow": 40960, + "maxInputTokens": 40960, "maxTokens": 16384, "capabilities": [ "tools", @@ -142831,31 +150349,6 @@ export const GENERATED_PROVIDER_MODELS: { "releaseDate": "2025-04-28", "family": "qwen" }, - "qwen/qwen3-8b": { - "id": "qwen/qwen3-8b", - "name": "Qwen3 8B", - "contextWindow": 131072, - "maxInputTokens": 131072, - "maxTokens": 8192, - "capabilities": [ - "tools", - "reasoning", - "temperature" - ], - "reasoningOptions": [ - { - "type": "toggle" - } - ], - "pricing": { - "input": 0.117, - "output": 0.455, - "cacheRead": 0, - "cacheWrite": 0 - }, - "releaseDate": "2025-04-28", - "family": "qwen" - }, "openai/o3": { "id": "openai/o3", "name": "o3", @@ -143039,7 +150532,7 @@ export const GENERATED_PROVIDER_MODELS: { "name": "Llama 4 Scout", "contextWindow": 1310720, "maxInputTokens": 1310720, - "maxTokens": 16384, + "maxTokens": 117964, "capabilities": [ "images", "tools", @@ -143047,39 +150540,14 @@ export const GENERATED_PROVIDER_MODELS: { "temperature" ], "pricing": { - "input": 0.1, - "output": 0.3, + "input": 0.18, + "output": 0.59, "cacheRead": 0, "cacheWrite": 0 }, "releaseDate": "2025-04-05", "family": "llama" }, - "qwen/qwen3-235b-a22b": { - "id": "qwen/qwen3-235b-a22b", - "name": "Qwen3 235B-A22B", - "contextWindow": 131072, - "maxInputTokens": 131072, - "maxTokens": 8192, - "capabilities": [ - "tools", - "reasoning", - "temperature" - ], - "reasoningOptions": [ - { - "type": "toggle" - } - ], - "pricing": { - "input": 0.455, - "output": 1.82, - "cacheRead": 0, - "cacheWrite": 0 - }, - "releaseDate": "2025-04", - "family": "qwen" - }, "qwen/qwen3-32b": { "id": "qwen/qwen3-32b", "name": "Qwen3 32B", @@ -143316,16 +150784,17 @@ export const GENERATED_PROVIDER_MODELS: { "name": "Llama-3.3-70B-Instruct", "contextWindow": 131072, "maxInputTokens": 131072, - "maxTokens": 16384, + "maxTokens": 32768, "capabilities": [ "tools", "structured_output", - "temperature" + "temperature", + "prompt-cache" ], "pricing": { - "input": 0.1, - "output": 0.32, - "cacheRead": 0, + "input": 0.59, + "output": 0.79, + "cacheRead": 0.295, "cacheWrite": 0 }, "releaseDate": "2024-12-06", @@ -143543,26 +151012,6 @@ export const GENERATED_PROVIDER_MODELS: { "releaseDate": "2024-08-30", "family": "command-r" }, - "sao10k/l3.1-euryale-70b": { - "id": "sao10k/l3.1-euryale-70b", - "name": "Llama 3.1 Euryale 70B v2.2", - "contextWindow": 131072, - "maxInputTokens": 131072, - "maxTokens": 16384, - "capabilities": [ - "tools", - "structured_output", - "temperature" - ], - "pricing": { - "input": 0.85, - "output": 0.85, - "cacheRead": 0, - "cacheWrite": 0 - }, - "releaseDate": "2024-08-28", - "family": "llama" - }, "openai/gpt-4o-2024-08-06": { "id": "openai/gpt-4o-2024-08-06", "name": "GPT-4o (2024-08-06)", @@ -143685,7 +151134,7 @@ export const GENERATED_PROVIDER_MODELS: { "temperature" ], "pricing": { - "input": 0.019, + "input": 0.029, "output": 0.03, "cacheRead": 0, "cacheWrite": 0 @@ -149312,25 +156761,6 @@ export const GENERATED_PROVIDER_MODELS: { }, "releaseDate": "2026-04-22" }, - "qwen3-coder-30b-a3b-instruct": { - "id": "qwen3-coder-30b-a3b-instruct", - "name": "Qwen3-Coder-30B-A3B-Instruct", - "contextWindow": 262144, - "maxInputTokens": 262144, - "maxTokens": 262144, - "capabilities": [ - "tools", - "structured_output", - "temperature" - ], - "pricing": { - "input": 0.07, - "output": 0.26, - "cacheRead": 0, - "cacheWrite": 0 - }, - "releaseDate": "2025-10-28" - }, "gpt-oss-120b": { "id": "gpt-oss-120b", "name": "gpt-oss-120b", @@ -156295,6 +163725,1857 @@ export const GENERATED_PROVIDER_MODELS: { "family": "laguna" } }, + "prime": { + "openai/gpt-6.1-sol": { + "id": "openai/gpt-6.1-sol", + "name": "GPT-6.1 Sol", + "contextWindow": 1050000, + "maxInputTokens": 1050000, + "maxTokens": 128000, + "capabilities": [ + "images", + "tools", + "reasoning", + "structured_output" + ], + "reasoningOptions": [ + { + "type": "effort", + "values": [ + "max", + "xhigh", + "high", + "medium", + "low" + ] + } + ], + "pricing": { + "input": 2, + "output": 10, + "cacheRead": 0, + "cacheWrite": 0 + }, + "releaseDate": "2026-09-29", + "family": "gpt-sol" + }, + "anthropic/claude-opus-5.5": { + "id": "anthropic/claude-opus-5.5", + "name": "Claude Opus 5.5", + "contextWindow": 1000000, + "maxInputTokens": 1000000, + "maxTokens": 128000, + "capabilities": [ + "images", + "tools", + "reasoning" + ], + "reasoningOptions": [ + { + "type": "effort", + "values": [ + "max", + "xhigh", + "high", + "medium", + "low" + ] + } + ], + "pricing": { + "input": 4, + "output": 20, + "cacheRead": 0, + "cacheWrite": 0 + }, + "releaseDate": "2026-09-22", + "family": "claude-opus" + }, + "openai/gpt-6-luna": { + "id": "openai/gpt-6-luna", + "name": "GPT-6 Luna", + "contextWindow": 1050000, + "maxInputTokens": 1050000, + "maxTokens": 128000, + "capabilities": [ + "images", + "tools", + "reasoning", + "structured_output" + ], + "reasoningOptions": [ + { + "type": "effort", + "values": [ + "max", + "xhigh", + "high", + "medium", + "low", + "none" + ] + } + ], + "pricing": { + "input": 0.1, + "output": 0.5, + "cacheRead": 0, + "cacheWrite": 0 + }, + "releaseDate": "2026-09-22", + "family": "gpt-luna" + }, + "openai/gpt-6-sol": { + "id": "openai/gpt-6-sol", + "name": "GPT-6 Sol", + "contextWindow": 1050000, + "maxInputTokens": 1050000, + "maxTokens": 128000, + "capabilities": [ + "images", + "tools", + "reasoning", + "structured_output" + ], + "reasoningOptions": [ + { + "type": "effort", + "values": [ + "max", + "xhigh", + "high", + "medium", + "low", + "none" + ] + } + ], + "pricing": { + "input": 2, + "output": 10, + "cacheRead": 0, + "cacheWrite": 0 + }, + "releaseDate": "2026-09-22", + "family": "gpt-sol" + }, + "x-ai/grok-4.7": { + "id": "x-ai/grok-4.7", + "name": "Grok 4.7", + "contextWindow": 500000, + "maxInputTokens": 500000, + "maxTokens": 450000, + "capabilities": [ + "images", + "tools", + "reasoning", + "structured_output", + "temperature" + ], + "reasoningOptions": [ + { + "type": "effort", + "values": [ + "xhigh", + "high", + "medium", + "low" + ] + } + ], + "pricing": { + "input": 1.6, + "output": 4.8, + "cacheRead": 0, + "cacheWrite": 0 + }, + "releaseDate": "2026-09-21", + "family": "grok" + }, + "deepseek/deepseek-v4.1-flash": { + "id": "deepseek/deepseek-v4.1-flash", + "name": "DeepSeek V4.1 Flash", + "contextWindow": 1048576, + "maxInputTokens": 1048576, + "maxTokens": 131072, + "capabilities": [ + "tools", + "reasoning", + "structured_output", + "temperature", + "prompt-cache" + ], + "reasoningOptions": [ + { + "type": "effort", + "values": [ + "none", + "low", + "high", + "max" + ] + } + ], + "pricing": { + "input": 0.3, + "output": 1.2, + "cacheRead": 0.03, + "cacheWrite": 0.3 + }, + "releaseDate": "2026-09-10", + "family": "deepseek-flash" + }, + "openai/gpt-6-astra": { + "id": "openai/gpt-6-astra", + "name": "GPT-6 Astra", + "contextWindow": 1050000, + "maxInputTokens": 1050000, + "maxTokens": 128000, + "capabilities": [ + "images", + "tools", + "reasoning", + "structured_output" + ], + "reasoningOptions": [ + { + "type": "effort", + "values": [ + "max", + "xhigh", + "high", + "medium", + "low" + ] + } + ], + "pricing": { + "input": 10, + "output": 50, + "cacheRead": 0, + "cacheWrite": 0 + }, + "releaseDate": "2026-09-04", + "family": "gpt-astra" + }, + "google/gemini-3.8-flash": { + "id": "google/gemini-3.8-flash", + "name": "Gemini 3.8 Flash", + "contextWindow": 1048576, + "maxInputTokens": 1048576, + "maxTokens": 65536, + "capabilities": [ + "images", + "video", + "tools", + "reasoning", + "structured_output", + "temperature" + ], + "reasoningOptions": [ + { + "type": "effort", + "values": [ + "high", + "medium", + "low" + ] + } + ], + "pricing": { + "input": 0.75, + "output": 3.75, + "cacheRead": 0, + "cacheWrite": 0 + }, + "releaseDate": "2026-09-02", + "family": "gemini-flash", + "modalities": { + "input": [ + "text", + "image", + "video", + "audio" + ], + "output": [ + "text" + ] + } + }, + "anthropic/claude-fable-5.1": { + "id": "anthropic/claude-fable-5.1", + "name": "Claude Fable 5.1", + "contextWindow": 1000000, + "maxInputTokens": 1000000, + "maxTokens": 128000, + "capabilities": [ + "images", + "tools", + "reasoning" + ], + "reasoningOptions": [ + { + "type": "effort", + "values": [ + "max", + "xhigh", + "high", + "medium", + "low" + ] + } + ], + "pricing": { + "input": 10, + "output": 50, + "cacheRead": 0, + "cacheWrite": 0 + }, + "releaseDate": "2026-09-01", + "family": "claude-fable" + }, + "z-ai/glm-5.3-flash": { + "id": "z-ai/glm-5.3-flash", + "name": "GLM-5.3-Flash", + "contextWindow": 1048576, + "maxInputTokens": 1048576, + "maxTokens": 943718, + "capabilities": [ + "images", + "video", + "tools", + "reasoning", + "structured_output", + "temperature" + ], + "reasoningOptions": [ + { + "type": "effort", + "values": [ + "max", + "high", + "low" + ] + } + ], + "pricing": { + "input": 0.15, + "output": 0.5, + "cacheRead": 0, + "cacheWrite": 0 + }, + "releaseDate": "2026-08-26", + "family": "glm-flash" + }, + "z-ai/glm-5.3": { + "id": "z-ai/glm-5.3", + "name": "GLM-5.3", + "contextWindow": 1048576, + "maxInputTokens": 1048576, + "maxTokens": 131072, + "capabilities": [ + "tools", + "reasoning", + "temperature", + "prompt-cache" + ], + "reasoningOptions": [ + { + "type": "effort", + "values": [ + "low", + "high", + "max" + ] + } + ], + "pricing": { + "input": 1.4, + "output": 4.4, + "cacheRead": 0.26, + "cacheWrite": 1.4 + }, + "releaseDate": "2026-08-14", + "family": "glm" + }, + "google/gemini-3.7-flash": { + "id": "google/gemini-3.7-flash", + "name": "Gemini 3.7 Flash", + "contextWindow": 1048576, + "maxInputTokens": 1048576, + "maxTokens": 65536, + "capabilities": [ + "images", + "video", + "tools", + "reasoning", + "structured_output", + "temperature" + ], + "reasoningOptions": [ + { + "type": "effort", + "values": [ + "high", + "medium", + "low" + ] + } + ], + "pricing": { + "input": 1.35, + "output": 6.75, + "cacheRead": 0, + "cacheWrite": 0 + }, + "releaseDate": "2026-08-13", + "family": "gemini-flash", + "modalities": { + "input": [ + "text", + "image", + "video", + "audio" + ], + "output": [ + "text" + ] + } + }, + "x-ai/grok-4.6": { + "id": "x-ai/grok-4.6", + "name": "Grok 4.6", + "contextWindow": 500000, + "maxInputTokens": 500000, + "maxTokens": 450000, + "capabilities": [ + "images", + "tools", + "reasoning", + "structured_output", + "temperature" + ], + "reasoningOptions": [ + { + "type": "effort", + "values": [ + "xhigh", + "high", + "medium", + "low" + ] + } + ], + "pricing": { + "input": 2, + "output": 6, + "cacheRead": 0, + "cacheWrite": 0 + }, + "releaseDate": "2026-08-12", + "family": "grok" + }, + "deepseek/deepseek-v4-flash-0731": { + "id": "deepseek/deepseek-v4-flash-0731", + "name": "DeepSeek V4 Flash 0731", + "contextWindow": 1048576, + "maxInputTokens": 1048576, + "maxTokens": 943718, + "capabilities": [ + "tools", + "reasoning", + "structured_output", + "temperature" + ], + "reasoningOptions": [ + { + "type": "effort", + "values": [ + "max", + "high", + "low" + ] + } + ], + "pricing": { + "input": 0.44, + "output": 1.32, + "cacheRead": 0, + "cacheWrite": 0 + }, + "releaseDate": "2026-07-31", + "family": "deepseek-flash" + }, + "anthropic/claude-opus-5": { + "id": "anthropic/claude-opus-5", + "name": "Claude Opus 5", + "contextWindow": 1000000, + "maxInputTokens": 1000000, + "maxTokens": 128000, + "capabilities": [ + "images", + "tools", + "reasoning" + ], + "reasoningOptions": [ + { + "type": "effort", + "values": [ + "max", + "xhigh", + "high", + "medium", + "low" + ] + } + ], + "pricing": { + "input": 5, + "output": 25, + "cacheRead": 0, + "cacheWrite": 0 + }, + "releaseDate": "2026-07-24", + "family": "claude-opus" + }, + "google/gemini-3.6-flash": { + "id": "google/gemini-3.6-flash", + "name": "Gemini 3.6 Flash", + "contextWindow": 1048576, + "maxInputTokens": 1048576, + "maxTokens": 65536, + "capabilities": [ + "images", + "video", + "tools", + "reasoning", + "structured_output", + "temperature" + ], + "reasoningOptions": [ + { + "type": "effort", + "values": [ + "high", + "medium", + "low", + "minimal" + ] + } + ], + "pricing": { + "input": 1.5, + "output": 7.5, + "cacheRead": 0, + "cacheWrite": 0 + }, + "releaseDate": "2026-07-21", + "family": "gemini-flash", + "modalities": { + "input": [ + "text", + "image", + "video", + "audio" + ], + "output": [ + "text" + ] + } + }, + "moonshotai/kimi-k3": { + "id": "moonshotai/kimi-k3", + "name": "Kimi K3", + "contextWindow": 1048576, + "maxInputTokens": 1048576, + "maxTokens": 943718, + "capabilities": [ + "images", + "video", + "tools", + "reasoning", + "structured_output", + "temperature" + ], + "reasoningOptions": [ + { + "type": "effort", + "values": [ + "max", + "high", + "low" + ] + } + ], + "pricing": { + "input": 3.45, + "output": 17.25, + "cacheRead": 0, + "cacheWrite": 0 + }, + "releaseDate": "2026-07-16", + "family": "kimi-k3" + }, + "openai/gpt-5.6-luna": { + "id": "openai/gpt-5.6-luna", + "name": "GPT-5.6 Luna", + "contextWindow": 1050000, + "maxInputTokens": 1050000, + "maxTokens": 128000, + "capabilities": [ + "images", + "tools", + "reasoning", + "structured_output" + ], + "reasoningOptions": [ + { + "type": "effort", + "values": [ + "max", + "xhigh", + "high", + "medium", + "low", + "none" + ] + } + ], + "pricing": { + "input": 0.2, + "output": 1.2, + "cacheRead": 0, + "cacheWrite": 0 + }, + "releaseDate": "2026-07-09", + "family": "gpt-luna" + }, + "openai/gpt-5.6-sol": { + "id": "openai/gpt-5.6-sol", + "name": "GPT-5.6 Sol", + "contextWindow": 1050000, + "maxInputTokens": 1050000, + "maxTokens": 128000, + "capabilities": [ + "images", + "tools", + "reasoning", + "structured_output" + ], + "reasoningOptions": [ + { + "type": "effort", + "values": [ + "max", + "xhigh", + "high", + "medium", + "low", + "none" + ] + } + ], + "pricing": { + "input": 5, + "output": 30, + "cacheRead": 0, + "cacheWrite": 0 + }, + "releaseDate": "2026-07-09", + "family": "gpt-sol" + }, + "openai/gpt-5.6-terra": { + "id": "openai/gpt-5.6-terra", + "name": "GPT-5.6 Terra", + "contextWindow": 1050000, + "maxInputTokens": 1050000, + "maxTokens": 128000, + "capabilities": [ + "images", + "tools", + "reasoning", + "structured_output" + ], + "reasoningOptions": [ + { + "type": "effort", + "values": [ + "max", + "xhigh", + "high", + "medium", + "low", + "none" + ] + } + ], + "pricing": { + "input": 2.5, + "output": 15, + "cacheRead": 0, + "cacheWrite": 0 + }, + "releaseDate": "2026-07-09", + "family": "gpt-terra" + }, + "anthropic/claude-sonnet-5": { + "id": "anthropic/claude-sonnet-5", + "name": "Claude Sonnet 5", + "contextWindow": 1000000, + "maxInputTokens": 1000000, + "maxTokens": 128000, + "capabilities": [ + "images", + "tools", + "reasoning" + ], + "reasoningOptions": [ + { + "type": "effort", + "values": [ + "max", + "xhigh", + "high", + "medium", + "low" + ] + } + ], + "pricing": { + "input": 2, + "output": 10, + "cacheRead": 0, + "cacheWrite": 0 + }, + "releaseDate": "2026-06-30", + "family": "claude-sonnet" + }, + "z-ai/glm-5.2": { + "id": "z-ai/glm-5.2", + "name": "GLM-5.2", + "contextWindow": 1048576, + "maxInputTokens": 1048576, + "maxTokens": 943718, + "capabilities": [ + "tools", + "reasoning", + "structured_output", + "temperature" + ], + "reasoningOptions": [ + { + "type": "effort", + "values": [ + "xhigh", + "high" + ] + } + ], + "pricing": { + "input": 1.54, + "output": 4.84, + "cacheRead": 0, + "cacheWrite": 0 + }, + "releaseDate": "2026-06-13", + "family": "glm" + }, + "anthropic/claude-fable-5": { + "id": "anthropic/claude-fable-5", + "name": "Claude Fable 5", + "contextWindow": 1000000, + "maxInputTokens": 1000000, + "maxTokens": 128000, + "capabilities": [ + "images", + "tools", + "reasoning" + ], + "reasoningOptions": [ + { + "type": "effort", + "values": [ + "max", + "xhigh", + "high", + "medium", + "low" + ] + } + ], + "pricing": { + "input": 10, + "output": 50, + "cacheRead": 0, + "cacheWrite": 0 + }, + "releaseDate": "2026-06-09", + "family": "claude-fable" + }, + "anthropic/claude-opus-4.8": { + "id": "anthropic/claude-opus-4.8", + "name": "Claude Opus 4.8", + "contextWindow": 1000000, + "maxInputTokens": 1000000, + "maxTokens": 128000, + "capabilities": [ + "images", + "tools", + "reasoning" + ], + "reasoningOptions": [ + { + "type": "effort", + "values": [ + "max", + "xhigh", + "high", + "medium", + "low" + ] + } + ], + "pricing": { + "input": 5, + "output": 25, + "cacheRead": 0, + "cacheWrite": 0 + }, + "releaseDate": "2026-05-28", + "family": "claude-opus" + }, + "google/gemini-3.5-flash": { + "id": "google/gemini-3.5-flash", + "name": "Gemini 3.5 Flash", + "contextWindow": 1048576, + "maxInputTokens": 1048576, + "maxTokens": 65536, + "capabilities": [ + "images", + "video", + "tools", + "reasoning", + "structured_output", + "temperature" + ], + "reasoningOptions": [ + { + "type": "effort", + "values": [ + "high", + "medium", + "low", + "minimal" + ] + } + ], + "pricing": { + "input": 1.5, + "output": 9, + "cacheRead": 0, + "cacheWrite": 0 + }, + "releaseDate": "2026-05-19", + "family": "gemini-flash", + "modalities": { + "input": [ + "text", + "image", + "video", + "audio" + ], + "output": [ + "text" + ] + } + }, + "deepseek/deepseek-v4-flash": { + "id": "deepseek/deepseek-v4-flash", + "name": "DeepSeek V4 Flash", + "contextWindow": 1048576, + "maxInputTokens": 1048576, + "maxTokens": 943718, + "capabilities": [ + "tools", + "reasoning", + "structured_output", + "temperature" + ], + "reasoningOptions": [ + { + "type": "effort", + "values": [ + "xhigh", + "high" + ] + } + ], + "pricing": { + "input": 0.44, + "output": 1.32, + "cacheRead": 0, + "cacheWrite": 0 + }, + "releaseDate": "2026-04-24", + "family": "deepseek-flash" + }, + "deepseek/deepseek-v4-pro": { + "id": "deepseek/deepseek-v4-pro", + "name": "DeepSeek V4 Pro", + "contextWindow": 1048576, + "maxInputTokens": 1048576, + "maxTokens": 384000, + "capabilities": [ + "tools", + "reasoning", + "structured_output", + "temperature" + ], + "reasoningOptions": [ + { + "type": "effort", + "values": [ + "xhigh", + "high" + ] + } + ], + "pricing": { + "input": 1.91, + "output": 3.83, + "cacheRead": 0, + "cacheWrite": 0 + }, + "releaseDate": "2026-04-24", + "family": "deepseek-thinking" + }, + "openai/gpt-5.5": { + "id": "openai/gpt-5.5", + "name": "GPT-5.5", + "contextWindow": 1050000, + "maxInputTokens": 1050000, + "maxTokens": 128000, + "capabilities": [ + "images", + "tools", + "reasoning", + "structured_output" + ], + "reasoningOptions": [ + { + "type": "effort", + "values": [ + "xhigh", + "high", + "medium", + "low", + "none" + ] + } + ], + "pricing": { + "input": 5, + "output": 30, + "cacheRead": 0, + "cacheWrite": 0 + }, + "releaseDate": "2026-04-23", + "family": "gpt" + }, + "anthropic/claude-opus-4.7": { + "id": "anthropic/claude-opus-4.7", + "name": "Claude Opus 4.7", + "contextWindow": 1000000, + "maxInputTokens": 1000000, + "maxTokens": 128000, + "capabilities": [ + "images", + "tools", + "reasoning" + ], + "reasoningOptions": [ + { + "type": "effort", + "values": [ + "max", + "xhigh", + "high", + "medium", + "low" + ] + } + ], + "pricing": { + "input": 5, + "output": 25, + "cacheRead": 0, + "cacheWrite": 0 + }, + "releaseDate": "2026-04-16", + "family": "claude-opus" + }, + "openai/gpt-5.4-mini": { + "id": "openai/gpt-5.4-mini", + "name": "GPT-5.4 mini", + "contextWindow": 400000, + "maxInputTokens": 400000, + "maxTokens": 128000, + "capabilities": [ + "images", + "tools", + "reasoning", + "structured_output" + ], + "reasoningOptions": [ + { + "type": "effort", + "values": [ + "xhigh", + "high", + "medium", + "low", + "none" + ] + } + ], + "pricing": { + "input": 0.75, + "output": 4.5, + "cacheRead": 0, + "cacheWrite": 0 + }, + "releaseDate": "2026-03-17", + "family": "gpt-mini" + }, + "openai/gpt-5.4-nano": { + "id": "openai/gpt-5.4-nano", + "name": "GPT-5.4 nano", + "contextWindow": 400000, + "maxInputTokens": 400000, + "maxTokens": 128000, + "capabilities": [ + "images", + "tools", + "reasoning", + "structured_output" + ], + "reasoningOptions": [ + { + "type": "effort", + "values": [ + "xhigh", + "high", + "medium", + "low", + "none" + ] + } + ], + "pricing": { + "input": 0.2, + "output": 1.25, + "cacheRead": 0, + "cacheWrite": 0 + }, + "releaseDate": "2026-03-17", + "family": "gpt-nano" + }, + "mistralai/mistral-small-2603": { + "id": "mistralai/mistral-small-2603", + "name": "Mistral Small 4", + "contextWindow": 262144, + "maxInputTokens": 262144, + "maxTokens": 209715, + "capabilities": [ + "images", + "tools", + "reasoning", + "temperature" + ], + "reasoningOptions": [ + { + "type": "effort", + "values": [ + "high", + "none" + ] + } + ], + "pricing": { + "input": 0.165, + "output": 0.66, + "cacheRead": 0, + "cacheWrite": 0 + }, + "releaseDate": "2026-03-16", + "family": "mistral-small" + }, + "openai/gpt-5.4": { + "id": "openai/gpt-5.4", + "name": "GPT-5.4", + "contextWindow": 1050000, + "maxInputTokens": 1050000, + "maxTokens": 128000, + "capabilities": [ + "images", + "tools", + "reasoning", + "structured_output" + ], + "reasoningOptions": [ + { + "type": "effort", + "values": [ + "xhigh", + "high", + "medium", + "low", + "none" + ] + } + ], + "pricing": { + "input": 2.5, + "output": 15, + "cacheRead": 0, + "cacheWrite": 0 + }, + "releaseDate": "2026-03-05", + "family": "gpt" + }, + "openai/gpt-5.4-pro": { + "id": "openai/gpt-5.4-pro", + "name": "GPT-5.4 Pro", + "contextWindow": 1050000, + "maxInputTokens": 1050000, + "maxTokens": 128000, + "capabilities": [ + "images", + "tools", + "reasoning" + ], + "reasoningOptions": [ + { + "type": "effort", + "values": [ + "xhigh", + "high", + "medium" + ] + } + ], + "pricing": { + "input": 30, + "output": 180, + "cacheRead": 0, + "cacheWrite": 0 + }, + "releaseDate": "2026-03-05", + "family": "gpt-pro" + }, + "google/gemini-3.1-pro-preview": { + "id": "google/gemini-3.1-pro-preview", + "name": "Gemini 3.1 Pro Preview", + "contextWindow": 1048576, + "maxInputTokens": 1048576, + "maxTokens": 65536, + "capabilities": [ + "images", + "video", + "tools", + "reasoning", + "structured_output", + "temperature" + ], + "reasoningOptions": [ + { + "type": "effort", + "values": [ + "high", + "medium", + "low" + ] + } + ], + "pricing": { + "input": 2, + "output": 12, + "cacheRead": 0, + "cacheWrite": 0 + }, + "releaseDate": "2026-02-19", + "family": "gemini-pro", + "modalities": { + "input": [ + "audio", + "image", + "text", + "video" + ], + "output": [ + "text" + ] + } + }, + "anthropic/claude-sonnet-4.6": { + "id": "anthropic/claude-sonnet-4.6", + "name": "Claude Sonnet 4.6", + "contextWindow": 1000000, + "maxInputTokens": 1000000, + "maxTokens": 128000, + "capabilities": [ + "images", + "tools", + "reasoning", + "temperature" + ], + "reasoningOptions": [ + { + "type": "effort", + "values": [ + "max", + "high", + "medium", + "low" + ] + } + ], + "pricing": { + "input": 3, + "output": 15, + "cacheRead": 0, + "cacheWrite": 0 + }, + "releaseDate": "2026-02-17", + "family": "claude-sonnet" + }, + "anthropic/claude-opus-4.6": { + "id": "anthropic/claude-opus-4.6", + "name": "Claude Opus 4.6", + "contextWindow": 1000000, + "maxInputTokens": 1000000, + "maxTokens": 128000, + "capabilities": [ + "images", + "tools", + "reasoning", + "temperature" + ], + "reasoningOptions": [ + { + "type": "effort", + "values": [ + "max", + "high", + "medium", + "low" + ] + } + ], + "pricing": { + "input": 5, + "output": 25, + "cacheRead": 0, + "cacheWrite": 0 + }, + "releaseDate": "2026-02-05", + "family": "claude-opus" + }, + "openai/gpt-5.3-codex": { + "id": "openai/gpt-5.3-codex", + "name": "GPT-5.3 Codex", + "contextWindow": 400000, + "maxInputTokens": 400000, + "maxTokens": 128000, + "capabilities": [ + "images", + "tools", + "reasoning", + "structured_output" + ], + "reasoningOptions": [ + { + "type": "effort", + "values": [ + "xhigh", + "high", + "medium", + "low", + "none" + ] + } + ], + "pricing": { + "input": 1.75, + "output": 14, + "cacheRead": 0, + "cacheWrite": 0 + }, + "releaseDate": "2026-02-05", + "family": "gpt-codex" + }, + "qwen/qwen3-coder-next": { + "id": "qwen/qwen3-coder-next", + "name": "Qwen3 Coder Next", + "contextWindow": 262144, + "maxInputTokens": 262144, + "maxTokens": 235929, + "capabilities": [ + "tools", + "structured_output", + "temperature" + ], + "pricing": { + "input": 0.3, + "output": 1.5, + "cacheRead": 0, + "cacheWrite": 0 + }, + "releaseDate": "2026-02-03", + "family": "qwen" + }, + "google/gemini-3-flash-preview": { + "id": "google/gemini-3-flash-preview", + "name": "Gemini 3 Flash Preview", + "contextWindow": 1048576, + "maxInputTokens": 1048576, + "maxTokens": 65536, + "capabilities": [ + "images", + "video", + "tools", + "reasoning", + "structured_output", + "temperature" + ], + "reasoningOptions": [ + { + "type": "effort", + "values": [ + "high", + "medium", + "low", + "minimal" + ] + } + ], + "pricing": { + "input": 0.5, + "output": 3, + "cacheRead": 0, + "cacheWrite": 0 + }, + "releaseDate": "2025-12-17", + "family": "gemini-flash", + "modalities": { + "input": [ + "text", + "image", + "audio", + "video" + ], + "output": [ + "text" + ] + } + }, + "openai/gpt-5.2": { + "id": "openai/gpt-5.2", + "name": "GPT-5.2", + "contextWindow": 400000, + "maxInputTokens": 400000, + "maxTokens": 128000, + "capabilities": [ + "images", + "tools", + "reasoning", + "structured_output" + ], + "reasoningOptions": [ + { + "type": "effort", + "values": [ + "xhigh", + "high", + "medium", + "low", + "none" + ] + } + ], + "pricing": { + "input": 1.75, + "output": 14, + "cacheRead": 0, + "cacheWrite": 0 + }, + "releaseDate": "2025-12-11", + "family": "gpt" + }, + "openai/gpt-5.2-pro": { + "id": "openai/gpt-5.2-pro", + "name": "GPT-5.2 Pro", + "contextWindow": 400000, + "maxInputTokens": 400000, + "maxTokens": 128000, + "capabilities": [ + "images", + "tools", + "reasoning" + ], + "reasoningOptions": [ + { + "type": "effort", + "values": [ + "xhigh", + "high", + "medium" + ] + } + ], + "pricing": { + "input": 21, + "output": 168, + "cacheRead": 0, + "cacheWrite": 0 + }, + "releaseDate": "2025-12-11", + "family": "gpt-pro" + }, + "mistralai/mistral-large-2512": { + "id": "mistralai/mistral-large-2512", + "name": "Mistral Large 3", + "contextWindow": 262144, + "maxInputTokens": 262144, + "maxTokens": 209715, + "capabilities": [ + "images", + "tools", + "temperature" + ], + "pricing": { + "input": 0.5, + "output": 1.5, + "cacheRead": 0, + "cacheWrite": 0 + }, + "releaseDate": "2025-12-02", + "family": "mistral-large" + }, + "openai/gpt-5.1": { + "id": "openai/gpt-5.1", + "name": "GPT-5.1", + "contextWindow": 400000, + "maxInputTokens": 400000, + "maxTokens": 128000, + "capabilities": [ + "images", + "tools", + "reasoning", + "structured_output" + ], + "reasoningOptions": [ + { + "type": "effort", + "values": [ + "high", + "medium", + "low", + "none" + ] + } + ], + "pricing": { + "input": 1.25, + "output": 10, + "cacheRead": 0, + "cacheWrite": 0 + }, + "releaseDate": "2025-11-13", + "family": "gpt" + }, + "qwen/qwen3-vl-30b-a3b-instruct": { + "id": "qwen/qwen3-vl-30b-a3b-instruct", + "name": "Qwen3 VL 30B A3B Instruct", + "contextWindow": 262144, + "maxInputTokens": 262144, + "maxTokens": 16384, + "capabilities": [ + "images", + "tools", + "temperature" + ], + "pricing": { + "input": 0.2, + "output": 0.7, + "cacheRead": 0, + "cacheWrite": 0 + }, + "releaseDate": "2025-10-04", + "family": "qwen" + }, + "qwen/qwen3-vl-235b-a22b-instruct": { + "id": "qwen/qwen3-vl-235b-a22b-instruct", + "name": "Qwen3 VL 235B A22B Instruct", + "contextWindow": 262144, + "maxInputTokens": 262144, + "maxTokens": 32768, + "capabilities": [ + "images", + "tools", + "structured_output", + "temperature" + ], + "pricing": { + "input": 0.3, + "output": 1.5, + "cacheRead": 0, + "cacheWrite": 0 + }, + "releaseDate": "2025-09-23", + "family": "qwen" + }, + "moonshotai/kimi-k2-0905": { + "id": "moonshotai/kimi-k2-0905", + "name": "Kimi K2 Instruct 0905", + "contextWindow": 262144, + "maxInputTokens": 262144, + "maxTokens": 98304, + "capabilities": [ + "tools", + "temperature" + ], + "pricing": { + "input": 0.6, + "output": 2.5, + "cacheRead": 0, + "cacheWrite": 0 + }, + "releaseDate": "2025-09-05", + "family": "kimi-k2" + }, + "openai/gpt-5": { + "id": "openai/gpt-5", + "name": "GPT-5", + "contextWindow": 400000, + "maxInputTokens": 400000, + "maxTokens": 128000, + "capabilities": [ + "images", + "tools", + "reasoning", + "structured_output" + ], + "reasoningOptions": [ + { + "type": "effort", + "values": [ + "high", + "medium", + "low", + "minimal" + ] + } + ], + "pricing": { + "input": 1.25, + "output": 10, + "cacheRead": 0, + "cacheWrite": 0 + }, + "releaseDate": "2025-08-07", + "family": "gpt" + }, + "openai/gpt-5-mini": { + "id": "openai/gpt-5-mini", + "name": "GPT-5 Mini", + "contextWindow": 400000, + "maxInputTokens": 400000, + "maxTokens": 128000, + "capabilities": [ + "images", + "tools", + "reasoning", + "structured_output" + ], + "reasoningOptions": [ + { + "type": "effort", + "values": [ + "high", + "medium", + "low", + "minimal" + ] + } + ], + "pricing": { + "input": 0.25, + "output": 2, + "cacheRead": 0, + "cacheWrite": 0 + }, + "releaseDate": "2025-08-07", + "family": "gpt-mini" + }, + "openai/gpt-5-nano": { + "id": "openai/gpt-5-nano", + "name": "GPT-5 Nano", + "contextWindow": 400000, + "maxInputTokens": 400000, + "maxTokens": 128000, + "capabilities": [ + "images", + "tools", + "reasoning", + "structured_output" + ], + "reasoningOptions": [ + { + "type": "effort", + "values": [ + "high", + "medium", + "low", + "minimal" + ] + } + ], + "pricing": { + "input": 0.05, + "output": 0.4, + "cacheRead": 0, + "cacheWrite": 0 + }, + "releaseDate": "2025-08-07", + "family": "gpt-nano" + }, + "openai/gpt-oss-120b": { + "id": "openai/gpt-oss-120b", + "name": "GPT OSS 120B", + "contextWindow": 131072, + "maxInputTokens": 131072, + "maxTokens": 117964, + "capabilities": [ + "tools", + "reasoning", + "structured_output", + "temperature" + ], + "reasoningOptions": [ + { + "type": "effort", + "values": [ + "high", + "medium", + "low" + ] + } + ], + "pricing": { + "input": 0.35, + "output": 0.75, + "cacheRead": 0, + "cacheWrite": 0 + }, + "releaseDate": "2025-08-05", + "family": "gpt-oss" + }, + "openai/gpt-oss-20b": { + "id": "openai/gpt-oss-20b", + "name": "GPT OSS 20B", + "contextWindow": 131072, + "maxInputTokens": 131072, + "maxTokens": 32768, + "capabilities": [ + "tools", + "reasoning", + "structured_output", + "temperature" + ], + "reasoningOptions": [ + { + "type": "effort", + "values": [ + "high", + "medium", + "low" + ] + } + ], + "pricing": { + "input": 0.07, + "output": 0.3, + "cacheRead": 0, + "cacheWrite": 0 + }, + "releaseDate": "2025-08-05", + "family": "gpt-oss" + }, + "openai/gpt-4.1": { + "id": "openai/gpt-4.1", + "name": "GPT-4.1", + "contextWindow": 1047576, + "maxInputTokens": 1047576, + "maxTokens": 32768, + "capabilities": [ + "images", + "tools", + "structured_output", + "temperature" + ], + "pricing": { + "input": 2, + "output": 8, + "cacheRead": 0, + "cacheWrite": 0 + }, + "releaseDate": "2025-04-14", + "family": "gpt" + }, + "openai/gpt-4.1-mini": { + "id": "openai/gpt-4.1-mini", + "name": "GPT-4.1 mini", + "contextWindow": 1047576, + "maxInputTokens": 1047576, + "maxTokens": 32768, + "capabilities": [ + "images", + "tools", + "structured_output", + "temperature" + ], + "pricing": { + "input": 0.4, + "output": 1.6, + "cacheRead": 0, + "cacheWrite": 0 + }, + "releaseDate": "2025-04-14", + "family": "gpt-mini" + }, + "openai/gpt-4.1-nano": { + "id": "openai/gpt-4.1-nano", + "name": "GPT-4.1 nano", + "contextWindow": 1047576, + "maxInputTokens": 1047576, + "maxTokens": 32768, + "capabilities": [ + "images", + "tools", + "structured_output", + "temperature" + ], + "pricing": { + "input": 0.1, + "output": 0.4, + "cacheRead": 0, + "cacheWrite": 0 + }, + "releaseDate": "2025-04-14", + "family": "gpt-nano" + }, + "google/gemma-3-27b-it": { + "id": "google/gemma-3-27b-it", + "name": "Gemma 3 27B IT", + "contextWindow": 131072, + "maxInputTokens": 131072, + "maxTokens": 117964, + "capabilities": [ + "images", + "tools", + "temperature" + ], + "pricing": { + "input": 0.119, + "output": 0.3, + "cacheRead": 0, + "cacheWrite": 0 + }, + "releaseDate": "2025-03-12", + "family": "gemma" + }, + "meta-llama/llama-3.3-70b-instruct": { + "id": "meta-llama/llama-3.3-70b-instruct", + "name": "Llama-3.3-70B-Instruct", + "contextWindow": 131072, + "maxInputTokens": 131072, + "maxTokens": 16384, + "capabilities": [ + "tools", + "temperature" + ], + "pricing": { + "input": 1.04, + "output": 2.253, + "cacheRead": 0, + "cacheWrite": 0 + }, + "releaseDate": "2024-12-06", + "family": "llama" + }, + "openai/gpt-4o-mini": { + "id": "openai/gpt-4o-mini", + "name": "GPT-4o mini", + "contextWindow": 128000, + "maxInputTokens": 128000, + "maxTokens": 16384, + "capabilities": [ + "images", + "tools", + "structured_output", + "temperature" + ], + "pricing": { + "input": 0.15, + "output": 0.6, + "cacheRead": 0, + "cacheWrite": 0 + }, + "releaseDate": "2024-07-18", + "family": "gpt-mini" + }, + "mistralai/mistral-nemo": { + "id": "mistralai/mistral-nemo", + "name": "Mistral Nemo", + "contextWindow": 131072, + "maxInputTokens": 131072, + "maxTokens": 16384, + "capabilities": [ + "tools", + "temperature" + ], + "pricing": { + "input": 0.165, + "output": 0.17, + "cacheRead": 0, + "cacheWrite": 0 + }, + "releaseDate": "2024-07-01", + "family": "mistral-nemo" + }, + "openai/gpt-4o": { + "id": "openai/gpt-4o", + "name": "GPT-4o", + "contextWindow": 128000, + "maxInputTokens": 128000, + "maxTokens": 16384, + "capabilities": [ + "images", + "tools", + "structured_output", + "temperature" + ], + "pricing": { + "input": 2.5, + "output": 10, + "cacheRead": 0, + "cacheWrite": 0 + }, + "releaseDate": "2024-05-13", + "family": "gpt" + } + }, "privatemode-ai": { "glm-5.3-flash": { "id": "glm-5.3-flash", @@ -161572,10 +170853,10 @@ export const GENERATED_PROVIDER_MODELS: { } ], "pricing": { - "input": 10, - "output": 50, - "cacheRead": 1, - "cacheWrite": 12.5 + "input": 11, + "output": 55, + "cacheRead": 1.1, + "cacheWrite": 13.75 }, "releaseDate": "2026-06-09", "family": "claude-fable" @@ -163758,7 +173039,7 @@ export const GENERATED_PROVIDER_MODELS: { "name": "Claude Sonnet 4.6", "contextWindow": 1000000, "maxInputTokens": 1000000, - "maxTokens": 128000, + "maxTokens": 64000, "capabilities": [ "images", "files", @@ -164038,6 +173319,41 @@ export const GENERATED_PROVIDER_MODELS: { "releaseDate": "2026-02-05", "family": "gpt-codex" }, + "kimi-k2.5@eu": { + "id": "kimi-k2.5@eu", + "name": "Kimi K2.5 (EU)", + "contextWindow": 128000, + "maxInputTokens": 128000, + "maxTokens": 16000, + "capabilities": [ + "images", + "video", + "tools", + "reasoning", + "structured_output", + "prompt-cache" + ], + "reasoningOptions": [ + { + "type": "effort", + "values": [ + "none", + "low", + "medium", + "high", + "max" + ] + } + ], + "pricing": { + "input": 0.72, + "output": 3.6, + "cacheRead": 0.72, + "cacheWrite": 0.72 + }, + "releaseDate": "2026-01", + "family": "kimi-k2" + }, "claude-opus-4-5": { "id": "claude-opus-4-5", "name": "Claude Opus 4.5 (latest)", @@ -165418,6 +174734,109 @@ export const GENERATED_PROVIDER_MODELS: { } }, "sapaicore": { + "gpt-6.1-sol": { + "id": "gpt-6.1-sol", + "name": "GPT-6.1 Sol", + "contextWindow": 1050000, + "maxInputTokens": 922000, + "maxTokens": 128000, + "capabilities": [ + "images", + "files", + "tools", + "reasoning", + "structured_output" + ], + "reasoningOptions": [ + { + "type": "effort", + "values": [ + "low", + "medium", + "high", + "xhigh", + "max" + ] + } + ], + "pricing": { + "input": 0, + "output": 0, + "cacheRead": 0, + "cacheWrite": 0 + }, + "releaseDate": "2026-09-29", + "family": "gpt-sol" + }, + "gpt-6-luna": { + "id": "gpt-6-luna", + "name": "GPT-6 Luna", + "contextWindow": 1050000, + "maxInputTokens": 922000, + "maxTokens": 128000, + "capabilities": [ + "images", + "files", + "tools", + "reasoning", + "structured_output" + ], + "reasoningOptions": [ + { + "type": "effort", + "values": [ + "none", + "low", + "medium", + "high", + "xhigh", + "max" + ] + } + ], + "pricing": { + "input": 0, + "output": 0, + "cacheRead": 0, + "cacheWrite": 0 + }, + "releaseDate": "2026-09-22", + "family": "gpt-luna" + }, + "gpt-6-astra": { + "id": "gpt-6-astra", + "name": "GPT-6 Astra", + "contextWindow": 1050000, + "maxInputTokens": 922000, + "maxTokens": 128000, + "capabilities": [ + "images", + "files", + "tools", + "reasoning", + "structured_output" + ], + "reasoningOptions": [ + { + "type": "effort", + "values": [ + "low", + "medium", + "high", + "xhigh", + "max" + ] + } + ], + "pricing": { + "input": 0, + "output": 0, + "cacheRead": 0, + "cacheWrite": 0 + }, + "releaseDate": "2026-09-04", + "family": "gpt-astra" + }, "gemini-3.8-flash": { "id": "gemini-3.8-flash", "name": "Gemini 3.8 Flash", @@ -165511,6 +174930,53 @@ export const GENERATED_PROVIDER_MODELS: { ] } }, + "gemini-3.6-flash": { + "id": "gemini-3.6-flash", + "name": "Gemini 3.6 Flash", + "contextWindow": 1048576, + "maxInputTokens": 1048576, + "maxTokens": 65536, + "capabilities": [ + "images", + "video", + "files", + "tools", + "reasoning", + "structured_output", + "temperature" + ], + "reasoningOptions": [ + { + "type": "effort", + "values": [ + "minimal", + "low", + "medium", + "high" + ] + } + ], + "pricing": { + "input": 0, + "output": 0, + "cacheRead": 0, + "cacheWrite": 0 + }, + "releaseDate": "2026-07-21", + "family": "gemini-flash", + "modalities": { + "input": [ + "text", + "image", + "video", + "audio", + "pdf" + ], + "output": [ + "text" + ] + } + }, "gpt-5.6-luna": { "id": "gpt-5.6-luna", "name": "gpt-5.6-luna", @@ -176721,41 +186187,6 @@ export const GENERATED_PROVIDER_MODELS: { "releaseDate": "2026-09-10", "family": "deepseek-flash" }, - "glm-5-3-flash": { - "id": "glm-5-3-flash", - "name": "GLM-5.3-Flash", - "contextWindow": 1048576, - "maxInputTokens": 1048576, - "maxTokens": 131072, - "capabilities": [ - "images", - "video", - "files", - "tools", - "reasoning", - "structured_output", - "temperature", - "prompt-cache" - ], - "reasoningOptions": [ - { - "type": "effort", - "values": [ - "low", - "high", - "max" - ] - } - ], - "pricing": { - "input": 0.4, - "output": 1.25, - "cacheRead": 0.1, - "cacheWrite": 0 - }, - "releaseDate": "2026-08-26", - "family": "glm-flash" - }, "glm-5-3": { "id": "glm-5-3", "name": "GLM-5.3", @@ -179263,74 +188694,6 @@ export const GENERATED_PROVIDER_MODELS: { "family": "solar-mini" } }, - "v0": { - "v0-1.5-lg": { - "id": "v0-1.5-lg", - "name": "v0-1.5-lg", - "contextWindow": 512000, - "maxInputTokens": 512000, - "maxTokens": 32000, - "capabilities": [ - "images", - "tools", - "reasoning", - "temperature" - ], - "reasoningOptions": [], - "pricing": { - "input": 15, - "output": 75, - "cacheRead": 0, - "cacheWrite": 0 - }, - "releaseDate": "2025-06-09", - "family": "v0" - }, - "v0-1.5-md": { - "id": "v0-1.5-md", - "name": "v0-1.5-md", - "contextWindow": 128000, - "maxInputTokens": 128000, - "maxTokens": 32000, - "capabilities": [ - "images", - "tools", - "reasoning", - "temperature" - ], - "reasoningOptions": [], - "pricing": { - "input": 3, - "output": 15, - "cacheRead": 0, - "cacheWrite": 0 - }, - "releaseDate": "2025-06-09", - "family": "v0" - }, - "v0-1.0-md": { - "id": "v0-1.0-md", - "name": "v0-1.0-md", - "contextWindow": 128000, - "maxInputTokens": 128000, - "maxTokens": 32000, - "capabilities": [ - "images", - "tools", - "reasoning", - "temperature" - ], - "reasoningOptions": [], - "pricing": { - "input": 3, - "output": 15, - "cacheRead": 0, - "cacheWrite": 0 - }, - "releaseDate": "2025-05-22", - "family": "v0" - } - }, "vancine": { "deepseek-v4.1-flash": { "id": "deepseek-v4.1-flash", @@ -179605,6 +188968,73 @@ export const GENERATED_PROVIDER_MODELS: { } }, "vercel-ai-gateway": { + "meta/muse-wonder-1.0": { + "id": "meta/muse-wonder-1.0", + "name": "Muse Wonder", + "contextWindow": 1048576, + "maxInputTokens": 1048576, + "maxTokens": 1048576, + "capabilities": [ + "images", + "files", + "tools", + "reasoning", + "structured_output", + "temperature", + "prompt-cache" + ], + "reasoningOptions": [ + { + "type": "effort", + "values": [ + "minimal", + "low", + "medium", + "high", + "xhigh" + ] + } + ], + "pricing": { + "input": 4, + "output": 20, + "cacheRead": 0.2, + "cacheWrite": 0 + }, + "releaseDate": "2026-10-12", + "family": "muse" + }, + "callstack/apex": { + "id": "callstack/apex", + "name": "Apex", + "contextWindow": 262144, + "maxInputTokens": 262144, + "maxTokens": 131072, + "capabilities": [ + "images", + "tools", + "reasoning", + "prompt-cache" + ], + "reasoningOptions": [ + { + "type": "effort", + "values": [ + "none", + "low", + "medium", + "xhigh" + ] + } + ], + "pricing": { + "input": 0.5, + "output": 3, + "cacheRead": 0.2, + "cacheWrite": 0 + }, + "releaseDate": "2026-10-08" + }, "anthropic/claude-haiku-5.5": { "id": "anthropic/claude-haiku-5.5", "name": "Claude Haiku 5.5", @@ -179701,7 +189131,7 @@ export const GENERATED_PROVIDER_MODELS: { "cacheWrite": 0 }, "releaseDate": "2026-10-06", - "family": "nano-banana", + "family": "gemini-flash", "modalities": { "input": [ "text", @@ -179730,8 +189160,6 @@ export const GENERATED_PROVIDER_MODELS: { "type": "effort", "values": [ "none", - "low", - "medium", "high" ] } @@ -180353,9 +189781,9 @@ export const GENERATED_PROVIDER_MODELS: { } ], "pricing": { - "input": 0.04, - "output": 1.28, - "cacheRead": 0.04, + "input": 0.14, + "output": 0.28, + "cacheRead": 0.0028, "cacheWrite": 0 }, "releaseDate": "2026-09-22", @@ -180578,6 +190006,68 @@ export const GENERATED_PROVIDER_MODELS: { ] } }, + "sakana/fugu-max": { + "id": "sakana/fugu-max", + "name": "Fugu Max", + "contextWindow": 1000000, + "maxInputTokens": 1000000, + "maxTokens": 1000000, + "capabilities": [ + "images", + "tools", + "reasoning", + "structured_output", + "prompt-cache" + ], + "reasoningOptions": [ + { + "type": "effort", + "values": [ + "high", + "xhigh" + ] + } + ], + "pricing": { + "input": 2, + "output": 6, + "cacheRead": 0.25, + "cacheWrite": 0 + }, + "releaseDate": "2026-09-11", + "family": "fugu" + }, + "sakana/fugu-ultra-v2": { + "id": "sakana/fugu-ultra-v2", + "name": "Fugu Ultra v2", + "contextWindow": 1000000, + "maxInputTokens": 1000000, + "maxTokens": 1000000, + "capabilities": [ + "images", + "tools", + "reasoning", + "structured_output", + "prompt-cache" + ], + "reasoningOptions": [ + { + "type": "effort", + "values": [ + "high", + "xhigh" + ] + } + ], + "pricing": { + "input": 5, + "output": 30, + "cacheRead": 0.5, + "cacheWrite": 0 + }, + "releaseDate": "2026-09-11", + "family": "fugu" + }, "deepseek/deepseek-v4.1-flash": { "id": "deepseek/deepseek-v4.1-flash", "name": "DeepSeek V4.1 Flash", @@ -180612,65 +190102,39 @@ export const GENERATED_PROVIDER_MODELS: { "releaseDate": "2026-09-10", "family": "deepseek-flash" }, - "sakana/fugu-max": { - "id": "sakana/fugu-max", - "name": "Fugu Max", - "contextWindow": 1000000, - "maxInputTokens": 1000000, - "maxTokens": 1000000, + "deepseek/deepseek-v4.1-flash-fast": { + "id": "deepseek/deepseek-v4.1-flash-fast", + "name": "DeepSeek V4.1 Flash Fast", + "contextWindow": 1048576, + "maxInputTokens": 1048576, + "maxTokens": 32768, "capabilities": [ "images", "tools", "reasoning", + "structured_output", + "temperature", "prompt-cache" ], "reasoningOptions": [ { "type": "effort", "values": [ - "high", - "xhigh" + "none", + "low", + "medium", + "high" ] } ], "pricing": { - "input": 2, - "output": 6, - "cacheRead": 0.25, + "input": 0.6, + "output": 2.4, + "cacheRead": 0.014, "cacheWrite": 0 }, "releaseDate": "2026-09-10", - "family": "fugu" - }, - "sakana/fugu-ultra-v2": { - "id": "sakana/fugu-ultra-v2", - "name": "Fugu Ultra v2", - "contextWindow": 1000000, - "maxInputTokens": 1000000, - "maxTokens": 1000000, - "capabilities": [ - "images", - "tools", - "reasoning", - "prompt-cache" - ], - "reasoningOptions": [ - { - "type": "effort", - "values": [ - "high", - "xhigh" - ] - } - ], - "pricing": { - "input": 5, - "output": 30, - "cacheRead": 0.5, - "cacheWrite": 0 - }, - "releaseDate": "2026-09-10", - "family": "fugu" + "family": "deepseek-flash" }, "inception/mercury-2.5": { "id": "inception/mercury-2.5", @@ -183790,37 +193254,6 @@ export const GENERATED_PROVIDER_MODELS: { ] } }, - "alibaba/qwen-3.6-max-preview": { - "id": "alibaba/qwen-3.6-max-preview", - "name": "Qwen 3.6 Max Preview", - "contextWindow": 240000, - "maxInputTokens": 240000, - "maxTokens": 64000, - "capabilities": [ - "tools", - "reasoning", - "temperature", - "prompt-cache" - ], - "reasoningOptions": [ - { - "type": "toggle" - }, - { - "type": "budget_tokens", - "min": 1, - "max": 131072 - } - ], - "pricing": { - "input": 1.3, - "output": 7.8, - "cacheRead": 0.13, - "cacheWrite": 1.625 - }, - "releaseDate": "2026-04-20", - "family": "qwen" - }, "spacexai/grok-4.3": { "id": "spacexai/grok-4.3", "name": "Grok 4.3", @@ -186344,99 +195777,22 @@ export const GENERATED_PROVIDER_MODELS: { "releaseDate": "2025-09-29", "family": "claude-sonnet" }, - "alibaba/qwen3-235b-a22b-thinking": { - "id": "alibaba/qwen3-235b-a22b-thinking", - "name": "Qwen3 235B A22B Thinking 2507", - "contextWindow": 131072, - "maxInputTokens": 131072, - "maxTokens": 32768, - "capabilities": [ - "images", - "files", - "tools", - "reasoning", - "temperature" - ], - "reasoningOptions": [ - { - "type": "budget_tokens", - "min": 1, - "max": 81920 - } - ], - "pricing": { - "input": 0.4, - "output": 4, - "cacheRead": 0, - "cacheWrite": 0 - }, - "releaseDate": "2025-09-23", - "family": "qwen" - }, - "alibaba/qwen3-max": { - "id": "alibaba/qwen3-max", - "name": "Qwen3 Max", + "alibaba/qwen3-vl-instruct": { + "id": "alibaba/qwen3-vl-instruct", + "name": "Qwen3 VL Instruct", "contextWindow": 262144, "maxInputTokens": 262144, - "maxTokens": 32768, + "maxTokens": 262144, "capabilities": [ + "images", "tools", "temperature", "prompt-cache" ], "pricing": { - "input": 1.2, - "output": 6, - "cacheRead": 0.24, - "cacheWrite": 0 - }, - "releaseDate": "2025-09-23", - "family": "qwen" - }, - "alibaba/qwen3-vl-instruct": { - "id": "alibaba/qwen3-vl-instruct", - "name": "Qwen3 VL Instruct", - "contextWindow": 131072, - "maxInputTokens": 131072, - "maxTokens": 129024, - "capabilities": [ - "images", - "tools", - "temperature" - ], - "pricing": { - "input": 0.4, - "output": 1.6, - "cacheRead": 0, - "cacheWrite": 0 - }, - "releaseDate": "2025-09-23", - "family": "qwen" - }, - "alibaba/qwen3-vl-thinking": { - "id": "alibaba/qwen3-vl-thinking", - "name": "Qwen3 VL Thinking", - "contextWindow": 131072, - "maxInputTokens": 131072, - "maxTokens": 32768, - "capabilities": [ - "images", - "files", - "tools", - "reasoning", - "temperature" - ], - "reasoningOptions": [ - { - "type": "budget_tokens", - "min": 1, - "max": 81920 - } - ], - "pricing": { - "input": 0.4, - "output": 4, - "cacheRead": 0, + "input": 0.2, + "output": 0.88, + "cacheRead": 0.11, "cacheWrite": 0 }, "releaseDate": "2025-09-23", @@ -186502,26 +195858,6 @@ export const GENERATED_PROVIDER_MODELS: { ] } }, - "alibaba/qwen3-max-preview": { - "id": "alibaba/qwen3-max-preview", - "name": "Qwen3 Max Preview", - "contextWindow": 262144, - "maxInputTokens": 262144, - "maxTokens": 32768, - "capabilities": [ - "tools", - "temperature", - "prompt-cache" - ], - "pricing": { - "input": 1.2, - "output": 6, - "cacheRead": 0.24, - "cacheWrite": 0 - }, - "releaseDate": "2025-09-05", - "family": "qwen" - }, "alibaba/qwen3-next-80b-a3b-instruct": { "id": "alibaba/qwen3-next-80b-a3b-instruct", "name": "Qwen3 Next 80B A3B Instruct", @@ -187047,32 +196383,12 @@ export const GENERATED_PROVIDER_MODELS: { "releaseDate": "2025-07-28", "family": "glm-air" }, - "alibaba/qwen3-coder-plus": { - "id": "alibaba/qwen3-coder-plus", - "name": "Qwen3 Coder Plus", - "contextWindow": 1000000, - "maxInputTokens": 1000000, - "maxTokens": 65536, - "capabilities": [ - "tools", - "temperature", - "prompt-cache" - ], - "pricing": { - "input": 1, - "output": 5, - "cacheRead": 0.2, - "cacheWrite": 0 - }, - "releaseDate": "2025-07-23", - "family": "qwen" - }, "alibaba/qwen3-coder": { "id": "alibaba/qwen3-coder", "name": "Qwen3 Coder 480B A35B Instruct", "contextWindow": 262144, "maxInputTokens": 262144, - "maxTokens": 65536, + "maxTokens": 32768, "capabilities": [ "tools", "reasoning", @@ -187081,9 +196397,9 @@ export const GENERATED_PROVIDER_MODELS: { ], "reasoningOptions": [], "pricing": { - "input": 1.5, - "output": 7.5, - "cacheRead": 0.3, + "input": 0.22, + "output": 1.8, + "cacheRead": 0.022, "cacheWrite": 0 }, "releaseDate": "2025-07-22", @@ -187324,38 +196640,6 @@ export const GENERATED_PROVIDER_MODELS: { ] } }, - "anthropic/claude-opus-4": { - "id": "anthropic/claude-opus-4", - "name": "Claude Opus 4", - "contextWindow": 200000, - "maxInputTokens": 200000, - "maxTokens": 32000, - "capabilities": [ - "images", - "files", - "tools", - "reasoning", - "temperature", - "prompt-cache" - ], - "reasoningOptions": [ - { - "type": "toggle" - }, - { - "type": "budget_tokens", - "min": 1024 - } - ], - "pricing": { - "input": 15, - "output": 75, - "cacheRead": 1.5, - "cacheWrite": 18.75 - }, - "releaseDate": "2025-05-22", - "family": "claude-opus" - }, "anthropic/claude-sonnet-4": { "id": "anthropic/claude-sonnet-4", "name": "Claude Sonnet 4", @@ -187491,8 +196775,8 @@ export const GENERATED_PROVIDER_MODELS: { } ], "pricing": { - "input": 0.16, - "output": 0.64, + "input": 0.15, + "output": 0.6, "cacheRead": 0, "cacheWrite": 0 }, @@ -187789,6 +197073,7 @@ export const GENERATED_PROVIDER_MODELS: { "maxTokens": 8000, "capabilities": [ "tools", + "structured_output", "temperature" ], "pricing": { @@ -187798,7 +197083,7 @@ export const GENERATED_PROVIDER_MODELS: { "cacheWrite": 0 }, "releaseDate": "2025-03-13", - "family": "command" + "family": "command-a" }, "inception/mercury-coder-small": { "id": "inception/mercury-coder-small", @@ -188162,16 +197447,16 @@ export const GENERATED_PROVIDER_MODELS: { "mistral/mistral-nemo": { "id": "mistral/mistral-nemo", "name": "Mistral Nemo", - "contextWindow": 60288, - "maxInputTokens": 60288, - "maxTokens": 16000, + "contextWindow": 131072, + "maxInputTokens": 131072, + "maxTokens": 131072, "capabilities": [ "tools", "temperature" ], "pricing": { - "input": 0.04, - "output": 0.17, + "input": 0.029, + "output": 0.03, "cacheRead": 0, "cacheWrite": 0 }, @@ -188290,27 +197575,6 @@ export const GENERATED_PROVIDER_MODELS: { "releaseDate": "2024-05-13", "family": "gpt" }, - "anthropic/claude-3-haiku": { - "id": "anthropic/claude-3-haiku", - "name": "Claude Haiku 3", - "contextWindow": 200000, - "maxInputTokens": 200000, - "maxTokens": 4096, - "capabilities": [ - "images", - "tools", - "temperature", - "prompt-cache" - ], - "pricing": { - "input": 0.25, - "output": 1.25, - "cacheRead": 0.03, - "cacheWrite": 0.3 - }, - "releaseDate": "2024-03-13", - "family": "claude-haiku" - }, "openai/gpt-4o-mini-transcribe": { "id": "openai/gpt-4o-mini-transcribe", "name": "GPT-4o mini Transcribe", @@ -188468,6 +197732,48 @@ export const GENERATED_PROVIDER_MODELS: { "releaseDate": "2026-10-07", "family": "claude-haiku" }, + "gemini-nano-banana-2.1": { + "id": "gemini-nano-banana-2.1", + "name": "Nano Banana 2.1", + "contextWindow": 131072, + "maxInputTokens": 131072, + "maxTokens": 32768, + "capabilities": [ + "images", + "video", + "reasoning", + "prompt-cache" + ], + "reasoningOptions": [ + { + "type": "effort", + "values": [ + "minimal", + "medium", + "high" + ] + } + ], + "pricing": { + "input": 1.5, + "output": 30, + "cacheRead": 0.15, + "cacheWrite": 0 + }, + "releaseDate": "2026-10-06", + "family": "gemini-flash", + "modalities": { + "input": [ + "text", + "image", + "video" + ], + "output": [ + "text", + "image" + ] + } + }, "claude-sonnet-5-5@default": { "id": "claude-sonnet-5-5@default", "name": "Claude Sonnet 5.5", @@ -188497,7 +197803,7 @@ export const GENERATED_PROVIDER_MODELS: { "pricing": { "input": 2, "output": 10, - "cacheRead": 0.2, + "cacheRead": 0.1, "cacheWrite": 2.5 }, "releaseDate": "2026-09-28", @@ -188538,6 +197844,42 @@ export const GENERATED_PROVIDER_MODELS: { "releaseDate": "2026-09-22", "family": "claude-opus" }, + "xai/grok-4.7": { + "id": "xai/grok-4.7", + "name": "Grok 4.7", + "contextWindow": 524288, + "maxInputTokens": 524288, + "maxTokens": 500000, + "capabilities": [ + "images", + "tools", + "reasoning", + "structured_output", + "temperature", + "prompt-cache" + ], + "reasoningOptions": [ + { + "type": "effort", + "values": [ + "low", + "medium", + "high" + ] + } + ], + "pricing": { + "input": 2, + "output": 6, + "cacheRead": 0.5, + "cacheWrite": 0 + }, + "releaseDate": "2026-09-21", + "family": "grok", + "metadata": { + "apiProtocol": "openai-chat" + } + }, "gemini-3.8-flash": { "id": "gemini-3.8-flash", "name": "Gemini 3.8 Flash", @@ -188667,54 +198009,6 @@ export const GENERATED_PROVIDER_MODELS: { ] } }, - "gemini-flash-latest": { - "id": "gemini-flash-latest", - "name": "Gemini Flash Latest", - "contextWindow": 1048576, - "maxInputTokens": 1048576, - "maxTokens": 65536, - "capabilities": [ - "images", - "video", - "files", - "tools", - "reasoning", - "structured_output", - "temperature", - "prompt-cache" - ], - "reasoningOptions": [ - { - "type": "effort", - "values": [ - "minimal", - "low", - "medium", - "high" - ] - } - ], - "pricing": { - "input": 1.5, - "output": 9, - "cacheRead": 0.15, - "cacheWrite": 0 - }, - "releaseDate": "2026-08-13", - "family": "gemini-flash", - "modalities": { - "input": [ - "text", - "image", - "video", - "audio", - "pdf" - ], - "output": [ - "text" - ] - } - }, "xai/grok-4.6": { "id": "xai/grok-4.6", "name": "Grok 4.6", @@ -188729,7 +198023,16 @@ export const GENERATED_PROVIDER_MODELS: { "temperature", "prompt-cache" ], - "reasoningOptions": [], + "reasoningOptions": [ + { + "type": "effort", + "values": [ + "low", + "medium", + "high" + ] + } + ], "pricing": { "input": 2, "output": 6, @@ -188824,101 +198127,6 @@ export const GENERATED_PROVIDER_MODELS: { ] } }, - "gemini-3.6-flash": { - "id": "gemini-3.6-flash", - "name": "Gemini 3.6 Flash", - "contextWindow": 1048576, - "maxInputTokens": 1048576, - "maxTokens": 65536, - "capabilities": [ - "images", - "video", - "files", - "tools", - "reasoning", - "structured_output", - "temperature", - "prompt-cache" - ], - "reasoningOptions": [ - { - "type": "effort", - "values": [ - "minimal", - "low", - "medium", - "high" - ] - } - ], - "pricing": { - "input": 0.75, - "output": 3.75, - "cacheRead": 0.075, - "cacheWrite": 0 - }, - "releaseDate": "2026-07-21", - "family": "gemini-flash", - "modalities": { - "input": [ - "text", - "image", - "video", - "audio", - "pdf" - ], - "output": [ - "text" - ] - } - }, - "gemini-flash-lite-latest": { - "id": "gemini-flash-lite-latest", - "name": "Gemini Flash-Lite Latest", - "contextWindow": 1048576, - "maxInputTokens": 1048576, - "maxTokens": 65536, - "capabilities": [ - "images", - "video", - "files", - "tools", - "reasoning", - "temperature", - "prompt-cache" - ], - "reasoningOptions": [ - { - "type": "effort", - "values": [ - "minimal", - "low", - "medium", - "high" - ] - } - ], - "pricing": { - "input": 0.25, - "output": 1.5, - "cacheRead": 0.025, - "cacheWrite": 0 - }, - "releaseDate": "2026-07-21", - "family": "gemini-flash-lite", - "modalities": { - "input": [ - "text", - "image", - "video", - "audio", - "pdf" - ], - "output": [ - "text" - ] - } - }, "claude-sonnet-5@default": { "id": "claude-sonnet-5@default", "name": "Claude Sonnet 5", @@ -188956,6 +198164,47 @@ export const GENERATED_PROVIDER_MODELS: { "releaseDate": "2026-06-30", "family": "claude-sonnet" }, + "gemini-3.1-flash-lite-image": { + "id": "gemini-3.1-flash-lite-image", + "name": "Nano Banana 2 Lite", + "contextWindow": 65536, + "maxInputTokens": 65536, + "maxTokens": 4096, + "capabilities": [ + "images", + "tools", + "reasoning", + "temperature", + "prompt-cache" + ], + "reasoningOptions": [ + { + "type": "effort", + "values": [ + "minimal", + "high" + ] + } + ], + "pricing": { + "input": 0.25, + "output": 30, + "cacheRead": 0.025, + "cacheWrite": 0 + }, + "releaseDate": "2026-06-30", + "family": "gemini-flash-lite", + "modalities": { + "input": [ + "text", + "image" + ], + "output": [ + "text", + "image" + ] + } + }, "zai-org/glm-5.2-maas": { "id": "zai-org/glm-5.2-maas", "name": "GLM-5.2", @@ -189062,6 +198311,7 @@ export const GENERATED_PROVIDER_MODELS: { "maxTokens": 32768, "capabilities": [ "images", + "files", "reasoning", "temperature", "prompt-cache" @@ -189078,7 +198328,8 @@ export const GENERATED_PROVIDER_MODELS: { "modalities": { "input": [ "text", - "image" + "image", + "pdf" ], "output": [ "text", @@ -189287,6 +198538,33 @@ export const GENERATED_PROVIDER_MODELS: { "releaseDate": "2026-04-16", "family": "claude-opus" }, + "google/gemma-4-26b-a4b-it-maas": { + "id": "google/gemma-4-26b-a4b-it-maas", + "name": "Gemma 4 26B A4B IT", + "contextWindow": 262144, + "maxInputTokens": 262144, + "maxTokens": 128000, + "capabilities": [ + "images", + "tools", + "reasoning", + "structured_output", + "temperature", + "prompt-cache" + ], + "reasoningOptions": [], + "pricing": { + "input": 0.15, + "output": 0.6, + "cacheRead": 0.015, + "cacheWrite": 0 + }, + "releaseDate": "2026-04-02", + "family": "gemma", + "metadata": { + "apiProtocol": "openai-chat" + } + }, "xai/grok-4.20-non-reasoning": { "id": "xai/grok-4.20-non-reasoning", "name": "Grok 4.20 (Non-Reasoning)", @@ -189623,35 +198901,6 @@ export const GENERATED_PROVIDER_MODELS: { "releaseDate": "2025-10-15", "family": "claude-haiku" }, - "claude-sonnet-4-5@20250929": { - "id": "claude-sonnet-4-5@20250929", - "name": "Claude Sonnet 4.5", - "contextWindow": 200000, - "maxInputTokens": 200000, - "maxTokens": 64000, - "capabilities": [ - "images", - "files", - "tools", - "reasoning", - "temperature", - "prompt-cache" - ], - "reasoningOptions": [ - { - "type": "budget_tokens", - "min": 1024 - } - ], - "pricing": { - "input": 3, - "output": 15, - "cacheRead": 0.3, - "cacheWrite": 3.75 - }, - "releaseDate": "2025-09-29", - "family": "claude-sonnet" - }, "gemini-2.5-flash-image": { "id": "gemini-2.5-flash-image", "name": "Nano Banana", @@ -189660,6 +198909,7 @@ export const GENERATED_PROVIDER_MODELS: { "maxTokens": 32768, "capabilities": [ "images", + "files", "temperature" ], "pricing": { @@ -189673,7 +198923,8 @@ export const GENERATED_PROVIDER_MODELS: { "modalities": { "input": [ "text", - "image" + "image", + "pdf" ], "output": [ "text", @@ -189715,140 +198966,45 @@ export const GENERATED_PROVIDER_MODELS: { "apiProtocol": "openai-chat" } }, - "gemini-2.5-flash": { - "id": "gemini-2.5-flash", - "name": "Gemini 2.5 Flash", - "contextWindow": 1048576, - "maxInputTokens": 1048576, - "maxTokens": 65536, + "codestral-2": { + "id": "codestral-2", + "name": "Codestral 2", + "contextWindow": 128000, + "maxInputTokens": 128000, + "maxTokens": 8192, "capabilities": [ - "images", - "video", - "files", "tools", - "reasoning", - "temperature", - "prompt-cache" - ], - "reasoningOptions": [ - { - "type": "toggle" - }, - { - "type": "budget_tokens", - "min": 0, - "max": 24576 - } + "structured_output", + "temperature" ], "pricing": { "input": 0.3, - "output": 2.5, - "cacheRead": 0.03, + "output": 0.9, + "cacheRead": 0, "cacheWrite": 0 }, - "releaseDate": "2025-06-17", - "family": "gemini-flash", - "modalities": { - "input": [ - "text", - "image", - "audio", - "video", - "pdf" - ], - "output": [ - "text" - ] - } + "releaseDate": "2025-07-30", + "family": "codestral" }, - "gemini-2.5-flash-lite": { - "id": "gemini-2.5-flash-lite", - "name": "Gemini 2.5 Flash-Lite", - "contextWindow": 1048576, - "maxInputTokens": 1048576, - "maxTokens": 65535, + "mistral-medium-3": { + "id": "mistral-medium-3", + "name": "Mistral Medium 3", + "contextWindow": 131072, + "maxInputTokens": 131072, + "maxTokens": 131072, "capabilities": [ "images", - "video", - "files", "tools", - "reasoning", - "temperature", - "prompt-cache" - ], - "reasoningOptions": [ - { - "type": "toggle" - }, - { - "type": "budget_tokens", - "min": 512, - "max": 24576 - } + "temperature" ], "pricing": { - "input": 0.1, - "output": 0.4, - "cacheRead": 0.01, + "input": 0.4, + "output": 2, + "cacheRead": 0, "cacheWrite": 0 }, - "releaseDate": "2025-06-17", - "family": "gemini-flash-lite", - "modalities": { - "input": [ - "text", - "image", - "audio", - "video", - "pdf" - ], - "output": [ - "text" - ] - } - }, - "gemini-2.5-pro": { - "id": "gemini-2.5-pro", - "name": "Gemini 2.5 Pro", - "contextWindow": 1048576, - "maxInputTokens": 1048576, - "maxTokens": 65536, - "capabilities": [ - "images", - "video", - "files", - "tools", - "reasoning", - "temperature", - "prompt-cache" - ], - "reasoningOptions": [ - { - "type": "budget_tokens", - "min": 128, - "max": 32768 - } - ], - "pricing": { - "input": 1.25, - "output": 10, - "cacheRead": 0.125, - "cacheWrite": 0 - }, - "releaseDate": "2025-06-17", - "family": "gemini-pro", - "modalities": { - "input": [ - "text", - "image", - "audio", - "video", - "pdf" - ], - "output": [ - "text" - ] - } + "releaseDate": "2025-05-07", + "family": "mistral-medium" }, "meta/llama-4-maverick-17b-128e-instruct-maas": { "id": "meta/llama-4-maverick-17b-128e-instruct-maas", @@ -189873,6 +199029,51 @@ export const GENERATED_PROVIDER_MODELS: { "metadata": { "apiProtocol": "openai-chat" } + }, + "meta/llama-4-scout-17b-16e-instruct-maas": { + "id": "meta/llama-4-scout-17b-16e-instruct-maas", + "name": "Llama 4 Scout 17B Instruct", + "contextWindow": 1310720, + "maxInputTokens": 1310720, + "maxTokens": 8192, + "capabilities": [ + "images", + "tools", + "structured_output", + "temperature" + ], + "pricing": { + "input": 0.25, + "output": 0.7, + "cacheRead": 0, + "cacheWrite": 0 + }, + "releaseDate": "2025-04-05", + "family": "llama", + "metadata": { + "apiProtocol": "openai-chat" + } + }, + "mistral-small-2503": { + "id": "mistral-small-2503", + "name": "Mistral Small 3.1 24B", + "contextWindow": 128000, + "maxInputTokens": 128000, + "maxTokens": 16384, + "capabilities": [ + "images", + "tools", + "structured_output", + "temperature" + ], + "pricing": { + "input": 0.1, + "output": 0.3, + "cacheRead": 0, + "cacheWrite": 0 + }, + "releaseDate": "2025-03-17", + "family": "mistral-small" } }, "vispark": { diff --git a/sdk/packages/llms/src/catalog/cline-recommended.generated.ts b/sdk/packages/llms/src/catalog/cline-recommended.generated.ts index 0f5353dd51..291cc58869 100644 --- a/sdk/packages/llms/src/catalog/cline-recommended.generated.ts +++ b/sdk/packages/llms/src/catalog/cline-recommended.generated.ts @@ -17,7 +17,7 @@ export const GENERATED_CLINE_RECOMMENDED_MODELS: ClineRecommendedModelsPayload = tags: ["NEW"], }, { - id: "spacexai/grok-4.7", + id: "x-ai/grok-4.7", name: "grok-4.7", description: "", tags: ["NEW"], @@ -44,9 +44,10 @@ export const GENERATED_CLINE_RECOMMENDED_MODELS: ClineRecommendedModelsPayload = ], free: [ { - id: "cline-free/solar-mini4", - name: "Solar Mini 4", - description: "Compact 35-billion parameter mixture-of-experts", + id: "cline-free/step-5-preview", + name: "Step 5 Preview", + description: + "StepFun's flagship model for agentic work, built on a sparse Mixture-of-Experts", tags: [], }, { @@ -63,6 +64,12 @@ export const GENERATED_CLINE_RECOMMENDED_MODELS: ClineRecommendedModelsPayload = "Meta’s multimodal reasoning model for experimentation, learning, and early-stage agentic, multi-agent, and coding workflows.", tags: [], }, + { + id: "cline-free/solar-mini4", + name: "Solar Mini 4", + description: "Compact 35-billion parameter mixture-of-experts", + tags: [], + }, ], clinePass: [ { @@ -120,18 +127,18 @@ export const GENERATED_CLINE_RECOMMENDED_MODELS: ClineRecommendedModelsPayload = description: "Latest natively multimodal model in the GLM-5 series", tags: [], }, - { - id: "cline-pass/qwen3.7-max", - name: "cline-pass/qwen3.7-max", - description: "Flagship agent model with 1M context window", - tags: [], - }, { id: "cline-pass/minimax-m3", name: "cline-pass/minimax-m3", description: "Frontier coding and agent model with 1M context window", tags: [], }, + { + id: "cline-pass/qwen3.7-max", + name: "cline-pass/qwen3.7-max", + description: "Flagship agent model with 1M context window", + tags: [], + }, { id: "cline-pass/qwen3.7-plus", name: "cline-pass/qwen3.7-plus", diff --git a/sdk/packages/llms/src/providers/ids.test.ts b/sdk/packages/llms/src/providers/ids.test.ts index 8435fe0e3d..e0ccd3baf7 100644 --- a/sdk/packages/llms/src/providers/ids.test.ts +++ b/sdk/packages/llms/src/providers/ids.test.ts @@ -36,7 +36,7 @@ describe("provider-ids", () => { } }); - it("registers v0 as a built-in provider with generated catalog models", async () => { + it("registers v0 as a built-in provider", async () => { await expect(getProvider("v0")).resolves.toMatchObject({ id: "v0", baseUrl: "https://api.v0.dev/v1", @@ -44,12 +44,9 @@ describe("provider-ids", () => { client: "openai-compatible", }); + // models.dev no longer lists v0, so only the built-in default remains. const models = await getModelsForProvider("v0"); - expect(Object.keys(models).sort()).toEqual([ - "v0-1.0-md", - "v0-1.5-lg", - "v0-1.5-md", - ]); + expect(Object.keys(models)).toEqual(["v0-1.5-md"]); const registration = BUILTIN_PROVIDER_REGISTRATIONS.find( (item) => item.manifest.id === "v0", diff --git a/sdk/packages/llms/src/providers/opencode-go.test.ts b/sdk/packages/llms/src/providers/opencode-go.test.ts index 505122ff62..bb51d85880 100644 --- a/sdk/packages/llms/src/providers/opencode-go.test.ts +++ b/sdk/packages/llms/src/providers/opencode-go.test.ts @@ -243,7 +243,9 @@ describe("OpenCode Go HTTP integration", () => { expect.objectContaining({ type: "finish", reason: "stop" }), ); const [url, init] = fetchMock.mock.calls.at(-1) ?? []; - expect(String(url)).toBe(`https://opencode.ai/zen/go/v1/${endpoint}`); + expect(String(url)).toBe( + `https://opencode.ai/inference/go/v1/${endpoint}`, + ); const headers = new Headers(init?.headers); expect(headers.get("x-opencode-session")).toBe(sessionId); expect(headers.get("user-agent")).toContain("Cline/"); diff --git a/sdk/packages/llms/src/providers/provider-ids.generated.ts b/sdk/packages/llms/src/providers/provider-ids.generated.ts index 65071a6126..92a02a4490 100644 --- a/sdk/packages/llms/src/providers/provider-ids.generated.ts +++ b/sdk/packages/llms/src/providers/provider-ids.generated.ts @@ -20,6 +20,7 @@ export const GENERATED_PROVIDER_IDS = [ "ai21", "aiand", "aihubmix", + "aimlapi", "ainetcafe", "aixy", "aki-io", @@ -110,6 +111,7 @@ export const GENERATED_PROVIDER_IDS = [ "llmtr", "lmstudio", "longcat", + "lowrouter", "lucidquery", "lynkr", "meganova", @@ -156,6 +158,7 @@ export const GENERATED_PROVIDER_IDS = [ "pioneer", "poe", "poolside", + "prime", "privatemode-ai", "qihang-ai", "qiniu-ai", @@ -197,7 +200,6 @@ export const GENERATED_PROVIDER_IDS = [ "umans-ai-coding-plan", "unorouter", "upstage", - "v0", "vancine", "vercel-ai-gateway", "vertex", diff --git a/sdk/packages/llms/src/providers/providers.generated.ts b/sdk/packages/llms/src/providers/providers.generated.ts index b07eef5f4d..e3306abd70 100644 --- a/sdk/packages/llms/src/providers/providers.generated.ts +++ b/sdk/packages/llms/src/providers/providers.generated.ts @@ -24,7 +24,7 @@ export const GENERATED_PROVIDER_SPECS: readonly BuiltinSpec[] = [ family: "openai-compatible", capabilities: ["tools", "reasoning", "prompt-cache"], modelsProviderId: "302ai", - defaultModelId: "claude-sonnet-5-5", + defaultModelId: "claude-haiku-5-5", apiKeyEnv: ["302AI_API_KEY"], docsUrl: "https://doc.302.ai", defaults: { @@ -150,10 +150,24 @@ export const GENERATED_PROVIDER_SPECS: readonly BuiltinSpec[] = [ family: "openai-compatible", capabilities: ["tools", "reasoning", "prompt-cache"], modelsProviderId: "aihubmix", - defaultModelId: "claude-opus-5-5", + defaultModelId: "claude-haiku-5-5", apiKeyEnv: ["AIHUBMIX_API_KEY"], docsUrl: "https://docs.aihubmix.com", }, + { + id: "aimlapi", + name: "AI/ML API", + description: "AI/ML API model provider from models.dev", + family: "openai-compatible", + capabilities: ["tools", "reasoning", "prompt-cache"], + modelsProviderId: "aimlapi", + defaultModelId: "anthropic/claude-haiku-5.5", + apiKeyEnv: ["AIMLAPI_API_KEY"], + docsUrl: "https://docs.aimlapi.com", + defaults: { + baseUrl: "https://api.aimlapi.com/v1", + }, + }, { id: "ainetcafe", name: "ainetcafe", @@ -173,9 +187,9 @@ export const GENERATED_PROVIDER_SPECS: readonly BuiltinSpec[] = [ name: "Aixy", description: "Aixy model provider from models.dev", family: "openai-compatible", - capabilities: ["tools", "prompt-cache"], + capabilities: ["tools", "reasoning", "prompt-cache"], modelsProviderId: "aixy", - defaultModelId: "openai/gpt-4.1-mini", + defaultModelId: "openai/gpt-6.1-sol", apiKeyEnv: ["AIXY_API_KEY"], docsUrl: "https://docs.aixy-gateway.com/integrations/overview", defaults: { @@ -289,9 +303,9 @@ export const GENERATED_PROVIDER_SPECS: readonly BuiltinSpec[] = [ name: "Ambient", description: "Ambient model provider from models.dev", family: "openai-compatible", - capabilities: ["tools", "reasoning", "prompt-cache"], + capabilities: ["tools", "prompt-cache", "reasoning"], modelsProviderId: "ambient", - defaultModelId: "qwen/qwen3.8-27b", + defaultModelId: "qwen/qwen3-30b-a3b-instruct-2507", apiKeyEnv: ["AMBIENT_API_KEY"], docsUrl: "https://ambient.xyz", defaults: { @@ -554,7 +568,7 @@ export const GENERATED_PROVIDER_SPECS: readonly BuiltinSpec[] = [ family: "openai-compatible", capabilities: ["tools", "reasoning"], modelsProviderId: "cloudferro-sherlock", - defaultModelId: "MiniMaxAI/MiniMax-M2.5", + defaultModelId: "zai-org/GLM-5.3", apiKeyEnv: ["CLOUDFERRO_SHERLOCK_API_KEY"], docsUrl: "https://docs.sherlock.cloudferro.com/", defaults: { @@ -597,7 +611,7 @@ export const GENERATED_PROVIDER_SPECS: readonly BuiltinSpec[] = [ family: "openai-compatible", capabilities: ["tools", "reasoning", "prompt-cache"], modelsProviderId: "cortecs", - defaultModelId: "mistral-large-4-0", + defaultModelId: "claude-haiku-5.5", apiKeyEnv: ["CORTECS_API_KEY"], docsUrl: "https://api.cortecs.ai/v1/models", defaults: { @@ -625,7 +639,7 @@ export const GENERATED_PROVIDER_SPECS: readonly BuiltinSpec[] = [ family: "openai-compatible", capabilities: ["tools", "reasoning", "prompt-cache"], modelsProviderId: "crossmodel", - defaultModelId: "openai/gpt-6.1-sol", + defaultModelId: "anthropic/claude-haiku-5-5", apiKeyEnv: ["CROSSMODEL_API_KEY"], docsUrl: "https://www.crossmodel.ai/docs", defaults: { @@ -696,7 +710,7 @@ export const GENERATED_PROVIDER_SPECS: readonly BuiltinSpec[] = [ family: "openai-compatible", capabilities: ["tools", "reasoning", "prompt-cache"], modelsProviderId: "digitalocean", - defaultModelId: "openai-gpt-6-1-sol", + defaultModelId: "anthropic-claude-5.5-haiku", apiKeyEnv: ["DIGITALOCEAN_ACCESS_TOKEN"], docsUrl: "https://docs.digitalocean.com/products/gradient-ai-platform/details/models/", @@ -1232,7 +1246,7 @@ export const GENERATED_PROVIDER_SPECS: readonly BuiltinSpec[] = [ family: "openai-compatible", capabilities: ["tools", "reasoning", "prompt-cache"], modelsProviderId: "kilo", - defaultModelId: "anthropic/claude-haiku-5.5", + defaultModelId: "meta/muse-wonder", apiKeyEnv: ["KILO_API_KEY"], docsUrl: "https://kilo.ai", defaults: { @@ -1345,7 +1359,7 @@ export const GENERATED_PROVIDER_SPECS: readonly BuiltinSpec[] = [ family: "openai-compatible", capabilities: ["tools", "reasoning", "prompt-cache"], modelsProviderId: "llmgateway", - defaultModelId: "claude-haiku-5-5", + defaultModelId: "muse-wonder-1.0", apiKeyEnv: ["LLMGATEWAY_API_KEY"], docsUrl: "https://llmgateway.io/docs", defaults: { @@ -1359,7 +1373,7 @@ export const GENERATED_PROVIDER_SPECS: readonly BuiltinSpec[] = [ family: "openai-compatible", capabilities: ["tools", "reasoning", "prompt-cache"], modelsProviderId: "llmgateway-providers", - defaultModelId: "anthropic/claude-haiku-5-5", + defaultModelId: "meta/muse-wonder-1.0", apiKeyEnv: ["LLMGATEWAY_API_KEY"], docsUrl: "https://llmgateway.io/docs", defaults: { @@ -1422,6 +1436,20 @@ export const GENERATED_PROVIDER_SPECS: readonly BuiltinSpec[] = [ baseUrl: "https://api.longcat.chat/openai", }, }, + { + id: "lowrouter", + name: "LowRouter", + description: "LowRouter model provider from models.dev", + family: "openai-compatible", + capabilities: ["tools", "reasoning", "prompt-cache"], + modelsProviderId: "lowrouter", + defaultModelId: "auto/mistralai/mistral-large-4", + apiKeyEnv: ["LOWROUTER_API_KEY"], + docsUrl: "https://lowrouter.ai/docs/models/available", + defaults: { + baseUrl: "https://api.lowrouter.ai/v1", + }, + }, { id: "lucidquery", name: "LucidQuery", @@ -1485,7 +1513,7 @@ export const GENERATED_PROVIDER_SPECS: readonly BuiltinSpec[] = [ family: "openai", capabilities: ["tools", "reasoning", "prompt-cache"], modelsProviderId: "meta", - defaultModelId: "muse-spark-1.3", + defaultModelId: "muse-wonder-1.0", apiKeyEnv: ["META_MODEL_API_KEY"], docsUrl: "https://dev.meta.ai/docs", defaults: { @@ -1580,9 +1608,9 @@ export const GENERATED_PROVIDER_SPECS: readonly BuiltinSpec[] = [ name: "Moark", description: "Moark model provider from models.dev", family: "openai-compatible", - capabilities: ["tools", "reasoning", "prompt-cache"], + capabilities: ["tools", "reasoning"], modelsProviderId: "moark", - defaultModelId: "MiniMax-M2.1", + defaultModelId: "GLM-5.3", apiKeyEnv: ["MOARK_API_KEY"], docsUrl: "https://moark.com/docs/openapi/v1#tag/%E6%96%87%E6%9C%AC%E7%94%9F%E6%88%90", @@ -1707,7 +1735,7 @@ export const GENERATED_PROVIDER_SPECS: readonly BuiltinSpec[] = [ family: "openai-compatible", capabilities: ["tools", "reasoning", "prompt-cache"], modelsProviderId: "nano-gpt", - defaultModelId: "anthropic/claude-haiku-5.5", + defaultModelId: "gemma-4-26b-a4b-it-chimeraxheretic", apiKeyEnv: ["NANO_GPT_API_KEY"], docsUrl: "https://docs.nano-gpt.com", defaults: { @@ -1721,7 +1749,7 @@ export const GENERATED_PROVIDER_SPECS: readonly BuiltinSpec[] = [ family: "openai-compatible", capabilities: ["tools", "reasoning", "prompt-cache"], modelsProviderId: "nearai", - defaultModelId: "google/gemini-3.5-flash", + defaultModelId: "anthropic/claude-haiku-5-5", apiKeyEnv: ["NEARAI_API_KEY"], docsUrl: "https://docs.near.ai/", defaults: { @@ -1777,7 +1805,7 @@ export const GENERATED_PROVIDER_SPECS: readonly BuiltinSpec[] = [ family: "openai-compatible", capabilities: ["tools", "reasoning", "prompt-cache"], modelsProviderId: "neuralwatt", - defaultModelId: "deepseek-v4.1-flash", + defaultModelId: "mimo-v2.6-pro", apiKeyEnv: ["NEURALWATT_API_KEY"], docsUrl: "https://portal.neuralwatt.com/docs", defaults: { @@ -1800,12 +1828,12 @@ export const GENERATED_PROVIDER_SPECS: readonly BuiltinSpec[] = [ }, { id: "novita-ai", - name: "NovitaAI", - description: "NovitaAI model provider from models.dev", + name: "Novita AI", + description: "Novita AI model provider from models.dev", family: "openai-compatible", capabilities: ["tools", "reasoning", "prompt-cache"], modelsProviderId: "novita-ai", - defaultModelId: "moonshotai/kimi-k3", + defaultModelId: "inclusionai/ling-3.1-flash", apiKeyEnv: ["NOVITA_API_KEY"], docsUrl: "https://novita.ai/docs/guides/introduction", defaults: { @@ -1849,7 +1877,7 @@ export const GENERATED_PROVIDER_SPECS: readonly BuiltinSpec[] = [ family: "openai-compatible", capabilities: ["tools", "reasoning", "prompt-cache"], modelsProviderId: "ofox", - defaultModelId: "openai/gpt-6.1-sol", + defaultModelId: "anthropic/claude-haiku-5.5", apiKeyEnv: ["OFOX_API_KEY"], docsUrl: "https://ofox.ai/docs", defaults: { @@ -1883,16 +1911,16 @@ export const GENERATED_PROVIDER_SPECS: readonly BuiltinSpec[] = [ }, { id: "opencode", - name: "OpenCode Zen", - description: "OpenCode Zen model provider from models.dev", + name: "OpenCode", + description: "OpenCode model provider from models.dev", family: "openai-compatible", capabilities: ["tools", "reasoning", "prompt-cache"], modelsProviderId: "opencode", - defaultModelId: "claude-haiku-5-5", + defaultModelId: "cat-jam-free", apiKeyEnv: ["OPENCODE_API_KEY"], - docsUrl: "https://opencode.ai/docs/zen", + docsUrl: "https://opencode.ai/v2/docs/console/models/", defaults: { - baseUrl: "https://opencode.ai/zen/v1", + baseUrl: "https://opencode.ai/inference/v1", }, }, { @@ -1904,9 +1932,9 @@ export const GENERATED_PROVIDER_SPECS: readonly BuiltinSpec[] = [ modelsProviderId: "opencode-go", defaultModelId: "claude-haiku-5-5", apiKeyEnv: ["OPENCODE_API_KEY"], - docsUrl: "https://opencode.ai/docs/go", + docsUrl: "https://opencode.ai/v2/docs/console/go/", defaults: { - baseUrl: "https://opencode.ai/zen/go/v1", + baseUrl: "https://opencode.ai/inference/go/v1", }, }, { @@ -1930,7 +1958,7 @@ export const GENERATED_PROVIDER_SPECS: readonly BuiltinSpec[] = [ family: "openai-compatible", capabilities: ["tools", "reasoning", "prompt-cache"], modelsProviderId: "openrouter", - defaultModelId: "anthropic/claude-haiku-5.5", + defaultModelId: "meta/muse-wonder", apiKeyEnv: ["OPENROUTER_API_KEY"], docsUrl: "https://openrouter.ai/models", defaults: { @@ -2064,6 +2092,20 @@ export const GENERATED_PROVIDER_SPECS: readonly BuiltinSpec[] = [ baseUrl: "https://inference.poolside.ai/v1", }, }, + { + id: "prime", + name: "Prime Inference", + description: "Prime Inference model provider from models.dev", + family: "openai-compatible", + capabilities: ["tools", "reasoning", "prompt-cache"], + modelsProviderId: "prime", + defaultModelId: "openai/gpt-6.1-sol", + apiKeyEnv: ["PRIME_API_KEY"], + docsUrl: "https://docs.primeintellect.ai/inference/overview", + defaults: { + baseUrl: "https://api.pinference.ai/api/v1", + }, + }, { id: "privatemode-ai", name: "Privatemode AI", @@ -2183,7 +2225,7 @@ export const GENERATED_PROVIDER_SPECS: readonly BuiltinSpec[] = [ family: "openai-compatible", capabilities: ["tools", "reasoning", "prompt-cache"], modelsProviderId: "sapaicore", - defaultModelId: "gemini-3.8-flash", + defaultModelId: "gpt-6.1-sol", apiKeyEnv: ["AICORE_SERVICE_KEY"], docsUrl: "https://help.sap.com/docs/sap-ai-core", }, @@ -2641,17 +2683,6 @@ export const GENERATED_PROVIDER_SPECS: readonly BuiltinSpec[] = [ baseUrl: "https://api.upstage.ai/v1/solar", }, }, - { - id: "v0", - name: "v0", - description: "v0 model provider from models.dev", - family: "openai-compatible", - capabilities: ["tools", "reasoning"], - modelsProviderId: "v0", - defaultModelId: "v0-1.5-lg", - apiKeyEnv: ["V0_API_KEY"], - docsUrl: "https://sdk.vercel.ai/providers/ai-sdk-providers/vercel", - }, { id: "vancine", name: "Vancine", @@ -2673,7 +2704,7 @@ export const GENERATED_PROVIDER_SPECS: readonly BuiltinSpec[] = [ family: "openai-compatible", capabilities: ["tools", "reasoning", "prompt-cache"], modelsProviderId: "vercel-ai-gateway", - defaultModelId: "anthropic/claude-haiku-5.5", + defaultModelId: "meta/muse-wonder-1.0", apiKeyEnv: ["AI_GATEWAY_API_KEY"], docsUrl: "https://github.com/vercel/ai/tree/5eb85cc45a259553501f535b8ac79a77d0e79223/packages/gateway", From f15ed3d98b82ca7a737df185107cc0920744d601 Mon Sep 17 00:00:00 2001 From: Saoud Rizwan <7799382+saoudrizwan@users.noreply.github.com> Date: Tue, 13 Oct 2026 00:52:03 -0700 Subject: [PATCH 02/21] chore(sdk): release v0.0.93 --- bun.lock | 10 +++--- sdk/CHANGELOG.md | 16 +++++++++ sdk/packages/agents/package.json | 2 +- sdk/packages/core/package.json | 2 +- sdk/packages/llms/package.json | 2 +- .../llms/src/catalog/catalog.generated.ts | 34 +++++++++---------- sdk/packages/sdk/package.json | 2 +- sdk/packages/shared/package.json | 2 +- 8 files changed, 43 insertions(+), 27 deletions(-) diff --git a/bun.lock b/bun.lock index a50147db61..78533bbb00 100644 --- a/bun.lock +++ b/bun.lock @@ -643,7 +643,7 @@ }, "sdk/packages/agents": { "name": "@cline/agents", - "version": "0.0.92", + "version": "0.0.93", "dependencies": { "@cline/llms": "workspace:*", "@cline/shared": "workspace:*", @@ -652,7 +652,7 @@ }, "sdk/packages/core": { "name": "@cline/core", - "version": "0.0.92", + "version": "0.0.93", "dependencies": { "@cline/agents": "workspace:*", "@cline/llms": "workspace:*", @@ -690,7 +690,7 @@ }, "sdk/packages/llms": { "name": "@cline/llms", - "version": "0.0.92", + "version": "0.0.93", "dependencies": { "@ai-sdk/amazon-bedrock": "^5.0.98", "@ai-sdk/anthropic": "^4.0.67", @@ -739,14 +739,14 @@ }, "sdk/packages/sdk": { "name": "@cline/sdk", - "version": "0.0.92", + "version": "0.0.93", "dependencies": { "@cline/core": "workspace:*", }, }, "sdk/packages/shared": { "name": "@cline/shared", - "version": "0.0.92", + "version": "0.0.93", "dependencies": { "aws4fetch": "^1.0.20", "jsonrepair": "^3.13.2", diff --git a/sdk/CHANGELOG.md b/sdk/CHANGELOG.md index 1e5cd8a3f0..04a70230b5 100644 --- a/sdk/CHANGELOG.md +++ b/sdk/CHANGELOG.md @@ -1,5 +1,21 @@ # Cline SDK Changelog +## 0.0.93 + +- Plugins load in-process through one registry per process instead of a node/bun subprocess, so the compiled CLI and its hub no longer need a runtime on PATH, and plugins can use the Bun global under the Bun-built hub. `CLINE_PLUGIN_MODE=sandbox` keeps the subprocess sandbox. The registry wraps import, setup, hooks, tools and commands with timeouts and error capture, and tracks each plugin's state (loading/ready/degraded/failed/disabled) with its last error. A failing plugin no longer silently drops its tools: `session.create` accepts a `plugins` policy and returns `pluginIssues`, later failures reach affected sessions as status notices, the hub answers `plugins.list`/`plugins.status`/`plugins.reload` and broadcasts `plugin.status_changed`, and the daemon marks a plugin failed instead of shutting down when an uncaught error comes from it. Each session gets its own copy of every plugin module, so plugins that keep per-session state at module level keep working +- Turning reasoning off now works on every provider, not just a fixed list. Models whose catalog entry has no off option are sent nothing and keep their default, and models without a `reasoning` capability are skipped. Gemini and Vertex send exact `thinkingBudget` values only to models that advertise a budget; level-only Gemini models get a thinking level instead (they rejected `thinkingBudget`). A request never carries both the portable reasoning option and a native provider control (`effort`, `thinking`, `thinkingConfig`, …); the native one wins +- The `cline` provider's default model is the first entry of the Cline recommended-models list, falling back to `anthropic/claude-sonnet-5.5`. `CLINE_DEFAULT_MODEL_ID` moves from Claude Sonnet 5 to Claude Sonnet 5.5, and the `openrouter` provider default is now `anthropic/claude-sonnet-5.5` too +- `estimateRequestInputTokens` and `createTokenEstimator` no longer bill base64 image payloads at the text rate. A ~90 KB screenshot counted as ~30k tokens, so a few images triggered auto compaction long before the transcript was near budget. Each image now adds a flat `ESTIMATED_TOKENS_PER_IMAGE` (1600). New `createImageAwareReplacer` export in `@cline/shared` +- Auto compaction uses the resolved model's context window. When the discovered catalog entry had no limit but the selected-model override did, compaction was handed the bare catalog entry and ran against the wrong budget +- Scheduled runs: queued runs are claimed correctly under `bun:sqlite`, which bound the store's named parameters as NULL so the desktop hub never claimed a queued run and it stayed pending with no error. Runs whose agent turn ends with a provider error (such as a retired model id) are now recorded as failed with the error message instead of succeeding silently, and the run report keeps usage and tool calls. Queued cron occurrences superseded by a newer occurrence of the same schedule are cancelled, so a backlog no longer replays every missed slot back to back +- Rule files that are symlinks are now loaded from config directories instead of skipped. Edits to a link's target are picked up on the next refresh or new session +- `resolveSkillsConfigSearchPaths` scans `.claude/skills`, so skills authored for Claude Code are visible to Cline +- The editor tool advertises `old_text` as a plain string. When the schema allowed null, some models sent null instead of the replacement text. Null is still accepted at runtime and treated as omitted +- Composio connectors: signed-in sessions register Composio's meta tools (search, manage connections, wait for connections, multi-execute), so the agent can find connector tools across toolkits, hand the user a Connect Link for an app that is not connected yet, and run what it found. Connector tools are discovered through meta search; saved desktop connection state no longer supplies model tools. Hosts can supply their own authentication through the new `connectorsRequest` option (`ConnectorsRequest` exported from `@cline/core`), which is also forwarded to scheduled sessions. `resolveComposioToolsStatePath` is renamed to `resolveComposioConnectionStatePath` +- The Cline cloud sign-in helpers in `@cline/core` accept an `AbortSignal`, so hosts can cancel a pending sign-in while it is polling +- Cloud session refreshes update open sessions with titles renamed on the web, and keep renames made locally while a refresh is in flight +- Refreshed the model catalog (6,572 → 6,862 models). Adds the AI/ML API, LowRouter, and Prime Inference providers. models.dev no longer lists v0, so the `v0` provider ships with only its default model, `v0-1.5-md`. OpenCode Go's base URL moves from `https://opencode.ai/zen/go/v1` to `https://opencode.ai/inference/go/v1`. The Cline free list adds Step 5 Preview. Default models change for 302.AI, CrossModel, Cortecs, DigitalOcean, NEAR AI Cloud, and Ofox (Claude Haiku 5.5), DevPass (LLM Gateway), LLM Gateway, and Meta (Muse Wonder 1.0), Aixy (GPT-6.1 Sol), CloudFerro Sherlock and Moark (GLM 5.3), Ambient, NanoGPT, Neuralwatt, and Novita AI + ## 0.0.92 - The AI SDK moves together to one release line: `ai` 7.0.120 and every `@ai-sdk/*` package on `@ai-sdk/provider-utils` 5.0.50 (previously `ai` 7.0.79 with packages bumped one by one). Turning reasoning off no longer sends `reasoning.effort: "none"` to OpenAI models that can't turn it off, such as GPT-6 Astra and GPT-6.1 Sol (GPT-6 Astra answered with a 400, for example from commit message generation); GPT-5.x and GPT-6 Sol/Luna still get `"none"`. Turning reasoning off also no longer sends `thinking: {type: "disabled"}` to Claude models that can't turn thinking off, such as Claude Fable 5 and Opus 5.5 (they answered with `"thinking.type.disabled" is not supported for this model`); other Claude models still get `disabled` diff --git a/sdk/packages/agents/package.json b/sdk/packages/agents/package.json index 7a73bd50eb..84ab3e5ade 100644 --- a/sdk/packages/agents/package.json +++ b/sdk/packages/agents/package.json @@ -1,6 +1,6 @@ { "name": "@cline/agents", - "version": "0.0.92", + "version": "0.0.93", "repository": { "type": "git", "url": "https://github.com/cline/cline", diff --git a/sdk/packages/core/package.json b/sdk/packages/core/package.json index 57fc53c694..d6499f5983 100644 --- a/sdk/packages/core/package.json +++ b/sdk/packages/core/package.json @@ -1,7 +1,7 @@ { "name": "@cline/core", "description": "Cline Core SDK for Node Runtime", - "version": "0.0.92", + "version": "0.0.93", "repository": { "type": "git", "url": "https://github.com/cline/cline", diff --git a/sdk/packages/llms/package.json b/sdk/packages/llms/package.json index 2e473c6f09..d1f3377f31 100644 --- a/sdk/packages/llms/package.json +++ b/sdk/packages/llms/package.json @@ -1,6 +1,6 @@ { "name": "@cline/llms", - "version": "0.0.92", + "version": "0.0.93", "description": "Config-driven SDK for selecting, extending, and instantiating LLM providers and models", "repository": { "type": "git", diff --git a/sdk/packages/llms/src/catalog/catalog.generated.ts b/sdk/packages/llms/src/catalog/catalog.generated.ts index e76b0b2de6..b3c01414e8 100644 --- a/sdk/packages/llms/src/catalog/catalog.generated.ts +++ b/sdk/packages/llms/src/catalog/catalog.generated.ts @@ -14,7 +14,7 @@ export const GENERATED_PROVIDER_MODELS: { version: number providers: Record> } = { - version: 1791870297371, + version: 1791877806756, providers: { "302ai": { "claude-haiku-5-5": { @@ -77188,7 +77188,7 @@ export const GENERATED_PROVIDER_MODELS: { } ], "pricing": { - "input": 0.045, + "input": 0.0441, "output": 0.891, "cacheRead": 0.0054, "cacheWrite": 0 @@ -80591,9 +80591,9 @@ export const GENERATED_PROVIDER_MODELS: { } ], "pricing": { - "input": 0.05, + "input": 0.0425, "output": 1.28, - "cacheRead": 0.05, + "cacheRead": 0.0425, "cacheWrite": 0 }, "releaseDate": "2026-04-24", @@ -114593,9 +114593,9 @@ export const GENERATED_PROVIDER_MODELS: { } ], "pricing": { - "input": 0.22, - "output": 0.66, - "cacheRead": 0.007, + "input": 0.44, + "output": 1.32, + "cacheRead": 0.014, "cacheWrite": 0 }, "releaseDate": "2026-08-21", @@ -142741,7 +142741,7 @@ export const GENERATED_PROVIDER_MODELS: { } ], "pricing": { - "input": 0.045, + "input": 0.0441, "output": 0.891, "cacheRead": 0.0054, "cacheWrite": 0 @@ -143020,7 +143020,7 @@ export const GENERATED_PROVIDER_MODELS: { } ], "pricing": { - "input": 0.045, + "input": 0.0441, "output": 0.891, "cacheRead": 0.0054, "cacheWrite": 0 @@ -143905,9 +143905,9 @@ export const GENERATED_PROVIDER_MODELS: { } ], "pricing": { - "input": 0.66, - "output": 1.98, - "cacheRead": 0.022, + "input": 1.32, + "output": 3.96, + "cacheRead": 0.044, "cacheWrite": 0 }, "releaseDate": "2026-08-12", @@ -146245,9 +146245,9 @@ export const GENERATED_PROVIDER_MODELS: { } ], "pricing": { - "input": 0.05, + "input": 0.0425, "output": 1.28, - "cacheRead": 0.05, + "cacheRead": 0.0425, "cacheWrite": 0 }, "releaseDate": "2026-04-24", @@ -146800,9 +146800,9 @@ export const GENERATED_PROVIDER_MODELS: { } ], "pricing": { - "input": 0.0765, - "output": 0.255, - "cacheRead": 0.0425, + "input": 0.09, + "output": 0.3, + "cacheRead": 0.05, "cacheWrite": 0 }, "releaseDate": "2026-04-02", diff --git a/sdk/packages/sdk/package.json b/sdk/packages/sdk/package.json index 56e181dd01..2685d9d86b 100644 --- a/sdk/packages/sdk/package.json +++ b/sdk/packages/sdk/package.json @@ -1,7 +1,7 @@ { "name": "@cline/sdk", "description": "Cline SDK - user-facing alias for @cline/core", - "version": "0.0.92", + "version": "0.0.93", "repository": { "type": "git", "url": "https://github.com/cline/cline", diff --git a/sdk/packages/shared/package.json b/sdk/packages/shared/package.json index b9a2fc6cfb..41226d4f42 100644 --- a/sdk/packages/shared/package.json +++ b/sdk/packages/shared/package.json @@ -1,6 +1,6 @@ { "name": "@cline/shared", - "version": "0.0.92", + "version": "0.0.93", "description": "Shared utilities, types, and schemas for Cline packages", "repository": { "type": "git", From c96fb0992a70501fc6b1b05f411bb549cf2c3d4d Mon Sep 17 00:00:00 2001 From: Saoud Rizwan <7799382+saoudrizwan@users.noreply.github.com> Date: Tue, 13 Oct 2026 00:57:27 -0700 Subject: [PATCH 03/21] chore(cli): release v3.0.71 --- apps/cli/CHANGELOG.md | 16 ++++++++++++++++ apps/cli/package.json | 2 +- bun.lock | 2 +- 3 files changed, 18 insertions(+), 2 deletions(-) diff --git a/apps/cli/CHANGELOG.md b/apps/cli/CHANGELOG.md index 1674cbce56..d1ba8a4b4f 100644 --- a/apps/cli/CHANGELOG.md +++ b/apps/cli/CHANGELOG.md @@ -1,5 +1,21 @@ # Cline CLI Changelog +## 3.0.71 + +- Plugins now load inside the hub instead of a separate node or bun process, so the CLI no longer needs a JavaScript runtime on your PATH to run plugins. A plugin that fails to load no longer silently loses its tools: Cline prints a warning naming the plugin and the error, `cline config tools --json` lists it as a `plugin_failure` entry, and the new `cline hub plugins` shows each plugin's status and last error (`--reload ` re-imports one without restarting the hub). Set `CLINE_PLUGIN_MODE=sandbox` to keep the old subprocess behavior +- `--thinking` now applies in ACP mode (`--acp`). Before, ACP sessions always ran with reasoning off +- `cline hook` accepts `agent_error` events. Before, it exited with an error and no response +- A provider API key set in the environment (such as `OPENROUTER_API_KEY`) now counts as configured at startup, for providers that read a single environment variable. Before, the TUI asked you to set up the provider anyway +- With no model chosen, the Cline provider now uses the first model in Cline's recommended list (Claude Sonnet 5.5) +- Turning reasoning off now works on every provider. Models that can't turn it off keep their default, and level-only Gemini models no longer reject the request with a thinking-budget error +- Screenshots and other images no longer inflate the token estimate, which made auto-compaction run long before the conversation was near its limit +- Auto-compaction uses the selected model's context window when the catalog entry has none +- Scheduled runs: queued runs now start instead of staying pending forever, a run whose model call fails (for example, a retired model) is marked failed with the error, and after downtime only the latest missed occurrence of a schedule runs instead of every one back to back +- Rule files that are symlinks now load +- Skills in `.claude/skills` are now discovered +- The editor tool no longer receives null in place of the text to replace from some models +- Refreshed the model catalog. Adds the AI/ML API, LowRouter, and Prime Inference providers. The v0 provider now offers only its default model, `v0-1.5-md`, and OpenCode Go uses its new `https://opencode.ai/inference/go/v1` endpoint. The Cline free list adds Step 5 Preview. Default models change for 302.AI, CrossModel, Cortecs, DigitalOcean, NEAR AI Cloud, and Ofox (Claude Haiku 5.5), DevPass (LLM Gateway), LLM Gateway, and Meta (Muse Wonder 1.0), Aixy (GPT-6.1 Sol), CloudFerro Sherlock and Moark (GLM 5.3), Ambient, NanoGPT, Neuralwatt, and Novita AI + ## 3.0.70 - Commit message generation and tasks with reasoning off no longer fail with a 400 error on GPT-6 Astra, GPT-6.1 Sol, Claude Fable 5 or Claude Opus 5.5 diff --git a/apps/cli/package.json b/apps/cli/package.json index 9d4d847a03..19858fe73a 100644 --- a/apps/cli/package.json +++ b/apps/cli/package.json @@ -1,7 +1,7 @@ { "name": "@cline/cli", "displayName": "cline", - "version": "3.0.70", + "version": "3.0.71", "description": "Autonomous coding agent CLI - capable of creating/editing files, running commands, using the browser, and more", "type": "module", "publishConfig": { diff --git a/bun.lock b/bun.lock index 78533bbb00..a3b64e5bb2 100644 --- a/bun.lock +++ b/bun.lock @@ -19,7 +19,7 @@ }, "apps/cli": { "name": "@cline/cli", - "version": "3.0.70", + "version": "3.0.71", "bin": { "cline": "src/index.ts", }, From 1a5c3f84e0933268a9dba14a55a6383a6578414d Mon Sep 17 00:00:00 2001 From: Saoud Rizwan <7799382+saoudrizwan@users.noreply.github.com> Date: Tue, 13 Oct 2026 01:11:16 -0700 Subject: [PATCH 04/21] test(core): follow OpenCode Go's move to /inference/go/v1 in routing test --- .../core/src/services/llms/handler-factory.routing.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/sdk/packages/core/src/services/llms/handler-factory.routing.test.ts b/sdk/packages/core/src/services/llms/handler-factory.routing.test.ts index 3463a47bce..fd491e0856 100644 --- a/sdk/packages/core/src/services/llms/handler-factory.routing.test.ts +++ b/sdk/packages/core/src/services/llms/handler-factory.routing.test.ts @@ -113,7 +113,7 @@ async function expectEndpoint( ); expect(fetchMock).toHaveBeenCalledTimes(1); const [url, init] = fetchMock.mock.calls[0]; - expect(String(url)).toBe(`https://opencode.ai/zen/go/v1/${endpoint}`); + expect(String(url)).toBe(`https://opencode.ai/inference/go/v1/${endpoint}`); const body = JSON.parse(String(init?.body)); expect(body[inputField]).toBeDefined(); if (inputField !== "contents") { From f8997dac23aa4b190a14847ed567f65c3c0d2998 Mon Sep 17 00:00:00 2001 From: Lucien <164857476+dakjdakd@users.noreply.github.com> Date: Tue, 13 Oct 2026 21:09:05 +0800 Subject: [PATCH 05/21] fix(vscode): preserve attachments in pending question responses (#14986) * fix: preserve attachments in VS Code question responses * fix(vscode): preserve pending question response ordering * fix(core): preserve display text in Hub submitted prompt events --- apps/vscode/src/sdk/message-translator.ts | 8 +- .../src/sdk/sdk-followup-coordinator.test.ts | 121 ++++++++++++++- .../src/sdk/sdk-followup-coordinator.ts | 29 +++- .../sdk/sdk-interaction-coordinator.test.ts | 145 +++++++++++++++++- .../src/sdk/sdk-interaction-coordinator.ts | 38 ++++- .../sdk/sdk-task-control-coordinator.test.ts | 2 +- sdk/packages/agents/src/agent-runtime.test.ts | 88 +++++++++++ sdk/packages/agents/src/agent-runtime.ts | 17 +- .../hub/runtime-host/hub-runtime-host.test.ts | 66 +++++++- .../src/hub/runtime-host/hub-runtime-host.ts | 2 + .../src/hub/server/handlers/run-handlers.ts | 5 + .../handlers/session-event-projector.ts | 1 + .../runtime/host/local-runtime-host.test.ts | 36 ++++- .../src/runtime/host/local-runtime-host.ts | 28 +++- .../core/src/runtime/host/runtime-host.ts | 1 + .../turn-queue/pending-prompt-service.ts | 10 +- sdk/packages/core/src/types/events.ts | 2 + sdk/packages/core/src/types/session.ts | 1 + sdk/packages/shared/src/agent.ts | 3 +- sdk/packages/shared/src/agents/types.ts | 9 +- 20 files changed, 582 insertions(+), 30 deletions(-) diff --git a/apps/vscode/src/sdk/message-translator.ts b/apps/vscode/src/sdk/message-translator.ts index 5fd65a9bcd..4edd08dec4 100644 --- a/apps/vscode/src/sdk/message-translator.ts +++ b/apps/vscode/src/sdk/message-translator.ts @@ -2184,7 +2184,7 @@ export function translateSessionEvent(event: CoreSessionEvent, state: MessageTra } case "pending_prompt_submitted": { - const { prompt, userImages, userFiles } = event.payload + const { prompt, displayPrompt, userImages, userFiles } = event.payload // Synthetic prompts (task resumption, plan -> act auto-continue) are // hidden from every other transcript surface, and this echo must // hide them too: a send that races a settling abort is auto-queued @@ -2197,8 +2197,8 @@ export function translateSessionEvent(event: CoreSessionEvent, state: MessageTra // Display boundary: formatDisplayUserInput strips runtime-generated // notice elements (e.g. mode_notice) that normalizeUserInput must // preserve, since the latter also sanitizes model-bound prompts. - const displayPrompt = isSyntheticUserPrompt(prompt) ? "" : formatDisplayUserInput(prompt) - const hasPrompt = displayPrompt.trim().length > 0 + const visiblePrompt = isSyntheticUserPrompt(prompt) ? "" : formatDisplayUserInput(displayPrompt ?? prompt) + const hasPrompt = visiblePrompt.trim().length > 0 const hasImages = (userImages?.length ?? 0) > 0 const hasFiles = (userFiles?.length ?? 0) > 0 if (hasPrompt || hasImages || hasFiles) { @@ -2206,7 +2206,7 @@ export function translateSessionEvent(event: CoreSessionEvent, state: MessageTra ts: state.nextTs(), type: "say", say: "user_feedback", - text: displayPrompt, + text: visiblePrompt, images: userImages, files: userFiles, partial: false, diff --git a/apps/vscode/src/sdk/sdk-followup-coordinator.test.ts b/apps/vscode/src/sdk/sdk-followup-coordinator.test.ts index 70b15f89c4..4721f90607 100644 --- a/apps/vscode/src/sdk/sdk-followup-coordinator.test.ts +++ b/apps/vscode/src/sdk/sdk-followup-coordinator.test.ts @@ -1,6 +1,8 @@ import { beforeEach, describe, expect, it, vi } from "vitest" import type { StateManager } from "@/core/storage/StateManager" +import { MessageTranslatorState, translateSessionEvent } from "./message-translator" import { SdkFollowupCoordinator, type SdkFollowupCoordinatorOptions } from "./sdk-followup-coordinator" +import { SdkInteractionCoordinator } from "./sdk-interaction-coordinator" vi.mock("@/shared/services/Logger", () => ({ Logger: { @@ -36,10 +38,126 @@ describe("SdkFollowupCoordinator", () => { await coordinator.askResponse("answer") - expect(options.interactions.resolvePendingAskQuestion).toHaveBeenCalledWith("answer") + expect(options.interactions.resolvePendingAskQuestion).toHaveBeenCalledWith( + "answer", + undefined, + undefined, + expect.any(Function), + ) expect(options.sessions.fireAndForgetSend).not.toHaveBeenCalled() }) + it.each([ + { prompt: "Use this screenshot", images: ["data:image/png;base64,abc"], files: undefined }, + { prompt: "Use this file", images: undefined, files: ["/workspace/spec.txt"] }, + { prompt: "Use @/spec.txt", images: ["data:image/png;base64,abc"], files: ["/workspace/spec.txt"] }, + { prompt: undefined, images: ["data:image/png;base64,abc"], files: ["/workspace/spec.txt"] }, + ])("steers question attachments before resolving the answer: $prompt", async ({ prompt, images, files }) => { + const activeSession = makeActiveSession({ isRunning: true }) + const { coordinator, options } = makeCoordinator({ activeSession }) + const resolvedPrompt = prompt ? `${prompt}\nImplementation details` : "" + options.resolveContextMentions.mockResolvedValue(resolvedPrompt) + const interactions = new SdkInteractionCoordinator({ + messages: options.messages, + getSessionId: () => activeSession.sessionId, + postStateToWebview: options.postStateToWebview, + }) + options.interactions = interactions as typeof options.interactions + const answer = interactions.handleAskQuestion("Which implementation?", ["A", "B"], undefined) + await vi.waitFor(() => expect(options.postStateToWebview).toHaveBeenCalledOnce()) + let releaseSend!: () => void + activeSession.sdkHost.send.mockImplementation( + () => + new Promise((resolve) => { + releaseSend = resolve + }), + ) + const answered = vi.fn() + void answer.then(answered) + + const submission = coordinator.askResponse(prompt, images, files) + await vi.waitFor(() => expect(activeSession.sdkHost.send).toHaveBeenCalledOnce()) + expect(activeSession.sdkHost.send).toHaveBeenCalledWith({ + sessionId: activeSession.sessionId, + prompt: resolvedPrompt, + ...(prompt !== undefined ? { displayPrompt: prompt } : {}), + userImages: images, + userFiles: files, + delivery: "steer", + }) + expect(answered).not.toHaveBeenCalled() + releaseSend() + await submission + await expect(answer).resolves.toBe(prompt ?? "") + // Core's pending_prompt_submitted event owns the response bubble; + // the interaction coordinator emits only the original question. + expect(options.messages.appendAndEmit).toHaveBeenCalledOnce() + const submitted = translateSessionEvent( + { + type: "pending_prompt_submitted", + payload: { + id: "question-response", + delivery: "steer", + attachmentCount: (images?.length ?? 0) + (files?.length ?? 0), + sessionId: activeSession.sessionId, + prompt: resolvedPrompt, + displayPrompt: prompt ?? "", + userImages: images, + userFiles: files, + }, + }, + new MessageTranslatorState(), + ) + expect(submitted.messages).toEqual([ + expect.objectContaining({ + say: "user_feedback", + text: prompt ?? "", + images, + files, + }), + ]) + expect(options.sessions.fireAndForgetSend).not.toHaveBeenCalled() + expect(options.sessions.startNewSession).not.toHaveBeenCalled() + }) + + it("does not enqueue attachments for a question cancelled during mention resolution", async () => { + const activeSession = makeActiveSession({ isRunning: true }) + const { coordinator, options } = makeCoordinator({ activeSession }) + const interactions = new SdkInteractionCoordinator({ + messages: options.messages, + getSessionId: () => activeSession.sessionId, + postStateToWebview: options.postStateToWebview, + }) + options.interactions = interactions as typeof options.interactions + const answer = interactions.handleAskQuestion("Which implementation?", ["A", "B"], undefined) + await new Promise((resolve) => setTimeout(resolve, 0)) + let resolveMentions!: (prompt: string) => void + options.resolveContextMentions.mockImplementation( + () => + new Promise((resolve) => { + resolveMentions = resolve + }), + ) + const submission = coordinator.askResponse("Use @file", ["image"]) + await vi.waitFor(() => expect(options.resolveContextMentions).toHaveBeenCalledOnce()) + interactions.clearPending("Cancelled") + resolveMentions("resolved mentions") + await submission + await expect(answer).resolves.toBe("") + expect(activeSession.sdkHost.send).not.toHaveBeenCalled() + expect(options.sessions.fireAndForgetSend).not.toHaveBeenCalled() + }) + + it("keeps the submitted task identity while checking pending questions", async () => { + const activeSession = makeActiveSession() + const { coordinator, options } = makeCoordinator({ activeSession, task: makeTask("session-123") }) + const submission = coordinator.askResponse("answer") + options.getTask.mockReturnValue(makeTask("new-task")) + await submission + expect(options.sessions.fireAndForgetSend).not.toHaveBeenCalled() + expect(options.onFollowUpAbandoned).toHaveBeenCalledOnce() + }) + it("sends a follow-up to an idle active session", async () => { const activeSession = makeActiveSession() const { coordinator, options } = makeCoordinator({ activeSession }) @@ -185,6 +303,7 @@ describe("SdkFollowupCoordinator", () => { const sendPromise = coordinator.askResponse("sent during rebuild") expect(options.sessions.startNewSession).not.toHaveBeenCalled() expect(options.sessions.fireAndForgetSend).not.toHaveBeenCalled() + await vi.waitFor(() => expect(runExclusive).toBeDefined()) await runExclusive?.() await sendPromise diff --git a/apps/vscode/src/sdk/sdk-followup-coordinator.ts b/apps/vscode/src/sdk/sdk-followup-coordinator.ts index 2bc2e7086c..6fe734b3cc 100644 --- a/apps/vscode/src/sdk/sdk-followup-coordinator.ts +++ b/apps/vscode/src/sdk/sdk-followup-coordinator.ts @@ -73,12 +73,37 @@ export class SdkFollowupCoordinator { return } - if (this.options.interactions.resolvePendingAskQuestion(prompt)) { + const task = this.options.getTask() + if ( + await this.options.interactions.resolvePendingAskQuestion(prompt, images, files, async (isCurrent) => { + const activeSession = this.options.sessions.getActiveSession() + if (!activeSession) { + throw new Error("No active session to receive the question attachments") + } + const resolvedPrompt = prompt ? await this.options.resolveContextMentions(prompt) : "" + if (!isCurrent()) { + return + } + if (this.options.sessions.getActiveSession() !== activeSession) { + throw new Error("Session changed while preparing the question attachments") + } + // Steer delivers multimodal input inside the current run, after the + // question's tool result and before the next model request. Await the + // enqueue before unblocking the question so it cannot miss that boundary. + await activeSession.sdkHost.send({ + sessionId: activeSession.sessionId, + prompt: resolvedPrompt, + ...(prompt !== undefined ? { displayPrompt: prompt } : {}), + userImages: images, + userFiles: files, + delivery: "steer", + }) + }) + ) { return } const activeSession = this.options.sessions.getActiveSession() - const task = this.options.getTask() const submittedDuringActiveTurn = turnPhaseAtSubmit === "streaming" || turnPhaseAtSubmit === "awaiting_approval" if (activeSession && (activeSession.isRunning || submittedDuringActiveTurn)) { await this.queueToActiveSession(activeSession, task?.taskId, prompt, images, files) diff --git a/apps/vscode/src/sdk/sdk-interaction-coordinator.test.ts b/apps/vscode/src/sdk/sdk-interaction-coordinator.test.ts index a0eb2bd6aa..a20b37b82e 100644 --- a/apps/vscode/src/sdk/sdk-interaction-coordinator.test.ts +++ b/apps/vscode/src/sdk/sdk-interaction-coordinator.test.ts @@ -350,7 +350,7 @@ describe("SdkInteractionCoordinator", () => { await vi.waitFor(() => expect(task.messageStateHandler.getClineMessages()).toHaveLength(1)) await new Promise((resolve) => setTimeout(resolve, 1)) - expect(coordinator.resolvePendingAskQuestion("yes")).toBe(true) + expect(await coordinator.resolvePendingAskQuestion("yes")).toBe(true) await expect(answerPromise).resolves.toBe("yes") expect(task.messageStateHandler.getClineMessages()).toMatchObject([ { type: "ask", ask: "followup" }, @@ -358,6 +358,149 @@ describe("SdkInteractionCoordinator", () => { ]) }) + it.each([ + "text", + "attachments", + ])("routes an overlapping %s response as a follow-up while attachments are delivered", async (kind) => { + const task = createTaskProxy("session-123", vi.fn(), vi.fn()) + const coordinator = new SdkInteractionCoordinator({ + messages: new SdkMessageCoordinator({ getTask: () => task }), + getSessionId: () => "session-123", + postStateToWebview: vi.fn().mockResolvedValue(undefined), + }) + const answer = coordinator.handleAskQuestion("Which design?", ["A", "B"], undefined) + await new Promise((resolve) => setTimeout(resolve, 0)) + const answered = vi.fn() + void answer.then(answered) + let finishDelivery!: () => void + let isCurrent!: () => boolean + const submission = coordinator.resolvePendingAskQuestion("first answer", ["image"], undefined, (check) => { + isCurrent = check + return new Promise((resolve) => { + finishDelivery = resolve + }) + }) + const secondDelivery = vi.fn().mockResolvedValue(undefined) + expect( + await coordinator.resolvePendingAskQuestion( + "second answer", + kind === "attachments" ? ["second image"] : undefined, + undefined, + secondDelivery, + ), + ).toBe(false) + expect(secondDelivery).not.toHaveBeenCalled() + expect(answered).not.toHaveBeenCalled() + expect(isCurrent()).toBe(true) + expect(task.messageStateHandler.getClineMessages()).toHaveLength(1) + finishDelivery() + expect(await submission).toBe(true) + await expect(answer).resolves.toBe("first answer") + expect(isCurrent()).toBe(false) + }) + + it("keeps a question pending if delivering its attachments fails", async () => { + const task = createTaskProxy("session-123", vi.fn(), vi.fn()) + const coordinator = new SdkInteractionCoordinator({ + messages: new SdkMessageCoordinator({ getTask: () => task }), + getSessionId: () => "session-123", + postStateToWebview: vi.fn().mockResolvedValue(undefined), + }) + const answer = coordinator.handleAskQuestion("Continue?", ["Yes"], undefined) + await vi.waitFor(() => expect(task.messageStateHandler.getClineMessages()).toHaveLength(1)) + await new Promise((resolve) => setTimeout(resolve, 0)) + let rejectDelivery!: (error: Error) => void + let failedIsCurrent!: () => boolean + const deliver = vi + .fn() + .mockImplementationOnce( + (check: () => boolean) => + new Promise((_resolve, reject) => { + failedIsCurrent = check + rejectDelivery = reject + }), + ) + .mockResolvedValue(undefined) + const submission = coordinator.resolvePendingAskQuestion("yes", ["image"], undefined, deliver) + const rejected = expect(submission).rejects.toThrow("Could not enqueue") + expect(await coordinator.resolvePendingAskQuestion("too early")).toBe(false) + rejectDelivery(new Error("Could not enqueue")) + await rejected + const retry = coordinator.resolvePendingAskQuestion("yes", ["image"], undefined, deliver) + expect(failedIsCurrent()).toBe(false) + expect(await retry).toBe(true) + await expect(answer).resolves.toBe("yes") + expect(task.messageStateHandler.getClineMessages()).toHaveLength(1) + }) + + it("does not resolve a replacement question while preparing attachments", async () => { + const task = createTaskProxy("session-123", vi.fn(), vi.fn()) + const coordinator = new SdkInteractionCoordinator({ + messages: new SdkMessageCoordinator({ getTask: () => task }), + getSessionId: () => "session-123", + postStateToWebview: vi.fn().mockResolvedValue(undefined), + }) + const answer = coordinator.handleAskQuestion("Continue?", ["Yes"], undefined) + await new Promise((resolve) => setTimeout(resolve, 0)) + let finishDelivery!: () => void + let isCurrent!: () => boolean + const submission = coordinator.resolvePendingAskQuestion("old answer", ["image"], undefined, (check) => { + isCurrent = check + return new Promise((resolve) => { + finishDelivery = resolve + }) + }) + coordinator.clearPending("Cancelled") + await expect(answer).resolves.toBe("") + const nextAnswer = coordinator.handleAskQuestion("Next?", ["A"], undefined) + await new Promise((resolve) => setTimeout(resolve, 0)) + expect(isCurrent()).toBe(false) + finishDelivery() + expect(await submission).toBe(true) + expect(await coordinator.resolvePendingAskQuestion("new answer")).toBe(true) + await expect(nextAnswer).resolves.toBe("new answer") + }) + + it("does not release a replacement response when a cancelled attachment delivery rejects", async () => { + const task = createTaskProxy("session-123", vi.fn(), vi.fn()) + const coordinator = new SdkInteractionCoordinator({ + messages: new SdkMessageCoordinator({ getTask: () => task }), + getSessionId: () => "session-123", + postStateToWebview: vi.fn().mockResolvedValue(undefined), + }) + const answer = coordinator.handleAskQuestion("Continue?", ["Yes"], undefined) + await new Promise((resolve) => setTimeout(resolve, 0)) + let rejectOldDelivery!: (error: Error) => void + let oldIsCurrent!: () => boolean + const oldSubmission = coordinator.resolvePendingAskQuestion("old answer", ["image"], undefined, (check) => { + oldIsCurrent = check + return new Promise((_resolve, reject) => { + rejectOldDelivery = reject + }) + }) + const oldRejected = expect(oldSubmission).rejects.toThrow("Old delivery failed") + coordinator.clearPending("Cancelled") + await expect(answer).resolves.toBe("") + const nextAnswer = coordinator.handleAskQuestion("Next?", ["A"], undefined) + await new Promise((resolve) => setTimeout(resolve, 0)) + let finishNewDelivery!: () => void + let newIsCurrent!: () => boolean + const nextSubmission = coordinator.resolvePendingAskQuestion("new answer", ["new image"], undefined, (check) => { + newIsCurrent = check + return new Promise((resolve) => { + finishNewDelivery = resolve + }) + }) + expect(oldIsCurrent()).toBe(false) + rejectOldDelivery(new Error("Old delivery failed")) + await oldRejected + expect(newIsCurrent()).toBe(true) + expect(await coordinator.resolvePendingAskQuestion("overlapping answer")).toBe(false) + finishNewDelivery() + expect(await nextSubmission).toBe(true) + await expect(nextAnswer).resolves.toBe("new answer") + }) + it("shows an error row and stops immediately when the mistake limit is reached", async () => { const task = createTaskProxy("session-123", vi.fn(), vi.fn()) const setTurnPhase = vi.fn() diff --git a/apps/vscode/src/sdk/sdk-interaction-coordinator.ts b/apps/vscode/src/sdk/sdk-interaction-coordinator.ts index 2c51d18a10..0834808eca 100644 --- a/apps/vscode/src/sdk/sdk-interaction-coordinator.ts +++ b/apps/vscode/src/sdk/sdk-interaction-coordinator.ts @@ -53,6 +53,7 @@ export interface SdkInteractionCoordinatorOptions { export class SdkInteractionCoordinator { private pendingAskResolve: ((answer: string) => void) | undefined + private claimedAskResponse: { resolve: (answer: string) => void } | undefined private pendingToolApprovalResolve: ((result: { approved: boolean; reason?: string }) => void) | undefined private pendingToolApprovalMessage: | { @@ -237,22 +238,52 @@ export class SdkInteractionCoordinator { return true } - resolvePendingAskQuestion(prompt: string | undefined): boolean { - if (!this.pendingAskResolve) { + async resolvePendingAskQuestion( + prompt: string | undefined, + images?: string[], + files?: string[], + deliverAttachments?: (isCurrent: () => boolean) => Promise, + ): Promise { + if (!this.pendingAskResolve || this.claimedAskResponse?.resolve === this.pendingAskResolve) { return false } const resolve = this.pendingAskResolve + // Claim before asynchronous delivery; later submissions use normal follow-up routing. + const response = { resolve } + this.claimedAskResponse = response + const hasAttachments = !!(images?.length || files?.length) + if (hasAttachments && deliverAttachments) { + try { + await deliverAttachments(() => this.pendingAskResolve === resolve && this.claimedAskResponse === response) + } catch (error) { + // Release only this response's claim so the still-pending question can be retried. + if (this.claimedAskResponse === response) { + this.claimedAskResponse = undefined + } + throw error + } + // Cancellation may have settled this question while input was prepared. + // Never resolve a replacement question with the old response. + if (this.pendingAskResolve !== resolve) { + return true + } + } this.pendingAskResolve = undefined + this.claimedAskResponse = undefined const responseText = prompt ?? "" Logger.log(`[SdkController] Resolving pending ask_question with: "${responseText.substring(0, 80)}"`) - if (responseText) { + // Core echoes steered input, including its attachments, when consumed. + // Keep exactly one user response in the visible transcript. + if ((responseText || hasAttachments) && !(hasAttachments && deliverAttachments)) { const userMessage: ClineMessage = { ts: this.nextMessageTs(), type: "say", say: "user_feedback", text: responseText, + images, + files, partial: false, } this.options.messages.appendAndEmit([userMessage], { @@ -270,6 +301,7 @@ export class SdkInteractionCoordinator { clearPending(reason: string): void { const resolveAsk = this.pendingAskResolve this.pendingAskResolve = undefined + this.claimedAskResponse = undefined // ask_question is awaiting this promise inside the outgoing agent run. Settle it // before session teardown so the run can unwind instead of remaining suspended; // use an empty answer so the lifecycle reason is not presented as user input. diff --git a/apps/vscode/src/sdk/sdk-task-control-coordinator.test.ts b/apps/vscode/src/sdk/sdk-task-control-coordinator.test.ts index aeac15ee25..c38b520b05 100644 --- a/apps/vscode/src/sdk/sdk-task-control-coordinator.test.ts +++ b/apps/vscode/src/sdk/sdk-task-control-coordinator.test.ts @@ -244,7 +244,7 @@ describe("SdkTaskControlCoordinator", () => { await coordinator.showTaskWithId("new-task") await expect(questionPromise).resolves.toBe("") - expect(interactions.resolvePendingAskQuestion("late answer")).toBe(false) + expect(await interactions.resolvePendingAskQuestion("late answer")).toBe(false) }) it("shows a legacy task with a warning and a resume ask", async () => { diff --git a/sdk/packages/agents/src/agent-runtime.test.ts b/sdk/packages/agents/src/agent-runtime.test.ts index 349f0ce33c..56556e101a 100644 --- a/sdk/packages/agents/src/agent-runtime.test.ts +++ b/sdk/packages/agents/src/agent-runtime.test.ts @@ -2243,6 +2243,94 @@ describe("AgentRuntime", () => { expect(result.outputText).toBe("done"); }); + it("delivers a multimodal question response after its tool result in the same run", async () => { + let pending: AgentMessage | undefined; + let resolveAnswer!: (answer: string) => void; + const questionStarted = Promise.withResolvers(); + const response: AgentMessage = { + id: "question-response", + role: "user", + createdAt: Date.now(), + content: [ + { type: "text", text: "Use this design" }, + { type: "image", image: "abc", mediaType: "image/png" }, + { type: "file", path: "spec.txt", content: "Use the existing API." }, + ], + metadata: { source: "question" }, + }; + const model = new ScriptedModel([ + () => [ + { + type: "tool-call-delta", + toolCallId: "question", + toolName: "ask_question", + inputText: "{}", + }, + { type: "finish", reason: "tool-calls" }, + ], + (request) => { + expect(request.messages.at(-2)).toMatchObject({ + role: "tool", + content: [ + { + type: "tool-result", + toolCallId: "question", + output: "Use this design", + }, + ], + }); + expect(request.messages.at(-1)).toMatchObject({ + ...response, + metadata: { source: "question", userRunSpan: 0 }, + }); + return [ + { type: "text-delta", text: "Design received" }, + { type: "finish", reason: "stop" }, + ]; + }, + ]); + const runtime = new AgentRuntime({ + model, + tools: [ + { + name: "ask_question", + description: "Ask the user", + inputSchema: { type: "object" }, + execute: async () => { + const answer = new Promise((resolve) => { + resolveAnswer = resolve; + }); + questionStarted.resolve(); + return answer; + }, + }, + ], + consumePendingUserMessage: async () => { + const message = pending; + pending = undefined; + return message; + }, + }); + const run = runtime.run("Start"); + await questionStarted.promise; + pending = response; + runtime.notifyPendingUserMessage(); + resolveAnswer("Use this design"); + const result = await run; + expect(result.status).toBe("completed"); + expect(model.requests).toHaveLength(2); + expect( + result.messages.filter((message) => message.id === response.id), + ).toHaveLength(1); + expect(result.messages.map((message) => message.role)).toEqual([ + "user", + "assistant", + "tool", + "user", + "assistant", + ]); + }); + it("injects a pending user message after tool results and before the next model request", async () => { const consumePendingUserMessage = vi .fn() diff --git a/sdk/packages/agents/src/agent-runtime.ts b/sdk/packages/agents/src/agent-runtime.ts index 57e96692b5..518871b9c4 100644 --- a/sdk/packages/agents/src/agent-runtime.ts +++ b/sdk/packages/agents/src/agent-runtime.ts @@ -2294,13 +2294,20 @@ export class AgentRuntime { if (!consumePendingUserMessage) { return undefined; } - const pending = (await consumePendingUserMessage())?.trim(); - if (!pending) { + const pending = await consumePendingUserMessage(); + if (!pending || (typeof pending === "string" && !pending.trim())) { return undefined; } - const message = createMessage("user", [{ type: "text", text: pending }], { - userRunSpan: 0, - }); + const message = + typeof pending === "string" + ? createMessage("user", [{ type: "text", text: pending.trim() }], { + userRunSpan: 0, + }) + : { + ...cloneMessages([pending])[0], + role: "user" as const, + metadata: { ...pending.metadata, userRunSpan: 0 }, + }; this.state.messages.push(message); await this.emit({ type: "message-added", diff --git a/sdk/packages/core/src/hub/runtime-host/hub-runtime-host.test.ts b/sdk/packages/core/src/hub/runtime-host/hub-runtime-host.test.ts index f24553d305..5df3a42b0c 100644 --- a/sdk/packages/core/src/hub/runtime-host/hub-runtime-host.test.ts +++ b/sdk/packages/core/src/hub/runtime-host/hub-runtime-host.test.ts @@ -3,7 +3,11 @@ import { afterEach, describe, expect, it, vi } from "vitest"; import { version as corePackageVersion } from "../../../package.json"; import { createSessionCompactionState } from "../../session/models/session-compaction"; import { SessionSource } from "../../types/common"; -import type { HubTransportContext } from "../server/handlers/context"; +import type { CoreSessionEvent } from "../../types/events"; +import { + buildHubEvent, + type HubTransportContext, +} from "../server/handlers/context"; import { projectSessionEvent } from "../server/handlers/session-event-projector"; const commandMock = vi.hoisted(() => vi.fn()); @@ -82,6 +86,66 @@ describe("HubRuntimeHost", () => { restartLocalHubIfIdleAfterStartupTimeoutMock.mockReset(); }); + it.each([ + "Use @/spec.txt", + "", + undefined, + ])("preserves submitted prompt display text through both Hub event copies: %s", async (displayPrompt) => { + let onEvent: ((event: HubEventEnvelope) => void) | undefined; + subscribeMock.mockImplementation((listener) => { + onEvent = listener; + return () => {}; + }); + commandMock.mockResolvedValue({ ok: true, payload: { result: undefined } }); + const { HubRuntimeHost } = await import("./hub-runtime-host"); + const host = new HubRuntimeHost({ url: "ws://127.0.0.1:25463/hub" }); + const events: CoreSessionEvent[] = []; + host.subscribe((event) => events.push(event)); + const prompt = + 'Use @/spec.txt\nReference details'; + await host.runTurn({ + sessionId: "sess-1", + prompt, + displayPrompt, + delivery: "steer", + }); + const published: HubEventEnvelope[] = []; + const ctx = { + buildEvent: buildHubEvent, + publish: (event: HubEventEnvelope) => { + published.push(event); + onEvent?.(event); + }, + } as unknown as HubTransportContext; + const payload = { + sessionId: "sess-1", + id: "pending-1", + prompt, + displayPrompt, + delivery: "steer" as const, + attachmentCount: 2, + userImages: ["data:image/png;base64,abc"], + userFiles: ["/tmp/project/spec.txt"], + }; + await projectSessionEvent(ctx, { + type: "pending_prompt_submitted", + payload, + }); + expect(published).toHaveLength(1); + expect(published[0]?.payload?.prompt).toEqual({ + id: payload.id, + prompt, + displayPrompt, + delivery: payload.delivery, + attachmentCount: payload.attachmentCount, + userImages: payload.userImages, + userFiles: payload.userFiles, + }); + expect( + events.filter((event) => event.type === "pending_prompt_submitted"), + ).toEqual([{ type: "pending_prompt_submitted", payload }]); + }); + it("does not auto-start a run during session creation", async () => { subscribeMock.mockReturnValue(() => {}); commandMock.mockResolvedValue({ diff --git a/sdk/packages/core/src/hub/runtime-host/hub-runtime-host.ts b/sdk/packages/core/src/hub/runtime-host/hub-runtime-host.ts index 4480578e44..db3c1d5bc1 100644 --- a/sdk/packages/core/src/hub/runtime-host/hub-runtime-host.ts +++ b/sdk/packages/core/src/hub/runtime-host/hub-runtime-host.ts @@ -1180,6 +1180,7 @@ export class HubRuntimeHost implements RuntimeHost { { sessionId: input.sessionId, input: input.prompt, + displayPrompt: input.displayPrompt, mode: input.mode, attachments: (input.userImages?.length ?? 0) > 0 || @@ -2023,6 +2024,7 @@ export class HubRuntimeHost implements RuntimeHost { sessionId, id: prompt.id, prompt: prompt.prompt, + displayPrompt: prompt.displayPrompt, delivery: prompt.delivery, attachmentCount: prompt.attachmentCount, userImages: prompt.userImages, diff --git a/sdk/packages/core/src/hub/server/handlers/run-handlers.ts b/sdk/packages/core/src/hub/server/handlers/run-handlers.ts index 5effe4bf62..0765d7b506 100644 --- a/sdk/packages/core/src/hub/server/handlers/run-handlers.ts +++ b/sdk/packages/core/src/hub/server/handlers/run-handlers.ts @@ -216,6 +216,10 @@ export async function handleSessionInput( : typeof payload.input === "string" ? payload.input : ""; + const displayPrompt = + typeof payload.displayPrompt === "string" + ? payload.displayPrompt + : undefined; const attachments = payload.attachments && typeof payload.attachments === "object" && @@ -267,6 +271,7 @@ export async function handleSessionInput( { sessionId, prompt, + displayPrompt, mode: parseTurnMode(payload.mode), delivery: payload.delivery === "queue" || payload.delivery === "steer" diff --git a/sdk/packages/core/src/hub/server/handlers/session-event-projector.ts b/sdk/packages/core/src/hub/server/handlers/session-event-projector.ts index 988ffa9b58..625e770317 100644 --- a/sdk/packages/core/src/hub/server/handlers/session-event-projector.ts +++ b/sdk/packages/core/src/hub/server/handlers/session-event-projector.ts @@ -80,6 +80,7 @@ export async function projectSessionEvent( const prompt: SessionPendingPrompt = { id: event.payload.id, prompt: event.payload.prompt, + displayPrompt: event.payload.displayPrompt, delivery: event.payload.delivery, attachmentCount: event.payload.attachmentCount, userImages: event.payload.userImages, diff --git a/sdk/packages/core/src/runtime/host/local-runtime-host.test.ts b/sdk/packages/core/src/runtime/host/local-runtime-host.test.ts index 7df2df8f15..d27f6de451 100644 --- a/sdk/packages/core/src/runtime/host/local-runtime-host.test.ts +++ b/sdk/packages/core/src/runtime/host/local-runtime-host.test.ts @@ -2946,7 +2946,7 @@ describe("LocalRuntimeHost", () => { ]); }); - it("wraps consumed steer prompts as mode-scoped user input", async () => { + it("wraps consumed steer prompts as mode-scoped user input and preserves attachments", async () => { const sessionId = "sess-steer-format"; const manifest = createManifest(sessionId); const sessionService = { @@ -3019,6 +3019,40 @@ describe("LocalRuntimeHost", () => { agentConfig?.consumePendingUserMessage?.(), ); expect(consumed).toBe('steer this'); + const attachmentDir = mkdtempSync( + join(tmpdir(), "cline-question-attachments-"), + ); + const attachmentPath = join(attachmentDir, "spec.txt"); + writeFileSync(attachmentPath, "Implementation must use the existing API."); + try { + await manager.runTurn({ + sessionId, + prompt: "Use these attachments", + mode: "plan", + delivery: "steer", + userImages: ["data:image/png;base64,abc"], + userFiles: [attachmentPath], + }); + const multimodal = await agentConfig?.consumePendingUserMessage?.(); + expect(multimodal).toMatchObject({ + role: "user", + content: [ + { + type: "text", + text: 'Use these attachments', + }, + { type: "image", image: "abc", mediaType: "image/png" }, + { + type: "file", + path: attachmentPath.replace(/\\/g, "/"), + content: "Implementation must use the existing API.", + }, + ], + }); + expect(await agentConfig?.consumePendingUserMessage?.()).toBeUndefined(); + } finally { + rmSync(attachmentDir, { recursive: true, force: true }); + } // The agent receives a session-scoped view over the host telemetry. const capture = vi.spyOn(telemetry, "capture"); agentConfig?.telemetry?.capture({ diff --git a/sdk/packages/core/src/runtime/host/local-runtime-host.ts b/sdk/packages/core/src/runtime/host/local-runtime-host.ts index a2339b29aa..768ab4cda4 100644 --- a/sdk/packages/core/src/runtime/host/local-runtime-host.ts +++ b/sdk/packages/core/src/runtime/host/local-runtime-host.ts @@ -101,6 +101,7 @@ import type { ActiveSession, PreparedTurnInput } from "../../types/session"; import type { SessionRecord } from "../../types/sessions"; import type { RuntimeCapabilities } from "../capabilities"; import { normalizeRuntimeCapabilities } from "../capabilities"; +import { messagesToAgentMessages } from "../config/agent-message-codec"; import { normalizeConnectionUpdate } from "../config/connection-update"; import { DefaultRuntimeBuilder } from "../orchestration/runtime-builder"; import { @@ -110,6 +111,7 @@ import { } from "../orchestration/runtime-oauth-token-manager"; import type { RuntimeBuilder } from "../orchestration/session-runtime"; import { SessionRuntime } from "../orchestration/session-runtime-orchestrator"; +import { buildInitialUserContent } from "../orchestration/user-input-builder"; import { PendingPromptsController } from "../turn-queue/pending-prompt-service"; import { manifestToSessionRecord } from "./history"; import { AgentEventBridge } from "./local/agent-event-bridge"; @@ -858,14 +860,25 @@ export class LocalRuntimeHost implements RuntimeHost { onConsecutiveMistakeLimitReached: configWithProvider.onConsecutiveMistakeLimitReached, completionPolicy: runtime.completionPolicy, - consumePendingUserMessage: () => { + consumePendingUserMessage: async () => { const entry = this.pendingPromptsController.consumeSteer(sessionId); - return entry - ? formatModePrompt( - entry.prompt, - entry.mode ?? configWithProvider.mode, - ) - : undefined; + if (!entry) return undefined; + const prompt = formatModePrompt( + entry.prompt, + entry.mode ?? configWithProvider.mode, + ); + if (!entry.userImages?.length && !entry.userFiles?.length) { + return prompt; + } + const content = await buildInitialUserContent( + prompt, + entry.userImages, + entry.userFiles, + loadUserFileContent, + ); + return messagesToAgentMessages([ + { id: crypto.randomUUID(), role: "user", content }, + ])[0]; }, logger: runtime.logger ?? configWithProvider.logger, extensionContext: configWithProvider.extensionContext, @@ -1158,6 +1171,7 @@ export class LocalRuntimeHost implements RuntimeHost { if (delivery === "queue" || delivery === "steer") { this.pendingPromptsController.enqueue(input.sessionId, { prompt: input.prompt, + displayPrompt: input.displayPrompt, mode: input.mode, delivery, userImages: input.userImages, diff --git a/sdk/packages/core/src/runtime/host/runtime-host.ts b/sdk/packages/core/src/runtime/host/runtime-host.ts index 0c3c3a13c1..ea4717ef69 100644 --- a/sdk/packages/core/src/runtime/host/runtime-host.ts +++ b/sdk/packages/core/src/runtime/host/runtime-host.ts @@ -261,6 +261,7 @@ export interface StartSessionResult { export interface SendSessionInput { sessionId: string; prompt: string; + displayPrompt?: string; mode?: AgentMode; userImages?: string[]; userFiles?: string[]; diff --git a/sdk/packages/core/src/runtime/turn-queue/pending-prompt-service.ts b/sdk/packages/core/src/runtime/turn-queue/pending-prompt-service.ts index ff3fdb7041..6e18bf7b31 100644 --- a/sdk/packages/core/src/runtime/turn-queue/pending-prompt-service.ts +++ b/sdk/packages/core/src/runtime/turn-queue/pending-prompt-service.ts @@ -16,6 +16,7 @@ export type PendingPromptDelivery = "queue" | "steer"; export interface PendingPromptEntry { id: string; prompt: string; + displayPrompt?: string; mode?: AgentMode; delivery: PendingPromptDelivery; userImages?: string[]; @@ -40,6 +41,7 @@ export interface PendingPromptsControllerDeps { export interface PendingPromptEnqueueInput { prompt: string; + displayPrompt?: string; mode?: AgentMode; delivery: PendingPromptDelivery; userImages?: string[]; @@ -138,7 +140,8 @@ export class PendingPromptService { state: PendingPromptQueueState, input: PendingPromptEnqueueInput, ): SessionPendingPrompt[] { - const { prompt, mode, delivery, userImages, userFiles } = input; + const { prompt, displayPrompt, mode, delivery, userImages, userFiles } = + input; const existingIndex = state.pendingPrompts.findIndex( (queued) => queued.prompt === prompt, ); @@ -147,6 +150,7 @@ export class PendingPromptService { const next: PendingPromptEntry = { ...existing, prompt, + displayPrompt: displayPrompt ?? existing.displayPrompt, mode: mode ?? existing.mode, userImages: userImages ?? existing.userImages, userFiles: userFiles ?? existing.userFiles, @@ -160,6 +164,7 @@ export class PendingPromptService { const newEntry: PendingPromptEntry = { id: `pending_${Date.now()}_${nanoid(5)}`, prompt, + displayPrompt, mode, delivery, userImages, @@ -257,6 +262,7 @@ export class PendingPromptsController { sessionId: string, entry: { prompt: string; + displayPrompt?: string; mode?: AgentMode; delivery: "queue" | "steer"; userImages?: string[]; @@ -382,6 +388,7 @@ export class PendingPromptsController { sessionId: session.sessionId, id: prompt.id, prompt: prompt.prompt, + displayPrompt: prompt.displayPrompt, delivery: prompt.delivery, attachmentCount: prompt.attachmentCount, userImages: prompt.userImages, @@ -404,6 +411,7 @@ function snapshotPrompt(entry: PendingPromptEntry): SessionPendingPrompt { return { id: entry.id, prompt: entry.prompt, + displayPrompt: entry.displayPrompt, delivery: entry.delivery, attachmentCount: (entry.userImages?.length ?? 0) + (entry.userFiles?.length ?? 0), diff --git a/sdk/packages/core/src/types/events.ts b/sdk/packages/core/src/types/events.ts index d14da8484b..bd6b6306f6 100644 --- a/sdk/packages/core/src/types/events.ts +++ b/sdk/packages/core/src/types/events.ts @@ -41,6 +41,7 @@ export interface SessionTeamProgressEvent { export interface SessionPendingPrompt { id: string; prompt: string; + displayPrompt?: string; delivery: "queue" | "steer"; attachmentCount: number; userImages?: string[]; @@ -56,6 +57,7 @@ export interface SessionPendingPromptSubmittedEvent { sessionId: string; id: string; prompt: string; + displayPrompt?: string; delivery: "queue" | "steer"; attachmentCount: number; userImages?: string[]; diff --git a/sdk/packages/core/src/types/session.ts b/sdk/packages/core/src/types/session.ts index b21d5359d8..a4ac94a48c 100644 --- a/sdk/packages/core/src/types/session.ts +++ b/sdk/packages/core/src/types/session.ts @@ -68,6 +68,7 @@ export type ActiveSession = { export type PendingPrompt = { id: string; prompt: string; + displayPrompt?: string; mode?: CoreAgentMode; delivery: "queue" | "steer"; userImages?: string[]; diff --git a/sdk/packages/shared/src/agent.ts b/sdk/packages/shared/src/agent.ts index b280ee2d48..cdbaef0efe 100644 --- a/sdk/packages/shared/src/agent.ts +++ b/sdk/packages/shared/src/agent.ts @@ -609,8 +609,9 @@ export interface AgentRuntimeConfig { // model request. consumePendingUserMessage?: () => | string + | AgentMessage | undefined - | Promise; + | Promise; } // ============================================================================= diff --git a/sdk/packages/shared/src/agents/types.ts b/sdk/packages/shared/src/agents/types.ts index 712ce2b849..8fa5d244aa 100644 --- a/sdk/packages/shared/src/agents/types.ts +++ b/sdk/packages/shared/src/agents/types.ts @@ -12,6 +12,7 @@ import { z } from "zod"; import type { + AgentMessage, AgentRuntimeHooks, AgentTool, ProviderErrorClass, @@ -904,12 +905,16 @@ export interface AgentConfig { /** * Optional callback invoked at the top of each agent loop iteration - * (after the first). If it returns a non-empty string, that string is + * (after the first). A non-empty string or a multimodal AgentMessage is * injected as a user message into the conversation before the next API * call. This allows the host to feed user input into a running loop * without waiting for the current run to finish. */ - consumePendingUserMessage?: () => string | undefined; + consumePendingUserMessage?: () => + | string + | AgentMessage + | undefined + | Promise; // ------------------------------------------------------------------------- // Cancellation From c606894cb14dd4ce5b76e26fa8625edecad25548 Mon Sep 17 00:00:00 2001 From: Saoud Rizwan <7799382+saoudrizwan@users.noreply.github.com> Date: Tue, 13 Oct 2026 07:31:32 -0700 Subject: [PATCH 06/21] Revert "fix(vscode): preserve attachments in pending question responses (#14986)" (#15058) This reverts commit f8997dac23aa4b190a14847ed567f65c3c0d2998. --- apps/vscode/src/sdk/message-translator.ts | 8 +- .../src/sdk/sdk-followup-coordinator.test.ts | 121 +-------------- .../src/sdk/sdk-followup-coordinator.ts | 29 +--- .../sdk/sdk-interaction-coordinator.test.ts | 145 +----------------- .../src/sdk/sdk-interaction-coordinator.ts | 38 +---- .../sdk/sdk-task-control-coordinator.test.ts | 2 +- sdk/packages/agents/src/agent-runtime.test.ts | 88 ----------- sdk/packages/agents/src/agent-runtime.ts | 17 +- .../hub/runtime-host/hub-runtime-host.test.ts | 66 +------- .../src/hub/runtime-host/hub-runtime-host.ts | 2 - .../src/hub/server/handlers/run-handlers.ts | 5 - .../handlers/session-event-projector.ts | 1 - .../runtime/host/local-runtime-host.test.ts | 36 +---- .../src/runtime/host/local-runtime-host.ts | 28 +--- .../core/src/runtime/host/runtime-host.ts | 1 - .../turn-queue/pending-prompt-service.ts | 10 +- sdk/packages/core/src/types/events.ts | 2 - sdk/packages/core/src/types/session.ts | 1 - sdk/packages/shared/src/agent.ts | 3 +- sdk/packages/shared/src/agents/types.ts | 9 +- 20 files changed, 30 insertions(+), 582 deletions(-) diff --git a/apps/vscode/src/sdk/message-translator.ts b/apps/vscode/src/sdk/message-translator.ts index 4edd08dec4..5fd65a9bcd 100644 --- a/apps/vscode/src/sdk/message-translator.ts +++ b/apps/vscode/src/sdk/message-translator.ts @@ -2184,7 +2184,7 @@ export function translateSessionEvent(event: CoreSessionEvent, state: MessageTra } case "pending_prompt_submitted": { - const { prompt, displayPrompt, userImages, userFiles } = event.payload + const { prompt, userImages, userFiles } = event.payload // Synthetic prompts (task resumption, plan -> act auto-continue) are // hidden from every other transcript surface, and this echo must // hide them too: a send that races a settling abort is auto-queued @@ -2197,8 +2197,8 @@ export function translateSessionEvent(event: CoreSessionEvent, state: MessageTra // Display boundary: formatDisplayUserInput strips runtime-generated // notice elements (e.g. mode_notice) that normalizeUserInput must // preserve, since the latter also sanitizes model-bound prompts. - const visiblePrompt = isSyntheticUserPrompt(prompt) ? "" : formatDisplayUserInput(displayPrompt ?? prompt) - const hasPrompt = visiblePrompt.trim().length > 0 + const displayPrompt = isSyntheticUserPrompt(prompt) ? "" : formatDisplayUserInput(prompt) + const hasPrompt = displayPrompt.trim().length > 0 const hasImages = (userImages?.length ?? 0) > 0 const hasFiles = (userFiles?.length ?? 0) > 0 if (hasPrompt || hasImages || hasFiles) { @@ -2206,7 +2206,7 @@ export function translateSessionEvent(event: CoreSessionEvent, state: MessageTra ts: state.nextTs(), type: "say", say: "user_feedback", - text: visiblePrompt, + text: displayPrompt, images: userImages, files: userFiles, partial: false, diff --git a/apps/vscode/src/sdk/sdk-followup-coordinator.test.ts b/apps/vscode/src/sdk/sdk-followup-coordinator.test.ts index 4721f90607..70b15f89c4 100644 --- a/apps/vscode/src/sdk/sdk-followup-coordinator.test.ts +++ b/apps/vscode/src/sdk/sdk-followup-coordinator.test.ts @@ -1,8 +1,6 @@ import { beforeEach, describe, expect, it, vi } from "vitest" import type { StateManager } from "@/core/storage/StateManager" -import { MessageTranslatorState, translateSessionEvent } from "./message-translator" import { SdkFollowupCoordinator, type SdkFollowupCoordinatorOptions } from "./sdk-followup-coordinator" -import { SdkInteractionCoordinator } from "./sdk-interaction-coordinator" vi.mock("@/shared/services/Logger", () => ({ Logger: { @@ -38,126 +36,10 @@ describe("SdkFollowupCoordinator", () => { await coordinator.askResponse("answer") - expect(options.interactions.resolvePendingAskQuestion).toHaveBeenCalledWith( - "answer", - undefined, - undefined, - expect.any(Function), - ) + expect(options.interactions.resolvePendingAskQuestion).toHaveBeenCalledWith("answer") expect(options.sessions.fireAndForgetSend).not.toHaveBeenCalled() }) - it.each([ - { prompt: "Use this screenshot", images: ["data:image/png;base64,abc"], files: undefined }, - { prompt: "Use this file", images: undefined, files: ["/workspace/spec.txt"] }, - { prompt: "Use @/spec.txt", images: ["data:image/png;base64,abc"], files: ["/workspace/spec.txt"] }, - { prompt: undefined, images: ["data:image/png;base64,abc"], files: ["/workspace/spec.txt"] }, - ])("steers question attachments before resolving the answer: $prompt", async ({ prompt, images, files }) => { - const activeSession = makeActiveSession({ isRunning: true }) - const { coordinator, options } = makeCoordinator({ activeSession }) - const resolvedPrompt = prompt ? `${prompt}\nImplementation details` : "" - options.resolveContextMentions.mockResolvedValue(resolvedPrompt) - const interactions = new SdkInteractionCoordinator({ - messages: options.messages, - getSessionId: () => activeSession.sessionId, - postStateToWebview: options.postStateToWebview, - }) - options.interactions = interactions as typeof options.interactions - const answer = interactions.handleAskQuestion("Which implementation?", ["A", "B"], undefined) - await vi.waitFor(() => expect(options.postStateToWebview).toHaveBeenCalledOnce()) - let releaseSend!: () => void - activeSession.sdkHost.send.mockImplementation( - () => - new Promise((resolve) => { - releaseSend = resolve - }), - ) - const answered = vi.fn() - void answer.then(answered) - - const submission = coordinator.askResponse(prompt, images, files) - await vi.waitFor(() => expect(activeSession.sdkHost.send).toHaveBeenCalledOnce()) - expect(activeSession.sdkHost.send).toHaveBeenCalledWith({ - sessionId: activeSession.sessionId, - prompt: resolvedPrompt, - ...(prompt !== undefined ? { displayPrompt: prompt } : {}), - userImages: images, - userFiles: files, - delivery: "steer", - }) - expect(answered).not.toHaveBeenCalled() - releaseSend() - await submission - await expect(answer).resolves.toBe(prompt ?? "") - // Core's pending_prompt_submitted event owns the response bubble; - // the interaction coordinator emits only the original question. - expect(options.messages.appendAndEmit).toHaveBeenCalledOnce() - const submitted = translateSessionEvent( - { - type: "pending_prompt_submitted", - payload: { - id: "question-response", - delivery: "steer", - attachmentCount: (images?.length ?? 0) + (files?.length ?? 0), - sessionId: activeSession.sessionId, - prompt: resolvedPrompt, - displayPrompt: prompt ?? "", - userImages: images, - userFiles: files, - }, - }, - new MessageTranslatorState(), - ) - expect(submitted.messages).toEqual([ - expect.objectContaining({ - say: "user_feedback", - text: prompt ?? "", - images, - files, - }), - ]) - expect(options.sessions.fireAndForgetSend).not.toHaveBeenCalled() - expect(options.sessions.startNewSession).not.toHaveBeenCalled() - }) - - it("does not enqueue attachments for a question cancelled during mention resolution", async () => { - const activeSession = makeActiveSession({ isRunning: true }) - const { coordinator, options } = makeCoordinator({ activeSession }) - const interactions = new SdkInteractionCoordinator({ - messages: options.messages, - getSessionId: () => activeSession.sessionId, - postStateToWebview: options.postStateToWebview, - }) - options.interactions = interactions as typeof options.interactions - const answer = interactions.handleAskQuestion("Which implementation?", ["A", "B"], undefined) - await new Promise((resolve) => setTimeout(resolve, 0)) - let resolveMentions!: (prompt: string) => void - options.resolveContextMentions.mockImplementation( - () => - new Promise((resolve) => { - resolveMentions = resolve - }), - ) - const submission = coordinator.askResponse("Use @file", ["image"]) - await vi.waitFor(() => expect(options.resolveContextMentions).toHaveBeenCalledOnce()) - interactions.clearPending("Cancelled") - resolveMentions("resolved mentions") - await submission - await expect(answer).resolves.toBe("") - expect(activeSession.sdkHost.send).not.toHaveBeenCalled() - expect(options.sessions.fireAndForgetSend).not.toHaveBeenCalled() - }) - - it("keeps the submitted task identity while checking pending questions", async () => { - const activeSession = makeActiveSession() - const { coordinator, options } = makeCoordinator({ activeSession, task: makeTask("session-123") }) - const submission = coordinator.askResponse("answer") - options.getTask.mockReturnValue(makeTask("new-task")) - await submission - expect(options.sessions.fireAndForgetSend).not.toHaveBeenCalled() - expect(options.onFollowUpAbandoned).toHaveBeenCalledOnce() - }) - it("sends a follow-up to an idle active session", async () => { const activeSession = makeActiveSession() const { coordinator, options } = makeCoordinator({ activeSession }) @@ -303,7 +185,6 @@ describe("SdkFollowupCoordinator", () => { const sendPromise = coordinator.askResponse("sent during rebuild") expect(options.sessions.startNewSession).not.toHaveBeenCalled() expect(options.sessions.fireAndForgetSend).not.toHaveBeenCalled() - await vi.waitFor(() => expect(runExclusive).toBeDefined()) await runExclusive?.() await sendPromise diff --git a/apps/vscode/src/sdk/sdk-followup-coordinator.ts b/apps/vscode/src/sdk/sdk-followup-coordinator.ts index 6fe734b3cc..2bc2e7086c 100644 --- a/apps/vscode/src/sdk/sdk-followup-coordinator.ts +++ b/apps/vscode/src/sdk/sdk-followup-coordinator.ts @@ -73,37 +73,12 @@ export class SdkFollowupCoordinator { return } - const task = this.options.getTask() - if ( - await this.options.interactions.resolvePendingAskQuestion(prompt, images, files, async (isCurrent) => { - const activeSession = this.options.sessions.getActiveSession() - if (!activeSession) { - throw new Error("No active session to receive the question attachments") - } - const resolvedPrompt = prompt ? await this.options.resolveContextMentions(prompt) : "" - if (!isCurrent()) { - return - } - if (this.options.sessions.getActiveSession() !== activeSession) { - throw new Error("Session changed while preparing the question attachments") - } - // Steer delivers multimodal input inside the current run, after the - // question's tool result and before the next model request. Await the - // enqueue before unblocking the question so it cannot miss that boundary. - await activeSession.sdkHost.send({ - sessionId: activeSession.sessionId, - prompt: resolvedPrompt, - ...(prompt !== undefined ? { displayPrompt: prompt } : {}), - userImages: images, - userFiles: files, - delivery: "steer", - }) - }) - ) { + if (this.options.interactions.resolvePendingAskQuestion(prompt)) { return } const activeSession = this.options.sessions.getActiveSession() + const task = this.options.getTask() const submittedDuringActiveTurn = turnPhaseAtSubmit === "streaming" || turnPhaseAtSubmit === "awaiting_approval" if (activeSession && (activeSession.isRunning || submittedDuringActiveTurn)) { await this.queueToActiveSession(activeSession, task?.taskId, prompt, images, files) diff --git a/apps/vscode/src/sdk/sdk-interaction-coordinator.test.ts b/apps/vscode/src/sdk/sdk-interaction-coordinator.test.ts index a20b37b82e..a0eb2bd6aa 100644 --- a/apps/vscode/src/sdk/sdk-interaction-coordinator.test.ts +++ b/apps/vscode/src/sdk/sdk-interaction-coordinator.test.ts @@ -350,7 +350,7 @@ describe("SdkInteractionCoordinator", () => { await vi.waitFor(() => expect(task.messageStateHandler.getClineMessages()).toHaveLength(1)) await new Promise((resolve) => setTimeout(resolve, 1)) - expect(await coordinator.resolvePendingAskQuestion("yes")).toBe(true) + expect(coordinator.resolvePendingAskQuestion("yes")).toBe(true) await expect(answerPromise).resolves.toBe("yes") expect(task.messageStateHandler.getClineMessages()).toMatchObject([ { type: "ask", ask: "followup" }, @@ -358,149 +358,6 @@ describe("SdkInteractionCoordinator", () => { ]) }) - it.each([ - "text", - "attachments", - ])("routes an overlapping %s response as a follow-up while attachments are delivered", async (kind) => { - const task = createTaskProxy("session-123", vi.fn(), vi.fn()) - const coordinator = new SdkInteractionCoordinator({ - messages: new SdkMessageCoordinator({ getTask: () => task }), - getSessionId: () => "session-123", - postStateToWebview: vi.fn().mockResolvedValue(undefined), - }) - const answer = coordinator.handleAskQuestion("Which design?", ["A", "B"], undefined) - await new Promise((resolve) => setTimeout(resolve, 0)) - const answered = vi.fn() - void answer.then(answered) - let finishDelivery!: () => void - let isCurrent!: () => boolean - const submission = coordinator.resolvePendingAskQuestion("first answer", ["image"], undefined, (check) => { - isCurrent = check - return new Promise((resolve) => { - finishDelivery = resolve - }) - }) - const secondDelivery = vi.fn().mockResolvedValue(undefined) - expect( - await coordinator.resolvePendingAskQuestion( - "second answer", - kind === "attachments" ? ["second image"] : undefined, - undefined, - secondDelivery, - ), - ).toBe(false) - expect(secondDelivery).not.toHaveBeenCalled() - expect(answered).not.toHaveBeenCalled() - expect(isCurrent()).toBe(true) - expect(task.messageStateHandler.getClineMessages()).toHaveLength(1) - finishDelivery() - expect(await submission).toBe(true) - await expect(answer).resolves.toBe("first answer") - expect(isCurrent()).toBe(false) - }) - - it("keeps a question pending if delivering its attachments fails", async () => { - const task = createTaskProxy("session-123", vi.fn(), vi.fn()) - const coordinator = new SdkInteractionCoordinator({ - messages: new SdkMessageCoordinator({ getTask: () => task }), - getSessionId: () => "session-123", - postStateToWebview: vi.fn().mockResolvedValue(undefined), - }) - const answer = coordinator.handleAskQuestion("Continue?", ["Yes"], undefined) - await vi.waitFor(() => expect(task.messageStateHandler.getClineMessages()).toHaveLength(1)) - await new Promise((resolve) => setTimeout(resolve, 0)) - let rejectDelivery!: (error: Error) => void - let failedIsCurrent!: () => boolean - const deliver = vi - .fn() - .mockImplementationOnce( - (check: () => boolean) => - new Promise((_resolve, reject) => { - failedIsCurrent = check - rejectDelivery = reject - }), - ) - .mockResolvedValue(undefined) - const submission = coordinator.resolvePendingAskQuestion("yes", ["image"], undefined, deliver) - const rejected = expect(submission).rejects.toThrow("Could not enqueue") - expect(await coordinator.resolvePendingAskQuestion("too early")).toBe(false) - rejectDelivery(new Error("Could not enqueue")) - await rejected - const retry = coordinator.resolvePendingAskQuestion("yes", ["image"], undefined, deliver) - expect(failedIsCurrent()).toBe(false) - expect(await retry).toBe(true) - await expect(answer).resolves.toBe("yes") - expect(task.messageStateHandler.getClineMessages()).toHaveLength(1) - }) - - it("does not resolve a replacement question while preparing attachments", async () => { - const task = createTaskProxy("session-123", vi.fn(), vi.fn()) - const coordinator = new SdkInteractionCoordinator({ - messages: new SdkMessageCoordinator({ getTask: () => task }), - getSessionId: () => "session-123", - postStateToWebview: vi.fn().mockResolvedValue(undefined), - }) - const answer = coordinator.handleAskQuestion("Continue?", ["Yes"], undefined) - await new Promise((resolve) => setTimeout(resolve, 0)) - let finishDelivery!: () => void - let isCurrent!: () => boolean - const submission = coordinator.resolvePendingAskQuestion("old answer", ["image"], undefined, (check) => { - isCurrent = check - return new Promise((resolve) => { - finishDelivery = resolve - }) - }) - coordinator.clearPending("Cancelled") - await expect(answer).resolves.toBe("") - const nextAnswer = coordinator.handleAskQuestion("Next?", ["A"], undefined) - await new Promise((resolve) => setTimeout(resolve, 0)) - expect(isCurrent()).toBe(false) - finishDelivery() - expect(await submission).toBe(true) - expect(await coordinator.resolvePendingAskQuestion("new answer")).toBe(true) - await expect(nextAnswer).resolves.toBe("new answer") - }) - - it("does not release a replacement response when a cancelled attachment delivery rejects", async () => { - const task = createTaskProxy("session-123", vi.fn(), vi.fn()) - const coordinator = new SdkInteractionCoordinator({ - messages: new SdkMessageCoordinator({ getTask: () => task }), - getSessionId: () => "session-123", - postStateToWebview: vi.fn().mockResolvedValue(undefined), - }) - const answer = coordinator.handleAskQuestion("Continue?", ["Yes"], undefined) - await new Promise((resolve) => setTimeout(resolve, 0)) - let rejectOldDelivery!: (error: Error) => void - let oldIsCurrent!: () => boolean - const oldSubmission = coordinator.resolvePendingAskQuestion("old answer", ["image"], undefined, (check) => { - oldIsCurrent = check - return new Promise((_resolve, reject) => { - rejectOldDelivery = reject - }) - }) - const oldRejected = expect(oldSubmission).rejects.toThrow("Old delivery failed") - coordinator.clearPending("Cancelled") - await expect(answer).resolves.toBe("") - const nextAnswer = coordinator.handleAskQuestion("Next?", ["A"], undefined) - await new Promise((resolve) => setTimeout(resolve, 0)) - let finishNewDelivery!: () => void - let newIsCurrent!: () => boolean - const nextSubmission = coordinator.resolvePendingAskQuestion("new answer", ["new image"], undefined, (check) => { - newIsCurrent = check - return new Promise((resolve) => { - finishNewDelivery = resolve - }) - }) - expect(oldIsCurrent()).toBe(false) - rejectOldDelivery(new Error("Old delivery failed")) - await oldRejected - expect(newIsCurrent()).toBe(true) - expect(await coordinator.resolvePendingAskQuestion("overlapping answer")).toBe(false) - finishNewDelivery() - expect(await nextSubmission).toBe(true) - await expect(nextAnswer).resolves.toBe("new answer") - }) - it("shows an error row and stops immediately when the mistake limit is reached", async () => { const task = createTaskProxy("session-123", vi.fn(), vi.fn()) const setTurnPhase = vi.fn() diff --git a/apps/vscode/src/sdk/sdk-interaction-coordinator.ts b/apps/vscode/src/sdk/sdk-interaction-coordinator.ts index 0834808eca..2c51d18a10 100644 --- a/apps/vscode/src/sdk/sdk-interaction-coordinator.ts +++ b/apps/vscode/src/sdk/sdk-interaction-coordinator.ts @@ -53,7 +53,6 @@ export interface SdkInteractionCoordinatorOptions { export class SdkInteractionCoordinator { private pendingAskResolve: ((answer: string) => void) | undefined - private claimedAskResponse: { resolve: (answer: string) => void } | undefined private pendingToolApprovalResolve: ((result: { approved: boolean; reason?: string }) => void) | undefined private pendingToolApprovalMessage: | { @@ -238,52 +237,22 @@ export class SdkInteractionCoordinator { return true } - async resolvePendingAskQuestion( - prompt: string | undefined, - images?: string[], - files?: string[], - deliverAttachments?: (isCurrent: () => boolean) => Promise, - ): Promise { - if (!this.pendingAskResolve || this.claimedAskResponse?.resolve === this.pendingAskResolve) { + resolvePendingAskQuestion(prompt: string | undefined): boolean { + if (!this.pendingAskResolve) { return false } const resolve = this.pendingAskResolve - // Claim before asynchronous delivery; later submissions use normal follow-up routing. - const response = { resolve } - this.claimedAskResponse = response - const hasAttachments = !!(images?.length || files?.length) - if (hasAttachments && deliverAttachments) { - try { - await deliverAttachments(() => this.pendingAskResolve === resolve && this.claimedAskResponse === response) - } catch (error) { - // Release only this response's claim so the still-pending question can be retried. - if (this.claimedAskResponse === response) { - this.claimedAskResponse = undefined - } - throw error - } - // Cancellation may have settled this question while input was prepared. - // Never resolve a replacement question with the old response. - if (this.pendingAskResolve !== resolve) { - return true - } - } this.pendingAskResolve = undefined - this.claimedAskResponse = undefined const responseText = prompt ?? "" Logger.log(`[SdkController] Resolving pending ask_question with: "${responseText.substring(0, 80)}"`) - // Core echoes steered input, including its attachments, when consumed. - // Keep exactly one user response in the visible transcript. - if ((responseText || hasAttachments) && !(hasAttachments && deliverAttachments)) { + if (responseText) { const userMessage: ClineMessage = { ts: this.nextMessageTs(), type: "say", say: "user_feedback", text: responseText, - images, - files, partial: false, } this.options.messages.appendAndEmit([userMessage], { @@ -301,7 +270,6 @@ export class SdkInteractionCoordinator { clearPending(reason: string): void { const resolveAsk = this.pendingAskResolve this.pendingAskResolve = undefined - this.claimedAskResponse = undefined // ask_question is awaiting this promise inside the outgoing agent run. Settle it // before session teardown so the run can unwind instead of remaining suspended; // use an empty answer so the lifecycle reason is not presented as user input. diff --git a/apps/vscode/src/sdk/sdk-task-control-coordinator.test.ts b/apps/vscode/src/sdk/sdk-task-control-coordinator.test.ts index c38b520b05..aeac15ee25 100644 --- a/apps/vscode/src/sdk/sdk-task-control-coordinator.test.ts +++ b/apps/vscode/src/sdk/sdk-task-control-coordinator.test.ts @@ -244,7 +244,7 @@ describe("SdkTaskControlCoordinator", () => { await coordinator.showTaskWithId("new-task") await expect(questionPromise).resolves.toBe("") - expect(await interactions.resolvePendingAskQuestion("late answer")).toBe(false) + expect(interactions.resolvePendingAskQuestion("late answer")).toBe(false) }) it("shows a legacy task with a warning and a resume ask", async () => { diff --git a/sdk/packages/agents/src/agent-runtime.test.ts b/sdk/packages/agents/src/agent-runtime.test.ts index 56556e101a..349f0ce33c 100644 --- a/sdk/packages/agents/src/agent-runtime.test.ts +++ b/sdk/packages/agents/src/agent-runtime.test.ts @@ -2243,94 +2243,6 @@ describe("AgentRuntime", () => { expect(result.outputText).toBe("done"); }); - it("delivers a multimodal question response after its tool result in the same run", async () => { - let pending: AgentMessage | undefined; - let resolveAnswer!: (answer: string) => void; - const questionStarted = Promise.withResolvers(); - const response: AgentMessage = { - id: "question-response", - role: "user", - createdAt: Date.now(), - content: [ - { type: "text", text: "Use this design" }, - { type: "image", image: "abc", mediaType: "image/png" }, - { type: "file", path: "spec.txt", content: "Use the existing API." }, - ], - metadata: { source: "question" }, - }; - const model = new ScriptedModel([ - () => [ - { - type: "tool-call-delta", - toolCallId: "question", - toolName: "ask_question", - inputText: "{}", - }, - { type: "finish", reason: "tool-calls" }, - ], - (request) => { - expect(request.messages.at(-2)).toMatchObject({ - role: "tool", - content: [ - { - type: "tool-result", - toolCallId: "question", - output: "Use this design", - }, - ], - }); - expect(request.messages.at(-1)).toMatchObject({ - ...response, - metadata: { source: "question", userRunSpan: 0 }, - }); - return [ - { type: "text-delta", text: "Design received" }, - { type: "finish", reason: "stop" }, - ]; - }, - ]); - const runtime = new AgentRuntime({ - model, - tools: [ - { - name: "ask_question", - description: "Ask the user", - inputSchema: { type: "object" }, - execute: async () => { - const answer = new Promise((resolve) => { - resolveAnswer = resolve; - }); - questionStarted.resolve(); - return answer; - }, - }, - ], - consumePendingUserMessage: async () => { - const message = pending; - pending = undefined; - return message; - }, - }); - const run = runtime.run("Start"); - await questionStarted.promise; - pending = response; - runtime.notifyPendingUserMessage(); - resolveAnswer("Use this design"); - const result = await run; - expect(result.status).toBe("completed"); - expect(model.requests).toHaveLength(2); - expect( - result.messages.filter((message) => message.id === response.id), - ).toHaveLength(1); - expect(result.messages.map((message) => message.role)).toEqual([ - "user", - "assistant", - "tool", - "user", - "assistant", - ]); - }); - it("injects a pending user message after tool results and before the next model request", async () => { const consumePendingUserMessage = vi .fn() diff --git a/sdk/packages/agents/src/agent-runtime.ts b/sdk/packages/agents/src/agent-runtime.ts index 518871b9c4..57e96692b5 100644 --- a/sdk/packages/agents/src/agent-runtime.ts +++ b/sdk/packages/agents/src/agent-runtime.ts @@ -2294,20 +2294,13 @@ export class AgentRuntime { if (!consumePendingUserMessage) { return undefined; } - const pending = await consumePendingUserMessage(); - if (!pending || (typeof pending === "string" && !pending.trim())) { + const pending = (await consumePendingUserMessage())?.trim(); + if (!pending) { return undefined; } - const message = - typeof pending === "string" - ? createMessage("user", [{ type: "text", text: pending.trim() }], { - userRunSpan: 0, - }) - : { - ...cloneMessages([pending])[0], - role: "user" as const, - metadata: { ...pending.metadata, userRunSpan: 0 }, - }; + const message = createMessage("user", [{ type: "text", text: pending }], { + userRunSpan: 0, + }); this.state.messages.push(message); await this.emit({ type: "message-added", diff --git a/sdk/packages/core/src/hub/runtime-host/hub-runtime-host.test.ts b/sdk/packages/core/src/hub/runtime-host/hub-runtime-host.test.ts index 5df3a42b0c..f24553d305 100644 --- a/sdk/packages/core/src/hub/runtime-host/hub-runtime-host.test.ts +++ b/sdk/packages/core/src/hub/runtime-host/hub-runtime-host.test.ts @@ -3,11 +3,7 @@ import { afterEach, describe, expect, it, vi } from "vitest"; import { version as corePackageVersion } from "../../../package.json"; import { createSessionCompactionState } from "../../session/models/session-compaction"; import { SessionSource } from "../../types/common"; -import type { CoreSessionEvent } from "../../types/events"; -import { - buildHubEvent, - type HubTransportContext, -} from "../server/handlers/context"; +import type { HubTransportContext } from "../server/handlers/context"; import { projectSessionEvent } from "../server/handlers/session-event-projector"; const commandMock = vi.hoisted(() => vi.fn()); @@ -86,66 +82,6 @@ describe("HubRuntimeHost", () => { restartLocalHubIfIdleAfterStartupTimeoutMock.mockReset(); }); - it.each([ - "Use @/spec.txt", - "", - undefined, - ])("preserves submitted prompt display text through both Hub event copies: %s", async (displayPrompt) => { - let onEvent: ((event: HubEventEnvelope) => void) | undefined; - subscribeMock.mockImplementation((listener) => { - onEvent = listener; - return () => {}; - }); - commandMock.mockResolvedValue({ ok: true, payload: { result: undefined } }); - const { HubRuntimeHost } = await import("./hub-runtime-host"); - const host = new HubRuntimeHost({ url: "ws://127.0.0.1:25463/hub" }); - const events: CoreSessionEvent[] = []; - host.subscribe((event) => events.push(event)); - const prompt = - 'Use @/spec.txt\nReference details'; - await host.runTurn({ - sessionId: "sess-1", - prompt, - displayPrompt, - delivery: "steer", - }); - const published: HubEventEnvelope[] = []; - const ctx = { - buildEvent: buildHubEvent, - publish: (event: HubEventEnvelope) => { - published.push(event); - onEvent?.(event); - }, - } as unknown as HubTransportContext; - const payload = { - sessionId: "sess-1", - id: "pending-1", - prompt, - displayPrompt, - delivery: "steer" as const, - attachmentCount: 2, - userImages: ["data:image/png;base64,abc"], - userFiles: ["/tmp/project/spec.txt"], - }; - await projectSessionEvent(ctx, { - type: "pending_prompt_submitted", - payload, - }); - expect(published).toHaveLength(1); - expect(published[0]?.payload?.prompt).toEqual({ - id: payload.id, - prompt, - displayPrompt, - delivery: payload.delivery, - attachmentCount: payload.attachmentCount, - userImages: payload.userImages, - userFiles: payload.userFiles, - }); - expect( - events.filter((event) => event.type === "pending_prompt_submitted"), - ).toEqual([{ type: "pending_prompt_submitted", payload }]); - }); - it("does not auto-start a run during session creation", async () => { subscribeMock.mockReturnValue(() => {}); commandMock.mockResolvedValue({ diff --git a/sdk/packages/core/src/hub/runtime-host/hub-runtime-host.ts b/sdk/packages/core/src/hub/runtime-host/hub-runtime-host.ts index db3c1d5bc1..4480578e44 100644 --- a/sdk/packages/core/src/hub/runtime-host/hub-runtime-host.ts +++ b/sdk/packages/core/src/hub/runtime-host/hub-runtime-host.ts @@ -1180,7 +1180,6 @@ export class HubRuntimeHost implements RuntimeHost { { sessionId: input.sessionId, input: input.prompt, - displayPrompt: input.displayPrompt, mode: input.mode, attachments: (input.userImages?.length ?? 0) > 0 || @@ -2024,7 +2023,6 @@ export class HubRuntimeHost implements RuntimeHost { sessionId, id: prompt.id, prompt: prompt.prompt, - displayPrompt: prompt.displayPrompt, delivery: prompt.delivery, attachmentCount: prompt.attachmentCount, userImages: prompt.userImages, diff --git a/sdk/packages/core/src/hub/server/handlers/run-handlers.ts b/sdk/packages/core/src/hub/server/handlers/run-handlers.ts index 0765d7b506..5effe4bf62 100644 --- a/sdk/packages/core/src/hub/server/handlers/run-handlers.ts +++ b/sdk/packages/core/src/hub/server/handlers/run-handlers.ts @@ -216,10 +216,6 @@ export async function handleSessionInput( : typeof payload.input === "string" ? payload.input : ""; - const displayPrompt = - typeof payload.displayPrompt === "string" - ? payload.displayPrompt - : undefined; const attachments = payload.attachments && typeof payload.attachments === "object" && @@ -271,7 +267,6 @@ export async function handleSessionInput( { sessionId, prompt, - displayPrompt, mode: parseTurnMode(payload.mode), delivery: payload.delivery === "queue" || payload.delivery === "steer" diff --git a/sdk/packages/core/src/hub/server/handlers/session-event-projector.ts b/sdk/packages/core/src/hub/server/handlers/session-event-projector.ts index 625e770317..988ffa9b58 100644 --- a/sdk/packages/core/src/hub/server/handlers/session-event-projector.ts +++ b/sdk/packages/core/src/hub/server/handlers/session-event-projector.ts @@ -80,7 +80,6 @@ export async function projectSessionEvent( const prompt: SessionPendingPrompt = { id: event.payload.id, prompt: event.payload.prompt, - displayPrompt: event.payload.displayPrompt, delivery: event.payload.delivery, attachmentCount: event.payload.attachmentCount, userImages: event.payload.userImages, diff --git a/sdk/packages/core/src/runtime/host/local-runtime-host.test.ts b/sdk/packages/core/src/runtime/host/local-runtime-host.test.ts index d27f6de451..7df2df8f15 100644 --- a/sdk/packages/core/src/runtime/host/local-runtime-host.test.ts +++ b/sdk/packages/core/src/runtime/host/local-runtime-host.test.ts @@ -2946,7 +2946,7 @@ describe("LocalRuntimeHost", () => { ]); }); - it("wraps consumed steer prompts as mode-scoped user input and preserves attachments", async () => { + it("wraps consumed steer prompts as mode-scoped user input", async () => { const sessionId = "sess-steer-format"; const manifest = createManifest(sessionId); const sessionService = { @@ -3019,40 +3019,6 @@ describe("LocalRuntimeHost", () => { agentConfig?.consumePendingUserMessage?.(), ); expect(consumed).toBe('steer this'); - const attachmentDir = mkdtempSync( - join(tmpdir(), "cline-question-attachments-"), - ); - const attachmentPath = join(attachmentDir, "spec.txt"); - writeFileSync(attachmentPath, "Implementation must use the existing API."); - try { - await manager.runTurn({ - sessionId, - prompt: "Use these attachments", - mode: "plan", - delivery: "steer", - userImages: ["data:image/png;base64,abc"], - userFiles: [attachmentPath], - }); - const multimodal = await agentConfig?.consumePendingUserMessage?.(); - expect(multimodal).toMatchObject({ - role: "user", - content: [ - { - type: "text", - text: 'Use these attachments', - }, - { type: "image", image: "abc", mediaType: "image/png" }, - { - type: "file", - path: attachmentPath.replace(/\\/g, "/"), - content: "Implementation must use the existing API.", - }, - ], - }); - expect(await agentConfig?.consumePendingUserMessage?.()).toBeUndefined(); - } finally { - rmSync(attachmentDir, { recursive: true, force: true }); - } // The agent receives a session-scoped view over the host telemetry. const capture = vi.spyOn(telemetry, "capture"); agentConfig?.telemetry?.capture({ diff --git a/sdk/packages/core/src/runtime/host/local-runtime-host.ts b/sdk/packages/core/src/runtime/host/local-runtime-host.ts index 768ab4cda4..a2339b29aa 100644 --- a/sdk/packages/core/src/runtime/host/local-runtime-host.ts +++ b/sdk/packages/core/src/runtime/host/local-runtime-host.ts @@ -101,7 +101,6 @@ import type { ActiveSession, PreparedTurnInput } from "../../types/session"; import type { SessionRecord } from "../../types/sessions"; import type { RuntimeCapabilities } from "../capabilities"; import { normalizeRuntimeCapabilities } from "../capabilities"; -import { messagesToAgentMessages } from "../config/agent-message-codec"; import { normalizeConnectionUpdate } from "../config/connection-update"; import { DefaultRuntimeBuilder } from "../orchestration/runtime-builder"; import { @@ -111,7 +110,6 @@ import { } from "../orchestration/runtime-oauth-token-manager"; import type { RuntimeBuilder } from "../orchestration/session-runtime"; import { SessionRuntime } from "../orchestration/session-runtime-orchestrator"; -import { buildInitialUserContent } from "../orchestration/user-input-builder"; import { PendingPromptsController } from "../turn-queue/pending-prompt-service"; import { manifestToSessionRecord } from "./history"; import { AgentEventBridge } from "./local/agent-event-bridge"; @@ -860,25 +858,14 @@ export class LocalRuntimeHost implements RuntimeHost { onConsecutiveMistakeLimitReached: configWithProvider.onConsecutiveMistakeLimitReached, completionPolicy: runtime.completionPolicy, - consumePendingUserMessage: async () => { + consumePendingUserMessage: () => { const entry = this.pendingPromptsController.consumeSteer(sessionId); - if (!entry) return undefined; - const prompt = formatModePrompt( - entry.prompt, - entry.mode ?? configWithProvider.mode, - ); - if (!entry.userImages?.length && !entry.userFiles?.length) { - return prompt; - } - const content = await buildInitialUserContent( - prompt, - entry.userImages, - entry.userFiles, - loadUserFileContent, - ); - return messagesToAgentMessages([ - { id: crypto.randomUUID(), role: "user", content }, - ])[0]; + return entry + ? formatModePrompt( + entry.prompt, + entry.mode ?? configWithProvider.mode, + ) + : undefined; }, logger: runtime.logger ?? configWithProvider.logger, extensionContext: configWithProvider.extensionContext, @@ -1171,7 +1158,6 @@ export class LocalRuntimeHost implements RuntimeHost { if (delivery === "queue" || delivery === "steer") { this.pendingPromptsController.enqueue(input.sessionId, { prompt: input.prompt, - displayPrompt: input.displayPrompt, mode: input.mode, delivery, userImages: input.userImages, diff --git a/sdk/packages/core/src/runtime/host/runtime-host.ts b/sdk/packages/core/src/runtime/host/runtime-host.ts index ea4717ef69..0c3c3a13c1 100644 --- a/sdk/packages/core/src/runtime/host/runtime-host.ts +++ b/sdk/packages/core/src/runtime/host/runtime-host.ts @@ -261,7 +261,6 @@ export interface StartSessionResult { export interface SendSessionInput { sessionId: string; prompt: string; - displayPrompt?: string; mode?: AgentMode; userImages?: string[]; userFiles?: string[]; diff --git a/sdk/packages/core/src/runtime/turn-queue/pending-prompt-service.ts b/sdk/packages/core/src/runtime/turn-queue/pending-prompt-service.ts index 6e18bf7b31..ff3fdb7041 100644 --- a/sdk/packages/core/src/runtime/turn-queue/pending-prompt-service.ts +++ b/sdk/packages/core/src/runtime/turn-queue/pending-prompt-service.ts @@ -16,7 +16,6 @@ export type PendingPromptDelivery = "queue" | "steer"; export interface PendingPromptEntry { id: string; prompt: string; - displayPrompt?: string; mode?: AgentMode; delivery: PendingPromptDelivery; userImages?: string[]; @@ -41,7 +40,6 @@ export interface PendingPromptsControllerDeps { export interface PendingPromptEnqueueInput { prompt: string; - displayPrompt?: string; mode?: AgentMode; delivery: PendingPromptDelivery; userImages?: string[]; @@ -140,8 +138,7 @@ export class PendingPromptService { state: PendingPromptQueueState, input: PendingPromptEnqueueInput, ): SessionPendingPrompt[] { - const { prompt, displayPrompt, mode, delivery, userImages, userFiles } = - input; + const { prompt, mode, delivery, userImages, userFiles } = input; const existingIndex = state.pendingPrompts.findIndex( (queued) => queued.prompt === prompt, ); @@ -150,7 +147,6 @@ export class PendingPromptService { const next: PendingPromptEntry = { ...existing, prompt, - displayPrompt: displayPrompt ?? existing.displayPrompt, mode: mode ?? existing.mode, userImages: userImages ?? existing.userImages, userFiles: userFiles ?? existing.userFiles, @@ -164,7 +160,6 @@ export class PendingPromptService { const newEntry: PendingPromptEntry = { id: `pending_${Date.now()}_${nanoid(5)}`, prompt, - displayPrompt, mode, delivery, userImages, @@ -262,7 +257,6 @@ export class PendingPromptsController { sessionId: string, entry: { prompt: string; - displayPrompt?: string; mode?: AgentMode; delivery: "queue" | "steer"; userImages?: string[]; @@ -388,7 +382,6 @@ export class PendingPromptsController { sessionId: session.sessionId, id: prompt.id, prompt: prompt.prompt, - displayPrompt: prompt.displayPrompt, delivery: prompt.delivery, attachmentCount: prompt.attachmentCount, userImages: prompt.userImages, @@ -411,7 +404,6 @@ function snapshotPrompt(entry: PendingPromptEntry): SessionPendingPrompt { return { id: entry.id, prompt: entry.prompt, - displayPrompt: entry.displayPrompt, delivery: entry.delivery, attachmentCount: (entry.userImages?.length ?? 0) + (entry.userFiles?.length ?? 0), diff --git a/sdk/packages/core/src/types/events.ts b/sdk/packages/core/src/types/events.ts index bd6b6306f6..d14da8484b 100644 --- a/sdk/packages/core/src/types/events.ts +++ b/sdk/packages/core/src/types/events.ts @@ -41,7 +41,6 @@ export interface SessionTeamProgressEvent { export interface SessionPendingPrompt { id: string; prompt: string; - displayPrompt?: string; delivery: "queue" | "steer"; attachmentCount: number; userImages?: string[]; @@ -57,7 +56,6 @@ export interface SessionPendingPromptSubmittedEvent { sessionId: string; id: string; prompt: string; - displayPrompt?: string; delivery: "queue" | "steer"; attachmentCount: number; userImages?: string[]; diff --git a/sdk/packages/core/src/types/session.ts b/sdk/packages/core/src/types/session.ts index a4ac94a48c..b21d5359d8 100644 --- a/sdk/packages/core/src/types/session.ts +++ b/sdk/packages/core/src/types/session.ts @@ -68,7 +68,6 @@ export type ActiveSession = { export type PendingPrompt = { id: string; prompt: string; - displayPrompt?: string; mode?: CoreAgentMode; delivery: "queue" | "steer"; userImages?: string[]; diff --git a/sdk/packages/shared/src/agent.ts b/sdk/packages/shared/src/agent.ts index cdbaef0efe..b280ee2d48 100644 --- a/sdk/packages/shared/src/agent.ts +++ b/sdk/packages/shared/src/agent.ts @@ -609,9 +609,8 @@ export interface AgentRuntimeConfig { // model request. consumePendingUserMessage?: () => | string - | AgentMessage | undefined - | Promise; + | Promise; } // ============================================================================= diff --git a/sdk/packages/shared/src/agents/types.ts b/sdk/packages/shared/src/agents/types.ts index 8fa5d244aa..712ce2b849 100644 --- a/sdk/packages/shared/src/agents/types.ts +++ b/sdk/packages/shared/src/agents/types.ts @@ -12,7 +12,6 @@ import { z } from "zod"; import type { - AgentMessage, AgentRuntimeHooks, AgentTool, ProviderErrorClass, @@ -905,16 +904,12 @@ export interface AgentConfig { /** * Optional callback invoked at the top of each agent loop iteration - * (after the first). A non-empty string or a multimodal AgentMessage is + * (after the first). If it returns a non-empty string, that string is * injected as a user message into the conversation before the next API * call. This allows the host to feed user input into a running loop * without waiting for the current run to finish. */ - consumePendingUserMessage?: () => - | string - | AgentMessage - | undefined - | Promise; + consumePendingUserMessage?: () => string | undefined; // ------------------------------------------------------------------------- // Cancellation From 1a25410ccd889a7c7ef4d10902869fd5b7203608 Mon Sep 17 00:00:00 2001 From: Saoud Rizwan <7799382+saoudrizwan@users.noreply.github.com> Date: Tue, 13 Oct 2026 07:40:40 -0700 Subject: [PATCH 07/21] chore(desktop): release v0.0.46 --- apps/examples/desktop-app/CHANGELOG.md | 18 ++++++++++++++++++ apps/examples/desktop-app/package.json | 2 +- .../desktop-app/src-tauri/tauri.conf.json | 2 +- bun.lock | 2 +- 4 files changed, 21 insertions(+), 3 deletions(-) diff --git a/apps/examples/desktop-app/CHANGELOG.md b/apps/examples/desktop-app/CHANGELOG.md index 41f4ce04af..f7a96bf6f2 100644 --- a/apps/examples/desktop-app/CHANGELOG.md +++ b/apps/examples/desktop-app/CHANGELOG.md @@ -1,5 +1,23 @@ # Cline Desktop Changelog +## 0.0.46 + +- A new outline along the left edge of the conversation lists each prompt you sent. Hover a mark to preview the prompt, and click it to jump there +- Plugins that fail to load or error during a session now show a status line in the chat naming the plugin and the error, instead of silently losing their tools +- You can cancel a Cline Cloud sign-in while it is waiting for the browser +- Session titles renamed on the web now update in the app +- The Cloud setup card stays visible on smaller windows when signed out +- Installing a skill without Git installed now says Git is required and how to fix it, instead of showing a raw clone error +- Scheduled routines: queued runs now start instead of staying pending forever, a run whose model call fails (for example, a retired model) is marked failed with the error, and after downtime only the latest missed occurrence runs instead of every one back to back. The routine editor now lists current models instead of retired ones +- With no model chosen, the Cline provider uses the first model in Cline's recommended list (Claude Sonnet 5.5) +- Turning reasoning off now works on every provider. Models that can't turn it off keep their default, and level-only Gemini models no longer reject the request with a thinking-budget error +- Screenshots and other images no longer inflate the token estimate, which made auto-compaction run long before the conversation was near its limit +- Auto-compaction uses the selected model's context window when the catalog entry has none +- Rule files that are symlinks now load +- Skills in `.claude/skills` are now discovered +- The editor tool no longer receives null in place of the text to replace from some models +- Refreshed the model catalog. Adds the AI/ML API, LowRouter, and Prime Inference providers. The v0 provider now offers only its default model, `v0-1.5-md`, and OpenCode Go uses its new `https://opencode.ai/inference/go/v1` endpoint. The Cline free list adds Step 5 Preview. Default models change for 302.AI, CrossModel, Cortecs, DigitalOcean, NEAR AI Cloud, and Ofox (Claude Haiku 5.5), DevPass (LLM Gateway), LLM Gateway, and Meta (Muse Wonder 1.0), Aixy (GPT-6.1 Sol), CloudFerro Sherlock and Moark (GLM 5.3), Ambient, NanoGPT, Neuralwatt, and Novita AI + ## 0.0.45 - SSH remotes start again. Since 0.0.38, connecting to an SSH environment failed with `SyntaxError: Invalid character` diff --git a/apps/examples/desktop-app/package.json b/apps/examples/desktop-app/package.json index 2bef4d0d13..ab4c19fb72 100644 --- a/apps/examples/desktop-app/package.json +++ b/apps/examples/desktop-app/package.json @@ -1,6 +1,6 @@ { "name": "@cline/code", - "version": "0.0.45", + "version": "0.0.46", "private": true, "scripts": { "build:ui": "bun -F @cline/ui build", diff --git a/apps/examples/desktop-app/src-tauri/tauri.conf.json b/apps/examples/desktop-app/src-tauri/tauri.conf.json index 370c287e63..dc24e05b8d 100644 --- a/apps/examples/desktop-app/src-tauri/tauri.conf.json +++ b/apps/examples/desktop-app/src-tauri/tauri.conf.json @@ -1,7 +1,7 @@ { "$schema": "https://schema.tauri.app/config/2", "productName": "Cline", - "version": "0.0.45", + "version": "0.0.46", "identifier": "bot.cline.app", "build": { "beforeDevCommand": "bun run build:sidecar:bin && bun run dev:web", diff --git a/bun.lock b/bun.lock index a3b64e5bb2..37faa71eb7 100644 --- a/bun.lock +++ b/bun.lock @@ -165,7 +165,7 @@ }, "apps/examples/desktop-app": { "name": "@cline/code", - "version": "0.0.45", + "version": "0.0.46", "dependencies": { "@ai-sdk/gateway": "^4.0.98", "@ai-sdk/openai": "^4.0.79", From ed469c753a0d7a0d41f906cf3fc75a983cd766ac Mon Sep 17 00:00:00 2001 From: Saoud Rizwan <7799382+saoudrizwan@users.noreply.github.com> Date: Tue, 13 Oct 2026 07:43:52 -0700 Subject: [PATCH 08/21] chore(vscode): release v4.1.24 --- CHANGELOG.md | 21 +++++++++++++++++++++ apps/vscode/package.json | 2 +- bun.lock | 2 +- 3 files changed, 23 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 19c277f10d..56fdcec5c7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,26 @@ # Changelog +## [4.1.24] + +### Changed + +- With no model chosen, the Cline and OpenRouter providers now use Claude Sonnet 5.5. For Cline, that's the first model in Cline's recommended list. +- Selecting ai& shows its own API key and model picker, instead of the generic OpenAI-compatible form with base URL and custom headers. +- Refreshed the model catalog. Adds Claude Haiku 5.5 and the AI/ML API, LowRouter, and Prime Inference providers. The Cline free list adds Step 5 Preview. The v0 provider now offers only its default model, `v0-1.5-md`, and OpenCode Go uses its new `https://opencode.ai/inference/go/v1` endpoint. Default models change for 302.AI, Cortecs, CrossModel, DevPass, DigitalOcean, Eden AI, GitHub Copilot, Google Vertex AI, LLM Gateway, Meta, Moark, NanoGPT, NEAR AI Cloud, Neuralwatt, Novita AI, Ofox, OpenCode Go, Requesty, Vivgrid, and a few others (most now Claude Haiku 5.5 or Muse Wonder 1.0). If you use one of those providers without pinning a model, expect a different default. + +### Fixed + +- Turning reasoning off no longer fails with a 400 error on GPT-6 Astra, GPT-6.1 Sol, Claude Fable 5 or Claude Opus 5.5, and now works on every provider. Level-only Gemini models no longer reject the request with a thinking-budget error. +- An MCP server or hook that exits before reading all of its input no longer crashes the extension host about 3 seconds later. +- A 404 from a stale or retired model id is no longer shown as a sign-in problem. Cline shows the real error instead of the logged-out card. +- Errors in the Cline output log keep their details. Production builds used to drop them, so a line like "Cline OAuth login failed:" showed no reason. +- Deleting a slash command from the input keeps the spacing of the text around it. +- Screenshots and other images no longer inflate the token estimate, which made auto-compaction run long before the conversation was near its limit. +- Auto-compaction uses the selected model's context window when the catalog entry has none. +- Rule files that are symlinks now load, and skills in `.claude/skills` are now discovered. +- The editor tool no longer receives null in place of the text to replace from some models. +- Settings migrated from older versions no longer add an empty SAP AI Core provider. + ## [4.1.23] ### Changed diff --git a/apps/vscode/package.json b/apps/vscode/package.json index d6c186d9f4..fd74c2216d 100644 --- a/apps/vscode/package.json +++ b/apps/vscode/package.json @@ -2,7 +2,7 @@ "name": "claude-dev", "displayName": "Cline", "description": "Autonomous coding agent right in your IDE, capable of creating/editing files, running commands, using the browser, and more with your permission every step of the way.", - "version": "4.1.23", + "version": "4.1.24", "icon": "assets/icons/icon.png", "engines": { "vscode": "^1.101.0" diff --git a/bun.lock b/bun.lock index 37faa71eb7..ff3fad81b7 100644 --- a/bun.lock +++ b/bun.lock @@ -363,7 +363,7 @@ }, "apps/vscode": { "name": "claude-dev", - "version": "4.1.23", + "version": "4.1.24", "dependencies": { "@anthropic-ai/sdk": "^0.37.0", "@bufbuild/protobuf": "^2.2.5", From 26cea2fa8bc481e942cac2f7cfb412c7c9d139f2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Miko=C5=82aj=20Kondratek?= <19799111+mkondratek@users.noreply.github.com> Date: Tue, 13 Oct 2026 21:44:25 +0200 Subject: [PATCH 09/21] fix: stop undici's 5-minute idle timeout killing local model requests (#15024) * fix(lmstudio): disable undici's idle-body timeout so slow local generations aren't killed mid-stream * fix(lmstudio): compose the bodyTimeout override onto the active dispatcher instead of replacing it * fix(vscode): disable undici idle timeouts for local model providers Node's fetch applies undici's default 300s headersTimeout/bodyTimeout, each measuring time since the last byte received. Local servers (LM Studio, Ollama) legitimately go silent for longer while processing a large prompt (headers arrive immediately, then no body bytes until the first token), so long requests died mid-task with 'terminated: BodyTimeoutError (UND_ERR_BODY_TIMEOUT)' even though the server was still generating. Route LM Studio and Ollama inference through a fetch bound to an EnvHttpProxyAgent with headersTimeout/bodyTimeout disabled. This calls undici's own fetch rather than global fetch because VS Code's http.fetchAdditionalSupport patch replaces caller-supplied dispatchers with its own default-timeout Agent. Cancellation stays with each request's AbortSignal. Fixes #13464 Co-authored-by: Saoud Rizwan * fix(vscode): disable undici idle timeouts for providers that may run a local model Combine the two earlier drafts: dispatch through undici's own fetch so the override survives VS Code's global fetch wrapper, and keep the env-var proxy agent so JetBrains proxy support is unchanged. Widen the provider set from LM Studio and Ollama to also cover OpenAI Compatible and LiteLLM, which telemetry shows carry most of the UND_ERR_BODY_TIMEOUT failures. The net test now exercises real undici fetch against a local server that flushes headers and then stays silent, with a short-timeout control to prove the silence is long enough to trip the default timer. * test(vscode): keep the idle-timeout net test off any machine-level proxy * chore: drop the changeset file, the repo no longer uses changesets * fix(vscode): only skip idle timeouts when the base URL is on the local network OpenAI Compatible and LiteLLM often point at remote corporate gateways. On VS Code, fetchWithoutIdleTimeouts bypasses the host's proxy handling (http.proxy, OS/PAC proxy, proxyStrictSSL), so routing those through it would break proxied setups. Gate on the base URL as well: loopback, private/link-local/CGNAT ranges, single-label hosts and .local/.internal names get the idle-timeout-free fetch; remote gateways keep the proxy-aware fetch and its dead-connection cap. Co-authored-by: Saoud Rizwan * fix(vscode): keep loopback direct in fetchWithoutIdleTimeouts, cover 0.0.0.0 and mapped IPv4 EnvHttpProxyAgent sends loopback through http_proxy whenever no_proxy is unset, which VS Code's own resolver never does. Pick a plain Agent for loopback hosts and the env-var proxy agent for everything else, both with idle timeouts off. The local-network gate now also accepts 0.0.0.0 and IPv4-mapped IPv6 addresses, which Ollama and LM Studio print when bound to all interfaces. The net test now runs with HTTP_PROXY set and NO_PROXY unset against a recording CONNECT proxy: loopback never touches it, an unroutable host goes through it, and neither path has an idle timeout. * fix(vscode): match loopback by address so IPv4-mapped IPv6 stays direct The URL parser rewrites [::ffff:127.0.0.1] to [::ffff:7f00:1], which a string match on "127." misses. Use a BlockList, which checks mapped addresses against the IPv4 rules, for the loopback bypass and drop the now-redundant unwrapping in the local-network gate. --------- Co-authored-by: lizhe Co-authored-by: Saoud Rizwan <7799382+saoudrizwan@users.noreply.github.com> Co-authored-by: Saoud Rizwan --- .../src/sdk/cline-session-factory.test.ts | 73 +++++++++++ apps/vscode/src/sdk/cline-session-factory.ts | 55 ++++++++- apps/vscode/src/sdk/sdk-api-handler.ts | 9 +- apps/vscode/src/shared/net.test.ts | 116 ++++++++++++++++++ apps/vscode/src/shared/net.ts | 70 ++++++++++- apps/vscode/vitest.config.ts | 1 + 6 files changed, 316 insertions(+), 8 deletions(-) create mode 100644 apps/vscode/src/shared/net.test.ts diff --git a/apps/vscode/src/sdk/cline-session-factory.test.ts b/apps/vscode/src/sdk/cline-session-factory.test.ts index b1235a8176..222debdbbd 100644 --- a/apps/vscode/src/sdk/cline-session-factory.test.ts +++ b/apps/vscode/src/sdk/cline-session-factory.test.ts @@ -6,6 +6,7 @@ import * as LlmsModels from "@cline/llms" import { ApiFormat } from "@shared/proto/cline/models" import { Logger } from "@shared/services/Logger" import { afterEach, beforeEach, describe, expect, it, vi } from "vitest" +import * as netModule from "@/shared/net" import { buildResumeSessionInput, buildSessionConfig, @@ -17,6 +18,7 @@ import { normalizeSdkBaseUrl, resolveApiKey, resolveAzureProviderConfig, + resolveProviderFetch, updateHistoryItem, } from "./cline-session-factory" import { parseProviderId } from "./model-catalog/provider-id" @@ -277,6 +279,54 @@ describe("normalizeSdkBaseUrl", () => { }) }) +// --------------------------------------------------------------------------- +// resolveProviderFetch +// --------------------------------------------------------------------------- + +describe("resolveProviderFetch", () => { + it("gives local model servers the idle-timeout-free fetch", () => { + for (const providerId of ["lmstudio", "ollama", "openai-compatible", "litellm"]) { + expect(resolveProviderFetch(providerId, undefined)).toBe(netModule.fetchWithoutIdleTimeouts) + } + for (const baseUrl of [ + "http://localhost:1234/v1", + "http://127.0.0.1:4000", + "http://0.0.0.0:11434", + "http://[::1]:8000/v1", + "http://[::ffff:127.0.0.1]:1234/v1", + "http://[::ffff:192.168.1.20]:8000/v1", + "http://192.168.1.20:8000/v1", + "http://10.0.0.5:11434", + "http://172.20.0.3:8000", + "http://100.101.102.103:8000", + "http://[fd12::1]:8000", + "http://gpu-box:8000/v1", + "http://host.docker.internal:1234/v1", + "http://studio.local:1234/v1", + ]) { + expect(resolveProviderFetch("openai-compatible", baseUrl)).toBe(netModule.fetchWithoutIdleTimeouts) + } + }) + + it("keeps the proxy-aware fetch for remote gateways", () => { + for (const baseUrl of [ + "https://litellm.corp.example.com/v1", + "https://8.8.8.8/v1", + "http://172.32.0.1:8000", + "https://[2001:db8::1]/v1", + "not a url", + ]) { + expect(resolveProviderFetch("litellm", baseUrl)).toBe(netModule.fetch) + } + }) + + it("keeps the proxy-aware fetch for everything else", () => { + for (const providerId of ["anthropic", "cline", "openrouter", "bedrock"]) { + expect(resolveProviderFetch(providerId, undefined)).toBe(netModule.fetch) + } + }) +}) + // --------------------------------------------------------------------------- // normalizeProviderReasoningSettings // --------------------------------------------------------------------------- @@ -717,6 +767,29 @@ describe("buildSessionConfig", () => { }) }) + // Regression test for cline/cline#13464: undici's default 5-minute + // idle-body timeout (UND_ERR_BODY_TIMEOUT) killed LM Studio requests + // mid-stream even while the local server was actively generating tokens. + it("gives LM Studio the fetch that ignores undici's idle timeouts", async () => { + mocks.stateManager.getApiConfiguration.mockReturnValue({ + actModeApiProvider: "lmstudio", + actModeApiModelId: "qwen3-coder-30b", + } as any) + + const config = await buildSessionConfig({ cwd: "/tmp/workspace" }) + expect((config.providerConfig as { fetch?: typeof fetch }).fetch).toBe(netModule.fetchWithoutIdleTimeouts) + }) + + it("keeps the idle timeouts for hosted providers", async () => { + mocks.stateManager.getApiConfiguration.mockReturnValue({ + actModeApiProvider: "moonshot", + actModeApiModelId: "kimi-k3", + } as any) + + const config = await buildSessionConfig({ cwd: "/tmp/workspace" }) + expect((config.providerConfig as { fetch?: typeof fetch }).fetch).toBe(netModule.fetch) + }) + it("inherits the base provider's legacy apiLine for coding variants", async () => { mocks.stateManager.getApiConfiguration.mockReturnValue({ actModeApiProvider: "zai-coding-plan", diff --git a/apps/vscode/src/sdk/cline-session-factory.ts b/apps/vscode/src/sdk/cline-session-factory.ts index 533dc43757..6ed73e5a1a 100644 --- a/apps/vscode/src/sdk/cline-session-factory.ts +++ b/apps/vscode/src/sdk/cline-session-factory.ts @@ -8,6 +8,7 @@ // // The factory does NOT handle UI concerns — that's the SdkController's job. +import { BlockList, isIP } from "node:net" import { buildWorkspaceMetadata, type ClineCoreStartInput, @@ -41,7 +42,7 @@ import { StateManager } from "@/core/storage/StateManager" import { HostProvider } from "@/hosts/host-provider" import { ExtensionRegistryInfo } from "@/registry" import { getDistinctId } from "@/services/logging/distinctId" -import { fetch } from "@/shared/net" +import { fetch, fetchWithoutIdleTimeouts } from "@/shared/net" import { type BedrockProviderConfig, buildBedrockProviderConfig } from "./bedrock-config" import { buildAgentHooks } from "./hooks-adapter" import { readTaskHistory, resolveDataDir } from "./legacy-state-reader" @@ -732,6 +733,56 @@ export function resolveOllamaProviderConfig(config: ApiConfiguration, modelId: s } } +/** + * Providers whose base URL commonly points at a local model server. Those + * can spend longer than undici's 300s idle timeouts producing the first + * token, so they get `fetchWithoutIdleTimeouts` when the base URL is on the + * local network. Remote gateways (and every other provider) keep the + * proxy-aware fetch: on VS Code it honours `http.proxy`, which + * `fetchWithoutIdleTimeouts` cannot, and it keeps the dead-connection cap. + */ +const LOCAL_SERVER_PROVIDERS = new Set(["lmstudio", "ollama", "openai-compatible", "litellm"]) + +const LOCAL_NETWORK_ADDRESSES = new BlockList() +for (const [address, prefix] of [ + ["127.0.0.0", 8], + ["0.0.0.0", 32], + ["10.0.0.0", 8], + ["172.16.0.0", 12], + ["192.168.0.0", 16], + ["169.254.0.0", 16], + ["100.64.0.0", 10], +] as const) { + LOCAL_NETWORK_ADDRESSES.addSubnet(address, prefix, "ipv4") +} +LOCAL_NETWORK_ADDRESSES.addAddress("::1", "ipv6") +LOCAL_NETWORK_ADDRESSES.addSubnet("fc00::", 7, "ipv6") +LOCAL_NETWORK_ADDRESSES.addSubnet("fe80::", 10, "ipv6") + +// An unset base URL means the provider's localhost default. +function isLocalNetworkUrl(baseUrl: string | undefined): boolean { + if (!baseUrl) { + return true + } + let host: string + try { + host = new URL(baseUrl).hostname.replace(/^\[|\]$/g, "").toLowerCase() + } catch { + return false + } + // BlockList matches IPv4-mapped IPv6 (`[::ffff:127.0.0.1]`) against the + // IPv4 rules, so no unwrapping is needed. + const family = isIP(host) + if (family) { + return LOCAL_NETWORK_ADDRESSES.check(host, family === 6 ? "ipv6" : "ipv4") + } + return !host.includes(".") || /\.(localhost|local|internal)$/.test(host) +} + +export function resolveProviderFetch(sdkProviderId: string, baseUrl: string | undefined): typeof globalThis.fetch { + return LOCAL_SERVER_PROVIDERS.has(sdkProviderId) && isLocalNetworkUrl(baseUrl) ? fetchWithoutIdleTimeouts : fetch +} + export function resolveBaseUrl(providerId: string, config: ApiConfiguration): string | undefined { const baseUrlMap: Record = { anthropic: "anthropicBaseUrl", @@ -1097,7 +1148,7 @@ export async function buildSessionConfig(input: SessionConfigInput): Promise { + return new Promise((resolve) => + server.listen(0, "127.0.0.1", () => resolve(`http://127.0.0.1:${(server.address() as AddressInfo).port}`)), + ) +} + +function close(server: http.Server): Promise { + return new Promise((resolve) => server.close(() => resolve())) +} + +describe("fetchWithoutIdleTimeouts", () => { + let modelServer: http.Server + let proxyServer: http.Server + let url: string + let proxiedRequests: string[] + const savedProxyEnv: Record = {} + + beforeAll(async () => { + modelServer = http.createServer((_req, res) => { + res.writeHead(200, { "content-type": "text/plain" }) + res.flushHeaders() + setTimeout(() => res.end("first token"), BODY_DELAY_MS) + }) + url = `${await listen(modelServer)}/v1/chat/completions` + + // undici tunnels every proxied request with CONNECT. Record the target + // and tunnel to the model server so the response proves the route. + proxiedRequests = [] + proxyServer = http.createServer() + proxyServer.on("connect", (req, clientSocket, head) => { + proxiedRequests.push(req.url ?? "") + const upstream = net.connect((modelServer.address() as AddressInfo).port, "127.0.0.1", () => { + clientSocket.write("HTTP/1.1 200 Connection Established\r\n\r\n") + upstream.write(head) + upstream.pipe(clientSocket) + clientSocket.pipe(upstream) + }) + }) + const proxyUrl = await listen(proxyServer) + + for (const name of PROXY_ENV_VARS) { + savedProxyEnv[name] = process.env[name] + delete process.env[name] + } + process.env.HTTP_PROXY = proxyUrl + }) + + afterAll(async () => { + await close(modelServer) + await close(proxyServer) + for (const name of PROXY_ENV_VARS) { + if (savedProxyEnv[name] === undefined) { + delete process.env[name] + } else { + process.env[name] = savedProxyEnv[name] + } + } + }) + + it("waits through a silent body where undici's idle timeout would abort", async () => { + // Control: the same request through a dispatcher with a short idle + // timeout dies, proving the server really is silent long enough. + const impatient = new Agent({ bodyTimeout: CONTROL_BODY_TIMEOUT_MS }) + await expect(undiciFetch(url, { dispatcher: impatient }).then((r) => r.text())).rejects.toThrow() + await impatient.close() + + const response = await fetchWithoutIdleTimeouts(url) + expect(await response.text()).toBe("first token") + }) + + it("still honours the caller's AbortSignal while the body is silent", async () => { + const controller = new AbortController() + const response = await fetchWithoutIdleTimeouts(url, { signal: controller.signal }) + setTimeout(() => controller.abort(), 20) + await expect(response.text()).rejects.toThrow() + }) + + it("connects to loopback directly even when HTTP_PROXY is set and NO_PROXY is not", async () => { + const response = await fetchWithoutIdleTimeouts(url) + expect(await response.text()).toBe("first token") + expect(proxiedRequests).toEqual([]) + }) + + it("treats IPv4-mapped loopback as loopback", async () => { + const mapped = url.replace("127.0.0.1", "[::ffff:127.0.0.1]") + const response = await fetchWithoutIdleTimeouts(mapped) + expect(await response.text()).toBe("first token") + expect(proxiedRequests).toEqual([]) + }) + + it("routes other hosts through the configured proxy, still without idle timeouts", async () => { + const response = await fetchWithoutIdleTimeouts(REMOTE_URL) + expect(await response.text()).toBe("first token") + expect(proxiedRequests).toEqual([new URL(REMOTE_URL).host]) + }) +}) diff --git a/apps/vscode/src/shared/net.ts b/apps/vscode/src/shared/net.ts index e5b8798f1b..89620c6390 100644 --- a/apps/vscode/src/shared/net.ts +++ b/apps/vscode/src/shared/net.ts @@ -97,7 +97,8 @@ * ``` */ -import { EnvHttpProxyAgent, setGlobalDispatcher, fetch as undiciFetch } from "undici" +import { BlockList, isIP } from "node:net" +import { Agent, type Dispatcher, EnvHttpProxyAgent, setGlobalDispatcher, fetch as undiciFetch } from "undici" type FetchFunction = (...args: Parameters) => ReturnType @@ -131,6 +132,73 @@ export const fetch: typeof globalThis.fetch = (() => { (mockFetch || baseFetch)(input, init)) as typeof globalThis.fetch })() +const NO_IDLE_TIMEOUTS = { headersTimeout: 0, bodyTimeout: 0 } +let directDispatcher: Agent | undefined +let proxiedDispatcher: EnvHttpProxyAgent | undefined + +// The same hosts VS Code's proxy resolver always connects to directly. +// `EnvHttpProxyAgent` would otherwise send them through `http_proxy` whenever +// `no_proxy` is unset, which is exactly the shape of a corporate environment. +// BlockList matches IPv4-mapped IPv6 (`[::ffff:127.0.0.1]`, which the URL +// parser rewrites to `[::ffff:7f00:1]`) against the IPv4 rules. +const LOOPBACK_ADDRESSES = new BlockList() +LOOPBACK_ADDRESSES.addSubnet("127.0.0.0", 8, "ipv4") +LOOPBACK_ADDRESSES.addAddress("0.0.0.0", "ipv4") +LOOPBACK_ADDRESSES.addAddress("::1", "ipv6") + +function isLoopbackHost(input: string | URL | Request): boolean { + try { + const host = new URL(typeof input === "string" ? input : input instanceof URL ? input.href : input.url).hostname + .replace(/^\[|\]$/g, "") + .toLowerCase() + if (host === "localhost") { + return true + } + const family = isIP(host) + return family !== 0 && LOOPBACK_ADDRESSES.check(host, family === 6 ? "ipv6" : "ipv4") + } catch { + return false + } +} + +/** + * Fetch for providers that may point at a local model server, with undici's + * idle timeouts disabled. + * + * undici aborts a response after `headersTimeout` / `bodyTimeout` (300s + * each) of silence on the wire. A local model processing a large prompt + * sends its headers at once and then nothing until the first token, often + * for longer than that, so the request dies with `UND_ERR_BODY_TIMEOUT` + * while the server is still generating (cline/cline#13371, #13464). + * Cancellation stays with the request's AbortSignal, i.e. the stop button. + * + * This goes through undici's own fetch rather than global fetch: the VS Code + * extension host wraps global fetch and swaps any caller-supplied dispatcher + * for its own default-timeout Agent, so the override would be dropped. The + * trade-off is that these requests honour `http_proxy` / `https_proxy` / + * `no_proxy` rather than VS Code's `http.proxy` setting, except that loopback + * always goes direct, as it does in VS Code. On the standalone build the + * proxy agent is the same one installed globally above. + */ +export const fetchWithoutIdleTimeouts: typeof globalThis.fetch = (( + input: string | URL | Request, + init?: RequestInit, +): Promise => { + if (mockFetch) { + return mockFetch(input, init) + } + let dispatcher: Dispatcher + if (isLoopbackHost(input)) { + dispatcher = directDispatcher ??= new Agent(NO_IDLE_TIMEOUTS) + } else { + dispatcher = proxiedDispatcher ??= new EnvHttpProxyAgent(NO_IDLE_TIMEOUTS) + } + return undiciFetch( + input as Parameters[0], + { ...init, dispatcher } as Parameters[1], + ) as unknown as Promise +}) as typeof globalThis.fetch + /** * Mocks `fetch` for testing and calls `callback`. Then restores `fetch`. If the * specified callback returns a Promise, the fetch is restored when that Promise diff --git a/apps/vscode/vitest.config.ts b/apps/vscode/vitest.config.ts index d5a5fe3975..1482187cfd 100644 --- a/apps/vscode/vitest.config.ts +++ b/apps/vscode/vitest.config.ts @@ -10,6 +10,7 @@ export default defineConfig({ "src/sdk/**/*.test.ts", "src/hosts/vscode/VscodeEditPreview.test.ts", "src/shared/vsCodeSelectorUtils.test.ts", + "src/shared/net.test.ts", "src/shared/proto-conversions/models/**/*.test.ts", "src/core/storage/remote-config/**/*.test.ts", "src/core/controller/account/setUserOrganization.test.ts", From 8009be6e33c4a503eda7282e4b33d8a437852f06 Mon Sep 17 00:00:00 2001 From: Saoud Rizwan <7799382+saoudrizwan@users.noreply.github.com> Date: Tue, 13 Oct 2026 18:07:06 -0700 Subject: [PATCH 10/21] fix(desktop): ship the SDK packages plugins import in production builds (#15068) * fix(desktop): ship the SDK packages plugins import in production builds Plugin installs strip @cline/* dependencies because the host provides them, and the plugin worker resolves those from node_modules beside its bootstrap. The packaged desktop app ships only the bootstrap, so a marketplace plugin like goal failed with "Cannot find module '@cline/sdk'" (dev builds worked because the repo's node_modules sit above src-tauri). Bundle self-contained @cline/sdk, core, agents, llms and shared next to the bootstrap (one shared copy via code splitting, ~17 MB), and widen the Tauri resource glob so the nested files are actually bundled. * fix(desktop): ship the SDK packages' subpath exports for plugins Plugins can import supported subpaths such as @cline/shared/storage or @cline/core/telemetry. Bundle every runtime export of each package and write its exports map, skipping type-only exports that have no JS. --- .../desktop-app/scripts/build-sidecar-bin.ts | 49 +++++++++++++++++++ .../desktop-app/src-tauri/tauri.conf.json | 2 +- 2 files changed, 50 insertions(+), 1 deletion(-) diff --git a/apps/examples/desktop-app/scripts/build-sidecar-bin.ts b/apps/examples/desktop-app/scripts/build-sidecar-bin.ts index 22b42db826..3eb75fc0d6 100644 --- a/apps/examples/desktop-app/scripts/build-sidecar-bin.ts +++ b/apps/examples/desktop-app/scripts/build-sidecar-bin.ts @@ -1,3 +1,4 @@ +import { existsSync } from "node:fs"; import { fileURLToPath } from "node:url"; import { $ } from "bun"; import { telemetryDefineArgs } from "./telemetry-define-args"; @@ -126,6 +127,53 @@ const buildUniversalMacSidecar = async (): Promise => { await $`lipo -info ${outfile}`; }; +// Plugin installs drop their @cline/* dependencies because the host provides +// them, and the plugin worker resolves those from node_modules beside its +// bootstrap. Nothing like that exists next to the compiled sidecar, so ship +// self-contained builds there. Splitting keeps one shared copy of the SDK, +// placed in core so every chunk sits inside a `"type": "module"` package. +const bundlePluginSdkPackages = async (): Promise => { + const outdir = "src-tauri/bin/extensions/node_modules/@cline"; + const packagesRoot = "../../../sdk/packages"; + const packages = await Promise.all( + ["sdk", "core", "agents", "llms", "shared"].map(async (name) => { + const root = `${packagesRoot}/${name}`; + const manifest = (await Bun.file(`${root}/package.json`).json()) as { + exports: Record; + }; + // Type-only exports (e.g. @cline/shared/types) have no JS to ship. + const exports = Object.fromEntries( + Object.entries(manifest.exports) + .map(([path, entry]) => [path, entry.import] as const) + .filter(([, file]) => existsSync(`${root}/${file}`)), + ); + return { root, name, exports }; + }), + ); + await $`rm -rf ${outdir}`; + const result = await Bun.build({ + entrypoints: packages.flatMap(({ root, exports }) => + Object.values(exports).map((file) => `${root}/${file}`), + ), + root: packagesRoot, + outdir, + naming: { chunk: "core/dist/[name]-[hash].[ext]" }, + target: "node", + format: "esm", + splitting: true, + minify: true, + }); + if (!result.success) { + throw new AggregateError(result.logs, "plugin SDK bundle failed"); + } + for (const { name, exports } of packages) { + await Bun.write( + `${outdir}/${name}/package.json`, + JSON.stringify({ name: `@cline/${name}`, type: "module", exports }), + ); + } +}; + const main = async () => { // All compiled helpers and the sidecar depend on fresh SDK package exports. await $`bun run build:sdk`.cwd( @@ -136,6 +184,7 @@ const main = async () => { // Plugins run in worker threads that load this self-contained bootstrap // from disk; the app ships it as a resource (see plugin-bootstrap.ts). await $`cp ../../../sdk/packages/core/dist/extensions/plugin-sandbox-bootstrap.js src-tauri/bin/extensions/plugin-sandbox-bootstrap.js`; + await bundlePluginSdkPackages(); if (targetTriple === "universal-apple-darwin") { await buildUniversalMacSidecar(); } else { diff --git a/apps/examples/desktop-app/src-tauri/tauri.conf.json b/apps/examples/desktop-app/src-tauri/tauri.conf.json index dc24e05b8d..9718ed1a53 100644 --- a/apps/examples/desktop-app/src-tauri/tauri.conf.json +++ b/apps/examples/desktop-app/src-tauri/tauri.conf.json @@ -46,7 +46,7 @@ "icons/app/*.png", "icons/app/macos/*.png", "bin/remote-helpers/*", - "bin/extensions/*" + "bin/extensions/**/*" ], "icon": [ "icons/32x32.png", From 1932d5dc37ae14b050073e3a3444f71e34ea0309 Mon Sep 17 00:00:00 2001 From: Saoud Rizwan <7799382+saoudrizwan@users.noreply.github.com> Date: Tue, 13 Oct 2026 20:31:05 -0700 Subject: [PATCH 11/21] feat(vscode): Cline Cloud sessions - run tasks in a hosted sandbox from the extension (#13799) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(sdk): support header-authenticated remote Hub connections Ports the NodeHubClient resolveConnectionHeaders option from #13519 so a client can authenticate a remote proxy's WebSocket upgrade with HTTP headers (refreshed on every reconnect), and threads it through HubRuntimeHost and RemoteRuntimeHost so ClineCore remote runtimes can reach Cline Cloud sandboxes. Co-authored-by: Saoud Rizwan * feat(vscode): cloud session runtime, control-plane client and history integration Adds the extension-host side of Cline Cloud sessions: - CloudSessionsService: REST client for GitHub App status/repos/branches and the outer session records (create/list/status/delete/rename/history) - CloudSessionHost: an SdkSessionHost over RemoteRuntimeHost that dials the sandbox Hub through the api.cline.bot proxy with the account token, maps outer/inner ids and re-emits events so the existing chat pipeline is reused - SdkCloudSessionCoordinator: starts/reopens cloud tasks, keeps sandbox connections alive to track running/finished status, notifies on completion and projects cloud sessions into task history - CloudService proto + handlers, NewTaskRequest execution target, history cloud fields/filter, ext-cloud-sessions feature flag Co-authored-by: Saoud Rizwan * feat(vscode): cloud sessions UI — home task-target panel, running strip, history and account - Home screen: Local | Cloud chooser above the composer with GitHub repo and branch pickers (prefilled from the workspace's origin remote and branch) and an onboarding card that walks through connecting GitHub, polling until done - Running-in-the-cloud strip and cloud status pills in Recent/History, plus a Cloud Only history filter - Task header cloud badge (repo @ branch, opens the dashboard); cloud tasks render inline diffs, hide checkpoint/restore and only attach images - Account view: Cline Cloud card with GitHub connection status Co-authored-by: Saoud Rizwan * fix(vscode): cloud task fixes found in manual testing - claim the task-view generation after clearTask (which bumps it), so cloud task start/open is not treated as superseded and left on Thinking - track completion from the agent done event and keep completed/failed over the trailing idle status so History shows Done instead of Cloud - stop the branch picker from re-querying on every render - readable popover text in the repo/branch pickers; hide cost chips on cloud rows Co-authored-by: Saoud Rizwan * docs(vscode): cloud sessions follow-ups and test stub for org switch reset Co-authored-by: Saoud Rizwan * fix(vscode): make cloud sessions Act-only Match the desktop app and the cloud dashboard: cloud sessions are created in Act mode regardless of the selected mode, no per-turn mode is sent, the host refuses Plan/Act toggles while a cloud session is active, and the webview pins the toggle to Act (disabled, with a tooltip) while a cloud task is shown or about to start. Co-authored-by: Saoud Rizwan * test(vscode): add local cloud session boundary * fix(vscode): preserve cloud outcomes and stale-work ownership * fix(vscode): drain cloud cleanup before account changes * fix(vscode): build cloud sandbox config through the Cline path * docs(vscode): clarify cloud proxy rollout boundary * fix(vscode): close cloud picker when leaving chat * fix(vscode): wait for cloud session before opening dashboard * fix(vscode): keep account history in document flow * fix(vscode): report available cloud task usage * fix(vscode): cancel cloud tasks during provisioning * fix(vscode): make cloud model billing explicit * fix(vscode): hide local approvals for cloud tasks * fix(vscode): resolve cloud prompt before session adoption * fix(vscode): seed local cloud development profile * fix(vscode): sanitize local cloud fixture errors * fix(vscode): handle cloud state before hydration * fix(core): honor proxies for remote Hub WebSockets * fix(vscode): preserve cloud task IDs * fix(vscode): explain cloud auto-approval * test: repair cloud and proxy CI fixtures * fix(vscode): resolve cloud status after restart * fix(vscode): resolve flags for restored accounts * fix(vscode): keep cloud tasks in the cloud and in Act mode * test(vscode): let the local cloud fixture delay model replies CLINE_LOCAL_CLOUD_MODEL_DELAY_MS holds the scripted reply so cancellation and navigation can be exercised while a cloud task is still thinking. * fix(vscode): cancel a cloud task that is still provisioning Cancelling during "Starting a cloud sandbox..." did not stop the start: - Cancel arriving while initTask awaited remote config found no pending start, so the sandbox went on to provision, connect and run the prompt behind a "Resume Task" view, and the user paid for a sandbox they had cancelled. initTask now claims the cloud start before its first await. - Cancel arriving during the readiness poll took effect only after the sandbox came up. createSession accepts an abort signal that stops the poll, so the unused record is deleted as soon as the control plane answers. - clearTask (New Task) now cancels a pending cloud start too; a provisioning task has no SDK session for the task controller to end. The local cloud fixture reports `ready` like the hosted control plane and accepts CLINE_LOCAL_CLOUD_PROVISION_DELAY_MS to hold new sandboxes in `provisioning`, so this window is reproducible. The cloud Vitest config restores the Zod namespace export the same way vitest.config.ts does. * fix(vscode): keep cloud status displays truthful and hosts open while in use A status check that resolved a cloud task as idle or finished closed the host while openCloudTask could still be reading messages or attaching the session on that same host. Entries now count the openCloudTask calls using them, and neither status resolution nor the idle sweep disposes a host that is pinned or belongs to the displayed task. Cancelling a cloud start while remote config was still loading left the turn phase at streaming after the view had returned home. initTask no longer sets the phase for cloud starts; the coordinator sets it once the start is still current and its task view is installed. useResolvedCloudStatuses kept a resolved status after history reported a newer one, so a task resolved as running stayed "Working" after it completed. The hook now returns statuses only for tasks history still reports as unknown, and the running strip lists a task only while its effective status is active or unconfirmed. An unknown status showed a spinner labelled "Checking" for as long as connections kept failing. It is now shown as Unconfirmed with a static icon; only provisioning and running animate. * test(core): run the proxy tunnel fixture with a real Node executable When Bun runs vitest it reports its own Node-compatible shim as process.execPath and places it first on PATH. That runtime ignores NODE_EXTRA_CA_CERTS, so the spawned fixture failed TLS verification on the Windows CI runner. The test now looks past the shim for an installed Node and is skipped when none is available. * fix(vscode): remember cloud task status across restarts After a restart every cloud row in History showed "Unconfirmed" and joined the running strip until a socket to each sandbox re-learned its status, and the first status a socket reported bumped the row's timestamp to now, reordering History (CLINE-3307, CLINE-3424, CLINE-3418). The coordinator now writes the last settled status it confirmed over a live connection to global state (`cloudSessionStatuses`, keyed by session id with the observation time) and reads it back when it first sees a record. The remembered status is trusted only while the control plane's `updatedAt` is no later than the observation plus a one-minute grace, because the control plane touches `updatedAt` when a client connects; a later touch means another client may have run the agent, so the row falls back to unknown and is resolved live as before. Active statuses are never remembered. Ids the account no longer lists are pruned on every list refresh. A connection that only learns the current status no longer counts as activity; the row's timestamp moves only when the status changes while connected or the user sends a follow-up. * fix(vscode): settle account switches without blocking History * fix(vscode): refresh stale cloud outcomes without restarting * test(vscode): wait for cloud run cleanup before removing sandbox * fix(vscode): reserve switch errors for account transitions * refactor(vscode): remove unused cloud accessors and refresh notes * test(vscode): seed expired sessions and a 402 model reply in the cloud fixture The loopback fixture only ever served live sandboxes, so the read-only expired path and the out-of-credits path could not be exercised without a day's wait or real spend. CLINE_LOCAL_CLOUD_SEED_EXPIRED=1 seeds two Personal sessions whose sandboxes are gone: one with an archived transcript, one that answers /history with the control plane's 404 "no history captured". Their WebSocket upgrade returns 410 like the real proxy and /status reports "expired". CLINE_LOCAL_CLOUD_INSUFFICIENT_CREDITS=1 makes every scripted model reply the hosted API's 402 insufficient_credits body. * fix(vscode): explain expired cloud sessions instead of showing an error Opening a cloud task whose sandbox the control plane retired showed a red error row and, when no transcript had been archived, a synthetic Done row above it. QA read this as data loss (CLINE-3322). The row is now an informational notice that says the sandbox was retired after 24 idle hours and names the repository to start a new cloud task on. It tells three cases apart: the archived conversation is shown above it; no conversation was archived (the control plane answered 404); or the archive could not be loaded right now, with the error. An empty transcript is no longer rendered, so no Done row appears. The History pill reads Archived with a tooltip explaining the retirement. * fix(vscode): show the out-of-credits card for cloud task failures A cloud turn that failed on the Cline API's 402 insufficient_credits reply ended with Retry / Start New Task and no explanation (CLINE-3376). A Hub-hosted session does not forward the agent's "error" event; the client learns of the failure only from run.failed, which the runtime host turns into done(reason: "error") with the failure message as its text. The translator recorded the error outcome for that event but rendered nothing. done(reason: "error") now renders the same api_req_started and api_req_failed rows as an "error" event, once per turn, so ErrorRow shows the Buy Credits card (or the plain error) for cloud tasks as it does for local ones. Both paths share one helper. * fix(vscode): explain cloud sessions deleted or retired since History loaded Opening a cloud task deleted from the dashboard showed "Could not connect to this cloud session: Unexpected server response: 404" and left the row in History (CLINE-3377). The Hub client reports only the HTTP status of the refused upgrade. A failed connection now asks the control plane for the session's status. A deleted session shows a notice and leaves History; a session retired since the list was fetched opens as expired, with its archived conversation if one exists. Other failures keep the connection error. The loopback fixture now answers an unknown session's upgrade with 404, as the hosted proxy does, instead of 401. * fix(vscode): only suggest cloud branches that exist on GitHub The cloud branch picker was prefilled with the workspace's checked-out branch even when it had never been pushed. The sandbox clones from GitHub, so the session then failed to start (CLIENTS-109). The workspace default is now the remote branch the checkout corresponds to: its upstream on origin when one is configured, otherwise the same name when origin has it. A local-only branch yields no suggestion and the picker falls back to the repository's default branch. Branches listed from the GitHub API were already remote. * fix(vscode): accumulate streamed text for cloud tasks A cloud task's reply flickered chunk by chunk and only appeared whole when the message finished (CLIENTS-111). The translator showed each content_start's `accumulated` text, falling back to the chunk alone. A local session sends the running total, but a Hub-hosted session forwards assistant.delta as a bare chunk, so each update replaced the last. The translator now keeps the running total itself when `accumulated` is absent, and clears it with the other per-block streaming state. * fix(vscode): show the cloud model in the composer while Cloud is selected With Cloud selected, or a cloud task open, the composer footer still showed the local provider and model, e.g. anthropic:claude-opus, although the sandbox runs a Cline model (CLIENTS-116). The extension now reports cloudModelId in state: the model the displayed cloud task runs on, else the one a new cloud task would start with. The start path and the label share one function, so they cannot disagree. The composer shows it as cline: whenever it pins the mode to Act for Cloud. * fix(vscode): release the retained host of a deleted cloud session When a session opened from History turns out to have been deleted, its entry leaves the registry. A connection retained from an earlier status check stayed open with nothing to watch; dispose it with the entry. * fix(vscode): keep expired-session and cloud-model displays truthful An archived cloud transcript was rendered with the final turn marked completed, so the last reply of a task that was still running when its sandbox retired showed as a green Done box. Retirement is not completion; the final reply now stays a plain text row. An archive that exists but holds no messages rendered nothing yet the notice pointed at "the conversation above", and the History tooltip promised a readable conversation for every Archived row. An empty archive now reads as none, and the tooltip says to open the session to see whether one was saved. With a non-Cline local provider the composer showed the cached or fallback recommendation while the start path refreshed the list first and could pick a different model. The start now uses the model shown when the user submitted, snapshotted before any await, and the composer warms the recommendation cache once so the label moves from the fallback to the live recommendation before the next submit. * fix(vscode): confirm an account switch overtaken by its own auth update switchOrganization confirmed a switch by re-reading the organizations and treating an answer overtaken by a newer read as no answer. The extension's auth refresh after the PUT starts such a newer read, so a switch that succeeded returned false and the ClinePass switch control asked the user to retry it. Every read now returns the server's answer; only the newest read still writes context state. * fix(core): connect header-authenticated Hub sockets under Bun The desktop sidecar runs the cloud session controller under Bun. Since a2cefba05 the header-authenticated Hub client imported the npm `ws` package through a `node-ws` alias so a proxy agent could be attached. Under Bun 1.3.13, the toolchain this branch was pinned to, Bun's node:http client never emits `upgrade`, so the npm `ws` treats the 101 as an ordinary response and connect() fails with "Unexpected server response: 101". Cloud sessions in the desktop app could not connect. Import `ws` directly again. Bun substitutes its own client, which reads the proxy URL and Proxy-Authorization from the same `agent` option that Node's `ws` uses, so one socket shape tunnels on both runtimes and the `node-ws` dependency and its @ts-expect-error go away. The tunnel test now runs the fixture under Bun as well as Node. Bun's TLS client does not read NODE_EXTRA_CA_CERTS, so the Bun leg tunnels a plain ws:// destination; it still checks the CONNECT, the upgrade and where the credentials land. With the `node-ws` import the Bun leg fails on 1.3.13 and the Node leg passes, which is the gap that let this ship. * fix(vscode): retry the cloud model recommendation after a failed fetch The composer shows the model a new cloud task will run on. When the local provider is not Cline that is the top recommendation, warmed once by the coordinator. A fetch that failed returned the built-in fallback without caching it, but the coordinator kept its warm-up handle, so it never asked again and the fallback stayed in the composer and in new sandboxes until the extension restarted. Release the handle when a fetch settles without filling the cache, so the next state post retries. * fix(vscode): stop the recommendation warm-up from polling a failing endpoint The warm-up posted state after every fetch, including one that returned the built-in fallback. That post reads the model again, which starts the next fetch, so a recommendation endpoint that keeps failing was polled without bound while Cloud was selected with a non-Cline provider. Post state only when the fetch filled the cache. A failed fetch still releases the handle, so the next read the user causes retries. * fix(vscode): disable the composer while a cloud sandbox is provisioning The composer allows sending during the streaming phase so follow-ups can queue on a running task, and a cloud start sets that phase while the sandbox is still provisioning. There is no session to queue on yet, so askResponse hit the expired-session guard and answered "This cloud session is no longer running" while the sandbox kept starting, dropping the message. The displayed cloud task now reports status provisioning while its start is pending, and the composer keeps submit disabled on that status so the draft stays in place until the sandbox is running. * fix(vscode): re-enable the composer after a failed cloud start The failure path posts state while the start is still pending, so the composer kept seeing "provisioning" and stayed disabled. Post state once more after the pending start is cleared. The askResponse guard, still reachable through the extension API while the composer is disabled, now says the sandbox is starting instead of that the session is gone. * style(vscode): wrap two JSX lines the CI formatter rejects Biome 2.4.5 on main wraps these attributes; the branch's Quality Checks failed on them after the rebase onto that formatter version. * test(vscode): pin the composer's provisioning status through a failed start The displayed cloud task reports provisioning while its start is pending, and the last state post after a failed start no longer carries it, so the composer re-enables without waiting for an unrelated refresh. * fix(vscode): keep a draft typed while a cloud task is provisioning handleSendMessage cleared the composer a second time after its RPC resolved. Every send path already clears the draft before the RPC, so the second clear only ever removed text the user typed while the RPC was pending. A local newTask resolves too quickly to notice; a cloud newTask resolves only after the sandbox is provisioned, so the draft the disabled composer was holding for the user vanished when the sandbox came up. * fix(vscode): follow main's rename of the sign-in retry field #14628 replaced pendingClineAuthRetryPrompt with pendingClineAuthRetry. The cloud coordinator's clearTask callback and the cloud branch of initTask still cleared the old field, which no longer exists. * fix(vscode): tear down cloud sessions when ClinePass switches the account Selecting the ClinePass provider switches the active account to Personal by calling switchAccount directly. The Account view's organization switch runs the same call inside resetCloudSessions, which detaches the displayed cloud task and its authenticated hosts and drains pending cleanup before the account changes. Without that, an organization cloud task stayed on screen after the account became Personal, and a still-pending DELETE could use the new account's credentials. Route the ClinePass switch through the same reset, and only when an organization is active: every API configuration update passes through this path, and the reset would otherwise clear a displayed cloud task for a model change that switches nothing. * fix(vscode): page History's Cloud Only filter after filtering The Cloud Only filter ran on a page already cut from the merged local-and-cloud list, and hasMore was computed from that page. With more than a page of newer local tasks the first page came back empty while cloud tasks existed further down, and hasMore said there was nothing more. When Cloud Only is on, list the whole history, filter, then apply offset, limit and hasMore to the filtered records. * fix(vscode): stop reporting a model change the cloud sandbox never made CloudSessionHost.updateSessionModel called an optional method that RemoteRuntimeHost does not have, then recorded the new model anyway. Choosing a different local model while a cloud task was displayed made the composer name that model while the sandbox kept running on the one it was started with. The Hub protocol has no command to change a running session's model, so drop the method. The lifecycle already treats a host without it as unable to switch, and the composer keeps naming the model the sandbox was started on. * fix(vscode): hide a cloud task's cost until the sandbox reports usage The task header decided whether to show the cost badge from the local provider's cost display setting. For a cloud task with no usage yet that showed $0.0000, which reads as a confirmed zero for a charge that is not known. Cloud tasks now show the badge only when the sandbox has reported usage; local tasks keep the provider-based decision. * fix(vscode): do not offer to open a cloud task's files in the local editor Cloud edits render through DiffEditRow, whose file name and open icon call openFileRelativePath. The path is relative to the sandbox's /workspace, so clicking opened an unrelated local file or failed. The row now takes canOpenFile; ChatRow passes false for cloud tasks, which drops the click action and the open icon and leaves the path as plain text. * fix(vscode): keep the cloud repository and branch choice truthful Two ways the stored cloud target could name something the current account cannot start a sandbox on: - The repository selection persists across account changes. When the new account has no accessible repositories the panel showed onboarding, but the old URL stayed stored, so the composer still treated the target as ready to submit. Clear the stored repository once the account's list is known and does not contain it. - The workspace branch was persisted from the local remote-tracking ref without checking GitHub. A branch deleted on GitHub but not pruned locally was suggested and submitted, and provisioning failed. Start on the repository's default branch and ask GitHub for the workspace branch by name; the unfiltered branch list is paginated, so its first page cannot decide. Suggest the branch only if GitHub returns it and the default branch is still selected, so a branch the user has since chosen stays. * fix(vscode): reconcile retained cloud connections with the account's list Two ways a cloud session's record and its connection could disagree: - A list refresh kept any entry that still had a host, so a task deleted from the dashboard stayed in History with an open authenticated connection, and a 401 cleared the entries without closing their hosts. An entry whose record has left the list, or every entry on a 401, is now dropped and its host closed. The displayed task is the exception in both cases: its host stays so that opening it can explain a deletion, and so that signing in again after a 401 leaves a task that can continue. - When a sandbox retires after History was listed, its WebSocket rejects with 410 while /status may answer 200 with status "expired". The probe treated every successful /status as "not expired", so the task opened to a connection error instead of the retirement notice. A 200 that says expired now counts as retirement, like a 410. * fix(vscode): subscribe monitored cloud tasks to their Hub event stream The Hub streams a session's events only to clients subscribed to it, and the runtime host subscribes when it first sends a command for that session. A connection opened only to resolve a History row's status sent none: it listed sessions, read the status, and then waited on an event stream that was never opened. A turn finished by another client left the row on Running and the completion notification never fired. Listing the task's pending prompts after discovery sends that first command and opens the stream. * test(vscode): keep the local cloud fixture alive when a client drops its socket An extension host that reloads or exits closes its bridged WebSocket without a close frame. The bridge attached no "error" listener to either side, so ws raised an unhandled error and the fixture process exited, taking every sandbox with it. Terminate the failed socket instead. * Own the account boundary in switchAccount Four paths changed the active account (the Account view, ClinePass selection and two remote-config paths) and only one of them tore down the previous account's cloud task and connections first. Each caller had to remember to wrap the switch, and two did not. switchAccount now runs every change inside a boundary the controller installs once (the cloud coordinator's reset), and serialises switches: one requested while another is in flight waits for it and then decides against the account as it is then. A caller that read a stale account cannot skip a needed switch or repeat one that just happened, so the ClinePass gate and the Account view's pending-switch map are gone. * Resolve the cloud task target in the extension The task-target panel computed the whole stored target from five effects (account repositories, workspace remote, GitHub branch check, repository click, Local/Cloud click), each writing the result back. Any two could interleave and the later write won: a branch check that landed after the user clicked Local flipped the target back to Cloud. The extension now stores only what the user chose, one field per choice (Local/Cloud, a repository, a branch), and resolves the rest on every state post: the stored repository if this account can reach it, else the workspace's, else the first; the user's branch, else the workspace branch once GitHub confirms it exists, else the default. Nothing derived is written, so no lookup can override a click, and a repository chosen under another account is never submitted. Failed lookups retry after 30 seconds; the GitHub connection is cached per account and shared with the Account view. The webview panel renders the resolved view and sends each click as one choice. * test(vscode): keep the cloud fixture alive when the Hub socket fails early The fixture's session proxy attached its error handler to the upstream Hub socket only after "open". An error during the handshake was unhandled and exited the fixture process, which the extension then saw as "Timed out connecting to hub after 8000ms". Attach the handler at construction for both sides of the bridge. * Refresh aged cloud-target inputs and always restore the server account The task-target resolver cached repositories and the workspace checkout for as long as the account and directory stayed the same. Repository access changes in the browser and the checkout changes in the terminal, and neither notifies the extension, so the picker could keep offering a revoked repository or suggesting the previous branch. A loaded input now ages out (repositories after 60 s, the checkout after 10 s): a read past that returns the cached answer and reloads behind it. switchAccount skipped the request when the cached account already matched. The server's active account is shared by every client on the login, so another client may have moved it. The request is now always sent; only the cloud teardown is gated on the cached account changing. * Retry a failed cloud start instead of declaring the session gone A cloud task whose sandbox never came up shows "Failed to start the cloud session" with Retry / Start New Task, but Retry appended "This cloud session is no longer running" and the footer then stayed disabled: the webview holds its recovery claim until the turn phase advances, and the refusal never advanced it. The coordinator now hands the failed input to the controller, which keeps it as the same pending start retry a "Sign in to Cline" error uses. Retry runs the cloud task again on the same repository and branch; a prompt typed into the composer starts one with that prompt. Refusing a response to a session that is gone sets the turn phase, so the footer releases its claim either way. The task-header tooltip no longer doubles its full stop when the task has no repository. The local cloud fixture starts a sandbox's Hub when the session becomes ready rather than on the first socket upgrade, so a cold start cannot outlast the client's connect timeout. * test(vscode): let the cloud fixture refuse session sockets CLINE_LOCAL_CLOUD_REFUSE_SOCKETS=1 answers every session socket upgrade with 503, so a cloud start fails after its sandbox is ready and the footer's Retry path can be exercised by hand. * Replace the carried-forward transcript when the new epoch's snapshot lands Retrying a failed cloud start appended the new attempt below the old one instead of replacing it. The new task's first partials reach the webview before the debounced state snapshot of the new epoch; the reducer deliberately carries the old rows forward on a lone newer-epoch partial, but then treated the snapshot as same-epoch and merged, so the old rows were never removed. The replica now remembers that it carried rows forward, and the first snapshot at that epoch replaces them while keeping the newer-epoch partials already held. A prompt typed after a failed start also left its optimistic bubble below the new task row: the bubble is user_feedback, but a restart is confirmed by a task row. Any user-authored row with the same text and attachments now confirms the bubble. * test(vscode): keep the cloud scope fixture complete Supply the failed-start callback required by the cloud retry path and assert that the coordinator hands it the failed task and original input. Check the fixture keys against the options interface before casting its partial service doubles, so a new required option fails typechecking. * fix(vscode): send Cancel to the task on screen, not a cloud start left behind (#14794) A cloud start stays pending after the user opens another task from History. Cancel always cancelled that pending start first, so cancelling a running task (cloud or local) cleared its view instead, and a cloud turn kept running in its sandbox. Only cancel the pending start when its own view, or no task, is on screen. * fix(vscode): delete cloud sandboxes left provisioning by a reload or quit (#14791) * fix(vscode): delete cloud sandboxes left provisioning by a reload or quit A terminating extension host cannot reach the network (VS Code's fetch resolves proxies through the window it is detaching from), so the cleanup DELETE a reload or quit triggers during provisioning never leaves the process and the sandbox is orphaned. Record each provisioning sandbox in a per-extension-host file under the Cline data directory until its task starts or its cleanup is confirmed, and let the next extension host delete records whose owner has exited. * fix(vscode): recover abandoned cloud starts only when it is safe Address review of the abandoned-start journal: - Keep a record until the control plane confirms the sandbox is gone. A 401, 429 or 5xx no longer erases it; only a successful DELETE or a 404 under the account and endpoint that started it settles it. - Keep a record until the sandbox has started the first turn, not just until the send was queued. Recovery connects to a sandbox before deleting it and leaves one that already holds a conversation, in case the host exited after the turn was accepted but before recording that. - Name each journal by pid and extension-host lifetime and refresh it while it has records, so an earlier lifetime of this pid, or a pid reused by another process, reads as abandoned instead of being skipped or overwritten. - Run recovery as one scope operation of the account that started the sandboxes: an account change waits for a DELETE in flight and stops the rest, nothing runs after disposal, and reset and dispose cancel the recheck timer. * fix(vscode): never recover a cloud start that may still be live - A sandbox's saved transcript stays empty until the first turn's iteration ends, so recovery could delete a task whose first turn was running. Treat any Hub session status other than idle as a turn the sandbox has taken. - Drop the journal heartbeat. A missed refresh does not prove the owner exited (it may be paused in a debugger or asleep), so a journal whose pid is alive is never taken; a pid reused by another process only delays recovery until that process exits. - Bound the sandbox inspection with a timeout and stop awaiting its detach, so an unresponsive sandbox cannot hold the account scope. * refactor(vscode): simplify abandoned cloud start recovery Drop the record right before the first prompt is sent instead of waiting for the sandbox to report turn acceptance. A surviving record then means the prompt never left the extension host, so its sandbox holds no user work and recovery can just DELETE it. This removes the turn-acceptance tracking in CloudSessionHost and the sandbox inspection (connect, status check, transcript read, timeout) that recovery needed to tell an orphan from a live task. Also fail the start when the record cannot be written, so its normal cleanup DELETE runs while the host can still reach the network, and treat only ESRCH as proof that a journal's owner has exited. * fix(vscode): fail a cloud start whose pending record cannot be dropped Recovery deletes any recorded sandbox without inspecting it, so the record must be gone before the first prompt is sent. If dropping it fails, fail the start instead of sending; its normal cleanup then deletes the sandbox. After a confirmed deletion a leftover record is harmless, so that path only logs. * fix(vscode): preserve provisioning tasks opened from History (#14793) Keep a pending cloud start alive when its own History row is opened, reject older selections, and relist its sandbox when startup settles. Carry the task-view fence across clearTask instead of claiming again. Retain abandoned-start journaling from the target branch and verify its record survives own-row reopening until immediately before the first send. * fix(vscode): keep a failed cloud start's images when Retry is clicked The webview's Retry response omits images, which protobuf decodes as an empty list, so `images ?? retry.images` dropped the attachments and the retried sandbox ran on the text prompt alone. Fall back to the failed start's images unless the response actually carries some. * fix(vscode): stop sending the account token into cloud sandboxes (#14970) * fix(vscode): keep cloud sessions out of Delete All History (#14967) * fix(vscode): keep cloud sessions out of Delete All History * fix(vscode): report the actual Delete All History count * fix(vscode): cancel a provisioning cloud start when the account changes (#14968) * fix(vscode): forget a remembered cloud status once the agent runs again (#14969) * fix(vscode): forget a remembered cloud status once the agent runs again * test(vscode): cover forgetting a remembered status on connect * feat(vscode): resume suspended cloud sandboxes and use the SDK's cloud client (#14973) * test(vscode): let the local cloud fixture suspend and resume sandboxes * fix(vscode): resume suspended cloud sandboxes instead of treating them as unknown * fix(vscode): reopen cloud tasks whose last turn failed * refactor(vscode): talk to the cloud control plane through the SDK's CloudSessionApi * feat(vscode): show cloud provisioning progress and dashboard renames * refactor(core): export the cloud session system prompt * feat(vscode): give cloud sandboxes the SDK's shared prompt and work-branch rules * perf(vscode): keep the local model catalog out of cloud session.create * docs(vscode): update cloud session follow-ups for the SDK alignment * fix(vscode): reconnect from scratch after resuming a suspended sandbox * fix(vscode): link an organization to its own GitHub connect page * test(vscode): only auto-suspend resumable fixture sandboxes * fix(vscode): show the resume notice when a suspended cloud task is opened from History showTaskWithId only dismisses the History view after the open completes, which for a suspended sandbox is after the whole resume. The notice the coordinator posts stayed covered and History sat unchanged for the duration. Bring the chat view forward right after the notice is posted. * fix(vscode): keep the UI on the server's account after a slow switch (#14971) * fix(vscode): keep the UI on the server's account after a slow switch * fix(vscode): retract a switch error only from the newest account read * feat(vscode): opt-in Cloud toggle in Settings behind ext-cloud-sessions (#15018) * feat(vscode): add opt-in Cloud toggle in Settings behind the ext-cloud-sessions flag * fix(vscode): refresh History when the Cloud toggle changes * fix(vscode): close four cloud-session gaps found in review and QA - A cancelled cloud start left a ghost STARTING row in History. A list during provisioning adds an entry for the new record before the start holds one, and cleanupUnusedSession only dropped the caller's entry. It now drops the entry by session id and invalidates History after the DELETE settles. - A failed DELETE left entry.host pointing at a disposed host, so a reopened task stopped reporting status. The host is cleared before disposal. - Switching repositories left the previous repository's branches selectable while the new list loaded, so a stale branch could be saved against the new repository. The list is cleared and the picker disabled until it loads. - Attached files were silently dropped from a cloud start. The composer now refuses the send and says why. - compile-tests could not resolve @cline/core/cloud under classic node resolution; tsconfig.test.json maps the subpath to the built declarations. * docs(vscode): track cloud session follow-ups as GitHub issues instead of a checked-in file * merge: preserve latest VS Code cloud updates with reviewed fixes * Saoudrizwan/cloud greptile fixes 26ac (#15073) * test(vscode): stub @cline/core/cloud for the CommonJS integration host * fix(vscode): keep the open cloud task's model visible when Cloud is turned off * fix(vscode): restore a cloud runtime the Hub lost after a turn reports it missing --------- Co-authored-by: Saoud Rizwan Co-authored-by: Cursor Agent Co-authored-by: Cline Agent Co-authored-by: Dominic Cooney Co-authored-by: Dominic Cooney Co-authored-by: Dominic Cooney <55120+dominiccooney@users.noreply.github.com> Co-authored-by: Dominic Coon Co-authored-by: John Choi --- .github/workflows/ext-vscode-test.yml | 8 + apps/vscode/package.json | 6 +- apps/vscode/proto/cline/cloud.proto | 84 + apps/vscode/proto/cline/state.proto | 3 + apps/vscode/proto/cline/task.proto | 13 + apps/vscode/scripts/repro-cloud-session.mjs | 70 + apps/vscode/src/config.ts | 13 + .../account/getUserOrganizations.ts | 16 +- .../account/setUserOrganization.test.ts | 47 +- .../controller/account/setUserOrganization.ts | 34 +- .../controller/cloud/chooseCloudTaskTarget.ts | 15 + .../core/controller/cloud/connectGitHub.ts | 10 + .../controller/cloud/getGitHubConnection.ts | 15 + .../controller/cloud/getRepositoryBranches.ts | 10 + .../cloud/openCloudSessionDashboard.ts | 12 + .../cloud/resolveCloudSessionStatuses.ts | 13 + .../handleClinePassProviderSelection.ts | 5 + .../models/refreshClineRecommendedModels.ts | 5 + .../core/controller/state/updateSettings.ts | 6 + .../src/core/controller/task/newTask.test.ts | 51 + .../src/core/controller/task/newTask.ts | 19 +- .../remote-config/sdk-control-plane.ts | 1 + .../vscode/src/dev/local-cloud-development.ts | 119 + apps/vscode/src/dev/local-cloud-server.ts | 31 + apps/vscode/src/sdk/SdkController.test.ts | 297 ++- apps/vscode/src/sdk/SdkController.ts | 296 ++- apps/vscode/src/sdk/account-service.test.ts | 114 + apps/vscode/src/sdk/account-service.ts | 77 +- .../src/sdk/cline-session-factory.test.ts | 33 + apps/vscode/src/sdk/cline-session-factory.ts | 15 +- .../src/sdk/cloud-pending-starts.test.ts | 70 + apps/vscode/src/sdk/cloud-pending-starts.ts | 112 + .../vscode/src/sdk/cloud-session-host.test.ts | 274 +++ apps/vscode/src/sdk/cloud-session-host.ts | 485 +++++ .../vscode/src/sdk/message-translator.test.ts | 39 + apps/vscode/src/sdk/message-translator.ts | 107 +- .../sdk/sdk-cloud-session-coordinator.test.ts | 1935 +++++++++++++++++ .../src/sdk/sdk-cloud-session-coordinator.ts | 1582 ++++++++++++++ .../src/sdk/sdk-cloud-session-scope.test.ts | 526 +++++ .../src/sdk/sdk-cloud-task-target.test.ts | 229 ++ apps/vscode/src/sdk/sdk-cloud-task-target.ts | 268 +++ apps/vscode/src/sdk/sdk-mode-coordinator.ts | 5 + .../src/sdk/sdk-session-lifecycle.test.ts | 12 + apps/vscode/src/sdk/sdk-session-lifecycle.ts | 79 +- .../src/sdk/sdk-session-rebuild-scheduler.ts | 10 +- .../sdk/sdk-task-control-coordinator.test.ts | 11 + .../src/sdk/sdk-task-control-coordinator.ts | 22 +- apps/vscode/src/sdk/sdk-task-history.test.ts | 27 +- apps/vscode/src/sdk/sdk-task-history.ts | 83 +- .../services/cloud/CloudSessionsService.ts | 321 +++ .../services/cloud/cloudSessionsFeature.ts | 32 + .../cloud/workspace-cloud-defaults.test.ts | 51 + .../cloud/workspace-cloud-defaults.ts | 52 + .../FeatureFlagsProviderFactory.ts | 2 +- .../feature-flags/FeatureFlagsService.test.ts | 58 + .../feature-flags/FeatureFlagsService.ts | 21 +- .../providers/IFeatureFlagsProvider.ts | 2 +- .../PostHogFeatureFlagsProvider.test.ts | 22 + .../providers/PostHogFeatureFlagsProvider.ts | 8 +- apps/vscode/src/shared/ExtensionMessage.ts | 11 + apps/vscode/src/shared/HistoryItem.ts | 10 + .../vscode/src/shared/cloud/cloud-sessions.ts | 158 ++ .../services/feature-flags/feature-flags.ts | 3 + apps/vscode/src/shared/storage/state-keys.ts | 6 + .../cloud/cloud-credit-error-boundary.test.ts | 80 + .../test/cloud/cloud-outcome-boundary.test.ts | 173 ++ .../test/cloud/cloud-service-contract.test.ts | 364 ++++ .../cloud/cloud-session-gone-boundary.test.ts | 75 + .../cloud/cloud-session-host-boundary.test.ts | 146 ++ .../cloud-session-resume-boundary.test.ts | 157 ++ .../cloud/local-cloud-development.test.ts | 378 ++++ .../src/test/cloud/local-cloud-environment.ts | 684 ++++++ apps/vscode/src/types/ws.d.ts | 17 - apps/vscode/test-setup.js | 15 + apps/vscode/tsconfig.test.json | 3 + apps/vscode/vitest.cloud.config.ts | 23 + apps/vscode/vitest.config.ts | 6 + apps/vscode/webview-ui/src/App.tsx | 11 +- .../components/account/AccountView.test.tsx | 196 ++ .../src/components/account/AccountView.tsx | 304 +-- .../account/CreditsHistoryTable.tsx | 2 +- .../src/components/chat/ChatRow.tsx | 25 +- .../src/components/chat/ChatTextArea.test.tsx | 30 + .../src/components/chat/ChatTextArea.tsx | 45 +- .../src/components/chat/ChatView.tsx | 17 +- .../src/components/chat/DiffEditRow.test.tsx | 37 + .../src/components/chat/DiffEditRow.tsx | 43 +- .../src/components/chat/ErrorRow.test.tsx | 6 +- .../chat/OrgClinePassRestrictionError.tsx | 64 +- .../auto-approve-menu/AutoApproveBar.test.tsx | 87 + .../chat/auto-approve-menu/AutoApproveBar.tsx | 63 +- .../auto-approve-menu/AutoApproveMenuItem.tsx | 9 +- .../auto-approve-menu/AutoApproveModal.tsx | 22 +- .../components/layout/InputSection.test.tsx | 89 +- .../components/layout/InputSection.tsx | 22 +- .../components/layout/WelcomeSection.tsx | 10 + .../hooks/useMessageHandlers.test.tsx | 89 +- .../chat-view/hooks/useMessageHandlers.ts | 25 +- .../chat/chat-view/messageReducer.test.ts | 30 + .../chat/chat-view/messageReducer.ts | 26 +- .../chat-view/shared/buttonConfig.test.ts | 22 + .../chat/chat-view/shared/buttonConfig.ts | 21 +- .../chat/chat-view/types/chatTypes.ts | 1 + .../chat-view/utils/pendingResponse.test.ts | 7 + .../chat/chat-view/utils/pendingResponse.ts | 18 +- .../chat/task-header/TaskHeader.test.tsx | 20 + .../chat/task-header/TaskHeader.tsx | 57 +- .../src/components/cloud/CloudGitHubCard.tsx | 82 + .../components/cloud/CloudOnboardingCard.tsx | 147 ++ .../components/cloud/CloudStatusPill.test.tsx | 27 + .../src/components/cloud/CloudStatusPill.tsx | 52 + .../components/cloud/CloudTaskBadge.test.tsx | 91 + .../src/components/cloud/CloudTaskBadge.tsx | 50 + .../components/cloud/RunningCloudTasks.tsx | 78 + .../src/components/cloud/SearchableSelect.tsx | 138 ++ .../components/cloud/TaskTargetPanel.test.tsx | 181 ++ .../src/components/cloud/TaskTargetPanel.tsx | 240 ++ .../src/components/history/HistoryPreview.tsx | 58 +- .../src/components/history/HistoryView.tsx | 42 +- .../components/history/HistoryViewItem.tsx | 118 +- .../sections/FeatureSettingsSection.spec.tsx | 15 + .../sections/FeatureSettingsSection.tsx | 12 + .../src/context/ClineAuthContext.test.tsx | 228 +- .../src/context/ClineAuthContext.tsx | 97 +- .../src/context/ExtensionStateContext.tsx | 2 + .../src/hooks/useGitHubConnection.test.tsx | 106 + .../src/hooks/useGitHubConnection.ts | 102 + .../hooks/useResolvedCloudStatuses.test.tsx | 62 + .../src/hooks/useResolvedCloudStatuses.ts | 53 + bun.lock | 8 + sdk/DOC.md | 4 + sdk/packages/core/package.json | 4 + sdk/packages/core/src/cloud/api.test.ts | 87 +- sdk/packages/core/src/cloud/api.ts | 13 + sdk/packages/core/src/cloud/controller.ts | 40 +- .../client/__fixtures__/localhost-cert.pem | 19 + .../hub/client/__fixtures__/localhost-key.der | Bin 0 -> 1217 bytes .../hub/client/__fixtures__/proxy-client.mjs | 19 + .../src/hub/client/connection-headers.test.ts | 226 +- sdk/packages/core/src/hub/client/index.ts | 34 + .../core/src/hub/client/proxy.test.ts | 276 +++ .../src/hub/runtime-host/hub-runtime-host.ts | 3 + .../hub/runtime-host/remote-runtime-host.ts | 1 + 143 files changed, 13636 insertions(+), 669 deletions(-) create mode 100644 apps/vscode/proto/cline/cloud.proto create mode 100644 apps/vscode/scripts/repro-cloud-session.mjs create mode 100644 apps/vscode/src/core/controller/cloud/chooseCloudTaskTarget.ts create mode 100644 apps/vscode/src/core/controller/cloud/connectGitHub.ts create mode 100644 apps/vscode/src/core/controller/cloud/getGitHubConnection.ts create mode 100644 apps/vscode/src/core/controller/cloud/getRepositoryBranches.ts create mode 100644 apps/vscode/src/core/controller/cloud/openCloudSessionDashboard.ts create mode 100644 apps/vscode/src/core/controller/cloud/resolveCloudSessionStatuses.ts create mode 100644 apps/vscode/src/core/controller/task/newTask.test.ts create mode 100644 apps/vscode/src/dev/local-cloud-development.ts create mode 100644 apps/vscode/src/dev/local-cloud-server.ts create mode 100644 apps/vscode/src/sdk/account-service.test.ts create mode 100644 apps/vscode/src/sdk/cloud-pending-starts.test.ts create mode 100644 apps/vscode/src/sdk/cloud-pending-starts.ts create mode 100644 apps/vscode/src/sdk/cloud-session-host.test.ts create mode 100644 apps/vscode/src/sdk/cloud-session-host.ts create mode 100644 apps/vscode/src/sdk/sdk-cloud-session-coordinator.test.ts create mode 100644 apps/vscode/src/sdk/sdk-cloud-session-coordinator.ts create mode 100644 apps/vscode/src/sdk/sdk-cloud-session-scope.test.ts create mode 100644 apps/vscode/src/sdk/sdk-cloud-task-target.test.ts create mode 100644 apps/vscode/src/sdk/sdk-cloud-task-target.ts create mode 100644 apps/vscode/src/services/cloud/CloudSessionsService.ts create mode 100644 apps/vscode/src/services/cloud/cloudSessionsFeature.ts create mode 100644 apps/vscode/src/services/cloud/workspace-cloud-defaults.test.ts create mode 100644 apps/vscode/src/services/cloud/workspace-cloud-defaults.ts create mode 100644 apps/vscode/src/services/feature-flags/FeatureFlagsService.test.ts create mode 100644 apps/vscode/src/services/feature-flags/providers/PostHogFeatureFlagsProvider.test.ts create mode 100644 apps/vscode/src/shared/cloud/cloud-sessions.ts create mode 100644 apps/vscode/src/test/cloud/cloud-credit-error-boundary.test.ts create mode 100644 apps/vscode/src/test/cloud/cloud-outcome-boundary.test.ts create mode 100644 apps/vscode/src/test/cloud/cloud-service-contract.test.ts create mode 100644 apps/vscode/src/test/cloud/cloud-session-gone-boundary.test.ts create mode 100644 apps/vscode/src/test/cloud/cloud-session-host-boundary.test.ts create mode 100644 apps/vscode/src/test/cloud/cloud-session-resume-boundary.test.ts create mode 100644 apps/vscode/src/test/cloud/local-cloud-development.test.ts create mode 100644 apps/vscode/src/test/cloud/local-cloud-environment.ts delete mode 100644 apps/vscode/src/types/ws.d.ts create mode 100644 apps/vscode/vitest.cloud.config.ts create mode 100644 apps/vscode/webview-ui/src/components/account/AccountView.test.tsx create mode 100644 apps/vscode/webview-ui/src/components/chat/DiffEditRow.test.tsx create mode 100644 apps/vscode/webview-ui/src/components/chat/auto-approve-menu/AutoApproveBar.test.tsx create mode 100644 apps/vscode/webview-ui/src/components/chat/task-header/TaskHeader.test.tsx create mode 100644 apps/vscode/webview-ui/src/components/cloud/CloudGitHubCard.tsx create mode 100644 apps/vscode/webview-ui/src/components/cloud/CloudOnboardingCard.tsx create mode 100644 apps/vscode/webview-ui/src/components/cloud/CloudStatusPill.test.tsx create mode 100644 apps/vscode/webview-ui/src/components/cloud/CloudStatusPill.tsx create mode 100644 apps/vscode/webview-ui/src/components/cloud/CloudTaskBadge.test.tsx create mode 100644 apps/vscode/webview-ui/src/components/cloud/CloudTaskBadge.tsx create mode 100644 apps/vscode/webview-ui/src/components/cloud/RunningCloudTasks.tsx create mode 100644 apps/vscode/webview-ui/src/components/cloud/SearchableSelect.tsx create mode 100644 apps/vscode/webview-ui/src/components/cloud/TaskTargetPanel.test.tsx create mode 100644 apps/vscode/webview-ui/src/components/cloud/TaskTargetPanel.tsx create mode 100644 apps/vscode/webview-ui/src/hooks/useGitHubConnection.test.tsx create mode 100644 apps/vscode/webview-ui/src/hooks/useGitHubConnection.ts create mode 100644 apps/vscode/webview-ui/src/hooks/useResolvedCloudStatuses.test.tsx create mode 100644 apps/vscode/webview-ui/src/hooks/useResolvedCloudStatuses.ts create mode 100644 sdk/packages/core/src/hub/client/__fixtures__/localhost-cert.pem create mode 100644 sdk/packages/core/src/hub/client/__fixtures__/localhost-key.der create mode 100644 sdk/packages/core/src/hub/client/__fixtures__/proxy-client.mjs create mode 100644 sdk/packages/core/src/hub/client/proxy.test.ts diff --git a/.github/workflows/ext-vscode-test.yml b/.github/workflows/ext-vscode-test.yml index 82c365e277..9381ce9276 100644 --- a/.github/workflows/ext-vscode-test.yml +++ b/.github/workflows/ext-vscode-test.yml @@ -211,6 +211,14 @@ jobs: exit 1 fi + - name: Cloud session boundary (local Hub) + if: ${{ !cancelled() && steps.build_step.outcome == 'success' && runner.os == 'Linux' }} + working-directory: . + run: | + bun run build:sdk + cd apps/vscode + bun run test:cloud + - name: Unit Tests (bun) - Linux id: unit_tests_linux if: ${{ !cancelled() && steps.build_step.outcome == 'success' && runner.os == 'Linux' }} diff --git a/apps/vscode/package.json b/apps/vscode/package.json index fd74c2216d..44b8202625 100644 --- a/apps/vscode/package.json +++ b/apps/vscode/package.json @@ -378,6 +378,7 @@ "test:integration": "bun run compile-tests && vscode-test", "test:unit": "bun scripts/run-bun-unit-tests.ts", "test:vitest": "vitest run --config vitest.config.ts", + "test:cloud": "vitest run --config vitest.cloud.config.ts", "test:vitest:watch": "vitest --config vitest.config.ts", "test:bun": "bun scripts/run-bun-tests.ts", "test:bun:unit": "bun scripts/run-bun-unit-tests.ts", @@ -385,6 +386,7 @@ "test:sca-server": "bun --watch scripts/test-standalone-core-api-server.ts", "test:tp-orchestrator": "bun scripts/testing-platform-orchestrator.ts", "dev:mcp-oauth-test-server": "bun src/dev/mcp-oauth-test-server/server.ts", + "dev:cloud-sessions": "bun src/dev/local-cloud-server.ts", "e2e": "playwright test -c playwright.config.ts", "test:e2e": "bun run build:production && node src/test/e2e/utils/build.mjs && playwright test", "test:e2e:ui": "bun scripts/interactive-playwright.ts", @@ -429,6 +431,7 @@ "@types/sinon": "^21.0.0", "@types/turndown": "^5.0.5", "@types/vscode": "1.101.0", + "@types/ws": "^8.18.1", "@vscode/test-cli": "^0.0.12", "@vscode/test-electron": "^2.5.2", "@vscode/vsce": "^3.6.0", @@ -454,7 +457,8 @@ "ts-proto": "^2.6.1", "tsconfig-paths": "^4.2.0", "typescript": "^5.4.5", - "vitest": "^4.0.17" + "vitest": "^4.0.17", + "ws": "^8.20.0" }, "dependencies": { "@anthropic-ai/sdk": "^0.37.0", diff --git a/apps/vscode/proto/cline/cloud.proto b/apps/vscode/proto/cline/cloud.proto new file mode 100644 index 0000000000..4ac5ae1786 --- /dev/null +++ b/apps/vscode/proto/cline/cloud.proto @@ -0,0 +1,84 @@ +syntax = "proto3"; + +package cline; + +import "cline/common.proto"; + +option go_package = "github.com/cline/grpc-go/cline"; +option java_multiple_files = true; +option java_package = "bot.cline.proto"; + +// Cline Cloud sessions: run a task in a hosted sandbox instead of the local workspace. +service CloudService { + // GitHub App connection status and the repositories it can access, for the active account scope. + // Cached per account; `refresh` re-asks the control plane. + rpc getGitHubConnection(GitHubConnectionRequest) returns (GitHubConnection); + // Opens the GitHub App install / manage-access page in the browser + rpc connectGitHub(EmptyRequest) returns (Empty); + // Lists branches of an accessible repository + rpc getRepositoryBranches(RepositoryBranchesRequest) returns (CloudBranchList); + // Records one user choice about where new tasks run: Local/Cloud, a repository, or a branch + rpc chooseCloudTaskTarget(CloudTaskTargetChoice) returns (Empty); + // Opens the Cline Cloud dashboard page for a cloud session + rpc openCloudSessionDashboard(StringRequest) returns (Empty); + // Resolves live agent status for only the cloud sessions currently displayed + rpc resolveCloudSessionStatuses(CloudSessionStatusRequest) returns (CloudSessionStatusList); +} + +message GitHubRepository { + int64 id = 1; + string name = 2; + string full_name = 3; + string url = 4; + string default_branch = 5; +} + +message GitHubConnectionRequest { + Metadata metadata = 1; + bool refresh = 2; +} + +message GitHubConnection { + bool signed_in = 1; + bool connected = 2; + string connect_url = 3; + repeated GitHubRepository repositories = 4; + // Set when the request failed (network / API error); other fields are then unreliable + optional string error = 5; +} + +message RepositoryBranchesRequest { + Metadata metadata = 1; + int64 repository_id = 2; + optional string query = 3; +} + +message CloudBranchList { + repeated string branches = 1; +} + +// Exactly one field is set: the choice the user just made. Choosing a +// repository forgets the branch chosen for the previous one. +message CloudTaskTargetChoice { + Metadata metadata = 1; + oneof choice { + // "local" | "cloud" + string target = 2; + int64 repository_id = 3; + string branch = 4; + } +} + +message CloudSessionStatusRequest { + Metadata metadata = 1; + repeated string session_ids = 2; +} + +message CloudSessionStatusItem { + string session_id = 1; + string status = 2; +} + +message CloudSessionStatusList { + repeated CloudSessionStatusItem statuses = 1; +} diff --git a/apps/vscode/proto/cline/state.proto b/apps/vscode/proto/cline/state.proto index 35f7f3b111..76c282842f 100644 --- a/apps/vscode/proto/cline/state.proto +++ b/apps/vscode/proto/cline/state.proto @@ -287,6 +287,8 @@ message Settings { optional OpenRouterModelInfo plan_mode_cline_pass_model_info = 184; optional string act_mode_cline_pass_model_id = 185; optional OpenRouterModelInfo act_mode_cline_pass_model_info = 186; + optional bool cloud_sessions_enabled = 188; + optional string open_telemetry_traces_exporter = 189; } message State { @@ -431,6 +433,7 @@ message UpdateSettingsRequest { optional bool show_feature_tips = 42; optional string compaction_strategy = 44; optional bool web_search_enabled = 45; + optional bool cloud_sessions_enabled = 46; } message UpdateTerminalConnectionTimeoutRequest { diff --git a/apps/vscode/proto/cline/task.proto b/apps/vscode/proto/cline/task.proto index 2f60c8671c..198f417ff4 100644 --- a/apps/vscode/proto/cline/task.proto +++ b/apps/vscode/proto/cline/task.proto @@ -54,6 +54,10 @@ message NewTaskRequest { repeated string images = 3; repeated string files = 4; optional Settings task_settings = 5; + // "local" (default) or "cloud"; cloud runs the task in a Cline Cloud sandbox + optional string execution_target = 6; + optional string cloud_repo_url = 7; + optional string cloud_branch = 8; } // Request message for toggling task favorite status @@ -88,6 +92,7 @@ message GetTaskHistoryRequest { bool current_workspace_only = 5; int32 limit = 6; int32 offset = 7; + bool cloud_only = 8; } // Response for task history @@ -113,6 +118,14 @@ message TaskItem { // Provider id the task ran on. Empty for tasks recorded before this // field existed and for legacy imports; consumers show cost in that case. string api_provider = 13; + // "cloud" for Cline Cloud sessions; empty/"local" otherwise + string execution_target = 14; + // Cloud session status: provisioning | running | idle | completed | failed | cancelled | unknown | expired + string cloud_status = 15; + string cloud_repo_url = 16; + string cloud_branch = 17; + // True only when token/cost fields came from an authoritative cloud runtime snapshot. + bool cloud_usage_available = 18; } // Request for ask response operation diff --git a/apps/vscode/scripts/repro-cloud-session.mjs b/apps/vscode/scripts/repro-cloud-session.mjs new file mode 100644 index 0000000000..ebcc9bba3a --- /dev/null +++ b/apps/vscode/scripts/repro-cloud-session.mjs @@ -0,0 +1,70 @@ +const accessToken = process.env.CLINE_OAUTH_TOKEN?.trim() +if (!accessToken) { + throw new Error("Set CLINE_OAUTH_TOKEN to a Cline OAuth access token.") +} + +const apiBaseUrl = (process.env.CLINE_API_BASE_URL ?? "https://api.cline.bot").replace(/\/+$/, "") +const repositoryUrl = process.env.CLINE_REPO_URL ?? "https://github.com/cline/cline" +const branch = process.env.CLINE_BRANCH +const modelId = process.env.CLINE_MODEL_ID ?? "openai/gpt-5.6-sol" +const organizationId = process.env.CLINE_ORGANIZATION_ID + +async function request(path, init = {}) { + const response = await fetch(`${apiBaseUrl}${path}`, { + ...init, + headers: { + Accept: "application/json", + Authorization: `Bearer ${accessToken}`, + ...(init.body ? { "Content-Type": "application/json" } : {}), + ...init.headers, + }, + }) + const body = response.status === 204 ? undefined : await response.json().catch(() => undefined) + if (!response.ok) { + throw new Error(`${init.method ?? "GET"} ${path} returned ${response.status}: ${JSON.stringify(body)}`) + } + return body?.data +} + +const created = await request("/api/v1/session", { + method: "POST", + body: JSON.stringify({ + modelId, + repoUrl: repositoryUrl, + ...(branch ? { branch } : {}), + ...(organizationId ? { organizationId } : {}), + }), +}) +const sessionId = created?.sessionId +if (!sessionId) { + throw new Error(`Create response had no session id: ${JSON.stringify(created)}`) +} + +console.log(JSON.stringify({ event: "created", sessionId, created })) +try { + const deadline = Date.now() + 10 * 60_000 + while (Date.now() < deadline) { + const sessions = await request( + `/api/v1/session${organizationId ? `?organizationId=${encodeURIComponent(organizationId)}` : ""}`, + ) + const record = sessions?.find((session) => session.id === sessionId) + const status = await request(`/api/v1/session/${encodeURIComponent(sessionId)}/status`) + console.log( + JSON.stringify({ + event: "status", + sessionId, + sandboxId: record?.sandboxId, + taskId: record?.metadata?.taskId, + status, + }), + ) + if (["ready", "active", "failed"].includes(status?.status)) { + break + } + await new Promise((resolve) => setTimeout(resolve, 1_000)) + } +} finally { + await request(`/api/v1/session/${encodeURIComponent(sessionId)}`, { method: "DELETE" }).catch((error) => { + console.error(`Cleanup failed for ${sessionId}:`, error) + }) +} diff --git a/apps/vscode/src/config.ts b/apps/vscode/src/config.ts index 69e8413af9..fb9cd5982d 100644 --- a/apps/vscode/src/config.ts +++ b/apps/vscode/src/config.ts @@ -314,6 +314,19 @@ class ClineEndpoint { mcpBaseUrl: "https://core-api.staging.int.cline.bot/v1/mcp", } case Environment.local: + // The local fixture's app and API share one listener. Resolve them together. + if (process.env.CLINE_LOCAL_CLOUD_URL) { + const url = new URL(process.env.CLINE_LOCAL_CLOUD_URL) + if (url.protocol !== "http:" || url.hostname !== "127.0.0.1" || url.username || url.password) { + throw new ClineConfigurationError("CLINE_LOCAL_CLOUD_URL must be an HTTP loopback URL on 127.0.0.1") + } + return { + environment: Environment.local, + appBaseUrl: url.origin, + apiBaseUrl: url.origin, + mcpBaseUrl: `${url.origin}/v1/mcp`, + } + } return { environment: Environment.local, appBaseUrl: "http://localhost:3000", diff --git a/apps/vscode/src/core/controller/account/getUserOrganizations.ts b/apps/vscode/src/core/controller/account/getUserOrganizations.ts index d15954992f..bfb4417cea 100644 --- a/apps/vscode/src/core/controller/account/getUserOrganizations.ts +++ b/apps/vscode/src/core/controller/account/getUserOrganizations.ts @@ -9,13 +9,23 @@ import type { Controller } from "../index" * @returns User credits data response */ export async function getUserOrganizations(controller: Controller, _request: EmptyRequest): Promise { + let timer: ReturnType | undefined try { if (!controller.accountService) { throw new Error("Account service not available") } // Fetch user organizations from the account service - const organizations = await controller.accountService.fetchUserOrganizationsRPC() + const organizations = await Promise.race([ + controller.accountService.fetchUserOrganizationsRPC(), + new Promise((_, reject) => { + timer = setTimeout( + () => reject(new Error("Account confirmation timed out. Check your connection and try again.")), + 10_000, + ) + }), + ]) + if (!organizations) throw new Error("Could not confirm the active account. Check your connection and try again.") return UserOrganizationsResponse.create({ organizations: @@ -29,7 +39,7 @@ export async function getUserOrganizations(controller: Controller, _request: Emp }), ) || [], }) - } catch (error) { - throw error + } finally { + clearTimeout(timer) } } diff --git a/apps/vscode/src/core/controller/account/setUserOrganization.test.ts b/apps/vscode/src/core/controller/account/setUserOrganization.test.ts index f4a9880651..7bb0171be9 100644 --- a/apps/vscode/src/core/controller/account/setUserOrganization.test.ts +++ b/apps/vscode/src/core/controller/account/setUserOrganization.test.ts @@ -1,15 +1,52 @@ import { UserOrganizationUpdateRequest } from "@shared/proto/cline/account" -import { describe, expect, it, vi } from "vitest" +import { afterEach, describe, expect, it, vi } from "vitest" +import { getUserOrganizations } from "./getUserOrganizations" import { setUserOrganization } from "./setUserOrganization" describe("setUserOrganization", () => { + afterEach(() => vi.useRealTimers()) + + it("returns an unconfirmed timeout while the switch keeps running", async () => { + vi.useFakeTimers() + let finish!: () => void + const switchAccount = vi.fn( + () => + new Promise((resolve) => { + finish = resolve + }), + ) + const refreshRemoteConfig = vi.fn(async () => {}) + const controller = { accountService: { switchAccount }, refreshRemoteConfig } + const request = UserOrganizationUpdateRequest.create({ organizationId: "org-new" }) + const first = expect(setUserOrganization(controller as never, request)).rejects.toThrow("not confirmed within 10 seconds") + await vi.advanceTimersByTimeAsync(10_000) + await first + expect(refreshRemoteConfig).not.toHaveBeenCalled() + finish() + await vi.advanceTimersByTimeAsync(0) + // The timed-out switch still finishes its work. + expect(refreshRemoteConfig).toHaveBeenCalledOnce() + }) + + it("does not turn unavailable organizations into confirmed Personal", async () => { + const fetchUserOrganizationsRPC = vi.fn().mockResolvedValue(undefined) + const controller = { accountService: { fetchUserOrganizationsRPC } } + await expect(getUserOrganizations(controller as never, {})).rejects.toThrow("Could not confirm") + fetchUserOrganizationsRPC.mockResolvedValue([]) + await expect(getUserOrganizations(controller as never, {})).resolves.toEqual({ organizations: [] }) + }) + + it("bounds confirmation even when obtaining credentials is stuck", async () => { + vi.useFakeTimers() + const controller = { accountService: { fetchUserOrganizationsRPC: () => new Promise(() => {}) } } + const checking = expect(getUserOrganizations(controller as never, {})).rejects.toThrow("confirmation timed out") + await vi.advanceTimersByTimeAsync(10_000) + await checking + }) it("refreshes through the authoritative SDK path after switching organizations", async () => { const switchAccount = vi.fn().mockResolvedValue(undefined) const refreshRemoteConfig = vi.fn().mockResolvedValue(undefined) - const controller = { - accountService: { switchAccount }, - refreshRemoteConfig, - } + const controller = { accountService: { switchAccount }, refreshRemoteConfig } await setUserOrganization(controller as never, UserOrganizationUpdateRequest.create({ organizationId: "org-new" })) diff --git a/apps/vscode/src/core/controller/account/setUserOrganization.ts b/apps/vscode/src/core/controller/account/setUserOrganization.ts index 2c24aaa9df..f670e91f54 100644 --- a/apps/vscode/src/core/controller/account/setUserOrganization.ts +++ b/apps/vscode/src/core/controller/account/setUserOrganization.ts @@ -2,6 +2,8 @@ import { UserOrganizationUpdateRequest } from "@shared/proto/cline/account" import { Empty } from "@shared/proto/cline/common" import type { Controller } from "../index" +const SWITCH_TIMEOUT_MS = 10_000 + /** * Handles setting the user's active organization * @param controller The controller instance @@ -9,15 +11,31 @@ import type { Controller } from "../index" * @returns Empty response */ export async function setUserOrganization(controller: Controller, request: UserOrganizationUpdateRequest): Promise { + if (!controller.accountService) throw new Error("Account service not available") + // switchAccount serialises switches and tears down the previous account's + // cloud state itself. The response deadline below does not release that + // ownership: a timed-out PUT may still commit, and a later switch waits for + // it before deciding what to do. + const switching = controller.accountService.switchAccount(request.organizationId).then(() => controller.refreshRemoteConfig()) + switching.catch(() => {}) + let timer: ReturnType | undefined try { - if (!controller.accountService) { - throw new Error("Account service not available") - } - // Switch to the specified organization using the account service - await controller.accountService.switchAccount(request.organizationId) - await controller.refreshRemoteConfig() + await Promise.race([ + switching, + new Promise((_, reject) => { + timer = setTimeout( + () => + reject( + new Error( + "Account switch was not confirmed within 10 seconds and may still finish. Check Account before starting another task.", + ), + ), + SWITCH_TIMEOUT_MS, + ) + }), + ]) return {} - } catch (error) { - throw error + } finally { + clearTimeout(timer) } } diff --git a/apps/vscode/src/core/controller/cloud/chooseCloudTaskTarget.ts b/apps/vscode/src/core/controller/cloud/chooseCloudTaskTarget.ts new file mode 100644 index 0000000000..15d8caa807 --- /dev/null +++ b/apps/vscode/src/core/controller/cloud/chooseCloudTaskTarget.ts @@ -0,0 +1,15 @@ +import type { CloudTaskTargetChoice } from "@shared/proto/cline/cloud" +import { Empty } from "@shared/proto/cline/common" +import type { Controller } from "../index" + +/** Records the one choice the user just made about where new tasks run. */ +export async function chooseCloudTaskTarget(controller: Controller, request: CloudTaskTargetChoice): Promise { + if (request.target !== undefined) { + await controller.cloudTaskTarget.choose({ target: request.target === "cloud" ? "cloud" : "local" }) + } else if (request.repositoryId !== undefined) { + await controller.cloudTaskTarget.choose({ repositoryId: Number(request.repositoryId) }) + } else if (request.branch !== undefined) { + await controller.cloudTaskTarget.choose({ branch: request.branch }) + } + return Empty.create() +} diff --git a/apps/vscode/src/core/controller/cloud/connectGitHub.ts b/apps/vscode/src/core/controller/cloud/connectGitHub.ts new file mode 100644 index 0000000000..ed2cd0bc4d --- /dev/null +++ b/apps/vscode/src/core/controller/cloud/connectGitHub.ts @@ -0,0 +1,10 @@ +import { Empty, type EmptyRequest } from "@shared/proto/cline/common" +import { openExternal } from "@/utils/env" +import type { Controller } from "../index" + +/** Opens the GitHub App install (or manage repository access) page in the browser. */ +export async function connectGitHub(controller: Controller, _request: EmptyRequest): Promise { + const url = await controller.cloudSessions.getGitHubInstallUrl().catch(() => controller.cloudSessions.githubConnectUrl()) + await openExternal(url) + return Empty.create() +} diff --git a/apps/vscode/src/core/controller/cloud/getGitHubConnection.ts b/apps/vscode/src/core/controller/cloud/getGitHubConnection.ts new file mode 100644 index 0000000000..7e72b37ea5 --- /dev/null +++ b/apps/vscode/src/core/controller/cloud/getGitHubConnection.ts @@ -0,0 +1,15 @@ +import { GitHubConnection, type GitHubConnectionRequest, GitHubRepository } from "@shared/proto/cline/cloud" +import type { Controller } from "../index" + +/** + * GitHub App connection status for the active account scope, with the + * repositories the Cline GitHub App can access. Served from the same cache + * the task target resolves against, so the panel and the composer agree. + */ +export async function getGitHubConnection(controller: Controller, request: GitHubConnectionRequest): Promise { + const status = await controller.cloudTaskTarget.connectionStatus(request.refresh) + return GitHubConnection.create({ + ...status, + repositories: status.repositories.map((repository) => GitHubRepository.create(repository)), + }) +} diff --git a/apps/vscode/src/core/controller/cloud/getRepositoryBranches.ts b/apps/vscode/src/core/controller/cloud/getRepositoryBranches.ts new file mode 100644 index 0000000000..f504023e51 --- /dev/null +++ b/apps/vscode/src/core/controller/cloud/getRepositoryBranches.ts @@ -0,0 +1,10 @@ +import { CloudBranchList, type RepositoryBranchesRequest } from "@shared/proto/cline/cloud" +import type { Controller } from "../index" + +export async function getRepositoryBranches( + controller: Controller, + request: RepositoryBranchesRequest, +): Promise { + const branches = await controller.cloudSessions.listBranches(Number(request.repositoryId), request.query) + return CloudBranchList.create({ branches }) +} diff --git a/apps/vscode/src/core/controller/cloud/openCloudSessionDashboard.ts b/apps/vscode/src/core/controller/cloud/openCloudSessionDashboard.ts new file mode 100644 index 0000000000..580b4d18af --- /dev/null +++ b/apps/vscode/src/core/controller/cloud/openCloudSessionDashboard.ts @@ -0,0 +1,12 @@ +import { isPersistedCloudSessionId } from "@shared/cloud/cloud-sessions" +import { Empty, type StringRequest } from "@shared/proto/cline/common" +import { openExternal } from "@/utils/env" +import type { Controller } from "../index" + +export async function openCloudSessionDashboard(controller: Controller, request: StringRequest): Promise { + if (!isPersistedCloudSessionId(request.value)) { + throw new Error("The cloud session is not ready to open in the dashboard.") + } + await openExternal(controller.cloudSessions.dashboardUrl(request.value)) + return Empty.create() +} diff --git a/apps/vscode/src/core/controller/cloud/resolveCloudSessionStatuses.ts b/apps/vscode/src/core/controller/cloud/resolveCloudSessionStatuses.ts new file mode 100644 index 0000000000..c4d7195d37 --- /dev/null +++ b/apps/vscode/src/core/controller/cloud/resolveCloudSessionStatuses.ts @@ -0,0 +1,13 @@ +import { CloudSessionStatusItem, CloudSessionStatusList, type CloudSessionStatusRequest } from "@shared/proto/cline/cloud" +import type { Controller } from "../index" + +/** Resolves live agent status for only the cloud sessions named by the visible UI surface. */ +export async function resolveCloudSessionStatuses( + controller: Controller, + request: CloudSessionStatusRequest, +): Promise { + const statuses = await controller.resolveCloudSessionStatuses(request.sessionIds) + return CloudSessionStatusList.create({ + statuses: statuses.map(({ sessionId, status }) => CloudSessionStatusItem.create({ sessionId, status })), + }) +} diff --git a/apps/vscode/src/core/controller/models/handleClinePassProviderSelection.ts b/apps/vscode/src/core/controller/models/handleClinePassProviderSelection.ts index 6eed5c8916..e9136059d8 100644 --- a/apps/vscode/src/core/controller/models/handleClinePassProviderSelection.ts +++ b/apps/vscode/src/core/controller/models/handleClinePassProviderSelection.ts @@ -12,6 +12,11 @@ export const CLINE_PASS_PROVIDER_ID = "cline-pass" * the state post that re-renders the settings UI — on it. Auth state changes * propagate to the webview separately once the switch completes. * + * switchAccount does nothing when the account is already Personal, waits for + * any switch already in flight before deciding, and tears down the previous + * account's cloud task and connections before the account changes, so this + * call is safe on every API configuration update. + * * This is intentionally best-effort: selecting the provider should still be * saved even if the account switch fails. */ diff --git a/apps/vscode/src/core/controller/models/refreshClineRecommendedModels.ts b/apps/vscode/src/core/controller/models/refreshClineRecommendedModels.ts index efca784313..2857a3cdc0 100644 --- a/apps/vscode/src/core/controller/models/refreshClineRecommendedModels.ts +++ b/apps/vscode/src/core/controller/models/refreshClineRecommendedModels.ts @@ -40,6 +40,11 @@ export async function refreshClineRecommendedModels(): Promise ({ actions: {} }) }, + } as unknown as Controller + return { controller, initTask } +} + +describe("newTask execution target", () => { + it("refuses a cloud request that names no repository instead of starting locally", async () => { + const { controller, initTask } = makeController() + + await expect( + newTask(controller, NewTaskRequest.create({ text: "hello", executionTarget: "cloud", cloudRepoUrl: " " })), + ).rejects.toThrow("Choose a GitHub repository") + expect(initTask).not.toHaveBeenCalled() + }) + + it("passes the cloud target through when the repository is set", async () => { + const { controller, initTask } = makeController() + + await newTask( + controller, + NewTaskRequest.create({ + text: "hello", + executionTarget: "cloud", + cloudRepoUrl: "https://github.com/cline/fixture", + cloudBranch: "main", + }), + ) + + expect(initTask).toHaveBeenCalledWith("hello", [], [], undefined, expect.any(Object), { + repoUrl: "https://github.com/cline/fixture", + branch: "main", + }) + }) + + it("starts locally when no execution target is given", async () => { + const { controller, initTask } = makeController() + + await newTask(controller, NewTaskRequest.create({ text: "hello" })) + + expect(initTask).toHaveBeenCalledWith("hello", [], [], undefined, expect.any(Object), undefined) + }) +}) diff --git a/apps/vscode/src/core/controller/task/newTask.ts b/apps/vscode/src/core/controller/task/newTask.ts index 0e53825843..b2f261a1b0 100644 --- a/apps/vscode/src/core/controller/task/newTask.ts +++ b/apps/vscode/src/core/controller/task/newTask.ts @@ -69,6 +69,23 @@ export async function newTask(controller: Controller, request: NewTaskRequest): }).filter(([_, value]) => value !== undefined), ) - const taskId = await controller.initTask(request.text, request.images, request.files, undefined, filteredTaskSettings) + let cloudTarget: { repoUrl: string; branch?: string } | undefined + if (request.executionTarget === "cloud") { + const repoUrl = request.cloudRepoUrl?.trim() + // A cloud request without a repository is incomplete; running it locally + // instead would contradict the Cloud choice the user made. + if (!repoUrl) { + throw new Error("Choose a GitHub repository before starting a cloud task.") + } + cloudTarget = { repoUrl, branch: request.cloudBranch?.trim() || undefined } + } + const taskId = await controller.initTask( + request.text, + request.images, + request.files, + undefined, + filteredTaskSettings, + cloudTarget, + ) return String.create({ value: taskId || "" }) } diff --git a/apps/vscode/src/core/storage/remote-config/sdk-control-plane.ts b/apps/vscode/src/core/storage/remote-config/sdk-control-plane.ts index 56a19e2079..af976dccb5 100644 --- a/apps/vscode/src/core/storage/remote-config/sdk-control-plane.ts +++ b/apps/vscode/src/core/storage/remote-config/sdk-control-plane.ts @@ -57,6 +57,7 @@ async function makeAuthenticatedRequest(endpoint: string, organizationId: str const apiEndpoint = endpoint.replace("{id}", organizationId) const url = new URL(apiEndpoint, ClineEnv.config().apiBaseUrl).toString() const requestConfig: AxiosRequestConfig = { + timeout: 10_000, headers: { Authorization: `Bearer ${authToken}`, "Content-Type": "application/json", diff --git a/apps/vscode/src/dev/local-cloud-development.ts b/apps/vscode/src/dev/local-cloud-development.ts new file mode 100644 index 0000000000..a02d1fad18 --- /dev/null +++ b/apps/vscode/src/dev/local-cloud-development.ts @@ -0,0 +1,119 @@ +import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises" +import { tmpdir } from "node:os" +import path from "node:path" +import { type LocalCloudEnvironment, startLocalCloudEnvironment } from "../test/cloud/local-cloud-environment" + +export async function startLocalCloudDevelopment(options: { port?: number; tempDir?: string } = {}) { + const accessToken = "local-cloud-development-token" + const clineDir = await mkdtemp(path.join(options.tempDir ?? tmpdir(), "cline-local-cloud-profile-")) + const dataDir = path.join(clineDir, "data") + let environment: LocalCloudEnvironment | undefined + let disposal: Promise | undefined + const dispose = (): Promise => { + disposal ??= (async () => { + try { + await environment?.dispose() + } finally { + await rm(clineDir, { recursive: true, force: true }) + } + })() + return disposal + } + + // CLINE_LOCAL_CLOUD_MODEL_DELAY_MS holds every scripted model reply so a + // person can act (cancel, navigate) while the sandbox is still "thinking". + const modelDelayMs = Number(process.env.CLINE_LOCAL_CLOUD_MODEL_DELAY_MS ?? 0) + const beforeModelResponse = + modelDelayMs > 0 + ? (signal?: AbortSignal | null) => + new Promise((resolve, reject) => { + const timer = setTimeout(resolve, modelDelayMs) + signal?.addEventListener( + "abort", + () => { + clearTimeout(timer) + reject(signal.reason) + }, + { once: true }, + ) + }) + : undefined + // CLINE_LOCAL_CLOUD_PROVISION_DELAY_MS keeps a new sandbox in `provisioning` + // for that long, like the hosted control plane, so cancelling during + // provisioning can be exercised. + const provisioningDelayMs = Number(process.env.CLINE_LOCAL_CLOUD_PROVISION_DELAY_MS ?? 0) + // CLINE_LOCAL_CLOUD_SEED_EXPIRED=1 starts with two Personal sessions whose + // sandboxes are already gone, so the read-only transcript view and the + // "no history captured" path can be exercised without waiting a day. + const seedExpiredSessions = process.env.CLINE_LOCAL_CLOUD_SEED_EXPIRED === "1" + // CLINE_LOCAL_CLOUD_INSUFFICIENT_CREDITS=1 makes every model reply the hosted + // API's 402 insufficient-credits response, so the out-of-credits card can be + // exercised without spending anything. + const insufficientCredits = process.env.CLINE_LOCAL_CLOUD_INSUFFICIENT_CREDITS === "1" + // CLINE_LOCAL_CLOUD_REFUSE_SOCKETS=1 refuses every session socket, so a cloud + // start fails after its sandbox is ready and the Retry path can be exercised. + const refuseSessionSockets = process.env.CLINE_LOCAL_CLOUD_REFUSE_SOCKETS === "1" + // CLINE_LOCAL_CLOUD_SUSPEND_IDLE_MS suspends a sandbox that long after its last + // client disconnects, like the hosted control plane does to resumable + // sandboxes, so reopening and resuming a suspended task can be exercised. + const suspendIdleMs = process.env.CLINE_LOCAL_CLOUD_SUSPEND_IDLE_MS + const suspendIdleAfterMs = suspendIdleMs ? Number(suspendIdleMs) : undefined + try { + environment = await startLocalCloudEnvironment({ + ...options, + port: options.port ?? 7777, + accessToken, + beforeModelResponse, + provisioningDelayMs, + seedExpiredSessions, + insufficientCredits, + refuseSessionSockets, + suspendIdleAfterMs, + }) + const settingsDir = path.join(dataDir, "settings") + await mkdir(settingsDir, { recursive: true }) + await writeFile(path.join(dataDir, "globalState.json"), JSON.stringify({ welcomeViewCompleted: true })) + await writeFile( + path.join(settingsDir, "providers.json"), + JSON.stringify({ + version: 1, + lastUsedProvider: "cline", + providers: { + cline: { + settings: { + provider: "cline", + auth: { + accessToken: `workos:${accessToken}`, + accountId: "local-cloud-user", + expiresAt: Date.now() + 24 * 60 * 60 * 1000, + }, + }, + tokenSource: "oauth", + updatedAt: new Date().toISOString(), + }, + }, + }), + ) + const launchEnv = { + CLINE_ENVIRONMENT: "local", + CLINE_ENVIRONMENT_OVERRIDE: "local", + CLINE_LOCAL_CLOUD_URL: environment.apiBaseUrl, + CLINE_API_BASE_URL: environment.apiBaseUrl, + CLINE_CLOUD_SESSIONS: "1", + CLINE_DIR: clineDir, + CLINE_DATA_DIR: dataDir, + } + return { + environment, + clineDir, + launchEnv, + launchEnvironment: Object.entries(launchEnv) + .map(([key, value]) => `${key}='${value.replaceAll("'", "'\\''")}'`) + .join(" "), + dispose, + } + } catch (error) { + await dispose() + throw error + } +} diff --git a/apps/vscode/src/dev/local-cloud-server.ts b/apps/vscode/src/dev/local-cloud-server.ts new file mode 100644 index 0000000000..038d77d7b4 --- /dev/null +++ b/apps/vscode/src/dev/local-cloud-server.ts @@ -0,0 +1,31 @@ +#!/usr/bin/env bun + +import { startLocalCloudDevelopment } from "./local-cloud-development" + +const development = await startLocalCloudDevelopment() + +let disposing = false +async function dispose(): Promise { + if (disposing) return + disposing = true + try { + await development.dispose() + process.exit(0) + } catch (error) { + console.error("Failed to dispose local cloud development resources:", error) + process.exit(1) + } +} + +process.on("SIGINT", () => void dispose()) +process.on("SIGTERM", () => void dispose()) + +console.log("Local cloud sessions fixture is ready.") +console.log(`API: ${development.environment.apiBaseUrl}`) +console.log(`Token: ${development.environment.accessToken}`) +console.log(`CLINE_DIR: ${development.clineDir}`) +console.log("") +console.log("Launch the extension host with:") +console.log(` ${development.launchEnvironment}`) +console.log("The fixture accepts only loopback connections and blocks unexpected model requests.") +console.log("Press Ctrl-C to dispose all sandboxes and exit.") diff --git a/apps/vscode/src/sdk/SdkController.test.ts b/apps/vscode/src/sdk/SdkController.test.ts index d83b98de6b..08b0fe620b 100644 --- a/apps/vscode/src/sdk/SdkController.test.ts +++ b/apps/vscode/src/sdk/SdkController.test.ts @@ -1,4 +1,8 @@ +import { CLOUD_CREATION_UNCONFIRMED_ERROR_CODE } from "@shared/cloud/cloud-sessions" +import type { ClineMessage, TurnState } from "@shared/ExtensionMessage" +import { GetTaskHistoryRequest } from "@shared/proto/cline/task" import { describe, expect, it, vi } from "vitest" +import { ClineError } from "@/services/error/ClineError" import { telemetryService } from "@/services/telemetry" import { isClineManagedProvider } from "@/shared/utils/cline" import { Controller as SdkController } from "./SdkController" @@ -42,6 +46,7 @@ describe("SDK remote-config coordination", () => { const controller = { stateManager: { getGlobalSettingsKey: () => undefined, + getGlobalStateKey: () => undefined, getRemoteConfigSettings: () => ({}), setGlobalState: vi.fn(), }, @@ -53,6 +58,7 @@ describe("SDK remote-config coordination", () => { ensureWorkspaceManager: async () => undefined, taskHistory: { listHistory: async () => [] }, sessions: { getActiveSession: () => undefined }, + cloud: { getCurrentTaskInfo: () => undefined }, turnStateTracker: { get: () => undefined }, messageTranslatorState: { getMinter: () => ({ epoch: 1, nextSeq: () => 1 }) }, } @@ -70,6 +76,7 @@ describe("SDK remote-config coordination", () => { const controller = { stateManager: { getGlobalSettingsKey: () => undefined, + getGlobalStateKey: () => undefined, getRemoteConfigSettings: () => ({}), setGlobalState: vi.fn(), }, @@ -88,6 +95,7 @@ describe("SDK remote-config coordination", () => { }, }, sessions: { getActiveSession: () => undefined }, + cloud: { getCurrentTaskInfo: () => undefined, getCloudModelId: () => "cloud-model" }, turnStateTracker: { get: () => undefined }, messageTranslatorState: { getMinter: () => minter }, getStateToPostToWebview: SdkController.prototype.getStateToPostToWebview, @@ -290,6 +298,7 @@ describe("SDK remote-config coordination", () => { messageTranslatorState: { clearTurnOutcome: vi.fn() }, messages: { appendAndEmit: vi.fn() }, sessions: { getActiveSession: () => undefined }, + cloud: { isCloudSessionId: () => false }, postStateToWebview: vi.fn(async () => {}), initTask: vi.fn(async () => "task-id"), followups: { askResponse: vi.fn(async () => {}) }, @@ -316,21 +325,42 @@ describe("SDK remote-config coordination", () => { it("restarts a new task with the original prompt when Retry is clicked", async () => { const { controller, errorTask } = controllerShowingSignInError() await errorTask.handleWebviewAskResponse("yesButtonClicked") - expect(controller.initTask).toHaveBeenCalledWith("original prompt", undefined, undefined) + expect(controller.initTask).toHaveBeenCalledWith( + "original prompt", + undefined, + undefined, + undefined, + undefined, + undefined, + ) expect(controller.followups.askResponse).not.toHaveBeenCalled() }) it("restarts a new task with a revised prompt submitted from the composer", async () => { const { controller, errorTask } = controllerShowingSignInError() await errorTask.handleWebviewAskResponse("messageResponse", "revised prompt", ["img"]) - expect(controller.initTask).toHaveBeenCalledWith("revised prompt", ["img"], undefined) + expect(controller.initTask).toHaveBeenCalledWith( + "revised prompt", + ["img"], + undefined, + undefined, + undefined, + undefined, + ) expect(controller.followups.askResponse).not.toHaveBeenCalled() }) it("keeps the original prompt when the revised submission has attachments but no text", async () => { const { controller, errorTask } = controllerShowingSignInError() await errorTask.handleWebviewAskResponse("messageResponse", " ", ["img"]) - expect(controller.initTask).toHaveBeenCalledWith("original prompt", ["img"], undefined) + expect(controller.initTask).toHaveBeenCalledWith( + "original prompt", + ["img"], + undefined, + undefined, + undefined, + undefined, + ) }) it("continues an existing conversation with a message submitted from the composer", async () => { @@ -366,6 +396,114 @@ describe("SDK remote-config coordination", () => { }) }) + describe("after a cloud start fails before it has a session", () => { + const input = { prompt: "cloud prompt", images: ["img"], repoUrl: "https://github.com/cline/fixture", branch: "main" } + const cloudTarget = { repoUrl: input.repoUrl, branch: input.branch } + + function controllerShowingCloudStartError(unconfirmedAsk?: ClineMessage) { + const controller = { + task: undefined as TaskProxy | undefined, + turnStateTracker: { + set: vi.fn(), + get: (): TurnState => ({ phase: "error", anchorTs: unconfirmedAsk?.ts, seq: 1 }), + }, + messageTranslatorState: { clearTurnOutcome: vi.fn() }, + messages: { appendAndEmit: vi.fn() }, + sessions: { getActiveSession: () => undefined }, + cloud: { isCloudSessionId: () => true, getCurrentTaskInfo: () => ({ status: "unknown" }) }, + postStateToWebview: vi.fn(async () => {}), + initTask: vi.fn(async () => "ses-retried"), + followups: { askResponse: vi.fn(async () => {}) }, + cancelTask: vi.fn(async () => {}), + askResponse(prompt?: string, images?: string[], files?: string[]) { + return SdkController.prototype.askResponse.call(controller as never, prompt, images, files) + }, + } + const errorTask = createTaskProxy("cloud-provisioning-1", controller.askResponse, controller.cancelTask) + controller.task = errorTask + if (unconfirmedAsk) errorTask.messageStateHandler.addMessages([unconfirmedAsk]) + else SdkController.prototype["offerCloudStartRetry"].call(controller as never, errorTask, input) + return { controller, errorTask } + } + + it("keeps unconfirmed-create guidance and its anchor when a stale Retry reaches the controller", async () => { + const message = "Cloud session creation could not be confirmed. Check History or the cloud dashboard." + const ask: ClineMessage = { + ts: Date.now() + 1_000, + type: "ask", + ask: "api_req_failed", + text: new ClineError({ message, code: CLOUD_CREATION_UNCONFIRMED_ERROR_CODE }).serialize(), + } + const { controller, errorTask } = controllerShowingCloudStartError(ask) + await errorTask.handleWebviewAskResponse("yesButtonClicked") + expect(controller.initTask).not.toHaveBeenCalled() + expect(controller.followups.askResponse).not.toHaveBeenCalled() + expect(controller.messages.appendAndEmit).toHaveBeenCalledWith( + [expect.objectContaining({ say: "error", text: message })], + expect.objectContaining({ type: "status" }), + ) + expect(controller.messages.appendAndEmit.mock.calls[0]?.[0][0].ts).toBeGreaterThan(ask.ts) + expect(controller.turnStateTracker.set).toHaveBeenCalledWith("error", ask.ts) + }) + + it("starts the same cloud task again when Retry is clicked", async () => { + const { controller, errorTask } = controllerShowingCloudStartError() + await errorTask.handleWebviewAskResponse("yesButtonClicked") + expect(controller.initTask).toHaveBeenCalledWith( + "cloud prompt", + ["img"], + undefined, + undefined, + undefined, + cloudTarget, + ) + expect(controller.messages.appendAndEmit).not.toHaveBeenCalled() + }) + + it("keeps the original attachments when Retry arrives with the webview's empty image list", async () => { + const { controller, errorTask } = controllerShowingCloudStartError() + // Protobuf decodes the Retry request's omitted repeated images field as an empty array. + await errorTask.handleWebviewAskResponse("yesButtonClicked", "", []) + expect(controller.initTask).toHaveBeenCalledWith( + "cloud prompt", + ["img"], + undefined, + undefined, + undefined, + cloudTarget, + ) + }) + + it("starts a cloud task on the same target with a prompt typed into the composer", async () => { + const { controller, errorTask } = controllerShowingCloudStartError() + await errorTask.handleWebviewAskResponse("messageResponse", "revised prompt") + expect(controller.initTask).toHaveBeenCalledWith( + "revised prompt", + undefined, + undefined, + undefined, + undefined, + cloudTarget, + ) + }) + + it("advances the turn phase when it refuses a response to a session that is gone", async () => { + const { controller, errorTask } = controllerShowingCloudStartError() + // The retry is consumed by the first response; a second one finds no session to send to. + await errorTask.handleWebviewAskResponse("yesButtonClicked") + controller.initTask.mockClear() + + await errorTask.handleWebviewAskResponse("yesButtonClicked") + + expect(controller.initTask).not.toHaveBeenCalled() + expect(controller.messages.appendAndEmit).toHaveBeenCalledOnce() + expect(controller.turnStateTracker.set).toHaveBeenCalledWith("error") + expect(controller.turnStateTracker.set.mock.invocationCallOrder[0]).toBeLessThan( + controller.postStateToWebview.mock.invocationCallOrder.at(-1)!, + ) + }) + }) + it("waits for initial remote config before resuming an existing task", async () => { const events: string[] = [] const controller = { @@ -532,3 +670,156 @@ describe("resolveWorkspaceManagerPaths", () => { expect(resolveWorkspaceManagerPaths([], " ")).toEqual([]) }) }) + +describe("starting a cloud task", () => { + it("leaves the turn phase to the coordinator so a start cancelled during policy loading stays idle", async () => { + const run = vi.fn(async () => undefined) + const controller = { + cloud: { beginCloudTask: vi.fn(() => run) }, + waitForInitialRemoteConfig: vi.fn(async () => undefined), + turnStateTracker: { set: vi.fn() }, + messageTranslatorState: { clearTurnOutcome: vi.fn() }, + } + + await SdkController.prototype.initTask.call(controller as never, "prompt", undefined, undefined, undefined, undefined, { + repoUrl: "https://github.com/cline/fixture", + }) + + expect(controller.cloud.beginCloudTask.mock.invocationCallOrder[0]).toBeLessThan( + controller.waitForInitialRemoteConfig.mock.invocationCallOrder[0], + ) + expect(run).toHaveBeenCalledOnce() + expect(controller.turnStateTracker.set).not.toHaveBeenCalled() + expect(controller.messageTranslatorState.clearTurnOutcome).not.toHaveBeenCalled() + }) +}) + +describe("cancelling a provisioning cloud task", () => { + it("returns to the home view instead of offering Resume Task", async () => { + const phases: string[] = [] + const controller = { + turnStateTracker: { set: (phase: string) => phases.push(phase) }, + cloud: { cancelPendingStartFor: vi.fn(() => true) }, + clearTask: vi.fn(async () => phases.push("cleared")), + taskControl: { cancelTask: vi.fn() }, + } + + await SdkController.prototype.cancelTask.call(controller as never) + + expect(phases).toEqual(["resumable", "cleared"]) + expect(controller.taskControl.cancelTask).not.toHaveBeenCalled() + }) + + it("cancels the pending cloud start when the user starts a new task", async () => { + const cancelPendingStart = vi.fn(() => true) + const controller = { + turnStateTracker: { set: vi.fn() }, + cloud: { cancelPendingStart }, + taskControl: { clearTask: vi.fn(async () => undefined) }, + postStateToWebview: vi.fn(async () => undefined), + } + + await SdkController.prototype.clearTask.call(controller as never) + + expect(cancelPendingStart).toHaveBeenCalledOnce() + expect(controller.taskControl.clearTask).toHaveBeenCalledOnce() + }) +}) + +describe("getTaskHistory with Cloud Only", () => { + function record(sessionId: string, updatedAt: number, executionTarget?: "cloud") { + return { + sessionId, + prompt: `task ${sessionId}`, + startedAt: new Date(updatedAt).toISOString(), + updatedAt: new Date(updatedAt).toISOString(), + metadata: executionTarget ? { executionTarget } : {}, + } + } + + it("finds cloud tasks that sit behind a full page of newer local tasks", async () => { + // 60 local tasks are newer than the 2 cloud tasks, so a page of 50 merged + // records holds no cloud task at all. + const history = [ + ...Array.from({ length: 60 }, (_, i) => record(`local-${i}`, 2_000_000 - i)), + record("ses-a", 1_000_000, "cloud"), + record("ses-b", 999_999, "cloud"), + ] + const listHistory = vi.fn(async ({ limit = history.length, offset = 0 }: { limit?: number; offset?: number }) => + history.slice(offset, offset + limit), + ) + const controller = { + task: undefined, + taskHistory: { listHistory }, + cloud: { getCurrentTaskInfo: () => undefined }, + getWorkspaceRoot: async () => "/workspace", + } + + const page = await SdkController.prototype.getTaskHistory.call( + controller as never, + GetTaskHistoryRequest.create({ cloudOnly: true, limit: 50, offset: 0 }), + ) + + expect(page.tasks.map((task) => task.id)).toEqual(["ses-a", "ses-b"]) + expect(page.hasMore).toBe(false) + }) + + it("pages the filtered cloud tasks, not the merged list", async () => { + const history = [ + ...Array.from({ length: 5 }, (_, i) => record(`local-${i}`, 2_000_000 - i)), + ...Array.from({ length: 3 }, (_, i) => record(`ses-${i}`, 1_000_000 - i, "cloud")), + ] + const listHistory = vi.fn(async ({ limit = history.length, offset = 0 }: { limit?: number; offset?: number }) => + history.slice(offset, offset + limit), + ) + const controller = { + task: undefined, + taskHistory: { listHistory }, + cloud: { getCurrentTaskInfo: () => undefined }, + getWorkspaceRoot: async () => "/workspace", + } + const page = (offset: number) => + SdkController.prototype.getTaskHistory.call( + controller as never, + GetTaskHistoryRequest.create({ cloudOnly: true, limit: 2, offset }), + ) + + const first = await page(0) + const second = await page(2) + + expect(first.tasks.map((task) => task.id)).toEqual(["ses-0", "ses-1"]) + expect(first.hasMore).toBe(true) + expect(second.tasks.map((task) => task.id)).toEqual(["ses-2"]) + expect(second.hasMore).toBe(false) + }) +}) + +describe("cloud tasks stay in the cloud", () => { + const stateManager = { getGlobalSettingsKey: (key: string) => (key === "mode" ? "plan" : undefined) } + + it("refuses to rebuild a cloud task's conversation as a local session", async () => { + const startNewSession = vi.fn() + const controller = { + task: { taskId: "ses-cloud", messageStateHandler: { getClineMessages: () => [] } }, + sessions: { getActiveSession: () => undefined, startNewSession }, + stateManager, + } + + await expect( + SdkController.prototype.editMessageAndRegenerate.call(controller as never, { messageTs: 1, text: "edited" }), + ).rejects.toThrow("not available for cloud tasks") + expect(startNewSession).not.toHaveBeenCalled() + }) + + it("renders a displayed cloud task in Act while the saved local mode is Plan", () => { + const mode = (taskId: string | undefined) => + SdkController.prototype["getDisplayedTaskMode"].call({ + task: taskId ? { taskId } : undefined, + stateManager, + } as never) + + expect(mode("ses-cloud")).toBe("act") + expect(mode("local-task")).toBe("plan") + expect(mode(undefined)).toBe("plan") + }) +}) diff --git a/apps/vscode/src/sdk/SdkController.ts b/apps/vscode/src/sdk/SdkController.ts index b621b4e257..c58dafa751 100644 --- a/apps/vscode/src/sdk/SdkController.ts +++ b/apps/vscode/src/sdk/SdkController.ts @@ -26,6 +26,7 @@ import { formatDisplayUserInput, type RemoteConfig, type RemoteConfigBundle } fr import type { ApiConfiguration } from "@shared/api" import type { ChatContent } from "@shared/ChatContent" import { CLINE_ACCOUNT_AUTH_ERROR_MESSAGE } from "@shared/ClineAccount" +import { CLOUD_CREATION_UNCONFIRMED_ERROR_CODE, CLOUD_SESSION_MODE, isCloudSessionId } from "@shared/cloud/cloud-sessions" import { mentionRegexGlobal } from "@shared/context-mentions" import type { ClineApiReqInfo, ClineMessage, ExtensionState } from "@shared/ExtensionMessage" import type { HistoryItem } from "@shared/HistoryItem" @@ -44,6 +45,8 @@ import { VscodeTerminalManager } from "@/hosts/vscode/terminal/VscodeTerminalMan import { ExtensionRegistryInfo } from "@/registry" import { OcaAuthService } from "@/services/auth/oca/OcaAuthService" import { UrlContentFetcher } from "@/services/browser/UrlContentFetcher" +import { CloudSessionsService } from "@/services/cloud/CloudSessionsService" +import { isCloudSessionsFeatureAvailable, isCloudSessionsFeatureEnabled } from "@/services/cloud/cloudSessionsFeature" import { ClineError } from "@/services/error/ClineError" import { McpHub } from "@/services/mcp/McpHub" import { telemetryService } from "@/services/telemetry" @@ -51,6 +54,7 @@ import type { ClineExtensionContext } from "@/shared/cline" import { toLegacyApiProvider } from "@/shared/model-catalog/provider-helpers" import { ShowMessageRequest, ShowMessageType } from "@/shared/proto/host/window" import { Logger } from "@/shared/services/Logger" +import { resolveDataDirFromEnv } from "@/shared/storage/storage-context" import { isClineManagedProvider } from "@/shared/utils/cline" import { arePathsEqual, getDesktopDir } from "@/utils/path" import { ClineAccountService } from "./account-service" @@ -74,6 +78,8 @@ import { getCheckpointRunCountForMessage, isVisibleCheckpointUserMessage, } from "./sdk-checkpoints" +import { type CloudTaskInput, SdkCloudSessionCoordinator } from "./sdk-cloud-session-coordinator" +import { SdkCloudTaskTarget } from "./sdk-cloud-task-target" import { SdkCompactionCoordinator } from "./sdk-compaction-coordinator" import { SdkDiffEditCoordinator } from "./sdk-diff-edit-coordinator" import { SdkFollowupCoordinator } from "./sdk-followup-coordinator" @@ -164,12 +170,17 @@ function historyItemToTaskResponse(item: HistoryItem): TaskResponse { } /** - * The prompt a "Sign in to Cline" error offers to retry, and the task that - * shows the error. + * A task start that failed before it had a session, kept so the footer's + * Retry (or a prompt typed into the composer) can run it again: a local start + * refused with "Sign in to Cline", or a cloud start whose sandbox never came + * up. `task` is the task view that shows the error. */ -interface ClineAuthRetry { +interface StartRetry { task: TaskProxy prompt: string + images?: string[] + /** Set for a cloud start; the retry runs on the same repository and branch. */ + cloudTarget?: CloudTarget /** * The error was shown on a task that already had a conversation, such as * a failed edit or checkpoint restore, rather than on a new task that @@ -178,6 +189,12 @@ interface ClineAuthRetry { hasConversation: boolean } +/** Where a cloud task runs; the composer submits it and a retry reuses it. */ +interface CloudTarget { + repoUrl: string + branch?: string +} + // --------------------------------------------------------------------------- // Controller // --------------------------------------------------------------------------- @@ -202,6 +219,9 @@ export class Controller { private taskStart: SdkTaskStartCoordinator private compaction: SdkCompactionCoordinator private sessionEvents: SdkSessionEventCoordinator + private cloud!: SdkCloudSessionCoordinator + readonly cloudSessions: CloudSessionsService + readonly cloudTaskTarget: SdkCloudTaskTarget private sessionHistory: SdkSessionHistoryLoader private readonly sdkTelemetry: VscodeSdkTelemetryHandle private readonly providerFailureTelemetryTurnGate = new ProviderFailureTelemetryTurnGate() @@ -244,7 +264,7 @@ export class Controller { // Private state kept for stub compatibility private backgroundCommandRunning = false private backgroundCommandTaskId?: string - private pendingClineAuthRetry?: ClineAuthRetry + private pendingStartRetry?: StartRetry checkpointRestoreInput?: ExtensionState["checkpointRestoreInput"] // Timer for periodic remote config fetching (enterprise policy enforcement) @@ -342,7 +362,7 @@ export class Controller { // start a new session, so start metadata never goes stale the way // a mid-task model-only switch does for models below. () => this.getSessionProviderId() ?? this.getActiveProviderId(), - () => (this.stateManager.getGlobalSettingsKey("mode") === "plan" ? "plan" : "act"), + () => this.getDisplayedTaskMode(), () => this.lastKnownWorkspaceRoot, // Model backing the active turn — lets error reshaping recognize // retired cline-free/ models (the error payload itself never names one). @@ -509,6 +529,13 @@ export class Controller { // History rendering mints ids from the shared authority so regenerated history ids // never overlap live-session ids. getMinter: () => this.messageTranslatorState.getMinter(), + // Resolved lazily: the cloud coordinator is constructed after the task coordinators it depends on. + cloud: { + isCloudSessionId: (id) => this.cloud.isCloudSessionId(id), + list: () => this.cloud.listHistoryRecords(), + find: (id) => this.cloud.findHistoryRecord(id), + delete: (id) => this.cloud.deleteSession(id), + }, }) this.mode = new SdkModeCoordinator({ stateManager: this.stateManager, @@ -634,7 +661,7 @@ export class Controller { buildStartSessionInput, createHistoryItemFromSession, clearTask: async () => { - this.pendingClineAuthRetry = undefined + this.pendingStartRetry = undefined await this.taskControl.clearTask() }, setTask: (task) => { @@ -651,6 +678,62 @@ export class Controller { captureProviderApiError: (event) => this.captureProviderFailure(event), postStateToWebview: () => this.postStateToWebview(), }) + this.cloudSessions = new CloudSessionsService({ + getAuthToken: () => this.authService.getAuthToken(), + getActiveOrganizationId: () => this.authService.getActiveOrganizationId(), + }) + const getAccountScope = () => { + const userId = this.authService.getInfo().user?.uid + return userId ? `${userId}:${this.authService.getActiveOrganizationId() ?? ""}` : undefined + } + this.cloudTaskTarget = new SdkCloudTaskTarget({ + cloudSessions: this.cloudSessions, + stateManager: this.stateManager, + getAccountScope, + getWorkspaceRoot: () => this.lastKnownWorkspaceRoot, + postStateToWebview: () => this.postStateToWebview(), + }) + this.cloud = new SdkCloudSessionCoordinator({ + cloudSessions: this.cloudSessions, + stateManager: this.stateManager, + sessions: this.sessions, + sessionConfigBuilder: this.sessionConfigBuilder, + messages: this.messages, + getMinter: () => this.messageTranslatorState.getMinter(), + getTask: () => this.task, + setTask: (task) => { + this.task = task + }, + onAskResponse: (text, images, files) => this.askResponse(text, images, files), + onCancelTask: () => this.cancelTask(), + clearTask: async () => { + this.pendingStartRetry = undefined + return this.taskControl.clearTask() + }, + onStartFailed: (task, input) => this.offerCloudStartRetry(task, input), + claimTaskViewGeneration: () => this.taskControl.claimTaskViewGeneration(), + requestToolApproval: (request) => this.interactions.handleRequestToolApproval(request), + getAuthToken: () => this.authService.getAuthToken(), + isSignedIn: () => !!this.authService.getInfo().user?.uid, + isEnabled: () => isCloudSessionsFeatureEnabled(), + resetMessageTranslator: () => this.resetMessageTranslatorAndFence(), + setTurnPhase: (phase, anchorTs) => this.turnStateTracker.set(phase, anchorTs), + clearTurnOutcome: () => this.messageTranslatorState.clearTurnOutcome(), + postStateToWebview: () => this.postStateToWebview(), + showChatView: async () => { + const { sendChatButtonClickedEvent } = await import("@core/controller/ui/subscribeToChatButtonClicked") + await sendChatButtonClickedEvent() + }, + invalidateHistoryCache: () => this.taskHistory.invalidateCache(), + resolveContextMentions: (text) => this.resolveContextMentions(text), + telemetry: this.sdkTelemetry.telemetry, + pendingStartsDir: path.join(resolveDataDirFromEnv(), "cloud-pending-starts"), + getAccountScope, + }) + // Every account change, whichever path requests it, tears down the + // previous account's cloud task and connections first and holds cloud + // reads until the change has settled. + this.accountService.onAccountChange((change) => this.cloud.reset(change)) this.compaction = new SdkCompactionCoordinator({ stateManager: this.stateManager, sessions: this.sessions, @@ -958,6 +1041,7 @@ export class Controller { this.mcpHub?.clearToolListChangeCallback() await this.diffEdits.discardAllPreviews("controller dispose") await this.clearTask() + await this.cloud.dispose() await this.sessions.dispose("SdkController.dispose") await this.taskHistory.dispose() this.mcpHub?.dispose?.() @@ -1298,6 +1382,17 @@ export class Controller { }) } + /** A cloud start failed before it had a session; the footer's Retry runs the same input again. */ + private offerCloudStartRetry(task: TaskProxy, input: CloudTaskInput): void { + this.pendingStartRetry = { + task, + prompt: input.prompt, + images: input.images, + cloudTarget: { repoUrl: input.repoUrl, branch: input.branch }, + hasConversation: false, + } + } + /** * Emit a proper auth error for the 'cline' provider when the user is not * logged in. The message sequence drives ErrorRow to render the @@ -1318,7 +1413,7 @@ export class Controller { () => this.cancelTask(), ) } - this.pendingClineAuthRetry = task === undefined ? undefined : { task: this.task, prompt: task, hasConversation } + this.pendingStartRetry = task === undefined ? undefined : { task: this.task, prompt: task, hasConversation } const clineError = new ClineError( { message: CLINE_ACCOUNT_AUTH_ERROR_MESSAGE, status: 401 }, @@ -1440,7 +1535,23 @@ export class Controller { files?: string[], historyItem?: HistoryItem, taskSettings?: Partial, + cloudTarget?: CloudTarget, ): Promise { + if (cloudTarget) { + // Register the cloud start before the first await so a Cancel that + // arrives while remote config is still loading finds it and stops it. + // The coordinator moves the turn phase to streaming only once the + // start is still current and its task view is installed. + const startCloudTask = this.cloud.beginCloudTask({ + prompt: prompt ?? "", + images, + repoUrl: cloudTarget.repoUrl, + branch: cloudTarget.branch, + }) + await this.waitForInitialRemoteConfig() + this.pendingStartRetry = undefined + return startCloudTask() + } await this.waitForInitialRemoteConfig() // A new task is starting — the agent is about to stream. this.turnStateTracker.set("streaming") @@ -1449,6 +1560,18 @@ export class Controller { return this.taskStart.initTask(prompt, images, files, historyItem, taskSettings) } + /** + * Plan/act mode governing the displayed task. A cloud task always runs in + * Act, so its completion rows render as Act output even while the user's + * saved local mode is Plan. + */ + private getDisplayedTaskMode(): Mode { + if (this.task && isCloudSessionId(this.task.taskId)) { + return CLOUD_SESSION_MODE + } + return this.stateManager.getGlobalSettingsKey("mode") === "plan" ? "plan" : "act" + } + async reinitExistingTaskFromId(taskId: string): Promise { await this.waitForInitialRemoteConfig() this.turnStateTracker.set("streaming") @@ -1457,10 +1580,15 @@ export class Controller { } async cancelTask(): Promise { - // Fence first: mark resumable before aborting so any straggler events from the aborted - // turn land on the wrong side of the UI mode. (Full fence-before-abort epoch bump lands - // in S6; this sets the authoritative phase now.) + // Fence first: mark resumable and invalidate provisioning before any abort await, + // so straggler work cannot restore the cancelled turn's streaming state. this.turnStateTracker.set("resumable") + if (this.cloud.cancelPendingStartFor(this.task)) { + // The sandbox never started and its record is being deleted, so there is + // nothing to resume: return to the home view instead of offering Resume Task. + await this.clearTask() + return + } await this.taskControl.cancelTask() } @@ -1516,9 +1644,12 @@ export class Controller { } async clearTask(): Promise { - this.pendingClineAuthRetry = undefined + this.pendingStartRetry = undefined // No active task — UI returns to idle (input enabled, no buttons/thinking). this.turnStateTracker.set("idle") + // A cloud task still provisioning has no SDK session for taskControl to end; + // stop it here so it cannot re-install its view after the user left. + this.cloud.cancelPendingStart() await this.taskControl.clearTask() await this.postStateToWebview() } @@ -1537,25 +1668,76 @@ export class Controller { * return immediately so the webview stays responsive. */ async askResponse(prompt?: string, images?: string[], files?: string[]): Promise { - // The sign-in retry answers only the next response to the task that - // showed the error; a task opened since then does not inherit it. - const retry = this.pendingClineAuthRetry - this.pendingClineAuthRetry = undefined + // A start retry answers only the next response to the task that showed + // the error; a task opened since then does not inherit it. + const retry = this.pendingStartRetry + this.pendingStartRetry = undefined if (retry && retry.task === this.task) { const askResponse = this.task.taskState.askResponse if (askResponse === "yesButtonClicked") { - await this.initTask(retry.prompt, images, files) + // A Retry click carries no attachments of its own (protobuf decodes the + // omitted field as an empty list), so the failed start's attachments stand. + await this.initTask( + retry.prompt, + images?.length ? images : retry.images, + files, + undefined, + undefined, + retry.cloudTarget, + ) return } // A task that failed before it had a session has nothing to // continue, so a prompt typed in the composer starts it again. With // attachments but no text, the original prompt is kept. if (askResponse === "messageResponse" && !retry.hasConversation) { - await this.initTask(prompt?.trim() ? prompt : retry.prompt, images, files) + await this.initTask( + prompt?.trim() ? prompt : retry.prompt, + images, + files, + undefined, + undefined, + retry.cloudTarget, + ) return } } + if (this.task && this.cloud.isCloudSessionId(this.task.taskId) && !this.sessions.getActiveSession()) { + // No sandbox to send to: still provisioning (only reachable through the + // extension API, the composer is disabled), expired, or unconfirmed. Refusing the + // response is itself a turn outcome: the footer holds its Retry claim + // until the turn phase advances, so set the phase even though it does + // not change. + const provisioning = this.cloud.getCurrentTaskInfo()?.status === "provisioning" + const anchorTs = this.turnStateTracker.get().anchorTs + const anchor = this.task.messageStateHandler.getClineMessages().find((message) => message.ts === anchorTs) + const anchoredError = + anchor?.type === "ask" && anchor.ask === "api_req_failed" ? ClineError.parse(anchor.text) : undefined + const guidance = + anchoredError?._error.code === CLOUD_CREATION_UNCONFIRMED_ERROR_CODE ? anchoredError.message : undefined + this.messages.appendAndEmit( + [ + { + ts: Math.max(Date.now(), (anchorTs ?? 0) + 1), + type: "say", + say: "error", + text: + guidance ?? + (provisioning + ? "The cloud sandbox is still starting. Send your message again once it is running." + : "This cloud session is no longer running. Start a new cloud task to continue."), + partial: false, + }, + ], + { type: "status", payload: { sessionId: this.task.taskId, status: "error" } }, + ) + if (guidance) this.turnStateTracker.set("error", anchorTs) + else this.turnStateTracker.set("error") + await this.postStateToWebview() + return + } + const turnStateBefore = this.turnStateTracker.get() // Answering an ask / continuing after completion / resuming a cancelled task all kick off a @@ -1593,6 +1775,12 @@ export class Controller { if (!currentTask) { throw new Error("No active task to edit") } + // The rebuilt conversation below is a local session on this machine's + // workspace. A cloud task's transcript lives in its sandbox, so rebuilding + // it here would silently move the task out of the cloud. + if (isCloudSessionId(currentTask.taskId)) { + throw new Error("Editing an earlier message is not available for cloud tasks yet.") + } const clineMessages = currentTask.messageStateHandler.getClineMessages() const targetIndex = clineMessages.findIndex((message) => message.ts === input.messageTs) @@ -1985,7 +2173,9 @@ export class Controller { * replace it. */ async showTaskWithId(taskId: string): Promise { - const historyItem = await this.taskControl.showTaskWithId(taskId) + const historyItem = this.cloud.isCloudSessionId(taskId) + ? await this.cloud.openCloudTask(taskId) + : await this.taskControl.showTaskWithId(taskId) if (!historyItem) { throw new Error(`Task not found in history: ${taskId}`) } @@ -2015,7 +2205,10 @@ export class Controller { // (which can hold enterprise secrets) is actually deleted on sign-out. const organizationId = this.authService.getActiveOrganizationId() ?? undefined await this.taskControl.cancelClineTaskOnSignOut(isClineManagedProvider(sessionProviderId)) - await this.authService.handleDeauth(LogoutReason.USER_INITIATED) + if (this.task && this.cloud.isCloudSessionId(this.task.taskId)) { + await this.clearTask() + } + await this.cloud.reset(() => this.authService.handleDeauth(LogoutReason.USER_INITIATED)) // Invalidate BEFORE clearing: a refresh that already fetched under the // signed-in identity must not republish the policy (and re-create the // secret-bearing caches) after the clear. The clear itself runs under the @@ -2079,15 +2272,18 @@ export class Controller { } async getTaskHistory(request: GetTaskHistoryRequest): Promise { - const { favoritesOnly, currentWorkspaceOnly, searchQuery, sortBy } = request + const { favoritesOnly, currentWorkspaceOnly, searchQuery, sortBy, cloudOnly } = request const limit = request.limit > 0 ? Math.min(request.limit, 100) : 50 const offset = request.offset > 0 ? request.offset : 0 const workspacePath = currentWorkspaceOnly ? await this.getWorkspaceRoot() : undefined - const sessionHistory = await this.taskHistory.listHistory({ - hydrate: false, - limit: limit + 1, - offset, - }) + // Cloud tasks are a small minority of the merged list, so page them after + // filtering: a page cut from the merged list first can hold none of them + // while cloud tasks exist further down. Other filters keep the cheaper + // pre-filter pagination the local-only history has always used. + const paginateAfterFilter = !!cloudOnly + const sessionHistory = await this.taskHistory.listHistory( + paginateAfterFilter ? { hydrate: false } : { hydrate: false, limit: limit + 1, offset }, + ) let filteredTasks = sessionHistory.filter((item) => { const ts = dateStringToTimestamp(item.updatedAt ?? item.endedAt ?? item.startedAt) @@ -2103,6 +2299,10 @@ export class Controller { return false } + if (cloudOnly && metadataString(item.metadata, "executionTarget") !== "cloud") { + return false + } + if (currentWorkspaceOnly && workspacePath) { const sessionWorkspacePath = item.cwd ?? item.workspaceRoot if (!sessionWorkspacePath || !arePathsEqual(sessionWorkspacePath, workspacePath)) { @@ -2149,7 +2349,10 @@ export class Controller { } }) - const hasMore = sessionHistory.length > limit + if (paginateAfterFilter) { + filteredTasks = filteredTasks.slice(offset) + } + const hasMore = paginateAfterFilter ? filteredTasks.length > limit : sessionHistory.length > limit const tasks = filteredTasks.slice(0, limit).map((item) => { const metadata = item.metadata return { @@ -2168,6 +2371,11 @@ export class Controller { isLegacy: metadataBoolean(metadata, "legacyTask") === true || metadataBoolean(metadata, "migratedFromLegacyTask") === true, + executionTarget: metadataString(metadata, "executionTarget") ?? "", + cloudStatus: metadataString(metadata, "cloudStatus") ?? "", + cloudRepoUrl: metadataString(metadata, "repoUrl") ?? "", + cloudBranch: metadataString(metadata, "branch") ?? "", + cloudUsageAvailable: metadataBoolean(metadata, "usageAvailable") ?? false, } }) @@ -2176,7 +2384,8 @@ export class Controller { .getClineMessages() .find((message) => message.type === "say" && message.say === "task" && message.text) const matchesSearch = !searchQuery || taskMessage?.text?.toLowerCase().includes(searchQuery.toLowerCase()) - if (taskMessage?.text && matchesSearch) { + const currentCloudTask = this.cloud.getCurrentTaskInfo() + if (taskMessage?.text && matchesSearch && (!cloudOnly || currentCloudTask)) { tasks.unshift({ id: this.task.taskId, task: formatDisplayUserInput(taskMessage.text), @@ -2191,6 +2400,11 @@ export class Controller { modelId: this.task.api?.getModel?.().id ?? "", apiProvider: "", isLegacy: false, + executionTarget: currentCloudTask ? "cloud" : "", + cloudStatus: currentCloudTask?.status ?? "", + cloudRepoUrl: currentCloudTask?.repoUrl ?? "", + cloudBranch: currentCloudTask?.branch ?? "", + cloudUsageAvailable: false, }) } } @@ -2198,6 +2412,14 @@ export class Controller { return TaskHistoryArray.create({ tasks: tasks.slice(0, limit), hasMore }) } + resolveCloudSessionStatuses(sessionIds: Iterable) { + return this.cloud.resolveStatuses(sessionIds) + } + + invalidateTaskHistoryCache(): void { + this.taskHistory.invalidateCache() + } + async exportTaskWithId(id: string): Promise { const taskDirPath = await this.taskHistory.getTaskDirPath(id) if (!taskDirPath) { @@ -2218,7 +2440,6 @@ export class Controller { await this.clearTask() const taskHistory = await this.taskHistory.listHistory({ hydrate: false }) - const totalTasks = taskHistory.length const userChoice = ( await HostProvider.window.showMessage( @@ -2269,9 +2490,7 @@ export class Controller { const tasksDeleted = await this.taskHistory.deleteAllTaskHistory() await this.postStateToWebview() - return DeleteAllTaskHistoryCount.create({ - tasksDeleted: tasksDeleted || totalTasks, - }) + return DeleteAllTaskHistoryCount.create({ tasksDeleted }) } async updateTaskHistory(item: HistoryItem): Promise { @@ -2387,6 +2606,7 @@ export class Controller { .getClineMessages() .find((message) => message.type === "say" && message.say === "task" && message.text) if (taskMessage?.text) { + const currentCloudTask = this.cloud.getCurrentTaskInfo() mergedTaskHistoryById.set(snapshotTask.taskId, { id: snapshotTask.taskId, ts: taskMessage.ts || Date.now(), @@ -2398,6 +2618,14 @@ export class Controller { totalCost: 0, modelId: snapshotTask.api?.getModel?.().id, cwdOnTaskInitialization: await this.getWorkspaceRoot(), + ...(currentCloudTask + ? { + executionTarget: "cloud" as const, + cloudStatus: currentCloudTask.status, + cloudRepoUrl: currentCloudTask.repoUrl, + cloudBranch: currentCloudTask.branch, + } + : {}), }) } } @@ -2436,6 +2664,7 @@ export class Controller { } throw new Error("Task changed while building webview state") } + const currentCloudTask = this.cloud.getCurrentTaskInfo() return { ...state, currentTaskItem: snapshotTask?.taskId @@ -2444,6 +2673,11 @@ export class Controller { taskHistory: processedTaskHistory, turnState: this.turnStateTracker.get(), queuedPrompts, + cloudSessionsAvailable: isCloudSessionsFeatureAvailable(), + cloudSessionsEnabled: isCloudSessionsFeatureEnabled(), + cloudTaskTarget: isCloudSessionsFeatureEnabled() ? this.cloudTaskTarget.view() : undefined, + currentCloudTask, + cloudModelId: isCloudSessionsFeatureEnabled() || currentCloudTask ? this.cloud.getCloudModelId() : undefined, stateVersion: minter.nextSeq(), epoch: minter.epoch, } diff --git a/apps/vscode/src/sdk/account-service.test.ts b/apps/vscode/src/sdk/account-service.test.ts new file mode 100644 index 0000000000..4773016fae --- /dev/null +++ b/apps/vscode/src/sdk/account-service.test.ts @@ -0,0 +1,114 @@ +import { beforeEach, describe, expect, it, vi } from "vitest" +import { getUserOrganizations } from "@/core/controller/account/getUserOrganizations" +import { ClineAccountService } from "./account-service" + +const mocks = vi.hoisted(() => ({ request: vi.fn(), restore: vi.fn(), activeOrganizationId: "org-old" as string | undefined })) +vi.mock("axios", () => ({ default: { request: mocks.request } })) +vi.mock("./auth-service", () => ({ + AuthService: { + getInstance: () => ({ + getAuthToken: async () => "fixture-token", + getActiveOrganizationId: () => mocks.activeOrganizationId, + restoreRefreshTokenAndRetrieveAuthInfo: mocks.restore, + }), + }, +})) + +function deferred() { + let resolve!: (value: T) => void + let reject!: (error: unknown) => void + const promise = new Promise((done, fail) => { + resolve = done + reject = fail + }) + return { promise, resolve, reject } +} +vi.mock("@/config", () => ({ ClineEnv: { config: () => ({ apiBaseUrl: "http://127.0.0.1:1" }) } })) +vi.mock("@/services/EnvUtils", () => ({ buildBasicClineHeaders: async () => ({}) })) +vi.mock("@/shared/net", () => ({ getAxiosSettings: () => ({}) })) + +describe("switchAccount owns the account boundary", () => { + beforeEach(() => { + vi.clearAllMocks() + mocks.restore.mockResolvedValue(undefined) + mocks.activeOrganizationId = "org-old" + }) + + it("runs the account request inside the installed boundary", async () => { + const order: string[] = [] + mocks.request.mockImplementation(async () => { + order.push("request") + return { status: 200, data: { success: true, data: "" } } + }) + const service = new ClineAccountService() + service.onAccountChange(async (change) => { + order.push("teardown") + await change() + order.push("reopen") + }) + + await service.switchAccount("org-new") + + expect(order).toEqual(["teardown", "request", "reopen"]) + }) + + it("serialises overlapping switches and re-checks the target once the pending one settles", async () => { + // The first switch is in flight; the second must not observe the cached + // account and decide it has nothing to do. + const firstRequest = deferred<{ status: number; data: { success: boolean; data: string } }>() + mocks.request + .mockImplementationOnce(() => firstRequest.promise) + .mockImplementation(async () => ({ status: 200, data: { success: true, data: "" } })) + mocks.restore.mockImplementation(async () => { + mocks.activeOrganizationId = "org-new" + }) + const service = new ClineAccountService() + + const first = service.switchAccount("org-new") + const second = service.switchAccount(undefined) + await vi.waitFor(() => expect(mocks.request).toHaveBeenCalledTimes(1)) + + firstRequest.resolve({ status: 200, data: { success: true, data: "" } }) + await Promise.all([first, second]) + + expect(mocks.request).toHaveBeenCalledTimes(2) + expect(mocks.request).toHaveBeenLastCalledWith(expect.objectContaining({ data: { organizationId: null } })) + }) + + it("restores the server account without tearing down cloud state when the cached account already matches", async () => { + // Another client on the same login may have moved the server's active + // account; the request must still be sent, but the cloud state here is + // scoped by the cached account, which is not changing. + mocks.request.mockResolvedValue({ status: 200, data: { success: true, data: "" } }) + const boundary = vi.fn(async (change: () => Promise) => change()) + const service = new ClineAccountService() + service.onAccountChange(boundary) + + await service.switchAccount("org-old") + + expect(mocks.request).toHaveBeenCalledWith(expect.objectContaining({ data: { organizationId: "org-old" } })) + expect(boundary).not.toHaveBeenCalled() + }) +}) + +describe("account confirmation", () => { + beforeEach(() => { + vi.clearAllMocks() + mocks.restore.mockResolvedValue(undefined) + mocks.activeOrganizationId = "org-old" + }) + it("propagates an unavailable profile through the real service and RPC handler", async () => { + mocks.request.mockRejectedValue(new Error("offline")) + const controller = { accountService: new ClineAccountService() } + await expect(getUserOrganizations(controller as never, {})).rejects.toThrow("Could not confirm") + expect(mocks.request).toHaveBeenCalledWith(expect.objectContaining({ timeout: 10_000, method: "GET" })) + }) + it("waits for the server's answer to the mutation and reconciles auth after a transport failure", async () => { + mocks.request.mockRejectedValue(new Error("offline")) + await expect(new ClineAccountService().switchAccount("org-new")).rejects.toThrow("offline") + expect(mocks.request).toHaveBeenCalledWith( + expect.objectContaining({ timeout: 0, method: "PUT", data: { organizationId: "org-new" } }), + ) + expect(mocks.restore).toHaveBeenCalledOnce() + }) +}) diff --git a/apps/vscode/src/sdk/account-service.ts b/apps/vscode/src/sdk/account-service.ts index dc9b7d56ca..d8c74741bb 100644 --- a/apps/vscode/src/sdk/account-service.ts +++ b/apps/vscode/src/sdk/account-service.ts @@ -23,11 +23,24 @@ import { AuthService } from "./auth-service" export class ClineAccountService { private static instance: ClineAccountService private _authService: AuthService + private accountChangeBoundary: ((change: () => Promise) => Promise) | undefined + private pendingSwitch: Promise | undefined constructor() { this._authService = AuthService.getInstance() } + /** + * Installs the boundary every account change runs inside. The controller + * uses it to tear down the previous account's cloud task and connections + * before `change` sends the request, and to hold cloud reads until it has + * settled. Owned here so that every path that changes the account (the + * Account view, ClinePass selection, remote-config discovery) runs it. + */ + onAccountChange(boundary: (change: () => Promise) => Promise): void { + this.accountChangeBoundary = boundary + } + /** * Returns the singleton instance of ClineAccountService */ @@ -61,6 +74,7 @@ export class ClineAccountService { throw new Error("No Cline account auth token found") } const requestConfig: AxiosRequestConfig = { + timeout: 10_000, ...config, headers: { Authorization: `Bearer ${clineAccountAuthToken}`, @@ -256,29 +270,56 @@ export class ClineAccountService { } /** - * Switches the active account to the specified organization or personal account. + * Switches the active account to the specified organization, or to the + * personal account when none is given. This is the only path that changes + * the account. Switches are serialised: one requested while another is in + * flight waits for it, then decides against the account as it is then, so + * a caller that read a stale account cannot tear down cloud state it does + * not need to. The account changes when this resolves; when the cached + * account changes, its cloud state is gone before the request is sent. */ - async switchAccount(organizationId?: string): Promise { - try { - await this.authenticatedRequest(CLINE_API_ENDPOINT.ACTIVE_ACCOUNT, { - method: "PUT", - headers: { - "Content-Type": "application/json", - }, - data: { - organizationId: organizationId || null, - }, - }) - const activeOrgId = this._authService.getActiveOrganizationId() - if (activeOrgId !== organizationId) { + switchAccount(organizationId?: string): Promise { + const previous = this.pendingSwitch ?? Promise.resolve() + const switching = previous.then( + () => this.switchAccountNow(organizationId), + () => this.switchAccountNow(organizationId), + ) + this.pendingSwitch = switching + return switching.finally(() => { + if (this.pendingSwitch === switching) this.pendingSwitch = undefined + }) + } + + private async switchAccountNow(organizationId: string | undefined): Promise { + // The server's active account is shared by every client on this login, so + // another client may have moved it; the request is always sent to restore + // it. The cloud state held here was scoped by the cached account, so it + // is torn down only when that cached account changes. + const changesCachedAccount = (this._authService.getActiveOrganizationId() ?? undefined) !== (organizationId || undefined) + const change = async () => { + try { + await this.authenticatedRequest(CLINE_API_ENDPOINT.ACTIVE_ACCOUNT, { + method: "PUT", + // No client deadline: the server can still commit a PUT the client gave + // up on, and only its answer triggers the auth refresh below that brings + // this window onto the new account. setUserOrganization bounds the UI wait. + timeout: 0, + headers: { + "Content-Type": "application/json", + }, + data: { + organizationId: organizationId || null, + }, + }) // Force a refresh of the auth info after switching await this._authService.restoreRefreshTokenAndRetrieveAuthInfo() + } catch (error) { + Logger.error("Error switching account:", error) + await this._authService.restoreRefreshTokenAndRetrieveAuthInfo() + throw error } - } catch (error) { - Logger.error("Error switching account:", error) - await this._authService.restoreRefreshTokenAndRetrieveAuthInfo() - throw error } + await (changesCachedAccount && this.accountChangeBoundary ? this.accountChangeBoundary(change) : change()) } private getCurrentUser() { diff --git a/apps/vscode/src/sdk/cline-session-factory.test.ts b/apps/vscode/src/sdk/cline-session-factory.test.ts index 222debdbbd..163fe862ca 100644 --- a/apps/vscode/src/sdk/cline-session-factory.test.ts +++ b/apps/vscode/src/sdk/cline-session-factory.test.ts @@ -1543,6 +1543,39 @@ describe("buildSessionConfig", () => { expect(planConfig.systemPrompt).not.toContain("switch_to_act_mode") expect(planConfig.systemPrompt).toContain("Plan/Act toggle") }) + + it("builds a coherent remote runtime model and platform snapshot", async () => { + const config = await buildSessionConfig({ + cwd: "/workspace", + workspaceRoot: "/workspace", + mode: "act", + runtime: { + modelSelection: { providerId: "cline", modelId: "fixture-cloud-model" }, + platform: "linux", + }, + }) + + expect(config).toMatchObject({ + providerId: "cline", + modelId: "fixture-cloud-model", + cwd: "/workspace", + workspaceRoot: "/workspace", + mode: "act", + providerConfig: { + providerId: "cline", + modelId: "fixture-cloud-model", + }, + extensionContext: { + workspace: { + rootPath: "/workspace", + cwd: "/workspace", + platform: "linux", + mode: "act", + }, + }, + }) + expect(config.systemPrompt).toContain("# Plan / Act Modes") + }) }) // --------------------------------------------------------------------------- diff --git a/apps/vscode/src/sdk/cline-session-factory.ts b/apps/vscode/src/sdk/cline-session-factory.ts index 6ed73e5a1a..b0ab52de32 100644 --- a/apps/vscode/src/sdk/cline-session-factory.ts +++ b/apps/vscode/src/sdk/cline-session-factory.ts @@ -77,6 +77,11 @@ export interface SessionConfigInput { workspaceRoot?: string /** Current mode (act/plan) */ mode?: Mode + /** Runtime identity when execution happens outside the extension host. */ + runtime?: { + modelSelection: { providerId: string; modelId: string } + platform: string + } } /** Active session state tracked by the factory */ @@ -931,7 +936,9 @@ export async function buildSessionConfig(input: SessionConfigInput): Promise { + dir = mkdtempSync(path.join(os.tmpdir(), "cloud-pending-starts-")) +}) + +afterEach(() => { + rmSync(dir, { recursive: true, force: true }) +}) + +function writeJournal(pid: number, records: PendingStartRecord[], ageMs = 0): string { + const file = path.join(dir, `${pid}-${randomUUID()}.json`) + writeFileSync(file, JSON.stringify(records)) + const time = new Date(Date.now() - ageMs) + utimesSync(file, time, time) + return file +} + +describe("PendingStartJournal", () => { + it("keeps its records in a file of its own until they are removed", () => { + const journal = new PendingStartJournal(dir) + journal.add({ sessionId: "ses-a", account: "user:" }) + journal.add({ sessionId: "ses-b", account: "user:" }) + journal.remove("ses-a") + + const [name] = readdirSync(dir) + expect(name).toMatch(new RegExp(`^${process.pid}-`)) + expect(JSON.parse(readFileSync(path.join(dir, name), "utf8"))).toEqual([{ sessionId: "ses-b", account: "user:" }]) + journal.remove("ses-b") + expect(readdirSync(dir)).toEqual([]) + }) + + it("treats an earlier lifetime of this pid as abandoned instead of skipping or overwriting it", () => { + const earlier = writeJournal(process.pid, [{ sessionId: "ses-earlier" }]) + const journal = new PendingStartJournal(dir) + journal.add({ sessionId: "ses-current" }) + + const { journals, liveOwners } = journal.abandoned() + + expect(liveOwners).toBe(false) + expect(journals.map((abandoned) => abandoned.records)).toEqual([[{ sessionId: "ses-earlier" }]]) + expect(JSON.parse(readFileSync(earlier, "utf8"))).toEqual([{ sessionId: "ses-earlier" }]) + expect(readdirSync(dir)).toHaveLength(2) + }) + + it("never takes the journal of a running process, however long ago it was written", () => { + writeJournal(process.ppid, [{ sessionId: "ses-live" }], 24 * 60 * 60 * 1000) + + expect(new PendingStartJournal(dir).abandoned()).toMatchObject({ journals: [], liveOwners: true }) + }) + + it("rewrites an abandoned journal with only what is still unresolved", () => { + const file = writeJournal(EXITED_PID, [{ sessionId: "ses-done" }, { sessionId: "ses-retry" }]) + const [abandoned] = new PendingStartJournal(dir).abandoned().journals + + abandoned.settle([{ sessionId: "ses-retry" }]) + expect(JSON.parse(readFileSync(file, "utf8"))).toEqual([{ sessionId: "ses-retry" }]) + abandoned.settle([]) + expect(readdirSync(dir)).toEqual([]) + }) +}) diff --git a/apps/vscode/src/sdk/cloud-pending-starts.ts b/apps/vscode/src/sdk/cloud-pending-starts.ts new file mode 100644 index 0000000000..8b1e79b082 --- /dev/null +++ b/apps/vscode/src/sdk/cloud-pending-starts.ts @@ -0,0 +1,112 @@ +import { randomUUID } from "node:crypto" +import { mkdirSync, readdirSync, readFileSync, rmSync, writeFileSync } from "node:fs" +import path from "node:path" + +/** A sandbox a cloud start created and has not yet sent the first prompt to. */ +export interface PendingStartRecord { + sessionId: string + /** Account scope (user and organization) the start ran under; only that scope may settle the record. */ + account?: string + /** Control-plane endpoint the sandbox was created on. */ + endpoint?: string +} + +/** Records left by an extension host that has exited, with the means to rewrite what is still unresolved. */ +export interface AbandonedJournal { + records: PendingStartRecord[] + settle(unresolved: PendingStartRecord[]): void +} + +const JOURNAL_FILE = /^(\d+)-([0-9a-f-]+)\.json$/ + +function isProcessAlive(pid: number): boolean { + try { + process.kill(pid, 0) + return true + } catch (error) { + // EPERM means the process exists but belongs to someone else; only ESRCH proves it is gone. + return (error as NodeJS.ErrnoException).code !== "ESRCH" + } +} + +/** + * Write-ahead record of the sandboxes this extension host is starting, so a + * later host can delete them if this one exits first. A terminating extension + * host cannot reach the network, so it cannot clean them up itself. + * + * Each extension-host lifetime writes its own file, `-.json`, + * so concurrent windows never overwrite each other's records and a later host + * that is given the same pid never mistakes an old file for its own. A file + * whose pid is alive is never taken: the owner may only be paused, and a pid + * reused by another process only delays recovery until that process exits. + */ +export class PendingStartJournal { + private readonly fileName = `${process.pid}-${randomUUID()}.json` + private readonly records = new Map() + + constructor(private readonly dir: string) {} + + /** Throws when the record cannot be written, so the start fails and cleans up while it still can. */ + add(record: PendingStartRecord): void { + this.records.set(record.sessionId, record) + this.write() + } + + /** Throws when the record cannot be dropped, so a start never sends its prompt while recovery could still delete it. */ + remove(sessionId: string): void { + if (this.records.delete(sessionId)) this.write() + } + + /** + * Journals whose host lifetime has ended: its process exited, or it is an + * earlier lifetime of this process's pid. `liveOwners` is set when a journal + * was skipped because its process is still running. + */ + abandoned(): { journals: AbandonedJournal[]; liveOwners: boolean } { + let names: string[] + try { + names = readdirSync(this.dir) + } catch { + return { journals: [], liveOwners: false } + } + const journals: AbandonedJournal[] = [] + let liveOwners = false + for (const name of names) { + const match = JOURNAL_FILE.exec(name) + if (!match || name === this.fileName) continue + const file = path.join(this.dir, name) + const pid = Number(match[1]) + if (pid !== process.pid && isProcessAlive(pid)) { + liveOwners = true + continue + } + let records: PendingStartRecord[] = [] + try { + const parsed: unknown = JSON.parse(readFileSync(file, "utf8")) + if (Array.isArray(parsed)) { + records = parsed.filter((record): record is PendingStartRecord => typeof record?.sessionId === "string") + } + } catch {} + journals.push({ + records, + settle: (unresolved) => { + try { + if (unresolved.length > 0) writeFileSync(file, JSON.stringify(unresolved)) + else rmSync(file, { force: true }) + } catch {} + }, + }) + } + return { journals, liveOwners } + } + + private write(): void { + const file = path.join(this.dir, this.fileName) + if (this.records.size === 0) { + rmSync(file, { force: true }) + return + } + mkdirSync(this.dir, { recursive: true }) + writeFileSync(file, JSON.stringify([...this.records.values()])) + } +} diff --git a/apps/vscode/src/sdk/cloud-session-host.test.ts b/apps/vscode/src/sdk/cloud-session-host.test.ts new file mode 100644 index 0000000000..67e033d55b --- /dev/null +++ b/apps/vscode/src/sdk/cloud-session-host.test.ts @@ -0,0 +1,274 @@ +import { beforeEach, describe, expect, it, vi } from "vitest" +import type { SdkSessionHost } from "./session-host" + +const runtime = vi.hoisted(() => ({ + listeners: [] as Array<(event: unknown) => void>, + runTurn: vi.fn(), + pendingList: vi.fn(), + pendingUpdate: vi.fn(), + pendingDelete: vi.fn(), + listSessions: vi.fn(), + updateSessionConnection: vi.fn(), + readSessionMessages: vi.fn(), + startSession: vi.fn(async (input: { config: { sessionId: string } }) => ({ sessionId: input.config.sessionId })), +})) + +vi.mock("@cline/core", () => ({ + RemoteRuntimeHost: class { + pendingPrompts = { list: runtime.pendingList, update: runtime.pendingUpdate, delete: runtime.pendingDelete } + connect = vi.fn(async () => undefined) + listSessions = runtime.listSessions + updateSessionConnection = runtime.updateSessionConnection + readSessionMessages = runtime.readSessionMessages + startSession = runtime.startSession + subscribe(listener: (event: unknown) => void) { + runtime.listeners.push(listener) + return vi.fn() + } + runTurn = runtime.runTurn + dispose = vi.fn(async () => undefined) + }, +})) + +const { CloudSessionHost, mapAgentFinishReason } = await import("./cloud-session-host") + +describe("CloudSessionHost status", () => { + beforeEach(() => { + runtime.listeners.length = 0 + runtime.runTurn.mockReset() + runtime.pendingList.mockReset().mockResolvedValue([]) + runtime.pendingUpdate.mockReset() + runtime.pendingDelete.mockReset() + runtime.listSessions.mockReset().mockResolvedValue([{ sessionId: "inner-session", status: "idle" }]) + runtime.updateSessionConnection.mockReset().mockResolvedValue(undefined) + runtime.readSessionMessages.mockReset().mockResolvedValue([]) + runtime.startSession.mockClear() + }) + + it("rebuilds the runtime a resumed sandbox lost from its saved conversation before the first turn", async () => { + const saved = [{ role: "user", content: "first prompt" }] + runtime.updateSessionConnection.mockRejectedValueOnce( + Object.assign(new Error("session not found: inner-session"), { code: "session_not_found" }), + ) + runtime.readSessionMessages.mockResolvedValue(saved) + runtime.runTurn.mockResolvedValue({ finishReason: "completed" }) + const host = await CloudSessionHost.connect({ + outerSessionId: "ses-outer", + taskId: "inner-session", + socketUrl: "ws://127.0.0.1:1/session", + getAuthToken: async () => "token", + restoreConfig: async () => ({ providerId: "cline", modelId: "sandbox-model", systemPrompt: "guidance" }) as never, + }) + + await host.send({ sessionId: "ses-outer", prompt: "follow-up" }) + await host.send({ sessionId: "ses-outer", prompt: "another" }) + + expect(runtime.startSession).toHaveBeenCalledOnce() + expect(runtime.startSession).toHaveBeenCalledWith( + expect.objectContaining({ + initialMessages: saved, + config: expect.objectContaining({ sessionId: "inner-session", modelId: "sandbox-model" }), + toolPolicies: { "*": { enabled: true, autoApprove: true } }, + }), + ) + expect(runtime.updateSessionConnection).toHaveBeenCalledOnce() + expect(runtime.runTurn).toHaveBeenCalledTimes(2) + }) + + it("leaves a live runtime alone and surfaces other probe failures", async () => { + const host = await CloudSessionHost.connect({ + outerSessionId: "ses-outer", + taskId: "inner-session", + socketUrl: "ws://127.0.0.1:1/session", + getAuthToken: async () => "token", + restoreConfig: async () => ({ providerId: "cline", modelId: "sandbox-model" }) as never, + }) + runtime.updateSessionConnection.mockRejectedValueOnce(new Error("connection closed")) + await expect(host.send({ sessionId: "ses-outer", prompt: "follow-up" })).rejects.toThrow("connection closed") + expect(runtime.runTurn).not.toHaveBeenCalled() + + runtime.runTurn.mockResolvedValue({ finishReason: "completed" }) + await host.send({ sessionId: "ses-outer", prompt: "follow-up" }) + expect(runtime.startSession).not.toHaveBeenCalled() + expect(runtime.runTurn).toHaveBeenCalledOnce() + }) + + it("restores the runtime on the next send after a turn finds it missing", async () => { + const missing = Object.assign(new Error("session not found: inner-session"), { code: "session_not_found" }) + runtime.readSessionMessages.mockResolvedValue([{ role: "user", content: "first prompt" }]) + const host = await CloudSessionHost.connect({ + outerSessionId: "ses-outer", + taskId: "inner-session", + socketUrl: "ws://127.0.0.1:1/session", + getAuthToken: async () => "token", + restoreConfig: async () => ({ providerId: "cline", modelId: "sandbox-model" }) as never, + }) + runtime.runTurn.mockRejectedValueOnce(missing) + await expect(host.send({ sessionId: "ses-outer", prompt: "follow-up" })).rejects.toThrow(missing.message) + expect(runtime.startSession).not.toHaveBeenCalled() + + runtime.updateSessionConnection.mockRejectedValueOnce(missing) + runtime.runTurn.mockResolvedValue({ finishReason: "completed" }) + await host.send({ sessionId: "ses-outer", prompt: "retry" }) + expect(runtime.startSession).toHaveBeenCalledOnce() + expect(runtime.runTurn).toHaveBeenCalledTimes(2) + }) + + it.each([ + ["completed", "completed"], + ["aborted", "cancelled"], + ["error", "failed"], + ["max_iterations", "failed"], + ["mistake_limit", "failed"], + ] as const)("maps %s to %s", (reason, expected) => { + expect(mapAgentFinishReason(reason)).toBe(expected) + }) + + it("sends every turn in Act mode even when the caller asks for Plan", async () => { + runtime.runTurn.mockResolvedValue({ finishReason: "completed" }) + const host = await CloudSessionHost.connect({ + outerSessionId: "ses-outer", + taskId: "inner-session", + socketUrl: "ws://127.0.0.1:1/session", + getAuthToken: async () => "token", + }) + + await host.send({ sessionId: "ses-outer", prompt: "continue", mode: "plan" }) + + expect(runtime.runTurn).toHaveBeenCalledWith(expect.objectContaining({ sessionId: "inner-session", mode: "act" })) + }) + + it("subscribes to the task it discovers so a completion by another client is seen", async () => { + runtime.listSessions.mockResolvedValue([{ sessionId: "inner-session", status: "running" }]) + runtime.pendingList.mockResolvedValue([]) + const onStatusChange = vi.fn() + const host = await CloudSessionHost.connect({ + outerSessionId: "ses-outer", + taskId: "inner-session", + socketUrl: "ws://127.0.0.1:1/session", + getAuthToken: async () => "token", + onStatusChange, + }) + expect(host.status).toBe("running") + // A status-only connection has sent no command for this task; the Hub + // only streams a session's events to clients subscribed to it. + expect(runtime.pendingList).toHaveBeenCalledWith({ sessionId: "inner-session" }) + + for (const listener of runtime.listeners) { + listener({ type: "ended", payload: { sessionId: "inner-session", reason: "completed" } }) + } + + expect(host.status).toBe("completed") + expect(onStatusChange).toHaveBeenCalledWith("completed") + }) + + it("does not claim to change the model the sandbox runs on", async () => { + runtime.listSessions.mockResolvedValue([]) + const host = await CloudSessionHost.connect({ + outerSessionId: "ses-outer", + taskId: "inner-session", + socketUrl: "ws://127.0.0.1:1/session", + getAuthToken: async () => "token", + }) + await host.start({ config: { providerId: "cline", modelId: "sandbox-model" }, interactive: true } as never) + + // The Hub protocol has no command to change a running session's model, + // so the lifecycle must see no capability and the composer must keep + // naming the model the sandbox was started on. + expect((host as SdkSessionHost).updateSessionModel).toBeUndefined() + expect(host.sessionModelId).toBe("sandbox-model") + }) + + it("refreshes a retained host without letting an older snapshot overwrite a live event", async () => { + runtime.listSessions.mockResolvedValueOnce([{ sessionId: "inner-session", status: "completed" }]) + const host = await CloudSessionHost.connect({ + outerSessionId: "ses-outer", + taskId: "inner-session", + socketUrl: "ws://127.0.0.1:1", + getAuthToken: async () => "token", + }) + let resolve!: (sessions: unknown[]) => void + runtime.listSessions.mockReturnValueOnce( + new Promise((done) => { + resolve = done + }), + ) + const refreshing = host.refreshStatus() + for (const listener of runtime.listeners) + listener({ type: "status", payload: { sessionId: "inner-session", status: "running" } }) + resolve([{ sessionId: "inner-session", status: "completed" }]) + expect(await refreshing).toBe("running") + runtime.listSessions.mockResolvedValueOnce([{ sessionId: "inner-session", status: "completed" }]) + expect(await host.refreshStatus()).toBe("completed") + runtime.listSessions.mockRejectedValueOnce(new Error("offline")) + await expect(host.refreshStatus()).rejects.toThrow("offline") + expect(host.status).toBe("unknown") + await host.dispose() + }) + + it("leaves running state when runTurn rejects without a terminal event", async () => { + const error = new Error("connection closed") + runtime.runTurn.mockRejectedValue(error) + const host = await CloudSessionHost.connect({ + outerSessionId: "ses-outer", + taskId: "inner-session", + socketUrl: "ws://127.0.0.1:1/session", + getAuthToken: async () => "token", + }) + + await expect(host.send({ sessionId: "ses-outer", prompt: "continue" })).rejects.toBe(error) + expect(host.status).toBe("unknown") + }) + + it.each(["update", "delete"] as const)("maps pending-prompt %s across the cloud id boundary", async (action) => { + const mutate = action === "update" ? runtime.pendingUpdate : runtime.pendingDelete + mutate.mockResolvedValue({ sessionId: "inner-session", prompts: [] }) + const host = await CloudSessionHost.connect({ + outerSessionId: "ses-outer", + taskId: "inner-session", + socketUrl: "ws://127.0.0.1:1/session", + getAuthToken: async () => "token", + }) + const input = { sessionId: "ses-outer", promptId: "prompt-1" } + + const result = + action === "update" ? await host.pendingPrompts("update", input) : await host.pendingPrompts("delete", input) + + expect(mutate).toHaveBeenCalledWith({ ...input, sessionId: "inner-session" }) + expect(result).toEqual({ sessionId: "ses-outer", prompts: [] }) + }) + + it.each([ + "completed", + "aborted", + "error", + "max_iterations", + "mistake_limit", + ] as const)("preserves %s across both idle/done orderings and a late RPC rejection", async (reason) => { + for (const idleFirst of [false, true]) { + let reject!: (error: Error) => void + runtime.runTurn.mockImplementationOnce( + () => + new Promise((_resolve, fail) => { + reject = fail + }), + ) + const host = await CloudSessionHost.connect({ + outerSessionId: "ses-outer", + taskId: "inner-session", + socketUrl: "ws://127.0.0.1:1", + getAuthToken: async () => "token", + }) + const sending = host.send({ sessionId: "ses-outer", prompt: "continue" }) + const idle = { type: "status", payload: { sessionId: "inner-session", status: "idle" } } + const done = { type: "agent_event", payload: { sessionId: "inner-session", event: { type: "done", reason } } } + for (const event of idleFirst ? [idle, done] : [done, idle]) { + for (const listener of runtime.listeners) listener(event) + } + reject(new Error("reply lost")) + await expect(sending).rejects.toThrow("reply lost") + expect(host.status).toBe(mapAgentFinishReason(reason)) + await host.dispose() + } + }) +}) diff --git a/apps/vscode/src/sdk/cloud-session-host.ts b/apps/vscode/src/sdk/cloud-session-host.ts new file mode 100644 index 0000000000..810e6838b3 --- /dev/null +++ b/apps/vscode/src/sdk/cloud-session-host.ts @@ -0,0 +1,485 @@ +// CloudSessionHost — an SdkSessionHost backed by a Cline Cloud sandbox. +// +// A cloud session has two ids: the outer `ses-…` record owned by the Cline +// Cloud control plane (what extension history and the task view use as their +// identifier), and the canonical `tsk-…` Hub session named by the control plane. This host dials the +// pod's Hub through the api.cline.bot WebSocket proxy with the user's Cline +// account token, maps outer <-> inner ids, and re-emits Hub events under the +// outer id so the rest of the extension (session lifecycle, event coordinator, +// message translator, chat view) works exactly as it does for local sessions. +// +// No local tool executors are attached: editing, commands and file reads all +// run inside the sandbox. Tools are auto-approved there (the sandbox is +// isolated and the session must keep going after VS Code closes), so the +// approval capability only surfaces anything the pod still insists on asking. + +import { + type CompareCheckpointInput, + type CompareCheckpointResult, + type CoreSessionEvent, + type HookEventPayload, + type ITelemetryService, + type PendingPromptMutationResult, + type PendingPromptsDeleteInput, + type PendingPromptsListInput, + type PendingPromptsUpdateInput, + RemoteRuntimeHost, + type RestoreInput, + type RestoreResult, + type SendSessionInput, + type SessionAccumulatedUsage, + type SessionCompactionState, + type SessionHistoryRecord, + type SessionPendingPrompt, + type SessionRecord, + type StartSessionInput, + type StartSessionResult, +} from "@cline/core" +import { buildCloudSessionSystemPrompt } from "@cline/core/cloud" +import type { AgentResult, ToolApprovalRequest, ToolApprovalResult } from "@cline/shared" +import { CLOUD_SESSION_MODE, CLOUD_WORKSPACE_ROOT, type CloudSessionStatus } from "@shared/cloud/cloud-sessions" +import { Logger } from "@/shared/services/Logger" +import type { SdkInitialMessages, SdkSessionHost } from "./session-host" + +export interface CloudSessionHostOptions { + outerSessionId: string + /** Canonical `tsk-…` id shared by runtime requests, transcripts and history snapshots. */ + taskId: string + /** A temporary (standard) sandbox's agent is told to push its work as it goes. */ + sandboxType?: "standard" | "resumable" + socketUrl: string + getAuthToken: () => Promise + requestToolApproval?: (request: ToolApprovalRequest) => Promise + telemetry?: ITelemetryService + onStatusChange?: (status: CloudSessionStatus) => void + /** Sandbox workspace root. Hosted sandboxes use /workspace. */ + workspaceRoot?: string + /** + * The session config to rebuild the conversation's runtime with when a + * resumed sandbox has the conversation saved but no longer running. + */ + restoreConfig?: () => Promise +} + +const AUTO_APPROVE_ALL = { "*": { enabled: true, autoApprove: true } } + +function isMissingSessionError(error: unknown, sessionId: string): boolean { + const { code, message } = (error ?? {}) as { code?: unknown; message?: unknown } + return code === "session_not_found" || message === `session not found: ${sessionId}` +} + +export function mapAgentStatus(status: string): CloudSessionStatus | undefined { + switch (status) { + case "running": + case "pending": + return "running" + case "completed": + return "completed" + case "failed": + case "error": + return "failed" + case "idle": + return "idle" + case "cancelled": + case "aborted": + return "cancelled" + default: + return undefined + } +} + +export function mapAgentFinishReason(reason: AgentResult["finishReason"] | string): CloudSessionStatus { + switch (reason) { + case "completed": + return "completed" + case "aborted": + return "cancelled" + case "error": + case "max_iterations": + case "mistake_limit": + return "failed" + default: + return "unknown" + } +} + +function unsupported(operation: string): never { + throw new Error(`${operation} is not supported for cloud sessions`) +} + +export class CloudSessionHost implements SdkSessionHost { + readonly isCloud = true as const + readonly runtimeAddress: string | undefined + readonly outerSessionId: string + private readonly taskId: string + private innerSessionId: string | undefined + private agentStatus: CloudSessionStatus = "idle" + private modelId: string | undefined + private readonly host: RemoteRuntimeHost + private readonly statusUnsubscribe: () => void + private disposed = false + private statusObservation = {} + /** Whether the inner session is known to have a live runtime on this connection's Hub. */ + private innerSessionLive = false + + private constructor( + private readonly options: CloudSessionHostOptions, + host: RemoteRuntimeHost, + ) { + this.outerSessionId = options.outerSessionId + this.taskId = options.taskId + this.host = host + this.runtimeAddress = options.socketUrl + // Track the agent's activity for the whole life of the connection, not + // just while a task view is subscribed: the registry uses it to show + // running/finished in History and to notify when a background cloud + // task completes. + this.statusUnsubscribe = host.subscribe((event) => this.trackStatus(event)) + } + + static async connect(options: CloudSessionHostOptions): Promise { + const workspaceRoot = options.workspaceRoot ?? CLOUD_WORKSPACE_ROOT + const host = new RemoteRuntimeHost({ + endpoint: options.socketUrl, + clientType: "vscode-cloud-session", + displayName: "Cline for VS Code (cloud session)", + workspaceRoot, + cwd: workspaceRoot, + telemetry: options.telemetry, + capabilities: options.requestToolApproval ? { requestToolApproval: options.requestToolApproval } : undefined, + resolveConnectionHeaders: async () => { + const token = (await options.getAuthToken())?.trim() + if (!token) { + throw new Error("Sign in to Cline to connect to this cloud session.") + } + return { Authorization: `Bearer ${token}` } + }, + }) + await host.connect() + const cloudHost = new CloudSessionHost(options, host) + try { + await cloudHost.discoverInnerSession() + } catch (error) { + await cloudHost.dispose("discoveryFailed").catch(() => {}) + throw error + } + return cloudHost + } + + get status(): CloudSessionStatus { + return this.agentStatus + } + + get sessionModelId(): string | undefined { + return this.modelId + } + + /** Attach only to the canonical conversation named by the control plane. */ + private async discoverInnerSession(): Promise { + const observation = this.statusObservation + const sessions = await this.host.listSessions(100) + if (this.disposed || this.statusObservation !== observation) return + const task = sessions.find((session) => session.sessionId === this.taskId) + if (task) { + this.innerSessionId = task.sessionId + this.modelId = typeof task.model === "string" ? task.model : undefined + const mapped = mapAgentStatus(String(task.status ?? "")) + this.setStatus(mapped ?? "unknown", false) + // The Hub streams a session's events only to clients subscribed to it, + // and it subscribes a client when it first sends a command for that + // session. A connection opened only to watch status has sent none, so + // list the task's pending prompts to open the stream; otherwise a turn + // finished by another client would leave this host on "running". + await this.host.pendingPrompts.list({ sessionId: task.sessionId }).catch(() => undefined) + } else if (this.innerSessionId) { + this.setStatus("unknown", false) + } + } + + /** Re-read a retained connection after its control-plane record changed. */ + async refreshStatus(): Promise { + const observation = this.statusObservation + try { + // The coordinator applies this snapshot only while its record is still + // current; a snapshot is not a new activity/completion event. + await this.discoverInnerSession() + } catch (error) { + if (!this.disposed && this.statusObservation === observation) this.setStatus("unknown", false) + throw error + } + return this.status + } + + private trackStatus(event: CoreSessionEvent): void { + if (!this.innerSessionId || event.payload.sessionId !== this.innerSessionId) { + return + } + this.statusObservation = {} + if (event.type === "status") { + const mapped = mapAgentStatus(event.payload.status) + // "idle" is the resting state after any turn; keep the more specific + // completed/failed/cancelled outcome until the agent runs again. + if (mapped && !(mapped === "idle" && ["completed", "failed", "cancelled"].includes(this.agentStatus))) { + this.setStatus(mapped) + } + } else if (event.type === "ended") { + this.setStatus(mapAgentFinishReason(event.payload.reason)) + } else if (event.type === "agent_event") { + const agentEvent = event.payload.event + if (agentEvent.type === "done") { + this.setStatus(mapAgentFinishReason(agentEvent.reason)) + } else if (agentEvent.type === "error" && agentEvent.recoverable === false) { + this.setStatus("failed") + } else if (this.agentStatus !== "running") { + this.setStatus("running") + } + } else if (event.type === "chunk" && this.agentStatus !== "running") { + this.setStatus("running") + } + } + + private setStatus(status: CloudSessionStatus, notify = true): void { + this.statusObservation = {} + if (this.agentStatus === status) { + return + } + this.agentStatus = status + if (notify) this.options.onStatusChange?.(status) + } + + private toInner(sessionId: string): string { + if (sessionId !== this.outerSessionId && sessionId !== this.innerSessionId) { + Logger.warn(`[CloudSessionHost] Unexpected session id ${sessionId} for cloud session ${this.outerSessionId}`) + } + if (!this.innerSessionId) { + throw new Error("This cloud session has no conversation yet.") + } + return this.innerSessionId + } + + private remap(event: CoreSessionEvent): CoreSessionEvent | undefined { + if (!this.innerSessionId || event.payload.sessionId !== this.innerSessionId) { + return undefined + } + return { ...event, payload: { ...event.payload, sessionId: this.outerSessionId } } as CoreSessionEvent + } + + // ---- SdkSessionHost ---- + + async start(input: StartSessionInput): Promise { + if (this.innerSessionId) { + throw new Error("This cloud session already has a conversation.") + } + // The control plane snapshots the outer session and canonical task ids + // together. Keep that task id for every artifact this Hub session writes. + const plannedId = this.taskId + this.innerSessionId = plannedId + this.setStatus("running") + try { + const result = await this.host.startSession({ + ...input, + config: this.sandboxSessionConfig(input.config, plannedId), + toolPolicies: AUTO_APPROVE_ALL, + }) + this.innerSessionId = result.sessionId + this.innerSessionLive = true + this.modelId = input.config.modelId + return { ...result, sessionId: this.outerSessionId } + } catch (error) { + this.innerSessionId = undefined + this.setStatus("failed") + throw error + } + } + + private sandboxSessionConfig(config: StartSessionInput["config"], sessionId: string): StartSessionInput["config"] { + const workspaceRoot = this.options.workspaceRoot ?? CLOUD_WORKSPACE_ROOT + // The same sandbox guidance (GitHub access, work branch) every cloud client sends. + const guidance = buildCloudSessionSystemPrompt({ + id: this.outerSessionId, + sandboxType: this.options.sandboxType, + metadata: { taskId: this.taskId }, + }) + return { + ...config, + sessionId, + cwd: config.cwd?.trim() || workspaceRoot, + workspaceRoot, + systemPrompt: config.systemPrompt ? `${guidance}\n\n${config.systemPrompt}` : guidance, + } + } + + /** + * A resumed sandbox restarts its Hub with the conversation saved on disk but + * no runtime, so a turn would fail with "session not found". Rebuild the + * runtime from the saved messages before the first turn on this connection. + */ + private async ensureInnerSessionLive( + sessionId: string, + restoreConfig: NonNullable, + ): Promise { + try { + await this.host.updateSessionConnection(sessionId, {}) + } catch (error) { + if (!isMissingSessionError(error, sessionId)) throw error + const initialMessages = await this.host.readSessionMessages(sessionId) + if (initialMessages.length === 0) throw new Error("This cloud session's saved conversation is unavailable.") + const config = this.sandboxSessionConfig(await restoreConfig(), sessionId) + try { + await this.host.startSession({ config, initialMessages, interactive: true, toolPolicies: AUTO_APPROVE_ALL }) + } catch (restoreError) { + // Another client restored it first. + if ((restoreError as { code?: unknown })?.code !== "session_already_exists") throw restoreError + } + } + this.innerSessionLive = true + } + + async send(input: SendSessionInput): Promise { + const sessionId = this.toInner(input.sessionId) + const { restoreConfig } = this.options + if (restoreConfig && !this.innerSessionLive) await this.ensureInnerSessionLive(sessionId, restoreConfig) + this.setStatus("running") + try { + return await this.host.runTurn({ + ...input, + sessionId, + // The sandbox runtime was built for Act; every turn must say the same. + mode: CLOUD_SESSION_MODE, + // Local file paths mean nothing inside the sandbox; images travel as data URLs. + userFiles: undefined, + }) + } catch (error) { + // The Hub can restart under this connection when another client resumes + // the sandbox; let the next send restore the runtime. + if (isMissingSessionError(error, sessionId)) this.innerSessionLive = false + // A rejected RPC proves only that this client stopped observing the turn; + // the sandbox may still be running after a transport loss. + if (!this.disposed && (this.agentStatus === "running" || this.agentStatus === "idle")) { + this.setStatus("unknown") + } + throw error + } + } + + async getAccumulatedUsage(sessionId: string): Promise { + const summary = await this.host.getAccumulatedUsage?.(this.toInner(sessionId)) + return summary?.usage + } + + async abort(sessionId: string, reason?: unknown): Promise { + try { + await this.host.abort(this.toInner(sessionId), reason) + } catch (error) { + if (error instanceof Error && (error.name === "AbortError" || error.message.toLowerCase().includes("aborted"))) { + return + } + throw error + } + } + + /** + * "Stopping" a cloud session from the task view must not stop the agent: + * the whole point is that it keeps running after the user moves on. The + * connection is kept by the registry; the caller only unsubscribes. + */ + async stop(_sessionId: string): Promise {} + + async dispose(_reason?: string): Promise { + if (this.disposed) { + return + } + this.disposed = true + this.statusUnsubscribe() + await this.host.dispose() + } + + async get(sessionId: string): Promise { + if (!this.innerSessionId) { + return undefined + } + const record = await this.host.getSession(this.toInner(sessionId)) + return record ? { ...record, sessionId: this.outerSessionId } : undefined + } + + async list(): Promise { + return [] + } + + async listHistory(): Promise { + return [] + } + + async delete(_sessionId: string): Promise { + return unsupported("Deleting the sandbox conversation") + } + + async readMessages(sessionId: string): Promise { + if (!this.innerSessionId) { + return [] + } + return (await this.host.readSessionMessages(this.toInner(sessionId))) as SdkInitialMessages + } + + async readLiveMessages(sessionId: string): Promise { + return this.readMessages(sessionId) + } + + async updateSessionCompactionState(sessionId: string, state: SessionCompactionState): Promise<{ updated: boolean }> { + return this.host.updateSessionCompactionState(this.toInner(sessionId), state) + } + + async restore(_input: RestoreInput): Promise { + return unsupported("Checkpoint restore") + } + + async compareCheckpoint(_input: CompareCheckpointInput): Promise { + return unsupported("Checkpoint comparison") + } + + async update( + sessionId: string, + updates: { prompt?: string | null; metadata?: Record | null; title?: string | null }, + ): Promise<{ updated: boolean }> { + if (!this.innerSessionId) { + return { updated: false } + } + return this.host.updateSession(this.toInner(sessionId), updates) + } + + async handleHookEvent(payload: HookEventPayload): Promise { + return this.host.dispatchHookEvent(payload) + } + + pendingPrompts(action: "list", input: PendingPromptsListInput): Promise + pendingPrompts(action: "update", input: PendingPromptsUpdateInput): Promise + pendingPrompts(action: "delete", input: PendingPromptsDeleteInput): Promise + // eslint-disable-next-line @typescript-eslint/no-explicit-any + async pendingPrompts(action: any, input: any): Promise { + const mapped = { ...input, sessionId: this.toInner(input.sessionId) } + switch (action) { + case "list": + return await this.host.pendingPrompts.list(mapped) + case "update": + return { ...(await this.host.pendingPrompts.update(mapped)), sessionId: this.outerSessionId } + case "delete": + return { ...(await this.host.pendingPrompts.delete(mapped)), sessionId: this.outerSessionId } + default: + throw new Error(`Unsupported pending prompt action: ${String(action)}`) + } + } + + subscribe(listener: (event: CoreSessionEvent) => void): () => void { + return this.host.subscribe((event) => { + const remapped = this.remap(event) + if (remapped) { + listener(remapped) + } + }) + } + + // updateSessionModel is deliberately absent: the Hub protocol has no command + // to change a running session's model, so the lifecycle reports the + // capability as unavailable and sessionModelId keeps naming the model the + // sandbox was started on. +} diff --git a/apps/vscode/src/sdk/message-translator.test.ts b/apps/vscode/src/sdk/message-translator.test.ts index 80b375cf74..f9ce7dad0d 100644 --- a/apps/vscode/src/sdk/message-translator.test.ts +++ b/apps/vscode/src/sdk/message-translator.test.ts @@ -1482,6 +1482,27 @@ describe("translateSessionEvent — agent_event error", () => { const result = translateSessionEvent(event, state) expect(result.turnComplete).toBe(true) expect(state.wasErrorSeen()).toBe(true) + expect(result.messages.map((message) => message.ask ?? message.say)).toEqual(["api_req_started", "api_req_failed"]) + expect(result.messages[1].text).toBe("stream failed before assistant output") + }) + + it("renders a failure carried only by done(reason:'error') once, as a credit error when it is one", () => { + const state = new MessageTranslatorState() + const done = (text: string): CoreSessionEvent => ({ + type: "agent_event", + payload: { + sessionId: "session-1", + event: { type: "done", reason: "error", text, iterations: 1 } as AgentEvent, + }, + }) + + const first = translateSessionEvent(done("Not enough credits available"), state) + const failed = first.messages.filter((message) => message.ask === "api_req_failed") + expect(failed).toHaveLength(1) + expect(JSON.parse(failed[0].text ?? "{}")).toMatchObject({ code: "insufficient_credits" }) + + // An "error" event already rendered this turn's failure. + expect(translateSessionEvent(done("Not enough credits available"), state).messages).toEqual([]) }) it("does not record an error outcome for a successful done event", () => { @@ -2532,6 +2553,24 @@ describe("translateSessionEvent — accumulated text streaming (S6-21 fix)", () expect(end.messages[0].ts).toBe(streamingTs) }) + it("accumulates text chunks that carry no running total, as a cloud session sends them", () => { + const state = new MessageTranslatorState() + const chunk = (text: string): CoreSessionEvent => ({ + type: "agent_event", + payload: { sessionId: "s1", event: { type: "content_start", contentType: "text", text } as AgentEvent }, + }) + const end = (text: string): CoreSessionEvent => ({ + type: "agent_event", + payload: { sessionId: "s1", event: { type: "content_end", contentType: "text", text } as AgentEvent }, + }) + + expect(translateSessionEvent(chunk("Hello "), state).messages[0].text).toBe("Hello ") + expect(translateSessionEvent(chunk("world"), state).messages[0].text).toBe("Hello world") + translateSessionEvent(end("Hello world"), state) + // The next text block starts from nothing. + expect(translateSessionEvent(chunk("Next"), state).messages[0].text).toBe("Next") + }) + it("accumulates reasoning deltas into text for webview rendering", () => { const state = new MessageTranslatorState() diff --git a/apps/vscode/src/sdk/message-translator.ts b/apps/vscode/src/sdk/message-translator.ts index 5fd65a9bcd..b974aa3202 100644 --- a/apps/vscode/src/sdk/message-translator.ts +++ b/apps/vscode/src/sdk/message-translator.ts @@ -125,6 +125,8 @@ function normalizeUsageEvent(usageEvent: { export class MessageTranslatorState { /** Current streaming text message timestamp (used for dedup) */ private streamingTextTs: number | undefined + /** Streaming text so far, for sources whose deltas carry no `accumulated` */ + private streamingText = "" /** Current streaming reasoning message timestamp */ private streamingReasoningTs: number | undefined /** Accumulated streaming reasoning text (SDK reasoning events are deltas) */ @@ -224,10 +226,21 @@ export class MessageTranslatorState { return this.streamingTextTs } + /** + * The text streamed so far after this chunk. A local session reports the + * running total as `accumulated`; a Hub-hosted (cloud) session forwards + * only the chunk, so the total is built here. + */ + appendStreamingText(chunk: string, accumulated: string | undefined): string { + this.streamingText = accumulated ?? this.streamingText + chunk + return this.streamingText + } + /** Clear streaming text (content ended) */ clearStreamingText(): number { const ts = this.streamingTextTs ?? this.nextTs() this.streamingTextTs = undefined + this.streamingText = "" return ts } @@ -531,6 +544,7 @@ export class MessageTranslatorState { */ reset(): void { this.streamingTextTs = undefined + this.streamingText = "" this.streamingReasoningTs = undefined this.streamingToolTs = undefined this.streamingToolInput = undefined @@ -1305,6 +1319,33 @@ function finalizeDanglingCompaction( messages.push(buildCompactionMessage({ status, mode: "auto" }, ts)) } +/** + * Renders a turn failure: an api_req_started carrying the error, so the + * request row shows it via ErrorRow instead of a spinner, then + * ask:"api_req_failed" as the last message, so the webview offers recovery + * (Retry, Buy Credits, Sign In). The error text is reshaped into the + * ClineError JSON that ErrorRow parses to pick a special card, e.g. the + * Cline provider's 402 `insufficient_credits` body. + */ +function pushTurnErrorRows( + messages: ClineMessage[], + state: MessageTranslatorState, + error: { message?: string; status?: number; code?: string }, + errorClass?: ProviderErrorClass, +): void { + const errorPayload = reshapeErrorForWebview(error, state.activeProviderId(), state.activeModelId(), errorClass) + messages.push( + { + ts: state.nextTs(), + type: "say", + say: "api_req_started", + text: JSON.stringify({ streamingFailedMessage: errorPayload } satisfies ClineApiReqInfo), + partial: false, + }, + { ts: state.nextTs(), type: "ask", ask: "api_req_failed", text: errorPayload, partial: false }, + ) +} + function translateAgentEvent(event: AgentEvent, state: MessageTranslatorState): ClineMessage[] { const messages: ClineMessage[] = [] @@ -1312,18 +1353,16 @@ function translateAgentEvent(event: AgentEvent, state: MessageTranslatorState): case "content_start": { switch (event.contentType) { case "text": { - // The SDK emits MULTIPLE content_start events for streaming text. - // Each has `text` (the delta) and `accumulated` (full text so far). - // We use `accumulated` so the webview can update the message in-place - // with the growing text, giving smooth streaming. Using `text` (delta) - // would cause a "flip book" effect where each update replaces the - // previous content with just the new chunk. + // The SDK emits MULTIPLE content_start events for streaming text, + // each carrying a chunk. The row must show the full text so far so + // the webview updates it in place; showing only the chunk gives a + // "flip book" effect where each update replaces the previous one. const ts = state.getStreamingTextTs() messages.push({ ts, type: "say", say: "text", - text: event.accumulated ?? event.text ?? "", + text: state.appendStreamingText(event.text ?? "", event.accumulated), partial: true, }) break @@ -1933,9 +1972,16 @@ function translateAgentEvent(event: AgentEvent, state: MessageTranslatorState): finalizeDanglingCompaction(state, messages, "cancelled") // A turn can terminate with done(reason:"error") without a separate - // "error" event — record the error outcome here too so turn end still - // resolves to the "error" phase (Retry / Start New Task). - if (event.reason === "error") { + // "error" event: a Hub-hosted (cloud) session forwards only the run's + // terminal result, whose text is the failure message. Record the + // error outcome so turn end resolves to the "error" phase (Retry / + // Start New Task), and render the failure unless an "error" event + // already did. + if (event.reason === "error" && !state.wasErrorSeen() && !state.isSuppressedToolApprovalDenial(event.text)) { + state.clearTurnFinalText() + state.setErrorSeen() + pushTurnErrorRows(messages, state, { message: event.text || undefined }) + } else if (event.reason === "error") { state.setErrorSeen() } @@ -1992,46 +2038,7 @@ function translateAgentEvent(event: AgentEvent, state: MessageTranslatorState): // (footer shows Retry / Start New Task) instead of awaiting_followup. state.setErrorSeen() - // Serialize the error message for the webview's ErrorRow to parse. - // The webview uses ClineError.parse() on the `api_req_failed` text to - // detect special error types (insufficient credits, spend limit, auth, - // quota exceeded) and render appropriate UI (e.g. "Add Credits" button). - // - // The error object from the SDK is a standard JS Error. Its `message` - // may contain JSON from the API (e.g. Cline provider's 402 response with - // `code: "insufficient_credits"`). We try to reshape it into the - // ClineError-serialized format the webview expects so that ErrorRow - // can render the correct UI (Buy Credits button, etc.). - const errorPayload = reshapeErrorForWebview( - event.error, - state.activeProviderId(), - state.activeModelId(), - event.errorClass, - ) - - // Emit an api_req_started with streamingFailedMessage so the - // RequestStartRow renders the error via ErrorRow. This replaces - // the spinner on the last API request row. - messages.push({ - ts: state.nextTs(), - type: "say", - say: "api_req_started", - text: JSON.stringify({ - streamingFailedMessage: errorPayload, - } satisfies ClineApiReqInfo), - partial: false, - }) - - // Emit ask:"api_req_failed" as the LAST message so the webview - // shows error recovery UI (Retry button, Add Credits button, - // Sign In button, etc.) instead of a stuck "Thinking..." spinner. - messages.push({ - ts: state.nextTs(), - type: "ask", - ask: "api_req_failed", - text: errorPayload, - partial: false, - }) + pushTurnErrorRows(messages, state, event.error, event.errorClass) break } diff --git a/apps/vscode/src/sdk/sdk-cloud-session-coordinator.test.ts b/apps/vscode/src/sdk/sdk-cloud-session-coordinator.test.ts new file mode 100644 index 0000000000..95f46f850a --- /dev/null +++ b/apps/vscode/src/sdk/sdk-cloud-session-coordinator.test.ts @@ -0,0 +1,1935 @@ +import { randomUUID } from "node:crypto" +import { mkdirSync, mkdtempSync, readdirSync, readFileSync, rmSync, writeFileSync } from "node:fs" +import * as os from "node:os" +import path from "node:path" +import * as sdkCore from "@cline/core" +import { CloudCreationUnconfirmedError } from "@cline/core/cloud" +import { CLOUD_CREATION_UNCONFIRMED_ERROR_CODE, type CloudSessionStatus } from "@shared/cloud/cloud-sessions" +import { afterEach, describe, expect, it, vi } from "vitest" +import { ClineEnv } from "@/config" +import { resetClineRecommendedModelsCacheForTests } from "@/core/controller/models/refreshClineRecommendedModels" +import { HostProvider } from "@/hosts/host-provider" +import { + type CloudProvisioningPhase, + CloudSessionError, + type CloudSessionRecord, + type CreateCloudSessionInput, +} from "@/services/cloud/CloudSessionsService" +import { ClineError } from "@/services/error/ClineError" +import type { McpHub } from "@/services/mcp/McpHub" +import { CLINE_RECOMMENDED_MODELS_FALLBACK } from "@/shared/cline/recommended-models" +import { getButtonConfigFromState } from "../../webview-ui/src/components/chat/chat-view/shared/buttonConfig" +import { CloudSessionHost } from "./cloud-session-host" +import { MessageIdMinter } from "./message-id-minter" +import { SdkCloudSessionCoordinator, type SdkCloudSessionCoordinatorOptions } from "./sdk-cloud-session-coordinator" +import { SdkSessionLifecycle } from "./sdk-session-lifecycle" +import { SdkTaskControlCoordinator, type SdkTaskControlCoordinatorOptions } from "./sdk-task-control-coordinator" +import { SdkTaskHistory } from "./sdk-task-history" +import type { SdkSessionHost } from "./session-host" +import { createTaskProxy, type TaskProxy } from "./task-proxy" + +vi.mock("@/hosts/host-provider", () => ({ HostProvider: { window: { showMessage: vi.fn(async () => ({})) } } })) + +afterEach(() => { + vi.useRealTimers() + vi.restoreAllMocks() +}) + +function deferred() { + let resolve!: (value: T) => void + let reject!: (error: unknown) => void + const promise = new Promise((done, fail) => { + resolve = done + reject = fail + }) + return { promise, resolve, reject } +} + +const EXITED_PID = 2_147_483_646 + +const record: CloudSessionRecord = { + id: "ses-stale", + status: "active", + sandboxUrl: "", + repoContext: { repoUrl: "https://github.com/cline/fixture", branch: "main" }, + metadata: { modelId: "fixture-model", taskId: "tsk-stale" }, + createdAt: new Date(0).toISOString(), + updatedAt: new Date(0).toISOString(), +} + +/** In-memory global state shared by coordinators that stand in for one extension install. */ +function makeStateManager(globalState: Record = {}) { + return { + getApiConfiguration: () => ({ actModeApiProvider: "cline", actModeClineModelId: "fixture-model" }), + getGlobalSettingsKey: () => "act", + getGlobalStateKey: (key: string) => globalState[key], + setGlobalState: (key: string, value: unknown) => { + globalState[key] = value + }, + } +} + +function makeCoordinator(overrides: Partial = {}) { + let task: { taskId: string } | undefined + const cloudSessions = { + appBaseUrl: "https://example.test", + listSessions: vi.fn<() => Promise>(async () => []), + createSession: vi.fn( + async ( + _input: CreateCloudSessionInput, + _onProvisioning?: (sessionId: string) => void, + _signal?: AbortSignal, + _onPhase?: (phase: CloudProvisioningPhase) => void, + ) => record, + ), + deleteSession: vi.fn(async (_sessionId: string) => undefined), + renameSession: vi.fn(async () => undefined), + getStatus: vi.fn(async (): Promise<{ status?: string }> => ({ status: "ready" })), + resumeSession: vi.fn(async (_sessionId: string) => undefined), + getHistory: vi.fn(async (): Promise => []), + dashboardUrl: vi.fn((id: string) => `https://example.test/${id}`), + sessionSocketUrl: vi.fn((id: string) => `ws://127.0.0.1/${id}`), + } + const options = { + cloudSessions, + stateManager: makeStateManager(), + sessionConfigBuilder: { + build: vi.fn(async () => ({ + providerId: "cline", + modelId: "fixture-model", + apiKey: "fixture-key", + cwd: "/workspace", + workspaceRoot: "/workspace", + systemPrompt: "normal Cline guidance", + enableTools: true, + enableSpawnAgent: false, + enableAgentTeams: false, + })), + }, + sessions: {}, + messages: { appendAndEmit: vi.fn(), finalizeMessagesForSave: vi.fn((messages) => messages) }, + getMinter: () => new MessageIdMinter(), + getTask: () => task, + setTask: (value: { taskId: string } | undefined) => { + task = value + }, + onAskResponse: vi.fn(async () => undefined), + onCancelTask: vi.fn(async () => undefined), + clearTask: vi.fn(async () => () => false), + onStartFailed: vi.fn(), + claimTaskViewGeneration: () => () => false, + requestToolApproval: vi.fn(), + getAuthToken: vi.fn(async () => "token"), + isSignedIn: () => true, + isEnabled: () => true, + resetMessageTranslator: vi.fn(), + setTurnPhase: vi.fn(), + clearTurnOutcome: vi.fn(), + postStateToWebview: vi.fn(async () => undefined), + showChatView: vi.fn(async () => undefined), + invalidateHistoryCache: vi.fn(), + resolveContextMentions: vi.fn(async (text: string) => text), + ...overrides, + } as unknown as SdkCloudSessionCoordinatorOptions + return { coordinator: new SdkCloudSessionCoordinator(options), cloudSessions, options } +} + +describe("SdkCloudSessionCoordinator ownership", () => { + it("starts the sandbox in Act with the Act-mode Cline model while the local UI is in Plan mode", async () => { + const host = { status: "idle", readMessages: async () => [], dispose: async () => {} } as unknown as CloudSessionHost + vi.spyOn(CloudSessionHost, "connect").mockResolvedValue(host) + const startNewSession = vi.fn(async () => ({ sdkHost: host, startResult: { sessionId: record.id } })) + const fireAndForgetSend = vi.fn() + const { coordinator, cloudSessions, options } = makeCoordinator({ + stateManager: { + ...makeStateManager(), + getApiConfiguration: () => ({ + actModeApiProvider: "cline", + actModeClineModelId: "act-cloud-model", + planModeApiProvider: "cline", + planModeClineModelId: "plan-local-model", + }), + getGlobalSettingsKey: () => "plan", + } as never, + sessions: { startNewSession, fireAndForgetSend } as never, + }) + + expect(await coordinator.beginCloudTask({ prompt: "test", repoUrl: record.repoContext.repoUrl! })()).toBe(record.id) + + expect(cloudSessions.createSession).toHaveBeenCalledWith( + expect.objectContaining({ modelId: "act-cloud-model" }), + expect.any(Function), + expect.any(AbortSignal), + expect.any(Function), + ) + expect(options.sessionConfigBuilder.build).toHaveBeenCalledWith(expect.objectContaining({ mode: "act" })) + expect(startNewSession).toHaveBeenCalledWith( + expect.objectContaining({ config: expect.objectContaining({ mode: "act" }) }), + host, + expect.any(Function), + ) + await coordinator.dispose() + }) + + it("updates the provisioning row in place as the sandbox reports its phase", async () => { + const host = { status: "idle", readMessages: async () => [], dispose: async () => {} } as unknown as CloudSessionHost + vi.spyOn(CloudSessionHost, "connect").mockResolvedValue(host) + const { coordinator, cloudSessions, options } = makeCoordinator({ + sessions: { + startNewSession: vi.fn(async () => ({ sdkHost: host, startResult: { sessionId: record.id } })), + fireAndForgetSend: vi.fn(), + } as never, + }) + cloudSessions.createSession.mockImplementation(async (_input, onProvisioning, _signal, onPhase) => { + onProvisioning?.(record.id) + for (const phase of ["cloning_repo", "cloning_repo", "agent_starting", "ready"] as const) onPhase?.(phase) + return record + }) + + await coordinator.beginCloudTask({ prompt: "test", repoUrl: record.repoContext.repoUrl!, branch: "main" })() + + const rows = vi + .mocked(options.messages.appendAndEmit) + .mock.calls.flatMap(([messages]) => messages.filter((message) => message.say === "text")) + expect(rows.map((row) => row.text)).toEqual([ + "Starting a cloud sandbox for cline/fixture (main)…", + "Cloning cline/fixture (main) into the cloud sandbox…", + "Starting the agent in the cloud sandbox…", + ]) + expect(new Set(rows.map((row) => row.ts)).size).toBe(1) + await coordinator.dispose() + }) + + it("titles History rows from the control plane so dashboard renames of tasks started here show up", async () => { + const host = { status: "idle", readMessages: async () => [], dispose: async () => {} } as unknown as CloudSessionHost + vi.spyOn(CloudSessionHost, "connect").mockResolvedValue(host) + const { coordinator, cloudSessions } = makeCoordinator({ + sessions: { + startNewSession: vi.fn(async () => ({ sdkHost: host, startResult: { sessionId: record.id } })), + fireAndForgetSend: vi.fn(), + } as never, + }) + await coordinator.beginCloudTask({ prompt: "Original prompt", repoUrl: record.repoContext.repoUrl! })() + cloudSessions.listSessions.mockResolvedValue([{ ...record, title: "Renamed on the web" }]) + // Past the list cache, as on the next poll. + vi.spyOn(Date, "now").mockReturnValue(Date.now() + 60_000) + expect((await coordinator.listHistoryRecords())[0].metadata?.title).toBe("Renamed on the web") + await coordinator.dispose() + }) + + it("keeps a provisioning start running when its own sandbox is opened from History", async () => { + const pendingStartsDir = mkdtempSync(path.join(os.tmpdir(), "cloud-pending-")) + const recorded = () => + readdirSync(pendingStartsDir).flatMap((name) => JSON.parse(readFileSync(path.join(pendingStartsDir, name), "utf8"))) + const host = { status: "idle", readMessages: async () => [], dispose: async () => {} } as unknown as CloudSessionHost + vi.spyOn(CloudSessionHost, "connect").mockResolvedValue(host) + let viewGeneration = 0 + const { coordinator, cloudSessions } = makeCoordinator({ + pendingStartsDir, + getAccountScope: () => "user:", + claimTaskViewGeneration: () => { + const claimed = ++viewGeneration + return () => claimed !== viewGeneration + }, + clearTask: async () => { + const claimed = ++viewGeneration + return () => claimed !== viewGeneration + }, + sessions: { + startNewSession: vi.fn(async () => ({ sdkHost: host, startResult: { sessionId: record.id } })), + fireAndForgetSend: vi.fn(() => expect(recorded()).toEqual([])), + } as never, + }) + const provisioned = deferred() + const named = deferred() + cloudSessions.createSession.mockImplementation(async (_input, onProvisioning) => { + onProvisioning?.(record.id) + named.resolve() + await provisioned.promise + return record + }) + cloudSessions.listSessions.mockResolvedValue([{ ...record, status: "provisioning" }]) + + const start = coordinator.beginCloudTask({ prompt: "test", repoUrl: record.repoContext.repoUrl! })() + await named.promise + expect(await coordinator.listHistoryRecords()).toEqual([]) + const provisionalId = coordinator.getCurrentTaskInfo()!.sessionId + expect(await coordinator.openCloudTask(provisionalId)).toMatchObject({ id: provisionalId }) + expect(await coordinator.openCloudTask(record.id)).toMatchObject({ id: record.id }) + expect(recorded()).toEqual([{ sessionId: record.id, account: "user:" }]) + provisioned.resolve() + + expect(await start).toBe(record.id) + expect(cloudSessions.deleteSession).not.toHaveBeenCalled() + await coordinator.dispose() + rmSync(pendingStartsDir, { recursive: true, force: true }) + }) + + it("returns to a provisioning start over a History selection that is still loading", async () => { + const host = { status: "idle", readMessages: async () => [], dispose: async () => {} } as unknown as CloudSessionHost + vi.spyOn(CloudSessionHost, "connect").mockResolvedValue(host) + let viewGeneration = 0 + const { coordinator, cloudSessions, options } = makeCoordinator({ + claimTaskViewGeneration: () => { + const claimed = ++viewGeneration + return () => claimed !== viewGeneration + }, + clearTask: async () => { + const claimed = ++viewGeneration + return () => claimed !== viewGeneration + }, + sessions: { + startNewSession: vi.fn(async () => ({ sdkHost: host, startResult: { sessionId: record.id } })), + fireAndForgetSend: vi.fn(), + } as never, + }) + const provisioned = deferred() + const named = deferred() + cloudSessions.createSession.mockImplementation(async (_input, onProvisioning) => { + onProvisioning?.(record.id) + named.resolve() + await provisioned.promise + return record + }) + const other = { ...record, id: "ses-other", metadata: { ...record.metadata, taskId: "tsk-other" } } + const otherListed = deferred() + cloudSessions.listSessions.mockReturnValueOnce(otherListed.promise) + + const start = coordinator.beginCloudTask({ prompt: "test", repoUrl: record.repoContext.repoUrl! })() + await named.promise + const startView = options.getTask() + const openOther = coordinator.openCloudTask(other.id) + expect(await coordinator.openCloudTask(startView!.taskId)).toMatchObject({ id: startView!.taskId }) + provisioned.resolve() + otherListed.resolve([other, record]) + + expect(await openOther).toBeUndefined() + expect(await start).toBe(record.id) + expect(options.getTask()).toBe(startView) + expect(cloudSessions.deleteSession).not.toHaveBeenCalled() + await coordinator.dispose() + }) + + it("keeps a provisioning start selected again while another open waits on the task transition", async () => { + const startHost = { + status: "idle", + readMessages: async () => [], + dispose: async () => {}, + } as unknown as CloudSessionHost + const connect = vi.spyOn(CloudSessionHost, "connect").mockResolvedValue(startHost) + let task: TaskProxy | undefined + let transitionGate: Promise | undefined + const runTaskTransition = vi.fn(async (operation: () => Promise) => { + await transitionGate + return operation() + }) + const taskControl = new SdkTaskControlCoordinator({ + sessions: { endActiveSession: vi.fn(async () => undefined) }, + interactions: { clearPending: vi.fn() }, + messages: { cancelPendingSave: vi.fn() }, + getTask: () => task, + setTask: (next: TaskProxy | undefined) => { + task = next + }, + resetMessageTranslator: vi.fn(), + clearTaskSettings: vi.fn(async () => undefined), + rebuilds: { runTaskTransition }, + } as unknown as SdkTaskControlCoordinatorOptions) + const { coordinator, cloudSessions } = makeCoordinator({ + getTask: () => task, + setTask: (next: TaskProxy | undefined) => { + task = next + }, + clearTask: () => taskControl.clearTask(), + claimTaskViewGeneration: () => taskControl.claimTaskViewGeneration(), + sessions: { + startNewSession: vi.fn(async () => ({ sdkHost: startHost, startResult: { sessionId: record.id } })), + fireAndForgetSend: vi.fn(), + } as never, + }) + const provisioned = deferred() + const named = deferred() + cloudSessions.createSession.mockImplementation(async (_input, onProvisioning) => { + onProvisioning?.(record.id) + named.resolve() + await provisioned.promise + return record + }) + const other = { ...record, id: "ses-other", metadata: { ...record.metadata, taskId: "tsk-other" } } + cloudSessions.listSessions.mockResolvedValue([other, record]) + + const start = coordinator.beginCloudTask({ prompt: "test", repoUrl: record.repoContext.repoUrl! })() + await named.promise + const startView = task! + const transition = deferred() + transitionGate = transition.promise + runTaskTransition.mockClear() + const openOther = coordinator.openCloudTask(other.id) + await vi.waitFor(() => expect(runTaskTransition).toHaveBeenCalled()) + expect(await coordinator.openCloudTask(startView.taskId)).toMatchObject({ id: startView.taskId }) + transitionGate = undefined + transition.resolve() + await openOther + provisioned.resolve() + + expect(await start).toBe(record.id) + expect(task).toBe(startView) + expect(connect).not.toHaveBeenCalledWith(expect.objectContaining({ outerSessionId: other.id })) + expect(cloudSessions.deleteSession).not.toHaveBeenCalled() + await coordinator.dispose() + }) + + it("lists the started sandbox in a cached History once its start settles", async () => { + const host = { status: "idle", readMessages: async () => [], dispose: async () => {} } as unknown as CloudSessionHost + vi.spyOn(CloudSessionHost, "connect").mockResolvedValue(host) + const hubStarted = deferred() + const startNewSession = vi.fn(async () => { + await hubStarted.promise + return { sdkHost: host, startResult: { sessionId: record.id } } + }) + let history!: SdkTaskHistory + const { coordinator, cloudSessions } = makeCoordinator({ + invalidateHistoryCache: () => history.invalidateCache(), + sessions: { startNewSession, fireAndForgetSend: vi.fn() } as never, + }) + history = new SdkTaskHistory({ + mcpHub: {} as McpHub, + sessions: { getActiveSession: () => ({ sdkHost: { listHistory: async () => [] } }) } as never, + cloud: { + isCloudSessionId: (id) => coordinator.isCloudSessionId(id), + list: () => coordinator.listHistoryRecords(), + find: (id) => coordinator.findHistoryRecord(id), + delete: (id) => coordinator.deleteSession(id), + }, + }) + cloudSessions.createSession.mockImplementation(async (_input, onProvisioning) => { + onProvisioning?.(record.id) + return record + }) + cloudSessions.listSessions.mockResolvedValue([record]) + const listedIds = async () => (await history.listHistory({ hydrate: false })).map((item) => item.sessionId) + + const start = coordinator.beginCloudTask({ prompt: "test", repoUrl: record.repoContext.repoUrl! })() + await vi.waitFor(() => expect(startNewSession).toHaveBeenCalled()) + expect(await listedIds()).not.toContain(record.id) + hubStarted.resolve() + await start + + expect(await listedIds()).toContain(record.id) + await history.dispose() + await coordinator.dispose() + }) + + it("lists the started sandbox when its start settles while a History listing reads usage", async () => { + const startHost = { + status: "idle", + readMessages: async () => [], + dispose: async () => {}, + } as unknown as CloudSessionHost + const usage = deferred() + const otherHost = { + status: "running", + readMessages: async () => [], + dispose: async () => {}, + getAccumulatedUsage: vi.fn(() => usage.promise), + } as unknown as CloudSessionHost + const other = { ...record, id: "ses-other", metadata: { ...record.metadata, taskId: "tsk-other" } } + vi.spyOn(CloudSessionHost, "connect").mockImplementation(async ({ outerSessionId }) => + outerSessionId === other.id ? otherHost : startHost, + ) + const hubStarted = deferred() + const startNewSession = vi.fn(async () => { + await hubStarted.promise + return { sdkHost: startHost, startResult: { sessionId: record.id } } + }) + let history!: SdkTaskHistory + const { coordinator, cloudSessions } = makeCoordinator({ + invalidateHistoryCache: () => history.invalidateCache(), + sessions: { startNewSession, fireAndForgetSend: vi.fn() } as never, + }) + history = new SdkTaskHistory({ + mcpHub: {} as McpHub, + sessions: { getActiveSession: () => ({ sdkHost: { listHistory: async () => [] } }) } as never, + cloud: { + isCloudSessionId: (id) => coordinator.isCloudSessionId(id), + list: () => coordinator.listHistoryRecords(), + find: (id) => coordinator.findHistoryRecord(id), + delete: (id) => coordinator.deleteSession(id), + }, + }) + cloudSessions.listSessions.mockResolvedValue([other]) + await coordinator.listHistoryRecords() + await coordinator.resolveStatuses([other.id]) + cloudSessions.createSession.mockImplementation(async (_input, onProvisioning) => { + onProvisioning?.(record.id) + return record + }) + cloudSessions.listSessions.mockResolvedValue([record, other]) + const listedIds = async () => (await history.listHistory({ hydrate: false })).map((item) => item.sessionId) + + const start = coordinator.beginCloudTask({ prompt: "test", repoUrl: record.repoContext.repoUrl! })() + await vi.waitFor(() => expect(startNewSession).toHaveBeenCalled()) + const listing = listedIds() + await vi.waitFor(() => expect(otherHost.getAccumulatedUsage).toHaveBeenCalled()) + hubStarted.resolve() + await start + usage.resolve(undefined) + await listing + + expect(await listedIds()).toContain(record.id) + await history.dispose() + await coordinator.dispose() + }) + + it("keeps a provisioning sandbox recorded until its first prompt is sent", async () => { + const pendingStartsDir = mkdtempSync(path.join(os.tmpdir(), "cloud-pending-")) + const host = { status: "idle", readMessages: async () => [], dispose: async () => {} } as unknown as CloudSessionHost + vi.spyOn(CloudSessionHost, "connect").mockResolvedValue(host) + const recorded = () => + readdirSync(pendingStartsDir).flatMap((name) => JSON.parse(readFileSync(path.join(pendingStartsDir, name), "utf8"))) + const recordedAtSend: unknown[] = [] + const { coordinator, cloudSessions } = makeCoordinator({ + pendingStartsDir, + getAccountScope: () => "user:", + sessions: { + startNewSession: vi.fn(async () => { + expect(recorded()).toEqual([{ sessionId: record.id, account: "user:" }]) + return { sdkHost: host, startResult: { sessionId: record.id } } + }), + fireAndForgetSend: vi.fn(() => recordedAtSend.push(...recorded())), + } as never, + }) + cloudSessions.createSession.mockImplementation(async (_input, onProvisioning) => { + onProvisioning?.(record.id) + return record + }) + + expect(await coordinator.beginCloudTask({ prompt: "test", repoUrl: record.repoContext.repoUrl! })()).toBe(record.id) + expect(recordedAtSend).toEqual([]) + expect(cloudSessions.deleteSession).not.toHaveBeenCalled() + await coordinator.dispose() + rmSync(pendingStartsDir, { recursive: true, force: true }) + }) + + it("fails a start and deletes its sandbox when the sandbox cannot be recorded", async () => { + const parent = mkdtempSync(path.join(os.tmpdir(), "cloud-pending-")) + const pendingStartsDir = path.join(parent, "not-a-directory") + writeFileSync(pendingStartsDir, "") + const host = { status: "idle", readMessages: async () => [], dispose: async () => {} } as unknown as CloudSessionHost + vi.spyOn(CloudSessionHost, "connect").mockResolvedValue(host) + const startNewSession = vi.fn(async () => ({ sdkHost: host, startResult: { sessionId: record.id } })) + const { coordinator, cloudSessions, options } = makeCoordinator({ + pendingStartsDir, + getAccountScope: () => "user:", + sessions: { startNewSession, fireAndForgetSend: vi.fn() } as never, + }) + cloudSessions.createSession.mockImplementation(async (_input, onProvisioning) => { + onProvisioning?.(record.id) + return record + }) + + expect(await coordinator.beginCloudTask({ prompt: "test", repoUrl: record.repoContext.repoUrl! })()).toBeUndefined() + expect(startNewSession).not.toHaveBeenCalled() + expect(cloudSessions.deleteSession).toHaveBeenCalledWith(record.id) + expect(options.onStartFailed).toHaveBeenCalled() + await coordinator.dispose() + rmSync(parent, { recursive: true, force: true }) + }) + + it("does not send the first prompt when the sandbox's record cannot be dropped", async () => { + const pendingStartsDir = mkdtempSync(path.join(os.tmpdir(), "cloud-pending-")) + const host = { status: "idle", readMessages: async () => [], dispose: async () => {} } as unknown as CloudSessionHost + vi.spyOn(CloudSessionHost, "connect").mockResolvedValue(host) + const fireAndForgetSend = vi.fn() + const { coordinator, cloudSessions, options } = makeCoordinator({ + pendingStartsDir, + getAccountScope: () => "user:", + sessions: { + startNewSession: vi.fn(async () => { + const [journal] = readdirSync(pendingStartsDir) + rmSync(path.join(pendingStartsDir, journal)) + mkdirSync(path.join(pendingStartsDir, journal)) + return { sdkHost: host, startResult: { sessionId: record.id } } + }), + fireAndForgetSend, + endActiveSessionIfHost: vi.fn(async () => undefined), + } as never, + }) + cloudSessions.createSession.mockImplementation(async (_input, onProvisioning) => { + onProvisioning?.(record.id) + return record + }) + + expect(await coordinator.beginCloudTask({ prompt: "test", repoUrl: record.repoContext.repoUrl! })()).toBeUndefined() + expect(fireAndForgetSend).not.toHaveBeenCalled() + expect(cloudSessions.deleteSession).toHaveBeenCalledWith(record.id) + expect(options.onStartFailed).toHaveBeenCalled() + await coordinator.dispose() + rmSync(pendingStartsDir, { recursive: true, force: true }) + }) + + it("settles an abandoned sandbox only when the control plane confirms it, for the account that started it", async () => { + const pendingStartsDir = mkdtempSync(path.join(os.tmpdir(), "cloud-pending-")) + const journal = path.join(pendingStartsDir, `${EXITED_PID}-${randomUUID()}.json`) + const pending = ["ses-deleted", "ses-gone", "ses-unauthorized", "ses-unavailable"].map((sessionId) => ({ + sessionId, + account: "user:", + })) + const otherAccount = { sessionId: "ses-other-account", account: "user:org" } + writeFileSync(journal, JSON.stringify([...pending, otherAccount])) + const { coordinator, cloudSessions } = makeCoordinator({ pendingStartsDir, getAccountScope: () => "user:" }) + cloudSessions.deleteSession.mockImplementation(async (sessionId: string) => { + if (sessionId === "ses-gone") throw new CloudSessionError("session_not_found", "gone", undefined, 404) + if (sessionId === "ses-unauthorized") throw new CloudSessionError("authentication_required", "no", undefined, 401) + if (sessionId === "ses-unavailable") throw new CloudSessionError("request_failed", "down", undefined, 503) + }) + + await coordinator.listHistoryRecords() + + await vi.waitFor(() => expect(cloudSessions.deleteSession).toHaveBeenCalledTimes(4)) + await vi.waitFor(() => expect(JSON.parse(readFileSync(journal, "utf8"))).toEqual([pending[2], pending[3], otherAccount])) + expect(cloudSessions.deleteSession).not.toHaveBeenCalledWith("ses-other-account") + await coordinator.dispose() + rmSync(pendingStartsDir, { recursive: true, force: true }) + }) + + it("finishes an abandoned-sandbox DELETE before an account switch and sends no more for the old account", async () => { + const pendingStartsDir = mkdtempSync(path.join(os.tmpdir(), "cloud-pending-")) + writeFileSync( + path.join(pendingStartsDir, `${EXITED_PID}-${randomUUID()}.json`), + JSON.stringify([ + { sessionId: "ses-first", account: "user:" }, + { sessionId: "ses-second", account: "user:" }, + ]), + ) + const { coordinator, cloudSessions } = makeCoordinator({ pendingStartsDir, getAccountScope: () => "user:" }) + const firstDelete = deferred() + const events: string[] = [] + cloudSessions.deleteSession.mockImplementation(async (sessionId: string) => { + events.push(`delete ${sessionId}`) + await firstDelete.promise + events.push(`deleted ${sessionId}`) + }) + + await coordinator.listHistoryRecords() + await vi.waitFor(() => expect(events).toEqual(["delete ses-first"])) + const switching = coordinator.reset(async () => { + events.push("account changed") + }) + await new Promise((resolve) => setTimeout(resolve, 50)) + firstDelete.resolve() + await switching + + expect(events).toEqual(["delete ses-first", "deleted ses-first", "account changed"]) + await coordinator.dispose() + rmSync(pendingStartsDir, { recursive: true, force: true }) + }) + + it("cancels the abandoned-sandbox recheck when it is disposed", async () => { + vi.useFakeTimers({ toFake: ["setTimeout", "clearTimeout"] }) + const pendingStartsDir = mkdtempSync(path.join(os.tmpdir(), "cloud-pending-")) + writeFileSync( + path.join(pendingStartsDir, `${process.ppid}-${randomUUID()}.json`), + JSON.stringify([{ sessionId: "ses-live-window", account: "user:" }]), + ) + const { coordinator, cloudSessions } = makeCoordinator({ pendingStartsDir, getAccountScope: () => "user:" }) + + await coordinator.listHistoryRecords() + writeFileSync( + path.join(pendingStartsDir, `${EXITED_PID}-${randomUUID()}.json`), + JSON.stringify([{ sessionId: "ses-abandoned", account: "user:" }]), + ) + await coordinator.dispose() + expect(vi.getTimerCount()).toBe(0) + await vi.advanceTimersByTimeAsync(60_000) + + expect(cloudSessions.deleteSession).not.toHaveBeenCalled() + rmSync(pendingStartsDir, { recursive: true, force: true }) + }) + + it("leaves a provisioning start alone when Cancel is for another task, and cancels it from its own view", async () => { + const { coordinator, cloudSessions, options } = makeCoordinator() + const named = deferred() + cloudSessions.createSession.mockImplementation(async (_input, onProvisioning, signal) => { + onProvisioning?.(record.id) + named.resolve() + await new Promise((_resolve, reject) => signal?.addEventListener("abort", () => reject(signal.reason))) + return record + }) + + const start = coordinator.beginCloudTask({ prompt: "test", repoUrl: record.repoContext.repoUrl! })() + await named.promise + const startView = options.getTask() as TaskProxy + const otherTask = createTaskProxy("ses-other", vi.fn(), vi.fn()) + + expect(coordinator.cancelPendingStartFor(otherTask)).toBe(false) + expect(coordinator.getCurrentTaskInfo()?.status).toBe("provisioning") + options.setTask(otherTask) + expect(coordinator.getCurrentTaskInfo()).toMatchObject({ sessionId: "ses-other", status: "unknown" }) + options.setTask(startView) + expect(coordinator.getCurrentTaskInfo()?.status).toBe("provisioning") + expect(coordinator.cancelPendingStartFor(startView)).toBe(true) + + expect(await start).toBe(record.id) + expect(cloudSessions.deleteSession).toHaveBeenCalledWith(record.id) + await coordinator.dispose() + }) + + it("starts the sandbox on the model the composer showed, even when a fresher recommendation lands first", async () => { + resetClineRecommendedModelsCacheForTests() + vi.spyOn(ClineEnv, "config").mockReturnValue({ apiBaseUrl: "https://api.cline-test.bot" } as ReturnType< + typeof ClineEnv.config + >) + const fetched = deferred>>() + vi.spyOn(sdkCore, "fetchClineRecommendedModels").mockReturnValue(fetched.promise) + const host = { status: "idle", readMessages: async () => [], dispose: async () => {} } as unknown as CloudSessionHost + vi.spyOn(CloudSessionHost, "connect").mockResolvedValue(host) + const { coordinator, cloudSessions, options } = makeCoordinator({ + stateManager: { + ...makeStateManager(), + getApiConfiguration: () => ({ actModeApiProvider: "anthropic" }), + } as never, + sessions: { + startNewSession: vi.fn(async () => ({ sdkHost: host, startResult: { sessionId: record.id } })), + fireAndForgetSend: vi.fn(), + } as never, + }) + + const shown = coordinator.getCloudModelId() + expect(shown).toBe(CLINE_RECOMMENDED_MODELS_FALLBACK.recommended[0].id) + const start = coordinator.beginCloudTask({ prompt: "test", repoUrl: record.repoContext.repoUrl! })() + fetched.resolve({ + recommended: [{ id: "fresh/model", name: "Fresh", description: "", tags: [] }], + free: [], + clinePass: [], + }) + expect(await start).toBe(record.id) + + expect(cloudSessions.createSession).toHaveBeenCalledWith( + expect.objectContaining({ modelId: shown }), + expect.any(Function), + expect.any(AbortSignal), + expect.any(Function), + ) + // The fresh list is for the next task: once the displayed task is gone the composer offers it. + await fetched.promise + options.setTask(undefined) + expect(coordinator.getCloudModelId()).toBe("fresh/model") + await coordinator.dispose() + resetClineRecommendedModelsCacheForTests() + }) + + it("bounds failed recommendation retries while state posts keep reading the fallback", async () => { + vi.useFakeTimers() + resetClineRecommendedModelsCacheForTests() + vi.spyOn(ClineEnv, "config").mockReturnValue({ apiBaseUrl: "https://api.cline-test.bot" } as ReturnType< + typeof ClineEnv.config + >) + const fetchRecommended = vi + .spyOn(sdkCore, "fetchClineRecommendedModels") + .mockResolvedValueOnce(structuredClone(sdkCore.FALLBACK_CLINE_RECOMMENDED_MODELS)) + .mockResolvedValueOnce({ + recommended: [{ id: "fresh/model", name: "Fresh", description: "", tags: [] }], + free: [], + clinePass: [], + }) + const { coordinator, options } = makeCoordinator({ + stateManager: { + ...makeStateManager(), + getApiConfiguration: () => ({ actModeApiProvider: "anthropic" }), + } as never, + }) + + // The failed fetch leaves the fallback in place and posts no state: a + // post would read the model again and start the next fetch, so a dead + // endpoint would be polled without bound. + expect(coordinator.getCloudModelId()).toBe(CLINE_RECOMMENDED_MODELS_FALLBACK.recommended[0].id) + await vi.advanceTimersByTimeAsync(0) + expect(fetchRecommended).toHaveBeenCalledTimes(1) + expect(options.postStateToWebview).not.toHaveBeenCalled() + + for (let read = 0; read < 10; read++) coordinator.getCloudModelId() + await vi.advanceTimersByTimeAsync(29_999) + expect(coordinator.getCloudModelId()).toBe(CLINE_RECOMMENDED_MODELS_FALLBACK.recommended[0].id) + expect(fetchRecommended).toHaveBeenCalledTimes(1) + await vi.advanceTimersByTimeAsync(1) + expect(coordinator.getCloudModelId()).toBe(CLINE_RECOMMENDED_MODELS_FALLBACK.recommended[0].id) + await vi.advanceTimersByTimeAsync(0) + expect(options.postStateToWebview).toHaveBeenCalledTimes(1) + expect(fetchRecommended).toHaveBeenCalledTimes(2) + expect(coordinator.getCloudModelId()).toBe("fresh/model") + await coordinator.dispose() + resetClineRecommendedModelsCacheForTests() + }) + + it("refuses to connect when the control plane omits the canonical task id", async () => { + const withoutTaskId = { ...record, metadata: { modelId: "fixture-model" } } + const { coordinator, cloudSessions, options } = makeCoordinator() + cloudSessions.listSessions.mockResolvedValue([withoutTaskId]) + const connect = vi.spyOn(CloudSessionHost, "connect") + + await coordinator.openCloudTask(withoutTaskId.id) + + expect(options.getTask()?.messageStateHandler.getClineMessages().at(-1)?.text).toBe( + `Could not connect to this cloud session: Cloud session ${withoutTaskId.id} has no canonical task id.`, + ) + expect(connect).not.toHaveBeenCalled() + }) + + describe("expired sessions", () => { + const expired = { ...record, id: "ses-expired", expiredAt: new Date(1_000).toISOString() } + const userTurn = { role: "user", content: [{ type: "text", text: "archived prompt" }], timestamp: 500 } + + async function openExpired(getHistory: () => Promise) { + const { coordinator, cloudSessions, options } = makeCoordinator() + cloudSessions.getHistory.mockImplementation(getHistory) + cloudSessions.listSessions.mockResolvedValue([expired]) + const connect = vi.spyOn(CloudSessionHost, "connect") + await coordinator.openCloudTask(expired.id) + expect(connect).not.toHaveBeenCalled() + const messages = options.getTask()!.messageStateHandler.getClineMessages() + await coordinator.dispose() + return messages + } + + it("shows the archived conversation with a notice, not an error", async () => { + const messages = await openExpired(async () => [userTurn]) + expect(messages.some((message) => message.text?.includes("archived prompt"))).toBe(true) + const notice = messages.at(-1)! + expect(notice.say).toBe("info") + expect(notice.text).toContain("retired after 24 hours without activity") + expect(notice.text).toContain("saved here to read") + expect(notice.text).toContain("start a new cloud task on cline/fixture (main)") + }) + + it("does not present the last archived response as a completed task", async () => { + const assistantTurn = { + role: "assistant", + content: [{ type: "text", text: "archived reply" }], + timestamp: 600, + } + const messages = await openExpired(async () => [userTurn, assistantTurn]) + const reply = messages.find((message) => message.text === "archived reply")! + expect(reply.say).toBe("text") + expect(messages.some((message) => message.say === "completion_result")).toBe(false) + }) + + for (const [archive, getHistory] of [ + ["no archive", async () => null], + ["an empty archive", async () => []], + ] as const) { + it(`says when no conversation was archived given ${archive}`, async () => { + const messages = await openExpired(getHistory) + expect(messages).toHaveLength(1) + expect(messages[0].say).toBe("info") + expect(messages[0].text).toContain("No conversation was saved") + }) + } + + it("says when the archive could not be loaded", async () => { + const messages = await openExpired(async () => { + throw new Error("network down") + }) + expect(messages).toHaveLength(1) + expect(messages[0].say).toBe("info") + expect(messages[0].text).toContain("could not be loaded right now (network down)") + }) + + it("shows a sandbox retired after History was listed as expired, not as a connection error", async () => { + const { coordinator, cloudSessions, options } = makeCoordinator() + cloudSessions.listSessions.mockResolvedValue([record]) + cloudSessions.getStatus.mockRejectedValue(new CloudSessionError("session_expired", "session expired", undefined, 410)) + cloudSessions.getHistory.mockResolvedValue(null) + vi.spyOn(CloudSessionHost, "connect").mockRejectedValue(new Error("Unexpected server response: 410")) + await coordinator.openCloudTask(record.id) + const messages = options.getTask()!.messageStateHandler.getClineMessages() + expect(messages).toHaveLength(1) + expect(messages[0].say).toBe("info") + expect(messages[0].text).toContain("retired after 24 hours") + await coordinator.dispose() + }) + + it("recognises retirement when /status answers expired instead of 410", async () => { + // The control plane keeps answering /status with 200 for a retired + // sandbox while its WebSocket rejects with 410. + const { coordinator, cloudSessions, options } = makeCoordinator() + cloudSessions.listSessions.mockResolvedValue([record]) + cloudSessions.getStatus.mockResolvedValue({ status: "expired" }) + cloudSessions.getHistory.mockResolvedValue(null) + vi.spyOn(CloudSessionHost, "connect").mockRejectedValue(new Error("Unexpected server response: 410")) + await coordinator.openCloudTask(record.id) + const messages = options.getTask()!.messageStateHandler.getClineMessages() + expect(messages).toHaveLength(1) + expect(messages[0].say).toBe("info") + expect(messages[0].text).toContain("retired after 24 hours") + await coordinator.dispose() + }) + + it("keeps a deleted session's notice when a newer selection did not supersede it", async () => { + const { coordinator, cloudSessions, options } = makeCoordinator() + cloudSessions.listSessions.mockResolvedValueOnce([record]).mockResolvedValue([]) + cloudSessions.getStatus.mockRejectedValue( + new CloudSessionError("session_not_found", "session not found", undefined, 404), + ) + vi.spyOn(CloudSessionHost, "connect").mockRejectedValue(new Error("Unexpected server response: 404")) + await coordinator.openCloudTask(record.id) + expect(options.getTask()?.taskId).toBe(record.id) + expect(options.getTask()!.messageStateHandler.getClineMessages()[0].text).toContain("was deleted") + expect(options.invalidateHistoryCache).toHaveBeenCalled() + expect(await coordinator.findHistoryRecord(record.id)).toBeUndefined() + await coordinator.dispose() + }) + + it("disposes a retained host when its session turns out to be deleted", async () => { + const dispose = vi.fn(async () => undefined) + const { coordinator, cloudSessions } = makeCoordinator() + cloudSessions.listSessions.mockResolvedValue([record]) + await coordinator.listHistoryRecords() + const retained = { + status: "unknown", + refreshStatus: vi.fn(async () => { + throw new Error("Unexpected server response: 404") + }), + dispose, + } + ;(coordinator as unknown as { entries: Map }).entries.get(record.id)!.host = retained + cloudSessions.getStatus.mockRejectedValue( + new CloudSessionError("session_not_found", "session not found", undefined, 404), + ) + await coordinator.openCloudTask(record.id) + expect(dispose).toHaveBeenCalledWith("deleted") + await coordinator.dispose() + }) + }) + + it("closes the retained host of a session that has left the account's list", async () => { + // Deleted from the dashboard while this instance still held a connection. + const dispose = vi.fn(async () => undefined) + const { coordinator, cloudSessions } = makeCoordinator() + cloudSessions.listSessions.mockResolvedValue([record]) + await coordinator.listHistoryRecords() + const entries = (coordinator as unknown as { entries: Map }).entries + entries.get(record.id)!.host = { status: "running", dispose } + cloudSessions.listSessions.mockResolvedValue([]) + + await (coordinator as unknown as { refreshList: (force: boolean) => Promise }).refreshList(true) + + expect(entries.has(record.id)).toBe(false) + expect(dispose).toHaveBeenCalledWith("deleted") + await coordinator.dispose() + }) + + it("keeps the displayed task's host when its record leaves the list, so opening it can explain why", async () => { + const dispose = vi.fn(async () => undefined) + const { coordinator, cloudSessions, options } = makeCoordinator() + cloudSessions.listSessions.mockResolvedValue([record]) + await coordinator.listHistoryRecords() + const entries = (coordinator as unknown as { entries: Map }).entries + entries.get(record.id)!.host = { status: "running", dispose } + options.setTask(createTaskProxy(record.id, vi.fn(), vi.fn())) + cloudSessions.listSessions.mockResolvedValue([]) + + await (coordinator as unknown as { refreshList: (force: boolean) => Promise }).refreshList(true) + + expect(entries.has(record.id)).toBe(true) + expect(dispose).not.toHaveBeenCalled() + await coordinator.dispose() + }) + + it("closes idle hosts but keeps the displayed task when the account's list can no longer be read", async () => { + const displayed = { ...record, id: "ses-displayed", metadata: { ...record.metadata, taskId: "tsk-displayed" } } + const disposeIdle = vi.fn(async () => undefined) + const disposeDisplayed = vi.fn(async () => undefined) + const { coordinator, cloudSessions, options } = makeCoordinator() + cloudSessions.listSessions.mockResolvedValue([record, displayed]) + await coordinator.listHistoryRecords() + const entries = (coordinator as unknown as { entries: Map }).entries + entries.get(record.id)!.host = { status: "running", dispose: disposeIdle } + entries.get(displayed.id)!.host = { status: "running", dispose: disposeDisplayed } + options.setTask(createTaskProxy(displayed.id, vi.fn(), vi.fn())) + cloudSessions.listSessions.mockRejectedValue(new CloudSessionError("authentication_required", "sign in", undefined, 401)) + + await (coordinator as unknown as { refreshList: (force: boolean) => Promise }).refreshList(true) + + expect([...entries.keys()]).toEqual([displayed.id]) + expect(disposeIdle).toHaveBeenCalledWith("authenticationRequired") + expect(disposeDisplayed).not.toHaveBeenCalled() + await coordinator.dispose() + }) + + it("ignores a successful list after disposal", async () => { + const list = deferred() + const entered = deferred() + const { coordinator, cloudSessions } = makeCoordinator() + cloudSessions.listSessions.mockImplementationOnce(() => { + entered.resolve() + return list.promise + }) + const pending = coordinator.listHistoryRecords() + await entered.promise + await coordinator.dispose() + list.resolve([record]) + expect(await pending).toEqual([]) + }) + + it("does not notify on initial discovery or let an old host update a same-id new-scope entry", async () => { + let statusChanged!: NonNullable[0]["onStatusChange"]> + const host = { status: "completed", readMessages: async () => [], dispose: async () => {} } as unknown as CloudSessionHost + vi.spyOn(CloudSessionHost, "connect").mockImplementation(async (options) => { + if (!options.onStatusChange) throw new Error("Coordinator must observe host status") + statusChanged = options.onStatusChange + statusChanged("completed") + return host + }) + const { coordinator, cloudSessions, options } = makeCoordinator({ + sessions: { attachExistingSession: async () => {} } as never, + }) + cloudSessions.listSessions.mockResolvedValue([record]) + await coordinator.openCloudTask(record.id) + expect(HostProvider.window.showMessage).not.toHaveBeenCalled() + await coordinator.reset() + await coordinator.listHistoryRecords() + vi.mocked(options.postStateToWebview).mockClear() + statusChanged("running") + statusChanged("failed") + // The old host's events never reach the new-scope entry; it still shows the + // status the previous scope remembered for the same record. + expect((await coordinator.listHistoryRecords())[0].metadata?.cloudStatus).toBe("completed") + expect(options.postStateToWebview).not.toHaveBeenCalled() + expect(HostProvider.window.showMessage).not.toHaveBeenCalled() + await coordinator.dispose() + }) + + describe("remembered statuses", () => { + const finished = { ...record, updatedAt: new Date(1_000).toISOString() } + + function connectReporting(status: CloudSessionStatus) { + vi.spyOn(CloudSessionHost, "connect").mockImplementation(async (options) => { + options.onStatusChange?.(status) + return { + status, + readMessages: async () => [], + dispose: vi.fn(async () => undefined), + } as unknown as CloudSessionHost + }) + } + + async function rememberCompleted(globalState: Record) { + connectReporting("completed") + const { coordinator, cloudSessions } = makeCoordinator({ stateManager: makeStateManager(globalState) as never }) + cloudSessions.listSessions.mockResolvedValue([finished]) + await coordinator.listHistoryRecords() + await coordinator.resolveStatuses([finished.id]) + await coordinator.dispose() + } + + it("shows a settled status after restart without reconnecting while the record is unchanged", async () => { + const globalState: Record = {} + await rememberCompleted(globalState) + + const connect = vi.spyOn(CloudSessionHost, "connect").mockClear() + const { coordinator, cloudSessions } = makeCoordinator({ stateManager: makeStateManager(globalState) as never }) + cloudSessions.listSessions.mockResolvedValue([finished]) + + expect((await coordinator.listHistoryRecords())[0].metadata?.cloudStatus).toBe("completed") + expect(await coordinator.resolveStatuses([finished.id])).toEqual([{ sessionId: finished.id, status: "completed" }]) + expect(connect).not.toHaveBeenCalled() + }) + + it("keeps a remembered status through the control plane's own connect touch", async () => { + const globalState: Record = {} + await rememberCompleted(globalState) + + const { coordinator, cloudSessions } = makeCoordinator({ stateManager: makeStateManager(globalState) as never }) + cloudSessions.listSessions.mockResolvedValue([{ ...finished, updatedAt: new Date(Date.now() + 2_000).toISOString() }]) + + expect((await coordinator.listHistoryRecords())[0].metadata?.cloudStatus).toBe("completed") + }) + + it("forgets a remembered status once the record is updated well after the observation", async () => { + const globalState: Record = {} + await rememberCompleted(globalState) + + const { coordinator, cloudSessions } = makeCoordinator({ stateManager: makeStateManager(globalState) as never }) + cloudSessions.listSessions.mockResolvedValue([ + { ...finished, updatedAt: new Date(Date.now() + 10 * 60_000).toISOString() }, + ]) + + expect((await coordinator.listHistoryRecords())[0].metadata?.cloudStatus).toBe("unknown") + }) + + it("keeps a suspended sandbox's last outcome through the control plane's housekeeping touches", async () => { + const globalState: Record = {} + await rememberCompleted(globalState) + + const connect = vi.spyOn(CloudSessionHost, "connect").mockClear() + const { coordinator, cloudSessions } = makeCoordinator({ stateManager: makeStateManager(globalState) as never }) + const touchedLater = new Date(Date.now() + 30 * 60_000).toISOString() + cloudSessions.listSessions.mockResolvedValue([ + { ...finished, status: "suspended", updatedAt: touchedLater }, + { ...finished, id: "ses-never-seen", status: "suspended", updatedAt: touchedLater }, + ]) + + const rows = await coordinator.listHistoryRecords() + expect(rows.map((row) => row.metadata?.cloudStatus)).toEqual(["completed", "idle"]) + await coordinator.resolveStatuses(rows.map((row) => row.sessionId)) + expect(connect).not.toHaveBeenCalled() + }) + + it.each([ + "ready", + "suspended", + ])("reopens a %s task whose last turn failed instead of reporting a failed sandbox", async (recordStatus) => { + const globalState: Record = { + cloudSessionStatuses: { [finished.id]: { status: "failed", observedAt: Date.now() } }, + } + const connect = vi.spyOn(CloudSessionHost, "connect").mockResolvedValue({ + status: "failed", + readMessages: async () => [{ role: "user", content: "original prompt" }], + dispose: vi.fn(async () => {}), + } as unknown as CloudSessionHost) + const { coordinator, cloudSessions, options } = makeCoordinator({ + stateManager: makeStateManager(globalState) as never, + sessions: { attachExistingSession: async () => {} } as never, + }) + cloudSessions.listSessions.mockResolvedValue([{ ...finished, status: recordStatus }]) + expect((await coordinator.listHistoryRecords())[0].metadata?.cloudStatus).toBe("failed") + + await coordinator.openCloudTask(finished.id) + + expect(cloudSessions.resumeSession).toHaveBeenCalledTimes(recordStatus === "suspended" ? 1 : 0) + // The resume notice is brought in front of History; a plain open leaves navigation to the caller. + expect(options.showChatView).toHaveBeenCalledTimes(recordStatus === "suspended" ? 1 : 0) + expect(connect).toHaveBeenCalledOnce() + const shown = JSON.stringify(options.getTask()?.messageStateHandler.getClineMessages()) + expect(shown).toContain("original prompt") + expect(shown).not.toContain("failed to start") + await coordinator.dispose() + }) + + it("reconnects from scratch after resuming a sandbox whose host was kept from before it suspended", async () => { + const host = (id: string) => + ({ + id, + status: "completed", + readMessages: async () => [{ role: "user", content: "original prompt" }], + dispose: vi.fn(async () => {}), + }) as unknown as CloudSessionHost & { id: string } + const before = host("before") + const after = host("after") + const connect = vi.spyOn(CloudSessionHost, "connect").mockResolvedValueOnce(before).mockResolvedValueOnce(after) + const attached: unknown[] = [] + const { coordinator, cloudSessions } = makeCoordinator({ + sessions: { attachExistingSession: async ({ sdkHost }: { sdkHost: unknown }) => attached.push(sdkHost) } as never, + }) + cloudSessions.listSessions.mockResolvedValue([finished]) + await coordinator.openCloudTask(finished.id) + + cloudSessions.listSessions.mockResolvedValue([{ ...finished, status: "suspended" }]) + // History lists the account again (past the cache) and sees the suspension. + vi.spyOn(Date, "now").mockReturnValue(Date.now() + 60_000) + await coordinator.listHistoryRecords() + await coordinator.openCloudTask(finished.id) + + expect(cloudSessions.resumeSession).toHaveBeenCalledOnce() + expect(before.dispose).toHaveBeenCalled() + expect(connect).toHaveBeenCalledTimes(2) + expect(attached).toEqual([before, after]) + await coordinator.dispose() + }) + + it("does not remember an active status", async () => { + const globalState: Record = {} + connectReporting("running") + const { coordinator, cloudSessions } = makeCoordinator({ stateManager: makeStateManager(globalState) as never }) + cloudSessions.listSessions.mockResolvedValue([finished]) + await coordinator.listHistoryRecords() + await coordinator.resolveStatuses([finished.id]) + + expect(globalState.cloudSessionStatuses).toBeUndefined() + }) + + it("forgets a remembered outcome when a connection finds the agent running again", async () => { + const globalState: Record = {} + await rememberCompleted(globalState) + vi.spyOn(CloudSessionHost, "connect").mockResolvedValue({ + status: "running", + readMessages: async () => [{ role: "user", content: "original prompt" }], + dispose: vi.fn(async () => {}), + } as unknown as CloudSessionHost) + const opened = makeCoordinator({ + stateManager: makeStateManager(globalState) as never, + sessions: { attachExistingSession: async () => {} } as never, + }) + opened.cloudSessions.listSessions.mockResolvedValue([finished]) + await opened.coordinator.openCloudTask(finished.id) + expect(globalState.cloudSessionStatuses).toEqual({}) + await opened.coordinator.dispose() + + const { coordinator, cloudSessions } = makeCoordinator({ stateManager: makeStateManager(globalState) as never }) + cloudSessions.listSessions.mockResolvedValue([finished]) + expect((await coordinator.listHistoryRecords())[0].metadata?.cloudStatus).toBe("unknown") + await coordinator.dispose() + }) + + it("forgets the idle status a fresh sandbox reported once its turn starts running", async () => { + const globalState: Record = {} + let statusChanged!: NonNullable[0]["onStatusChange"]> + vi.spyOn(CloudSessionHost, "connect").mockImplementation(async (options) => { + statusChanged = options.onStatusChange! + return { status: "idle", readMessages: async () => [], dispose: vi.fn(async () => {}) } as never + }) + const first = makeCoordinator({ stateManager: makeStateManager(globalState) as never }) + first.cloudSessions.listSessions.mockResolvedValue([finished]) + await first.coordinator.listHistoryRecords() + await first.coordinator.resolveStatuses([finished.id]) + expect(globalState.cloudSessionStatuses).toMatchObject({ [finished.id]: { status: "idle" } }) + statusChanged("running") + expect(globalState.cloudSessionStatuses).toEqual({}) + await first.coordinator.dispose() + + // After a reload mid-turn, the row must be re-resolved instead of trusting the stale idle. + const { coordinator, cloudSessions } = makeCoordinator({ stateManager: makeStateManager(globalState) as never }) + cloudSessions.listSessions.mockResolvedValue([finished]) + expect((await coordinator.listHistoryRecords())[0].metadata?.cloudStatus).toBe("unknown") + await coordinator.dispose() + }) + + it.each([false, true])("invalidates a later record without restarting (retained host=%s)", async (retained) => { + vi.useFakeTimers() + vi.setSystemTime(100_000) + const globalState: Record = {} + const host = { + status: "completed", + readMessages: async () => [], + dispose: vi.fn(async () => {}), + refreshStatus: vi.fn(async () => "running"), + } + const connect = vi.spyOn(CloudSessionHost, "connect").mockResolvedValue(host as unknown as CloudSessionHost) + const { coordinator, cloudSessions, options } = makeCoordinator({ + stateManager: makeStateManager(globalState) as never, + }) + cloudSessions.listSessions.mockResolvedValue([finished]) + await coordinator.listHistoryRecords() + if (retained) options.setTask({ taskId: finished.id } as never) + await coordinator.resolveStatuses([finished.id]) + expect((await coordinator.listHistoryRecords())[0].metadata?.cloudStatus).toBe("completed") + vi.setSystemTime(180_000) + cloudSessions.listSessions.mockResolvedValue([{ ...finished, updatedAt: new Date(170_000).toISOString() }]) + expect((await coordinator.listHistoryRecords())[0].metadata?.cloudStatus).toBe("unknown") + expect(globalState.cloudSessionStatuses).toEqual({ [finished.id]: { status: "completed", observedAt: 100_000 } }) + if (!retained) connect.mockResolvedValue({ ...host, status: "running" } as unknown as CloudSessionHost) + expect(await coordinator.resolveStatuses([finished.id])).toEqual([{ sessionId: finished.id, status: "running" }]) + if (retained) { + expect(host.refreshStatus).toHaveBeenCalledOnce() + expect(connect).toHaveBeenCalledOnce() + expect(host.dispose).not.toHaveBeenCalled() + } else expect(connect).toHaveBeenCalledTimes(2) + await coordinator.dispose() + }) + + it("drops remembered statuses for sessions the account no longer lists", async () => { + const globalState: Record = { + cloudSessionStatuses: { "ses-gone": { status: "completed", observedAt: 5_000 } }, + } + const { coordinator, cloudSessions } = makeCoordinator({ stateManager: makeStateManager(globalState) as never }) + cloudSessions.listSessions.mockResolvedValue([finished]) + await coordinator.listHistoryRecords() + + expect(globalState.cloudSessionStatuses).toEqual({}) + }) + }) + + describe("History timestamps", () => { + it("keeps the control plane timestamp when a connection only learns the current status", async () => { + let statusChanged!: NonNullable[0]["onStatusChange"]> + vi.spyOn(CloudSessionHost, "connect").mockImplementation(async (options) => { + statusChanged = options.onStatusChange! + statusChanged("completed") + return { + status: "completed", + readMessages: async () => [], + dispose: vi.fn(async () => undefined), + } as unknown as CloudSessionHost + }) + const old = { ...record, updatedAt: new Date(1_000).toISOString() } + const { coordinator, cloudSessions } = makeCoordinator() + cloudSessions.listSessions.mockResolvedValue([old]) + await coordinator.listHistoryRecords() + await coordinator.resolveStatuses([old.id]) + + expect((await coordinator.listHistoryRecords())[0].updatedAt).toBe(old.updatedAt) + + statusChanged("running") + expect(Date.parse((await coordinator.listHistoryRecords())[0].updatedAt!)).toBeGreaterThan(1_000) + }) + }) + + it.each([ + "idle", + "cancelled", + "unknown", + "completed", + ] as const)("opens %s with a truthful resume prompt and phase", async (status) => { + const host = { + status, + readMessages: async () => [{ role: "user", content: "original prompt" }], + dispose: async () => {}, + } as unknown as CloudSessionHost + vi.spyOn(CloudSessionHost, "connect").mockResolvedValue(host) + const { coordinator, cloudSessions, options } = makeCoordinator({ + sessions: { attachExistingSession: async () => {} } as never, + }) + cloudSessions.listSessions.mockResolvedValue([record]) + await coordinator.openCloudTask(record.id) + expect(options.getTask()?.messageStateHandler.getClineMessages().at(-1)?.ask).toBe( + status === "completed" ? "resume_completed_task" : "resume_task", + ) + expect(options.setTurnPhase).toHaveBeenLastCalledWith( + ...(status === "completed" ? ["completed", expect.any(Number)] : ["idle"]), + ) + await coordinator.dispose() + }) + it.each([ + "authentication_required", + "request_failed", + ] as const)("ignores old-scope %s after the new History snapshot is installed", async (code) => { + const oldList = deferred() + const entered = deferred() + const { coordinator, cloudSessions } = makeCoordinator() + cloudSessions.listSessions.mockImplementationOnce(() => { + entered.resolve() + return oldList.promise + }) + const pending = coordinator.listHistoryRecords() + await entered.promise + await coordinator.reset() + const current = { ...record, id: "ses-current" } + cloudSessions.listSessions.mockResolvedValue([current]) + expect(await coordinator.listHistoryRecords()).toMatchObject([{ sessionId: current.id }]) + oldList.reject(new CloudSessionError(code, "Old account failed")) + await pending + expect(await coordinator.listHistoryRecords()).toMatchObject([{ sessionId: current.id }]) + expect(cloudSessions.listSessions).toHaveBeenCalledTimes(2) + }) + + it("answers History listed by the account mutation without waiting on the transition", async () => { + const { coordinator, cloudSessions, options } = makeCoordinator() + cloudSessions.listSessions.mockResolvedValue([record]) + await coordinator.listHistoryRecords() + let listedDuringSwitch: unknown + const changeScope = vi.fn(async () => { + // Refreshing auth posts state, which lists History (and so cloud rows). + listedDuringSwitch = await coordinator.listHistoryRecords() + }) + vi.mocked(options.postStateToWebview).mockClear() + + await coordinator.reset(changeScope) + + expect(changeScope).toHaveBeenCalledOnce() + expect(listedDuringSwitch).toEqual([]) + expect(options.postStateToWebview).toHaveBeenCalled() + expect(await coordinator.listHistoryRecords()).toMatchObject([{ sessionId: record.id }]) + expect(cloudSessions.listSessions).toHaveBeenCalledTimes(2) + }) + + it("omits old rows immediately while task teardown posts History", async () => { + const clearing = deferred<() => boolean>() + const { coordinator, cloudSessions, options } = makeCoordinator() + cloudSessions.listSessions.mockResolvedValue([record]) + await coordinator.listHistoryRecords() + options.setTask({ taskId: record.id } as never) + vi.mocked(options.clearTask).mockReturnValue(clearing.promise) + const switching = coordinator.reset() + expect(await coordinator.listHistoryRecords()).toEqual([]) + expect(await coordinator.findHistoryRecord(record.id)).toBeUndefined() + clearing.resolve(() => false) + await switching + }) + + it("refreshes History after an account mutation fails", async () => { + const { coordinator, options } = makeCoordinator() + await expect( + coordinator.reset(async () => { + throw new Error("refresh failed") + }), + ).rejects.toThrow("refresh failed") + expect(options.postStateToWebview).toHaveBeenCalledOnce() + }) + + it("runs a queued logout after an earlier account mutation fails", async () => { + const changing = deferred() + const entered = deferred() + const { coordinator } = makeCoordinator() + const switching = coordinator.reset(async () => { + entered.resolve() + await changing.promise + }) + const failed = expect(switching).rejects.toThrow("account refresh failed") + await entered.promise + const logout = vi.fn(async () => undefined) + const signingOut = coordinator.reset(logout) + changing.reject(new Error("account refresh failed")) + await failed + await signingOut + expect(logout).toHaveBeenCalledOnce() + await coordinator.dispose() + }) + + it("still drains unfinished cleanup before a queued logout after the first transition times out", async () => { + vi.useFakeTimers() + const deleting = deferred() + const entered = deferred() + const { coordinator, cloudSessions } = makeCoordinator() + cloudSessions.deleteSession.mockImplementation(async () => { + entered.resolve() + await deleting.promise + }) + const cleanup = coordinator.deleteSession(record.id) + await entered.promise + const failed = expect(coordinator.reset()).rejects.toThrow("cleanup is still pending") + const logout = vi.fn(async () => undefined) + const signingOut = coordinator.reset(logout) + await vi.advanceTimersByTimeAsync(15_000) + await failed + expect(logout).not.toHaveBeenCalled() + deleting.resolve() + await cleanup + await signingOut + expect(logout).toHaveBeenCalledOnce() + await coordinator.dispose() + }) + + it("does not stamp a fresh cache timestamp when an old request fails after reset", async () => { + const oldList = deferred() + const entered = deferred() + const { coordinator, cloudSessions } = makeCoordinator() + cloudSessions.listSessions.mockImplementationOnce(() => { + entered.resolve() + return oldList.promise + }) + const pending = coordinator.listHistoryRecords() + await entered.promise + await coordinator.reset() + oldList.reject(new Error("Disconnected")) + await pending + cloudSessions.listSessions.mockResolvedValue([record]) + expect(await coordinator.listHistoryRecords()).toMatchObject([{ sessionId: record.id }]) + expect(cloudSessions.listSessions).toHaveBeenCalledTimes(2) + }) + + it("keeps ownership of a newer pending list when the old list settles", async () => { + const oldList = deferred() + const newList = deferred() + const oldEntered = deferred() + const newEntered = deferred() + const { coordinator, cloudSessions } = makeCoordinator() + cloudSessions.listSessions + .mockImplementationOnce(() => { + oldEntered.resolve() + return oldList.promise + }) + .mockImplementationOnce(() => { + newEntered.resolve() + return newList.promise + }) + const first = coordinator.listHistoryRecords() + await oldEntered.promise + await coordinator.reset() + const second = coordinator.listHistoryRecords() + await newEntered.promise + oldList.reject(new Error("old transport")) + await first + const third = coordinator.listHistoryRecords() + newList.resolve([record]) + expect(await second).toEqual(await third) + expect(cloudSessions.listSessions).toHaveBeenCalledTimes(2) + }) + + it("preserves a newer selection when real lifecycle attachment is superseded during teardown", async () => { + const sessions = new SdkSessionLifecycle({ + mcpHub: {} as never, + requestToolApproval: vi.fn(), + askQuestion: vi.fn(), + onSessionEvent: vi.fn(), + onSendComplete: vi.fn(), + onSendError: vi.fn(), + }) + let superseded = false + const host = { + status: "idle", + readMessages: async () => [], + subscribe: () => () => {}, + stop: vi.fn(), + dispose: async () => {}, + } as unknown as CloudSessionHost + vi.spyOn(CloudSessionHost, "connect").mockResolvedValue(host) + const { coordinator, cloudSessions, options } = makeCoordinator({ + sessions, + claimTaskViewGeneration: () => () => superseded, + clearTask: async () => () => superseded, + }) + cloudSessions.listSessions.mockResolvedValue([record]) + const successor = createTaskProxy("newer-local-task", vi.fn(), vi.fn()) + // endActiveSession clears its reference and unsubscribes before yielding. + // Re-entry from unsubscribe claims a newer task in that exact interval. + await sessions.attachExistingSession({ + sdkHost: { + ...host, + subscribe: () => () => { + superseded = true + options.setTask(successor) + }, + } as unknown as SdkSessionHost, + sessionId: "previous", + isRunning: false, + }) + await coordinator.openCloudTask(record.id) + expect(options.getTask()).toBe(successor) + expect(options.postStateToWebview).not.toHaveBeenCalled() + expect(sessions.getActiveSession()).toBeUndefined() + await coordinator.dispose() + }) + + it.each([false, true])("renders connection failure only for its current task claim (superseded=%s)", async (superseded) => { + const connection = deferred() + const entered = deferred() + let stale = false + vi.spyOn(CloudSessionHost, "connect").mockImplementation(() => { + entered.resolve() + return connection.promise + }) + const { coordinator, cloudSessions, options } = makeCoordinator({ + claimTaskViewGeneration: () => () => stale, + clearTask: async () => () => stale, + }) + cloudSessions.listSessions.mockResolvedValue([record]) + const opening = coordinator.openCloudTask(record.id) + await entered.promise + const successor = createTaskProxy("newer-local-task", vi.fn(), vi.fn()) + stale = superseded + if (superseded) options.setTask(successor) + connection.reject(new Error("connection failed")) + await opening + expect(options.getTask()?.taskId).toBe(superseded ? successor.taskId : record.id) + expect(options.postStateToWebview).toHaveBeenCalledTimes(superseded ? 0 : 1) + await coordinator.dispose() + }) + it("rejects a list result that resolves after account scope reset", async () => { + const list = deferred() + const entered = deferred() + const { coordinator, cloudSessions } = makeCoordinator() + cloudSessions.listSessions.mockImplementationOnce(() => { + entered.resolve() + return list.promise + }) + + const pending = coordinator.listHistoryRecords() + await entered.promise + await coordinator.reset() + const current = { ...record, id: "ses-current" } + cloudSessions.listSessions.mockResolvedValue([current]) + await coordinator.listHistoryRecords() + list.resolve([record]) + + expect(await pending).toMatchObject([{ sessionId: current.id }]) + }) + + it("disposes every owned host during account scope reset", async () => { + const dispose = vi.fn(async () => undefined) + const { coordinator } = makeCoordinator() + ;(coordinator as unknown as { entries: Map }).entries.set(record.id, { + record, + host: { dispose }, + lastActivityAt: 0, + }) + + await coordinator.reset() + + expect(dispose).toHaveBeenCalledWith("accountScopeChanged") + }) + + it("clears a displayed cloud task before disposing its old-scope host", async () => { + const dispose = vi.fn(async () => undefined) + const clearTask = vi.fn(async () => () => false) + const { coordinator } = makeCoordinator({ + getTask: () => ({ taskId: record.id }) as never, + clearTask, + }) + ;(coordinator as unknown as { entries: Map }).entries.set(record.id, { + record, + host: { dispose }, + lastActivityAt: 0, + }) + + await coordinator.reset() + + expect(clearTask).toHaveBeenCalledOnce() + expect(clearTask.mock.invocationCallOrder[0]).toBeLessThan(dispose.mock.invocationCallOrder[0]) + }) + + it("does not provision when the task view was already superseded", async () => { + const { coordinator, cloudSessions } = makeCoordinator({ + claimTaskViewGeneration: () => () => true, + clearTask: async () => () => true, + }) + + const result = await coordinator.beginCloudTask({ + prompt: "test", + repoUrl: "https://github.com/cline/fixture", + })() + + expect(result).toBeUndefined() + expect(cloudSessions.createSession).not.toHaveBeenCalled() + expect(cloudSessions.deleteSession).not.toHaveBeenCalled() + }) + + it("deletes a sandbox when the user cancels during provisioning", async () => { + const created = deferred() + const provisioned = deferred() + const startNewSession = vi.fn() + const { coordinator, cloudSessions, options } = makeCoordinator({ sessions: { startNewSession } as never }) + cloudSessions.createSession.mockImplementation(async (_input, onProvisioning) => { + onProvisioning?.(record.id) + provisioned.resolve() + return created.promise + }) + + const starting = coordinator.beginCloudTask({ + prompt: "test", + repoUrl: "https://github.com/cline/fixture", + })() + await provisioned.promise + expect(coordinator.cancelPendingStart()).toBe(true) + expect(coordinator.cancelPendingStart()).toBe(false) + created.resolve(record) + + expect(await starting).toBe(record.id) + expect(cloudSessions.deleteSession).toHaveBeenCalledWith(record.id) + expect(startNewSession).not.toHaveBeenCalled() + expect(options.resolveContextMentions).not.toHaveBeenCalled() + }) + + it("reconnects after a failed deletion instead of reusing the host it disposed", async () => { + const dispose = vi.fn(async () => {}) + const connect = vi.spyOn(CloudSessionHost, "connect").mockImplementation( + async () => + ({ + status: "completed", + readMessages: async () => [], + dispose, + subscribe: () => () => {}, + }) as unknown as CloudSessionHost, + ) + const { coordinator, cloudSessions } = makeCoordinator({ + sessions: { attachExistingSession: async () => {} } as never, + }) + cloudSessions.listSessions.mockResolvedValue([record]) + cloudSessions.deleteSession.mockRejectedValue(new Error("control plane unavailable")) + + await coordinator.openCloudTask(record.id) + expect(connect).toHaveBeenCalledTimes(1) + await expect(coordinator.deleteSession(record.id)).rejects.toThrow("control plane unavailable") + expect(dispose).toHaveBeenCalledWith("deleted") + + await coordinator.openCloudTask(record.id) + expect(connect).toHaveBeenCalledTimes(2) + await coordinator.dispose() + }) + + it("stops the readiness poll as soon as the user cancels", async () => { + const provisioned = deferred() + const { coordinator, cloudSessions } = makeCoordinator({ sessions: { startNewSession: vi.fn() } as never }) + cloudSessions.createSession.mockImplementation(async (_input, onProvisioning, signal) => { + onProvisioning?.(record.id) + provisioned.resolve() + // The real service polls /status until the sandbox is ready or the signal aborts. + await new Promise((_resolve, reject) => signal?.addEventListener("abort", () => reject(signal.reason))) + throw new Error("unreachable") + }) + + const starting = coordinator.beginCloudTask({ prompt: "test", repoUrl: "https://github.com/cline/fixture" })() + await provisioned.promise + coordinator.cancelPendingStart() + + expect(await starting).toBe(record.id) + expect(cloudSessions.deleteSession).toHaveBeenCalledWith(record.id) + }) + + it("reports the displayed task as provisioning until a failed start has settled, then offers the start again", async () => { + const provisioned = deferred() + const created = deferred() + const { coordinator, cloudSessions, options } = makeCoordinator({ sessions: { startNewSession: vi.fn() } as never }) + cloudSessions.createSession.mockImplementation(async (_input, onProvisioning) => { + onProvisioning?.(record.id) + provisioned.resolve() + return created.promise + }) + + // The webview learns the status only through state posts, so record what each post would carry. + const postedStatuses: Array = [] + vi.mocked(options.postStateToWebview).mockImplementation(async () => { + postedStatuses.push(coordinator.getCurrentTaskInfo()?.status) + }) + + const input = { prompt: "test", images: ["img"], repoUrl: "https://github.com/cline/fixture", branch: "main" } + const starting = coordinator.beginCloudTask(input)() + await provisioned.promise + expect(coordinator.getCurrentTaskInfo()?.status).toBe("provisioning") + + created.reject(new Error("control plane unavailable")) + expect(await starting).toBeUndefined() + + expect(postedStatuses.at(-1)).toBe("unknown") + // The failure is shown on the task that is still displayed, and Retry re-runs the same input. + expect(options.onStartFailed).toHaveBeenCalledWith(options.getTask(), input) + expect(vi.mocked(options.onStartFailed).mock.invocationCallOrder[0]).toBeLessThan( + vi.mocked(options.setTurnPhase).mock.invocationCallOrder.at(-1)!, + ) + }) + + it("projects an unconfirmed create into History guidance and a New Task-only footer", async () => { + const { coordinator, cloudSessions, options } = makeCoordinator() + cloudSessions.createSession.mockRejectedValue(new CloudCreationUnconfirmedError(new Error("response lost"))) + expect(await coordinator.beginCloudTask({ prompt: "test", repoUrl: record.repoContext.repoUrl! })()).toBeUndefined() + const emitted = vi.mocked(options.messages.appendAndEmit).mock.calls.flatMap(([messages]) => messages) + const ask = emitted.find((message) => message.type === "ask" && message.ask === "api_req_failed") + expect(ask).toBeDefined() + if (!ask) throw new Error("Missing cloud creation recovery marker") + const error = ClineError.parse(ask.text) + expect(error?._error.code).toBe(CLOUD_CREATION_UNCONFIRMED_ERROR_CODE) + expect(error?.message).toContain("History") + expect(error?.message).toContain("https://example.test/agents") + expect(emitted).toContainEqual(expect.objectContaining({ say: "error", text: error?.message })) + const outcome = vi.mocked(options.setTurnPhase).mock.calls.at(-1) + expect(outcome).toEqual(["error", ask.ts]) + expect(getButtonConfigFromState(emitted, { phase: "error", anchorTs: outcome?.[1], seq: 1 })).toEqual({ + sendingDisabled: true, + enableButtons: true, + primaryText: "Start New Task", + primaryAction: "new_task", + }) + expect(options.onStartFailed).not.toHaveBeenCalled() + expect(cloudSessions.createSession).toHaveBeenCalledOnce() + expect(cloudSessions.deleteSession).not.toHaveBeenCalled() + await coordinator.dispose() + }) + + it("does not offer to retry a start the user cancelled", async () => { + const provisioned = deferred() + const { coordinator, cloudSessions, options } = makeCoordinator({ sessions: { startNewSession: vi.fn() } as never }) + cloudSessions.createSession.mockImplementation(async (_input, onProvisioning, signal) => { + onProvisioning?.(record.id) + provisioned.resolve() + await new Promise((_resolve, reject) => signal?.addEventListener("abort", () => reject(signal.reason))) + throw new Error("unreachable") + }) + + const starting = coordinator.beginCloudTask({ prompt: "test", repoUrl: "https://github.com/cline/fixture" })() + await provisioned.promise + coordinator.cancelPendingStart() + await starting + + expect(options.onStartFailed).not.toHaveBeenCalled() + }) + + it("defeats a start that is claimed but not yet running when the user cancels first", async () => { + const { coordinator, cloudSessions } = makeCoordinator() + + const run = coordinator.beginCloudTask({ prompt: "test", repoUrl: "https://github.com/cline/fixture" }) + expect(coordinator.cancelPendingStart()).toBe(true) + + expect(await run()).toBeUndefined() + expect(cloudSessions.createSession).not.toHaveBeenCalled() + }) + + it("projects an authoritative usage snapshot from its live cloud host", async () => { + const getAccumulatedUsage = vi.fn(async () => ({ + inputTokens: 101, + outputTokens: 202, + cacheReadTokens: 303, + cacheWriteTokens: 404, + totalCost: 0.5, + })) + const { coordinator, cloudSessions } = makeCoordinator() + cloudSessions.listSessions.mockResolvedValue([record]) + ;(coordinator as unknown as { entries: Map }).entries.set(record.id, { + record, + host: { getAccumulatedUsage }, + lastActivityAt: 0, + }) + + const [history] = await coordinator.listHistoryRecords() + + expect(getAccumulatedUsage).toHaveBeenCalledWith(record.id) + expect(history.metadata).toMatchObject({ + usageAvailable: true, + tokensIn: 101, + tokensOut: 202, + cacheReads: 303, + cacheWrites: 404, + totalCost: 0.5, + }) + }) + + it("leaves usage unavailable when REST has no authoritative usage", async () => { + const { coordinator, cloudSessions } = makeCoordinator() + cloudSessions.listSessions.mockResolvedValue([record]) + + const [history] = await coordinator.listHistoryRecords() + + expect(history.metadata).not.toHaveProperty("usageAvailable") + expect(history.metadata).not.toHaveProperty("tokensIn") + }) + + it("resolves only requested visible statuses with bounded concurrency", async () => { + const records = Array.from({ length: 7 }, (_, index) => ({ + ...record, + id: `ses-${index}`, + metadata: { ...record.metadata, taskId: `tsk-${index}` }, + })) + const entered = deferred() + const release = deferred() + let active = 0 + let maxActive = 0 + const connect = vi.spyOn(CloudSessionHost, "connect").mockImplementation(async (options) => { + active++ + maxActive = Math.max(maxActive, active) + if (active === 4) entered.resolve() + await release.promise + active-- + return { + status: + options.outerSessionId === records[0].id + ? "completed" + : options.outerSessionId === records[1].id + ? "running" + : "idle", + dispose: vi.fn(async () => undefined), + } as unknown as CloudSessionHost + }) + const { coordinator, cloudSessions } = makeCoordinator() + cloudSessions.listSessions.mockResolvedValue(records) + await coordinator.listHistoryRecords() + + const pending = coordinator.resolveStatuses([ + records[0].id, + records[0].id, + records[1].id, + records[2].id, + records[3].id, + records[4].id, + records[5].id, + "local-task", + ]) + await entered.promise + expect(connect).toHaveBeenCalledTimes(4) + expect(maxActive).toBe(4) + release.resolve() + const statuses = await pending + + expect(connect).toHaveBeenCalledTimes(6) + expect(connect).not.toHaveBeenCalledWith(expect.objectContaining({ outerSessionId: records[6].id })) + expect(statuses).toContainEqual({ sessionId: records[0].id, status: "completed" }) + expect(statuses).toHaveLength(6) + for (const [index, result] of connect.mock.results.entries()) { + expect(result.type).toBe("return") + if (index === 1) { + expect((await result.value).dispose).not.toHaveBeenCalled() + } else { + expect((await result.value).dispose).toHaveBeenCalledWith("statusResolved") + } + } + }) + + it("leaves the host open when status resolution overlaps opening the same task", async () => { + const attached = deferred() + const attaching = deferred() + const host = { + status: "completed", + readMessages: async () => [], + dispose: vi.fn(async () => undefined), + } as unknown as CloudSessionHost + vi.spyOn(CloudSessionHost, "connect").mockResolvedValue(host) + const { coordinator, cloudSessions } = makeCoordinator({ + sessions: { + attachExistingSession: async () => { + attaching.resolve() + await attached.promise + }, + } as never, + }) + cloudSessions.listSessions.mockResolvedValue([record]) + await coordinator.listHistoryRecords() + + const opening = coordinator.openCloudTask(record.id) + await attaching.promise + const statuses = await coordinator.resolveStatuses([record.id]) + expect(statuses).toEqual([{ sessionId: record.id, status: "completed" }]) + expect(host.dispose).not.toHaveBeenCalled() + + attached.resolve() + await opening + expect(host.dispose).not.toHaveBeenCalled() + await coordinator.dispose() + }) + + it("drops resolved statuses when the account changes during connection", async () => { + const connected = deferred() + vi.spyOn(CloudSessionHost, "connect").mockReturnValue(connected.promise) + const { coordinator, cloudSessions } = makeCoordinator() + cloudSessions.listSessions.mockResolvedValue([record]) + await coordinator.listHistoryRecords() + + const resolving = coordinator.resolveStatuses([record.id]) + const resetting = coordinator.reset() + connected.resolve({ status: "completed", dispose: vi.fn(async () => undefined) } as unknown as CloudSessionHost) + + expect(await resolving).toEqual([]) + await resetting + }) + + it("restarts History in the new account scope when old usage resolves late", async () => { + const usage = deferred() + const entered = deferred() + const { coordinator, cloudSessions } = makeCoordinator() + cloudSessions.listSessions.mockResolvedValue([record]) + ;(coordinator as unknown as { entries: Map }).entries.set(record.id, { + record, + host: { + getAccumulatedUsage: () => { + entered.resolve() + return usage.promise + }, + dispose: vi.fn(async () => undefined), + }, + lastActivityAt: 0, + }) + + const pending = coordinator.listHistoryRecords() + await entered.promise + await coordinator.reset() + const current = { ...record, id: "ses-current" } + cloudSessions.listSessions.mockResolvedValue([current]) + usage.resolve(undefined) + + expect(await pending).toMatchObject([{ sessionId: current.id }]) + }) + + it("does not block History on a non-responsive usage RPC", async () => { + vi.useFakeTimers() + const { coordinator, cloudSessions } = makeCoordinator() + cloudSessions.listSessions.mockResolvedValue([record]) + ;(coordinator as unknown as { entries: Map }).entries.set(record.id, { + record, + host: { getAccumulatedUsage: () => new Promise(() => {}) }, + lastActivityAt: 0, + }) + + const pending = coordinator.listHistoryRecords() + await vi.advanceTimersByTimeAsync(2_000) + const [history] = await pending + + expect(history.metadata).not.toHaveProperty("usageAvailable") + }) +}) diff --git a/apps/vscode/src/sdk/sdk-cloud-session-coordinator.ts b/apps/vscode/src/sdk/sdk-cloud-session-coordinator.ts new file mode 100644 index 0000000000..997fd7263b --- /dev/null +++ b/apps/vscode/src/sdk/sdk-cloud-session-coordinator.ts @@ -0,0 +1,1582 @@ +// SdkCloudSessionCoordinator — starts, reopens and monitors Cline Cloud sessions. +// +// Cloud sessions run in a hosted sandbox and keep going after the user moves on, +// so this coordinator owns two things the local task flow does not need: +// +// 1. A registry of live sandbox connections (CloudSessionHost). A connection +// is kept after the user leaves the task view so the agent's real status +// (running / completed / failed) stays known for History and so a +// background cloud task can raise a "finished" notification. REST alone +// cannot tell a busy agent from an idle sandbox ("active" only means the +// sandbox is up). +// 2. The projection of cloud records into task history (SessionHistoryRecord +// shape) so History, the home screen and the running-now strip list cloud +// tasks next to local ones. + +import type { ITelemetryService, SessionAccumulatedUsage, SessionHistoryRecord, StartSessionInput } from "@cline/core" +import { CloudCreationUnconfirmedError } from "@cline/core/cloud" +import type { MessageWithMetadata as SdkMessage } from "@cline/llms" +import type { ToolApprovalRequest, ToolApprovalResult } from "@cline/shared" +import { + ACTIVE_CLOUD_STATUSES, + CLOUD_CREATION_UNCONFIRMED_ERROR_CODE, + CLOUD_PROVISIONING_ID_PREFIX, + CLOUD_SESSION_MODE, + CLOUD_WORKSPACE_ROOT, + type CloudSessionStatus, + type CurrentCloudTaskInfo, + isCloudSessionId, + isPersistedCloudSessionId, + type RememberedCloudStatuses, +} from "@shared/cloud/cloud-sessions" +import type { ClineMessage, TurnPhase } from "@shared/ExtensionMessage" +import type { HistoryItem } from "@shared/HistoryItem" +import { ShowMessageType } from "@shared/proto/host/window" +import { + getCachedClineRecommendedModels, + refreshClineRecommendedModels, +} from "@/core/controller/models/refreshClineRecommendedModels" +import type { StateManager } from "@/core/storage/StateManager" +import { HostProvider } from "@/hosts/host-provider" +import { + type CloudProvisioningPhase, + CloudSessionError, + type CloudSessionRecord, + type CloudSessionsService, + isCloudSessionExpired, +} from "@/services/cloud/CloudSessionsService" +import { ClineError } from "@/services/error/ClineError" +import { CLINE_RECOMMENDED_MODELS_FALLBACK } from "@/shared/cline/recommended-models" +import { Logger } from "@/shared/services/Logger" +import { PendingStartJournal, type PendingStartRecord } from "./cloud-pending-starts" +import { CloudSessionHost } from "./cloud-session-host" +import type { MessageIdMinter } from "./message-id-minter" +import type { SdkMessageCoordinator } from "./sdk-message-coordinator" +import type { SdkSessionConfigBuilder } from "./sdk-session-config-builder" +import type { SdkSessionLifecycle } from "./sdk-session-lifecycle" +import { sdkMessagesToDisplayClineMessages, sessionHistoryRecordToHistoryItem } from "./sdk-task-history" +import type { SdkSessionHost } from "./session-host" +import { createTaskProxy, type TaskProxy } from "./task-proxy" + +const LIST_CACHE_TTL_MS = 10_000 +const ACTIVE_POLL_INTERVAL_MS = 15_000 +const IDLE_CONNECTION_TTL_MS = 5 * 60_000 + +/** + * The control plane stamps a record's `updatedAt` when a client connects to + * its sandbox, a moment after the socket has reported the agent status. A + * touch this soon after an observation is attributed to the connection that + * made the observation, not to activity elsewhere. + */ +const CONNECT_TOUCH_GRACE_MS = 60_000 + +/** + * Whether a status is worth remembering across restarts. An active status is + * re-learned live, because only rows still unknown are re-resolved on launch. + */ +function isSettled(status: CloudSessionStatus): boolean { + return status !== "unknown" && !ACTIVE_CLOUD_STATUSES.has(status) +} + +/** Idle time after which the control plane retires a standard sandbox. */ +const CLOUD_SANDBOX_IDLE_HOURS = 24 + +/** + * Explains an expired session in the user's terms. The sandbox is retired by + * the control plane, not lost through anything the user did; whether a + * transcript survived depends on whether a viewer disconnected from the live + * sandbox before it was retired. + */ +function describeExpiredCloudSession( + record: Pick, + archived: SdkMessage[] | undefined, + archiveError: string | undefined, +): string { + const repo = record.repoContext.repoUrl?.replace(/^https:\/\/github\.com\//, "") + const where = repo ? ` on ${repo}${record.repoContext.branch ? ` (${record.repoContext.branch})` : ""}` : "" + const retired = `This cloud sandbox was retired after ${CLOUD_SANDBOX_IDLE_HOURS} hours without activity.` + const resume = `To keep working on this, start a new cloud task${where}.` + if (archiveError) { + return `${retired} Its saved conversation could not be loaded right now (${archiveError}). ${resume}` + } + if (!archived) { + return `${retired} No conversation was saved for it: the sandbox was already gone when the last viewer disconnected. ${resume}` + } + return `${retired} The conversation above is saved here to read. ${resume}` +} +const USAGE_REFRESH_TIMEOUT_MS = 2_000 +const STATUS_RESOLUTION_RETRY_MS = 30_000 +const STATUS_RESOLUTION_CONCURRENCY = 4 +const RECOMMENDED_MODELS_RETRY_MS = 30_000 + +function provisioningPhaseText(phase: CloudProvisioningPhase, sandboxLabel: string): string | undefined { + switch (phase) { + case "cloning_repo": + return `Cloning ${sandboxLabel} into the cloud sandbox…` + case "agent_starting": + return "Starting the agent in the cloud sandbox…" + default: + return undefined + } +} + +function withTimeout(promise: Promise, timeoutMs: number): Promise { + return new Promise((resolve, reject) => { + const timeout = globalThis.setTimeout(() => resolve(undefined), timeoutMs) + promise.then( + (value) => { + globalThis.clearTimeout(timeout) + resolve(value) + }, + (error) => { + globalThis.clearTimeout(timeout) + reject(error) + }, + ) + }) +} +const SCOPE_DRAIN_TIMEOUT_MS = 15_000 +const ABANDONED_STARTS_RECHECK_MS = 10_000 + +export interface CloudTaskInput { + prompt: string + images?: string[] + repoUrl: string + branch?: string +} + +const HISTORY_STATUS: Record = { + provisioning: "pending", + running: "running", + idle: "idle", + completed: "completed", + failed: "failed", + cancelled: "cancelled", + unknown: "pending", + expired: "cancelled", +} + +export interface SdkCloudSessionCoordinatorOptions { + cloudSessions: CloudSessionsService + stateManager: StateManager + sessions: SdkSessionLifecycle + sessionConfigBuilder: SdkSessionConfigBuilder + messages: SdkMessageCoordinator + getMinter: () => MessageIdMinter + getTask: () => TaskProxy | undefined + setTask: (task: TaskProxy | undefined) => void + onAskResponse: (text?: string, images?: string[], files?: string[]) => Promise + onCancelTask: () => Promise + /** + * Ends the current task view (local or cloud) before a cloud task is + * installed, and returns the fence of the task-view claim it made. + */ + clearTask: () => Promise<() => boolean> + /** + * A cloud start failed before it had a session and `task` now shows the + * error. The controller keeps `input` so the footer's Retry runs it again. + */ + onStartFailed: (task: TaskProxy, input: CloudTaskInput) => void + claimTaskViewGeneration: () => () => boolean + requestToolApproval: (request: ToolApprovalRequest) => Promise + getAuthToken: () => Promise + isSignedIn: () => boolean + isEnabled: () => boolean + resetMessageTranslator: () => void + setTurnPhase: (phase: TurnPhase, anchorTs?: number) => void + /** Forgets the previous turn's completion signal so a new cloud turn's phase is computed fresh. */ + clearTurnOutcome: () => void + postStateToWebview: () => Promise + /** Brings the chat view forward, dismissing History or any other view covering it. */ + showChatView: () => Promise + invalidateHistoryCache: () => void + resolveContextMentions: (text: string) => Promise + telemetry?: ITelemetryService + /** + * Where this extension host records sandboxes it is still starting (one file + * per host, so concurrent windows never overwrite each other's records), so + * a later host can delete them if this one exits first. Off when unset. + */ + pendingStartsDir?: string + /** Identity of the active account (user and organization), so recovery acts only for the account that started a sandbox. */ + getAccountScope?: () => string | undefined +} + +interface CloudSessionEntry { + record: CloudSessionRecord + /** Live connection, when this extension instance is attached to the sandbox. */ + host?: CloudSessionHost + connection?: Promise + /** + * Number of openCloudTask calls currently reading from or attaching to the + * host. Status resolution and the idle sweep leave a pinned host open. + */ + pinned: number + /** + * Last agent status observed over a live connection, kept after the + * connection is dropped and remembered across restarts while the control + * plane record has not changed since (see rememberedStatusOf). + */ + agentStatus?: CloudSessionStatus + /** Usage snapshot from this entry's live host. REST-only records do not expose usage. */ + usage?: SessionAccumulatedUsage + title?: string + lastActivityAt: number +} + +export class SdkCloudSessionCoordinator { + private readonly entries = new Map() + private listFetchedAt = 0 + private listPromise: Promise | undefined + private pollTimer: NodeJS.Timeout | undefined + private disposed = false + private scopeGeneration = 0 + private startGeneration = 0 + /** Set while a cloud start is provisioning; aborted by cancelPendingStart. */ + private pendingStart: AbortController | undefined + /** The sandbox the pending start created, once the control plane has named it. */ + private pendingStartSessionId: string | undefined + /** The task view the pending start installed, once it has one. */ + private pendingStartTask: TaskProxy | undefined + /** + * Re-claims the task view for the pending start when the user returns to it, + * so a selection made in between is rejected. False once the start has seen + * that it lost the view and is cleaning up. + */ + private renewPendingStartView: (() => boolean) | undefined + /** The one recommendation fetch started for the composer label; see warmRecommendedModels. */ + private recommendedModelsWarmup: Promise | undefined + private recommendedModelsRetryAt = 0 + private scopeTransition: Promise | undefined + private readonly scopeOperations = new Set>() + private readonly statusResolutionAttempts = new Map() + private readonly journal: PendingStartJournal | undefined + private abandonedStartsRecovery: Promise | undefined + private abandonedStartsRecheck: ReturnType | undefined + + constructor(private readonly options: SdkCloudSessionCoordinatorOptions) { + this.journal = options.pendingStartsDir ? new PendingStartJournal(options.pendingStartsDir) : undefined + } + + isCloudSessionId(id: string): boolean { + return isCloudSessionId(id) + } + + isAvailable(): boolean { + return this.options.isEnabled() && this.options.isSignedIn() + } + + // ---- Status / state projection ---- + + statusOf(entry: CloudSessionEntry): CloudSessionStatus { + if (isCloudSessionExpired(entry.record)) { + return "expired" + } + const rest = entry.record.status?.toLowerCase() + if (rest === "provisioning" || rest === "pending") { + return "provisioning" + } + if (rest === "failed") { + return "failed" + } + if (rest === "suspended") { + // A suspended sandbox runs nothing, so there is nothing to resolve over a + // socket (it would be refused). Keep the last outcome seen here, even + // though the control plane keeps touching updatedAt while it sleeps. + const known = entry.agentStatus ?? this.rememberedStatuses()[entry.record.id]?.status + return known && isSettled(known) ? known : "idle" + } + return entry.agentStatus ?? "unknown" + } + + /** + * The status remembered for a record from a previous connection, if the + * control plane has not updated the record since it was observed. A later + * `updatedAt` means another client connected while nothing here watched, + * so the agent may have run again. + */ + private rememberedStatusOf(record: CloudSessionRecord): CloudSessionStatus | undefined { + const remembered = this.rememberedStatuses()[record.id] + if (!remembered) { + return undefined + } + const updatedAt = Date.parse(record.updatedAt) + return Number.isFinite(updatedAt) && updatedAt <= remembered.observedAt + CONNECT_TOUCH_GRACE_MS + ? remembered.status + : undefined + } + + /** Global state does not apply declared defaults on read; an install that never remembered anything has no key. */ + private rememberedStatuses(): RememberedCloudStatuses { + return this.options.stateManager.getGlobalStateKey("cloudSessionStatuses") ?? {} + } + + private rememberStatus(record: CloudSessionRecord, status: CloudSessionStatus, observedAt: number): void { + this.options.stateManager.setGlobalState("cloudSessionStatuses", { + ...this.rememberedStatuses(), + [record.id]: { status, observedAt }, + }) + } + + /** An active status supersedes whatever settled status was remembered before the agent ran again. */ + private forgetStatus(sessionId: string): void { + const { [sessionId]: forgotten, ...kept } = this.rememberedStatuses() + if (forgotten) this.options.stateManager.setGlobalState("cloudSessionStatuses", kept) + } + + private rememberPendingStart(sessionId: string): void { + this.journal?.add({ + sessionId, + account: this.options.getAccountScope?.(), + endpoint: this.options.cloudSessions.apiBaseUrl, + }) + } + + /** After a confirmed deletion; a record left behind only makes a later host see a 404. */ + private forgetPendingStart(sessionId: string): void { + try { + this.journal?.remove(sessionId) + } catch (error) { + Logger.warn("[CloudSessions] Failed to update pending cloud starts:", error) + } + } + + /** + * Settles sandboxes left by starts that a reload, quit or crash interrupted, + * as one scope operation of the account that started them: an account change + * waits for a DELETE in flight and stops the rest, and nothing runs after + * disposal. Records of another account or endpoint wait for that scope. + */ + private recoverAbandonedStarts(): Promise { + if (!this.journal || this.disposed || this.scopeTransition) return Promise.resolve() + this.abandonedStartsRecovery ??= this.trackScopeOperation(() => this.recoverAbandonedStartsInScope()).finally(() => { + this.abandonedStartsRecovery = undefined + }) + return this.abandonedStartsRecovery + } + + private async recoverAbandonedStartsInScope(): Promise { + const generation = this.scopeGeneration + const isCurrent = () => !this.disposed && generation === this.scopeGeneration + if (!this.journal || !isCurrent()) return + const { journals, liveOwners } = this.journal.abandoned() + // After a reload the old extension host can still be exiting; look again shortly. + if (liveOwners) this.scheduleAbandonedStartsRecheck() + const account = this.options.getAccountScope?.() + const endpoint = this.options.cloudSessions.apiBaseUrl + let deleted = false + for (const journal of journals) { + const unresolved: PendingStartRecord[] = [] + for (const pending of journal.records) { + if (pending.account !== account || pending.endpoint !== endpoint || !isCurrent()) { + unresolved.push(pending) + continue + } + // A record is dropped before the first prompt is sent, so its sandbox holds no user work. + // Only a confirmed deletion settles it; anything else is retried later. + try { + await this.options.cloudSessions.deleteSession(pending.sessionId) + deleted = true + } catch (error) { + if (!(error instanceof CloudSessionError && error.code === "session_not_found")) { + Logger.warn(`[CloudSessions] Failed to delete abandoned cloud session ${pending.sessionId}:`, error) + unresolved.push(pending) + } + } + } + journal.settle(unresolved) + } + if (deleted && isCurrent()) { + this.listFetchedAt = 0 + this.options.invalidateHistoryCache() + void this.options.postStateToWebview().catch(() => {}) + } + } + + private scheduleAbandonedStartsRecheck(): void { + if (this.abandonedStartsRecheck) return + this.abandonedStartsRecheck = setTimeout(() => { + this.abandonedStartsRecheck = undefined + void this.recoverAbandonedStarts() + }, ABANDONED_STARTS_RECHECK_MS) + this.abandonedStartsRecheck.unref?.() + } + + private cancelAbandonedStartsRecheck(): void { + clearTimeout(this.abandonedStartsRecheck) + this.abandonedStartsRecheck = undefined + } + + /** Drops remembered statuses for sessions the account's list no longer contains. */ + private pruneRememberedStatuses(liveIds: ReadonlySet): void { + const statuses = this.rememberedStatuses() + const kept = Object.fromEntries(Object.entries(statuses).filter(([id]) => liveIds.has(id))) + if (Object.keys(kept).length !== Object.keys(statuses).length) { + this.options.stateManager.setGlobalState("cloudSessionStatuses", kept) + } + } + + getCurrentTaskInfo(): CurrentCloudTaskInfo | undefined { + const task = this.options.getTask() + const taskId = task?.taskId + if (!taskId || !isCloudSessionId(taskId)) { + return undefined + } + const entry = this.entries.get(taskId) + return { + sessionId: taskId, + repoUrl: entry?.record.repoContext.repoUrl, + branch: entry?.record.repoContext.branch, + // While the start is pending there is no session to send to yet; the + // composer disables submit on this status instead of queueing. + status: + this.pendingStart && task === this.pendingStartTask ? "provisioning" : entry ? this.statusOf(entry) : "unknown", + } + } + + private toHistoryRecord(entry: CloudSessionEntry): SessionHistoryRecord { + const { record } = entry + const status = this.statusOf(entry) + // The control plane's title wins so renames from the dashboard show up; the + // local one covers the moment before this window's own rename lands. + const title = record.title?.trim() || entry.title || "" + return { + sessionId: record.id, + source: "vscode", + pid: 0, + startedAt: record.createdAt, + endedAt: status === "expired" ? (record.expiredAt ?? undefined) : undefined, + exitCode: status === "completed" ? 0 : undefined, + status: HISTORY_STATUS[status], + interactive: true, + provider: "cline", + model: record.metadata.modelId ?? "", + cwd: CLOUD_WORKSPACE_ROOT, + workspaceRoot: CLOUD_WORKSPACE_ROOT, + enableTools: true, + enableSpawn: false, + enableTeams: false, + isSubagent: false, + prompt: title, + metadata: { + title: title || `Cloud session on ${record.repoContext.repoUrl ?? "GitHub"}`, + executionTarget: "cloud", + cloudStatus: status, + repoUrl: record.repoContext.repoUrl ?? "", + branch: record.repoContext.branch ?? "", + modelId: record.metadata.modelId ?? "", + ...(entry.usage + ? { + usageAvailable: true, + tokensIn: entry.usage.inputTokens, + tokensOut: entry.usage.outputTokens, + cacheReads: entry.usage.cacheReadTokens, + cacheWrites: entry.usage.cacheWriteTokens, + totalCost: entry.usage.totalCost, + } + : {}), + git: { url: record.repoContext.repoUrl, branch: record.repoContext.branch }, + }, + // Keep a task the user is actively working on at the top of History. + updatedAt: new Date(Math.max(Date.parse(record.updatedAt) || 0, entry.lastActivityAt)).toISOString(), + } + } + + /** History rows for every cloud session in the active account scope (cached briefly). */ + async listHistoryRecords(): Promise { + if (!this.isAvailable() || this.scopeTransition) { + return [] + } + await this.refreshList() + if (this.scopeTransition) return [] + const generation = this.scopeGeneration + // The pending start's own task row already stands for its sandbox. + const pendingStartSessionId = this.pendingStartSessionId + const entries = [...this.entries.values()].filter((entry) => entry.record.id !== pendingStartSessionId) + await this.refreshUsage(entries) + // A start that settled meanwhile has invalidated History; omitting its row now would be cached as current. + if (generation !== this.scopeGeneration || pendingStartSessionId !== this.pendingStartSessionId) { + return this.listHistoryRecords() + } + return entries.map((entry) => this.toHistoryRecord(entry)) + } + + async findHistoryRecord(sessionId: string): Promise { + if (!this.isAvailable() || this.scopeTransition) { + return undefined + } + let entry = this.entries.get(sessionId) + if (!entry) { + await this.refreshList(true) + entry = this.entries.get(sessionId) + } + if (!entry) { + return undefined + } + const generation = this.scopeGeneration + await this.refreshUsage([entry]) + if (generation !== this.scopeGeneration || this.entries.get(sessionId) !== entry) { + return this.findHistoryRecord(sessionId) + } + return this.toHistoryRecord(entry) + } + + /** Resolves live status only for cloud rows a caller is about to display. */ + async resolveStatuses(sessionIds: Iterable): Promise> { + await this.scopeTransition + const generation = this.scopeGeneration + const now = Date.now() + const visibleIds = [...new Set(sessionIds)].filter(isPersistedCloudSessionId).slice(0, 100) + let changed = false + const pendingIds = [...visibleIds] + const resolveNext = async (): Promise => { + for (let sessionId = pendingIds.shift(); sessionId; sessionId = pendingIds.shift()) { + const entry = this.entries.get(sessionId) + if (!entry || this.statusOf(entry) !== "unknown") continue + try { + if (entry.connection) { + await entry.connection + changed = true + continue + } + const lastAttempt = this.statusResolutionAttempts.get(sessionId) + if (lastAttempt !== undefined && now - lastAttempt < STATUS_RESOLUTION_RETRY_MS) continue + this.statusResolutionAttempts.set(sessionId, now) + const host = await this.connect(entry) + this.statusResolutionAttempts.delete(sessionId) + changed = true + if (!ACTIVE_CLOUD_STATUSES.has(this.statusOf(entry)) && !this.isHostInUse(entry)) { + await host.dispose("statusResolved").catch(() => undefined) + if (entry.host === host) entry.host = undefined + } + } catch (error) { + Logger.warn(`[CloudSessions] Failed to resolve status for ${sessionId}:`, error) + } + } + } + await Promise.allSettled( + Array.from({ length: Math.min(STATUS_RESOLUTION_CONCURRENCY, pendingIds.length) }, () => resolveNext()), + ) + if (changed && !this.disposed && generation === this.scopeGeneration) { + this.options.invalidateHistoryCache() + void this.options.postStateToWebview().catch(() => undefined) + } + if (this.disposed || generation !== this.scopeGeneration) return [] + return visibleIds.flatMap((sessionId) => { + const entry = this.entries.get(sessionId) + return entry ? [{ sessionId, status: this.statusOf(entry) }] : [] + }) + } + + /** Whether the displayed task or an openCloudTask in progress relies on this entry's host. */ + private isHostInUse(entry: CloudSessionEntry): boolean { + return entry.pinned > 0 || this.options.getTask()?.taskId === entry.record.id + } + + private async refreshUsage(entries: CloudSessionEntry[]): Promise { + await Promise.all( + entries.map(async (entry) => { + const host = entry.host + if (!host) { + return + } + try { + const usage = await withTimeout(host.getAccumulatedUsage(entry.record.id), USAGE_REFRESH_TIMEOUT_MS) + if (usage && !this.disposed && this.entries.get(entry.record.id) === entry && entry.host === host) { + entry.usage = usage + } + } catch (error) { + Logger.warn(`[CloudSessions] Failed to read usage for ${entry.record.id}:`, error) + } + }), + ) + } + + private async refreshList(force = false): Promise { + // The account mutation inside a scope transition posts state, which lists + // History and lands here. Waiting for the transition would deadlock it, and + // fetching would attribute old-account rows to the new scope. History + // omits cloud rows during the transition; reset refreshes them afterwards. + if (this.scopeTransition || this.disposed) return + if (!force && Date.now() - this.listFetchedAt < LIST_CACHE_TTL_MS) { + return + } + if (this.listPromise) { + return this.listPromise + } + const generation = this.scopeGeneration + let listPromise!: Promise + listPromise = (async () => { + try { + void this.recoverAbandonedStarts() + const records = await this.options.cloudSessions.listSessions() + if (generation !== this.scopeGeneration || this.disposed) { + return + } + const seen = new Set() + for (const record of records) { + seen.add(record.id) + this.upsertRecord(record) + } + // A record that left the account's list was deleted elsewhere (for + // example from the dashboard). Close its connection rather than keep + // it in History; the task on screen keeps its host so that opening it + // can explain the deletion (see explainConnectFailure). + for (const [id, entry] of this.entries) { + if (seen.has(id) || this.isHostInUse(entry)) continue + this.entries.delete(id) + this.statusResolutionAttempts.delete(id) + void entry.host?.dispose("deleted").catch(() => undefined) + } + this.pruneRememberedStatuses(seen) + this.listFetchedAt = Date.now() + } catch (error) { + // Reset invalidates both successful responses and failed requests. + if (generation !== this.scopeGeneration || this.disposed) return + if (error instanceof CloudSessionError && error.code === "authentication_required") { + // The account's credentials no longer work. Drop the rows and close + // the retained connections that nothing is using. The displayed task + // keeps its entry and host: closing them would leave a task on screen + // that cannot continue even after the user signs in again, and its + // next interaction reports the authentication failure itself. + for (const [id, entry] of this.entries) { + if (this.isHostInUse(entry)) continue + this.entries.delete(id) + this.statusResolutionAttempts.delete(id) + void entry.host?.dispose("authenticationRequired").catch(() => undefined) + } + } + // Keep the last snapshot; History still renders local tasks. + Logger.warn("[CloudSessions] Failed to refresh cloud session list:", error) + this.listFetchedAt = Date.now() + } finally { + if (this.listPromise === listPromise) { + this.listPromise = undefined + } + } + })() + this.listPromise = listPromise + return listPromise + } + + private upsertRecord(record: CloudSessionRecord): CloudSessionEntry { + const existing = this.entries.get(record.id) + if (existing) { + existing.record = record + // A retained host is not proof of continuous observation. Revalidate + // settled outcomes against the record just as we do after restart. + if (existing.agentStatus && isSettled(existing.agentStatus)) { + existing.agentStatus = this.rememberedStatusOf(record) + } + return existing + } + const entry: CloudSessionEntry = { + record, + pinned: 0, + agentStatus: this.rememberedStatusOf(record), + lastActivityAt: Date.parse(record.updatedAt) || Date.now(), + } + this.entries.set(record.id, entry) + return entry + } + + /** Changes account scope as one boundary: invalidate, detach, dispose, mutate scope, then reopen reads. */ + async reset(changeScope?: () => Promise): Promise { + // A provisioning start belongs to the old account. Stop its readiness poll + // so the drain below waits for its DELETE, not for the sandbox to boot. + this.cancelPendingStart() + this.scopeGeneration++ + this.statusResolutionAttempts.clear() + this.cancelAbandonedStartsRecheck() + const previousTransition = this.scopeTransition + const transition = (async () => { + await previousTransition?.catch(() => undefined) + this.listFetchedAt = 0 + this.listPromise = undefined + if (this.pollTimer) { + clearInterval(this.pollTimer) + this.pollTimer = undefined + } + if (this.options.getTask()?.taskId && isCloudSessionId(this.options.getTask()?.taskId)) { + await this.options.clearTask() + } + const entries = [...this.entries.values()] + const hosts = entries.flatMap((entry) => (entry.host ? [entry.host] : [])) + const connections = entries.flatMap((entry) => (entry.connection ? [entry.connection] : [])) + this.entries.clear() + this.options.invalidateHistoryCache() + // Starts and deletions own their cleanup until settlement. Keep the originating + // account active through every await, including DELETE; never retain old tokens. + void this.trackScopeOperation(() => + Promise.allSettled([...connections, ...hosts.map((host) => host.dispose("accountScopeChanged"))]), + ) + await this.drainScopeOperations([...this.scopeOperations]) + await changeScope?.() + })() + this.scopeTransition = transition + try { + await transition + } finally { + if (this.scopeTransition === transition) { + this.scopeTransition = undefined + // Refresh even after failure: the server may have switched accounts + // before a later auth/config refresh failed. + if (!this.disposed) { + this.options.invalidateHistoryCache() + void this.options.postStateToWebview().catch(() => {}) + } + } + } + } + + private trackScopeOperation(operation: () => Promise): Promise { + // Enroll before executing any code that can yield or re-enter reset. + const promise = Promise.resolve().then(operation) + this.scopeOperations.add(promise) + return promise.finally(() => this.scopeOperations.delete(promise)) + } + + private async drainScopeOperations(operations: Promise[]): Promise { + let timer: ReturnType | undefined + try { + await Promise.race([ + Promise.allSettled(operations), + new Promise((_resolve, reject) => { + timer = setTimeout( + () => + reject( + new Error( + "Cloud session cleanup is still pending. Your account has not changed; try again after cleanup finishes.", + ), + ), + SCOPE_DRAIN_TIMEOUT_MS, + ) + }), + ]) + } finally { + clearTimeout(timer) + } + } + + // ---- Connections ---- + + private async connect(entry: CloudSessionEntry): Promise { + if (entry.connection) { + return entry.connection + } + if (entry.host && this.statusOf(entry) !== "unknown") return entry.host + const sessionId = entry.record.id + const taskId = entry.record.metadata.taskId?.trim() + if (!taskId) { + throw new Error(`Cloud session ${sessionId} has no canonical task id.`) + } + const generation = this.scopeGeneration + const record = entry.record + const connection = (async () => { + const retainedHost = entry.host + const host = + retainedHost ?? + (await CloudSessionHost.connect({ + outerSessionId: sessionId, + taskId, + sandboxType: record.sandboxType ?? record.metadata.sandboxType, + socketUrl: this.options.cloudSessions.sessionSocketUrl(sessionId), + getAuthToken: this.options.getAuthToken, + requestToolApproval: this.options.requestToolApproval, + telemetry: this.options.telemetry, + restoreConfig: () => this.cloudSessionConfig(record.metadata.modelId ?? this.nextCloudModelId()), + onStatusChange: (status) => { + // A replaced account entry must never receive its predecessor's events. + if (!this.disposed && generation === this.scopeGeneration && this.entries.get(sessionId) === entry) { + this.handleStatusChange(sessionId, status) + } + }, + })) + const status = retainedHost ? await retainedHost.refreshStatus() : host.status + if (this.disposed || generation !== this.scopeGeneration || this.entries.get(sessionId) !== entry) { + await host.dispose("accountScopeChanged").catch(() => undefined) + throw new Error("Cloud session connection was superseded") + } + entry.host = host + // A later list may invalidate the snapshot while the RPC is pending. + if (entry.record === record) { + entry.agentStatus = status + if (isSettled(status)) this.rememberStatus(record, status, Date.now()) + else if (ACTIVE_CLOUD_STATUSES.has(status)) this.forgetStatus(record.id) + } + this.ensurePolling() + return host + })() + entry.connection = connection + try { + return await connection + } finally { + if (entry.connection === connection) entry.connection = undefined + } + } + + private handleStatusChange(sessionId: string, status: CloudSessionStatus): void { + const entry = this.entries.get(sessionId) + if (!entry) { + return + } + const previous = entry.agentStatus + const now = Date.now() + entry.agentStatus = status + if (isSettled(status)) { + this.rememberStatus(entry.record, status, now) + } else if (ACTIVE_CLOUD_STATUSES.has(status)) { + this.forgetStatus(entry.record.id) + } + // Learning the current status on connect is not agent activity; only a + // change observed while connected moves the task up in History. + if (previous !== undefined && previous !== status) { + entry.lastActivityAt = now + } + this.options.invalidateHistoryCache() + const isDisplayed = this.options.getTask()?.taskId === sessionId + if (!isDisplayed && previous !== undefined && previous !== status && (status === "completed" || status === "failed")) { + this.notifyFinished(entry, status) + } + this.options.postStateToWebview().catch(() => {}) + } + + private notifyFinished(entry: CloudSessionEntry, status: CloudSessionStatus): void { + const title = entry.record.title?.trim() || entry.title || entry.record.repoContext.repoUrl || entry.record.id + const label = status === "failed" ? "Cloud task failed" : "Cloud task finished" + HostProvider.window + .showMessage({ + type: status === "failed" ? ShowMessageType.WARNING : ShowMessageType.INFORMATION, + message: `${label}: ${title}`, + options: { items: ["Open"] }, + }) + .then((response) => { + if (!this.disposed && this.entries.get(entry.record.id) === entry && response.selectedOption === "Open") { + return this.openCloudTask(entry.record.id) + } + return undefined + }) + .catch((error) => Logger.warn("[CloudSessions] Failed to show completion notification:", error)) + } + + private ensurePolling(): void { + if (this.pollTimer || this.disposed) { + return + } + this.pollTimer = setInterval(() => void this.pollTick(), ACTIVE_POLL_INTERVAL_MS) + this.pollTimer.unref?.() + } + + private async pollTick(): Promise { + if (this.disposed) { + return + } + const now = Date.now() + for (const entry of this.entries.values()) { + const status = this.statusOf(entry) + if ( + entry.host && + !this.isHostInUse(entry) && + !ACTIVE_CLOUD_STATUSES.has(status) && + // Loss of observation is not evidence that the sandbox stopped. + status !== "unknown" && + now - entry.lastActivityAt > IDLE_CONNECTION_TTL_MS + ) { + // Finished a while ago and nobody is looking: release the socket. + await entry.host.dispose("idle").catch(() => undefined) + entry.host = undefined + } + } + const hasActive = [...this.entries.values()].some((entry) => ACTIVE_CLOUD_STATUSES.has(this.statusOf(entry))) + if (hasActive || this.entries.size > 0) { + await this.refreshList(true) + this.options.invalidateHistoryCache() + this.options.postStateToWebview().catch(() => {}) + } + if (![...this.entries.values()].some((entry) => entry.host)) { + clearInterval(this.pollTimer) + this.pollTimer = undefined + } + } + + // ---- Starting a task ---- + + /** + * The Cline model a new sandbox runs: the user's Act-mode Cline model, + * else the top recommendation known so far. Synchronous, and the only + * source for both the composer label and the start path, so the sandbox + * runs the model the user saw when they submitted. + */ + private nextCloudModelId(): string { + const apiConfig = this.options.stateManager.getApiConfiguration() + if (apiConfig.actModeApiProvider === "cline" && apiConfig.actModeClineModelId?.trim()) { + return apiConfig.actModeClineModelId.trim() + } + this.warmRecommendedModels() + const recommended = getCachedClineRecommendedModels() ?? CLINE_RECOMMENDED_MODELS_FALLBACK + return recommended.recommended[0]?.id ?? CLINE_RECOMMENDED_MODELS_FALLBACK.recommended[0].id + } + + /** + * Fetches the recommendation list so the composer label moves from the + * built-in fallback to the live recommendation before the user submits. + * State is re-posted only when the list lands, because that post reads + * the model again and would otherwise start the next fetch. A fetch that + * leaves the cache empty waits briefly before another state post can retry. + */ + private warmRecommendedModels(): void { + if (this.recommendedModelsWarmup || getCachedClineRecommendedModels() || Date.now() < this.recommendedModelsRetryAt) { + return + } + const warmup = refreshClineRecommendedModels() + .then(() => { + if (!this.disposed && getCachedClineRecommendedModels()) { + this.options.postStateToWebview().catch(() => {}) + } + }) + .catch(() => undefined) + .finally(() => { + if (this.recommendedModelsWarmup === warmup) { + if (!getCachedClineRecommendedModels()) + this.recommendedModelsRetryAt = Date.now() + RECOMMENDED_MODELS_RETRY_MS + this.recommendedModelsWarmup = undefined + } + }) + this.recommendedModelsWarmup = warmup + } + + /** The model the displayed cloud task runs on, else the one a new cloud task would use. */ + getCloudModelId(): string { + const taskId = this.options.getTask()?.taskId + const entry = taskId ? this.entries.get(taskId) : undefined + return entry?.host?.sessionModelId ?? entry?.record.metadata.modelId ?? this.nextCloudModelId() + } + + /** + * Claims the next cloud start synchronously and returns the function that + * runs it. Callers that must await other work first (remote config) claim + * before awaiting, so a Cancel arriving in that gap invalidates this start. + */ + beginCloudTask(input: CloudTaskInput): () => Promise { + const generationBeforeTransition = this.scopeGeneration + const startGeneration = ++this.startGeneration + const pendingStart = new AbortController() + this.pendingStart = pendingStart + this.pendingStartTask = undefined + this.renewPendingStartView = undefined + // Snapshot the model now, before any await: it is what the composer was + // showing when the user submitted. + const modelId = this.nextCloudModelId() + return async () => { + try { + await this.scopeTransition + if ( + this.disposed || + generationBeforeTransition !== this.scopeGeneration || + startGeneration !== this.startGeneration + ) + return undefined + return await this.trackScopeOperation(() => + this.startCloudTaskInScope(input, modelId, generationBeforeTransition, startGeneration, pendingStart.signal), + ) + } finally { + if (this.pendingStart === pendingStart) { + this.pendingStart = undefined + this.pendingStartSessionId = undefined + this.pendingStartTask = undefined + this.renewPendingStartView = undefined + // History omitted this start's record while it was pending. + this.options.invalidateHistoryCache() + // The failure path posts state while the start is still pending; + // re-post so the composer no longer sees "provisioning". + this.options.postStateToWebview().catch(() => {}) + } + } + } + } + + /** + * Invalidates cloud provisioning before an active SDK session exists and + * stops the readiness poll, so the sandbox record is deleted as soon as the + * control plane answers instead of after the sandbox finishes starting. + */ + cancelPendingStart(): boolean { + const pendingStart = this.pendingStart + if (!pendingStart) { + return false + } + this.pendingStart = undefined + this.pendingStartSessionId = undefined + this.pendingStartTask = undefined + this.renewPendingStartView = undefined + this.startGeneration++ + pendingStart.abort(new Error("Cloud task cancelled while provisioning")) + return true + } + + /** + * Cancels the pending start when `displayedTask` is its view, or when no task + * is shown yet because the start has not installed one. A Cancel on any other + * task belongs to that task, even while a start the user left is still settling. + */ + cancelPendingStartFor(displayedTask: TaskProxy | undefined): boolean { + if (displayedTask && displayedTask !== this.pendingStartTask) return false + return this.cancelPendingStart() + } + + /** The sandbox session config a start uses, and that a resumed sandbox rebuilds its conversation with. */ + private async cloudSessionConfig(modelId: string): Promise { + const { + apiKey: _apiKey, + knownModels: _knownModels, + providerConfig, + ...config + } = await this.options.sessionConfigBuilder.build({ + cwd: CLOUD_WORKSPACE_ROOT, + workspaceRoot: CLOUD_WORKSPACE_ROOT, + mode: CLOUD_SESSION_MODE, + runtime: { + modelSelection: { providerId: "cline", modelId }, + platform: "linux", + }, + }) + return { + ...config, + // The sandbox bills inference server-side, so the user's account token + // (the Cline provider key) must never be shipped into it. It resolves + // Cline models itself, so the local catalog (~150 KB) stays home too. + ...(providerConfig ? { providerConfig: { ...providerConfig, apiKey: undefined, knownModels: undefined } } : {}), + cwd: CLOUD_WORKSPACE_ROOT, + workspaceRoot: CLOUD_WORKSPACE_ROOT, + mode: CLOUD_SESSION_MODE, + enableTools: true, + checkpoint: { enabled: false }, + enableSpawnAgent: false, + enableAgentTeams: false, + } + } + + private async startCloudTaskInScope( + input: CloudTaskInput, + modelId: string, + generation: number, + startGeneration: number, + cancelSignal: AbortSignal, + ): Promise { + if (this.disposed || generation !== this.scopeGeneration || startGeneration !== this.startGeneration) return undefined + // Keep the claim clearTask made: claiming again after the await would + // override a selection the user made while the view was clearing. + let isSuperseded = await this.options.clearTask() + if (this.disposed || isSuperseded() || generation !== this.scopeGeneration || startGeneration !== this.startGeneration) + return undefined + let abandoned = false + const isStale = () => { + abandoned ||= + this.disposed || isSuperseded() || generation !== this.scopeGeneration || startGeneration !== this.startGeneration + return abandoned + } + const startedAt = Date.now() + const provisionalId = `${CLOUD_PROVISIONING_ID_PREFIX}${startedAt}` + const task = this.installTask(provisionalId) + this.pendingStartTask = task + this.renewPendingStartView = () => { + if (abandoned) return false + isSuperseded = this.options.claimTaskViewGeneration() + return true + } + const title = input.prompt.trim().split("\n")[0]?.trim().slice(0, 120) || input.prompt.trim() + const repoLabel = input.repoUrl.replace(/^https:\/\/github\.com\//, "") + const sandboxLabel = `${repoLabel}${input.branch ? ` (${input.branch})` : ""}` + const provisioningRow = (text: string): ClineMessage => ({ + ts: startedAt + 1, + type: "say", + say: "text", + text, + partial: false, + }) + const provisioningEvent = { type: "status", payload: { sessionId: provisionalId, status: "running" } } as const + + this.options.messages.appendAndEmit( + [ + { + ts: startedAt, + type: "say", + say: "task", + text: input.prompt, + ...(input.images?.length ? { images: input.images } : {}), + partial: false, + }, + provisioningRow(`Starting a cloud sandbox for ${sandboxLabel}…`), + ], + provisioningEvent, + ) + this.options.clearTurnOutcome() + this.options.setTurnPhase("streaming") + this.options.postStateToWebview().catch(() => {}) + + let sessionId: string | undefined + let entry: CloudSessionEntry | undefined + let host: SdkSessionHost | undefined + let sent = false + try { + const config = await this.cloudSessionConfig(modelId) + if (isStale()) return undefined + let shownPhase: CloudProvisioningPhase | undefined + const record = await this.options.cloudSessions.createSession( + { modelId, repoUrl: input.repoUrl, branch: input.branch }, + (id) => { + sessionId = id + if (startGeneration === this.startGeneration) this.pendingStartSessionId = id + this.rememberPendingStart(id) + }, + cancelSignal, + (phase) => { + const text = phase === shownPhase ? undefined : provisioningPhaseText(phase, sandboxLabel) + if (!text || isStale()) return + shownPhase = phase + // Same ts: the progress row is updated in place, not appended. + this.options.messages.appendAndEmit([provisioningRow(text)], provisioningEvent) + }, + ) + sessionId = record.id + if (isStale()) return sessionId + entry = this.upsertRecord(record) + entry.title = title + this.options.invalidateHistoryCache() + // The task id becomes the outer session id once provisioning succeeds. + task.taskId = record.id + await this.options.cloudSessions.renameSession(record.id, title).catch(() => undefined) + if (isStale()) return sessionId + const resolvedPrompt = await this.options.resolveContextMentions(input.prompt) + if (isStale()) return sessionId + + host = await this.connect(entry) + if (isStale()) return sessionId + const startInput: StartSessionInput = { + config: { ...config, sessionId: record.id }, + interactive: true, + prompt: undefined, + userImages: input.images, + sessionMetadata: { title, modelId, executionTarget: "cloud", repoUrl: input.repoUrl, branch: input.branch }, + } + const { sdkHost } = await this.options.sessions.startNewSession(startInput, host, () => !isStale()) + if (isStale()) return sessionId + this.options.postStateToWebview().catch(() => {}) + // From here the sandbox may hold the user's work, so recovery must never delete it. + // Throws if the record cannot be dropped; the start then fails and deletes the sandbox. + this.journal?.remove(record.id) + this.options.sessions.fireAndForgetSend(sdkHost, record.id, resolvedPrompt, input.images) + sent = true + Logger.log(`[CloudSessions] Cloud task started: ${record.id}`) + return record.id + } catch (error) { + if (isStale()) return sessionId + Logger.error("[CloudSessions] Failed to start cloud task:", error) + const unconfirmed = error instanceof CloudCreationUnconfirmedError + const text = unconfirmed + ? `Cloud session creation could not be confirmed. Check History or the cloud dashboard (${this.options.cloudSessions.appBaseUrl}/agents) before starting another task; the first session may already exist.` + : `Failed to start the cloud session: ${error instanceof Error ? error.message : String(error)}` + const failedAt = Math.max(Date.now(), startedAt + 2) + const messages: ClineMessage[] = [{ ts: failedAt, type: "say", say: "error", text, partial: false }] + if (unconfirmed) { + messages.push({ + ts: failedAt + 1, + type: "ask", + ask: "api_req_failed", + text: new ClineError({ message: text, code: CLOUD_CREATION_UNCONFIRMED_ERROR_CODE }).serialize(), + partial: false, + }) + } + this.options.messages.appendAndEmit(messages, { + type: "status", + payload: { sessionId: sessionId ?? provisionalId, status: "error" }, + }) + if (unconfirmed) this.options.setTurnPhase("error", failedAt + 1) + else { + this.options.onStartFailed(task, input) + this.options.setTurnPhase("error") + } + await this.options.postStateToWebview().catch(() => {}) + return undefined + } finally { + if (sessionId && !sent) await this.cleanupUnusedSession(sessionId, entry, host) + } + } + + private async cleanupUnusedSession(sessionId: string, entry?: CloudSessionEntry, host?: SdkSessionHost): Promise { + // A list during provisioning may have added this record before the start + // held an entry for it (a cancelled create never reaches upsertRecord), so + // drop whatever entry carries this id unless a successor replaced it. + const listed = this.entries.get(sessionId) + if (listed && (!entry || listed === entry)) { + this.entries.delete(sessionId) + } + let failed = false + if (host) { + await this.options.sessions.endActiveSessionIfHost(host, "cloudStartSuperseded").catch(() => { + failed = true + }) + await host.dispose("cloudStartSuperseded").catch(() => { + failed = true + }) + } + await this.options.cloudSessions.deleteSession(sessionId).then( + () => this.forgetPendingStart(sessionId), + (error: unknown) => { + if (error instanceof CloudSessionError && error.code === "session_not_found") { + this.forgetPendingStart(sessionId) + return + } + failed = true + }, + ) + // The pending start's own invalidation is skipped when it was cancelled, + // so History learns here that the record is gone. + this.options.invalidateHistoryCache() + this.options.postStateToWebview().catch(() => {}) + if (failed) { + // Do not log response bodies or credentials, or retry with another account. + const message = + "Cloud sandbox cleanup could not be confirmed. Open History in the account that started it and delete the unused cloud session." + Logger.warn(`[CloudSessions] ${message}`) + void HostProvider.window.showMessage({ type: ShowMessageType.WARNING, message }).catch(() => { + Logger.warn("[CloudSessions] Could not display the cloud cleanup warning.") + }) + } + } + + // ---- Reopening a task from History ---- + + async openCloudTask(sessionId: string): Promise { + const displayed = this.options.getTask() + if ( + displayed && + displayed === this.pendingStartTask && + (sessionId === displayed.taskId || sessionId === this.pendingStartSessionId) && + this.renewPendingStartView?.() + ) { + // Back on the start that is already on screen: it keeps the view, and any + // selection still loading since is rejected instead of replacing it. + return { + id: sessionId, + ts: Date.now(), + task: "", + tokensIn: 0, + tokensOut: 0, + totalCost: 0, + executionTarget: "cloud", + cloudStatus: "provisioning", + } + } + const lookupWasSuperseded = this.options.claimTaskViewGeneration() + const generationBeforeTransition = this.scopeGeneration + await this.scopeTransition + if (generationBeforeTransition !== this.scopeGeneration) return undefined + const lookupGeneration = generationBeforeTransition + const record = await this.findHistoryRecord(sessionId) + if (lookupWasSuperseded() || lookupGeneration !== this.scopeGeneration) return undefined + if (!record) { + Logger.error(`[CloudSessions] Cloud session not found: ${sessionId}`) + return undefined + } + const entry = this.entries.get(sessionId) + if (!entry) { + return undefined + } + const historyItem = sessionHistoryRecordToHistoryItem(record) + + // Keep the claim clearTask made: claiming again after the await would + // override a selection the user made while the view was clearing. + const isSuperseded = await this.options.clearTask() + const generation = this.scopeGeneration + const isStale = () => + this.disposed || isSuperseded() || generation !== this.scopeGeneration || this.entries.get(sessionId) !== entry + if (isStale()) return historyItem + + // Pin the host so a concurrent status resolution or idle sweep does not + // close it between connecting and installing the task that owns it. + entry.pinned++ + // The resume notice is posted as its own render. Whatever replaces it must + // start a new epoch right before it is installed, with no await between, or + // the webview merges it into the notice instead of replacing it. + let showedResumeNotice = false + const installReplacingNotice = () => { + if (showedResumeNotice) this.options.resetMessageTranslator() + return this.installTask(sessionId) + } + try { + this.options.resetMessageTranslator() + const status = this.statusOf(entry) + let messages: ClineMessage[] = [] + let attachedRunning = false + let observedStatus = status + if (status === "expired") { + messages = await this.renderExpired(entry) + if (isStale()) return historyItem + } else if (entry.record.status?.toLowerCase() === "failed" && !entry.host) { + // Only the control plane's record says the sandbox failed; a remembered + // "failed" is the last agent turn's outcome, and that conversation can continue. + messages.push({ + ts: Date.now(), + type: "say", + say: "error", + text: `The cloud sandbox failed to start${entry.record.metadata.statusReason ? `: ${entry.record.metadata.statusReason}` : "."}`, + partial: false, + }) + } else { + let host = await this.connectUnlessSuspended(entry) + if (!host) { + if (isStale()) return historyItem + showedResumeNotice = true + await this.resume(entry, historyItem.task) + if (isStale()) return historyItem + host = await this.connect(entry) + } + if (isStale()) { + return historyItem + } + const transcript = (await host.readMessages(sessionId)) as SdkMessage[] + if (isStale()) return historyItem + attachedRunning = host.status === "running" + messages = this.renderTranscript(transcript, host.status === "completed") + await this.options.sessions.attachExistingSession({ + sdkHost: host, + sessionId, + startConfig: { providerId: "cline", modelId: host.sessionModelId ?? record.model ?? "" }, + isRunning: attachedRunning, + shouldContinue: () => !isStale(), + }) + observedStatus = host.status + attachedRunning = observedStatus === "running" + } + if (isStale()) { + return historyItem + } + + const finalized = this.options.messages.finalizeMessagesForSave(messages) + if (!attachedRunning && observedStatus !== "expired" && observedStatus !== "failed" && finalized.length > 0) { + finalized.push({ + ts: Date.now(), + type: "ask", + ask: observedStatus === "completed" ? "resume_completed_task" : "resume_task", + text: "", + }) + } + const task = installReplacingNotice() + if (finalized.length > 0) { + task.messageStateHandler.addMessages(finalized) + } + entry.lastActivityAt = Date.now() + if (attachedRunning) { + this.options.setTurnPhase("streaming") + } else if (observedStatus === "completed") { + this.options.setTurnPhase("completed", finalized.at(-1)?.ts) + } else { + this.options.setTurnPhase("idle") + } + await this.options.postStateToWebview() + Logger.log(`[CloudSessions] Showing cloud task ${sessionId} (${status})`) + } catch (error) { + // The task-view claim guards error rendering as well as successful attachment. + if (isStale()) return historyItem + Logger.error("[CloudSessions] Failed to open cloud task:", error) + const { messages, deleted } = await this.explainConnectFailure(entry, error) + if (isStale()) return historyItem + const task = installReplacingNotice() + task.messageStateHandler.addMessages(messages) + this.options.setTurnPhase("idle") + if (deleted) { + // A retained host from an earlier observation has nothing left to watch. + const retained = entry.host + entry.host = undefined + this.entries.delete(sessionId) + this.options.invalidateHistoryCache() + await retained?.dispose("deleted").catch(() => undefined) + } + await this.options.postStateToWebview().catch(() => {}) + } finally { + entry.pinned-- + } + return historyItem + } + + /** + * Connects to a session's sandbox, or returns undefined when the control + * plane has suspended it. The listed record may predate the suspension, so + * a refused connection asks the control plane for the live status. + */ + private async connectUnlessSuspended(entry: CloudSessionEntry): Promise { + if (entry.record.status?.toLowerCase() === "suspended") return undefined + try { + return await this.connect(entry) + } catch (error) { + const live = await this.options.cloudSessions.getStatus(entry.record.id).catch(() => undefined) + if (live?.status?.toLowerCase() === "suspended") return undefined + throw error + } + } + + /** + * Wakes a suspended sandbox so it accepts connections again. The task view + * shows a notice meanwhile, and the composer stays disabled because the + * session reads as provisioning until the sandbox is back. + */ + private async resume(entry: CloudSessionEntry, title: string): Promise { + const suspended = entry.record + const resuming = { ...suspended, status: "provisioning" } + entry.record = resuming + const startedAt = Date.now() + this.installTask(suspended.id).messageStateHandler.addMessages([ + { ts: startedAt, type: "say", say: "task", text: title, partial: false }, + { ts: startedAt + 1, type: "say", say: "text", text: "Resuming the cloud sandbox…", partial: false }, + ]) + await this.options.postStateToWebview() + // Opened from History, the chat view (and this notice) stays covered until + // the open completes, which is after the whole resume. Show it now. + await this.options.showChatView() + try { + await this.options.cloudSessions.resumeSession(suspended.id) + } catch (error) { + if (entry.record === resuming) entry.record = suspended + throw error + } + entry.record = { ...entry.record, status: "ready" } + // A host kept from before the suspension still believes the conversation's + // runtime is live; the resumed Hub has none, so reconnect from scratch. + const retained = entry.host + entry.host = undefined + await retained?.dispose("resumed").catch(() => undefined) + } + + /** + * The archived conversation of a retired sandbox, followed by a notice. The + * control plane archives a transcript only when a viewer disconnects from a + * live sandbox, so an old or never-viewed session may have none; the notice + * tells the cases apart instead of showing an empty error. An archive with + * no messages is treated as none, so the notice never points at rows that + * are not there. + */ + private async renderExpired(entry: CloudSessionEntry): Promise { + let archived: SdkMessage[] | undefined + let archiveError: string | undefined + try { + const history = await this.options.cloudSessions.getHistory(entry.record.id) + archived = history?.length ? (history as SdkMessage[]) : undefined + } catch (error) { + archiveError = error instanceof Error ? error.message : String(error) + } + // Retirement says nothing about whether the last turn finished, so the + // final response stays a plain text row rather than a completion box. + const messages = archived ? this.renderTranscript(archived, false) : [] + messages.push({ + ts: Date.now(), + type: "say", + say: "info", + text: describeExpiredCloudSession(entry.record, archived, archiveError), + partial: false, + }) + return messages + } + + /** + * Explains a failed sandbox connection. The Hub client reports the proxy's + * refusal only as "Unexpected server response", so ask the control plane + * why: a session deleted elsewhere (for example from the dashboard) is + * reported as `deleted` so the caller drops it from History; one retired + * since the list was fetched is shown as expired. Retirement arrives either + * as a 410 from `/status` or as a 200 whose status is `expired`. + */ + private async explainConnectFailure( + entry: CloudSessionEntry, + error: unknown, + ): Promise<{ messages: ClineMessage[]; deleted: boolean }> { + const probe = await this.options.cloudSessions.getStatus(entry.record.id).then( + (status) => (status.status?.toLowerCase() === "expired" ? ("session_expired" as const) : undefined), + (probeError: unknown) => (probeError instanceof CloudSessionError ? probeError.code : undefined), + ) + if (probe === "session_expired") { + return { messages: await this.renderExpired(entry), deleted: false } + } + const notice = (say: "info" | "error", text: string): ClineMessage[] => [ + { ts: Date.now(), type: "say", say, text, partial: false }, + ] + if (probe === "session_not_found") { + return { + messages: notice( + "info", + "This cloud session was deleted, so it can no longer be opened. It has been removed from History.", + ), + deleted: true, + } + } + return { + messages: notice( + "error", + `Could not connect to this cloud session: ${error instanceof Error ? error.message : String(error)}`, + ), + deleted: false, + } + } + + private renderTranscript(messages: SdkMessage[], finalTurnCompleted: boolean): ClineMessage[] { + return sdkMessagesToDisplayClineMessages(messages, this.options.getMinter(), { + finalTurnCompleted, + cwd: CLOUD_WORKSPACE_ROOT, + }) + } + + private installTask(taskId: string): TaskProxy { + const task = createTaskProxy( + taskId, + (text, images, files) => this.options.onAskResponse(text, images, files), + () => this.options.onCancelTask(), + ) + this.options.setTask(task) + return task + } + + // ---- Deletion / disposal ---- + + async deleteSession(sessionId: string): Promise { + const generation = this.scopeGeneration + await this.scopeTransition + const assertCurrentScope = () => { + if (this.disposed || generation !== this.scopeGeneration) { + throw new Error("Cloud session deletion was cancelled because its account scope changed.") + } + } + assertCurrentScope() + return this.trackScopeOperation(async () => { + assertCurrentScope() + const entry = this.entries.get(sessionId) + if (entry?.host) { + // A disposed host no longer reports status; if the DELETE fails and the + // task is reopened, it must get a fresh connection rather than this one. + const host = entry.host + entry.host = undefined + await host.dispose("deleted").catch(() => undefined) + } + await this.options.cloudSessions.deleteSession(sessionId) + if (entry && this.entries.get(sessionId) === entry) this.entries.delete(sessionId) + }) + } + + async dispose(): Promise { + this.disposed = true + this.cancelAbandonedStartsRecheck() + if (this.pollTimer) { + clearInterval(this.pollTimer) + this.pollTimer = undefined + } + const hosts = [...this.entries.values()].flatMap((entry) => (entry.host ? [entry.host] : [])) + this.entries.clear() + await this.drainScopeOperations([ + ...this.scopeOperations, + ...hosts.map((host) => host.dispose("controllerDispose")), + ]).catch(() => { + // The controller must still dispose its other services. In-flight starts + // remain enrolled and clean their sandbox if its id arrives after this wait. + Logger.warn("[CloudSessions] Cloud cleanup is still pending during controller disposal.") + }) + } +} diff --git a/apps/vscode/src/sdk/sdk-cloud-session-scope.test.ts b/apps/vscode/src/sdk/sdk-cloud-session-scope.test.ts new file mode 100644 index 0000000000..42b606fbb7 --- /dev/null +++ b/apps/vscode/src/sdk/sdk-cloud-session-scope.test.ts @@ -0,0 +1,526 @@ +import { afterEach, describe, expect, it, vi } from "vitest" +import { HostProvider } from "@/hosts/host-provider" +import { CloudSessionsService } from "@/services/cloud/CloudSessionsService" +import { CloudSessionHost } from "./cloud-session-host" +import { MessageIdMinter } from "./message-id-minter" +import { SdkCloudSessionCoordinator, type SdkCloudSessionCoordinatorOptions } from "./sdk-cloud-session-coordinator" +import { SdkSessionLifecycle } from "./sdk-session-lifecycle" +import type { TaskProxy } from "./task-proxy" + +vi.mock("@/hosts/host-provider", () => ({ HostProvider: { window: { showMessage: vi.fn(async () => ({})) } } })) +vi.mock("@/core/storage/StateManager", () => ({ StateManager: { get: () => ({ getGlobalSettingsKey: () => undefined }) } })) + +function deferred() { + let resolve!: (value: T) => void + let reject!: (error: unknown) => void + const promise = new Promise((done, fail) => { + resolve = done + reject = fail + }) + return { promise, resolve, reject } +} + +type WaitPoint = "create" | "readiness" | "connect" | "lifecycle" | "prompt" | "delete" +function fixture(waitPoint: WaitPoint, organizationId?: string) { + const barrier = deferred() + const entered = deferred() + let scope = { token: "fixture-origin", endpoint: "http://127.0.0.1:1111", organizationId } + const origin = scope + const successor = { token: "fixture-successor", endpoint: "http://127.0.0.1:2222", organizationId: "org-successor" } + let deletionStatus = 204 + let readinessStatus = "active" + const deleted = new Set() + const requests: Array<{ method: string; url: string; token: string | null; body?: string }> = [] + const record = { + id: "ses-owned", + status: "active", + repoContext: {}, + metadata: { taskId: "tsk-owned" }, + createdAt: new Date(0).toISOString(), + updatedAt: new Date(0).toISOString(), + } + const currentRecord = () => + scope === origin ? record : { ...record, id: "ses-successor", metadata: { ...record.metadata, taskId: "tsk-successor" } } + const wait = async (point: WaitPoint) => { + if (waitPoint === point) { + entered.resolve() + await barrier.promise + } + } + const service = new CloudSessionsService({ + get apiBaseUrl() { + return scope.endpoint + }, + appBaseUrl: "http://127.0.0.1:1111", + getAuthToken: async () => scope.token, + getActiveOrganizationId: () => scope.organizationId, + fetch: (async (input: RequestInfo | URL, init?: RequestInit) => { + const url = String(input) + const method = init?.method ?? "GET" + requests.push({ + method, + url, + token: new Headers(init?.headers).get("Authorization"), + body: typeof init?.body === "string" ? init.body : undefined, + }) + if (method === "POST") { + await wait("create") + return Response.json({ + data: { + sessionId: currentRecord().id, + status: waitPoint === "readiness" ? "provisioning" : "active", + sandboxUrl: origin.endpoint, + }, + }) + } + if (method === "DELETE") { + await wait("delete") + if (deletionStatus === 204) { + deleted.add(decodeURIComponent(url.slice(url.lastIndexOf("/") + 1))) + return new Response(null, { status: 204 }) + } + return Response.json({ error: "untrusted private server detail" }, { status: deletionStatus }) + } + if (url.endsWith("/status")) { + await wait("readiness") + return Response.json({ data: { status: readinessStatus } }) + } + if (method === "PATCH") return Response.json({ data: record }) + return Response.json({ data: [currentRecord()].filter((listed) => !deleted.has(listed.id)) }) + }) as typeof fetch, + }) + const host = { + start: vi.fn(async () => { + await wait("lifecycle") + return { sessionId: record.id } + }), + stop: async () => {}, + dispose: vi.fn(async () => {}), + subscribe: () => () => {}, + send: vi.fn(async () => {}), + } as unknown as CloudSessionHost + vi.spyOn(CloudSessionHost, "connect").mockImplementation(async () => { + await wait("connect") + return host + }) + const lifecycle = new SdkSessionLifecycle({ + mcpHub: {} as never, + requestToolApproval: vi.fn(), + askQuestion: vi.fn(), + onSessionEvent: vi.fn(), + onSendComplete: vi.fn(), + onSendError: vi.fn(), + }) + let task: TaskProxy | undefined + let viewGeneration = 0 + const options = { + cloudSessions: service, + stateManager: { + getApiConfiguration: () => ({ actModeApiProvider: "cline", actModeClineModelId: "fixture-model" }), + getGlobalSettingsKey: () => "act", + getGlobalStateKey: () => ({}), + setGlobalState: vi.fn(), + }, + sessionConfigBuilder: { + build: vi.fn(async () => ({ + providerId: "cline", + modelId: "fixture-model", + apiKey: "fixture-key", + providerConfig: { + providerId: "cline", + modelId: "fixture-model", + apiKey: "fixture-key", + knownModels: { "catalog-model": { id: "catalog-model" } }, + }, + knownModels: { "catalog-model": { id: "catalog-model" } }, + cwd: "/workspace", + workspaceRoot: "/workspace", + systemPrompt: "normal Cline guidance", + enableTools: true, + enableSpawnAgent: false, + enableAgentTeams: false, + })), + }, + sessions: lifecycle, + messages: { appendAndEmit: vi.fn(), finalizeMessagesForSave: (messages: unknown) => messages }, + getMinter: () => new MessageIdMinter(), + getTask: () => task, + setTask: (next: TaskProxy | undefined) => { + task = next + }, + onAskResponse: vi.fn(), + onCancelTask: vi.fn(), + clearTask: async () => { + task = undefined + const claimed = ++viewGeneration + return () => claimed !== viewGeneration + }, + onStartFailed: vi.fn(), + claimTaskViewGeneration: () => { + const claim = ++viewGeneration + return () => claim !== viewGeneration + }, + requestToolApproval: vi.fn(), + getAuthToken: async () => scope.token, + isSignedIn: () => true, + isEnabled: () => true, + resetMessageTranslator: vi.fn(), + setTurnPhase: vi.fn(), + clearTurnOutcome: vi.fn(), + postStateToWebview: vi.fn(async () => {}), + showChatView: vi.fn(async () => {}), + invalidateHistoryCache: vi.fn(), + resolveContextMentions: async (prompt: string) => { + await wait("prompt") + return prompt + }, + } satisfies { [Key in keyof SdkCloudSessionCoordinatorOptions]: unknown } + const coordinator = new SdkCloudSessionCoordinator(options as unknown as SdkCloudSessionCoordinatorOptions) + const changeScope = vi.fn(async () => { + scope = successor + }) + return { + coordinator, + service, + host, + lifecycle, + options, + requests, + origin, + successor, + record, + entered, + barrier, + changeScope, + get scope() { + return scope + }, + setDeletionStatus: (status: number) => { + deletionStatus = status + }, + setReadinessStatus: (status: string) => { + readinessStatus = status + }, + } +} + +const startInput = { prompt: "fixture task", repoUrl: "https://github.com/cline/fixture" } +afterEach(() => { + vi.restoreAllMocks() + vi.clearAllMocks() + vi.useRealTimers() +}) + +describe("originating-account cloud cleanup", () => { + it.each([ + "create", + "readiness", + "connect", + "lifecycle", + "prompt", + ] as const)("drains %s and its DELETE through the real service before switching account", async (waitPoint) => { + const f = fixture(waitPoint, waitPoint === "create" ? undefined : "org-origin") + const starting = f.coordinator.beginCloudTask(startInput)() + await f.entered.promise + const switching = f.coordinator.reset(f.changeScope) + expect(f.changeScope).not.toHaveBeenCalled() + f.barrier.resolve() + await Promise.all([starting, switching]) + const deletion = f.requests.filter((request) => request.method === "DELETE") + expect(deletion).toEqual([ + { + method: "DELETE", + url: `${f.origin.endpoint}/api/v1/session/${f.record.id}`, + token: `Bearer ${f.origin.token}`, + body: undefined, + }, + ]) + expect( + f.requests.every( + (request) => request.token === `Bearer ${f.origin.token}` && request.url.startsWith(f.origin.endpoint), + ), + ).toBe(true) + expect(JSON.parse(f.requests[0].body ?? "{}").organizationId).toBe(f.origin.organizationId) + expect(f.changeScope).toHaveBeenCalledOnce() + expect(f.host.send).not.toHaveBeenCalled() + expect(await f.coordinator.listHistoryRecords()).toMatchObject([{ sessionId: "ses-successor" }]) + await f.coordinator.dispose() + }) + + it("keeps the original account on timeout and can explicitly retry after cleanup settles", async () => { + vi.useFakeTimers() + const f = fixture("create") + const starting = f.coordinator.beginCloudTask(startInput)() + await f.entered.promise + const switching = f.coordinator.reset(f.changeScope) + const rejected = expect(switching).rejects.toThrow("Your account has not changed") + await vi.advanceTimersByTimeAsync(15_000) + await rejected + expect(f.scope).toBe(f.origin) + expect(f.changeScope).not.toHaveBeenCalled() + f.barrier.resolve() + await starting + await f.coordinator.reset(f.changeScope) + expect(f.requests.find((request) => request.method === "DELETE")?.token).toBe(`Bearer ${f.origin.token}`) + expect(f.changeScope).toHaveBeenCalledOnce() + await f.coordinator.dispose() + }) + + it("drops a cancelled start's record that History listed while it was provisioning", async () => { + const f = fixture("readiness") + f.setReadinessStatus("provisioning") + const starting = f.coordinator.beginCloudTask(startInput)() + await f.entered.promise + // A state post during provisioning lists the account's sessions, which now + // include the record the create returned; the start has no entry for it yet. + await f.coordinator.listHistoryRecords() + f.options.invalidateHistoryCache.mockClear() + expect(f.coordinator.cancelPendingStart()).toBe(true) + f.barrier.resolve() + expect(await starting).toBe(f.record.id) + expect(f.requests.filter((request) => request.method === "DELETE")).toHaveLength(1) + expect(f.options.invalidateHistoryCache).toHaveBeenCalled() + expect(await f.coordinator.listHistoryRecords()).toEqual([]) + await f.coordinator.dispose() + }) + + it("cancels a still-provisioning start instead of waiting for it to boot", async () => { + const f = fixture("readiness", "org-origin") + f.setReadinessStatus("provisioning") + const starting = f.coordinator.beginCloudTask(startInput)() + await f.entered.promise + f.barrier.resolve() + // The user left the provisioning view for a local task before the change. + f.options.setTask({ taskId: "local-task" } as TaskProxy) + await f.coordinator.reset(f.changeScope) + expect(await starting).toBe(f.record.id) + expect(f.changeScope).toHaveBeenCalledOnce() + expect(f.requests.filter((request) => request.method === "DELETE")).toEqual([ + expect.objectContaining({ token: `Bearer ${f.origin.token}` }), + ]) + await f.coordinator.dispose() + }) + + it("reports rejected cleanup once without exposing response text or retrying as the successor", async () => { + const f = fixture("create") + f.setDeletionStatus(401) + const starting = f.coordinator.beginCloudTask(startInput)() + await f.entered.promise + const switching = f.coordinator.reset(f.changeScope) + f.barrier.resolve() + await Promise.all([starting, switching]) + expect(f.requests.filter((request) => request.method === "DELETE")).toHaveLength(1) + expect(HostProvider.window.showMessage).toHaveBeenCalledExactlyOnceWith( + expect.objectContaining({ message: expect.stringContaining("account that started it") }), + ) + expect(JSON.stringify(vi.mocked(HostProvider.window.showMessage).mock.calls)).not.toContain("private server detail") + expect(await f.coordinator.listHistoryRecords()).toMatchObject([{ sessionId: "ses-successor" }]) + await f.coordinator.dispose() + }) + + it("has one cleanup owner when readiness fails after provisioning returns an id", async () => { + const f = fixture("readiness") + f.setReadinessStatus("failed") + const starting = f.coordinator.beginCloudTask(startInput)() + await f.entered.promise + const switching = f.coordinator.reset(f.changeScope) + f.barrier.resolve() + await Promise.all([starting, switching]) + expect(f.requests.filter((request) => request.method === "DELETE")).toHaveLength(1) + expect(HostProvider.window.showMessage).not.toHaveBeenCalled() + await f.coordinator.dispose() + }) + + it("keeps an ordinary successful start running rather than cleaning its sandbox", async () => { + const f = fixture("prompt") + const starting = f.coordinator.beginCloudTask(startInput)() + await f.entered.promise + f.barrier.resolve() + expect(await starting).toBe(f.record.id) + expect(f.options.sessionConfigBuilder.build).toHaveBeenCalledWith({ + cwd: "/workspace", + workspaceRoot: "/workspace", + mode: "act", + runtime: { + modelSelection: { providerId: "cline", modelId: "fixture-model" }, + platform: "linux", + }, + }) + expect(f.host.start).toHaveBeenCalledWith( + expect.objectContaining({ + config: expect.objectContaining({ + systemPrompt: "normal Cline guidance", + mode: "act", + checkpoint: { enabled: false }, + }), + }), + ) + expect(f.host.send).toHaveBeenCalledOnce() + expect(f.requests.some((request) => request.method === "DELETE")).toBe(false) + await f.coordinator.reset(f.changeScope) + expect(f.requests.some((request) => request.method === "DELETE")).toBe(false) + await f.coordinator.dispose() + }) + + it("never sends the user's provider key or the local model catalog into the sandbox", async () => { + const f = fixture("prompt") + const starting = f.coordinator.beginCloudTask(startInput)() + await f.entered.promise + f.barrier.resolve() + expect(await starting).toBe(f.record.id) + const config = vi.mocked(f.host.start).mock.calls[0]?.[0]?.config + expect(config?.providerConfig).toMatchObject({ providerId: "cline", modelId: "fixture-model" }) + expect(JSON.stringify(config)).not.toContain("fixture-key") + expect(JSON.stringify(config)).not.toContain("catalog-model") + await f.coordinator.dispose() + }) + + it("includes explicit History deletion in the same account boundary", async () => { + const f = fixture("delete") + const deleting = f.coordinator.deleteSession(f.record.id) + await f.entered.promise + const switching = f.coordinator.reset(f.changeScope) + expect(f.changeScope).not.toHaveBeenCalled() + f.barrier.resolve() + await Promise.all([deleting, switching]) + expect(f.requests[0].token).toBe(`Bearer ${f.origin.token}`) + expect(f.changeScope).toHaveBeenCalledOnce() + await f.coordinator.dispose() + }) + + it.each(["lifecycle", "prompt"] as const)("cleans up a rejected %s wait without touching successor state", async (point) => { + const f = fixture(point) + const starting = f.coordinator.beginCloudTask(startInput)() + await f.entered.promise + const switching = f.coordinator.reset(f.changeScope) + f.barrier.reject(new Error("fixture failure")) + await Promise.all([starting, switching]) + expect(f.requests.filter((request) => request.method === "DELETE")).toHaveLength(1) + expect(f.requests.every((request) => request.token === `Bearer ${f.origin.token}`)).toBe(true) + expect(await f.coordinator.listHistoryRecords()).toMatchObject([{ sessionId: "ses-successor" }]) + await f.coordinator.dispose() + }) + + it("keeps old connectors enrolled after a timed-out transition and concurrent retry", async () => { + vi.useFakeTimers() + const f = fixture("connect") + const starting = f.coordinator.beginCloudTask(startInput)() + await f.entered.promise + const first = f.coordinator.reset(f.changeScope) + const rejected = expect(first).rejects.toThrow("Your account has not changed") + await vi.advanceTimersByTimeAsync(15_000) + await rejected + const second = f.coordinator.reset(f.changeScope) + f.barrier.resolve() + await Promise.all([starting, second]) + expect(f.changeScope).toHaveBeenCalledOnce() + expect(f.requests.find((request) => request.method === "DELETE")?.token).toBe(`Bearer ${f.origin.token}`) + await f.coordinator.dispose() + }) + + it("cleans up an in-flight create during controller disposal", async () => { + const f = fixture("create") + const starting = f.coordinator.beginCloudTask(startInput)() + await f.entered.promise + const disposing = f.coordinator.dispose() + f.barrier.resolve() + await Promise.all([starting, disposing]) + expect(f.requests.filter((request) => request.method === "DELETE")).toHaveLength(1) + expect(await f.coordinator.listHistoryRecords()).toEqual([]) + }) + + it("delays successor starts until originating cleanup and account mutation finish", async () => { + const f = fixture("create") + const first = f.coordinator.beginCloudTask(startInput)() + await f.entered.promise + const switching = f.coordinator.reset(f.changeScope) + const second = f.coordinator.beginCloudTask(startInput)() + f.barrier.resolve() + await Promise.all([first, switching, second]) + const posts = f.requests.filter((request) => request.method === "POST") + expect(posts.map((request) => request.token)).toEqual([`Bearer ${f.origin.token}`, `Bearer ${f.successor.token}`]) + const deletedAt = f.requests.findIndex((request) => request.method === "DELETE") + const newPostAt = f.requests.findIndex( + (request) => request.method === "POST" && request.token === `Bearer ${f.successor.token}`, + ) + expect(deletedAt).toBeLessThan(newPostAt) + expect(f.host.send).toHaveBeenCalledOnce() + await f.coordinator.dispose() + }) + + it("does not suppress a rejected explicit History deletion", async () => { + const f = fixture("delete") + f.setDeletionStatus(403) + const deleting = f.coordinator.deleteSession(f.record.id) + await f.entered.promise + const switching = f.coordinator.reset(f.changeScope) + const rejected = expect(deleting).rejects.toMatchObject({ status: 403 }) + f.barrier.resolve() + await Promise.all([rejected, switching]) + expect(f.requests.filter((request) => request.method === "DELETE")).toHaveLength(1) + await f.coordinator.dispose() + }) + + it("rejects deletion superseded before it can enter the account scope", async () => { + const f = fixture("delete") + const deleting = f.coordinator.deleteSession(f.record.id) + const rejected = expect(deleting).rejects.toThrow("account scope changed") + await Promise.all([rejected, f.coordinator.reset(f.changeScope)]) + expect(f.requests).toEqual([]) + await f.coordinator.dispose() + }) + + it("allows controller teardown to continue after its deadline without dropping late cleanup", async () => { + vi.useFakeTimers() + const f = fixture("create") + const starting = f.coordinator.beginCloudTask(startInput)() + await f.entered.promise + const disposing = f.coordinator.dispose() + await vi.advanceTimersByTimeAsync(15_000) + await disposing + f.barrier.resolve() + await starting + expect(f.requests.filter((request) => request.method === "DELETE")).toHaveLength(1) + expect(f.requests.every((request) => request.token === `Bearer ${f.origin.token}`)).toBe(true) + }) + + it("cleans ordinary pre-send failures and leaves a truthful error phase", async () => { + const f = fixture("prompt") + const starting = f.coordinator.beginCloudTask(startInput)() + await f.entered.promise + f.barrier.reject(new Error("cannot resolve prompt")) + expect(await starting).toBeUndefined() + expect(f.options.onStartFailed).toHaveBeenCalledOnce() + expect(f.options.onStartFailed).toHaveBeenCalledWith(expect.objectContaining({ taskId: f.record.id }), startInput) + expect(f.options.setTurnPhase).toHaveBeenLastCalledWith("error") + expect(f.requests.filter((request) => request.method === "DELETE")).toHaveLength(1) + expect(f.lifecycle.getActiveSession()).toBeUndefined() + await f.coordinator.dispose() + }) + + it("keeps originating cleanup enrolled until its DELETE response settles", async () => { + const f = fixture("delete") + vi.spyOn(f.options, "resolveContextMentions").mockRejectedValue(new Error("prompt failure")) + const starting = f.coordinator.beginCloudTask(startInput)() + await f.entered.promise + const switching = f.coordinator.reset(f.changeScope) + expect(f.changeScope).not.toHaveBeenCalled() + f.barrier.resolve() + await Promise.all([starting, switching]) + expect(f.changeScope).toHaveBeenCalledOnce() + expect(f.requests.filter((request) => request.method === "DELETE")).toHaveLength(1) + await f.coordinator.dispose() + }) + + it("keeps service-owned readiness cleanup when no coordinator callback takes ownership", async () => { + const f = fixture("readiness") + f.setReadinessStatus("failed") + const creating = f.service.createSession({ modelId: "fixture-model", repoUrl: startInput.repoUrl }) + const rejected = expect(creating).rejects.toMatchObject({ code: "session_failed" }) + await f.entered.promise + f.barrier.resolve() + await rejected + expect(f.requests.filter((request) => request.method === "DELETE")).toHaveLength(1) + await f.coordinator.dispose() + }) +}) diff --git a/apps/vscode/src/sdk/sdk-cloud-task-target.test.ts b/apps/vscode/src/sdk/sdk-cloud-task-target.test.ts new file mode 100644 index 0000000000..5b72a65626 --- /dev/null +++ b/apps/vscode/src/sdk/sdk-cloud-task-target.test.ts @@ -0,0 +1,229 @@ +import { afterEach, describe, expect, it, vi } from "vitest" +import { type CloudRepository, CloudSessionError } from "@/services/cloud/CloudSessionsService" +import { + LOOKUP_RETRY_MS, + REPOSITORIES_MAX_AGE_MS, + SdkCloudTaskTarget, + type SdkCloudTaskTargetOptions, + WORKSPACE_MAX_AGE_MS, +} from "./sdk-cloud-task-target" + +const mocks = vi.hoisted(() => ({ + workspaceDefaults: vi.fn<(cwd: string) => Promise<{ repoUrl?: string; branch?: string }>>(async () => ({})), +})) +vi.mock("@/services/cloud/workspace-cloud-defaults", () => ({ + resolveWorkspaceCloudDefaults: (cwd: string) => mocks.workspaceDefaults(cwd), +})) + +afterEach(() => { + vi.restoreAllMocks() + mocks.workspaceDefaults.mockReset() + mocks.workspaceDefaults.mockResolvedValue({}) +}) + +const cline: CloudRepository = { + id: 7, + name: "cline", + fullName: "cline/cline", + url: "https://github.com/cline/cline", + defaultBranch: "main", +} +const other: CloudRepository = { + id: 9, + name: "other", + fullName: "acme/other", + url: "https://github.com/acme/other", + defaultBranch: "trunk", +} + +/** In-memory global state; share one between targets to model one install seen by two accounts. */ +function makeStateManager(globalState: Record = {}) { + return { + getGlobalStateKey: (key: string) => globalState[key], + setGlobalState: (key: string, value: unknown) => { + globalState[key] = value + }, + } as never +} + +function makeTarget(overrides: Partial = {}) { + const globalState: Record = {} + const cloudSessions = { + getGitHubConnection: vi.fn(async () => ({ connected: true, connectUrl: "", repositories: [cline, other] })), + listBranches: vi.fn(async (_repositoryId: number, query?: string) => (query ? [query] : ["main"])), + githubConnectUrl: () => "https://app.test/connect", + } + let now = 1_000_000 + const options: SdkCloudTaskTargetOptions = { + cloudSessions: cloudSessions as never, + stateManager: makeStateManager(globalState), + getAccountScope: () => "user-1:", + getWorkspaceRoot: () => "/workspace", + postStateToWebview: vi.fn(async () => undefined), + now: () => now, + ...overrides, + } + return { target: new SdkCloudTaskTarget(options), cloudSessions, options, globalState, advance: (ms: number) => (now += ms) } +} + +/** Waits for the background lookups a view() started to land. */ +async function settle() { + for (let i = 0; i < 10; i++) { + await new Promise((resolve) => setTimeout(resolve, 0)) + } +} + +describe("SdkCloudTaskTarget", () => { + it("stores only the user's choice and resolves the rest on read", async () => { + const { target, globalState } = makeTarget() + mocks.workspaceDefaults.mockResolvedValue({ repoUrl: "https://github.com/cline/cline", branch: "feature/x" }) + + await target.choose({ target: "cloud" }) + expect(globalState.cloudTaskTarget).toEqual({ target: "cloud" }) + + // The first read starts the lookups; nothing is written. The branch + // check needs the repository list, so it starts on the read after that + // lands (each state post converges one step). + expect(target.view()).toEqual({ target: "cloud" }) + await settle() + expect(target.view()).toMatchObject({ repositoryId: 7, branch: "main" }) + await settle() + expect(globalState.cloudTaskTarget).toEqual({ target: "cloud" }) + expect(target.view()).toEqual({ + target: "cloud", + repositoryId: 7, + repoUrl: "https://github.com/cline/cline", + branch: "feature/x", + workspaceRepoUrl: "https://github.com/cline/cline", + }) + }) + + it("keeps Local when a lookup lands after the user chose it", async () => { + const { target, cloudSessions, options } = makeTarget() + let release!: () => void + cloudSessions.getGitHubConnection.mockReturnValue( + new Promise((resolve) => { + release = () => resolve({ connected: true, connectUrl: "", repositories: [cline] }) + }), + ) + await target.choose({ target: "cloud" }) + target.view() + await target.choose({ target: "local" }) + release() + await settle() + + expect(target.view()).toEqual({ target: "local" }) + // The landed lookup posted state, and that state carries Local. + expect(options.postStateToWebview).toHaveBeenCalled() + }) + + it("does not select a repository another account chose, nor carry its branch over", async () => { + const globalState: Record = {} + const first = makeTarget({ stateManager: makeStateManager(globalState) }) + await first.target.choose({ repositoryId: other.id }) + await first.target.choose({ branch: "release" }) + first.target.view() + await settle() + expect(first.target.view()).toMatchObject({ repositoryId: other.id, branch: "release" }) + + // Same install, another account: only cline/cline is reachable. + const second = makeTarget({ stateManager: makeStateManager(globalState), getAccountScope: () => "user-2:" }) + second.cloudSessions.getGitHubConnection.mockResolvedValue({ connected: true, connectUrl: "", repositories: [cline] }) + second.target.view() + await settle() + + expect(second.target.view()).toMatchObject({ repositoryId: cline.id, branch: "main" }) + expect(globalState.cloudTaskTarget).toEqual({ target: "cloud", repositoryId: other.id, branch: "release" }) + }) + + it("suggests the workspace branch only once GitHub confirms it, and retries a failed check later", async () => { + const { target, cloudSessions, advance } = makeTarget() + mocks.workspaceDefaults.mockResolvedValue({ repoUrl: "https://github.com/cline/cline", branch: "feature/gone" }) + cloudSessions.listBranches.mockRejectedValueOnce(new Error("offline")) + await target.choose({ target: "cloud" }) + target.view() + await settle() + expect(target.view().branch).toBe("main") + expect(cloudSessions.listBranches).toHaveBeenCalledTimes(1) + + // Too soon: the failed check is left alone. + target.view() + await settle() + expect(cloudSessions.listBranches).toHaveBeenCalledTimes(1) + + advance(LOOKUP_RETRY_MS) + cloudSessions.listBranches.mockResolvedValueOnce([]) + target.view() + await settle() + expect(cloudSessions.listBranches).toHaveBeenCalledTimes(2) + expect(cloudSessions.listBranches).toHaveBeenLastCalledWith(7, "feature/gone") + // GitHub no longer has it, whatever the local remote-tracking ref says. + expect(target.view().branch).toBe("main") + }) + + it("attaches a chosen branch to the repository the view resolved", async () => { + const { target } = makeTarget() + mocks.workspaceDefaults.mockResolvedValue({ repoUrl: "https://github.com/acme/other" }) + await target.choose({ target: "cloud" }) + target.view() + await settle() + expect(target.view()).toMatchObject({ repositoryId: other.id, branch: "trunk" }) + + await target.choose({ branch: "hotfix" }) + + expect(target.view()).toMatchObject({ repositoryId: other.id, branch: "hotfix" }) + }) + + it("reports the connection per account and refreshes on request", async () => { + const { target, cloudSessions } = makeTarget() + expect(await target.connectionStatus()).toMatchObject({ signedIn: true, connected: true }) + await target.connectionStatus() + expect(cloudSessions.getGitHubConnection).toHaveBeenCalledTimes(1) + await target.connectionStatus(true) + expect(cloudSessions.getGitHubConnection).toHaveBeenCalledTimes(2) + + cloudSessions.getGitHubConnection.mockRejectedValueOnce(new CloudSessionError("authentication_required", "expired")) + expect(await target.connectionStatus(true)).toMatchObject({ signedIn: false, connectUrl: "https://app.test/connect" }) + }) + + it("re-reads repositories and the workspace checkout once they age, keeping the old answer meanwhile", async () => { + const { target, cloudSessions, advance } = makeTarget() + mocks.workspaceDefaults.mockResolvedValue({ repoUrl: "https://github.com/cline/cline", branch: "feature/a" }) + await target.choose({ target: "cloud" }) + target.view() + await settle() + target.view() + await settle() + expect(target.view()).toMatchObject({ repositoryId: 7, branch: "feature/a" }) + expect(cloudSessions.getGitHubConnection).toHaveBeenCalledTimes(1) + expect(mocks.workspaceDefaults).toHaveBeenCalledTimes(1) + + // The user checks out another branch in the terminal and revokes the + // GitHub App's access to the repository in the browser. + mocks.workspaceDefaults.mockResolvedValue({ repoUrl: "https://github.com/cline/cline", branch: "feature/b" }) + cloudSessions.getGitHubConnection.mockResolvedValue({ connected: true, connectUrl: "", repositories: [other] }) + + advance(WORKSPACE_MAX_AGE_MS) + // The aged read still answers from cache and reloads behind it. + expect(target.view()).toMatchObject({ repositoryId: 7, branch: "feature/a" }) + await settle() + target.view() + await settle() + expect(target.view()).toMatchObject({ repositoryId: 7, branch: "feature/b" }) + expect(cloudSessions.getGitHubConnection).toHaveBeenCalledTimes(1) + + advance(REPOSITORIES_MAX_AGE_MS) + target.view() + await settle() + expect(cloudSessions.getGitHubConnection).toHaveBeenCalledTimes(2) + expect(target.view()).toMatchObject({ repositoryId: other.id, branch: "trunk" }) + }) + + it("is Local with no lookups when the user has not chosen Cloud", async () => { + const { target, cloudSessions } = makeTarget() + expect(target.view()).toEqual({ target: "local" }) + await settle() + expect(cloudSessions.getGitHubConnection).not.toHaveBeenCalled() + expect(mocks.workspaceDefaults).not.toHaveBeenCalled() + }) +}) diff --git a/apps/vscode/src/sdk/sdk-cloud-task-target.ts b/apps/vscode/src/sdk/sdk-cloud-task-target.ts new file mode 100644 index 0000000000..f7e38b5889 --- /dev/null +++ b/apps/vscode/src/sdk/sdk-cloud-task-target.ts @@ -0,0 +1,268 @@ +// SdkCloudTaskTarget — the one writer and the one reader of "where does the +// next task run". +// +// The user makes three kinds of choice (Local/Cloud, a repository, a branch) +// and each is stored as itself. Everything else the panel shows and the +// composer submits — which repository the account can actually reach, which +// branch to start on — is resolved here from the stored choice, the active +// account's repositories and the workspace's git remote, on every state post. +// Nothing derived is ever written back, so no background lookup can overwrite +// a choice the user made while it was in flight. + +import { type CloudTaskTargetSelection, type CloudTaskTargetView, normalizeGitHubRemoteUrl } from "@shared/cloud/cloud-sessions" +import type { StateManager } from "@/core/storage/StateManager" +import { + type CloudRepository, + CloudSessionError, + type CloudSessionsService, + type GitHubConnectionResult, +} from "@/services/cloud/CloudSessionsService" +import { resolveWorkspaceCloudDefaults } from "@/services/cloud/workspace-cloud-defaults" + +/** GitHub App connection for the active account, or `signedIn: false` when there is none. */ +export interface GitHubConnectionStatus extends GitHubConnectionResult { + signedIn: boolean + /** Set when the request failed (network / API error); other fields are then unreliable. */ + error?: string +} + +export type CloudTaskTargetChoice = { target: "local" | "cloud" } | { repositoryId: number } | { branch: string } + +export interface SdkCloudTaskTargetOptions { + cloudSessions: CloudSessionsService + stateManager: StateManager + /** Identity of the active account (user and organization), or undefined when signed out. */ + getAccountScope: () => string | undefined + /** Primary workspace root as last observed; undefined without a workspace. */ + getWorkspaceRoot: () => string | undefined + postStateToWebview: () => Promise + now?: () => number +} + +/** How long a failed lookup is left alone before a read retries it. */ +export const LOOKUP_RETRY_MS = 30_000 +/** + * How long a loaded input is trusted before a read re-asks in the background. + * Repository access changes in the browser (GitHub App install page) and the + * git checkout changes in the terminal; neither notifies the extension, so a + * read older than this refreshes and the next state post carries the answer. + */ +export const REPOSITORIES_MAX_AGE_MS = 60_000 +export const WORKSPACE_MAX_AGE_MS = 10_000 + +interface WorkspaceDefaults { + repoUrl?: string + branch?: string +} + +/** + * One input to the resolved target, cached under the key it was loaded for + * (account scope, workspace root, repository+branch). A read under a new + * key discards the old value and starts a load; a load whose slot has been + * replaced meanwhile is dropped, by identity, not by key. + */ +class Lookup { + private key: string | undefined + private value: T | undefined + private loadedAt: number | undefined + private pending: Promise | undefined + private failedAt: number | undefined + + /** + * @param maxAgeMs how long a loaded value is trusted; a read after that + * returns the old value and reloads in the background. Omit for inputs that + * only change with the key. + */ + constructor( + private readonly onLoaded: () => void, + private readonly now: () => number, + private readonly maxAgeMs?: number, + ) {} + + /** The cached value for `key`, starting a load in the background when there is none or it has aged out. */ + read(key: string, load: () => Promise): T | undefined { + this.start(key, load, false) + return this.value + } + + /** The value for `key`, loaded now when there is none; `refresh` discards the cached one first. */ + async get(key: string, load: () => Promise, refresh: boolean): Promise { + await this.start(key, load, refresh) + return this.value + } + + private start(key: string, load: () => Promise, refresh: boolean): Promise { + if (this.key !== key || refresh) { + this.key = key + this.value = undefined + this.loadedAt = undefined + this.pending = undefined + this.failedAt = undefined + } + if (this.pending) { + return this.pending + } + if (this.value !== undefined && !this.aged()) { + return Promise.resolve() + } + if (this.failedAt !== undefined && this.now() - this.failedAt < LOOKUP_RETRY_MS) { + return Promise.resolve() + } + const pending: Promise = load().then( + (value) => { + if (this.pending !== pending) { + return + } + this.pending = undefined + this.value = value + this.loadedAt = this.now() + this.onLoaded() + }, + () => { + if (this.pending !== pending) { + return + } + this.pending = undefined + this.failedAt = this.now() + }, + ) + this.pending = pending + return pending + } + + private aged(): boolean { + return this.maxAgeMs !== undefined && this.loadedAt !== undefined && this.now() - this.loadedAt >= this.maxAgeMs + } +} + +export class SdkCloudTaskTarget { + private readonly connection: Lookup + private readonly workspace: Lookup + private readonly workspaceBranch: Lookup + + constructor(private readonly options: SdkCloudTaskTargetOptions) { + // A landed input posts state so the panel and composer see the new view. + const onLoaded = () => void options.postStateToWebview().catch(() => {}) + const now = () => options.now?.() ?? Date.now() + this.connection = new Lookup(onLoaded, now, REPOSITORIES_MAX_AGE_MS) + this.workspace = new Lookup(onLoaded, now, WORKSPACE_MAX_AGE_MS) + // A branch either exists on GitHub or it does not; a stale "yes" fails at + // provisioning with a clear error, and the key changes with the checkout. + this.workspaceBranch = new Lookup(onLoaded, now) + } + + // ---- Writes: one field per user choice ---- + + async choose(choice: CloudTaskTargetChoice): Promise { + const current = this.selection() + let next: CloudTaskTargetSelection + if ("target" in choice) { + next = { ...current, target: choice.target } + } else if ("repositoryId" in choice) { + // A branch belongs to the repository it was chosen for. + next = { target: "cloud", repositoryId: choice.repositoryId } + } else { + // The branch attaches to the repository the user is looking at, which + // the view resolved and the stored selection may not name. + next = { + target: "cloud", + repositoryId: this.view().repositoryId ?? current.repositoryId, + branch: choice.branch.trim() || undefined, + } + } + this.options.stateManager.setGlobalState("cloudTaskTarget", next) + await this.options.postStateToWebview() + } + + private selection(): CloudTaskTargetSelection { + return this.options.stateManager.getGlobalStateKey("cloudTaskTarget") ?? { target: "local" } + } + + // ---- Reads: the resolved target, from whatever inputs are known now ---- + + /** + * The target as it stands. An input not yet known is requested in the + * background and the state is posted again when it arrives, so the view + * converges without anything being written. Local starts no lookups. + */ + view(): CloudTaskTargetView { + const selection = this.selection() + if (selection.target !== "cloud") { + return { target: "local" } + } + const workspace = this.workspaceDefaults() + const view: CloudTaskTargetView = { target: "cloud", workspaceRepoUrl: workspace?.repoUrl } + const repositories = this.cachedConnection()?.repositories ?? [] + const repository = + repositories.find((repo) => repo.id === selection.repositoryId) ?? + repositories.find((repo) => this.isWorkspaceRepository(repo, workspace)) ?? + repositories[0] + if (!repository) { + return view + } + view.repositoryId = repository.id + view.repoUrl = normalizeGitHubRemoteUrl(repository.url) ?? repository.url + const chosenBranch = selection.repositoryId === repository.id ? selection.branch : undefined + const workspaceBranch = + workspace?.branch && this.isWorkspaceRepository(repository, workspace) + ? this.confirmedWorkspaceBranch(repository, workspace.branch) + : undefined + view.branch = chosenBranch ?? workspaceBranch ?? repository.defaultBranch ?? undefined + return view + } + + private isWorkspaceRepository(repo: CloudRepository, workspace: WorkspaceDefaults | undefined): boolean { + return !!workspace?.repoUrl && normalizeGitHubRemoteUrl(repo.url) === workspace.repoUrl + } + + // ---- Inputs, each cached under the key it depends on ---- + + /** + * The active account's GitHub App connection and repositories, for the + * Account view and the onboarding card. `refresh` re-asks the control plane. + * The cache is keyed by account scope, so another account's repositories + * are never returned as this one's. + */ + async connectionStatus(refresh = false): Promise { + const scope = this.options.getAccountScope() + const status = scope && (await this.connection.get(scope, () => this.fetchConnection(), refresh)) + return status || this.signedOut() + } + + private cachedConnection(): GitHubConnectionStatus | undefined { + const scope = this.options.getAccountScope() + return scope ? this.connection.read(scope, () => this.fetchConnection()) : undefined + } + + private async fetchConnection(): Promise { + try { + return { signedIn: true, ...(await this.options.cloudSessions.getGitHubConnection()) } + } catch (error) { + if (error instanceof CloudSessionError && error.code === "authentication_required") { + return this.signedOut() + } + return { ...this.signedOut(), signedIn: true, error: error instanceof Error ? error.message : String(error) } + } + } + + private signedOut(): GitHubConnectionStatus { + return { signedIn: false, connected: false, connectUrl: this.options.cloudSessions.githubConnectUrl(), repositories: [] } + } + + private workspaceDefaults(): WorkspaceDefaults | undefined { + const cwd = this.options.getWorkspaceRoot() + return cwd ? this.workspace.read(cwd, () => resolveWorkspaceCloudDefaults(cwd)) : undefined + } + + /** + * The workspace branch, once GitHub confirms `repository` has it. A local + * remote-tracking ref can outlive the branch on GitHub, so GitHub is asked + * by name rather than trusting the ref or the first page of branches. + */ + private confirmedWorkspaceBranch(repository: CloudRepository, branch: string): string | undefined { + const exists = this.workspaceBranch.read(`${repository.id}:${branch}`, async () => + (await this.options.cloudSessions.listBranches(repository.id, branch)).includes(branch), + ) + return exists ? branch : undefined + } +} diff --git a/apps/vscode/src/sdk/sdk-mode-coordinator.ts b/apps/vscode/src/sdk/sdk-mode-coordinator.ts index 4fb0ac71c5..3a81ef3752 100644 --- a/apps/vscode/src/sdk/sdk-mode-coordinator.ts +++ b/apps/vscode/src/sdk/sdk-mode-coordinator.ts @@ -117,6 +117,11 @@ export class SdkModeCoordinator { } const activeSession = this.options.sessions.getActiveSession() + // Cloud sessions are Act-only; the webview disables the toggle, this + // guards the keyboard shortcut and any other caller. + if (activeSession?.sdkHost && "isCloud" in activeSession.sdkHost) { + return false + } if (activeSession) { // awaiting_followup is also used for non-plan turns, so it is not // sufficient evidence that the user has a plan to approve. Require the diff --git a/apps/vscode/src/sdk/sdk-session-lifecycle.test.ts b/apps/vscode/src/sdk/sdk-session-lifecycle.test.ts index e1e2dd5e1e..43bcca6aed 100644 --- a/apps/vscode/src/sdk/sdk-session-lifecycle.test.ts +++ b/apps/vscode/src/sdk/sdk-session-lifecycle.test.ts @@ -41,6 +41,18 @@ describe("SdkSessionLifecycle", () => { expect(lifecycle.getActiveSession()?.isRunning).toBe(true) }) + it("does not start a host when the caller is superseded", async () => { + const sdkHost = makeSdkHost({ startResult: { sessionId: "session-stale" } }) + const lifecycle = makeLifecycle() + + await expect(lifecycle.startNewSession({} as StartInput, sdkHost as never, () => false)).rejects.toMatchObject({ + name: "SessionStartSupersededError", + }) + + expect(sdkHost.start).not.toHaveBeenCalled() + expect(lifecycle.getActiveSession()).toBeUndefined() + }) + it("stores the provider and model config used to start the active session", async () => { const sdkHost = makeSdkHost({ startResult: { sessionId: "session-123" } }) mockCreateSessionHost.mockResolvedValueOnce(sdkHost) diff --git a/apps/vscode/src/sdk/sdk-session-lifecycle.ts b/apps/vscode/src/sdk/sdk-session-lifecycle.ts index f86135cdf4..5ce21c68c0 100644 --- a/apps/vscode/src/sdk/sdk-session-lifecycle.ts +++ b/apps/vscode/src/sdk/sdk-session-lifecycle.ts @@ -163,7 +163,18 @@ export class SdkSessionLifecycle { async startNewSession( startInput: Parameters[0], + /** + * Session-scoped host (e.g. a CloudSessionHost) to start on instead of the + * shared local host. Its events are routed to onSessionEvent for as long + * as the session is active; tool policies are left to the host. + */ + hostOverride?: SdkSessionHost, + /** Rechecked at every suspension point so a stale start cannot replace its successor. */ + shouldContinue: () => boolean = () => true, ): Promise<{ startResult: StartSessionResult; sdkHost: SdkSessionHost }> { + if (!shouldContinue()) { + throw new SessionStartSupersededError() + } if (this.activeSession) { await this.endActiveSession("startNewSession") } @@ -174,16 +185,27 @@ export class SdkSessionLifecycle { if (requestedSessionId) { await this.waitForPendingStop(requestedSessionId) } + if (!shouldContinue()) { + throw new SessionStartSupersededError() + } const autoApprovalSettings = StateManager.get().getGlobalSettingsKey("autoApprovalSettings") - const toolPolicies = autoApprovalSettings ? buildToolPolicies(autoApprovalSettings, this.options.mcpHub) : undefined + const toolPolicies = + !hostOverride && autoApprovalSettings ? buildToolPolicies(autoApprovalSettings, this.options.mcpHub) : undefined - const sdkHost = await this.getOrCreateSharedHost() + const sdkHost = hostOverride ?? (await this.getOrCreateSharedHost()) + if (!shouldContinue()) { + throw new SessionStartSupersededError() + } const startResult = await sdkHost.start({ ...startInput, ...(toolPolicies ? { toolPolicies } : {}), }) + if (!shouldContinue()) { + await sdkHost.stop(startResult.sessionId) + throw new SessionStartSupersededError() + } this.activeSession = { sessionId: startResult.sessionId, startConfig: startInput.config @@ -193,7 +215,7 @@ export class SdkSessionLifecycle { } : undefined, sdkHost, - unsubscribe: () => {}, + unsubscribe: hostOverride ? this.subscribeSessionHost(hostOverride) : () => {}, startResult, isRunning: true, queuedPromptCount: 0, @@ -202,6 +224,50 @@ export class SdkSessionLifecycle { return { startResult, sdkHost } } + /** + * Makes an already-running conversation on a session-scoped host the active + * session without starting a new one (e.g. reopening a cloud session that + * keeps running in its sandbox). Events flow to onSessionEvent until the + * session is ended; the host itself is not stopped when it is. + */ + async attachExistingSession(input: { + sdkHost: SdkSessionHost + sessionId: string + startConfig?: ActiveSession["startConfig"] + isRunning: boolean + shouldContinue?: () => boolean + }): Promise { + if (input.shouldContinue?.() === false) { + throw new SessionStartSupersededError() + } + if (this.activeSession) { + await this.endActiveSession("attachExistingSession") + } + if (input.shouldContinue?.() === false) { + throw new SessionStartSupersededError() + } + const session: ActiveSession = { + sessionId: input.sessionId, + startConfig: input.startConfig, + sdkHost: input.sdkHost, + unsubscribe: this.subscribeSessionHost(input.sdkHost), + isRunning: input.isRunning, + queuedPromptCount: 0, + } + this.activeSession = session + return session + } + + async endActiveSessionIfHost(host: SdkSessionHost, reason: string): Promise { + if (this.activeSession?.sdkHost === host) { + await this.endActiveSession(reason) + } + } + + private subscribeSessionHost(sdkHost: SdkSessionHost): () => void { + return this.createSafeUnsubscribe(sdkHost.subscribe(this.options.onSessionEvent), "session-host") + } + async replaceActiveSession(options: { expectedSession: ActiveSession startInput: Parameters[0] @@ -451,6 +517,13 @@ export class SdkSessionLifecycle { } } +export class SessionStartSupersededError extends Error { + constructor() { + super("Session start was superseded") + this.name = "SessionStartSupersededError" + } +} + export function isAbortError(error: unknown): boolean { if (error instanceof Error) { return error.name === "AbortError" || error.message.toLowerCase().includes("aborted") diff --git a/apps/vscode/src/sdk/sdk-session-rebuild-scheduler.ts b/apps/vscode/src/sdk/sdk-session-rebuild-scheduler.ts index ca8b40bd0e..95bc9fd595 100644 --- a/apps/vscode/src/sdk/sdk-session-rebuild-scheduler.ts +++ b/apps/vscode/src/sdk/sdk-session-rebuild-scheduler.ts @@ -89,7 +89,15 @@ export class SdkSessionRebuildScheduler { } private drainIfIdle(): void { - if (this.drainInFlight || this.pending.size === 0 || !isIdle(this.options.sessions.getActiveSession())) { + const activeSession = this.options.sessions.getActiveSession() + if (this.drainInFlight || this.pending.size === 0 || !activeSession || !isIdle(activeSession)) { + return + } + // Cloud sessions run on the sandbox with its own tools/provider; local + // MCP, provider and terminal changes do not apply and must not rebuild + // the sandbox conversation into a local one. + if (activeSession.sdkHost && "isCloud" in activeSession.sdkHost) { + this.pending.clear() return } diff --git a/apps/vscode/src/sdk/sdk-task-control-coordinator.test.ts b/apps/vscode/src/sdk/sdk-task-control-coordinator.test.ts index aeac15ee25..42aaf2e99b 100644 --- a/apps/vscode/src/sdk/sdk-task-control-coordinator.test.ts +++ b/apps/vscode/src/sdk/sdk-task-control-coordinator.test.ts @@ -55,6 +55,17 @@ describe("SdkTaskControlCoordinator", () => { expect(options.sessions.setRunning).not.toHaveBeenCalled() }) + it("leaves a running cloud host alone on sign-out even without a displayed task", async () => { + const activeSession = makeActiveSession() + Object.assign(activeSession.sdkHost, { isCloud: true }) + const { coordinator, options } = makeCoordinator({ activeSession }) + + await coordinator.cancelClineTaskOnSignOut(true) + + expect(activeSession.sdkHost.abort).not.toHaveBeenCalled() + expect(options.sessions.setRunning).not.toHaveBeenCalled() + }) + it("raises the cancel fence BEFORE aborting the session (so stragglers are fenced)", async () => { const activeSession = makeActiveSession() const { coordinator, options } = makeCoordinator({ activeSession }) diff --git a/apps/vscode/src/sdk/sdk-task-control-coordinator.ts b/apps/vscode/src/sdk/sdk-task-control-coordinator.ts index 55fbb52b58..5054e8f183 100644 --- a/apps/vscode/src/sdk/sdk-task-control-coordinator.ts +++ b/apps/vscode/src/sdk/sdk-task-control-coordinator.ts @@ -57,9 +57,19 @@ export class SdkTaskControlCoordinator { constructor(private readonly options: SdkTaskControlCoordinatorOptions) {} + /** + * Allocates a task-view generation for a task-open flow that runs outside + * this coordinator (cloud sessions). The returned predicate reports whether + * a newer showTaskWithId/clearTask has superseded the caller. + */ + claimTaskViewGeneration(): () => boolean { + const generation = ++this.taskViewGeneration + return () => generation !== this.taskViewGeneration + } + async cancelClineTaskOnSignOut(isClineManagedProvider: boolean): Promise { const activeSession = this.options.sessions.getActiveSession() - if (!isClineManagedProvider || !activeSession?.isRunning) { + if (!isClineManagedProvider || !activeSession?.isRunning || "isCloud" in activeSession.sdkHost) { return } @@ -109,10 +119,17 @@ export class SdkTaskControlCoordinator { Logger.log(`[SdkController] Task cancelled: ${sessionId}`) } - async clearTask(): Promise { + /** + * Clears the task view under a new task-view generation and returns that + * generation's fence. A caller installing a task next (a cloud open or + * start) keeps this fence rather than claiming again after the await: a + * selection made while the view was clearing must win over it. + */ + async clearTask(): Promise<() => boolean> { // Supersede any in-flight showTaskWithId so it cannot re-install a task // after the user cleared the view (e.g. clicked New Task). const generation = ++this.taskViewGeneration + const isSuperseded = () => generation !== this.taskViewGeneration this.options.interactions.clearPending("Task cleared") await this.options.rebuilds.runTaskTransition(async () => { if (generation !== this.taskViewGeneration) { @@ -133,6 +150,7 @@ export class SdkTaskControlCoordinator { await this.options.clearTaskSettings() this.options.resetMessageTranslator() }) + return isSuperseded } /** diff --git a/apps/vscode/src/sdk/sdk-task-history.test.ts b/apps/vscode/src/sdk/sdk-task-history.test.ts index ca7fb98b2e..4b8180598d 100644 --- a/apps/vscode/src/sdk/sdk-task-history.test.ts +++ b/apps/vscode/src/sdk/sdk-task-history.test.ts @@ -7,7 +7,7 @@ import type { TelemetryService } from "@/services/telemetry/TelemetryService" import { deleteLegacyTask, readApiConversationHistory, readTaskHistory, readUiMessages } from "./legacy-state-reader" import { sdkMessagesToClineMessages } from "./message-translator" import type { SdkSessionLifecycle } from "./sdk-session-lifecycle" -import { SdkTaskHistory, sessionHistoryRecordToHistoryItem } from "./sdk-task-history" +import { type CloudHistorySource, SdkTaskHistory, sessionHistoryRecordToHistoryItem } from "./sdk-task-history" import type { VscodeSessionHost } from "./vscode-session-host" vi.mock("@/core/storage/disk", () => ({ @@ -564,6 +564,23 @@ describe("SdkTaskHistory", () => { await expect(history.findHistoryItem("favorite-legacy-task")).resolves.toMatchObject({ id: "favorite-legacy-task" }) }) + it("never deletes cloud sessions when deleting all history", async () => { + const cloudRecord = makeSessionRecord("ses-cloud", { metadata: { executionTarget: "cloud" } }) + const cloud = { + isCloudSessionId: (id: string) => id.startsWith("ses-"), + list: vi.fn(async () => [cloudRecord]), + find: vi.fn(async (id: string) => (id === cloudRecord.sessionId ? cloudRecord : undefined)), + delete: vi.fn(async () => {}), + } + const { history, deleteSession } = makeHistory([makeSessionRecord("sdk-task")], undefined, undefined, cloud) + + await expect(history.deleteAllTaskHistory()).resolves.toBe(1) + await expect(history.deleteAllTaskHistory({ preserveFavorites: true })).resolves.toBe(0) + + expect(deleteSession).toHaveBeenCalledWith("sdk-task") + expect(cloud.delete).not.toHaveBeenCalled() + }) + it("identifies legacy tasks without migrating them", async () => { legacyStateReaderMock.taskHistory = [makeHistoryItem("legacy-task", { task: "legacy prompt" })] const telemetry = makeTelemetry() @@ -926,7 +943,12 @@ function makeTelemetry(): TelemetryService { } as unknown as TelemetryService } -function makeHistory(records: SessionHistoryRecord[], telemetry?: TelemetryService, legacyExtensionStorageDir?: string) { +function makeHistory( + records: SessionHistoryRecord[], + telemetry?: TelemetryService, + legacyExtensionStorageDir?: string, + cloud?: CloudHistorySource, +) { let currentRecords = records const updateSession = vi.fn( async ( @@ -980,6 +1002,7 @@ function makeHistory(records: SessionHistoryRecord[], telemetry?: TelemetryServi sessions, telemetry, legacyExtensionStorageDir, + cloud, }) return { diff --git a/apps/vscode/src/sdk/sdk-task-history.ts b/apps/vscode/src/sdk/sdk-task-history.ts index 56e9747390..ec64e7c0f8 100644 --- a/apps/vscode/src/sdk/sdk-task-history.ts +++ b/apps/vscode/src/sdk/sdk-task-history.ts @@ -43,6 +43,19 @@ export interface SdkTaskHistoryOptions { */ getMinter?: () => MessageIdMinter telemetry?: TelemetryService + /** + * Cline Cloud sessions live in the cloud control plane, not the local SDK + * store. When provided, they are merged into history listings and lookups + * (identified by their `ses-…` ids) so History is the one place for every task. + */ + cloud?: CloudHistorySource +} + +export interface CloudHistorySource { + isCloudSessionId: (id: string) => boolean + list: () => Promise + find: (id: string) => Promise + delete: (id: string) => Promise } type SdkTaskHistoryListOptions = ClineCoreListHistoryOptions & { @@ -149,6 +162,15 @@ function parseUserMessageMode(content: SdkMessage["content"]): "plan" | "act" | return undefined } +/** Renders a persisted SDK transcript as chat rows, the same way History does for local tasks. */ +export function sdkMessagesToDisplayClineMessages( + messages: SdkMessage[], + minter: MessageIdMinter | undefined, + options: { finalTurnCompleted: boolean; cwd?: string }, +): ClineMessage[] { + return sdkMessagesToClineMessages(sanitizeSdkUserMessagesForDisplay(messages), minter, options) +} + function sanitizeSdkUserMessagesForDisplay(messages: SdkMessage[]): SdkDisplayMessage[] { return messages.map((message): SdkDisplayMessage => { if (message.role !== "user") { @@ -193,6 +215,15 @@ export function sessionHistoryRecordToHistoryItem(item: SessionHistoryRecord): H cwdOnTaskInitialization: item.cwd ?? item.workspaceRoot, isLegacy: metadataBoolean(metadata, "legacyTask") === true || metadataBoolean(metadata, "migratedFromLegacyTask") === true, + ...(metadataString(metadata, "executionTarget") === "cloud" + ? { + executionTarget: "cloud" as const, + cloudStatus: metadataString(metadata, "cloudStatus") as HistoryItem["cloudStatus"], + cloudRepoUrl: metadataString(metadata, "repoUrl"), + cloudBranch: metadataString(metadata, "branch"), + cloudUsageAvailable: metadataBoolean(metadata, "usageAvailable") ?? false, + } + : {}), } } @@ -243,9 +274,14 @@ export class SdkTaskHistory { return this.readAllLegacyTaskHistory().find(({ item }) => item.id === taskId) } + private isCloudTask(taskId: string): boolean { + return this.options.cloud?.isCloudSessionId(taskId) ?? false + } + private getActiveHistoryHost(): VscodeSessionHost | undefined { const sdkHost = this.options.sessions.getActiveSession()?.sdkHost - if (sdkHost && "listHistory" in sdkHost) { + // A cloud host only knows its own sandbox; local history always needs the local store. + if (sdkHost && "listHistory" in sdkHost && !("isCloud" in sdkHost)) { return sdkHost as VscodeSessionHost } return undefined @@ -332,6 +368,11 @@ export class SdkTaskHistory { this.metadataHistoryCache = undefined } + /** Forces the next listing to re-enumerate (e.g. a cloud session changed status). */ + invalidateCache(): void { + this.invalidateMetadataHistoryCache() + } + /** * Mirror a persistence-layer write into the cache so the next read sees * the updated record without a full re-enumeration. @@ -421,7 +462,16 @@ export class SdkTaskHistory { metadataBoolean(item.metadata, "legacyTask") === true, ).length - const mergedHistory = [...visibleSdkHistory, ...legacyHistory].sort(compareSessionHistoryRecordsByRecencyDesc) + const cloudHistory = this.options.cloud + ? await this.options.cloud.list().catch((error) => { + Logger.warn("[SdkTaskHistory] Failed to list cloud sessions:", error) + return [] as SessionHistoryRecord[] + }) + : [] + + const mergedHistory = [...visibleSdkHistory, ...legacyHistory, ...cloudHistory].sort( + compareSessionHistoryRecordsByRecencyDesc, + ) if (useCache) { this.metadataHistoryCache = { records: mergedHistory, @@ -450,6 +500,10 @@ export class SdkTaskHistory { } async getClineMessages(taskId: string): Promise { + if (this.isCloudTask(taskId)) { + // Cloud transcripts are read through the sandbox connection (see SdkCloudSessionCoordinator). + return [] + } const sdkRecord = await this.getSdkRecord(taskId) const legacyTask = this.findLegacyTask(taskId) if (!sdkRecord && legacyTask) { @@ -517,11 +571,17 @@ export class SdkTaskHistory { * Start New Task affordances. */ async getSessionStatus(taskId: string): Promise { + if (this.isCloudTask(taskId)) { + return (await this.options.cloud?.find(taskId))?.status + } const sdkRecord = await this.getSdkRecord(taskId).catch(() => undefined) return sdkRecord?.status } async isLegacyTask(taskId: string): Promise { + if (this.isCloudTask(taskId)) { + return false + } const sdkRecord = await this.getSdkRecord(taskId) if (sdkRecord) { return ( @@ -592,10 +652,19 @@ export class SdkTaskHistory { } async updateTaskHistoryItem(item: HistoryItem): Promise { + if (this.isCloudTask(item.id)) { + // Cloud records are owned by the control plane; nothing to persist locally. + return + } await this.updateSession(item.id, item) } private async deleteSession(sessionId: string): Promise { + if (this.isCloudTask(sessionId)) { + await this.options.cloud?.delete(sessionId) + this.invalidateMetadataHistoryCache() + return + } const legacyTask = this.findLegacyTask(sessionId) try { await this.withHistoryHost(async (host) => { @@ -614,6 +683,10 @@ export class SdkTaskHistory { } async findHistoryItem(taskId: string): Promise { + if (this.isCloudTask(taskId)) { + const cloudRecord = await this.options.cloud?.find(taskId) + return cloudRecord ? sessionHistoryRecordToHistoryItem(cloudRecord) : undefined + } const sdkHistoryItem = await this.withHistoryHost(async (host) => { const sdkRecord = await host.get(taskId) if (!sdkRecord || sdkRecord.isSubagent === true) { @@ -638,7 +711,9 @@ export class SdkTaskHistory { } async deleteAllTaskHistory(options: { preserveFavorites?: boolean } = {}): Promise { - const history = await this.listHistory({ hydrate: false }) + // Cloud sessions are account-wide control-plane records (possibly started from + // another client), so bulk-clearing local history must never delete them. + const history = (await this.listHistory({ hydrate: false })).filter((item) => !this.isCloudTask(item.sessionId)) const tasksToDelete = options.preserveFavorites ? history.filter( (item) => @@ -673,7 +748,7 @@ export class SdkTaskHistory { `[SdkController] Task usage: tokensIn=${usage.tokensIn}, tokensOut=${usage.tokensOut}, cost=${usage.totalCost ?? 0}`, ) - if (!taskId) { + if (!taskId || this.isCloudTask(taskId)) { return } diff --git a/apps/vscode/src/services/cloud/CloudSessionsService.ts b/apps/vscode/src/services/cloud/CloudSessionsService.ts new file mode 100644 index 0000000000..600e4064cf --- /dev/null +++ b/apps/vscode/src/services/cloud/CloudSessionsService.ts @@ -0,0 +1,321 @@ +import { + CloudCreationUnconfirmedError, + type CloudErrorCode, + type CloudProvisioningPhase, + type CloudRepositoryOption, + CloudSessionApi, + CloudSessionError as CoreCloudSessionError, + type CloudSessionRecord as CoreCloudSessionRecord, +} from "@cline/core/cloud" +import { ClineEnv } from "@/config" +import { fetch } from "@/shared/net" +import { Logger } from "@/shared/services/Logger" + +const REQUEST_TIMEOUT_MS = 15_000 +const CREATE_TIMEOUT_MS = 610_000 + +export type CloudSessionErrorCode = CloudErrorCode +export type { CloudProvisioningPhase } + +export class CloudSessionError extends Error { + constructor( + readonly code: CloudSessionErrorCode, + message: string, + readonly connectUrl?: string, + readonly status?: number, + ) { + super(message) + this.name = "CloudSessionError" + } +} + +export type CloudSessionRecord = Omit & { sandboxUrl?: string } + +export type CloudRepository = CloudRepositoryOption + +export interface GitHubConnectionResult { + connected: boolean + connectUrl: string + repositories: CloudRepository[] +} + +export interface CreateCloudSessionInput { + modelId: string + repoUrl: string + branch?: string + organizationId?: string +} + +function trimTrailingSlash(value: string): string { + return value.replace(/\/+$/, "") +} + +export function isCloudSessionExpired(record: Pick): boolean { + if (record.status === "expired") { + return true + } + const expiredAt = record.expiredAt ? Date.parse(record.expiredAt) : Number.NaN + return Number.isFinite(expiredAt) && expiredAt <= Date.now() +} + +export interface CloudSessionsServiceOptions { + getAuthToken: () => Promise + getActiveOrganizationId: () => string | null | undefined + fetch?: typeof fetch + /** Explicit endpoints for hermetic hosts and tests. Production uses ClineEnv. */ + apiBaseUrl?: string + appBaseUrl?: string +} + +export class CloudSessionsService { + private readonly fetchImpl: typeof fetch + + constructor(private readonly options: CloudSessionsServiceOptions) { + this.fetchImpl = options.fetch ?? fetch + } + + get apiBaseUrl(): string { + return trimTrailingSlash(this.options.apiBaseUrl ?? ClineEnv.config().apiBaseUrl) + } + + get appBaseUrl(): string { + return trimTrailingSlash(this.options.appBaseUrl ?? ClineEnv.config().appBaseUrl) + } + + dashboardUrl(sessionId: string): string { + return `${this.appBaseUrl}/agents?sessionId=${encodeURIComponent(sessionId)}` + } + + /** WebSocket endpoint the sandbox's Hub is proxied on. */ + sessionSocketUrl(sessionId: string): string { + const url = new URL(`/api/v1/session/${encodeURIComponent(sessionId)}`, this.apiBaseUrl) + url.protocol = url.protocol === "http:" ? "ws:" : "wss:" + return url.toString() + } + + githubConnectUrl(): string { + return this.options.getActiveOrganizationId()?.trim() + ? `${this.appBaseUrl}/dashboard/organization/integrations` + : `${this.appBaseUrl}/dashboard/integrations` + } + + private async requireToken(): Promise { + const token = (await this.options.getAuthToken())?.trim() + if (!token) { + throw new CloudSessionError("authentication_required", "Sign in to Cline to use cloud sessions.") + } + return token + } + + private async withApi(operation: (api: CloudSessionApi) => Promise): Promise { + const api = new CloudSessionApi({ + apiBaseUrl: this.apiBaseUrl, + appBaseUrl: this.appBaseUrl, + getAuthToken: async () => (await this.options.getAuthToken())?.trim() || undefined, + fetch: this.fetchImpl, + createTimeoutMs: CREATE_TIMEOUT_MS, + }) + try { + return await operation(api) + } catch (error) { + if (error instanceof CloudCreationUnconfirmedError || !(error instanceof CoreCloudSessionError)) { + throw error + } + if (error.status === 403) { + throw new CloudSessionError( + "request_failed", + "Your active account or organization cannot use cloud sessions. Switch to Personal or another organization in the Account view and try again.", + undefined, + 403, + ) + } + const status = + error.status ?? + { + authentication_required: 401, + session_not_found: 404, + session_expired: 410, + github_not_connected: 412, + session_failed: undefined, + request_failed: undefined, + }[error.code] + throw new CloudSessionError(error.code, error.detail, error.connectUrl, status) + } + } + + // ---- GitHub integration ---- + + async getGitHubConnection(): Promise { + const connectUrl = this.githubConnectUrl() + const organizationId = this.options.getActiveOrganizationId()?.trim() + try { + const result = await this.withApi((api) => api.listRepositories(organizationId)) + result.repositories.sort((a, b) => a.fullName.localeCompare(b.fullName)) + return result + } catch (error) { + if ( + error instanceof CloudSessionError && + (error.code === "github_not_connected" || error.code === "session_not_found") + ) { + return { connected: false, connectUrl, repositories: [] } + } + throw error + } + } + + /** Resolves the GitHub App install URL (the API answers with a redirect to github.com). */ + async getGitHubInstallUrl(): Promise { + const token = await this.requireToken() + const installUrl = new URL("/api/v1/integrations/github/install", this.apiBaseUrl) + installUrl.searchParams.set("redirect", new URL("/dashboard/integrations", this.appBaseUrl).toString()) + const response = await this.fetchImpl(installUrl, { + method: "GET", + headers: { Authorization: `Bearer ${token}` }, + redirect: "manual", + signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS), + }) + const location = response.headers.get("location")?.trim() + if (response.status >= 300 && response.status < 400 && location) { + const resolved = new URL(location, installUrl) + if (resolved.protocol === "https:" && resolved.hostname === "github.com") { + return resolved.toString() + } + } + // Fall back to the dashboard, which hosts the same connect flow. + Logger.warn(`[CloudSessions] GitHub install URL unavailable (status ${response.status}); opening dashboard instead`) + return this.githubConnectUrl() + } + + async listBranches(repositoryId: number, query?: string): Promise { + if (!Number.isSafeInteger(repositoryId) || repositoryId <= 0) { + return [] + } + const organizationId = this.options.getActiveOrganizationId()?.trim() + const result = await this.withApi((api) => api.listBranches(repositoryId, organizationId, { query })) + return result.branches + } + + // ---- Sessions ---- + + async listSessions(): Promise { + const orgId = this.options.getActiveOrganizationId()?.trim() + return this.withApi((api) => api.list(orgId)) + } + + async getSession(sessionId: string): Promise { + const sessions = await this.listSessions() + return sessions.find((session) => session.id === sessionId) + } + + async getStatus( + sessionId: string, + signal?: AbortSignal, + ): Promise<{ status?: string; statusReason?: string; phase?: CloudProvisioningPhase }> { + return (await this.withApi((api) => api.status(sessionId, { signal }))) ?? {} + } + + /** + * Creates a resumable sandbox and resolves once it is ready to accept a Hub connection. + * `onProvisioning` fires as soon as the record exists so the UI can show progress. + * Providing that callback transfers cleanup ownership to the caller, including + * when readiness or subsequent record lookup fails. Aborting `signal` stops the + * readiness poll; the record already created stays the caller's to delete. + */ + async createSession( + input: CreateCloudSessionInput, + onProvisioning?: (sessionId: string) => void, + signal?: AbortSignal, + onPhase?: (phase: CloudProvisioningPhase) => void, + ): Promise { + const orgId = input.organizationId ?? this.options.getActiveOrganizationId()?.trim() + return this.withApi(async (api) => { + const created = await api.create({ ...input, organizationId: orgId, sandboxType: "resumable" }) + const sessionId = created.sessionId + onProvisioning?.(sessionId) + if (created.status === "provisioning" || !created.sandboxUrl?.trim()) { + try { + await this.waitForReady(api, sessionId, signal, onPhase) + } catch (error) { + if (!onProvisioning && error instanceof CoreCloudSessionError && error.code === "session_failed") { + await api.delete(sessionId) + } + throw error + } + } + const record = (await api.list(orgId)).find((session) => session.id === sessionId) + return ( + record ?? { + id: sessionId, + status: created.status || "active", + sandboxType: "resumable", + sandboxUrl: created.sandboxUrl, + repoContext: { repoUrl: input.repoUrl, branch: input.branch }, + metadata: { modelId: input.modelId, sandboxType: "resumable" }, + createdAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), + } + ) + }) + } + + /** + * Polls until the sandbox accepts connections. Aborting the signal stops the + * poll at once (the caller then deletes the unused record) instead of after + * the sandbox comes up. + */ + async waitUntilReady( + sessionId: string, + signal?: AbortSignal, + onPhase?: (phase: CloudProvisioningPhase) => void, + ): Promise { + return this.withApi((api) => this.waitForReady(api, sessionId, signal, onPhase)) + } + + private async waitForReady( + api: CloudSessionApi, + sessionId: string, + signal?: AbortSignal, + onPhase?: (phase: CloudProvisioningPhase) => void, + ): Promise { + try { + await api.waitUntilReady(sessionId, signal ?? new AbortController().signal, ({ phase }) => { + if (phase) onPhase?.(phase) + }) + } catch (error) { + signal?.throwIfAborted() + if (error instanceof DOMException && error.name === "TimeoutError") { + throw new CloudSessionError("request_failed", "Timed out waiting for the cloud sandbox to become ready.") + } + throw error + } + } + + /** Wakes a suspended sandbox and waits until it accepts connections. */ + async resumeSession(sessionId: string, onPhase?: (phase: CloudProvisioningPhase) => void): Promise { + let status: string | undefined + try { + status = (await this.withApi((api) => api.resume(sessionId)))?.status + } catch (error) { + // Another client may have resumed it first, or resume may still be in flight. + if (!(error instanceof CloudSessionError) || error.status !== 409) throw error + status = (await this.getStatus(sessionId)).status + if (status !== "provisioning" && status !== "ready" && status !== "active") throw error + } + if (status !== "ready" && status !== "active") { + await this.waitUntilReady(sessionId, undefined, onPhase) + } + } + + async deleteSession(sessionId: string): Promise { + await this.withApi((api) => api.delete(sessionId)) + } + + async renameSession(sessionId: string, title: string): Promise { + await this.withApi((api) => api.updateTitle(sessionId, title)) + } + + /** Archived transcript of an expired sandbox; null when no archive exists. */ + async getHistory(sessionId: string): Promise { + return this.withApi((api) => api.history(sessionId)) + } +} diff --git a/apps/vscode/src/services/cloud/cloudSessionsFeature.ts b/apps/vscode/src/services/cloud/cloudSessionsFeature.ts new file mode 100644 index 0000000000..54fd9e2cd7 --- /dev/null +++ b/apps/vscode/src/services/cloud/cloudSessionsFeature.ts @@ -0,0 +1,32 @@ +import { StateManager } from "@/core/storage/StateManager" +import { featureFlagsService } from "@/services/feature-flags" +import { FeatureFlag } from "@/shared/services/feature-flags/feature-flags" + +/** + * `CLINE_CLOUD_SESSIONS=1` forces cloud sessions on (and `0` off) for local + * development and testing, bypassing both the flag and the Settings toggle. + */ +function readCloudSessionsOverride(): boolean | undefined { + const override = process.env.CLINE_CLOUD_SESSIONS?.trim().toLowerCase() + if (override === "1" || override === "true") { + return true + } + if (override === "0" || override === "false") { + return false + } + return undefined +} + +/** Whether the `ext-cloud-sessions` rollout makes cloud sessions available to this user. */ +export function isCloudSessionsFeatureAvailable(): boolean { + return readCloudSessionsOverride() ?? featureFlagsService.getBooleanFlagEnabled(FeatureFlag.CLOUD_SESSIONS) +} + +/** Whether cloud sessions are both available and turned on by the user in Settings. */ +export function isCloudSessionsFeatureEnabled(): boolean { + const override = readCloudSessionsOverride() + if (override !== undefined) { + return override + } + return isCloudSessionsFeatureAvailable() && StateManager.get().getGlobalSettingsKey("cloudSessionsEnabled") +} diff --git a/apps/vscode/src/services/cloud/workspace-cloud-defaults.test.ts b/apps/vscode/src/services/cloud/workspace-cloud-defaults.test.ts new file mode 100644 index 0000000000..ec30275654 --- /dev/null +++ b/apps/vscode/src/services/cloud/workspace-cloud-defaults.test.ts @@ -0,0 +1,51 @@ +import { execFile } from "node:child_process" +import { mkdtemp, rm } from "node:fs/promises" +import { tmpdir } from "node:os" +import path from "node:path" +import { promisify } from "node:util" +import { afterEach, beforeEach, describe, expect, it } from "vitest" +import { resolveWorkspaceCloudDefaults } from "./workspace-cloud-defaults" + +const execFileAsync = promisify(execFile) + +describe("resolveWorkspaceCloudDefaults", () => { + let root: string + let clone: string + const git = (...args: string[]) => execFileAsync("git", args, { cwd: clone }) + + beforeEach(async () => { + root = await mkdtemp(path.join(tmpdir(), "cloud-defaults-")) + const upstream = path.join(root, "upstream.git") + clone = path.join(root, "clone") + await execFileAsync("git", ["init", "--bare", "--initial-branch=main", upstream]) + await execFileAsync("git", ["clone", upstream, clone]) + await git("config", "user.email", "fixture@example.test") + await git("config", "user.name", "Fixture") + await git("commit", "--allow-empty", "-m", "initial") + await git("push", "origin", "main") + // Point origin at GitHub after pushing so the URL is recognised; the + // remote-tracking refs already recorded are what the lookup reads. + await git("remote", "set-url", "origin", "git@github.com:cline/fixture.git") + }) + + afterEach(async () => { + await rm(root, { recursive: true, force: true }) + }) + + it("suggests the checked-out branch once it exists on origin", async () => { + expect(await resolveWorkspaceCloudDefaults(clone)).toEqual({ + repoUrl: "https://github.com/cline/fixture", + branch: "main", + }) + }) + + it("does not suggest a branch that was never pushed", async () => { + await git("switch", "-c", "local-only") + expect(await resolveWorkspaceCloudDefaults(clone)).toEqual({ repoUrl: "https://github.com/cline/fixture" }) + }) + + it("suggests the upstream branch when it is named differently", async () => { + await git("switch", "-c", "renamed-locally", "--track", "origin/main") + expect(await resolveWorkspaceCloudDefaults(clone)).toMatchObject({ branch: "main" }) + }) +}) diff --git a/apps/vscode/src/services/cloud/workspace-cloud-defaults.ts b/apps/vscode/src/services/cloud/workspace-cloud-defaults.ts new file mode 100644 index 0000000000..1cee6b5d67 --- /dev/null +++ b/apps/vscode/src/services/cloud/workspace-cloud-defaults.ts @@ -0,0 +1,52 @@ +import { execFile } from "node:child_process" +import { promisify } from "node:util" +import { normalizeGitHubRemoteUrl } from "@shared/cloud/cloud-sessions" +import { getGitRemoteUrls } from "@/utils/git" + +const execFileAsync = promisify(execFile) + +async function git(cwd: string, ...args: string[]): Promise { + try { + const { stdout } = await execFileAsync("git", args, { cwd }) + return stdout.trim() || undefined + } catch { + return undefined + } +} + +/** + * The branch of `remote` that the checked-out branch corresponds to, or + * undefined when it has never been pushed there. A cloud sandbox clones + * from GitHub, so a local-only branch cannot be started from. Prefers the + * configured upstream, whose name may differ from the local branch. + */ +async function remoteBranchForCheckout(cwd: string, remote: string): Promise { + const upstream = await git(cwd, "rev-parse", "--abbrev-ref", "--symbolic-full-name", "@{upstream}") + if (upstream?.startsWith(`${remote}/`)) { + return upstream.slice(remote.length + 1) + } + const branch = await git(cwd, "branch", "--show-current") + if (!branch) { + return undefined + } + const tracked = await git(cwd, "rev-parse", "--verify", "--quiet", `refs/remotes/${remote}/${branch}`) + return tracked ? branch : undefined +} + +/** + * Suggests the repository and branch for a cloud task from a working tree's + * git remotes (preferring `origin`) and the remote branch it has checked + * out. Empty when the working tree is not a GitHub repository. + */ +export async function resolveWorkspaceCloudDefaults(cwd: string): Promise<{ repoUrl?: string; branch?: string }> { + const remotes = (await getGitRemoteUrls(cwd)).map((line) => { + const separator = line.indexOf(": ") + return { name: line.slice(0, separator), url: line.slice(separator + 2) } + }) + const origin = remotes.find((remote) => remote.name === "origin") ?? remotes[0] + const repoUrl = origin ? normalizeGitHubRemoteUrl(origin.url) : null + if (!origin || !repoUrl) { + return {} + } + return { repoUrl, branch: await remoteBranchForCheckout(cwd, origin.name) } +} diff --git a/apps/vscode/src/services/feature-flags/FeatureFlagsProviderFactory.ts b/apps/vscode/src/services/feature-flags/FeatureFlagsProviderFactory.ts index acc50c8121..4ab97e4163 100644 --- a/apps/vscode/src/services/feature-flags/FeatureFlagsProviderFactory.ts +++ b/apps/vscode/src/services/feature-flags/FeatureFlagsProviderFactory.ts @@ -64,7 +64,7 @@ export class FeatureFlagsProviderFactory { * or for testing purposes */ class NoOpFeatureFlagsProvider implements IFeatureFlagsProvider { - async getAllFlagsAndPayloads(_: { flagKeys?: string[] }): Promise { + async getAllFlagsAndPayloads(_: { distinctId?: string; flagKeys?: string[] }): Promise { return {} } diff --git a/apps/vscode/src/services/feature-flags/FeatureFlagsService.test.ts b/apps/vscode/src/services/feature-flags/FeatureFlagsService.test.ts new file mode 100644 index 0000000000..7a5984e21e --- /dev/null +++ b/apps/vscode/src/services/feature-flags/FeatureFlagsService.test.ts @@ -0,0 +1,58 @@ +import { describe, expect, it, mock } from "bun:test" +import { FeatureFlag } from "@/shared/services/feature-flags/feature-flags" +import type { FeatureFlagsAndPayloads, FeatureFlagsSettings, IFeatureFlagsProvider } from "./providers/IFeatureFlagsProvider" + +mock.module("@/core/controller/models/getClineOnboardingModels", () => ({ + clearOnboardingModelsCache: () => undefined, + getClineOnboardingModels: () => undefined, +})) + +mock.module("../telemetry", () => ({ + telemetryService: { capture: () => undefined }, +})) + +type PendingPoll = { + distinctId?: string + resolve: (value: FeatureFlagsAndPayloads) => void +} + +class DeferredFeatureFlagsProvider implements IFeatureFlagsProvider { + readonly pending: PendingPoll[] = [] + + getAllFlagsAndPayloads(options: { distinctId?: string }): Promise { + return new Promise((resolve) => { + this.pending.push({ distinctId: options.distinctId, resolve }) + }) + } + + isEnabled(): boolean { + return true + } + + getSettings(): FeatureFlagsSettings { + return { enabled: true } + } + + async dispose(): Promise {} +} + +describe("FeatureFlagsService", () => { + it("keeps the authenticated result when an older anonymous poll resolves last", async () => { + const { FeatureFlagsService } = await import("./FeatureFlagsService") + const provider = new DeferredFeatureFlagsProvider() + const service = new FeatureFlagsService(provider) + + const anonymousPoll = service.poll(null) + const authenticatedPoll = service.poll("account-123") + + expect(provider.pending.map((request) => request.distinctId)).toEqual([undefined, "account-123"]) + + provider.pending[1].resolve({ featureFlags: { [FeatureFlag.CLOUD_SESSIONS]: true } }) + await authenticatedPoll + expect(service.getBooleanFlagEnabled(FeatureFlag.CLOUD_SESSIONS)).toBe(true) + + provider.pending[0].resolve({ featureFlags: { [FeatureFlag.CLOUD_SESSIONS]: false } }) + await anonymousPoll + expect(service.getBooleanFlagEnabled(FeatureFlag.CLOUD_SESSIONS)).toBe(true) + }) +}) diff --git a/apps/vscode/src/services/feature-flags/FeatureFlagsService.ts b/apps/vscode/src/services/feature-flags/FeatureFlagsService.ts index b3bd302f54..0b38099044 100644 --- a/apps/vscode/src/services/feature-flags/FeatureFlagsService.ts +++ b/apps/vscode/src/services/feature-flags/FeatureFlagsService.ts @@ -28,6 +28,7 @@ export class FeatureFlagsService { public constructor(private provider: IFeatureFlagsProvider) {} private cache: Map = new Map() + private pollGeneration = 0 /** * Tracks cache update time and user ID for cache validity */ @@ -46,30 +47,38 @@ export class FeatureFlagsService { } } - // Only update timestamp after successfully populating cache - this.cacheInfo = { updateTime: timesNow, userId: userId || null } + const pollGeneration = ++this.pollGeneration try { const values = await this.provider.getAllFlagsAndPayloads({ + distinctId: userId || undefined, flagKeys: FEATURE_FLAGS, }) - this.cacheInfo.flagsPayload = values + // Authentication can change while a request is in flight. Only the newest + // auth snapshot may become the cache observed by extension features. + if (pollGeneration !== this.pollGeneration) { + return + } + + this.cacheInfo = { updateTime: timesNow, userId: userId || null, flagsPayload: values } Logger.log("Fetched Feature Flag values + " + JSON.stringify(values)) for (const flag of FEATURE_FLAGS) { - const payload = await this.getFeatureFlag(flag).catch(() => false) + const payload = this.getFeatureFlag(flag) this.cache.set(flag, payload ?? false) } } catch (error) { // On error, clear cache info to force refresh on next poll - this.cacheInfo = { updateTime: 0, userId: null } + if (pollGeneration === this.pollGeneration) { + this.cacheInfo = { updateTime: 0, userId: null } + } throw error } getClineOnboardingModels() // Refresh onboarding models cache if relevant flag changed } - private async getFeatureFlag(flagName: FeatureFlag): Promise { + private getFeatureFlag(flagName: FeatureFlag): FeatureFlagPayload | undefined { try { const payload = this.cacheInfo.flagsPayload?.featureFlagPayloads?.[flagName] const flagValue = this.cacheInfo.flagsPayload?.featureFlags?.[flagName] diff --git a/apps/vscode/src/services/feature-flags/providers/IFeatureFlagsProvider.ts b/apps/vscode/src/services/feature-flags/providers/IFeatureFlagsProvider.ts index 97326e5c4e..d022a1d523 100644 --- a/apps/vscode/src/services/feature-flags/providers/IFeatureFlagsProvider.ts +++ b/apps/vscode/src/services/feature-flags/providers/IFeatureFlagsProvider.ts @@ -34,7 +34,7 @@ export type FeatureFlagsAndPayloads = { * Any feature flags provider must implement this interface */ export interface IFeatureFlagsProvider { - getAllFlagsAndPayloads(options: { flagKeys?: string[] }): Promise + getAllFlagsAndPayloads(options: { distinctId?: string; flagKeys?: string[] }): Promise /** * Check if the provider is enabled and ready diff --git a/apps/vscode/src/services/feature-flags/providers/PostHogFeatureFlagsProvider.test.ts b/apps/vscode/src/services/feature-flags/providers/PostHogFeatureFlagsProvider.test.ts new file mode 100644 index 0000000000..6bebea713b --- /dev/null +++ b/apps/vscode/src/services/feature-flags/providers/PostHogFeatureFlagsProvider.test.ts @@ -0,0 +1,22 @@ +import { describe, expect, it, mock } from "bun:test" + +mock.module("@/services/logging/distinctId", () => ({ + getDistinctId: () => "newer-global-identity", +})) + +describe("PostHogFeatureFlagsProvider", () => { + it("uses the identity captured by the poll", async () => { + const getAllFlagsAndPayloads = mock(async () => ({})) + const { PostHogFeatureFlagsProvider } = await import("./PostHogFeatureFlagsProvider") + const provider = new PostHogFeatureFlagsProvider({ getAllFlagsAndPayloads } as never) + + await provider.getAllFlagsAndPayloads({ + distinctId: "polled-account", + flagKeys: ["ext-cloud-sessions"], + }) + + expect(getAllFlagsAndPayloads).toHaveBeenCalledWith("polled-account", { + flagKeys: ["ext-cloud-sessions"], + }) + }) +}) diff --git a/apps/vscode/src/services/feature-flags/providers/PostHogFeatureFlagsProvider.ts b/apps/vscode/src/services/feature-flags/providers/PostHogFeatureFlagsProvider.ts index 5c41a65c24..2d8da39a5a 100644 --- a/apps/vscode/src/services/feature-flags/providers/PostHogFeatureFlagsProvider.ts +++ b/apps/vscode/src/services/feature-flags/providers/PostHogFeatureFlagsProvider.ts @@ -42,13 +42,17 @@ export class PostHogFeatureFlagsProvider implements IFeatureFlagsProvider { return getDistinctId() } - async getAllFlagsAndPayloads(options: { flagKeys?: string[] }): Promise { + async getAllFlagsAndPayloads(options: { + distinctId?: string + flagKeys?: string[] + }): Promise { if (!this.isEnabled()) { return undefined } try { - return await this.client.getAllFlagsAndPayloads(this.distinctId, options) + const { distinctId = this.distinctId, ...flagOptions } = options + return await this.client.getAllFlagsAndPayloads(distinctId, flagOptions) } catch (error) { Logger.error(`Error getting feature flags`, error) return {} diff --git a/apps/vscode/src/shared/ExtensionMessage.ts b/apps/vscode/src/shared/ExtensionMessage.ts index 2242c062ee..7221447950 100644 --- a/apps/vscode/src/shared/ExtensionMessage.ts +++ b/apps/vscode/src/shared/ExtensionMessage.ts @@ -10,6 +10,7 @@ import { BrowserSettings } from "./BrowserSettings" import { ClineFeatureSetting } from "./ClineFeatureSetting" import { BannerCardData } from "./cline/banner" import { ClineRulesToggles } from "./cline-rules" +import type { CloudTaskTargetView, CurrentCloudTaskInfo } from "./cloud/cloud-sessions" import { HistoryItem } from "./HistoryItem" import { McpDisplayMode } from "./McpDisplayMode" import { ClineMessageModelInfo } from "./messages" @@ -144,6 +145,16 @@ export interface ExtensionState { showFeatureTips?: boolean banners?: BannerCardData[] welcomeBanners?: BannerCardData[] + /** Cline Cloud sessions rollout flag; shows the Settings toggle. */ + cloudSessionsAvailable?: boolean + /** Cline Cloud sessions are available and turned on in Settings. */ + cloudSessionsEnabled?: boolean + /** Where a new task runs, resolved from the user's choice, the account's repositories and the workspace. */ + cloudTaskTarget?: CloudTaskTargetView + /** Present while the displayed task runs in Cline Cloud. */ + currentCloudTask?: CurrentCloudTaskInfo + /** Cline model the displayed cloud task runs on, else the one a new cloud task would use. */ + cloudModelId?: string openAiCodexIsAuthenticated?: boolean } diff --git a/apps/vscode/src/shared/HistoryItem.ts b/apps/vscode/src/shared/HistoryItem.ts index b26865d435..4a16a5c831 100644 --- a/apps/vscode/src/shared/HistoryItem.ts +++ b/apps/vscode/src/shared/HistoryItem.ts @@ -1,3 +1,5 @@ +import type { CloudSessionStatus } from "./cloud/cloud-sessions" + export type HistoryItem = { id: string ulid?: string // ULID for better tracking and metrics @@ -23,4 +25,12 @@ export type HistoryItem = { */ apiProvider?: string isLegacy?: boolean + + /** "cloud" for Cline Cloud sessions; absent/"local" for tasks run in this workspace. */ + executionTarget?: "local" | "cloud" + cloudStatus?: CloudSessionStatus + cloudRepoUrl?: string + cloudBranch?: string + /** True when cloud usage came from an authoritative remote runtime snapshot. */ + cloudUsageAvailable?: boolean } diff --git a/apps/vscode/src/shared/cloud/cloud-sessions.ts b/apps/vscode/src/shared/cloud/cloud-sessions.ts new file mode 100644 index 0000000000..63a66b14d5 --- /dev/null +++ b/apps/vscode/src/shared/cloud/cloud-sessions.ts @@ -0,0 +1,158 @@ +export type CloudExecutionTarget = "local" | "cloud" + +export const CLOUD_CREATION_UNCONFIRMED_ERROR_CODE = "cloud_creation_unconfirmed" + +/** + * Lifecycle of a Cline Cloud session as shown in the extension. + * - provisioning: the sandbox is being created + * - running: the agent is working (known from a live hub connection) + * - idle: the sandbox is up but the agent is not running a turn + * - completed / failed: the last turn ended that way (known from a live hub connection) + * - cancelled: the last turn was aborted, not successfully completed + * - unknown: live agent status is being resolved or could not be confirmed + * - expired: the sandbox is gone; only the archived transcript remains + */ +export type CloudSessionStatus = + | "provisioning" + | "running" + | "idle" + | "completed" + | "failed" + | "cancelled" + | "unknown" + | "expired" + +/** + * The last settled agent status confirmed over a live sandbox connection, + * remembered across restarts so History does not show every finished cloud + * task as unconfirmed until a socket re-learns it. `observedAt` is compared + * with the control plane's `updatedAt`: a record updated well after the + * observation invalidates it. Active statuses are never remembered. + */ +export interface RememberedCloudStatus { + status: CloudSessionStatus + observedAt: number +} + +/** Remembered statuses keyed by outer cloud session id. */ +export type RememberedCloudStatuses = Record + +/** + * What the user has chosen about where the next task runs. Only the user + * writes this, one field per choice; nothing derived is stored. A missing + * repository or branch means "whatever the extension resolves", see + * CloudTaskTargetView. + */ +export interface CloudTaskTargetSelection { + target: CloudExecutionTarget + /** GitHub repository id the user picked; may belong to another account. */ + repositoryId?: number + /** Branch the user picked for that repository. */ + branch?: string +} + +/** + * The Local/Cloud target as the extension resolves it from the stored + * selection, the active account's repositories and the workspace's git + * remote. Recomputed on every state post; the composer submits `repoUrl` + * and `branch` from here, never from the stored selection. + */ +export interface CloudTaskTargetView { + target: CloudExecutionTarget + /** Repository a cloud task would clone: the stored one if this account can reach it, else the workspace's, else the first. */ + repoUrl?: string + repositoryId?: number + /** Branch a cloud task would check out: the user's, else the workspace's when GitHub has it, else the default. */ + branch?: string + /** Normalized URL of the workspace's origin remote, when it is a GitHub repository. */ + workspaceRepoUrl?: string +} + +/** Cloud-specific details of the task currently shown in the chat view. */ +export interface CurrentCloudTaskInfo { + sessionId: string + repoUrl?: string + branch?: string + status: CloudSessionStatus +} + +export const CLOUD_WORKSPACE_ROOT = "/workspace" + +/** + * Cloud sessions run in Act mode only, whatever Plan/Act mode the user has + * saved for local tasks. The sandbox runtime, each turn sent to it and the + * chat view all read this one value so they cannot disagree. + */ +export const CLOUD_SESSION_MODE = "act" as const + +/** + * Whether the resolved target names a sandbox that can be started: Cloud + * with a repository. Cloud without a repository (none reachable, or not yet + * loaded) is an incomplete selection, not a fallback to Local. + */ +export function isCloudTargetReady(view: CloudTaskTargetView | undefined): boolean { + return view?.target === "cloud" && !!view.repoUrl +} + +export const ACTIVE_CLOUD_STATUSES: ReadonlySet = new Set(["provisioning", "running"]) + +export const CLOUD_PROVISIONING_ID_PREFIX = "cloud-provisioning-" + +/** Whether an id names a control-plane record that other Cline surfaces can open. */ +export function isPersistedCloudSessionId(id: string | undefined): boolean { + return typeof id === "string" && id.trim().startsWith("ses-") +} + +/** Outer Cline Cloud session ids (`ses-…`), plus the placeholder id used while a sandbox is provisioning. */ +export function isCloudSessionId(id: string | undefined): boolean { + if (typeof id !== "string") { + return false + } + const trimmed = id.trim() + return isPersistedCloudSessionId(trimmed) || trimmed.startsWith(CLOUD_PROVISIONING_ID_PREFIX) +} + +/** Formats https://github.com/owner/repo as owner/repo for compact display. */ +export function formatRepoLabel(repoUrl: string | undefined): string { + if (!repoUrl) { + return "" + } + const normalized = normalizeGitHubRemoteUrl(repoUrl) + return normalized ? normalized.replace(/^https:\/\/github\.com\//, "") : repoUrl +} + +function trimGitSuffix(path: string): string { + return path + .replace(/^\/+/, "") + .replace(/\/+$/, "") + .replace(/\.git$/i, "") +} + +/** + * Normalizes any GitHub remote form (https, ssh scp-style, ssh://, git://) into + * https://github.com/owner/repo. Returns null for non-GitHub or malformed remotes. + */ +export function normalizeGitHubRemoteUrl(remoteUrl: string): string | null { + const value = remoteUrl.trim() + if (!value) { + return null + } + const scpMatch = value.match(/^(?:[^@/\s]+@)?github\.com:([^\s]+)$/i) + if (scpMatch?.[1]) { + const path = trimGitSuffix(scpMatch[1]) + return path.split("/").length === 2 ? `https://github.com/${path}` : null + } + try { + const url = new URL(value) + if (url.hostname.toLowerCase() !== "github.com") { + return null + } + if (!new Set(["https:", "http:", "ssh:", "git:"]).has(url.protocol)) { + return null + } + const path = trimGitSuffix(url.pathname) + return path.split("/").length === 2 ? `https://github.com/${path}` : null + } catch { + return null + } +} diff --git a/apps/vscode/src/shared/services/feature-flags/feature-flags.ts b/apps/vscode/src/shared/services/feature-flags/feature-flags.ts index e4e414cdd2..10c08e5289 100644 --- a/apps/vscode/src/shared/services/feature-flags/feature-flags.ts +++ b/apps/vscode/src/shared/services/feature-flags/feature-flags.ts @@ -18,6 +18,8 @@ export enum FeatureFlag { CLINE_RECOMMENDED_MODELS_UPSTREAM = "cline_recommended_models_upstream", // Use the websocket mode for OpenAI native Responses API format OPENAI_RESPONSES_WEBSOCKET_MODE = "openai-responses-websocket-mode", + // Rollout flag for Cline Cloud sessions (run a task in a hosted sandbox) + CLOUD_SESSIONS = "ext-cloud-sessions", } export const FeatureFlagDefaultValue: Partial> = { @@ -29,6 +31,7 @@ export const FeatureFlagDefaultValue: Partial }, + // Last Local/Cloud choice (and repository) picked on the home screen for new tasks + cloudTaskTarget: { default: undefined as CloudTaskTargetSelection | undefined }, + // Last agent status confirmed over a live connection, per cloud session id + cloudSessionStatuses: { default: {} as RememberedCloudStatuses }, // Path to worktree that should auto-open Cline sidebar when launched worktreeAutoOpenPath: { default: undefined as string | undefined }, } satisfies FieldDefinitions @@ -276,6 +281,7 @@ const USER_SETTINGS_FIELDS = { useAutoCondense: { default: true as boolean }, subagentsEnabled: { default: false as boolean }, worktreesEnabled: { default: false as boolean }, + cloudSessionsEnabled: { default: false as boolean }, preferredLanguage: { default: "English" as string }, mode: { default: "act" as Mode }, backgroundEditEnabled: { default: false as boolean }, diff --git a/apps/vscode/src/test/cloud/cloud-credit-error-boundary.test.ts b/apps/vscode/src/test/cloud/cloud-credit-error-boundary.test.ts new file mode 100644 index 0000000000..8bb9df7e9c --- /dev/null +++ b/apps/vscode/src/test/cloud/cloud-credit-error-boundary.test.ts @@ -0,0 +1,80 @@ +import { type CoreSessionEvent, SessionSource } from "@cline/core" +import { afterEach, describe, expect, it } from "vitest" +import { CloudSessionHost } from "@/sdk/cloud-session-host" +import { MessageTranslatorState, translateSessionEvent } from "@/sdk/message-translator" +import { CloudSessionsService } from "@/services/cloud/CloudSessionsService" +import type { ClineMessage } from "@/shared/ExtensionMessage" +import { type LocalCloudEnvironment, startLocalCloudEnvironment } from "./local-cloud-environment" + +describe("cloud insufficient-credit failures through the real Hub", () => { + let environment: LocalCloudEnvironment | undefined + let host: CloudSessionHost | undefined + + afterEach(async () => { + await host?.dispose("test teardown") + await environment?.dispose() + host = undefined + environment = undefined + }) + + it("renders the out-of-credits failure instead of an unexplained Retry", async () => { + environment = await startLocalCloudEnvironment({ insufficientCredits: true }) + const service = new CloudSessionsService({ + apiBaseUrl: environment.apiBaseUrl, + appBaseUrl: environment.apiBaseUrl, + getAuthToken: async () => environment?.accessToken, + getActiveOrganizationId: () => undefined, + }) + const record = await service.createSession({ modelId: "fixture-model", repoUrl: "https://github.com/cline/fixture" }) + const owned = await environment.activateSession(record.id) + const taskId = record.metadata.taskId + if (!taskId) throw new Error("Local cloud session did not return a task id") + + host = await CloudSessionHost.connect({ + outerSessionId: record.id, + taskId, + socketUrl: service.sessionSocketUrl(record.id), + workspaceRoot: owned.root, + getAuthToken: async () => environment?.accessToken, + }) + const events: CoreSessionEvent[] = [] + host.subscribe((event) => events.push(event)) + await host.start({ + source: SessionSource.CORE, + interactive: true, + prompt: undefined, + config: { + providerId: "cline", + modelId: "fixture-model", + apiKey: "fixture-key", + systemPrompt: "normal Cline guidance", + enableTools: false, + enableSpawnAgent: false, + enableAgentTeams: false, + cwd: owned.root, + workspaceRoot: owned.root, + }, + }) + await host.send({ sessionId: record.id, prompt: "spend a credit" }) + expect(host.status).toBe("failed") + + const state = new MessageTranslatorState() + const messages: ClineMessage[] = [] + let turnComplete = false + for (const event of events) { + const result = translateSessionEvent(event, state) + messages.push(...result.messages) + turnComplete ||= result.turnComplete + } + + expect(turnComplete).toBe(true) + expect(state.wasErrorSeen()).toBe(true) + const failed = messages.filter((message) => message.ask === "api_req_failed") + expect(failed).toHaveLength(1) + expect(JSON.parse(failed[0].text ?? "{}")).toMatchObject({ + code: "insufficient_credits", + providerId: "cline", + details: { message: "Not enough credits available" }, + }) + }) +}) diff --git a/apps/vscode/src/test/cloud/cloud-outcome-boundary.test.ts b/apps/vscode/src/test/cloud/cloud-outcome-boundary.test.ts new file mode 100644 index 0000000000..efba82d650 --- /dev/null +++ b/apps/vscode/src/test/cloud/cloud-outcome-boundary.test.ts @@ -0,0 +1,173 @@ +import { SessionSource } from "@cline/core" +import { afterEach, describe, expect, it, vi } from "vitest" +import { HostProvider } from "@/hosts/host-provider" +import { CloudSessionHost } from "@/sdk/cloud-session-host" +import { MessageIdMinter } from "@/sdk/message-id-minter" +import { SdkCloudSessionCoordinator, type SdkCloudSessionCoordinatorOptions } from "@/sdk/sdk-cloud-session-coordinator" +import { sessionHistoryRecordToHistoryItem } from "@/sdk/sdk-task-history" +import type { TaskProxy } from "@/sdk/task-proxy" +import { CloudSessionsService } from "@/services/cloud/CloudSessionsService" +import type { CloudSessionStatus } from "@/shared/cloud/cloud-sessions" +import { type LocalCloudEnvironment, startLocalCloudEnvironment } from "./local-cloud-environment" + +// Only the IDE window and model-catalog refresh are replaced; transport, runtime, +// status tracking, registry and History conversion are the shipped implementations. +vi.mock("@/hosts/host-provider", () => ({ HostProvider: { window: { showMessage: vi.fn(async () => ({})) } } })) +vi.mock("@/core/controller/models/refreshClineRecommendedModels", () => ({ refreshClineRecommendedModels: vi.fn() })) + +function deferred() { + let resolve!: (value: T) => void + const promise = new Promise((done) => { + resolve = done + }) + return { promise, resolve } +} + +describe("cloud outcome → History and notifications through a real Hub", () => { + let environment: LocalCloudEnvironment | undefined + let coordinator: SdkCloudSessionCoordinator | undefined + let release: (() => void) | undefined + let unsubscribeEnded: (() => void) | undefined + + afterEach(async () => { + release?.() + unsubscribeEnded?.() + unsubscribeEnded = undefined + await coordinator?.dispose() + await environment?.dispose() + vi.restoreAllMocks() + vi.clearAllMocks() + }) + + it.each(["completed", "cancelled", "unknown"] as const)("projects %s without fabricating completion", async (outcome) => { + const modelEntered = deferred() + const modelReleased = deferred() + release = () => modelReleased.resolve() + environment = await startLocalCloudEnvironment({ + beforeModelResponse: async (signal) => { + modelEntered.resolve() + if (signal?.aborted) throw signal.reason + await new Promise((resolve, reject) => { + const aborted = () => reject(signal?.reason) + signal?.addEventListener("abort", aborted, { once: true }) + void modelReleased.promise.then(() => { + signal?.removeEventListener("abort", aborted) + resolve() + }) + }) + }, + }) + const service = new CloudSessionsService({ + apiBaseUrl: environment.apiBaseUrl, + appBaseUrl: environment.apiBaseUrl, + getAuthToken: async () => environment?.accessToken, + getActiveOrganizationId: () => undefined, + }) + const record = await service.createSession({ modelId: "fixture-model", repoUrl: "https://github.com/cline/fixture" }) + const owned = await environment.activateSession(record.id) + const connect = CloudSessionHost.connect.bind(CloudSessionHost) + let host!: CloudSessionHost + const statuses: CloudSessionStatus[] = [] + const completed = deferred() + vi.spyOn(CloudSessionHost, "connect").mockImplementation(async (options) => { + host = await connect({ + ...options, + workspaceRoot: owned.root, + onStatusChange: (status) => { + statuses.push(status) + options.onStatusChange?.(status) + if (status === "completed") completed.resolve() + }, + }) + return host + }) + let task: TaskProxy | undefined + const options = { + cloudSessions: service, + stateManager: { getGlobalSettingsKey: () => "act", getGlobalStateKey: () => ({}), setGlobalState: vi.fn() }, + sessionConfigBuilder: { build: vi.fn() }, + sessions: { attachExistingSession: vi.fn() }, + messages: { finalizeMessagesForSave: (messages: unknown) => messages }, + getMinter: () => new MessageIdMinter(), + getTask: () => task, + setTask: (next: TaskProxy | undefined) => { + task = next + }, + clearTask: async () => { + task = undefined + return () => false + }, + claimTaskViewGeneration: () => () => false, + getAuthToken: async () => environment?.accessToken, + isSignedIn: () => true, + isEnabled: () => true, + resetMessageTranslator: vi.fn(), + setTurnPhase: vi.fn(), + postStateToWebview: vi.fn(async () => {}), + invalidateHistoryCache: vi.fn(), + onAskResponse: vi.fn(), + onCancelTask: vi.fn(), + } as unknown as SdkCloudSessionCoordinatorOptions + coordinator = new SdkCloudSessionCoordinator(options) + await coordinator.openCloudTask(record.id) + await host.start({ + source: SessionSource.VSCODE, + config: { + providerId: "cline", + modelId: "fixture-model", + apiKey: "fixture-key", + systemPrompt: "Cline test guidance", + enableTools: false, + enableSpawnAgent: false, + enableAgentTeams: false, + cwd: owned.root, + workspaceRoot: owned.root, + }, + }) + const ended = deferred() + unsubscribeEnded = host.subscribe((event) => { + if (event.type === "ended" && event.payload.sessionId === record.id) ended.resolve() + }) + options.setTask(undefined) + const sending = host.send({ sessionId: record.id, prompt: "wait for the barrier" }) + // Attach rejection handling before deliberately severing the transport. + const settled = sending.then( + () => undefined, + (error: unknown) => error, + ) + await modelEntered.promise + expect(host.status).toBe("running") + if (outcome === "cancelled") { + await host.abort(record.id) + await settled + } else if (outcome === "unknown") { + environment.disconnectClients() + expect(await settled).toBeInstanceOf(Error) + // A fresh real Hub snapshot proves the disconnected sandbox is still busy. + expect(await host.get(record.id)).toMatchObject({ status: "running" }) + } else { + modelReleased.resolve() + await sending + } + expect(host.status).toBe(outcome) + const history = (await coordinator.listHistoryRecords())[0] + expect(history.status).toBe(outcome === "unknown" ? "pending" : outcome) + expect(history.exitCode).toBe(outcome === "completed" ? 0 : undefined) + expect(sessionHistoryRecordToHistoryItem(history).cloudStatus).toBe(outcome) + if (outcome === "completed") { + expect(HostProvider.window.showMessage).toHaveBeenCalledExactlyOnceWith( + expect.objectContaining({ message: expect.stringContaining("Cloud task finished") }), + ) + } else { + expect(HostProvider.window.showMessage).not.toHaveBeenCalled() + } + if (outcome === "unknown") { + modelReleased.resolve() + await completed.promise + expect(host.status).toBe("completed") + expect(statuses).toContain("unknown") + // The UI completion event precedes server persistence and workspace cleanup. + await ended.promise + } + }) +}) diff --git a/apps/vscode/src/test/cloud/cloud-service-contract.test.ts b/apps/vscode/src/test/cloud/cloud-service-contract.test.ts new file mode 100644 index 0000000000..b5dcc14514 --- /dev/null +++ b/apps/vscode/src/test/cloud/cloud-service-contract.test.ts @@ -0,0 +1,364 @@ +import { CloudCreationUnconfirmedError } from "@cline/core/cloud" +import { afterEach, describe, expect, it, vi } from "vitest" +import { CloudSessionError, CloudSessionsService, type CloudSessionsServiceOptions } from "@/services/cloud/CloudSessionsService" +import { type LocalCloudEnvironment, startLocalCloudEnvironment } from "./local-cloud-environment" + +describe("local cloud service boundary", () => { + let environment: LocalCloudEnvironment | undefined + const createInput = { modelId: "fixture-model", repoUrl: "https://github.com/cline/fixture", branch: "main" } + const controlledService = ( + fetchImpl: (url: string | URL | Request, init?: RequestInit) => Promise, + options: Partial = {}, + ) => + new CloudSessionsService({ + apiBaseUrl: "http://127.0.0.1", + appBaseUrl: "http://127.0.0.1", + getAuthToken: async () => "fixture-token", + getActiveOrganizationId: () => undefined, + ...options, + fetch: Object.assign(fetchImpl, { preconnect: vi.fn() }), + }) + + afterEach(async () => { + vi.restoreAllMocks() + await environment?.dispose() + environment = undefined + }) + + it.each([true, false])("handles a lost create response with a visible recovery match: %s", async (hasMatch) => { + const calls: string[] = [] + const lostResponse = new Error("response lost") + let recoveryTitle = "" + let organizationId = "original-org" + const service = controlledService( + async (url, init) => { + const path = new URL(String(url)).pathname + if (init?.method === "POST") { + calls.push("post") + const body = JSON.parse(String(init.body)) + expect(body).toMatchObject({ ...createInput, organizationId: "original-org" }) + recoveryTitle = body.title + expect(recoveryTitle).toMatch(/^__cline_create_request__:/) + throw lostResponse + } + if (path.endsWith("/status")) { + calls.push("status") + return Response.json({ data: { status: "ready" } }) + } + calls.push("list") + expect(new URL(String(url)).searchParams.get("organizationId")).toBe("original-org") + return Response.json({ + data: hasMatch + ? [ + { + id: "ses-fixture", + title: recoveryTitle, + status: "active", + repoContext: { repoUrl: createInput.repoUrl, branch: createInput.branch }, + metadata: { modelId: createInput.modelId }, + createdAt: "", + updatedAt: "", + }, + ] + : [], + }) + }, + { getActiveOrganizationId: () => organizationId }, + ) + const onProvisioning = vi.fn((id: string) => { + expect(id).toBe("ses-fixture") + calls.push("provisioning") + organizationId = "later-org" + }) + const result = service.createSession(createInput, onProvisioning) + if (hasMatch) { + await expect(result).resolves.toMatchObject({ + id: "ses-fixture", + title: undefined, + metadata: { createRequestTitle: expect.stringMatching(/^__cline_create_request__:/) }, + }) + expect(calls).toEqual(["post", "list", "provisioning", "status", "list"]) + } else { + await expect(result).rejects.toBeInstanceOf(CloudCreationUnconfirmedError) + await expect(result).rejects.toMatchObject({ cause: lostResponse }) + expect(onProvisioning).not.toHaveBeenCalled() + expect(calls).toEqual(["post", "list"]) + } + }) + + it.each([422, 403])("keeps a definite %s rejection friendly and retryable without recovery", async (status) => { + const fetchImpl = vi.fn(async () => Response.json({ error: "fixture rejection" }, { status })) + const result = controlledService(fetchImpl).createSession(createInput) + await expect(result).rejects.toBeInstanceOf(CloudSessionError) + await expect(result).rejects.toMatchObject({ + status, + code: "request_failed", + message: status === 403 ? expect.stringContaining("Switch to Personal") : "fixture rejection", + }) + expect(fetchImpl).toHaveBeenCalledTimes(1) + }) + + it("preserves GitHub disconnection, branch validation and repository sorting", async () => { + const fetchImpl = vi.fn(async () => Response.json({ error: "Connect GitHub" }, { status: 412 })) + const service = controlledService(fetchImpl, { getActiveOrganizationId: () => "fixture-org" }) + await expect(service.getGitHubConnection()).resolves.toEqual({ + connected: false, + repositories: [], + connectUrl: "http://127.0.0.1/dashboard/organization/integrations", + }) + await expect(service.listBranches(-1)).resolves.toEqual([]) + expect(fetchImpl).toHaveBeenCalledTimes(1) + fetchImpl.mockResolvedValueOnce( + Response.json({ + data: [ + { id: 2, full_name: "fixture/z", html_url: "https://github.com/fixture/z" }, + { id: 1, full_name: "fixture/a", html_url: "https://github.com/fixture/a" }, + ], + }), + ) + expect((await service.getGitHubConnection()).repositories.map((repo) => repo.fullName)).toEqual([ + "fixture/a", + "fixture/z", + ]) + fetchImpl.mockResolvedValueOnce(Response.json({ error: "Missing" }, { status: 404 })) + await expect(service.listBranches(1)).resolves.toEqual([]) + }) + + it("does not dispatch creation after the authenticated account changes", async () => { + const token = (subject: string) => + `header.${Buffer.from(JSON.stringify({ sub: subject })).toString("base64url")}.signature` + const getAuthToken = vi.fn().mockResolvedValueOnce(token("first")).mockResolvedValue(token("second")) + const fetchImpl = vi.fn(async () => Response.json({ data: [] })) + await expect(controlledService(fetchImpl, { getAuthToken }).createSession(createInput)).rejects.toMatchObject({ + code: "authentication_required", + message: "The signed-in account changed during cloud session creation.", + }) + expect(fetchImpl).not.toHaveBeenCalled() + }) + + it.each(["journal failure", "cancellation"])("returns a learned id before readiness despite %s", async (failure) => { + const cancel = new AbortController() + const error = new Error(failure) + const fetchImpl = vi.fn(async (_url: string | URL | Request, init?: RequestInit) => { + if (failure === "cancellation") { + cancel.abort(error) + expect(init?.signal?.aborted).toBe(false) + } + return Response.json({ data: { sessionId: "ses-fixture", status: "provisioning" } }) + }) + let learnedId: string | undefined + await expect( + controlledService(fetchImpl).createSession( + createInput, + (id) => { + learnedId = id + if (failure === "journal failure") throw error + }, + cancel.signal, + ), + ).rejects.toBe(error) + expect(learnedId).toBe("ses-fixture") + expect(fetchImpl).toHaveBeenCalledTimes(1) + }) + + it.each(["readiness", "status"] as const)("bounds a hung %s request with a non-expiring caller signal", async (operation) => { + const timeout = AbortSignal.timeout.bind(AbortSignal) + vi.spyOn(AbortSignal, "timeout").mockImplementation((ms) => + timeout(ms === 610_000 ? 40 : ms === 15_000 ? (operation === "status" ? 20 : 1_000) : ms), + ) + const cancel = new AbortController() + const service = controlledService( + async (_url, init) => + new Promise((_resolve, reject) => { + init?.signal?.addEventListener("abort", () => reject(init.signal?.reason), { once: true }) + }), + ) + let deadline: ReturnType | undefined + try { + const request = + operation === "readiness" + ? service.waitUntilReady("ses-fixture", cancel.signal) + : service.getStatus("ses-fixture", cancel.signal) + const outcome = await Promise.race([ + request.then( + () => "ready", + (error) => { + if (operation === "readiness") { + expect(error).toMatchObject({ + code: "request_failed", + message: "Timed out waiting for the cloud sandbox to become ready.", + }) + } else { + expect(error).toMatchObject({ name: "TimeoutError" }) + } + return "bounded" + }, + ), + new Promise((resolve) => { + deadline = setTimeout(() => resolve("hung"), 150) + }), + ]) + expect(outcome).toBe("bounded") + } finally { + clearTimeout(deadline) + cancel.abort() + } + }) + + it("does not expose server exception details in HTTP errors", async () => { + environment = await startLocalCloudEnvironment() + const response = await fetch(`${environment.apiBaseUrl}/api/v1/session`, { + method: "POST", + headers: { Authorization: `Bearer ${environment.accessToken}`, "Content-Type": "application/json" }, + body: "{", + }) + const body = (await response.json()) as { error?: string } + + expect(response.status).toBe(500) + expect(body.error).toBe("Local cloud fixture request failed") + expect(body.error).not.toMatch(/json|position|stack/i) + }) + + it("exercises the production service contract without credentials or non-loopback traffic", async () => { + environment = await startLocalCloudEnvironment() + const service = new CloudSessionsService({ + apiBaseUrl: environment.apiBaseUrl, + appBaseUrl: environment.apiBaseUrl, + getAuthToken: async () => environment?.accessToken, + getActiveOrganizationId: () => undefined, + }) + + const github = await service.getGitHubConnection() + expect(github).toMatchObject({ + connected: true, + repositories: [{ id: 1, fullName: "cline/fixture", defaultBranch: "main" }], + }) + expect(await service.listBranches(1)).toEqual(["main", "fixture"]) + + const created = await service.createSession({ + modelId: "fixture-model", + repoUrl: "https://github.com/cline/fixture", + branch: "fixture", + }) + expect(created).toMatchObject({ + status: "ready", + sandboxType: "resumable", + repoContext: { repoUrl: "https://github.com/cline/fixture", branch: "fixture" }, + metadata: { modelId: "fixture-model", taskId: expect.stringMatching(/^tsk-/) }, + }) + expect(service.sessionSocketUrl(created.id)).toBe( + environment.apiBaseUrl.replace("http://", "ws://") + `/api/v1/session/${created.id}`, + ) + + await service.renameSession(created.id, "renamed") + expect(await service.listSessions()).toEqual([expect.objectContaining({ id: created.id, title: "renamed" })]) + expect(await service.getStatus(created.id)).toEqual({ status: "ready", phase: "ready" }) + expect(await service.getHistory(created.id)).toEqual([]) + + await service.deleteSession(created.id) + expect(await service.listSessions()).toEqual([]) + }) + + it("stops waiting for a provisioning sandbox when the start is cancelled", async () => { + environment = await startLocalCloudEnvironment({ provisioningDelayMs: 60_000 }) + const service = new CloudSessionsService({ + apiBaseUrl: environment.apiBaseUrl, + appBaseUrl: environment.apiBaseUrl, + getAuthToken: async () => environment?.accessToken, + getActiveOrganizationId: () => undefined, + }) + const cancel = new AbortController() + let provisioningId: string | undefined + const creating = service.createSession( + { modelId: "fixture-model", repoUrl: "https://github.com/cline/fixture" }, + (id) => { + provisioningId = id + cancel.abort(new Error("cancelled by test")) + }, + cancel.signal, + ) + + await expect(creating).rejects.toThrow("cancelled by test") + expect(provisioningId).toMatch(/^ses-/) + // The record exists in `provisioning`; the caller owns its deletion. + expect(await service.getStatus(provisioningId!)).toEqual({ status: "provisioning", phase: "cloning_repo" }) + await service.deleteSession(provisioningId!) + expect(await service.listSessions()).toEqual([]) + }) + + it("adopts a sandbox whose create response was lost instead of provisioning a second one", async () => { + environment = await startLocalCloudEnvironment() + const service = new CloudSessionsService({ + apiBaseUrl: environment.apiBaseUrl, + appBaseUrl: environment.apiBaseUrl, + getAuthToken: async () => environment?.accessToken, + getActiveOrganizationId: () => undefined, + }) + environment.loseNextCreateResponse() + + const created = await service.createSession({ modelId: "fixture-model", repoUrl: "https://github.com/cline/fixture" }) + + expect([...environment.sessions.keys()]).toEqual([created.id]) + // The request marker used for recovery never reaches History as a title. + expect(await service.listSessions()).toEqual([expect.objectContaining({ id: created.id, title: undefined })]) + }) + + it("reports provisioning phases while a sandbox starts", async () => { + environment = await startLocalCloudEnvironment({ provisioningDelayMs: 4_000 }) + const service = new CloudSessionsService({ + apiBaseUrl: environment.apiBaseUrl, + appBaseUrl: environment.apiBaseUrl, + getAuthToken: async () => environment?.accessToken, + getActiveOrganizationId: () => undefined, + }) + const phases: string[] = [] + + await service.createSession( + { modelId: "fixture-model", repoUrl: "https://github.com/cline/fixture" }, + undefined, + undefined, + (phase) => { + if (phases.at(-1) !== phase) phases.push(phase) + }, + ) + + expect(phases).toEqual(["cloning_repo", "agent_starting", "ready"]) + }) + + it("rejects an invalid credential", async () => { + environment = await startLocalCloudEnvironment() + const service = new CloudSessionsService({ + apiBaseUrl: environment.apiBaseUrl, + appBaseUrl: environment.apiBaseUrl, + getAuthToken: async () => "wrong-token", + getActiveOrganizationId: () => undefined, + }) + + await expect(service.listSessions()).rejects.toMatchObject({ + code: "authentication_required", + message: "Unauthorized", + } satisfies Partial) + }) + + it("serializes concurrent activation as one owned sandbox", async () => { + environment = await startLocalCloudEnvironment() + const service = new CloudSessionsService({ + apiBaseUrl: environment.apiBaseUrl, + appBaseUrl: environment.apiBaseUrl, + getAuthToken: async () => environment?.accessToken, + getActiveOrganizationId: () => undefined, + }) + const created = await service.createSession({ + modelId: "fixture-model", + repoUrl: "https://github.com/cline/fixture", + }) + + const [first, second] = await Promise.all([ + environment.activateSession(created.id), + environment.activateSession(created.id), + ]) + + expect(first).toBe(second) + expect(first.hub).toBeDefined() + }) +}) diff --git a/apps/vscode/src/test/cloud/cloud-session-gone-boundary.test.ts b/apps/vscode/src/test/cloud/cloud-session-gone-boundary.test.ts new file mode 100644 index 0000000000..c19905dda3 --- /dev/null +++ b/apps/vscode/src/test/cloud/cloud-session-gone-boundary.test.ts @@ -0,0 +1,75 @@ +import { afterEach, describe, expect, it, vi } from "vitest" +import { MessageIdMinter } from "@/sdk/message-id-minter" +import { SdkCloudSessionCoordinator, type SdkCloudSessionCoordinatorOptions } from "@/sdk/sdk-cloud-session-coordinator" +import type { TaskProxy } from "@/sdk/task-proxy" +import { CloudSessionsService } from "@/services/cloud/CloudSessionsService" +import { type LocalCloudEnvironment, startLocalCloudEnvironment } from "./local-cloud-environment" + +// Only the IDE window and model-catalog refresh are replaced; the control +// plane, proxy, Hub transport and coordinator are the shipped implementations. +vi.mock("@/hosts/host-provider", () => ({ HostProvider: { window: { showMessage: vi.fn(async () => ({})) } } })) +vi.mock("@/core/controller/models/refreshClineRecommendedModels", () => ({ refreshClineRecommendedModels: vi.fn() })) + +describe("opening a cloud session whose sandbox is gone", () => { + let environment: LocalCloudEnvironment | undefined + let coordinator: SdkCloudSessionCoordinator | undefined + + afterEach(async () => { + await coordinator?.dispose() + await environment?.dispose() + coordinator = undefined + environment = undefined + }) + + it("explains a session deleted from the dashboard and drops it from History", async () => { + environment = await startLocalCloudEnvironment() + const service = new CloudSessionsService({ + apiBaseUrl: environment.apiBaseUrl, + appBaseUrl: environment.apiBaseUrl, + getAuthToken: async () => environment?.accessToken, + getActiveOrganizationId: () => undefined, + }) + const record = await service.createSession({ modelId: "fixture-model", repoUrl: "https://github.com/cline/fixture" }) + let task: TaskProxy | undefined + const invalidateHistoryCache = vi.fn() + coordinator = new SdkCloudSessionCoordinator({ + cloudSessions: service, + stateManager: { getGlobalSettingsKey: () => "act", getGlobalStateKey: () => ({}), setGlobalState: vi.fn() }, + sessionConfigBuilder: { build: vi.fn() }, + sessions: { attachExistingSession: vi.fn() }, + messages: { finalizeMessagesForSave: (messages: unknown) => messages }, + getMinter: () => new MessageIdMinter(), + getTask: () => task, + setTask: (next: TaskProxy | undefined) => { + task = next + }, + clearTask: async () => { + task = undefined + return () => false + }, + claimTaskViewGeneration: () => () => false, + getAuthToken: async () => environment?.accessToken, + isSignedIn: () => true, + isEnabled: () => true, + resetMessageTranslator: vi.fn(), + setTurnPhase: vi.fn(), + postStateToWebview: vi.fn(async () => {}), + invalidateHistoryCache, + onAskResponse: vi.fn(), + onCancelTask: vi.fn(), + } as unknown as SdkCloudSessionCoordinatorOptions) + + // History lists the session, then someone deletes it in the dashboard. + expect((await coordinator.listHistoryRecords()).map((row) => row.sessionId)).toEqual([record.id]) + await service.deleteSession(record.id) + await coordinator.openCloudTask(record.id) + + const messages = task?.messageStateHandler.getClineMessages() ?? [] + expect(messages).toHaveLength(1) + expect(messages[0]).toMatchObject({ say: "info" }) + expect(messages[0].text).toContain("was deleted") + expect(messages[0].text).not.toContain("Unexpected server response") + expect(invalidateHistoryCache).toHaveBeenCalled() + expect(await coordinator.listHistoryRecords()).toEqual([]) + }) +}) diff --git a/apps/vscode/src/test/cloud/cloud-session-host-boundary.test.ts b/apps/vscode/src/test/cloud/cloud-session-host-boundary.test.ts new file mode 100644 index 0000000000..9d96a41556 --- /dev/null +++ b/apps/vscode/src/test/cloud/cloud-session-host-boundary.test.ts @@ -0,0 +1,146 @@ +import { SessionSource } from "@cline/core" +import { CLOUD_SESSION_SYSTEM_PROMPT } from "@cline/core/cloud" +import { afterEach, describe, expect, it } from "vitest" +import { CloudSessionHost } from "@/sdk/cloud-session-host" +import { CloudSessionsService } from "@/services/cloud/CloudSessionsService" +import { type LocalCloudEnvironment, startLocalCloudEnvironment } from "./local-cloud-environment" + +describe("CloudSessionHost real Hub boundary", () => { + let environment: LocalCloudEnvironment | undefined + let host: CloudSessionHost | undefined + + afterEach(async () => { + await environment?.dispose() + await host?.dispose("test teardown") + host = undefined + environment = undefined + }) + + it("authenticates, maps the outer id, and runs a turn through the real Hub", async () => { + let releaseSecondTurn!: () => void + let secondTurnEntered!: () => void + const entered = new Promise((resolve) => { + secondTurnEntered = resolve + }) + const released = new Promise((resolve) => { + releaseSecondTurn = resolve + }) + let turns = 0 + environment = await startLocalCloudEnvironment({ + beforeModelResponse: async () => { + if (++turns === 2) { + secondTurnEntered() + await released + } + }, + }) + const service = new CloudSessionsService({ + apiBaseUrl: environment.apiBaseUrl, + appBaseUrl: environment.apiBaseUrl, + getAuthToken: async () => environment?.accessToken, + getActiveOrganizationId: () => undefined, + }) + const record = await service.createSession({ + modelId: "fixture-model", + repoUrl: "https://github.com/cline/fixture", + branch: "main", + }) + const owned = await environment.activateSession(record.id) + expect(owned).toBeDefined() + const taskId = record.metadata.taskId + expect(taskId).toMatch(/^tsk-/) + if (!taskId) throw new Error("Local cloud session did not return a task id") + + host = await CloudSessionHost.connect({ + outerSessionId: record.id, + taskId, + socketUrl: service.sessionSocketUrl(record.id), + workspaceRoot: owned?.root, + getAuthToken: async () => environment?.accessToken, + }) + + const started = await host.start({ + source: SessionSource.CORE, + interactive: true, + prompt: undefined, + config: { + providerId: "cline", + modelId: "fixture-model", + apiKey: "fixture-key", + systemPrompt: "normal Cline guidance", + enableTools: false, + enableSpawnAgent: false, + enableAgentTeams: false, + cwd: owned?.root, + workspaceRoot: owned?.root, + }, + }) + + expect(started.sessionId).toBe(record.id) + expect(taskId).not.toBe(record.id) + await host.send({ sessionId: record.id, prompt: "reply from the fixture" }) + expect(host.status).not.toBe("running") + expect(owned.sessionStore?.get(taskId)?.sessionId).toBe(taskId) + expect(owned.sessionStore?.get(record.id)).toBeUndefined() + const messages = await host.readMessages(record.id) + expect(JSON.stringify(messages)).toContain("cloud fixture reply") + expect(JSON.stringify(messages)).toContain("reply from the fixture") + const created = owned.sessionStore?.get(taskId) + expect(created?.metadata?.systemPrompt).toEqual(expect.stringContaining(CLOUD_SESSION_SYSTEM_PROMPT)) + expect(created?.metadata?.systemPrompt).toContain(`cline/${taskId.slice(-8).toLowerCase()}`) + expect(created?.metadata?.systemPrompt).toContain("normal Cline guidance") + + // A status-only connection has no turn subscription. Re-read its snapshot + // when another client resumes the same canonical task. + const observer = await CloudSessionHost.connect({ + outerSessionId: record.id, + taskId, + socketUrl: service.sessionSocketUrl(record.id), + workspaceRoot: owned.root, + getAuthToken: async () => environment?.accessToken, + }) + const sending = host.send({ sessionId: record.id, prompt: "resume from another client" }) + void sending.catch(() => {}) + try { + await Promise.race([ + entered, + sending.then(() => { + throw new Error("Second turn ended before reaching the model") + }), + ]) + expect(await observer.refreshStatus()).toBe("running") + releaseSecondTurn() + await sending + expect(await observer.refreshStatus()).not.toBe("running") + } finally { + releaseSecondTurn() + await sending.catch(() => {}) + await observer.dispose() + } + }) + + it("rejects the WebSocket connection when the cloud credential is wrong", async () => { + environment = await startLocalCloudEnvironment() + const service = new CloudSessionsService({ + apiBaseUrl: environment.apiBaseUrl, + appBaseUrl: environment.apiBaseUrl, + getAuthToken: async () => environment?.accessToken, + getActiveOrganizationId: () => undefined, + }) + const record = await service.createSession({ + modelId: "fixture-model", + repoUrl: "https://github.com/cline/fixture", + }) + const taskId = record.metadata.taskId + if (!taskId) throw new Error("Local cloud session did not return a task id") + + await expect( + CloudSessionHost.connect({ + outerSessionId: record.id, + taskId, + socketUrl: service.sessionSocketUrl(record.id), + getAuthToken: async () => "wrong-token", + }), + ).rejects.toThrow() + }) +}) diff --git a/apps/vscode/src/test/cloud/cloud-session-resume-boundary.test.ts b/apps/vscode/src/test/cloud/cloud-session-resume-boundary.test.ts new file mode 100644 index 0000000000..a61aa8d650 --- /dev/null +++ b/apps/vscode/src/test/cloud/cloud-session-resume-boundary.test.ts @@ -0,0 +1,157 @@ +import { SessionSource } from "@cline/core" +import { afterEach, describe, expect, it, vi } from "vitest" +import { CloudSessionHost } from "@/sdk/cloud-session-host" +import { MessageIdMinter } from "@/sdk/message-id-minter" +import { SdkCloudSessionCoordinator, type SdkCloudSessionCoordinatorOptions } from "@/sdk/sdk-cloud-session-coordinator" +import type { SdkSessionHost } from "@/sdk/session-host" +import type { TaskProxy } from "@/sdk/task-proxy" +import { CloudSessionsService } from "@/services/cloud/CloudSessionsService" +import { type LocalCloudEnvironment, startLocalCloudEnvironment } from "./local-cloud-environment" + +// Only the IDE window and model-catalog refresh are replaced; the control +// plane, proxy, Hub transport and coordinator are the shipped implementations. +vi.mock("@/hosts/host-provider", () => ({ HostProvider: { window: { showMessage: vi.fn(async () => ({})) } } })) +vi.mock("@/core/controller/models/refreshClineRecommendedModels", () => ({ refreshClineRecommendedModels: vi.fn() })) + +const config = { + providerId: "cline", + modelId: "fixture-model", + systemPrompt: "normal Cline guidance", + enableTools: false, + enableSpawnAgent: false, + enableAgentTeams: false, +} + +describe("resuming a suspended cloud sandbox through the real Hub", () => { + let environment: LocalCloudEnvironment | undefined + let coordinator: SdkCloudSessionCoordinator | undefined + const hosts: CloudSessionHost[] = [] + + afterEach(async () => { + await coordinator?.dispose() + coordinator = undefined + await Promise.allSettled(hosts.map((host) => host.dispose("test teardown"))) + hosts.length = 0 + await environment?.dispose() + environment = undefined + }) + + /** A cloud task that has finished one turn, with no client connected. */ + async function finishedTask() { + environment = await startLocalCloudEnvironment() + const service = new CloudSessionsService({ + apiBaseUrl: environment.apiBaseUrl, + appBaseUrl: environment.apiBaseUrl, + getAuthToken: async () => environment?.accessToken, + getActiveOrganizationId: () => undefined, + }) + const record = await service.createSession({ modelId: "fixture-model", repoUrl: "https://github.com/cline/fixture" }) + const taskId = record.metadata.taskId + if (!taskId) throw new Error("Local cloud session did not return a task id") + const connect = async (restoreConfig?: () => Promise) => { + const host = await CloudSessionHost.connect({ + outerSessionId: record.id, + taskId, + socketUrl: service.sessionSocketUrl(record.id), + getAuthToken: async () => environment?.accessToken, + restoreConfig, + }) + hosts.push(host) + return host + } + const first = await connect() + await first.start({ source: SessionSource.CORE, interactive: true, prompt: undefined, config }) + await first.send({ sessionId: record.id, prompt: "first prompt" }) + expect(first.status).toBe("completed") + await first.dispose("left the task") + return { environment, service, record, connect } + } + + it("refuses sockets while suspended, then continues the saved conversation after resume", async () => { + const { environment, service, record, connect } = await finishedTask() + + await environment.suspendSession(record.id) + await expect(service.getStatus(record.id)).resolves.toMatchObject({ status: "suspended" }) + await expect(connect()).rejects.toThrow("409") + + await service.resumeSession(record.id) + const resumed = await connect(async () => config) + expect(JSON.stringify(await resumed.readMessages(record.id))).toContain("first prompt") + await resumed.send({ sessionId: record.id, prompt: "follow-up after resume" }) + expect(resumed.status).toBe("completed") + const transcript = JSON.stringify(await resumed.readMessages(record.id)) + expect(transcript).toContain("first prompt") + expect(transcript).toContain("follow-up after resume") + }) + + it.each([ + "after", + "before", + ] as const)("opens a task History listed %s its sandbox was suspended by resuming it", async (listed) => { + const { environment, service, record } = await finishedTask() + let task: TaskProxy | undefined + let attached: SdkSessionHost | undefined + const renders: string[] = [] + coordinator = new SdkCloudSessionCoordinator({ + cloudSessions: service, + stateManager: { getGlobalSettingsKey: () => "act", getGlobalStateKey: () => ({}), setGlobalState: vi.fn() }, + sessionConfigBuilder: { build: vi.fn(async () => config) }, + sessions: { + attachExistingSession: vi.fn(async ({ sdkHost }: { sdkHost: SdkSessionHost }) => { + attached = sdkHost + }), + }, + messages: { finalizeMessagesForSave: (messages: unknown) => messages }, + getMinter: () => new MessageIdMinter(), + getTask: () => task, + setTask: (next: TaskProxy | undefined) => { + task = next + }, + clearTask: async () => { + task = undefined + return () => false + }, + claimTaskViewGeneration: () => () => false, + getAuthToken: async () => environment.accessToken, + isSignedIn: () => true, + isEnabled: () => true, + resetMessageTranslator: vi.fn(() => renders.push("new epoch")), + setTurnPhase: vi.fn(), + postStateToWebview: vi.fn(async () => { + const text = task?.messageStateHandler.getClineMessages().at(-1)?.text + if (text !== undefined) renders.push(`post: ${text}`) + }), + showChatView: vi.fn(async () => renders.push("chat view shown")), + invalidateHistoryCache: vi.fn(), + onAskResponse: vi.fn(), + onCancelTask: vi.fn(), + } as unknown as SdkCloudSessionCoordinatorOptions) + + if (listed === "before") await coordinator.listHistoryRecords() + await environment.suspendSession(record.id) + if (listed === "after") { + // Never Unconfirmed, so History does not probe it with sockets that would be refused. + const [row] = await coordinator.listHistoryRecords() + expect(row.metadata?.cloudStatus).not.toBe("unknown") + expect(await coordinator.resolveStatuses([record.id])).not.toContainEqual( + expect.objectContaining({ status: "unknown" }), + ) + } + + await coordinator.openCloudTask(record.id) + // The transcript starts a new epoch after the notice, so the webview replaces the notice. + const noticePosted = renders.indexOf("post: Resuming the cloud sandbox…") + expect(noticePosted).toBeGreaterThan(-1) + // The chat view is brought forward while the notice is showing, before the conversation replaces it. + expect(renders.indexOf("chat view shown")).toBeGreaterThan(noticePosted) + expect(renders.lastIndexOf("new epoch")).toBeGreaterThan(renders.indexOf("chat view shown")) + expect(environment.sessions.get(record.id)?.record.status).toBe("ready") + const shown = JSON.stringify(task?.messageStateHandler.getClineMessages()) + expect(shown).toContain("first prompt") + expect(shown).not.toContain("Could not connect") + + if (!attached) throw new Error("The resumed task was not attached") + await attached.send({ sessionId: record.id, prompt: "follow-up after resume" }) + expect(JSON.stringify(await attached.readMessages(record.id))).toContain("follow-up after resume") + }) +}) diff --git a/apps/vscode/src/test/cloud/local-cloud-development.test.ts b/apps/vscode/src/test/cloud/local-cloud-development.test.ts new file mode 100644 index 0000000000..cb95a65088 --- /dev/null +++ b/apps/vscode/src/test/cloud/local-cloud-development.test.ts @@ -0,0 +1,378 @@ +import * as fs from "node:fs/promises" +import { createServer } from "node:http" +import * as os from "node:os" +import path from "node:path" +import { afterEach, describe, expect, it, vi } from "vitest" +import WebSocket from "ws" +import { ClineEndpoint, ClineEnv, Environment } from "@/config" +import { startLocalCloudDevelopment } from "@/dev/local-cloud-development" +import { CloudSessionsService, isCloudSessionExpired } from "@/services/cloud/CloudSessionsService" +import type { UserResponse } from "@/shared/ClineAccount" +import { startLocalCloudEnvironment } from "./local-cloud-environment" + +vi.mock("node:fs/promises", async (original) => { + const actual = await original() + return { ...actual, writeFile: vi.fn(actual.writeFile) } +}) + +describe("local cloud development ownership", () => { + afterEach(() => { + vi.restoreAllMocks() + vi.unstubAllEnvs() + }) + + it("removes both roots when a real listener already owns the requested port", async () => { + const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "cloud-startup-test-")) + const listener = createServer() + try { + await new Promise((resolve) => listener.listen(0, "127.0.0.1", resolve)) + const address = listener.address() + if (!address || typeof address === "string") throw new Error("No port") + await expect(startLocalCloudDevelopment({ port: address.port, tempDir })).rejects.toMatchObject({ + code: "EADDRINUSE", + }) + expect(await fs.readdir(tempDir)).toEqual([]) + expect(listener.listening).toBe(true) + } finally { + await new Promise((resolve) => listener.close(() => resolve())) + await fs.rm(tempDir, { recursive: true, force: true }) + } + }) + + it("closes a bound fixture and removes its profile when writing settings fails", async () => { + const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "cloud-startup-test-")) + const write = vi.spyOn(fs, "writeFile").mockRejectedValueOnce(new Error("disk full")) + try { + await expect(startLocalCloudDevelopment({ port: 0, tempDir })).rejects.toThrow("disk full") + expect(await fs.readdir(tempDir)).toEqual([]) + } finally { + write.mockRestore() + await fs.rm(tempDir, { recursive: true, force: true }) + } + }) + + it("generates app and GitHub links through the real extension environment and service", async () => { + const development = await startLocalCloudDevelopment({ port: 0 }) + try { + vi.stubEnv("HOME", development.clineDir) + vi.stubEnv("USERPROFILE", development.clineDir) + for (const [key, value] of Object.entries(development.launchEnv)) vi.stubEnv(key, value) + await ClineEndpoint.initialize(development.clineDir) + const service = new CloudSessionsService({ + getAuthToken: async () => development.environment.accessToken, + getActiveOrganizationId: () => undefined, + }) + expect(ClineEnv.config().apiBaseUrl).toBe(development.environment.apiBaseUrl) + expect(ClineEnv.config().mcpBaseUrl).toBe(`${development.environment.apiBaseUrl}/v1/mcp`) + for (const url of [service.dashboardUrl("ses-local"), service.githubConnectUrl()]) { + expect(new URL(url).origin).toBe(development.environment.apiBaseUrl) + expect((await fetch(url)).status).toBe(200) + } + vi.stubEnv("CLINE_LOCAL_CLOUD_URL", "https://example.com") + expect(() => ClineEnv.config()).toThrow("loopback") + ClineEnv.setEnvironment(Environment.production) + expect(ClineEnv.config().appBaseUrl).toBe("https://app.cline.bot") + } finally { + await Promise.all([development.dispose(), development.dispose()]) + await expect(fs.access(development.clineDir)).rejects.toThrow() + } + }) + + it("seeds the current provider and onboarding storage contracts", async () => { + const development = await startLocalCloudDevelopment({ port: 0 }) + try { + const globalState = JSON.parse( + await fs.readFile(path.join(development.clineDir, "data", "globalState.json"), "utf8"), + ) as Record + const providers = JSON.parse( + await fs.readFile(path.join(development.clineDir, "data", "settings", "providers.json"), "utf8"), + ) as { + lastUsedProvider?: string + providers: Record< + string, + { + settings?: { provider?: string; auth?: { accessToken?: string; accountId?: string } } + tokenSource?: string + } + > + } + + expect(globalState.welcomeViewCompleted).toBe(true) + expect(providers.lastUsedProvider).toBe("cline") + expect(providers.providers.cline).toMatchObject({ + settings: { + provider: "cline", + auth: { + accessToken: `workos:${development.environment.accessToken}`, + accountId: "local-cloud-user", + }, + }, + tokenSource: "oauth", + }) + } finally { + await development.dispose() + } + }) + + it("switches the fixture account over HTTP and keeps repositories and session lists scoped", async () => { + const development = await startLocalCloudDevelopment({ port: 0 }) + const { environment } = development + const headers = { Authorization: `Bearer workos:${environment.accessToken}`, "Content-Type": "application/json" } + let activeOrganizationId: string | null = null + const service = new CloudSessionsService({ + apiBaseUrl: environment.apiBaseUrl, + appBaseUrl: environment.apiBaseUrl, + getAuthToken: async () => `workos:${environment.accessToken}`, + getActiveOrganizationId: () => activeOrganizationId, + }) + const refreshUser = async () => { + const response = await fetch(`${environment.apiBaseUrl}/api/v1/users/me`, { headers }) + expect(response.status).toBe(200) + const { data } = (await response.json()) as { data: UserResponse } + activeOrganizationId = data.organizations.find((organization) => organization.active)?.organizationId ?? null + return data + } + const switchAccount = async (organizationId: string | null) => { + const response = await fetch(`${environment.apiBaseUrl}/api/v1/users/active-account`, { + method: "PUT", + headers, + body: JSON.stringify({ organizationId }), + }) + expect(response.status).toBe(200) + expect(await response.json()).toEqual({ success: true, data: expect.any(String) }) + await refreshUser() + expect(activeOrganizationId).toBe(organizationId) + } + try { + const user = await refreshUser() + expect(user.organizations).toEqual([ + { + active: false, + memberId: "local-cloud-member", + name: "Local Cloud QA", + organizationId: "local-cloud-organization", + roles: ["owner"], + }, + ]) + const personal = await service.getGitHubConnection() + expect(personal.repositories).toMatchObject([{ id: 1, fullName: "cline/fixture" }]) + expect(await service.listBranches(1)).toEqual(["main", "fixture"]) + const personalSession = await service.createSession({ + modelId: "fixture-model", + repoUrl: personal.repositories[0].url, + }) + await service.renameSession(personalSession.id, "Personal history") + + await switchAccount(user.organizations[0].organizationId) + const organization = await service.getGitHubConnection() + expect(organization).toMatchObject({ + connected: true, + connectUrl: `${environment.apiBaseUrl}/dashboard/organization/integrations`, + repositories: [{ id: 2, fullName: "cline/organization-fixture" }], + }) + expect(await service.listBranches(2)).toEqual(["main", "organization-fixture"]) + // A repository outside the account scope has no branches to offer, rather than an error. + expect(await service.listBranches(1)).toEqual([]) + expect(await service.listSessions()).toEqual([]) + expect(await service.getSession(personalSession.id)).toBeUndefined() + const organizationSession = await service.createSession({ + modelId: "fixture-model", + repoUrl: organization.repositories[0].url, + }) + await service.renameSession(organizationSession.id, "Organization history") + expect(await service.listSessions()).toEqual([ + expect.objectContaining({ id: organizationSession.id, title: "Organization history" }), + ]) + // An omitted organizationId remains Personal even while the profile selects an org. + const personalList = await fetch(`${environment.apiBaseUrl}/api/v1/session`, { headers }) + expect(await personalList.json()).toMatchObject({ data: [{ id: personalSession.id }] }) + + await switchAccount(null) + expect(await service.getGitHubConnection()).toEqual(personal) + expect(await service.listBranches(2)).toEqual([]) + expect(await service.listSessions()).toEqual([ + expect.objectContaining({ id: personalSession.id, title: "Personal history" }), + ]) + expect(await service.getSession(organizationSession.id)).toBeUndefined() + await switchAccount(user.organizations[0].organizationId) + expect(await service.listSessions()).toEqual([ + expect.objectContaining({ id: organizationSession.id, title: "Organization history" }), + ]) + } finally { + await development.dispose() + } + }) + + it("serves fixture credit and disabled remote config contracts without external requests", async () => { + const environment = await startLocalCloudEnvironment() + const headers = { Authorization: `Bearer ${environment.accessToken}` } + try { + const profile = await fetch(`${environment.apiBaseUrl}/api/v1/users/me`, { headers }) + const { data: user } = (await profile.json()) as { data: UserResponse } + const [organization] = user.organizations + const userPath = `/api/v1/users/${user.id}` + const organizationPath = `/api/v1/organizations/${organization.organizationId}` + const responses = [ + [`${userPath}/balance`, { balance: 100, userId: user.id }], + [`${userPath}/usages`, { items: [] }], + [`${userPath}/payments`, { paymentTransactions: [] }], + [`${organizationPath}/balance`, { balance: 250, organizationId: organization.organizationId }], + [`${organizationPath}/members/${organization.memberId}/usages`, { items: [] }], + ["/api/v1/users/me/remote-config", null], + [`${organizationPath}/remote-config`, { enabled: false, value: "{}" }], + ] as const + for (const [endpoint, data] of responses) { + const response = await fetch(`${environment.apiBaseUrl}${endpoint}`, { headers }) + expect(response.status, endpoint).toBe(200) + expect(await response.json(), endpoint).toEqual({ success: true, data }) + } + } finally { + await environment.dispose() + } + }) + + it("rejects unauthorized switches and unknown organization scopes without changing fixture state", async () => { + const environment = await startLocalCloudEnvironment() + const headers = { Authorization: `Bearer ${environment.accessToken}`, "Content-Type": "application/json" } + try { + const unauthorized = await fetch(`${environment.apiBaseUrl}/api/v1/users/active-account`, { + method: "PUT", + body: JSON.stringify({ organizationId: "local-cloud-organization" }), + }) + expect(unauthorized.status).toBe(401) + for (const [endpoint, method] of [ + ["/api/v1/users/active-account", "PUT"], + ["/api/v1/session", "POST"], + ["/api/v1/session?organizationId=unknown", "GET"], + ["/api/v1/organizations/unknown/integrations/github/repositories", "GET"], + ] as const) { + const response = await fetch(`${environment.apiBaseUrl}${endpoint}`, { + method, + headers, + ...(method !== "GET" ? { body: JSON.stringify({ organizationId: "unknown" }) } : {}), + }) + expect(response.status, endpoint).toBe(403) + } + const profile = await fetch(`${environment.apiBaseUrl}/api/v1/users/me`, { headers }) + expect(await profile.json()).toMatchObject({ data: { organizations: [{ active: false }] } }) + expect(environment.sessions.size).toBe(0) + } finally { + await environment.dispose() + } + }) + + it("serves seeded expired sessions as archived-or-missing history and refuses their sandbox connection", async () => { + vi.stubEnv("CLINE_LOCAL_CLOUD_SEED_EXPIRED", "1") + const development = await startLocalCloudDevelopment({ port: 0 }) + const { environment } = development + const service = new CloudSessionsService({ + apiBaseUrl: environment.apiBaseUrl, + getAuthToken: async () => environment.accessToken, + getActiveOrganizationId: () => undefined, + }) + try { + const sessions = await service.listSessions() + expect(sessions.map((record) => record.title).sort()).toEqual(["Archived fixture task", "Unarchived fixture task"]) + for (const record of sessions) { + expect(record.status).toBe("active") + expect(isCloudSessionExpired(record)).toBe(true) + expect(record.repoContext).toEqual({ repoUrl: "https://github.com/cline/fixture", branch: "main" }) + expect(Date.parse(record.createdAt)).toBeLessThan(Date.parse(record.expiredAt ?? "")) + expect(await service.getStatus(record.id)).toEqual({ status: "expired" }) + const upgrade = await new Promise<{ status: number; body: string }>((resolve, reject) => { + const socket = new WebSocket(service.sessionSocketUrl(record.id), { + headers: { Authorization: `Bearer ${environment.accessToken}` }, + }) + socket.once("unexpected-response", (_request, response) => { + const chunks: Buffer[] = [] + response.on("data", (chunk: Buffer) => chunks.push(chunk)) + response.once("end", () => + resolve({ status: response.statusCode ?? 0, body: Buffer.concat(chunks).toString("utf8") }), + ) + }) + socket.once("open", () => reject(new Error("Expired session accepted a WebSocket upgrade"))) + socket.once("error", reject) + }) + expect(upgrade.status).toBe(410) + expect(JSON.parse(upgrade.body)).toEqual({ error: "session expired", success: false }) + } + const archived = sessions.find((record) => record.title === "Archived fixture task") + const unarchived = sessions.find((record) => record.title === "Unarchived fixture task") + if (!archived || !unarchived) throw new Error("Expected both seeded sessions") + expect(await service.getHistory(archived.id)).toEqual([ + expect.objectContaining({ role: "user", sessionId: archived.metadata.taskId }), + expect.objectContaining({ + role: "assistant", + content: [{ type: "text", text: "cloud fixture reply" }], + sessionId: archived.metadata.taskId, + }), + ]) + expect(await service.getHistory(unarchived.id)).toBeNull() + const missing = await fetch(`${environment.apiBaseUrl}/api/v1/session/${unarchived.id}/history`, { + headers: { Authorization: `Bearer ${environment.accessToken}` }, + }) + expect(missing.status).toBe(404) + expect(await missing.json()).toEqual({ error: "no history captured for this session" }) + const live = await service.createSession({ modelId: "fixture-model", repoUrl: "https://github.com/cline/fixture" }) + expect(await service.getHistory(live.id)).toEqual([]) + } finally { + await development.dispose() + } + }) + + it("scripts the hosted insufficient-credits reply for every model request", async () => { + vi.stubEnv("CLINE_LOCAL_CLOUD_INSUFFICIENT_CREDITS", "1") + const development = await startLocalCloudDevelopment({ port: 0 }) + try { + const response = await development.environment.modelFetch("https://api.cline.bot/api/v1/chat/completions", { + method: "POST", + body: "{}", + }) + expect(response.status).toBe(402) + expect(response.headers.get("content-type")).toBe("application/json") + expect(await response.json()).toEqual({ + error: { + code: "insufficient_credits", + message: "Not enough credits available", + current_balance: 0.01, + total_spent: 4.99, + total_promotions: 0, + buy_credits_url: "http://127.0.0.1/credits", + }, + }) + } finally { + await development.dispose() + } + }) + + it("removes a fixture root on invalid bind arguments as well as listener errors", async () => { + const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "cloud-startup-test-")) + try { + await expect(startLocalCloudEnvironment({ tempDir, port: -1 })).rejects.toThrow() + expect(await fs.readdir(tempDir)).toEqual([]) + } finally { + await fs.rm(tempDir, { recursive: true, force: true }) + } + }) + + it("reports sandbox cleanup failure after closing the listener and removing the profile", async () => { + const development = await startLocalCloudDevelopment({ port: 0 }) + const service = new CloudSessionsService({ + apiBaseUrl: development.environment.apiBaseUrl, + getAuthToken: async () => development.environment.accessToken, + getActiveOrganizationId: () => undefined, + }) + const record = await service.createSession({ modelId: "fixture-model", repoUrl: "https://github.com/cline/fixture" }) + const sandbox = await development.environment.activateSession(record.id) + if (!sandbox.hub) throw new Error("Expected a real Hub") + const close = sandbox.hub.close.bind(sandbox.hub) + vi.spyOn(sandbox.hub, "close").mockImplementation(async () => { + await close() + throw new Error("cleanup failure") + }) + await expect(development.dispose()).rejects.toThrow("Local cloud sandbox cleanup failed") + await expect(fs.access(development.clineDir)).rejects.toThrow() + await expect(fetch(`${development.environment.apiBaseUrl}/health`)).rejects.toThrow() + await expect(development.dispose()).rejects.toThrow("Local cloud sandbox cleanup failed") + }) +}) diff --git a/apps/vscode/src/test/cloud/local-cloud-environment.ts b/apps/vscode/src/test/cloud/local-cloud-environment.ts new file mode 100644 index 0000000000..e1e7581d77 --- /dev/null +++ b/apps/vscode/src/test/cloud/local-cloud-environment.ts @@ -0,0 +1,684 @@ +import { randomUUID } from "node:crypto" +import { mkdtemp, rm } from "node:fs/promises" +import { createServer, type IncomingMessage, type Server } from "node:http" +import type { Socket } from "node:net" +import { tmpdir } from "node:os" +import path from "node:path" +import { + CoreSessionService, + createLocalHubScheduleRuntimeHandlers, + type HubWebSocketServer, + LocalRuntimeHost, + SqliteSessionStore, + startHubWebSocketServer, +} from "@cline/core" +import type { MessageWithMetadata as SdkMessage } from "@cline/llms" +import WebSocket, { type RawData, WebSocketServer } from "ws" +import type { UserResponse } from "@/shared/ClineAccount" + +const LOOPBACK_HOST = "127.0.0.1" +const HUB_AUTH_PROTOCOL_PREFIX = "cline-hub-auth." +const FIXTURE_USER_ID = "local-cloud-user" +const FIXTURE_ORGANIZATION = { + organizationId: "local-cloud-organization", + memberId: "local-cloud-member", + name: "Local Cloud QA", + roles: ["owner"], +} satisfies Omit +const PERSONAL_REPOSITORY = { + id: 1, + name: "fixture", + full_name: "cline/fixture", + html_url: "https://github.com/cline/fixture", + default_branch: "main", +} +const ORGANIZATION_REPOSITORY = { + id: 2, + name: "organization-fixture", + full_name: "cline/organization-fixture", + html_url: "https://github.com/cline/organization-fixture", + default_branch: "main", +} + +export interface LocalCloudSessionRecord { + id: string + status: string + title?: string + sandboxUrl?: string + repoContext: { repoUrl?: string; branch?: string } + metadata: { + modelId?: string + taskId: string + statusReason?: string + sandboxType?: "standard" | "resumable" + provisioningPhase?: "provisioning" | "cloning_repo" | "agent_starting" | "ready" + } + sandboxType?: "standard" | "resumable" + expiredAt?: string | null + createdAt: string + updatedAt: string +} + +interface OwnedSandbox { + record: LocalCloudSessionRecord + organizationId: string | null + root: string + hub?: HubWebSocketServer + sessionStore?: SqliteSessionStore + /** Pending flip from `provisioning` to `ready`. */ + readyTimer?: ReturnType + /** Transcript snapshot served by GET /history once the sandbox is gone. */ + archive?: SdkMessage[] + /** Client sockets currently bridged to this sandbox's Hub. */ + clients: Set + /** Pending idle suspension after the last client disconnected. */ + suspendTimer?: ReturnType +} + +export interface LocalCloudEnvironment { + readonly apiBaseUrl: string + readonly accessToken: string + /** The fetch every sandbox uses for model requests; scripted, never leaves the process. */ + readonly modelFetch: typeof fetch + readonly sessions: ReadonlyMap + activateSession(sessionId: string): Promise + /** + * Suspend a resumable sandbox like the hosted control plane does after it + * idles: its Hub stops (dropping every client and live runtime) but its disk, + * including the saved conversation, survives until POST /resume. + */ + suspendSession(sessionId: string): Promise + /** Provision the next created sandbox but answer its POST with a 502, as if the response were lost. */ + loseNextCreateResponse(): void + /** Drop only the client transport; the sandbox continues running. */ + disconnectClients(): void + dispose(): Promise +} + +function json(res: import("node:http").ServerResponse, status: number, value?: unknown): void { + res.statusCode = status + if (value === undefined) { + res.end() + return + } + res.setHeader("content-type", "application/json") + res.end(JSON.stringify(value)) +} + +async function readJson(req: IncomingMessage): Promise> { + const chunks: Buffer[] = [] + for await (const chunk of req) chunks.push(Buffer.from(chunk)) + if (chunks.length === 0) return {} + return JSON.parse(Buffer.concat(chunks).toString("utf8")) as Record +} + +/** The 402 body the hosted chat router sends when the account runs out of credits. */ +const INSUFFICIENT_CREDITS_RESPONSE = { + error: { + code: "insufficient_credits", + message: "Not enough credits available", + current_balance: 0.01, + total_spent: 4.99, + total_promotions: 0, + buy_credits_url: "http://127.0.0.1/credits", + }, +} + +function isExpired(record: LocalCloudSessionRecord): boolean { + return typeof record.expiredAt === "string" && Date.parse(record.expiredAt) <= Date.now() +} + +/** The archived transcript captured from a sandbox before it expired. */ +function archivedTranscript(taskId: string, capturedAt: number): SdkMessage[] { + return [ + { + role: "user", + content: 'Summarize the fixture repository.', + sessionId: taskId, + ts: capturedAt, + }, + { + role: "assistant", + content: [{ type: "text", text: "cloud fixture reply" }], + sessionId: taskId, + modelInfo: { id: "fixture-model", provider: "cline" }, + metrics: { inputTokens: 1, outputTokens: 3 }, + ts: capturedAt + 1_000, + }, + ] +} + +function scriptedModelFetch(options: { + beforeResponse?: (signal?: AbortSignal | null) => Promise + insufficientCredits?: boolean +}): typeof fetch { + const fetchImpl = async (input: RequestInfo | URL, init?: RequestInit): Promise => { + const url = new URL(typeof input === "string" ? input : input instanceof URL ? input : input.url) + if (url.hostname !== "api.cline.bot" || url.pathname !== "/api/v1/chat/completions") { + throw new Error(`Local cloud fixture blocked unexpected model request to ${url.toString()}`) + } + if (init?.signal?.aborted) throw init.signal.reason + await options.beforeResponse?.(init?.signal) + if (init?.signal?.aborted) throw init.signal.reason + if (options.insufficientCredits) { + return new Response(JSON.stringify(INSUFFICIENT_CREDITS_RESPONSE), { + status: 402, + headers: { "content-type": "application/json" }, + }) + } + const body = [ + `data: ${JSON.stringify({ choices: [{ index: 0, delta: { role: "assistant", content: "cloud fixture reply" }, finish_reason: null }] })}\n\n`, + `data: ${JSON.stringify({ choices: [{ index: 0, delta: {}, finish_reason: "stop" }], usage: { prompt_tokens: 1, completion_tokens: 3, total_tokens: 4 } })}\n\n`, + "data: [DONE]\n\n", + ].join("") + return new Response(body, { status: 200, headers: { "content-type": "text/event-stream" } }) + } + return fetchImpl as typeof fetch +} + +export async function startLocalCloudEnvironment( + options: { + port?: number + accessToken?: string + tempDir?: string + beforeModelResponse?: (signal?: AbortSignal | null) => Promise + /** How long a new sandbox reports `provisioning` before `ready`. Default: ready at once. */ + provisioningDelayMs?: number + /** Seed two Personal sessions whose sandboxes expired an hour ago: one with an archived transcript, one without. */ + seedExpiredSessions?: boolean + /** Every scripted model reply is the hosted API's 402 insufficient-credits response. */ + insufficientCredits?: boolean + /** Every session socket upgrade is refused, so a start fails after the sandbox is ready. */ + refuseSessionSockets?: boolean + /** Suspend a sandbox this long after its last client disconnects. Default: never. */ + suspendIdleAfterMs?: number + } = {}, +): Promise { + const accessToken = options.accessToken ?? `local-cloud-${randomUUID()}` + const provisioningDelayMs = options.provisioningDelayMs ?? 0 + const modelFetch = scriptedModelFetch({ + beforeResponse: options.beforeModelResponse, + insufficientCredits: options.insufficientCredits, + }) + const root = await mkdtemp(path.join(options.tempDir ?? tmpdir(), "cline-local-cloud-")) + const sessions = new Map() + if (options.seedExpiredSessions) { + // The hosted control plane leaves an expired session's stored status as it + // was ("active") and lets expiredAt decide; readers compare it with now. + const twoDaysAgo = Date.now() - 2 * 24 * 60 * 60 * 1000 + const expiredAt = new Date(Date.now() - 60 * 60 * 1000).toISOString() + for (const [title, archived] of [ + ["Archived fixture task", true], + ["Unarchived fixture task", false], + ] as const) { + const id = `ses-${randomUUID()}` + const taskId = `tsk-${randomUUID()}` + sessions.set(id, { + record: { + id, + status: "active", + title, + repoContext: { repoUrl: PERSONAL_REPOSITORY.html_url, branch: PERSONAL_REPOSITORY.default_branch }, + metadata: { modelId: "fixture-model", taskId }, + expiredAt, + createdAt: new Date(twoDaysAgo).toISOString(), + updatedAt: new Date(twoDaysAgo).toISOString(), + }, + organizationId: null, + root: await mkdtemp(path.join(root, "sandbox-")), + archive: archived ? archivedTranscript(taskId, twoDaysAgo) : undefined, + clients: new Set(), + }) + } + } + let activeOrganizationId: string | null = null + const sockets = new Set() + const bridgedSockets = new Set() + const activations = new Map>() + const wss = new WebSocketServer({ noServer: true }) + let disposing = false + const activateSession = async (sessionId: string): Promise => { + if (disposing) throw new Error("Local cloud environment is disposing") + const owned = sessions.get(sessionId) + if (!owned) throw new Error(`Unknown local cloud session ${sessionId}`) + if (owned.hub) return owned + const pending = activations.get(sessionId) + if (pending) return pending + const activation = (async () => { + const sessionStore = new SqliteSessionStore({ sessionsDir: path.join(owned.root, "data") }) + try { + const sessionHost = new LocalRuntimeHost({ + sessionService: new CoreSessionService(sessionStore, { + sessionArtifactsDir: path.join(owned.root, "sessions"), + }), + fetch: modelFetch, + }) + const hub = await startHubWebSocketServer({ + host: LOOPBACK_HOST, + port: 0, + workspaceRoot: owned.root, + owner: { ownerId: sessionId, discoveryPath: path.join(owned.root, "hub.json") }, + eventLog: false, + runQueue: false, + sessionHost, + runtimeHandlers: createLocalHubScheduleRuntimeHandlers({ fetch: modelFetch }), + }) + owned.hub = hub + owned.sessionStore = sessionStore + return owned + } catch (error) { + sessionStore.close() + throw error + } + })() + activations.set(sessionId, activation) + try { + return await activation + } finally { + if (activations.get(sessionId) === activation) activations.delete(sessionId) + } + } + + let apiBaseUrl = "" + let loseNextCreateResponse = false + // The hosted sandbox is listening by the time /status says ready. Start the + // Hub as the session becomes ready rather than on the first socket upgrade: + // a cold start can outlast the client's connect timeout. Like the hosted + // control plane, /status also reports the provisioning phase: the repository + // is cloned first, then the agent starts. + const provision = (owned: OwnedSandbox) => { + const becomeReady = () => { + if (sessions.get(owned.record.id) !== owned) return + owned.record.status = "ready" + owned.record.metadata.provisioningPhase = "ready" + owned.record.sandboxUrl = apiBaseUrl + owned.record.updatedAt = new Date().toISOString() + void activateSession(owned.record.id).catch(() => undefined) + } + if (provisioningDelayMs > 0) { + owned.record.metadata.provisioningPhase = "cloning_repo" + const agentStarting = setTimeout(() => { + if (owned.record.status === "provisioning") owned.record.metadata.provisioningPhase = "agent_starting" + }, provisioningDelayMs / 2) + owned.readyTimer = setTimeout(() => { + owned.readyTimer = undefined + clearTimeout(agentStarting) + becomeReady() + }, provisioningDelayMs) + } else { + becomeReady() + } + } + const suspendSession = async (sessionId: string): Promise => { + const owned = sessions.get(sessionId) + if (!owned) throw new Error(`Unknown local cloud session ${sessionId}`) + clearTimeout(owned.suspendTimer) + owned.suspendTimer = undefined + await activations.get(sessionId)?.catch(() => undefined) + owned.record.status = "suspended" + owned.record.updatedAt = new Date().toISOString() + const { hub, sessionStore } = owned + owned.hub = undefined + owned.sessionStore = undefined + try { + await hub?.close() + } finally { + sessionStore?.close() + } + } + + const server: Server = createServer(async (req, res) => { + try { + const url = new URL(req.url ?? "/", apiBaseUrl) + if (url.pathname === "/health") return json(res, 200, { status: "ok" }) + if (["/agents", "/dashboard/integrations", "/dashboard/organization/integrations"].includes(url.pathname)) { + return json(res, 200, { fixture: "Local cloud development page", path: url.pathname }) + } + const presentedToken = req.headers.authorization?.replace(/^Bearer\s+/i, "").replace(/^workos:/i, "") + if (presentedToken !== accessToken) return json(res, 401, { error: "Unauthorized" }) + if (url.pathname === "/api/v1/users/me" && req.method === "GET") { + const now = new Date().toISOString() + return json(res, 200, { + success: true, + data: { + id: FIXTURE_USER_ID, + email: "local-cloud@example.test", + displayName: "Local Cloud Developer", + photoUrl: "", + organizations: [{ ...FIXTURE_ORGANIZATION, active: activeOrganizationId !== null }], + createdAt: now, + updatedAt: now, + } satisfies UserResponse, + }) + } + if (url.pathname === "/api/v1/users/active-account" && req.method === "PUT") { + const input = await readJson(req) + const organizationId = input.organizationId ?? null + if (organizationId !== null && organizationId !== FIXTURE_ORGANIZATION.organizationId) { + return json(res, 403, { error: "Unknown fixture organization" }) + } + // The switch commits before the response; subsequent profile reads see it. + activeOrganizationId = organizationId + return json(res, 200, { success: true, data: "Active account updated" }) + } + if (url.pathname === "/api/v1/users/me/remote-config" && req.method === "GET") { + return json(res, 200, { success: true, data: null }) + } + if (req.method === "GET") { + const userPath = `/api/v1/users/${FIXTURE_USER_ID}` + const organizationPath = `/api/v1/organizations/${FIXTURE_ORGANIZATION.organizationId}` + if (url.pathname === `${userPath}/balance`) { + return json(res, 200, { success: true, data: { balance: 100, userId: FIXTURE_USER_ID } }) + } + if (url.pathname === `${organizationPath}/balance`) { + return json(res, 200, { + success: true, + data: { balance: 250, organizationId: FIXTURE_ORGANIZATION.organizationId }, + }) + } + if ( + url.pathname === `${userPath}/usages` || + url.pathname === `${organizationPath}/members/${FIXTURE_ORGANIZATION.memberId}/usages` + ) { + return json(res, 200, { success: true, data: { items: [] } }) + } + if (url.pathname === `${userPath}/payments`) { + return json(res, 200, { success: true, data: { paymentTransactions: [] } }) + } + if (url.pathname === `${organizationPath}/remote-config`) { + return json(res, 200, { success: true, data: { enabled: false, value: "{}" } }) + } + } + + const repositoryPath = url.pathname.match( + /^\/api\/v1(?:\/organizations\/([^/]+))?\/integrations\/github\/repositories(?:\/(\d+)\/branches)?$/, + ) + if (repositoryPath && req.method === "GET") { + const organizationId = repositoryPath[1] ? decodeURIComponent(repositoryPath[1]) : null + if (organizationId !== null && organizationId !== FIXTURE_ORGANIZATION.organizationId) { + return json(res, 403, { error: "Unknown fixture organization" }) + } + const repository = organizationId ? ORGANIZATION_REPOSITORY : PERSONAL_REPOSITORY + if (repositoryPath[2]) { + if (Number(repositoryPath[2]) !== repository.id) return json(res, 404, { error: "Repository not found" }) + return json(res, 200, { + success: true, + data: [{ name: repository.default_branch }, { name: repository.name }], + }) + } + // CLINE_LOCAL_CLOUD_NO_REPOSITORIES=1 reproduces a connected GitHub App with + // no accessible repositories, so Cloud can be selected without a repository. + if (process.env.CLINE_LOCAL_CLOUD_NO_REPOSITORIES === "1") { + return json(res, 200, { success: true, data: [] }) + } + return json(res, 200, { success: true, data: [repository] }) + } + if (url.pathname === "/api/v1/session" && req.method === "GET") { + const organizationId = url.searchParams.get("organizationId") + if (organizationId !== null && organizationId !== FIXTURE_ORGANIZATION.organizationId) { + return json(res, 403, { error: "Unknown fixture organization" }) + } + return json(res, 200, { + success: true, + data: [...sessions.values()] + .filter((owned) => owned.organizationId === organizationId) + .map(({ record }) => record), + }) + } + if (url.pathname === "/api/v1/session" && req.method === "POST") { + const input = await readJson(req) + // Scope belongs to the create request, even if the account switches during provisioning. + const organizationId = input.organizationId ?? null + if (organizationId !== null && organizationId !== FIXTURE_ORGANIZATION.organizationId) { + return json(res, 403, { error: "Unknown fixture organization" }) + } + const id = `ses-${randomUUID()}` + const taskId = `tsk-${randomUUID()}` + const sandboxRoot = await mkdtemp(path.join(root, "sandbox-")) + const now = new Date().toISOString() + // The hosted control plane answers POST with `provisioning` and flips + // /status to `ready` once the sandbox is up; the fixture does the same + // after provisioningDelayMs so cancellation during that window is testable. + const sandboxType = input.sandboxType === "resumable" ? "resumable" : "standard" + const record: LocalCloudSessionRecord = { + id, + status: "provisioning", + // Clients tag creates with a request title so a lost response can be recovered. + ...(typeof input.title === "string" ? { title: input.title } : {}), + sandboxType, + repoContext: { + repoUrl: String(input.repoUrl ?? ""), + branch: typeof input.branch === "string" ? input.branch : undefined, + }, + metadata: { + modelId: typeof input.modelId === "string" ? input.modelId : undefined, + taskId, + sandboxType, + }, + createdAt: now, + updatedAt: now, + } + const owned: OwnedSandbox = { record, organizationId, root: sandboxRoot, clients: new Set() } + sessions.set(id, owned) + provision(owned) + if (loseNextCreateResponse) { + // The sandbox exists, but the client never learns its id. + loseNextCreateResponse = false + return json(res, 502, { error: "Bad gateway" }) + } + return json(res, 200, { + success: true, + data: { + sessionId: id, + status: record.status, + ...(record.sandboxUrl ? { sandboxUrl: record.sandboxUrl } : {}), + }, + }) + } + + const match = url.pathname.match(/^\/api\/v1\/session\/([^/]+)(?:\/(status|history|resume))?$/) + if (!match) return json(res, 404, { error: "Not found" }) + const owned = sessions.get(decodeURIComponent(match[1])) + if (!owned) return json(res, 404, { error: "Session not found" }) + if (match[2] === "resume" && req.method === "POST") { + if (owned.record.status !== "suspended") return json(res, 409, { error: "session is not suspended" }) + owned.record.status = "provisioning" + owned.record.updatedAt = new Date().toISOString() + provision(owned) + return json(res, 200, { success: true, data: owned.record }) + } + if (match[2] === "status" && req.method === "GET") { + return json(res, 200, { + success: true, + data: { + status: isExpired(owned.record) ? "expired" : owned.record.status, + ...(owned.record.metadata.provisioningPhase ? { phase: owned.record.metadata.provisioningPhase } : {}), + }, + }) + } + if (match[2] === "history" && req.method === "GET") { + // Like the hosted API, the snapshot is the top-level body (no `data` + // envelope). Live sandboxes have no snapshot yet; the hosted API only + // stores one when the client disconnects. Expired sessions serve the + // snapshot they captured, or answer 404 when the sandbox never produced one. + const snapshot = { version: 1, updated_at: owned.record.expiredAt, sessionId: owned.record.metadata.taskId } + if (!isExpired(owned.record)) return json(res, 200, { ...snapshot, messages: [] }) + if (!owned.archive) return json(res, 404, { error: "no history captured for this session" }) + return json(res, 200, { ...snapshot, messages: owned.archive }) + } + if (req.method === "GET") return json(res, 200, { success: true, data: owned.record }) + if (req.method === "PATCH") { + const input = await readJson(req) + owned.record.title = typeof input.title === "string" ? input.title : owned.record.title + return json(res, 200, { success: true, data: owned.record }) + } + if (req.method === "DELETE") { + sessions.delete(owned.record.id) + clearTimeout(owned.readyTimer) + clearTimeout(owned.suspendTimer) + await activations.get(owned.record.id)?.catch(() => undefined) + await owned.hub?.close() + owned.sessionStore?.close() + await rm(owned.root, { recursive: true, force: true }) + return json(res, 204) + } + return json(res, 405, { error: "Method not allowed" }) + } catch (error) { + console.error("Local cloud fixture request failed:", error) + json(res, 500, { error: "Local cloud fixture request failed" }) + } + }) + server.on("connection", (socket) => { + sockets.add(socket) + socket.once("close", () => sockets.delete(socket)) + }) + server.on("upgrade", (request, socket, head) => { + const url = new URL(request.url ?? "/", apiBaseUrl) + const match = url.pathname.match(/^\/api\/v1\/session\/([^/]+)$/) + const owned = match ? sessions.get(decodeURIComponent(match[1])) : undefined + const presentedToken = request.headers.authorization?.replace(/^Bearer\s+/i, "").replace(/^workos:/i, "") + if (presentedToken !== accessToken) { + socket.write("HTTP/1.1 401 Unauthorized\r\nConnection: close\r\n\r\n") + socket.destroy() + return + } + if (!owned) { + // The hosted proxy answers a deleted or unknown session this way. + const body = JSON.stringify({ error: "session not found", success: false }) + socket.end( + `HTTP/1.1 404 Not Found\r\nContent-Type: application/json\r\nContent-Length: ${Buffer.byteLength(body)}\r\nConnection: close\r\n\r\n${body}`, + ) + return + } + if (isExpired(owned.record)) { + const body = JSON.stringify({ error: "session expired", success: false }) + socket.end( + `HTTP/1.1 410 Gone\r\nContent-Type: application/json\r\nContent-Length: ${Buffer.byteLength(body)}\r\nConnection: close\r\n\r\n${body}`, + ) + return + } + if (owned.record.status === "suspended") { + const body = JSON.stringify({ error: "session is suspended; resume it before connecting" }) + socket.end( + `HTTP/1.1 409 Conflict\r\nContent-Type: application/json\r\nContent-Length: ${Buffer.byteLength(body)}\r\nConnection: close\r\n\r\n${body}`, + ) + return + } + if (options.refuseSessionSockets) { + socket.write("HTTP/1.1 503 Service Unavailable\r\nConnection: close\r\n\r\n") + socket.destroy() + return + } + void activateSession(owned.record.id) + .then((active) => { + if (disposing) { + socket.destroy() + return + } + if (!active.hub) throw new Error("Local cloud session did not start a Hub") + const upstream = new WebSocket(active.hub.url, [`${HUB_AUTH_PROTOCOL_PREFIX}${active.hub.authToken}`]) + bridgedSockets.add(upstream) + // Either side can drop without a close frame (an extension host that + // reloads, a Hub that is disposed mid-handshake). Every error, at any + // point in the socket's life, ends that socket; an unhandled "error" + // would exit the fixture process. + const bridge = (ws: WebSocket) => { + ws.once("close", () => bridgedSockets.delete(ws)) + ws.on("error", () => ws.terminate()) + } + bridge(upstream) + upstream.once("error", () => socket.destroy()) + upstream.once("open", () => { + wss.handleUpgrade(request, socket, head, (downstream: WebSocket) => { + bridgedSockets.add(downstream) + bridge(downstream) + clearTimeout(active.suspendTimer) + active.suspendTimer = undefined + active.clients.add(downstream) + downstream.once("close", () => { + active.clients.delete(downstream) + // Like the hosted control plane, only resumable sandboxes are suspended. + if ( + active.clients.size === 0 && + active.record.sandboxType === "resumable" && + options.suspendIdleAfterMs !== undefined && + !disposing + ) { + active.suspendTimer = setTimeout( + () => void suspendSession(active.record.id).catch(() => undefined), + options.suspendIdleAfterMs, + ) + } + }) + downstream.on("message", (data: RawData, binary: boolean) => upstream.send(data, { binary })) + upstream.on("message", (data: RawData, binary: boolean) => downstream.send(data, { binary })) + downstream.once("close", () => upstream.close()) + upstream.once("close", () => downstream.close()) + }) + }) + }) + .catch(() => socket.destroy()) + }) + + const disconnectClients = () => { + for (const ws of bridgedSockets) ws.terminate() + for (const socket of sockets) socket.destroy() + } + let disposal: Promise | undefined + const dispose = (): Promise => { + disposal ??= (async () => { + disposing = true + disconnectClients() + try { + await Promise.allSettled(activations.values()) + const results = await Promise.allSettled( + [...sessions.values()].map(async ({ hub, sessionStore, readyTimer, suspendTimer }) => { + clearTimeout(readyTimer) + clearTimeout(suspendTimer) + try { + await hub?.close() + } finally { + sessionStore?.close() + } + }), + ) + const errors = results.flatMap((result) => (result.status === "rejected" ? [result.reason] : [])) + if (errors.length > 0) throw new AggregateError(errors, "Local cloud sandbox cleanup failed") + } finally { + await new Promise((resolve) => server.close(() => resolve())) + wss.close() + await rm(root, { recursive: true, force: true }) + } + })() + return disposal + } + try { + await new Promise((resolve, reject) => { + server.once("error", reject) + server.listen(options.port ?? 0, LOOPBACK_HOST, () => { + server.removeListener("error", reject) + resolve() + }) + }) + const address = server.address() + if (!address || typeof address === "string") throw new Error("Local cloud fixture did not bind a TCP port") + apiBaseUrl = `http://${LOOPBACK_HOST}:${address.port}` + } catch (error) { + await dispose() + throw error + } + + return { + apiBaseUrl, + accessToken, + modelFetch, + sessions, + activateSession, + suspendSession, + loseNextCreateResponse: () => { + loseNextCreateResponse = true + }, + disconnectClients, + dispose, + } +} diff --git a/apps/vscode/src/types/ws.d.ts b/apps/vscode/src/types/ws.d.ts deleted file mode 100644 index 1201c092e8..0000000000 --- a/apps/vscode/src/types/ws.d.ts +++ /dev/null @@ -1,17 +0,0 @@ -declare module "ws" { - class WebSocket { - constructor(url: string) - on(event: "open", listener: () => void): this - on(event: "error", listener: (error: Error) => void): this - on(event: "close", listener: () => void): this - on(event: "message", listener: (data: WebSocket.Data) => void): this - send(data: string): void - close(): void - } - - namespace WebSocket { - export type Data = string | Buffer | ArrayBuffer | Buffer[] - } - - export default WebSocket -} diff --git a/apps/vscode/test-setup.js b/apps/vscode/test-setup.js index ca7d8bd20d..eb201f553f 100644 --- a/apps/vscode/test-setup.js +++ b/apps/vscode/test-setup.js @@ -236,6 +236,21 @@ Module.prototype.require = function (id) { } } + if (id === "@cline/core/cloud") { + class CloudSessionError extends Error {} + class CloudCreationUnconfirmedError extends CloudSessionError {} + return { + buildCloudSessionSystemPrompt: () => "", + CloudCreationUnconfirmedError, + CloudSessionApi: class { + constructor() { + throw new Error("Cloud sessions are not available in VS Code integration tests") + } + }, + CloudSessionError, + } + } + if (id === "@cline/shared") { // Mirrors USER_REJECTED_TOOL_REASON / TOOL_REJECTION_SUFFIX in // sdk/packages/shared/src/llms/tools.ts (the ESM-only real package diff --git a/apps/vscode/tsconfig.test.json b/apps/vscode/tsconfig.test.json index 18f1b98bf2..13b91c2eb6 100644 --- a/apps/vscode/tsconfig.test.json +++ b/apps/vscode/tsconfig.test.json @@ -49,6 +49,9 @@ ], "@cline/shared/*": [ "./node_modules/@cline/shared/dist/*" + ], + "@cline/core/cloud": [ + "./node_modules/@cline/core/dist/cloud/index.d.ts" ] }, "types": [ diff --git a/apps/vscode/vitest.cloud.config.ts b/apps/vscode/vitest.cloud.config.ts new file mode 100644 index 0000000000..e2ef62b13f --- /dev/null +++ b/apps/vscode/vitest.cloud.config.ts @@ -0,0 +1,23 @@ +import path from "node:path" +import { defineConfig } from "vitest/config" + +export default defineConfig({ + test: { + include: ["src/test/cloud/**/*.test.ts"], + environment: "node", + fileParallelism: false, + testTimeout: 30_000, + hookTimeout: 30_000, + }, + resolve: { + alias: [ + // Vite drops Zod's named namespace export on Windows while preserving + // its default export. Restore the package's declared `z` export for tests. + { find: /^zod$/, replacement: path.resolve(__dirname, "src/test/zod-vitest-stub.ts") }, + { find: "@", replacement: path.resolve(__dirname, "src") }, + { find: "@core", replacement: path.resolve(__dirname, "src/core") }, + { find: "@utils", replacement: path.resolve(__dirname, "src/utils") }, + { find: "@shared", replacement: path.resolve(__dirname, "src/shared") }, + ], + }, +}) diff --git a/apps/vscode/vitest.config.ts b/apps/vscode/vitest.config.ts index 1482187cfd..b92165f258 100644 --- a/apps/vscode/vitest.config.ts +++ b/apps/vscode/vitest.config.ts @@ -14,9 +14,11 @@ export default defineConfig({ "src/shared/proto-conversions/models/**/*.test.ts", "src/core/storage/remote-config/**/*.test.ts", "src/core/controller/account/setUserOrganization.test.ts", + "src/services/cloud/workspace-cloud-defaults.test.ts", "src/core/controller/remoteConfig/**/*.test.ts", "src/core/controller/state/**/*.test.ts", "src/core/controller/slash/**/*.test.ts", + "src/core/controller/task/newTask.test.ts", "src/services/ClineClientIdentity.test.ts", "src/services/mcp/__tests__/settingsLock.test.ts", "src/shared/model-catalog/provider-helpers.test.ts", @@ -47,6 +49,10 @@ export default defineConfig({ // Vite drops Zod's named namespace export on Windows while preserving // its default export. Restore the package's declared `z` export for tests. { find: /^zod$/, replacement: path.resolve(__dirname, "src/test/zod-vitest-stub.ts") }, + { + find: /^@cline\/core\/cloud$/, + replacement: path.resolve(__dirname, "node_modules/@cline/core/dist/cloud/index.js"), + }, { find: "@cline/core", replacement: path.resolve(__dirname, "src/test/cline-core-vitest-stub.ts") }, { find: "@cline/llms", replacement: path.resolve(__dirname, "node_modules/@cline/llms/dist/index.js") }, // Map @cline/shared subpath exports explicitly. The bare "@cline/shared" diff --git a/apps/vscode/webview-ui/src/App.tsx b/apps/vscode/webview-ui/src/App.tsx index 1b94c49544..ff74bafa84 100644 --- a/apps/vscode/webview-ui/src/App.tsx +++ b/apps/vscode/webview-ui/src/App.tsx @@ -40,7 +40,7 @@ const AppContent = () => { hideAnnouncement, } = useExtensionState() - const { clineUser, organizations, activeOrganization } = useClineAuth() + const { clineUser, organizations, activeOrganization, accountSwitch, accountSwitchError } = useClineAuth() const showUpdateAnnouncementModal = useCallback(() => { setShowAnnouncement(true) @@ -78,6 +78,15 @@ const AppContent = () => { return (
+ {(accountSwitch || accountSwitchError) && !showAccount && ( +
+ {accountSwitch + ? accountSwitch.slow + ? "Account switch is still pending. You can navigate while it finishes." + : "Switching account…" + : `Could not confirm account switch: ${accountSwitchError}`} +
+ )} {showSettings && } {showHistory && } {showMarketplace && } diff --git a/apps/vscode/webview-ui/src/components/account/AccountView.test.tsx b/apps/vscode/webview-ui/src/components/account/AccountView.test.tsx new file mode 100644 index 0000000000..adc6492e0f --- /dev/null +++ b/apps/vscode/webview-ui/src/components/account/AccountView.test.tsx @@ -0,0 +1,196 @@ +import { act, fireEvent, render, screen, waitFor } from "@testing-library/react" +import { beforeEach, describe, expect, it, vi } from "vitest" +import { ClineAuthProvider, useClineAuth } from "@/context/ClineAuthContext" +import AccountView from "./AccountView" + +vi.mock("./CreditBalance", () => ({ + CreditBalance: ({ balance }: { balance: number | null }) => ( +
{balance ?? "unavailable"}
+ ), +})) + +const mocks = vi.hoisted(() => ({ + setUserOrganization: vi.fn(), + getUserCredits: vi.fn(), + getOrganizationCredits: vi.fn(), + getUserOrganizations: vi.fn(), + subscribeToAuthStatusUpdate: vi.fn(), +})) + +vi.mock("@/context/ExtensionStateContext", () => ({ + useExtensionState: () => ({ remoteConfigSettings: {}, environment: "production", cloudSessionsEnabled: false }), +})) + +vi.mock("@/hooks/useClinePassPromo", () => ({ + useClinePassPromo: () => ({ isClinePassEnabled: false }), +})) + +vi.mock("@/services/grpc-client", () => ({ + AccountServiceClient: { + setUserOrganization: mocks.setUserOrganization, + getUserCredits: mocks.getUserCredits, + getOrganizationCredits: mocks.getOrganizationCredits, + getUserOrganizations: mocks.getUserOrganizations, + subscribeToAuthStatusUpdate: mocks.subscribeToAuthStatusUpdate, + }, + StateServiceClient: { updateSettings: vi.fn() }, +})) + +vi.mock("@vscode/webview-ui-toolkit/react", () => ({ + VSCodeLink: ({ children, ...props }: any) => {children}, + VSCodeButton: ({ children, ...props }: any) => , + VSCodeDivider: () =>
, + VSCodeTag: ({ children }: any) => {children}, + VSCodeDropdown: ({ currentValue, disabled, onChange, children }: any) => ( + + ), + VSCodeOption: ({ value, children }: any) => , +})) + +function deferred() { + let resolve!: (value: T) => void + let reject!: (error: unknown) => void + const promise = new Promise((done, fail) => { + resolve = done + reject = fail + }) + return { promise, resolve, reject } +} + +const user = { uid: "user-1", email: "user@example.com", displayName: "User", appBaseUrl: "https://app.cline.bot" } +const organization = { active: true, memberId: "member-1", name: "Organization", organizationId: "org-1", roles: ["member"] } + +function ConnectedAccountView({ visible = true }: { visible?: boolean }) { + const { clineUser, organizations, activeOrganization } = useClineAuth() + return visible ? ( + + ) : null +} + +async function renderAccount() { + const view = render( + + + , + ) + await waitFor(() => expect((screen.getByLabelText("Account") as HTMLSelectElement).value).toBe(organization.organizationId)) + return view +} + +describe("AccountView organization switch", () => { + beforeEach(() => { + vi.clearAllMocks() + mocks.getUserCredits.mockResolvedValue({ balance: { currentBalance: 1 }, usageTransactions: [], paymentTransactions: [] }) + mocks.getOrganizationCredits.mockResolvedValue({ balance: { currentBalance: 2 }, usageTransactions: [] }) + mocks.getUserOrganizations.mockResolvedValue({ organizations: [organization] }) + mocks.subscribeToAuthStatusUpdate.mockImplementation((_request, callbacks) => { + callbacks.onResponse({ user }) + return vi.fn() + }) + }) + + it("disables the dropdown until the server answers, then reverts and reports a refused switch", async () => { + const switching = deferred>() + mocks.setUserOrganization.mockReturnValue(switching.promise) + await renderAccount() + const dropdown = screen.getByLabelText("Account") as HTMLSelectElement + await waitFor(() => expect(dropdown.disabled).toBe(false)) + + fireEvent.change(dropdown, { target: { value: user.uid } }) + + expect(mocks.setUserOrganization).toHaveBeenCalledWith({ organizationId: undefined }) + expect(dropdown.value).toBe(user.uid) + expect(dropdown.disabled).toBe(true) + expect(screen.getByLabelText("Switching account")).toBeInTheDocument() + + await act(async () => { + switching.reject(new Error("Cloud session cleanup is still pending.")) + }) + + expect(dropdown.value).toBe(organization.organizationId) + expect(dropdown.disabled).toBe(false) + expect(screen.getByRole("alert")).toHaveTextContent( + "Could not confirm account switch: Cloud session cleanup is still pending.", + ) + }) + + it("loads the new account's credits only after the server has switched", async () => { + const switching = deferred>() + mocks.setUserOrganization.mockReturnValue(switching.promise) + await renderAccount() + const dropdown = screen.getByLabelText("Account") as HTMLSelectElement + await waitFor(() => expect(dropdown.disabled).toBe(false)) + mocks.getUserCredits.mockClear() + + fireEvent.change(dropdown, { target: { value: user.uid } }) + expect(mocks.getUserCredits).not.toHaveBeenCalled() + expect(screen.getByTestId("credit-balance")).toHaveTextContent("unavailable") + + await act(async () => { + mocks.getUserOrganizations.mockResolvedValue({ organizations: [{ ...organization, active: false }] }) + switching.resolve({}) + }) + + await waitFor(() => expect(mocks.getUserCredits).toHaveBeenCalledOnce()) + expect(dropdown.value).toBe(user.uid) + expect(dropdown.disabled).toBe(false) + expect(screen.queryByRole("alert")).toBeNull() + }) + + it("keeps a switch pending across Account navigation and uses confirmed state after a partial failure", async () => { + const switching = deferred>() + mocks.setUserOrganization.mockReturnValue(switching.promise) + const view = await renderAccount() + fireEvent.change(screen.getByLabelText("Account"), { target: { value: user.uid } }) + view.rerender( + + + , + ) + view.rerender( + + + , + ) + expect(screen.getByLabelText("Account")).toBeDisabled() + await act(async () => { + mocks.getUserOrganizations.mockResolvedValue({ organizations: [{ ...organization, active: false }] }) + switching.reject(new Error("Remote config failed after switching")) + }) + expect(screen.getByLabelText("Account")).toHaveValue(user.uid) + expect(screen.getByLabelText("Account")).not.toBeDisabled() + expect(mocks.setUserOrganization).toHaveBeenCalledOnce() + }) + + it("ignores old-account credits that arrive after a switch", async () => { + const oldCredits = deferred<{ balance: { currentBalance: number }; usageTransactions: [] }>() + mocks.getOrganizationCredits.mockReturnValue(oldCredits.promise) + mocks.setUserOrganization.mockImplementation(async () => { + mocks.getUserOrganizations.mockResolvedValue({ organizations: [{ ...organization, active: false }] }) + }) + await renderAccount() + fireEvent.change(screen.getByLabelText("Account"), { target: { value: user.uid } }) + await waitFor(() => expect(screen.getByTestId("credit-balance")).toHaveTextContent("1")) + await act(async () => { + oldCredits.resolve({ balance: { currentBalance: 999 }, usageTransactions: [] }) + }) + expect(screen.getByTestId("credit-balance")).toHaveTextContent("1") + }) + + it("keeps the last account and reports a failed confirmation read", async () => { + await renderAccount() + mocks.setUserOrganization.mockResolvedValue({}) + mocks.getUserOrganizations.mockRejectedValue(new Error("offline")) + fireEvent.change(screen.getByLabelText("Account"), { target: { value: user.uid } }) + await waitFor(() => expect(screen.getByLabelText("Account")).not.toBeDisabled()) + expect(screen.getByLabelText("Account")).toHaveValue(organization.organizationId) + expect(screen.getByRole("alert")).toHaveTextContent("Could not confirm the active account") + }) +}) diff --git a/apps/vscode/webview-ui/src/components/account/AccountView.tsx b/apps/vscode/webview-ui/src/components/account/AccountView.tsx index ba4e61e23b..39aa7ce714 100644 --- a/apps/vscode/webview-ui/src/components/account/AccountView.tsx +++ b/apps/vscode/webview-ui/src/components/account/AccountView.tsx @@ -3,11 +3,12 @@ import { isClineInternalTester } from "@shared/internal/account" import type { UserOrganization } from "@shared/proto/cline/account" import { EmptyRequest } from "@shared/proto/cline/common" import { VSCodeButton, VSCodeDivider, VSCodeDropdown, VSCodeOption, VSCodeTag } from "@vscode/webview-ui-toolkit/react" -import deepEqual from "fast-deep-equal" +import { LoaderCircleIcon } from "lucide-react" import { memo, useCallback, useEffect, useMemo, useRef, useState } from "react" import { useInterval } from "react-use" +import { CloudGitHubCard } from "@/components/cloud/CloudGitHubCard" import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip" -import { type ClineUser, handleSignOut } from "@/context/ClineAuthContext" +import { type ClineUser, handleSignOut, useClineAuth } from "@/context/ClineAuthContext" import { useExtensionState } from "@/context/ExtensionStateContext" import { AccountServiceClient } from "@/services/grpc-client" import ViewHeader from "../common/ViewHeader" @@ -68,239 +69,71 @@ const AccountView = ({ onDone, clineUser, organizations, activeOrganization }: A const ClineAccountView = ({ clineUser, userOrganizations, activeOrganization, clineEnv }: ClineAccountViewProps) => { const { email, displayName, appBaseUrl, uid } = clineUser - const { remoteConfigSettings, environment } = useExtensionState() + const { remoteConfigSettings, environment, cloudSessionsEnabled } = useExtensionState() // Determine if dropdown should be locked by remote config const isLockedByRemoteConfig = Object.keys(remoteConfigSettings || {}).length > 0 - // Source of truth: Dedicated state for dropdown value that persists through failures - // and represents that user's current selection. - const [dropdownValue, setDropdownValue] = useState(activeOrganization?.organizationId || uid) + const { accountSwitch, accountSwitchError, switchOrganization } = useClineAuth() + const dropdownValue = activeOrganization?.organizationId || uid const [isLoading, setIsLoading] = useState(false) - // Cache data per organization/user ID to avoid showing empty state when switching - const dataCache = useRef>(new Map()) - - // Current displayed data - const [balance, setBalance] = useState(null) - const [usageData, setUsageData] = useState([]) - const [paymentsData, setPaymentsData] = useState([]) - const [lastFetchTime, setLastFetchTime] = useState(Date.now()) - - // Load cached data for current dropdown value - const loadCachedData = useCallback((id: string) => { - const cached = dataCache.current.get(id) - if (cached) { - setBalance(cached.balance) - setUsageData(cached.usageData) - setPaymentsData(cached.paymentsData) - setLastFetchTime(cached.lastFetchTime) - return true - } - return false - }, []) - - // Simple cache function without dependencies - const cacheCurrentData = useCallback( - (id: string) => { - dataCache.current.set(id, { - balance, - usageData, - paymentsData, - lastFetchTime, - }) - }, - [balance, usageData, paymentsData, lastFetchTime], - ) - // Track the active organization ID to detect changes - const [lastActiveOrgId, setLastActiveOrgId] = useState(activeOrganization?.organizationId) - // Use ref for debounce timeout to avoid re-renders - const debounceTimeoutRef = useRef(null) - // Track if manual fetch is in progress to avoid duplicate fetches - const manualFetchInProgressRef = useRef(false) - // Track if initial mount fetch has completed to avoid duplicate fetches - const initialFetchCompleteRef = useRef(false) - + const [creditData, setCreditData] = useState() + const creditRequest = useRef(null) + const activeAccount = useRef(dropdownValue) + activeAccount.current = dropdownValue const isClineTester = useMemo(() => (email ? isClineInternalTester(email) : false), [email]) - - const fetchUserCredit = useCallback(async () => { - try { - const response = await AccountServiceClient.getUserCredits(EmptyRequest.create()) - const newBalance = response?.balance?.currentBalance - // Always update balance, even if it's 0 or null - don't skip undefined - setBalance(newBalance ?? null) - const newUsage = convertProtoUsageTransactions(response.usageTransactions) - setUsageData((prev) => (deepEqual(newUsage, prev) ? prev : newUsage)) - const newPaymentsData = response.paymentTransactions - setPaymentsData((prev) => (deepEqual(newPaymentsData, prev) ? prev : newPaymentsData)) - } catch (error) { - console.error("Failed to fetch user credit:", error) - } - }, []) + const clineUrl = appBaseUrl || "https://app.cline.bot" const fetchCreditBalance = useCallback( - async (id: string, skipCache = false) => { + async (accountId: string) => { + const request = {} + creditRequest.current = request + setIsLoading(true) try { - if (isLoading) { - return // Prevent multiple concurrent fetches - } - - // Load cached data immediately if available (unless skipping cache) - if (!skipCache && loadCachedData(id)) { - // If we have cached data, show it first, then fetch in background - } - - setIsLoading(true) - if (id === uid) { - await fetchUserCredit() - } else { - const response = await AccountServiceClient.getOrganizationCredits({ - organizationId: id, - }) - // Update balance - handle all values including 0 and null - const newBalance = response.balance?.currentBalance - setBalance(newBalance ?? null) - - const newUsage = convertProtoUsageTransactions(response.usageTransactions) - setUsageData((prev) => (deepEqual(newUsage, prev) ? prev : newUsage)) - } - - // Cache the updated data - cacheCurrentData(id) + const response = + accountId === uid + ? await AccountServiceClient.getUserCredits(EmptyRequest.create()) + : await AccountServiceClient.getOrganizationCredits({ organizationId: accountId }) + if (creditRequest.current !== request || activeAccount.current !== accountId) return + setCreditData({ + accountId, + balance: response.balance?.currentBalance ?? null, + usageData: convertProtoUsageTransactions(response.usageTransactions), + paymentsData: "paymentTransactions" in response ? response.paymentTransactions : [], + lastFetchTime: Date.now(), + }) } catch (error) { console.error("Failed to fetch credit balance:", error) } finally { - setLastFetchTime(Date.now()) - setIsLoading(false) + if (creditRequest.current === request) setIsLoading(false) } }, - [isLoading, uid, fetchUserCredit, loadCachedData], + [uid], ) - const handleOrganizationChange = useCallback( - async (event: any) => { - const target = event.target as HTMLSelectElement - if (!target) { - return - } - - const newValue = target.value - if (newValue !== dropdownValue) { - // Clear any pending debounced fetch since we're doing a manual one - if (debounceTimeoutRef.current) { - clearTimeout(debounceTimeoutRef.current) - debounceTimeoutRef.current = null - } - - // Cache current data before switching - cacheCurrentData(dropdownValue) - setDropdownValue(newValue) - - // Load cached data for new selection immediately, or clear if no cache - // Only clear if we don't have cached data to avoid unnecessary flashing - if (!loadCachedData(newValue)) { - // No cached data - clear current state to avoid showing wrong data - setBalance(null) - setUsageData([]) - setPaymentsData([]) - } - - // Set flag to indicate manual fetch in progress - manualFetchInProgressRef.current = true - - // Fetch the new data - await fetchCreditBalance(newValue) - - // Update the last active org ID to prevent the effect from triggering - setLastActiveOrgId(newValue === uid ? undefined : newValue) - - // Send the change to the server - const organizationId = newValue === uid ? undefined : newValue - await AccountServiceClient.setUserOrganization({ organizationId }) - - // Clear the manual fetch flag after everything is done - manualFetchInProgressRef.current = false - } - }, - [uid, dropdownValue, loadCachedData, fetchCreditBalance, cacheCurrentData], - ) - - // Fetch balance every 60 seconds - useInterval(() => { - fetchCreditBalance(dropdownValue) - }, 60000) - - const clineUrl = appBaseUrl || "https://app.cline.bot" - - // Fetch balance on mount useEffect(() => { - async function initialFetch() { - await fetchCreditBalance(dropdownValue) - initialFetchCompleteRef.current = true - } - initialFetch() - }, []) - - useEffect(() => { - // Handle organization changes with 500ms debounce - const currentActiveOrgId = activeOrganization?.organizationId - const hasActiveOrgChanged = currentActiveOrgId !== lastActiveOrgId - - // Only handle external organization changes (not dropdown changes) - // Dropdown changes are handled by handleOrganizationChange - const isExternalOrgChange = hasActiveOrgChanged && !manualFetchInProgressRef.current - - if (isExternalOrgChange) { - // Clear any existing timeout - if (debounceTimeoutRef.current) { - clearTimeout(debounceTimeoutRef.current) - } - - // Update dropdown to match the new active organization - const newDropdownValue = currentActiveOrgId || uid - if (newDropdownValue !== dropdownValue) { - // Cache current data before switching - cacheCurrentData(dropdownValue) - setDropdownValue(newDropdownValue) - - // Load cached data for new selection immediately, or clear if no cache - // Only clear data if initial fetch has completed to avoid clearing on mount - if (!loadCachedData(newDropdownValue) && initialFetchCompleteRef.current) { - // No cached data - clear to avoid showing wrong data - setBalance(null) - setUsageData([]) - setPaymentsData([]) - } - } - - // Only set timeout if initial fetch is complete - if (initialFetchCompleteRef.current) { - // Set new timeout to fetch after 500ms - debounceTimeoutRef.current = setTimeout(() => { - fetchCreditBalance(newDropdownValue) - setLastActiveOrgId(currentActiveOrgId) - }, 500) - } else { - // Just update the active org ID - setLastActiveOrgId(currentActiveOrgId) - } - } - - // Cleanup timeout on unmount + if (!accountSwitch) void fetchCreditBalance(dropdownValue) return () => { - if (debounceTimeoutRef.current) { - clearTimeout(debounceTimeoutRef.current) - } + creditRequest.current = null } - }, [ - activeOrganization?.organizationId, - lastActiveOrgId, - uid, - dropdownValue, - loadCachedData, - fetchCreditBalance, - cacheCurrentData, - ]) + }, [dropdownValue, accountSwitch, fetchCreditBalance]) + useInterval(() => { + if (!accountSwitch) void fetchCreditBalance(dropdownValue) + }, 60_000) + + const displayedCredit = !accountSwitch && creditData?.accountId === dropdownValue ? creditData : undefined + const balance = displayedCredit?.balance ?? null + const usageData = displayedCredit?.usageData ?? [] + const paymentsData = displayedCredit?.paymentsData ?? [] + const lastFetchTime = displayedCredit?.lastFetchTime ?? 0 + + const handleOrganizationChange = (event: { target: EventTarget | null }) => { + const newValue = (event.target as HTMLSelectElement | null)?.value + if (newValue && newValue !== dropdownValue && !accountSwitch) { + void switchOrganization(newValue === uid ? undefined : newValue) + } + } return (
@@ -325,8 +158,8 @@ const ClineAccountView = ({ clineUser, userOrganizations, activeOrganization, cl Personal @@ -342,12 +175,29 @@ const ClineAccountView = ({ clineUser, userOrganizations, activeOrganization, cl This cannot be changed while your organization has remote configuration enabled. - {activeOrganization && ( - - {getMainRole(activeOrganization.roles)} - + {accountSwitch ? ( + + ) : ( + activeOrganization && ( + + {getMainRole(activeOrganization.roles)} + + ) )}
+ {accountSwitchError && ( +
+ Could not confirm account switch: {accountSwitchError} +
+ )} + {accountSwitch?.slow && ( +
+ Account switch is still pending. You can navigate while it finishes. +
+ )}
@@ -368,11 +218,17 @@ const ClineAccountView = ({ clineUser, userOrganizations, activeOrganization, cl + {cloudSessionsEnabled && ( +
+ +
+ )} + fetchCreditBalance(dropdownValue)} - isLoading={isLoading} + isLoading={isLoading || !!accountSwitch} lastFetchTime={lastFetchTime} /> @@ -380,9 +236,9 @@ const ClineAccountView = ({ clineUser, userOrganizations, activeOrganization, cl -
+
("usage") return ( -
+
{/* Tabs container */}
setActiveTab("usage")}> diff --git a/apps/vscode/webview-ui/src/components/chat/ChatRow.tsx b/apps/vscode/webview-ui/src/components/chat/ChatRow.tsx index 662dfbba00..730d59f013 100644 --- a/apps/vscode/webview-ui/src/components/chat/ChatRow.tsx +++ b/apps/vscode/webview-ui/src/components/chat/ChatRow.tsx @@ -152,14 +152,19 @@ export const ChatRowContent = memo( retryFailedRequest, }: ChatRowContentProps) => { const { - backgroundEditEnabled, + backgroundEditEnabled: backgroundEditSetting, mcpServers, vscodeTerminalExecutionMode, clineMessages, showFeatureTips, - enableCheckpointsSetting, + enableCheckpointsSetting: checkpointsSetting, turnState, + currentCloudTask, } = useExtensionState() + // Cloud tasks edit files inside the sandbox: there is no local editor to + // preview in, so diffs render inline, and no local workspace to checkpoint. + const backgroundEditEnabled = backgroundEditSetting || !!currentCloudTask + const enableCheckpointsSetting = checkpointsSetting && !currentCloudTask const [quoteButtonState, setQuoteButtonState] = useState({ visible: false, top: 0, @@ -419,6 +424,7 @@ export const ChatRowContent = memo(
{backgroundEditEnabled && tool.path && (tool.diff || tool.content) ? ( Cline wants to create a new file:
{backgroundEditEnabled && tool.path && tool.content ? ( - + ) : ( diff --git a/apps/vscode/webview-ui/src/components/chat/ChatTextArea.test.tsx b/apps/vscode/webview-ui/src/components/chat/ChatTextArea.test.tsx index cb24fbe944..b4c67cc2af 100644 --- a/apps/vscode/webview-ui/src/components/chat/ChatTextArea.test.tsx +++ b/apps/vscode/webview-ui/src/components/chat/ChatTextArea.test.tsx @@ -5,6 +5,7 @@ import ChatTextArea from "./ChatTextArea" const mocks = vi.hoisted(() => ({ supportsImages: true as boolean | undefined, navigateToSettingsModelPicker: vi.fn(), + cloudState: {} as Record, })) vi.mock("@/context/ExtensionStateContext", () => ({ @@ -19,6 +20,7 @@ vi.mock("@/context/ExtensionStateContext", () => ({ remoteConfigSettings: undefined, navigateToSettingsModelPicker: mocks.navigateToSettingsModelPicker, mcpServers: [], + ...mocks.cloudState, }), })) @@ -83,12 +85,40 @@ function pasteImage(target: HTMLElement) { }) } +describe("ChatTextArea model label", () => { + beforeEach(() => { + mocks.cloudState = {} + }) + + it("shows the local provider and model for a local task", () => { + renderTextArea() + expect(screen.getByTitle("Open API Settings")).toHaveTextContent("anthropic:text-only-model") + }) + + it.each([ + ["a new task targets Cloud", { cloudSessionsEnabled: true, cloudTaskTarget: { target: "cloud" } }], + ["a cloud task is shown", { currentCloudTask: { sessionId: "ses-1", status: "running" } }], + ])("shows the cloud model when %s", (_case, cloudState) => { + mocks.cloudState = { ...cloudState, cloudModelId: "anthropic/claude-sonnet-4.5" } + renderTextArea() + expect(screen.getByTitle("Open API Settings")).toHaveTextContent("cline:anthropic/claude-sonnet-4.5") + }) +}) + describe("ChatTextArea image attachments vs. model capability", () => { beforeEach(() => { mocks.supportsImages = true + mocks.cloudState = {} mocks.navigateToSettingsModelPicker.mockReset() }) + it("renders before optional cloud state has hydrated", () => { + renderTextArea() + + expect(screen.getByPlaceholderText("Type a message")).toBeInTheDocument() + expect(screen.getByTestId("mode-switch")).toHaveAttribute("aria-disabled", "false") + }) + it("still takes the image attach path on paste for a text-only model, without a refusal message", () => { mocks.supportsImages = false const { textarea } = renderTextArea() diff --git a/apps/vscode/webview-ui/src/components/chat/ChatTextArea.tsx b/apps/vscode/webview-ui/src/components/chat/ChatTextArea.tsx index 2da5890694..7598abc24c 100644 --- a/apps/vscode/webview-ui/src/components/chat/ChatTextArea.tsx +++ b/apps/vscode/webview-ui/src/components/chat/ChatTextArea.tsx @@ -215,7 +215,7 @@ const ChatTextArea = forwardRef( ref, ) => { const { - mode, + mode: selectedMode, apiConfiguration, openRouterModels, platform, @@ -225,7 +225,18 @@ const ChatTextArea = forwardRef( remoteConfigSettings, navigateToSettingsModelPicker, mcpServers, + clineMessages = [], + cloudSessionsEnabled, + cloudTaskTarget, + currentCloudTask, + cloudModelId, } = useExtensionState() + // Cloud sessions are Act-only (like the desktop app and the cloud dashboard). + // While a cloud task is shown, or a new task is about to run in the cloud, + // the toggle is pinned to Act and disabled; the stored mode is untouched. + const cloudActOnly = + !!currentCloudTask || (clineMessages.length === 0 && !!cloudSessionsEnabled && cloudTaskTarget?.target === "cloud") + const mode = cloudActOnly ? "act" : selectedMode const [isTextAreaFocused, setIsTextAreaFocused] = useState(false) const [isDraggingOver, setIsDraggingOver] = useState(false) const [gitCommits, setGitCommits] = useState([]) @@ -1028,6 +1039,9 @@ const ChatTextArea = forwardRef( ) const onModeToggle = useCallback(() => { + if (cloudActOnly) { + return + } void (async () => { const convertedProtoMode = mode === "plan" ? PlanActMode.ACT : PlanActMode.PLAN const submittedText = inputValue @@ -1080,7 +1094,7 @@ const ChatTextArea = forwardRef( textAreaRef.current?.focus() }, 100) })() - }, [mode, inputValue, selectedImages, selectedFiles, setInputValue, setSelectedImages, setSelectedFiles]) + }, [cloudActOnly, mode, inputValue, selectedImages, selectedFiles, setInputValue, setSelectedImages, setSelectedFiles]) useShortcut(usePlatform().togglePlanActKeys, onModeToggle, { disableTextInputs: false }) // important that we don't disable the text input here @@ -1142,6 +1156,11 @@ const ChatTextArea = forwardRef( } = getModeSpecificFields(apiConfiguration, mode) const unknownModel = "unknown" + // A cloud task runs on a Cline model in the sandbox, whatever the + // local provider is; the extension reports which one. + if (cloudActOnly) { + return `cline:${cloudModelId || unknownModel}` + } if (!apiConfiguration) { return unknownModel } @@ -1175,7 +1194,7 @@ const ChatTextArea = forwardRef( default: return `${selectedProvider}:${selectedModelId}` } - }, [apiConfiguration, mode, selectedProvider, selectedModelId]) + }, [apiConfiguration, mode, selectedProvider, selectedModelId, cloudActOnly, cloudModelId]) // Function to show error message for unsupported files for drag and drop const showUnsupportedFileErrorMessage = () => { @@ -1685,13 +1704,23 @@ const ChatTextArea = forwardRef( className="text-xs px-2 flex flex-col gap-1" hidden={shownTooltipMode === null} side="top"> - {`In ${shownTooltipMode === "act" ? "Act" : "Plan"} mode, Cline will ${shownTooltipMode === "act" ? "complete the task immediately" : "gather information to architect a plan"}`} -

- Toggle w/ {togglePlanActKeys} -

+ {cloudActOnly ? ( + "Cloud sessions only support Act mode for now." + ) : ( + <> + {`In ${shownTooltipMode === "act" ? "Act" : "Plan"} mode, Cline will ${shownTooltipMode === "act" ? "complete the task immediately" : "gather information to architect a plan"}`} +

+ Toggle w/ {togglePlanActKeys} +

+ + )} - + {["Plan", "Act"].map((m) => (
{ try { const response = await FileServiceClient.selectFiles( @@ -226,6 +235,9 @@ const ChatView = ({ isHidden, showAnnouncement, hideAnnouncement, showHistoryVie value: selectedModelInfo.supportsImages, }), ) + if (response && imagesOnly) { + response.values2 = [] + } if ( response && response.values1 && @@ -252,7 +264,7 @@ const ChatView = ({ isHidden, showAnnouncement, hideAnnouncement, showHistoryVie } catch (error) { console.error("Error selecting images & files:", error) } - }, [selectedModelInfo.supportsImages]) + }, [selectedModelInfo.supportsImages, imagesOnly]) const shouldDisableFilesAndImages = selectedImages.length + selectedFiles.length >= MAX_IMAGES_AND_FILES_PER_MESSAGE @@ -393,6 +405,7 @@ const ChatView = ({ isHidden, showAnnouncement, hideAnnouncement, showHistoryVie ) : (
- + ({ + FileServiceClient: { openFileRelativePath: (request: unknown) => openFileRelativePath(request) }, +})) +vi.mock("@shared/proto/cline/common", () => ({ + StringRequest: { create: (x: unknown) => x }, +})) + +import { DiffEditRow } from "./DiffEditRow" + +const patch = ["------- SEARCH", "old line", "=======", "new line", "+++++++ REPLACE"].join("\n") + +describe("DiffEditRow", () => { + it("opens a local task's file in the editor", () => { + openFileRelativePath.mockClear() + render() + + fireEvent.click(screen.getByText("src/app.ts")) + + expect(openFileRelativePath).toHaveBeenCalledWith({ value: "src/app.ts" }) + }) + + it("does not offer to open a file that lives in a cloud sandbox", () => { + openFileRelativePath.mockClear() + render() + + // The path is relative to the sandbox's /workspace; resolving it against the + // local workspace would open an unrelated file or fail. + fireEvent.click(screen.getByText("src/app.ts")) + + expect(openFileRelativePath).not.toHaveBeenCalled() + expect(screen.queryByTitle("Open file in editor")).toBeNull() + }) +}) diff --git a/apps/vscode/webview-ui/src/components/chat/DiffEditRow.tsx b/apps/vscode/webview-ui/src/components/chat/DiffEditRow.tsx index adb5bd21b6..5f8f28abba 100644 --- a/apps/vscode/webview-ui/src/components/chat/DiffEditRow.tsx +++ b/apps/vscode/webview-ui/src/components/chat/DiffEditRow.tsx @@ -34,9 +34,15 @@ interface DiffEditRowProps { path: string isLoading?: boolean startLineNumbers?: number[] + /** + * Whether the file path can be opened in the local editor. False for a + * cloud task, whose paths are relative to the sandbox and not to any local + * workspace. + */ + canOpenFile?: boolean } -export const DiffEditRow = memo(({ patch, path, isLoading, startLineNumbers }) => { +export const DiffEditRow = memo(({ patch, path, isLoading, startLineNumbers, canOpenFile = true }) => { const { parsedFiles, isStreaming } = useMemo(() => { const parsed = parsePatch(patch, path) return { @@ -53,6 +59,7 @@ export const DiffEditRow = memo(({ patch, path, isLoading, sta
{parsedFiles.map((file, index) => ( (({ patch, path, isLoading, sta ) }) -const FileBlock = memo<{ file: Patch; isStreaming: boolean; startLineNumber?: number }>( - ({ file, isStreaming, startLineNumber }) => { +const FileBlock = memo<{ file: Patch; isStreaming: boolean; startLineNumber?: number; canOpenFile: boolean }>( + ({ file, isStreaming, startLineNumber, canOpenFile }) => { const [isExpanded, setIsExpanded] = useState(true) const scrollContainerRef = useRef(null) const shouldFollowRef = useRef(true) @@ -145,22 +152,28 @@ const FileBlock = memo<{ file: Patch; isStreaming: boolean; startLineNumber?: nu
- - {file.path} - + {canOpenFile ? ( + + {file.path} + + ) : ( + {file.path} + )}
- - - + {canOpenFile && ( + + + + )}
diff --git a/apps/vscode/webview-ui/src/components/chat/ErrorRow.test.tsx b/apps/vscode/webview-ui/src/components/chat/ErrorRow.test.tsx index 07881900d6..8738b4add3 100644 --- a/apps/vscode/webview-ui/src/components/chat/ErrorRow.test.tsx +++ b/apps/vscode/webview-ui/src/components/chat/ErrorRow.test.tsx @@ -17,6 +17,8 @@ const mockApiConfiguration = vi.hoisted(() => ({ vi.mock("@/context/ClineAuthContext", () => ({ useClineAuth: () => ({ clineUser: null, + switchOrganization: mockSetUserOrganization, + accountSwitch: null, }), useClineSignIn: () => ({ isLoginLoading: false, @@ -86,7 +88,7 @@ describe("ErrorRow", () => { beforeEach(() => { vi.clearAllMocks() - mockSetUserOrganization.mockResolvedValue({}) + mockSetUserOrganization.mockResolvedValue(true) mockUpdateApiConfigurationProto.mockResolvedValue({}) }) @@ -263,7 +265,7 @@ describe("ErrorRow", () => { fireEvent.click(screen.getByText("Switch to personal account")) - await waitFor(() => expect(mockSetUserOrganization).toHaveBeenCalledWith({})) + await waitFor(() => expect(mockSetUserOrganization).toHaveBeenCalledWith()) expect(screen.getByText("Switched to personal account")).toBeInTheDocument() }) diff --git a/apps/vscode/webview-ui/src/components/chat/OrgClinePassRestrictionError.tsx b/apps/vscode/webview-ui/src/components/chat/OrgClinePassRestrictionError.tsx index b4da0b7350..ff0ea517af 100644 --- a/apps/vscode/webview-ui/src/components/chat/OrgClinePassRestrictionError.tsx +++ b/apps/vscode/webview-ui/src/components/chat/OrgClinePassRestrictionError.tsx @@ -1,62 +1,44 @@ -import { VSCodeButton } from "@vscode/webview-ui-toolkit/react"; -import { useState } from "react"; -import { AccountServiceClient } from "@/services/grpc-client"; +import { VSCodeButton } from "@vscode/webview-ui-toolkit/react" +import { useState } from "react" +import { useClineAuth } from "@/context/ClineAuthContext" -const ORG_CLINE_PASS_RESTRICTION_MESSAGE = - "Organization accounts cannot use ClinePass subscriptions."; +const ORG_CLINE_PASS_RESTRICTION_MESSAGE = "Organization accounts cannot use ClinePass subscriptions." const OrgClinePassRestrictionError = () => { - const [isSwitching, setIsSwitching] = useState(false); - const [didSwitch, setDidSwitch] = useState(false); - const [error, setError] = useState(); + const { accountSwitch, switchOrganization } = useClineAuth() + const isSwitching = !!accountSwitch + const [didSwitch, setDidSwitch] = useState(false) + const [error, setError] = useState() const handleSwitchToPersonalAccount = async () => { - setIsSwitching(true); - setError(undefined); + setError(undefined) try { - await AccountServiceClient.setUserOrganization({}); - setDidSwitch(true); + if (!(await switchOrganization())) throw new Error("Account switch not confirmed") + setDidSwitch(true) } catch (error) { - console.error("Failed to switch to personal Cline account:", error); - setError( - "Failed to switch account. Use /accounts to switch to your personal account.", - ); - } finally { - setIsSwitching(false); + console.error("Failed to switch to personal Cline account:", error) + setError("Failed to switch account. Use /accounts to switch to your personal account.") } - }; + } return (
-
- Organization account cannot use ClinePass -
+ data-testid="org-cline-pass-restriction-error"> +
Organization account cannot use ClinePass
{ORG_CLINE_PASS_RESTRICTION_MESSAGE}
- - {isSwitching - ? "Switching..." - : didSwitch - ? "Switched to personal account" - : "Switch to personal account"} + + {isSwitching ? "Switching..." : didSwitch ? "Switched to personal account" : "Switch to personal account"} {didSwitch && ( -
- Retry the request after switching. -
+
Retry the request after switching.
)} {error &&
{error}
}
- ); -}; + ) +} -export { ORG_CLINE_PASS_RESTRICTION_MESSAGE }; -export default OrgClinePassRestrictionError; +export { ORG_CLINE_PASS_RESTRICTION_MESSAGE } +export default OrgClinePassRestrictionError diff --git a/apps/vscode/webview-ui/src/components/chat/auto-approve-menu/AutoApproveBar.test.tsx b/apps/vscode/webview-ui/src/components/chat/auto-approve-menu/AutoApproveBar.test.tsx new file mode 100644 index 0000000000..362de85bfd --- /dev/null +++ b/apps/vscode/webview-ui/src/components/chat/auto-approve-menu/AutoApproveBar.test.tsx @@ -0,0 +1,87 @@ +import { fireEvent, render, screen } from "@testing-library/react" +import userEvent from "@testing-library/user-event" +import type React from "react" +import { beforeEach, describe, expect, it, vi } from "vitest" +import AutoApproveBar, { CLOUD_AUTO_APPROVE_TOOLTIP } from "./AutoApproveBar" + +vi.mock("@vscode/webview-ui-toolkit/react", () => ({ + VSCodeCheckbox: ({ checked, children, disabled }: { checked: boolean; children: React.ReactNode; disabled: boolean }) => ( + + ), +})) + +const mocks = vi.hoisted(() => ({ + autoApprovalSettings: { + version: 1, + enabled: true, + favorites: [], + maxRequests: 20, + actions: { + readFiles: true, + editFiles: false, + executeSafeCommands: false, + executeAllCommands: false, + useBrowser: false, + useMcp: false, + }, + enableNotifications: false, + }, + updateAutoApproveSettings: vi.fn(), +})) + +vi.mock("@/context/ExtensionStateContext", () => ({ + useExtensionState: () => ({ autoApprovalSettings: mocks.autoApprovalSettings }), +})) + +vi.mock("./AutoApproveSettingsAPI", () => ({ + updateAutoApproveSettings: mocks.updateAutoApproveSettings, +})) + +describe("AutoApproveBar", () => { + beforeEach(() => { + vi.clearAllMocks() + vi.stubGlobal( + "ResizeObserver", + class { + observe() {} + unobserve() {} + disconnect() {} + }, + ) + }) + + it("shows forced read-only cloud approvals without changing local settings", async () => { + const localSettings = structuredClone(mocks.autoApprovalSettings) + const user = userEvent.setup() + const { rerender } = render() + const trigger = screen.getByLabelText("Open auto-approve settings") + + expect(trigger).toHaveTextContent("Auto-approve:Read, Edit, Commands, Web Fetch, MCP") + await user.hover(trigger) + expect(await screen.findByText(CLOUD_AUTO_APPROVE_TOOLTIP)).toBeInTheDocument() + + fireEvent.click(trigger) + expect( + screen.getByText("Cloud sessions always auto-approve these actions. Your local settings are unchanged."), + ).toBeInTheDocument() + + const checkboxes = screen.getAllByRole("checkbox") + expect(checkboxes).toHaveLength(5) + for (const checkbox of checkboxes) { + expect(checkbox).toBeChecked() + expect(checkbox).toBeDisabled() + fireEvent.click(checkbox) + } + expect(mocks.updateAutoApproveSettings).not.toHaveBeenCalled() + expect(mocks.autoApprovalSettings).toEqual(localSettings) + + fireEvent.click(screen.getByLabelText("Close auto-approve settings")) + rerender() + expect(screen.getByLabelText("Open auto-approve settings")).toHaveTextContent("Auto-approve:Read") + expect(screen.queryByText(CLOUD_AUTO_APPROVE_TOOLTIP)).not.toBeInTheDocument() + expect(mocks.autoApprovalSettings).toEqual(localSettings) + }) +}) diff --git a/apps/vscode/webview-ui/src/components/chat/auto-approve-menu/AutoApproveBar.tsx b/apps/vscode/webview-ui/src/components/chat/auto-approve-menu/AutoApproveBar.tsx index ac0fc92780..779f42977f 100644 --- a/apps/vscode/webview-ui/src/components/chat/auto-approve-menu/AutoApproveBar.tsx +++ b/apps/vscode/webview-ui/src/components/chat/auto-approve-menu/AutoApproveBar.tsx @@ -1,5 +1,6 @@ import { ChevronDownIcon, ChevronRightIcon } from "lucide-react" import { useRef, useState } from "react" +import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip" import { useExtensionState } from "@/context/ExtensionStateContext" import { getAsVar, VSC_TITLEBAR_INACTIVE_FOREGROUND } from "@/utils/vscStyles" import AutoApproveModal from "./AutoApproveModal" @@ -7,9 +8,13 @@ import { ACTION_METADATA } from "./constants" interface AutoApproveBarProps { style?: React.CSSProperties + cloudAutoApprove?: boolean } -const AutoApproveBar = ({ style }: AutoApproveBarProps) => { +export const CLOUD_AUTO_APPROVE_TOOLTIP = + "Cloud sessions always auto-approve actions so they can continue running when VS Code is closed. Your local auto-approve settings are unchanged." + +const AutoApproveBar = ({ style, cloudAutoApprove = false }: AutoApproveBarProps) => { const { autoApprovalSettings } = useExtensionState() const [isModalVisible, setIsModalVisible] = useState(false) @@ -19,9 +24,11 @@ const AutoApproveBar = ({ style }: AutoApproveBarProps) => { const baseClasses = isModalVisible ? "text-foreground truncate" : "text-muted-foreground group-hover:text-foreground truncate" - const enabledActionsNames = Object.keys(autoApprovalSettings.actions).filter( - (key) => autoApprovalSettings.actions[key as keyof typeof autoApprovalSettings.actions], - ) + const enabledActionsNames = cloudAutoApprove + ? ACTION_METADATA.map((action) => action.id) + : Object.keys(autoApprovalSettings.actions).filter( + (key) => autoApprovalSettings.actions[key as keyof typeof autoApprovalSettings.actions], + ) const enabledActions = enabledActionsNames.map((action) => { return ACTION_METADATA.flatMap((a) => [a, a.subAction]).find((a) => a?.id === action) }) @@ -90,31 +97,37 @@ const AutoApproveBar = ({ style }: AutoApproveBarProps) => { }} /> -
{ - setIsModalVisible((prev) => !prev) - }} - onKeyDown={(e) => { - if (e.key === "Enter" || e.key === " ") { - e.preventDefault() - e.stopPropagation() - setIsModalVisible((prev) => !prev) - } - }} - ref={buttonRef} - tabIndex={0}> -
- Auto-approve: - {getEnabledActionsText()} -
- {isModalVisible ? : } -
+ + +
{ + setIsModalVisible((prev) => !prev) + }} + onKeyDown={(e) => { + if (e.key === "Enter" || e.key === " ") { + e.preventDefault() + e.stopPropagation() + setIsModalVisible((prev) => !prev) + } + }} + ref={buttonRef} + tabIndex={0}> +
+ Auto-approve: + {getEnabledActionsText()} +
+ {isModalVisible ? : } +
+
+ {cloudAutoApprove && {CLOUD_AUTO_APPROVE_TOOLTIP}} +
diff --git a/apps/vscode/webview-ui/src/components/chat/auto-approve-menu/AutoApproveMenuItem.tsx b/apps/vscode/webview-ui/src/components/chat/auto-approve-menu/AutoApproveMenuItem.tsx index 9ed1766c0e..f6b2af0b7f 100644 --- a/apps/vscode/webview-ui/src/components/chat/auto-approve-menu/AutoApproveMenuItem.tsx +++ b/apps/vscode/webview-ui/src/components/chat/auto-approve-menu/AutoApproveMenuItem.tsx @@ -44,14 +44,19 @@ const AutoApproveMenuItem = ({ action, isChecked, onToggle, showIcon = true, dis
- {showIcon && } + {showIcon && } {action.label}
{action.subAction && ( - + )}
diff --git a/apps/vscode/webview-ui/src/components/chat/auto-approve-menu/AutoApproveModal.tsx b/apps/vscode/webview-ui/src/components/chat/auto-approve-menu/AutoApproveModal.tsx index 322e917f10..3302d0038e 100644 --- a/apps/vscode/webview-ui/src/components/chat/auto-approve-menu/AutoApproveModal.tsx +++ b/apps/vscode/webview-ui/src/components/chat/auto-approve-menu/AutoApproveModal.tsx @@ -12,10 +12,18 @@ interface AutoApproveModalProps { setIsVisible: (visible: boolean) => void buttonRef: React.RefObject ACTION_METADATA: ActionMetadata[] + cloudAutoApprove?: boolean } -const AutoApproveModal: React.FC = ({ isVisible, setIsVisible, buttonRef, ACTION_METADATA }) => { +const AutoApproveModal: React.FC = ({ + isVisible, + setIsVisible, + buttonRef, + ACTION_METADATA, + cloudAutoApprove = false, +}) => { const { isChecked, updateAction } = useAutoApproveActions() + const displayedIsChecked = cloudAutoApprove ? () => true : isChecked const modalRef = useRef(null) const itemsContainerRef = useRef(null) const [containerWidth, setContainerWidth] = useState(0) @@ -68,7 +76,9 @@ const AutoApproveModal: React.FC = ({ isVisible, setIsVis maxHeight: "60vh", }}>
setIsVisible(false)}> - Let Cline take these actions without asking for approval.{" "} + {cloudAutoApprove + ? "Cloud sessions always auto-approve these actions. Your local settings are unchanged. " + : "Let Cline take these actions without asking for approval. "} = ({ isVisible, setIsVis {/* All items in a single list - CSS Grid will handle the column distribution */} {ACTION_METADATA.map((action) => ( - + ))}
diff --git a/apps/vscode/webview-ui/src/components/chat/chat-view/components/layout/InputSection.test.tsx b/apps/vscode/webview-ui/src/components/chat/chat-view/components/layout/InputSection.test.tsx index 164cc40150..210d2b2baf 100644 --- a/apps/vscode/webview-ui/src/components/chat/chat-view/components/layout/InputSection.test.tsx +++ b/apps/vscode/webview-ui/src/components/chat/chat-view/components/layout/InputSection.test.tsx @@ -6,8 +6,9 @@ import type { ChatState, MessageHandlers, ScrollBehavior } from "../../types/cha import { InputSection } from "./InputSection" const mockTurnState = vi.fn<() => TurnState | undefined>(() => undefined) +const mockCloudState = vi.fn<() => Record>(() => ({})) vi.mock("@/context/ExtensionStateContext", () => ({ - useExtensionState: () => ({ turnState: mockTurnState() }), + useExtensionState: () => ({ turnState: mockTurnState(), ...mockCloudState() }), })) vi.mock("@/components/chat/ChatTextArea", () => ({ @@ -132,6 +133,92 @@ describe("InputSection", () => { expect(handleSendMessage).toHaveBeenCalledWith("queue this", [], []) }) + it.each([ + ["without a repository", { target: "cloud" }, true], + ["with a repository", { target: "cloud", repoUrl: "https://github.com/cline/fixture" }, false], + ])("on the home screen with Cloud selected %s, submit disabled=%s", (_label, cloudTaskTarget, disabled) => { + mockTurnState.mockReturnValue({ phase: "idle", seq: 1 }) + mockCloudState.mockReturnValue({ cloudSessionsEnabled: true, cloudTaskTarget, clineMessages: [] }) + const handleSendMessage = vi.fn().mockResolvedValue(undefined) + + render( + , + ) + + const composer = screen.getByLabelText("composer") + fireEvent.keyDown(composer, { key: "Enter" }) + if (disabled) { + expect(composer).toBeDisabled() + expect(handleSendMessage).not.toHaveBeenCalled() + } else { + expect(composer).not.toBeDisabled() + expect(handleSendMessage).toHaveBeenCalledWith("queue this", [], []) + } + mockCloudState.mockReturnValue({}) + }) + + it("refuses to send attached files to a cloud sandbox and says why", () => { + mockTurnState.mockReturnValue({ phase: "idle", seq: 1 }) + mockCloudState.mockReturnValue({ + cloudSessionsEnabled: true, + cloudTaskTarget: { target: "cloud", repoUrl: "https://github.com/cline/fixture" }, + clineMessages: [], + }) + const handleSendMessage = vi.fn().mockResolvedValue(undefined) + + render( + , + ) + + const composer = screen.getByLabelText("composer") + expect(composer).toBeDisabled() + expect(screen.getByRole("status")).toHaveTextContent("Attached files can't be sent to a cloud sandbox") + fireEvent.keyDown(composer, { key: "Enter" }) + expect(handleSendMessage).not.toHaveBeenCalled() + mockCloudState.mockReturnValue({}) + }) + + it("keeps submit disabled while a cloud sandbox is still provisioning", () => { + mockTurnState.mockReturnValue({ phase: "streaming", seq: 1 }) + mockCloudState.mockReturnValue({ + cloudSessionsEnabled: true, + currentCloudTask: { sessionId: "cloud-provisioning-1", status: "provisioning" }, + clineMessages: [{ ts: 1, type: "say", say: "task", text: "test" }], + }) + const handleSendMessage = vi.fn().mockResolvedValue(undefined) + + render( + , + ) + + const composer = screen.getByLabelText("composer") + expect(composer).toBeDisabled() + fireEvent.keyDown(composer, { key: "Enter" }) + expect(handleSendMessage).not.toHaveBeenCalled() + mockCloudState.mockReturnValue({}) + }) + it("allows submit after a failed request so the draft can start the next turn", () => { mockTurnState.mockReturnValue({ phase: "error", seq: 1 }) const handleSendMessage = vi.fn().mockResolvedValue(undefined) diff --git a/apps/vscode/webview-ui/src/components/chat/chat-view/components/layout/InputSection.tsx b/apps/vscode/webview-ui/src/components/chat/chat-view/components/layout/InputSection.tsx index c1d089690a..a8f1952a76 100644 --- a/apps/vscode/webview-ui/src/components/chat/chat-view/components/layout/InputSection.tsx +++ b/apps/vscode/webview-ui/src/components/chat/chat-view/components/layout/InputSection.tsx @@ -1,3 +1,4 @@ +import { isCloudTargetReady } from "@shared/cloud/cloud-sessions" import React from "react" import ChatTextArea from "@/components/chat/ChatTextArea" import QuotedMessagePreview from "@/components/chat/QuotedMessagePreview" @@ -42,7 +43,7 @@ export const InputSection: React.FC = ({ } = chatState const { isAtBottom, scrollToBottomAuto } = scrollBehavior - const { turnState } = useExtensionState() + const { turnState, cloudSessionsEnabled, cloudTaskTarget, currentCloudTask, clineMessages = [] } = useExtensionState() const legacyTaskRunning = turnState === undefined && (lastMessage?.partial === true || (lastMessage?.type === "say" && lastMessage.say === "api_req_started")) @@ -51,10 +52,27 @@ export const InputSection: React.FC = ({ turnState?.phase === "awaiting_approval" || (messageHandlers.errorRecoveryAvailable && pendingResponse === undefined && !messageHandlers.recoveryActionInFlight) || legacyTaskRunning - const submitDisabled = sendingDisabled && !allowSubmitWhileDisabled + const cloudSelectedForNewTask = clineMessages.length === 0 && !!cloudSessionsEnabled && cloudTaskTarget?.target === "cloud" + // The task-target panel above the composer shows what is still missing. + const cloudTargetIncomplete = cloudSelectedForNewTask && !isCloudTargetReady(cloudTaskTarget) + // Local file paths cannot be read from a cloud sandbox. Refuse the send + // rather than silently dropping the attachments from the request. + const cloudFilesUnsupported = cloudSelectedForNewTask && selectedFiles.length > 0 + // A provisioning sandbox has no session to queue a follow-up on yet; the draft stays in the composer. + const submitDisabled = + (sendingDisabled && !allowSubmitWhileDisabled) || + cloudTargetIncomplete || + cloudFilesUnsupported || + currentCloudTask?.status === "provisioning" return ( <> + {cloudFilesUnsupported && ( +

+ Attached files can't be sent to a cloud sandbox. Remove them, or mention them in the prompt and let the agent + read them from the repository. +

+ )} {activeQuote && (
= ({ + isVisible, showAnnouncement, hideAnnouncement, showHistoryView, @@ -73,6 +76,7 @@ export const WelcomeSection: React.FC = ({ worktreesEnabled, banners, welcomeBanners, + cloudSessionsEnabled, } = useExtensionState() const { handleFieldsChange } = useApiConfigurationHandlers() const { isClinePassEnabled, isUsingClinePass, openSubscribePage, switchToClinePassProvider } = useClinePassPromo() @@ -327,6 +331,7 @@ export const WelcomeSection: React.FC = ({ {!showWhatsNewModal && ( <> + {cloudSessionsEnabled && } {!shouldShowQuickWins && taskHistory.length > 0 && } {/* Quick launch worktree button */} {isGitRepo && worktreesEnabled?.featureFlag && worktreesEnabled?.user && ( @@ -378,6 +383,11 @@ export const WelcomeSection: React.FC = ({ )}
+ {cloudSessionsEnabled && isVisible && ( +
+ +
+ )} {/* Quick launch worktree modal */} ({ StringRequest: { create: (x: unknown) => x }, })) -// useExtensionState supplies turnState (+ backgroundCommandRunning) to the hook. +// useExtensionState supplies turnState (+ backgroundCommandRunning, cloud target) to the hook. let mockTurnState: TurnState | undefined +let mockCloudTaskTarget: { target: "local" | "cloud"; repoUrl?: string; branch?: string } | undefined vi.mock("@/context/ExtensionStateContext", () => ({ useExtensionState: () => ({ backgroundCommandRunning: false, turnState: mockTurnState, + cloudSessionsEnabled: true, + cloudTaskTarget: mockCloudTaskTarget, }), })) @@ -119,6 +124,33 @@ describe("useMessageHandlers — send routing", () => { trackIntent.mockReset() trackIntent.mockResolvedValue(undefined) mockTurnState = undefined + mockCloudTaskTarget = undefined + }) + + it("sends a cloud request when Cloud is selected even without a repository, never a local task", async () => { + mockCloudTaskTarget = { target: "cloud" } + const { result } = renderHook(() => useMessageHandlers([], makeChatState([]))) + + await act(async () => { + await result.current.handleSendMessage("hello", [], ["/local/file.ts"]) + }) + + expect(newTask).toHaveBeenCalledTimes(1) + expect(newTask).toHaveBeenCalledWith( + expect.objectContaining({ text: "hello", executionTarget: "cloud", cloudRepoUrl: undefined, files: [] }), + ) + }) + + it("sends a local request when Local is selected", async () => { + mockCloudTaskTarget = { target: "local", repoUrl: "https://github.com/cline/fixture" } + const { result } = renderHook(() => useMessageHandlers([], makeChatState([]))) + + await act(async () => { + await result.current.handleSendMessage("hello", [], ["/local/file.ts"]) + }) + + expect(newTask).toHaveBeenCalledWith(expect.objectContaining({ text: "hello", files: ["/local/file.ts"] })) + expect(newTask.mock.calls[0][0]).not.toHaveProperty("executionTarget") }) it("routes /compact to the condense RPC instead of sending it as a message", async () => { @@ -514,6 +546,30 @@ describe("useMessageHandlers — send routing", () => { }) }) + it("disables recovery for an unconfirmed cloud creation but still clears it for New Task", async () => { + mockTurnState = { phase: "error", anchorTs: 2, seq: 4 } + const conversation: ClineMessage[] = [ + { ts: 1, type: "say", say: "task", text: "task" }, + { + ts: 2, + type: "ask", + ask: "api_req_failed", + text: new ClineError({ + message: "Check History before starting another cloud task.", + code: CLOUD_CREATION_UNCONFIRMED_ERROR_CODE, + }).serialize(), + }, + ] + const { result } = renderHook(() => useMessageHandlers(conversation, makeChatState(conversation))) + expect(result.current.errorRecoveryAvailable).toBe(false) + await act(async () => { + await result.current.startNewTask() + }) + expect(clearTask).toHaveBeenCalledOnce() + expect(askResponse).not.toHaveBeenCalled() + expect(newTask).not.toHaveBeenCalled() + }) + it("submits a new prompt instead of retrying the failed prompt from the error state", async () => { mockTurnState = { phase: "error", anchorTs: 2, seq: 4 } const failedConversation: ClineMessage[] = [ @@ -841,6 +897,37 @@ describe("useMessageHandlers — send routing", () => { expect(trackIntent).toHaveBeenCalledWith(expect.objectContaining({ action: "new_task_clicked", source: "navbar" })) }) + it("keeps a draft typed while a new task's RPC is still pending", async () => { + mockTurnState = { phase: "idle", seq: 1 } + let resolveNewTask: () => void = () => {} + newTask.mockImplementationOnce( + () => + new Promise((resolve) => { + resolveNewTask = resolve + }), + ) + const { result } = renderHook(() => { + const chatState = useChatState([]) + return { chatState, handlers: useMessageHandlers([], chatState) } + }) + act(() => result.current.chatState.setInputValue("start a task")) + + let sendPromise: Promise = Promise.resolve() + await act(async () => { + sendPromise = result.current.handlers.handleSendMessage("start a task", [], []) + await Promise.resolve() + }) + expect(result.current.chatState.inputValue).toBe("") + // The composer disables submit while the task provisions, so the draft stays in the input. + act(() => result.current.chatState.setInputValue("follow up")) + + await act(async () => { + resolveNewTask() + await sendPromise + }) + expect(result.current.chatState.inputValue).toBe("follow up") + }) + it("preserves edits made while a recovery response succeeds", async () => { mockTurnState = { phase: "error", anchorTs: 2, seq: 4 } const failedConversation: ClineMessage[] = [ diff --git a/apps/vscode/webview-ui/src/components/chat/chat-view/hooks/useMessageHandlers.ts b/apps/vscode/webview-ui/src/components/chat/chat-view/hooks/useMessageHandlers.ts index 32f7542801..556b544104 100644 --- a/apps/vscode/webview-ui/src/components/chat/chat-view/hooks/useMessageHandlers.ts +++ b/apps/vscode/webview-ui/src/components/chat/chat-view/hooks/useMessageHandlers.ts @@ -30,7 +30,7 @@ function isCompactionCommand(text: string): boolean { * Handles sending messages, button clicks, and task management */ export function useMessageHandlers(messages: ClineMessage[], chatState: ChatState): MessageHandlers { - const { backgroundCommandRunning, turnState } = useExtensionState() + const { backgroundCommandRunning, turnState, cloudSessionsEnabled, cloudTaskTarget } = useExtensionState() const { setInputValue, activeQuote, @@ -262,10 +262,23 @@ export function useMessageHandlers(messages: ClineMessage[], chatState: ChatStat } if (messages.length === 0) { + // Cloud selected means cloud requested, even without a repository: the + // extension refuses an incomplete cloud request rather than running the + // task locally behind a Cloud label. + const runInCloud = !!cloudSessionsEnabled && cloudTaskTarget?.target === "cloud" const request = NewTaskRequest.create({ text: messageToSend, images, - files, + // Local file paths cannot be read from a cloud sandbox; the composer + // refuses a cloud send while files are attached. + files: runInCloud ? [] : files, + ...(runInCloud + ? { + executionTarget: "cloud", + cloudRepoUrl: cloudTaskTarget.repoUrl, + cloudBranch: cloudTaskTarget.branch, + } + : {}), }) clearSentMessageState() trackPromptSubmitted(false) @@ -391,10 +404,10 @@ export function useMessageHandlers(messages: ClineMessage[], chatState: ChatStat } } - // New tasks clear optimistically before the RPC; the repeated success cleanup is idempotent. + // Every send path clears the draft before its RPC. Do not clear it again + // here: the RPC can take a while (a cloud task provisions its sandbox + // first) and the user may have typed a new draft in the meantime. if (messageSent) { - clearSentMessageState() - // Reset auto-scroll if ("disableAutoScrollRef" in chatState) { ;(chatState as any).disableAutoScrollRef.current = false @@ -423,6 +436,8 @@ export function useMessageHandlers(messages: ClineMessage[], chatState: ChatStat setPendingUserMessage, setPendingResponse, chatState, + cloudSessionsEnabled, + cloudTaskTarget, ], ) diff --git a/apps/vscode/webview-ui/src/components/chat/chat-view/messageReducer.test.ts b/apps/vscode/webview-ui/src/components/chat/chat-view/messageReducer.test.ts index 685cf72c35..818e6249b8 100644 --- a/apps/vscode/webview-ui/src/components/chat/chat-view/messageReducer.test.ts +++ b/apps/vscode/webview-ui/src/components/chat/chat-view/messageReducer.test.ts @@ -233,5 +233,35 @@ describe("messageReducer — deterministic", () => { expect(tsList(s)).toContain(1) expect(tsList(s)).toContain(2) }) + + it("the first snapshot at the new epoch drops the rows carried forward from the old one", () => { + // A new task's partials (task row, status row) can reach the webview before the + // state snapshot that replaces the transcript. Carrying the old rows forward keeps + // the view alive meanwhile, but once the snapshot for the new epoch arrives the + // old task must not stay interleaved above the new one. + let s = createReplicaState() + s = applyStateSnapshot(s, [msg(1, 1, 1, false, "old task"), msg(2, 2, 1, false, "old error")], 1, 5) + s = applyMessage(s, msg(10, 6, 2, false, "new task")) + s = applyMessage(s, msg(11, 7, 2, true, "start")) + s = applyMessage(s, msg(11, 9, 2, false, "starting")) + expect(texts(s)).toEqual(["old task", "old error", "new task", "starting"]) + + // The snapshot was built between the two copies of ts 11 and also carries a row + // (ts 12) whose partial-stream copy never arrived. + s = applyStateSnapshot( + s, + [msg(10, 6, 2, false, "new task"), msg(11, 7, 2, true, "start"), msg(12, 8, 2, false, "failed")], + 2, + 10, + ) + expect(texts(s)).toEqual(["new task", "starting", "failed"]) + expect(s.turnState).toBeUndefined() + + // Later same-epoch snapshots merge as usual. + s = applyMessage(s, msg(13, 11, 2, false, "retry offered")) + s = applyStateSnapshot(s, [msg(10, 6, 2, false, "new task")], 2, 12, { phase: "error", seq: 12 }) + expect(texts(s)).toEqual(["new task", "starting", "failed", "retry offered"]) + expect(s.turnState).toEqual({ phase: "error", seq: 12 }) + }) }) }) diff --git a/apps/vscode/webview-ui/src/components/chat/chat-view/messageReducer.ts b/apps/vscode/webview-ui/src/components/chat/chat-view/messageReducer.ts index ea45126e80..51aa4ebb91 100644 --- a/apps/vscode/webview-ui/src/components/chat/chat-view/messageReducer.ts +++ b/apps/vscode/webview-ui/src/components/chat/chat-view/messageReducer.ts @@ -33,6 +33,11 @@ export interface ReplicaState { * phase (e.g. "streaming"). `undefined` for classic/legacy state with no turnState. */ turnState?: TurnState + /** + * Set when a lone newer-epoch message advanced `epoch` while the transcript still holds + * the previous epoch's rows. The first snapshot at `epoch` replaces them. + */ + transcriptCarriedForward?: boolean } /** Create an empty replica. */ @@ -105,14 +110,15 @@ export function applyMessage(state: ReplicaState, incoming: ClineMessage): Repli if (incomingEpoch > state.epoch) { // Advance the fence without throwing away an existing transcript. Carry the prior - // messages forward at the new epoch and merge this one in; a subsequent newer-epoch - // snapshot performs the real wholesale replace when a genuine new task begins. + // messages forward at the new epoch and merge this one in; the first snapshot at this + // epoch (applyStateSnapshot) replaces the carried rows with the real transcript. const advanced: ReplicaState = { messages: [...state.messages], epoch: incomingEpoch, seqByTs: new Map(state.seqByTs), stateVersion: state.stateVersion, turnState: state.turnState, + transcriptCarriedForward: state.messages.length > 0, } return applyMessage(advanced, incoming) } @@ -145,6 +151,8 @@ export function applyMessage(state: ReplicaState, incoming: ClineMessage): Repli * * - older epoch -> drop entirely * - newer epoch -> replace the transcript wholesale (new task / history load) + * - same epoch, reached by a partial before any snapshot + * -> replace the carried-forward transcript, keeping this epoch's partials * - same epoch: * - older/equal stateVersion -> ignore (a newer snapshot already applied) * - newer stateVersion -> MERGE each message by ts/seq (NEVER truncate). This is the @@ -173,7 +181,19 @@ export function applyStateSnapshot( return resetTo(snapshotEpoch, snapshotMessages, snapshotVersion, snapshotTurnState) } - // Same epoch. + // Same epoch. When a partial reached this epoch first, the replica still shows the + // previous conversation's rows. This is the snapshot that would have replaced them had it + // arrived first, so apply it that way, then re-merge the partials already held for this + // epoch (they may be fresher than the snapshot's copies). + if (state.transcriptCarriedForward) { + let next = resetTo(snapshotEpoch, snapshotMessages, snapshotVersion, state.turnState) + for (const message of state.messages) { + if (epochOf(message) === snapshotEpoch) { + next = applyMessage(next, message) + } + } + return applyTurnState(next, snapshotTurnState) + } if (snapshotVersion !== 0 && snapshotVersion <= state.stateVersion) { // A newer (or equal) snapshot already applied for the transcript — but a turnState with a // higher seq may still need to move the UI forward, so don't bail before the seq gate. diff --git a/apps/vscode/webview-ui/src/components/chat/chat-view/shared/buttonConfig.test.ts b/apps/vscode/webview-ui/src/components/chat/chat-view/shared/buttonConfig.test.ts index 9d1f4b50b9..0e6b5e9ac6 100644 --- a/apps/vscode/webview-ui/src/components/chat/chat-view/shared/buttonConfig.test.ts +++ b/apps/vscode/webview-ui/src/components/chat/chat-view/shared/buttonConfig.test.ts @@ -1,5 +1,7 @@ +import { CLOUD_CREATION_UNCONFIRMED_ERROR_CODE } from "@shared/cloud/cloud-sessions" import type { ClineMessage, TurnState } from "@shared/ExtensionMessage" import { describe, expect, it } from "vitest" +import { ClineError } from "../../../../../../src/services/error/ClineError" import { BUTTON_CONFIGS, buttonsForPhase, @@ -222,6 +224,26 @@ describe("buttonsForPhase (TurnState-driven)", () => { const mistake: ClineMessage = { ts: 8, type: "ask", ask: "mistake_limit_reached", text: "" } expect(buttonsForPhase(ts("error", 8), mistake)).toEqual(BUTTON_CONFIGS.mistake_limit_reached) }) + + it("offers only New Task for an unconfirmed cloud creation in anchored and legacy state", () => { + const failed: ClineMessage = { + ts: 9, + type: "ask", + ask: "api_req_failed", + text: new ClineError({ + message: "Check History before starting another cloud task.", + code: CLOUD_CREATION_UNCONFIRMED_ERROR_CODE, + }).serialize(), + } + for (const state of [ts("error", failed.ts), undefined]) { + expect(getButtonConfigFromState([failed], state, "act")).toEqual({ + sendingDisabled: true, + enableButtons: true, + primaryText: "Start New Task", + primaryAction: "new_task", + }) + } + }) }) describe("getButtonConfigFromState (dispatch + legacy fallback)", () => { diff --git a/apps/vscode/webview-ui/src/components/chat/chat-view/shared/buttonConfig.ts b/apps/vscode/webview-ui/src/components/chat/chat-view/shared/buttonConfig.ts index 1d7d5794ca..babaf1d6e4 100644 --- a/apps/vscode/webview-ui/src/components/chat/chat-view/shared/buttonConfig.ts +++ b/apps/vscode/webview-ui/src/components/chat/chat-view/shared/buttonConfig.ts @@ -1,5 +1,7 @@ +import { CLOUD_CREATION_UNCONFIRMED_ERROR_CODE } from "@shared/cloud/cloud-sessions" import type { ClineMessage, ClineSayTool, TurnState } from "@shared/ExtensionMessage" import type { Mode } from "@shared/storage/types" +import { ClineError } from "../../../../../../src/services/error/ClineError" /** * Button action types that determine the behavior @@ -33,6 +35,12 @@ export const BUTTON_CONFIGS: Record = { primaryAction: "retry", secondaryAction: "new_task", }, + cloud_creation_unconfirmed: { + sendingDisabled: true, + enableButtons: true, + primaryText: "Start New Task", + primaryAction: "new_task", + }, mistake_limit_reached: { sendingDisabled: false, enableButtons: true, @@ -243,7 +251,9 @@ export function getButtonConfig(message: ClineMessage | undefined, _mode: Mode = switch (message.ask) { // Error recovery states case "api_req_failed": - return BUTTON_CONFIGS.api_req_failed + return ClineError.parse(message.text)?._error.code === CLOUD_CREATION_UNCONFIRMED_ERROR_CODE + ? BUTTON_CONFIGS.cloud_creation_unconfirmed + : BUTTON_CONFIGS.api_req_failed case "mistake_limit_reached": return BUTTON_CONFIGS.mistake_limit_reached @@ -376,10 +386,11 @@ export function buttonsForPhase( case "resumable": return BUTTON_CONFIGS.resume_task case "error": - // The anchored message distinguishes mistake_limit (Proceed/New Task) from a failed - // API request (Retry/New Task). Default to the retry config. - if (anchoredMessage?.type === "ask" && anchoredMessage.ask === "mistake_limit_reached") { - return BUTTON_CONFIGS.mistake_limit_reached + if ( + anchoredMessage?.type === "ask" && + (anchoredMessage.ask === "mistake_limit_reached" || anchoredMessage.ask === "api_req_failed") + ) { + return getButtonConfig(anchoredMessage) } return BUTTON_CONFIGS.api_req_failed case "awaiting_followup": diff --git a/apps/vscode/webview-ui/src/components/chat/chat-view/types/chatTypes.ts b/apps/vscode/webview-ui/src/components/chat/chat-view/types/chatTypes.ts index 44f316f035..7ee786cdde 100644 --- a/apps/vscode/webview-ui/src/components/chat/chat-view/types/chatTypes.ts +++ b/apps/vscode/webview-ui/src/components/chat/chat-view/types/chatTypes.ts @@ -122,6 +122,7 @@ export interface ScrollBehavior { * Welcome section props */ export interface WelcomeSectionProps { + isVisible: boolean showAnnouncement: boolean hideAnnouncement: () => void showHistoryView: () => void diff --git a/apps/vscode/webview-ui/src/components/chat/chat-view/utils/pendingResponse.test.ts b/apps/vscode/webview-ui/src/components/chat/chat-view/utils/pendingResponse.test.ts index f284ff520c..f06ad4b0e2 100644 --- a/apps/vscode/webview-ui/src/components/chat/chat-view/utils/pendingResponse.test.ts +++ b/apps/vscode/webview-ui/src/components/chat/chat-view/utils/pendingResponse.test.ts @@ -47,4 +47,11 @@ describe("withPendingUserMessage", () => { expect(withPendingUserMessage([task], { afterTs: 1, message: followup })).toEqual([task, followup]) }) + + it("treats a task row as confirmation of a follow-up bubble that restarted the task", () => { + const followup: ClineMessage = { ts: 3, type: "say", say: "user_feedback", text: "revised prompt" } + const restarted: ClineMessage = { ts: 4, type: "say", say: "task", text: "revised prompt" } + + expect(withPendingUserMessage([restarted], { afterTs: 2, message: followup })).toEqual([restarted]) + }) }) diff --git a/apps/vscode/webview-ui/src/components/chat/chat-view/utils/pendingResponse.ts b/apps/vscode/webview-ui/src/components/chat/chat-view/utils/pendingResponse.ts index a41b59e45d..7c947fee6e 100644 --- a/apps/vscode/webview-ui/src/components/chat/chat-view/utils/pendingResponse.ts +++ b/apps/vscode/webview-ui/src/components/chat/chat-view/utils/pendingResponse.ts @@ -1,18 +1,26 @@ import type { ClineMessage, TurnState } from "@shared/ExtensionMessage" import type { PendingResponse, PendingUserMessage } from "../types/chatTypes" +/** The rows the backend writes for text the user submitted. */ +function isUserAuthored(message: ClineMessage): boolean { + return message.type === "say" && (message.say === "task" || message.say === "user_feedback") +} + +/** + * A submission is confirmed by the user-authored row the backend writes for it. The row's + * kind depends on how the backend handled the submission, not on where it was typed: a prompt + * sent from a task that failed before it had a session starts a new task, so the optimistic + * `user_feedback` bubble is confirmed by a `task` row. + */ function sameOptimisticMessage(left: ClineMessage, right: ClineMessage): boolean { const leftImages = left.images ?? [] const rightImages = right.images ?? [] const leftFiles = left.files ?? [] const rightFiles = right.files ?? [] - const optimisticSay = left.say === "task" || left.say === "user_feedback" return ( - left.type === "say" && - right.type === "say" && - optimisticSay && - left.say === right.say && + isUserAuthored(left) && + isUserAuthored(right) && left.text === right.text && leftImages.length === rightImages.length && leftImages.every((image, index) => image === rightImages[index]) && diff --git a/apps/vscode/webview-ui/src/components/chat/task-header/TaskHeader.test.tsx b/apps/vscode/webview-ui/src/components/chat/task-header/TaskHeader.test.tsx new file mode 100644 index 0000000000..28398675fc --- /dev/null +++ b/apps/vscode/webview-ui/src/components/chat/task-header/TaskHeader.test.tsx @@ -0,0 +1,20 @@ +import { describe, expect, it } from "vitest" +import { isTaskCostAvailable } from "./TaskHeader" + +describe("TaskHeader cost badge", () => { + it("hides the cost of a cloud task until the sandbox has reported usage", () => { + // The local provider would show a cost, but nothing about a cloud task's + // charge is known yet; $0.0000 would read as a confirmed zero. + expect(isTaskCostAvailable({ isCloudTask: true, cloudUsageAvailable: undefined, localCostAvailable: true })).toBe(false) + expect(isTaskCostAvailable({ isCloudTask: true, cloudUsageAvailable: false, localCostAvailable: true })).toBe(false) + }) + + it("shows a cloud task's cost once the sandbox has reported usage, whatever the local provider is", () => { + expect(isTaskCostAvailable({ isCloudTask: true, cloudUsageAvailable: true, localCostAvailable: false })).toBe(true) + }) + + it("leaves local tasks on the local provider's cost display", () => { + expect(isTaskCostAvailable({ isCloudTask: false, cloudUsageAvailable: undefined, localCostAvailable: true })).toBe(true) + expect(isTaskCostAvailable({ isCloudTask: false, cloudUsageAvailable: undefined, localCostAvailable: false })).toBe(false) + }) +}) diff --git a/apps/vscode/webview-ui/src/components/chat/task-header/TaskHeader.tsx b/apps/vscode/webview-ui/src/components/chat/task-header/TaskHeader.tsx index 22377736d6..59bfebd7dc 100644 --- a/apps/vscode/webview-ui/src/components/chat/task-header/TaskHeader.tsx +++ b/apps/vscode/webview-ui/src/components/chat/task-header/TaskHeader.tsx @@ -1,6 +1,7 @@ import { ClineMessage } from "@shared/ExtensionMessage" import { ChevronDownIcon, ChevronRightIcon } from "lucide-react" import React, { useCallback, useLayoutEffect, useMemo, useState } from "react" +import { CloudTaskBadge } from "@/components/cloud/CloudTaskBadge" import Thumbnails from "@/components/common/Thumbnails" import { getModeSpecificFields } from "@/components/settings/utils/providerUtils" import { useExtensionState } from "@/context/ExtensionStateContext" @@ -34,6 +35,23 @@ interface TaskHeaderProps { const BUTTON_CLASS = "max-h-3 border-0 font-bold bg-transparent hover:opacity-100 text-foreground" +/** + * Whether the header may show the task's cost. A cloud task is billed by the + * cloud service, so its cost is shown only when the sandbox reported usage; + * the local provider's cost display setting does not apply to it, and an + * unknown cloud charge must not render as a confirmed $0.0000. + */ +export function isTaskCostAvailable(input: { + isCloudTask: boolean + cloudUsageAvailable: boolean | undefined + localCostAvailable: boolean +}): boolean { + if (input.isCloudTask) { + return input.cloudUsageAvailable === true + } + return input.localCostAvailable +} + const TaskHeader: React.FC = ({ task, tokensIn, @@ -56,6 +74,7 @@ const TaskHeader: React.FC = ({ environment, workspaceRoots, platform, + currentCloudTask, } = useExtensionState() const [isHighlightedTextExpanded, setIsHighlightedTextExpanded] = useState(false) @@ -103,15 +122,21 @@ const TaskHeader: React.FC = ({ // mirrors the CLI's `shouldShowCliUsageCost` consumer and removes the // previous extension-side hard-coded "openai-codex" check. const usageCostDisplay = useProviderUsageCostDisplay(modeFields.apiProvider) - const isCostAvailable = - (totalCost && - modeFields.apiProvider === "openai" && - modeFields.openAiModelInfo?.inputPrice && - modeFields.openAiModelInfo?.outputPrice) || - (modeFields.apiProvider !== "vscode-lm" && - modeFields.apiProvider !== "ollama" && - modeFields.apiProvider !== "lmstudio" && - usageCostDisplay === "show") + const isCostAvailable = isTaskCostAvailable({ + isCloudTask: !!currentCloudTask, + cloudUsageAvailable: currentTaskItem?.cloudUsageAvailable, + localCostAvailable: + !!( + totalCost && + modeFields.apiProvider === "openai" && + modeFields.openAiModelInfo?.inputPrice && + modeFields.openAiModelInfo?.outputPrice + ) || + (modeFields.apiProvider !== "vscode-lm" && + modeFields.apiProvider !== "ollama" && + modeFields.apiProvider !== "lmstudio" && + usageCostDisplay === "show"), + }) // Event handlers const toggleTaskExpanded = useCallback(() => setIsTaskExpanded(!isTaskExpanded), [setIsTaskExpanded, isTaskExpanded]) @@ -170,11 +195,15 @@ const TaskHeader: React.FC = ({ )}
- + {currentCloudTask ? ( + + ) : ( + + )} {isCostAvailable && (
{ + setWaiting(true) + CloudServiceClient.connectGitHub(EmptyRequest.create()).catch((error) => + console.error("Failed to open GitHub connect:", error), + ) + } + + const connected = !!connection?.connected + const repoCount = connection?.repositories.length ?? 0 + useEffect(() => { + if (connected && repoCount > 0) { + setWaiting(false) + } + }, [connected, repoCount]) + + return ( +
+
+ + Cline Cloud + +
+
+ {connection?.error ? ( + Could not check GitHub: {connection.error} + ) : !connection ? ( + + Checking GitHub connection… + + ) : connected ? ( + + + + GitHub connected + {" · "} + {repoCount === 0 + ? "no repositories accessible yet" + : `${repoCount} ${repoCount === 1 ? "repository" : "repositories"} accessible`} + + + ) : ( + + Connect GitHub to run tasks in isolated cloud sandboxes that keep working after you close VS Code. + + )} +
+
+ + {connected ? "Manage repository access" : "Connect GitHub"} + + {waiting && !(connected && repoCount > 0) && ( + + Waiting for GitHub… + + )} +
+
+ ) +} diff --git a/apps/vscode/webview-ui/src/components/cloud/CloudOnboardingCard.tsx b/apps/vscode/webview-ui/src/components/cloud/CloudOnboardingCard.tsx new file mode 100644 index 0000000000..3dfd004657 --- /dev/null +++ b/apps/vscode/webview-ui/src/components/cloud/CloudOnboardingCard.tsx @@ -0,0 +1,147 @@ +import { CheckIcon, CloudIcon, GitBranchIcon, LoaderCircleIcon, LogInIcon, PlayIcon, RefreshCcwIcon } from "lucide-react" +import type { ReactNode } from "react" +import { Button } from "@/components/ui/button" +import { cn } from "@/lib/utils" + +export type CloudOnboardingVariant = "signed_out" | "not_connected" | "no_repositories" | "error" + +/** + * Shown in place of the repository pickers when a cloud task cannot be started + * yet. Explains what cloud sessions are and walks through connecting GitHub; + * the parent keeps re-checking so finishing the flow in the browser flips this + * into the ready state on its own. + */ +export function CloudOnboardingCard({ + variant, + onConnect, + onRefresh, + onSignIn, + checking = false, + waiting = false, + errorMessage, +}: { + variant: CloudOnboardingVariant + onConnect: () => void + onRefresh: () => void + onSignIn: () => void + checking?: boolean + /** True after the user opened the connect flow and we are polling for completion. */ + waiting?: boolean + errorMessage?: string +}) { + if (variant === "error") { + return ( +
+
Could not reach Cline Cloud
+
{errorMessage || "Check your connection and try again."}
+
+ +
+
+ ) + } + + const isSignedOut = variant === "signed_out" + const isNoRepositories = variant === "no_repositories" + + return ( +
+
+
+ {isSignedOut + ? "Run Cline in the cloud" + : isNoRepositories + ? "Give Cline access to a repository" + : "Connect GitHub to get started"} +
+

+ Cloud sessions run in an isolated sandbox on Cline's infrastructure. Cline clones your repository, works on a + branch, and keeps going after you close VS Code. +

+
+ +
    + {isSignedOut ? ( + } index={1} title="Sign in to Cline" /> + ) : ( + } index={1} title="Connect GitHub" /> + )} + } + index={2} + title="Choose the repositories Cline may access" + /> + } index={3} title="Pick a repo and branch, then start a task" /> +
+ +
+ {isSignedOut ? ( + + ) : ( + + )} + {!isSignedOut && ( + + {waiting || checking ? ( + <> + + {isNoRepositories ? "Waiting for repository access…" : "Waiting for GitHub…"} + + ) : ( + + )} + + )} +
+
+ ) +} + +function Step({ + icon, + index, + title, + done = false, + active = false, +}: { + icon: ReactNode + index: number + title: string + done?: boolean + active?: boolean +}) { + return ( +
  • + + {done ? : index} + + + {icon} + {title} + +
  • + ) +} diff --git a/apps/vscode/webview-ui/src/components/cloud/CloudStatusPill.test.tsx b/apps/vscode/webview-ui/src/components/cloud/CloudStatusPill.test.tsx new file mode 100644 index 0000000000..800d369b73 --- /dev/null +++ b/apps/vscode/webview-ui/src/components/cloud/CloudStatusPill.test.tsx @@ -0,0 +1,27 @@ +import { render, screen } from "@testing-library/react" +import { describe, expect, it } from "vitest" +import { CloudStatusPill, isCloudStatusActive } from "./CloudStatusPill" + +describe("cloud outcome labels", () => { + it.each([ + ["cancelled", "Cancelled"], + ["unknown", "Unconfirmed"], + ["idle", "Cloud"], + ] as const)("does not present %s as successful or running", (status, label) => { + const { container } = render() + expect(screen.getByText(label)).toBeInTheDocument() + expect(screen.queryByText("Done")).not.toBeInTheDocument() + expect(container.querySelector(".animate-spin")).not.toBeInTheDocument() + expect(isCloudStatusActive(status)).toBe(false) + }) + + it("labels confirmed completion Done", () => { + render() + expect(screen.getByText("Done")).toBeInTheDocument() + }) + + it("animates only while the sandbox is starting or the agent is working", () => { + const { container } = render() + expect(container.querySelector(".animate-spin")).toBeInTheDocument() + }) +}) diff --git a/apps/vscode/webview-ui/src/components/cloud/CloudStatusPill.tsx b/apps/vscode/webview-ui/src/components/cloud/CloudStatusPill.tsx new file mode 100644 index 0000000000..7f8b563292 --- /dev/null +++ b/apps/vscode/webview-ui/src/components/cloud/CloudStatusPill.tsx @@ -0,0 +1,52 @@ +import type { CloudSessionStatus } from "@shared/cloud/cloud-sessions" +import { CloudIcon, LoaderCircleIcon } from "lucide-react" +import { cn } from "@/lib/utils" + +const LABELS: Record = { + provisioning: "Starting", + running: "Running", + idle: "Cloud", + completed: "Done", + failed: "Failed", + cancelled: "Cancelled", + unknown: "Unconfirmed", + expired: "Archived", +} + +const TITLES: Partial> = { + unknown: "Cloud session: status could not be confirmed", + expired: + "Cloud session: the sandbox was retired after 24 hours without activity; open it to see whether a conversation was saved", +} + +export function isCloudStatusActive(status: string | undefined): boolean { + return status === "provisioning" || status === "running" +} + +function labelFor(status: string | undefined): string { + return status && status in LABELS ? LABELS[status as CloudSessionStatus] : "Cloud" +} + +/** + * Compact cloud marker for history rows: a cloud icon plus the session's state. + * Only active states animate; a status the extension could not confirm is + * shown as Unconfirmed rather than as a check that never ends. + */ +export function CloudStatusPill({ status, className }: { status: string | undefined; className?: string }) { + const active = isCloudStatusActive(status) + const label = labelFor(status) + return ( + + {active ? : } + {label} + + ) +} diff --git a/apps/vscode/webview-ui/src/components/cloud/CloudTaskBadge.test.tsx b/apps/vscode/webview-ui/src/components/cloud/CloudTaskBadge.test.tsx new file mode 100644 index 0000000000..051dadd80a --- /dev/null +++ b/apps/vscode/webview-ui/src/components/cloud/CloudTaskBadge.test.tsx @@ -0,0 +1,91 @@ +import { fireEvent, render, screen } from "@testing-library/react" +import { beforeEach, describe, expect, it, vi } from "vitest" +import { CloudTaskBadge } from "./CloudTaskBadge" + +const mocks = vi.hoisted(() => ({ + openCloudSessionDashboard: vi.fn(), + resolveCloudSessionStatuses: vi.fn(), +})) + +vi.mock("@/services/grpc-client", () => ({ + CloudServiceClient: { + openCloudSessionDashboard: mocks.openCloudSessionDashboard, + resolveCloudSessionStatuses: mocks.resolveCloudSessionStatuses, + }, +})) + +vi.mock("@/components/ui/tooltip", () => ({ + Tooltip: ({ children }: { children: React.ReactNode }) => <>{children}, + TooltipContent: ({ children }: { children: React.ReactNode }) =>
    {children}
    , + TooltipTrigger: ({ children }: { children: React.ReactNode }) => <>{children}, +})) + +describe("CloudTaskBadge", () => { + beforeEach(() => { + vi.clearAllMocks() + mocks.openCloudSessionDashboard.mockResolvedValue({}) + mocks.resolveCloudSessionStatuses.mockResolvedValue({ statuses: [] }) + }) + + it("does not open a client-only provisioning id in the dashboard", () => { + render( + , + ) + + const badge = screen.getByRole("button") + expect(badge).toBeDisabled() + fireEvent.click(badge) + expect(mocks.openCloudSessionDashboard).not.toHaveBeenCalled() + }) + + it("opens a persisted cloud session in the dashboard", () => { + render( + , + ) + + const badge = screen.getByRole("button") + expect(badge).toBeEnabled() + fireEvent.click(badge) + expect(mocks.openCloudSessionDashboard).toHaveBeenCalledWith(expect.objectContaining({ value: "ses-ready" })) + }) + + it("describes the task in whole sentences with and without a repository", () => { + const { unmount } = render( + , + ) + expect( + screen.getByText("Running in Cline Cloud on cline/fixture (main). Click to open in the dashboard."), + ).toBeInTheDocument() + unmount() + + render() + expect( + screen.getByText("Running in Cline Cloud. The dashboard link will be available when provisioning finishes."), + ).toBeInTheDocument() + }) + + it("does not present an unconfirmed status as an ongoing check", () => { + mocks.resolveCloudSessionStatuses.mockReturnValue(new Promise(() => {})) + const { container } = render() + + expect(container.querySelector(".animate-spin")).not.toBeInTheDocument() + expect(screen.getByText(/status could not be confirmed/)).toBeInTheDocument() + }) +}) diff --git a/apps/vscode/webview-ui/src/components/cloud/CloudTaskBadge.tsx b/apps/vscode/webview-ui/src/components/cloud/CloudTaskBadge.tsx new file mode 100644 index 0000000000..d4b18671c0 --- /dev/null +++ b/apps/vscode/webview-ui/src/components/cloud/CloudTaskBadge.tsx @@ -0,0 +1,50 @@ +import { type CurrentCloudTaskInfo, formatRepoLabel, isPersistedCloudSessionId } from "@shared/cloud/cloud-sessions" +import { StringRequest } from "@shared/proto/cline/common" +import { CloudIcon, ExternalLinkIcon, LoaderCircleIcon } from "lucide-react" +import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip" +import { useResolvedCloudStatuses } from "@/hooks/useResolvedCloudStatuses" +import { CloudServiceClient } from "@/services/grpc-client" + +/** Task-header marker for a task running in Cline Cloud; click opens the session in the dashboard. */ +export function CloudTaskBadge({ cloudTask }: { cloudTask: CurrentCloudTaskInfo }) { + const resolvedStatuses = useResolvedCloudStatuses([ + { id: cloudTask.sessionId, executionTarget: "cloud", cloudStatus: cloudTask.status }, + ]) + const status = resolvedStatuses.get(cloudTask.sessionId) ?? cloudTask.status + const active = status === "running" || status === "provisioning" + const unconfirmed = status === "unknown" + const canOpenDashboard = isPersistedCloudSessionId(cloudTask.sessionId) + const repo = formatRepoLabel(cloudTask.repoUrl) + const where = repo ? ` on ${repo}${cloudTask.branch ? ` (${cloudTask.branch})` : ""}` : "" + return ( + + + + + + {unconfirmed ? "Cline Cloud status could not be confirmed" : "Running in Cline Cloud"} + {where}. + {canOpenDashboard + ? " Click to open in the dashboard." + : " The dashboard link will be available when provisioning finishes."} + + + ) +} diff --git a/apps/vscode/webview-ui/src/components/cloud/RunningCloudTasks.tsx b/apps/vscode/webview-ui/src/components/cloud/RunningCloudTasks.tsx new file mode 100644 index 0000000000..f6e504b27d --- /dev/null +++ b/apps/vscode/webview-ui/src/components/cloud/RunningCloudTasks.tsx @@ -0,0 +1,78 @@ +import { formatRepoLabel } from "@shared/cloud/cloud-sessions" +import { StringRequest } from "@shared/proto/cline/common" +import { CloudIcon, LoaderCircleIcon } from "lucide-react" +import { memo, useMemo } from "react" +import { useExtensionState } from "@/context/ExtensionStateContext" +import { useResolvedCloudStatuses } from "@/hooks/useResolvedCloudStatuses" +import { TaskServiceClient } from "@/services/grpc-client" +import { isCloudStatusActive } from "./CloudStatusPill" + +/** + * Home-screen strip listing cloud tasks that are still starting or running, + * so parallel sessions stay visible without opening History. + */ +const RunningCloudTasks = () => { + const { taskHistory } = useExtensionState() + const unknown = useMemo( + () => taskHistory.filter((item) => item.executionTarget === "cloud" && item.cloudStatus === "unknown").slice(0, 10), + [taskHistory], + ) + const resolvedCloudStatuses = useResolvedCloudStatuses(unknown) + // A task stays listed while it is active or its status is still unconfirmed; + // a resolved terminal status removes it before history refreshes. + const running = useMemo( + () => + taskHistory + .filter((item) => item.executionTarget === "cloud") + .map((item) => ({ ...item, cloudStatus: resolvedCloudStatuses.get(item.id) ?? item.cloudStatus })) + .filter((item) => isCloudStatusActive(item.cloudStatus) || item.cloudStatus === "unknown"), + [resolvedCloudStatuses, taskHistory], + ) + if (running.length === 0) { + return null + } + return ( +
    +
    + + Running in the cloud + + {running.length} + +
    +
    + {running.map((item) => ( + + ))} +
    +
    + ) +} + +export default memo(RunningCloudTasks) diff --git a/apps/vscode/webview-ui/src/components/cloud/SearchableSelect.tsx b/apps/vscode/webview-ui/src/components/cloud/SearchableSelect.tsx new file mode 100644 index 0000000000..65222890ca --- /dev/null +++ b/apps/vscode/webview-ui/src/components/cloud/SearchableSelect.tsx @@ -0,0 +1,138 @@ +import { CheckIcon, ChevronDownIcon, LoaderCircleIcon } from "lucide-react" +import { type ReactNode, useEffect, useMemo, useRef, useState } from "react" +import { Input } from "@/components/ui/input" +import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover" +import { cn } from "@/lib/utils" + +export interface SearchableSelectOption { + value: string + label: string + description?: string +} + +/** Small combobox: a trigger showing the current value and a filterable list in a popover. */ +export function SearchableSelect({ + value, + options, + onChange, + onQueryChange, + placeholder, + icon, + loading, + disabled, + emptyText = "No matches", + className, + ariaLabel, +}: { + value: string | undefined + options: SearchableSelectOption[] + onChange: (value: string) => void + /** Called (debounced) when the filter text changes, for server-side searches. */ + onQueryChange?: (query: string) => void + placeholder: string + icon?: ReactNode + loading?: boolean + disabled?: boolean + emptyText?: string + className?: string + ariaLabel: string +}) { + const [open, setOpen] = useState(false) + const [query, setQuery] = useState("") + const lastEmittedQuery = useRef("") + + useEffect(() => { + if (!onQueryChange || query === lastEmittedQuery.current) { + return + } + const timer = setTimeout(() => { + lastEmittedQuery.current = query + onQueryChange(query) + }, 250) + return () => clearTimeout(timer) + }, [query, onQueryChange]) + + const filtered = useMemo(() => { + const lowered = query.trim().toLowerCase() + if (!lowered) { + return options + } + return options.filter( + (option) => option.label.toLowerCase().includes(lowered) || option.description?.toLowerCase().includes(lowered), + ) + }, [options, query]) + + const selected = options.find((option) => option.value === value) + const shownLabel = selected?.label ?? value + + return ( + { + setOpen(next) + if (!next) { + setQuery("") + } + }} + open={open}> + + + + + setQuery(event.target.value)} + placeholder={`Search…`} + value={query} + /> +
    + {filtered.length === 0 ? ( +
    {loading ? "Loading…" : emptyText}
    + ) : ( + filtered.map((option) => ( + + )) + )} +
    +
    +
    + ) +} diff --git a/apps/vscode/webview-ui/src/components/cloud/TaskTargetPanel.test.tsx b/apps/vscode/webview-ui/src/components/cloud/TaskTargetPanel.test.tsx new file mode 100644 index 0000000000..4193243fbf --- /dev/null +++ b/apps/vscode/webview-ui/src/components/cloud/TaskTargetPanel.test.tsx @@ -0,0 +1,181 @@ +import { act, render, screen } from "@testing-library/react" +import userEvent from "@testing-library/user-event" +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest" +import { TaskTargetPanel } from "./TaskTargetPanel" + +const mocks = vi.hoisted(() => ({ + cloudTaskTarget: { target: "cloud" } as { + target: "local" | "cloud" + repoUrl?: string + repositoryId?: number + branch?: string + workspaceRepoUrl?: string + }, + clineUser: undefined as { uid: string } | undefined, + connection: undefined as + | { + signedIn: boolean + connected: boolean + connectUrl: string + repositories: Array<{ id: number; name: string; fullName: string; url: string; defaultBranch: string }> + error?: string + } + | undefined, + getRepositoryBranches: vi.fn(), + chooseCloudTaskTarget: vi.fn(), +})) + +vi.mock("@/context/ExtensionStateContext", () => ({ + useExtensionState: () => ({ cloudTaskTarget: mocks.cloudTaskTarget }), +})) + +vi.mock("@/context/ClineAuthContext", () => ({ + useClineAuth: () => ({ clineUser: mocks.clineUser }), + useClineSignIn: () => ({ handleSignIn: vi.fn() }), +})) + +vi.mock("@/hooks/useGitHubConnection", () => ({ + useGitHubConnection: () => ({ connection: mocks.connection, loading: false, refresh: vi.fn() }), +})) + +vi.mock("@/services/grpc-client", () => ({ + CloudServiceClient: { + connectGitHub: vi.fn(), + getRepositoryBranches: mocks.getRepositoryBranches, + chooseCloudTaskTarget: mocks.chooseCloudTaskTarget, + }, +})) + +const clineRepo = { id: 7, name: "cline", fullName: "cline/cline", url: "https://github.com/cline/cline", defaultBranch: "main" } + +describe("TaskTargetPanel", () => { + afterEach(() => vi.unstubAllGlobals()) + beforeEach(() => { + vi.clearAllMocks() + // The branch picker's popover measures its trigger. + vi.stubGlobal( + "ResizeObserver", + class ResizeObserver { + observe() {} + unobserve() {} + disconnect() {} + }, + ) + mocks.cloudTaskTarget = { target: "cloud" } + mocks.clineUser = undefined + mocks.connection = undefined + mocks.getRepositoryBranches.mockResolvedValue({ branches: [] }) + mocks.chooseCloudTaskTarget.mockResolvedValue({}) + }) + + it("shows sign-in onboarding when Cloud is selected while signed out", async () => { + render() + + expect(await screen.findByText("Run Cline in the cloud")).toBeInTheDocument() + expect(screen.getByRole("button", { name: /Sign in to Cline/i })).toBeInTheDocument() + }) + + it("shows repository onboarding when GitHub has no accessible repositories", async () => { + mocks.clineUser = { uid: "user-1" } + mocks.connection = { signedIn: true, connected: true, connectUrl: "", repositories: [] } + + render() + + expect(await screen.findByText("Give Cline access to a repository")).toBeInTheDocument() + expect(screen.getByRole("button", { name: /Manage repository access/i })).toBeInTheDocument() + }) + + it("shows the repository and branch the extension resolved and loads that repository's branches", async () => { + mocks.clineUser = { uid: "user-1" } + mocks.connection = { signedIn: true, connected: true, connectUrl: "", repositories: [clineRepo] } + mocks.cloudTaskTarget = { target: "cloud", repositoryId: 7, repoUrl: clineRepo.url, branch: "feature/cloud" } + mocks.getRepositoryBranches.mockResolvedValue({ branches: ["main", "develop"] }) + + render() + + expect(await screen.findByText("cline/cline")).toBeInTheDocument() + expect(screen.getByText("feature/cloud")).toBeInTheDocument() + expect(mocks.getRepositoryBranches).toHaveBeenCalledWith(expect.objectContaining({ repositoryId: 7 })) + expect(mocks.chooseCloudTaskTarget).not.toHaveBeenCalled() + }) + + it("offers no branches from the previous repository while the new repository's list loads", async () => { + const otherRepo = { + id: 9, + name: "other", + fullName: "cline/other", + url: "https://github.com/cline/other", + defaultBranch: "main", + } + mocks.clineUser = { uid: "user-1" } + mocks.connection = { signedIn: true, connected: true, connectUrl: "", repositories: [clineRepo, otherRepo] } + mocks.cloudTaskTarget = { target: "cloud", repositoryId: 7, repoUrl: clineRepo.url, branch: "main" } + mocks.getRepositoryBranches.mockResolvedValueOnce({ branches: ["main", "develop"] }) + + const { rerender } = render() + await userEvent.click(screen.getByRole("button", { name: "Branch" })) + expect(await screen.findByRole("button", { name: "develop" })).toBeInTheDocument() + await userEvent.keyboard("{Escape}") + + mocks.getRepositoryBranches.mockReturnValue(new Promise(() => {})) + mocks.cloudTaskTarget = { target: "cloud", repositoryId: 9, repoUrl: otherRepo.url, branch: undefined } + rerender() + + expect(screen.getByRole("button", { name: "Branch" })).toBeDisabled() + expect(screen.queryByRole("button", { name: "develop" })).not.toBeInTheDocument() + }) + + it("sends Local as one choice and nothing else, even while GitHub is still answering", async () => { + mocks.clineUser = { uid: "user-1" } + mocks.connection = { signedIn: true, connected: true, connectUrl: "", repositories: [clineRepo] } + mocks.cloudTaskTarget = { target: "cloud", repositoryId: 7, repoUrl: clineRepo.url, branch: "main" } + mocks.getRepositoryBranches.mockReturnValue(new Promise(() => {})) + + render() + await userEvent.click(screen.getByRole("radio", { name: "Local" })) + + expect(mocks.chooseCloudTaskTarget).toHaveBeenCalledTimes(1) + expect(mocks.chooseCloudTaskTarget).toHaveBeenCalledWith(expect.objectContaining({ target: "local" })) + }) + + it("hides the previous repository's branches while the new repository loads and ignores a cleared request", async () => { + mocks.clineUser = { uid: "user-1" } + mocks.connection = { signedIn: true, connected: true, connectUrl: "", repositories: [clineRepo] } + mocks.cloudTaskTarget = { target: "cloud", repositoryId: 7, repoUrl: clineRepo.url } + let resolveBranches!: (value: { branches: string[] }) => void + mocks.getRepositoryBranches.mockResolvedValueOnce({ branches: ["a-only"] }).mockImplementationOnce( + () => + new Promise((resolve) => { + resolveBranches = resolve + }), + ) + const { rerender } = render() + await userEvent.click(screen.getByRole("button", { name: "Branch" })) + expect(await screen.findByRole("button", { name: "a-only" })).toBeInTheDocument() + + mocks.cloudTaskTarget = { target: "cloud", repositoryId: 8 } + rerender() + expect(screen.queryByRole("button", { name: "a-only" })).not.toBeInTheDocument() + mocks.cloudTaskTarget = { target: "cloud" } + rerender() + await act(async () => resolveBranches({ branches: ["b-only"] })) + expect(screen.queryByRole("button", { name: "b-only" })).not.toBeInTheDocument() + expect(mocks.chooseCloudTaskTarget).not.toHaveBeenCalled() + }) + + it("warns when the workspace's repository is not accessible to the GitHub App", async () => { + mocks.clineUser = { uid: "user-1" } + mocks.connection = { signedIn: true, connected: true, connectUrl: "", repositories: [clineRepo] } + mocks.cloudTaskTarget = { + target: "cloud", + repositoryId: 7, + repoUrl: clineRepo.url, + workspaceRepoUrl: "https://github.com/other/private", + } + + render() + + expect(await screen.findByText(/other\/private/)).toBeInTheDocument() + expect(screen.getByRole("button", { name: "Manage access" })).toBeInTheDocument() + }) +}) diff --git a/apps/vscode/webview-ui/src/components/cloud/TaskTargetPanel.tsx b/apps/vscode/webview-ui/src/components/cloud/TaskTargetPanel.tsx new file mode 100644 index 0000000000..9d0b48b856 --- /dev/null +++ b/apps/vscode/webview-ui/src/components/cloud/TaskTargetPanel.tsx @@ -0,0 +1,240 @@ +import { formatRepoLabel, normalizeGitHubRemoteUrl } from "@shared/cloud/cloud-sessions" +import { CloudTaskTargetChoice, RepositoryBranchesRequest } from "@shared/proto/cline/cloud" +import { EmptyRequest } from "@shared/proto/cline/common" +import { CloudIcon, GitBranchIcon, MonitorIcon } from "lucide-react" +import { useCallback, useEffect, useMemo, useRef, useState } from "react" +import { useClineAuth, useClineSignIn } from "@/context/ClineAuthContext" +import { useExtensionState } from "@/context/ExtensionStateContext" +import { useGitHubConnection } from "@/hooks/useGitHubConnection" +import { cn } from "@/lib/utils" +import { CloudServiceClient } from "@/services/grpc-client" +import { CloudOnboardingCard, type CloudOnboardingVariant } from "./CloudOnboardingCard" +import { SearchableSelect } from "./SearchableSelect" + +const GITHUB_ICON = ( + +) + +/** + * Home-screen chooser for where the next task runs. Lives above the composer + * (not in it) so the live-chat controls stay uncluttered: Local | Cloud, and + * for Cloud the GitHub repository and branch, or the onboarding steps when + * GitHub isn't set up. + * + * The panel renders the target the extension resolved (`cloudTaskTarget`) + * and sends each click as one choice. It never computes or stores a target + * itself, so nothing it does in the background can override a click. + */ +export function TaskTargetPanel() { + const { cloudTaskTarget } = useExtensionState() + const { clineUser } = useClineAuth() + const { handleSignIn } = useClineSignIn() + const signedIn = !!clineUser?.uid + const isCloud = cloudTaskTarget?.target === "cloud" + + const { connection, loading, refresh } = useGitHubConnection(isCloud && signedIn) + const [waitingForGitHub, setWaitingForGitHub] = useState(false) + const [branchResult, setBranchResult] = useState<{ repositoryId: number; branches: string[] }>() + const [branchesLoading, setBranchesLoading] = useState(false) + const branchRequest = useRef(0) + + const repositories = connection?.repositories ?? [] + const selectedRepositoryId = cloudTaskTarget?.repositoryId + + const choose = (choice: Omit) => { + CloudServiceClient.chooseCloudTaskTarget(CloudTaskTargetChoice.create(choice)).catch((error) => + console.error("Failed to save task target:", error), + ) + } + + // Load branches for the selected repository. + const loadBranches = useCallback(async (repositoryId: number | undefined, query?: string) => { + const requestId = ++branchRequest.current + if (repositoryId === undefined) { + setBranchResult(undefined) + setBranchesLoading(false) + return + } + setBranchesLoading(true) + try { + const result = await CloudServiceClient.getRepositoryBranches( + RepositoryBranchesRequest.create({ repositoryId, query }), + ) + if (requestId === branchRequest.current) { + setBranchResult({ repositoryId, branches: result.branches }) + } + } catch (error) { + console.error("Failed to load branches:", error) + if (requestId === branchRequest.current) { + setBranchResult(undefined) + } + } finally { + if (requestId === branchRequest.current) { + setBranchesLoading(false) + } + } + }, []) + useEffect(() => { + void loadBranches(isCloud ? selectedRepositoryId : undefined) + }, [isCloud, selectedRepositoryId, loadBranches]) + const onBranchQueryChange = useCallback( + (query: string) => void loadBranches(selectedRepositoryId, query || undefined), + [loadBranches, selectedRepositoryId], + ) + + useEffect(() => { + if (connection?.connected && repositories.length > 0) { + setWaitingForGitHub(false) + } + }, [connection?.connected, repositories.length]) + + const connectGitHub = () => { + setWaitingForGitHub(true) + CloudServiceClient.connectGitHub(EmptyRequest.create()).catch((error) => + console.error("Failed to open GitHub connect:", error), + ) + } + + const onboardingVariant: CloudOnboardingVariant | undefined = !isCloud + ? undefined + : !signedIn + ? "signed_out" + : connection?.error + ? "error" + : connection && !connection.connected + ? "not_connected" + : connection && connection.repositories.length === 0 + ? "no_repositories" + : undefined + + const branches = branchResult && branchResult.repositoryId === selectedRepositoryId ? branchResult.branches : [] + const branchOptions = useMemo(() => { + const known = new Set(branches) + const current = cloudTaskTarget?.branch + const all = current && !known.has(current) ? [current, ...branches] : branches + return all.map((branch) => ({ value: branch, label: branch })) + }, [branches, cloudTaskTarget?.branch]) + + const repoOptions = useMemo( + () => repositories.map((repo) => ({ value: String(repo.id), label: repo.fullName || formatRepoLabel(repo.url) })), + [repositories], + ) + + const workspaceRepoUrl = cloudTaskTarget?.workspaceRepoUrl + const workspaceRepoAccessible = + !workspaceRepoUrl || + (connection?.connected && repositories.some((repo) => normalizeGitHubRemoteUrl(repo.url) === workspaceRepoUrl)) + + return ( +
    +
    + Run task +
    + } + label="Local" + onClick={() => isCloud && choose({ target: "local" })} + /> + } + label="Cloud" + onClick={() => !isCloud && choose({ target: "cloud" })} + /> +
    +
    + + {isCloud && ( +
    + {onboardingVariant ? ( + void refresh()} + onSignIn={handleSignIn} + variant={onboardingVariant} + waiting={waitingForGitHub} + /> + ) : ( +
    +
    + choose({ repositoryId: Number(value) })} + options={repoOptions} + placeholder="Choose a repository" + value={selectedRepositoryId === undefined ? undefined : String(selectedRepositoryId)} + /> + } + loading={branchesLoading} + onChange={(branch) => choose({ branch })} + onQueryChange={onBranchQueryChange} + options={branchOptions} + placeholder="Branch" + value={cloudTaskTarget?.branch} + /> +
    +

    + {!workspaceRepoAccessible ? ( + <> + This workspace's repository ({formatRepoLabel(workspaceRepoUrl)}) isn't accessible to the + Cline GitHub App.{" "} + + + ) : ( + "Uses a Cline model and Cline credits in an isolated sandbox. Tools are auto-approved so the task can keep going after you close VS Code." + )} +

    +
    + )} +
    + )} +
    + ) +} + +function TargetButton({ + active, + icon, + label, + onClick, +}: { + active: boolean + icon: React.ReactNode + label: string + onClick: () => void +}) { + return ( + + ) +} diff --git a/apps/vscode/webview-ui/src/components/history/HistoryPreview.tsx b/apps/vscode/webview-ui/src/components/history/HistoryPreview.tsx index 1ab8bdeee6..d2712a303b 100644 --- a/apps/vscode/webview-ui/src/components/history/HistoryPreview.tsx +++ b/apps/vscode/webview-ui/src/components/history/HistoryPreview.tsx @@ -1,6 +1,8 @@ import { StringRequest } from "@shared/proto/cline/common" -import { memo } from "react" +import { memo, useMemo } from "react" +import { CloudStatusPill } from "@/components/cloud/CloudStatusPill" import { useExtensionState } from "@/context/ExtensionStateContext" +import { useResolvedCloudStatuses } from "@/hooks/useResolvedCloudStatuses" import { useUsageCostVisibility } from "@/hooks/useUsageCostVisibility" import { TaskServiceClient } from "@/services/grpc-client" @@ -10,6 +12,8 @@ type HistoryPreviewProps = { const HistoryPreview = ({ showHistoryView }: HistoryPreviewProps) => { const { taskHistory } = useExtensionState() + const previewItems = useMemo(() => taskHistory.filter((item) => item.ts && item.task).slice(0, 3), [taskHistory]) + const resolvedCloudStatuses = useResolvedCloudStatuses(previewItems) const isCostVisible = useUsageCostVisibility() const handleHistorySelect = (id: string) => { TaskServiceClient.showTaskWithId(StringRequest.create({ value: id })).catch((error) => @@ -145,34 +149,36 @@ const HistoryPreview = ({ showHistoryView }: HistoryPreviewProps) => { {
    - {taskHistory.filter((item) => item.ts && item.task).length > 0 ? ( - taskHistory - .filter((item) => item.ts && item.task) - .slice(0, 3) - .map((item) => ( -
    handleHistorySelect(item.id)}> -
    - {item.isFavorited && ( - - )} -
    {item.task}
    - {item.isLegacy && Legacy} -
    -
    - {formatDate(item.ts)} - {item.totalCost != null && isCostVisible(item.apiProvider) && ( + {previewItems.length > 0 ? ( + previewItems.map((item) => ( +
    handleHistorySelect(item.id)}> +
    + {item.isFavorited && ( + + )} +
    {item.task}
    + {item.isLegacy && Legacy} + {item.executionTarget === "cloud" && ( + + )} +
    +
    + {formatDate(item.ts)} + {item.totalCost != null && + item.executionTarget !== "cloud" && + isCostVisible(item.apiProvider) && ( ${item.totalCost.toFixed(2)} )} -
    - )) +
    + )) ) : (
    { const extensionStateContext = useExtensionState() - const { taskHistory, environment } = extensionStateContext + const { taskHistory, environment, cloudSessionsEnabled } = extensionStateContext const [searchQuery, setSearchQuery] = useState("") const [sortOption, setSortOption] = useState("newest") const [lastNonRelevantSort, setLastNonRelevantSort] = useState("newest") @@ -47,12 +49,22 @@ const HistoryView = ({ onDone }: HistoryViewProps) => { const [selectedItems, setSelectedItems] = useState([]) const [showFavoritesOnly, setShowFavoritesOnly] = useState(false) const [showCurrentWorkspaceOnly, setShowCurrentWorkspaceOnly] = useState(false) + const [showCloudOnly, setShowCloudOnly] = useState(false) // Keep track of pending favorite toggle operations const [pendingFavoriteToggles, setPendingFavoriteToggles] = useState>({}) // Load filtered task history with gRPC const [tasks, setTasks] = useState([]) + const resolvedCloudStatuses = useResolvedCloudStatuses(tasks) + const tasksWithResolvedCloudStatuses = useMemo( + () => + tasks.map((task) => { + const cloudStatus = resolvedCloudStatuses.get(task.id) + return cloudStatus ? { ...task, cloudStatus } : task + }), + [resolvedCloudStatuses, tasks], + ) const [hasMoreTasks, setHasMoreTasks] = useState(false) const [nextHistoryOffset, setNextHistoryOffset] = useState(0) const [isLoadingHistory, setIsLoadingHistory] = useState(false) @@ -78,6 +90,7 @@ const HistoryView = ({ onDone }: HistoryViewProps) => { searchQuery: searchQuery || undefined, sortBy: sortOption, currentWorkspaceOnly: showCurrentWorkspaceOnly, + cloudOnly: showCloudOnly, limit: HISTORY_PAGE_SIZE, offset, }), @@ -111,7 +124,7 @@ const HistoryView = ({ onDone }: HistoryViewProps) => { } } }, - [showFavoritesOnly, showCurrentWorkspaceOnly, searchQuery, sortOption], + [showFavoritesOnly, showCurrentWorkspaceOnly, showCloudOnly, searchQuery, sortOption], ) const loadMoreTaskHistory = useCallback(() => { @@ -127,7 +140,7 @@ const HistoryView = ({ onDone }: HistoryViewProps) => { setHasMoreTasks(false) setNextHistoryOffset(0) loadTaskHistory(0) - }, [loadTaskHistory, showFavoritesOnly, showCurrentWorkspaceOnly]) + }, [loadTaskHistory, showFavoritesOnly, showCurrentWorkspaceOnly, showCloudOnly]) const toggleFavorite = useCallback( async (taskId: string, currentValue: boolean) => { @@ -262,7 +275,7 @@ const HistoryView = ({ onDone }: HistoryViewProps) => { }, [fetchTotalTasksSize, loadTaskHistory]) const fuse = useMemo(() => { - return new Fuse(tasks, { + return new Fuse(tasksWithResolvedCloudStatuses, { keys: ["task"], threshold: 0.6, shouldSort: true, @@ -276,7 +289,7 @@ const HistoryView = ({ onDone }: HistoryViewProps) => { includeMatches: true, minMatchCharLength: 1, }) - }, [tasks]) + }, [tasksWithResolvedCloudStatuses]) const taskHistorySearchResults = useMemo(() => { const results = searchQuery @@ -284,7 +297,7 @@ const HistoryView = ({ onDone }: HistoryViewProps) => { .search(searchQuery) ?.filter(({ matches }) => matches && matches.length) .map(({ item }) => item) - : tasks + : tasksWithResolvedCloudStatuses results.sort((a, b) => { switch (sortOption) { @@ -427,6 +440,8 @@ const HistoryView = ({ onDone }: HistoryViewProps) => { setShowCurrentWorkspaceOnly(!showCurrentWorkspaceOnly) } else if (value === "favoritesOnly") { setShowFavoritesOnly(!showFavoritesOnly) + } else if (value === "cloudOnly") { + setShowCloudOnly(!showCloudOnly) } }} value={sortOption}> @@ -438,14 +453,19 @@ const HistoryView = ({ onDone }: HistoryViewProps) => { const isSortOption = ["newest", "oldest", "mostExpensive", "mostTokens", "mostRelevant"].includes( key, ) - const isFilterOption = ["workspaceOnly", "favoritesOnly"].includes(key) + const isFilterOption = ["workspaceOnly", "favoritesOnly", "cloudOnly"].includes(key) + if (key === "cloudOnly" && !cloudSessionsEnabled) { + return null + } const isSelected = isSortOption ? sortOption === key : key === "workspaceOnly" ? showCurrentWorkspaceOnly : key === "favoritesOnly" ? showFavoritesOnly - : false + : key === "cloudOnly" + ? showCloudOnly + : false const isDisabled = key === "mostRelevant" && !searchQuery return ( @@ -458,7 +478,11 @@ const HistoryView = ({ onDone }: HistoryViewProps) => { {isFilterOption && ( )} diff --git a/apps/vscode/webview-ui/src/components/history/HistoryViewItem.tsx b/apps/vscode/webview-ui/src/components/history/HistoryViewItem.tsx index 916bad2e5b..44aae83fe3 100644 --- a/apps/vscode/webview-ui/src/components/history/HistoryViewItem.tsx +++ b/apps/vscode/webview-ui/src/components/history/HistoryViewItem.tsx @@ -1,5 +1,7 @@ +import { formatRepoLabel } from "@shared/cloud/cloud-sessions" import { HistoryItem } from "@shared/HistoryItem" import { StringRequest } from "@shared/proto/cline/common" +import type { TaskItem } from "@shared/proto/cline/task" import { VSCodeCheckbox } from "@vscode/webview-ui-toolkit/react" import { ArrowDownIcon, @@ -13,6 +15,7 @@ import { TrashIcon, } from "lucide-react" import { memo, useCallback, useMemo, useState } from "react" +import { CloudStatusPill } from "@/components/cloud/CloudStatusPill" import { Button } from "@/components/ui/button" import { useUsageCostVisibility } from "@/hooks/useUsageCostVisibility" import { cn } from "@/lib/utils" @@ -20,7 +23,7 @@ import { TaskServiceClient } from "@/services/grpc-client" import { formatLargeNumber, formatSize } from "@/utils/format" type HistoryViewItemProps = { - item: HistoryItem + item: HistoryItem | TaskItem index: number selectedItems: string[] pendingFavoriteToggles: Record @@ -44,6 +47,8 @@ const HistoryViewItem = ({ () => pendingFavoriteToggles[item.id] ?? item.isFavorited, [item.id, item.isFavorited, pendingFavoriteToggles], ) + // Cloud sessions are owned by Cline Cloud; favorites are a local-history concept. + const isCloud = item.executionTarget === "cloud" const handleShowTaskWithId = useCallback((id: string) => { TaskServiceClient.showTaskWithId(StringRequest.create({ value: id })).catch((error) => @@ -105,6 +110,7 @@ const HistoryViewItem = ({ Legacy )} + {isCloud && }
    - -
    + {isCloud && item.cloudRepoUrl && ( +
    + Repository: + + {formatRepoLabel(item.cloudRepoUrl)} + {item.cloudBranch ? ` @ ${item.cloudBranch}` : ""} + +
    + )} + + {!isCloud && ( +
    + Size: + + {formatSize(item.size)} + + +
    + )}
    diff --git a/apps/vscode/webview-ui/src/components/settings/sections/FeatureSettingsSection.spec.tsx b/apps/vscode/webview-ui/src/components/settings/sections/FeatureSettingsSection.spec.tsx index 73b62c1fa9..d95851b405 100644 --- a/apps/vscode/webview-ui/src/components/settings/sections/FeatureSettingsSection.spec.tsx +++ b/apps/vscode/webview-ui/src/components/settings/sections/FeatureSettingsSection.spec.tsx @@ -91,4 +91,19 @@ describe("FeatureSettingsSection", () => { expect(mockUpdateSetting).toHaveBeenCalledWith("showFeatureTips", true) }) + + it("hides the Cloud toggle unless the rollout flag makes it available", () => { + render( null} />) + + expect(screen.queryByRole("switch", { name: "Cloud" })).toBeNull() + }) + + it("calls updateSetting with cloudSessionsEnabled when the available Cloud toggle is switched", () => { + mockExtensionState.value = { ...mockExtensionState.value, cloudSessionsAvailable: true, cloudSessionsEnabled: false } + render( null} />) + + fireEvent.click(screen.getByRole("switch", { name: "Cloud" })) + + expect(mockUpdateSetting).toHaveBeenCalledWith("cloudSessionsEnabled", true) + }) }) diff --git a/apps/vscode/webview-ui/src/components/settings/sections/FeatureSettingsSection.tsx b/apps/vscode/webview-ui/src/components/settings/sections/FeatureSettingsSection.tsx index f479a73292..a7bc2b9ce4 100644 --- a/apps/vscode/webview-ui/src/components/settings/sections/FeatureSettingsSection.tsx +++ b/apps/vscode/webview-ui/src/components/settings/sections/FeatureSettingsSection.tsx @@ -39,6 +39,14 @@ const agentFeatures: FeatureToggle[] = [ stateKey: "useAutoCondense", settingKey: "useAutoCondense", }, + { + id: "cloud-sessions", + label: "Cloud", + description: + "Run Cline on your GitHub repositories in secure cloud sandboxes. Adds a Cloud option to the new task composer. Requires a Cline account with GitHub connected.", + stateKey: "cloudSessionsEnabled", + settingKey: "cloudSessionsEnabled", + }, ] const editorFeatures: FeatureToggle[] = [ @@ -154,6 +162,8 @@ const FeatureSettingsSection = ({ renderSectionHeader }: FeatureSettingsSectionP worktreesEnabled, backgroundEditEnabled, showFeatureTips, + cloudSessionsAvailable, + cloudSessionsEnabled, } = useExtensionState() // State lookup for mapped features @@ -165,11 +175,13 @@ const FeatureSettingsSection = ({ renderSectionHeader }: FeatureSettingsSectionP subagentsEnabled, worktreesEnabled: worktreesEnabled?.user, backgroundEditEnabled, + cloudSessionsEnabled, } // Visibility lookup for features with feature flags const featureVisibility: Record = { worktreesEnabled: worktreesEnabled?.featureFlag, + cloudSessionsEnabled: !!cloudSessionsAvailable, } return ( diff --git a/apps/vscode/webview-ui/src/context/ClineAuthContext.test.tsx b/apps/vscode/webview-ui/src/context/ClineAuthContext.test.tsx index 57efbf93aa..409c737df1 100644 --- a/apps/vscode/webview-ui/src/context/ClineAuthContext.test.tsx +++ b/apps/vscode/webview-ui/src/context/ClineAuthContext.test.tsx @@ -1,6 +1,6 @@ import type { AuthState, UserOrganizationsResponse } from "@shared/proto/cline/account" -import { act, render, screen } from "@testing-library/react" -import { beforeEach, describe, expect, it, vi } from "vitest" +import { act, fireEvent, render, screen } from "@testing-library/react" +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest" import { ClineAuthProvider, useClineAuth } from "./ClineAuthContext" type AuthStatusCallbacks = { @@ -10,6 +10,7 @@ type AuthStatusCallbacks = { const grpcMocks = vi.hoisted(() => ({ getUserOrganizations: vi.fn(), subscribeToAuthStatusUpdate: vi.fn(), + setUserOrganization: vi.fn(), authStatusCallbacks: undefined as AuthStatusCallbacks | undefined, })) @@ -17,6 +18,7 @@ vi.mock("@/services/grpc-client", () => ({ AccountServiceClient: { getUserOrganizations: grpcMocks.getUserOrganizations, subscribeToAuthStatusUpdate: grpcMocks.subscribeToAuthStatusUpdate, + setUserOrganization: grpcMocks.setUserOrganization, }, })) @@ -29,10 +31,15 @@ function createDeferred() { } function AuthStateProbe() { - const { clineUser, organizations } = useClineAuth() + const { clineUser, organizations, switchOrganization, accountSwitch, accountSwitchError } = useClineAuth() return ( <>
    {clineUser?.uid ?? "signed-out"}
    + +
    {accountSwitch ? (accountSwitch.slow ? "slow" : "pending") : "settled"}
    +
    {accountSwitchError ?? "none"}
    {organizations?.map((organization) => organization.organizationId).join(",") ?? "none"}
    @@ -41,6 +48,7 @@ function AuthStateProbe() { } describe("ClineAuthProvider", () => { + afterEach(() => vi.useRealTimers()) beforeEach(() => { vi.clearAllMocks() grpcMocks.authStatusCallbacks = undefined @@ -81,4 +89,218 @@ describe("ClineAuthProvider", () => { expect(screen.getByTestId("user-state")).toHaveTextContent("signed-out") expect(screen.getByTestId("organizations-state")).toHaveTextContent("none") }) + + it("keeps a slow switch owned and ignores its completion after sign-out", async () => { + vi.useFakeTimers() + const switching = createDeferred>() + grpcMocks.setUserOrganization.mockReturnValue(switching.promise) + grpcMocks.getUserOrganizations.mockResolvedValue({ organizations: [] }) + render( + + + , + ) + await act(async () => { + grpcMocks.authStatusCallbacks?.onResponse({ user: { uid: "user-1" } }) + }) + fireEvent.click(screen.getByText("Switch")) + fireEvent.click(screen.getByText("Switch")) + expect(grpcMocks.setUserOrganization).toHaveBeenCalledOnce() + await act(async () => { + await vi.advanceTimersByTimeAsync(10_000) + }) + expect(screen.getByTestId("switch-state")).toHaveTextContent("slow") + act(() => { + grpcMocks.authStatusCallbacks?.onResponse({}) + }) + grpcMocks.getUserOrganizations.mockClear() + await act(async () => { + switching.resolve({}) + }) + expect(grpcMocks.getUserOrganizations).not.toHaveBeenCalled() + expect(screen.getByTestId("switch-state")).toHaveTextContent("settled") + expect(screen.getByTestId("user-state")).toHaveTextContent("signed-out") + }) + + it("gives a replacement user a separate switch owner", async () => { + const first = createDeferred>() + const second = createDeferred>() + grpcMocks.setUserOrganization.mockReturnValueOnce(first.promise).mockReturnValueOnce(second.promise) + grpcMocks.getUserOrganizations.mockResolvedValue({ organizations: [] }) + render( + + + , + ) + await act(async () => { + grpcMocks.authStatusCallbacks?.onResponse({ user: { uid: "user-a" } }) + }) + fireEvent.click(screen.getByText("Switch")) + await act(async () => { + grpcMocks.authStatusCallbacks?.onResponse({ user: { uid: "user-b" } }) + }) + expect(screen.getByTestId("switch-state")).toHaveTextContent("settled") + fireEvent.click(screen.getByText("Switch")) + await act(async () => { + first.resolve({}) + }) + expect(screen.getByTestId("switch-state")).toHaveTextContent("pending") + await act(async () => { + second.resolve({}) + }) + expect(grpcMocks.setUserOrganization).toHaveBeenCalledTimes(2) + expect(screen.getByTestId("switch-state")).toHaveTextContent("settled") + }) + + it("confirms a switch whose confirmation read is overtaken by the switch's own auth-status update", async () => { + const confirmation = createDeferred() + const activeNext = { + organizations: [{ organizationId: "org-next", active: true, memberId: "m", name: "Next", roles: [] }], + } + grpcMocks.getUserOrganizations + .mockResolvedValueOnce({ organizations: [] }) + .mockReturnValueOnce(confirmation.promise) + .mockResolvedValue(activeNext) + grpcMocks.setUserOrganization.mockResolvedValue({}) + let result: Promise | undefined + function SwitchProbe() { + const { switchOrganization } = useClineAuth() + return ( + + ) + } + render( + + + , + ) + await act(async () => { + grpcMocks.authStatusCallbacks?.onResponse({ user: { uid: "user-1" } }) + }) + await act(async () => { + fireEvent.click(screen.getByText("Switch next")) + }) + // The extension's auth refresh after the PUT starts a newer read before + // the switch's confirmation read has answered. + await act(async () => { + grpcMocks.authStatusCallbacks?.onResponse({ user: { uid: "user-1" } }) + }) + await act(async () => { + confirmation.resolve(activeNext) + }) + expect(await result).toBe(true) + }) + + it("retracts an unconfirmed switch error once a later refresh shows the requested account", async () => { + const activeOld = { + organizations: [{ organizationId: "org-old", active: true, memberId: "m", name: "Old", roles: [] }], + } + const activeNext = { + organizations: [{ organizationId: "org-next", active: true, memberId: "m", name: "Next", roles: [] }], + } + grpcMocks.getUserOrganizations.mockResolvedValue(activeOld) + grpcMocks.setUserOrganization.mockRejectedValue(new Error("Account switch was not confirmed within 10 seconds")) + render( + + + , + ) + await act(async () => { + grpcMocks.authStatusCallbacks?.onResponse({ user: { uid: "user-1" } }) + }) + await act(async () => { + fireEvent.click(screen.getByText("Switch")) + }) + expect(screen.getByTestId("switch-error")).toHaveTextContent("not confirmed") + + // An unrelated refresh while the server is still on the old account keeps the error. + await act(async () => { + grpcMocks.authStatusCallbacks?.onResponse({ user: { uid: "user-1" } }) + }) + expect(screen.getByTestId("switch-error")).toHaveTextContent("not confirmed") + + // The late PUT commits and the extension's auth refresh lands on the new account. + grpcMocks.getUserOrganizations.mockResolvedValue(activeNext) + await act(async () => { + grpcMocks.authStatusCallbacks?.onResponse({ user: { uid: "user-1" } }) + }) + expect(screen.getByTestId("switch-error")).toHaveTextContent("none") + expect(screen.getByTestId("organizations-state")).toHaveTextContent("org-next") + }) + + it("retracts the error when the switch's own confirmation read shows the requested account", async () => { + grpcMocks.getUserOrganizations.mockResolvedValue({ + organizations: [{ organizationId: "org-next", active: true, memberId: "m", name: "Next", roles: [] }], + }) + grpcMocks.setUserOrganization.mockRejectedValue(new Error("Account switch was not confirmed within 10 seconds")) + render( + + + , + ) + await act(async () => { + grpcMocks.authStatusCallbacks?.onResponse({ user: { uid: "user-1" } }) + }) + await act(async () => { + fireEvent.click(screen.getByText("Switch")) + }) + expect(screen.getByTestId("switch-state")).toHaveTextContent("settled") + expect(screen.getByTestId("switch-error")).toHaveTextContent("none") + }) + + it("does not let an outdated read retract a current switch error", async () => { + const activeOld = { + organizations: [{ organizationId: "org-old", active: true, memberId: "m", name: "Old", roles: [] }], + } + const outdated = createDeferred() + grpcMocks.getUserOrganizations + .mockResolvedValueOnce(activeOld) + .mockReturnValueOnce(outdated.promise) + .mockResolvedValue(activeOld) + grpcMocks.setUserOrganization.mockRejectedValue(new Error("Account switch was not confirmed within 10 seconds")) + render( + + + , + ) + await act(async () => { + grpcMocks.authStatusCallbacks?.onResponse({ user: { uid: "user-1" } }) + }) + // A refresh starts a read that will answer late. + act(() => { + grpcMocks.authStatusCallbacks?.onResponse({ user: { uid: "user-1" } }) + }) + await act(async () => { + fireEvent.click(screen.getByText("Switch")) + }) + expect(screen.getByTestId("switch-error")).toHaveTextContent("not confirmed") + + await act(async () => { + outdated.resolve({ + organizations: [{ organizationId: "org-next", active: true, memberId: "m", name: "Next", roles: [] }], + }) + await outdated.promise + }) + expect(screen.getByTestId("switch-error")).toHaveTextContent("not confirmed") + expect(screen.getByTestId("organizations-state")).toHaveTextContent("org-old") + }) + + it("does not report a background profile read as a failed account switch", async () => { + grpcMocks.getUserOrganizations.mockRejectedValue(new Error("offline")) + render( + + + , + ) + await act(async () => { + grpcMocks.authStatusCallbacks?.onResponse({ user: { uid: "user-a" } }) + }) + expect(screen.getByTestId("switch-error")).toHaveTextContent("none") + }) }) diff --git a/apps/vscode/webview-ui/src/context/ClineAuthContext.tsx b/apps/vscode/webview-ui/src/context/ClineAuthContext.tsx index 112dabcb65..5b4f522128 100644 --- a/apps/vscode/webview-ui/src/context/ClineAuthContext.tsx +++ b/apps/vscode/webview-ui/src/context/ClineAuthContext.tsx @@ -18,6 +18,9 @@ export interface ClineAuthContextType { clineUser: ClineUser | null organizations: UserOrganization[] | null activeOrganization: UserOrganization | null + accountSwitch: { organizationId: string | undefined; slow: boolean } | null + accountSwitchError: string | null + switchOrganization: (organizationId?: string) => Promise } export const ClineAuthContext = createContext(undefined) @@ -26,26 +29,87 @@ export const ClineAuthProvider: React.FC<{ children: React.ReactNode }> = ({ chi const [user, setUser] = useState(null) const [userOrganizations, setUserOrganizations] = useState(null) const organizationsRequestIdRef = useRef(0) + const userIdRef = useRef(undefined) + const switchRequestRef = useRef(null) + // A switch that reported an error may still commit on the server. Its error is + // retracted once the newest read shows the requested account active. + const unconfirmedSwitchRef = useRef<{ organizationId: string | undefined } | null>(null) + const [accountSwitch, setAccountSwitch] = useState(null) + const [accountSwitchError, setAccountSwitchError] = useState(null) + // Only the newest read may write context state, but every read returns the + // server's answer: a switch confirms itself with a read that the switch's + // own auth-status update can overtake, and that answer is still current. const getUserOrganizations = useCallback(async () => { const requestId = ++organizationsRequestIdRef.current try { const response = await AccountServiceClient.getUserOrganizations(EmptyRequest.create()) - if (requestId !== organizationsRequestIdRef.current) { - return - } - setUserOrganizations((old) => { - if (!deepEqual(response.organizations, old)) { - return response.organizations + if (requestId === organizationsRequestIdRef.current) { + setUserOrganizations((old) => (deepEqual(response.organizations, old) ? old : response.organizations)) + const unconfirmed = unconfirmedSwitchRef.current + const activeId = response.organizations.find((org) => org.active)?.organizationId ?? undefined + if (unconfirmed && activeId === unconfirmed.organizationId) { + unconfirmedSwitchRef.current = null + setAccountSwitchError(null) } - - return old - }) + } + return response.organizations } catch (error) { console.error("Failed to fetch user organizations:", error) + if (requestId === organizationsRequestIdRef.current && switchRequestRef.current) { + setAccountSwitchError("Could not confirm the active account. Check your connection and reopen Account.") + } + return undefined } }, []) + // The provider outlives AccountView, including navigation from host-owned + // toolbar buttons. A switch remains owned until its RPC actually settles. + const switchOrganization = useCallback( + async (organizationId?: string) => { + if (switchRequestRef.current) return false + const request = {} + switchRequestRef.current = request + setAccountSwitch({ organizationId, slow: false }) + setAccountSwitchError(null) + unconfirmedSwitchRef.current = null + const timer = setTimeout(() => { + if (switchRequestRef.current === request) setAccountSwitch({ organizationId, slow: true }) + }, 10_000) + let succeeded = false + try { + await AccountServiceClient.setUserOrganization({ organizationId }) + succeeded = true + } catch (error) { + if (switchRequestRef.current === request) { + unconfirmedSwitchRef.current = { organizationId } + setAccountSwitchError(error instanceof Error ? error.message : String(error)) + } + } finally { + if (switchRequestRef.current === request) { + // Failure does not imply rollback: the PUT may have succeeded before + // auth refresh failed. Re-read the server instead of assuming rollback. + const organizations = await getUserOrganizations() + succeeded = + succeeded && + !!organizations && + (organizations.find((org) => org.active)?.organizationId ?? undefined) === organizationId + if (switchRequestRef.current === request) { + switchRequestRef.current = null + setAccountSwitch(null) + } else { + succeeded = false + } + } else { + succeeded = false + } + clearTimeout(timer) + } + return succeeded + }, + [getUserOrganizations], + ) + const activeOrganization = useMemo(() => { return userOrganizations?.find((org) => org.active) ?? null }, [userOrganizations]) @@ -59,8 +123,19 @@ export const ClineAuthProvider: React.FC<{ children: React.ReactNode }> = ({ chi const cancelSubscription = AccountServiceClient.subscribeToAuthStatusUpdate(EmptyRequest.create(), { onResponse: (response: AuthState) => { const responseUser = response.user + if (userIdRef.current !== responseUser?.uid) { + userIdRef.current = responseUser?.uid + switchRequestRef.current = null + unconfirmedSwitchRef.current = null + setAccountSwitch(null) + setAccountSwitchError(null) + setUserOrganizations(null) + } if (!responseUser?.uid) { organizationsRequestIdRef.current++ + switchRequestRef.current = null + setAccountSwitch(null) + setAccountSwitchError(null) setUser(null) setUserOrganizations(null) return @@ -86,6 +161,7 @@ export const ClineAuthProvider: React.FC<{ children: React.ReactNode }> = ({ chi // Cleanup function to cancel subscription when component unmounts return () => { organizationsRequestIdRef.current++ + switchRequestRef.current = null cancelSubscription() } }, [getUserOrganizations]) @@ -96,6 +172,9 @@ export const ClineAuthProvider: React.FC<{ children: React.ReactNode }> = ({ chi clineUser: user, organizations: userOrganizations, activeOrganization, + accountSwitch, + accountSwitchError, + switchOrganization, }}> {children} diff --git a/apps/vscode/webview-ui/src/context/ExtensionStateContext.tsx b/apps/vscode/webview-ui/src/context/ExtensionStateContext.tsx index 7bde10a65d..781c789138 100644 --- a/apps/vscode/webview-ui/src/context/ExtensionStateContext.tsx +++ b/apps/vscode/webview-ui/src/context/ExtensionStateContext.tsx @@ -309,6 +309,8 @@ export const ExtensionStateContextProvider: React.FC<{ webSearchEnabled: false, subagentsEnabled: false, worktreesEnabled: { user: true, featureFlag: false }, + cloudSessionsAvailable: false, + cloudSessionsEnabled: false, favoritedModelIds: [], lastDismissedInfoBannerVersion: 0, lastDismissedModelBannerVersion: 0, diff --git a/apps/vscode/webview-ui/src/hooks/useGitHubConnection.test.tsx b/apps/vscode/webview-ui/src/hooks/useGitHubConnection.test.tsx new file mode 100644 index 0000000000..0853ad7620 --- /dev/null +++ b/apps/vscode/webview-ui/src/hooks/useGitHubConnection.test.tsx @@ -0,0 +1,106 @@ +import { act, renderHook, waitFor } from "@testing-library/react" +import { beforeEach, describe, expect, it, vi } from "vitest" +import { useGitHubConnection } from "./useGitHubConnection" + +const mocks = vi.hoisted(() => ({ + activeOrganization: undefined as { organizationId: string } | undefined, + clineUser: { uid: "user-a" }, + accountSwitch: null as object | null, + getGitHubConnection: vi.fn(), +})) + +vi.mock("@/context/ClineAuthContext", () => ({ + useClineAuth: () => ({ + activeOrganization: mocks.activeOrganization, + clineUser: mocks.clineUser, + accountSwitch: mocks.accountSwitch, + }), +})) + +vi.mock("@/services/grpc-client", () => ({ + CloudServiceClient: { getGitHubConnection: mocks.getGitHubConnection }, +})) + +function deferred() { + let resolve!: (value: T) => void + const promise = new Promise((done) => { + resolve = done + }) + return { promise, resolve } +} + +const connected = { signedIn: true, connected: true, connectUrl: "", repositories: [{ id: 1 }] } +const disconnected = { signedIn: true, connected: false, connectUrl: "https://connect", repositories: [] } + +describe("useGitHubConnection", () => { + beforeEach(() => { + vi.clearAllMocks() + mocks.activeOrganization = { organizationId: "org-a" } + mocks.clineUser = { uid: "user-a" } + mocks.accountSwitch = null + }) + + it("re-reads the connection for the new active account and ignores the old account's late answer", async () => { + const first = deferred() + const second = deferred() + mocks.getGitHubConnection.mockReturnValueOnce(first.promise).mockReturnValueOnce(second.promise) + const { result, rerender } = renderHook(() => useGitHubConnection(true)) + expect(mocks.getGitHubConnection).toHaveBeenCalledTimes(1) + + mocks.activeOrganization = { organizationId: "org-b" } + rerender() + await waitFor(() => expect(mocks.getGitHubConnection).toHaveBeenCalledTimes(2)) + expect(result.current.connection).toBeUndefined() + + await act(async () => { + first.resolve(connected) + }) + expect(result.current.connection).toBeUndefined() + + await act(async () => { + second.resolve(disconnected) + }) + expect(result.current.connection).toEqual(disconnected) + expect(result.current.loading).toBe(false) + }) + + it("discards a disabled request even when the same account is enabled again", async () => { + const first = deferred() + mocks.getGitHubConnection.mockReturnValueOnce(first.promise).mockResolvedValueOnce(disconnected) + const { result, rerender } = renderHook(({ enabled }) => useGitHubConnection(enabled), { + initialProps: { enabled: true }, + }) + rerender({ enabled: false }) + await act(async () => { + first.resolve(connected) + }) + expect(result.current.connection).toBeUndefined() + rerender({ enabled: true }) + await waitFor(() => expect(result.current.connection).toEqual(disconnected)) + expect(mocks.getGitHubConnection).toHaveBeenCalledTimes(2) + }) + + it("does not reuse the previous user's personal GitHub connection", async () => { + mocks.activeOrganization = undefined + mocks.getGitHubConnection.mockResolvedValueOnce(connected).mockResolvedValueOnce(disconnected) + const { result, rerender } = renderHook(() => useGitHubConnection(true)) + await waitFor(() => expect(result.current.connection).toEqual(connected)) + mocks.clineUser = { uid: "user-b" } + rerender() + expect(result.current.connection).toBeUndefined() + await waitFor(() => expect(result.current.connection).toEqual(disconnected)) + }) + + it("hides repositories during a switch and refetches even if the switch is refused", async () => { + mocks.getGitHubConnection.mockResolvedValueOnce(connected).mockResolvedValueOnce(disconnected) + const { result, rerender } = renderHook(() => useGitHubConnection(true)) + await waitFor(() => expect(result.current.connection).toEqual(connected)) + mocks.accountSwitch = {} + rerender() + expect(result.current.connection).toBeUndefined() + expect(mocks.getGitHubConnection).toHaveBeenCalledTimes(1) + mocks.accountSwitch = null + rerender() + await waitFor(() => expect(result.current.connection).toEqual(disconnected)) + }) +}) diff --git a/apps/vscode/webview-ui/src/hooks/useGitHubConnection.ts b/apps/vscode/webview-ui/src/hooks/useGitHubConnection.ts new file mode 100644 index 0000000000..f787e6c4cb --- /dev/null +++ b/apps/vscode/webview-ui/src/hooks/useGitHubConnection.ts @@ -0,0 +1,102 @@ +import { type GitHubConnection, GitHubConnectionRequest } from "@shared/proto/cline/cloud" +import { useCallback, useEffect, useMemo, useRef, useState } from "react" +import { useClineAuth } from "@/context/ClineAuthContext" +import { CloudServiceClient } from "@/services/grpc-client" + +const DISCONNECTED_POLL_MS = 6_000 + +/** + * GitHub App connection status for cloud sessions. The connection belongs to + * the active account (personal or organization), so it is re-read whenever + * that changes. While the user is signed in but GitHub is not connected (or + * grants no repositories), the status is re-checked periodically and on window + * focus so finishing the connect flow in the browser flips the UI on its own. + */ +export function useGitHubConnection(enabled: boolean) { + const { clineUser, activeOrganization, accountSwitch } = useClineAuth() + const available = enabled && !!clineUser?.uid && !accountSwitch + const scope = useMemo( + () => ({ available, userId: clineUser?.uid, organizationId: activeOrganization?.organizationId }), + [available, clineUser?.uid, activeOrganization?.organizationId], + ) + const [snapshot, setSnapshot] = useState<{ scope: object; connection: GitHubConnection }>() + const connection = available && snapshot?.scope === scope ? snapshot.connection : undefined + const [loading, setLoading] = useState(false) + const inFlight = useRef<{ scope: object; promise: Promise } | undefined>(undefined) + + const load = useCallback( + async (refresh: boolean) => { + if (!available) { + return + } + if (inFlight.current?.scope === scope) { + return inFlight.current.promise + } + setLoading(true) + const request = { + scope, + promise: CloudServiceClient.getGitHubConnection(GitHubConnectionRequest.create({ refresh })) + .then((result) => { + // A response for a previous account must not describe the current one. + if (inFlight.current === request) setSnapshot({ scope, connection: result }) + }) + .catch((error) => { + if (inFlight.current !== request) return + console.error("Failed to load GitHub connection:", error) + setSnapshot({ + scope, + connection: { + signedIn: true, + connected: false, + connectUrl: "", + repositories: [], + error: error instanceof Error ? error.message : String(error), + }, + }) + }) + .finally(() => { + if (inFlight.current === request) { + setLoading(false) + inFlight.current = undefined + } + }), + } + inFlight.current = request + return request.promise + }, + [available, scope], + ) + + // The extension caches the connection per account, so a first read after + // mounting or an account change is cheap; only the user's Refresh and the + // not-yet-connected poll ask the control plane again. + useEffect(() => { + setLoading(false) + void load(false) + return () => { + inFlight.current = undefined + } + }, [load]) + const refresh = useCallback(() => load(true), [load]) + + const needsPolling = + available && + !!connection && + connection.signedIn && + (!connection.connected || connection.repositories.length === 0) && + !connection.error + useEffect(() => { + if (!needsPolling) { + return + } + const timer = setInterval(() => void refresh(), DISCONNECTED_POLL_MS) + const onFocus = () => void refresh() + window.addEventListener("focus", onFocus) + return () => { + clearInterval(timer) + window.removeEventListener("focus", onFocus) + } + }, [needsPolling, refresh]) + + return { connection, loading, refresh } +} diff --git a/apps/vscode/webview-ui/src/hooks/useResolvedCloudStatuses.test.tsx b/apps/vscode/webview-ui/src/hooks/useResolvedCloudStatuses.test.tsx new file mode 100644 index 0000000000..633231b632 --- /dev/null +++ b/apps/vscode/webview-ui/src/hooks/useResolvedCloudStatuses.test.tsx @@ -0,0 +1,62 @@ +import { act, renderHook, waitFor } from "@testing-library/react" +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest" +import { useResolvedCloudStatuses } from "./useResolvedCloudStatuses" + +const mocks = vi.hoisted(() => ({ resolveCloudSessionStatuses: vi.fn() })) + +vi.mock("@/services/grpc-client", () => ({ + CloudServiceClient: { resolveCloudSessionStatuses: mocks.resolveCloudSessionStatuses }, +})) + +describe("useResolvedCloudStatuses", () => { + beforeEach(() => { + vi.clearAllMocks() + mocks.resolveCloudSessionStatuses.mockResolvedValue({ + statuses: [{ sessionId: "ses-visible", status: "completed" }], + }) + }) + + afterEach(() => vi.useRealTimers()) + + it("requests only displayed cloud tasks whose status is unknown", async () => { + const { result } = renderHook(() => + useResolvedCloudStatuses([ + { id: "ses-visible", executionTarget: "cloud", cloudStatus: "unknown" }, + { id: "ses-known", executionTarget: "cloud", cloudStatus: "completed" }, + { id: "local-task", executionTarget: "local", cloudStatus: "unknown" }, + ]), + ) + + await waitFor(() => expect(result.current.get("ses-visible")).toBe("completed")) + expect(mocks.resolveCloudSessionStatuses).toHaveBeenCalledWith(expect.objectContaining({ sessionIds: ["ses-visible"] })) + }) + + it("lets a newer history status replace the status it resolved earlier", async () => { + mocks.resolveCloudSessionStatuses.mockResolvedValue({ + statuses: [{ sessionId: "ses-visible", status: "running" }], + }) + const { result, rerender } = renderHook( + (tasks: Array<{ id: string; executionTarget: string; cloudStatus: string }>) => useResolvedCloudStatuses(tasks), + { initialProps: [{ id: "ses-visible", executionTarget: "cloud", cloudStatus: "unknown" }] }, + ) + await waitFor(() => expect(result.current.get("ses-visible")).toBe("running")) + + rerender([{ id: "ses-visible", executionTarget: "cloud", cloudStatus: "completed" }]) + + expect(result.current.has("ses-visible")).toBe(false) + }) + + it("retries while a displayed cloud status remains indeterminate", async () => { + vi.useFakeTimers() + mocks.resolveCloudSessionStatuses.mockResolvedValue({ + statuses: [{ sessionId: "ses-visible", status: "unknown" }], + }) + renderHook(() => useResolvedCloudStatuses([{ id: "ses-visible", executionTarget: "cloud", cloudStatus: "unknown" }])) + await act(async () => {}) + expect(mocks.resolveCloudSessionStatuses).toHaveBeenCalledTimes(1) + + await act(() => vi.advanceTimersByTimeAsync(30_000)) + await act(async () => {}) + expect(mocks.resolveCloudSessionStatuses).toHaveBeenCalledTimes(2) + }) +}) diff --git a/apps/vscode/webview-ui/src/hooks/useResolvedCloudStatuses.ts b/apps/vscode/webview-ui/src/hooks/useResolvedCloudStatuses.ts new file mode 100644 index 0000000000..185ef12724 --- /dev/null +++ b/apps/vscode/webview-ui/src/hooks/useResolvedCloudStatuses.ts @@ -0,0 +1,53 @@ +import { CloudSessionStatusRequest } from "@shared/proto/cline/cloud" +import { useEffect, useMemo, useState } from "react" +import { CloudServiceClient } from "@/services/grpc-client" + +const STATUS_RETRY_MS = 30_000 + +interface CloudStatusTask { + id: string + executionTarget?: string + cloudStatus?: string +} + +/** + * Live statuses for the displayed cloud tasks whose history status is still + * `unknown`. Once history itself reports a status for a task, that newer + * value wins: the task drops out of the returned map. + */ +export function useResolvedCloudStatuses(tasks: CloudStatusTask[]): ReadonlyMap { + const [resolved, setResolved] = useState>(() => new Map()) + const [retry, setRetry] = useState(0) + const unknownIds = tasks + .filter((task) => task.executionTarget === "cloud" && task.cloudStatus === "unknown") + .map((task) => task.id) + .join("\n") + const statuses = useMemo(() => { + const unknown = new Set(unknownIds ? unknownIds.split("\n") : []) + return new Map([...resolved].filter(([sessionId]) => unknown.has(sessionId))) + }, [resolved, unknownIds]) + + useEffect(() => { + if (!unknownIds) return + let disposed = false + let retryTimer: ReturnType | undefined + const scheduleRetry = () => { + retryTimer = setTimeout(() => setRetry((value) => value + 1), STATUS_RETRY_MS) + } + CloudServiceClient.resolveCloudSessionStatuses(CloudSessionStatusRequest.create({ sessionIds: unknownIds.split("\n") })) + .then((response) => { + if (disposed) return + setResolved(new Map(response.statuses.map((item) => [item.sessionId, item.status]))) + if (response.statuses.some((item) => item.status === "unknown")) scheduleRetry() + }) + .catch((error) => { + console.error("Failed to resolve cloud session status:", error) + if (!disposed) scheduleRetry() + }) + return () => { + disposed = true + clearTimeout(retryTimer) + } + }, [retry, unknownIds]) + return statuses +} diff --git a/bun.lock b/bun.lock index ff3fad81b7..42bbad8d45 100644 --- a/bun.lock +++ b/bun.lock @@ -472,6 +472,7 @@ "@types/sinon": "^21.0.0", "@types/turndown": "^5.0.5", "@types/vscode": "1.101.0", + "@types/ws": "^8.18.1", "@vscode/test-cli": "^0.0.12", "@vscode/test-electron": "^2.5.2", "@vscode/vsce": "^3.6.0", @@ -498,6 +499,7 @@ "tsconfig-paths": "^4.2.0", "typescript": "^5.4.5", "vitest": "^4.0.17", + "ws": "^8.20.0", }, }, "apps/vscode-rollout": { @@ -669,15 +671,19 @@ "@opentelemetry/sdk-trace-base": "^2.6.1", "@opentelemetry/sdk-trace-node": "^2.6.1", "@opentelemetry/semantic-conventions": "^1.40.0", + "http-proxy-agent": "^7.0.2", + "https-proxy-agent": "^7.0.6", "jiti": "^2.7.0", "nanoid": "^5.1.7", "node-machine-id": "^1.1.12", + "proxy-from-env": "^2.1.0", "simple-git": "3.36.0", "ws": "^8.20.0", "yaml": "^2.8.2", "zod": "^4.3.6", }, "devDependencies": { + "@types/proxy-from-env": "^1.0.4", "@types/ws": "^8.18.1", "posthog-node": "^5.8.0", }, @@ -2644,6 +2650,8 @@ "@types/prop-types": ["@types/prop-types@15.7.15", "", {}, "sha512-F6bEyamV9jKGAFBEmlQnesRPGOQqS2+Uwi0Em15xenOxHaf2hv6L8YCVn3rPdPJOiJfPiCnLIRyvwVaqMY3MIw=="], + "@types/proxy-from-env": ["@types/proxy-from-env@1.0.4", "", { "dependencies": { "@types/node": "*" } }, "sha512-TPR9/bCZAr3V1eHN4G3LD3OLicdJjqX1QRXWuNcCYgE66f/K8jO2ZRtHxI2D9MbnuUP6+qiKSS8eUHp6TFHGCw=="], + "@types/react": ["@types/react@19.2.14", "", { "dependencies": { "csstype": "^3.2.2" } }, "sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w=="], "@types/react-dom": ["@types/react-dom@19.2.5", "", { "peerDependencies": { "@types/react": "^19.2.0" } }, "sha512-fMPwH9v7r/pp43yUd2/Mbiex5KouJwwR3dzHkhLREUC6764VyDsqxhAxv6OFEYR1RhjOyD1naqba8ECDBe7ZQg=="], diff --git a/sdk/DOC.md b/sdk/DOC.md index 7ba89086aa..ee38f9f838 100644 --- a/sdk/DOC.md +++ b/sdk/DOC.md @@ -49,6 +49,10 @@ session lifecycle, transcript reconciliation, and approvals. Hosts provide API URLs and a fresh-token callback; feature gating and account selection stay in the host. Importing this subpath does not start a local agent. +For ordinary creation, `CloudCreationUnconfirmedError` means a dispatched request +could not be reconciled. It remains a `CloudSessionError` with code `request_failed`; +hosts should direct users to their session list instead of offering a blind retry. + Use `subscribe` for immutable snapshots and live events, `attach`/`readMessages` to open a session, and `send` for a follow-up. Attaching a provisioning or failed session returns its receipt without connecting. `detach` closes this viewer, not diff --git a/sdk/packages/core/package.json b/sdk/packages/core/package.json index d6499f5983..8dda8b0385 100644 --- a/sdk/packages/core/package.json +++ b/sdk/packages/core/package.json @@ -76,9 +76,12 @@ "@opentelemetry/sdk-trace-base": "^2.6.1", "@opentelemetry/sdk-trace-node": "^2.6.1", "@opentelemetry/semantic-conventions": "^1.40.0", + "http-proxy-agent": "^7.0.2", + "https-proxy-agent": "^7.0.6", "jiti": "^2.7.0", "node-machine-id": "^1.1.12", "nanoid": "^5.1.7", + "proxy-from-env": "^2.1.0", "simple-git": "3.36.0", "ws": "^8.20.0", "yaml": "^2.8.2", @@ -93,6 +96,7 @@ } }, "devDependencies": { + "@types/proxy-from-env": "^1.0.4", "@types/ws": "^8.18.1", "posthog-node": "^5.8.0" }, diff --git a/sdk/packages/core/src/cloud/api.test.ts b/sdk/packages/core/src/cloud/api.test.ts index 3146890d4e..1dce537f48 100644 --- a/sdk/packages/core/src/cloud/api.test.ts +++ b/sdk/packages/core/src/cloud/api.test.ts @@ -1,6 +1,7 @@ import type { MessageWithMetadata } from "@cline/shared"; import { describe, expect, it, vi } from "vitest"; import { + CloudCreationUnconfirmedError, CloudHandoffCreationRejectedError, CloudSessionApi, type CloudSessionApiOptions, @@ -131,7 +132,10 @@ describe("CloudSessionApi", () => { }); await expect( api.create({ modelId: "model", repoUrl: "repo" }), - ).rejects.toThrow("POST response lost"); + ).rejects.toMatchObject({ + name: "CloudCreationUnconfirmedError", + cause: expect.objectContaining({ message: "POST response lost" }), + }); expect(requests).toEqual([ { method: "POST", authorization: `Bearer ${original}` }, ...(mode === "rotated" @@ -441,7 +445,11 @@ describe("CloudSessionApi", () => { expect(requests).toEqual(["POST /api/v1/session", "GET /api/v1/session"]); }); - it("recovers the real id after the create request times out", async () => { + it.each([ + "match", + "empty", + "failed lookup", + ])("reconciles a timed-out create with %s", async (recovery) => { vi.useFakeTimers(); const requests: string[] = []; let recoveryTitle = ""; @@ -465,30 +473,42 @@ describe("CloudSessionApi", () => { ); }); } + if (recovery === "failed lookup") + return jsonResponse({ error: "unavailable" }, 500); return jsonResponse({ success: true, - data: [ - { - ...REMOTE_SESSION, - id: "ses-recovered", - title: recoveryTitle, - status: "provisioning", - sandboxUrl: "", - }, - ], + data: + recovery === "match" + ? [ + { + ...REMOTE_SESSION, + id: "ses-recovered", + title: recoveryTitle, + status: "provisioning", + sandboxUrl: "", + }, + ] + : [], }); }, }); - const creating = api.create({ - modelId: "anthropic/claude-sonnet-5", - repoUrl: "https://github.com/cline/test", - }); + const creating = api + .create({ + modelId: "anthropic/claude-sonnet-5", + repoUrl: "https://github.com/cline/test", + }) + .catch((error) => error); await vi.advanceTimersByTimeAsync(100); - - await expect(creating).resolves.toMatchObject({ - sessionId: "ses-recovered", - }); + if (recovery === "match") + expect(await creating).toMatchObject({ sessionId: "ses-recovered" }); + else { + expect(await creating).toBeInstanceOf(CloudCreationUnconfirmedError); + expect(await creating).toMatchObject({ + code: "request_failed", + cause: expect.any(Error), + }); + } expect(requests).toEqual(["POST /api/v1/session", "GET /api/v1/session"]); } finally { vi.useRealTimers(); @@ -859,7 +879,10 @@ describe("CloudSessionApi", () => { }; const error = await api.create(input).catch((caught) => caught); - expect(error).toMatchObject({ code: "request_failed" }); + expect(error).toMatchObject({ + name: "CloudCreationUnconfirmedError", + code: "request_failed", + }); expect(String(error)).toContain("ambiguous result"); }); @@ -938,6 +961,30 @@ describe("CloudSessionApi", () => { expect(listRequests).toBe(0); }); + it("keeps a credential failure before dispatch retryable", async () => { + const rejected = new Error("scope changed"); + const getAuthToken = vi + .fn(async () => "token") + .mockResolvedValueOnce("token") + .mockRejectedValueOnce(rejected); + const fetch = vi.fn( + async (_url: string | URL | Request, _init?: RequestInit) => + jsonResponse({ data: [] }), + ); + const api = new CloudSessionApi({ + apiBaseUrl: "https://api.example", + appBaseUrl: "https://app.example", + getAuthToken, + fetch, + }); + await expect( + api.create({ modelId: "model", repoUrl: "repo" }), + ).rejects.toBe(rejected); + expect(fetch.mock.calls.some(([, init]) => init?.method === "POST")).toBe( + false, + ); + }); + it("returns the GitHub connection action when no integration exists", async () => { const api = new CloudSessionApi({ apiBaseUrl: "https://api.example", diff --git a/sdk/packages/core/src/cloud/api.ts b/sdk/packages/core/src/cloud/api.ts index 2a09e4052a..ecce4e8610 100644 --- a/sdk/packages/core/src/cloud/api.ts +++ b/sdk/packages/core/src/cloud/api.ts @@ -121,6 +121,17 @@ export class CloudSessionError extends Error { } } +export class CloudCreationUnconfirmedError extends CloudSessionError { + constructor(cause: unknown) { + super( + "request_failed", + "Cloud session creation had an ambiguous result. Check your cloud session list or dashboard before starting another task.", + ); + this.name = "CloudCreationUnconfirmedError"; + this.cause = cause; + } +} + /** The fresh handoff POST definitely did not create a workspace. Marker/list * failures and ambiguous transport outcomes deliberately never use this type. */ export class CloudHandoffCreationRejectedError extends Error { @@ -788,6 +799,8 @@ export class CloudSessionApi { ); } + if (!input.handoff && !definitelyRejected && candidates.length !== 1) + throw new CloudCreationUnconfirmedError(error); if (candidates.length > 1) { throw new CloudSessionError( "request_failed", diff --git a/sdk/packages/core/src/cloud/controller.ts b/sdk/packages/core/src/cloud/controller.ts index e4e99c2de6..32c9471de2 100644 --- a/sdk/packages/core/src/cloud/controller.ts +++ b/sdk/packages/core/src/cloud/controller.ts @@ -76,7 +76,7 @@ const CLOUD_WORKSPACE_ROOT = "/workspace"; const QUEUE_COMMAND_TIMEOUT_MS = 30_000; const MAX_BUFFERED_SYNC_EVENTS = 2_000; const MAX_SEEN_EVENT_IDS = 2_000; -const CLOUD_SESSION_SYSTEM_PROMPT = +export const CLOUD_SESSION_SYSTEM_PROMPT = "IMPORTANT: GitHub authentication is handled automatically by the infrastructure. " + "An egress proxy transparently injects credentials into all GitHub traffic. " + "You do NOT need to set up, configure, or manage any tokens, API keys, or credentials, " + @@ -86,6 +86,28 @@ const CLOUD_SESSION_SYSTEM_PROMPT = "`git` push and pull are authenticated the same way. " + "Simply run the commands normally — credentials are injected transparently."; +/** The prompt a new cloud task starts with: GitHub access plus its work-branch rules. */ +export function buildCloudSessionSystemPrompt( + record: Pick, +): string { + const branch = `cline/${(record.metadata.taskId?.trim() || record.id).slice(-8).toLowerCase()}`; + const resumable = + record.sandboxType === "resumable" || + record.metadata.sandboxType === "resumable"; + return ( + `${CLOUD_SESSION_SYSTEM_PROMPT}\n\n` + + `Do all work for this task on the branch \`${branch}\`: create it from the current checkout before your first change ` + + "(or check it out if it already exists), and never commit directly to the default branch. " + + (resumable + ? "Commit and push only when the user asks. " + : "SAVE YOUR WORK: This sandbox is temporary. Push your progress to origin so it remains available outside the sandbox. " + + `The branch \`${branch}\` is a backup of your work-in-progress, not a finished deliverable, so commit to it freely even when the work is incomplete. ` + + "Commit regularly as you complete meaningful steps, using clear, descriptive messages. " + + `The first time you commit, push the branch with \`git push -u origin ${branch}\`, and push again after each later commit. `) + + "Do not force-push or amend commits that are already pushed unless the user explicitly asks." + ); +} + function cloudWorkspaceCwd(workspaceRelativePath?: string): string { if (!workspaceRelativePath) return CLOUD_WORKSPACE_ROOT; if ( @@ -2752,21 +2774,7 @@ export class CloudSessionController { if (handoffSeed) throw new CloudHandoffSeedRejectedError(error); throw error; } - const branch = `cline/${(connection.remote.metadata.taskId?.trim() || connection.remote.id).slice(-8).toLowerCase()}`; - const resumable = - connection.remote.sandboxType === "resumable" || - connection.remote.metadata.sandboxType === "resumable"; - const systemPrompt = - `${CLOUD_SESSION_SYSTEM_PROMPT}\n\n` + - `Do all work for this task on the branch \`${branch}\`: create it from the current checkout before your first change ` + - "(or check it out if it already exists), and never commit directly to the default branch. " + - (resumable - ? "Commit and push only when the user asks. " - : "SAVE YOUR WORK: This sandbox is temporary. Push your progress to origin so it remains available outside the sandbox. " + - `The branch \`${branch}\` is a backup of your work-in-progress, not a finished deliverable, so commit to it freely even when the work is incomplete. ` + - "Commit regularly as you complete meaningful steps, using clear, descriptive messages. " + - `The first time you commit, push the branch with \`git push -u origin ${branch}\`, and push again after each later commit. `) + - "Do not force-push or amend commits that are already pushed unless the user explicitly asks."; + const systemPrompt = buildCloudSessionSystemPrompt(connection.remote); const thinking = handoffSeed?.config?.thinking ?? live?.config.thinking; const reasoningEffort = handoffSeed?.config?.reasoningEffort ?? live?.config.reasoningEffort; diff --git a/sdk/packages/core/src/hub/client/__fixtures__/localhost-cert.pem b/sdk/packages/core/src/hub/client/__fixtures__/localhost-cert.pem new file mode 100644 index 0000000000..3481cf7780 --- /dev/null +++ b/sdk/packages/core/src/hub/client/__fixtures__/localhost-cert.pem @@ -0,0 +1,19 @@ +-----BEGIN CERTIFICATE----- +MIIDJjCCAg6gAwIBAgIUX9PGHK5T3FbPcvHtCrQzGYzFQ04wDQYJKoZIhvcNAQEL +BQAwFDESMBAGA1UEAwwJMTI3LjAuMC4xMB4XDTI2MDkyMzIzMDUxNVoXDTM2MDky +MDIzMDUxNVowFDESMBAGA1UEAwwJMTI3LjAuMC4xMIIBIjANBgkqhkiG9w0BAQEF +AAOCAQ8AMIIBCgKCAQEAvH85PqRa/6HifghTNN+794b5TKD9Q79udtHL+DVOv6xQ +F75ZD0kJPtZ8liTDKN4cDueqd8bck4QXyAS1NwuEejh0BCffa7sFy16wuinU+yp7 +wHT01iEI3RZfVGIOA4DYjc0to2t0uS9oieE5q6YcJrbHwW8N2Mpi/iyKPbeI+ue1 +HvQMANs5un007fgUU5n71DNmWN0xmsHV9gUK5xR1R3OC5gOrniOY7LEafKm0pBkt +jiwvVMKXF4kFGnqlIDzRJD9UQgu0Pgy8//Zm5t9BORjwumHkLnQ9YroiK3uaV4iX +iyNDuNAIfhN5S9GSs807xlZBanY4h8FMROQfooIVCwIDAQABo3AwbjAaBgNVHREE +EzARhwR/AAABgglsb2NhbGhvc3QwDAYDVR0TAQH/BAIwADAOBgNVHQ8BAf8EBAMC +BaAwEwYDVR0lBAwwCgYIKwYBBQUHAwEwHQYDVR0OBBYEFGXPkAwt8yv2vqB+3zQM +7nZsmPDiMA0GCSqGSIb3DQEBCwUAA4IBAQB/ou50h680dClED5FU15/54SE0pQb3 +9vgawzJitOtd6r7odBAoy76T001ppIrSBRrqxBlqRsllezq2DA+SFhPNWw1l07DH +UisWhTEZh71RKzgx6ghODRvAfdwLP6EZ6kPTV2Azl7OcNXC+tfvLWwIEKFAm1OmA +IKeL/rXIl/OG5W8O4Kt2ggOuzfulIeKgRwfmF7yQ0Huyym3YX74oYPxOQviw0tjW +G3T0BmXDmwCMFvCE8X4mvIgV67bpXVMzqiMRM+peqC/Ic75GpYEFlAlYPrsqAFjM +cpWpEUPmQa6qctBM+dg7r2WIuD5Z5/Z6E+Y1DtxjfBHEba2+6cSIOR4x +-----END CERTIFICATE----- diff --git a/sdk/packages/core/src/hub/client/__fixtures__/localhost-key.der b/sdk/packages/core/src/hub/client/__fixtures__/localhost-key.der new file mode 100644 index 0000000000000000000000000000000000000000..b6ab9a6c2d868e3196b98384872f503a87ed54a7 GIT binary patch literal 1217 zcmV;y1U~yPf&{$+0RS)!1_>&LNQUrr!ay9qXGc{0)hbn0K9)WKBQXz zq2hiBQ#9YZ_lEgQp#4L?Zg$bj_%%+ytWX!eSr17GKGuAeB*Q4)91iEIcgEb4gcryJ zwKofddN_0hC*Nzk1!AwNtAEJU43jzZH009Dm0RRAp z*(m6Iix{yJ3c>X?rU_BobkIdMLLD^!Z8Q@uSQ5*e?SH|q!gFU7Pwmz;1LzgoX zq)RQu2XaDmc!F>T3bxUqU3f$Sf;LczoDhpC*nZX;vNv_w*tI?t)yL!5%YQnCBp%z$ zzB%+_b=*?7WVapx!RS85VDgdqG%B(R+2dFgFB7FnORB^62~?|aJm}seJWrBm$4*}* z=V9~`u2%nmEGMz>9bJfF8QFsFCTB$CmQRis;U{cUO|>4(=#0`z;0ww+P5lCafdKLB z>!>BL_$hyIirD8VK*NXD`lkEu-t+97%>8@&ktN?pTWQ{&cUhDhnN+FiuBU#pKg#QU zrO_Xx%@FR|@|<(!K(*ko@T-~v_kMKzAu-2{%Oq{b$oags?Lz2xN`G@sI#KF4ELwFz zO0|3RLDVxS@`|6+m=&K{-3Vm*)+GXgfdI#LlI}|b;NetpVm0`+1H)FR>$b=$iTfy%_V7atCTyKvvhaQ=H)F5v)Y6X&B>s&3!EOq< zG@=)5=`p(Hztk*U-@-}Uk3wR7&TQzrc?#^%S+?$(yzi9pnKq0c61&;Gc8=lE-){ne zfdHtD_Fj+dE$Y+6xTuKUASPQ?cnh)0onHIG|GT)4JiyaH=Q`^JBZz?geWnyZAr!~l z;hbXFhZr5;_mfShw39a2N^Y&*`M<@jJ$#tUHQ{yh?Qt_7m@*H(udW9}UEffd_g*bN z_y7uH^hRITeW~~V7KLc{#-)M*-BU#G(-i`NfELx|W^ke=3C0j14NAuLf>V}_K3Gn| znmaHrCQt{#ZWA@$j?1Y^G4TZ$J=y_Yx2a;EKg@kZoaDP@!w|k}@b+RRMF8@pBb4rfg9XRjh`WxCj3W_LfAv-u->o3=msywQd zIez4U7o>_Y00LYe++Q|lF13U+-mS)A{)!%b@qU#*PW2Gv(+T}?o36mZaRwx4H-}Qs fVEWLq4sKg`X^De@uB6Yj3YD7eCx~PH4!au@1-4bA literal 0 HcmV?d00001 diff --git a/sdk/packages/core/src/hub/client/__fixtures__/proxy-client.mjs b/sdk/packages/core/src/hub/client/__fixtures__/proxy-client.mjs new file mode 100644 index 0000000000..ac8cb919f0 --- /dev/null +++ b/sdk/packages/core/src/hub/client/__fixtures__/proxy-client.mjs @@ -0,0 +1,19 @@ +import { NodeHubClient } from "../../../../dist/hub/index.js"; + +const url = process.env.CLINE_TEST_HUB_URL; +const authorization = process.env.CLINE_TEST_HUB_AUTHORIZATION; +if (!url || !authorization) { + throw new Error("Missing CLINE_TEST_HUB_URL or CLINE_TEST_HUB_AUTHORIZATION"); +} + +const client = new NodeHubClient({ + url, + resolveConnectionHeaders: () => ({ Authorization: authorization }), +}); + +try { + await client.connect(); + console.log(`connected under ${process.versions.bun ? "Bun" : "Node"}`); +} finally { + client.close(); +} diff --git a/sdk/packages/core/src/hub/client/connection-headers.test.ts b/sdk/packages/core/src/hub/client/connection-headers.test.ts index 938f7221e3..ed1900dc8f 100644 --- a/sdk/packages/core/src/hub/client/connection-headers.test.ts +++ b/sdk/packages/core/src/hub/client/connection-headers.test.ts @@ -1,10 +1,30 @@ -import type { IncomingMessage } from "node:http"; -import type { AddressInfo } from "node:net"; -import { afterEach, describe, expect, it, vi } from "vitest"; +import { spawn } from "node:child_process"; +import { createPrivateKey } from "node:crypto"; +import { existsSync, readFileSync } from "node:fs"; +import { createServer, type IncomingMessage, type Server } from "node:http"; +import { createServer as createHttpsServer } from "node:https"; +import { type AddressInfo, connect as connectTcp, type Socket } from "node:net"; +import { delimiter, join } from "node:path"; +import type { Duplex } from "node:stream"; +import { fileURLToPath } from "node:url"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { WebSocketServer } from "ws"; import { NodeHubClient } from "."; const servers: WebSocketServer[] = []; +const networkServers: Server[] = []; +const tunnelSockets = new Set(); +const proxyEnvironmentKeys = [ + "HTTP_PROXY", + "http_proxy", + "HTTPS_PROXY", + "https_proxy", + "ALL_PROXY", + "all_proxy", + "NO_PROXY", + "no_proxy", +] as const; +let proxyEnvironment = new Map(); type CommandContext = { command: string; @@ -16,13 +36,13 @@ type CommandContext = { ) => void; }; -async function startHubServer(options?: { - onConnection?: (request: IncomingMessage) => void; - onCommand?: (context: CommandContext) => void; -}): Promise<{ server: WebSocketServer; url: string }> { - const server = new WebSocketServer({ host: "127.0.0.1", port: 0 }); - servers.push(server); - await new Promise((resolve) => server.once("listening", resolve)); +function configureHubServer( + server: WebSocketServer, + options?: { + onConnection?: (request: IncomingMessage) => void; + onCommand?: (context: CommandContext) => void; + }, +): void { server.on("connection", (socket, request) => { options?.onConnection?.(request); socket.on("message", (data) => { @@ -66,10 +86,86 @@ async function startHubServer(options?: { } }); }); +} + +async function startHubServer(options?: { + onConnection?: (request: IncomingMessage) => void; + onCommand?: (context: CommandContext) => void; +}): Promise<{ server: WebSocketServer; url: string }> { + const server = new WebSocketServer({ host: "127.0.0.1", port: 0 }); + servers.push(server); + await new Promise((resolve) => server.once("listening", resolve)); + configureHubServer(server, options); const { port } = server.address() as AddressInfo; return { server, url: `ws://127.0.0.1:${port}/hub` }; } +/** + * The header-authenticated client ships to two runtimes that resolve `ws` + * differently: Node loads the npm package, Bun substitutes its own. The + * tunnel fixture runs under each so a client shape that works on one cannot + * silently break the other. + * + * When Bun runs vitest it puts a Bun-backed `node` shim first on PATH and + * reports it as execPath; that shim is Bun, so look past it for an installed + * Node. + */ +function resolveNodeExecutable(): string | undefined { + if (!process.versions.bun) { + return process.execPath; + } + const names = + process.platform === "win32" ? ["node.exe", "node.cmd"] : ["node"]; + for (const dir of (process.env.PATH ?? "").split(delimiter)) { + if (!dir || dir.includes("bun-node-")) continue; + for (const name of names) { + const candidate = join(dir, name); + if (existsSync(candidate)) return candidate; + } + } + return undefined; +} + +function resolveBunExecutable(): string | undefined { + if (process.versions.bun) { + return process.execPath; + } + const names = process.platform === "win32" ? ["bun.exe"] : ["bun"]; + for (const dir of (process.env.PATH ?? "").split(delimiter)) { + if (!dir) continue; + for (const name of names) { + const candidate = join(dir, name); + if (existsSync(candidate)) return candidate; + } + } + return undefined; +} + +/** + * One leg per runtime. Node trusts the fixture certificate through + * NODE_EXTRA_CA_CERTS; Bun's TLS client has no equivalent, so its leg tunnels + * a plain `ws://` destination and covers the CONNECT handshake, the upgrade + * and header placement without TLS. + */ +const tunnelLegs = [ + { runtime: "Node", executable: resolveNodeExecutable(), secure: true }, + { runtime: "Bun", executable: resolveBunExecutable(), secure: false }, +]; + +async function listen(server: Server): Promise { + networkServers.push(server); + server.listen(0, "127.0.0.1"); + await new Promise((resolve) => server.once("listening", resolve)); + return (server.address() as AddressInfo).port; +} + +beforeEach(() => { + proxyEnvironment = new Map( + proxyEnvironmentKeys.map((name) => [name, process.env[name]]), + ); + for (const name of proxyEnvironmentKeys) process.env[name] = ""; +}); + afterEach(async () => { for (const server of servers.splice(0)) { for (const socket of server.clients) { @@ -77,10 +173,120 @@ afterEach(async () => { } await new Promise((resolve) => server.close(() => resolve())); } + for (const socket of tunnelSockets) socket.destroy(); + tunnelSockets.clear(); + for (const server of networkServers.splice(0)) { + await new Promise((resolve) => server.close(() => resolve())); + } + for (const name of proxyEnvironmentKeys) delete process.env[name]; + for (const [name, value] of proxyEnvironment) { + if (value !== undefined) process.env[name] = value; + } vi.restoreAllMocks(); }); describe("NodeHubClient connection headers", () => { + for (const { runtime, executable, secure } of tunnelLegs) { + it.skipIf(!executable)( + `${runtime}: tunnels through the proxy without leaking origin authorization to it`, + async () => { + const certificateUrl = new URL( + "./__fixtures__/localhost-cert.pem", + import.meta.url, + ); + const certificatePath = fileURLToPath(certificateUrl); + const certificate = readFileSync(certificateUrl, "utf8"); + const key = createPrivateKey({ + key: readFileSync( + new URL("./__fixtures__/localhost-key.der", import.meta.url), + ), + format: "der", + type: "pkcs8", + }).export({ format: "pem", type: "pkcs8" }); + const destinationUpgrades: IncomingMessage[] = []; + const destinationServer = secure + ? createHttpsServer({ cert: certificate, key }) + : createServer(); + const hubServer = new WebSocketServer({ server: destinationServer }); + servers.push(hubServer); + configureHubServer(hubServer, { + onConnection: (request) => destinationUpgrades.push(request), + }); + const destinationPort = await listen(destinationServer); + + const proxyConnects: IncomingMessage[] = []; + const proxyServer = createServer(); + proxyServer.on("connect", (request, clientSocket, head) => { + proxyConnects.push(request); + tunnelSockets.add(clientSocket); + const separator = request.url?.lastIndexOf(":") ?? -1; + const host = request.url?.slice(0, separator); + const port = Number(request.url?.slice(separator + 1)); + const upstream = connectTcp({ host, port }); + tunnelSockets.add(upstream); + // The fixture exits as soon as it has connected, so either end of + // the tunnel may see a reset; a proxy treats that as the end of + // the tunnel, not an error. + clientSocket.on("error", () => upstream.destroy()); + upstream.on("error", () => clientSocket.destroy()); + upstream.once("connect", () => { + clientSocket.write("HTTP/1.1 200 Connection Established\r\n\r\n"); + if (head.length > 0) upstream.write(head); + clientSocket.pipe(upstream); + upstream.pipe(clientSocket); + }); + }); + const proxyPort = await listen(proxyServer); + + const proxyUrl = `http://proxy-user:proxy-pass@127.0.0.1:${proxyPort}`; + const url = `${secure ? "wss" : "ws"}://127.0.0.1:${destinationPort}/hub`; + const fixture = fileURLToPath( + new URL("./__fixtures__/proxy-client.mjs", import.meta.url), + ); + const child = spawn(executable as string, [fixture], { + env: { + ...process.env, + ...(secure + ? { HTTPS_PROXY: proxyUrl, NODE_EXTRA_CA_CERTS: certificatePath } + : { HTTP_PROXY: proxyUrl }), + CLINE_TEST_HUB_URL: url, + CLINE_TEST_HUB_AUTHORIZATION: "Bearer workos:destination-token", + }, + stdio: ["ignore", "pipe", "pipe"], + }); + const stdout: Buffer[] = []; + const stderr: Buffer[] = []; + child.stdout.on("data", (chunk: Buffer) => stdout.push(chunk)); + child.stderr.on("data", (chunk: Buffer) => stderr.push(chunk)); + const exitCode = await new Promise((resolve, reject) => { + child.once("error", reject); + child.once("exit", resolve); + }); + expect(exitCode, Buffer.concat(stderr).toString()).toBe(0); + expect(Buffer.concat(stdout).toString()).toContain( + `connected under ${runtime}`, + ); + + expect(proxyConnects).toHaveLength(1); + expect(proxyConnects[0].url).toBe(`127.0.0.1:${destinationPort}`); + for (const request of proxyConnects) { + expect(request.headers.authorization).toBeUndefined(); + expect(request.headers["proxy-authorization"]).toBe( + `Basic ${Buffer.from("proxy-user:proxy-pass").toString("base64")}`, + ); + } + expect(destinationUpgrades).toHaveLength(1); + expect(destinationUpgrades[0].url).toBe("/hub"); + expect(destinationUpgrades[0].headers.authorization).toBe( + "Bearer workos:destination-token", + ); + expect( + destinationUpgrades[0].headers["proxy-authorization"], + ).toBeUndefined(); + }, + ); + } + it("keeps concurrent connects pending until registration finishes", async () => { let acknowledgeRegistration: (() => void) | undefined; let registrationReceived: (() => void) | undefined; diff --git a/sdk/packages/core/src/hub/client/index.ts b/sdk/packages/core/src/hub/client/index.ts index aaf1ee3e27..4ce0b7baad 100644 --- a/sdk/packages/core/src/hub/client/index.ts +++ b/sdk/packages/core/src/hub/client/index.ts @@ -10,6 +10,15 @@ import { resolveClineBuildEnv, resolveHubCommandTimeoutMs, } from "@cline/shared"; +import { HttpProxyAgent } from "http-proxy-agent"; +import { HttpsProxyAgent } from "https-proxy-agent"; +import { getProxyForUrl } from "proxy-from-env"; +// Node loads the npm package; Bun substitutes its own `ws`, which reads the +// proxy URL and Proxy-Authorization from the same `agent` option. One socket +// shape therefore serves the extension host, the CLI and the Bun-hosted +// desktop sidecar. Do not alias the npm package to bypass Bun's substitution: +// Bun's node:http client has not always emitted `upgrade`, and the npm client +// then fails the handshake with "Unexpected server response: 101". import NodeWebSocket from "ws"; import corePackage from "../../../package.json"; import { @@ -78,6 +87,18 @@ function getWebSocketCtor(): WebSocketCtor { return ctor; } +function resolveWebSocketProxyAgent(url: URL) { + const proxyLookupUrl = new URL(url); + proxyLookupUrl.protocol = url.protocol === "wss:" ? "https:" : "http:"; + const proxyUrl = getProxyForUrl(proxyLookupUrl.toString()); + if (!proxyUrl) { + return undefined; + } + return url.protocol === "wss:" + ? new HttpsProxyAgent(proxyUrl) + : new HttpProxyAgent(proxyUrl); +} + function decodeSocketData(data: unknown): string { if (typeof data === "string") { return data; @@ -556,9 +577,22 @@ export class NodeHubClient { throw error; } + // Proxy settings and origin headers are resolved for the same connection + // attempt so environment and credential changes apply on reconnect. + let agent: ReturnType; + try { + agent = headers ? resolveWebSocketProxyAgent(url) : undefined; + } catch (error) { + const transportError = normalizeWebSocketConnectError(error, url); + if (generation === this.connectGeneration) { + this.lastCloseError = transportError; + } + throw transportError; + } const socket = headers ? (new NodeWebSocket(url.toString(), { headers: { ...headers }, + agent, }) as unknown as WebSocketLike) : new (getWebSocketCtor())( url.toString(), diff --git a/sdk/packages/core/src/hub/client/proxy.test.ts b/sdk/packages/core/src/hub/client/proxy.test.ts new file mode 100644 index 0000000000..85a515158d --- /dev/null +++ b/sdk/packages/core/src/hub/client/proxy.test.ts @@ -0,0 +1,276 @@ +import { HttpProxyAgent } from "http-proxy-agent"; +import { HttpsProxyAgent } from "https-proxy-agent"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +type SocketOptions = { + agent?: HttpProxyAgent | HttpsProxyAgent; + headers?: Record; +}; + +const { FakeNodeWebSocket } = vi.hoisted(() => { + class FakeNodeWebSocket { + static instances: FakeNodeWebSocket[] = []; + readonly listeners = new Map void>>(); + readyState = 0; + + constructor( + readonly url: string, + readonly options: SocketOptions, + ) { + FakeNodeWebSocket.instances.push(this); + queueMicrotask(() => { + this.readyState = 1; + this.emit("open"); + }); + } + + addEventListener( + type: string, + listener: (...args: unknown[]) => void, + ): void { + const listeners = this.listeners.get(type) ?? []; + listeners.push(listener); + this.listeners.set(type, listeners); + } + + send(data: string): void { + const frame = JSON.parse(data) as { + kind?: string; + envelope?: { command?: string; requestId?: string }; + }; + if ( + frame.kind !== "command" || + frame.envelope?.command !== "client.register" || + !frame.envelope.requestId + ) { + return; + } + queueMicrotask(() => { + this.emit("message", { + data: JSON.stringify({ + kind: "reply", + envelope: { + version: "v1", + command: "client.register", + requestId: frame.envelope?.requestId, + clientId: "hub", + ok: true, + payload: {}, + }, + }), + }); + }); + } + + close(): void { + if (this.readyState === 3) return; + this.readyState = 3; + this.emit("close", { code: 1000, reason: "" }); + } + + disconnect(): void { + this.readyState = 3; + this.emit("close", { code: 1006, reason: "" }); + } + + private emit(type: string, ...args: unknown[]): void { + for (const listener of this.listeners.get(type) ?? []) { + listener(...args); + } + } + } + + return { FakeNodeWebSocket }; +}); + +vi.mock("ws", () => ({ default: FakeNodeWebSocket })); + +import { NodeHubClient } from "."; + +const PROXY_ENV_KEYS = [ + "HTTP_PROXY", + "http_proxy", + "HTTPS_PROXY", + "https_proxy", + "ALL_PROXY", + "all_proxy", + "NO_PROXY", + "no_proxy", +] as const; + +const originalProxyEnv = new Map( + PROXY_ENV_KEYS.map((key) => [key, process.env[key]]), +); + +function clearProxyEnv(): void { + for (const key of PROXY_ENV_KEYS) process.env[key] = ""; +} + +function agentProxyUrl(agent: SocketOptions["agent"]): string | undefined { + return agent?.proxy.href; +} + +beforeEach(() => { + clearProxyEnv(); + FakeNodeWebSocket.instances.length = 0; +}); + +afterEach(() => { + clearProxyEnv(); + for (const [key, value] of originalProxyEnv) { + if (value !== undefined) process.env[key] = value; + } + vi.restoreAllMocks(); +}); + +describe("NodeHubClient proxy selection", () => { + it("maps WSS to HTTPS_PROXY and preserves destination headers and URL", async () => { + process.env.HTTPS_PROXY = "http://proxy.example:8443"; + const client = new NodeHubClient({ + url: "wss://api.cline.bot/api/v1/session/session-1", + resolveConnectionHeaders: () => ({ + Authorization: "Bearer workos:account-token", + }), + }); + + try { + await client.connect(); + const socket = FakeNodeWebSocket.instances[0]; + expect(socket.url).toBe("wss://api.cline.bot/api/v1/session/session-1"); + expect(socket.options.headers).toEqual({ + Authorization: "Bearer workos:account-token", + }); + expect(socket.options.agent).toBeInstanceOf(HttpsProxyAgent); + expect(agentProxyUrl(socket.options.agent)).toBe( + "http://proxy.example:8443/", + ); + } finally { + client.close(); + } + }); + + it("maps WS to HTTP_PROXY", async () => { + process.env.HTTP_PROXY = "http://proxy.example:8080"; + const client = new NodeHubClient({ + url: "ws://remote.example/hub", + resolveConnectionHeaders: () => ({ Authorization: "Bearer token" }), + }); + + try { + await client.connect(); + const agent = FakeNodeWebSocket.instances[0].options.agent; + expect(agent).toBeInstanceOf(HttpProxyAgent); + expect(agentProxyUrl(agent)).toBe("http://proxy.example:8080/"); + } finally { + client.close(); + } + }); + + it.each([ + ["wss://remote.example/hub", "https_proxy", HttpsProxyAgent], + ["ws://remote.example/hub", "http_proxy", HttpProxyAgent], + ] as const)("supports lowercase proxy variables for %s", async (url, key, Agent) => { + process.env[key] = "http://lowercase-proxy.example:8080"; + const client = new NodeHubClient({ + url, + resolveConnectionHeaders: () => ({ Authorization: "Bearer token" }), + }); + + try { + await client.connect(); + const agent = FakeNodeWebSocket.instances[0].options.agent; + expect(agent).toBeInstanceOf(Agent); + expect(agentProxyUrl(agent)).toBe("http://lowercase-proxy.example:8080/"); + } finally { + client.close(); + } + }); + + it("bypasses the proxy for an exact NO_PROXY host and port", async () => { + process.env.HTTPS_PROXY = "http://proxy.example:8443"; + process.env.NO_PROXY = "remote.example:443"; + const client = new NodeHubClient({ + url: "wss://remote.example:443/hub", + resolveConnectionHeaders: () => ({ Authorization: "Bearer token" }), + }); + + try { + await client.connect(); + expect(FakeNodeWebSocket.instances[0].options.agent).toBeUndefined(); + } finally { + client.close(); + } + }); + + it("preserves direct behavior when no proxy is configured", async () => { + const client = new NodeHubClient({ + url: "wss://remote.example/hub", + resolveConnectionHeaders: () => ({ Authorization: "Bearer token" }), + }); + + try { + await client.connect(); + expect(FakeNodeWebSocket.instances[0].options.agent).toBeUndefined(); + } finally { + client.close(); + } + }); + + it("reports malformed proxy configuration as a connection failure", async () => { + process.env.HTTPS_PROXY = "not a url"; + const client = new NodeHubClient({ + url: "wss://remote.example/hub", + resolveConnectionHeaders: () => ({ Authorization: "Bearer token" }), + }); + + await expect(client.connect()).rejects.toMatchObject({ + code: "hub_connect_failed", + message: expect.stringContaining("Invalid URL"), + }); + expect(client.getConnectionError()).toMatchObject({ + code: "hub_connect_failed", + message: expect.stringContaining("Invalid URL"), + }); + expect(FakeNodeWebSocket.instances).toHaveLength(0); + }); + + it("re-resolves proxy selection and headers on reconnect", async () => { + process.env.HTTPS_PROXY = "http://first-proxy.example:8080"; + let headerVersion = 0; + const client = new NodeHubClient({ + url: "wss://remote.example/hub", + resolveConnectionHeaders: () => ({ + Authorization: `Bearer token-${++headerVersion}`, + }), + }); + client.subscribe(() => {}); + + try { + await client.connect(); + process.env.HTTPS_PROXY = "http://second-proxy.example:8080"; + FakeNodeWebSocket.instances[0].disconnect(); + await vi.waitFor(() => { + expect(FakeNodeWebSocket.instances).toHaveLength(2); + expect(client.isConnected()).toBe(true); + }); + + expect( + FakeNodeWebSocket.instances.map((socket) => ({ + proxy: agentProxyUrl(socket.options.agent), + authorization: socket.options.headers?.Authorization, + })), + ).toEqual([ + { + proxy: "http://first-proxy.example:8080/", + authorization: "Bearer token-1", + }, + { + proxy: "http://second-proxy.example:8080/", + authorization: "Bearer token-2", + }, + ]); + } finally { + client.close(); + } + }); +}); diff --git a/sdk/packages/core/src/hub/runtime-host/hub-runtime-host.ts b/sdk/packages/core/src/hub/runtime-host/hub-runtime-host.ts index 4480578e44..69d0b18076 100644 --- a/sdk/packages/core/src/hub/runtime-host/hub-runtime-host.ts +++ b/sdk/packages/core/src/hub/runtime-host/hub-runtime-host.ts @@ -588,6 +588,8 @@ function hubReplyErrorMessage( export interface HubRuntimeHostOptions { url: string; authToken?: string; + /** Header-based upgrade auth for remote proxies; see NodeHubClient. */ + resolveConnectionHeaders?: HubClientOptions["resolveConnectionHeaders"]; clientType?: string; displayName?: string; capabilities?: RuntimeCapabilities; @@ -789,6 +791,7 @@ export class HubRuntimeHost implements RuntimeHost { this.clientContext = clientContext; this.clientOptions = { authToken: options.authToken, + resolveConnectionHeaders: options.resolveConnectionHeaders, clientType: options.clientType ?? "core-hub-runtime", displayName: options.displayName ?? "core hub runtime", workspaceRoot: clientContext?.workspaceRoot, diff --git a/sdk/packages/core/src/hub/runtime-host/remote-runtime-host.ts b/sdk/packages/core/src/hub/runtime-host/remote-runtime-host.ts index 25e8e437ad..36d070571f 100644 --- a/sdk/packages/core/src/hub/runtime-host/remote-runtime-host.ts +++ b/sdk/packages/core/src/hub/runtime-host/remote-runtime-host.ts @@ -14,6 +14,7 @@ export class RemoteRuntimeHost extends HubRuntimeHost { { url: normalizeHubWebSocketUrl(options.endpoint), authToken: options.authToken, + resolveConnectionHeaders: options.resolveConnectionHeaders, clientType: options.clientType ?? "core-remote-runtime", displayName: options.displayName ?? "core remote runtime", capabilities: options.capabilities, From 83ea9a41d11d733015adc63f47f7363098db2231 Mon Sep 17 00:00:00 2001 From: Bee <68532117+abeatrix@users.noreply.github.com> Date: Tue, 13 Oct 2026 21:58:41 -0700 Subject: [PATCH 12/21] refactor: Start the desktop and SSH remote hubs through the bundled Cline CLI (#14792) * Let hosts choose the hub daemon launcher and defer spawn-only setup Adds setHubDaemonLauncher so an embedding process can start the hub daemon through a bundled Cline CLI instead of its own executable, and a beforeSpawn hook on ensureDetachedHubServer that only runs when a new daemon is actually started. * Add programmatic options to cline hub ensure hub ensure/start gain --json (URL + auth token), --allow-port-fallback, --no-connectors and --login-shell-path, and the hub command accepts --discovery-path for a dedicated discovery record. These let the desktop app and SSH remotes start or reuse a hub through the CLI. Also extracts the standalone binary compile step into script/compile-binary.ts so other packages can build the CLI binary. * Run SSH remote hubs through the Cline CLI SSH remotes now upload the Cline CLI binary and start or reuse the remote hub with 'cline hub ensure --json --discovery-path ...', stopping it with 'cline hub stop'. The dedicated remote helper entrypoint is removed; cleanup records from older releases still stop through the old flag. * Run the desktop backend on the bundled Cline CLI The desktop app no longer compiles its own executable. It bundles the self-contained Cline CLI (externalBin cline-cli) plus the backend as a JS bundle that runs on the CLI's embedded runtime. At startup the backend has the CLI run 'hub ensure', which reuses a compatible healthy Hub or starts one, and any Hub it starts later also goes through the CLI binary. Linux CLI copies are bundled for SSH remotes. * Launch the desktop backend through the bundled Cline CLI The Tauri shell resolves the bundled cline-cli and the backend bundle (per-platform resource layouts) and starts 'cline-cli run' on it. The Windows installer stops users of cline-cli.exe, and also stops and removes the backend executable of earlier releases on upgrade. * Recognize the CLI-hosted desktop backend in cline doctor --fix * Update desktop publish workflow and docs for the bundled Cline CLI Telemetry selfchecks and smoke tests run the backend bundle on the bundled CLI, and the Windows signature gate verifies cline-cli.exe. * fix(desktop): address runtime packaging review findings * fix(desktop): preserve failed hub cleanup and handle exited daemons * fix(hub): distinguish missing discovery records from read failures * fix(hub): preserve discovery recovery while enforcing strict shutdown reads * fix(cli): do not count hub commands as CLI activations (#14870) * fix(cli): do not count hub commands as CLI activations The desktop app runs `cline-cli hub ensure` on every launch and the SSH remote flow runs `cline hub ensure` on every connect. Both went through runCli(), which captured user.extension_activated unconditionally, so each desktop launch was double-counted as a CLI activation and every SSH remote host (with its own distinct id) showed up as a new CLI user. Skip the activation capture for the hub subcommand; it is plumbing other clients invoke, not a user activation. Hub-owned task telemetry is unaffected since the daemon path never enters runCli(). * test(cli): clear the hub command mock between main.test.ts cases * test(cli): stop stubbing the hub command module in main.test.ts The module-wide vi.mock("./commands/hub") replaced createHubCommand with a no-op, so `cline hub dashboard` (routed through createHubCommand) never reached the dashboard mock and three dashboard tests failed. Drop the module mock and assert the activation exclusion through `hub dashboard --no-open`, which is already mocked. --------- Co-authored-by: Saoud Rizwan <7799382+saoudrizwan@users.noreply.github.com> --- .cline/skills/publish-desktop/SKILL.md | 10 +- .github/workflows/desktop-publish.yml | 230 ++++++++------- .github/workflows/desktop-test.yml | 4 +- apps/cli/script/build.ts | 119 +------- apps/cli/script/compile-binary.ts | 162 +++++++++++ apps/cli/src/commands/doctor.test.ts | 19 +- apps/cli/src/commands/doctor.ts | 3 + apps/cli/src/commands/hub.test.ts | 203 +++++++++++++ apps/cli/src/commands/hub.ts | 142 ++++++--- apps/cli/src/index.ts | 8 + apps/cli/src/main.test.ts | 23 ++ apps/cli/src/main.ts | 7 +- apps/examples/desktop-app/README.md | 63 ++-- apps/examples/desktop-app/bun.mts | 2 +- apps/examples/desktop-app/package.json | 2 +- .../scripts/build-desktop-runtime.ts | 239 +++++++++++++++ .../desktop-app/scripts/build-sidecar-bin.ts | 199 ------------- .../scripts/desktop-startup.test.ts | 272 +++++++++++++----- .../desktop-app/scripts/package-desktop.ts | 4 +- .../scripts/tauri-sign-windows.ps1 | 2 +- .../scripts/windows-installer.test.ts | 35 ++- .../desktop-app/sidecar/ARCHITECTURE.md | 8 +- .../desktop-app/sidecar/cli-runtime.test.ts | 74 +++++ .../desktop-app/sidecar/cli-runtime.ts | 111 +++++++ .../desktop-app/sidecar/connectors.test.ts | 12 + .../desktop-app/sidecar/connectors.ts | 6 + apps/examples/desktop-app/sidecar/index.ts | 42 +-- .../sidecar/plugin-bootstrap.test.ts | 6 +- .../desktop-app/sidecar/plugin-bootstrap.ts | 5 +- .../desktop-app/sidecar/remote-helper.test.ts | 72 +++-- .../desktop-app/sidecar/remote-helper.ts | 49 ++-- .../src-tauri/nsis/installer-hooks.nsh | 15 +- .../desktop-app/src-tauri/src/main.rs | 157 ++++++---- .../desktop-app/src-tauri/tauri.conf.json | 5 +- sdk/DOC.md | 23 +- sdk/packages/core/bun.mts | 8 - sdk/packages/core/package.json | 8 - sdk/packages/core/src/hub/client/index.ts | 4 +- .../core/src/hub/daemon/index.test.ts | 46 ++- sdk/packages/core/src/hub/daemon/index.ts | 55 +++- .../core/src/hub/discovery/index.test.ts | 38 ++- sdk/packages/core/src/hub/discovery/index.ts | 29 +- .../src/remote/remote-environments.test.ts | 73 +++-- .../core/src/remote/remote-environments.ts | 106 +++++-- .../core/src/remote/remote-helper-entry.ts | 12 - .../core/src/remote/remote-helper.test.ts | 196 ------------- sdk/packages/core/src/remote/remote-helper.ts | 138 --------- sdk/packages/core/tsconfig.build.json | 2 - sdk/packages/shared/src/index.browser.ts | 5 + sdk/packages/shared/src/index.ts | 5 + .../src/services}/telemetry-selfcheck.test.ts | 0 .../src/services}/telemetry-selfcheck.ts | 2 +- 52 files changed, 1915 insertions(+), 1145 deletions(-) create mode 100644 apps/cli/script/compile-binary.ts create mode 100644 apps/examples/desktop-app/scripts/build-desktop-runtime.ts delete mode 100644 apps/examples/desktop-app/scripts/build-sidecar-bin.ts create mode 100644 apps/examples/desktop-app/sidecar/cli-runtime.test.ts create mode 100644 apps/examples/desktop-app/sidecar/cli-runtime.ts delete mode 100644 sdk/packages/core/src/remote/remote-helper-entry.ts delete mode 100644 sdk/packages/core/src/remote/remote-helper.test.ts delete mode 100644 sdk/packages/core/src/remote/remote-helper.ts rename {apps/examples/desktop-app/sidecar => sdk/packages/shared/src/services}/telemetry-selfcheck.test.ts (100%) rename {apps/examples/desktop-app/sidecar => sdk/packages/shared/src/services}/telemetry-selfcheck.ts (94%) diff --git a/.cline/skills/publish-desktop/SKILL.md b/.cline/skills/publish-desktop/SKILL.md index 5497f46e3a..5df8ab5c98 100644 --- a/.cline/skills/publish-desktop/SKILL.md +++ b/.cline/skills/publish-desktop/SKILL.md @@ -9,7 +9,7 @@ Use this skill when the user asks to release the desktop app, publish the Cline > Working directory: run every command below from the repository root. -Desktop releases ship three platforms, built entirely in GitHub Actions — there is no local publish path. macOS: a single signed + notarized universal DMG that runs natively on both Apple Silicon and Intel. Linux: x64 `.deb` and `.rpm` packages (`__amd64.deb`, `__x86_64.rpm`) built on Ubuntu 22.04 in the `build-linux` job, deb + rpm only (no AppImage: linuxdeploy cannot process the Bun-compiled sidecar). Windows: an Authenticode-signed NSIS installer (`__x64-setup.exe`), signed via Azure Trusted Signing in the `build-windows` job (jsign through Tauri's `signCommand`, see `apps/examples/desktop-app/scripts/tauri-sign-windows.ps1`; requires the repo-level `AZURE_*` secrets including `AZURE_TRUSTED_SIGNING_CERTIFICATE_PROFILE_DESKTOP`, plus a `PublishDesktop`-environment federated credential on the `cline-cli-signing` Entra app). Installed apps discover new releases automatically through the Tauri updater, so publishing a release is what ships the update to every existing user **on that channel**. +Desktop releases ship three platforms, built entirely in GitHub Actions — there is no local publish path. macOS: a single signed + notarized universal DMG that runs natively on both Apple Silicon and Intel. Linux: x64 `.deb` and `.rpm` packages (`__amd64.deb`, `__x86_64.rpm`) built on Ubuntu 22.04 in the `build-linux` job, deb + rpm only (no AppImage: linuxdeploy cannot process the Bun-compiled Cline CLI the app bundles). Windows: an Authenticode-signed NSIS installer (`__x64-setup.exe`), signed via Azure Trusted Signing in the `build-windows` job (jsign through Tauri's `signCommand`, see `apps/examples/desktop-app/scripts/tauri-sign-windows.ps1`; requires the repo-level `AZURE_*` secrets including `AZURE_TRUSTED_SIGNING_CERTIFICATE_PROFILE_DESKTOP`, plus a `PublishDesktop`-environment federated credential on the `cline-cli-signing` Entra app). Installed apps discover new releases automatically through the Tauri updater, so publishing a release is what ships the update to every existing user **on that channel**. ## Release contract @@ -50,12 +50,12 @@ For a **beta** release, work on `desktop-experimental` (check out `origin/deskto ```sh # stable (on main): -git log ..HEAD --oneline --no-merges -- apps/examples/desktop-app sdk/packages .github/workflows/desktop-publish.yml +git log ..HEAD --oneline --no-merges -- apps/examples/desktop-app apps/cli sdk/packages .github/workflows/desktop-publish.yml # beta (on desktop-experimental): -git log ..origin/desktop-experimental --oneline --no-merges -- apps/examples/desktop-app sdk/packages .github/workflows/desktop-publish.yml +git log ..origin/desktop-experimental --oneline --no-merges -- apps/examples/desktop-app apps/cli sdk/packages .github/workflows/desktop-publish.yml ``` -The sidecar bundles `@cline/core` and friends from the monorepo, so SDK changes ship inside the desktop app too. Fold user-visible SDK changes (providers, models, behavior fixes) into the notes; skip purely internal ones. +The desktop backend and the bundled Cline CLI (which runs the Hub) are built from the monorepo's `@cline/core` and friends, so SDK and CLI changes ship inside the desktop app too. Fold user-visible SDK changes (providers, models, behavior fixes) into the notes; skip purely internal ones. 3. Draft user-facing release notes. @@ -129,7 +129,7 @@ gh api repos/cline/cline/actions/runs//pending_deployments \ Nothing after `validate` runs — and no signing key is readable — until then. -The workflow builds one universal macOS bundle (`tauri build --target universal-apple-darwin` lipos the aarch64 + x86_64 Rust binaries; the Bun sidecar is lipo'd by `build-sidecar-bin.ts`; beta adds the `tauri.beta.conf.json` overlay), verifies every Mach-O in the bundle carries both slices and that the compiled binary embeds exactly its own channel's feed URL, signs with the Developer ID certificate, notarizes with the App Store Connect API key, and signs the updater artifact with the Tauri updater key. In parallel, `build-windows` builds the x64 NSIS installer on a Windows runner, Authenticode-signs every binary via Azure Trusted Signing (Tauri `signCommand` -> `scripts/tauri-sign-windows.ps1`), runs the same feed-endpoint and telemetry guardrails, and verifies the shipped installer with `Get-AuthenticodeSignature`. The release job then creates the GitHub release (prerelease for beta), refreshes the channel's feed (`desktop-latest/latest.json` or `desktop-beta/latest.json`), and posts to Slack. Notarization typically adds 2–10 minutes. +The workflow builds one universal macOS bundle (`tauri build --target universal-apple-darwin` lipos the aarch64 + x86_64 Rust binaries; the bundled Cline CLI is lipo'd by `build-desktop-runtime.ts`; beta adds the `tauri.beta.conf.json` overlay), verifies every Mach-O in the bundle carries both slices and that the compiled binary embeds exactly its own channel's feed URL, signs with the Developer ID certificate, notarizes with the App Store Connect API key, and signs the updater artifact with the Tauri updater key. In parallel, `build-windows` builds the x64 NSIS installer on a Windows runner, Authenticode-signs every binary via Azure Trusted Signing (Tauri `signCommand` -> `scripts/tauri-sign-windows.ps1`), runs the same feed-endpoint and telemetry guardrails, and verifies the shipped installer with `Get-AuthenticodeSignature`. The release job then creates the GitHub release (prerelease for beta), refreshes the channel's feed (`desktop-latest/latest.json` or `desktop-beta/latest.json`), and posts to Slack. Notarization typically adds 2–10 minutes. If the workflow fails on missing credentials, see "Publish secrets (one-time setup)" below. diff --git a/.github/workflows/desktop-publish.yml b/.github/workflows/desktop-publish.yml index 028f4d6467..c0bfb990cb 100644 --- a/.github/workflows/desktop-publish.yml +++ b/.github/workflows/desktop-publish.yml @@ -335,12 +335,12 @@ jobs: run: bunx tauri build --target universal-apple-darwin $CONFIG_ARGS env: CONFIG_ARGS: ${{ needs.validate.outputs.channel == 'nightly' && '--config src-tauri/tauri.release.conf.json --config src-tauri/tauri.nightly.conf.json' || needs.validate.outputs.channel == 'beta' && '--config src-tauri/tauri.release.conf.json --config src-tauri/tauri.beta.conf.json' || '--config src-tauri/tauri.release.conf.json' }} - # Telemetry config for the sidecar binary. Tauri's beforeBuildCommand - # (`bun run build` -> build:sidecar:bin) compiles the sidecar during - # this step and inlines these values into the binary via `--define` - # (scripts/telemetry-define-args.ts); a packaged app launched from - # Finder/the Dock has no runtime env, so build-time inlining is the - # only way the shipped sidecar can ever report telemetry. + # Telemetry config for the desktop backend and bundled Cline CLI. + # Tauri's beforeBuildCommand (`bun run build` -> build:runtime) + # builds both during this step and inlines these values via + # `--define` (scripts/telemetry-define-args.ts); a packaged app + # launched from Finder/the Dock has no runtime env, so build-time + # inlining is the only way the shipped backend can report telemetry. TELEMETRY_SERVICE_API_KEY: ${{ secrets.TELEMETRY_SERVICE_API_KEY }} ERROR_SERVICE_API_KEY: ${{ secrets.ERROR_SERVICE_API_KEY }} OTEL_TELEMETRY_ENABLED: ${{ secrets.OTEL_TELEMETRY_ENABLED }} @@ -368,10 +368,11 @@ jobs: # bare DMG. macOS runners have a GUI session, so opt out of the skip. TAURI_BUNDLER_DMG_IGNORE_CI: "true" - # Tauri lipos the main binary itself but sidecars are merged by our own - # build-sidecar-bin.ts, so assert every Mach-O in the bundle really - # carries both slices before anything is published. A single-arch - # sidecar would otherwise ship fine and only crash on the other arch. + # Tauri lipos the main binary itself but the bundled Cline CLI is merged + # by our own build-desktop-runtime.ts, so assert every Mach-O in the + # bundle really carries both slices before anything is published. A + # single-arch CLI would otherwise ship fine and only crash on the other + # arch. - name: Verify bundle is a universal binary working-directory: apps/examples/desktop-app env: @@ -450,49 +451,57 @@ jobs: echo "Updater endpoint verified: ${WANT:-disabled}" # Guardrail: assert the telemetry config actually made it into the - # compiled sidecar. Missing env on the build step (or a regression in + # bundled desktop backend. Missing env on the build step (or a regression in # the --define inlining) would otherwise ship a release with telemetry # silently disabled — exactly what happened for every release before # this check existed. Being enabled is not enough on its own: an empty, # malformed, or non-http(s) OTLP endpoint would still drop every event # at runtime (the SDK exporters speak OTLP http/json only), so the # selfcheck must also report a usable endpoint host. - - name: Verify sidecar telemetry config was inlined + - name: Verify CLI and desktop backend telemetry configs were inlined working-directory: apps/examples/desktop-app run: | - SELFCHECK=$(./src-tauri/bin/code-sidecar-universal-apple-darwin --telemetry-selfcheck) - echo "$SELFCHECK" - if ! printf '%s' "$SELFCHECK" | grep -q '"enabled":true'; then - echo "Packaged sidecar reports telemetry disabled." - echo "Check the OTEL_* / TELEMETRY_SERVICE_API_KEY env on the" - echo "'Build, sign, and notarize desktop bundle' step and the" - echo "--define inlining in scripts/build-sidecar-bin.ts." - exit 1 - fi - if printf '%s' "$SELFCHECK" | grep -Eq '"otlp_endpoint_host":"(invalid-endpoint-url)?"'; then - echo "Packaged sidecar reports telemetry enabled but its OTLP" - echo "endpoint is missing, unparseable, or not an http(s) URL, so" - echo "every event would be dropped at runtime. Check the" - echo "OTEL_EXPORTER_OTLP_ENDPOINT secret." - exit 1 - fi + for COMPONENT in cli backend; do + if [ "$COMPONENT" = cli ]; then + SELFCHECK=$(./src-tauri/bin/cline-cli-universal-apple-darwin --telemetry-selfcheck) + else + SELFCHECK=$(BUN_BE_BUN=1 ./src-tauri/bin/cline-cli-universal-apple-darwin run --no-env-file src-tauri/bin/desktop-backend/index.js --telemetry-selfcheck) + fi + echo "$SELFCHECK" + if ! printf '%s' "$SELFCHECK" | grep -q '"enabled":true'; then + echo "Packaged $COMPONENT reports telemetry disabled." + echo "Check the OTEL_* / TELEMETRY_SERVICE_API_KEY env on the" + echo "'Build, sign, and notarize desktop bundle' step and the" + echo "--define inlining in scripts/build-desktop-runtime.ts." + exit 1 + fi + if printf '%s' "$SELFCHECK" | grep -Eq '"otlp_endpoint_host":"(invalid-endpoint-url)?"'; then + echo "Packaged $COMPONENT reports telemetry enabled but its OTLP" + echo "endpoint is missing, unparseable, or not an http(s) URL, so" + echo "every event would be dropped at runtime. Check the" + echo "OTEL_EXPORTER_OTLP_ENDPOINT secret." + exit 1 + fi + done - # Boot the sidecar that will actually ship — built with the production - # --target, --minify and telemetry defines, on the OS it will run on — - # against an isolated Hub, and require its ready line plus a healthy - # /health. Desktop 0.0.31 shipped a sidecar whose Hub daemon loaded a + # Boot the Cline CLI and desktop backend that will actually ship — built + # with the production --target, --minify and telemetry defines, on the + # OS they will run on — against an isolated Hub started by + # `cline hub ensure`, and require the backend's ready line plus a + # healthy /health. Desktop 0.0.31 shipped a backend whose Hub daemon loaded a # second copy of @cline/core with a different build id, so the app # retired its own Hub on every launch and never got past "backend # unavailable". PR CI runs this same test on ubuntu against a plain - # `bun build --compile`, which catches that resolution class but not a + # build from source, which catches that resolution class but not a # break specific to the shipped flags or platform; this is the # release-time half. The test isolates CLINE_DIR and the Hub discovery # path, so it cannot reach anything else on the runner. Telemetry is - # inlined into the binary, so expect one activation event per publish. - - name: Smoke-test the packaged sidecar against an isolated Hub + # inlined into the backend, so expect one activation event per publish. + - name: Smoke-test the packaged desktop runtime against an isolated Hub working-directory: apps/examples/desktop-app env: - CLINE_TEST_SIDECAR_BIN: src-tauri/bin/code-sidecar-universal-apple-darwin + CLINE_TEST_DESKTOP_CLI_BIN: src-tauri/bin/cline-cli-universal-apple-darwin + CLINE_TEST_DESKTOP_BACKEND_BUNDLE: src-tauri/bin/desktop-backend/index.js run: bun test scripts/desktop-startup.test.ts - name: Collect artifacts @@ -615,7 +624,7 @@ jobs: with: bun-version: "1.4.2" - # build-sidecar-bin.ts cross-compiles the Linux SSH remote helpers on + # build-desktop-runtime.ts cross-compiles the Linux SSH remote CLIs on # every platform, and the Windows installer ships them (they are Tauri # resources, bundle.resources in tauri.conf.json). Bun cross-compiles by # downloading the target's release zip and extracting the executable @@ -627,13 +636,13 @@ jobs: # with the same runtimes from npm (@oven/bun-, byte-identical to # the zip payload) and the build never enters the broken path. The macOS # job does not need this; the extractor works there. - - name: Seed Bun cache with Linux remote-helper runtimes + - name: Seed Bun cache with Linux remote CLI runtimes shell: bash run: | BUN_VERSION="$(bun --version)" CACHE_DIR="${RUNNER_TEMP//\\//}/bun-install-cache" mkdir -p "$CACHE_DIR" - # Keep these runtimes aligned with build-sidecar-bin.ts, including + # Keep these runtimes aligned with build-desktop-runtime.ts, including # the baseline x64 runtime needed by SSH hosts without AVX2. for target in linux-x64-baseline linux-aarch64; do dest="$CACHE_DIR/bun-$target-v$BUN_VERSION" @@ -697,7 +706,7 @@ jobs: - name: Prime Azure Trusted Signing token run: az account get-access-token --resource https://codesigning.azure.net --query expiresOn --output tsv - # Tauri invokes signCommand once per staged binary (main exe, sidecar, + # Tauri invokes signCommand once per staged binary (main exe, Cline CLI, # NSIS uninstaller, and the installer itself). The overlay is generated # here rather than committed because signCommand needs an absolute path # to the signing script on this runner. @@ -728,7 +737,7 @@ jobs: run: bunx tauri build --bundles nsis $CONFIG_ARGS --config "$SIGN_CONF" env: CONFIG_ARGS: ${{ needs.validate.outputs.channel == 'nightly' && '--config src-tauri/tauri.release.conf.json --config src-tauri/tauri.nightly.conf.json' || needs.validate.outputs.channel == 'beta' && '--config src-tauri/tauri.release.conf.json --config src-tauri/tauri.beta.conf.json' || '--config src-tauri/tauri.release.conf.json' }} - # Telemetry inlined into the sidecar at compile time, same as macOS. + # Telemetry inlined into the desktop backend at build time, same as macOS. TELEMETRY_SERVICE_API_KEY: ${{ secrets.TELEMETRY_SERVICE_API_KEY }} ERROR_SERVICE_API_KEY: ${{ secrets.ERROR_SERVICE_API_KEY }} OTEL_TELEMETRY_ENABLED: ${{ secrets.OTEL_TELEMETRY_ENABLED }} @@ -794,33 +803,40 @@ jobs: fi echo "Updater endpoint verified: ${WANT:-disabled}" - # Same guardrail as the macOS job, run natively on the Windows sidecar. - - name: Verify sidecar telemetry config was inlined + # Same guardrail as the macOS job, run natively on the Windows build. + - name: Verify CLI and desktop backend telemetry configs were inlined shell: bash working-directory: apps/examples/desktop-app run: | - SELFCHECK=$(./src-tauri/bin/code-sidecar-x86_64-pc-windows-msvc.exe --telemetry-selfcheck) - echo "$SELFCHECK" - if ! printf '%s' "$SELFCHECK" | grep -q '"enabled":true'; then - echo "Packaged sidecar reports telemetry disabled." - echo "Check the OTEL_* / TELEMETRY_SERVICE_API_KEY env on the" - echo "'Build and sign desktop bundle' step and the --define" - echo "inlining in scripts/build-sidecar-bin.ts." - exit 1 - fi - if printf '%s' "$SELFCHECK" | grep -Eq '"otlp_endpoint_host":"(invalid-endpoint-url)?"'; then - echo "Packaged sidecar reports telemetry enabled but its OTLP" - echo "endpoint is missing, unparseable, or not an http(s) URL." - echo "Check the OTEL_EXPORTER_OTLP_ENDPOINT secret." - exit 1 - fi + for COMPONENT in cli backend; do + if [ "$COMPONENT" = cli ]; then + SELFCHECK=$(./src-tauri/bin/cline-cli-x86_64-pc-windows-msvc.exe --telemetry-selfcheck) + else + SELFCHECK=$(BUN_BE_BUN=1 ./src-tauri/bin/cline-cli-x86_64-pc-windows-msvc.exe run --no-env-file src-tauri/bin/desktop-backend/index.js --telemetry-selfcheck) + fi + echo "$SELFCHECK" + if ! printf '%s' "$SELFCHECK" | grep -q '"enabled":true'; then + echo "Packaged $COMPONENT reports telemetry disabled." + echo "Check the OTEL_* / TELEMETRY_SERVICE_API_KEY env on the" + echo "'Build and sign desktop bundle' step and the --define" + echo "inlining in scripts/build-desktop-runtime.ts." + exit 1 + fi + if printf '%s' "$SELFCHECK" | grep -Eq '"otlp_endpoint_host":"(invalid-endpoint-url)?"'; then + echo "Packaged $COMPONENT reports telemetry enabled but its OTLP" + echo "endpoint is missing, unparseable, or not an http(s) URL." + echo "Check the OTEL_EXPORTER_OTLP_ENDPOINT secret." + exit 1 + fi + done # Same release-time gate as the macOS job; see the comment there. - - name: Smoke-test the packaged sidecar against an isolated Hub + - name: Smoke-test the packaged desktop runtime against an isolated Hub shell: bash working-directory: apps/examples/desktop-app env: - CLINE_TEST_SIDECAR_BIN: src-tauri/bin/code-sidecar-x86_64-pc-windows-msvc.exe + CLINE_TEST_DESKTOP_CLI_BIN: src-tauri/bin/cline-cli-x86_64-pc-windows-msvc.exe + CLINE_TEST_DESKTOP_BACKEND_BUNDLE: src-tauri/bin/desktop-backend/index.js run: bun test scripts/desktop-startup.test.ts - name: Collect artifacts @@ -860,11 +876,11 @@ jobs: shell: pwsh working-directory: apps/examples/desktop-app run: | - # The Tauri bundler signs the sidecar in place, so check it here too; - # a WDAC-locked machine blocks the app at runtime if the sidecar it - # spawns is unsigned, even when the installer itself is fine. - $files = @(Get-ChildItem dist/publish/*.exe) + @(Get-Item src-tauri/bin/code-sidecar-x86_64-pc-windows-msvc.exe) - if ($files.Count -lt 2) { throw "expected at least the installer and the sidecar to verify" } + # The Tauri bundler signs the bundled Cline CLI in place, so check it + # here too; a WDAC-locked machine blocks the app at runtime if the + # CLI it spawns is unsigned, even when the installer itself is fine. + $files = @(Get-ChildItem dist/publish/*.exe) + @(Get-Item src-tauri/bin/cline-cli-x86_64-pc-windows-msvc.exe) + if ($files.Count -lt 2) { throw "expected at least the installer and the Cline CLI to verify" } foreach ($file in $files) { $sig = Get-AuthenticodeSignature $file.FullName if ($sig.Status -ne "Valid") { @@ -900,7 +916,7 @@ jobs: rpm_arch: x86_64 # Native arm64 runner rather than cross-compiling: WebKitGTK and the # other GTK build deps have no usable cross toolchain in apt, and a - # native build lets the sidecar smoke test below run the real binary. + # native build lets the runtime smoke test below run the real binary. - arch: arm64 runner: ubuntu-22.04-arm target: aarch64-unknown-linux-gnu @@ -999,13 +1015,13 @@ jobs: - name: Build desktop bundles working-directory: apps/examples/desktop-app # deb and rpm only. The AppImage target is excluded because linuxdeploy - # cannot process the Bun-compiled sidecar (ldd fails on it and patchelf + # cannot process the Bun-compiled Cline CLI (ldd fails on it and patchelf # corrupts it), which aborts the bundle step. $CONFIG_ARGS is # deliberately unquoted: it must word-split into separate flags. run: bunx tauri build --bundles deb,rpm $CONFIG_ARGS env: CONFIG_ARGS: ${{ needs.validate.outputs.channel == 'nightly' && '--config src-tauri/tauri.release.conf.json --config src-tauri/tauri.nightly.conf.json' || needs.validate.outputs.channel == 'beta' && '--config src-tauri/tauri.release.conf.json --config src-tauri/tauri.beta.conf.json' || '--config src-tauri/tauri.release.conf.json' }} - # Telemetry inlined into the sidecar at compile time, same as macOS. + # Telemetry inlined into the desktop backend at build time, same as macOS. TELEMETRY_SERVICE_API_KEY: ${{ secrets.TELEMETRY_SERVICE_API_KEY }} ERROR_SERVICE_API_KEY: ${{ secrets.ERROR_SERVICE_API_KEY }} OTEL_TELEMETRY_ENABLED: ${{ secrets.OTEL_TELEMETRY_ENABLED }} @@ -1057,59 +1073,67 @@ jobs: fi echo "Updater endpoint verified: ${WANT:-disabled}" - # Same guardrail as the other build jobs, run on the Linux sidecar. - - name: Verify sidecar telemetry config was inlined + # Same guardrail as the other build jobs, run on the Linux build. + - name: Verify CLI and desktop backend telemetry configs were inlined working-directory: apps/examples/desktop-app env: TARGET: ${{ matrix.target }} run: | - SELFCHECK=$(./src-tauri/bin/code-sidecar-${TARGET} --telemetry-selfcheck) - echo "$SELFCHECK" - if ! printf '%s' "$SELFCHECK" | grep -q '"enabled":true'; then - echo "Packaged sidecar reports telemetry disabled." - echo "Check the OTEL_* / TELEMETRY_SERVICE_API_KEY env on the" - echo "'Build desktop bundles' step and the --define inlining in" - echo "scripts/build-sidecar-bin.ts." - exit 1 - fi - if printf '%s' "$SELFCHECK" | grep -Eq '"otlp_endpoint_host":"(invalid-endpoint-url)?"'; then - echo "Packaged sidecar reports telemetry enabled but its OTLP" - echo "endpoint is missing, unparseable, or not an http(s) URL." - echo "Check the OTEL_EXPORTER_OTLP_ENDPOINT secret." - exit 1 - fi + for COMPONENT in cli backend; do + if [ "$COMPONENT" = cli ]; then + SELFCHECK=$(./src-tauri/bin/cline-cli-${TARGET} --telemetry-selfcheck) + else + SELFCHECK=$(BUN_BE_BUN=1 ./src-tauri/bin/cline-cli-${TARGET} run --no-env-file src-tauri/bin/desktop-backend/index.js --telemetry-selfcheck) + fi + echo "$SELFCHECK" + if ! printf '%s' "$SELFCHECK" | grep -q '"enabled":true'; then + echo "Packaged $COMPONENT reports telemetry disabled." + echo "Check the OTEL_* / TELEMETRY_SERVICE_API_KEY env on the" + echo "'Build desktop bundles' step and the --define inlining in" + echo "scripts/build-desktop-runtime.ts." + exit 1 + fi + if printf '%s' "$SELFCHECK" | grep -Eq '"otlp_endpoint_host":"(invalid-endpoint-url)?"'; then + echo "Packaged $COMPONENT reports telemetry enabled but its OTLP" + echo "endpoint is missing, unparseable, or not an http(s) URL." + echo "Check the OTEL_EXPORTER_OTLP_ENDPOINT secret." + exit 1 + fi + done # Same release-time gate as the macOS job; see the comment there. - - name: Smoke-test the packaged sidecar against an isolated Hub + - name: Smoke-test the packaged desktop runtime against an isolated Hub working-directory: apps/examples/desktop-app env: - CLINE_TEST_SIDECAR_BIN: src-tauri/bin/code-sidecar-${{ matrix.target }} + CLINE_TEST_DESKTOP_CLI_BIN: src-tauri/bin/cline-cli-${{ matrix.target }} + CLINE_TEST_DESKTOP_BACKEND_BUNDLE: src-tauri/bin/desktop-backend/index.js run: bun test scripts/desktop-startup.test.ts - # Run the SSH remote helper this job bundles (same arch as the runner) - # through the exact command the desktop sends over SSH, then stop the - # Hub it started. Desktop 0.0.38 through 0.0.44 shipped helpers that - # printed --help fine but crashed on every --remote-hub-ensure, because - # UPX packing broke the Bun 1.4 runtime's lazy reads of its own payload + # Run the Linux Cline CLI this job bundles for SSH remotes (same arch as + # the runner) through the exact `hub ensure` / `hub stop` the desktop + # sends over SSH. Desktop 0.0.38 through 0.0.44 shipped remote binaries + # that printed --help fine but crashed on every ensure, because UPX + # packing broke the Bun 1.4 runtime's lazy reads of its own payload # (#14815, #14781); nothing exercised that path before release. Isolated - # CLINE_DIR and discovery record, as in the sidecar test above. Only the + # CLINE_DIR and discovery record, as in the runtime test above. Only the # exit status is checked; the ensure output carries the Hub auth token. - - name: Smoke-test the bundled SSH remote helper + - name: Smoke-test the bundled SSH remote CLI working-directory: apps/examples/desktop-app env: - HELPER: src-tauri/bin/remote-helpers/cline-remote-helper-${{ matrix.target }} + REMOTE_CLI: src-tauri/bin/remote-helpers/cline-${{ matrix.target }} run: | export CLINE_DIR="$(mktemp -d)" export CLINE_DATA_DIR="$CLINE_DIR/data" + export CLINE_NO_AUTO_UPDATE=1 DISCOVERY="$CLINE_DIR/remote-hub.json" - trap 'timeout 30 "$HELPER" --remote-hub-stop --discovery-path "$DISCOVERY" >/dev/null 2>&1 || true' EXIT - if ! timeout 60 "$HELPER" --remote-hub-ensure --cwd "$CLINE_DIR" --discovery-path "$DISCOVERY" >/dev/null; then - echo "Bundled remote helper failed --remote-hub-ensure; SSH remotes" - echo "would not start from this build. Check scripts/build-sidecar-bin.ts." + trap 'timeout 30 "$REMOTE_CLI" hub stop --discovery-path "$DISCOVERY" >/dev/null 2>&1 || true' EXIT + if ! timeout 60 "$REMOTE_CLI" hub ensure --json --allow-port-fallback --no-connectors --cwd "$CLINE_DIR" --host 127.0.0.1 --port 0 --pathname /hub --discovery-path "$DISCOVERY" >/dev/null; then + echo "Bundled remote CLI failed hub ensure; SSH remotes would not" + echo "start from this build. Check scripts/build-desktop-runtime.ts." exit 1 fi - timeout 30 "$HELPER" --remote-hub-stop --discovery-path "$DISCOVERY" - echo "Remote helper started and stopped an isolated Hub." + timeout 30 "$REMOTE_CLI" hub stop --discovery-path "$DISCOVERY" + echo "Remote CLI started and stopped an isolated Hub." - name: Collect artifacts working-directory: apps/examples/desktop-app @@ -1373,7 +1397,7 @@ jobs: # Nightlies have no changelog, so the announcement lists the commits # since the nearest desktop tag - the previous nightly or the last # release, whichever is closer - scoped to what ships in the app - # (the sidecar bundles sdk/packages). Runs before this build is + # (the app bundles sdk/packages and the Cline CLI). Runs before this build is # tagged below so it never compares against itself. STAMP=${VERSION#*-nightly.} TAG="desktop-nightly-${STAMP%%.*}" @@ -1385,7 +1409,7 @@ jobs: if [ -z "$PREV_TAG" ]; then BODY="No previous nightly or desktop release tag found to compare against." else - COMMITS=$(git log "${PREV_TAG}..HEAD" --no-merges --format='%s' -- apps/examples/desktop-app sdk/packages .github/workflows/desktop-publish.yml) + COMMITS=$(git log "${PREV_TAG}..HEAD" --no-merges --format='%s' -- apps/examples/desktop-app apps/cli sdk/packages .github/workflows/desktop-publish.yml) TOTAL=$(printf "%s" "$COMMITS" | grep -c . || true) COMPARE="https://github.com/${GITHUB_REPOSITORY}/compare/${PREV_TAG}...$(git rev-parse --short HEAD)" if [ "$TOTAL" -eq 0 ]; then diff --git a/.github/workflows/desktop-test.yml b/.github/workflows/desktop-test.yml index 585571a45f..788c07ff63 100644 --- a/.github/workflows/desktop-test.yml +++ b/.github/workflows/desktop-test.yml @@ -58,7 +58,7 @@ jobs: run: bun run test:dmg-background windows-installer: - name: Test x86 installer against x64 sidecars + name: Test x86 installer against x64 app executables runs-on: windows-latest timeout-minutes: 10 defaults: @@ -78,7 +78,7 @@ jobs: no-cache: true # Use the same NSIS distribution as the locked Tauri CLI. No workspace - # install or full desktop build: the test compiles a disposable sidecar. + # install or full desktop build: the test compiles a disposable fixture executable. # The test asserts makensis reports NSIS_VERSION and that bun.lock still # resolves TAURI_CLI_VERSION, so changing this download or bumping the # CLI without updating windows-installer.test.ts fails the job. diff --git a/apps/cli/script/build.ts b/apps/cli/script/build.ts index 306aec5c65..cf25cf511c 100644 --- a/apps/cli/script/build.ts +++ b/apps/cli/script/build.ts @@ -7,51 +7,27 @@ import { mkdtempSync, readdirSync, readFileSync, - realpathSync, rmSync, statSync, } from "node:fs"; import { type AddressInfo, createServer } from "node:net"; import { tmpdir } from "node:os"; -import { join, relative, resolve } from "node:path"; +import { join, resolve } from "node:path"; import { $ } from "bun"; import { parseBuildOptions, shouldInstallNativeVariants, validateBuildOptions, } from "./build-options"; +import { + compileCliBinary, + installOpenTuiNativeVariants, +} from "./compile-binary"; const cliDir = resolve(import.meta.dir, ".."); const rootDir = resolve(cliDir, "../.."); process.chdir(cliDir); -// Telemetry / OTEL environment variables that should be baked into the -// compiled binary at build time. Mirrors the list of secrets injected by the -// `cli-publish` GitHub Actions workflow. These are inlined via Bun's `define` -// so the CLI ships with the production telemetry configuration without -// requiring the end user to set any env vars. -const BUILD_TIME_INLINED_ENV_VARS = [ - "TELEMETRY_SERVICE_API_KEY", - "ERROR_SERVICE_API_KEY", - "OTEL_TELEMETRY_ENABLED", - "OTEL_LOGS_EXPORTER", - "OTEL_METRICS_EXPORTER", - "OTEL_TRACES_EXPORTER", - "CLINE_TRACE_SAMPLE_PERCENT", - "CLINE_TRACE_RECORD_CONTENT", - "OTEL_EXPORTER_OTLP_PROTOCOL", - "OTEL_EXPORTER_OTLP_ENDPOINT", - "OTEL_EXPORTER_OTLP_HEADERS", -] as const; - -function buildInlinedEnvDefines(): Record { - const defines: Record = {}; - for (const name of BUILD_TIME_INLINED_ENV_VARS) { - defines[`process.env.${name}`] = JSON.stringify(process.env[name] ?? ""); - } - return defines; -} - const pkg = JSON.parse(readFileSync(join(cliDir, "package.json"), "utf-8")); const version: string = pkg.version; const repository: unknown = pkg.repository; @@ -95,10 +71,7 @@ await $`rm -rf dist`; // can resolve them. Without this, Bun only has the host platform's native // binary and cross-compiled builds fail to resolve @opentui/core's FFI layer. if (shouldInstallNativeVariants({ options: buildOptions, opentuiVersion })) { - console.log( - `Installing all platform variants of @opentui/core@${opentuiVersion}...`, - ); - await $`bun install --os="*" --cpu="*" @opentui/core@${opentuiVersion}`; + await installOpenTuiNativeVariants(); } // Build the SDK first (the CLI bundles workspace packages) @@ -154,19 +127,6 @@ if (shouldBuildHubWebview()) { const binaries: Record = {}; -function findOpenTuiParserWorker(): string { - const localPath = resolve( - cliDir, - "node_modules/@opentui/core/parser.worker.js", - ); - const rootPath = resolve( - rootDir, - "node_modules/@opentui/core/parser.worker.js", - ); - const parserWorkerPath = existsSync(localPath) ? localPath : rootPath; - return realpathSync(parserWorkerPath); -} - function findFreePort(): Promise { return new Promise((resolvePort, reject) => { const server = createServer(); @@ -185,66 +145,6 @@ function getBunTarget( return `bun-${targetOs}-${item.arch}` as Bun.Build.CompileTarget; } -async function buildCompiledBinary(input: { - bunTarget: Bun.Build.CompileTarget; - dirName: string; - outfile: string; -}): Promise { - const parserWorker = findOpenTuiParserWorker(); - const targetOs = input.bunTarget.includes("windows") ? "windows" : "posix"; - const bunfsRoot = targetOs === "windows" ? "B:/~BUN/root/" : "/$bunfs/root/"; - const parserWorkerPath = relative(rootDir, parserWorker).replaceAll( - "\\", - "/", - ); - - // Build to /tmp first so Bun's temp-file rename stays on one filesystem - // layer in containerized environments (virtiofs, overlayfs). - const entrypoint = join(cliDir, "src/index.ts"); - const tmpDir = join("/tmp", `cline-build-${input.dirName}`); - const tmpOutfile = join( - tmpDir, - input.outfile.endsWith(".exe") ? "cline.exe" : "cline", - ); - mkdirSync(tmpDir, { recursive: true }); - - process.chdir("/tmp"); - const result = await Bun.build({ - entrypoints: [entrypoint, parserWorker], - splitting: true, - compile: { - target: input.bunTarget, - outfile: tmpOutfile, - // Plugins import the SDK from the wrapper's node_modules at runtime. - // Without package.json loading Bun cannot resolve bare imports from - // files on disk, so jiti falls back to transpiling the whole SDK - // graph, which can blow the plugin load timeout on a cold cache. - autoloadPackageJson: true, - }, - minify: true, - external: ["@anthropic-ai/vertex-sdk"], - define: { - OTUI_TREE_SITTER_WORKER_PATH: bunfsRoot + parserWorkerPath, - // Inline telemetry/OTEL env vars at build time so the compiled - // binary ships with production telemetry configuration baked in. - ...buildInlinedEnvDefines(), - }, - throw: false, - }); - process.chdir(cliDir); - - if (!result.success) { - console.error(`Build failed for ${input.dirName}:`); - for (const log of result.logs) { - console.error(log); - } - process.exit(1); - } - - await $`cp ${tmpOutfile} ${input.outfile} && chmod 755 ${input.outfile}`; - await $`rm -rf ${tmpDir}`; -} - for (const item of targets) { // npm treats "win32" specially in os field, but for package naming use "windows" const displayOs = item.os === "win32" ? "windows" : item.os; @@ -259,7 +159,12 @@ for (const item of targets) { const outfile = join(outDir, binaryName); - await buildCompiledBinary({ bunTarget, dirName, outfile }); + try { + await compileCliBinary({ bunTarget, outfile }); + } catch (error) { + console.error(`Build failed for ${dirName}:`, error); + process.exit(1); + } // Smoke test: only run on current platform if (item.os === process.platform && item.arch === process.arch) { diff --git a/apps/cli/script/compile-binary.ts b/apps/cli/script/compile-binary.ts new file mode 100644 index 0000000000..ae89733df1 --- /dev/null +++ b/apps/cli/script/compile-binary.ts @@ -0,0 +1,162 @@ +import { + chmodSync, + copyFileSync, + existsSync, + mkdirSync, + readFileSync, + realpathSync, + rmSync, +} from "node:fs"; +import { tmpdir } from "node:os"; +import { dirname, join, relative, resolve } from "node:path"; +import { $ } from "bun"; + +const cliDir = resolve(import.meta.dir, ".."); +const rootDir = resolve(cliDir, "../.."); + +// Telemetry / OTEL environment variables that should be baked into the +// compiled binary at build time. Mirrors the list of secrets injected by the +// `cli-publish` GitHub Actions workflow. These are inlined via Bun's `define` +// so the CLI ships with the production telemetry configuration without +// requiring the end user to set any env vars. +const BUILD_TIME_INLINED_ENV_VARS = [ + "TELEMETRY_SERVICE_API_KEY", + "ERROR_SERVICE_API_KEY", + "OTEL_TELEMETRY_ENABLED", + "OTEL_LOGS_EXPORTER", + "OTEL_METRICS_EXPORTER", + "OTEL_TRACES_EXPORTER", + "CLINE_TRACE_SAMPLE_PERCENT", + "CLINE_TRACE_RECORD_CONTENT", + "OTEL_EXPORTER_OTLP_PROTOCOL", + "OTEL_EXPORTER_OTLP_ENDPOINT", + "OTEL_EXPORTER_OTLP_HEADERS", +] as const; + +function buildInlinedEnvDefines(): Record { + const defines: Record = {}; + for (const name of BUILD_TIME_INLINED_ENV_VARS) { + defines[`process.env.${name}`] = JSON.stringify(process.env[name] ?? ""); + } + return defines; +} + +function findOpenTuiParserWorker(): string { + const localPath = resolve( + cliDir, + "node_modules/@opentui/core/parser.worker.js", + ); + const rootPath = resolve( + rootDir, + "node_modules/@opentui/core/parser.worker.js", + ); + const parserWorkerPath = existsSync(localPath) ? localPath : rootPath; + return realpathSync(parserWorkerPath); +} + +export function readOpenTuiVersion(): string | undefined { + const pkg = JSON.parse(readFileSync(join(cliDir, "package.json"), "utf-8")); + return pkg.dependencies?.["@opentui/core"]; +} + +/** + * Bun only has the host platform's OpenTUI native package by default, and a + * cross-compiled build cannot resolve the FFI layer without the target's. + * Installs every platform variant of the pinned version. + */ +export async function installOpenTuiNativeVariants(): Promise { + const opentuiVersion = readOpenTuiVersion(); + if (!opentuiVersion) { + return; + } + console.log( + `Installing all platform variants of @opentui/core@${opentuiVersion}...`, + ); + await $`bun install --no-save --os="*" --cpu="*" @opentui/core@${opentuiVersion}`.cwd( + cliDir, + ); +} + +export interface CompileCliBinaryOptions { + bunTarget: Bun.Build.CompileTarget; + outfile: string; + /** Runtime flags baked into the executable, e.g. `--use-system-ca`. */ + execArgv?: string[]; + /** + * Read `.env` / `bunfig.toml` from the launch directory before the CLI + * starts. Bun's default; binaries launched in directories the user did not + * pick (an SSH home, a GUI app's cwd) should turn this off. + */ + autoloadLaunchDirectoryConfig?: boolean; +} + +/** Compiles the CLI entrypoint into one self-contained executable. */ +export async function compileCliBinary( + input: CompileCliBinaryOptions, +): Promise { + const parserWorker = findOpenTuiParserWorker(); + const targetOs = input.bunTarget.includes("windows") ? "windows" : "posix"; + const bunfsRoot = targetOs === "windows" ? "B:/~BUN/root/" : "/$bunfs/root/"; + const parserWorkerPath = relative(rootDir, parserWorker).replaceAll( + "\\", + "/", + ); + + // Build to /tmp first so Bun's temp-file rename stays on one filesystem + // layer in containerized environments (virtiofs, overlayfs). + const entrypoint = join(cliDir, "src/index.ts"); + const scratchRoot = process.platform === "win32" ? tmpdir() : "/tmp"; + const tmpDir = join( + scratchRoot, + `cline-build-${input.bunTarget}-${process.pid}`, + ); + const tmpOutfile = join( + tmpDir, + input.outfile.endsWith(".exe") ? "cline.exe" : "cline", + ); + mkdirSync(tmpDir, { recursive: true }); + + const previousCwd = process.cwd(); + process.chdir(scratchRoot); + let result: Awaited>; + try { + const autoload = input.autoloadLaunchDirectoryConfig !== false; + result = await Bun.build({ + entrypoints: [entrypoint, parserWorker], + splitting: true, + compile: { + target: input.bunTarget, + outfile: tmpOutfile, + // Plugins import SDK packages from disk at runtime; let Bun resolve + // their package exports without falling back to transpiling the SDK. + autoloadPackageJson: true, + ...(input.execArgv?.length ? { execArgv: input.execArgv } : {}), + ...(autoload ? {} : { autoloadDotenv: false, autoloadBunfig: false }), + }, + minify: true, + external: ["@anthropic-ai/vertex-sdk"], + define: { + OTUI_TREE_SITTER_WORKER_PATH: bunfsRoot + parserWorkerPath, + // Inline telemetry/OTEL env vars at build time so the compiled + // binary ships with production telemetry configuration baked in. + ...buildInlinedEnvDefines(), + }, + throw: false, + }); + } finally { + process.chdir(previousCwd); + } + + if (!result.success) { + for (const log of result.logs) { + console.error(log); + } + rmSync(tmpDir, { recursive: true, force: true }); + throw new Error(`CLI build failed for ${input.bunTarget}`); + } + + mkdirSync(dirname(input.outfile), { recursive: true }); + copyFileSync(tmpOutfile, input.outfile); + chmodSync(input.outfile, 0o755); + rmSync(tmpDir, { recursive: true, force: true }); +} diff --git a/apps/cli/src/commands/doctor.test.ts b/apps/cli/src/commands/doctor.test.ts index 395e537d76..1599ab93c6 100644 --- a/apps/cli/src/commands/doctor.test.ts +++ b/apps/cli/src/commands/doctor.test.ts @@ -387,7 +387,16 @@ describe("runDoctorCommand", () => { }); }); - it("doctor --fix kills stale code sidecar processes", async () => { + it.each([ + [ + "/src-tauri/bin/code-sidecar", + "60123 /Users/example/dev/apps/examples/desktop-app/src-tauri/bin/code-sidecar\n", + ], + [ + "/bin/desktop-backend/index.js", + "60123 /Applications/Cline.app/Contents/MacOS/cline-cli run --no-env-file /Applications/Cline.app/Contents/Resources/bin/desktop-backend/index.js\n", + ], + ])("doctor --fix kills stale desktop backend processes matching %s", async (pattern, pgrepOutput) => { const cwd = "/workspace"; mockReadHubDiscovery.mockResolvedValue(undefined); mockProbeHubServer.mockResolvedValue(undefined); @@ -397,13 +406,9 @@ describe("runDoctorCommand", () => { Array.isArray(args) && args[0] === "-fal" && args[1] === "--" && - args[2] === "/src-tauri/bin/code-sidecar" + args[2] === pattern ) { - return { - status: 0, - stdout: - "60123 /Users/example/dev/apps/examples/desktop-app/src-tauri/bin/code-sidecar\n", - }; + return { status: 0, stdout: pgrepOutput }; } return { status: 1, stdout: "" }; }); diff --git a/apps/cli/src/commands/doctor.ts b/apps/cli/src/commands/doctor.ts index 853b301de0..0e0aa3dc03 100644 --- a/apps/cli/src/commands/doctor.ts +++ b/apps/cli/src/commands/doctor.ts @@ -259,10 +259,13 @@ function listStaleSidecarPids(): number[] { const patterns = [ "/apps/examples/desktop-app/sidecar/index.ts", "/apps/examples/desktop-app/dist/sidecar/index.js", + // The packaged desktop backend runs on the bundled Cline CLI. + "/bin/desktop-backend/index.js", // Keep the pre-example-reorg paths so `doctor --fix` can still clean up // stale sidecars that were launched from older checkouts. "/apps/code/sidecar/index.ts", "/apps/code/dist/sidecar/index.js", + // Backend executable of earlier desktop releases. "/src-tauri/bin/code-sidecar", "/Resources/code-sidecar", " code-sidecar", diff --git a/apps/cli/src/commands/hub.test.ts b/apps/cli/src/commands/hub.test.ts index adc4a15431..23a6a607f8 100644 --- a/apps/cli/src/commands/hub.test.ts +++ b/apps/cli/src/commands/hub.test.ts @@ -3,6 +3,7 @@ import { afterEach, describe, expect, it, vi } from "vitest"; const { mockClearHubDiscovery, mockEnsureDetachedHubServer, + mockEnsureLoginShellPath, mockLocalHubHasNoActiveSessions, mockProbeHubServer, mockReadHubDiscovery, @@ -13,6 +14,7 @@ const { } = vi.hoisted(() => ({ mockClearHubDiscovery: vi.fn(), mockEnsureDetachedHubServer: vi.fn(), + mockEnsureLoginShellPath: vi.fn(async () => undefined), mockLocalHubHasNoActiveSessions: vi.fn(), mockProbeHubServer: vi.fn(), mockReadHubDiscovery: vi.fn(), @@ -31,6 +33,7 @@ const { vi.mock("@cline/core", () => ({ clearHubDiscovery: mockClearHubDiscovery, ensureDetachedHubServer: mockEnsureDetachedHubServer, + ensureLoginShellPath: mockEnsureLoginShellPath, localHubHasNoActiveSessions: mockLocalHubHasNoActiveSessions, probeHubServer: mockProbeHubServer, readHubDiscovery: mockReadHubDiscovery, @@ -48,6 +51,7 @@ const originalBuildEnv = process.env.CLINE_BUILD_ENV; describe("createHubCommand", () => { afterEach(() => { vi.clearAllMocks(); + vi.restoreAllMocks(); if (originalBuildEnv === undefined) { delete process.env.CLINE_BUILD_ENV; } else { @@ -258,6 +262,28 @@ describe("createHubCommand", () => { ); }); + it("un-drains and aborts upgrade when authenticated shutdown fails", async () => { + mockReadHubDiscovery.mockResolvedValue({ + url: "ws://127.0.0.1:25463/hub", + authToken: "token", + }); + mockRequestHubDrain.mockResolvedValue(true); + mockLocalHubHasNoActiveSessions.mockResolvedValue(true); + mockStopLocalHubServerGracefully.mockResolvedValue(false); + const { cmd, errors, exitCode } = createCommand(); + await cmd.parseAsync(["upgrade", "--wait", "0"], { from: "user" }); + expect(exitCode()).toBe(1); + expect(errors).toEqual(["Hub shutdown failed; upgrade aborted."]); + expect(mockClearHubDiscovery).not.toHaveBeenCalled(); + expect(mockEnsureDetachedHubServer).not.toHaveBeenCalled(); + expect(mockRequestHubDrain).toHaveBeenLastCalledWith( + "ws://127.0.0.1:25463/hub", + "token", + "cline hub upgrade aborted", + { off: true }, + ); + }); + it("rejects a non-numeric upgrade --wait instead of treating it as an expired deadline", async () => { mockReadHubDiscovery.mockResolvedValue({ url: "ws://127.0.0.1:25463/hub", @@ -307,4 +333,181 @@ describe("createHubCommand", () => { }); expect(JSON.parse(output[0] || "")).toEqual({ stopped: true }); }); + + it("does not signal a stale discovery PID when authenticated shutdown fails", async () => { + mockReadHubDiscovery.mockResolvedValue({ + url: "ws://127.0.0.1:25466/hub", + pid: 50174, + authToken: "expired-token", + }); + mockStopLocalHubServerGracefully.mockResolvedValue(false); + const kill = vi.spyOn(process, "kill").mockReturnValue(true); + const { cmd, output, exitCode } = createCommand(); + await cmd.parseAsync(["stop"], { from: "user" }); + expect(kill).toHaveBeenCalledExactlyOnceWith(50174, 0); + expect(exitCode()).toBe(1); + expect(mockClearHubDiscovery).not.toHaveBeenCalled(); + expect(JSON.parse(output[0] || "")).toEqual({ stopped: false }); + }); + + it("succeeds when the hub has already exited", async () => { + mockReadHubDiscovery.mockResolvedValue({ + url: "ws://127.0.0.1:25466/hub", + pid: 50174, + }); + mockStopLocalHubServerGracefully.mockResolvedValue(false); + vi.spyOn(process, "kill").mockImplementation(() => { + throw Object.assign(new Error("no such process"), { code: "ESRCH" }); + }); + const { cmd, output, exitCode } = createCommand(); + await cmd.parseAsync(["stop"], { from: "user" }); + expect(exitCode()).toBe(0); + expect(JSON.parse(output[0] || "")).toEqual({ stopped: true }); + expect(mockClearHubDiscovery).toHaveBeenCalled(); + }); + + it.each([ + Object.assign(new Error("permission denied"), { code: "EACCES" }), + new Error("Invalid JSON in Hub discovery record"), + new Error("Invalid Hub discovery record"), + ])("fails stop when discovery cannot be read: %s", async (error) => { + mockReadHubDiscovery.mockRejectedValue(error); + const { cmd, output, errors, exitCode } = createCommand(); + await cmd.parseAsync(["stop"], { from: "user" }); + expect(exitCode()).toBe(1); + expect(output).toEqual([]); + expect(errors).toEqual([error.message]); + expect(mockReadHubDiscovery).toHaveBeenCalledWith(expect.any(String), { + onError: "throw", + }); + expect(mockStopLocalHubServerGracefully).not.toHaveBeenCalled(); + expect(mockClearHubDiscovery).not.toHaveBeenCalled(); + }); + + it("succeeds when there is no discovery record", async () => { + mockReadHubDiscovery.mockResolvedValue(undefined); + const { cmd, output, exitCode } = createCommand(); + await cmd.parseAsync(["stop"], { from: "user" }); + expect(exitCode()).toBe(0); + expect(JSON.parse(output[0] || "")).toEqual({ stopped: true }); + expect(mockStopLocalHubServerGracefully).not.toHaveBeenCalled(); + }); + + it("does not mistake an inaccessible process for an exited hub", async () => { + mockReadHubDiscovery.mockResolvedValue({ + url: "ws://127.0.0.1:25466/hub", + pid: 50174, + }); + mockStopLocalHubServerGracefully.mockResolvedValue(false); + vi.spyOn(process, "kill").mockImplementation(() => { + throw Object.assign(new Error("not permitted"), { code: "EPERM" }); + }); + const { cmd, exitCode } = createCommand(); + await cmd.parseAsync(["stop"], { from: "user" }); + expect(exitCode()).toBe(1); + expect(mockClearHubDiscovery).not.toHaveBeenCalled(); + }); + + it("replaces a hub that exits before upgrade can shut it down", async () => { + mockReadHubDiscovery.mockResolvedValue({ + url: "ws://127.0.0.1:25466/hub", + pid: 50174, + authToken: "token", + }); + mockRequestHubDrain.mockResolvedValue(false); + mockLocalHubHasNoActiveSessions.mockResolvedValue(true); + mockStopLocalHubServerGracefully.mockResolvedValue(false); + mockEnsureDetachedHubServer.mockResolvedValue({ + url: "ws://127.0.0.1:25466/hub", + }); + vi.spyOn(process, "kill").mockImplementation(() => { + throw Object.assign(new Error("no such process"), { code: "ESRCH" }); + }); + const { cmd, output, errors, exitCode } = createCommand(); + await cmd.parseAsync(["upgrade", "--wait", "0"], { from: "user" }); + expect(exitCode()).toBe(0); + expect(errors).toEqual([]); + expect(mockClearHubDiscovery).toHaveBeenCalled(); + expect(mockEnsureDetachedHubServer).toHaveBeenCalled(); + expect(JSON.parse(output[0] || "")).toEqual({ + upgraded: true, + url: "ws://127.0.0.1:25466/hub", + }); + }); + + it("prints the hub URL by default from ensure", async () => { + mockEnsureDetachedHubServer.mockResolvedValue({ + url: "ws://127.0.0.1:25463/hub", + authToken: "token", + }); + + const { cmd, output, exitCode } = createCommand(); + await cmd.parseAsync(["ensure"], { from: "user" }); + + expect(exitCode()).toBe(0); + expect(output).toEqual(["ws://127.0.0.1:25463/hub"]); + expect(mockEnsureLoginShellPath).not.toHaveBeenCalled(); + }); + + it("starts a dedicated hub for programmatic clients with ensure --json", async () => { + const originalDiscoveryPath = process.env.CLINE_HUB_DISCOVERY_PATH; + mockEnsureDetachedHubServer.mockImplementation(async () => { + expect(process.env.CLINE_HUB_DISCOVERY_PATH).toBe( + "/home/dev/.cline/data/remote/owner.json", + ); + return { url: "ws://127.0.0.1:41000/hub", authToken: "remote-token" }; + }); + + try { + const { cmd, output, errors, exitCode } = createCommand(); + await cmd.parseAsync( + [ + "ensure", + "--cwd", + "/home/dev", + "--discovery-path", + "/home/dev/.cline/data/remote/owner.json", + "--host", + "127.0.0.1", + "--port", + "0", + "--pathname", + "/hub", + "--json", + "--allow-port-fallback", + "--no-connectors", + "--login-shell-path", + ], + { from: "user" }, + ); + + expect(errors).toEqual([]); + expect(exitCode()).toBe(0); + expect(mockEnsureDetachedHubServer).toHaveBeenCalledWith("/home/dev", { + host: "127.0.0.1", + port: 0, + pathname: "/hub", + allowPortFallback: true, + manageConnectors: false, + beforeSpawn: expect.any(Function), + }); + // PATH resolution is deferred until a new daemon is actually spawned. + expect(mockEnsureLoginShellPath).not.toHaveBeenCalled(); + await mockEnsureDetachedHubServer.mock.calls[0]?.[1]?.beforeSpawn?.(); + expect(mockEnsureLoginShellPath).toHaveBeenCalledTimes(1); + expect(JSON.parse(output[0] || "")).toEqual({ + url: "ws://127.0.0.1:41000/hub", + authToken: "remote-token", + cwd: "/home/dev", + platform: process.platform, + arch: process.arch, + }); + } finally { + if (originalDiscoveryPath === undefined) { + delete process.env.CLINE_HUB_DISCOVERY_PATH; + } else { + process.env.CLINE_HUB_DISCOVERY_PATH = originalDiscoveryPath; + } + } + }); }); diff --git a/apps/cli/src/commands/hub.ts b/apps/cli/src/commands/hub.ts index faa9098203..82681f1022 100644 --- a/apps/cli/src/commands/hub.ts +++ b/apps/cli/src/commands/hub.ts @@ -1,6 +1,7 @@ import { clearHubDiscovery, ensureDetachedHubServer, + ensureLoginShellPath, localHubHasNoActiveSessions, NodeHubClient, probeHubServer, @@ -29,33 +30,52 @@ interface HubOptions { host?: string; port?: number; pathname?: string; + discoveryPath?: string; } +interface HubEnsureCommandOptions extends Partial { + json?: boolean; + allowPortFallback?: boolean; + connectors?: boolean; + loginShellPath?: boolean; +} + +const HUB_DISCOVERY_PATH_ENV = "CLINE_HUB_DISCOVERY_PATH"; + function addHubOptions(command: Command, defaultCwd?: string): Command { return command .option("--cwd ", "Workspace root", defaultCwd) .option("--host ", "Hub host") .option("--port ", "Hub port", (value) => Number.parseInt(value, 10)) - .option("--pathname ", "Hub websocket path"); + .option("--pathname ", "Hub websocket path") + .option( + "--discovery-path ", + "Use a dedicated hub discovery record instead of the default one", + ); } -async function stopHubServer(_workspaceRoot: string): Promise { +async function stopHubServer(): Promise { const owner = resolveCliHubOwnerContext(); - const discovery = await readHubDiscovery(owner.discoveryPath); - if (await stopLocalHubServerGracefully(owner)) { - await clearHubDiscovery(owner.discoveryPath); - return true; + const discovery = await readHubDiscovery(owner.discoveryPath, { + onError: "throw", + }); + if (!discovery) { + return true; // Already stopped: cleanup is idempotent. } - const pid = discovery?.pid; - if (pid) { + if (!(await stopLocalHubServerGracefully(owner))) { + // A stale PID must never receive a termination signal. A zero-signal + // existence check can only establish that the recorded process exited; + // a live/reused PID or a permission error cannot prove shutdown. + if (!discovery.pid) return false; try { - process.kill(pid, "SIGTERM"); - } catch { - // best effort + process.kill(discovery.pid, 0); + return false; + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== "ESRCH") return false; } } await clearHubDiscovery(owner.discoveryPath); - return !!pid; + return true; } function formatHubUptimeFromStartedAt( @@ -111,6 +131,19 @@ export function createHubCommand( .exitOverride() // Subcommands may use the same flag names for different services. .enablePositionalOptions() + .hook("preAction", (_hub, actionCommand) => { + // Every subcommand resolves its owner record from this env var, so a + // dedicated record (SSH remote hubs) is honored by ensure, status, + // and stop alike, and by the daemon the ensure spawns. Accepted + // before or after the subcommand. + const discoveryPath = ( + actionCommand.opts<{ discoveryPath?: string }>().discoveryPath ?? + hub.opts<{ discoveryPath?: string }>().discoveryPath + )?.trim(); + if (discoveryPath) { + process.env[HUB_DISCOVERY_PATH_ENV] = discoveryPath; + } + }) .hook("postAction", () => { setExitCode(actionExitCode); }); @@ -120,29 +153,58 @@ export function createHubCommand( ...options, }); - hub.command("ensure").action( - action(async (options: Partial) => { - const opts = resolveOptions(options); - const { url } = await ensureDetachedHubServer(opts.cwd, { - host: opts.host, - port: opts.port, - pathname: opts.pathname, - }); - io.writeln(url); - }), - ); - - hub.command("start").action( - action(async (options: Partial) => { - const opts = resolveOptions(options); - const { url } = await ensureDetachedHubServer(opts.cwd, { - host: opts.host, - port: opts.port, - pathname: opts.pathname, - }); - io.writeln(url); - }), - ); + const ensureAction = action(async (cmdOptions: HubEnsureCommandOptions) => { + const opts = resolveOptions(cmdOptions); + const result = await ensureDetachedHubServer(opts.cwd, { + host: opts.host, + port: opts.port, + pathname: opts.pathname, + ...(cmdOptions.allowPortFallback ? { allowPortFallback: true } : {}), + ...(cmdOptions.connectors === false ? { manageConnectors: false } : {}), + // Launchers without a login shell (GUI apps, non-interactive SSH) + // would otherwise hand the daemon a minimal PATH, so agent tools + // installed from shell profiles could not be found. + ...(cmdOptions.loginShellPath + ? { + beforeSpawn: async () => { + await ensureLoginShellPath(); + }, + } + : {}), + }); + if (cmdOptions.json) { + io.writeln( + JSON.stringify({ + url: result.url, + authToken: result.authToken, + cwd: opts.cwd, + platform: process.platform, + arch: process.arch, + }), + ); + return; + } + io.writeln(result.url); + }); + for (const name of ["ensure", "start"]) { + hub + .command(name) + .description("Start the hub daemon unless a compatible one is running") + .option( + "--json", + "Print the hub URL and auth token as JSON for programmatic clients", + ) + .option( + "--allow-port-fallback", + "Use an OS-assigned port when the requested one is unavailable", + ) + .option("--no-connectors", "Do not supervise account-wide connectors") + .option( + "--login-shell-path", + "Resolve PATH from the user's login shell before starting the hub", + ) + .action(ensureAction); + } hub.command("status").action( action(async () => { @@ -218,10 +280,10 @@ export function createHubCommand( ); hub.command("stop").action( - action(async (options: Partial) => { - const opts = resolveOptions(options); - const stopped = await stopHubServer(opts.cwd); + action(async () => { + const stopped = await stopHubServer(); io.writeln(JSON.stringify({ stopped })); + if (!stopped) fail(); }), ); @@ -317,7 +379,9 @@ export function createHubCommand( fail(); return; } - await stopHubServer(opts.cwd); + if (!(await stopHubServer())) { + throw new Error("Hub shutdown failed; upgrade aborted."); + } } catch (error) { await undrain(); throw error; diff --git a/apps/cli/src/index.ts b/apps/cli/src/index.ts index f5c92d1d9d..3b4d9e7eb5 100755 --- a/apps/cli/src/index.ts +++ b/apps/cli/src/index.ts @@ -2,8 +2,10 @@ import { isMainThread } from "node:worker_threads"; import { + buildTelemetrySelfcheckReport, claimHubDaemonProcess, claimSupervisedConnectorProcess, + createClineTelemetryServiceConfig, disableCurrentDirectoryExecutableSearch, disposeAll, ensureLoopbackProxyBypass, @@ -32,6 +34,12 @@ ensureLoopbackProxyBypass(); if (!isMainThread) { // Worker imports of the bundled CLI entrypoint should not start the CLI. +} else if (process.argv.includes("--telemetry-selfcheck")) { + // Inspect the config embedded in this executable without starting a hub, + // loading user settings, or initializing telemetry exporters. + process.stdout.write( + `${JSON.stringify(buildTelemetrySelfcheckReport(createClineTelemetryServiceConfig()))}\n`, + ); } else if (claimHubDaemonProcess()) { // Claim rather than read: the sentinel is consumed here so the processes a // daemon-hosted session spawns do not inherit it and try to become daemons. diff --git a/apps/cli/src/main.test.ts b/apps/cli/src/main.test.ts index f0f5d9f972..7e6b6b0ceb 100644 --- a/apps/cli/src/main.test.ts +++ b/apps/cli/src/main.test.ts @@ -406,6 +406,29 @@ describe("runCli lightweight command dispatch", () => { expect(mockState.runInteractiveImports).toBe(0); }, 30_000); + it("does not capture an activation event for hub commands", async () => { + // The desktop app and the SSH remote flow run `cline hub ensure` on + // every launch/connect; those must not count as CLI activations. + process.argv = ["bun", "src/index.ts", "hub", "dashboard", "--no-open"]; + + const { runCli } = await import("./main"); + await runCli(); + + expect(dashboardMocks.runDashboardCommand).toHaveBeenCalledTimes(1); + expect(telemetryMocks.captureCliExtensionActivated).not.toHaveBeenCalled(); + }); + + it("captures an activation event for regular commands", async () => { + process.argv = ["bun", "src/index.ts", "history", "--json"]; + + const { runCli } = await import("./main"); + await runCli(); + + expect(telemetryMocks.captureCliExtensionActivated).toHaveBeenCalledTimes( + 1, + ); + }); + it.each([ "connect", "mcp", diff --git a/apps/cli/src/main.ts b/apps/cli/src/main.ts index b6a0e6138d..5b8fddd815 100644 --- a/apps/cli/src/main.ts +++ b/apps/cli/src/main.ts @@ -166,7 +166,12 @@ export async function runCli(): Promise { // has been applied, so the telemetry singleton's persisted distinct-id // (and any other storage it touches) lands under the user-selected // `--config ` rather than the default home/config location. - captureCliExtensionActivated(); + // `cline hub ...` is plumbing other clients invoke (the desktop app on + // every launch, the SSH remote flow on every connect), not a user + // activation, so it must not count as one. + if (cliArgs[0] !== "hub") { + captureCliExtensionActivated(); + } const normalizedArgs = normalizeAutoApproveArgs(cliArgs); diff --git a/apps/examples/desktop-app/README.md b/apps/examples/desktop-app/README.md index e9bea1a444..874cdbcf40 100644 --- a/apps/examples/desktop-app/README.md +++ b/apps/examples/desktop-app/README.md @@ -1,6 +1,6 @@ # Desktop App Example -Tauri desktop shell + Bun sidecar backend + Next.js UI for running and inspecting Cline chat sessions. +Tauri desktop shell + desktop backend (`sidecar/`) + Next.js UI for running and inspecting Cline chat sessions. The packaged app bundles a self-contained Cline CLI binary that starts the shared Hub and runs the backend, so no Node or Bun install is needed. ## Dev Commands @@ -11,9 +11,9 @@ From `apps/examples/desktop-app/`: - `bun run dev:sidecar` - sidecar backend only (approval-gated tools require `dev:headless` or the native app) - `bun run dev` - Tauri desktop dev - `bun run build:web` - build production web assets only (includes the shared UI build) -- `bun run build` - build web assets and the sidecar binary +- `bun run build` - build web assets and the desktop runtime - `bun run build:sidecar` - build the Bun sidecar bundle -- `bun run build:sidecar:bin` - compile the Bun sidecar into a local binary +- `bun run build:runtime` - compile the bundled Cline CLI (host plus Linux SSH remote copies) and bundle the desktop backend into `src-tauri/bin/` - `bun run build:binary` - build desktop binary - `bun run package:desktop` - package the current OS desktop app into `dist/desktop/` - `bun run typecheck` - TypeScript check @@ -151,27 +151,27 @@ Desktop owns the settings UI and packaged helper resource lookup. The service stores host metadata at `~/.cline/data/settings/remote-environments.json` with mode `0600`. It stores an identity-file path, never private-key contents. On first connect it uploads a -content-addressed, branch-matched, self-contained Hub helper under -`~/.cline/remote/`, binds the Hub to remote loopback, and forwards it to a -random local loopback port. Linux x64 and arm64 helpers are bundled by -`bun run build:sidecar:bin`; 32-bit Raspberry Pi operating systems are not -supported. A macOS desktop reaches macOS SSH hosts with its own signed -universal sidecar, which runs the same helper entrypoint; Windows and Linux -desktops need a locally built darwin helper passed through -`CLINE_REMOTE_HELPER_BINARY`. The helper includes its own runtime (about 115 MB -per helper) and must not be UPX-compressed; Bun reads its embedded code back -from the executable at runtime, so a packed helper crashes with +content-addressed, branch-matched, self-contained Cline CLI binary under +`~/.cline/remote/` and runs `cline hub ensure --json --discovery-path ...` +there, which reuses a compatible healthy remote Hub or starts one bound to +remote loopback. The desktop forwards it to a random local loopback port. +Linux x64 and arm64 CLI copies are bundled by `bun run build:runtime`; 32-bit +Raspberry Pi operating systems are not supported. A macOS desktop reaches +macOS SSH hosts with its own signed universal Cline CLI; Windows and Linux +desktops need a locally built darwin CLI passed through +`CLINE_REMOTE_HELPER_BINARY`. The remote CLI includes its own runtime (about +115 MB per copy) and must not be UPX-compressed; Bun reads its embedded code +back from the executable at runtime, so a packed binary crashes with `SyntaxError: Invalid character: '\0'`. It is copied once per matching desktop -build and cached, with no -`apt`, `npm`, root access, -global CLI install, or public Hub port. Disconnecting stops the desktop-owned -remote Hub but leaves the helper cached for a faster reconnect. The helper -imports the remote login-shell `PATH`, so user-installed Git, GitHub CLI, and -MCP executables remain visible. +build and cached, with no `apt`, `npm`, root access, global CLI install, or +public Hub port. Disconnecting runs `cline hub stop` for the desktop-owned +remote Hub but leaves the CLI cached for a faster reconnect. The remote Hub +imports the remote login-shell `PATH` (`--login-shell-path`), so +user-installed Git, GitHub CLI, and MCP executables remain visible. Each service instance uses its own discovery record, so an existing Cline CLI/Hub on the same account is neither replaced nor stopped. Both Hub processes can coexist -while the desktop is connected; this isolation keeps the remote helper separate from the default CLI Hub. +while the desktop is connected; this isolation keeps the desktop's remote Hub separate from the default CLI Hub. The desktop currently leaves file attachments and opening a remote file in a local editor disabled. Text, images, file mentions/search, Git branch operations, @@ -239,11 +239,11 @@ sudo dnf install ./Cline__aarch64.rpm # Fedora / RHEL (arm64) ``` The app installs as `/usr/bin/cline-app` with a `Cline` launcher entry; the -sidecar is `/usr/bin/code-sidecar` and the bundled SSH remote helpers live in -`/usr/lib/Cline/`. The tray icon needs a StatusNotifier host (KDE, XFCE, and +bundled Cline CLI is `/usr/bin/cline-cli`, and the desktop backend bundle and +SSH remote CLIs live in `/usr/lib/Cline/`. The tray icon needs a StatusNotifier host (KDE, XFCE, and GNOME with the AppIndicator extension); without one the app still runs but the tray menu is unavailable. There is no AppImage: linuxdeploy cannot process the -Bun-compiled sidecar (`ldd` fails on it and `patchelf` corrupts it), so the +Bun-compiled Cline CLI (`ldd` fails on it and `patchelf` corrupts it), so the AppImage target is excluded from `tauri build` on Linux. A Linux desktop cannot use a Mac as an SSH remote host (see the changelog). @@ -293,16 +293,20 @@ bun run package:desktop:mac The first signing run pops a keychain dialog — enter your macOS login password and click **Always Allow**. Notarization uploads the app to Apple's automated malware scan (typically 2–10 minutes) and staples the ticket. Artifacts land in `dist/desktop/`; share the `.dmg`. The DMG name takes its version from `src-tauri/tauri.conf.json`, the zip name from `package.json` — bump both. -Do not remove `src-tauri/entitlements.plist` or the `bundle.macOS.entitlements` reference in `tauri.conf.json`: notarization requires the hardened runtime, which breaks the Bun-compiled sidecar (`SharedArrayBuffer is not defined`, surfacing in-app as "desktop backend endpoint not ready") unless the JIT entitlements are present. +Do not remove `src-tauri/entitlements.plist` or the `bundle.macOS.entitlements` reference in `tauri.conf.json`: notarization requires the hardened runtime, which breaks the Bun-compiled Cline CLI (`SharedArrayBuffer is not defined`, surfacing in-app as "desktop backend endpoint not ready") unless the JIT entitlements are present. ## Runtime Overview Startup flow: 1. Tauri starts a persistent local desktop backend and keeps only native window/file-picker/open-path responsibilities. -2. The desktop backend starts the Bun sidecar, which discovers or starts the - canonical shared Cline Hub and exposes one websocket transport (`/transport`) - for desktop commands, queries, and pushed events. + In a packaged app the backend is `bin/desktop-backend/index.js`, executed by + the bundled Cline CLI (`cline-cli run`); `bun run dev` runs `sidecar/index.ts` + with Bun directly. +2. The backend runs `cline-cli hub ensure`, which reuses a compatible healthy + shared Cline Hub on loopback or starts one (the Hub daemon is the same CLI + binary). The backend then attaches to the Hub and exposes one websocket + transport (`/transport`) for desktop commands, queries, and pushed events. 3. The React app uses `lib/desktop-client.ts` and no longer imports `@tauri-apps/api/core` directly in feature code. 4. Tool approval updates are pushed from the backend instead of polled from the UI. 5. Session process context resolves `workspaceRoot` from git root and uses that same path as default `cwd` for chat runtime and git operations unless explicitly overridden. @@ -323,7 +327,8 @@ Desktop transport envelope: ## Key Files - [`src-tauri/src/main.rs`](./src-tauri/src/main.rs) - Tauri shell lifecycle, backend launch, and native-only commands -- [`sidecar/index.ts`](./sidecar/index.ts) - persistent Bun sidecar and Hub-daemon entry dispatch +- [`sidecar/index.ts`](./sidecar/index.ts) - persistent desktop backend entrypoint +- [`sidecar/cli-runtime.ts`](./sidecar/cli-runtime.ts) - bundled Cline CLI runtime adoption and `hub ensure` - [`sidecar/chat-session.ts`](./sidecar/chat-session.ts) - shared-Hub chat session adapter - [`webview/lib/desktop-client.ts`](./webview/lib/desktop-client.ts) - typed desktop websocket client - [`webview/hooks/use-chat-session.ts`](./webview/hooks/use-chat-session.ts) - UI chat session state + backend subscriptions @@ -364,7 +369,7 @@ credentials, request headers, recorded audio, or transcript contents. ## Troubleshooting - If live updates stall, verify the desktop backend websocket is connected and `chat_event` messages are arriving. -- Tauri restarts the desktop backend if the sidecar process exits and kills it on app teardown. +- Tauri restarts the desktop backend if its process exits and stops it on app teardown. The shared Hub keeps running for other clients. - Chat sends now preflight provider credentials. If a provider that requires API-key auth is selected without a key, the UI blocks the turn with a clear error message instead of starting a hanging session. - If a turn completes with `finishReason=error` before any assistant content is produced, the UI now adds an explicit error chat message so failed turns are visible in the transcript. - Linux with the proprietary NVIDIA driver: WebKitGTK's DMA-BUF renderer diff --git a/apps/examples/desktop-app/bun.mts b/apps/examples/desktop-app/bun.mts index d5bd117dcd..ebc4205cc0 100644 --- a/apps/examples/desktop-app/bun.mts +++ b/apps/examples/desktop-app/bun.mts @@ -1,7 +1,7 @@ import { $ } from "bun"; const main = async () => { - await $`bun run build:sidecar:bin`; + await $`bun run build:runtime`; await $`next build`.cwd("webview"); }; diff --git a/apps/examples/desktop-app/package.json b/apps/examples/desktop-app/package.json index ab4c19fb72..6b38488880 100644 --- a/apps/examples/desktop-app/package.json +++ b/apps/examples/desktop-app/package.json @@ -15,7 +15,7 @@ "prebuild": "bun run build:ui", "build": "bun run bun.mts", "build:sidecar": "mkdir -p dist/sidecar && bun build ./sidecar/index.ts --outfile ./dist/sidecar/index.js --target bun", - "build:sidecar:bin": "bun run scripts/build-sidecar-bin.ts", + "build:runtime": "bun run scripts/build-desktop-runtime.ts", "build:binary": "tauri build", "dmg:background": "bun run scripts/dmg-background.ts", "test:dmg-background": "bun test scripts/dmg-background.test.ts", diff --git a/apps/examples/desktop-app/scripts/build-desktop-runtime.ts b/apps/examples/desktop-app/scripts/build-desktop-runtime.ts new file mode 100644 index 0000000000..80e707037d --- /dev/null +++ b/apps/examples/desktop-app/scripts/build-desktop-runtime.ts @@ -0,0 +1,239 @@ +import { existsSync } from "node:fs"; +import { fileURLToPath } from "node:url"; +import { $ } from "bun"; +import { + compileCliBinary, + installOpenTuiNativeVariants, +} from "../../../cli/script/compile-binary"; +import { telemetryDefineArgs } from "./telemetry-define-args"; + +// The desktop app ships two runtime artifacts and no app-specific executable: +// +// - The Cline CLI, compiled self-contained for this target (Tauri externalBin +// `bin/cline-cli`). `cline-cli hub ensure` starts or reuses the CLI-managed +// Hub, and the Hub daemon is that same binary. +// - The desktop backend (sidecar/ sources: the UI command router plus its +// HTTP/WebSocket server) as one JS bundle, executed on the CLI's embedded +// runtime. It attaches to the Hub as an ordinary client. +// +// Linux CLI copies are bundled as resources so SSH remotes run a CLI-managed +// Hub too; macOS remotes reuse the signed universal CLI itself. + +const APP_ROOT = fileURLToPath(new URL("..", import.meta.url)); +const REPO_ROOT = fileURLToPath(new URL("../../../../", import.meta.url)); +const BIN_DIR = "src-tauri/bin"; +export const DESKTOP_CLI_NAME = "cline-cli"; +export const DESKTOP_BACKEND_BUNDLE = `${BIN_DIR}/desktop-backend/index.js`; +export const REMOTE_CLI_DIR = `${BIN_DIR}/remote-helpers`; + +const resolveTargetTriple = async (): Promise => { + const fromEnv = process.env.TAURI_ENV_TARGET_TRIPLE ?? process.env.TARGET; + if (fromEnv?.trim()) { + return fromEnv.trim(); + } + + const rustcVersion = await $`rustc -vV`.text(); + const hostLine = rustcVersion + .split("\n") + .find((line) => line.startsWith("host: ")); + const host = hostLine?.slice("host: ".length).trim(); + if (!host) { + throw new Error("failed to resolve Rust host target triple"); + } + return host; +}; + +// Bun cross-compiles --compile binaries, so a CI runner can produce the CLI +// for a different architecture than its own (e.g. the x86_64 macOS bundle +// from an arm64 runner). +const resolveBunCompileTarget = ( + targetTriple: string, +): Bun.Build.CompileTarget => { + if (targetTriple.startsWith("aarch64-apple-darwin")) + return "bun-darwin-arm64"; + if (targetTriple.startsWith("x86_64-apple-darwin")) return "bun-darwin-x64"; + if (targetTriple.startsWith("x86_64-pc-windows")) return "bun-windows-x64"; + if (targetTriple.startsWith("aarch64-pc-windows")) return "bun-windows-arm64"; + // SSH hosts may predate AVX2 (e.g. Ivy Bridge Xeons). The default Bun + // x64 runtime can SIGILL before our entrypoint runs on those CPUs. + if (targetTriple.startsWith("x86_64-unknown-linux")) + return "bun-linux-x64-baseline"; + if (targetTriple.startsWith("aarch64-unknown-linux")) + return "bun-linux-arm64"; + throw new Error(`unsupported desktop target triple: ${targetTriple}`); +}; + +const desktopCliOutfile = (targetTriple: string): string => { + const extension = targetTriple.includes("windows") ? ".exe" : ""; + return `${BIN_DIR}/${DESKTOP_CLI_NAME}-${targetTriple}${extension}`; +}; + +const compileDesktopCli = async ( + targetTriple: string, + outfile = desktopCliOutfile(targetTriple), +): Promise => { + await compileCliBinary({ + bunTarget: resolveBunCompileTarget(targetTriple), + outfile: fileURLToPath(new URL(`../${outfile}`, import.meta.url)), + // A packaged app and an SSH session launch the CLI from directories the + // user did not choose (/, an SSH home). Reading .env or bunfig.toml + // from there could fail startup or leak credentials into the Hub. + autoloadLaunchDirectoryConfig: false, + // Bun only trusts its bundled Mozilla roots on macOS/Windows, so TLS to + // intranet endpoints signed by a corporate CA (LiteLLM proxies, MITM + // firewalls) fails with "unable to get local issuer certificate". Bake + // --use-system-ca in so the Hub and the backend running on this runtime + // also trust the OS Keychain/cert store, as the npm wrapper does. + execArgv: ["--use-system-ca"], + }); + return outfile; +}; + +const bundleDesktopBackend = async (): Promise => { + // Telemetry config must be inlined into the bundle: a packaged app launched + // from Finder/the Dock has no OTEL_* env at runtime, so without this the + // backend silently ships with telemetry disabled. Verify with + // `BUN_BE_BUN=1 cline-cli run --telemetry-selfcheck`. + const defines = telemetryDefineArgs(); + await $`bun build ./sidecar/index.ts --target bun --minify ${defines} --outfile ${DESKTOP_BACKEND_BUNDLE}`; +}; + +// Each compiled CLI embeds a full Bun runtime (~100 MB), and both Linux copies +// ship inside every desktop bundle. They must not be UPX-packed: the Bun 1.4 +// runtime reads its embedded module payload back from its own executable at +// runtime, and on a packed binary those reads return NUL padding, so every +// `hub ensure` fails with "SyntaxError: Invalid character: '\0'" (#14815). +// `strip` is not an option either; it discards Bun's appended module payload. + +// Linux x64 and arm64 cover common SSH hosts. macOS copies are deliberately +// not bundled: they would be Mach-O files under Contents/Resources, which +// Tauri does not codesign, and any unsigned Mach-O in the bundle fails +// notarization. Mac remotes are served by the signed desktop CLI instead +// (sidecar/remote-helper.ts). +// +// On a Windows host, Bun fails to extract the downloaded Linux runtime these +// cross-compiles need ("Failed to extract executable for 'bun-linux-x64-…'"). +// Bun skips the download when `$BUN_INSTALL_CACHE_DIR/bun--v` +// already exists, so seed those two files from the @oven/bun- npm +// packages first; desktop-publish.yml does exactly that in its Windows job. +const buildRemoteClis = async (): Promise => { + for (const targetTriple of [ + "x86_64-unknown-linux-gnu", + "aarch64-unknown-linux-gnu", + ]) { + await compileDesktopCli( + targetTriple, + `${REMOTE_CLI_DIR}/cline-${targetTriple}`, + ); + } +}; + +// Tauri's universal-apple-darwin pseudo-target lipos the Rust binary itself +// but expects externalBin binaries to already be fat binaries named +// `-universal-apple-darwin`, so build both slices and merge them here. +const buildUniversalMacCli = async (): Promise => { + const arm64 = await compileDesktopCli("aarch64-apple-darwin"); + const x64 = await compileDesktopCli("x86_64-apple-darwin"); + const outfile = desktopCliOutfile("universal-apple-darwin"); + await $`lipo -create -output ${outfile} ${arm64} ${x64}`; + await $`chmod +x ${outfile}`; + await $`lipo -info ${outfile}`; +}; + +// Plugin installs drop their @cline/* dependencies because the host provides +// them, and the plugin worker resolves those from node_modules beside its +// bootstrap. Nothing like that exists next to the compiled CLI, so ship +// self-contained builds there. Splitting keeps one shared copy of the SDK, +// placed in core so every chunk sits inside a `"type": "module"` package. +const bundlePluginSdkPackages = async (): Promise => { + const outdir = `${BIN_DIR}/extensions/node_modules/@cline`; + const packagesRoot = "../../../sdk/packages"; + const packages = await Promise.all( + ["sdk", "core", "agents", "llms", "shared"].map(async (name) => { + const root = `${packagesRoot}/${name}`; + const manifest = (await Bun.file(`${root}/package.json`).json()) as { + exports: Record; + }; + // Type-only exports (e.g. @cline/shared/types) have no JS to ship. + const exports = Object.fromEntries( + Object.entries(manifest.exports) + .map(([path, entry]) => [path, entry.import] as const) + .filter(([, file]) => existsSync(`${root}/${file}`)), + ); + return { root, name, exports }; + }), + ); + await $`rm -rf ${outdir}`; + const result = await Bun.build({ + entrypoints: packages.flatMap(({ root, exports }) => + Object.values(exports).map((file) => `${root}/${file}`), + ), + root: packagesRoot, + outdir, + naming: { chunk: "core/dist/[name]-[hash].[ext]" }, + target: "node", + format: "esm", + splitting: true, + minify: true, + }); + if (!result.success) { + throw new AggregateError(result.logs, "plugin SDK bundle failed"); + } + for (const { name, exports } of packages) { + await Bun.write( + `${outdir}/${name}/package.json`, + JSON.stringify({ name: `@cline/${name}`, type: "module", exports }), + ); + } +}; + +const hasOpenTuiNativeVariant = (os: string, arch: string): boolean => + existsSync(`${REPO_ROOT}/node_modules/.bun`) && + [ + ...new Bun.Glob(`@opentui+core-${os}-${arch}@*`).scanSync({ + cwd: `${REPO_ROOT}/node_modules/.bun`, + onlyFiles: false, + }), + ].length > 0; + +const main = async () => { + // The CLI, its Hub daemon, and the backend bundle must all carry the same + // SDK build identity, so build the SDK packages they bundle once, first. + await $`bun run build:sdk`.cwd(REPO_ROOT); + process.chdir(APP_ROOT); + const targetTriple = await resolveTargetTriple(); + await $`mkdir -p ${BIN_DIR}/desktop-backend ${REMOTE_CLI_DIR} ${BIN_DIR}/extensions`; + // Plugin workers load this bootstrap from disk in the backend and Hub. + await $`cp ../../../sdk/packages/core/dist/extensions/plugin-sandbox-bootstrap.js ${BIN_DIR}/extensions/plugin-sandbox-bootstrap.js`; + await bundlePluginSdkPackages(); + // Every build cross-compiles the Linux SSH copies (and macOS builds both + // architectures), which needs the target platforms' OpenTUI native + // packages in addition to the host's. + const needed: Array<[string, string]> = [ + ["linux", "x64"], + ["linux", "arm64"], + ...(targetTriple === "universal-apple-darwin" + ? ([ + ["darwin", "x64"], + ["darwin", "arm64"], + ] as Array<[string, string]>) + : []), + ]; + if (needed.some(([os, arch]) => !hasOpenTuiNativeVariant(os, arch))) { + await installOpenTuiNativeVariants(); + } + await bundleDesktopBackend(); + if (targetTriple === "universal-apple-darwin") { + await buildUniversalMacCli(); + } else { + await compileDesktopCli(targetTriple); + } + await buildRemoteClis(); +}; + +if (import.meta.main) { + main().catch((error: unknown) => { + console.error(error); + process.exitCode = 1; + }); +} diff --git a/apps/examples/desktop-app/scripts/build-sidecar-bin.ts b/apps/examples/desktop-app/scripts/build-sidecar-bin.ts deleted file mode 100644 index 3eb75fc0d6..0000000000 --- a/apps/examples/desktop-app/scripts/build-sidecar-bin.ts +++ /dev/null @@ -1,199 +0,0 @@ -import { existsSync } from "node:fs"; -import { fileURLToPath } from "node:url"; -import { $ } from "bun"; -import { telemetryDefineArgs } from "./telemetry-define-args"; - -const resolveTargetTriple = async (): Promise => { - const fromEnv = process.env.TAURI_ENV_TARGET_TRIPLE ?? process.env.TARGET; - if (fromEnv?.trim()) { - return fromEnv.trim(); - } - - const rustcVersion = await $`rustc -vV`.text(); - const hostLine = rustcVersion - .split("\n") - .find((line) => line.startsWith("host: ")); - const host = hostLine?.slice("host: ".length).trim(); - if (!host) { - throw new Error("failed to resolve Rust host target triple"); - } - return host; -}; - -// Bun cross-compiles --compile binaries, so a CI runner can produce the -// sidecar for a different architecture than its own (e.g. the x86_64 macOS -// bundle from an arm64 runner). Without an explicit --target, bun always -// emits a host-arch binary even when Tauri is building for another triple. -const resolveBunCompileTarget = (targetTriple: string): string | undefined => { - if (targetTriple.startsWith("aarch64-apple-darwin")) - return "bun-darwin-arm64"; - if (targetTriple.startsWith("x86_64-apple-darwin")) return "bun-darwin-x64"; - if (targetTriple.startsWith("x86_64-pc-windows")) return "bun-windows-x64"; - // SSH hosts may predate AVX2 (e.g. Ivy Bridge Xeons). The default Bun - // x64 runtime can SIGILL before our entrypoint runs on those CPUs. - if (targetTriple.startsWith("x86_64-unknown-linux")) - return "bun-linux-x64-baseline"; - if (targetTriple.startsWith("aarch64-unknown-linux")) - return "bun-linux-arm64"; - return undefined; -}; - -const sidecarOutfile = (targetTriple: string): string => { - const extension = targetTriple.includes("windows") ? ".exe" : ""; - return `./src-tauri/bin/code-sidecar-${targetTriple}${extension}`; -}; - -const buildSidecar = async ( - targetTriple: string, - outfile = sidecarOutfile(targetTriple), - entrypoint = "./sidecar/index.ts", - minify = false, -): Promise => { - const bunTarget = resolveBunCompileTarget(targetTriple); - // Telemetry config must be inlined into the compiled binary: a packaged - // app launched from Finder/the Dock has no OTEL_* env at runtime, so - // without this the sidecar silently ships with telemetry disabled. - // Verify with ` --telemetry-selfcheck` after building. - const defines = telemetryDefineArgs(); - const optimizationArgs = minify ? ["--minify"] : []; - // A compiled Bun executable otherwise reads .env and bunfig.toml from its - // launch directory before our entrypoint runs. Remote helpers are launched - // from an SSH user's home directory, so that behavior can both make the - // helper fail on an unrelated dotenv file and leak workspace credentials - // into the Hub process. Packaged binaries must depend only on their explicit - // process environment and compiled configuration. - const runtimeIsolationArgs = [ - "--no-compile-autoload-dotenv", - "--no-compile-autoload-bunfig", - // Bun only trusts its bundled Mozilla roots on macOS/Windows, so TLS to - // intranet endpoints signed by a corporate CA (LiteLLM proxies, MITM - // firewalls) fails with "unable to get local issuer certificate". Bake - // --use-system-ca into the runtime so the sidecar and the Hub daemon it - // re-executes from this binary also trust the OS Keychain/cert store, - // matching the CLI wrapper's OS trust-anchor harvesting. - "--compile-exec-argv=--use-system-ca", - ]; - if (bunTarget) { - await $`bun build ${entrypoint} --compile --target=${bunTarget} ${runtimeIsolationArgs} ${optimizationArgs} ${defines} --outfile ${outfile}`; - } else { - await $`bun build ${entrypoint} --compile ${runtimeIsolationArgs} ${optimizationArgs} ${defines} --outfile ${outfile}`; - } - return outfile; -}; - -// Each compiled helper embeds a full Bun runtime (~100 MB) around ~14 MB of -// our code, and both helpers ship uncompressed inside every desktop bundle. -// Do not UPX-pack or `strip` them: since Bun 1.4 the runtime lazily reads its -// appended module payload back from its own executable file, so a packed -// helper starts but crashes with "SyntaxError: Invalid character: '\0'" on the -// first lazy read (cline/cline#14815, #14781). `strip` discards the payload. -// -// SSH environments run the same Hub build as the desktop in a dedicated -// bootstrap/daemon binary. It intentionally excludes the desktop HTTP server, -// command router, and UI backend. Linux x64 and arm64 cover common SSH hosts. -// macOS helpers are deliberately not bundled: they are Mach-O files under -// Contents/Resources, which Tauri does not codesign, and any unsigned Mach-O -// in the bundle fails notarization. Mac remotes are served from a macOS -// desktop by its own signed sidecar instead (sidecar/remote-helper.ts). -// -// On a Windows host, Bun fails to extract the downloaded Linux runtime these -// cross-compiles need ("Failed to extract executable for 'bun-linux-x64-…'"). -// Bun skips the download when `$BUN_INSTALL_CACHE_DIR/bun--v` -// already exists, so seed those two files from the @oven/bun- npm -// packages first; desktop-publish.yml does exactly that in its Windows job. -const buildRemoteHelpers = async (): Promise => { - for (const targetTriple of [ - "x86_64-unknown-linux-gnu", - "aarch64-unknown-linux-gnu", - ]) { - await buildSidecar( - targetTriple, - `./src-tauri/bin/remote-helpers/cline-remote-helper-${targetTriple}`, - "../../../sdk/packages/core/dist/remote/remote-helper-entry.js", - true, - ); - } -}; - -// Tauri's universal-apple-darwin pseudo-target lipos the Rust binary itself -// but expects sidecars (externalBin) to already be fat binaries named -// `-universal-apple-darwin`, so build both slices and merge them here. -const buildUniversalMacSidecar = async (): Promise => { - const arm64 = await buildSidecar("aarch64-apple-darwin"); - const x64 = await buildSidecar("x86_64-apple-darwin"); - const outfile = sidecarOutfile("universal-apple-darwin"); - await $`lipo -create -output ${outfile} ${arm64} ${x64}`; - await $`chmod +x ${outfile}`; - await $`lipo -info ${outfile}`; -}; - -// Plugin installs drop their @cline/* dependencies because the host provides -// them, and the plugin worker resolves those from node_modules beside its -// bootstrap. Nothing like that exists next to the compiled sidecar, so ship -// self-contained builds there. Splitting keeps one shared copy of the SDK, -// placed in core so every chunk sits inside a `"type": "module"` package. -const bundlePluginSdkPackages = async (): Promise => { - const outdir = "src-tauri/bin/extensions/node_modules/@cline"; - const packagesRoot = "../../../sdk/packages"; - const packages = await Promise.all( - ["sdk", "core", "agents", "llms", "shared"].map(async (name) => { - const root = `${packagesRoot}/${name}`; - const manifest = (await Bun.file(`${root}/package.json`).json()) as { - exports: Record; - }; - // Type-only exports (e.g. @cline/shared/types) have no JS to ship. - const exports = Object.fromEntries( - Object.entries(manifest.exports) - .map(([path, entry]) => [path, entry.import] as const) - .filter(([, file]) => existsSync(`${root}/${file}`)), - ); - return { root, name, exports }; - }), - ); - await $`rm -rf ${outdir}`; - const result = await Bun.build({ - entrypoints: packages.flatMap(({ root, exports }) => - Object.values(exports).map((file) => `${root}/${file}`), - ), - root: packagesRoot, - outdir, - naming: { chunk: "core/dist/[name]-[hash].[ext]" }, - target: "node", - format: "esm", - splitting: true, - minify: true, - }); - if (!result.success) { - throw new AggregateError(result.logs, "plugin SDK bundle failed"); - } - for (const { name, exports } of packages) { - await Bun.write( - `${outdir}/${name}/package.json`, - JSON.stringify({ name: `@cline/${name}`, type: "module", exports }), - ); - } -}; - -const main = async () => { - // All compiled helpers and the sidecar depend on fresh SDK package exports. - await $`bun run build:sdk`.cwd( - fileURLToPath(new URL("../../../../", import.meta.url)), - ); - const targetTriple = await resolveTargetTriple(); - await $`mkdir -p src-tauri/bin src-tauri/bin/remote-helpers src-tauri/bin/extensions`; - // Plugins run in worker threads that load this self-contained bootstrap - // from disk; the app ships it as a resource (see plugin-bootstrap.ts). - await $`cp ../../../sdk/packages/core/dist/extensions/plugin-sandbox-bootstrap.js src-tauri/bin/extensions/plugin-sandbox-bootstrap.js`; - await bundlePluginSdkPackages(); - if (targetTriple === "universal-apple-darwin") { - await buildUniversalMacSidecar(); - } else { - await buildSidecar(targetTriple); - } - await buildRemoteHelpers(); -}; - -main().catch((error: unknown) => { - console.error(error); - process.exitCode = 1; -}); diff --git a/apps/examples/desktop-app/scripts/desktop-startup.test.ts b/apps/examples/desktop-app/scripts/desktop-startup.test.ts index 04c0ccab5b..2f036a8b2d 100644 --- a/apps/examples/desktop-app/scripts/desktop-startup.test.ts +++ b/apps/examples/desktop-app/scripts/desktop-startup.test.ts @@ -6,52 +6,77 @@ import { openSync, readFileSync, rmSync, + writeFileSync, } from "node:fs"; +import { createServer } from "node:net"; import { tmpdir } from "node:os"; -import { join, resolve } from "node:path"; +import { dirname, join, resolve } from "node:path"; import { fileURLToPath } from "node:url"; +import { compileCliBinary } from "../../../cli/script/compile-binary"; -// Exercise the actual compiled entrypoint: source-only tests miss mixed SDK -// build identities between the desktop client and its embedded Hub daemon. +// Exercise the shipped runtime artifacts: the compiled Cline CLI that starts +// the Hub and the backend bundle that runs on its embedded runtime. Source-only +// tests miss mixed SDK build identities between the desktop client and the +// CLI-managed Hub it attaches to. -let compiledBinaryDir: string | undefined; -let compiledBinary: string | undefined; +let buildDir: string | undefined; -function resolveSidecarBinary(): string { - if (process.env.CLINE_TEST_SIDECAR_BIN) { - return resolve(process.env.CLINE_TEST_SIDECAR_BIN); +function ensureBuildDir(): string { + buildDir ??= mkdtempSync(join(tmpdir(), "cline-desktop-startup-bin-")); + return buildDir; +} + +let compiledCli: string | undefined; +async function resolveCliBinary(): Promise { + if (process.env.CLINE_TEST_DESKTOP_CLI_BIN) { + return resolve(process.env.CLINE_TEST_DESKTOP_CLI_BIN); } - if (compiledBinary) { - return compiledBinary; + if (!compiledCli) { + const outfile = join( + ensureBuildDir(), + process.platform === "win32" ? "cline-cli.exe" : "cline-cli", + ); + await compileCliBinary({ + bunTarget: + `bun-${process.platform === "win32" ? "windows" : process.platform}-${process.arch}` as Bun.Build.CompileTarget, + outfile, + autoloadLaunchDirectoryConfig: false, + execArgv: ["--use-system-ca"], + }); + compiledCli = outfile; } - compiledBinaryDir = mkdtempSync(join(tmpdir(), "cline-desktop-startup-bin-")); - const binary = join( - compiledBinaryDir, - process.platform === "win32" ? "sidecar.exe" : "sidecar", - ); - const build = spawnSync( - process.execPath, - [ - "build", - fileURLToPath(new URL("../sidecar/index.ts", import.meta.url)), - "--compile", - "--no-compile-autoload-dotenv", - "--no-compile-autoload-bunfig", - "--compile-exec-argv=--use-system-ca", - "--outfile", - binary, - ], - { cwd: compiledBinaryDir, encoding: "utf8", timeout: 60_000 }, - ); - expect(build.status, build.stderr || String(build.error)).toBe(0); - compiledBinary = binary; - return binary; + return compiledCli; +} + +let bundledBackend: string | undefined; +function resolveBackendBundle(): string { + if (process.env.CLINE_TEST_DESKTOP_BACKEND_BUNDLE) { + return resolve(process.env.CLINE_TEST_DESKTOP_BACKEND_BUNDLE); + } + if (!bundledBackend) { + const outdir = join(ensureBuildDir(), "desktop-backend"); + const build = spawnSync( + process.execPath, + [ + "build", + fileURLToPath(new URL("../sidecar/index.ts", import.meta.url)), + "--target", + "bun", + "--outdir", + outdir, + ], + { encoding: "utf8", timeout: 120_000 }, + ); + expect(build.status, build.stderr || String(build.error)).toBe(0); + bundledBackend = join(outdir, "index.js"); + } + return bundledBackend; } afterAll(() => { - if (compiledBinaryDir) { + if (buildDir) { try { - rmSync(compiledBinaryDir, { + rmSync(buildDir, { recursive: true, force: true, maxRetries: 20, @@ -63,8 +88,51 @@ afterAll(() => { } }); +test("the shipped CLI and backend report telemetry without starting a hub", async () => { + const cli = await resolveCliBinary(); + const bundle = resolveBackendBundle(); + for (const args of [ + ["--telemetry-selfcheck"], + ["run", "--no-env-file", bundle, "--telemetry-selfcheck"], + ]) { + const result = spawnSync(cli, args, { + encoding: "utf8", + timeout: 10_000, + env: { ...process.env, BUN_BE_BUN: args[0] === "run" ? "1" : "" }, + }); + expect(result.status, result.stderr || String(result.error)).toBe(0); + expect(JSON.parse(result.stdout)).toEqual({ + telemetry_selfcheck: true, + enabled: expect.any(Boolean), + otlp_endpoint_host: expect.any(String), + logs_exporter: expect.any(String), + metrics_exporter: expect.any(String), + }); + } +}, 120_000); + +async function reserveLoopbackPort(): Promise { + const server = createServer(); + await new Promise((done) => server.listen(0, "127.0.0.1", done)); + const address = server.address(); + await new Promise((done) => server.close(() => done())); + if (!address || typeof address === "string") { + throw new Error("could not reserve a loopback port"); + } + return address.port; +} + +function processCommandLine(pid: number): string | undefined { + if (process.platform === "win32") return undefined; + const ps = spawnSync("ps", ["-o", "command=", "-p", String(pid)], { + encoding: "utf8", + }); + return ps.status === 0 ? ps.stdout.trim() : undefined; +} + async function runStartupScenario( extraEnv: Record, + options: { hubAlreadyRunning: boolean; discoveryContents?: string }, ): Promise { const root = mkdtempSync(join(tmpdir(), "cline-desktop-startup-")); const discoveryPath = join(root, "hub.json"); @@ -75,12 +143,16 @@ async function runStartupScenario( let child: ReturnType | undefined; let hubPid: number | undefined; try { - const binary = resolveSidecarBinary(); + if (options.discoveryContents !== undefined) { + writeFileSync(discoveryPath, options.discoveryContents); + } + const cli = await resolveCliBinary(); + const bundle = resolveBackendBundle(); const env = Object.fromEntries( Object.entries(process.env).filter( ([key]) => - !/^(CLINE_|OTEL_|TELEMETRY_|ERROR_SERVICE_)/.test(key) && + !/^(CLINE_|OTEL_|TELEMETRY_|ERROR_SERVICE_|BUN_BE_BUN)/.test(key) && !/^(https?_proxy|all_proxy|no_proxy)$/i.test(key), ), ); @@ -88,31 +160,60 @@ async function runStartupScenario( CLINE_DIR: root, CLINE_DATA_DIR: join(root, "data"), CLINE_HUB_DISCOVERY_PATH: discoveryPath, + CLINE_NO_AUTO_UPDATE: "1", ...extraEnv, }); - // Bootstrap on an OS-assigned port using the same executable. Pin the - // desktop to that port so this test cannot touch a developer's real Hub, - // even if a regression makes it reject its own discovery record. - const bootstrap = spawnSync( - binary, - ["--remote-hub-ensure", "--discovery-path", discoveryPath, "--cwd", root], - { cwd: root, env, encoding: "utf8", timeout: 20_000 }, - ); - expect(bootstrap.status, bootstrap.stderr || String(bootstrap.error)).toBe( - 0, - ); - const hub = JSON.parse(readFileSync(discoveryPath, "utf8")); - hubPid = hub.pid; - env.CLINE_HUB_PORT = String(hub.port); - child = Bun.spawn([binary], { - cwd: root, - env, + // Pin the Hub to a free port so this test cannot touch a developer's + // real Hub even if a regression makes the backend reject the CLI's + // discovery record. + let existingHub: { pid: number; url: string } | undefined; + if (options.hubAlreadyRunning) { + // Another client (e.g. a terminal) already started the shared Hub. + const ensure = spawnSync( + cli, + [ + "hub", + "ensure", + "--json", + "--cwd", + root, + "--port", + "0", + "--allow-port-fallback", + "--no-connectors", + ], + { cwd: root, env, encoding: "utf8", timeout: 30_000 }, + ); + expect(ensure.status, ensure.stderr || String(ensure.error)).toBe(0); + const ensured = JSON.parse(ensure.stdout.trim().split("\n").at(-1) ?? ""); + const hub = JSON.parse(readFileSync(discoveryPath, "utf8")); + hubPid = hub.pid; + expect(ensured).toMatchObject({ + url: hub.url, + authToken: hub.authToken, + }); + env.CLINE_HUB_PORT = String(hub.port); + existingHub = { pid: hub.pid, url: hub.url }; + } else { + env.CLINE_HUB_PORT = String(await reserveLoopbackPort()); + } + + // The backend bundle on the CLI's runtime, started outside the + // workspace like the packaged app does. + child = Bun.spawn([cli, "run", "--no-env-file", bundle], { + cwd: dirname(bundle), + env: { + ...env, + BUN_BE_BUN: "1", + CLINE_DESKTOP_CLI_PATH: cli, + CLINE_DESKTOP_WORKSPACE_ROOT: root, + }, stdin: "ignore", stdout, stderr, }); let endpoint: string | undefined; - const deadline = Date.now() + 15_000; + const deadline = Date.now() + 30_000; while (Date.now() < deadline) { for (const line of readFileSync(stdoutPath, "utf8").split("\n")) { try { @@ -134,6 +235,21 @@ async function runStartupScenario( }); expect(health.ok).toBe(true); expect(await health.json()).toMatchObject({ ok: true, pid: child.pid }); + const hub = JSON.parse(readFileSync(discoveryPath, "utf8")); + hubPid = hub.pid; + if (existingHub) { + // The backend attached to the running Hub instead of replacing it. + expect(hub).toMatchObject(existingHub); + } else { + expect(hub.port).toBe(Number(env.CLINE_HUB_PORT)); + } + // Either way the Hub daemon is the bundled CLI, not the backend. + expect(hub.pid).not.toBe(child.pid); + const hubCommand = processCommandLine(hub.pid); + if (hubCommand !== undefined) { + expect(hubCommand).toContain(cli); + expect(hubCommand).toContain("--cline-hub-daemon"); + } } finally { if (child && child.exitCode === null) { child.kill(); @@ -141,7 +257,7 @@ async function runStartupScenario( if (child.exitCode === null) child.kill("SIGKILL"); await child.exited; } - // A failed bootstrap may still have published a discovery record. + // A failed start may still have published a discovery record. try { hubPid ??= JSON.parse(readFileSync(discoveryPath, "utf8")).pid; if (hubPid) process.kill(hubPid, "SIGTERM"); @@ -149,10 +265,10 @@ async function runStartupScenario( /* The isolated Hub may already have exited. */ } // Windows refuses to remove a directory that is any live process's cwd, - // and both the Hub and the backend run with `root` as theirs. The kill - // above only requests termination, so wait for the pid to actually go - // away; on POSIX the remove would have succeeded regardless, which is - // why this only ever failed on the Windows runner. + // and the Hub runs with `root` as its cwd. The kill above only requests + // termination, so wait for the pid to actually go away; on POSIX the + // remove would have succeeded regardless, which is why this only ever + // failed on the Windows runner. if (hubPid) { const gone = Date.now() + 10_000; while (Date.now() < gone) { @@ -182,17 +298,33 @@ async function runStartupScenario( } } -test("compiled desktop backend publishes its endpoint with its own Hub", async () => { - await runStartupScenario({}); -}, 100_000); +test("desktop backend has the bundled CLI start the Hub when none is running", async () => { + await runStartupScenario({}, { hubAlreadyRunning: false }); +}, 180_000); + +test("desktop backend reuses a compatible Hub the CLI already started", async () => { + await runStartupScenario({}, { hubAlreadyRunning: true }); +}, 180_000); // Bun's fetch has no localhost proxy bypass, so a system proxy used to swallow // the hub discovery probes (cline/cline#14265, #14292). -test("compiled desktop backend starts behind a dead HTTP(S) proxy", async () => { - await runStartupScenario({ - HTTP_PROXY: "http://127.0.0.1:9", - HTTPS_PROXY: "http://127.0.0.1:9", - http_proxy: "http://127.0.0.1:9", - https_proxy: "http://127.0.0.1:9", - }); -}, 100_000); +test("desktop startup works behind a dead HTTP(S) proxy", async () => { + await runStartupScenario( + { + HTTP_PROXY: "http://127.0.0.1:9", + HTTPS_PROXY: "http://127.0.0.1:9", + http_proxy: "http://127.0.0.1:9", + https_proxy: "http://127.0.0.1:9", + }, + { hubAlreadyRunning: false }, + ); +}, 180_000); + +for (const discoveryContents of ["{ invalid json", "{}"]) { + test(`desktop startup recovers from an invalid discovery record: ${discoveryContents}`, async () => { + await runStartupScenario( + {}, + { hubAlreadyRunning: false, discoveryContents }, + ); + }, 120_000); +} diff --git a/apps/examples/desktop-app/scripts/package-desktop.ts b/apps/examples/desktop-app/scripts/package-desktop.ts index c775acbf55..4fdaa63055 100644 --- a/apps/examples/desktop-app/scripts/package-desktop.ts +++ b/apps/examples/desktop-app/scripts/package-desktop.ts @@ -140,7 +140,7 @@ const assertCanBuildPlatform = (platform: DesktopPlatform): void => { throw new Error( [ `cannot build ${platform} desktop bundles from ${host}.`, - "Tauri desktop bundles are produced on the target OS because the native bundle tools and sidecar binary are platform-specific.", + "Tauri desktop bundles are produced on the target OS because the native bundle tools and bundled Cline CLI binary are platform-specific.", "Run this same package script on macOS, Windows, and Linux runners to produce all three artifact sets.", ].join("\n"), ); @@ -285,7 +285,7 @@ const main = async () => { if (!skipBuild) { // Linux ships deb and rpm only. The AppImage target is skipped because - // linuxdeploy cannot process the Bun-compiled sidecar (ldd fails on it + // linuxdeploy cannot process the Bun-compiled Cline CLI (ldd fails on it // and patchelf corrupts it), which aborts the whole bundle step. if (platform === "linux") { await $`bun run build:binary --bundles deb,rpm`; diff --git a/apps/examples/desktop-app/scripts/tauri-sign-windows.ps1 b/apps/examples/desktop-app/scripts/tauri-sign-windows.ps1 index 7fcca88e35..db3cc0d4ca 100644 --- a/apps/examples/desktop-app/scripts/tauri-sign-windows.ps1 +++ b/apps/examples/desktop-app/scripts/tauri-sign-windows.ps1 @@ -2,7 +2,7 @@ # # Invoked by the Tauri bundler through `bundle > windows > signCommand` (the # desktop-publish workflow generates a config overlay pointing here), once per -# binary it stages: the main app exe, the code-sidecar external binary, the +# binary it stages: the main app exe, the bundled cline-cli external binary, the # NSIS uninstaller, and the NSIS installer itself. # # Requirements (all provided by the desktop-publish Windows job): diff --git a/apps/examples/desktop-app/scripts/windows-installer.test.ts b/apps/examples/desktop-app/scripts/windows-installer.test.ts index 6dc4209f4b..8842d43397 100644 --- a/apps/examples/desktop-app/scripts/windows-installer.test.ts +++ b/apps/examples/desktop-app/scripts/windows-installer.test.ts @@ -158,7 +158,7 @@ Section Quit CreateDirectory "$INSTDIR" ClearErrors - FileOpen $0 "$INSTDIR\\code-sidecar.exe" w + FileOpen $0 "$INSTDIR\\cline-cli.exe" w IfErrors 0 +3 SetErrorLevel 4 Quit @@ -170,7 +170,7 @@ SectionEnd Section "Uninstall" !insertmacro NSIS_HOOK_PREUNINSTALL ClearErrors - Delete "$INSTDIR\\code-sidecar.exe" + Delete "$INSTDIR\\cline-cli.exe" Delete "$INSTDIR\\cline-app.exe" IfErrors 0 +2 SetErrorLevel 5 @@ -201,28 +201,28 @@ SectionEnd const dir = path.join(root, "fresh install"); expect(await run(installer, ["/S", `/D=${dir}`])).toBe(0); expect(await run(installer, ["/S", `/D=${dir}`])).toBe(0); - expect(readFileSync(path.join(dir, "code-sidecar.exe"), "utf8")).toBe( + expect(readFileSync(path.join(dir, "cline-cli.exe"), "utf8")).toBe( "replaced", ); }, 120_000); test("stops all target x64 processes, releases the file, and preserves another install", async () => { const dir = path.join(root, "Cline 'quoted' $ space (测试)"); - const beta = await start(`${dir} Beta`, "code-sidecar.exe"); + const beta = await start(`${dir} Beta`, "cline-cli.exe"); const targets = [ await start(dir, "cline-app.exe"), - await start(dir, "code-sidecar.exe"), - await start(dir, "code-sidecar.exe"), + await start(dir, "cline-cli.exe"), + await start(dir, "cline-cli.exe"), ]; // Windows must actually have the executable locked before the test. expect(() => - writeFileSync(path.join(dir, "code-sidecar.exe"), "locked"), + writeFileSync(path.join(dir, "cline-cli.exe"), "locked"), ).toThrow(); expect(await run(installer, ["/S", `/D=${dir}`])).toBe(0); await waitFor(() => targets.every((p) => p.exitCode !== null || p.signalCode !== null), ); - expect(readFileSync(path.join(dir, "code-sidecar.exe"), "utf8")).toBe( + expect(readFileSync(path.join(dir, "cline-cli.exe"), "utf8")).toBe( "replaced", ); expect(beta.exitCode).toBeNull(); @@ -232,14 +232,25 @@ SectionEnd test("uninstaller releases the same files before deleting them", async () => { const dir = path.join(root, "uninstall"); expect(await run(installer, ["/S", `/D=${dir}`])).toBe(0); - copyFileSync(fixture, path.join(dir, "code-sidecar.exe")); - const target = await start(dir, "code-sidecar.exe"); + copyFileSync(fixture, path.join(dir, "cline-cli.exe")); + const target = await start(dir, "cline-cli.exe"); // _?= disables NSIS's temp-copy trampoline so we wait for the real exit. expect( await run(path.join(dir, "uninstall.exe"), ["/S", `_?=${dir}`]), ).toBe(0); await waitFor(() => target.exitCode !== null || target.signalCode !== null); + expect(existsSync(path.join(dir, "cline-cli.exe"))).toBe(false); + }, 120_000); + + test("upgrade stops and removes the earlier backend executable", async () => { + const dir = path.join(root, "upgrade"); + const legacy = await start(dir, "code-sidecar.exe"); + expect(await run(installer, ["/S", `/D=${dir}`])).toBe(0); + await waitFor(() => legacy.exitCode !== null || legacy.signalCode !== null); expect(existsSync(path.join(dir, "code-sidecar.exe"))).toBe(false); + expect(readFileSync(path.join(dir, "cline-cli.exe"), "utf8")).toBe( + "replaced", + ); }, 120_000); // Only silent mode is automatable: passive (/P, what the Tauri updater @@ -249,9 +260,9 @@ SectionEnd // RmRegisterResources rejects directories with ERROR_ACCESS_DENIED. // This exercises a real native error, not an injected API mock. mkdirSync(path.join(dir, "cline-app.exe"), { recursive: true }); - writeFileSync(path.join(dir, "code-sidecar.exe"), "untouched"); + writeFileSync(path.join(dir, "cline-cli.exe"), "untouched"); expect(await run(installer, ["/S", `/D=${dir}`])).toBe(2); - expect(readFileSync(path.join(dir, "code-sidecar.exe"), "utf8")).toBe( + expect(readFileSync(path.join(dir, "cline-cli.exe"), "utf8")).toBe( "untouched", ); expect(existsSync(path.join(dir, "uninstall.exe"))).toBe(false); diff --git a/apps/examples/desktop-app/sidecar/ARCHITECTURE.md b/apps/examples/desktop-app/sidecar/ARCHITECTURE.md index d1ba555e4b..1716153ce6 100644 --- a/apps/examples/desktop-app/sidecar/ARCHITECTURE.md +++ b/apps/examples/desktop-app/sidecar/ARCHITECTURE.md @@ -82,9 +82,11 @@ sessionManager.subscribe((event) => { }); ``` -The compiled sidecar also recognizes Core's Hub-daemon launch mode. This lets -the desktop start the same detached Hub when no CLI process has started it yet. -Startup discovery and locking ensure concurrent clients converge on one Hub. +The packaged backend runs on the bundled Cline CLI and has it start the Hub: +`cline-cli hub ensure` reuses a compatible healthy Hub or starts one, and the +Hub daemon is that same CLI binary. Any Hub the backend itself has to start +later goes through the same binary (`setHubDaemonLauncher`). Startup discovery +and locking ensure concurrent clients converge on one Hub. Every create, restart, fork, and restore also attaches the serializable Desktop `ExtensionContext.client` and current `ExtensionContext.user`. Core forwards diff --git a/apps/examples/desktop-app/sidecar/cli-runtime.test.ts b/apps/examples/desktop-app/sidecar/cli-runtime.test.ts new file mode 100644 index 0000000000..175ae78714 --- /dev/null +++ b/apps/examples/desktop-app/sidecar/cli-runtime.test.ts @@ -0,0 +1,74 @@ +import { describe, expect, it, vi } from "vitest"; +import { adoptDesktopCliRuntime, ensureHubWithDesktopCli } from "./cli-runtime"; + +describe("ensureHubWithDesktopCli", () => { + it("asks the bundled CLI to start or reuse the Hub and returns only its URL", async () => { + const run = vi.fn( + async () => + 'warming up\n{"url":"ws://127.0.0.1:25463/hub","authToken":"secret","cwd":"/work"}\n', + ); + + await expect( + ensureHubWithDesktopCli("/opt/cline/cline-cli", "/work", { + env: { PATH: "/usr/bin" }, + run, + }), + ).resolves.toEqual({ url: "ws://127.0.0.1:25463/hub" }); + expect(run).toHaveBeenCalledWith( + "/opt/cline/cline-cli", + ["hub", "ensure", "--json", "--cwd", "/work"], + { + cwd: "/work", + env: { PATH: "/usr/bin", CLINE_NO_AUTO_UPDATE: "1" }, + timeoutMs: 60_000, + }, + ); + }); + + it("rejects when the CLI does not report a Hub URL", async () => { + await expect( + ensureHubWithDesktopCli("/opt/cline/cline-cli", "/work", { + env: {}, + run: async () => "", + }), + ).rejects.toThrow("did not report a Hub URL"); + }); +}); + +describe("adoptDesktopCliRuntime", () => { + it("routes Hub spawns through the bundled CLI and restores the workspace cwd", () => { + const env: NodeJS.ProcessEnv = { + BUN_BE_BUN: "1", + CLINE_DESKTOP_CLI_PATH: + "/Applications/Cline.app/Contents/MacOS/cline-cli", + CLINE_DESKTOP_WORKSPACE_ROOT: "/Users/dev/project", + }; + const chdir = vi.fn(); + const setLauncher = vi.fn(); + + expect(adoptDesktopCliRuntime({ env, chdir, setLauncher })).toEqual({ + cliPath: "/Applications/Cline.app/Contents/MacOS/cline-cli", + }); + + expect(env.BUN_BE_BUN).toBeUndefined(); + expect(env.CLINE_DESKTOP_WORKSPACE_ROOT).toBeUndefined(); + expect(env.CLINE_DESKTOP_CLI_PATH).toBe( + "/Applications/Cline.app/Contents/MacOS/cline-cli", + ); + expect(chdir).toHaveBeenCalledWith("/Users/dev/project"); + expect(setLauncher).toHaveBeenCalledWith({ + command: "/Applications/Cline.app/Contents/MacOS/cline-cli", + }); + }); + + it("keeps the default Hub launcher when no bundled CLI is provided", () => { + const chdir = vi.fn(); + const setLauncher = vi.fn(); + + expect(adoptDesktopCliRuntime({ env: {}, chdir, setLauncher })).toEqual({ + cliPath: undefined, + }); + expect(chdir).not.toHaveBeenCalled(); + expect(setLauncher).not.toHaveBeenCalled(); + }); +}); diff --git a/apps/examples/desktop-app/sidecar/cli-runtime.ts b/apps/examples/desktop-app/sidecar/cli-runtime.ts new file mode 100644 index 0000000000..3f46da4ef9 --- /dev/null +++ b/apps/examples/desktop-app/sidecar/cli-runtime.ts @@ -0,0 +1,111 @@ +import { execFile } from "node:child_process"; +import { setHubDaemonLauncher } from "@cline/core"; + +/** Absolute path of the Cline CLI binary the desktop shell bundles and launched us with. */ +export const DESKTOP_CLI_PATH_ENV = "CLINE_DESKTOP_CLI_PATH"; +/** Workspace root the shell resolved; the backend starts in its own resource directory. */ +export const DESKTOP_WORKSPACE_ROOT_ENV = "CLINE_DESKTOP_WORKSPACE_ROOT"; +/** Makes a compiled Bun executable run a script instead of its embedded entrypoint. */ +export const BUN_BE_BUN_ENV = "BUN_BE_BUN"; + +export function resolveDesktopCliPath( + env: NodeJS.ProcessEnv = process.env, +): string | undefined { + return env[DESKTOP_CLI_PATH_ENV]?.trim() || undefined; +} + +/** + * The packaged backend is a script running on the bundled Cline CLI's embedded + * runtime. Adopt that host before anything spawns: every child (the Hub + * daemon, connectors, agent tools) must see the CLI binary as the CLI, not as + * a script runner, and Hubs this process starts must be CLI-managed Hubs. + */ +export function adoptDesktopCliRuntime( + options: { + env?: NodeJS.ProcessEnv; + chdir?: (directory: string) => void; + setLauncher?: typeof setHubDaemonLauncher; + } = {}, +): { cliPath?: string } { + const env = options.env ?? process.env; + const chdir = options.chdir ?? ((directory) => process.chdir(directory)); + const setLauncher = options.setLauncher ?? setHubDaemonLauncher; + delete env[BUN_BE_BUN_ENV]; + // The shell starts the runtime outside the workspace so it cannot pick up a + // workspace .env or bunfig.toml; workspace-relative code expects that cwd. + const workspaceRoot = env[DESKTOP_WORKSPACE_ROOT_ENV]?.trim(); + delete env[DESKTOP_WORKSPACE_ROOT_ENV]; + if (workspaceRoot) { + chdir(workspaceRoot); + } + const cliPath = resolveDesktopCliPath(env); + if (cliPath) { + setLauncher({ command: cliPath }); + } + return { cliPath }; +} + +const HUB_ENSURE_TIMEOUT_MS = 60_000; + +export type RunCliCommand = ( + command: string, + args: string[], + options: { cwd: string; env: NodeJS.ProcessEnv; timeoutMs: number }, +) => Promise; + +const runCliCommand: RunCliCommand = (command, args, options) => + new Promise((resolve, reject) => { + execFile( + command, + args, + { + cwd: options.cwd, + env: options.env, + timeout: options.timeoutMs, + windowsHide: true, + maxBuffer: 1024 * 1024, + }, + (error, stdout, stderr) => { + if (error) { + const detail = String(stderr).trim(); + reject( + new Error(detail ? `${error.message}: ${detail}` : error.message), + ); + return; + } + resolve(String(stdout)); + }, + ); + }); + +/** + * Has the bundled CLI start the shared Hub, or reuse a compatible healthy + * one, before the session manager connects. Returns the Hub URL (never the + * auth token, which callers may log). + */ +export async function ensureHubWithDesktopCli( + cliPath: string, + workspaceRoot: string, + options: { env?: NodeJS.ProcessEnv; run?: RunCliCommand } = {}, +): Promise<{ url: string }> { + const run = options.run ?? runCliCommand; + const stdout = await run( + cliPath, + ["hub", "ensure", "--json", "--cwd", workspaceRoot], + { + cwd: workspaceRoot, + env: { ...(options.env ?? process.env), CLINE_NO_AUTO_UPDATE: "1" }, + timeoutMs: HUB_ENSURE_TIMEOUT_MS, + }, + ); + const line = stdout + .split(/\r?\n/) + .map((value) => value.trim()) + .filter((value) => value.startsWith("{")) + .at(-1); + const parsed = line ? (JSON.parse(line) as { url?: unknown }) : undefined; + if (typeof parsed?.url !== "string" || !parsed.url) { + throw new Error("cline hub ensure did not report a Hub URL"); + } + return { url: parsed.url }; +} diff --git a/apps/examples/desktop-app/sidecar/connectors.test.ts b/apps/examples/desktop-app/sidecar/connectors.test.ts index 39ffd7d520..9826f041ba 100644 --- a/apps/examples/desktop-app/sidecar/connectors.test.ts +++ b/apps/examples/desktop-app/sidecar/connectors.test.ts @@ -29,6 +29,18 @@ describe("desktop connector lifecycle", () => { }); }); + it("runs connector commands through the bundled CLI when the shell provides one", () => { + expect( + __test__.buildCliConnectCommand("/repo", ["telegram"], { + desktopCliPath: "C:\\Program Files\\Cline\\cline-cli.exe", + execPath: "C:\\Program Files\\Cline\\cline-cli.exe", + }), + ).toEqual({ + launcher: "C:\\Program Files\\Cline\\cline-cli.exe", + childArgs: ["connect", "telegram"], + }); + }); + it("uses the atomic restart command for an active channel", () => { expect( __test__.buildConnectorLaunchArgs(["telegram", "-k", "token"], true), diff --git a/apps/examples/desktop-app/sidecar/connectors.ts b/apps/examples/desktop-app/sidecar/connectors.ts index c808d057f3..4c4ea17fe0 100644 --- a/apps/examples/desktop-app/sidecar/connectors.ts +++ b/apps/examples/desktop-app/sidecar/connectors.ts @@ -10,6 +10,7 @@ import { setConnectorCliLaunchSpec, withResolvedClineBuildEnv, } from "@cline/shared"; +import { resolveDesktopCliPath } from "./cli-runtime"; import type { JsonRecord } from "./types"; type ConnectorField = { @@ -113,9 +114,14 @@ function buildCliConnectCommand( options: { execPath?: string; cliPath?: string; + desktopCliPath?: string; exists?: (path: string) => boolean; } = {}, ): CliConnectCommand { + const desktopCliPath = options.desktopCliPath ?? resolveDesktopCliPath(); + if (desktopCliPath) { + return { launcher: desktopCliPath, childArgs: ["connect", ...args] }; + } const execPath = options.execPath ?? process.execPath; const cliPath = options.cliPath ?? normalize(join(workspaceRoot, "apps/cli/src/index.ts")); diff --git a/apps/examples/desktop-app/sidecar/index.ts b/apps/examples/desktop-app/sidecar/index.ts index b18d906648..be7dfcd550 100644 --- a/apps/examples/desktop-app/sidecar/index.ts +++ b/apps/examples/desktop-app/sidecar/index.ts @@ -8,15 +8,15 @@ import { setModelToolEnabledGlobally, watchManagedHubBuildMismatch, } from "@cline/core"; -import { runRemoteHelperEntrypoint } from "@cline/core/remote/helper"; import { + buildTelemetrySelfcheckReport, captureSdkError, - claimHubDaemonProcess, disableCurrentDirectoryExecutableSearch, ensureLoopbackProxyBypass, setClineClientIdentity, } from "@cline/shared"; import { prewarmWorkspaceMetadata } from "./chat-session"; +import { adoptDesktopCliRuntime, ensureHubWithDesktopCli } from "./cli-runtime"; import { DESKTOP_CLIENT_CONTEXT } from "./client-context"; import { configureConnectorCliLaunch } from "./connectors"; import { @@ -29,7 +29,6 @@ import { createDesktopObservability } from "./observability"; import { configurePluginBootstrap } from "./plugin-bootstrap"; import { resolveWorkspaceRoot } from "./paths"; import { startServer } from "./server"; -import { buildTelemetrySelfcheckReport } from "./telemetry-selfcheck"; import { BunRuntime, SIDECAR_HOST, SIDECAR_MODE, SIDECAR_PORT } from "./types"; const SHUTDOWN_TIMEOUT_MS = 5_000; @@ -54,7 +53,7 @@ function withTimeout(promise: Promise, timeoutMs: number): Promise { }); } -async function main() { +async function main(desktopCliPath?: string) { if (!BunRuntime) { throw new Error("sidecar must be run with Bun"); } @@ -96,6 +95,18 @@ async function main() { "Login shell PATH resolution", await shellPathPromise, ); + // The packaged app hands Hub startup to its bundled CLI: it reuses a + // compatible healthy Hub or starts one, and the session manager attaches. + // Afterwards the PATH above is set, so a new Hub inherits it. On failure + // the session manager still starts the Hub through the same CLI launcher. + if (desktopCliPath) { + try { + const hub = await ensureHubWithDesktopCli(desktopCliPath, workspaceRoot); + observability.logger.log("Hub ready via Cline CLI", { url: hub.url }); + } catch (error) { + observability.logger.error?.("Cline CLI hub ensure failed", { error }); + } + } await initializeSessionManager(ctx); let shuttingDown = false; @@ -221,8 +232,8 @@ async function main() { /** * Prints whether the telemetry configuration that was inlined at build time - * (see scripts/telemetry-define-args.ts) actually made it into this binary, - * then exits. CI runs this against the packaged sidecar and fails the + * (see scripts/telemetry-define-args.ts) actually made it into this bundle, + * then exits. CI runs this against the packaged backend and fails the * publish when a release-grade build reports `"enabled":false` or an * unusable OTLP endpoint, so a regression in the build-time inlining can * never ship silently again. @@ -235,31 +246,20 @@ function runTelemetrySelfcheck(): void { } async function runEntrypoint(): Promise { - // Before the daemon-sentinel claim: the selfcheck only inspects build-time - // config and must not consume the sentinel or start anything. + // The selfcheck only inspects build-time config and must not start anything. if (process.argv.includes("--telemetry-selfcheck")) { runTelemetrySelfcheck(); return; } + const { cliPath } = adoptDesktopCliRuntime(); setClineClientIdentity(DESKTOP_CLIENT_CONTEXT); disableCurrentDirectoryExecutableSearch(); - // Before the Hub daemon (this binary, re-executed) loads any plugin. + // Before the bundled CLI starts the Hub daemon and loads any plugin. configurePluginBootstrap(); // Before the Hub daemon and agent-spawned processes inherit this env. ensureLoopbackProxyBypass(); - // Claim the Hub daemon sentinel here, not in the shared remote helper: its - // daemon import resolves to the dist build of @cline/core while this bundle - // resolves the source build, and a daemon from the other copy publishes a - // different build id, so the sidecar would retire its own Hub on launch. - if (claimHubDaemonProcess()) { - await import("@cline/core/hub/daemon-entry"); - return; - } - if (await runRemoteHelperEntrypoint()) { - return; - } - await main(); + await main(cliPath); } runEntrypoint().catch(async (error) => { diff --git a/apps/examples/desktop-app/sidecar/plugin-bootstrap.test.ts b/apps/examples/desktop-app/sidecar/plugin-bootstrap.test.ts index 0ef71d22c7..1b50d672af 100644 --- a/apps/examples/desktop-app/sidecar/plugin-bootstrap.test.ts +++ b/apps/examples/desktop-app/sidecar/plugin-bootstrap.test.ts @@ -28,7 +28,7 @@ function withLayout( it("finds the bootstrap in the macOS app bundle resources", () => { withLayout( (root) => join(root, "Cline.app", "Contents", "Resources"), - (root) => join(root, "Cline.app", "Contents", "MacOS", "code-sidecar"), + (root) => join(root, "Cline.app", "Contents", "MacOS", "cline-cli"), (bootstrap, execPath) => { expect(resolveDesktopPluginBootstrap({ execPath, cwd: tmpdir() })).toBe( bootstrap, @@ -40,7 +40,7 @@ it("finds the bootstrap in the macOS app bundle resources", () => { it("finds the bootstrap in the Linux and Windows resource layouts", () => { withLayout( (root) => join(root, "usr", "lib", "Cline"), - (root) => join(root, "usr", "bin", "code-sidecar"), + (root) => join(root, "usr", "bin", "cline-cli"), (bootstrap, execPath) => { expect(resolveDesktopPluginBootstrap({ execPath, cwd: tmpdir() })).toBe( bootstrap, @@ -49,7 +49,7 @@ it("finds the bootstrap in the Linux and Windows resource layouts", () => { ); withLayout( (root) => root, - (root) => join(root, "code-sidecar.exe"), + (root) => join(root, "cline-cli.exe"), (bootstrap, execPath) => { expect(resolveDesktopPluginBootstrap({ execPath, cwd: tmpdir() })).toBe( bootstrap, diff --git a/apps/examples/desktop-app/sidecar/plugin-bootstrap.ts b/apps/examples/desktop-app/sidecar/plugin-bootstrap.ts index 872318642d..fb08251387 100644 --- a/apps/examples/desktop-app/sidecar/plugin-bootstrap.ts +++ b/apps/examples/desktop-app/sidecar/plugin-bootstrap.ts @@ -7,9 +7,8 @@ const BUNDLED_BOOTSTRAP = join("bin", "extensions", "plugin-sandbox-bootstrap.js /** * Finds the plugin worker bootstrap the app bundle ships as a Tauri resource. - * The compiled sidecar cannot locate it from its own module path (that points - * inside the binary), so the Hub daemon it runs would otherwise fall back to - * loading plugins in-process. + * The backend and CLI-managed Hub need an on-disk bootstrap to run plugins + * in workers; without it they would fall back to loading plugins in-process. */ export function resolveDesktopPluginBootstrap( options: { execPath?: string; cwd?: string } = {}, diff --git a/apps/examples/desktop-app/sidecar/remote-helper.test.ts b/apps/examples/desktop-app/sidecar/remote-helper.test.ts index 202e1c3a33..55b5c9bb1d 100644 --- a/apps/examples/desktop-app/sidecar/remote-helper.test.ts +++ b/apps/examples/desktop-app/sidecar/remote-helper.test.ts @@ -15,7 +15,7 @@ it("finds SSH helpers in the installed Windows resource layout", () => { writeFileSync(helper, "helper"); expect( resolveDesktopRemoteHelper(target, { - execPath: join(root, "code-sidecar.exe"), + execPath: join(root, "cline-cli.exe"), cwd: tmpdir(), env: {}, }), @@ -29,25 +29,35 @@ it("finds SSH helpers in the installed Windows resource layout", () => { const FAT_MACHO_HEADER = Buffer.from("cafebabe00000002", "hex"); const THIN_ARM64_MACHO_HEADER = Buffer.from("cffaedfe0c000001", "hex"); -it("uses the packaged universal macOS sidecar as the helper for Mac remotes", () => { +it("uses the bundled universal macOS CLI for Mac remotes", () => { const root = mkdtempSync(join(tmpdir(), "cline-packaged-helpers-")); try { const macOS = join(root, "Cline.app", "Contents", "MacOS"); mkdirSync(macOS, { recursive: true }); - const sidecar = join(macOS, "code-sidecar"); - writeFileSync(sidecar, FAT_MACHO_HEADER); + const cli = join(macOS, "cline-cli"); + writeFileSync(cli, FAT_MACHO_HEADER); for (const arch of ["arm64", "x64"] as const) { expect( resolveDesktopRemoteHelper( { platform: "darwin", arch }, - { execPath: sidecar, cwd: tmpdir(), env: {}, platform: "darwin" }, + { + execPath: cli, + cwd: tmpdir(), + env: { CLINE_DESKTOP_CLI_PATH: cli }, + platform: "darwin", + }, ), - ).toBe(sidecar); + ).toBe(cli); } expect( resolveDesktopRemoteHelper( { platform: "linux", arch: "arm64" }, - { execPath: sidecar, cwd: tmpdir(), env: {}, platform: "darwin" }, + { + execPath: cli, + cwd: tmpdir(), + env: { CLINE_DESKTOP_CLI_PATH: cli }, + platform: "darwin", + }, ), ).toBeUndefined(); } finally { @@ -55,17 +65,17 @@ it("uses the packaged universal macOS sidecar as the helper for Mac remotes", () } }); -it("only offers a thin packaged macOS sidecar to Mac remotes of its own architecture", () => { +it("only offers a thin bundled macOS CLI to Mac remotes of its own architecture", () => { const root = mkdtempSync(join(tmpdir(), "cline-packaged-helpers-")); try { const macOS = join(root, "Cline.app", "Contents", "MacOS"); mkdirSync(macOS, { recursive: true }); - const sidecar = join(macOS, "code-sidecar"); - writeFileSync(sidecar, THIN_ARM64_MACHO_HEADER); + const cli = join(macOS, "cline-cli"); + writeFileSync(cli, THIN_ARM64_MACHO_HEADER); const options = { - execPath: sidecar, + execPath: cli, cwd: tmpdir(), - env: {}, + env: { CLINE_DESKTOP_CLI_PATH: cli }, platform: "darwin" as const, }; expect( @@ -73,7 +83,7 @@ it("only offers a thin packaged macOS sidecar to Mac remotes of its own architec { platform: "darwin", arch: "arm64" }, options, ), - ).toBe(sidecar); + ).toBe(cli); expect( resolveDesktopRemoteHelper({ platform: "darwin", arch: "x64" }, options), ).toBeUndefined(); @@ -82,13 +92,13 @@ it("only offers a thin packaged macOS sidecar to Mac remotes of its own architec } }); -it("uses the compiled sidecar for Mac remotes under tauri dev on macOS", () => { +it("uses the compiled CLI for Mac remotes under tauri dev on macOS", () => { const root = mkdtempSync(join(tmpdir(), "cline-dev-helpers-")); try { const bin = join(root, "src-tauri", "bin"); mkdirSync(bin, { recursive: true }); - const sidecar = join(bin, "code-sidecar-aarch64-apple-darwin"); - writeFileSync(sidecar, "sidecar"); + const cli = join(bin, "cline-cli-aarch64-apple-darwin"); + writeFileSync(cli, "cli"); const options = { execPath: "/usr/local/bin/bun", cwd: root, @@ -100,7 +110,7 @@ it("uses the compiled sidecar for Mac remotes under tauri dev on macOS", () => { { platform: "darwin", arch: "arm64" }, options, ), - ).toBe(sidecar); + ).toBe(cli); expect( resolveDesktopRemoteHelper({ platform: "darwin", arch: "x64" }, options), ).toBeUndefined(); @@ -109,7 +119,7 @@ it("uses the compiled sidecar for Mac remotes under tauri dev on macOS", () => { } }); -it("finds SSH helpers in the installed Linux resource layout", () => { +it("selects only the running Linux channels SSH helpers", () => { const root = mkdtempSync(join(tmpdir(), "cline-packaged-helpers-")); try { const target = { platform: "linux", arch: "arm64" } as const; @@ -124,13 +134,35 @@ it("finds SSH helpers in the installed Linux resource layout", () => { mkdirSync(directory, { recursive: true }); const helper = join(directory, remoteHelperBinaryFilename(target)); writeFileSync(helper, "helper"); + const otherDirectory = join( + root, + "usr", + "lib", + "Cline", + "bin", + "remote-helpers", + ); + mkdirSync(otherDirectory, { recursive: true }); + writeFileSync( + join(otherDirectory, remoteHelperBinaryFilename(target)), + "other channel", + ); expect( resolveDesktopRemoteHelper(target, { - execPath: join(root, "usr", "bin", "code-sidecar"), + execPath: join(root, "usr", "bin", "cline-cli"), cwd: tmpdir(), - env: {}, + env: { CLINE_REMOTE_HELPER_DIRECTORY: directory }, }), ).toBe(helper); + // A missing binary in this channel must not fall back to another app. + rmSync(helper); + expect( + resolveDesktopRemoteHelper(target, { + execPath: join(root, "usr", "bin", "cline-cli"), + cwd: tmpdir(), + env: { CLINE_REMOTE_HELPER_DIRECTORY: directory }, + }), + ).toBeUndefined(); } finally { rmSync(root, { recursive: true, force: true }); } diff --git a/apps/examples/desktop-app/sidecar/remote-helper.ts b/apps/examples/desktop-app/sidecar/remote-helper.ts index 2b8f90ef3b..fc473e468a 100644 --- a/apps/examples/desktop-app/sidecar/remote-helper.ts +++ b/apps/examples/desktop-app/sidecar/remote-helper.ts @@ -1,15 +1,13 @@ -import { - closeSync, - existsSync, - openSync, - readdirSync, - readSync, -} from "node:fs"; -import { basename, dirname, join } from "node:path"; +import { closeSync, existsSync, openSync, readdirSync, readSync } from "node:fs"; +import { dirname, join } from "node:path"; import { type RemoteHelperTarget, remoteHelperBinaryFilename, } from "@cline/core"; +import { resolveDesktopCliPath } from "./cli-runtime"; + +/** Base name of the Cline CLI binary the desktop app bundles (Tauri externalBin). */ +export const DESKTOP_CLI_BINARY_NAME = "cline-cli"; // Tauri's Linux bundles (deb, rpm, AppImage) install binaries under `usr/bin` // and resources under `usr/lib/`. The product name differs per @@ -36,8 +34,8 @@ const MACHO_CPU_TYPE: Record = { }; // Published desktop builds are universal, but `package:desktop:mac` and local -// `tauri build` produce a thin host-arch sidecar that cannot run on a Mac of -// the other architecture. A fat binary starts with a big-endian magic; a thin +// `tauri build` produce a thin host-arch CLI that cannot run on a Mac of the +// other architecture. A fat binary starts with a big-endian magic; a thin // 64-bit Mach-O starts with its little-endian magic followed by the cputype. function machoRunsOn(path: string, arch: RemoteHelperTarget["arch"]): boolean { const header = Buffer.alloc(8); @@ -58,25 +56,23 @@ function machoRunsOn(path: string, arch: RemoteHelperTarget["arch"]): boolean { ); } -// macOS bundles carry no dedicated darwin helper: Tauri signs only externalBin -// and the main binary, so a Mach-O under Contents/Resources would ship -// unsigned and fail notarization. The sidecar already runs the shared helper -// entrypoint (see index.ts) and is the signed, notarized, universal Mach-O in -// the bundle, so it serves both x64 and arm64 Mac remotes from a Mac. Under -// `tauri dev` the sidecar runs as a script, so use the compiled sidecar that -// beforeDevCommand builds for the matching architecture instead. -function macSidecarCandidates( +// macOS bundles carry no separate darwin copies of the CLI: Tauri signs only +// externalBin and the main binary, so a Mach-O under Contents/Resources would +// ship unsigned and fail notarization. The bundled CLI is the signed, +// notarized, universal Mach-O in the bundle, so it serves both x64 and arm64 +// Mac remotes from a Mac. Under `tauri dev` the backend may run on a system +// Bun, so fall back to the CLI that beforeDevCommand compiles. +function macCliCandidates( target: RemoteHelperTarget, - execPath: string, + desktopCliPath: string | undefined, cwd: string, ): string[] { - const compiledSidecar = `code-sidecar-${target.arch === "arm64" ? "aarch64" : "x86_64"}-apple-darwin`; + const compiledCli = `${DESKTOP_CLI_BINARY_NAME}-${target.arch === "arm64" ? "aarch64" : "x86_64"}-apple-darwin`; return [ - ...(basename(execPath).startsWith("code-sidecar") && - machoRunsOn(execPath, target.arch) - ? [execPath] + ...(desktopCliPath && machoRunsOn(desktopCliPath, target.arch) + ? [desktopCliPath] : []), - join(cwd, "src-tauri", "bin", compiledSidecar), + join(cwd, "src-tauri", "bin", compiledCli), join( cwd, "apps", @@ -84,7 +80,7 @@ function macSidecarCandidates( "desktop-app", "src-tauri", "bin", - compiledSidecar, + compiledCli, ), ]; } @@ -111,12 +107,11 @@ export function resolveDesktopRemoteHelper( ? [join(env.CLINE_REMOTE_HELPER_DIRECTORY, filename)] : []), ...(target.platform === "darwin" && platform === "darwin" - ? macSidecarCandidates(target, execPath, cwd) + ? macCliCandidates(target, resolveDesktopCliPath(env), cwd) : []), join(executableDirectory, "remote-helpers", filename), join(executableDirectory, bundledPath), join(executableDirectory, "..", "Resources", bundledPath), - ...linuxResourceCandidates(executableDirectory, bundledPath), join(cwd, "src-tauri", bundledPath), join(cwd, "apps", "examples", "desktop-app", "src-tauri", bundledPath), ].find(existsSync); diff --git a/apps/examples/desktop-app/src-tauri/nsis/installer-hooks.nsh b/apps/examples/desktop-app/src-tauri/nsis/installer-hooks.nsh index 2013932543..8d403e6d84 100644 --- a/apps/examples/desktop-app/src-tauri/nsis/installer-hooks.nsh +++ b/apps/examples/desktop-app/src-tauri/nsis/installer-hooks.nsh @@ -1,8 +1,10 @@ !include "Win\RestartManager.nsh" -; The Hub re-executes code-sidecar.exe as a detached daemon and outlives the -; desktop. Stop users of both installed executables before replacing either: -; stopping only the sidecar first leaves the desktop able to spawn it again. +; The bundled cline-cli.exe runs the desktop backend and, as a detached +; daemon, the Hub, which outlives the desktop. Stop users of every installed +; executable before replacing any: stopping only the CLI first leaves the +; desktop able to spawn it again. code-sidecar.exe is the backend and Hub +; executable of earlier releases; its Hub may still be running on upgrade. ; ; Restart Manager identifies users of the exact files across architectures. ; Do not use Get-Process.Path: NSIS is x86 even for an x64 bundle, and x86 @@ -26,6 +28,11 @@ Goto end_session ${EndIf} + !insertmacro RestartManager_RegisterFile $1 "$INSTDIR\cline-cli.exe" + ${If} $0 != 0 + Goto end_session + ${EndIf} + !insertmacro RestartManager_RegisterFile $1 "$INSTDIR\code-sidecar.exe" ${If} $0 != 0 Goto end_session @@ -64,6 +71,8 @@ Abort done: + ; Nothing installs the earlier backend executable any more. + Delete "$INSTDIR\code-sidecar.exe" Pop $4 Pop $3 Pop $2 diff --git a/apps/examples/desktop-app/src-tauri/src/main.rs b/apps/examples/desktop-app/src-tauri/src/main.rs index 04ab8fa7ae..aecb764b09 100644 --- a/apps/examples/desktop-app/src-tauri/src/main.rs +++ b/apps/examples/desktop-app/src-tauri/src/main.rs @@ -94,6 +94,7 @@ struct TrayMenuState { struct AppContext { launch_cwd: String, workspace_root: String, + resource_dir: PathBuf, } #[derive(Debug, Clone, Serialize)] @@ -361,7 +362,7 @@ impl DesktopBackendState { let _ = Command::new("kill").arg(child.id().to_string()).status(); // Windows has no SIGTERM equivalent, so terminate outright. // Reap the child too: TerminateProcess is quick, and the - // update-restart path needs the sidecar exe's file lock + // update-restart path needs the bundled CLI's file lock // released before the NSIS installer replaces it. #[cfg(not(unix))] { @@ -396,7 +397,7 @@ struct DesktopBackendReadyLine { } /// The release binary is a GUI-subsystem app (no console), so on Windows -/// every console-subsystem child (git, cmd, the sidecar) would otherwise +/// every console-subsystem child (git, cmd, the bundled CLI) would otherwise /// allocate its own visible console window. Piped stdio does not prevent /// that; only CREATE_NO_WINDOW does. #[cfg(windows)] @@ -456,9 +457,12 @@ fn resolve_desktop_backend_script_path(context: &AppContext) -> Option candidates.into_iter().find(|path| path.exists()) } -fn desktop_backend_binary_names() -> Vec { +/// The bundled Cline CLI (a Tauri `externalBin`). It starts or reuses the +/// shared Hub, runs as the Hub daemon, and is the runtime the packaged +/// desktop backend script executes on, so the app needs no Node or Bun. +fn desktop_cli_binary_names() -> Vec { let extension = if cfg!(windows) { ".exe" } else { "" }; - let bundled_name = format!("code-sidecar{extension}"); + let bundled_name = format!("cline-cli{extension}"); let target_triple = option_env!("TAURI_ENV_TARGET_TRIPLE").unwrap_or("").trim(); if target_triple.is_empty() { return vec![bundled_name]; @@ -466,72 +470,108 @@ fn desktop_backend_binary_names() -> Vec { vec![ bundled_name, - format!("code-sidecar-{target_triple}{extension}"), + format!("cline-cli-{target_triple}{extension}"), ] } -fn resolve_desktop_backend_binary_path(context: &AppContext) -> Option { - if cfg!(debug_assertions) { - return None; - } - let explicit = std::env::var("CLINE_CODE_SIDECAR_BIN") - .ok() - .map(|value| value.trim().to_string()) - .filter(|value| !value.is_empty()) - .map(PathBuf::from); +fn non_empty_env_path(name: &str) -> Option { + non_empty_env(name).map(PathBuf::from) +} + +fn desktop_app_bin_dir(context: &AppContext) -> PathBuf { + PathBuf::from(&context.workspace_root) + .join("apps") + .join("examples") + .join("desktop-app") + .join("src-tauri") + .join("bin") +} + +fn resolve_desktop_cli_path(context: &AppContext) -> Option { let current_exe = std::env::current_exe().ok(); let mut candidates = Vec::new(); - if let Some(path) = explicit { + if let Some(path) = non_empty_env_path("CLINE_DESKTOP_CLI_BIN") { candidates.push(path); } - - for binary_name in desktop_backend_binary_names() { - candidates.push( - PathBuf::from(&context.workspace_root) - .join("apps") - .join("examples") - .join("desktop-app") - .join("src-tauri") - .join("bin") - .join(&binary_name), - ); + for binary_name in desktop_cli_binary_names() { if let Some(path) = current_exe .as_ref() .and_then(|path| path.parent().map(|parent| parent.join(&binary_name))) { candidates.push(path); } - if let Some(path) = current_exe.as_ref().and_then(|path| { - path.parent() - .and_then(|parent| parent.parent()) - .map(|parent| parent.join("Resources").join(&binary_name)) - }) { - candidates.push(path); - } + candidates.push(desktop_app_bin_dir(context).join(&binary_name)); } + candidates.into_iter().find(|path| path.is_file()) +} - candidates.into_iter().find(|path| path.exists()) +fn desktop_backend_bundle_candidates(resource_dir: &Path) -> Vec { + vec![resource_dir + .join("bin") + .join("desktop-backend") + .join("index.js")] +} + +fn resolve_desktop_backend_bundle_path(context: &AppContext) -> Option { + let mut candidates = Vec::new(); + if let Some(path) = non_empty_env_path("CLINE_DESKTOP_BACKEND_BUNDLE") { + candidates.push(path); + } + candidates.extend(desktop_backend_bundle_candidates(&context.resource_dir)); + candidates.push( + desktop_app_bin_dir(context) + .join("desktop-backend") + .join("index.js"), + ); + candidates.into_iter().find(|path| path.is_file()) } fn spawn_desktop_backend_process(context: &AppContext) -> Result { - let mut command = if let Some(binary_path) = resolve_desktop_backend_binary_path(context) { - let mut command = Command::new(binary_path); - command.current_dir(&context.workspace_root); - command - } else if let Some(script_path) = resolve_desktop_backend_script_path(context) { + let cli_path = resolve_desktop_cli_path(context); + let script_path = if cfg!(debug_assertions) { + resolve_desktop_backend_script_path(context) + } else { + None + }; + let mut command = if let Some(script_path) = script_path { let mut command = Command::new("bun"); command .arg("run") .arg(script_path.to_string_lossy().to_string()) .current_dir(&context.workspace_root); command + } else if let (Some(cli_path), Some(bundle_path)) = ( + cli_path.as_ref(), + resolve_desktop_backend_bundle_path(context), + ) { + let mut command = Command::new(cli_path); + command.arg("run").arg("--no-env-file").arg(&bundle_path); + // Run from the bundle's own directory so the runtime cannot pick up + // a workspace .env or bunfig.toml; the backend moves to the + // workspace root itself (CLINE_DESKTOP_WORKSPACE_ROOT). + if let Some(bundle_dir) = bundle_path.parent() { + command.current_dir(bundle_dir); + } + command + .env("BUN_BE_BUN", "1") + .env("CLINE_DESKTOP_WORKSPACE_ROOT", &context.workspace_root) + .env( + "CLINE_REMOTE_HELPER_DIRECTORY", + context.resource_dir.join("bin").join("remote-helpers"), + ); + command } else { return Err(format!( - "desktop backend sidecar not found. checked binary/script under workspace_root={} and launch_cwd={}", - context.workspace_root, context.launch_cwd + "desktop backend not found: needs the bundled Cline CLI ({}) and backend bundle. checked under workspace_root={} and launch_cwd={}", + if cli_path.is_some() { "found" } else { "missing" }, + context.workspace_root, + context.launch_cwd )); }; + if let Some(cli_path) = cli_path.as_ref() { + command.env("CLINE_DESKTOP_CLI_PATH", cli_path); + } command .stdin(Stdio::null()) .stdout(Stdio::piped()) @@ -539,7 +579,7 @@ fn spawn_desktop_backend_process(context: &AppContext) -> Result hide_console_window(&mut command); command .spawn() - .map_err(|e| format!("failed to start desktop backend sidecar: {e}")) + .map_err(|e| format!("failed to start desktop backend: {e}")) } fn ensure_desktop_backend_started( @@ -803,7 +843,7 @@ fn wait_for_desktop_backend_endpoint( .unwrap_or(true) { last_respawn = Some(Instant::now()); - // A spawn error (sidecar binary missing) is permanent for this + // A spawn error (bundled CLI or backend missing) is permanent for this // wait; report it instead of burning the rest of the window. respawn()?; } @@ -899,7 +939,7 @@ fn apply_staged_update(app: &tauri::AppHandle) { let update_state = app.state::>(); // Neither restart() nor install() returns, so the run-loop Exit handler // does not get a chance to stop the sidecar; shut it down explicitly - // first. On Windows this also releases the sidecar exe's file lock, + // first. On Windows this also releases the bundled CLI's file lock, // which the NSIS installer needs in order to replace it. backend_state.stop(); // Windows and Linux: install the bytes staged by the background cycle. @@ -1489,10 +1529,6 @@ fn main() { .map(|p| p.to_string_lossy().to_string()) .unwrap_or_else(|_| ".".to_string()); let workspace_root = resolve_workspace_root(&launch_cwd); - let app_context = AppContext { - launch_cwd, - workspace_root, - }; tauri::Builder::default() // Closing the window only hides it, so on Windows a second launch from @@ -1506,10 +1542,14 @@ fn main() { .plugin(tauri_plugin_notification::init()) .plugin(tauri_plugin_updater::Builder::new().build()) .manage(desktop_backend) - .manage(app_context) .manage(Arc::new(UpdateState::default())) .manage(DesktopActionState::default()) - .setup(|app| { + .setup(move |app| { + app.manage(AppContext { + launch_cwd: launch_cwd.clone(), + workspace_root: workspace_root.clone(), + resource_dir: app.path().resource_dir()?, + }); if tauri::is_dev() { if let (Some(window), Some(product_name)) = ( app.get_webview_window(MAIN_WINDOW_LABEL), @@ -1626,6 +1666,23 @@ mod tests { assert!(entitlements.contains("com.apple.security.device.audio-input")); } + #[test] + fn backend_bundle_uses_only_the_running_apps_resource_directory() { + let relative = Path::new("bin").join("desktop-backend").join("index.js"); + for resource_dir in [ + Path::new("/Applications/Cline.app/Contents/Resources"), + Path::new("C:/Program Files/Cline"), + Path::new("/usr/lib/Cline"), + Path::new("/usr/lib/Cline Beta"), + Path::new("/usr/lib/Cline Nightly"), + ] { + assert_eq!( + desktop_backend_bundle_candidates(resource_dir), + vec![resource_dir.join(&relative)] + ); + } + } + #[test] fn oversized_window_is_clamped_to_the_work_area() { // 1920x1080 at 150% scaling: 1280x720 logical work area (minus a diff --git a/apps/examples/desktop-app/src-tauri/tauri.conf.json b/apps/examples/desktop-app/src-tauri/tauri.conf.json index 9718ed1a53..09b6f151b8 100644 --- a/apps/examples/desktop-app/src-tauri/tauri.conf.json +++ b/apps/examples/desktop-app/src-tauri/tauri.conf.json @@ -4,7 +4,7 @@ "version": "0.0.46", "identifier": "bot.cline.app", "build": { - "beforeDevCommand": "bun run build:sidecar:bin && bun run dev:web", + "beforeDevCommand": "bun run build:runtime && bun run dev:web", "devUrl": "http://localhost:3125", "beforeBuildCommand": "bun run dmg:background && bun run build", "frontendDist": "../webview/out" @@ -41,11 +41,12 @@ "shortDescription": "Cline AI coding agent", "longDescription": "Cline is an AI coding agent. It reads your code, edits files, runs commands, and works through tasks with you in any project on your machine.", "homepage": "https://cline.bot", - "externalBin": ["bin/code-sidecar"], + "externalBin": ["bin/cline-cli"], "resources": [ "icons/app/*.png", "icons/app/macos/*.png", "bin/remote-helpers/*", + "bin/desktop-backend/*", "bin/extensions/**/*" ], "icon": [ diff --git a/sdk/DOC.md b/sdk/DOC.md index ee38f9f838..36c79fb253 100644 --- a/sdk/DOC.md +++ b/sdk/DOC.md @@ -163,15 +163,20 @@ configuration variables are `CLINE_REMOTE_HELPER_BINARY`, `CLINE_REMOTE_HELPER_DIRECTORY`, `CLINE_SSH_PATH`, and `CLINE_SSH_KNOWN_HOSTS_FILE`. -Clients package a matching self-contained helper using the -`@cline/core/remote/helper-entry` executable entrypoint, compiled with Bun for the remote OS and -architecture. Use `remoteHelperBinaryFilename({ platform, arch })` for the -filename (`cline-remote-helper-`). Linux and macOS on x64/arm64 -are supported. Helpers must include the same SDK build as the client; missing -helpers produce an explicit error, without installing a runtime from the network. -The helper implements `--remote-hub-ensure --cwd --discovery-path ` -and the core detached-daemon sentinel. Agent tools and persistence run remotely; -the host only manages SSH and forwards the authenticated hub connection. +Remote Hubs are CLI-managed. Clients package the self-contained Cline CLI +binary (`apps/cli`, compiled with Bun) for each remote OS and architecture; the +service stages it on the host and runs `cline hub ensure --json --discovery-path + ...` to start or reuse a loopback Hub, and `cline hub stop +--discovery-path ` to stop it. Use `remoteHelperBinaryFilename({ platform, +arch })` for the bundled filename (`cline-`). Linux and macOS on +x64/arm64 are supported. The CLI must be built from the same SDK build as the +client; missing binaries produce an explicit error, without installing a +runtime from the network. Agent tools and persistence run remotely; the host +only manages SSH and forwards the authenticated hub connection. + +Clients whose own process cannot host the Hub daemon can call +`setHubDaemonLauncher({ command: "/path/to/cline" })` so detached Hubs they +start are launched through the Cline CLI. ### Provider authentication metadata for host UIs diff --git a/sdk/packages/core/bun.mts b/sdk/packages/core/bun.mts index 4ee0b34f51..b9cb6976cf 100644 --- a/sdk/packages/core/bun.mts +++ b/sdk/packages/core/bun.mts @@ -59,14 +59,6 @@ const builds: Parameters[0][] = [ outdir: "./dist/cloud", ...buildConfig, }, - { - entrypoints: [ - "./src/remote/remote-helper.ts", - "./src/remote/remote-helper-entry.ts", - ], - outdir: "./dist/remote", - ...buildConfig, - }, // Build main exports separately to avoid Bun bundler output path conflicts { entrypoints: ["./src/index.ts"], diff --git a/sdk/packages/core/package.json b/sdk/packages/core/package.json index 8dda8b0385..84f0d076d2 100644 --- a/sdk/packages/core/package.json +++ b/sdk/packages/core/package.json @@ -35,17 +35,9 @@ "types": "./dist/services/feature-flags/posthog.d.ts", "import": "./dist/services/feature-flags/posthog.js" }, - "./remote/helper-entry": { - "types": "./dist/remote/remote-helper-entry.d.ts", - "import": "./dist/remote/remote-helper-entry.js" - }, "./cloud": { "types": "./dist/cloud/index.d.ts", "import": "./dist/cloud/index.js" - }, - "./remote/helper": { - "types": "./dist/remote/remote-helper.d.ts", - "import": "./dist/remote/remote-helper.js" } }, "scripts": { diff --git a/sdk/packages/core/src/hub/client/index.ts b/sdk/packages/core/src/hub/client/index.ts index 4ce0b7baad..c576e6384f 100644 --- a/sdk/packages/core/src/hub/client/index.ts +++ b/sdk/packages/core/src/hub/client/index.ts @@ -1452,7 +1452,9 @@ export async function requestHubShutdown( export async function stopLocalHubServerGracefully( owner: HubOwnerContext = resolveDefaultHubOwnerContext(), ): Promise { - const discovery = await readHubDiscovery(owner.discoveryPath); + const discovery = await readHubDiscovery(owner.discoveryPath, { + onError: "throw", + }); if (!discovery?.url) { return false; } diff --git a/sdk/packages/core/src/hub/daemon/index.test.ts b/sdk/packages/core/src/hub/daemon/index.test.ts index c2c8d5aea6..f576251a35 100644 --- a/sdk/packages/core/src/hub/daemon/index.test.ts +++ b/sdk/packages/core/src/hub/daemon/index.test.ts @@ -227,10 +227,15 @@ describe("ensureDetachedHubServer", () => { authToken: "new-token", }); + const beforeSpawn = vi.fn(async () => { + expect(spawn).not.toHaveBeenCalled(); + }); const { ensureDetachedHubServer } = await import("."); const result = await ensureDetachedHubServer("/workspace", { manageConnectors, + beforeSpawn, }); + expect(beforeSpawn).toHaveBeenCalledOnce(); const spawnCalls = (spawn as unknown as { mock: { calls: unknown[][] } }) .mock.calls; const spawnArgs = spawnCalls[0]?.[1] as string[] | undefined; @@ -712,13 +717,17 @@ describe("ensureDetachedHubServer", () => { }); verifyHubConnection.mockResolvedValue(true); + const beforeSpawn = vi.fn(async () => undefined); const { ensureDetachedHubServer } = await import("."); - await expect(ensureDetachedHubServer("/workspace")).resolves.toEqual({ + await expect( + ensureDetachedHubServer("/workspace", { beforeSpawn }), + ).resolves.toEqual({ url: "ws://127.0.0.1:25463/hub", authToken: "known-token", }); expect(writeHubDiscovery).toHaveBeenCalled(); expect(spawn).not.toHaveBeenCalled(); + expect(beforeSpawn).not.toHaveBeenCalled(); }); it("uses matching discovery pid and token when retiring an incompatible expected-url hub", async () => { @@ -1291,3 +1300,38 @@ describe("resolveDaemonEntryArgs", () => { ).toEqual(["/repo/dist/hub/daemon/entry.js"]); }); }); + +describe("setHubDaemonLauncher", () => { + afterEach(async () => { + const { setHubDaemonLauncher } = await import("."); + setHubDaemonLauncher(undefined); + }); + + it("launches the daemon through the configured CLI binary", async () => { + const { __test__, setHubDaemonLauncher } = await import("."); + setHubDaemonLauncher({ command: "/Applications/Cline.app/cline-cli" }); + const command = __test__.resolveLaunchCommand("/work", { + port: 0, + manageConnectors: false, + }); + expect(command.launcher).toBe("/Applications/Cline.app/cline-cli"); + expect(command.args).toEqual([ + "--cline-hub-daemon", + "--cwd", + "/work", + "--port", + "0", + "--no-connectors", + ]); + expect(command.env.CLINE_RUN_AS_HUB_DAEMON).toBe("1"); + }); + + it("restores the current runtime when cleared", async () => { + const { __test__, setHubDaemonLauncher } = await import("."); + setHubDaemonLauncher({ command: "/opt/cline-cli", args: ["--x"] }); + setHubDaemonLauncher(undefined); + expect(__test__.resolveLaunchCommand("/work").launcher).toBe( + process.execPath, + ); + }); +}); diff --git a/sdk/packages/core/src/hub/daemon/index.ts b/sdk/packages/core/src/hub/daemon/index.ts index 9ef4b7d065..fa99f57c63 100644 --- a/sdk/packages/core/src/hub/daemon/index.ts +++ b/sdk/packages/core/src/hub/daemon/index.ts @@ -51,6 +51,11 @@ export interface DetachedHubOptions extends HubEndpointOverrides { allowPortFallback?: boolean; /** Disable account-wide connector supervision for session-only Hubs. Defaults to true. */ manageConnectors?: boolean; + /** + * Runs only when a new daemon is about to be spawned (never when a running + * Hub is reused), e.g. to prepare the environment the daemon inherits. + */ + beforeSpawn?: () => Promise; } /** @@ -82,6 +87,10 @@ export const __test__ = { retireAttemptsByUrl.clear(); }, resolveDaemonEntryArgs, + resolveLaunchCommand: ( + workspaceRoot: string, + endpoint: DetachedHubOptions = {}, + ) => resolveLaunchCommand(workspaceRoot, endpoint), }; /** @@ -398,6 +407,28 @@ function resolveDaemonEntryArgs( ]; } +/** + * An executable that hosts the detached Hub daemon in place of the current + * process. Clients that are not themselves a Hub-capable binary (for example + * the desktop backend, a script running on the bundled CLI's runtime) point + * this at the Cline CLI so every Hub they start is a CLI-managed Hub. + */ +export interface HubDaemonLauncher { + command: string; + /** Arguments before the daemon's `--cwd`/endpoint arguments. Defaults to the daemon marker flag. */ + args?: string[]; +} + +let configuredHubDaemonLauncher: HubDaemonLauncher | undefined; + +/** Route detached Hub spawns through `launcher`; pass `undefined` to restore the default. */ +export function setHubDaemonLauncher(launcher?: HubDaemonLauncher): void { + const command = launcher?.command.trim(); + configuredHubDaemonLauncher = command + ? { command, args: launcher?.args ? [...launcher.args] : undefined } + : undefined; +} + function resolveLaunchCommand( workspaceRoot: string, endpoint: DetachedHubOptions, @@ -407,15 +438,24 @@ function resolveLaunchCommand( cwd: string; env: NodeJS.ProcessEnv; } { - const daemonEntryPath = resolveDaemonEntryPath(); - const execPath = process.execPath?.trim(); - if (!execPath) { - throw new Error("unable to resolve runtime executable for hub daemon"); + let launcher: string; + let entryArgs: string[]; + if (configuredHubDaemonLauncher) { + launcher = configuredHubDaemonLauncher.command; + entryArgs = configuredHubDaemonLauncher.args ?? [ + COMPILED_BUN_HUB_DAEMON_ARG, + ]; + } else { + const execPath = process.execPath?.trim(); + if (!execPath) { + throw new Error("unable to resolve runtime executable for hub daemon"); + } + const isBunRuntime = basename(execPath).toLowerCase().includes("bun"); + launcher = execPath; + entryArgs = resolveDaemonEntryArgs(resolveDaemonEntryPath(), isBunRuntime); } - const isBunRuntime = basename(execPath).toLowerCase().includes("bun"); - const entryArgs = resolveDaemonEntryArgs(daemonEntryPath, isBunRuntime); return { - launcher: execPath, + launcher, args: [ ...entryArgs, "--cwd", @@ -687,6 +727,7 @@ async function ensureDetachedHubServerLocked( const spawnEndpoint = shouldUseFallbackPort ? { ...endpoint, port: 0 } : endpoint; + await endpointOverrides.beforeSpawn?.(); await spawnDetachedHubServerWithRetry(workspaceRoot, { ...spawnEndpoint, manageConnectors: endpointOverrides.manageConnectors, diff --git a/sdk/packages/core/src/hub/discovery/index.test.ts b/sdk/packages/core/src/hub/discovery/index.test.ts index 83032e018f..fb119ba4a8 100644 --- a/sdk/packages/core/src/hub/discovery/index.test.ts +++ b/sdk/packages/core/src/hub/discovery/index.test.ts @@ -246,7 +246,43 @@ describe("hub discovery", () => { "utf8", ); - await expect(readHubDiscovery(discoveryPath)).resolves.toBeUndefined(); + await expect( + readHubDiscovery(discoveryPath, { onError: "throw" }), + ).rejects.toThrow("Invalid Hub discovery record"); + }); + + it("ignores invalid records for recovery but rejects them for shutdown", async () => { + snapshot = captureEnv(); + delete process.env.CLINE_HUB_DISCOVERY_PATH; + process.env.CLINE_DATA_DIR = "/tmp/cline-data"; + const discoveryPath = + resolveHubOwnerContext("invalid-record").discoveryPath; + await mkdir(dirname(discoveryPath), { recursive: true }); + for (const raw of ["{ invalid json", "null", "{}", "[]"]) { + await writeFile(discoveryPath, raw, "utf8"); + await expect(readHubDiscovery(discoveryPath)).resolves.toBeUndefined(); + await expect( + readHubDiscovery(discoveryPath, { onError: "throw" }), + ).rejects.toThrow("Hub discovery record"); + } + await clearHubDiscovery(discoveryPath); + }); + + it("propagates discovery read errors instead of reporting absence", async () => { + snapshot = captureEnv(); + delete process.env.CLINE_HUB_DISCOVERY_PATH; + process.env.CLINE_DATA_DIR = "/tmp/cline-data"; + const discoveryPath = resolveHubOwnerContext( + "directory-read-error", + ).discoveryPath; + await mkdir(dirname(discoveryPath), { recursive: true }); + // Opening the parent directory as a file fails on every supported OS. + await expect( + readHubDiscovery(dirname(discoveryPath)), + ).resolves.toBeUndefined(); + await expect( + readHubDiscovery(dirname(discoveryPath), { onError: "throw" }), + ).rejects.toThrow(); }); it("serializes generation cleanup with replacement publication", async () => { diff --git a/sdk/packages/core/src/hub/discovery/index.ts b/sdk/packages/core/src/hub/discovery/index.ts index 7feecb3641..9bbe7cb6ea 100644 --- a/sdk/packages/core/src/hub/discovery/index.ts +++ b/sdk/packages/core/src/hub/discovery/index.ts @@ -344,14 +344,32 @@ export function createInMemoryHubOwnerContext( return resolveHubOwnerContext(label); } +/** + * Reads a usable discovery record. Startup and connection discovery ignore + * unreadable/invalid records so endpoint probing and recovery can continue. + * Shutdown must use onError: "throw": only a missing record proves absence. + */ export async function readHubDiscovery( discoveryPath: string, + options: { onError?: "ignore" | "throw" } = {}, ): Promise { try { - const parsed = JSON.parse( - await readFile(discoveryPath, "utf8"), - ) as Partial; + let raw: string; + try { + raw = await readFile(discoveryPath, "utf8"); + } catch (error) { + if ((error as NodeJS.ErrnoException).code === "ENOENT") return undefined; + throw error; + } + let parsed: Partial; + try { + parsed = JSON.parse(raw) as Partial; + } catch { + // JSON parser errors may quote record contents, including the auth token. + throw new Error(`Invalid JSON in Hub discovery record: ${discoveryPath}`); + } if ( + !parsed || typeof parsed.hubId !== "string" || typeof parsed.protocolVersion !== "string" || typeof parsed.authToken !== "string" || @@ -361,7 +379,7 @@ export async function readHubDiscovery( typeof parsed.startedAt !== "string" || typeof parsed.updatedAt !== "string" ) { - return undefined; + throw new Error(`Invalid Hub discovery record: ${discoveryPath}`); } return { hubId: parsed.hubId, @@ -395,7 +413,8 @@ export async function readHubDiscovery( startedAt: parsed.startedAt, updatedAt: parsed.updatedAt, }; - } catch { + } catch (error) { + if (options.onError === "throw") throw error; return undefined; } } diff --git a/sdk/packages/core/src/remote/remote-environments.test.ts b/sdk/packages/core/src/remote/remote-environments.test.ts index 1444a7d46d..2f07a5b6ef 100644 --- a/sdk/packages/core/src/remote/remote-environments.test.ts +++ b/sdk/packages/core/src/remote/remote-environments.test.ts @@ -1,6 +1,14 @@ import * as childProcess from "node:child_process"; import { EventEmitter } from "node:events"; -import { mkdtemp, readdir, readFile, rm, stat } from "node:fs/promises"; +import { + mkdir, + mkdtemp, + readdir, + readFile, + rm, + stat, + writeFile, +} from "node:fs/promises"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { PassThrough } from "node:stream"; @@ -210,14 +218,14 @@ describe("RemoteEnvironmentService", () => { } if (helperMissing && command.includes("'test' '-x'")) return { stdout: "", stderr: "", exitCode: 1 }; - if (helperMissing && command.includes("--remote-hub-stop")) + if (helperMissing && command.includes("'hub' 'stop'")) throw new Error("Helper missing"); if (requiredIdentity && !args.includes(requiredIdentity)) { throw new Error("Obsolete SSH identity"); } if (command.includes("uname -s")) return inspection("Linux", "x86_64", "/home/dev"); - if (command.includes("--remote-hub-ensure")) + if (command.includes("'hub' 'ensure'")) return success( '{"url":"ws://127.0.0.1:25463/hub","authToken":"token"}', ); @@ -243,7 +251,7 @@ describe("RemoteEnvironmentService", () => { await service.connect(first.id); await service.connect(second.id); const ensures = commands.filter((command) => - command.includes("--remote-hub-ensure"), + command.includes("'hub' 'ensure'"), ); expect(ensures).toHaveLength(2); expect(ensures[0]).not.toBe(ensures[1]); @@ -262,13 +270,39 @@ describe("RemoteEnvironmentService", () => { await restarted.dispose(); expect(await readdir(`${profilesPath}.cleanup`)).toEqual([]); const stop = commands - .filter((command) => command.includes("--remote-hub-stop")) + .filter((command) => command.includes("'hub' 'stop'")) .at(-1); expect(stop?.split("'--discovery-path' ")[1]).toBe( ensures[0]?.split("'--discovery-path' ")[1], ); }); + it("stops a Hub recorded by an earlier dedicated helper with its own flag", async () => { + const commands: string[] = []; + const service = createService({ + runProcess: async (_executable, args) => { + commands.push(args.at(-1) ?? ""); + return success(); + }, + }); + const profile = await service.upsert({ name: "Old", host: "old-host" }); + const legacyHelper = + "/home/dev/.cline/remote/cline-remote-helper-linux-x64-0123456789abcdef"; + const discoveryPath = "/home/dev/.cline/data/remote/owner.json"; + await mkdir(`${profilesPath}.cleanup`, { recursive: true }); + await writeFile( + join(`${profilesPath}.cleanup`, "legacy.json"), + JSON.stringify({ profile, helper: legacyHelper, discoveryPath }), + ); + + await expect(service.disconnect(profile.id)).resolves.toBe(false); + + expect(commands).toContain( + `exec '${legacyHelper}' '--remote-hub-stop' '--discovery-path' '${discoveryPath}'`, + ); + expect(await readdir(`${profilesPath}.cleanup`)).toEqual([]); + }); + it("persists profiles atomically with private permissions and updates in place", async () => { const service = createService(); const created = await service.upsert({ @@ -421,7 +455,7 @@ describe("RemoteEnvironmentService", () => { if (command.includes("'test' '-x'")) { return { stdout: "", stderr: "", exitCode: 1 }; } - if (command.includes("--remote-hub-ensure")) { + if (command.includes("'hub' 'ensure'")) { return success( '{"url":"ws://127.0.0.1:25463/hub","authToken":"remote-secret"}\n', ); @@ -478,12 +512,15 @@ describe("RemoteEnvironmentService", () => { }); expect(upload?.args.at(-1)).toContain("umask 077; cat >"); const ensure = invocations.find((invocation) => - invocation.args.at(-1)?.includes("--remote-hub-ensure"), + invocation.args.at(-1)?.includes("'hub' 'ensure'"), ); expect(ensure?.args.at(-1)).toContain("'/home/dev'"); expect(ensure?.args.at(-1)).toMatch( /\/home\/dev\/\.cline\/data\/remote\/[a-f0-9-]+\.json/, ); + expect(ensure?.args.at(-1)).toContain( + "exec 'env' 'CLINE_NO_AUTO_UPDATE=1' '/home/dev/.cline/remote/cline-linux-arm64-abcdef0123456789' 'hub' 'ensure' '--json' '--allow-port-fallback' '--no-connectors' '--login-shell-path' '--cwd' '/home/dev' '--host' '127.0.0.1' '--port' '0' '--pathname' '/hub' '--discovery-path' ", + ); expect(spawnTunnel).toHaveBeenCalledWith( "ssh", expect.arrayContaining([ @@ -521,7 +558,7 @@ describe("RemoteEnvironmentService", () => { commands.push(command); if (command.includes("uname -s")) return inspection("Linux", "aarch64", "/home/dev"); - if (command.includes("--remote-hub-ensure")) + if (command.includes("'hub' 'ensure'")) return success( '{"url":"ws://127.0.0.1:25463/hub","authToken":"secret"}', ); @@ -541,11 +578,9 @@ describe("RemoteEnvironmentService", () => { "tunnel setup failed", ); const ensure = commands.find((command) => - command.includes("--remote-hub-ensure"), - ); - const stop = commands.find((command) => - command.includes("--remote-hub-stop"), + command.includes("'hub' 'ensure'"), ); + const stop = commands.find((command) => command.includes("'hub' 'stop'")); expect(stop).toBeDefined(); expect(stop?.split("'--discovery-path' ")[1]).toBe( ensure?.split("'--discovery-path' ")[1], @@ -566,7 +601,7 @@ describe("RemoteEnvironmentService", () => { if (command.includes("uname -s")) { return inspection("Linux", "aarch64", "/home/pi"); } - if (command.includes("--remote-hub-ensure")) { + if (command.includes("'hub' 'ensure'")) { return success( '{"url":"ws://127.0.0.1:25463/hub","authToken":"token"}\n', ); @@ -600,7 +635,7 @@ describe("RemoteEnvironmentService", () => { if (command.includes("uname -s")) { return inspection("Darwin", "arm64", "/Users/dev"); } - if (command.includes("--remote-hub-ensure")) { + if (command.includes("'hub' 'ensure'")) { return success( '{"url":"ws://localhost:29000/hub","authToken":"token"}\n', ); @@ -634,7 +669,7 @@ describe("RemoteEnvironmentService", () => { if (command.includes("uname -s")) { return inspection("Linux", "aarch64", "/home/pi"); } - if (command.includes("--remote-hub-ensure")) { + if (command.includes("'hub' 'ensure'")) { return success( '{"url":"ws://127.0.0.1:25463/hub","authToken":"token"}\n', ); @@ -716,7 +751,7 @@ describe("RemoteEnvironmentService", () => { }); await expect(service.connect(profile.id)).rejects.toThrow( - "unsupported in SSH: no compatible remote helper binary", + "unsupported in SSH: no compatible Cline CLI binary", ); expect(invocations.some((invocation) => invocation.options.inputFile)).toBe( false, @@ -733,7 +768,7 @@ describe("RemoteEnvironmentService", () => { if (command.includes("uname -s")) { return inspection("Linux", "aarch64", "/home/dev"); } - if (command.includes("--remote-hub-ensure")) { + if (command.includes("'hub' 'ensure'")) { return destination === "host-b" ? { stdout: "", stderr: "bootstrap failed", exitCode: 1 } : success( @@ -781,7 +816,7 @@ describe("RemoteEnvironmentService", () => { if (command.includes("uname -s")) { return inspection("Linux", "aarch64", `/home/${destination}`); } - if (command.includes("--remote-hub-ensure")) { + if (command.includes("'hub' 'ensure'")) { return success( `{"url":"ws://127.0.0.1:25463/hub","authToken":"${destination}-token"}\n`, ); @@ -840,7 +875,7 @@ describe("RemoteEnvironmentService", () => { if (command.includes("uname -s")) { return inspection("Linux", "x86_64", "/home/dev"); } - if (command.includes("--remote-hub-ensure")) { + if (command.includes("'hub' 'ensure'")) { return success( '{"url":"ws://127.0.0.1:25463/hub","authToken":"token"}\n', ); diff --git a/sdk/packages/core/src/remote/remote-environments.ts b/sdk/packages/core/src/remote/remote-environments.ts index d7f815fb56..07120623f9 100644 --- a/sdk/packages/core/src/remote/remote-environments.ts +++ b/sdk/packages/core/src/remote/remote-environments.ts @@ -14,7 +14,7 @@ import { } from "node:fs/promises"; import { createConnection, createServer } from "node:net"; import { homedir } from "node:os"; -import { dirname, join } from "node:path"; +import { basename, dirname, join } from "node:path"; import { resolveClineDataDir } from "@cline/shared/storage"; import { requestHubShutdown } from "../hub/client"; @@ -343,16 +343,10 @@ export class RemoteEnvironmentService { `${REMOTE_DISCOVERY_DIRECTORY}/${this.ownerId}-${createHash("sha256").update(profile.id).digest("hex").slice(0, 16)}.json`, ); bootstrap = { helper: remoteHelper, discoveryPath }; - const ensureResult = await this.execRemote(profile, { - command: remoteHelper, - args: [ - "--remote-hub-ensure", - "--cwd", - inspection.home, - "--discovery-path", - discoveryPath, - ], - }); + const ensureResult = await this.execRemote( + profile, + remoteHubEnsureCommand(remoteHelper, inspection.home, discoveryPath), + ); const hub = parseRemoteHubResult(ensureResult.stdout); const localPort = await this.dependencies.reservePort(); const tunnel = this.dependencies.spawnTunnel( @@ -414,14 +408,10 @@ export class RemoteEnvironmentService { if (bootstrap) { try { // An independent SSH command works even when forwarding never opened. - await this.execRemote(profile, { - command: bootstrap.helper, - args: [ - "--remote-hub-stop", - "--discovery-path", - bootstrap.discoveryPath, - ], - }); + await this.execRemote( + profile, + remoteHubStopCommand(bootstrap.helper, bootstrap.discoveryPath), + ); } catch (failure) { cleanupError = failure; await this.persistPendingCleanup({ profile, ...bootstrap }); @@ -537,7 +527,7 @@ export class RemoteEnvironmentService { if (!localHelper || !(await this.dependencies.fileReadable(localHelper))) { throw new Error( `Remote target ${inspection.platform}/${inspection.arch} is unsupported in SSH: ` + - "no compatible remote helper binary is available. Build the matching core remote helper and set " + + "no compatible Cline CLI binary is available. Build the Cline CLI for that target and set " + "CLINE_REMOTE_HELPER_BINARY; network installers are intentionally not used.", ); } @@ -549,7 +539,7 @@ export class RemoteEnvironmentService { ); const remoteHelper = joinRemote( remoteDirectory, - `cline-remote-helper-${inspection.platform}-${inspection.arch}-${hash.slice(0, 16)}`, + `cline-${inspection.platform}-${inspection.arch}-${hash.slice(0, 16)}`, ); await this.installHelper( profile, @@ -866,10 +856,10 @@ export class RemoteEnvironmentService { cleanupProfile, await this.inspectRemote(cleanupProfile), ); - await this.execRemote(cleanupProfile, { - command: helper, - args: ["--remote-hub-stop", "--discovery-path", cleanup.discoveryPath], - }); + await this.execRemote( + cleanupProfile, + remoteHubStopCommand(helper, cleanup.discoveryPath), + ); await rm(path, { force: true }); } } @@ -985,6 +975,7 @@ function createDefaultDependencies( }; } +/** File name of the Cline CLI binary built for `target`, as clients bundle it. */ export function remoteHelperBinaryFilename(target: RemoteHelperTarget): string { const triple = target.platform === "darwin" @@ -994,7 +985,70 @@ export function remoteHelperBinaryFilename(target: RemoteHelperTarget): string { : target.arch === "arm64" ? "aarch64-unknown-linux-gnu" : "x86_64-unknown-linux-gnu"; - return `cline-remote-helper-${triple}`; + return `cline-${triple}`; +} + +/** + * Remote Hubs are CLI-managed: the staged Cline CLI starts (or reuses) a + * loopback Hub tracked by a discovery record that this client owns, so the + * user's own CLI Hub on that host is never read or stopped. The discovery + * path comes last so a stop command can be matched to the ensure it undoes. + */ +function remoteHubEnsureCommand( + cli: string, + cwd: string, + discoveryPath: string, +): RemoteCommandInput { + return { + command: "env", + args: [ + "CLINE_NO_AUTO_UPDATE=1", + cli, + "hub", + "ensure", + "--json", + "--allow-port-fallback", + "--no-connectors", + "--login-shell-path", + "--cwd", + cwd, + "--host", + "127.0.0.1", + "--port", + "0", + "--pathname", + "/hub", + "--discovery-path", + discoveryPath, + ], + }; +} + +const LEGACY_REMOTE_HELPER_PREFIX = "cline-remote-helper-"; + +function remoteHubStopCommand( + cli: string, + discoveryPath: string, +): RemoteCommandInput { + // Cleanup records persisted by earlier releases name the dedicated helper + // binary they staged, which only understands its own stop flag. + if (basename(cli).startsWith(LEGACY_REMOTE_HELPER_PREFIX)) { + return { + command: cli, + args: ["--remote-hub-stop", "--discovery-path", discoveryPath], + }; + } + return { + command: "env", + args: [ + "CLINE_NO_AUTO_UPDATE=1", + cli, + "hub", + "stop", + "--discovery-path", + discoveryPath, + ], + }; } export async function runRemoteProcess( diff --git a/sdk/packages/core/src/remote/remote-helper-entry.ts b/sdk/packages/core/src/remote/remote-helper-entry.ts deleted file mode 100644 index 31a8d274ff..0000000000 --- a/sdk/packages/core/src/remote/remote-helper-entry.ts +++ /dev/null @@ -1,12 +0,0 @@ -import { runRemoteHelperEntrypoint } from "./remote-helper"; - -// Executable entrypoint; importing remote/helper never runs the CLI. -void (async () => { - if (!(await runRemoteHelperEntrypoint())) - throw new Error("A remote helper command is required"); -})().catch((error) => { - process.stderr.write( - `${error instanceof Error ? error.message : String(error)}\n`, - ); - process.exitCode = 1; -}); diff --git a/sdk/packages/core/src/remote/remote-helper.test.ts b/sdk/packages/core/src/remote/remote-helper.test.ts deleted file mode 100644 index 99694d9b08..0000000000 --- a/sdk/packages/core/src/remote/remote-helper.test.ts +++ /dev/null @@ -1,196 +0,0 @@ -import { execFileSync } from "node:child_process"; -import { fileURLToPath } from "node:url"; -import { describe, expect, it, vi } from "vitest"; -import { - type RemoteHelperDependencies, - runRemoteHelperEntrypoint, -} from "./remote-helper"; - -function createDependencies( - overrides: Partial = {}, -): { - dependencies: RemoteHelperDependencies; - output: string[]; -} { - const output: string[] = []; - return { - output, - dependencies: { - readHubDiscovery: vi.fn(async () => undefined), - clearHubDiscoveryIfOwned: vi.fn(async () => true), - probeProcess: vi.fn(), - requestHubShutdown: vi.fn(async () => true), - ensureDetachedHubServer: vi.fn(async () => ({ - url: "ws://127.0.0.1:25463/hub", - authToken: "desktop-owner-token", - })), - claimHubDaemonProcess: vi.fn(() => false), - loadHubDaemon: vi.fn(async () => undefined), - ensureLoginShellPath: vi.fn(async () => ({ - status: "skipped" as const, - reason: "test", - })), - setHomeDirIfUnset: vi.fn(), - homeDir: () => "/home/pi", - cwd: () => "/home/pi", - env: {}, - writeOutput: (value) => output.push(value), - ...overrides, - }, - }; -} - -describe("remote helper entrypoint", () => { - it("imports the built public helper without executing its CLI", () => { - const result = execFileSync( - process.execPath, - [ - "--input-type=module", - "-e", - `const before = process.exitCode; const helper = await import('@cline/core/remote/helper'); if (process.exitCode !== before || typeof helper.runRemoteHelperEntrypoint !== 'function') throw new Error('Import side effect'); console.log('imported');`, - ], - { - cwd: fileURLToPath(new URL("../../", import.meta.url)), - encoding: "utf8", - }, - ); - expect(result.trim()).toBe("imported"); - }); - - it("starts only the explicitly owned desktop Hub discovery record", async () => { - const { dependencies, output } = createDependencies(); - const discoveryPath = "/home/pi/.cline/data/remote/desktop-hub.json"; - - await expect( - runRemoteHelperEntrypoint( - [ - "code-sidecar", - "--remote-hub-ensure", - "--cwd", - "/home/pi", - "--discovery-path", - discoveryPath, - ], - dependencies, - ), - ).resolves.toBe(true); - - expect(dependencies.env.CLINE_HUB_DISCOVERY_PATH).toBe(discoveryPath); - expect(dependencies.ensureDetachedHubServer).toHaveBeenCalledWith( - "/home/pi", - { - host: "127.0.0.1", - port: 0, - pathname: "/hub", - allowPortFallback: true, - manageConnectors: false, - }, - ); - expect(JSON.parse(output.join(""))).toMatchObject({ - url: "ws://127.0.0.1:25463/hub", - authToken: "desktop-owner-token", - cwd: "/home/pi", - }); - }); - - it("refuses bootstrap without an explicit discovery owner", async () => { - const { dependencies } = createDependencies(); - await expect( - runRemoteHelperEntrypoint( - ["code-sidecar", "--remote-hub-ensure"], - dependencies, - ), - ).rejects.toThrow("--discovery-path is required"); - }); - - it("stops only the explicitly owned Hub using its authentication token", async () => { - const discoveryPath = "/home/pi/.cline/data/remote/owned.json"; - const { dependencies } = createDependencies({ - readHubDiscovery: vi.fn( - async () => - ({ - url: "ws://127.0.0.1:1234/hub", - authToken: "owner-token", - }) as Awaited< - ReturnType - >, - ), - }); - await expect( - runRemoteHelperEntrypoint( - ["helper", "--remote-hub-stop", "--discovery-path", discoveryPath], - dependencies, - ), - ).resolves.toBe(true); - expect(dependencies.readHubDiscovery).toHaveBeenCalledWith(discoveryPath); - expect(dependencies.requestHubShutdown).toHaveBeenCalledWith( - "ws://127.0.0.1:1234/hub", - "owner-token", - ); - }); - it.each([ - "dead", - "alive", - "denied", - ])("handles %s Hub processes during cleanup", async (state) => { - const discoveryPath = "/home/pi/.cline/data/remote/owned.json"; - const { dependencies } = createDependencies({ - readHubDiscovery: vi.fn( - async () => - ({ - hubId: "owned-hub", - pid: 1234, - url: "ws://127.0.0.1:1234/hub", - authToken: "owner-token", - }) as Awaited< - ReturnType - >, - ), - probeProcess: vi.fn(() => { - if (state !== "alive") - throw Object.assign(new Error(state), { - code: state === "dead" ? "ESRCH" : "EPERM", - }); - }), - requestHubShutdown: vi.fn(async () => { - throw new Error("Connection refused"); - }), - }); - const result = runRemoteHelperEntrypoint( - ["helper", "--remote-hub-stop", "--discovery-path", discoveryPath], - dependencies, - ); - if (state === "dead") { - await expect(result).resolves.toBe(true); - expect(dependencies.clearHubDiscoveryIfOwned).toHaveBeenCalledWith( - discoveryPath, - "owned-hub", - ); - expect(dependencies.requestHubShutdown).not.toHaveBeenCalled(); - } else { - await expect(result).rejects.toThrow("Connection refused"); - expect(dependencies.clearHubDiscoveryIfOwned).not.toHaveBeenCalled(); - } - }); - - it("refuses shutdown without an explicit discovery owner", async () => { - const { dependencies } = createDependencies(); - await expect( - runRemoteHelperEntrypoint(["helper", "--remote-hub-stop"], dependencies), - ).rejects.toThrow("--discovery-path is required"); - expect(dependencies.readHubDiscovery).not.toHaveBeenCalled(); - }); - - it("hosts the detached daemon when the one-shot sentinel is claimed", async () => { - const loadHubDaemon = vi.fn(async () => undefined); - const { dependencies } = createDependencies({ - claimHubDaemonProcess: () => true, - loadHubDaemon, - }); - - await expect( - runRemoteHelperEntrypoint(["code-sidecar"], dependencies), - ).resolves.toBe(true); - expect(loadHubDaemon).toHaveBeenCalledOnce(); - }); -}); diff --git a/sdk/packages/core/src/remote/remote-helper.ts b/sdk/packages/core/src/remote/remote-helper.ts deleted file mode 100644 index 741c85db35..0000000000 --- a/sdk/packages/core/src/remote/remote-helper.ts +++ /dev/null @@ -1,138 +0,0 @@ -import { homedir } from "node:os"; -import { claimHubDaemonProcess } from "@cline/shared"; -import { setHomeDirIfUnset } from "@cline/shared/storage"; -import { requestHubShutdown } from "../hub/client"; -import { ensureDetachedHubServer } from "../hub/daemon"; -import { clearHubDiscoveryIfOwned, readHubDiscovery } from "../hub/discovery"; -import { ensureLoginShellPath } from "./shell-path"; - -export type RemoteHelperDependencies = { - readHubDiscovery: typeof readHubDiscovery; - clearHubDiscoveryIfOwned: typeof clearHubDiscoveryIfOwned; - probeProcess: (pid: number) => void; - requestHubShutdown: typeof requestHubShutdown; - ensureDetachedHubServer: typeof ensureDetachedHubServer; - claimHubDaemonProcess: typeof claimHubDaemonProcess; - loadHubDaemon: () => Promise; - ensureLoginShellPath: typeof ensureLoginShellPath; - setHomeDirIfUnset: typeof setHomeDirIfUnset; - homeDir: () => string; - cwd: () => string; - env: NodeJS.ProcessEnv; - writeOutput: (output: string) => void; -}; - -const defaultDependencies: RemoteHelperDependencies = { - readHubDiscovery, - clearHubDiscoveryIfOwned, - probeProcess: (pid) => { - process.kill(pid, 0); - }, - requestHubShutdown, - ensureDetachedHubServer, - claimHubDaemonProcess, - loadHubDaemon: () => import("@cline/core/hub/daemon-entry"), - ensureLoginShellPath, - setHomeDirIfUnset, - homeDir: homedir, - cwd: () => process.cwd(), - env: process.env, - writeOutput: (output) => process.stdout.write(output), -}; - -function readArgument(argv: string[], name: string): string | undefined { - const index = argv.indexOf(name); - const value = index >= 0 ? argv[index + 1] : undefined; - return value?.trim() || undefined; -} - -function configureDedicatedDiscovery( - argv: string[], - dependencies: RemoteHelperDependencies, -): string { - const discoveryPath = readArgument(argv, "--discovery-path"); - if (!discoveryPath) { - throw new Error("--discovery-path is required for remote Hub management"); - } - // This explicit owner record is the safety boundary: the remote helper never - // reads or shuts down the user's default CLI-owned Hub discovery record. - dependencies.env.CLINE_HUB_DISCOVERY_PATH = discoveryPath; - return discoveryPath; -} - -export async function runRemoteHubEnsure( - argv = process.argv, - dependencies: RemoteHelperDependencies = defaultDependencies, -): Promise { - dependencies.setHomeDirIfUnset(dependencies.homeDir()); - await dependencies.ensureLoginShellPath(); - const cwd = readArgument(argv, "--cwd") ?? dependencies.cwd(); - configureDedicatedDiscovery(argv, dependencies); - const result = await dependencies.ensureDetachedHubServer(cwd, { - host: "127.0.0.1", - port: 0, - pathname: "/hub", - allowPortFallback: true, - manageConnectors: false, - }); - dependencies.writeOutput( - `${JSON.stringify({ - ...result, - cwd, - platform: process.platform, - arch: process.arch, - })}\n`, - ); -} - -/** - * Handles the SSH bootstrap command and the detached-daemon sentinel. The - * standalone helper is compiled for the target host and contains no client UI - * server or command router. Client executables may also use this entrypoint - * to support the daemon sentinel. - */ -export async function runRemoteHelperEntrypoint( - argv = process.argv, - dependencies: RemoteHelperDependencies = defaultDependencies, -): Promise { - if (argv.includes("--remote-hub-stop")) { - const discoveryPath = configureDedicatedDiscovery(argv, dependencies); - const hub = await dependencies.readHubDiscovery(discoveryPath); - // A crash or reboot can leave discovery pointing at a dead Hub. Only - // ESRCH proves the process is gone; permission/probe errors and live - // processes must still go through authenticated shutdown. - if ( - hub && - typeof hub.pid === "number" && - Number.isInteger(hub.pid) && - hub.pid > 0 - ) { - try { - dependencies.probeProcess(hub.pid); - } catch (error) { - if ((error as NodeJS.ErrnoException)?.code === "ESRCH") { - await dependencies.clearHubDiscoveryIfOwned(discoveryPath, hub.hubId); - return true; - } - } - } - if ( - hub && - !(await dependencies.requestHubShutdown(hub.url, hub.authToken)) - ) { - throw new Error("Remote Hub shutdown failed"); - } - return true; - } - if (argv.includes("--remote-hub-ensure")) { - await runRemoteHubEnsure(argv, dependencies); - return true; - } - // Claim rather than read: consuming the sentinel keeps daemon-hosted - // sessions from handing it to every process they spawn. - if (dependencies.claimHubDaemonProcess()) { - await dependencies.loadHubDaemon(); - return true; - } - return false; -} diff --git a/sdk/packages/core/tsconfig.build.json b/sdk/packages/core/tsconfig.build.json index 3b767d2363..60719eaad5 100644 --- a/sdk/packages/core/tsconfig.build.json +++ b/sdk/packages/core/tsconfig.build.json @@ -11,8 +11,6 @@ }, "include": [ "src/index.ts", - "src/remote/remote-helper.ts", - "src/remote/remote-helper-entry.ts", "src/hub/index.ts", "src/cloud/index.ts", "src/hub/daemon/entry.ts", diff --git a/sdk/packages/shared/src/index.browser.ts b/sdk/packages/shared/src/index.browser.ts index 8991134425..5c0f042ebf 100644 --- a/sdk/packages/shared/src/index.browser.ts +++ b/sdk/packages/shared/src/index.browser.ts @@ -486,6 +486,11 @@ export { createClineTelemetryServiceConfig, createClineTelemetryServiceMetadata, } from "./services/telemetry-config"; +export type { + TelemetrySelfcheckConfig, + TelemetrySelfcheckReport, +} from "./services/telemetry-selfcheck"; +export { buildTelemetrySelfcheckReport } from "./services/telemetry-selfcheck"; export type { HookSessionContext, HookSessionContextLookup, diff --git a/sdk/packages/shared/src/index.ts b/sdk/packages/shared/src/index.ts index b4bb4f95df..3d7cb1aa5f 100644 --- a/sdk/packages/shared/src/index.ts +++ b/sdk/packages/shared/src/index.ts @@ -581,6 +581,11 @@ export { createClineTelemetryServiceConfig, createClineTelemetryServiceMetadata, } from "./services/telemetry-config"; +export type { + TelemetrySelfcheckConfig, + TelemetrySelfcheckReport, +} from "./services/telemetry-selfcheck"; +export { buildTelemetrySelfcheckReport } from "./services/telemetry-selfcheck"; export type { HookSessionContext, HookSessionContextLookup, diff --git a/apps/examples/desktop-app/sidecar/telemetry-selfcheck.test.ts b/sdk/packages/shared/src/services/telemetry-selfcheck.test.ts similarity index 100% rename from apps/examples/desktop-app/sidecar/telemetry-selfcheck.test.ts rename to sdk/packages/shared/src/services/telemetry-selfcheck.test.ts diff --git a/apps/examples/desktop-app/sidecar/telemetry-selfcheck.ts b/sdk/packages/shared/src/services/telemetry-selfcheck.ts similarity index 94% rename from apps/examples/desktop-app/sidecar/telemetry-selfcheck.ts rename to sdk/packages/shared/src/services/telemetry-selfcheck.ts index 374f467e1f..23429e8ebd 100644 --- a/apps/examples/desktop-app/sidecar/telemetry-selfcheck.ts +++ b/sdk/packages/shared/src/services/telemetry-selfcheck.ts @@ -1,6 +1,6 @@ /** * Builds the `--telemetry-selfcheck` report (see index.ts). CI runs the - * packaged sidecar with that flag and fails the publish unless the report + * packaged CLI and desktop backend with that flag and fails the publish unless the report * shows telemetry enabled with a usable OTLP endpoint, so the checks here * define what a release-grade build must prove. */ From 868464e4f359e88c22c2529c003f3c60e5f7eee7 Mon Sep 17 00:00:00 2001 From: WANG JIANFEI Date: Wed, 14 Oct 2026 15:26:11 +0900 Subject: [PATCH 13/21] fix(vscode): don't append default path to OpenAI Compatible base URL (#15057) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(vscode): don't append default path to OpenAI Compatible base URL normalizeSdkBaseUrl inherited the SDK default path (/v1) for URLs with no path, turning https://abc.com into https://abc.com/v1. OpenAI Compatible endpoints are user-supplied and were passed verbatim by the legacy client, so skip default-path inheritance for this provider. Co-Authored-By: Claude Sonnet 5.5 * fix(vscode): reword base URL comment per review Co-authored-by: Mikołaj Kondratek <19799111+mkondratek@users.noreply.github.com> --------- Co-authored-by: Claude Sonnet 5.5 Co-authored-by: Mikołaj Kondratek <19799111+mkondratek@users.noreply.github.com> --- apps/vscode/src/sdk/cline-session-factory.test.ts | 7 +++++++ apps/vscode/src/sdk/cline-session-factory.ts | 9 ++++++++- 2 files changed, 15 insertions(+), 1 deletion(-) diff --git a/apps/vscode/src/sdk/cline-session-factory.test.ts b/apps/vscode/src/sdk/cline-session-factory.test.ts index 163fe862ca..c6e6f16095 100644 --- a/apps/vscode/src/sdk/cline-session-factory.test.ts +++ b/apps/vscode/src/sdk/cline-session-factory.test.ts @@ -271,6 +271,13 @@ describe("normalizeSdkBaseUrl", () => { expect(normalizeSdkBaseUrl("openai", " https://example.com/custom ")).toBe("https://example.com/custom") }) + it("does not append a default path to OpenAI Compatible base URLs", () => { + expect(normalizeSdkBaseUrl("openai", "https://example.com")).toBe("https://example.com") + expect(normalizeSdkBaseUrl("openai", "http://localhost:8000/")).toBe("http://localhost:8000/") + expect(normalizeSdkBaseUrl("openai-compatible", "https://example.com")).toBe("https://example.com") + expect(normalizeSdkBaseUrl("openai", "https://example.com/v1")).toBe("https://example.com/v1") + }) + it("inherits the AskSage default /server path when the custom URL has no path", () => { expect(normalizeSdkBaseUrl("asksage", "https://asksage.internal.example")).toBe("https://asksage.internal.example/server") expect(normalizeSdkBaseUrl("asksage", "https://asksage.internal.example/custom")).toBe( diff --git a/apps/vscode/src/sdk/cline-session-factory.ts b/apps/vscode/src/sdk/cline-session-factory.ts index b0ab52de32..126d001ed3 100644 --- a/apps/vscode/src/sdk/cline-session-factory.ts +++ b/apps/vscode/src/sdk/cline-session-factory.ts @@ -610,7 +610,14 @@ export function normalizeSdkBaseUrl(providerId: string, baseUrl: unknown): strin return undefined } - const providerDefaultBaseUrl = MODEL_COLLECTIONS_BY_PROVIDER_ID[toSdkProviderId(providerId)]?.provider.baseUrl + const sdkProviderId = toSdkProviderId(providerId) + // OpenAI Compatible endpoints are user-supplied, so use the URL as entered + // rather than inheriting the OpenAI default `/v1` path. + if (sdkProviderId === "openai-compatible") { + return trimmed + } + + const providerDefaultBaseUrl = MODEL_COLLECTIONS_BY_PROVIDER_ID[sdkProviderId]?.provider.baseUrl if (!providerDefaultBaseUrl) { return trimmed } From adc7b15535135117ad6fd1ac1ea9df7d73bd1674 Mon Sep 17 00:00:00 2001 From: Saoud Rizwan <7799382+saoudrizwan@users.noreply.github.com> Date: Wed, 14 Oct 2026 00:17:19 -0700 Subject: [PATCH 14/21] fix(vscode): include attachments in ask_question answers (#15059) * fix(vscode): include attachments in ask_question answers Answer a pending ask_question with the user's images and attached files in the tool result itself, as the pre-SDK extension did, instead of dropping them. The SDK's AskQuestionExecutor may now return text and image blocks (the same shape read_file returns), and the answer bubble shows the attachments. * fix(vscode): let cancel settle an ask_question answer while attachments load Cancelling the task while an answer's attached files were still being extracted could not release the question, so the old run (and anything waiting for it to stop) stalled until extraction finished. Keep the claimed resolver reachable so clearPending settles it immediately. --- .../src/sdk/sdk-followup-coordinator.test.ts | 8 ++- .../src/sdk/sdk-followup-coordinator.ts | 2 +- .../sdk/sdk-interaction-coordinator.test.ts | 58 ++++++++++++++++++ .../src/sdk/sdk-interaction-coordinator.ts | 59 ++++++++++++++++--- apps/vscode/src/sdk/vscode-session-host.ts | 11 +--- .../core/src/extensions/tools/definitions.ts | 2 +- .../core/src/extensions/tools/types.ts | 5 +- 7 files changed, 124 insertions(+), 21 deletions(-) diff --git a/apps/vscode/src/sdk/sdk-followup-coordinator.test.ts b/apps/vscode/src/sdk/sdk-followup-coordinator.test.ts index 70b15f89c4..688059c848 100644 --- a/apps/vscode/src/sdk/sdk-followup-coordinator.test.ts +++ b/apps/vscode/src/sdk/sdk-followup-coordinator.test.ts @@ -34,9 +34,13 @@ describe("SdkFollowupCoordinator", () => { const { coordinator, options } = makeCoordinator() options.interactions.resolvePendingAskQuestion.mockReturnValue(true) - await coordinator.askResponse("answer") + await coordinator.askResponse("answer", ["data:image/png;base64,abc"], ["/workspace/spec.md"]) - expect(options.interactions.resolvePendingAskQuestion).toHaveBeenCalledWith("answer") + expect(options.interactions.resolvePendingAskQuestion).toHaveBeenCalledWith( + "answer", + ["data:image/png;base64,abc"], + ["/workspace/spec.md"], + ) expect(options.sessions.fireAndForgetSend).not.toHaveBeenCalled() }) diff --git a/apps/vscode/src/sdk/sdk-followup-coordinator.ts b/apps/vscode/src/sdk/sdk-followup-coordinator.ts index 2bc2e7086c..1ad4f64457 100644 --- a/apps/vscode/src/sdk/sdk-followup-coordinator.ts +++ b/apps/vscode/src/sdk/sdk-followup-coordinator.ts @@ -73,7 +73,7 @@ export class SdkFollowupCoordinator { return } - if (this.options.interactions.resolvePendingAskQuestion(prompt)) { + if (this.options.interactions.resolvePendingAskQuestion(prompt, images, files)) { return } diff --git a/apps/vscode/src/sdk/sdk-interaction-coordinator.test.ts b/apps/vscode/src/sdk/sdk-interaction-coordinator.test.ts index a0eb2bd6aa..e65f19eee3 100644 --- a/apps/vscode/src/sdk/sdk-interaction-coordinator.test.ts +++ b/apps/vscode/src/sdk/sdk-interaction-coordinator.test.ts @@ -1,5 +1,6 @@ import type { AgentEvent } from "@cline/shared" import { describe, expect, it, vi } from "vitest" +import { processFilesIntoText } from "@/integrations/misc/extract-text" import { MessageTranslatorState, translateSessionEvent } from "./message-translator" import { SdkInteractionCoordinator, type ToolApprovalRequest } from "./sdk-interaction-coordinator" import { SdkMessageCoordinator } from "./sdk-message-coordinator" @@ -14,6 +15,10 @@ vi.mock("@core/storage/disk", () => ({ saveClineMessages: vi.fn().mockResolvedValue(undefined), })) +vi.mock("@/integrations/misc/extract-text", () => ({ + processFilesIntoText: vi.fn(async (files: string[]) => `Files attached by the user:\n\n${files.join("\n")}`), +})) + describe("SdkInteractionCoordinator", () => { it("emits a tool approval ask and resolves approval from askResponse state", async () => { const task = createTaskProxy("session-123", vi.fn(), vi.fn()) @@ -358,6 +363,59 @@ describe("SdkInteractionCoordinator", () => { ]) }) + it.each([ + { prompt: "Use this design", expectedText: [{ type: "text", text: "Use this design" }] }, + { prompt: undefined, expectedText: [] }, + ])("resolves ask_question with attached images and files: $prompt", async ({ prompt, expectedText }) => { + const task = createTaskProxy("session-123", vi.fn(), vi.fn()) + const coordinator = new SdkInteractionCoordinator({ + messages: new SdkMessageCoordinator({ getTask: () => task }), + getSessionId: () => "session-123", + postStateToWebview: vi.fn().mockResolvedValue(undefined), + }) + const images = ["data:image/jpeg;base64,abc"] + const files = ["/workspace/spec.md"] + + const answerPromise = coordinator.handleAskQuestion("Which layout?", ["A", "B"], undefined) + await vi.waitFor(() => expect(task.messageStateHandler.getClineMessages()).toHaveLength(1)) + + expect(coordinator.resolvePendingAskQuestion(prompt, images, files)).toBe(true) + await expect(answerPromise).resolves.toEqual([ + ...expectedText, + { type: "image", mediaType: "image/jpeg", data: "abc" }, + { type: "text", text: "Files attached by the user:\n\n/workspace/spec.md" }, + ]) + expect(task.messageStateHandler.getClineMessages()).toMatchObject([ + { type: "ask", ask: "followup" }, + { type: "say", say: "user_feedback", text: prompt ?? "", images, files }, + ]) + }) + + it("settles an answered ask_question on cancel while its attachments are still loading", async () => { + const task = createTaskProxy("session-123", vi.fn(), vi.fn()) + const coordinator = new SdkInteractionCoordinator({ + messages: new SdkMessageCoordinator({ getTask: () => task }), + getSessionId: () => "session-123", + postStateToWebview: vi.fn().mockResolvedValue(undefined), + }) + let finishLoading!: (text: string) => void + vi.mocked(processFilesIntoText).mockImplementationOnce( + () => + new Promise((resolve) => { + finishLoading = resolve + }), + ) + + const answerPromise = coordinator.handleAskQuestion("Which spec?", ["A", "B"], undefined) + await vi.waitFor(() => expect(task.messageStateHandler.getClineMessages()).toHaveLength(1)) + expect(coordinator.resolvePendingAskQuestion("Use this spec", undefined, ["/workspace/huge.pdf"])).toBe(true) + + coordinator.clearPending("Cancelled") + await expect(answerPromise).resolves.toBe("") + finishLoading("Files attached by the user:\n\n/workspace/huge.pdf") + await expect(answerPromise).resolves.toBe("") + }) + it("shows an error row and stops immediately when the mistake limit is reached", async () => { const task = createTaskProxy("session-123", vi.fn(), vi.fn()) const setTurnPhase = vi.fn() diff --git a/apps/vscode/src/sdk/sdk-interaction-coordinator.ts b/apps/vscode/src/sdk/sdk-interaction-coordinator.ts index 2c51d18a10..4d176f579e 100644 --- a/apps/vscode/src/sdk/sdk-interaction-coordinator.ts +++ b/apps/vscode/src/sdk/sdk-interaction-coordinator.ts @@ -1,6 +1,8 @@ -import type { ConsecutiveMistakeLimitContext, ConsecutiveMistakeLimitDecision } from "@cline/shared" +import type { AskQuestionExecutor } from "@cline/core" +import type { ConsecutiveMistakeLimitContext, ConsecutiveMistakeLimitDecision, ImageContent, TextContent } from "@cline/shared" import type { ClineAskQuestion, ClineMessage, TurnPhase } from "@shared/ExtensionMessage" import type { ClineAskResponse } from "@shared/WebviewMessage" +import { processFilesIntoText } from "@/integrations/misc/extract-text" import { Logger } from "@/shared/services/Logger" import { MessageIdMinter } from "./message-id-minter" import { buildToolApprovalAskMessage } from "./message-translator" @@ -9,6 +11,24 @@ import { buildToolApprovalDenialReason } from "./tool-approval-denial" const TOOL_APPROVAL_DELIVERY_FAILED_REASON = "The tool approval request could not be displayed, so the tool was not executed." +type AskQuestionAnswer = Awaited> + +async function buildAnswerWithAttachments( + text: string, + images: string[] = [], + files: string[] = [], +): Promise> { + const filesText = files.length > 0 ? await processFilesIntoText(files) : "" + return [ + ...(text ? [{ type: "text" as const, text }] : []), + ...images.map((dataUrl): ImageContent => { + const match = dataUrl.match(/^data:([^;,]+);base64,(.+)$/) + return { type: "image", mediaType: match?.[1] ?? "image/png", data: match?.[2] ?? dataUrl } + }), + ...(filesText ? [{ type: "text" as const, text: filesText }] : []), + ] +} + export interface ToolApprovalRequest { agentId: string conversationId: string @@ -52,7 +72,9 @@ export interface SdkInteractionCoordinatorOptions { } export class SdkInteractionCoordinator { - private pendingAskResolve: ((answer: string) => void) | undefined + private pendingAskResolve: ((answer: AskQuestionAnswer) => void) | undefined + // An answered question whose attachments are still loading; cancellation must still settle it. + private loadingAskResolve: ((answer: AskQuestionAnswer) => void) | undefined private pendingToolApprovalResolve: ((result: { approved: boolean; reason?: string }) => void) | undefined private pendingToolApprovalMessage: | { @@ -152,7 +174,7 @@ export class SdkInteractionCoordinator { return approval } - async handleAskQuestion(question: string, options: string[], _context: unknown): Promise { + async handleAskQuestion(question: string, options: string[], _context: unknown): Promise { const askData: ClineAskQuestion = { question, options: options?.length ? options : undefined, @@ -172,7 +194,7 @@ export class SdkInteractionCoordinator { this.options.setTurnPhase?.("awaiting_followup", askMessage.ts) await this.options.postStateToWebview() - return new Promise((resolve) => { + return new Promise((resolve) => { this.pendingAskResolve = resolve }) } @@ -237,7 +259,7 @@ export class SdkInteractionCoordinator { return true } - resolvePendingAskQuestion(prompt: string | undefined): boolean { + resolvePendingAskQuestion(prompt: string | undefined, images?: string[], files?: string[]): boolean { if (!this.pendingAskResolve) { return false } @@ -245,14 +267,17 @@ export class SdkInteractionCoordinator { const resolve = this.pendingAskResolve this.pendingAskResolve = undefined const responseText = prompt ?? "" + const hasAttachments = !!(images?.length || files?.length) Logger.log(`[SdkController] Resolving pending ask_question with: "${responseText.substring(0, 80)}"`) - if (responseText) { + if (responseText || hasAttachments) { const userMessage: ClineMessage = { ts: this.nextMessageTs(), type: "say", say: "user_feedback", text: responseText, + images, + files, partial: false, } this.options.messages.appendAndEmit([userMessage], { @@ -263,17 +288,37 @@ export class SdkInteractionCoordinator { // User answered the follow-up — the agent resumes its turn. this.options.setTurnPhase?.("streaming") - resolve(responseText) + if (!hasAttachments) { + resolve(responseText) + return true + } + + this.loadingAskResolve = resolve + void buildAnswerWithAttachments(responseText, images, files) + .catch((error) => { + Logger.error(`[SdkController] Failed to load ask_question attachments: ${error}`) + return responseText + }) + .then((answer) => { + if (this.loadingAskResolve === resolve) { + this.loadingAskResolve = undefined + } + // No-op if clearPending already settled this question. + resolve(answer) + }) return true } clearPending(reason: string): void { const resolveAsk = this.pendingAskResolve + const resolveLoadingAsk = this.loadingAskResolve this.pendingAskResolve = undefined + this.loadingAskResolve = undefined // ask_question is awaiting this promise inside the outgoing agent run. Settle it // before session teardown so the run can unwind instead of remaining suspended; // use an empty answer so the lifecycle reason is not presented as user input. resolveAsk?.("") + resolveLoadingAsk?.("") this.denyPendingToolApproval(reason) } diff --git a/apps/vscode/src/sdk/vscode-session-host.ts b/apps/vscode/src/sdk/vscode-session-host.ts index 9127ed717c..301f74c89e 100644 --- a/apps/vscode/src/sdk/vscode-session-host.ts +++ b/apps/vscode/src/sdk/vscode-session-host.ts @@ -6,6 +6,7 @@ import { type ApplyPatchExecutor, + type AskQuestionExecutor, ClineCore, type ClineCoreListHistoryOptions, type ClineCoreStartInput, @@ -33,13 +34,7 @@ import { type ToolExecutors, toClineCoreStartInput, } from "@cline/core" -import { - type AgentToolContext, - RUNTIME_CONFIG_EXTENSION_KINDS, - type ToolApprovalRequest, - type ToolApprovalResult, - type ToolPolicy, -} from "@cline/shared" +import { RUNTIME_CONFIG_EXTENSION_KINDS, type ToolApprovalRequest, type ToolApprovalResult, type ToolPolicy } from "@cline/shared" import { StateManager } from "@/core/storage/StateManager" import type { VscodeTerminalManager } from "@/hosts/vscode/terminal/VscodeTerminalManager" import { getDistinctId } from "@/services/logging/distinctId" @@ -63,7 +58,7 @@ export interface VscodeSessionHostOptions { policy: { enabled: boolean; autoApprove: boolean } }) => Promise<{ approved: boolean; reason?: string }> /** Executor for the SDK's built-in ask_question tool (equivalent to classic ask_followup_question). */ - askQuestion?: (question: string, options: string[], context: AgentToolContext) => Promise + askQuestion?: AskQuestionExecutor /** * Custom `editor` tool executor (diff-view edit pipeline). Fully replaces the SDK's * default disk-writing executor. diff --git a/sdk/packages/core/src/extensions/tools/definitions.ts b/sdk/packages/core/src/extensions/tools/definitions.ts index 47c84a0c86..9f783791c8 100644 --- a/sdk/packages/core/src/extensions/tools/definitions.ts +++ b/sdk/packages/core/src/extensions/tools/definitions.ts @@ -817,7 +817,7 @@ export function createSkillsTool( */ export function createAskQuestionTool( executor: AskQuestionExecutor, -): AgentTool { +): AgentTool>> { return { name: "ask_question", description: diff --git a/sdk/packages/core/src/extensions/tools/types.ts b/sdk/packages/core/src/extensions/tools/types.ts index 06e7ee3407..177ceaedac 100644 --- a/sdk/packages/core/src/extensions/tools/types.ts +++ b/sdk/packages/core/src/extensions/tools/types.ts @@ -145,13 +145,14 @@ export type SkillsExecutor = ( * @param question - Single clarifying question for the user * @param options - 2-5 selectable answer options * @param context - Tool execution context - * @returns Executor-specific result payload + * @returns The user's answer, as text or as text and image blocks when the + * user attached images or files to it */ export type AskQuestionExecutor = ( question: string, options: string[], context: AgentToolContext, -) => Promise; +) => Promise>; /** * Skill metadata exposed by SkillsExecutor for clients/UI From 789f79e46d3ba579717e8dc8be35fe28af935b06 Mon Sep 17 00:00:00 2001 From: Dominic Cooney Date: Wed, 14 Oct 2026 16:42:19 +0900 Subject: [PATCH 15/21] feat(core): run plugins only in workers; drop the in-process mode (#15069) Plugin code never runs on a host's main thread. The in_process execution mode is removed along with the fallback to it when a host shipped no worker bootstrap: that fallback turned a loud "plugins do not load" into a silent "plugins run inside the hub", which is the failure class behind CLINE_PLUGIN_MODE is now `worker` (default) or `sandbox`; `in_process` is no longer honoured. A host that ships no bootstrap for the requested mode fails every plugin load with PluginBootstrapMissingError, which names CLINE_PLUGIN_BOOTSTRAP_PATH, so the cause shows in `cline config tools`, `cline hub plugins`, and session-start warnings instead of plugins silently dropping. The SSH remote helper is such a host today: it is uploaded as a single file with no bootstrap beside it, so plugin tools are reported missing there instead of running inside the remote hub. With no plugin code in the host, the registry loses the jiti import path, per-session module copies, AsyncLocalStorage call scoping, the __clinePluginHost shim, and stack attribution of uncaught errors. The daemon's uncaughtException / unhandledRejection handlers are fatal again. The in-process registry tests that cover retained behaviour (hook timeouts and error modes, multi-session notices, capability checks, discovery and settings issues, command/rule/builder timeouts, host-rejected registrations, timeout overrides, reload) move to the worker suite. How each host obtains the bootstrap and the SDK packages plugins import, and embedding them in compiled binaries, is left to follow-ups. --- docs/sdk/plugins.mdx | 9 +- sdk/ARCHITECTURE.md | 60 +- .../plugin/plugin-config-loader.test.ts | 7 +- .../extensions/plugin/plugin-config-loader.ts | 37 +- .../extensions/plugin/plugin-contributions.ts | 10 +- .../plugin/plugin-registry-worker.test.ts | 591 ------- .../extensions/plugin/plugin-registry.test.ts | 1410 ++++++++--------- .../src/extensions/plugin/plugin-registry.ts | 590 +------ .../plugin/plugin-sandbox-bootstrap.ts | 2 +- .../extensions/plugin/plugin-sandbox.test.ts | 11 + .../src/extensions/plugin/plugin-sandbox.ts | 82 +- .../core/src/hub/daemon/entry.test.ts | 81 +- sdk/packages/core/src/hub/daemon/entry.ts | 46 +- .../core/src/services/plugin-tools.test.ts | 40 +- 14 files changed, 876 insertions(+), 2100 deletions(-) delete mode 100644 sdk/packages/core/src/extensions/plugin/plugin-registry-worker.test.ts diff --git a/docs/sdk/plugins.mdx b/docs/sdk/plugins.mdx index 362692f433..35800277a7 100644 --- a/docs/sdk/plugins.mdx +++ b/docs/sdk/plugins.mdx @@ -108,14 +108,13 @@ A worker contains its plugin's failures: Values passed to and returned from a plugin cross a thread boundary, so they must be plain data; functions are dropped. `process.cwd()` reports the session's directory, but relative file paths resolve against the host's, so use `ctx.cwd`. Workers share the host's memory limit, environment snapshot, and native addons: this is crash isolation, not a security boundary. -Two other modes are available as opt-ins through `CLINE_PLUGIN_MODE` (or `mode` on `resolveAndLoadAgentPlugins`): +Plugin code never runs on the host's main thread. One other mode is available as an opt-in through `CLINE_PLUGIN_MODE` (or `mode` on `resolveAndLoadAgentPlugins`): -- **`in_process`**: plugins are imported into the host process. Lowest overhead, but globals, `process.env`, and the working directory are shared with the host, a synchronous loop blocks it, and a stray error the host cannot attribute to the plugin can end it. - **`sandbox`**: the previous per-session subprocess sandbox. It needs `node` or `bun` on `PATH` when the host isn't `node` or `bun` itself, and does not apply the per-session plugin policy or report plugin status. -Hosts that do not ship the worker bootstrap fall back to `in_process`. The CLI, the hub, the desktop app, and the VS Code extension ship it; an SDK host that bundles `@cline/core` can copy `dist/extensions/plugin-sandbox-bootstrap.js` next to its bundle, or point `CLINE_PLUGIN_BOOTSTRAP_PATH` at it. +Both modes need the worker bootstrap. The CLI, the hub, the desktop app, and the VS Code extension ship it; an SDK host that bundles `@cline/core` can copy `dist/extensions/plugin-sandbox-bootstrap.js` next to its bundle, or point `CLINE_PLUGIN_BOOTSTRAP_PATH` at it. A host that ships none (the SSH remote, which receives only the Cline CLI binary, does not yet) cannot load any plugin; each one is reported `failed` with a message naming that variable. -Plugins are bound to a session when it starts. Adding, editing, or reloading a plugin affects only sessions started afterwards; running sessions keep the plugins and code they started with. In worker mode every new session imports the plugin fresh, so any change is picked up; `cline hub plugins --reload ` re-checks it and clears a failed status. In `in_process` mode, edits to the plugin's entry file, or to any file it reaches through a relative `import ... from` or `require()`, are picked up automatically; after changing anything else the plugin loads (a dependency in `node_modules`, a side-effect `import "./x"`, a dynamic import), run `cline hub plugins --reload `. +Plugins are bound to a session when it starts. Adding, editing, or reloading a plugin affects only sessions started afterwards; running sessions keep the plugins and code they started with. Every new session imports the plugin fresh, so any change is picked up; `cline hub plugins --reload ` re-checks it and clears a failed status. ## Choosing Plugins per Session @@ -132,7 +131,7 @@ await cline.start({ }) ``` -A session only gets tools, hooks, rules, and providers from plugins it enabled. Plugin tools still go through `toolPolicies`. The policy applies in worker and `in_process` mode; the opt-in `sandbox` mode loads every discovered plugin. +A session only gets tools, hooks, rules, and providers from plugins it enabled. Plugin tools still go through `toolPolicies`. The policy applies in worker mode; the opt-in `sandbox` mode loads every discovered plugin. ## Setup and Call Context diff --git a/sdk/ARCHITECTURE.md b/sdk/ARCHITECTURE.md index 3b7471fe09..785da2000f 100644 --- a/sdk/ARCHITECTURE.md +++ b/sdk/ARCHITECTURE.md @@ -615,11 +615,12 @@ Design implications: #### Plugin execution -Every plugin a session uses goes through one `PluginRegistry` per process -(`getProcessPluginRegistry()`, `extensions/plugin/plugin-registry.ts`). The -registry owns status, failure counting, timeouts, the session policy, and -reload; the execution mode (`CLINE_PLUGIN_MODE`, `resolvePluginExecutionMode`) -only decides where a session's copy of a plugin runs: +Plugin code never runs on a host's main thread. Every plugin a session uses +goes through one `PluginRegistry` per process (`getProcessPluginRegistry()`, +`extensions/plugin/plugin-registry.ts`). The registry owns status, failure +counting, timeouts, the session policy, and reload; the execution mode +(`CLINE_PLUGIN_MODE`, `resolvePluginExecutionMode`) only decides which +isolated transport a session's copy of a plugin runs in: - `worker` (default): each plugin runs in its own worker thread per session (`WorkerSandbox`), so the host never imports plugin code. The copy the @@ -632,26 +633,22 @@ only decides where a session's copy of a plugin runs: terminates its worker, which stops synchronous loops; the next call starts a fresh worker. Released copies end their worker, which stops everything the plugin started. -- `in_process`: the registry imports each copy into the host with jiti. The - first session claims the copy imported at startup, and later sessions - re-evaluate the module (reusing jiti's transform cache). - `sandbox`: the previous per-session `node`/`bun` subprocess. It bypasses the registry (no status, policy, or reload) and is kept as an escape hatch. Plugins written for the per-session sandbox keep state at module level, often -one plugin object per import that refuses a second `setup()`, so every mode -gives each session its own copy. The session bootstrap, plugin tool listing +one plugin object per import that refuses a second `setup()`, so both modes +give each session its own copy. The session bootstrap, plugin tool listing (`listPluginToolsWithDiagnostics`), plugin MCP settings sync, and plugin slash -commands all load through the registry, so they see the same mode. Neither -`worker` nor `in_process` starts a child process, so plugins work in the -compiled CLI, the Hub it starts, the desktop app, and VS Code/Electron without -a runtime on `PATH`, and plugins can use the host runtime's globals (`Bun` -under the Bun-built Hub, including inside a Bun worker). +commands all load through the registry, so they see the same mode. Worker mode +starts no child process, so plugins work in the compiled CLI, the Hub it +starts, the desktop app, the SSH remote host, and VS Code/Electron without a +runtime on `PATH`, and plugins can use the host runtime's globals (`Bun` under +the Bun-built Hub, including inside a Bun worker). In the Hub daemon the registry is Hub-owned: the daemon checks global and -workspace plugins at startup (in throwaway workers in `worker` mode, by -importing them in `in_process` mode) so status is known before any session -exists. +workspace plugins at startup in throwaway workers so status is known before +any session exists. Each session gets its own guarded view of the plugins it enabled: @@ -738,16 +735,9 @@ Status is surfaced, never only logged: cache is keyed on each plugin's registry status, so a plugin a running session turned off is never answered from an earlier clean inspection. -In `in_process` mode the daemon's `uncaughtException` / `unhandledRejection` -handlers first ask the registry to attribute the error: when its stack points -into a known plugin root (the package directory for package plugins, the file -itself for drop-in files), that plugin is marked `failed` and the Hub keeps -running. Other errors still go to `shutdownFatal`, which is the accepted risk of -that mode: `process.exit()` from a plugin, or a rejection raised by runtime -internals with no plugin frame (an un-caught `fetch()`), ends the Hub. Plugins -also share the process (globals, `process.env`, native resources), and a -plugin stuck in a synchronous loop freezes the Hub until it returns. Worker -mode exists to remove these risks. +Because no plugin code runs on the daemon's thread, its `uncaughtException` / +`unhandledRejection` handlers treat every error as the daemon's own and go to +`shutdownFatal`; there is no stack attribution to plugins. #### Worker and subprocess transports @@ -770,10 +760,14 @@ imports anything but Node builtins, because hosts ship it without Hosts find the bootstrap beside their bundle (`plugin-sandbox-bootstrap.js`, or `.mjs` for the CommonJS VS Code bundle), beside the CLI binary -(`extensions/`), or through `CLINE_PLUGIN_BOOTSTRAP_PATH` (the desktop sidecar -sets it to the copy shipped as a Tauri resource). A host that ships none -cannot start workers, and `resolvePluginExecutionMode` falls back to -`in_process`. +(`extensions/`), or through `CLINE_PLUGIN_BOOTSTRAP_PATH` (the desktop +sidecar sets it to the copy shipped as a Tauri resource). A host that ships +none cannot run plugins at all: every load fails with +`PluginBootstrapMissingError`, which names the env var, rather than +importing plugin code into the host. The SSH remote host is in that state +today: since #14792 it is the bundled Cline CLI uploaded as a single file +with no bootstrap beside it, so plugins fail on remotes until the remote +runtime carries one. Sandboxed plugin subprocesses and workers are session-local but lazily recreatable. Core reclaims a sandbox after 30 minutes without an in-flight RPC call (configurable @@ -1055,7 +1049,7 @@ completed turn. Report and cleanup failures are logged separately. **I want to understand the agent loop and tool execution:** - Start: `packages/agents/src/agent.ts` — the stateless runtime loop - Then: `packages/agents/src/agent-step.ts` — individual iteration steps -- Extensions: `packages/core/src/extensions/plugin/` — plugin discovery, the in-process registry, and the opt-in sandbox +- Extensions: `packages/core/src/extensions/plugin/` — plugin discovery, the registry, and the worker/subprocess transports **I want to understand session persistence and state:** - Start: `packages/core/src/runtime/host/local-runtime-host.ts` — local session lifecycle diff --git a/sdk/packages/core/src/extensions/plugin/plugin-config-loader.test.ts b/sdk/packages/core/src/extensions/plugin/plugin-config-loader.test.ts index 0757eee588..32121b9fff 100644 --- a/sdk/packages/core/src/extensions/plugin/plugin-config-loader.test.ts +++ b/sdk/packages/core/src/extensions/plugin/plugin-config-loader.test.ts @@ -491,17 +491,18 @@ describe("plugin-config-loader", () => { } }); - it("runs plugins in workers by default and keeps the other modes opt-in", () => { + it("runs plugins in workers by default and keeps the sandbox opt-in", () => { delete process.env.CLINE_PLUGIN_MODE; expect(resolvePluginExecutionMode()).toBe("worker"); expect(resolvePluginExecutionMode("sandbox")).toBe("sandbox"); process.env.CLINE_PLUGIN_MODE = "sandbox"; expect(resolvePluginExecutionMode()).toBe("sandbox"); + // The removed in-process mode is not honoured: plugin code never runs + // on the host's main thread. process.env.CLINE_PLUGIN_MODE = "in_process"; - expect(resolvePluginExecutionMode()).toBe("in_process"); + expect(resolvePluginExecutionMode()).toBe("worker"); process.env.CLINE_PLUGIN_MODE = "unknown"; expect(resolvePluginExecutionMode()).toBe("worker"); - expect(resolvePluginExecutionMode("in_process")).toBe("in_process"); }); it("applies the session plugin policy", async () => { diff --git a/sdk/packages/core/src/extensions/plugin/plugin-config-loader.ts b/sdk/packages/core/src/extensions/plugin/plugin-config-loader.ts index ff01d2fcd1..d86e3186c7 100644 --- a/sdk/packages/core/src/extensions/plugin/plugin-config-loader.ts +++ b/sdk/packages/core/src/extensions/plugin/plugin-config-loader.ts @@ -21,10 +21,7 @@ import { type PluginHookErrorMode, type PluginRegistry, } from "./plugin-registry"; -import { - isPluginSandboxBootstrapAvailable, - loadSandboxedPlugins, -} from "./plugin-sandbox"; +import { loadSandboxedPlugins } from "./plugin-sandbox"; import type { PluginTargeting } from "./plugin-targeting"; export { getPluginDisplayName }; @@ -282,7 +279,7 @@ export function resolveAgentPluginSkillDirectories( ); } -export type PluginExecutionMode = "sandbox" | "worker" | "in_process"; +export type PluginExecutionMode = "sandbox" | "worker"; export const CLINE_PLUGIN_MODE_ENV = "CLINE_PLUGIN_MODE"; @@ -291,25 +288,17 @@ export const CLINE_PLUGIN_MODE_ENV = "CLINE_PLUGIN_MODE"; * - `worker` (default): each plugin runs in its own worker thread per * session, inside the host runtime. A plugin crash, `process.exit()`, or * hung loop ends only its worker, and no `node`/`bun` is needed on PATH. - * - `in_process`: plugins are imported into the host process. * - `sandbox`: the previous per-session `node`/`bun` subprocess. * - * Builds that do not ship the sandbox bootstrap (a host bundle without it) - * cannot start workers and fall back to `in_process`. + * Plugin code never runs on a host's main thread. Both modes need the + * sandbox bootstrap; a build that ships none fails every plugin load with + * `PluginBootstrapMissingError` rather than importing plugins into the host. */ export function resolvePluginExecutionMode( mode?: PluginExecutionMode, ): PluginExecutionMode { const configured = process.env[CLINE_PLUGIN_MODE_ENV]?.trim(); - const requested: PluginExecutionMode = - mode ?? - (configured === "sandbox" || configured === "in_process" - ? configured - : "worker"); - if (requested === "worker" && !isPluginSandboxBootstrapAvailable()) { - return "in_process"; - } - return requested; + return mode ?? (configured === "sandbox" ? configured : "worker"); } export interface ResolveAndLoadAgentPluginsOptions @@ -322,9 +311,8 @@ export interface ResolveAndLoadAgentPluginsOptions contributionTimeoutMs?: number; onEvent?: (event: { name: string; payload?: unknown }) => void; /** - * Structured workspace and git metadata. Forwarded to sandboxed plugins - * via PluginSetupCtx.workspaceInfo and made available to in-process plugins - * in the extension context. + * Structured workspace and git metadata, forwarded to plugins via + * PluginSetupCtx.workspaceInfo. */ workspaceInfo?: WorkspaceInfo; session?: PluginSetupContext["session"]; @@ -335,14 +323,14 @@ export interface ResolveAndLoadAgentPluginsOptions telemetry?: PluginSetupContext["telemetry"]; /** * Per-session plugin selection (`"*"` default plus per-plugin overrides). - * In-process only; the sandbox loads every discovered plugin. + * Worker mode only; the subprocess sandbox loads every discovered plugin. */ policy?: PluginPolicies; /** How a failing plugin hook affects the run. Defaults to `"ignore"`. */ hookErrorMode?: PluginHookErrorMode; - /** In-process registry. Defaults to the process-wide registry. */ + /** Worker mode: the registry to load through. Defaults to the process-wide one. */ registry?: PluginRegistry; - /** In-process only: a plugin this session uses became degraded or failed. */ + /** Worker mode only: a plugin this session uses became degraded or failed. */ onIssue?: (issue: SessionPluginIssue) => void; } @@ -358,7 +346,7 @@ export async function resolveAndLoadAgentPlugins( } & PluginLoadDiagnostics > { const mode = resolvePluginExecutionMode(options.mode); - if (mode === "in_process" || mode === "worker") { + if (mode === "worker") { const resolved = resolveAgentPluginPathsWithDiagnostics(options); if ( resolved.paths.length === 0 && @@ -375,7 +363,6 @@ export async function resolveAndLoadAgentPlugins( } const registry = options.registry ?? getProcessPluginRegistry(); const loaded = await registry.loadForSession({ - isolation: mode, sessionId: options.session?.sessionId, pluginPaths: resolved.paths, disabledPluginPaths: resolved.disabledPaths, diff --git a/sdk/packages/core/src/extensions/plugin/plugin-contributions.ts b/sdk/packages/core/src/extensions/plugin/plugin-contributions.ts index 39f29f3c6b..e54ccd2f27 100644 --- a/sdk/packages/core/src/extensions/plugin/plugin-contributions.ts +++ b/sdk/packages/core/src/extensions/plugin/plugin-contributions.ts @@ -93,15 +93,14 @@ function emptyContributions( /** * Runs each plugin's `setup` with a collecting API, for settings surfaces * that list what plugins contribute without starting a session. Plugins load - * the same way sessions load them: through the shared registry (in workers - * by default, or in-process), so failures are recorded there like any - * other, or through the subprocess sandbox when `CLINE_PLUGIN_MODE=sandbox`. + * the same way sessions load them: through the shared registry's workers, so + * failures are recorded there like any other, or through the subprocess + * sandbox when `CLINE_PLUGIN_MODE=sandbox`. */ export async function collectPluginContributions( input: CollectPluginContributionsInput, ): Promise { - const mode = resolvePluginExecutionMode(input.mode); - if (mode === "sandbox") { + if (resolvePluginExecutionMode(input.mode) === "sandbox") { return collectSandboxedContributions(input); } const registry = input.registry ?? getProcessPluginRegistry(); @@ -110,7 +109,6 @@ export async function collectPluginContributions( : undefined; const setupFailures = new Map(); const loaded = await registry.loadForSession({ - isolation: mode, pluginPaths: input.pluginPaths, disabledPluginPaths: input.disabledPluginPaths, discoveryFailures: input.discoveryFailures, diff --git a/sdk/packages/core/src/extensions/plugin/plugin-registry-worker.test.ts b/sdk/packages/core/src/extensions/plugin/plugin-registry-worker.test.ts deleted file mode 100644 index 6ef0699f74..0000000000 --- a/sdk/packages/core/src/extensions/plugin/plugin-registry-worker.test.ts +++ /dev/null @@ -1,591 +0,0 @@ -import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; -import { tmpdir } from "node:os"; -import { join } from "node:path"; -import type { - AgentExtension, - AgentExtensionApi, - AgentTool, - AgentToolContext, - Message, - PluginStatusRecord, - SessionPluginIssue, -} from "@cline/shared"; -import { afterEach, beforeEach, describe, expect, it } from "vitest"; -import { - PluginRegistry, - type PluginSessionLoadInput, - type PluginSessionLoadResult, -} from "./plugin-registry"; - -async function toolsOf(extension: AgentExtension | undefined) { - const tools: AgentTool[] = []; - const api = { - registerTool: (tool: AgentTool) => tools.push(tool), - registerCommand: () => {}, - registerRule: () => {}, - registerMessageBuilder: () => {}, - registerProvider: () => {}, - registerAutomationEventType: () => {}, - registerMcpServer: () => {}, - } as AgentExtensionApi; - await extension?.setup?.(api, {}); - return tools; -} - -const toolContext: AgentToolContext = { agentId: "agent", iteration: 1 }; - -/** A plugin whose tool runs `body` (an async function body taking `input`). */ -function toolPlugin(name: string, body = `return "ok from ${name}";`) { - return `import { isMainThread } from "node:worker_threads"; -export default { - name: ${JSON.stringify(name)}, - manifest: { capabilities: ["tools", "hooks"] }, - hooks: { beforeRun() {} }, - setup(api) { - api.registerTool({ - name: ${JSON.stringify(`${name}_tool`)}, - description: "test tool", - inputSchema: { type: "object" }, - execute: async (input) => { ${body} }, - }); - }, -}; -`; -} - -describe("PluginRegistry (worker isolation)", () => { - let root: string; - let registry: PluginRegistry; - let statusEvents: PluginStatusRecord[]; - const sessions: PluginSessionLoadResult[] = []; - - const write = async (file: string, source: string) => { - const path = join(root, file); - await writeFile(path, source, "utf8"); - return path; - }; - - const load = async ( - input: Omit, - ): Promise => { - const loaded = await registry.loadForSession({ - ...input, - isolation: "worker", - }); - sessions.push(loaded); - return loaded; - }; - - beforeEach(async () => { - root = await mkdtemp(join(tmpdir(), "core-plugin-registry-worker-")); - registry = new PluginRegistry({ - hookTimeoutMs: 2_000, - toolTimeoutMs: 2_000, - importTimeoutMs: 15_000, - failureThreshold: 3, - }); - statusEvents = []; - registry.subscribe((status) => statusEvents.push(status)); - }); - - afterEach(async () => { - await Promise.all(sessions.splice(0).map((session) => session.release())); - await rm(root, { recursive: true, force: true }); - }); - - it("runs the plugin off the main thread and reports it ready", async () => { - const path = await write( - "thread.mjs", - toolPlugin("thread", "return { isMainThread };"), - ); - const session = await load({ sessionId: "s1", pluginPaths: [path] }); - const [tool] = await toolsOf(session.extensions[0]); - await expect(tool?.execute({}, toolContext)).resolves.toEqual({ - isMainThread: false, - }); - const [status] = registry.get("thread"); - expect(status).toMatchObject({ - name: "thread", - state: "ready", - sessionIds: ["s1"], - hooks: ["beforeRun"], - }); - expect(status?.capabilities).toContain("tools"); - }, 30_000); - - it("gives each session its own worker", async () => { - const path = await write( - "counter.mjs", - toolPlugin( - "counter", - "globalThis.n = (globalThis.n ?? 0) + 1; return globalThis.n;", - ), - ); - const first = await load({ sessionId: "a", pluginPaths: [path] }); - const second = await load({ sessionId: "b", pluginPaths: [path] }); - const [toolA] = await toolsOf(first.extensions[0]); - const [toolB] = await toolsOf(second.extensions[0]); - await toolA?.execute({}, toolContext); - await expect(toolA?.execute({}, toolContext)).resolves.toBe(2); - await expect(toolB?.execute({}, toolContext)).resolves.toBe(1); - }, 30_000); - - it("applies the session policy", async () => { - const kept = await write("kept.mjs", toolPlugin("kept")); - const dropped = await write("dropped.mjs", toolPlugin("dropped")); - const session = await load({ - sessionId: "s1", - pluginPaths: [kept, dropped], - policy: { dropped: { enabled: false } }, - }); - expect(session.extensions.map((extension) => extension.name)).toEqual([ - "kept", - ]); - expect(session.issues).toContainEqual( - expect.objectContaining({ - name: "dropped", - state: "disabled", - reason: "session_policy", - }), - ); - }, 30_000); - - it("marks a plugin that fails to import failed for every session", async () => { - const broken = await write( - "broken.mjs", - "throw new Error('boom at import');\nexport default {};", - ); - const good = await write("good.mjs", toolPlugin("good")); - const session = await load({ - sessionId: "s1", - pluginPaths: [broken, good], - }); - expect(session.extensions.map((extension) => extension.name)).toEqual([ - "good", - ]); - expect(session.issues).toContainEqual( - expect.objectContaining({ name: "broken", state: "failed" }), - ); - expect(registry.get("broken")[0]?.lastError?.message).toMatch( - /boom at import/, - ); - const next = await load({ sessionId: "s2", pluginPaths: [broken] }); - expect(next.extensions).toHaveLength(0); - expect(next.issues[0]).toMatchObject({ name: "broken", state: "failed" }); - }, 30_000); - - it("fails only the session whose setup threw", async () => { - const path = await write( - "flaky.mjs", - `export default { - name: "flaky", - manifest: { capabilities: ["tools"] }, - setup() { if (process.env.FLAKY_FAIL === "1") throw new Error("setup boom"); }, -};`, - ); - process.env.FLAKY_FAIL = "1"; - try { - const failing = await load({ sessionId: "s1", pluginPaths: [path] }); - expect(failing.issues).toContainEqual( - expect.objectContaining({ name: "flaky", state: "failed" }), - ); - expect(registry.get("flaky")[0]?.lastError?.phase).toBe("setup"); - } finally { - delete process.env.FLAKY_FAIL; - } - // Setup failure does not block the plugin: the next session tries again. - const working = await load({ sessionId: "s2", pluginPaths: [path] }); - expect(working.extensions.map((extension) => extension.name)).toEqual([ - "flaky", - ]); - expect(registry.get("flaky")[0]?.state).toBe("ready"); - }, 30_000); - - it("degrades on tool failures and turns the plugin off at the threshold", async () => { - const path = await write( - "thrower.mjs", - toolPlugin("thrower", "throw new Error('tool boom');"), - ); - const issues: SessionPluginIssue[] = []; - const session = await load({ - sessionId: "s1", - pluginPaths: [path], - onIssue: (issue) => issues.push(issue), - }); - const [tool] = await toolsOf(session.extensions[0]); - for (let attempt = 0; attempt < 3; attempt++) { - await expect(tool?.execute({}, toolContext)).rejects.toThrow(/tool boom/); - } - expect(registry.get("thrower")[0]?.state).toBe("failed"); - expect(issues.map((issue) => issue.state)).toEqual(["degraded", "failed"]); - await expect(tool?.execute({}, toolContext)).rejects.toThrow( - /is unavailable/, - ); - const next = await load({ sessionId: "s2", pluginPaths: [path] }); - expect(next.extensions).toHaveLength(0); - }, 30_000); - - it("turns a plugin off when its worker dies, and the host keeps running", async () => { - const path = await write( - "crasher.mjs", - toolPlugin( - "crasher", - "setTimeout(() => { Promise.reject(new Error('stray rejection')); }, 0); return 'scheduled';", - ), - ); - const issues: SessionPluginIssue[] = []; - const session = await load({ - sessionId: "s1", - pluginPaths: [path], - onIssue: (issue) => issues.push(issue), - }); - const [tool] = await toolsOf(session.extensions[0]); - await expect(tool?.execute({}, toolContext)).resolves.toBe("scheduled"); - await expect - .poll(() => registry.get("crasher")[0]?.state, { timeout: 10_000 }) - .toBe("failed"); - expect(registry.get("crasher")[0]?.lastError).toMatchObject({ - phase: "uncaught", - }); - expect(issues.at(-1)).toMatchObject({ name: "crasher", state: "failed" }); - }, 30_000); - - it("reports process.exit during setup as a failed plugin", async () => { - const path = await write( - "exiter.mjs", - `export default { - name: "exiter", - manifest: { capabilities: ["tools"] }, - setup() { process.exit(1); }, -};`, - ); - const session = await load({ sessionId: "s1", pluginPaths: [path] }); - expect(session.extensions).toHaveLength(0); - expect(registry.get("exiter")[0]?.state).toBe("failed"); - expect(registry.get("exiter")[0]?.lastError).toMatchObject({ - phase: "setup", - message: expect.stringMatching(/exited \(code=1\)/), - }); - }, 30_000); - - it("stops a hung tool at its timeout without failing the host", async () => { - const path = await write( - "spinner.mjs", - toolPlugin("spinner", "if (input.spin) { for (;;) {} } return 'ok';"), - ); - const session = await load({ - sessionId: "s1", - pluginPaths: [path], - callTimeoutMs: 500, - }); - const [tool] = await toolsOf(session.extensions[0]); - await expect(tool?.execute({ spin: true }, toolContext)).rejects.toThrow( - /timed out/, - ); - expect(registry.get("spinner")[0]).toMatchObject({ - state: "degraded", - timeoutCount: 1, - }); - }, 30_000); - - it("reloads a fixed plugin and keeps stale copies from turning it off", async () => { - const path = await write( - "fixme.mjs", - toolPlugin("fixme", "throw new Error('old code');"), - ); - const old = await load({ sessionId: "old", pluginPaths: [path] }); - const [oldTool] = await toolsOf(old.extensions[0]); - await expect(oldTool?.execute({}, toolContext)).rejects.toThrow(/old code/); - - await write("fixme.mjs", toolPlugin("fixme", "return 'new code';")); - const [reloaded] = await registry.reload("fixme"); - expect(reloaded).toMatchObject({ state: "ready", errorCount: 0 }); - - // The old session's copy keeps failing; that must not count. - for (let attempt = 0; attempt < 3; attempt++) { - await expect(oldTool?.execute({}, toolContext)).rejects.toThrow( - /old code/, - ); - } - expect(registry.get("fixme")[0]?.state).toBe("ready"); - const fresh = await load({ sessionId: "new", pluginPaths: [path] }); - const [freshTool] = await toolsOf(fresh.extensions[0]); - await expect(freshTool?.execute({}, toolContext)).resolves.toBe("new code"); - }, 30_000); - - it("preloads status in throwaway workers", async () => { - const good = await write("pre-good.mjs", toolPlugin("pre-good")); - const bad = await write( - "pre-bad.mjs", - "throw new Error('nope');\nexport default {};", - ); - const statuses = await registry.preload({ - pluginPaths: [good, bad], - isolation: "worker", - }); - expect( - statuses.map(({ name, state, sessionIds }) => ({ - name, - state, - sessionIds, - })), - ).toEqual([ - { name: "pre-bad", state: "failed", sessionIds: [] }, - { name: "pre-good", state: "ready", sessionIds: [] }, - ]); - }, 30_000); - - it("gives setup the session cwd, events, and onDispose cleanup", async () => { - const marker = join(root, "disposed.txt"); - const path = await write( - "ctx.mjs", - `import { writeFileSync } from "node:fs"; -export default { - name: "ctx", - manifest: { capabilities: ["tools"] }, - setup(api, ctx) { - ctx.emitEvent("setup_ctx", { cwd: ctx.cwd }); - ctx.onDispose(() => writeFileSync(${JSON.stringify(marker)}, "done")); - api.registerTool({ - name: "ctx_tool", - description: "test tool", - inputSchema: { type: "object" }, - execute: async () => ctx.cwd, - }); - }, -};`, - ); - const events: Array<{ name: string; payload?: unknown }> = []; - const session = await load({ - sessionId: "s1", - pluginPaths: [path], - cwd: root, - emitEvent: (event) => events.push(event), - }); - const [tool] = await toolsOf(session.extensions[0]); - await expect(tool?.execute({}, toolContext)).resolves.toBe(root); - expect(events).toContainEqual({ - name: "setup_ctx", - payload: { cwd: root }, - }); - await session.release(); - await expect(readFile(marker, "utf8")).resolves.toBe("done"); - }, 30_000); - - it("bridges abort, progress updates, and events in the tool context", async () => { - const path = await write( - "live.mjs", - toolPlugin( - "live", - `context.emitUpdate({ step: 1 }); - context.emitEvent("tool_event", { from: "tool" }); - if (!input.wait) return "done"; - return await new Promise((resolve) => { - context.signal.addEventListener("abort", () => resolve("aborted")); - });`, - ).replace( - "execute: async (input) =>", - "execute: async (input, context) =>", - ), - ); - const events: Array<{ name: string; payload?: unknown }> = []; - const session = await load({ - sessionId: "s1", - pluginPaths: [path], - emitEvent: (event) => events.push(event), - }); - const [tool] = await toolsOf(session.extensions[0]); - const updates: unknown[] = []; - const controller = new AbortController(); - const running = tool?.execute( - { wait: true }, - { - ...toolContext, - signal: controller.signal, - emitUpdate: (update) => updates.push(update), - }, - ); - await expect.poll(() => updates.length).toBe(1); - controller.abort(); - await expect(running).resolves.toBe("aborted"); - expect(updates).toEqual([{ step: 1 }]); - expect(events).toContainEqual({ - name: "tool_event", - payload: { from: "tool" }, - }); - }, 30_000); - - it("keeps the tool definition fields the plugin declared", async () => { - const path = await write( - "definition.mjs", - `export default { - name: "definition", - manifest: { capabilities: ["tools"] }, - setup(api) { - api.registerTool({ - name: "finish", - description: "ends the run", - inputSchema: { type: "object" }, - lifecycle: { completesRun: true }, - executionMode: "sequential", - resultPolicy: "cache-oversized", - maxRetries: 2, - execute: async () => "ok", - }); - }, -};`, - ); - const session = await load({ sessionId: "s1", pluginPaths: [path] }); - const [tool] = await toolsOf(session.extensions[0]); - expect(tool).toMatchObject({ - name: "finish", - lifecycle: { completesRun: true }, - executionMode: "sequential", - resultPolicy: "cache-oversized", - maxRetries: 2, - }); - }, 30_000); - - it("fails only the session whose setup is too slow", async () => { - const path = await write( - "slow.mjs", - `export default { - name: "slow", - manifest: { capabilities: ["tools"] }, - async setup() { - if (process.env.SLOW_SETUP === "1") await new Promise((r) => setTimeout(r, 5000)); - }, -};`, - ); - const slowRegistry = new PluginRegistry({ - setupTimeoutMs: 300, - importTimeoutMs: 15_000, - }); - process.env.SLOW_SETUP = "1"; - let slow: PluginSessionLoadResult | undefined; - try { - slow = await slowRegistry.loadForSession({ - sessionId: "s1", - pluginPaths: [path], - isolation: "worker", - }); - } finally { - delete process.env.SLOW_SETUP; - } - expect(slow.extensions).toHaveLength(0); - expect(slowRegistry.get("slow")[0]?.lastError).toMatchObject({ - phase: "setup", - message: expect.stringMatching(/timed out/), - }); - const next = await slowRegistry.loadForSession({ - sessionId: "s2", - pluginPaths: [path], - isolation: "worker", - }); - expect(next.extensions.map((extension) => extension.name)).toEqual([ - "slow", - ]); - await Promise.all([slow.release(), next.release()]); - }, 30_000); - - it("treats a setup that hangs the worker as a setup failure", async () => { - const path = await write( - "hang.mjs", - `export default { - name: "hang", - manifest: { capabilities: ["tools"] }, - setup() { if (process.env.HANG_SETUP === "1") { for (;;) {} } }, -};`, - ); - // A synchronous loop blocks the in-worker setup timer, so the host's - // import + setup deadline is what stops it. - const hangRegistry = new PluginRegistry({ - setupTimeoutMs: 300, - importTimeoutMs: 3_000, - }); - process.env.HANG_SETUP = "1"; - try { - const hung = await hangRegistry.loadForSession({ - sessionId: "s1", - pluginPaths: [path], - isolation: "worker", - }); - expect(hung.extensions).toHaveLength(0); - } finally { - delete process.env.HANG_SETUP; - } - expect(hangRegistry.get("hang")[0]?.lastError?.phase).toBe("setup"); - const next = await hangRegistry.loadForSession({ - sessionId: "s2", - pluginPaths: [path], - isolation: "worker", - }); - expect(next.extensions).toHaveLength(1); - await next.release(); - }, 60_000); - - it("applies a policy that names the plugin by its exported name", async () => { - // The file name (kept.mjs) differs from the plugin's own name. - const path = await write("kept.mjs", toolPlugin("kept-plugin")); - const session = await load({ - sessionId: "s1", - pluginPaths: [path], - policy: { "*": { enabled: false }, "kept-plugin": { enabled: true } }, - }); - expect(session.extensions.map((extension) => extension.name)).toEqual([ - "kept-plugin", - ]); - }, 30_000); - - it("does not run setup for a plugin the policy turns off by exported name", async () => { - const marker = join(root, "setup-ran.txt"); - const path = await write( - "off.mjs", - `import { writeFileSync } from "node:fs"; -export default { - name: "off-plugin", - manifest: { capabilities: ["tools"] }, - setup() { writeFileSync(${JSON.stringify(marker)}, "ran"); }, -};`, - ); - const policy = { "off-plugin": { enabled: false } }; - const first = await load({ sessionId: "s1", pluginPaths: [path], policy }); - expect(first.extensions).toHaveLength(0); - expect(first.issues).toContainEqual( - expect.objectContaining({ - name: "off-plugin", - state: "disabled", - reason: "session_policy", - }), - ); - await expect(readFile(marker, "utf8")).rejects.toThrow(); - // Once the name is known, later sessions skip the worker entirely. - const second = await load({ sessionId: "s2", pluginPaths: [path], policy }); - expect(second.issues[0]).toMatchObject({ - name: "off-plugin", - reason: "session_policy", - }); - }, 30_000); - - it("runs a failed setup's onDispose cleanup", async () => { - const marker = join(root, "cleaned-after-failure.txt"); - const path = await write( - "fails-after-start.mjs", - `import { writeFileSync } from "node:fs"; -export default { - name: "fails-after-start", - manifest: { capabilities: ["tools"] }, - setup(_api, ctx) { - ctx.onDispose(() => writeFileSync(${JSON.stringify(marker)}, "cleaned")); - throw new Error("setup failed after starting work"); - }, -};`, - ); - const session = await load({ sessionId: "s1", pluginPaths: [path] }); - expect(session.extensions).toHaveLength(0); - expect(registry.get("fails-after-start")[0]?.lastError?.phase).toBe( - "setup", - ); - await expect(readFile(marker, "utf8")).resolves.toBe("cleaned"); - }, 30_000); -}); diff --git a/sdk/packages/core/src/extensions/plugin/plugin-registry.test.ts b/sdk/packages/core/src/extensions/plugin/plugin-registry.test.ts index e31ee78422..2acc94642e 100644 --- a/sdk/packages/core/src/extensions/plugin/plugin-registry.test.ts +++ b/sdk/packages/core/src/extensions/plugin/plugin-registry.test.ts @@ -1,5 +1,4 @@ -import { realpathSync } from "node:fs"; -import { mkdir, mkdtemp, rm, symlink, writeFile } from "node:fs/promises"; +import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; import { join } from "node:path"; import type { @@ -9,51 +8,53 @@ import type { AgentToolContext, Message, PluginStatusRecord, + SessionPluginIssue, } from "@cline/shared"; import { afterEach, beforeEach, describe, expect, it } from "vitest"; -import { PluginCallTimeoutError, PluginRegistry } from "./plugin-registry"; +import { + PluginCallTimeoutError, + PluginRegistry, + type PluginSessionLoadInput, + type PluginSessionLoadResult, +} from "./plugin-registry"; +import { setPluginBootstrapUnavailableForTests } from "./plugin-sandbox"; -type Collected = { tools: AgentTool[]; rules: unknown[] }; - -function collectingApi(): { - api: AgentExtensionApi; - collected: Collected; -} { - const collected: Collected = { tools: [], rules: [] }; +function noopApi(): AgentExtensionApi { return { - collected, - api: { - registerTool: (tool) => collected.tools.push(tool), - registerCommand: () => {}, - registerRule: (rule) => collected.rules.push(rule), - registerMessageBuilder: () => {}, - registerProvider: () => {}, - registerAutomationEventType: () => {}, - registerMcpServer: () => {}, - }, + registerTool: () => {}, + registerCommand: () => {}, + registerRule: () => {}, + registerMessageBuilder: () => {}, + registerProvider: () => {}, + registerAutomationEventType: () => {}, + registerMcpServer: () => {}, }; } -async function setUp( - extension: AgentExtension | undefined, -): Promise { - const { api, collected } = collectingApi(); - await extension?.setup?.(api, {}); - return collected; +async function toolsOf(extension: AgentExtension | undefined) { + const tools: AgentTool[] = []; + await extension?.setup?.( + { ...noopApi(), registerTool: (tool: AgentTool) => tools.push(tool) }, + {}, + ); + return tools; } const toolContext: AgentToolContext = { agentId: "agent", iteration: 1 }; -function toolPlugin(name: string, toolName = `${name}_tool`): string { - return `export default { +/** A plugin whose tool runs `body` (an async function body taking `input`). */ +function toolPlugin(name: string, body = `return "ok from ${name}";`) { + return `import { isMainThread } from "node:worker_threads"; +export default { name: ${JSON.stringify(name)}, - manifest: { capabilities: ["tools"] }, + manifest: { capabilities: ["tools", "hooks"] }, + hooks: { beforeRun() {} }, setup(api) { api.registerTool({ - name: ${JSON.stringify(toolName)}, + name: ${JSON.stringify(`${name}_tool`)}, description: "test tool", inputSchema: { type: "object" }, - execute: async () => "ok from ${name}", + execute: async (input) => { ${body} }, }); }, }; @@ -64,6 +65,7 @@ describe("PluginRegistry", () => { let root: string; let registry: PluginRegistry; let statusEvents: PluginStatusRecord[]; + const sessions: PluginSessionLoadResult[] = []; const write = async (file: string, source: string) => { const path = join(root, file); @@ -71,12 +73,20 @@ describe("PluginRegistry", () => { return path; }; + const load = async ( + input: PluginSessionLoadInput, + ): Promise => { + const loaded = await registry.loadForSession(input); + sessions.push(loaded); + return loaded; + }; + beforeEach(async () => { root = await mkdtemp(join(tmpdir(), "core-plugin-registry-")); registry = new PluginRegistry({ - hookTimeoutMs: 100, - toolTimeoutMs: 100, - setupTimeoutMs: 500, + hookTimeoutMs: 2_000, + toolTimeoutMs: 2_000, + importTimeoutMs: 15_000, failureThreshold: 3, }); statusEvents = []; @@ -84,264 +94,519 @@ describe("PluginRegistry", () => { }); afterEach(async () => { + await Promise.all(sessions.splice(0).map((session) => session.release())); await rm(root, { recursive: true, force: true }); }); - it("loads a plugin once and reports it ready with its sessions", async () => { - const path = await write("good.js", toolPlugin("good")); - const first = await registry.loadForSession({ - sessionId: "s1", - pluginPaths: [path], - }); - const second = await registry.loadForSession({ - sessionId: "s2", - pluginPaths: [path], - }); - - const collected = await setUp(first.extensions[0]); - expect(collected.tools.map((tool) => tool.name)).toEqual(["good_tool"]); - await expect(collected.tools[0]?.execute({}, toolContext)).resolves.toBe( - "ok from good", + it("runs the plugin off the main thread and reports it ready", async () => { + const path = await write( + "thread.mjs", + toolPlugin("thread", "return { isMainThread };"), ); - expect(second.extensions).toHaveLength(1); - - const [status] = registry.list(); + const session = await load({ sessionId: "s1", pluginPaths: [path] }); + const [tool] = await toolsOf(session.extensions[0]); + await expect(tool?.execute({}, toolContext)).resolves.toEqual({ + isMainThread: false, + }); + const [status] = registry.get("thread"); expect(status).toMatchObject({ - name: "good", + name: "thread", state: "ready", - errorCount: 0, - sessionIds: ["s1", "s2"], + sessionIds: ["s1"], + hooks: ["beforeRun"], }); - expect(statusEvents.map((event) => event.state)).toEqual([ - "loading", - "ready", - ]); + expect(status?.capabilities).toContain("tools"); + }, 30_000); - first.release(); - expect(registry.list()[0]?.sessionIds).toEqual(["s2"]); - }); - - it("marks a plugin that throws on import failed and keeps the others", async () => { - const good = await write("good.js", toolPlugin("good")); - const broken = await write( - "throws-on-import.js", - `throw new Error("import exploded");\nexport default {};\n`, + it("gives each session its own worker", async () => { + const path = await write( + "counter.mjs", + toolPlugin( + "counter", + "globalThis.n = (globalThis.n ?? 0) + 1; return globalThis.n;", + ), ); + const first = await load({ sessionId: "a", pluginPaths: [path] }); + const second = await load({ sessionId: "b", pluginPaths: [path] }); + const [toolA] = await toolsOf(first.extensions[0]); + const [toolB] = await toolsOf(second.extensions[0]); + await toolA?.execute({}, toolContext); + await expect(toolA?.execute({}, toolContext)).resolves.toBe(2); + await expect(toolB?.execute({}, toolContext)).resolves.toBe(1); + }, 30_000); - const loaded = await registry.loadForSession({ + it("applies the session policy", async () => { + const kept = await write("kept.mjs", toolPlugin("kept")); + const dropped = await write("dropped.mjs", toolPlugin("dropped")); + const session = await load({ + sessionId: "s1", + pluginPaths: [kept, dropped], + policy: { dropped: { enabled: false } }, + }); + expect(session.extensions.map((extension) => extension.name)).toEqual([ + "kept", + ]); + expect(session.issues).toContainEqual( + expect.objectContaining({ + name: "dropped", + state: "disabled", + reason: "session_policy", + }), + ); + }, 30_000); + + it("marks a plugin that fails to import failed for every session", async () => { + const broken = await write( + "broken.mjs", + "throw new Error('boom at import');\nexport default {};", + ); + const good = await write("good.mjs", toolPlugin("good")); + const session = await load({ sessionId: "s1", pluginPaths: [broken, good], }); - - expect(loaded.extensions.map((extension) => extension.name)).toEqual([ + expect(session.extensions.map((extension) => extension.name)).toEqual([ "good", ]); - const status = registry.get(broken)[0]; - expect(status?.state).toBe("failed"); - expect(status?.lastError?.phase).toBe("import"); - expect(status?.lastError?.message).toContain("import exploded"); - expect(loaded.failures).toEqual([ - expect.objectContaining({ pluginPath: broken, phase: "load" }), - ]); - expect(loaded.issues).toEqual([ - expect.objectContaining({ - pluginPath: broken, - state: "failed", - reason: "error", - }), - ]); - }); + expect(session.issues).toContainEqual( + expect.objectContaining({ name: "broken", state: "failed" }), + ); + expect(registry.get("broken")[0]?.lastError?.message).toMatch( + /boom at import/, + ); + const next = await load({ sessionId: "s2", pluginPaths: [broken] }); + expect(next.extensions).toHaveLength(0); + expect(next.issues[0]).toMatchObject({ name: "broken", state: "failed" }); + }, 30_000); - it("marks a plugin that throws in setup failed without partial registrations", async () => { - const good = await write("good.js", toolPlugin("good")); - const broken = await write( - "throws-in-setup.js", + it("fails only the session whose setup threw", async () => { + const path = await write( + "flaky.mjs", `export default { - name: "throws-in-setup", + name: "flaky", + manifest: { capabilities: ["tools"] }, + setup() { if (process.env.FLAKY_FAIL === "1") throw new Error("setup boom"); }, +};`, + ); + process.env.FLAKY_FAIL = "1"; + try { + const failing = await load({ sessionId: "s1", pluginPaths: [path] }); + expect(failing.issues).toContainEqual( + expect.objectContaining({ name: "flaky", state: "failed" }), + ); + expect(registry.get("flaky")[0]?.lastError?.phase).toBe("setup"); + } finally { + delete process.env.FLAKY_FAIL; + } + // Setup failure does not block the plugin: the next session tries again. + const working = await load({ sessionId: "s2", pluginPaths: [path] }); + expect(working.extensions.map((extension) => extension.name)).toEqual([ + "flaky", + ]); + expect(registry.get("flaky")[0]?.state).toBe("ready"); + }, 30_000); + + it("degrades on tool failures and turns the plugin off at the threshold", async () => { + const path = await write( + "thrower.mjs", + toolPlugin("thrower", "throw new Error('tool boom');"), + ); + const issues: SessionPluginIssue[] = []; + const session = await load({ + sessionId: "s1", + pluginPaths: [path], + onIssue: (issue) => issues.push(issue), + }); + const [tool] = await toolsOf(session.extensions[0]); + for (let attempt = 0; attempt < 3; attempt++) { + await expect(tool?.execute({}, toolContext)).rejects.toThrow(/tool boom/); + } + expect(registry.get("thrower")[0]?.state).toBe("failed"); + expect(issues.map((issue) => issue.state)).toEqual(["degraded", "failed"]); + await expect(tool?.execute({}, toolContext)).rejects.toThrow( + /is unavailable/, + ); + const next = await load({ sessionId: "s2", pluginPaths: [path] }); + expect(next.extensions).toHaveLength(0); + }, 30_000); + + it("turns a plugin off when its worker dies, and the host keeps running", async () => { + const path = await write( + "crasher.mjs", + toolPlugin( + "crasher", + "setTimeout(() => { Promise.reject(new Error('stray rejection')); }, 0); return 'scheduled';", + ), + ); + const issues: SessionPluginIssue[] = []; + const session = await load({ + sessionId: "s1", + pluginPaths: [path], + onIssue: (issue) => issues.push(issue), + }); + const [tool] = await toolsOf(session.extensions[0]); + await expect(tool?.execute({}, toolContext)).resolves.toBe("scheduled"); + await expect + .poll(() => registry.get("crasher")[0]?.state, { timeout: 10_000 }) + .toBe("failed"); + expect(registry.get("crasher")[0]?.lastError).toMatchObject({ + phase: "uncaught", + }); + expect(issues.at(-1)).toMatchObject({ name: "crasher", state: "failed" }); + }, 30_000); + + it("reports process.exit during setup as a failed plugin", async () => { + const path = await write( + "exiter.mjs", + `export default { + name: "exiter", + manifest: { capabilities: ["tools"] }, + setup() { process.exit(1); }, +};`, + ); + const session = await load({ sessionId: "s1", pluginPaths: [path] }); + expect(session.extensions).toHaveLength(0); + expect(registry.get("exiter")[0]?.state).toBe("failed"); + expect(registry.get("exiter")[0]?.lastError).toMatchObject({ + phase: "setup", + message: expect.stringMatching(/exited \(code=1\)/), + }); + }, 30_000); + + it("stops a hung tool at its timeout without failing the host", async () => { + const path = await write( + "spinner.mjs", + toolPlugin("spinner", "if (input.spin) { for (;;) {} } return 'ok';"), + ); + const session = await load({ + sessionId: "s1", + pluginPaths: [path], + callTimeoutMs: 500, + }); + const [tool] = await toolsOf(session.extensions[0]); + await expect(tool?.execute({ spin: true }, toolContext)).rejects.toThrow( + /timed out/, + ); + expect(registry.get("spinner")[0]).toMatchObject({ + state: "degraded", + timeoutCount: 1, + }); + }, 30_000); + + it("reloads a fixed plugin and keeps stale copies from turning it off", async () => { + const path = await write( + "fixme.mjs", + toolPlugin("fixme", "throw new Error('old code');"), + ); + const old = await load({ sessionId: "old", pluginPaths: [path] }); + const [oldTool] = await toolsOf(old.extensions[0]); + await expect(oldTool?.execute({}, toolContext)).rejects.toThrow(/old code/); + + await write("fixme.mjs", toolPlugin("fixme", "return 'new code';")); + const [reloaded] = await registry.reload("fixme"); + expect(reloaded).toMatchObject({ state: "ready", errorCount: 0 }); + + // The old session's copy keeps failing; that must not count. + for (let attempt = 0; attempt < 3; attempt++) { + await expect(oldTool?.execute({}, toolContext)).rejects.toThrow( + /old code/, + ); + } + expect(registry.get("fixme")[0]?.state).toBe("ready"); + const fresh = await load({ sessionId: "new", pluginPaths: [path] }); + const [freshTool] = await toolsOf(fresh.extensions[0]); + await expect(freshTool?.execute({}, toolContext)).resolves.toBe("new code"); + }, 30_000); + + it("preloads status in throwaway workers", async () => { + const good = await write("pre-good.mjs", toolPlugin("pre-good")); + const bad = await write( + "pre-bad.mjs", + "throw new Error('nope');\nexport default {};", + ); + const statuses = await registry.preload({ + pluginPaths: [good, bad], + }); + expect( + statuses.map(({ name, state, sessionIds }) => ({ + name, + state, + sessionIds, + })), + ).toEqual([ + { name: "pre-bad", state: "failed", sessionIds: [] }, + { name: "pre-good", state: "ready", sessionIds: [] }, + ]); + }, 30_000); + + it("gives setup the session cwd, events, and onDispose cleanup", async () => { + const marker = join(root, "disposed.txt"); + const path = await write( + "ctx.mjs", + `import { writeFileSync } from "node:fs"; +export default { + name: "ctx", + manifest: { capabilities: ["tools"] }, + setup(api, ctx) { + ctx.emitEvent("setup_ctx", { cwd: ctx.cwd }); + ctx.onDispose(() => writeFileSync(${JSON.stringify(marker)}, "done")); + api.registerTool({ + name: "ctx_tool", + description: "test tool", + inputSchema: { type: "object" }, + execute: async () => ctx.cwd, + }); + }, +};`, + ); + const events: Array<{ name: string; payload?: unknown }> = []; + const session = await load({ + sessionId: "s1", + pluginPaths: [path], + cwd: root, + emitEvent: (event) => events.push(event), + }); + const [tool] = await toolsOf(session.extensions[0]); + await expect(tool?.execute({}, toolContext)).resolves.toBe(root); + expect(events).toContainEqual({ + name: "setup_ctx", + payload: { cwd: root }, + }); + await session.release(); + await expect(readFile(marker, "utf8")).resolves.toBe("done"); + }, 30_000); + + it("bridges abort, progress updates, and events in the tool context", async () => { + const path = await write( + "live.mjs", + toolPlugin( + "live", + `context.emitUpdate({ step: 1 }); + context.emitEvent("tool_event", { from: "tool" }); + if (!input.wait) return "done"; + return await new Promise((resolve) => { + context.signal.addEventListener("abort", () => resolve("aborted")); + });`, + ).replace( + "execute: async (input) =>", + "execute: async (input, context) =>", + ), + ); + const events: Array<{ name: string; payload?: unknown }> = []; + const session = await load({ + sessionId: "s1", + pluginPaths: [path], + emitEvent: (event) => events.push(event), + }); + const [tool] = await toolsOf(session.extensions[0]); + const updates: unknown[] = []; + const controller = new AbortController(); + const running = tool?.execute( + { wait: true }, + { + ...toolContext, + signal: controller.signal, + emitUpdate: (update) => updates.push(update), + }, + ); + await expect.poll(() => updates.length).toBe(1); + controller.abort(); + await expect(running).resolves.toBe("aborted"); + expect(updates).toEqual([{ step: 1 }]); + expect(events).toContainEqual({ + name: "tool_event", + payload: { from: "tool" }, + }); + }, 30_000); + + it("keeps the tool definition fields the plugin declared", async () => { + const path = await write( + "definition.mjs", + `export default { + name: "definition", manifest: { capabilities: ["tools"] }, setup(api) { - api.registerTool({ name: "half", description: "", inputSchema: {}, execute: () => "" }); - throw new Error("setup exploded"); + api.registerTool({ + name: "finish", + description: "ends the run", + inputSchema: { type: "object" }, + lifecycle: { completesRun: true }, + executionMode: "sequential", + resultPolicy: "cache-oversized", + maxRetries: 2, + execute: async () => "ok", + }); }, -}; -`, +};`, ); - - const loaded = await registry.loadForSession({ - sessionId: "s1", - pluginPaths: [broken, good], + const session = await load({ sessionId: "s1", pluginPaths: [path] }); + const [tool] = await toolsOf(session.extensions[0]); + expect(tool).toMatchObject({ + name: "finish", + lifecycle: { completesRun: true }, + executionMode: "sequential", + resultPolicy: "cache-oversized", + maxRetries: 2, }); - const [brokenExtension, goodExtension] = loaded.extensions; - expect((await setUp(brokenExtension)).tools).toEqual([]); - expect((await setUp(goodExtension)).tools).toHaveLength(1); + }, 30_000); - const status = registry.get("throws-in-setup")[0]; - expect(status?.state).toBe("failed"); - expect(status?.lastError).toMatchObject({ + it("fails only the session whose setup is too slow", async () => { + const path = await write( + "slow.mjs", + `export default { + name: "slow", + manifest: { capabilities: ["tools"] }, + async setup() { + if (process.env.SLOW_SETUP === "1") await new Promise((r) => setTimeout(r, 5000)); + }, +};`, + ); + const slowRegistry = new PluginRegistry({ + setupTimeoutMs: 300, + importTimeoutMs: 15_000, + }); + process.env.SLOW_SETUP = "1"; + let slow: PluginSessionLoadResult | undefined; + try { + slow = await slowRegistry.loadForSession({ + sessionId: "s1", + pluginPaths: [path], + }); + } finally { + delete process.env.SLOW_SETUP; + } + expect(slow.extensions).toHaveLength(0); + expect(slowRegistry.get("slow")[0]?.lastError).toMatchObject({ phase: "setup", - message: "setup exploded", - sessionId: "s1", + message: expect.stringMatching(/timed out/), }); - - // Setup failure is per session: the next session gets a fresh copy, - // tries again, and is told about its own failure. - const told: string[] = []; - const next = await registry.loadForSession({ + const next = await slowRegistry.loadForSession({ sessionId: "s2", - pluginPaths: [broken, good], - onIssue: (issue) => told.push(`${issue.name}:${issue.state}`), + pluginPaths: [path], }); expect(next.extensions.map((extension) => extension.name)).toEqual([ - "throws-in-setup", - "good", + "slow", ]); - expect((await setUp(next.extensions[0])).tools).toEqual([]); - expect(told).toEqual(["throws-in-setup:failed"]); - }); + await Promise.all([slow.release(), next.release()]); + }, 30_000); - it("gives each session its own copy of the plugin module", async () => { - // Plugins written for the per-session sandbox build one plugin object - // per import and refuse a second setup(). + it("treats a setup that hangs the worker as a setup failure", async () => { const path = await write( - "one-setup-per-module.js", - `let setupDone = false; -export default { - name: "one-setup", + "hang.mjs", + `export default { + name: "hang", manifest: { capabilities: ["tools"] }, - setup(api, ctx) { - if (setupDone) throw new Error("Create a separate handle for each session"); - setupDone = true; - const owner = ctx.session?.sessionId; - api.registerTool({ name: "owner", description: "", inputSchema: {}, execute: () => owner }); - }, -}; -`, + setup() { if (process.env.HANG_SETUP === "1") { for (;;) {} } }, +};`, ); - const tools: AgentTool[] = []; - for (const sessionId of ["s1", "s2", "s3"]) { - const loaded = await registry.loadForSession({ - sessionId, + // A synchronous loop blocks the in-worker setup timer, so the host's + // import + setup deadline is what stops it. + const hangRegistry = new PluginRegistry({ + setupTimeoutMs: 300, + importTimeoutMs: 3_000, + }); + process.env.HANG_SETUP = "1"; + try { + const hung = await hangRegistry.loadForSession({ + sessionId: "s1", pluginPaths: [path], - setupContext: { session: { sessionId } }, }); - tools.push(...(await setUp(loaded.extensions[0])).tools); + expect(hung.extensions).toHaveLength(0); + } finally { + delete process.env.HANG_SETUP; } - - expect(registry.get(path)[0]).toMatchObject({ - state: "ready", - errorCount: 0, - sessionIds: ["s1", "s2", "s3"], - }); - await expect( - Promise.all(tools.map((tool) => tool.execute({}, toolContext))), - ).resolves.toEqual(["s1", "s2", "s3"]); - }); - - it("keeps other sessions working when one session's setup fails", async () => { - const path = await write( - "session-picky.js", - `export default { - name: "session-picky", - manifest: { capabilities: ["tools"] }, - setup(api, ctx) { - if (ctx.session?.sessionId === "bad") throw new Error("not for this session"); - api.registerTool({ name: "picky", description: "", inputSchema: {}, execute: () => "ok" }); - }, -}; -`, - ); - const told: string[] = []; - const load = (sessionId: string) => - registry.loadForSession({ - sessionId, - pluginPaths: [path], - setupContext: { session: { sessionId } }, - onIssue: (issue) => told.push(`${sessionId}:${issue.state}`), - }); - const [tool] = (await setUp((await load("good")).extensions[0])).tools; - await setUp((await load("bad")).extensions[0]); - expect(registry.get(path)[0]?.lastError?.phase).toBe("setup"); - // Only the session whose setup failed is told; "good" still works. - expect(told).toEqual(["bad:failed"]); - - await expect(tool?.execute({}, toolContext)).resolves.toBe("ok"); - const later = await setUp((await load("later")).extensions[0]); - expect(later.tools).toHaveLength(1); - expect(registry.get(path)[0]?.state).toBe("ready"); - }); - - it("notifies every session using a plugin when it degrades or fails", async () => { - const path = await write( - "flaky-hook.js", - `export default { - name: "flaky-hook", - manifest: { capabilities: ["hooks"] }, - hooks: { beforeRun: async () => { throw new Error("flaky"); } }, -}; -`, - ); - const seen: Array<{ session: string; state: string }> = []; - const first = await registry.loadForSession({ - sessionId: "s1", - pluginPaths: [path], - onIssue: (issue) => seen.push({ session: "s1", state: issue.state }), - }); - await registry.loadForSession({ + expect(hangRegistry.get("hang")[0]?.lastError?.phase).toBe("setup"); + const next = await hangRegistry.loadForSession({ sessionId: "s2", pluginPaths: [path], - onIssue: (issue) => seen.push({ session: "s2", state: issue.state }), }); - const beforeRun = first.extensions[0]?.hooks?.beforeRun; - for (let attempt = 0; attempt < 3; attempt++) { - await beforeRun?.({ snapshot: {} } as never); - } + expect(next.extensions).toHaveLength(1); + await next.release(); + }, 60_000); - // One notice per state change per session, not one per failure. - expect(seen).toEqual([ - { session: "s1", state: "degraded" }, - { session: "s2", state: "degraded" }, - { session: "s1", state: "failed" }, - { session: "s2", state: "failed" }, + it("applies a policy that names the plugin by its exported name", async () => { + // The file name (kept.mjs) differs from the plugin's own name. + const path = await write("kept.mjs", toolPlugin("kept-plugin")); + const session = await load({ + sessionId: "s1", + pluginPaths: [path], + policy: { "*": { enabled: false }, "kept-plugin": { enabled: true } }, + }); + expect(session.extensions.map((extension) => extension.name)).toEqual([ + "kept-plugin", ]); - }); + }, 30_000); - it("rejects registrations that need a missing capability", async () => { + it("does not run setup for a plugin the policy turns off by exported name", async () => { + const marker = join(root, "setup-ran.txt"); const path = await write( - "no-rules-capability.js", - `export default { - name: "no-rules", + "off.mjs", + `import { writeFileSync } from "node:fs"; +export default { + name: "off-plugin", manifest: { capabilities: ["tools"] }, - setup(api) { api.registerRule({ id: "r", content: "x" }); }, -}; -`, + setup() { writeFileSync(${JSON.stringify(marker)}, "ran"); }, +};`, ); - const loaded = await registry.loadForSession({ pluginPaths: [path] }); - expect((await setUp(loaded.extensions[0])).rules).toEqual([]); - expect(registry.get(path)[0]?.lastError?.message).toContain( - 'requires the "rules" capability', + const policy = { "off-plugin": { enabled: false } }; + const first = await load({ sessionId: "s1", pluginPaths: [path], policy }); + expect(first.extensions).toHaveLength(0); + expect(first.issues).toContainEqual( + expect.objectContaining({ + name: "off-plugin", + state: "disabled", + reason: "session_policy", + }), ); - }); + await expect(readFile(marker, "utf8")).rejects.toThrow(); + // Once the name is known, later sessions skip the worker entirely. + const second = await load({ sessionId: "s2", pluginPaths: [path], policy }); + expect(second.issues[0]).toMatchObject({ + name: "off-plugin", + reason: "session_policy", + }); + }, 30_000); + + it("runs a failed setup's onDispose cleanup", async () => { + const marker = join(root, "cleaned-after-failure.txt"); + const path = await write( + "fails-after-start.mjs", + `import { writeFileSync } from "node:fs"; +export default { + name: "fails-after-start", + manifest: { capabilities: ["tools"] }, + setup(_api, ctx) { + ctx.onDispose(() => writeFileSync(${JSON.stringify(marker)}, "cleaned")); + throw new Error("setup failed after starting work"); + }, +};`, + ); + const session = await load({ sessionId: "s1", pluginPaths: [path] }); + expect(session.extensions).toHaveLength(0); + expect(registry.get("fails-after-start")[0]?.lastError?.phase).toBe( + "setup", + ); + await expect(readFile(marker, "utf8")).resolves.toBe("cleaned"); + }, 30_000); it("records a rejecting hook, follows hookErrorMode, and stops after repeated failures", async () => { + const marker = join(root, "hook-calls.txt"); const path = await write( - "rejecting-hook.js", - `export default { + "rejecting-hook.mjs", + `import { appendFileSync } from "node:fs"; +export default { name: "rejecting-hook", manifest: { capabilities: ["hooks"] }, hooks: { async beforeRun() { - globalThis.__rejectingHookCalls = (globalThis.__rejectingHookCalls ?? 0) + 1; + appendFileSync(${JSON.stringify(marker)}, "x"); throw new Error("hook rejected"); }, }, -}; -`, +};`, ); - const ignore = await registry.loadForSession({ - sessionId: "s1", - pluginPaths: [path], - }); - const strict = await registry.loadForSession({ + const ignore = await load({ sessionId: "s1", pluginPaths: [path] }); + const strict = await load({ sessionId: "s2", pluginPaths: [path], hookErrorMode: "throw", @@ -361,118 +626,94 @@ export default { expect(registry.get(path)[0]?.state).toBe("failed"); // A failed plugin is no longer called. - const hookCalls = () => - (globalThis as unknown as Record).__rejectingHookCalls; - const calls = hookCalls(); + const calls = (await readFile(marker, "utf8")).length; await beforeRun?.(context); - expect(hookCalls()).toBe(calls); + expect((await readFile(marker, "utf8")).length).toBe(calls); expect(statusEvents.map((event) => event.state)).toContain("degraded"); - }); + }, 30_000); it("times out a hook that never resolves and lets the run continue", async () => { const path = await write( - "hanging-hook.js", + "hanging-hook.mjs", `export default { name: "hanging-hook", manifest: { capabilities: ["hooks"] }, hooks: { beforeRun: () => new Promise(() => {}) }, -}; -`, +};`, ); - const loaded = await registry.loadForSession({ + const session = await load({ sessionId: "s1", pluginPaths: [path], + hookTimeoutMs: 200, }); await expect( - loaded.extensions[0]?.hooks?.beforeRun?.({ snapshot: {} } as never), + session.extensions[0]?.hooks?.beforeRun?.({ snapshot: {} } as never), ).resolves.toBeUndefined(); expect(registry.get(path)[0]).toMatchObject({ state: "degraded", timeoutCount: 1, lastError: { phase: "hook:beforeRun", timedOut: true }, }); - }); + }, 30_000); - it("returns tool failures to the model and blocks tools of failed plugins", async () => { + it("notifies every session using a plugin when it degrades or fails", async () => { const path = await write( - "bad-tools.js", + "flaky-hook.mjs", `export default { - name: "bad-tools", - manifest: { capabilities: ["tools"] }, - setup(api) { - api.registerTool({ name: "throws", description: "", inputSchema: {}, execute: () => { throw new Error("tool broke"); } }); - api.registerTool({ name: "hangs", description: "", inputSchema: {}, execute: () => new Promise(() => {}) }); - }, -}; -`, + name: "flaky-hook", + manifest: { capabilities: ["hooks"] }, + hooks: { beforeRun: async () => { throw new Error("flaky"); } }, +};`, ); - const loaded = await registry.loadForSession({ + const seen: Array<{ session: string; state: string }> = []; + const first = await load({ sessionId: "s1", pluginPaths: [path], + onIssue: (issue) => seen.push({ session: "s1", state: issue.state }), }); - const [throws, hangs] = (await setUp(loaded.extensions[0])).tools; - - await expect(throws?.execute({}, toolContext)).rejects.toThrow( - "tool broke", - ); - expect(registry.get(path)[0]?.lastError?.phase).toBe("tool:throws"); - await expect(hangs?.execute({}, toolContext)).rejects.toBeInstanceOf( - PluginCallTimeoutError, - ); - expect(registry.get(path)[0]?.timeoutCount).toBe(1); - await expect(throws?.execute({}, toolContext)).rejects.toThrow(); - expect(registry.get(path)[0]?.state).toBe("failed"); - await expect(throws?.execute({}, toolContext)).rejects.toThrow( - /bad-tools" tool "throws" is unavailable/, - ); - }); - - it("gives each session only the plugins its policy enables", async () => { - const alpha = await write("alpha.js", toolPlugin("alpha")); - const beta = await write("beta.js", toolPlugin("beta")); - const onlyAlpha = await registry.loadForSession({ - sessionId: "s1", - pluginPaths: [alpha, beta], - policy: { "*": { enabled: false }, alpha: { enabled: true } }, - }); - const noAlpha = await registry.loadForSession({ + await load({ sessionId: "s2", - pluginPaths: [alpha, beta], - policy: { alpha: { enabled: false } }, + pluginPaths: [path], + onIssue: (issue) => seen.push({ session: "s2", state: issue.state }), }); + const beforeRun = first.extensions[0]?.hooks?.beforeRun; + for (let attempt = 0; attempt < 3; attempt++) { + await beforeRun?.({ snapshot: {} } as never); + } - expect(onlyAlpha.extensions.map((extension) => extension.name)).toEqual([ - "alpha", + // One notice per state change per session, not one per failure. + expect(seen).toEqual([ + { session: "s1", state: "degraded" }, + { session: "s2", state: "degraded" }, + { session: "s1", state: "failed" }, + { session: "s2", state: "failed" }, ]); - expect(noAlpha.extensions.map((extension) => extension.name)).toEqual([ - "beta", - ]); - expect(noAlpha.issues).toEqual([ - expect.objectContaining({ - name: "alpha", - state: "disabled", - reason: "session_policy", - }), - ]); - expect(registry.get("alpha")[0]?.sessionIds).toEqual(["s1"]); + }, 30_000); - // A broken plugin the session turned off is reported as disabled. - const broken = await write("broken.js", `throw new Error("x");`); - const off = await registry.loadForSession({ - sessionId: "s3", - pluginPaths: [broken], - policy: { "*": { enabled: false } }, - }); - expect(off.failures).toEqual([]); - expect(off.issues).toEqual([ - expect.objectContaining({ name: "broken", state: "disabled" }), - ]); - expect(registry.get("beta")[0]?.sessionIds).toEqual(["s2"]); - }); + it("rejects registrations that need a missing capability", async () => { + const path = await write( + "no-rules-capability.mjs", + `export default { + name: "no-rules", + manifest: { capabilities: ["tools"] }, + setup(api) { api.registerRule({ id: "r", content: "x" }); }, +};`, + ); + const session = await load({ sessionId: "s1", pluginPaths: [path] }); + const rules: unknown[] = []; + await session.extensions[0]?.setup?.( + { ...noopApi(), registerRule: (rule) => rules.push(rule) }, + {}, + ); + expect(rules).toEqual([]); + expect(registry.get(path)[0]?.lastError?.message).toContain( + 'requires the "rules" capability', + ); + }, 30_000); it("reports plugins disabled in settings and paths that could not be resolved", async () => { - const disabled = await write("off.js", toolPlugin("off")); - const loaded = await registry.loadForSession({ + const disabled = await write("off.mjs", toolPlugin("off")); + const session = await load({ pluginPaths: [], disabledPluginPaths: [disabled], discoveryFailures: [ @@ -486,325 +727,22 @@ export default { }); // A session that would have used the settings-disabled plugin is told // why it is missing. - expect(loaded.issues).toEqual( + expect(session.issues).toEqual( expect.arrayContaining([ expect.objectContaining({ name: "off", reason: "settings" }), expect.objectContaining({ state: "failed", reason: "error" }), ]), ); - const optedOut = await registry.loadForSession({ + const optedOut = await load({ pluginPaths: [], disabledPluginPaths: [disabled], policy: { off: { enabled: false } }, }); expect(optedOut.issues).toEqual([]); - }); - - it("attributes a stray error to the plugin whose file is in its stack", async () => { - const path = await write( - "stray.js", - `export function makeError() { return new Error("stray rejection"); } -export default { name: "stray", manifest: { capabilities: ["tools"] } }; -`, - ); - const other = await write("other.js", toolPlugin("other")); - await registry.loadForSession({ pluginPaths: [path, other] }); - - const { importPluginModule } = await import("./plugin-module-import"); - const module = await importPluginModule(path, { useCache: false }); - const error = (module.makeError as () => Error)(); - - expect(registry.attributeUncaughtError(new Error("hub bug"))).toBe( - undefined, - ); - const status = registry.attributeUncaughtError(error, "unhandledRejection"); - expect(status).toMatchObject({ - name: "stray", - state: "failed", - lastError: { - phase: "uncaught", - message: "unhandledRejection: stray rejection", - }, - }); - expect(registry.get(other)[0]?.state).toBe("ready"); - }); - - it("attributes a stray error whose stack names the plugin's real path", async () => { - const realDir = join(root, "real"); - const linkDir = join(root, "link"); - await mkdir(realDir); - // A junction works without elevated rights on Windows. - await symlink(realDir, linkDir, "junction"); - await writeFile(join(realDir, "linked.js"), toolPlugin("linked"), "utf8"); - const viaLink = join(linkDir, "linked.js"); - await registry.loadForSession({ pluginPaths: [viaLink] }); - - // Both realpath flavours: on Windows only the native one expands 8.3 - // short names, and resolvers differ in which they use. - for (const realpath of [realpathSync, realpathSync.native]) { - const error = new Error("from the real path"); - error.stack = `Error: from the real path\n at run (${realpath(viaLink)}:3:5)`; - expect(registry.attributeUncaughtError(error)?.name).toBe("linked"); - } - }); - - it("reloads a plugin after it is fixed", async () => { - const path = await write( - "fixable.js", - `throw new Error("not yet");\nexport default {};\n`, - ); - await registry.loadForSession({ pluginPaths: [path] }); - expect(registry.get(path)[0]?.state).toBe("failed"); - - await writeFile(path, toolPlugin("fixable"), "utf8"); - const [status] = await registry.reload(path); - expect(status).toMatchObject({ - name: "fixable", - state: "ready", - errorCount: 0, - }); - expect(status?.lastError).toBeUndefined(); - const loaded = await registry.loadForSession({ pluginPaths: [path] }); - expect(loaded.extensions).toHaveLength(1); - }); - - it("passes cwd, session id, and emitEvent to setup, tools, and the legacy host shim", async () => { - const path = await write( - "context.js", - `export default { - name: "context", - manifest: { capabilities: ["tools"] }, - setup(api, ctx) { - globalThis.__contextPluginSetup = { cwd: ctx.cwd, sessionId: ctx.session?.sessionId }; - ctx.emitEvent?.("from_setup", { ok: true }); - api.registerTool({ - name: "ctx_tool", - description: "", - inputSchema: {}, - execute: async (_input, context) => { - globalThis.__clinePluginHost?.emitEvent?.("from_shim", { cwd: context.cwd }); - return context.cwd; - }, - }); - }, -}; -`, - ); - const events: Array<{ name: string; payload?: unknown }> = []; - const loaded = await registry.loadForSession({ - sessionId: "s1", - pluginPaths: [path], - cwd: "/work/project", - setupContext: { session: { sessionId: "s1" } }, - emitEvent: (event) => events.push(event), - }); - const [tool] = (await setUp(loaded.extensions[0])).tools; - await expect(tool?.execute({}, toolContext)).resolves.toBe("/work/project"); - - expect( - (globalThis as Record).__contextPluginSetup, - ).toEqual({ cwd: "/work/project", sessionId: "s1" }); - expect(events).toEqual([ - { name: "from_setup", payload: { ok: true } }, - { name: "from_shim", payload: { cwd: "/work/project" } }, - ]); - }); - - it("keeps running sessions' copies usable across a reload", async () => { - const path = await write("versioned.js", toolPlugin("versioned")); - const before = await registry.loadForSession({ - sessionId: "old", - pluginPaths: [path], - }); - const [oldTool] = (await setUp(before.extensions[0])).tools; - - await writeFile(path, toolPlugin("versioned", "versioned_v2"), "utf8"); - await registry.reload(path); - const after = await registry.loadForSession({ - sessionId: "new", - pluginPaths: [path], - }); - const [newTool] = (await setUp(after.extensions[0])).tools; - - await expect(oldTool?.execute({}, toolContext)).resolves.toBe( - "ok from versioned", - ); - expect(newTool?.name).toBe("versioned_v2"); - expect(registry.get(path)[0]?.sessionIds).toEqual(["new", "old"]); - }); - - it("does not let a stale copy's failures turn off a reloaded plugin", async () => { - const path = await write( - "stale.js", - `export default { - name: "stale", - manifest: { capabilities: ["hooks"] }, - hooks: { beforeRun: async () => { throw new Error("old copy broke"); } }, -}; -`, - ); - const told: string[] = []; - const old = await registry.loadForSession({ - sessionId: "old", - pluginPaths: [path], - onIssue: (issue) => told.push(`${issue.state}:${issue.lastError?.phase}`), - }); - // Fix the file and reload; the old session keeps its broken copy. - await writeFile(path, toolPlugin("stale"), "utf8"); - await registry.reload(path); - - // Well past the failure threshold (3). - for (let index = 0; index < 5; index++) { - await old.extensions[0]?.hooks?.beforeRun?.({ snapshot: {} } as never); - } - - // Its own session hears about it; the fixed plugin is unaffected. - expect(told).toEqual(Array(5).fill("degraded:hook:beforeRun")); - expect(registry.get(path)[0]).toMatchObject({ - state: "ready", - errorCount: 0, - }); - const fresh = await registry.loadForSession({ - sessionId: "fresh", - pluginPaths: [path], - }); - expect((await setUp(fresh.extensions[0])).tools.map((t) => t.name)).toEqual( - ["stale_tool"], - ); - }); - - it("does not let a session import that fails after a reload turn off the reloaded plugin", async () => { - const path = await write( - "slow-import.js", - `const gate = globalThis.__slowImportGate; -if (gate) { - await gate; - throw new Error("old copy failed to import"); -} -${toolPlugin("slow-import").replace("export default", "const plugin =")} -export default plugin; -`, - ); - const globals = globalThis as Record; - // The first session claims the spare copy, so the next one imports its own. - await registry.loadForSession({ sessionId: "first", pluginPaths: [path] }); - - let releaseGate: () => void = () => {}; - globals.__slowImportGate = new Promise((resolve) => { - releaseGate = resolve; - }); - const pending = registry.loadForSession({ - sessionId: "slow", - pluginPaths: [path], - }); - await new Promise((resolve) => setTimeout(resolve, 50)); - - // The plugin is reloaded (fixed) while that copy is still importing. - delete globals.__slowImportGate; - await registry.reload(path); - expect(registry.get(path)[0]?.state).toBe("ready"); - - releaseGate(); - const slow = await pending; - expect(slow.extensions).toHaveLength(0); - expect(slow.issues).toEqual([ - expect.objectContaining({ - name: "slow-import", - state: "failed", - lastError: expect.objectContaining({ - phase: "import", - message: "old copy failed to import", - }), - }), - ]); - - // The reloaded plugin stays on for everyone else. - expect(registry.get(path)[0]).toMatchObject({ - state: "ready", - errorCount: 0, - }); - const fresh = await registry.loadForSession({ - sessionId: "fresh", - pluginPaths: [path], - }); - expect((await setUp(fresh.extensions[0])).tools.map((t) => t.name)).toEqual( - ["slow-import_tool"], - ); - }); - - it("lets a successful reload clear a block recorded while it was importing", async () => { - const path = await write( - "flaky.js", - `export default { - name: "flaky", - manifest: { capabilities: ["hooks"] }, - hooks: { beforeRun: async () => { throw new Error("still broken"); } }, -}; -`, - ); - const running = await registry.loadForSession({ - sessionId: "running", - pluginPaths: [path], - }); - const hook = running.extensions[0]?.hooks?.beforeRun; - // Reaches the failure threshold (3) while the reload's import is in - // flight, which blocks the plugin mid-import. - const reloading = registry.reload(path); - for (let index = 0; index < 3; index++) { - await hook?.({ snapshot: {} } as never); - } - await reloading; - - expect(registry.get(path)[0]?.state).toBe("ready"); - const next = await registry.loadForSession({ - sessionId: "next", - pluginPaths: [path], - }); - expect(next.extensions.map((extension) => extension.name)).toEqual([ - "flaky", - ]); - }); - - it("tells working sessions when an uncaught error turns off a plugin another session's setup already failed", async () => { - const path = await write( - "setup-then-crash.js", - `export default { - name: "setup-then-crash", - manifest: { capabilities: ["tools"] }, - setup() { if (globalThis.__failNextSetup) { globalThis.__failNextSetup = false; throw new Error("setup broke"); } }, -}; -`, - ); - const told: string[] = []; - const healthy = await registry.loadForSession({ - sessionId: "healthy", - pluginPaths: [path], - onIssue: (issue) => told.push(`healthy:${issue.lastError?.phase}`), - }); - await setUp(healthy.extensions[0]); - (globalThis as Record).__failNextSetup = true; - const unlucky = await registry.loadForSession({ - sessionId: "unlucky", - pluginPaths: [path], - onIssue: (issue) => told.push(`unlucky:${issue.lastError?.phase}`), - }); - await setUp(unlucky.extensions[0]); - expect(registry.get(path)[0]?.state).toBe("failed"); - expect(told).toEqual(["unlucky:setup"]); - - const stray = new Error("boom"); - stray.stack = `Error: boom\n at run (${path}:3:5)`; - registry.attributeUncaughtError(stray); - expect(told).toEqual([ - "unlucky:setup", - "healthy:uncaught", - "unlucky:uncaught", - ]); - }); - + }, 30_000); it("times out and stops commands, rule content, and message builders", async () => { const path = await write( - "callbacks.js", + "callbacks.mjs", `export default { name: "callbacks", manifest: { capabilities: ["commands", "rules", "messageBuilders"] }, @@ -814,13 +752,12 @@ export default plugin; api.registerRule({ id: "slow-rule", content: () => new Promise(() => {}) }); api.registerMessageBuilder({ name: "bad-builder", build: () => { throw new Error("builder broke"); } }); }, -}; -`, +};`, ); - const loaded = await registry.loadForSession({ + const session = await load({ sessionId: "s1", pluginPaths: [path], - callTimeoutMs: 50, + callTimeoutMs: 200, }); const commands: Array<{ name: string; @@ -828,9 +765,9 @@ export default plugin; }> = []; const rules: Array<{ content: unknown }> = []; const builders: Array<{ build: (messages: Message[]) => unknown }> = []; - await loaded.extensions[0]?.setup?.( + await session.extensions[0]?.setup?.( { - ...collectingApi().api, + ...noopApi(), registerCommand: (command) => commands.push(command), registerRule: (rule) => rules.push(rule), registerMessageBuilder: (builder) => builders.push(builder), @@ -854,35 +791,11 @@ export default plugin; // Turned off after repeated failures: commands are refused. const boom = commands.find((command) => command.name === "boom"); await expect(boom?.handler?.("")).rejects.toThrow(/is unavailable/); - }); - - it("runs onDispose cleanup when the session ends", async () => { - const path = await write( - "dispose.js", - `export default { - name: "dispose", - manifest: { capabilities: ["tools"] }, - setup(_api, ctx) { - ctx.onDispose?.(() => { globalThis.__disposedSession = ctx.session?.sessionId; }); - }, -}; -`, - ); - const loaded = await registry.loadForSession({ - sessionId: "s1", - pluginPaths: [path], - setupContext: { session: { sessionId: "s1" } }, - }); - await setUp(loaded.extensions[0]); - const globals = globalThis as unknown as Record; - expect(globals.__disposedSession).toBeUndefined(); - await loaded.release(); - expect(globals.__disposedSession).toBe("s1"); - }); + }, 30_000); it("treats a registration the host rejects as a setup failure", async () => { const path = await write( - "bad-registration.js", + "bad-registration.mjs", `export default { name: "bad-registration", manifest: { capabilities: ["tools"] }, @@ -890,21 +803,19 @@ export default plugin; api.registerTool({ name: "kept_out", description: "", inputSchema: {}, execute: () => "" }); api.registerProvider({ name: "rejected" }); }, -}; -`, +};`, ); const told: string[] = []; - const loaded = await registry.loadForSession({ + const session = await load({ sessionId: "s1", pluginPaths: [path], onIssue: (issue) => told.push(`${issue.state}:${issue.lastError?.message}`), }); - const { api } = collectingApi(); await expect( - loaded.extensions[0]?.setup?.( + session.extensions[0]?.setup?.( { - ...api, + ...noopApi(), registerProvider: () => { throw new Error("provider rejected by host"); }, @@ -914,148 +825,81 @@ export default plugin; ).rejects.toThrow("provider rejected by host"); expect(told).toEqual(["failed:provider rejected by host"]); expect(registry.get(path)[0]?.lastError?.phase).toBe("setup"); - }); + }, 30_000); it("applies per-session timeout overrides", async () => { const path = await write( - "slow-hook.js", + "slow-hook.mjs", `export default { name: "slow-hook", manifest: { capabilities: ["hooks"] }, - hooks: { beforeRun: () => new Promise((resolve) => setTimeout(() => resolve({ ok: true }), 150)) }, -}; -`, + hooks: { beforeRun: () => new Promise((resolve) => setTimeout(() => resolve({ ok: true }), 400)) }, +};`, ); - const patient = await registry.loadForSession({ + const hasty = await load({ + sessionId: "hasty", + pluginPaths: [path], + hookTimeoutMs: 100, + }); + await expect( + hasty.extensions[0]?.hooks?.beforeRun?.({ snapshot: {} } as never), + ).resolves.toBeUndefined(); + expect(registry.get(path)[0]?.timeoutCount).toBe(1); + + const patient = await load({ sessionId: "patient", pluginPaths: [path], - hookTimeoutMs: 1_000, + hookTimeoutMs: 5_000, }); await expect( patient.extensions[0]?.hooks?.beforeRun?.({ snapshot: {} } as never), ).resolves.toEqual({ ok: true }); - expect(registry.get(path)[0]?.timeoutCount).toBe(0); - }); + expect(registry.get(path)[0]?.timeoutCount).toBe(1); + }, 30_000); - it("keeps the module copy imported by preload for the first session", async () => { + it("fails every plugin, and never imports one, when the host ships no bootstrap", async () => { + const marker = join(root, "imported.txt"); const path = await write( - "counted.js", - `globalThis.__countedImports = (globalThis.__countedImports ?? 0) + 1; -export default { name: "counted", manifest: { capabilities: ["tools"] } }; -`, + "orphan.mjs", + `import { writeFileSync } from "node:fs"; +writeFileSync(${JSON.stringify(marker)}, "imported"); +export default { name: "orphan", manifest: { capabilities: ["tools"] } };`, ); - const globals = globalThis as unknown as Record; - globals.__countedImports = 0; - await registry.preload({ pluginPaths: [path] }); - expect(globals.__countedImports).toBe(1); - await registry.loadForSession({ sessionId: "first", pluginPaths: [path] }); - expect(globals.__countedImports).toBe(1); - await registry.loadForSession({ sessionId: "second", pluginPaths: [path] }); - expect(globals.__countedImports).toBe(2); - }); + setPluginBootstrapUnavailableForTests(true); + try { + const session = await load({ sessionId: "s1", pluginPaths: [path] }); + expect(session.extensions).toHaveLength(0); + expect(session.issues).toEqual([ + expect.objectContaining({ + name: "orphan", + state: "failed", + lastError: expect.objectContaining({ + phase: "import", + message: expect.stringContaining("CLINE_PLUGIN_BOOTSTRAP_PATH"), + }), + }), + ]); + expect(registry.get("orphan")[0]?.state).toBe("failed"); + } finally { + setPluginBootstrapUnavailableForTests(false); + } + await expect(readFile(marker, "utf8")).rejects.toThrow(); + }, 30_000); - it("keeps the plugin's this for setup and its registered methods", async () => { + it("keeps running sessions' copies usable across a reload", async () => { const path = await write( - "this-binding.js", - `export default { - name: "this-binding", - label: "plugin-object", - manifest: { capabilities: ["commands", "rules", "messageBuilders"] }, - setup(api) { - globalThis.__setupThis = this?.label; - api.registerRule({ id: "self-rule", content() { return this.id; } }); - api.registerCommand({ name: "who", handler(input) { return this.name + ":" + input; } }); - api.registerMessageBuilder({ name: "tagger", build(messages) { return [...messages, { role: "user", content: this.name }]; } }); - }, -}; -`, + "stable.mjs", + toolPlugin("stable", 'return "v1";'), ); - const loaded = await registry.loadForSession({ - sessionId: "s1", - pluginPaths: [path], - }); - const rules: Array<{ content: unknown }> = []; - const commands: Array<{ handler?: (input: string) => unknown }> = []; - const builders: Array<{ build: (messages: Message[]) => unknown }> = []; - await loaded.extensions[0]?.setup?.( - { - ...collectingApi().api, - registerRule: (rule) => rules.push(rule), - registerCommand: (command) => commands.push(command), - registerMessageBuilder: (builder) => builders.push(builder), - }, - {}, - ); - const globals = globalThis as unknown as Record; - - expect(globals.__setupThis).toBe("plugin-object"); - await expect((rules[0]?.content as () => Promise)()).resolves.toBe( - "self-rule", - ); - await expect(commands[0]?.handler?.("me")).resolves.toBe("who:me"); - await expect(builders[0]?.build([])).resolves.toEqual([ - { role: "user", content: "tagger" }, - ]); - expect(registry.get(path)[0]?.errorCount).toBe(0); - }); - - it("routes events from work the module started at import to the session that claimed it", async () => { - const path = await write( - "import-time-work.js", - `setTimeout(() => { - globalThis.__clinePluginHost?.emitEvent?.("steer_message", { prompt: "from import" }); -}, 40); -export default { name: "import-time-work", manifest: { capabilities: ["tools"] } }; -`, - ); - const events: Array<{ name: string; payload?: unknown }> = []; - await registry.loadForSession({ - sessionId: "claimer", - pluginPaths: [path], - emitEvent: (event) => events.push(event), - }); - await new Promise((resolve) => setTimeout(resolve, 80)); - expect(events).toEqual([ - { name: "steer_message", payload: { prompt: "from import" } }, - ]); - }); - - it("gives new sessions changes made outside the entry file, and only new sessions", async () => { - const helper = await write( - "helper.js", - `export const toolName = "helper_v1";\n`, - ); - const path = await write( - "uses-helper.js", - `import { toolName } from "./helper.js"; -export default { - name: "uses-helper", - manifest: { capabilities: ["tools"] }, - setup(api) { - api.registerTool({ name: toolName, description: "", inputSchema: {}, execute: () => toolName }); - }, -}; -`, - ); - const before = await registry.loadForSession({ - sessionId: "before", - pluginPaths: [path], - }); - // Leaves an imported copy waiting for the next session to claim. - await registry.reload(path); - // Same length as before, written immediately, and only in a file the - // entry imports: the waiting copy is now stale. - await writeFile(helper, `export const toolName = "helper_v2";\n`, "utf8"); - const after = await registry.loadForSession({ - sessionId: "after", - pluginPaths: [path], - }); - - expect( - (await setUp(before.extensions[0])).tools.map((t) => t.name), - ).toEqual(["helper_v1"]); - expect((await setUp(after.extensions[0])).tools.map((t) => t.name)).toEqual( - ["helper_v2"], - ); - }); + const session = await load({ sessionId: "s1", pluginPaths: [path] }); + const [tool] = await toolsOf(session.extensions[0]); + await write("stable.mjs", toolPlugin("stable", 'return "v2";')); + await registry.reload("stable"); + expect(registry.get("stable")[0]?.state).toBe("ready"); + // The running session keeps the code it set up with. + await expect(tool?.execute({}, toolContext)).resolves.toBe("v1"); + const next = await load({ sessionId: "s2", pluginPaths: [path] }); + const [nextTool] = await toolsOf(next.extensions[0]); + await expect(nextTool?.execute({}, toolContext)).resolves.toBe("v2"); + }, 30_000); }); diff --git a/sdk/packages/core/src/extensions/plugin/plugin-registry.ts b/sdk/packages/core/src/extensions/plugin/plugin-registry.ts index 62a37cb23f..a476515d53 100644 --- a/sdk/packages/core/src/extensions/plugin/plugin-registry.ts +++ b/sdk/packages/core/src/extensions/plugin/plugin-registry.ts @@ -1,7 +1,5 @@ -import { AsyncLocalStorage } from "node:async_hooks"; -import { existsSync, readFileSync, realpathSync } from "node:fs"; -import { basename, dirname, extname, join, resolve, sep } from "node:path"; -import { pathToFileURL } from "node:url"; +import { existsSync, readFileSync } from "node:fs"; +import { basename, dirname, extname, join, resolve } from "node:path"; import type { AgentExtension, AgentExtensionApi, @@ -25,13 +23,9 @@ import type { PluginInitializationFailure, PluginInitializationWarning, } from "./plugin-load-report"; -import { loadAgentPluginFromPath } from "./plugin-loader"; import { fingerprintPluginSources } from "./plugin-module-import"; import { loadSandboxedPlugins } from "./plugin-sandbox"; -import { - matchesPluginManifestTargeting, - type PluginTargeting, -} from "./plugin-targeting"; +import type { PluginTargeting } from "./plugin-targeting"; export const DEFAULT_PLUGIN_IMPORT_TIMEOUT_MS = 4_000; export const DEFAULT_PLUGIN_SETUP_TIMEOUT_MS = 4_000; @@ -61,8 +55,8 @@ type SetupApi = AgentExtensionApi; interface PluginEntry { pluginPath: string; - /** Directory (package plugins) or file path used to attribute stack frames. */ - attributionRoot: string; + /** Directory (package plugins) or the file itself (drop-in plugins). */ + pluginRoot: string; name: string; state: PluginRuntimeState; /** @@ -80,30 +74,20 @@ interface PluginEntry { importId: number; /** The imported module, read for metadata (name, manifest). */ extension?: AgentExtension; - /** - * An imported copy no session has used yet. The first session claims it; - * later sessions import their own copy so module state is per session. - */ - spare?: PluginInstance; fingerprint?: string; lastError?: PluginErrorRecord; errorCount: number; timeoutCount: number; /** Sessions holding a copy, with their `onIssue` callback. */ sessions: Map void) | undefined>; + /** The export the plugin was last loaded from, reused by `reload`. */ + exportName?: string; /** - * Set once the plugin has loaded in a worker. Its copies then run in - * workers and the host never imports the module, so a reload validates - * the new code in a worker too. - */ - isolated?: { exportName?: string }; - /** - * Worker mode: the plugin's own (exported) name has been seen for its - * current code. Until then `name` is derived from the path, so the host - * cannot apply a policy that names the plugin and leaves it to the worker. + * The plugin's own (exported) name has been seen for its current code. + * Until then `name` is derived from the path, so the host cannot apply a + * policy that names the plugin and leaves it to the worker. */ nameResolved?: boolean; - loading?: Promise; updatedAt: number; } @@ -116,20 +100,9 @@ interface PluginInstance { closed: boolean; /** Cleanup registered through `ctx.onDispose`. */ disposers: Array<() => void | Promise>; - /** Worker copies: stops the worker the copy runs in. */ + /** Stops the worker the copy runs in. */ shutdown?: () => Promise; - /** - * The async scope every call into this copy runs in, created at import. - * Work the module starts at import time keeps this scope, so claiming the - * copy for a session fills in its session id and event callback here - * rather than in a new scope that work would never see. - */ - scope: PluginCallScope; -} - -interface PluginCallScope { - sessionId?: string; - pluginName: string; + /** The session's event sink, given to tools that do not bring their own. */ emitEvent?: (name: string, payload?: unknown) => void; } @@ -149,18 +122,8 @@ export interface PluginRegistryOptions { failureThreshold?: number; } -/** - * Where a session's plugin copies run. `in_process` imports them into the - * host. `worker` runs each plugin in its own worker thread per session: the - * host never loads plugin code, and a crash, `process.exit()`, or hung loop - * ends only that worker. - */ -export type PluginIsolation = "in_process" | "worker"; - export interface PluginSessionLoadInput extends PluginTargeting { sessionId?: string; - /** Defaults to `in_process`. */ - isolation?: PluginIsolation; /** Resolved, enabled plugin module paths, in load order. */ pluginPaths: ReadonlyArray; /** Plugin paths turned off in settings; reported as `disabled`. */ @@ -217,10 +180,10 @@ function toErrorParts(error: unknown): { message: string; stack?: string } { /** * Package plugins own their whole package directory; a single-file plugin - * in a shared `plugins/` folder owns only its file, so a stray error from one - * drop-in file is never blamed on its neighbours. + * in a shared `plugins/` folder owns only its file. The root decides the + * plugin's display name before its module is imported. */ -function resolveAttributionRoot(pluginPath: string): string { +function resolvePluginRoot(pluginPath: string): string { let current = dirname(pluginPath); for (let depth = 0; depth < 4; depth++) { const manifestPath = join(current, "package.json"); @@ -243,66 +206,16 @@ function resolveAttributionRoot(pluginPath: string): string { } /** Name used before the module is imported (or when it never imports). */ -function derivePluginName(pluginPath: string, attributionRoot: string): string { - return attributionRoot === pluginPath +function derivePluginName(pluginPath: string, pluginRoot: string): string { + return pluginRoot === pluginPath ? basename(pluginPath, extname(pluginPath)) - : getPluginDisplayName(pluginPath, attributionRoot); + : getPluginDisplayName(pluginPath, pluginRoot); } /** The name a plugin is known by before its module is imported. */ export function derivePluginNameFromPath(pluginPath: string): string { const absolute = resolve(pluginPath); - return derivePluginName(absolute, resolveAttributionRoot(absolute)); -} - -/** - * Windows stack frames may spell a path differently from how it was - * configured (case, separators, 8.3 short names such as `RUNNER~1`), so - * compare a normalized form there. - */ -function normalizeStackPath(value: string): string { - return process.platform === "win32" - ? value.replace(/\\/g, "/").toLowerCase() - : value; -} - -function stackMentionsPath(stack: string, root: string): boolean { - const roots = new Set([root]); - // Module resolution follows symlinks, and on Windows may or may not - // expand 8.3 short names depending on the resolver, so the stack can name - // the real path in either spelling. - for (const realpath of [realpathSync, realpathSync.native]) { - try { - roots.add(realpath(root)); - } catch { - // A missing root can only match by its configured spelling. - } - } - const haystack = normalizeStackPath(stack); - const candidates = [...roots].flatMap((candidate) => [ - normalizeStackPath(candidate), - normalizeStackPath(pathToFileURL(candidate).href), - ]); - return candidates.some((candidate) => { - let index = haystack.indexOf(candidate); - while (index !== -1) { - const next = haystack[index + candidate.length]; - // Require a boundary so /plugins/foo does not match /plugins/foobar. - if ( - next === undefined || - next === sep || - next === "/" || - next === ":" || - next === ")" || - next === "?" || - /\s/.test(next) - ) { - return true; - } - index = haystack.indexOf(candidate, index + 1); - } - return false; - }); + return derivePluginName(absolute, resolvePluginRoot(absolute)); } function runWithTimeout( @@ -349,54 +262,25 @@ function isPluginEnabledByPolicy( return policy["*"]?.enabled ?? true; } -const pluginCallScope = new AsyncLocalStorage(); -let lastPluginCallScope: PluginCallScope | undefined; - -function runInPluginScope(scope: PluginCallScope, fn: () => T): T { - lastPluginCallScope = scope; - return pluginCallScope.run(scope, fn); -} - -/** - * Plugins written for the subprocess sandbox emit events through - * `globalThis.__clinePluginHost.emitEvent`. In-process, route those calls to - * the session whose hook or tool is running (async context follows timers and - * promises the call started), else to the session that last called a plugin. - */ -export function installPluginHostShim(): void { - const globals = globalThis as Record; - if (globals.__clinePluginHost) return; - globals.__clinePluginHost = { - emitEvent: (name: string, payload?: unknown) => { - const scope = pluginCallScope.getStore() ?? lastPluginCallScope; - scope?.emitEvent?.(name, payload); - }, - }; -} - -function newInstance( - extension: AgentExtension | undefined, - pluginName: string, - importId: number, -): PluginInstance { +function newInstance(importId: number): PluginInstance { return { - // Assigned as soon as the import resolves; never read before that. - extension: extension as AgentExtension, + // Assigned as soon as the worker reports its import; never read before. + extension: undefined as unknown as AgentExtension, importId, setupFailed: false, closed: false, disposers: [], - scope: { pluginName }, }; } /** * Tracks plugins for the whole process and hands each session a guarded copy - * of the plugins it enabled. Each session gets its own import of the module, - * matching the per-session sandbox plugins were written for. Every import, - * setup, hook, tool, command, rule, and message-builder call is wrapped with - * a timeout and error capture, and the result is recorded per plugin so - * failures are visible instead of silently dropping the plugin's tools. + * of the plugins it enabled. Each copy runs in its own worker thread, so + * module state is per session and plugin code never runs on the host's main + * thread. Every import, setup, hook, tool, command, rule, and message-builder + * call is wrapped with a timeout and error capture, and the result is + * recorded per plugin so failures are visible instead of silently dropping + * the plugin's tools. */ export class PluginRegistry { private readonly entries = new Map(); @@ -418,7 +302,6 @@ export class PluginRegistry { }; this.failureThreshold = options.failureThreshold ?? DEFAULT_PLUGIN_FAILURE_THRESHOLD; - installPluginHostShim(); } setLogger(logger: BasicLogger | undefined): void { @@ -443,60 +326,28 @@ export class PluginRegistry { } /** - * Re-imports the matching plugins and resets their status. Sessions - * started afterwards use the new module; running sessions keep the copy - * they set up, which stays callable unless the re-import fails (the plugin - * is then turned off for every session, failing closed). + * Re-validates the matching plugins' current code in throwaway workers and + * resets their status. Sessions started afterwards use the new code; + * running sessions keep the copy they set up, which stays callable unless + * the re-import fails (the plugin is then turned off for every session, + * failing closed). */ async reload(nameOrPath: string): Promise { const matches = this.match(nameOrPath); for (const entry of matches) { - entry.fingerprint = undefined; entry.lastError = undefined; entry.errorCount = 0; entry.timeoutCount = 0; this.log("info", "plugin.reload", entry, {}); - if (entry.isolated) { - await this.reloadIsolated(entry); - continue; - } - await this.ensureLoaded(entry.pluginPath, { force: true }); + await this.reloadEntry(entry); } return matches.map((entry) => this.toStatus(entry)); } /** - * Finds the plugin whose module root appears in `error`'s stack and turns - * it off for every session. Used by the Hub daemon so a stray plugin error - * does not take the process down. - */ - attributeUncaughtError( - error: unknown, - label = "uncaught", - ): PluginStatusRecord | undefined { - const stack = error instanceof Error ? error.stack : undefined; - if (!stack) return undefined; - // Prefer the most specific root when roots nest. - const entry = [...this.entries.values()] - .filter((candidate) => - stackMentionsPath(stack, candidate.attributionRoot), - ) - .sort( - (left, right) => - right.attributionRoot.length - left.attributionRoot.length, - )[0]; - if (!entry) return undefined; - this.recordFailure(entry, "uncaught", error, { - fatal: true, - messagePrefix: `${label}: `, - }); - return this.toStatus(entry); - } - - /** - * Imports plugins without attaching them to a session, so the Hub can - * discover them at startup and report status before any session exists. - * The imported copy is kept for the first session to claim. + * Loads each plugin in a throwaway worker without attaching it to a + * session, so the Hub can discover plugins at startup and report status + * (including import errors) before any session exists. */ async preload( input: Pick< @@ -506,171 +357,21 @@ export class PluginRegistry { | "discoveryFailures" | "exportName" | "importTimeoutMs" - | "isolation" >, ): Promise { - if (input.isolation === "worker") { - // Load each plugin in a throwaway worker so status (and import - // errors) are known before any session starts. - const loaded = await this.loadIsolatedForSession(input); - await loaded.release(); - return this.list(); - } - this.recordDiscovery(input, []); - for (const rawPath of input.pluginPaths) { - await this.ensureLoaded(resolve(rawPath), { - exportName: input.exportName, - importTimeoutMs: input.importTimeoutMs, - }); - } + const loaded = await this.loadForSession(input); + await loaded.release(); return this.list(); } - async loadForSession( - input: PluginSessionLoadInput, - ): Promise { - if (input.isolation === "worker") { - return this.loadIsolatedForSession(input); - } - const failures: PluginInitializationFailure[] = []; - const warnings: PluginInitializationWarning[] = []; - const issues: SessionPluginIssue[] = []; - const sessionId = input.sessionId; - const timeouts = this.resolveTimeouts(input); - - for (const entry of this.recordDiscovery(input, issues)) { - failures.push(this.toInitializationFailure(entry)); - } - - const loadedByName = new Map< - string, - { entry: PluginEntry; order: number } - >(); - let order = 0; - for (const rawPath of input.pluginPaths) { - const pluginPath = resolve(rawPath); - const entry = await this.ensureLoaded(pluginPath, { - exportName: input.exportName, - importTimeoutMs: timeouts.import, - }); - const enabledByPolicy = this.isEnabledForSession(entry, input); - if (entry.blocked || entry.state === "disabled" || !entry.extension) { - // A broken plugin the session turned off is not this session's - // problem; report it as disabled rather than as a failure. - if (!enabledByPolicy) { - issues.push(this.policyIssue(entry)); - continue; - } - if (entry.state === "failed") { - failures.push(this.toInitializationFailure(entry)); - } - issues.push( - this.toIssue( - entry, - entry.state === "disabled" ? "settings" : "error", - ), - ); - continue; - } - if (!matchesPluginManifestTargeting(entry.extension.manifest, input)) { - continue; - } - if (!enabledByPolicy) { - issues.push(this.policyIssue(entry)); - continue; - } - const existing = loadedByName.get(entry.name); - if (existing) { - warnings.push({ - type: "duplicate_plugin_override", - pluginName: entry.name, - pluginPath, - overriddenPluginPath: existing.entry.pluginPath, - message: `Plugin "${entry.name}" from ${pluginPath} overrides ${existing.entry.pluginPath}`, - }); - } - loadedByName.set(entry.name, { entry, order: order++ }); - } - - const ordered = [...loadedByName.values()].sort( - (left, right) => left.order - right.order, - ); - const selected: Array<{ entry: PluginEntry; instance: PluginInstance }> = - []; - for (const { entry } of ordered) { - const importId = entry.importId; - try { - const instance = await this.claimInstance( - entry, - input.exportName, - timeouts, - sessionId, - ); - selected.push({ entry, instance }); - } catch (error) { - if (entry.importId !== importId) { - // A reload or source change re-imported the plugin while this - // copy was loading. The failure describes the old module: tell - // this session, but leave the current module's status alone. - const { message, stack } = toErrorParts(error); - issues.push({ - name: entry.name, - pluginPath: entry.pluginPath, - state: "failed", - reason: "error", - lastError: { - phase: "import", - message, - ...(stack ? { stack } : {}), - pluginPath: entry.pluginPath, - timestamp: Date.now(), - ...(sessionId ? { sessionId } : {}), - }, - }); - continue; - } - this.recordFailure(entry, "import", error, { - fatal: true, - sessionId, - }); - failures.push(this.toInitializationFailure(entry)); - issues.push(this.toIssue(entry, "error")); - } - } - if (sessionId) { - for (const { entry } of selected) { - entry.sessions.set(sessionId, input.onIssue); - } - } - const extensions = selected.map(({ entry, instance }) => - this.wrapForSession(entry, instance, input, timeouts), - ); - let released: Promise | undefined; - return { - extensions, - pluginPaths: selected.map(({ entry }) => entry.pluginPath), - failures, - warnings, - issues, - release: () => { - released ??= Promise.all( - selected.map(({ entry, instance }) => - this.releaseInstance(entry, instance, sessionId, timeouts), - ), - ).then(() => undefined); - return released; - }, - }; - } - /** - * Worker isolation: each plugin loads in its own worker for this session, - * and the copy handed to the session is a proxy whose calls go to that - * worker. The copy goes through the same wrapping as an in-process one, so - * status, timeouts, failure counting, policy, and notices are unchanged. - * What differs is where the code runs: the host never imports it. + * Each plugin loads in its own worker for this session, and the copy + * handed to the session is a proxy whose calls go to that worker. The host + * never imports plugin code. Every setup, hook, tool, command, rule, and + * message-builder call is wrapped with a timeout and error capture, and + * the result is recorded per plugin. */ - private async loadIsolatedForSession( + async loadForSession( input: PluginSessionLoadInput, ): Promise { const failures: PluginInitializationFailure[] = []; @@ -686,8 +387,8 @@ export class PluginRegistry { const candidates: PluginEntry[] = []; for (const rawPath of input.pluginPaths) { const entry = this.ensureEntry(resolve(rawPath)); - entry.isolated = { exportName: input.exportName }; - this.refreshIsolatedEntry(entry); + entry.exportName = input.exportName; + this.refreshEntry(entry); // Skip the worker only when the plugin's own name is known; else the // worker applies the policy after import, before setup. if (entry.nameResolved && !this.isEnabledForSession(entry, input)) { @@ -725,7 +426,7 @@ export class PluginRegistry { continue; } if (result.phase === "setup") { - // As in-process: a setup failure costs only this session. + // A setup failure costs only this session. this.recordFailure(entry, "setup", result.error, { sessionId, state: "failed", @@ -800,7 +501,7 @@ export class PluginRegistry { * re-import. A source change (or re-enabling) still starts a new import * id: the plugin gets a clean slate, and copies of the old code go stale. */ - private refreshIsolatedEntry(entry: PluginEntry): void { + private refreshEntry(entry: PluginEntry): void { const fingerprint = fingerprintPluginSources(entry.pluginPath); if (entry.fingerprint === fingerprint && entry.state !== "disabled") { return; @@ -831,12 +532,11 @@ export class PluginRegistry { } > { const importId = entry.importId; - const instance = newInstance(undefined, entry.name, importId); - instance.scope.sessionId = sessionId; + const instance = newInstance(importId); let ready = false; // Set once the import finished and setup() began, so a failure after // that (setup threw, timed out, or crashed the worker) costs only this - // session's copy, as in-process, instead of turning the plugin off. + // session's copy instead of turning the plugin off. let setupStarted = false; const setup = input.setupContext; const startedAt = Date.now(); @@ -857,7 +557,7 @@ export class PluginRegistry { policy: input.policy, policyNames: { [entry.pluginPath]: [ - derivePluginName(entry.pluginPath, entry.attributionRoot), + derivePluginName(entry.pluginPath, entry.pluginRoot), ], }, // A worker that dies on its own (stray rejection, process.exit) @@ -919,7 +619,6 @@ export class PluginRegistry { if (importId === entry.importId) { entry.name = extension.name; entry.nameResolved = true; - instance.scope.pluginName = extension.name; entry.extension = extension; if ( !entry.blocked && @@ -929,20 +628,18 @@ export class PluginRegistry { } this.log("info", "plugin.import.ready", entry, { elapsedMs: Date.now() - startedAt, - isolation: "worker", }); } return { entry, instance }; } - /** Re-validates a worker plugin's current code in a throwaway worker. */ - private async reloadIsolated(entry: PluginEntry): Promise { + /** Re-validates a plugin's current code in a throwaway worker. */ + private async reloadEntry(entry: PluginEntry): Promise { entry.fingerprint = undefined; - this.refreshIsolatedEntry(entry); + this.refreshEntry(entry); const input: PluginSessionLoadInput = { pluginPaths: [entry.pluginPath], - exportName: entry.isolated?.exportName, - isolation: "worker", + exportName: entry.exportName, }; const result = await this.startWorkerCopy( entry, @@ -1012,7 +709,6 @@ export class PluginRegistry { const entry = this.ensureEntry(pluginPath); if (entry.state !== "disabled") { entry.extension = undefined; - entry.spare = undefined; this.setState(entry, "disabled"); } if (this.isEnabledForSession(entry, input)) { @@ -1028,7 +724,7 @@ export class PluginRegistry { ): boolean { return isPluginEnabledByPolicy(input.policy, [ entry.name, - derivePluginName(entry.pluginPath, entry.attributionRoot), + derivePluginName(entry.pluginPath, entry.pluginRoot), ]); } @@ -1058,7 +754,7 @@ export class PluginRegistry { (entry) => entry.pluginPath === absolute || entry.name === target || - derivePluginName(entry.pluginPath, entry.attributionRoot) === target, + derivePluginName(entry.pluginPath, entry.pluginRoot) === target, ); } @@ -1066,11 +762,11 @@ export class PluginRegistry { const absolute = resolve(pluginPath); let entry = this.entries.get(absolute); if (!entry) { - const attributionRoot = resolveAttributionRoot(absolute); + const pluginRoot = resolvePluginRoot(absolute); entry = { pluginPath: absolute, - attributionRoot, - name: derivePluginName(absolute, attributionRoot), + pluginRoot, + name: derivePluginName(absolute, pluginRoot), state: "loading", blocked: false, consecutiveFailures: 0, @@ -1085,116 +781,6 @@ export class PluginRegistry { return entry; } - private async ensureLoaded( - pluginPath: string, - options: { force?: boolean; exportName?: string; importTimeoutMs?: number }, - ): Promise { - const entry = this.ensureEntry(pluginPath); - if (entry.loading) { - await entry.loading; - return entry; - } - const fingerprint = fingerprintPluginSources(entry.pluginPath); - const upToDate = - !options.force && - entry.fingerprint === fingerprint && - entry.state !== "disabled" && - entry.state !== "loading"; - if (upToDate) return entry; - - entry.loading = this.importEntry( - entry, - fingerprint, - options.exportName, - options.importTimeoutMs ?? this.defaults.import, - ); - try { - await entry.loading; - } finally { - entry.loading = undefined; - } - return entry; - } - - /** Imports one copy of the module in its own call scope. */ - private async importCopy( - entry: PluginEntry, - exportName: string | undefined, - timeoutMs: number, - sessionId: string | undefined, - ): Promise { - const instance = newInstance(undefined, entry.name, entry.importId); - instance.scope.sessionId = sessionId; - instance.extension = await runWithTimeout( - () => - runInPluginScope(instance.scope, () => - loadAgentPluginFromPath(entry.pluginPath, { - exportName, - freshModule: true, - }), - ), - timeoutMs, - `Plugin import of ${entry.pluginPath}`, - ); - return instance; - } - - /** - * Hands a session its own copy of the plugin module. Plugins written for - * the per-session sandbox keep state at module level (one plugin object - * per import), so sharing one copy across sessions would break them. - */ - private async claimInstance( - entry: PluginEntry, - exportName: string | undefined, - timeouts: PluginTimeouts, - sessionId: string | undefined, - ): Promise { - const spare = entry.spare; - if (spare) { - entry.spare = undefined; - return spare; - } - return this.importCopy(entry, exportName, timeouts.import, sessionId); - } - - private async importEntry( - entry: PluginEntry, - fingerprint: string, - exportName: string | undefined, - importTimeoutMs: number, - ): Promise { - entry.fingerprint = fingerprint; - entry.extension = undefined; - entry.spare = undefined; - // Copies from before this import are stale from here on: what they - // report no longer describes the module being imported. - entry.importId += 1; - this.setState(entry, "loading", true); - const startedAt = Date.now(); - try { - const instance = await this.importCopy( - entry, - exportName, - importTimeoutMs, - undefined, - ); - // A successful re-import (reload or source change) gives the plugin - // a clean slate. - entry.blocked = false; - entry.consecutiveFailures = 0; - entry.extension = instance.extension; - entry.spare = instance; - entry.name = instance.extension.name; - this.setState(entry, "ready"); - this.log("info", "plugin.import.ready", entry, { - elapsedMs: Date.now() - startedAt, - }); - } catch (error) { - this.recordFailure(entry, "import", error, { fatal: true }); - } - } - private wrapForSession( entry: PluginEntry, instance: PluginInstance, @@ -1202,29 +788,16 @@ export class PluginRegistry { timeouts: PluginTimeouts, ): AgentExtension { const extension = instance.extension; - const sessionId = input.sessionId; const emitEvent = input.emitEvent ? (name: string, payload?: unknown) => input.emitEvent?.({ name, payload }) : undefined; - // Connect the copy's own scope (the one its import-time work holds) to - // this session. - const scope = instance.scope; - scope.sessionId = sessionId; - scope.pluginName = entry.name; - scope.emitEvent = emitEvent; + instance.emitEvent = emitEvent; const originalSetup = extension.setup; const wrapped: AgentExtension & { __clinePluginPath?: string } = { ...extension, __clinePluginPath: entry.pluginPath, - hooks: this.wrapHooks( - entry, - instance, - extension.hooks, - scope, - input, - timeouts, - ), + hooks: this.wrapHooks(entry, instance, extension.hooks, input, timeouts), setup: originalSetup ? async (api, ctx) => { if (!this.isUsable(entry, instance)) return; @@ -1253,7 +826,6 @@ export class PluginRegistry { (bufferedApi) => originalSetup.call(extension, bufferedApi, setupContext), api, - scope, input, timeouts, ); @@ -1268,7 +840,6 @@ export class PluginRegistry { instance: PluginInstance, invoke: (api: SetupApi) => void | Promise, api: SetupApi, - scope: PluginCallScope, input: PluginSessionLoadInput, timeouts: PluginTimeouts, ): Promise { @@ -1300,7 +871,6 @@ export class PluginRegistry { entry, instance, rule, - scope, input, timeouts, ); @@ -1311,7 +881,6 @@ export class PluginRegistry { entry, instance, builder, - scope, input, timeouts, ); @@ -1347,7 +916,7 @@ export class PluginRegistry { const startedAt = Date.now(); try { await runWithTimeout( - () => runInPluginScope(scope, () => invoke(bufferedApi)), + () => invoke(bufferedApi), timeouts.setup, `Plugin "${entry.name}" setup`, ); @@ -1358,13 +927,11 @@ export class PluginRegistry { this.warnIfSlow(entry, "setup", startedAt, timeouts.setup, input); try { for (const tool of pending.tools) { - api.registerTool( - this.wrapTool(entry, instance, tool, scope, input, timeouts), - ); + api.registerTool(this.wrapTool(entry, instance, tool, input, timeouts)); } for (const command of pending.commands) { api.registerCommand( - this.wrapCommand(entry, instance, command, scope, input, timeouts), + this.wrapCommand(entry, instance, command, input, timeouts), ); } for (const call of pending.calls) { @@ -1389,7 +956,6 @@ export class PluginRegistry { entry: PluginEntry, instance: PluginInstance, hooks: ExtensionHooks | undefined, - scope: PluginCallScope, input: PluginSessionLoadInput, timeouts: PluginTimeouts, ): ExtensionHooks | undefined { @@ -1406,7 +972,6 @@ export class PluginRegistry { instance, phase, timeouts.hook, - scope, input, () => (hook as HookFn).apply(hooks, args), ); @@ -1423,7 +988,6 @@ export class PluginRegistry { entry: PluginEntry, instance: PluginInstance, tool: AgentTool, - scope: PluginCallScope, input: PluginSessionLoadInput, timeouts: PluginTimeouts, ): AgentTool { @@ -1437,13 +1001,12 @@ export class PluginRegistry { instance, phase, tool.timeoutMs ?? timeouts.call, - scope, input, () => tool.execute(toolInput, { ...context, cwd: context.cwd ?? input.cwd, - emitEvent: context.emitEvent ?? scope.emitEvent, + emitEvent: context.emitEvent ?? instance.emitEvent, }), // A cancelled run is not the plugin's fault. () => context.signal?.aborted === true, @@ -1456,7 +1019,6 @@ export class PluginRegistry { entry: PluginEntry, instance: PluginInstance, command: AgentExtensionCommand, - scope: PluginCallScope, input: PluginSessionLoadInput, timeouts: PluginTimeouts, ): AgentExtensionCommand { @@ -1471,7 +1033,6 @@ export class PluginRegistry { instance, `command:${command.name}`, timeouts.call, - scope, input, () => handler.call(command, commandInput), ); @@ -1484,7 +1045,6 @@ export class PluginRegistry { entry: PluginEntry, instance: PluginInstance, rule: AgentExtensionRule, - scope: PluginCallScope, input: PluginSessionLoadInput, timeouts: PluginTimeouts, ): AgentExtensionRule { @@ -1500,7 +1060,6 @@ export class PluginRegistry { instance, `rule:${rule.id}`, timeouts.call, - scope, input, () => content.call(rule), ); @@ -1516,7 +1075,6 @@ export class PluginRegistry { entry: PluginEntry, instance: PluginInstance, builder: AgentExtensionMessageBuilder, - scope: PluginCallScope, input: PluginSessionLoadInput, timeouts: PluginTimeouts, ): AgentExtensionMessageBuilder { @@ -1530,7 +1088,6 @@ export class PluginRegistry { instance, `messageBuilder:${builder.name}`, timeouts.call, - scope, input, () => builder.build.call(builder, messages), ); @@ -1542,15 +1099,14 @@ export class PluginRegistry { } /** - * Runs one plugin call in its session scope with a timeout, records the - * outcome against the plugin, and rethrows failures. + * Runs one plugin call with a timeout, records the outcome against the + * plugin, and rethrows failures. */ private async guardedCall( entry: PluginEntry, instance: PluginInstance, phase: PluginErrorPhase, timeoutMs: number, - scope: PluginCallScope, input: PluginSessionLoadInput, call: () => T | Promise, isCancelled?: () => boolean, @@ -1558,7 +1114,7 @@ export class PluginRegistry { const startedAt = Date.now(); try { const result = await runWithTimeout( - () => runInPluginScope(scope, call), + call, timeoutMs, `Plugin "${entry.name}" ${phase}`, ); @@ -1609,11 +1165,10 @@ export class PluginRegistry { if (instance.closed) return; instance.closed = true; if (sessionId) entry.sessions.delete(sessionId); - const scope: PluginCallScope = { sessionId, pluginName: entry.name }; for (const cleanup of instance.disposers.splice(0).reverse()) { try { await runWithTimeout( - () => runInPluginScope(scope, cleanup), + cleanup, timeouts.hook, `Plugin "${entry.name}" dispose`, ); @@ -1630,7 +1185,7 @@ export class PluginRegistry { } } // Ending the worker stops everything the copy started (timers, - // sockets, pending work), which in-process cleanup cannot guarantee. + // sockets, pending work). await instance.shutdown?.().catch(() => { // Best-effort: the worker may already be gone. }); @@ -1710,7 +1265,6 @@ export class PluginRegistry { if (block) { entry.blocked = true; entry.extension = undefined; - entry.spare = undefined; } this.setState(entry, next, true); const issue = this.toIssue(entry, "error"); diff --git a/sdk/packages/core/src/extensions/plugin/plugin-sandbox-bootstrap.ts b/sdk/packages/core/src/extensions/plugin/plugin-sandbox-bootstrap.ts index 91ce629dce..228806a03c 100644 --- a/sdk/packages/core/src/extensions/plugin/plugin-sandbox-bootstrap.ts +++ b/sdk/packages/core/src/extensions/plugin/plugin-sandbox-bootstrap.ts @@ -775,7 +775,7 @@ async function loadPluginDescriptor(args: { }, // Only offered when the host actually has a telemetry service: // the feature-detection contract (`ctx.telemetry?.capture`) must - // mean "someone is listening", matching in-process behavior. + // mean "someone is listening". ...(args.telemetryEnabled ? { telemetry: createPluginTelemetry(plugin.name) } : {}), diff --git a/sdk/packages/core/src/extensions/plugin/plugin-sandbox.test.ts b/sdk/packages/core/src/extensions/plugin/plugin-sandbox.test.ts index 7890d60019..dcdec8fa41 100644 --- a/sdk/packages/core/src/extensions/plugin/plugin-sandbox.test.ts +++ b/sdk/packages/core/src/extensions/plugin/plugin-sandbox.test.ts @@ -778,6 +778,17 @@ describe.each([ exists: makeExists(["/wrapper/extensions/bootstrap.js"]), }), ).toEqual({ file: "/wrapper/extensions/bootstrap.js" }); + + // A host that ships no bootstrap at all: there is no fallback. Plugin + // code never runs on the host's main thread, so this is a failure. + expect( + selectBootstrapCandidate({ + siblingCandidates: ["/$bunfs/root/plugin-sandbox-bootstrap.js"], + sourceBootstrapPath: "/$bunfs/root/plugin-sandbox-bootstrap.ts", + installedCandidates: [undefined, undefined], + exists: makeExists([]), + }), + ).toBeUndefined(); }); it("ignores the npm wrapper platform package bootstrap when running from source", async () => { diff --git a/sdk/packages/core/src/extensions/plugin/plugin-sandbox.ts b/sdk/packages/core/src/extensions/plugin/plugin-sandbox.ts index 36c093d37f..3645664e0b 100644 --- a/sdk/packages/core/src/extensions/plugin/plugin-sandbox.ts +++ b/sdk/packages/core/src/extensions/plugin/plugin-sandbox.ts @@ -256,7 +256,7 @@ export function selectBootstrapCandidate(options: { sourceBootstrapPath: string; installedCandidates: Array; exists?: (path: string) => boolean; -}): { file: string } | { sourcePath: string } { +}): { file: string } | { sourcePath: string } | undefined { const exists = options.exists ?? existsSync; for (const candidate of options.siblingCandidates) { if (exists(candidate)) return { file: candidate }; @@ -267,11 +267,12 @@ export function selectBootstrapCandidate(options: { for (const candidate of options.installedCandidates) { if (candidate && exists(candidate)) return { file: candidate }; } - return { sourcePath: options.sourceBootstrapPath }; + return undefined; } /** - * Resolve the bootstrap for the sandbox subprocess. + * Resolve the bootstrap for the sandbox worker or subprocess, or `undefined` + * when this host ships none. * * In production (bundled), the compiled `.js` file lives next to this module * and can be passed directly as a file to spawn. In development/test @@ -280,7 +281,8 @@ export function selectBootstrapCandidate(options: { */ function resolveBootstrap(): | { file: string } - | { script: string; sourcePath: string } { + | { script: string; sourcePath: string } + | undefined { const dir = dirname(fileURLToPath(import.meta.url)); const requireFromHere = createRequire(import.meta.url); // In dev, the bootstrap sits next to this file in src/extensions/. @@ -306,7 +308,7 @@ function resolveBootstrap(): resolveBootstrapFromExecutable(), ], }); - if ("file" in selected) { + if (!selected || "file" in selected) { return selected; } const tsPath = selected.sourcePath; @@ -330,24 +332,68 @@ function resolveBootstrap(): } let resolvedBootstrap: ReturnType | undefined; +let bootstrapUnavailableForTests = false; /** * Resolved on first use, not at import, so a host can set - * `CLINE_PLUGIN_BOOTSTRAP_PATH` after loading this module. + * `CLINE_PLUGIN_BOOTSTRAP_PATH` after loading this module. A host that ships + * no bootstrap cannot run plugins at all: plugin code never runs on a host's + * main thread, so the load fails instead. */ -function getBootstrap(): ReturnType { +function getBootstrap(): NonNullable> { + if (bootstrapUnavailableForTests) { + throw new PluginBootstrapMissingError(); + } resolvedBootstrap ??= resolveBootstrap(); + if (!resolvedBootstrap) { + throw new PluginBootstrapMissingError(); + } return resolvedBootstrap; } /** - * Whether this build can start the sandbox bootstrap: the compiled file ships - * beside it, or its TypeScript source exists (running from source). Bundles - * that ship neither cannot isolate plugins. + * @internal Test hook: behave as a host that ships no bootstrap. Tests run + * from source, where the `.ts` bootstrap always resolves. */ -export function isPluginSandboxBootstrapAvailable(): boolean { - const bootstrap = getBootstrap(); - return "file" in bootstrap || existsSync(bootstrap.sourcePath); +export function setPluginBootstrapUnavailableForTests( + unavailable: boolean, +): void { + bootstrapUnavailableForTests = unavailable; +} + +/** This build ships no `plugin-sandbox-bootstrap.js`, so no plugin can run. */ +export class PluginBootstrapMissingError extends Error { + constructor() { + super( + "This build does not ship plugin-sandbox-bootstrap.js, so plugins cannot run. " + + `Set ${CLINE_PLUGIN_BOOTSTRAP_PATH_ENV} to a copy of the file, or reinstall Cline.`, + ); + this.name = "PluginBootstrapMissingError"; + } +} + +/** + * The result of a sandbox load when the host ships no bootstrap: every plugin + * is reported failed at import with the same error, so listings and session + * starts show the cause instead of silently dropping the plugins. + */ +function pluginBootstrapMissingResult( + pluginPaths: ReadonlyArray, + error: PluginBootstrapMissingError, +): Awaited> { + return { + extensions: [], + pluginPaths: [], + disabledByPolicy: [], + warnings: [], + failures: pluginPaths.map((pluginPath) => ({ + pluginPath, + phase: "load" as const, + message: error.message, + stack: error.stack, + })), + shutdown: async () => {}, + }; } function withTimeoutFallback( @@ -399,7 +445,15 @@ export async function loadSandboxedPlugins( DEFAULT_PLUGIN_SANDBOX_IDLE_TIMEOUT_MS, CLINE_PLUGIN_IDLE_TIMEOUT_MS_ENV, ); - const resolved = getBootstrap(); + let resolved: ReturnType; + try { + resolved = getBootstrap(); + } catch (error) { + if (error instanceof PluginBootstrapMissingError) { + return pluginBootstrapMissingResult(options.pluginPaths, error); + } + throw error; + } const bootstrap = "file" in resolved ? { bootstrapFile: resolved.file } diff --git a/sdk/packages/core/src/hub/daemon/entry.test.ts b/sdk/packages/core/src/hub/daemon/entry.test.ts index e3c84eadac..6f8d62e683 100644 --- a/sdk/packages/core/src/hub/daemon/entry.test.ts +++ b/sdk/packages/core/src/hub/daemon/entry.test.ts @@ -119,19 +119,13 @@ vi.mock("./bind-diagnostics", () => ({ describeAddressInUse: mockDescribeAddressInUse, })); -const { mockAttributeUncaughtError, mockPluginPreload } = vi.hoisted(() => ({ - mockAttributeUncaughtError: vi.fn( - (_error: unknown, _label?: string): unknown => undefined, - ), +const { mockPluginPreload } = vi.hoisted(() => ({ mockPluginPreload: vi.fn(async () => []), })); vi.mock("../../extensions/plugin/plugin-config-loader", () => ({ resolvePluginExecutionMode: () => - process.env.CLINE_PLUGIN_MODE === "sandbox" || - process.env.CLINE_PLUGIN_MODE === "in_process" - ? process.env.CLINE_PLUGIN_MODE - : "worker", + process.env.CLINE_PLUGIN_MODE === "sandbox" ? "sandbox" : "worker", resolveAgentPluginPathsWithDiagnostics: () => ({ paths: [], disabledPaths: [], @@ -141,7 +135,6 @@ vi.mock("../../extensions/plugin/plugin-config-loader", () => ({ vi.mock("../../extensions/plugin/plugin-registry", () => ({ getProcessPluginRegistry: () => ({ - attributeUncaughtError: mockAttributeUncaughtError, preload: mockPluginPreload, }), })); @@ -372,65 +365,6 @@ describe("hub daemon entry", () => { }); }); - it("keeps running when an uncaught error is attributed to a plugin", async () => { - const cwd = mkdtempSync(join(tmpdir(), "cline-hub-entry-test-")); - tempDirs.push(cwd); - process.argv = ["node", "entry.js", "--cwd", cwd]; - const handlers = new Map void>(); - vi.spyOn(process, "on").mockImplementation((( - event: string, - handler: (reason: unknown) => void, - ) => { - handlers.set(event, handler); - return process; - }) as never); - const stderr = vi - .spyOn(process.stderr, "write") - .mockImplementation(() => true); - const exitSpy = vi - .spyOn(process, "exit") - .mockImplementation(() => undefined as never); - const pluginError = new Error("plugin went wrong"); - mockAttributeUncaughtError.mockImplementation((error) => - error === pluginError - ? { - name: "bad-plugin", - pluginPath: "/plugins/bad-plugin.ts", - state: "failed", - } - : undefined, - ); - - const { hubDaemonReady } = await import("./entry"); - await hubDaemonReady; - expect(mockPluginPreload).toHaveBeenCalled(); - - handlers.get("unhandledRejection")?.(pluginError); - handlers.get("uncaughtException")?.(pluginError); - expect(mockAttributeUncaughtError).toHaveBeenCalledWith( - pluginError, - "unhandledRejection", - ); - expect(mockAttributeUncaughtError).toHaveBeenCalledWith( - pluginError, - "uncaughtException", - ); - expect(exitSpy).not.toHaveBeenCalled(); - expect( - stderr.mock.calls.some(([line]) => - String(line).includes('from plugin "bad-plugin"'), - ), - ).toBe(true); - - // Errors that do not come from a plugin stay fatal. - handlers.get("uncaughtException")?.(new Error("hub bug")); - await vi.waitFor(() => { - expect(exitSpy).toHaveBeenCalledWith(1); - }); - mockAttributeUncaughtError.mockReset(); - mockAttributeUncaughtError.mockImplementation(() => undefined); - }); - it("does not preload plugins in sandbox mode", async () => { const cwd = mkdtempSync(join(tmpdir(), "cline-hub-entry-test-")); tempDirs.push(cwd); @@ -451,10 +385,7 @@ describe("hub daemon entry", () => { } }); - it.each([ - "worker", - "in_process", - ])("preloads plugin status in %s mode", async (mode) => { + it("preloads plugin status in worker mode", async () => { const cwd = mkdtempSync(join(tmpdir(), "cline-hub-entry-test-")); tempDirs.push(cwd); process.argv = ["node", "entry.js", "--cwd", cwd]; @@ -462,14 +393,14 @@ describe("hub daemon entry", () => { vi.spyOn(process.stderr, "write").mockImplementation(() => true); mockPluginPreload.mockClear(); const previous = process.env.CLINE_PLUGIN_MODE; - process.env.CLINE_PLUGIN_MODE = mode; + delete process.env.CLINE_PLUGIN_MODE; try { const { hubDaemonReady } = await import("./entry"); await hubDaemonReady; await new Promise((resolve) => setTimeout(resolve, 10)); - // Worker mode preloads in throwaway workers, never in the hub. + // Preloads in throwaway workers, never in the hub. expect(mockPluginPreload).toHaveBeenCalledWith( - expect.objectContaining({ isolation: mode }), + expect.objectContaining({ pluginPaths: [] }), ); } finally { if (previous === undefined) delete process.env.CLINE_PLUGIN_MODE; diff --git a/sdk/packages/core/src/hub/daemon/entry.ts b/sdk/packages/core/src/hub/daemon/entry.ts index 9000645423..5761d5e7cc 100644 --- a/sdk/packages/core/src/hub/daemon/entry.ts +++ b/sdk/packages/core/src/hub/daemon/entry.ts @@ -9,10 +9,7 @@ import { resolveAgentPluginPathsWithDiagnostics, resolvePluginExecutionMode, } from "../../extensions/plugin/plugin-config-loader"; -import { - getProcessPluginRegistry, - type PluginRegistry, -} from "../../extensions/plugin/plugin-registry"; +import { getProcessPluginRegistry } from "../../extensions/plugin/plugin-registry"; import { cleanupConnectorInstanceViaCli } from "../../services/connectors/connector-cleanup"; import { ConnectorSupervisor, @@ -163,29 +160,6 @@ export function isAbortRejection(reason: unknown): boolean { return false; } -/** - * Plugins run inside the daemon, so a stray exception or rejection from a - * plugin must not shut every session down. When the error's stack points into - * a loaded plugin, mark that plugin `failed` and keep running. Returns false - * for errors that are not attributable to a plugin. - */ -export function attributeUncaughtErrorToPlugin( - label: "uncaughtException" | "unhandledRejection", - error: unknown, - registry: PluginRegistry = getProcessPluginRegistry(), - write: (line: string) => void = (line) => process.stderr.write(line), -): boolean { - const status = registry.attributeUncaughtError(error, label); - if (!status) { - return false; - } - const message = error instanceof Error ? error.message : String(error); - write( - `[hub-daemon] ${label} from plugin "${status.name}" (${status.pluginPath}); marked it failed and kept running: ${message}\n`, - ); - return true; -} - async function main(): Promise { ensureLoopbackProxyBypass(); const options = parseArgs(process.argv.slice(2)); @@ -268,10 +242,9 @@ async function main(): Promise { // instead of taking Node/Bun's default exit path and leaving stale discovery. process.on("SIGINT", () => handleTerminationSignal("SIGINT")); process.on("SIGTERM", () => handleTerminationSignal("SIGTERM")); + // Plugin code runs in workers, never on this thread, so an uncaught error + // here is the daemon's own and is fatal. process.on("uncaughtException", (error) => { - if (attributeUncaughtErrorToPlugin("uncaughtException", error)) { - return; - } shutdownFatal("uncaughtException", error); }); process.on("unhandledRejection", (reason) => { @@ -282,9 +255,6 @@ async function main(): Promise { ); return; } - if (attributeUncaughtErrorToPlugin("unhandledRejection", reason)) { - return; - } shutdownFatal("unhandledRejection", reason); }); @@ -438,11 +408,10 @@ async function main(): Promise { } async function preloadDaemonPlugins(cwd: string): Promise { - // In sandbox mode plugins must never run inside the Hub process; each - // session starts its own sandbox instead. Worker mode preloads in - // throwaway workers, so the Hub still never imports plugin code. - const mode = resolvePluginExecutionMode(); - if (mode === "sandbox") { + // Sandbox mode has no process-wide registry; each session starts its own + // subprocess. Worker mode preloads in throwaway workers so status is known + // before any session starts. + if (resolvePluginExecutionMode() === "sandbox") { return; } try { @@ -451,7 +420,6 @@ async function preloadDaemonPlugins(cwd: string): Promise { cwd, }); const statuses = await getProcessPluginRegistry().preload({ - isolation: mode, pluginPaths: resolved.paths, disabledPluginPaths: resolved.disabledPaths, discoveryFailures: resolved.discoveryFailures, diff --git a/sdk/packages/core/src/services/plugin-tools.test.ts b/sdk/packages/core/src/services/plugin-tools.test.ts index 02abf7ccb0..cdc4e3e0f2 100644 --- a/sdk/packages/core/src/services/plugin-tools.test.ts +++ b/sdk/packages/core/src/services/plugin-tools.test.ts @@ -1,6 +1,7 @@ import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; import { join } from "node:path"; +import type { AgentTool } from "@cline/shared"; import { setHomeDir } from "@cline/shared/storage"; import { afterEach, beforeEach, describe, expect, it } from "vitest"; import { @@ -23,7 +24,10 @@ describe("listPluginToolsWithDiagnostics", () => { process.env.HOME = root; setHomeDir(root); process.env.CLINE_GLOBAL_SETTINGS_PATH = join(root, "settings.json"); - registry = new PluginRegistry(); + registry = new PluginRegistry({ + failureThreshold: 1, + importTimeoutMs: 15_000, + }); resetProcessPluginRegistryForTests(registry); await mkdir(join(root, ".cline", "plugins"), { recursive: true }); }); @@ -38,14 +42,14 @@ describe("listPluginToolsWithDiagnostics", () => { await rm(root, { recursive: true, force: true }); }); - const writePlugin = async (name: string) => { + const writePlugin = async (name: string, execute = '() => "ok"') => { const path = join(root, ".cline", "plugins", `${name}.js`); await writeFile( path, `export default { name: "${name}", manifest: { capabilities: ["tools"] }, - setup(api) { api.registerTool({ name: "${name}_tool", description: "", inputSchema: {}, execute: () => "ok" }); }, + setup(api) { api.registerTool({ name: "${name}_tool", description: "", inputSchema: {}, execute: ${execute} }); }, };`, "utf8", ); @@ -53,14 +57,36 @@ describe("listPluginToolsWithDiagnostics", () => { }; it("does not answer from a cached inspection after the plugin is turned off", async () => { - const path = await writePlugin("cached"); + const path = await writePlugin( + "cached", + '() => { throw new Error("tool broke"); }', + ); const first = await listPluginToolsWithDiagnostics({ workspacePath: root }); expect(first.tools.map((tool) => tool.name)).toEqual(["cached_tool"]); // A running session's copy failed badly enough to turn it off. - const error = new Error("stray"); - error.stack = `Error: stray\n at run (${path}:1:1)`; - registry.attributeUncaughtError(error); + const session = await registry.loadForSession({ + sessionId: "s1", + pluginPaths: [path], + }); + const tools: AgentTool[] = []; + await session.extensions[0]?.setup?.( + { + registerTool: (tool) => tools.push(tool), + registerCommand: () => {}, + registerRule: () => {}, + registerMessageBuilder: () => {}, + registerProvider: () => {}, + registerAutomationEventType: () => {}, + registerMcpServer: () => {}, + }, + {}, + ); + await expect( + tools[0]?.execute({}, { agentId: "agent", iteration: 1 }), + ).rejects.toThrow("tool broke"); + await session.release(); + expect(registry.get(path)[0]?.state).toBe("failed"); const second = await listPluginToolsWithDiagnostics({ workspacePath: root, From 0474213f6ca8dc2a2fec761965c8aab0873862d8 Mon Sep 17 00:00:00 2001 From: Saoud Rizwan <7799382+saoudrizwan@users.noreply.github.com> Date: Thu, 15 Oct 2026 00:15:39 -0700 Subject: [PATCH 16/21] fix(vscode): keep Auto Compact and its strategy together in Feature settings (#15109) The Cloud toggle was appended to the agent toggle list, which renders before the Auto Compact Strategy dropdown, so it split Auto Compact from its strategy. Render the Cloud row after Web Search instead. --- .../sections/FeatureSettingsSection.tsx | 18 ++++++++---------- 1 file changed, 8 insertions(+), 10 deletions(-) diff --git a/apps/vscode/webview-ui/src/components/settings/sections/FeatureSettingsSection.tsx b/apps/vscode/webview-ui/src/components/settings/sections/FeatureSettingsSection.tsx index a7bc2b9ce4..7892121fed 100644 --- a/apps/vscode/webview-ui/src/components/settings/sections/FeatureSettingsSection.tsx +++ b/apps/vscode/webview-ui/src/components/settings/sections/FeatureSettingsSection.tsx @@ -39,14 +39,6 @@ const agentFeatures: FeatureToggle[] = [ stateKey: "useAutoCondense", settingKey: "useAutoCondense", }, - { - id: "cloud-sessions", - label: "Cloud", - description: - "Run Cline on your GitHub repositories in secure cloud sandboxes. Adds a Cloud option to the new task composer. Requires a Cline account with GitHub connected.", - stateKey: "cloudSessionsEnabled", - settingKey: "cloudSessionsEnabled", - }, ] const editorFeatures: FeatureToggle[] = [ @@ -175,13 +167,11 @@ const FeatureSettingsSection = ({ renderSectionHeader }: FeatureSettingsSectionP subagentsEnabled, worktreesEnabled: worktreesEnabled?.user, backgroundEditEnabled, - cloudSessionsEnabled, } // Visibility lookup for features with feature flags const featureVisibility: Record = { worktreesEnabled: worktreesEnabled?.featureFlag, - cloudSessionsEnabled: !!cloudSessionsAvailable, } return ( @@ -228,6 +218,14 @@ const FeatureSettingsSection = ({ renderSectionHeader }: FeatureSettingsSectionP label="Web Search" onChange={(checked) => updateSetting("webSearchEnabled", checked)} /> + updateSetting("cloudSessionsEnabled", checked)} + /> From 2ffa920b0e6a5b2f7a8c22d253f51c765cba4011 Mon Sep 17 00:00:00 2001 From: Saoud Rizwan <7799382+saoudrizwan@users.noreply.github.com> Date: Thu, 15 Oct 2026 02:15:02 -0700 Subject: [PATCH 17/21] fix: add /v1 to host-only OpenAI Compatible base URLs once, on VS Code and JetBrains (#15108) * fix(vscode): migrate host-only OpenAI Compatible base URLs to /v1 once Earlier extension builds sent requests for a host-only OpenAI Compatible base URL to /v1. #15057 now uses the URL as entered, so persist the /v1 for existing users once instead of retrying requests at runtime. * Revert "fix(vscode): migrate host-only OpenAI Compatible base URLs to /v1 once" This reverts commit 424bad47e8359f7a9e6af0a2f668d1b9a78b7727. * fix(vscode): add /v1 to host-only OpenAI Compatible base URLs in the storage migration Earlier SDK extension builds sent requests for a host-only OpenAI Compatible base URL to /v1. #15057 now uses the URL as entered, so add a v4 step to the VS Code storage migration that saves the /v1 for existing users once. * fix(vscode): don't log the OpenAI Compatible base URL in the /v1 migration Base URLs can carry credentials (user:pass@host) or tokens in the query string. * fix: run the OpenAI Compatible /v1 migration on JetBrains too Move the host-only base URL rewrite out of the VS Code-only storage migration into a host-neutral step that both extension.ts and the standalone cline-core.ts run before initialize(). It keeps its own marker in the shared globalState.json, set even when there is nothing to rewrite, so it runs once across hosts and later host-only URLs stay as entered. --- .../openai-base-url-migration.test.ts | 73 +++++++++++++++++++ .../core/storage/openai-base-url-migration.ts | 29 ++++++++ apps/vscode/src/extension.ts | 2 + apps/vscode/src/standalone/cline-core.ts | 2 + 4 files changed, 106 insertions(+) create mode 100644 apps/vscode/src/core/storage/__tests__/openai-base-url-migration.test.ts create mode 100644 apps/vscode/src/core/storage/openai-base-url-migration.ts diff --git a/apps/vscode/src/core/storage/__tests__/openai-base-url-migration.test.ts b/apps/vscode/src/core/storage/__tests__/openai-base-url-migration.test.ts new file mode 100644 index 0000000000..72cfbd33a2 --- /dev/null +++ b/apps/vscode/src/core/storage/__tests__/openai-base-url-migration.test.ts @@ -0,0 +1,73 @@ +import { afterEach, beforeEach, describe, expect, it } from "bun:test" +import fs from "node:fs" +import os from "node:os" +import path from "node:path" +import { createStorageContext, type StorageContext } from "@/shared/storage/storage-context" +import { migrateOpenAiCompatibleBaseUrlToV1 } from "../openai-base-url-migration" + +describe("migrateOpenAiCompatibleBaseUrlToV1", () => { + let tempDir: string + let storage: StorageContext + + beforeEach(() => { + tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "openai-base-url-migration-")) + storage = createStorageContext({ clineDir: tempDir, workspacePath: tempDir }) + }) + + afterEach(() => { + fs.rmSync(tempDir, { recursive: true, force: true }) + }) + + for (const [baseUrl, expected] of [ + ["https://example.com", "https://example.com/v1"], + ["http://localhost:8000/", "http://localhost:8000/v1"], + ]) { + it(`adds /v1 to the host-only base URL ${baseUrl}`, () => { + storage.globalState.update("openAiBaseUrl", baseUrl) + + migrateOpenAiCompatibleBaseUrlToV1(storage) + + expect(storage.globalState.get("openAiBaseUrl")).toBe(expected) + }) + } + + for (const baseUrl of ["https://example.com/v1", "https://example.com/api/foo", "not a url"]) { + it(`leaves the base URL ${baseUrl} unchanged`, () => { + storage.globalState.update("openAiBaseUrl", baseUrl) + + migrateOpenAiCompatibleBaseUrlToV1(storage) + + expect(storage.globalState.get("openAiBaseUrl")).toBe(baseUrl) + }) + } + + it("runs only once, so a host-only URL saved afterwards stays as entered", () => { + storage.globalState.update("openAiBaseUrl", "https://example.com") + migrateOpenAiCompatibleBaseUrlToV1(storage) + storage.globalState.update("openAiBaseUrl", "https://example.com") + + migrateOpenAiCompatibleBaseUrlToV1(storage) + + expect(storage.globalState.get("openAiBaseUrl")).toBe("https://example.com") + }) + + it("marks itself done when there is no base URL, so a later host-only URL stays as entered", () => { + migrateOpenAiCompatibleBaseUrlToV1(storage) + storage.globalState.update("openAiBaseUrl", "https://example.com") + + migrateOpenAiCompatibleBaseUrlToV1(storage) + + expect(storage.globalState.get("openAiBaseUrl")).toBe("https://example.com") + }) + + it("shares its marker across hosts through globalState.json", () => { + storage.globalState.update("openAiBaseUrl", "https://example.com") + migrateOpenAiCompatibleBaseUrlToV1(storage) + storage.globalState.update("openAiBaseUrl", "https://example.com") + + const otherHost = createStorageContext({ clineDir: tempDir, workspacePath: tempDir }) + migrateOpenAiCompatibleBaseUrlToV1(otherHost) + + expect(otherHost.globalState.get("openAiBaseUrl")).toBe("https://example.com") + }) +}) diff --git a/apps/vscode/src/core/storage/openai-base-url-migration.ts b/apps/vscode/src/core/storage/openai-base-url-migration.ts new file mode 100644 index 0000000000..73402b6081 --- /dev/null +++ b/apps/vscode/src/core/storage/openai-base-url-migration.ts @@ -0,0 +1,29 @@ +import { Logger } from "@/shared/services/Logger" +import type { StorageContext } from "@/shared/storage/storage-context" + +const MIGRATED_KEY = "__openAiBaseUrlV1Migrated" + +/** + * Earlier SDK builds sent requests for an OpenAI Compatible base URL with no + * path (`http://localhost:8000`) to `/v1`; the URL is now used as entered. + * Save the `/v1` so existing setups keep reaching the same endpoint. + * + * Runs once across all hosts: the marker lives in the shared globalState.json + * and is set even when there is nothing to rewrite, so a host-only URL saved + * afterwards (in any host) stays as entered. + */ +export function migrateOpenAiCompatibleBaseUrlToV1(storage: StorageContext): void { + if (storage.globalState.get(MIGRATED_KEY)) { + return + } + const baseUrl = storage.globalState.get("openAiBaseUrl")?.trim() + if (baseUrl && URL.canParse(baseUrl)) { + const url = new URL(baseUrl) + if (url.pathname === "/") { + url.pathname = "/v1" + storage.globalState.update("openAiBaseUrl", url.toString().replace(/\/$/, "")) + Logger.info("[Migration] Added /v1 to the host-only OpenAI Compatible base URL") + } + } + storage.globalState.update(MIGRATED_KEY, true) +} diff --git a/apps/vscode/src/extension.ts b/apps/vscode/src/extension.ts index f93bec1a83..2bbc09bf8e 100644 --- a/apps/vscode/src/extension.ts +++ b/apps/vscode/src/extension.ts @@ -29,6 +29,7 @@ import { improveWithCline } from "./core/controller/commands/improveWithCline" import { sendAddToInputEvent } from "./core/controller/ui/subscribeToAddToInput" import { sendShowWebviewEvent } from "./core/controller/ui/subscribeToShowWebview" import { HookDiscoveryCache } from "./core/hooks/HookDiscoveryCache" +import { migrateOpenAiCompatibleBaseUrlToV1 } from "./core/storage/openai-base-url-migration" import { cleanupMcpMarketplaceCatalogFromGlobalState, cleanupOldApiKey, @@ -84,6 +85,7 @@ export async function activate(context: vscode.ExtensionContext) { const workspacePath = vscode.workspace.workspaceFolders?.[0]?.uri.fsPath const storageContext = createStorageContext({ workspacePath }) await exportVSCodeStorageToSharedFiles(context, storageContext) + migrateOpenAiCompatibleBaseUrlToV1(storageContext) // 4. Register services and perform common initialization // IMPORTANT: Must be done after host provider is setup and migrations are complete diff --git a/apps/vscode/src/standalone/cline-core.ts b/apps/vscode/src/standalone/cline-core.ts index d95c006cbf..56372a6d0b 100644 --- a/apps/vscode/src/standalone/cline-core.ts +++ b/apps/vscode/src/standalone/cline-core.ts @@ -14,6 +14,7 @@ import { retryOperation } from "@utils/retry" import * as path from "path" import { initialize, tearDown } from "@/common" import { SqliteLockManager } from "@/core/locks/SqliteLockManager" +import { migrateOpenAiCompatibleBaseUrlToV1 } from "@/core/storage/openai-base-url-migration" import { WebviewProvider } from "@/core/webview" import { AuthHandler } from "@/hosts/external/AuthHandler" import { HostProvider } from "@/hosts/host-provider" @@ -87,6 +88,7 @@ async function main() { // Create shared file-backed storage const storageContext = createStorageContext() + migrateOpenAiCompatibleBaseUrlToV1(storageContext) const webviewProvider = await initialize(storageContext) // Enable the localhost HTTP server that handles auth redirects. From 48594e5a7c896b0b65be4a435fed9cbafd0d4319 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tom=C3=A1s=20Barreiro?= <52393857+BarreiroT@users.noreply.github.com> Date: Thu, 15 Oct 2026 09:13:09 -0300 Subject: [PATCH 18/21] Log request ids (#15044) * Log request ids on failure logs * refactor * Capture only the base error message * address review comments --- sdk/packages/llms/src/providers/ai-sdk.ts | 26 ++-- .../llms/src/providers/gateway.test.ts | 118 +++++++++++++++--- .../src/providers/response-request-id.test.ts | 76 +++++++++++ .../llms/src/providers/response-request-id.ts | 58 +++++++++ 4 files changed, 255 insertions(+), 23 deletions(-) create mode 100644 sdk/packages/llms/src/providers/response-request-id.test.ts create mode 100644 sdk/packages/llms/src/providers/response-request-id.ts diff --git a/sdk/packages/llms/src/providers/ai-sdk.ts b/sdk/packages/llms/src/providers/ai-sdk.ts index b4dfa9e9bc..890a7e207a 100644 --- a/sdk/packages/llms/src/providers/ai-sdk.ts +++ b/sdk/packages/llms/src/providers/ai-sdk.ts @@ -60,6 +60,11 @@ import { recordProviderRequestCapture, wrapFetchForProviderRequestCapture, } from "./provider-request-capture"; +import { + appendRequestId, + extractResponseRequestId, + findRequestIdHeader, +} from "./response-request-id"; import { applyPromptCacheToLastTextPart, shouldApplyPromptCache, @@ -1467,13 +1472,19 @@ interface CapturedStreamError { * does not report the same failure a second time. */ reported?: boolean; + requestId?: string; + baseMessage: string; } function captureStreamError(error: unknown): CapturedStreamError { + const requestId = extractResponseRequestId(error); + const baseMessage = extractErrorMessage(error); return { - message: extractErrorMessage(error), + message: appendRequestId(baseMessage, requestId), + baseMessage, errorClass: classifyProviderError(error), retryable: isRetryableBeyondSdkRetries(error), + ...(requestId ? { requestId } : {}), }; } @@ -1517,9 +1528,7 @@ async function* emitAiSdkEvents( continue; } if (part.type === "finish-step") { - requestId = Object.entries(part.response?.headers ?? {}).find( - ([name]) => name.toLowerCase() === "x-request-id", - )?.[1]; + requestId = findRequestIdHeader(part.response?.headers); continue; } if (part.type === "text-delta") { @@ -1994,10 +2003,11 @@ async function* emitAiSdkEvents( }; } + const finishRequestId = requestId ?? streamError?.requestId; yield { type: "finish", reason: streamError ? "error" : mapFinishReason(finishReason), - ...(requestId ? { requestId } : {}), + ...(finishRequestId ? { requestId: finishRequestId } : {}), error: streamError?.message, errorClass: streamError?.errorClass, errorRetryable: streamError?.retryable, @@ -2381,13 +2391,14 @@ function createAiSdkProvider( component: "llms", operation: "provider.stream", error: streamError, - errorMessage: msg, + errorMessage: captured.baseMessage, severity: "error", handled: true, context: { providerId: request.providerId, modelId: request.modelId, providerKind: kind, + requestId: captured.requestId, }, }); }, @@ -2430,13 +2441,14 @@ function createAiSdkProvider( component: "llms", operation: "provider.create_or_stream", error, - errorMessage: msg, + errorMessage: captured.baseMessage, severity: "error", handled: true, context: { providerId: request.providerId, modelId: request.modelId, providerKind: kind, + requestId: captured.requestId, }, }); yield { diff --git a/sdk/packages/llms/src/providers/gateway.test.ts b/sdk/packages/llms/src/providers/gateway.test.ts index fbff5b9a39..b501fa67a0 100644 --- a/sdk/packages/llms/src/providers/gateway.test.ts +++ b/sdk/packages/llms/src/providers/gateway.test.ts @@ -19,6 +19,7 @@ import { type ITelemetryService, resetSdkErrorRateLimiterForTests, } from "@cline/shared"; +import { APICallError } from "ai"; import { beforeEach, describe, expect, it, vi } from "vitest"; import { normalizeModelsDevProviderModels } from "../catalog/catalog-live"; import { createOpenAICompatibleProvider } from "./ai-sdk"; @@ -96,22 +97,28 @@ function createFetchMock() { }; } -vi.mock("ai", () => ({ - jsonSchema: (schema: unknown, options: unknown) => ({ - jsonSchema: schema, - ...(options && typeof options === "object" ? options : {}), - }), - generateImage: (input: unknown) => generateImageSpy(input), - streamText: (input: unknown) => streamTextSpy(input), - // `wrapLanguageModel` is used by the openai-compatible and mistral - // vendors to attach `splitToolImagesMiddleware`. The middleware itself - // is exercised by its own unit tests; here we just need an identity - // pass-through so the vendor factories' downstream `model:` callbacks - // keep returning the spy-produced mock objects unchanged. (The mock - // objects don't satisfy the real `LanguageModelV4` interface, so we - // can't call the real `wrapLanguageModel` either way.) - wrapLanguageModel: ({ model }: { model: unknown }) => model, -})); +vi.mock("ai", async (importOriginal) => { + const { APICallError, RetryError } = + await importOriginal(); + return { + APICallError, + RetryError, + jsonSchema: (schema: unknown, options: unknown) => ({ + jsonSchema: schema, + ...(options && typeof options === "object" ? options : {}), + }), + generateImage: (input: unknown) => generateImageSpy(input), + streamText: (input: unknown) => streamTextSpy(input), + // `wrapLanguageModel` is used by the openai-compatible and mistral + // vendors to attach `splitToolImagesMiddleware`. The middleware itself + // is exercised by its own unit tests; here we just need an identity + // pass-through so the vendor factories' downstream `model:` callbacks + // keep returning the spy-produced mock objects unchanged. (The mock + // objects don't satisfy the real `LanguageModelV4` interface, so we + // can't call the real `wrapLanguageModel` either way.) + wrapLanguageModel: ({ model }: { model: unknown }) => model, + }; +}); vi.mock("@ai-sdk/openai", () => ({ createOpenAI: () => ({ @@ -1401,6 +1408,44 @@ describe("sdk-gateway", () => { }); }); + it("includes the response x-request-id in stream error messages", async () => { + streamTextSpy.mockReturnValue({ + fullStream: makeStreamParts([ + { + type: "error", + error: new APICallError({ + message: "Unauthorized", + url: "https://api.cline.bot/api/v1/chat/completions", + requestBodyValues: {}, + statusCode: 401, + responseHeaders: { "X-Request-ID": "req_abc123" }, + responseBody: JSON.stringify({ + error: { message: "Unauthorized: re-authenticate" }, + }), + }), + }, + ]), + }); + const gateway = createGateway({ + providerConfigs: [{ providerId: "openai-native", apiKey: "test" }], + }); + + const events = await collect( + await gateway.stream({ + providerId: "openai-native", + modelId: "gpt-5-mini", + messages: baseMessages, + }), + ); + + expect(events.at(-1)).toMatchObject({ + type: "finish", + reason: "error", + requestId: "req_abc123", + error: "Unauthorized: re-authenticate (Request ID: req_abc123)", + }); + }); + it("preserves valid mixed text when a generated image is rejected", async () => { const oversized = "A".repeat(DEFAULT_MAX_IMAGE_ENCODED_BYTES + 4); streamTextSpy.mockReturnValue({ @@ -2802,6 +2847,47 @@ describe("sdk-gateway", () => { expect(sdkErrors).toHaveLength(5); }); + it("rate-limits stream failures that differ only by request ID", async () => { + const { telemetry, capture } = createTelemetryMock(); + const gateway = createGateway({ + telemetry, + providerConfigs: [{ providerId: "openai-native", apiKey: "test" }], + }); + + for (let i = 0; i < 20; i++) { + const rawError = new APICallError({ + message: "Unauthorized", + url: "https://api.cline.bot/api/v1/chat/completions", + requestBodyValues: {}, + statusCode: 401, + responseHeaders: { "x-request-id": `req_${"abcdefghijklmnopqrst"[i]}` }, + }); + streamTextSpy.mockImplementation( + (input: { onError?: (event: { error: unknown }) => void }) => { + input.onError?.({ error: rawError }); + return { + fullStream: makeStreamParts([{ type: "error", error: rawError }]), + }; + }, + ); + await collect( + await gateway.stream({ + providerId: "openai-native", + modelId: "gpt-5-mini", + messages: baseMessages, + }), + ); + } + + const sdkErrors = capture.mock.calls.filter( + ([call]) => (call as { event: string }).event === "sdk.error", + ); + expect(sdkErrors).toHaveLength(5); + expect(sdkErrors[0]?.[0]).toMatchObject({ + properties: { error_message: "Unauthorized", requestId: "req_a" }, + }); + }); + it("records the extracted cause when provider creation fails through a generic wrapper", async () => { streamTextSpy.mockImplementation(() => { throw new Error("No output generated. Check the stream for errors.", { diff --git a/sdk/packages/llms/src/providers/response-request-id.test.ts b/sdk/packages/llms/src/providers/response-request-id.test.ts new file mode 100644 index 0000000000..69931e281d --- /dev/null +++ b/sdk/packages/llms/src/providers/response-request-id.test.ts @@ -0,0 +1,76 @@ +import { APICallError, RetryError } from "ai"; +import { describe, expect, it } from "vitest"; +import { + extractResponseRequestId, + findRequestIdHeader, +} from "./response-request-id"; + +function apiCallError(responseHeaders?: Record) { + return new APICallError({ + message: "Bad Gateway", + url: "https://api.cline.bot/api/v1/chat/completions", + requestBodyValues: {}, + statusCode: 502, + responseHeaders, + }); +} + +describe("findRequestIdHeader", () => { + it("matches the header case-insensitively and trims it", () => { + expect(findRequestIdHeader({ "X-Request-ID": " req_1 " })).toBe("req_1"); + }); + + it("returns undefined for missing or blank headers", () => { + expect(findRequestIdHeader(undefined)).toBeUndefined(); + expect(findRequestIdHeader({ "x-request-id": " " })).toBeUndefined(); + }); +}); + +describe("extractResponseRequestId", () => { + it("reads a top-level APICallError", () => { + expect( + extractResponseRequestId(apiCallError({ "x-request-id": "req_top" })), + ).toBe("req_top"); + }); + + it("walks the cause chain to a wrapped APICallError", () => { + const wrapped = new Error("outer", { + cause: new Error("middle", { + cause: apiCallError({ "x-request-id": "req_nested" }), + }), + }); + expect(extractResponseRequestId(wrapped)).toBe("req_nested"); + }); + + it("stops at the depth guard on cyclic causes", () => { + const a = new Error("a") as Error & { cause?: unknown }; + const b = new Error("b", { cause: a }); + a.cause = b; + expect(extractResponseRequestId(a)).toBeUndefined(); + }); + + it("falls back to earlier RetryError attempts newest-first", () => { + const error = new RetryError({ + message: "Failed after 3 attempts", + reason: "maxRetriesExceeded", + errors: [ + apiCallError({ "x-request-id": "req_first" }), + apiCallError({ "x-request-id": "req_second" }), + apiCallError(), + ], + }); + expect(extractResponseRequestId(error)).toBe("req_second"); + }); + + it("prefers RetryError.lastError when it has a request id", () => { + const error = new RetryError({ + message: "Failed after 2 attempts", + reason: "maxRetriesExceeded", + errors: [ + apiCallError({ "x-request-id": "req_first" }), + apiCallError({ "x-request-id": "req_last" }), + ], + }); + expect(extractResponseRequestId(error)).toBe("req_last"); + }); +}); diff --git a/sdk/packages/llms/src/providers/response-request-id.ts b/sdk/packages/llms/src/providers/response-request-id.ts new file mode 100644 index 0000000000..2fbba27818 --- /dev/null +++ b/sdk/packages/llms/src/providers/response-request-id.ts @@ -0,0 +1,58 @@ +import { APICallError, RetryError } from "ai"; + +const REQUEST_ID_HEADER = "x-request-id"; +const MAX_CAUSE_DEPTH = 5; + +export function findRequestIdHeader( + headers: Record | undefined, +): string | undefined { + const value = Object.entries(headers ?? {}).find( + ([name]) => name.toLowerCase() === REQUEST_ID_HEADER, + )?.[1]; + return value?.trim() || undefined; +} + +export function extractResponseRequestId( + error: unknown, + depth = 0, +): string | undefined { + if (depth > MAX_CAUSE_DEPTH || !error || typeof error !== "object") { + return undefined; + } + if (RetryError.isInstance(error)) { + const fromLast = extractResponseRequestId(error.lastError, depth + 1); + if (fromLast) { + return fromLast; + } + for (let i = error.errors.length - 1; i >= 0; i--) { + const attempt = error.errors[i]; + if (attempt === error.lastError) { + continue; + } + const fromAttempt = extractResponseRequestId(attempt, depth + 1); + if (fromAttempt) { + return fromAttempt; + } + } + } + if (APICallError.isInstance(error)) { + const fromHeaders = findRequestIdHeader(error.responseHeaders); + if (fromHeaders) { + return fromHeaders; + } + } + return extractResponseRequestId( + (error as { cause?: unknown }).cause, + depth + 1, + ); +} + +export function appendRequestId( + message: string, + requestId: string | undefined, +): string { + if (!requestId || message.includes(requestId)) { + return message; + } + return `${message} (Request ID: ${requestId})`; +} From 3834735da26b70503aa5c0aa377949612a2816ad Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Miko=C5=82aj=20Kondratek?= <19799111+mkondratek@users.noreply.github.com> Date: Thu, 15 Oct 2026 15:45:19 +0200 Subject: [PATCH 19/21] fix(webview): keep the Run task card above the Auto-approve bar in JetBrains (#15120) --- .../chat/chat-view/components/layout/WelcomeSection.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/vscode/webview-ui/src/components/chat/chat-view/components/layout/WelcomeSection.tsx b/apps/vscode/webview-ui/src/components/chat/chat-view/components/layout/WelcomeSection.tsx index f506717aea..1e62a3fd91 100644 --- a/apps/vscode/webview-ui/src/components/chat/chat-view/components/layout/WelcomeSection.tsx +++ b/apps/vscode/webview-ui/src/components/chat/chat-view/components/layout/WelcomeSection.tsx @@ -318,7 +318,7 @@ export const WelcomeSection: React.FC = ({ }, [bannerConfig, banners, clineUser, handleBannerAction, handleBannerDismiss, clinePassPromoBanner]) return ( -
    +
    Date: Thu, 15 Oct 2026 13:23:49 -0700 Subject: [PATCH 20/21] feat(desktop): remove the Cloud sessions settings toggle (#15110) * feat(desktop): enable cloud sessions for everyone Remove the Cloud sessions preview toggle from Settings and the code-cloud-agents rollout flag that revealed it. Cloud sessions are now on by default; CLINE_CODE_CLOUD_AGENTS=0 still turns them off for development and tests. * fix(desktop): keep the code-cloud-agents rollout flag as the cloud gate Only the Settings opt-in should go away. Cloud sessions are enabled wherever the code-cloud-agents PostHog flag is on (or the CLINE_CODE_CLOUD_AGENTS env override forces them). --- .../desktop-app/sidecar/ARCHITECTURE.md | 5 +- .../sidecar/commands-settings.test.ts | 51 +------ apps/examples/desktop-app/sidecar/commands.ts | 26 ---- .../sidecar/desktop-settings.test.ts | 62 -------- .../desktop-app/sidecar/desktop-settings.ts | 49 ------ .../desktop-app/sidecar/diagnostics.ts | 2 - .../desktop-app/sidecar/feature-flags.test.ts | 27 +--- .../desktop-app/sidecar/feature-flags.ts | 16 +- .../examples/desktop-app/webview/app/page.tsx | 12 -- .../views/settings/settings-view.test.tsx | 49 ------ .../views/settings/settings-view.tsx | 140 ++---------------- sdk/packages/shared/src/feature-flags.ts | 2 +- 12 files changed, 29 insertions(+), 412 deletions(-) delete mode 100644 apps/examples/desktop-app/sidecar/desktop-settings.test.ts delete mode 100644 apps/examples/desktop-app/sidecar/desktop-settings.ts diff --git a/apps/examples/desktop-app/sidecar/ARCHITECTURE.md b/apps/examples/desktop-app/sidecar/ARCHITECTURE.md index 1716153ce6..cc58a1c476 100644 --- a/apps/examples/desktop-app/sidecar/ARCHITECTURE.md +++ b/apps/examples/desktop-app/sidecar/ARCHITECTURE.md @@ -21,7 +21,6 @@ sidecar/ ├── chat-session.ts # Shared-Hub chat session adapter (local + cloud routing) ├── cloud-sessions.ts # Cloud session REST client + Hub-proxy manager ├── cline-auth.ts # Refresh-aware Cline auth token resolution -├── desktop-settings.ts # Desktop-owned settings (cloud sessions opt-in) ├── feature-flags.ts # Cloud sessions gate (env override + settings toggle) ├── session-data/ # Shared discovery, messages, artifacts, search helpers ├── paths.ts # Path resolution @@ -212,9 +211,7 @@ Supported commands: | `read_session_hooks` | Session data readers | | `delete_chat_session` | `SqliteSessionStore.delete` + file cleanup; cloud sessions also delete the sandbox | | `update_chat_session_title` | `resolveSessionBackend().updateSession`; cloud sessions PATCH the cloud API | -| `get_feature_flags` | `isCloudAgentsEnabled()` (env override + settings toggle) | -| `get_desktop_settings` | `readDesktopSettings()` | -| `set_cloud_sessions_enabled` | `setCloudSessionsEnabled()` + `feature_flags_changed` broadcast | +| `get_feature_flags` | PostHog flag snapshot + `isCloudAgentsEnabled()` (env override + `code-cloud-agents` rollout flag) | | `list_cloud_repositories` | `CloudSessionManager.listRepositories()` (GitHub integration) | | `list_cloud_models` | `CloudSessionManager.listModels()` (account-scoped Usage-Billing, ClinePass, and ClineFree catalogs; model IDs use the Cline cloud transport) | | `list_cloud_branches` | `CloudSessionManager.listBranches()` (paginated) | diff --git a/apps/examples/desktop-app/sidecar/commands-settings.test.ts b/apps/examples/desktop-app/sidecar/commands-settings.test.ts index 309d800c20..d03998b50e 100644 --- a/apps/examples/desktop-app/sidecar/commands-settings.test.ts +++ b/apps/examples/desktop-app/sidecar/commands-settings.test.ts @@ -9,7 +9,6 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { getCloudSessionManager } from "./cloud-sessions"; import { handleCommand } from "./commands"; import { createSidecarContext } from "./context"; -import { setCloudSessionsEnabled } from "./desktop-settings"; import { getDesktopFeatureFlagsService, resetDesktopFeatureFlagsForTesting, @@ -79,7 +78,7 @@ describe("desktop settings commands", () => { "list_chat_sessions", "list_discovered_sessions", "search_sessions", - ])("hides cloud rows from %s unless rollout and user toggle are enabled", async (command) => { + ])("hides cloud rows from %s unless the rollout flag is enabled", async (command) => { const { ctx } = createContext(); for (const target of ["local", "cloud"] as const) { ctx.liveSessions.set(target, { @@ -113,16 +112,13 @@ describe("desktop settings commands", () => { .map((row) => row.sessionId) .sort(); - expect(await rows()).toEqual(["local"]); - expect(discovery).not.toHaveBeenCalled(); - setCloudSessionsEnabled(true); expect(await rows()).toEqual(["cloud", "local"]); discovery.mockImplementationOnce(async () => { - setCloudSessionsEnabled(false); + process.env.CLINE_CODE_CLOUD_AGENTS = "0"; return cloudRows; }); expect(await rows()).toEqual(["local"]); - setCloudSessionsEnabled(true); + delete process.env.CLINE_CODE_CLOUD_AGENTS; discovery.mockClear(); flags.hydrateCache({ userId: null, @@ -172,45 +168,4 @@ describe("desktop settings commands", () => { expect.objectContaining({ id: "cline-cloud/cloud-only" }), ); }); - - it("rejects a non-boolean cloud sessions toggle value", async () => { - const { ctx, events } = createContext(); - - await expect( - handleCommand(ctx, "set_cloud_sessions_enabled", { - cloud_sessions_enabled: "yes", - }), - ).rejects.toThrow("cloud_sessions_enabled must be a boolean"); - expect(events).toEqual([]); - }); - - it.each([ - true, - false, - ])("persists toggle %s and refreshes cloud history immediately", async (enabled) => { - const { ctx, events } = createContext(); - - await expect( - handleCommand(ctx, "set_cloud_sessions_enabled", { - cloud_sessions_enabled: enabled, - }), - ).resolves.toEqual({ cloudSessionsEnabled: enabled }); - expect(events).toEqual([ - { name: "cloud_sessions_changed", payload: { environmentId: "local" } }, - { - name: "feature_flags_changed", - payload: { - cloudAgents: false, - cloudAgentsAvailable: false, - environmentId: "local", - }, - }, - ]); - await expect( - handleCommand(ctx, "get_feature_flags", {}), - ).resolves.toMatchObject({ cloudAgents: false }); - await expect( - handleCommand(ctx, "get_desktop_settings", {}), - ).resolves.toEqual({ cloudSessionsEnabled: enabled }); - }); }); diff --git a/apps/examples/desktop-app/sidecar/commands.ts b/apps/examples/desktop-app/sidecar/commands.ts index d0d8df21fc..45662858b4 100644 --- a/apps/examples/desktop-app/sidecar/commands.ts +++ b/apps/examples/desktop-app/sidecar/commands.ts @@ -126,14 +126,9 @@ import { resolveSidecarAskQuestion, sendEventToClient, } from "./context"; -import { - readDesktopSettings, - setCloudSessionsEnabled, -} from "./desktop-settings"; import { writeDiagnosticsReport } from "./diagnostics"; import { identifyDesktopFeatureFlagsAccount, - isCloudAgentsAvailable, isCloudAgentsEnabled, refreshDesktopFeatureFlags, } from "./feature-flags"; @@ -2207,8 +2202,6 @@ export async function handleCommand( }; } if (command === "get_feature_flags") { - // Resolve flags in the sidecar: available shows the opt-in; - // enabled requires both the rollout flag and the user's opt-in. const snapshot = await refreshDesktopFeatureFlags({ logger: ctx.logger, telemetry: ctx.telemetry, @@ -2219,10 +2212,6 @@ export async function handleCommand( logger: ctx.logger, telemetry: ctx.telemetry, }), - cloudAgentsAvailable: isCloudAgentsAvailable({ - logger: ctx.logger, - telemetry: ctx.telemetry, - }), }; } if (command === "list_cloud_models") { @@ -3221,9 +3210,6 @@ export async function handleCommand( setAutoUpdateEnabledGlobally(args.auto_update_enabled); return readGlobalSettings(); } - if (command === "get_desktop_settings") { - return readDesktopSettings(); - } if (command === "export_diagnostics") { const sessionIds = Array.isArray(args?.sessionIds) ? args.sessionIds.filter( @@ -3234,18 +3220,6 @@ export async function handleCommand( openFileInEditor(dirname(result.path)); return result; } - if (command === "set_cloud_sessions_enabled") { - if (typeof args?.cloud_sessions_enabled !== "boolean") { - throw new Error("cloud_sessions_enabled must be a boolean"); - } - const settings = setCloudSessionsEnabled(args.cloud_sessions_enabled); - broadcastEvent(ctx, "cloud_sessions_changed", {}); - broadcastEvent(ctx, "feature_flags_changed", { - cloudAgents: isCloudAgentsEnabled(), - cloudAgentsAvailable: isCloudAgentsAvailable(), - }); - return settings; - } // ── Connector channels ───────────────────────────────────────────── if (command === "list_connector_channels") { diff --git a/apps/examples/desktop-app/sidecar/desktop-settings.test.ts b/apps/examples/desktop-app/sidecar/desktop-settings.test.ts deleted file mode 100644 index e8d68da192..0000000000 --- a/apps/examples/desktop-app/sidecar/desktop-settings.test.ts +++ /dev/null @@ -1,62 +0,0 @@ -import { - mkdirSync, - mkdtempSync, - readFileSync, - rmSync, - writeFileSync, -} from "node:fs"; -import { tmpdir } from "node:os"; -import { dirname, join } from "node:path"; -import { afterEach, beforeEach, describe, expect, it } from "vitest"; -import { - readDesktopSettings, - resolveDesktopSettingsPath, - setCloudSessionsEnabled, -} from "./desktop-settings"; - -let dataDir: string; - -beforeEach(() => { - dataDir = mkdtempSync(join(tmpdir(), "cline-desktop-settings-")); - process.env.CLINE_DATA_DIR = dataDir; -}); - -afterEach(() => { - delete process.env.CLINE_DATA_DIR; - rmSync(dataDir, { recursive: true, force: true }); -}); - -describe("desktop settings", () => { - it("defaults cloud sessions to off when no settings file exists", () => { - expect(readDesktopSettings()).toEqual({ cloudSessionsEnabled: false }); - }); - - it("persists the cloud sessions opt-in and reads it back", () => { - expect(setCloudSessionsEnabled(true)).toEqual({ - cloudSessionsEnabled: true, - }); - expect(readDesktopSettings()).toEqual({ cloudSessionsEnabled: true }); - expect(resolveDesktopSettingsPath().endsWith("code-settings.json")).toBe( - true, - ); - expect( - JSON.parse(readFileSync(resolveDesktopSettingsPath(), "utf8")), - ).toMatchObject({ cloudSessionsEnabled: true }); - expect(setCloudSessionsEnabled(false)).toEqual({ - cloudSessionsEnabled: false, - }); - expect(readDesktopSettings()).toEqual({ cloudSessionsEnabled: false }); - }); - - it("treats malformed files and non-boolean values as off", () => { - mkdirSync(dirname(resolveDesktopSettingsPath()), { recursive: true }); - writeFileSync(resolveDesktopSettingsPath(), "{not json", "utf8"); - expect(readDesktopSettings()).toEqual({ cloudSessionsEnabled: false }); - writeFileSync( - resolveDesktopSettingsPath(), - JSON.stringify({ cloudSessionsEnabled: "yes" }), - "utf8", - ); - expect(readDesktopSettings()).toEqual({ cloudSessionsEnabled: false }); - }); -}); diff --git a/apps/examples/desktop-app/sidecar/desktop-settings.ts b/apps/examples/desktop-app/sidecar/desktop-settings.ts deleted file mode 100644 index dc26e2fe7c..0000000000 --- a/apps/examples/desktop-app/sidecar/desktop-settings.ts +++ /dev/null @@ -1,49 +0,0 @@ -import { mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs"; -import { dirname, join } from "node:path"; -import { resolveClineDataDir } from "@cline/shared/storage"; - -/** Desktop-only preferences kept separate from strict shared global settings. */ -export type DesktopSettings = { - /** Opt-in gate for cloud sessions while the feature is in preview. */ - cloudSessionsEnabled: boolean; -}; - -const DEFAULT_SETTINGS: DesktopSettings = { - cloudSessionsEnabled: false, -}; - -export function resolveDesktopSettingsPath(): string { - return join(resolveClineDataDir(), "settings", "code-settings.json"); -} - -export function readDesktopSettings(): DesktopSettings { - let raw: string; - try { - raw = readFileSync(resolveDesktopSettingsPath(), "utf8"); - } catch { - return { ...DEFAULT_SETTINGS }; - } - try { - const parsed = JSON.parse(raw) as Record; - return { - cloudSessionsEnabled: parsed.cloudSessionsEnabled === true, - }; - } catch { - return { ...DEFAULT_SETTINGS }; - } -} - -export function writeDesktopSettings(settings: DesktopSettings): void { - const filePath = resolveDesktopSettingsPath(); - mkdirSync(dirname(filePath), { recursive: true }); - // Avoid leaving torn settings if the process exits mid-write. - const tempPath = `${filePath}.${process.pid}.tmp`; - writeFileSync(tempPath, `${JSON.stringify(settings, null, 2)}\n`, "utf8"); - renameSync(tempPath, filePath); -} - -export function setCloudSessionsEnabled(enabled: boolean): DesktopSettings { - const next = { ...readDesktopSettings(), cloudSessionsEnabled: enabled }; - writeDesktopSettings(next); - return next; -} diff --git a/apps/examples/desktop-app/sidecar/diagnostics.ts b/apps/examples/desktop-app/sidecar/diagnostics.ts index 0595aa9484..3226878b30 100644 --- a/apps/examples/desktop-app/sidecar/diagnostics.ts +++ b/apps/examples/desktop-app/sidecar/diagnostics.ts @@ -11,7 +11,6 @@ import { arch, homedir, platform, release } from "node:os"; import { join } from "node:path"; import { readGlobalSettings, resolveClineDataDir } from "@cline/core"; import packageJson from "../package.json"; -import { readDesktopSettings } from "./desktop-settings"; import { readSessionManifest } from "./paths"; /** How much of each log file (from the end) is included in the report. */ @@ -88,7 +87,6 @@ export function buildDiagnosticsReport( }, dataDir, globalSettings: readGlobalSettings(), - desktopSettings: readDesktopSettings(), }; let text = `${JSON.stringify(report, null, 2)}\n`; diff --git a/apps/examples/desktop-app/sidecar/feature-flags.test.ts b/apps/examples/desktop-app/sidecar/feature-flags.test.ts index 82b5cc2a56..119325a172 100644 --- a/apps/examples/desktop-app/sidecar/feature-flags.test.ts +++ b/apps/examples/desktop-app/sidecar/feature-flags.test.ts @@ -337,45 +337,32 @@ describe("cloud agents gate", () => { delete process.env.CLINE_CODE_CLOUD_AGENTS; }); - it("is unavailable and disabled while the rollout flag is off", async () => { - const { isCloudAgentsAvailable, isCloudAgentsEnabled } = await import( - "./feature-flags" - ); - expect(isCloudAgentsAvailable()).toBe(false); + it("is disabled while the rollout flag is off", async () => { + const { isCloudAgentsEnabled } = await import("./feature-flags"); expect(isCloudAgentsEnabled()).toBe(false); }); it("does not enable a boolean rollout from a truthy variant payload", async () => { - const { isCloudAgentsAvailable } = await import("./feature-flags"); + const { isCloudAgentsEnabled } = await import("./feature-flags"); mocks.getFlagPayload.mockReturnValue("control"); - expect(isCloudAgentsAvailable()).toBe(false); + expect(isCloudAgentsEnabled()).toBe(false); expect(mocks.getBooleanFlagEnabled).toHaveBeenCalledWith( "code-cloud-agents", ); }); - it("needs both the rollout flag and the user's opt-in to enable", async () => { - const { isCloudAgentsEnabled, isCloudAgentsAvailable } = await import( - "./feature-flags" - ); - const { setCloudSessionsEnabled } = await import("./desktop-settings"); + it("is enabled by the rollout flag alone", async () => { + const { isCloudAgentsEnabled } = await import("./feature-flags"); mocks.getBooleanFlagEnabled.mockImplementation( (flag: unknown) => flag === "code-cloud-agents", ); - expect(isCloudAgentsAvailable()).toBe(true); - setCloudSessionsEnabled(false); - expect(isCloudAgentsEnabled()).toBe(false); - setCloudSessionsEnabled(true); expect(isCloudAgentsEnabled()).toBe(true); }); it("lets the env override force the gate in both directions", async () => { - const { isCloudAgentsEnabled, isCloudAgentsAvailable } = await import( - "./feature-flags" - ); + const { isCloudAgentsEnabled } = await import("./feature-flags"); mocks.getFlagPayload.mockReturnValue(undefined); process.env.CLINE_CODE_CLOUD_AGENTS = "1"; - expect(isCloudAgentsAvailable()).toBe(true); expect(isCloudAgentsEnabled()).toBe(true); process.env.CLINE_CODE_CLOUD_AGENTS = "0"; expect(isCloudAgentsEnabled()).toBe(false); diff --git a/apps/examples/desktop-app/sidecar/feature-flags.ts b/apps/examples/desktop-app/sidecar/feature-flags.ts index 298724eddd..47eaeba7af 100644 --- a/apps/examples/desktop-app/sidecar/feature-flags.ts +++ b/apps/examples/desktop-app/sidecar/feature-flags.ts @@ -22,7 +22,6 @@ import { } from "@cline/core/services/feature-flags/posthog"; import { FeatureFlag as SharedFeatureFlag } from "@cline/shared"; import { resolveClineDataDir } from "@cline/shared/storage"; -import { readDesktopSettings } from "./desktop-settings"; const FEATURE_FLAG_CODE_CLOUD_AGENTS = SharedFeatureFlag.CODE_CLOUD_AGENTS; @@ -282,8 +281,8 @@ export function readCloudAgentsEnvOverride(): boolean | undefined { return undefined; } -/** Whether the rollout makes cloud sessions available to this install. */ -export function isCloudAgentsAvailable(options?: { +/** Whether the rollout enables cloud sessions for this install. */ +export function isCloudAgentsEnabled(options?: { logger?: BasicLogger; telemetry?: ITelemetryService; }): boolean { @@ -293,14 +292,3 @@ export function isCloudAgentsAvailable(options?: { FEATURE_FLAG_CODE_CLOUD_AGENTS, ); } - -/** Whether cloud sessions are both available and enabled by the user. */ -export function isCloudAgentsEnabled(options?: { - logger?: BasicLogger; - telemetry?: ITelemetryService; -}): boolean { - const override = readCloudAgentsEnvOverride(); - if (override !== undefined) return override; - if (!isCloudAgentsAvailable(options)) return false; - return readDesktopSettings().cloudSessionsEnabled; -} diff --git a/apps/examples/desktop-app/webview/app/page.tsx b/apps/examples/desktop-app/webview/app/page.tsx index ac957758ca..9406775719 100644 --- a/apps/examples/desktop-app/webview/app/page.tsx +++ b/apps/examples/desktop-app/webview/app/page.tsx @@ -1276,20 +1276,8 @@ function ChatThreadPane({ }); }; fetchFlags(); - // Reflect Settings changes without restarting or switching accounts. - const unsubscribe = desktopClient.subscribe( - "feature_flags_changed", - (payload) => { - if (!cancelled) { - setCloudAgentsFlagEnabled( - Boolean((payload as { cloudAgents?: boolean })?.cloudAgents), - ); - } - }, - ); return () => { cancelled = true; - unsubscribe(); if (retryTimer !== undefined) window.clearTimeout(retryTimer); }; }, [accountUserId]); diff --git a/apps/examples/desktop-app/webview/components/views/settings/settings-view.test.tsx b/apps/examples/desktop-app/webview/components/views/settings/settings-view.test.tsx index d694502f55..788d51f366 100644 --- a/apps/examples/desktop-app/webview/components/views/settings/settings-view.test.tsx +++ b/apps/examples/desktop-app/webview/components/views/settings/settings-view.test.tsx @@ -112,52 +112,3 @@ describe("SettingsView font size", () => { expect(increaseButton?.disabled).toBe(true); }); }); - -describe("SettingsView cloud sessions rollout", () => { - it.each([ - { - caseName: "the rollout explicitly enables it", - featureFlags: { cloudAgents: false, cloudAgentsAvailable: true }, - visible: true, - }, - { - caseName: "feature flags are unavailable", - featureFlags: new Error("feature flags unavailable"), - visible: false, - }, - ])("shows the preview setting only when $caseName", async ({ - featureFlags, - visible, - }) => { - invoke.mockImplementation(async (command: string) => { - if (command === "get_feature_flags") { - if (featureFlags instanceof Error) throw featureFlags; - return featureFlags; - } - if (command === "get_desktop_settings") { - return { cloudSessionsEnabled: false }; - } - return { - telemetryOptOut: false, - autoUpdateEnabled: true, - }; - }); - - await act(async () => { - root.render( - , - ); - }); - await vi.waitFor(() => - expect( - container.querySelector( - '[role="switch"][aria-label="Cloud sessions"]', - ) !== null, - ).toBe(visible), - ); - }); -}); diff --git a/apps/examples/desktop-app/webview/components/views/settings/settings-view.tsx b/apps/examples/desktop-app/webview/components/views/settings/settings-view.tsx index c9c6f05cff..333f3d9027 100644 --- a/apps/examples/desktop-app/webview/components/views/settings/settings-view.tsx +++ b/apps/examples/desktop-app/webview/components/views/settings/settings-view.tsx @@ -699,32 +699,6 @@ function GeneralSettingsContent({ const [autoUpdateLoading, setAutoUpdateLoading] = useState(true); const [autoUpdateSaving, setAutoUpdateSaving] = useState(false); const [autoUpdateError, setAutoUpdateError] = useState(null); - const [cloudSessionsEnabled, setCloudSessionsEnabled] = useState(false); - const [cloudSessionsLoading, setCloudSessionsLoading] = useState(true); - const [cloudSessionsSaving, setCloudSessionsSaving] = useState(false); - const [cloudSessionsError, setCloudSessionsError] = useState( - null, - ); - // The environment override can differ from the stored opt-in. - const [cloudSessionsEffective, setCloudSessionsEffective] = useState< - boolean | null - >(null); - // Keep the preview hidden until the rollout service explicitly enables it. - const [cloudSessionsAvailable, setCloudSessionsAvailable] = useState(false); - - const refreshCloudSessionsEffective = useCallback(async () => { - try { - const flags = await desktopClient.invoke<{ - cloudAgents?: boolean; - cloudAgentsAvailable?: boolean; - }>("get_feature_flags"); - setCloudSessionsEffective(Boolean(flags.cloudAgents)); - setCloudSessionsAvailable(flags.cloudAgentsAvailable === true); - } catch { - setCloudSessionsEffective(null); - setCloudSessionsAvailable(false); - } - }, []); useEffect(() => setAppIconLocation(appIconSurface(navigator.userAgent)), []); useEffect(() => subscribeToAppFontSize(setFontSize), []); @@ -734,45 +708,21 @@ function GeneralSettingsContent({ setTelemetryError(null); setAutoUpdateLoading(true); setAutoUpdateError(null); - setCloudSessionsLoading(true); - setCloudSessionsError(null); - await Promise.all([ - (async () => { - try { - const settings = await desktopClient.invoke( - "get_global_settings", - ); - setTelemetryOptOut(settings.telemetryOptOut); - setAutoUpdateEnabled(settings.autoUpdateEnabled); - } catch (error) { - const message = - error instanceof Error ? error.message : String(error); - setTelemetryError(message); - setAutoUpdateError(message); - } finally { - setTelemetryLoading(false); - setAutoUpdateLoading(false); - } - })(), - (async () => { - try { - const desktopSettings = await desktopClient.invoke<{ - cloudSessionsEnabled: boolean; - }>("get_desktop_settings"); - setCloudSessionsEnabled( - Boolean(desktopSettings.cloudSessionsEnabled), - ); - } catch (error) { - setCloudSessionsError( - error instanceof Error ? error.message : String(error), - ); - } finally { - setCloudSessionsLoading(false); - } - })(), - refreshCloudSessionsEffective(), - ]); - }, [refreshCloudSessionsEffective]); + try { + const settings = await desktopClient.invoke( + "get_global_settings", + ); + setTelemetryOptOut(settings.telemetryOptOut); + setAutoUpdateEnabled(settings.autoUpdateEnabled); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + setTelemetryError(message); + setAutoUpdateError(message); + } finally { + setTelemetryLoading(false); + setAutoUpdateLoading(false); + } + }, []); useEffect(() => { const timeoutId = window.setTimeout(() => { @@ -821,26 +771,6 @@ function GeneralSettingsContent({ } }; - const updateCloudSessionsEnabled = async (nextValue: boolean) => { - const previousValue = cloudSessionsEnabled; - setCloudSessionsEnabled(nextValue); - setCloudSessionsSaving(true); - setCloudSessionsError(null); - try { - const settings = await desktopClient.invoke<{ - cloudSessionsEnabled: boolean; - }>("set_cloud_sessions_enabled", { cloud_sessions_enabled: nextValue }); - setCloudSessionsEnabled(Boolean(settings.cloudSessionsEnabled)); - await refreshCloudSessionsEffective(); - } catch (error) { - const message = error instanceof Error ? error.message : String(error); - setCloudSessionsEnabled(previousValue); - setCloudSessionsError(message); - } finally { - setCloudSessionsSaving(false); - } - }; - const updateTheme = (darkModeEnabled: boolean) => { const nextTheme = darkModeEnabled ? "dark" : "light"; setTheme(setStoredHubTheme(nextTheme)); @@ -1051,46 +981,6 @@ function GeneralSettingsContent({ onCheckedChange={(checked) => void updateAutoUpdateEnabled(checked)} />
    - {cloudSessionsAvailable ? ( -
    -
    -

    - Cloud sessions - - Preview - -

    -

    - Run Cline on your GitHub repositories in secure cloud sandboxes. - Adds a Cloud option to the new-session composer. Requires a - Cline account with GitHub connected. -

    - {cloudSessionsError ? ( -

    - Failed to update cloud sessions setting: {cloudSessionsError} -

    - ) : null} - {cloudSessionsEffective !== null && - !cloudSessionsLoading && - cloudSessionsEffective !== cloudSessionsEnabled ? ( -

    - Cloud sessions are currently{" "} - {cloudSessionsEffective ? "enabled" : "disabled"} by the - CLINE_CODE_CLOUD_AGENTS environment override, which takes - precedence over this setting. -

    - ) : null} -
    - - void updateCloudSessionsEnabled(checked) - } - /> -
    - ) : null}

    Telemetry

    diff --git a/sdk/packages/shared/src/feature-flags.ts b/sdk/packages/shared/src/feature-flags.ts index afc01524f9..4efa7adca7 100644 --- a/sdk/packages/shared/src/feature-flags.ts +++ b/sdk/packages/shared/src/feature-flags.ts @@ -1,7 +1,7 @@ export const FeatureFlag = { /** Enables ClinePass provider/model list exposure in supported clients. */ CLINE_PASS: "ext-cline-pass", - /** Makes the Cloud sessions opt-in visible in the Cline Code desktop app. */ + /** Enables Cloud sessions in the Cline Code desktop app. */ CODE_CLOUD_AGENTS: "code-cloud-agents", /** Shows the GitHub integration step in the desktop app */ CODE_ONBOARDING_GITHUB: "code-onboarding-github", From c8f808b51e2ae80ac7fb7b41c6a780a0bdbdec89 Mon Sep 17 00:00:00 2001 From: Saoud Rizwan <7799382+saoudrizwan@users.noreply.github.com> Date: Thu, 15 Oct 2026 13:25:30 -0700 Subject: [PATCH 21/21] test(core): expect the OpenCode Go /inference/go/v1 endpoint in routing tests (#15051) The catalog refresh in #15047 moved OpenCode Go from /zen/go/v1 to /inference/go/v1 and updated the llms tests, but the core routing test still asserted the old URL, failing sdk-test on main (and therefore sdk-publish).