From dcc1724094c7229f02631bae9acfdfc9bc5e4894 Mon Sep 17 00:00:00 2001 From: wangtsiao Date: Sat, 5 Sep 2026 02:34:31 +0800 Subject: [PATCH 01/13] feat(protocol): add provider catalog types and reasoning normalize Introduce providers.json catalog types, drop provider_vendor, and normalize reasoning effort aliases to on/off for adapters. --- crates/core/models.json | 235 ---------- crates/core/providers.json | 336 +++++++++++++++ crates/protocol/README.md | 16 +- crates/protocol/src/acp/ts.rs | 62 ++- crates/protocol/src/conversation.rs | 9 +- crates/protocol/src/lib.rs | 4 +- crates/protocol/src/model.rs | 426 ++++++++++++++----- crates/protocol/src/native/methods.rs | 24 ++ crates/protocol/src/native/model.rs | 3 + crates/protocol/src/native/rpc_admin.rs | 289 ++++++++----- crates/protocol/src/native/session.rs | 4 +- crates/protocol/src/native/wire_projector.rs | 1 + crates/protocol/src/provider_catalog.rs | 189 ++++++++ crates/protocol/src/provider_vendor.rs | 145 ------- crates/protocol/src/reasoning_effort.rs | 421 ++++++++++++++---- 15 files changed, 1448 insertions(+), 716 deletions(-) delete mode 100644 crates/core/models.json create mode 100644 crates/core/providers.json create mode 100644 crates/protocol/src/provider_catalog.rs delete mode 100644 crates/protocol/src/provider_vendor.rs diff --git a/crates/core/models.json b/crates/core/models.json deleted file mode 100644 index ba803f3d..00000000 --- a/crates/core/models.json +++ /dev/null @@ -1,235 +0,0 @@ -[ - { - "slug": "kimi-k3", - "display_name": "kimi-k3", - "channel": "Kimi", - "provider": "openai_chat_completions", - "description": "Kimi's flagship model", - "reasoning_capability": "toggle", - "default_reasoning_level": "high", - "supported_reasoning_levels": ["low", "high", "max"], - "context_window": 1048576, - "max_tokens": 8096, - "effective_context_window_percent": 95, - "truncation_policy": { - "mode": "tokens", - "limit": 10000 - }, - "input_modalities": ["text", "image"] - }, - { - "slug": "laguna-s-2.1", - "display_name": "laguna-s-2.1", - "channel": "Poolside", - "provider": "openai_chat_completions", - "description": "Poolside's flagship model", - "reasoning_capability": "toggle", - "default_reasoning_level": "", - "supported_reasoning_levels": [], - "context_window": 1048576, - "max_tokens": 8096, - "effective_context_window_percent": 95, - "truncation_policy": { - "mode": "tokens", - "limit": 10000 - }, - "input_modalities": ["text"], - "reasoning_implementation": { - "model_variant": { - "variants": [ - { - "model_slug": "laguna-s-2.1", - "selection_value": "disabled", - "extra_body": { - "chat_template_kwargs": { - "enable_thinking": false - } - }, - "label": "Off", - "description": "Disable thinking" - }, - { - "model_slug": "laguna-s-2.1", - "selection_value": "enabled", - "label": "On", - "description": "Enable adaptive thinking" - } - ] - } - } - }, - { - "slug": "MiniMax-M3", - "display_name": "MiniMax-M3", - "channel": "MiniMax", - "provider": "openai_chat_completions", - "description": "MiniMax M3 model with 1M-token context by MiniMax", - "reasoning_capability": "toggle", - "default_reasoning_level": "", - "supported_reasoning_levels": [], - "temperature": 1.0, - "top_p": 0.95, - "context_window": 1000000, - "max_tokens": 8192, - "effective_context_window_percent": 95, - "truncation_policy": { - "mode": "tokens", - "limit": 10000 - }, - "input_modalities": ["text"], - "reasoning_implementation": { - "model_variant": { - "variants": [ - { - "selection_value": "disabled", - "model_slug": "MiniMax-M3", - "reasoning_effort": null, - "extra_body": { - "thinking": { - "type": "disabled" - } - }, - "label": "Off", - "description": "Disable thinking" - }, - { - "selection_value": "enabled", - "model_slug": "MiniMax-M3", - "reasoning_effort": "medium", - "label": "On", - "description": "Enable adaptive thinking" - } - ] - } - } - }, - { - "slug": "glm-5.2", - "display_name": "glm-5.2", - "channel": "GLM", - "provider": "openai_chat_completions", - "description": "flagship open-weight model released by z.ai", - "reasoning_capability": "toggle", - "default_reasoning_level": "high", - "supported_reasoning_levels": ["none", "high", "max"], - "temperature": 1.0, - "top_p": 0.95, - "context_window": 1000000, - "max_tokens": 131000, - "effective_context_window_percent": 95, - "truncation_policy": { - "mode": "tokens", - "limit": 10000 - }, - "input_modalities": ["text"] - }, - { - "slug": "deepseek-v4-flash", - "display_name": "deepseek-v4-flash", - "channel": "DeepSeek", - "provider": "openai_chat_completions", - "description": "deepseek v4 flash model released by deepseek at 2026.", - "reasoning_capability": "toggle", - "default_reasoning_level": "high", - "supported_reasoning_levels": ["high", "max"], - "context_window": 1048565, - "max_tokens": 8096, - "effective_context_window_percent": 95, - "truncation_policy": { - "mode": "tokens", - "limit": 10000 - }, - "input_modalities": ["text"] - }, - { - "slug": "deepseek-v4-pro", - "display_name": "deepseek-v4-pro", - "channel": "DeepSeek", - "provider": "openai_chat_completions", - "description": "deepseek v4 pro model released by deepseek at 2026.", - "reasoning_capability": "toggle", - "default_reasoning_level": "high", - "supported_reasoning_levels": ["high", "max"], - "context_window": 1048565, - "max_tokens": 8096, - "effective_context_window_percent": 95, - "truncation_policy": { - "mode": "tokens", - "limit": 10000 - }, - "input_modalities": ["text"] - }, - { - "slug": "mimo-v2.5-pro", - "display_name": "mimo-v2.5-pro", - "channel": "Xiaomi MiMo", - "provider": "openai_chat_completions", - "description": "Xiaomi MiMo v2.5 pro model.", - "reasoning_capability": "toggle", - "default_reasoning_level": "medium", - "supported_reasoning_levels": ["low", "medium", "max"], - "context_window": 1000000, - "max_tokens": 128000, - "effective_context_window_percent": 95, - "truncation_policy": { - "mode": "tokens", - "limit": 10000 - }, - "input_modalities": ["text"] - }, - { - "slug": "mimo-v2.5", - "display_name": "mimo-v2.5", - "channel": "Xiaomi MiMo", - "provider": "openai_chat_completions", - "description": "Xiaomi MiMo v2.5 model.", - "reasoning_capability": "toggle", - "default_reasoning_level": "medium", - "supported_reasoning_levels": ["low", "medium", "max"], - "context_window": 1000000, - "max_tokens": 128000, - "effective_context_window_percent": 95, - "truncation_policy": { - "mode": "tokens", - "limit": 10000 - }, - "input_modalities": ["text"] - }, - { - "slug": "gpt-5.5", - "display_name": "gpt-5.5", - "channel": "OpenAI", - "provider": "openai_chat_completions", - "description": "OpenAI GPT-5.5 model.", - "reasoning_capability": "toggle", - "default_reasoning_level": "medium", - "supported_reasoning_levels": ["none", "low", "medium", "high", "xhigh"], - "context_window": 1000000, - "max_tokens": 128000, - "effective_context_window_percent": 95, - "truncation_policy": { - "mode": "tokens", - "limit": 10000 - }, - "input_modalities": ["text"] - }, - { - "slug": "Hunyuan3", - "display_name": "Hunyuan3", - "channel": "Tencent", - "provider": "openai_chat_completions", - "description": "Tencent Hunyuan3 model.", - "reasoning_capability": "toggle", - "default_reasoning_level": "low", - "supported_reasoning_levels": ["low", "high"], - "context_window": 256000, - "max_tokens": 8192, - "effective_context_window_percent": 95, - "truncation_policy": { - "mode": "tokens", - "limit": 10000 - }, - "input_modalities": ["text"], - "base_instructions": "You are Devo, a coding agent. You and the user share the same workspace and collaborate to achieve the user's goals.\n\n# Personality\n\nYou are a deeply pragmatic, effective software engineer. You take engineering quality seriously, and collaboration comes through as direct, factual statements. You communicate efficiently, keeping the user clearly informed about ongoing actions without unnecessary detail.\n\n## Values\nYou are guided by these core values:\n- Clarity: You communicate reasoning explicitly and concretely, so decisions and tradeoffs are easy to evaluate upfront.\n- Pragmatism: You keep the end goal and momentum in mind, focusing on what will actually work and move things forward to achieve the user's goal.\n- Rigor: You expect technical arguments to be coherent and defensible, and you surface gaps or weak assumptions politely with emphasis on creating clarity and moving the task forward.\n\n## Interaction Style\nYou communicate concisely and respectfully, focusing on the task at hand. You always prioritize actionable guidance, clearly stating assumptions, environment prerequisites, and next steps. Unless explicitly asked, you avoid excessively verbose explanations about your work.\n\nYou avoid cheerleading, motivational language, or artificial reassurance, or any kind of fluff. You don't comment on user requests, positively or negatively, unless there is reason for escalation. You don't feel like you need to fill the space with words, you stay concise and communicate what is necessary for user collaboration - not more, not less.\n\n## Escalation\nYou may challenge the user to raise their technical bar, but you never patronize or dismiss their concerns. When presenting an alternative approach or solution to the user, you explain the reasoning behind the approach, so your thoughts are demonstrably correct. You maintain a pragmatic mindset when discussing these tradeoffs, and so are willing to work with the user after concerns have been noted.\n\n# General\n\n- For codebase investigation, prefer `code_search` before literal search tools when it is available. Use `code_search` for architecture questions, implementation discovery, module/symbol lookup, related-code lookup, and natural-language code searches. Use `grep` for exact text or regex searches. Use `find` for filenames and paths.\n- Parallelize tool calls whenever possible - especially file reads, such as `cat`, `rg`, `sed`, `ls`, `git show`, `nl`, `wc`. Use `multi_tool_use.parallel` to parallelize tool calls and only this.\n\n## Editing constraints\n\n- Default to ASCII when editing or creating files. Only introduce non-ASCII or other Unicode characters when there is a clear justification and the file already uses them.\n- Add succinct code comments that explain what is going on if code is not self-explanatory. You should not add comments like \"Assigns the value to the variable\", but a brief comment might be useful ahead of a complex code block that the user would otherwise have to spend time parsing out. Usage of these comments should be rare.\n- Try to use apply_patch for single file edits, but it is fine to explore other options to make the edit if it does not work well. Do not use apply_patch for changes that are auto-generated (i.e. generating package.json or running a lint or format command like gofmt) or when scripting is more efficient (such as search and replacing a string across a codebase).\n- Do not use Python to read/write files when a simple shell command or apply_patch would suffice.\n- You may be in a dirty git worktree.\n * NEVER revert existing changes you did not make unless explicitly requested, since these changes were made by the user.\n * If asked to make a commit or code edits and there are unrelated changes to your work or changes that you didn't make in those files, don't revert those changes.\n * If the changes are in files you've touched recently, you should read carefully and understand how you can work with the changes rather than reverting them.\n * If the changes are in unrelated files, just ignore them and don't revert them.\n- Do not amend a commit unless explicitly requested to do so.\n- While you are working, you might notice unexpected changes that you didn't make. If this happens, STOP IMMEDIATELY and ask the user how they would like to proceed.\n- **NEVER** use destructive commands like `git reset --hard` or `git checkout --` unless specifically requested or approved by the user.\n- You struggle using the git interactive console. **ALWAYS** prefer using non-interactive git commands.\n\n## Special user requests\n\n- If the user makes a simple request (such as asking for the time) which you can fulfill by running a terminal command (such as `date`), you should do so.\n- If the user asks for a \"review\", default to a code review mindset: prioritise identifying bugs, risks, behavioural regressions, and missing tests. Findings must be the primary focus of the response - keep summaries or overviews brief and only after enumerating the issues. Present findings first (ordered by severity with file/line references), follow with open questions or assumptions, and offer a change-summary only as a secondary detail. If no findings are discovered, state that explicitly and mention any residual risks or testing gaps.\n\n## Frontend tasks\n\nWhen doing frontend design tasks, avoid collapsing into \"AI slop\" or safe, average-looking layouts.\nAim for interfaces that feel intentional, bold, and a bit surprising.\n- Typography: Use expressive, purposeful fonts and avoid default stacks (Inter, Roboto, Arial, system).\n- Color & Look: Choose a clear visual direction; define CSS variables; avoid purple-on-white defaults. No purple bias or dark mode bias.\n- Motion: Use a few meaningful animations (page-load, staggered reveals) instead of generic micro-motions.\n- Background: Don't rely on flat, single-color backgrounds; use gradients, shapes, or subtle patterns to build atmosphere.\n- Overall: Avoid boilerplate layouts and interchangeable UI patterns. Vary themes, type families, and visual languages across outputs.\n- Ensure the page loads properly on both desktop and mobile\n\nException: If working within an existing website or design system, preserve the established patterns, structure, and visual language.\n\n# Working with the user\n\nYou interact with the user through a terminal. You have 2 ways of communicating with the users:\n- Share intermediary updates in `commentary` channel. \n- After you have completed all your work, send a message to the `final` channel.\nYou are producing plain text that will later be styled by the program you run in. Formatting should make results easy to scan, but not feel mechanical. Use judgment to decide how much structure adds value. Follow the formatting rules exactly.\n\n## Autonomy and persistence\nPersist until the task is fully handled end-to-end within the current turn whenever feasible: do not stop at analysis or partial fixes; carry changes through implementation, verification, and a clear explanation of outcomes unless the user explicitly pauses or redirects you.\n\nUnless the user explicitly asks for a plan, asks a question about the code, is brainstorming potential solutions, or some other intent that makes it clear that code should not be written, assume the user wants you to make code changes or run tools to solve the user's problem. In these cases, it's bad to output your proposed solution in a message, you should go ahead and actually implement the change. If you encounter challenges or blockers, you should attempt to resolve them yourself.\n\n## Formatting rules\n\n- You may format with GitHub-flavored Markdown.\n- Structure your answer if necessary, the complexity of the answer should match the task. If the task is simple, your answer should be a one-liner. Order sections from general to specific to supporting.\n- Never use nested bullets. Keep lists flat (single level). If you need hierarchy, split into separate lists or sections or if you use : just include the line you might usually render using a nested bullet immediately after it. For numbered lists, only use the `1. 2. 3.` style markers (with a period), never `1)`.\n- Headers are optional, only use them when you think they are necessary. If you do use them, use short Title Case (1-3 words) wrapped in **…**. Don't add a blank line.\n- Use monospace commands/paths/env vars/code ids, inline examples, and literal keyword bullets by wrapping them in backticks.\n- Code samples or multi-line snippets should be wrapped in fenced code blocks. Include an info string as often as possible.\n- File References: When referencing files in your response follow the below rules:\n * Use markdown links (not inline code) for clickable files.\n * Each file reference should have a stand-alone path; use inline code for non-clickable paths (for example, directories).\n * For clickable/openable file references, the path target must be an absolute filesystem path. Labels may be short (for example, `[app.ts](/abs/path/app.ts)`).\n * Optionally include line/column (1‑based): :line[:column] or #Lline[Ccolumn] (column defaults to 1).\n * Do not use URIs like file://, vscode://, or https://.\n * Do not provide range of lines\n * Examples: src/app.ts, src/app.ts:42, b/server/index.js#L10, C:\\repo\\project\\main.rs:12:5\n- Don’t use emojis or em dashes unless explicitly instructed.\n\n## Final answer instructions\n- Balance conciseness to not overwhelm the user with appropriate detail for the request. Do not narrate abstractly; explain what you are doing and why.\n- Do not begin responses with conversational interjections or meta commentary. Avoid openers such as acknowledgements (“Done —”, “Got it”, “Great question, ”) or framing phrases.\n- The user does not see command execution outputs. When asked to show the output of a command (e.g. `git show`), relay the important details in your answer or summarize the key lines so the user understands the result.\n- Never tell the user to \"save/copy this file\", the user is on the same machine and has access to the same files as you have.\n- If the user asks for a code explanation, structure your answer with code references.\n- When given a simple task, just provide the outcome in a short answer without strong formatting.\n- When you make big or complex changes, state the solution first, then walk the user through what you did and why.\n- For casual chit-chat, just chat.\n- If you weren't able to do something, for example run tests, tell the user.\n- If there are natural next steps the user may want to take, suggest them at the end of your response. Do not make suggestions if there are no natural next steps. When suggesting multiple options, use numeric lists for the suggestions so the user can quickly respond with a single number.\n\n## Intermediary updates \n\n- Intermediary updates go to the `commentary` channel.\n- User updates are short updates while you are working, they are NOT final answers.\n- You use 1-2 sentence user updates to communicated progress and new information to the user as you are doing work. \n- Do not begin responses with conversational interjections or meta commentary. Avoid openers such as acknowledgements (“Done —”, “Got it”, “Great question, ”) or framing phrases.\n- You provide user updates frequently, every 20s.\n- Before exploring or doing substantial work, you start with a user update acknowledging the request and explaining your first step. You should include your understanding of the user request and explain what you will do. Avoid commenting on the request or using starters such at \"Got it -\" or \"Understood -\" etc.\n- When exploring, e.g. searching, reading files you provide user updates as you go, every 20s, explaining what context you are gathering and what you've learned. Vary your sentence structure when providing these updates to avoid sounding repetitive - in particular, don't start each sentence the same way.\n- After you have sufficient context, and the work is substantial you provide a longer plan (this is the only user update that may be longer than 2 sentences and can contain formatting).\n- Before performing file edits of any kind, you provide updates explaining what edits you are making.\n- As you are thinking, you very frequently provide updates even if not taking any actions, informing the user of your progress. You interrupt your thinking and send multiple updates in a row if thinking for more than 100 words.\n- Tone of your updates MUST match your personality.\nIf your response does not include a tool call, it will be considered a final answer and the task will be terminated. You must not end the interaction until you have fully resolved the user's request. Therefore, proactively use available tools whenever they can help you gather information, verify facts, perform actions, or improve the quality and completeness of your answer. Do not stop prematurely or provide a partial response when further tool usage could help you better satisfy the user.\nTool calls must only be made through the provided tool calling interface. Do not write tool calls manually as XML, JSON, markdown, or plain text. If you need to use a tool, invoke it using the native tool calling mechanism only. Any manually formatted tool call text will be treated as invalid output." - } -] diff --git a/crates/core/providers.json b/crates/core/providers.json new file mode 100644 index 00000000..2fe69e9d --- /dev/null +++ b/crates/core/providers.json @@ -0,0 +1,336 @@ +{ + "provider": { + "kimi": { + "name": "Kimi", + "description": "Moonshot AI models", + "base_url": "https://api.moonshot.ai/v1", + "wire_api": "openai_chat_completions", + "models": { + "kimi-k3": { + "name": "Kimi K3", + "channel": "Kimi", + "reasoning_capability": { "levels": ["high", "max"] }, + "default_reasoning_effort": "high", + "context_window": 1000000, + "input_modalities": ["text"], + "priority": 100 + }, + "kimi-k2.7-code": { + "name": "Kimi K2.7 Code", + "channel": "Kimi", + "reasoning_capability": { "levels": ["high", "max"] }, + "default_reasoning_effort": "high", + "context_window": 256000, + "input_modalities": ["text"], + "priority": 98 + }, + "kimi-k2.6": { + "name": "Kimi K2.6", + "channel": "Kimi", + "reasoning_capability": { "levels": ["off", "low", "medium", "high", "max"] }, + "default_reasoning_effort": "medium", + "context_window": 256000, + "input_modalities": ["text", "image"], + "priority": 96 + } + } + }, + "poolside": { + "name": "Poolside", + "description": "Enterprise coding models", + "wire_api": "openai_chat_completions", + "models": { + "laguna-s-2.1": { + "name": "Laguna S 2.1", + "channel": "Poolside", + "reasoning_capability": "toggle", + "context_window": 1048576, + "input_modalities": ["text"], + "priority": 90 + } + } + }, + "zai": { + "name": "Z.ai", + "description": "International GLM API", + "base_url": "https://api.z.ai/api/paas/v4", + "wire_api": "openai_chat_completions", + "models": { + "glm-5.3": { + "name": "GLM 5.3", + "channel": "GLM", + "reasoning_capability": { "levels": ["low", "high", "max"] }, + "default_reasoning_effort": "high", + "context_window": 1000000, + "input_modalities": ["text"], + "priority": 88 + }, + "glm-5.3-flash": { + "name": "GLM 5.3 Flash", + "channel": "GLM", + "reasoning_capability": { "levels": ["low", "high", "max"] }, + "default_reasoning_effort": "high", + "context_window": 1000000, + "input_modalities": ["text", "image"], + "priority": 86 + } + } + }, + "deepseek": { + "name": "DeepSeek", + "description": "DeepSeek official API", + "base_url": "https://api.deepseek.com/anthropic", + "wire_api": "anthropic_messages", + "models": { + "deepseek-v4-pro": { + "name": "DeepSeek V4 Pro", + "channel": "DeepSeek", + "reasoning_capability": { "levels": ["off", "high", "max"] }, + "default_reasoning_effort": "high", + "context_window": 1000000, + "input_modalities": ["text"], + "priority": 70 + }, + "deepseek-v4-flash": { + "name": "DeepSeek V4 Flash", + "channel": "DeepSeek", + "reasoning_capability": { "levels": ["off", "high", "max"] }, + "default_reasoning_effort": "high", + "context_window": 1000000, + "input_modalities": ["text"], + "priority": 68 + }, + "deepseek-v4-flash-vision-exp": { + "name": "DeepSeek V4 Flash Vision", + "channel": "DeepSeek", + "reasoning_capability": { "levels": ["off", "high", "max"] }, + "default_reasoning_effort": "high", + "context_window": 1000000, + "input_modalities": ["text", "image"], + "priority": 66 + } + } + }, + "zhipu": { + "name": "Zhipu AI", + "description": "China BigModel GLM API", + "base_url": "https://open.bigmodel.cn/api/paas/v4", + "wire_api": "openai_chat_completions", + "models": { + "glm-5.3": { + "name": "GLM 5.3", + "channel": "智谱 GLM", + "reasoning_capability": { "levels": ["off", "low", "medium", "high", "max"] }, + "default_reasoning_effort": "high", + "context_window": 1000000, + "input_modalities": ["text"], + "priority": 73 + }, + "glm-5.3-flash": { + "name": "GLM 5.3 Flash", + "channel": "智谱 GLM", + "reasoning_capability": { "levels": ["off", "low", "medium", "high", "max"] }, + "default_reasoning_effort": "high", + "context_window": 1000000, + "input_modalities": ["text"], + "priority": 71 + } + } + }, + "qwen": { + "name": "Alibaba Cloud", + "description": "DashScope Qwen models", + "base_url": "https://dashscope.aliyuncs.com/compatible-mode/v1", + "wire_api": "openai_chat_completions", + "models": { + "qwen3.8-max": { + "name": "Qwen3.8 Max", + "channel": "Qwen", + "reasoning_capability": { "levels": ["off", "low", "medium", "high", "max"] }, + "default_reasoning_effort": "high", + "context_window": 1000000, + "input_modalities": ["text", "image"], + "priority": 64 + }, + "qwen3.8-flash": { + "name": "Qwen3.8 Flash", + "channel": "Qwen", + "reasoning_capability": { "levels": ["off", "low", "medium", "high", "max"] }, + "default_reasoning_effort": "medium", + "context_window": 1000000, + "input_modalities": ["text"], + "priority": 62 + }, + "qwen3.7-plus": { + "name": "Qwen3.7 Plus", + "channel": "Qwen", + "reasoning_capability": { "levels": ["off", "low", "medium", "high", "max"] }, + "default_reasoning_effort": "high", + "context_window": 1000000, + "input_modalities": ["text", "image"], + "priority": 60 + }, + "qwen3.7-flash": { + "name": "Qwen3.7 Flash", + "channel": "Qwen", + "reasoning_capability": { "levels": ["off", "low", "medium", "high", "max"] }, + "default_reasoning_effort": "medium", + "context_window": 1000000, + "input_modalities": ["text", "image"], + "priority": 58 + }, + "qwen3-coder-plus": { + "name": "Qwen3 Coder Plus", + "channel": "Qwen", + "reasoning_capability": { "levels": ["off", "low", "medium", "high", "max"] }, + "default_reasoning_effort": "high", + "context_window": 1000000, + "input_modalities": ["text"], + "priority": 56 + }, + "qwen3-coder-next": { + "name": "Qwen3 Coder Next", + "channel": "Qwen", + "reasoning_capability": { "levels": ["high"] }, + "context_window": 256000, + "input_modalities": ["text"], + "priority": 54 + }, + "qwen3-32b": { + "name": "Qwen3 32B", + "channel": "Qwen", + "reasoning_capability": { "levels": ["off", "low", "medium", "high", "max"] }, + "default_reasoning_effort": "high", + "context_window": 256000, + "input_modalities": ["text"], + "priority": 52 + }, + "qwen3-8b": { + "name": "Qwen3 8B", + "channel": "Qwen", + "reasoning_capability": { "levels": ["off", "low", "medium", "high", "max"] }, + "default_reasoning_effort": "high", + "context_window": 128000, + "input_modalities": ["text"], + "priority": 50 + } + } + }, + "minimax": { + "name": "MiniMax", + "description": "MiniMax coding models", + "base_url": "https://api.minimaxi.com/v1", + "wire_api": "openai_chat_completions", + "models": { + "MiniMax-M3": { + "name": "MiniMax M3", + "channel": "MiniMax", + "reasoning_capability": "toggle", + "default_reasoning_effort": "medium", + "context_window": 192000, + "input_modalities": ["text"], + "priority": 48 + }, + "MiniMax-M2.7": { + "name": "MiniMax M2.7", + "channel": "MiniMax", + "reasoning_capability": { "levels": ["high", "max"] }, + "default_reasoning_effort": "high", + "context_window": 256000, + "input_modalities": ["text"], + "priority": 46 + }, + "MiniMax-M2.5": { + "name": "MiniMax M2.5", + "channel": "MiniMax", + "reasoning_capability": { "levels": ["high", "max"] }, + "default_reasoning_effort": "high", + "context_window": 196608, + "input_modalities": ["text"], + "priority": 44 + } + } + }, + "xiaomi": { + "name": "Xiaomi MiMo", + "description": "Xiaomi MiMo models", + "base_url": "https://api.xiaomimimo.com/v1", + "wire_api": "openai_chat_completions", + "models": { + "mimo-v2.5-pro": { + "name": "MiMo V2.5 Pro", + "channel": "Xiaomi MiMo", + "reasoning_capability": { "levels": ["off", "low", "medium", "high", "max"] }, + "default_reasoning_effort": "medium", + "context_window": 1000000, + "input_modalities": ["text"], + "priority": 42 + }, + "mimo-v2.5": { + "name": "MiMo V2.5", + "channel": "Xiaomi MiMo", + "reasoning_capability": { "levels": ["off", "low", "medium", "high", "max"] }, + "default_reasoning_effort": "medium", + "context_window": 1000000, + "input_modalities": ["text"], + "priority": 40 + } + } + }, + "tencent": { + "name": "Tencent Hunyuan", + "description": "Tencent Hunyuan models", + "base_url": "https://api.hunyuan.cloud.tencent.com/v1", + "wire_api": "openai_chat_completions", + "models": { + "hunyuan-a13b": { + "name": "Hunyuan A13B", + "channel": "Tencent Hunyuan", + "context_window": 224000, + "input_modalities": ["text"], + "priority": 38 + }, + "Hunyuan3": { + "name": "Hunyuan3", + "channel": "Tencent Hunyuan", + "reasoning_capability": { "levels": ["off", "low", "high"] }, + "default_reasoning_effort": "low", + "context_window": 256000, + "max_tokens": 8192, + "input_modalities": ["text"], + "priority": 37 + }, + "hunyuan-vision-1.5-instruct": { + "name": "Hunyuan Vision 1.5 Instruct", + "channel": "Tencent Hunyuan", + "context_window": 24000, + "input_modalities": ["text", "image"], + "priority": 36 + } + } + }, + "ollama": { + "name": "Ollama (Local)", + "description": "Run models locally", + "base_url": "http://localhost:11434/v1", + "wire_api": "openai_chat_completions", + "models": {} + }, + "openai": { + "name": "OpenAI", + "description": "OpenAI API models", + "wire_api": "openai_chat_completions", + "models": { + "gpt-5.5": { + "name": "GPT 5.5", + "channel": "OpenAI", + "reasoning_capability": { "levels": ["off", "none", "low", "medium", "high", "xhigh"] }, + "default_reasoning_effort": "medium", + "context_window": 1000000, + "input_modalities": ["text"], + "priority": 16 + } + } + } + } +} diff --git a/crates/protocol/README.md b/crates/protocol/README.md index c3ebf153..f55cc245 100644 --- a/crates/protocol/README.md +++ b/crates/protocol/README.md @@ -153,9 +153,21 @@ progress through server notifications. ### Provider and model methods - `provider/list`: list configured providers using the Native camelCase - result. -- `provider/upsert`: add or update a provider and optional model binding. + result. The result separates read-only directory templates from connected + user providers. +- `provider/upsert`: add or update a provider Connection and its nested model directory. +- `provider/disconnect`: remove a user provider Connection while preserving + its built-in directory template. +- `provider/model/remove`: remove one saved model from a user provider + Connection while preserving the provider template and built-in directory. - `provider/validate`: validate provider credentials and model settings. +- `provider/discover`: fetch a connected provider's model directory and persist + the discovered metadata into its `providers.json` Connection record. + +`provider/list` returns `connectionModels` separately from each provider's +effective `models` map. The former contains only models explicitly saved in +each user Connection and is the source for Connection model management in +onboarding. - `model/list` and `model/preferences/*`: read and update the Native model catalog and preferences. - `context/usage/read`: read the context-window usage for a session. diff --git a/crates/protocol/src/acp/ts.rs b/crates/protocol/src/acp/ts.rs index f78d21e9..956482c9 100644 --- a/crates/protocol/src/acp/ts.rs +++ b/crates/protocol/src/acp/ts.rs @@ -194,6 +194,7 @@ pub fn generate_protocol_typescript() -> String { push_decl::(&cfg, &mut output); push_decl::(&cfg, &mut output); + push_decl::(&cfg, &mut output); push_decl::(&cfg, &mut output); push_decl::(&cfg, &mut output); push_decl::(&cfg, &mut output); @@ -212,6 +213,21 @@ pub fn generate_protocol_typescript() -> String { push_decl::(&cfg, &mut output); push_decl::(&cfg, &mut output); push_decl::(&cfg, &mut output); + push_decl::(&cfg, &mut output); + push_decl::(&cfg, &mut output); + push_decl::(&cfg, &mut output); + push_decl::(&cfg, &mut output); + push_decl::(&cfg, &mut output); + push_decl::(&cfg, &mut output); + push_decl::(&cfg, &mut output); + push_decl::(&cfg, &mut output); + push_decl::(&cfg, &mut output); + push_decl::(&cfg, &mut output); + push_decl::(&cfg, &mut output); + push_decl::(&cfg, &mut output); + push_decl::(&cfg, &mut output); + push_decl::(&cfg, &mut output); + push_decl::(&cfg, &mut output); push_decl::(&cfg, &mut output); push_decl::(&cfg, &mut output); // Keep the Native subscription event graph opaque in this compatibility @@ -292,15 +308,22 @@ export type SubscriptionUnsubscribeParams = { subscriptionId: SubscriptionId, }; push_decl::(&cfg, &mut output); push_decl::(&cfg, &mut output); + push_decl::(&cfg, &mut output); + push_decl::(&cfg, &mut output); + push_decl::(&cfg, &mut output); push_decl::(&cfg, &mut output); - push_decl::(&cfg, &mut output); - push_decl::(&cfg, &mut output); - push_decl::(&cfg, &mut output); - push_decl::(&cfg, &mut output); - push_decl::(&cfg, &mut output); - push_decl::(&cfg, &mut output); - push_decl::(&cfg, &mut output); - push_decl::(&cfg, &mut output); + push_decl::(&cfg, &mut output); + push_decl::(&cfg, &mut output); + push_decl::(&cfg, &mut output); + push_decl::(&cfg, &mut output); + push_decl::(&cfg, &mut output); + push_decl::(&cfg, &mut output); + push_decl::(&cfg, &mut output); + push_decl::(&cfg, &mut output); + push_decl::(&cfg, &mut output); + push_decl::(&cfg, &mut output); + push_decl::(&cfg, &mut output); + push_decl::(&cfg, &mut output); push_decl::(&cfg, &mut output); push_decl::(&cfg, &mut output); @@ -912,20 +935,19 @@ fn register_devo_protocol_schemas( schema::(schemas); schema::(schemas); schema::(schemas); - schema::(schemas); - schema::(schemas); - schema::(schemas); - schema::(schemas); - schema::(schemas); - schema::(schemas); - schema::(schemas); - schema::(schemas); + schema::(schemas); schema::(schemas); schema::(schemas); schema::(schemas); + schema::(schemas); + schema::(schemas); + schema::(schemas); + schema::(schemas); schema::(schemas); schema::(schemas); + schema::(schemas); + schema::(schemas); method( methods, @@ -1003,6 +1025,14 @@ fn register_devo_protocol_schemas( methods, "provider/upsert", ); + native_method::< + native::rpc_admin::ProviderDisconnectParams, + native::rpc_admin::ProviderDisconnectResult, + >(methods, "provider/disconnect"); + native_method::< + native::rpc_admin::ProviderModelRemoveParams, + native::rpc_admin::ProviderModelRemoveResult, + >(methods, "provider/model/remove"); } fn method( diff --git a/crates/protocol/src/conversation.rs b/crates/protocol/src/conversation.rs index 26802f4b..c1a2775d 100644 --- a/crates/protocol/src/conversation.rs +++ b/crates/protocol/src/conversation.rs @@ -104,9 +104,10 @@ define_id!(TurnId); define_id!(ItemId); define_id!(PendingInputId); -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema, TS)] +#[derive(Debug, Clone, PartialEq, Eq, Default, Serialize, Deserialize, JsonSchema, TS)] #[serde(rename_all = "camelCase")] pub enum SessionTitleState { + #[default] #[serde(alias = "Unset")] Unset, #[serde(alias = "Provisional", alias = "Generating")] @@ -115,12 +116,6 @@ pub enum SessionTitleState { Final(SessionTitleFinalSource), } -impl Default for SessionTitleState { - fn default() -> Self { - Self::Unset - } -} - #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema, TS)] pub enum SessionTitleFinalSource { ModelGenerated, diff --git a/crates/protocol/src/lib.rs b/crates/protocol/src/lib.rs index 715fdb44..993476b7 100644 --- a/crates/protocol/src/lib.rs +++ b/crates/protocol/src/lib.rs @@ -18,7 +18,7 @@ pub mod native; pub mod parse_command; mod permissions; pub mod protocol; -mod provider_vendor; +mod provider_catalog; mod reasoning_effort; mod reference_search; mod request_normalize; @@ -47,7 +47,7 @@ pub use hosted_tools::*; pub use model::*; pub use permissions::*; pub use protocol::*; -pub use provider_vendor::*; +pub use provider_catalog::*; pub use reasoning_effort::*; pub use reference_search::*; pub use request_normalize::*; diff --git a/crates/protocol/src/model.rs b/crates/protocol/src/model.rs index 1a29cad8..c5c7b0b5 100644 --- a/crates/protocol/src/model.rs +++ b/crates/protocol/src/model.rs @@ -20,18 +20,34 @@ use schemars::JsonSchema; use serde::Deserialize; use serde::Serialize; use serde_json::Value; +use std::collections::BTreeMap; use std::fmt; use ts_rs::TS; use crate::HostedToolDefinition; +use crate::ProviderInfo; +use crate::ProviderModelInfo; use crate::ReasoningCapability; use crate::ReasoningEffort; use crate::ReasoningEffortPreset; use crate::ReasoningImplementation; use crate::ResolvedReasoningRequest; +use crate::adapter_request_thinking_wire; +use crate::find_effort_variant_key; use crate::nearest_effort; +use crate::normalize_reasoning_effort_literal; use crate::truncation::TruncationPolicyConfig; +/// Catalog variant metadata used when a logical effort selection maps onto a +/// named `variants` entry (request-body / request-model encoding). +#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] +pub struct ModelEffortVariant { + /// Optional wire model id override for this effort selection. + pub request_model: Option, + /// Whether this variant may be selected. + pub disabled: bool, +} + #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema, TS)] #[serde(rename_all = "lowercase")] #[derive(Default)] @@ -241,15 +257,26 @@ pub struct Model { pub reasoning_capability: ReasoningCapability, /// Default reasoning effort selected for the model when no levels are exposed. pub default_reasoning_effort: Option, + /// Exact default reasoning selection, including toggle values such as + /// `on` and `off`. + pub default_reasoning_selection: Option, /// How the selected reasoning effort should be applied to requests. #[serde(alias = "thinking_implementation")] pub reasoning_implementation: Option, + /// Catalog `variants` keyed by logical effort selection (`off`/`on`/`low`…). + /// + /// When the normalized selection matches a non-disabled entry, resolution + /// uses CatalogVariant mode (no first-class thinking/effort fields). + #[serde(default, skip_serializing_if = "BTreeMap::is_empty")] + pub catalog_variants: BTreeMap, /// Base system instructions bundled with the model. pub base_instructions: String, /// Maximum context window in tokens. pub context_window: u32, /// Percentage of the context window treated as effectively usable. - pub effective_context_window_percent: Option, + /// + /// May be fractional (for example `25.5`). Defaults to `95` when unset. + pub effective_context_window_percent: Option, /// Policy used when truncating content for requests. pub truncation_policy: TruncationPolicyConfig, /// Input types accepted by the model. @@ -277,7 +304,9 @@ impl Default for Model { description: None, reasoning_capability: ReasoningCapability::Unsupported, default_reasoning_effort: Some(ReasoningEffort::default()), + default_reasoning_selection: None, reasoning_implementation: None, + catalog_variants: BTreeMap::new(), base_instructions: String::new(), context_window: 200_000, effective_context_window_percent: None, @@ -309,14 +338,10 @@ impl Model { pub fn reasoning_effort_options(&self) -> Vec { match &self.reasoning_capability { - ReasoningCapability::Levels(levels) => levels - .iter() - .copied() - .map(|effort| ReasoningEffortPreset::new(effort, effort.description())) - .collect(), - ReasoningCapability::ToggleWithLevels(levels) => levels - .iter() - .copied() + ReasoningCapability::Levels(_) => self + .reasoning_capability + .effort_levels() + .into_iter() .map(|effort| ReasoningEffortPreset::new(effort, effort.description())) .collect(), _ => self @@ -342,28 +367,42 @@ impl Model { }) } - pub fn effective_context_window_percent(&self) -> u8 { - self.effective_context_window_percent.unwrap_or(95) + pub fn effective_context_window_percent(&self) -> f64 { + self.effective_context_window_percent.unwrap_or(95.0) } pub fn effective_context_window(&self) -> u32 { - self.context_window - .saturating_mul(self.effective_context_window_percent() as u32) - / 100 + let percent = self.effective_context_window_percent().clamp(0.0, 100.0); + ((f64::from(self.context_window) * percent) / 100.0).floor() as u32 } pub fn default_reasoning_effort_selection(&self) -> Option { + if let Some(selection) = self + .default_reasoning_selection + .as_deref() + .map(str::trim) + .filter(|selection| !selection.is_empty()) + { + return Some(normalize_reasoning_effort_literal(selection)); + } match &self.reasoning_capability { ReasoningCapability::Unsupported => None, - ReasoningCapability::Toggle => Some(String::from("enabled")), - ReasoningCapability::ToggleWithLevels(levels) => self - .default_reasoning_effort - .or_else(|| levels.first().copied()) - .map(|effort| effort.label().to_lowercase()), - ReasoningCapability::Levels(levels) => self + ReasoningCapability::Toggle => Some(String::from("on")), + ReasoningCapability::Levels(choices) => self .default_reasoning_effort - .or_else(|| levels.first().copied()) - .map(|effort| effort.label().to_lowercase()), + .or_else(|| { + choices + .iter() + .copied() + .find_map(crate::ReasoningLevelChoice::effort) + }) + .map(|effort| effort.label().to_lowercase()) + .or_else(|| { + choices + .first() + .copied() + .map(|choice| choice.selection_value().to_string()) + }), } } @@ -372,27 +411,56 @@ impl Model { .map(str::trim) .filter(|selection| !selection.is_empty()) .filter(|selection| !selection.eq_ignore_ascii_case("default")) - .map(|selection| selection.to_ascii_lowercase()) + .map(normalize_reasoning_effort_literal) .or_else(|| self.default_reasoning_effort_selection()) } pub fn nearest_supported_reasoning_effort(&self, target: ReasoningEffort) -> ReasoningEffort { - match &self.reasoning_capability { - ReasoningCapability::Levels(levels) | ReasoningCapability::ToggleWithLevels(levels) - if !levels.is_empty() => - { - nearest_effort(target, levels) - } - _ => self.default_reasoning_effort.unwrap_or(target), + let levels = self.reasoning_capability.effort_levels(); + if levels.is_empty() { + self.default_reasoning_effort.unwrap_or(target) + } else { + nearest_effort(target, &levels) } } + /// Returns the catalog variant key that encodes the given logical effort + /// selection, when one exists and is selectable. + pub fn effort_catalog_variant_key(&self, selection: Option<&str>) -> Option<&str> { + let normalized = self.normalize_reasoning_effort_selection(selection)?; + let key = find_effort_variant_key(&self.catalog_variants, &normalized)?; + let variant = self.catalog_variants.get(key)?; + (!variant.disabled).then_some(key) + } + pub fn resolve_reasoning_effort_selection( &self, selection: Option<&str>, ) -> ResolvedReasoningRequest { let normalized_selection = self.normalize_reasoning_effort_selection(selection); + if let Some(variant_key) = self.effort_catalog_variant_key(selection) { + let variant = &self.catalog_variants[variant_key]; + let effective_reasoning_effort = normalized_selection + .as_deref() + .and_then(|value| match value { + "off" => None, + "on" => self.default_reasoning_effort, + other => other.parse::().ok(), + }) + .map(|effort| self.nearest_supported_reasoning_effort(effort)); + return ResolvedReasoningRequest { + request_model: variant + .request_model + .clone() + .unwrap_or_else(|| self.slug.clone()), + request_thinking: None, + request_reasoning_effort: None, + effective_reasoning_effort, + extra_body: None, + }; + } + match self.effective_reasoning_implementation() { ReasoningImplementation::Disabled => ResolvedReasoningRequest { request_model: self.slug.clone(), @@ -406,61 +474,66 @@ impl Model { match self.effective_reasoning_capability() { ReasoningCapability::Unsupported => (None, None, None), ReasoningCapability::Toggle => { - let request_thinking = normalized_selection - .filter(|selection| { - selection == "enabled" || selection == "disabled" - }) + let logical = normalized_selection + .filter(|selection| selection == "on" || selection == "off") .or_else(|| self.default_reasoning_effort_selection()); - let effective_reasoning_effort = self.default_reasoning_effort; + let effective_reasoning_effort = logical + .as_deref() + .filter(|selection| *selection == "on") + .and(self.default_reasoning_effort); + let request_thinking = + logical.as_deref().map(adapter_request_thinking_wire); (request_thinking, None, effective_reasoning_effort) } ReasoningCapability::Levels(_) => { - let request_reasoning_effort = normalized_selection - .as_deref() - .and_then(|selection| selection.parse::().ok()) - .map(|effort| self.nearest_supported_reasoning_effort(effort)) - .or(self.default_reasoning_effort); - ( - request_reasoning_effort - .map(|effort| effort.label().to_lowercase()), - request_reasoning_effort, - request_reasoning_effort, - ) - } - ReasoningCapability::ToggleWithLevels(_) => { - let request_reasoning_effort = normalized_selection - .as_deref() - .and_then(|selection| match selection { - "enabled" => self.default_reasoning_effort, - "disabled" => None, - _ => selection.parse::().ok(), - }) - .map(|effort| self.nearest_supported_reasoning_effort(effort)) - .or_else(|| { - normalized_selection - .as_deref() - .filter(|selection| *selection == "enabled") - .and(self.default_reasoning_effort) - }); - let request_thinking = normalized_selection.as_deref().map_or_else( - || { + let allows_off = self.reasoning_capability.allows_off(); + if allows_off { + let request_reasoning_effort = normalized_selection + .as_deref() + .and_then(|selection| match selection { + "on" => self.default_reasoning_effort, + "off" => None, + _ => selection.parse::().ok(), + }) + .map(|effort| self.nearest_supported_reasoning_effort(effort)) + .or_else(|| { + normalized_selection + .as_deref() + .filter(|selection| *selection == "on") + .and(self.default_reasoning_effort) + }); + let request_thinking = normalized_selection.as_deref().map_or_else( + || { + request_reasoning_effort + .map(|_| String::from("enabled")) + .or_else(|| Some(String::from("disabled"))) + }, + |selection| { + if selection == "off" { + Some(String::from("disabled")) + } else { + Some(String::from("enabled")) + } + }, + ); + ( + request_thinking, + request_reasoning_effort, + request_reasoning_effort, + ) + } else { + let request_reasoning_effort = normalized_selection + .as_deref() + .and_then(|selection| selection.parse::().ok()) + .map(|effort| self.nearest_supported_reasoning_effort(effort)) + .or(self.default_reasoning_effort); + ( request_reasoning_effort - .map(|_| String::from("enabled")) - .or_else(|| Some(String::from("disabled"))) - }, - |selection| { - if selection == "disabled" { - Some(String::from("disabled")) - } else { - Some(String::from("enabled")) - } - }, - ); - ( - request_thinking, - request_reasoning_effort, - request_reasoning_effort, - ) + .map(|effort| effort.label().to_lowercase()), + request_reasoning_effort, + request_reasoning_effort, + ) + } } }; ResolvedReasoningRequest { @@ -475,24 +548,25 @@ impl Model { let selected_variant = normalized_selection .as_deref() .and_then(|selection| { - config - .variants - .iter() - .find(|variant| variant.selection_value.eq_ignore_ascii_case(selection)) + config.variants.iter().find(|variant| { + normalize_reasoning_effort_literal(&variant.selection_value) + == selection + }) }) .or_else(|| { self.default_reasoning_effort_selection() .as_deref() .and_then(|selection| { config.variants.iter().find(|variant| { - variant.selection_value.eq_ignore_ascii_case(selection) + normalize_reasoning_effort_literal(&variant.selection_value) + == selection }) }) }) .or_else(|| config.variants.first()); if let Some(variant) = selected_variant { ResolvedReasoningRequest { - request_model: variant.model_slug.clone(), + request_model: variant.model.clone(), request_thinking: None, request_reasoning_effort: variant.reasoning_effort, effective_reasoning_effort: variant.reasoning_effort, @@ -517,6 +591,61 @@ pub trait ModelCatalog: Send + Sync { /// Lists all models that are available for user-facing selection. fn list_visible(&self) -> Vec<&Model>; + /// Lists provider directory entries associated with this model catalog. + /// + /// Implementations that only provide models may return an empty list. A + /// bundled provider/model directory should return stable provider ids, + /// display names, endpoints, and credential references without exposing + /// credential values. + fn list_providers(&self) -> Vec { + Vec::new() + } + + /// Lists provider ids from the read-only built-in directory. + /// + /// Resolved catalog overlays may also expose user-defined providers, so + /// callers should use this list to distinguish templates from Connections. + fn list_template_provider_ids(&self) -> Vec { + Vec::new() + } + + /// Lists model metadata below one provider directory entry. + /// + /// Catalog implementations with richer source metadata should override + /// this method. The default projection keeps older in-memory catalogs + /// compatible while still exposing the canonical nested shape. + fn list_provider_models(&self, provider_id: &str) -> BTreeMap { + self.list_visible() + .into_iter() + .filter_map(|model| { + let (model_provider, model_id) = model.slug.split_once('/')?; + if model_provider != provider_id { + return None; + } + Some(( + model_id.to_string(), + ProviderModelInfo { + name: Some(model.display_name.clone()), + wire_api: Some(model.provider), + context_window: Some(model.context_window), + effective_context_window_percent: model.effective_context_window_percent, + max_tokens: model.max_tokens, + temperature: model.temperature, + top_p: model.top_p, + top_k: model.top_k, + reasoning_capability: Some(model.reasoning_capability.clone()), + reasoning_implementation: model.reasoning_implementation.clone(), + default_reasoning_effort: model.default_reasoning_effort, + base_instructions: Some(model.base_instructions.clone()), + input_modalities: Some(model.input_modalities.clone()), + channel: model.channel.clone(), + ..ProviderModelInfo::default() + }, + )) + }) + .collect() + } + /// Returns the model whose slug exactly matches `slug`. fn get(&self, slug: &str) -> Option<&Model>; @@ -584,6 +713,7 @@ pub struct ModelCatalogEntry { pub reasoning_capability: ReasoningCapability, pub input_modalities: Vec, pub max_tokens: Option, + pub default_reasoning_selection: Option, } impl From<&Model> for ModelCatalogEntry { @@ -598,6 +728,7 @@ impl From<&Model> for ModelCatalogEntry { reasoning_capability: m.reasoning_capability.clone(), input_modalities: m.input_modalities.clone(), max_tokens: m.max_tokens, + default_reasoning_selection: m.default_reasoning_selection.clone(), } } } @@ -627,7 +758,9 @@ mod tests { description: None, reasoning_capability: ReasoningCapability::Unsupported, default_reasoning_effort: Some(ReasoningEffort::Medium), + default_reasoning_selection: None, reasoning_implementation: None, + catalog_variants: Default::default(), base_instructions: String::new(), context_window: 200_000, effective_context_window_percent: None, @@ -733,17 +866,16 @@ mod tests { assert_eq!(resolved.request_model, "glm-5.1"); assert_eq!(resolved.request_thinking, Some(String::from("disabled"))); - assert_eq!( - resolved.effective_reasoning_effort, - Some(ReasoningEffort::Medium) - ); + assert_eq!(resolved.effective_reasoning_effort, None); } #[test] fn resolve_reasoning_effort_selection_snaps_effort_for_level_models() { let mut preset = model("o-model"); - preset.reasoning_capability = - ReasoningCapability::Levels(vec![ReasoningEffort::Low, ReasoningEffort::High]); + preset.reasoning_capability = ReasoningCapability::Levels(vec![ + ReasoningEffort::Low.into(), + ReasoningEffort::High.into(), + ]); preset.default_reasoning_effort = Some(ReasoningEffort::Low); let resolved = preset.resolve_reasoning_effort_selection(Some("medium")); @@ -757,12 +889,13 @@ mod tests { } #[test] - fn resolve_reasoning_effort_selection_supports_toggle_with_levels() { + fn resolve_reasoning_effort_selection_supports_levels_with_off() { let mut preset = model("deepseek-v4"); - preset.reasoning_capability = ReasoningCapability::ToggleWithLevels(vec![ - ReasoningEffort::High, - ReasoningEffort::Max, - ]); + preset.reasoning_capability = + ReasoningCapability::Levels(crate::levels_with_leading_off([ + ReasoningEffort::High, + ReasoningEffort::Max, + ])); preset.default_reasoning_effort = Some(ReasoningEffort::High); let enabled = preset.resolve_reasoning_effort_selection(Some("enabled")); @@ -793,18 +926,21 @@ mod tests { toggle.reasoning_capability = ReasoningCapability::Toggle; let mut levels = model("levels-model"); - levels.reasoning_capability = - ReasoningCapability::Levels(vec![ReasoningEffort::Low, ReasoningEffort::High]); + levels.reasoning_capability = ReasoningCapability::Levels(vec![ + ReasoningEffort::Low.into(), + ReasoningEffort::High.into(), + ]); levels.default_reasoning_effort = Some(ReasoningEffort::High); - let mut toggle_with_levels = model("toggle-levels-model"); - toggle_with_levels.reasoning_capability = ReasoningCapability::ToggleWithLevels(vec![ - ReasoningEffort::High, - ReasoningEffort::Max, - ]); - toggle_with_levels.default_reasoning_effort = Some(ReasoningEffort::High); + let mut levels_with_off = model("toggle-levels-model"); + levels_with_off.reasoning_capability = + ReasoningCapability::Levels(crate::levels_with_leading_off([ + ReasoningEffort::High, + ReasoningEffort::Max, + ])); + levels_with_off.default_reasoning_effort = Some(ReasoningEffort::High); - for preset in [toggle, levels, toggle_with_levels] { + for preset in [toggle, levels, levels_with_off] { let absent = preset.resolve_reasoning_effort_selection(None); assert_eq!( @@ -818,6 +954,65 @@ mod tests { } } + #[test] + fn model_default_reasoning_selection_preserves_toggle_off() { + let mut model = model("toggle-model"); + model.reasoning_capability = ReasoningCapability::Toggle; + model.default_reasoning_selection = Some("disabled".to_string()); + + assert_eq!( + model.default_reasoning_effort_selection(), + Some("off".to_string()) + ); + assert_eq!( + model.resolve_reasoning_effort_selection(None), + crate::ResolvedReasoningRequest { + request_model: "toggle-model".to_string(), + request_thinking: Some("disabled".to_string()), + request_reasoning_effort: None, + effective_reasoning_effort: None, + extra_body: None, + } + ); + } + + #[test] + fn resolve_reasoning_effort_selection_uses_catalog_variants_when_present() { + let mut preset = model("custom/gateway-model"); + preset.reasoning_capability = ReasoningCapability::Levels(vec![ + ReasoningEffort::Low.into(), + ReasoningEffort::High.into(), + ]); + preset.catalog_variants = [ + ( + "low".to_string(), + super::ModelEffortVariant { + request_model: Some("gateway-model-fast".to_string()), + disabled: false, + }, + ), + ( + "high".to_string(), + super::ModelEffortVariant { + request_model: Some("gateway-model-think".to_string()), + disabled: false, + }, + ), + ] + .into_iter() + .collect(); + + let resolved = preset.resolve_reasoning_effort_selection(Some("high")); + assert_eq!(resolved.request_model, "gateway-model-think"); + assert_eq!(resolved.request_thinking, None); + assert_eq!(resolved.request_reasoning_effort, None); + assert_eq!( + resolved.effective_reasoning_effort, + Some(ReasoningEffort::High) + ); + assert_eq!(preset.effort_catalog_variant_key(Some("low")), Some("low")); + } + #[test] fn resolve_reasoning_effort_selection_uses_model_variants_when_configured() { let mut preset = model("kimi-k2.5"); @@ -827,7 +1022,7 @@ mod tests { variants: vec![ ReasoningVariant { selection_value: String::from("disabled"), - model_slug: String::from("kimi-k2.5"), + model: String::from("kimi-k2.5"), reasoning_effort: None, label: String::from("Off"), description: String::from("Use the standard model"), @@ -835,7 +1030,7 @@ mod tests { }, ReasoningVariant { selection_value: String::from("enabled"), - model_slug: String::from("kimi-k2.5-thinking"), + model: String::from("kimi-k2.5-thinking"), reasoning_effort: Some(ReasoningEffort::Medium), label: String::from("On"), description: String::from("Use the reasoning model"), @@ -863,7 +1058,7 @@ mod tests { ReasoningVariantConfig { variants: vec![ReasoningVariant { selection_value: String::from("disabled"), - model_slug: String::from("deepseek-chat"), + model: String::from("deepseek-chat"), reasoning_effort: None, label: String::from("Off"), description: String::from("Use the standard model"), @@ -977,13 +1172,24 @@ mod tests { fn model_effective_context_window_uses_configured_percent() { let model = Model { context_window: 1_000, - effective_context_window_percent: Some(80), + effective_context_window_percent: Some(80.0), ..Model::default() }; assert_eq!(model.effective_context_window(), 800); } + #[test] + fn model_effective_context_window_keeps_fractional_percent() { + let model = Model { + context_window: 1_000_000, + effective_context_window_percent: Some(33.3333), + ..Model::default() + }; + + assert_eq!(model.effective_context_window(), 333_333); + } + #[test] fn model_effective_context_window_defaults_to_95_percent() { let model = Model { diff --git a/crates/protocol/src/native/methods.rs b/crates/protocol/src/native/methods.rs index 529b581d..31d85bdc 100644 --- a/crates/protocol/src/native/methods.rs +++ b/crates/protocol/src/native/methods.rs @@ -383,6 +383,22 @@ pub static NATIVE_METHODS: &[MethodSpec] = &[ required_capability: None, idempotency: Idempotency::None, }, + MethodSpec { + name: "provider/disconnect", + params_schema: schema_of::, + result_schema: schema_of::, + error_codes: &[], + required_capability: None, + idempotency: Idempotency::None, + }, + MethodSpec { + name: "provider/model/remove", + params_schema: schema_of::, + result_schema: schema_of::, + error_codes: &[], + required_capability: None, + idempotency: Idempotency::None, + }, MethodSpec { name: "provider/validate", params_schema: schema_of::, @@ -391,6 +407,14 @@ pub static NATIVE_METHODS: &[MethodSpec] = &[ required_capability: None, idempotency: Idempotency::None, }, + MethodSpec { + name: "provider/discover", + params_schema: schema_of::, + result_schema: schema_of::, + error_codes: &[], + required_capability: None, + idempotency: Idempotency::None, + }, MethodSpec { name: "tool/list", params_schema: schema_of::, diff --git a/crates/protocol/src/native/model.rs b/crates/protocol/src/native/model.rs index e36ee95c..b24e082a 100644 --- a/crates/protocol/src/native/model.rs +++ b/crates/protocol/src/native/model.rs @@ -15,6 +15,9 @@ use crate::ReasoningEffort; pub struct ModelBinding { pub provider: String, pub model: String, + /// Optional named variant from the provider model directory. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub variant: Option, #[serde(default, skip_serializing_if = "Option::is_none")] pub reasoning_effort: Option, } diff --git a/crates/protocol/src/native/rpc_admin.rs b/crates/protocol/src/native/rpc_admin.rs index 70f50f23..5e8bdea9 100644 --- a/crates/protocol/src/native/rpc_admin.rs +++ b/crates/protocol/src/native/rpc_admin.rs @@ -5,14 +5,14 @@ use schemars::JsonSchema; use serde::Deserialize; use serde::Serialize; +use std::collections::BTreeMap; use ts_rs::TS; -use std::path::PathBuf; - use super::ids::SessionId; use super::item::ContextOccupancy; use super::item::ToolSource; use super::model::PermissionProfile; +use std::path::PathBuf; // ── initialize ── @@ -108,8 +108,8 @@ pub struct PreferencesOption { #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema, TS)] #[serde(rename_all = "camelCase")] pub struct ModelPreferences { - /// Current default: the provider model binding id when configured, - /// otherwise the model slug. + /// Current default in canonical `provider/model` or + /// `provider/model/variant` form. #[serde(default, skip_serializing_if = "Option::is_none")] pub model: Option, /// Current default reasoning effort selection @@ -164,11 +164,17 @@ pub struct ModelPreferencesWriteResult { #[serde(rename_all = "camelCase")] pub struct ModelListParams {} -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema, TS)] +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema, TS)] #[serde(rename_all = "camelCase")] pub struct ModelInfo { pub slug: String, pub display_name: String, + /// Provider id and provider-facing model id when this entry came from the + /// canonical provider directory. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub provider_id: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub model_id: Option, #[serde(default, skip_serializing_if = "Option::is_none")] pub channel: Option, #[serde(default, skip_serializing_if = "Option::is_none")] @@ -181,6 +187,34 @@ pub struct ModelInfo { pub input_modalities: Vec, #[serde(default, skip_serializing_if = "Option::is_none")] pub max_tokens: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub family: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub release_date: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub status: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub capabilities: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub cost: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub metadata: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub request: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub options: Option, + #[serde(default, skip_serializing_if = "BTreeMap::is_empty")] + pub headers: BTreeMap, + #[serde(default, skip_serializing_if = "BTreeMap::is_empty")] + pub variants: BTreeMap, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub default_variant: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub default_reasoning_selection: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub enabled: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub priority: Option, } impl From for ModelInfo { @@ -188,6 +222,8 @@ impl From for ModelInfo { Self { slug: entry.slug, display_name: entry.display_name, + provider_id: None, + model_id: None, channel: entry.channel, description: entry.description, provider: entry.provider, @@ -195,11 +231,72 @@ impl From for ModelInfo { reasoning_capability: entry.reasoning_capability, input_modalities: entry.input_modalities, max_tokens: entry.max_tokens, + family: None, + release_date: None, + status: None, + capabilities: None, + cost: None, + metadata: None, + request: None, + options: None, + headers: BTreeMap::new(), + variants: BTreeMap::new(), + default_variant: None, + default_reasoning_selection: entry.default_reasoning_selection, + enabled: None, + priority: None, } } } -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema, TS)] +impl ModelInfo { + /// Adds the richer directory record while preserving stable fields used + /// by older Native clients. + pub fn with_provider_metadata( + mut self, + provider_id: String, + model_id: String, + metadata: crate::ProviderModelInfo, + ) -> Self { + self.provider_id = Some(provider_id); + self.model_id = Some(model_id); + if let Some(wire_api) = metadata.wire_api { + self.provider = wire_api; + } + if let Some(context_window) = metadata.context_window { + self.context_window = context_window; + } + if let Some(reasoning_capability) = metadata.reasoning_capability { + self.reasoning_capability = reasoning_capability; + } + if let Some(input_modalities) = metadata.input_modalities { + self.input_modalities = input_modalities; + } + if metadata.max_tokens.is_some() { + self.max_tokens = metadata.max_tokens; + } + if metadata.channel.is_some() { + self.channel = metadata.channel; + } + self.family = metadata.family; + self.release_date = metadata.release_date; + self.status = metadata.status; + self.capabilities = metadata.capabilities; + self.cost = metadata.cost; + self.metadata = metadata.metadata; + self.request = metadata.request; + self.options = metadata.options; + self.headers = metadata.headers; + self.variants = metadata.variants; + self.default_variant = metadata.default_variant; + self.default_reasoning_selection = metadata.default_reasoning_selection; + self.enabled = metadata.enabled; + self.priority = metadata.priority; + self + } +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema, TS)] #[serde(rename_all = "camelCase")] pub struct ModelListResult { pub models: Vec, @@ -415,140 +512,118 @@ pub struct PermissionProfileUpdateResult { // ── provider/* (ratified #11) ── -/// Native mirror of the legacy provider vendor. `credential` is a -/// credential *id* into `auth.json`, never the secret; `api_key` on -/// upsert/validate is write-only. -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema, TS)] +#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize, JsonSchema, TS)] #[serde(rename_all = "camelCase")] -pub struct ProviderVendorInfo { - pub name: String, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub base_url: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub credential: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub headers: Option, - pub wire_apis: Vec, - pub enabled: bool, -} +pub struct ProviderListParams {} -impl From for ProviderVendorInfo { - fn from(vendor: crate::ProviderVendor) -> Self { - Self { - name: vendor.name, - base_url: vendor.base_url, - credential: vendor.credential, - headers: vendor.headers, - wire_apis: vendor.wire_apis, - enabled: vendor.enabled, - } - } +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +pub struct ProviderListResult { + pub providers: Vec, + #[serde(default)] + pub template_provider_ids: Vec, + /// Provider ids with a user-created Connection. Directory entries that + /// are not in this list are read-only templates. + #[serde(default)] + pub connected_provider_ids: Vec, + /// Models explicitly configured on each user-created Connection. + /// + /// This is intentionally separate from `providers[*].models`, which is + /// the effective provider directory and may include built-in templates. + #[serde(default)] + pub connection_models: std::collections::BTreeMap< + String, + std::collections::BTreeMap, + >, } -impl From for crate::ProviderVendor { - fn from(vendor: ProviderVendorInfo) -> Self { - Self { - name: vendor.name, - base_url: vendor.base_url, - credential: vendor.credential, - headers: vendor.headers, - wire_apis: vendor.wire_apis, - enabled: vendor.enabled, - } - } +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +pub struct ProviderUpsertParams { + pub provider: crate::ProviderInfo, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub default_model: Option, + /// Optional lower-cost model for lightweight background work. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub small_model: Option, + /// Write-only secret, stored in the user auth store; never echoed. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub api_key: Option, } -/// Native mirror of the legacy provider model binding. -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema, TS)] +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema, TS)] #[serde(rename_all = "camelCase")] -pub struct ProviderModelBindingInfo { - pub binding_id: String, - pub model_slug: String, - pub provider: String, - pub request_model: String, +pub struct ProviderUpsertResult { + pub provider: crate::ProviderInfo, #[serde(default, skip_serializing_if = "Option::is_none")] - pub display_name: Option, - pub invocation_method: crate::ProviderWireApi, + pub default_model: Option, #[serde(default, skip_serializing_if = "Option::is_none")] - pub default_reasoning_effort: Option, - pub enabled: bool, + pub small_model: Option, } -impl From for ProviderModelBindingInfo { - fn from(binding: crate::ProviderModelBinding) -> Self { - Self { - binding_id: binding.binding_id, - model_slug: binding.model_slug, - provider: binding.provider, - request_model: binding.request_model, - display_name: binding.display_name, - invocation_method: binding.invocation_method, - default_reasoning_effort: binding.default_reasoning_effort, - enabled: binding.enabled, - } - } +/// Disconnects a user-created provider Connection without modifying the +/// corresponding built-in provider directory entry. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +pub struct ProviderDisconnectParams { + pub provider_id: String, } -impl From for crate::ProviderModelBinding { - fn from(binding: ProviderModelBindingInfo) -> Self { - Self { - binding_id: binding.binding_id, - model_slug: binding.model_slug, - provider: binding.provider, - request_model: binding.request_model, - display_name: binding.display_name, - invocation_method: binding.invocation_method, - default_reasoning_effort: binding.default_reasoning_effort, - enabled: binding.enabled, - } - } +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +pub struct ProviderDisconnectResult { + pub provider_id: String, } -#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize, JsonSchema, TS)] +/// Removes one model from a user-created provider Connection. +/// +/// Built-in provider templates are not modified. The model is removed only +/// from the user's provider catalog overlay and its provider-owned defaults +/// are cleared when they point at that model. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema, TS)] #[serde(rename_all = "camelCase")] -pub struct ProviderListParams {} +pub struct ProviderModelRemoveParams { + pub provider_id: String, + pub model_id: String, +} -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema, TS)] +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema, TS)] #[serde(rename_all = "camelCase")] -pub struct ProviderListResult { - pub providers: Vec, +pub struct ProviderModelRemoveResult { + pub provider_id: String, + pub model_id: String, } -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema, TS)] +/// Live network probe of a provider Connection and its selected model. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema, TS)] #[serde(rename_all = "camelCase")] -pub struct ProviderUpsertParams { - pub provider_vendor: ProviderVendorInfo, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub model_binding: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub default_model_binding: Option, - /// Write-only secret, stored in the keyring; never echoed. +pub struct ProviderValidateParams { + pub provider: crate::ProviderInfo, + pub model: String, #[serde(default, skip_serializing_if = "Option::is_none")] pub api_key: Option, } -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema, TS)] +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema, TS)] #[serde(rename_all = "camelCase")] -pub struct ProviderUpsertResult { - pub provider_vendor: ProviderVendorInfo, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub model_binding: Option, +pub struct ProviderValidateResult { + pub reply_preview: String, } -/// Live network probe of a vendor + binding configuration. +/// Refreshes the model directory from a provider's models endpoint. #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema, TS)] #[serde(rename_all = "camelCase")] -pub struct ProviderValidateParams { - pub provider_vendor: ProviderVendorInfo, - pub model_binding: ProviderModelBindingInfo, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub api_key: Option, +pub struct ProviderDiscoverParams { + pub provider_id: String, + #[serde(default)] + pub force_refresh: bool, } -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema, TS)] +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema, TS)] #[serde(rename_all = "camelCase")] -pub struct ProviderValidateResult { - pub reply_preview: String, +pub struct ProviderDiscoverResult { + pub provider_id: String, + pub models: std::collections::BTreeMap, } // ── credential/* ── diff --git a/crates/protocol/src/native/session.rs b/crates/protocol/src/native/session.rs index 6ff13c6e..eb249451 100644 --- a/crates/protocol/src/native/session.rs +++ b/crates/protocol/src/native/session.rs @@ -139,7 +139,7 @@ pub enum SessionParent { pub struct SessionSettings { pub permission_profile: PermissionProfile, /// User's reasoning-effort selection as persisted, including the toggle - /// keywords `enabled`/`disabled` used by toggle/variant-style models — + /// keywords `on`/`off` used by toggle/variant-style models — /// the typed `ReasoningEffort` enum cannot express those, so the snapshot /// carries the raw selection string (same contract as /// `SessionSettingsPatch.reasoning_effort`). @@ -186,7 +186,7 @@ mod tests { /// "wide write" (patch) always reads back equal from the "snapshot" side. #[test] fn session_settings_reasoning_effort_round_trips_raw_selection() { - for literal in ["enabled", "disabled", "high", "xhigh"] { + for literal in ["on", "off", "enabled", "disabled", "high", "xhigh"] { let settings = SessionSettings { permission_profile: PermissionProfile::Default, reasoning_effort: Some(literal.to_string()), diff --git a/crates/protocol/src/native/wire_projector.rs b/crates/protocol/src/native/wire_projector.rs index 5da810b5..ea242dc1 100644 --- a/crates/protocol/src/native/wire_projector.rs +++ b/crates/protocol/src/native/wire_projector.rs @@ -420,6 +420,7 @@ pub fn native_turn_from_metadata(metadata: &crate::TurnMetadata) -> crate::nativ } else { metadata.request_model.clone() }, + variant: None, reasoning_effort: metadata .reasoning_effort_selection .as_deref() diff --git a/crates/protocol/src/provider_catalog.rs b/crates/protocol/src/provider_catalog.rs new file mode 100644 index 00000000..1a25d571 --- /dev/null +++ b/crates/protocol/src/provider_catalog.rs @@ -0,0 +1,189 @@ +//! Canonical provider-connection and model-directory types. +//! +//! These types are intentionally map-shaped: a provider id identifies a +//! connection and a model id identifies a model below that connection. The +//! old provider/model binding vocabulary is kept only in internal migration +//! code and is not part of this public catalog contract. + +use std::collections::BTreeMap; + +use schemars::JsonSchema; +use serde::{Deserialize, Serialize}; +use serde_json::Value; +use ts_rs::TS; + +use crate::{ + InputModality, ProviderWireApi, ReasoningCapability, ReasoningEffort, ReasoningImplementation, +}; + +/// A named model variant, such as a provider's fast or high-reasoning mode. +/// +/// `request`, `options`, and `headers` are deliberately open-ended so a +/// provider integration can add capabilities without a protocol migration. +#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +pub struct ProviderModelVariant { + #[serde(default, skip_serializing_if = "Option::is_none")] + pub label: Option, + #[serde(default)] + pub disabled: bool, + /// Optional wire model id override when this variant is selected. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub request_model: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub request: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub options: Option, + #[serde(default, skip_serializing_if = "BTreeMap::is_empty")] + pub headers: BTreeMap, +} + +/// One model entry in a provider's directory. +/// +/// The containing `models` map supplies the model id. No second slug, name, +/// or binding id is needed. `name` is display metadata only. +#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +pub struct ProviderModelInfo { + #[serde( + default, + alias = "display_name", + skip_serializing_if = "Option::is_none" + )] + pub name: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub family: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub release_date: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub status: Option, + /// Open-ended model capabilities, compatible with directory sources such + /// as OpenCode/models.dev (for example tools, input/output, attachment, + /// and interleaved reasoning support). + #[serde(default, skip_serializing_if = "Option::is_none")] + pub capabilities: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub wire_api: Option, + #[serde( + default, + alias = "context_window", + skip_serializing_if = "Option::is_none" + )] + pub context_window: Option, + #[serde( + default, + alias = "effective_context_window_percent", + skip_serializing_if = "Option::is_none" + )] + pub effective_context_window_percent: Option, + #[serde(default, alias = "max_tokens", skip_serializing_if = "Option::is_none")] + pub max_tokens: Option, + #[serde( + default, + alias = "temperature", + skip_serializing_if = "Option::is_none" + )] + pub temperature: Option, + #[serde(default, alias = "top_p", skip_serializing_if = "Option::is_none")] + pub top_p: Option, + #[serde(default, alias = "top_k", skip_serializing_if = "Option::is_none")] + pub top_k: Option, + #[serde( + default, + alias = "reasoning_capability", + skip_serializing_if = "Option::is_none" + )] + pub reasoning_capability: Option, + #[serde( + default, + alias = "reasoning_implementation", + skip_serializing_if = "Option::is_none" + )] + pub reasoning_implementation: Option, + #[serde( + default, + alias = "default_reasoning_effort", + skip_serializing_if = "Option::is_none" + )] + pub default_reasoning_effort: Option, + /// Exact persisted/UI selection, including `on` or `off` for + /// toggle-capable models. + #[serde( + default, + alias = "default_reasoning_selection", + skip_serializing_if = "Option::is_none" + )] + pub default_reasoning_selection: Option, + #[serde( + default, + alias = "base_instructions", + skip_serializing_if = "Option::is_none" + )] + pub base_instructions: Option, + #[serde( + default, + alias = "input_modalities", + skip_serializing_if = "Option::is_none" + )] + pub input_modalities: Option>, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub channel: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + #[serde(alias = "supports_image_detail_original")] + pub supports_image_detail_original: Option, + #[serde( + default, + alias = "truncation_policy", + skip_serializing_if = "Option::is_none" + )] + pub truncation_policy: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub web_search: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub web_fetch: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub cost: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub metadata: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub request: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub options: Option, + #[serde(default, skip_serializing_if = "BTreeMap::is_empty")] + pub headers: BTreeMap, + #[serde(default, skip_serializing_if = "BTreeMap::is_empty")] + pub variants: BTreeMap, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub default_variant: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub enabled: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub priority: Option, +} + +/// A provider Connection and the model directory available through it. +/// +/// `credential` is an id into `auth.json`; it is never the secret itself. +#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +pub struct ProviderInfo { + #[serde(default)] + pub id: String, + pub name: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub description: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub base_url: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub credential: Option, + #[serde(default, skip_serializing_if = "BTreeMap::is_empty")] + pub headers: BTreeMap, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub options: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub request: Option, + pub wire_apis: Vec, + #[serde(default, skip_serializing_if = "BTreeMap::is_empty")] + pub models: BTreeMap, + pub enabled: bool, +} diff --git a/crates/protocol/src/provider_vendor.rs b/crates/protocol/src/provider_vendor.rs deleted file mode 100644 index b49c61b4..00000000 --- a/crates/protocol/src/provider_vendor.rs +++ /dev/null @@ -1,145 +0,0 @@ -use schemars::JsonSchema; -use serde::Deserialize; -use serde::Serialize; -use ts_rs::TS; - -use crate::ProviderWireApi; - -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema, TS)] -pub struct ProviderVendor { - pub name: String, - pub base_url: Option, - pub credential: Option, - pub headers: Option, - pub wire_apis: Vec, - pub enabled: bool, -} - -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema, TS)] -pub struct ProviderModelBinding { - pub binding_id: String, - pub model_slug: String, - pub provider: String, - #[serde(alias = "model_name")] - pub request_model: String, - pub display_name: Option, - pub invocation_method: ProviderWireApi, - pub default_reasoning_effort: Option, - pub enabled: bool, -} - -#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize, JsonSchema, TS)] -pub struct ProviderVendorListParams {} - -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema, TS)] -pub struct ProviderVendorListResult { - pub provider_vendors: Vec, -} - -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema, TS)] -pub struct ProviderVendorUpsertParams { - pub provider_vendor: ProviderVendor, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub model_binding: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub default_model_binding: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub api_key: Option, -} - -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema, TS)] -pub struct ProviderVendorUpsertResult { - pub provider_vendor: ProviderVendor, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub model_binding: Option, -} - -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema, TS)] -pub struct ProviderValidateParams { - pub provider_vendor: ProviderVendor, - pub model_binding: ProviderModelBinding, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub api_key: Option, -} - -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema, TS)] -pub struct ProviderValidateResult { - pub reply_preview: String, -} - -// TODO: Write ProviderVendor list to the current configuration -// TODO: Read ProviderVendor list from current configuration -// TODO: The api key should at auth.json file - -#[derive(Debug, Default)] -pub struct ProviderVendorCatalog { - pub provider_vendors: Vec, -} - -impl ProviderVendorCatalog { - pub fn list(&self) -> Vec<&ProviderVendor> { - self.provider_vendors.iter().collect() - } - - pub fn get(&self, name: &str) -> Option<&ProviderVendor> { - self.provider_vendors - .iter() - .find(|&provider_vendor| provider_vendor.name.as_str() == name) - } - - pub fn new() -> Self { - Self { - provider_vendors: Vec::new(), - } - } -} - -#[cfg(test)] -mod tests { - use pretty_assertions::assert_eq; - - use super::*; - - #[test] - fn provider_model_binding_reads_legacy_name_and_writes_request_model() { - let legacy = serde_json::json!({ - "binding_id": "glm-zai", - "model_slug": "glm-4.5", - "provider": "zai", - "model_name": "renamed-provider-model", - "display_name": "GLM 4.5", - "invocation_method": "openai_chat_completions", - "default_reasoning_effort": "enabled", - "enabled": true - }); - let binding: ProviderModelBinding = - serde_json::from_value(legacy).expect("deserialize legacy binding"); - - assert_eq!( - binding, - ProviderModelBinding { - binding_id: "glm-zai".to_string(), - model_slug: "glm-4.5".to_string(), - provider: "zai".to_string(), - request_model: "renamed-provider-model".to_string(), - display_name: Some("GLM 4.5".to_string()), - invocation_method: ProviderWireApi::OpenAIChatCompletions, - default_reasoning_effort: Some("enabled".to_string()), - enabled: true, - } - ); - assert_eq!( - serde_json::to_value(binding).expect("serialize binding"), - serde_json::json!({ - "binding_id": "glm-zai", - "model_slug": "glm-4.5", - "provider": "zai", - "request_model": "renamed-provider-model", - "display_name": "GLM 4.5", - "invocation_method": "openai_chat_completions", - "default_reasoning_effort": "enabled", - "enabled": true - }) - ); - } -} diff --git a/crates/protocol/src/reasoning_effort.rs b/crates/protocol/src/reasoning_effort.rs index 053ea25d..6b2e3627 100644 --- a/crates/protocol/src/reasoning_effort.rs +++ b/crates/protocol/src/reasoning_effort.rs @@ -16,7 +16,8 @@ //! two-step design: //! //! 1. The user or session stores a logical reasoning-effort selection such as -//! `disabled`, `enabled`, or `medium`. +//! `off`, `on`, or `medium` (legacy `disabled`/`enabled` normalize to +//! `off`/`on` on read). //! 2. The runtime resolves that logical selection into concrete provider //! request fields: //! - the final request model slug @@ -76,10 +77,14 @@ pub struct ReasoningVariantConfig { /// Maps one logical reasoning-effort selection to a concrete request model and defaults. #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema, TS)] pub struct ReasoningVariant { - /// Logical reasoning-effort selection value, such as `enabled` or `disabled`. + /// Logical reasoning-effort selection value, such as `on` or `off`. pub selection_value: String, - /// Concrete wire-model slug to send to the provider for this selection. - pub model_slug: String, + /// Concrete provider model id to send for this selection. + /// + /// `model_slug` remains a read-only serde alias for older configuration + /// during startup migration; new JSON uses `model`. + #[serde(alias = "model_slug")] + pub model: String, /// Effective reasoning effort implied by this variant, when one exists. pub reasoning_effort: Option, /// User-facing label shown for this selection in pickers. @@ -158,12 +163,53 @@ impl FromStr for ReasoningEffort { /// Normalizes a persisted reasoning-effort selection literal for storage and /// comparison: trimmed and ASCII-lowercased. Unlike /// [`Model::normalize_reasoning_effort_selection`](crate::Model::normalize_reasoning_effort_selection) -/// this is model-agnostic — it keeps toggle keywords (`enabled`/`disabled`) and -/// the `"default"` marker untouched, and never falls back to a model default. +/// this is model-agnostic — it maps legacy toggle keywords (`enabled`/`disabled`) +/// to canonical `on`/`off`, keeps the `"default"` marker untouched, and never +/// falls back to a model default. /// Read and write paths share it so a stored selection compares equal to the /// same selection arriving in a patch. pub fn normalize_reasoning_effort_literal(raw: &str) -> String { - raw.trim().to_ascii_lowercase() + match raw.trim().to_ascii_lowercase().as_str() { + "disabled" => String::from("off"), + "enabled" => String::from("on"), + other => other.to_string(), + } +} + +/// Maps a canonical logical toggle/effort selection onto the wire value expected +/// by built-in adapters that still speak `disabled`/`enabled` for thinking. +pub fn adapter_request_thinking_wire(selection: &str) -> String { + match selection { + "off" => String::from("disabled"), + "on" => String::from("enabled"), + other => other.to_string(), + } +} + +/// Candidate catalog-variant keys for a normalized logical selection. +/// +/// Canonical keys come first; legacy toggle aliases follow so old overlays keep +/// matching during the migration window. +pub fn effort_variant_lookup_keys(selection: &str) -> Vec { + let normalized = normalize_reasoning_effort_literal(selection); + match normalized.as_str() { + "off" => vec![String::from("off"), String::from("disabled")], + "on" => vec![String::from("on"), String::from("enabled")], + _ => vec![normalized], + } +} + +/// Finds the first catalog variant key that matches a logical effort selection. +pub fn find_effort_variant_key<'a, V>( + variants: &'a std::collections::BTreeMap, + selection: &str, +) -> Option<&'a str> { + for key in effort_variant_lookup_keys(selection) { + if let Some(matched) = variants.keys().find(|candidate| candidate.as_str() == key) { + return Some(matched.as_str()); + } + } + None } impl ReasoningEffort { @@ -204,64 +250,102 @@ fn reasoning_effort_wire_value(effort: ReasoningEffort) -> &'static str { } } -#[cfg(test)] -mod tests { - use pretty_assertions::assert_eq; +/// One entry in a [`ReasoningCapability::Levels`] list. +/// +/// Logical `off` disables reasoning; effort variants are the selectable depths. +/// Legacy `disabled` deserializes as [`Self::Off`]. +/// +/// On the wire this is a plain string (`"off"` or an effort label), not a tagged +/// object — keep the TypeScript alias aligned with that shape. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, TS)] +#[ts(type = "\"off\" | ReasoningEffort")] +pub enum ReasoningLevelChoice { + Off, + Effort(ReasoningEffort), +} - use super::ReasoningCapability; - use super::ReasoningEffort; - use super::ReasoningEffortOption; +impl ReasoningLevelChoice { + pub fn selection_value(self) -> &'static str { + match self { + Self::Off => "off", + Self::Effort(effort) => reasoning_effort_wire_value(effort), + } + } - #[test] - fn reasoning_effort_from_str_accepts_wire_values() { - assert_eq!("none".parse::(), Ok(ReasoningEffort::None)); - assert_eq!( - "minimal".parse::(), - Ok(ReasoningEffort::Minimal) - ); - assert_eq!("low".parse::(), Ok(ReasoningEffort::Low)); - assert_eq!( - "medium".parse::(), - Ok(ReasoningEffort::Medium) - ); - assert_eq!("high".parse::(), Ok(ReasoningEffort::High)); - assert_eq!( - "xhigh".parse::(), - Ok(ReasoningEffort::XHigh) - ); - assert_eq!("max".parse::(), Ok(ReasoningEffort::Max)); + pub fn effort(self) -> Option { + match self { + Self::Off => None, + Self::Effort(effort) => Some(effort), + } } - #[test] - fn reasoning_effort_from_str_preserves_serde_strictness() { - assert_eq!( - "High".parse::(), - Err("invalid reasoning_effort: High".to_string()) - ); - assert_eq!( - " high ".parse::(), - Err("invalid reasoning_effort: high ".to_string()) - ); + pub fn option(self) -> ReasoningEffortOption { + match self { + Self::Off => ReasoningEffortOption { + label: "Off".to_string(), + description: "Disable reasoning effort for this turn".to_string(), + value: "off".to_string(), + }, + Self::Effort(effort) => reasoning_effort_option_for_effort(effort), + } } +} - #[test] - fn reasoning_options_use_reasoning_effort_wire_values() { - assert_eq!( - ReasoningCapability::ToggleWithLevels(vec![ReasoningEffort::XHigh]).options(), - vec![ - ReasoningEffortOption { - label: "Off".to_string(), - description: "Disable reasoning effort for this turn".to_string(), - value: "disabled".to_string(), - }, - ReasoningEffortOption { - label: "XHigh".to_string(), - description: "Most deliberate, highest effort".to_string(), - value: "xhigh".to_string(), - }, - ] - ); +impl From for ReasoningLevelChoice { + fn from(effort: ReasoningEffort) -> Self { + Self::Effort(effort) + } +} + +impl Serialize for ReasoningLevelChoice { + fn serialize(&self, serializer: S) -> Result + where + S: serde::Serializer, + { + serializer.serialize_str(self.selection_value()) + } +} + +impl<'de> Deserialize<'de> for ReasoningLevelChoice { + fn deserialize(deserializer: D) -> Result + where + D: serde::Deserializer<'de>, + { + let raw = String::deserialize(deserializer)?; + let normalized = normalize_reasoning_effort_literal(&raw); + match normalized.as_str() { + "off" => Ok(Self::Off), + other => other + .parse::() + .map(Self::Effort) + .map_err(serde::de::Error::custom), + } + } +} + +impl JsonSchema for ReasoningLevelChoice { + fn schema_name() -> String { + "ReasoningLevelChoice".to_string() } + + fn json_schema(generator: &mut schemars::SchemaGenerator) -> schemars::schema::Schema { + String::json_schema(generator) + } +} + +/// Prepends [`ReasoningLevelChoice::Off`] when migrating legacy +/// `toggle_with_levels` arrays that listed only effort depths. +pub fn levels_with_leading_off( + efforts: impl IntoIterator, +) -> Vec { + let mut choices = vec![ReasoningLevelChoice::Off]; + for effort in efforts { + let choice = ReasoningLevelChoice::Effort(effort); + if !choices.contains(&choice) { + choices.push(choice); + } + } + choices } /// Maps reasoning efforts onto a stable numeric scale for comparison. @@ -314,20 +398,78 @@ pub struct ReasoningEffortOption { pub value: String, } -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema, TS)] +#[derive(Debug, Clone, PartialEq, Eq, Serialize, JsonSchema, TS)] #[serde(rename_all = "lowercase")] pub enum ReasoningCapability { /// Model reasoning effort cannot be controlled. Unsupported, /// Model reasoning effort can be toggled on and off. Toggle, - /// Multiple reasoning effort levels can be selected. - Levels(Vec), - /// Reasoning effort can be turned off, or enabled with one of several effort levels. - ToggleWithLevels(Vec), + /// Selectable reasoning chips in array order. Include [`ReasoningLevelChoice::Off`] + /// when the model can disable reasoning; omit it when reasoning is always on. + Levels(Vec), +} + +impl<'de> Deserialize<'de> for ReasoningCapability { + fn deserialize(deserializer: D) -> Result + where + D: serde::Deserializer<'de>, + { + let value = Value::deserialize(deserializer)?; + match value { + Value::String(raw) => match raw.trim().to_ascii_lowercase().as_str() { + "unsupported" => Ok(Self::Unsupported), + "toggle" => Ok(Self::Toggle), + other => Err(serde::de::Error::custom(format!( + "invalid reasoning_capability string '{other}'; expected unsupported or toggle" + ))), + }, + Value::Object(map) => { + if let Some(levels) = map.get("levels") { + let choices: Vec = + serde_json::from_value(levels.clone()).map_err(serde::de::Error::custom)?; + return Ok(Self::Levels(choices)); + } + let legacy_levels = map + .get("toggle_with_levels") + .or_else(|| map.get("togglewithlevels")); + if let Some(levels) = legacy_levels { + let efforts: Vec = + serde_json::from_value(levels.clone()).map_err(serde::de::Error::custom)?; + return Ok(Self::Levels(levels_with_leading_off(efforts))); + } + Err(serde::de::Error::custom( + "invalid reasoning_capability object; expected levels or legacy toggle_with_levels", + )) + } + _ => Err(serde::de::Error::custom( + "invalid reasoning_capability; expected string or object", + )), + } + } } impl ReasoningCapability { + /// Effort depths listed in a [`Self::Levels`] capability (excludes `off`). + pub fn effort_levels(&self) -> Vec { + match self { + Self::Levels(choices) => choices + .iter() + .copied() + .filter_map(ReasoningLevelChoice::effort) + .collect(), + Self::Unsupported | Self::Toggle => Vec::new(), + } + } + + /// True when [`Self::Levels`] includes logical `off` (hybrid / disableable). + pub fn allows_off(&self) -> bool { + matches!( + self, + Self::Levels(choices) if choices.iter().any(|choice| matches!(choice, ReasoningLevelChoice::Off)) + ) + } + pub fn options(&self) -> Vec { match self { ReasoningCapability::Unsupported => Vec::new(), @@ -335,39 +477,19 @@ impl ReasoningCapability { ReasoningEffortOption { label: "Off".to_string(), description: "Disable reasoning effort for this turn".to_string(), - value: "disabled".to_string(), + value: "off".to_string(), }, ReasoningEffortOption { label: "On".to_string(), description: "Enable model reasoning effort".to_string(), - value: "enabled".to_string(), + value: "on".to_string(), }, ], - ReasoningCapability::Levels(levels) => { - let mut presets = Vec::with_capacity(levels.len()); - presets.extend( - levels - .iter() - .copied() - .map(reasoning_effort_option_for_effort), - ); - presets - } - ReasoningCapability::ToggleWithLevels(levels) => { - let mut presets = Vec::with_capacity(levels.len() + 1); - presets.push(ReasoningEffortOption { - label: "Off".to_string(), - description: "Disable reasoning effort for this turn".to_string(), - value: "disabled".to_string(), - }); - presets.extend( - levels - .iter() - .copied() - .map(reasoning_effort_option_for_effort), - ); - presets - } + ReasoningCapability::Levels(levels) => levels + .iter() + .copied() + .map(ReasoningLevelChoice::option) + .collect(), } } } @@ -379,3 +501,122 @@ fn reasoning_effort_option_for_effort(effort: ReasoningEffort) -> ReasoningEffor value: reasoning_effort_wire_value(effort).to_string(), } } + +#[cfg(test)] +mod tests { + use pretty_assertions::assert_eq; + + use super::ReasoningCapability; + use super::ReasoningEffort; + use super::ReasoningEffortOption; + use super::ReasoningLevelChoice; + use super::levels_with_leading_off; + + #[test] + fn reasoning_effort_from_str_accepts_wire_values() { + assert_eq!("none".parse::(), Ok(ReasoningEffort::None)); + assert_eq!( + "minimal".parse::(), + Ok(ReasoningEffort::Minimal) + ); + assert_eq!("low".parse::(), Ok(ReasoningEffort::Low)); + assert_eq!( + "medium".parse::(), + Ok(ReasoningEffort::Medium) + ); + assert_eq!("high".parse::(), Ok(ReasoningEffort::High)); + assert_eq!( + "xhigh".parse::(), + Ok(ReasoningEffort::XHigh) + ); + assert_eq!("max".parse::(), Ok(ReasoningEffort::Max)); + } + + #[test] + fn reasoning_effort_from_str_preserves_serde_strictness() { + assert_eq!( + "High".parse::(), + Err("invalid reasoning_effort: High".to_string()) + ); + assert_eq!( + " high ".parse::(), + Err("invalid reasoning_effort: high ".to_string()) + ); + } + + #[test] + fn reasoning_options_use_reasoning_effort_wire_values() { + assert_eq!( + ReasoningCapability::Levels(levels_with_leading_off([ReasoningEffort::XHigh])) + .options(), + vec![ + ReasoningEffortOption { + label: "Off".to_string(), + description: "Disable reasoning effort for this turn".to_string(), + value: "off".to_string(), + }, + ReasoningEffortOption { + label: "XHigh".to_string(), + description: "Most deliberate, highest effort".to_string(), + value: "xhigh".to_string(), + }, + ] + ); + } + + #[test] + fn normalize_reasoning_effort_literal_maps_legacy_toggle_aliases() { + assert_eq!(super::normalize_reasoning_effort_literal("disabled"), "off"); + assert_eq!(super::normalize_reasoning_effort_literal("ENABLED"), "on"); + assert_eq!( + super::normalize_reasoning_effort_literal(" medium "), + "medium" + ); + } + + #[test] + fn toggle_with_levels_migrates_to_levels_with_leading_off() { + let canonical = + serde_json::from_str::(r#"{"toggle_with_levels":["low","high"]}"#) + .expect("canonical reasoning capability should deserialize"); + let legacy = + serde_json::from_str::(r#"{"togglewithlevels":["low","high"]}"#) + .expect("legacy reasoning capability should remain readable"); + + assert_eq!(canonical, legacy); + assert_eq!( + canonical, + ReasoningCapability::Levels(vec![ + ReasoningLevelChoice::Off, + ReasoningLevelChoice::Effort(ReasoningEffort::Low), + ReasoningLevelChoice::Effort(ReasoningEffort::High), + ]) + ); + assert_eq!( + serde_json::to_value(canonical).expect("reasoning capability should serialize"), + serde_json::json!({ + "levels": ["off", "low", "high"] + }) + ); + } + + #[test] + fn levels_accepts_off_in_array() { + let capability = + serde_json::from_str::(r#"{"levels":["off","low","high"]}"#) + .expect("levels with off should deserialize"); + assert_eq!( + capability, + ReasoningCapability::Levels(vec![ + ReasoningLevelChoice::Off, + ReasoningLevelChoice::Effort(ReasoningEffort::Low), + ReasoningLevelChoice::Effort(ReasoningEffort::High), + ]) + ); + assert!(capability.allows_off()); + assert_eq!( + capability.effort_levels(), + vec![ReasoningEffort::Low, ReasoningEffort::High] + ); + } +} From b0c2d070298ebad744c621275b852e1af9d5194f Mon Sep 17 00:00:00 2001 From: wangtsiao Date: Sat, 5 Sep 2026 02:34:31 +0800 Subject: [PATCH 02/13] feat(config): migrate providers.json catalog and connections Add catalog/migration/runtime modules, connection persistence, and legacy model_bindings migration onto provider/model overlays. --- crates/config/README.md | 349 +++-- crates/config/src/app.rs | 407 +++--- crates/config/src/app/mcp_store.rs | 2 +- crates/config/src/app_store_tests.rs | 371 +++++- crates/config/src/error.rs | 3 + crates/config/src/provider.rs | 579 +------- crates/config/src/provider/auth.rs | 50 +- crates/config/src/provider/catalog.rs | 1164 +++++++++++++++++ crates/config/src/provider/migration.rs | 480 +++++++ crates/config/src/provider/persistence.rs | 210 +-- crates/config/src/provider/request.rs | 147 +++ crates/config/src/provider/resolve.rs | 292 ----- crates/config/src/provider/runtime.rs | 121 ++ .../src/provider/runtime_equivalence.rs | 51 - crates/config/src/provider/schema.rs | 123 +- crates/config/src/provider_connection.rs | 390 ++++++ crates/config/src/tests.rs | 366 +++--- crates/config/src/tools.rs | 16 +- 18 files changed, 3403 insertions(+), 1718 deletions(-) create mode 100644 crates/config/src/provider/catalog.rs create mode 100644 crates/config/src/provider/migration.rs create mode 100644 crates/config/src/provider/request.rs delete mode 100644 crates/config/src/provider/resolve.rs create mode 100644 crates/config/src/provider/runtime.rs delete mode 100644 crates/config/src/provider/runtime_equivalence.rs create mode 100644 crates/config/src/provider_connection.rs diff --git a/crates/config/README.md b/crates/config/README.md index f7c9816a..4b9f0541 100644 --- a/crates/config/README.md +++ b/crates/config/README.md @@ -25,9 +25,10 @@ consumer crates. ## Config Files -The user-level config file is `/config.toml`. `DEVO_HOME` defaults to -`~/.devo`; if the environment variable is set, it must point to an existing -directory. +The user-level application config file is `/config.toml`. Provider +connections and model selection live in the standalone +`/providers.json` file. `DEVO_HOME` defaults to `~/.devo`; if the +environment variable is set, it must point to an existing directory. When a workspace is known, the project-level config file is: @@ -35,14 +36,41 @@ When a workspace is known, the project-level config file is: /.devo/config.toml ``` -Provider credentials are stored separately in: +When a workspace is known, its provider/model overlay is: ```text -/auth.json +/.devo/providers.json ``` -`auth.json` stores secret values, while `config.toml` stores references to those -credentials. +The canonical provider/model shape is intentionally small: + +```json +{ + "model": "local/my-model", + "provider": { + "local": { + "base_url": "http://127.0.0.1:8000/v1", + "credential": "local_api_key", + "wire_api": "openai_chat_completions", + "models": { + "my-model": {"name": "My Model", "context_window": 131072} + } + } + } +} +``` + +Provider ids and model ids are map keys; the only model reference exposed to +users is `provider/model`. There is no persisted binding id, model slug, model +name, model id, or model description. `crates/core/providers.json` is the +git-tracked built-in directory. User and workspace `providers.json` files may +add or override arbitrary providers and models. + +Provider API keys are stored in the user-scoped `auth.json`; `providers.json` +contains only the matching `credential` id. Do not commit `auth.json` or other +files containing real secrets. The old provider TOML tables remain readable as +migration compatibility paths, while new provider/model writes use JSON plus +the separate auth file. ## Load And Merge Order @@ -314,56 +342,119 @@ Config-ready but not currently triggered: ## Provider Config -Provider config is part of `config.toml` and is modeled by `ProviderConfigSection`. -The current provider schema uses provider vendor entries plus model bindings: +The canonical provider config is standalone JSON and is modeled by +`ProviderConfigFile`. Provider ids and model ids are map keys, so the only +model reference exposed to users is `provider/model`: -```toml -model = "gpt-5.4" -model_reasoning_effort_selection = "medium" -model_auto_compact_token_limit = 970000 -model_context_window = 997500 -disable_response_storage = true -preferred_auth_method = "apikey" - -[defaults] -model_binding = "gpt54-main" - -[providers.main] -enabled = true -name = "Main Provider" -base_url = "https://api.example.com/v1" -credential = "main_api_key" -wire_apis = ["openai_responses"] - -[model_bindings.gpt54-main] -enabled = true -model_slug = "gpt-5.4" -provider = "main" -request_model = "gpt-5.4" -invocation_method = "openai_responses" -default_reasoning_effort = "medium" +```json +{ + "model": "main/gpt-5.4", + "reasoning_effort": "medium", + "provider": { + "main": { + "enabled": true, + "name": "Main Provider", + "base_url": "https://api.example.com/v1", + "credential": "main_api_key", + "wire_api": "openai_responses", + "models": { + "gpt-5.4": { + "name": "GPT 5.4", + "default_reasoning_effort": "medium" + } + } + } + } +} ``` -Supported `wire_apis` and `invocation_method` values are: - -- `openai_chat_completions` -- `openai_responses` -- `anthropic_messages` - -The default `invocation_method` is `openai_chat_completions` when a model -binding omits it. +`ProviderConfigFile` also accepts nested model capability metadata and arbitrary +custom provider/model records. The git-tracked built-in directory is +`crates/core/providers.json`; user and workspace JSON files are overlays. + +The built-in directory covers Kimi, Z.ai and BigModel/Zhipu AI (each with +`glm-5.3` and `glm-5.3-flash`), DeepSeek, +Qwen, MiniMax, Xiaomi MiMo, Tencent Hunyuan, and a local Ollama template. +The Ollama entry uses `http://localhost:11434/v1` with an empty model list; +connected clients should refresh models through `provider/discover` +(`/v1/models`). This list is an overlayable catalog, not a restriction on +custom provider/model entries. + +The canonical root fields are: + +| Field | Type | Default | Meaning | +| --- | --- | --- | --- | +| `model` | string | first enabled model | Active `provider/model` reference. | +| `small_model` | string | automatic same-provider model, then `model` | Optional lower-cost model for lightweight background work. Invalid values use the same fallback. | +| `reasoning_effort` | string | model default | `default`, `off`, `on`, or a supported effort value. Legacy `disabled`/`enabled` normalize to `off`/`on`. | +| `provider` | object | `{}` | Provider id to provider record map. | + +Provider records support `name`, `base_url`, `credential`, string-to-string +`headers`, `wire_api`, `enabled`, `env`, +`web_search`, `web_fetch`, and a `models` map. All are optional; `wire_api` +defaults to `openai_chat_completions` and `enabled` defaults to `true`. +`credential` refers to a credential id in the user-scoped `auth.json`; the +secret value is never stored in `providers.json`. + +Model records support `name`, `wire_api`, `context_window`, +`effective_context_window_percent`, `max_tokens`, `temperature`, `top_p`, +`top_k`, `reasoning_capability`, `reasoning_implementation`, +`default_reasoning_effort`, `base_instructions`, `input_modalities`, `channel`, +`truncation_policy`, `supports_image_detail_original`, `enabled`, and +`priority`. They may also contain open-ended `family`, `release_date`, `status`, +`cost`, `metadata`, `options`, `request`, `headers`, `variants`, and +`default_variant` values. The nested model map key is both the provider-facing request id and +the `provider/model` identity; there is no separate `model_slug`, `model_name`, +`model_id`, or model `description` field. + +`wire_api` has exactly three values: + +| Value | Request family | +| --- | --- | +| `openai_chat_completions` | OpenAI-compatible Chat Completions | +| `openai_responses` | OpenAI-compatible Responses | +| `anthropic_messages` | Anthropic-compatible Messages | + +`reasoning_capability` is one of `"unsupported"`, `"toggle"`, or +`{"levels":[...]}`. Include `off` in `levels` to allow disabling; omit `off` +if reasoning cannot be turned off. Legacy `{"toggle_with_levels":[...]}` +(and spelling `togglewithlevels`) still reads and migrates to `levels` with a +leading `off`. Toggle options are `off`/`on`. Effort values +are `none`, `minimal`, `low`, `medium`, `high`, `xhigh`, and `max`. +`reasoning_implementation` is a legacy TOML-compatibility field. New JSON +should use `reasoning_capability` plus the named `variants` map (keys named +after logical selections; optional `request_model`) documented in +the configuration reference. The full +field reference, including JSON shapes for web capabilities and truncation, +is maintained in [`docs/configuration.md`](../../docs/configuration.md) and +its [Chinese version](../../docs/configuration.zh-Hans.md). + +Onboarding keeps the provider template directory separate from Connection +model management. Selecting a saved Connection lists only its nested models; +the user can add a custom model or remove a saved model with d/Delete. Model +removal updates the user Connection overlay and never edits the tracked +provider directory. + +Native `provider/discover` refreshes a connected Connection from its +`/models` or compatible `/v1/models` endpoint. It reads the credential from +the user-scoped `auth.json`, accepts OpenAI-style `data` arrays and provider +`models` arrays, normalizes common model metadata, and stores the raw entry in +the model's `metadata`. Discovery only updates the user Connection overlay; +the git-tracked `crates/core/providers.json` directory remains unchanged. + +The old provider TOML shape remains readable only as a one-time startup +migration input; it is not the canonical write format and is never used to +build runtime provider settings after loading. -`preferred_auth_method` accepts `apikey` and `api_key`; it serializes as -`apikey`. +## Provider Credentials -Legacy `[model_providers]` fields still deserialize into `ProviderConfigSection`, -but the provider resolver does not use legacy-only config to produce runtime -provider settings. +`providers.json` contains the provider connection and a credential reference: -## Provider Credentials +```json +{"provider":{"main":{"credential":"main_api_key"}}} +``` -`auth.json` is modeled by `UserAuthConfigFile`. Example -`/auth.json`: +The actual secret is stored in the user-scoped `auth.json`: ```json { @@ -377,20 +468,17 @@ provider settings. } ``` -The credential id, such as `main_api_key`, is referenced from provider config -with `credential = "main_api_key"`. - -Only `api_key` credentials are currently supported. Reading `auth.json` fails if -the schema version is unsupported or a credential value is empty. Missing -`auth.json` is treated as an empty credential file. +Do not put `apiKey` or `api_key` in `providers.json`. Reading an existing +`auth.json` fails if the schema version is unsupported or a credential value is +empty; a missing file is treated as an empty credential file. ## Web Search `[tools.web_search]` controls whether a turn exposes web search to the model. The effective value is resolved with this priority: -1. `[model_bindings..web_search]` -2. `[providers..web_search]` +1. The selected model's `web_search` object in `providers.json` +2. The selected provider's `web_search` object in `providers.json` 3. `[tools.web_search]` Supported modes: @@ -416,8 +504,8 @@ to the model. `[tools.web_fetch]` controls whether a turn exposes URL fetching to the model. It resolves with the same priority as web search: -1. `[model_bindings..web_fetch]` -2. `[providers..web_fetch]` +1. The selected model's `web_fetch` object in `providers.json` +2. The selected provider's `web_fetch` object in `providers.json` 3. `[tools.web_fetch]` Supported modes: @@ -433,27 +521,21 @@ Supported modes: ## Provider Resolution -`resolve_provider_settings_from_config_and_auth` chooses the active model -binding in this order: +The canonical resolver reads the standalone JSON file directly. It chooses the +active model in this order: -1. `[defaults].model_binding`, when it points to an existing binding. -2. The top-level `model`, when it matches a binding's `model_slug` or - `request_model`. -3. The first enabled model binding. +1. The top-level `model`, when it is a `provider/model` reference. +2. The first enabled model entry. -Runtime turn resolution uses an explicit requested model first, when it matches -an enabled binding's `model_slug` or `request_model`. Without a requested model, it -uses `[defaults].model_binding` only when that binding is enabled, then falls -back to the first enabled binding. +Runtime turn resolution uses an explicit canonical `provider/model` selection +and falls back to the first enabled directory model. -After a binding is selected, resolution requires: +After a model is selected, resolution requires: -- The binding's `provider` exists in `[providers]`. +- The provider exists in `provider`. - The provider is enabled. -- The binding is enabled. -- The binding's `model_slug` exists in the effective model catalog. -- If the provider lists `wire_apis`, the binding's `invocation_method` is in - that list. +- The model is enabled. +- The model's `wire_api`, or its provider's `wire_api`, is supported. - If the provider references a credential, that credential exists in `auth.json`. @@ -461,66 +543,49 @@ The resolved runtime settings contain the provider id, wire API, final model name, optional base URL, optional API key, model limits, reasoning effort selection, response-storage flag, and preferred auth method. -`model_slug` is the local catalog key. Model metadata starts from the built-in -catalog and is overlaid field-by-field from user and workspace `config.toml` -`[model.]` sections. Existing slugs are partial overrides; new slugs create -custom models with safe defaults. `request_model` is the provider-facing model id -used for the API request. The legacy `model_name` key is accepted when reading -existing configuration, while subsequent writes use `request_model`. Turn -metadata records `model` as the catalog slug and `request_model` as the provider -request model; these values may be identical. - -Model metadata `provider` describes the wire API and accepts -`openai_chat_completions`, `openai_responses`, or `anthropic_messages`. The -binding's `invocation_method` is the operational connection choice and should -match that metadata. A usable custom model therefore needs a -`[providers.]` connection and `[model_bindings.]` binding; the provider's -optional `credential` points to an API key stored in `auth.json`. +Model metadata starts from the tracked provider directory and is overlaid +field-by-field from user and workspace `providers.json` files. Repeating a +provider/model entry partially overrides it; a new nested model is a custom +model with safe defaults. The nested model key is also the provider-facing id +used for the API request, so there is no `model_slug`, `request_model`, or +`model_name` alias in new config. A built-in partial override can be as small as: -```toml -[model.qwen3-coder-next] -context_window = 262144 -effective_context_window_percent = 90 +```json +{"provider":{"deepseek":{"models":{"deepseek-v4-flash":{"context_window":262144,"effective_context_window_percent":90}}}}} ``` -A custom model must also be selected through connection wiring: +A custom model is selected directly through its provider/model reference: -```toml -[defaults] -model_binding = "custom-example" - -[model.custom] -display_name = "Custom" -provider = "openai_responses" -context_window = 128000 -reasoning_capability = { levels = ["low", "medium", "high"] } -reasoning_implementation = "request_parameter" -default_reasoning_effort = "medium" - -[providers.example] -enabled = true -name = "Example" -base_url = "https://api.example.com/v1" -credential = "example_api_key" -wire_apis = ["openai_responses"] - -[model_bindings.custom-example] -enabled = true -model_slug = "custom" -provider = "example" -request_model = "provider-facing-model-id" -invocation_method = "openai_responses" +```json +{ + "model": "example/custom", + "provider": { + "example": { + "base_url": "https://api.example.com/v1", + "credential": "example_api_key", + "wire_api": "openai_responses", + "models": { + "custom": { + "name": "Custom", + "context_window": 128000, + "reasoning_capability": {"levels": ["low", "medium", "high"]}, + "reasoning_implementation": "request_parameter", + "default_reasoning_effort": "medium" + } + } + } + } +} ``` -`ModelOverrideConfig` exposes `display_name`, `description`, `channel`, +`ProviderModelConfig` exposes `name`, `channel`, `context_window`, `effective_context_window_percent`, `max_tokens`, `temperature`, -`top_p`, `top_k`, `provider`, `reasoning_capability`, +`top_p`, `top_k`, `wire_api`, `reasoning_capability`, `reasoning_implementation`, `default_reasoning_effort`, `base_instructions`, `input_modalities`, `truncation_policy`, and `supports_image_detail_original`. -`display_name` is the picker label, `description` is its explanatory text, and -`channel` groups related models. The effective context is +`name` is the picker label and `channel` groups related models. The effective context is `context_window * effective_context_window_percent / 100` and is also the automatic-compaction boundary; `max_tokens` is the default response-output limit. `temperature`, `top_p`, and `top_k` are request sampling defaults. @@ -534,24 +599,26 @@ enables original-resolution image detail. Omitted built-in fields are preserved. Omitted custom-model `base_instructions` use the default instructions, while an explicit empty string means no base instructions. -Old `/models.json` and `/.devo/models.json` files are -ignored. Migration is manual: copy desired fields into the corresponding user or -workspace `config.toml` `[model.]` sections. The legacy top-level scalar -`model = "slug"` remains readable, but it collides with the new `model` table -namespace, so new configuration must select with `[defaults].model_binding`. +The old TOML provider, binding, and model override fields remain readable as a +compatibility input. On startup, they are migrated to the matching +`providers.json` overlay before model resolution. User TOML moves to +`/providers.json`; workspace TOML moves to +`/.devo/providers.json`. Existing JSON values win, API keys are +copied to user-scoped `auth.json`, and unrelated app settings stay in +`config.toml`. The migration is idempotent. The tracked +`crates/core/providers.json` file is the canonical built-in directory. -When reasoning effort resolution selects a model variant catalog slug, the provider -request model is resolved from enabled bindings for the same provider as the -selected turn binding. Duplicate `model_slug` values under other providers do -not affect that request. +When reasoning effort resolution selects a model variant, the provider request +model is resolved within the selected provider namespace. ## Writing Provider Config -Provider writes use atomic file replacement. They preserve unrelated TOML in -`config.toml` and only overlay provider-owned keys. +Provider writes use atomic file replacement. They write `providers.json` and +preserve unrelated application settings in `config.toml`. -`AppConfigStore::upsert_provider_vendor` writes provider vendors and model -bindings to the user config. Project config may still override resolved -settings, but onboarding and provider management persist shared provider setup in -the user-level `config.toml`. The upsert rejects provider vendors with an empty -`wire_apis` list and reloads the effective app config after a successful write. +`AppConfigStore::upsert_provider_connection` writes a provider Connection and +nested model record to the user-level `providers.json` file. The optional API +key argument is written to the user-scoped `auth.json`; only its credential id +is stored in `providers.json`. Project config may still override resolved +settings. Disconnecting a Connection removes its user overlay; built-in +provider templates are never modified. diff --git a/crates/config/src/app.rs b/crates/config/src/app.rs index 37ac1836..685a725f 100644 --- a/crates/config/src/app.rs +++ b/crates/config/src/app.rs @@ -1,4 +1,5 @@ use std::collections::BTreeMap; +use std::collections::BTreeSet; use std::collections::HashSet; use std::fs; use std::path::Path; @@ -6,8 +7,6 @@ use std::path::PathBuf; use devo_protocol::CollaborationMode; use devo_protocol::PermissionPreset; -use devo_protocol::ProviderModelBinding; -use devo_protocol::ProviderVendor; use serde::Deserialize; use serde::Serialize; @@ -16,7 +15,6 @@ use devo_util_paths::APP_CONFIG_DIR_NAME; use devo_util_paths::APP_CONFIG_FILE_NAME; use devo_util_paths::FileSystemConfigPathResolver; -use crate::AUTH_CONFIG_FILE_NAME; use crate::AppConfigError; use crate::ExperimentalConfig; use crate::HooksConfig; @@ -27,27 +25,26 @@ use crate::McpConfig; use crate::McpHostConfig; use crate::McpServerId; use crate::McpServerRecordToml; -use crate::ModelBindingConfig; use crate::OAuthCredentialsStoreMode; use crate::PermissionConfig; use crate::ProviderConfigError; +use crate::ProviderConfigFile; use crate::ProviderConfigSection; use crate::ProviderHttpConfig; -use crate::ResolvedProviderSettings; use crate::ServerConfig; use crate::SkillsConfig; use crate::ToolsConfig; use crate::non_empty_string; -use crate::provider_vendor_from_config; -use crate::read_provider_config; +use crate::provider::migrate_legacy_provider_config_on_startup; +use crate::read_provider_catalog_config; use crate::read_provider_config_document; -use crate::read_user_auth_config; -use crate::resolve_provider_settings_from_config_and_auth; -use crate::upsert_user_auth_api_key; +use crate::remove_user_auth_credential; use crate::write_atomic; -use crate::write_provider_config; +use crate::write_provider_catalog_config; mod mcp_store; +#[path = "provider_connection.rs"] +mod provider_connection; pub use mcp_store::mcp_server_record_for_cli; @@ -92,6 +89,9 @@ pub struct AppConfig { /// Provider, model, and active model defaults. #[serde(flatten)] pub provider: ProviderConfigSection, + /// Effective standalone provider/model JSON configuration. + #[serde(skip, default)] + pub provider_catalog: ProviderConfigFile, /// HTTP transport settings shared by model-provider requests. #[serde(default, skip_serializing_if = "ProviderHttpConfig::is_empty")] pub provider_http: ProviderHttpConfig, @@ -150,8 +150,9 @@ pub enum SummaryModelSelection { /// priority order: /// /// 1. command-line startup arguments -/// 2. `/.devo/config.toml` for the currently opened project -/// 3. the user config file under the configured config directory +/// 2. `/.devo/config.toml` and `providers.json` overlays for the +/// currently opened project +/// 3. the user config files under the configured config directory /// /// When the same field appears in multiple sources, the higher-priority source /// must win. @@ -197,6 +198,7 @@ impl Default for AppConfig { hooks: HooksConfig::default(), permission: PermissionConfig::default(), provider: ProviderConfigSection::default(), + provider_catalog: ProviderConfigFile::default(), provider_http: ProviderHttpConfig::default(), updates: UpdatesConfig { enabled: true, @@ -254,144 +256,125 @@ impl AppConfigStore { .expect("user config file should have a parent directory") } - /// Returns the configured provider vendors from the effective config. - pub fn provider_vendors(&self) -> Vec { - self.config - .provider - .providers - .iter() - .map(|(provider_id, provider_config)| { - provider_vendor_from_config(provider_id, provider_config) - }) - .collect() + /// Returns the standalone user provider/model configuration path. + pub fn user_provider_config_file(&self) -> PathBuf { + self.user_config_dir() + .join(crate::PROVIDER_CONFIG_FILE_NAME) } - /// Upserts a provider vendor and refreshes the shared effective app config. - pub fn upsert_provider_vendor( - &mut self, - provider_id: String, - provider_vendor: ProviderVendor, - model_binding: Option, - default_model_binding: Option, - api_key: Option, - ) -> anyhow::Result { - if provider_vendor.wire_apis.is_empty() { - anyhow::bail!("wire_apis must contain at least one wire API"); - } - if let Some(binding) = &model_binding { - validate_provider_model_binding(&provider_id, &provider_vendor, binding)?; - } + /// Returns provider ids that have a user-created Connection. + /// + /// Built-in directory entries are intentionally excluded. A provider is + /// connected when it exists in the user provider overlay (or in the old + /// TOML provider section that is still being migrated). + pub fn provider_connection_ids(&self) -> anyhow::Result> { + let target_config_file = self.user_provider_config_file(); + Ok(read_provider_catalog_config(&target_config_file)? + .providers + .into_keys() + .collect()) + } - let target_config_file = self.user_config_file.as_path(); - let mut config = read_provider_config(target_config_file)?; - let credential_id = if let Some(api_key) = api_key.as_deref().and_then(non_empty_string) { - let credential_id = provider_vendor - .credential - .as_deref() - .and_then(non_empty_string) - .unwrap_or_else(|| credential_id_for_provider(&provider_id)); - let user_config_dir = self - .user_config_file - .parent() - .ok_or_else(|| anyhow::anyhow!("user config file has no parent directory"))?; - upsert_user_auth_api_key(user_config_dir, &credential_id, &api_key)?; - Some(credential_id) - } else { - provider_vendor - .credential - .as_deref() - .and_then(non_empty_string) + /// Disconnects a user-created provider Connection. + /// + /// This removes the user's provider overlay, model entries rooted at that + /// provider, and an unshared credential from auth.json. Built-in catalog + /// entries remain available for a future connection. + pub fn disconnect_provider(&mut self, provider_id: &str) -> anyhow::Result<()> { + let provider_id = non_empty_string(provider_id) + .ok_or_else(|| anyhow::anyhow!("provider id must not be empty"))?; + let target_config_file = self.user_provider_config_file(); + let mut config = read_provider_catalog_config(&target_config_file)?; + + let Some(removed_provider) = config.providers.remove(&provider_id) else { + return Ok(()); }; - let entry = config.providers.entry(provider_id.clone()).or_default(); - entry.name = provider_vendor.name.trim().to_string(); - entry.base_url = provider_vendor - .base_url - .as_deref() - .and_then(non_empty_string); - entry.credential = credential_id; - entry.headers = provider_vendor - .headers + let credential_id = removed_provider.credential; + let provider_prefix = format!("{provider_id}/"); + if config + .model .as_deref() - .and_then(non_empty_string); - entry.wire_apis = provider_vendor.wire_apis.clone(); - entry.enabled = provider_vendor.enabled; - - if let Some(binding) = &model_binding { - config.model_bindings.insert( - binding.binding_id.clone(), - ModelBindingConfig { - model_slug: binding.model_slug.trim().to_string(), - provider: binding.provider.trim().to_string(), - request_model: binding.request_model.trim().to_string(), - display_name: binding.display_name.as_deref().and_then(non_empty_string), - invocation_method: binding.invocation_method, - default_reasoning_effort: binding - .default_reasoning_effort - .as_deref() - .and_then(non_empty_string), - web_search: None, - web_fetch: None, - enabled: binding.enabled, - }, - ); + .is_some_and(|model| model == provider_id || model.starts_with(&provider_prefix)) + { + config.model = None; } - if let Some(binding_id) = default_model_binding.as_deref().and_then(non_empty_string) { - if !config.model_bindings.contains_key(&binding_id) { - anyhow::bail!("default model binding `{binding_id}` does not exist"); - } - config.defaults.model_binding = Some(binding_id); + if config + .small_model + .as_deref() + .is_some_and(|model| model == provider_id || model.starts_with(&provider_prefix)) + { + config.small_model = None; } - write_provider_config(target_config_file, &config)?; + let remaining_credentials = config + .providers + .values() + .filter_map(|provider| provider.credential.as_deref()) + .collect::>(); + write_provider_catalog_config(&target_config_file, &config)?; + migrate_legacy_provider_config_file(&self.user_config_file)?; + if let Some(credential_id) = credential_id + && !remaining_credentials.contains(credential_id.as_str()) + { + remove_user_auth_credential(self.user_config_dir(), &credential_id) + .map_err(|error| anyhow::anyhow!(error))?; + } self.config = self .loader .load(self.workspace_root.as_deref()) .map_err(|error| anyhow::anyhow!(error))?; - - Ok(provider_vendor_from_config( - &provider_id, - self.config - .provider - .providers - .get(&provider_id) - .expect("provider entry should exist after upsert"), - )) + Ok(()) } - /// Persists a user-level model default option and refreshes effective config. pub fn set_model_config_option(&mut self, config_id: &str, value: &str) -> anyhow::Result<()> { let value = value.trim(); if value.is_empty() { anyhow::bail!("model config value must not be empty"); } - let target_config_file = self.user_config_file.as_path(); + let target_config_file = self.user_provider_config_file(); if let Some(parent) = target_config_file.parent() { fs::create_dir_all(parent)?; } - let mut config = read_provider_config(target_config_file)?; + let mut config = read_provider_catalog_config(&target_config_file)?; match config_id { "model" => { - let binding = config - .model_bindings - .get(value) - .ok_or_else(|| anyhow::anyhow!("model binding `{value}` does not exist"))?; - if !binding.enabled { - anyhow::bail!("model binding `{value}` is disabled"); + let (provider_id, model_id) = value + .split_once('/') + .ok_or_else(|| anyhow::anyhow!("model must use `provider/model` form"))?; + let provider = config + .providers + .get(provider_id) + .ok_or_else(|| anyhow::anyhow!("provider `{provider_id}` does not exist"))?; + if provider.enabled == Some(false) + || provider + .models + .get(model_id) + .is_some_and(|model| model.enabled == Some(false)) + { + anyhow::bail!("model `{value}` is disabled"); } - config.defaults.model_binding = Some(value.to_string()); + config + .providers + .get_mut(provider_id) + .expect("provider was checked above") + .models + .entry(model_id.to_string()) + .or_default(); + config.model = Some(value.to_string()); } "thought_level" => { - config.model_reasoning_effort_selection = Some(value.to_string()); + config.reasoning_effort = Some(value.to_string()); } _ => { anyhow::bail!("unknown model config option `{config_id}`"); } } - write_provider_config(target_config_file, &config)?; + write_provider_catalog_config(&target_config_file, &config)?; + migrate_legacy_provider_config_file(&self.user_config_file)?; self.config = self .loader @@ -510,67 +493,42 @@ impl AppConfigStore { } } -fn validate_provider_model_binding( - provider_id: &str, - provider_vendor: &ProviderVendor, - binding: &ProviderModelBinding, -) -> anyhow::Result<()> { - if binding.binding_id.trim().is_empty() { - anyhow::bail!("model binding id cannot be empty"); - } - if binding.model_slug.trim().is_empty() { - anyhow::bail!("model binding model_slug cannot be empty"); - } - if binding.request_model.trim().is_empty() { - anyhow::bail!("model binding request_model cannot be empty"); - } - if binding.provider.trim() != provider_id { - anyhow::bail!("model binding provider must match provider vendor"); - } - if !provider_vendor - .wire_apis - .contains(&binding.invocation_method) +/// Removes provider/model binding tables after they have been migrated to JSON. +pub fn migrate_legacy_provider_config_file(config_file: &Path) -> Result<(), ProviderConfigError> { + if !config_file.exists() { + return Ok(()); + } + + let mut document = read_provider_config_document(config_file)?; + let table = ensure_toml_table(&mut document); + let mut changed = false; + for key in [ + "model_provider", + "model", + "model_reasoning_effort_selection", + "providers", + "model_bindings", + "model_providers", + ] { + changed |= table.remove(key).is_some(); + } + if let Some(defaults) = table + .get_mut("defaults") + .and_then(toml::Value::as_table_mut) { - anyhow::bail!("model binding invocation_method must be supported by provider vendor"); - } - Ok(()) -} - -fn credential_id_for_provider(provider_id: &str) -> String { - let mut out = String::with_capacity(provider_id.len()); - for ch in provider_id.chars() { - if ch.is_ascii_alphanumeric() { - out.push(ch.to_ascii_lowercase()); - } else if !out.is_empty() && !out.ends_with('_') { - out.push('_'); + changed |= defaults.remove("model_binding").is_some(); + if defaults.is_empty() { + changed |= table.remove("defaults").is_some(); } } - if out.ends_with('_') { - out.pop(); - } - out.push_str("_api_key"); - out -} - -#[cfg(test)] -mod app_tests { - use pretty_assertions::assert_eq; - - use super::credential_id_for_provider; - - #[test] - fn credential_id_normalizes_provider_id_without_extra_allocation_suffix() { - let cases = [ - ("OpenRouter", "openrouter_api_key"), - ("deep-seek", "deep_seek_api_key"), - ("__custom/provider__", "custom_provider_api_key"), - ("---", "_api_key"), - ]; - - for (provider_id, expected) in cases { - assert_eq!(credential_id_for_provider(provider_id), expected); - } + if changed { + let data = + toml::to_string_pretty(&document).map_err(|error| ProviderConfigError::Serialize { + message: error.to_string(), + })?; + write_atomic(config_file, data.as_bytes())?; } + Ok(()) } #[cfg(test)] @@ -578,26 +536,20 @@ mod app_tests { mod app_store_tests; impl AppConfig { - /// Resolves the active provider settings from this already-merged config. - /// - /// `user_config_dir` is used only for user-scoped auth material such as - /// `auth.json`; provider selection itself comes from this `AppConfig`. - pub fn resolve_provider_settings( - &self, - user_config_dir: &Path, - ) -> Result { - let auth = read_user_auth_config(&user_config_dir.join(AUTH_CONFIG_FILE_NAME))?; - let mut resolved = resolve_provider_settings_from_config_and_auth(&self.provider, &auth)?; - resolved.proxy_url = self.provider_http.proxy_url.clone(); - resolved.no_proxy = self.provider_http.no_proxy.clone(); - Ok(resolved) - } - /// Returns true when the merged config contains any provider-era setup. pub fn has_provider_configuration(&self) -> bool { - !self.provider.providers.is_empty() - || !self.provider.model_bindings.is_empty() - || !self.provider.model_providers.is_empty() + let provider_catalog = self.provider_catalog_config(); + !provider_catalog.providers.is_empty() + } + + /// Returns the effective JSON provider/model catalog, with a compatibility + /// projection for callers that construct only the legacy TOML shape. + pub fn provider_catalog_config(&self) -> ProviderConfigFile { + if self.provider_catalog == ProviderConfigFile::default() { + ProviderConfigFile::from_provider_config_section(&self.provider) + } else { + self.provider_catalog.clone() + } } } @@ -698,6 +650,17 @@ impl FileSystemAppConfigLoader { .join(APP_CONFIG_DIR_NAME) .join(APP_CONFIG_FILE_NAME) } + + fn user_provider_config_path(&self) -> PathBuf { + self.config_folder_home + .join(crate::PROVIDER_CONFIG_FILE_NAME) + } + + fn project_provider_config_path(&self, workspace_root: &Path) -> PathBuf { + workspace_root + .join(APP_CONFIG_DIR_NAME) + .join(crate::PROVIDER_CONFIG_FILE_NAME) + } } impl AppConfigLoader for FileSystemAppConfigLoader { @@ -707,35 +670,90 @@ impl AppConfigLoader for FileSystemAppConfigLoader { let mut merged = toml::Value::try_from(AppConfig::default()) .expect("default app config must serialize to TOML"); let mut provider_config = ProviderConfigSection::default(); + let mut provider_catalog = ProviderConfigFile::default(); let user_path = self.user_config_path(); + let user_provider_path = self.user_provider_config_path(); + migrate_legacy_provider_config_on_startup( + &user_path, + &user_provider_path, + &self.config_folder_home, + ) + .map_err(|source| AppConfigError::Provider { source })?; if user_path.exists() { let user_config = read_config_value(&user_path)?; provider_config.merge_overlay( provider_section_from_value(&user_path, &user_config)?, &user_config, ); + provider_catalog.merge_overlay(ProviderConfigFile::from_provider_config_section( + &provider_section_from_value(&user_path, &user_config)?, + )); merge_app_config_values(&mut merged, user_config); } + if user_provider_path.exists() { + let user_provider_file = read_provider_catalog_config(&user_provider_path) + .map_err(|source| AppConfigError::Provider { source })?; + let user_provider_section = user_provider_file.to_provider_config_section(); + let user_provider_source = + toml::Value::try_from(&user_provider_section).map_err(|error| { + AppConfigError::Provider { + source: ProviderConfigError::Serialize { + message: error.to_string(), + }, + } + })?; + provider_config.merge_overlay(user_provider_section, &user_provider_source); + provider_catalog.merge_overlay(user_provider_file); + } + if let Some(workspace_root) = workspace_root { let project_path = self.project_config_path(workspace_root); + let project_provider_path = self.project_provider_config_path(workspace_root); + migrate_legacy_provider_config_on_startup( + &project_path, + &project_provider_path, + &self.config_folder_home, + ) + .map_err(|source| AppConfigError::Provider { source })?; if project_path.exists() { let project_config = read_config_value(&project_path)?; provider_config.merge_overlay( provider_section_from_value(&project_path, &project_config)?, &project_config, ); + provider_catalog.merge_overlay(ProviderConfigFile::from_provider_config_section( + &provider_section_from_value(&project_path, &project_config)?, + )); merge_app_config_values(&mut merged, project_config); } + + if project_provider_path.exists() { + let project_provider_file = read_provider_catalog_config(&project_provider_path) + .map_err(|source| AppConfigError::Provider { source })?; + let project_provider_section = project_provider_file.to_provider_config_section(); + let project_provider_source = toml::Value::try_from(&project_provider_section) + .map_err(|error| AppConfigError::Provider { + source: ProviderConfigError::Serialize { + message: error.to_string(), + }, + })?; + provider_config.merge_overlay(project_provider_section, &project_provider_source); + provider_catalog.merge_overlay(project_provider_file); + } } - provider_config.merge_overlay( - provider_section_from_value(Path::new(""), &self.cli_overrides)?, - &self.cli_overrides, - ); + let cli_provider_section = + provider_section_from_value(Path::new(""), &self.cli_overrides)?; + provider_catalog.merge_overlay(ProviderConfigFile::from_provider_config_section( + &cli_provider_section, + )); + provider_config.merge_overlay(cli_provider_section, &self.cli_overrides); merge_app_config_values_ref(&mut merged, &self.cli_overrides); + provider_catalog.apply_model_overrides(&provider_config.model_overrides); + let mut config: AppConfig = merged .try_into() @@ -744,6 +762,7 @@ impl AppConfigLoader for FileSystemAppConfigLoader { message: source.to_string(), })?; config.provider = provider_config; + config.provider_catalog = provider_catalog; // Build normalized MCP runtime config from the persisted TOML shape. let servers = config diff --git a/crates/config/src/app/mcp_store.rs b/crates/config/src/app/mcp_store.rs index 489da5b9..457933ac 100644 --- a/crates/config/src/app/mcp_store.rs +++ b/crates/config/src/app/mcp_store.rs @@ -30,7 +30,7 @@ impl AppConfigStore { let mut document = read_provider_config_document(target_config_file)?; let mcp_servers = mcp_servers_table_mut(&mut document)?; let id = record.id.0.as_str(); - let server_value = toml::Value::try_from(&McpServerRecordToml::from(&record)) + let server_value = toml::Value::try_from(McpServerRecordToml::from(&record)) .map_err(|error| anyhow::anyhow!("failed to serialize mcp server: {error}"))?; mcp_servers.insert(id.to_string(), server_value); diff --git a/crates/config/src/app_store_tests.rs b/crates/config/src/app_store_tests.rs index 00f1c503..826c9886 100644 --- a/crates/config/src/app_store_tests.rs +++ b/crates/config/src/app_store_tests.rs @@ -1,9 +1,69 @@ use pretty_assertions::assert_eq; +use std::collections::BTreeMap; use std::fs; use std::time::SystemTime; use super::APP_CONFIG_FILE_NAME; use super::AppConfigStore; +use crate::PROVIDER_CONFIG_FILE_NAME; +use crate::read_user_auth_config; +use devo_protocol::ProviderInfo; +use devo_protocol::ProviderModelInfo; +use devo_protocol::ProviderWireApi; + +#[test] +fn loader_reads_standalone_provider_catalog_and_projects_provider_model() { + let root = unique_temp_dir("provider-json-load"); + let home = root.join("home").join(".devo"); + fs::create_dir_all(&home).expect("create config dir"); + fs::write( + home.join(PROVIDER_CONFIG_FILE_NAME), + r#" +{ + "model": "local/qwen3", + "provider": { + "local": { + "name": "Local Gateway", + "base_url": "http://127.0.0.1:8000/v1", + "credential": "local_key", + "wire_api": "openai_chat_completions", + "models": { + "qwen3": { + "name": "Qwen 3", + "context_window": 131072 + } + } + } + } +} +"#, + ) + .expect("write provider config"); + + let store = AppConfigStore::load(home, None).expect("load config"); + let config = store.effective_config(); + assert_eq!(config.provider.model.as_deref(), Some("local/qwen3")); + assert_eq!( + config.provider.defaults.model_binding.as_deref(), + Some("local/qwen3") + ); + assert_eq!( + config.provider.model_bindings["local/qwen3"].request_model, + "qwen3" + ); + assert_eq!( + config.provider.providers["local"].base_url.as_deref(), + Some("http://127.0.0.1:8000/v1") + ); + assert_eq!( + config.provider_catalog.providers["local"].models["qwen3"] + .name + .as_deref(), + Some("Qwen 3") + ); + + let _ = fs::remove_dir_all(root); +} fn unique_temp_dir(label: &str) -> std::path::PathBuf { let nonce = SystemTime::now() @@ -56,17 +116,14 @@ enabled = true let mut store = AppConfigStore::load(home.clone(), None).expect("load config"); store - .set_model_config_option("model", "alt-binding") + .set_model_config_option("model", "openai/alt-model") .expect("write model default"); - let config_text = fs::read_to_string(home.join(APP_CONFIG_FILE_NAME)).expect("read config"); - let document: toml::Value = toml::from_str(&config_text).expect("parse config"); - assert_eq!( - document["defaults"]["model_binding"].as_str(), - Some("alt-binding") - ); - assert_eq!(document["model_provider"].as_str(), Some("openai")); - assert_eq!(document["model"].as_str(), Some("test-model")); + let config_text = + fs::read_to_string(home.join(PROVIDER_CONFIG_FILE_NAME)).expect("read provider config"); + let document: serde_json::Value = + serde_json::from_str(&config_text).expect("parse provider config"); + assert_eq!(document["model"].as_str(), Some("openai/alt-model")); assert_eq!( store .effective_config() @@ -74,7 +131,7 @@ enabled = true .defaults .model_binding .as_deref(), - Some("alt-binding") + Some("openai/alt-model") ); let _ = fs::remove_dir_all(root); @@ -98,12 +155,11 @@ model_reasoning_effort_selection = "medium" .set_model_config_option("thought_level", "high") .expect("write reasoning default"); - let config_text = fs::read_to_string(home.join(APP_CONFIG_FILE_NAME)).expect("read config"); - let document: toml::Value = toml::from_str(&config_text).expect("parse config"); - assert_eq!( - document["model_reasoning_effort_selection"].as_str(), - Some("high") - ); + let config_text = + fs::read_to_string(home.join(PROVIDER_CONFIG_FILE_NAME)).expect("read provider config"); + let document: serde_json::Value = + serde_json::from_str(&config_text).expect("parse provider config"); + assert_eq!(document["reasoning_effort"].as_str(), Some("high")); assert_eq!( store .effective_config() @@ -180,3 +236,286 @@ fn set_compaction_token_limit_rejects_values_above_i64_max() { let _ = fs::remove_dir_all(root); } + +#[test] +fn startup_migrates_legacy_provider_config_to_catalog_and_auth() { + let root = unique_temp_dir("provider-startup-migration"); + let home = root.join(".devo"); + fs::create_dir_all(&home).expect("create config dir"); + fs::write( + home.join(APP_CONFIG_FILE_NAME), + r#" +theme = "aurora" +model_provider = "zhipu" +model = "glm-5.3" + +[model_providers.zhipu] +name = "Zhipu" +base_url = "https://open.bigmodel.cn/api" +api_key = "zhipu-secret" +wire_api = "anthropic_messages" + +[[model_providers.zhipu.models]] +model = "glm-5.3" + +[[model_providers.zhipu.models]] +model = "glm-5.3-flash" +"#, + ) + .expect("write legacy config"); + + let store = AppConfigStore::load(home.clone(), None).expect("migrate config on startup"); + let provider_file = home + .join(PROVIDER_CONFIG_FILE_NAME) + .to_string_lossy() + .into_owned(); + let provider_document: serde_json::Value = + serde_json::from_str(&fs::read_to_string(&provider_file).expect("read provider catalog")) + .expect("parse provider catalog"); + assert_eq!(provider_document["model"].as_str(), Some("zhipu/glm-5.3")); + assert_eq!( + provider_document["provider"]["zhipu"]["base_url"].as_str(), + Some("https://open.bigmodel.cn/api") + ); + assert_eq!( + provider_document["provider"]["zhipu"]["credential"].as_str(), + Some("zhipu_api_key") + ); + assert_eq!( + provider_document["provider"]["zhipu"]["models"]["glm-5.3"] + .get("wire_api") + .and_then(serde_json::Value::as_str), + Some("anthropic_messages") + ); + assert_eq!( + store.effective_config().provider.model.as_deref(), + Some("zhipu/glm-5.3") + ); + + let auth_document: serde_json::Value = serde_json::from_str( + &fs::read_to_string(home.join("auth.json")).expect("read auth config"), + ) + .expect("parse auth config"); + assert_eq!( + auth_document["credentials"]["zhipu_api_key"]["value"].as_str(), + Some("zhipu-secret") + ); + + let legacy_document: toml::Value = toml::from_str( + &fs::read_to_string(home.join(APP_CONFIG_FILE_NAME)).expect("read migrated config"), + ) + .expect("parse migrated config"); + assert_eq!(legacy_document["theme"].as_str(), Some("aurora")); + assert!(legacy_document.get("model_provider").is_none()); + assert!(legacy_document.get("model_providers").is_none()); + assert!(legacy_document.get("model").is_none()); + + let catalog_before_restart = + fs::read_to_string(home.join(PROVIDER_CONFIG_FILE_NAME)).expect("read catalog"); + let auth_before_restart = fs::read_to_string(home.join("auth.json")).expect("read auth"); + AppConfigStore::load(home.clone(), None).expect("reload migrated config"); + assert_eq!( + fs::read_to_string(home.join(PROVIDER_CONFIG_FILE_NAME)).expect("read catalog"), + catalog_before_restart + ); + assert_eq!( + fs::read_to_string(home.join("auth.json")).expect("read auth"), + auth_before_restart + ); + + let _ = fs::remove_dir_all(root); +} + +#[test] +fn startup_migration_keeps_existing_json_values_over_legacy_toml() { + let root = unique_temp_dir("provider-startup-migration-overlay"); + let home = root.join(".devo"); + fs::create_dir_all(&home).expect("create config dir"); + fs::write( + home.join(APP_CONFIG_FILE_NAME), + r#" +model_provider = "legacy" +model = "legacy-model" + +[providers.legacy] +name = "Legacy" +base_url = "https://old.example/v1" +api_key = "old-secret" +wire_apis = ["openai_chat_completions"] +"#, + ) + .expect("write legacy config"); + fs::write( + home.join(PROVIDER_CONFIG_FILE_NAME), + r#" +{ + "model": "legacy/new-model", + "provider": { + "legacy": { + "base_url": "https://new.example/v1", + "models": { + "new-model": { + "name": "New model" + } + } + } + } +} +"#, + ) + .expect("write existing provider catalog"); + + AppConfigStore::load(home.clone(), None).expect("migrate config on startup"); + + let provider_document: serde_json::Value = serde_json::from_str( + &fs::read_to_string(home.join(PROVIDER_CONFIG_FILE_NAME)).expect("read provider catalog"), + ) + .expect("parse provider catalog"); + assert_eq!( + provider_document["model"].as_str(), + Some("legacy/new-model") + ); + assert_eq!( + provider_document["provider"]["legacy"]["base_url"].as_str(), + Some("https://new.example/v1") + ); + assert_eq!( + provider_document["provider"]["legacy"]["models"]["new-model"]["name"].as_str(), + Some("New model") + ); + let auth = read_user_auth_config(&home.join("auth.json")).expect("read auth"); + assert_eq!(auth.credentials["legacy_api_key"].value, "old-secret"); + + let legacy_document: toml::Value = toml::from_str( + &fs::read_to_string(home.join(APP_CONFIG_FILE_NAME)).expect("read migrated config"), + ) + .expect("parse migrated config"); + assert!(legacy_document.get("model_provider").is_none()); + assert!(legacy_document.get("providers").is_none()); + assert!(legacy_document.get("model").is_none()); + + let _ = fs::remove_dir_all(root); +} + +#[test] +fn disconnect_provider_removes_connection_and_unshared_auth_credential() { + let root = unique_temp_dir("provider-disconnect"); + let home = root.join(".devo"); + fs::create_dir_all(&home).expect("create config dir"); + let mut store = AppConfigStore::load(home.clone(), None).expect("load config"); + store + .upsert_provider_connection( + ProviderInfo { + id: "custom-provider".to_string(), + name: "Custom Provider".to_string(), + description: None, + base_url: Some("https://example.com/v1".to_string()), + credential: None, + headers: BTreeMap::new(), + options: None, + request: None, + wire_apis: vec![ProviderWireApi::OpenAIChatCompletions], + models: BTreeMap::new(), + enabled: true, + }, + None, + None, + Some("secret-value".to_string()), + ) + .expect("create provider connection"); + + let auth = read_user_auth_config(&home.join("auth.json")).expect("read auth"); + assert_eq!( + auth.credentials["custom_provider_api_key"].value, + "secret-value" + ); + assert_eq!( + store.provider_connection_ids().expect("list connections"), + vec!["custom-provider".to_string()] + ); + + store + .disconnect_provider("custom-provider") + .expect("disconnect provider"); + + let providers = crate::read_provider_catalog_config(&home.join("providers.json")) + .expect("read provider catalog"); + assert!(!providers.providers.contains_key("custom-provider")); + assert!( + store + .provider_connection_ids() + .expect("list connections after disconnect") + .is_empty() + ); + let auth = read_user_auth_config(&home.join("auth.json")).expect("read auth after disconnect"); + assert!(!auth.credentials.contains_key("custom_provider_api_key")); + + let _ = fs::remove_dir_all(root); +} + +#[test] +fn connection_models_can_be_listed_and_removed_without_affecting_the_provider() { + let root = unique_temp_dir("provider-model-remove"); + let home = root.join(".devo"); + fs::create_dir_all(&home).expect("create config dir"); + let mut store = AppConfigStore::load(home.clone(), None).expect("load config"); + let provider = ProviderInfo { + id: "custom-provider".to_string(), + name: "Custom Provider".to_string(), + base_url: Some("https://example.com/v1".to_string()), + wire_apis: vec![ProviderWireApi::OpenAIChatCompletions], + models: BTreeMap::from([( + "custom-model".to_string(), + ProviderModelInfo { + name: Some("Custom model".to_string()), + web_search: Some(serde_json::json!({"mode": "disabled"})), + web_fetch: Some(serde_json::json!({"mode": "provider"})), + ..ProviderModelInfo::default() + }, + )]), + enabled: true, + ..ProviderInfo::default() + }; + store + .upsert_provider_connection( + provider, + Some("custom-provider/custom-model".to_string()), + None, + None, + ) + .expect("create provider connection"); + + let expected = BTreeMap::from([( + "custom-provider".to_string(), + BTreeMap::from([( + "custom-model".to_string(), + ProviderModelInfo { + name: Some("Custom model".to_string()), + web_search: Some(serde_json::json!({"mode": "disabled"})), + web_fetch: Some(serde_json::json!({"mode": "provider"})), + ..ProviderModelInfo::default() + }, + )]), + )]); + assert_eq!( + store.provider_connection_models().expect("list models"), + expected + ); + + store + .remove_provider_model("custom-provider", "custom-model") + .expect("remove model"); + assert_eq!( + store + .provider_connection_models() + .expect("list models after removal"), + BTreeMap::from([("custom-provider".to_string(), BTreeMap::new())]) + ); + assert_eq!( + store.effective_config().provider.model, + None, + "removing the selected model clears the default" + ); + + let _ = fs::remove_dir_all(root); +} diff --git a/crates/config/src/error.rs b/crates/config/src/error.rs index b7c03a28..06fefc8c 100644 --- a/crates/config/src/error.rs +++ b/crates/config/src/error.rs @@ -47,6 +47,9 @@ pub enum ProviderConfigError { /// Parsing provider TOML from a file failed. #[error("failed to parse {path}: {message}")] ParseTomlFile { path: PathBuf, message: String }, + /// Parsing provider JSON from a file failed. + #[error("failed to parse {path}: {message}")] + ParseJsonFile { path: PathBuf, message: String }, /// Parsing user-scoped auth JSON failed. #[error("failed to parse {path}: {message}")] ParseAuth { path: PathBuf, message: String }, diff --git a/crates/config/src/provider.rs b/crates/config/src/provider.rs index 2b92ac95..e90c6529 100644 --- a/crates/config/src/provider.rs +++ b/crates/config/src/provider.rs @@ -1,578 +1,31 @@ mod auth; +mod catalog; +mod migration; mod persistence; -mod resolve; -mod runtime_equivalence; +mod request; +mod runtime; mod schema; pub use devo_protocol::ProviderWireApi; pub use auth::AUTH_CONFIG_FILE_NAME; +pub use auth::default_provider_credential_id; pub use auth::read_user_auth_config; +pub use auth::remove_user_auth_credential; pub use auth::upsert_user_auth_api_key; +pub use catalog::{ + PROVIDER_CONFIG_FILE_VERSION, ProviderConfigEntry, ProviderConfigFile, ProviderModelConfig, + ProviderModelSelection, ProviderModelVariantConfig, model_reference, +}; pub use persistence::CONFIG_FILE_NAME; -pub use resolve::ResolvedModelBinding; -pub use resolve::load_config; -pub use resolve::provider_request_model_map_for_binding; -pub use resolve::resolve_enabled_model_binding; -pub use resolve::resolve_model_binding; -pub use resolve::resolve_provider_settings; -pub use resolve::resolve_provider_settings_from_config_and_auth; +pub use persistence::PROVIDER_CONFIG_FILE_NAME; +pub use request::provider_request_config; +pub use runtime::provider_runtime_config_changed; pub use schema::*; -pub fn provider_id_for_endpoint(provider: &ProviderWireApi, _base_url: Option<&str>) -> String { - provider.as_str().to_string() -} - -pub fn provider_name_for_endpoint(provider: &ProviderWireApi, base_url: Option<&str>) -> String { - provider_id_for_endpoint(provider, base_url) -} - +pub(crate) use migration::migrate_legacy_provider_config_on_startup; pub(crate) use persistence::non_empty_string; -pub(crate) use persistence::provider_vendor_from_config; -pub(crate) use persistence::read_provider_config; +pub use persistence::read_provider_catalog_config; pub(crate) use persistence::read_provider_config_document; pub(crate) use persistence::write_atomic; -pub(crate) use persistence::write_provider_config; - -#[cfg(test)] -mod tests { - use pretty_assertions::assert_eq; - - use super::AUTH_CONFIG_FILE_NAME; - use super::AuthCredentialConfig; - use super::AuthCredentialKind; - use super::ModelBindingConfig; - use super::ModelOverrideConfig; - use super::ModelProviderConfig; - use super::PreferredAuthMethod; - use super::ProviderConfigSection; - use super::ProviderDefaultsConfig; - use super::ProviderWireApi; - use super::ResolvedModelBinding; - use super::ResolvedProviderSettings; - use super::UserAuthConfigFile; - use super::read_provider_config; - use super::read_user_auth_config; - use super::resolve_enabled_model_binding; - use super::resolve_model_binding; - use super::resolve_provider_settings_from_config_and_auth; - use super::upsert_user_auth_api_key; - use super::write_provider_config; - - #[test] - fn resolves_new_style_provider_and_model_settings() { - let config = toml::from_str::( - r#" -model_provider = "xxxxx" -model = "gpt-5.4" -model_auto_compact_token_limit = 970000 -model_context_window = 997500 -model_reasoning_effort_selection = "medium" -disable_response_storage = true -preferred_auth_method = "apikey" - -[defaults] -model_binding = "gpt54-xxxxx" - -[providers.xxxxx] -enabled = true -name = "xxxxx" -base_url = "https://xxxxx/v1" -credential = "xxxxx_api_key" -wire_apis = ["openai_responses"] - -[model_bindings.gpt54-xxxxx] -enabled = true -model_slug = "gpt-5.4" -provider = "xxxxx" -request_model = "gpt-5.4" -invocation_method = "openai_responses" -"#, - ) - .expect("parse config"); - - let auth = UserAuthConfigFile { - credentials: [( - "xxxxx_api_key".to_string(), - AuthCredentialConfig { - kind: AuthCredentialKind::ApiKey, - value: "secret-value".to_string(), - }, - )] - .into_iter() - .collect(), - ..UserAuthConfigFile::default() - }; - let resolved = resolve_provider_settings_from_config_and_auth(&config, &auth) - .expect("resolve provider settings"); - - assert_eq!( - resolved, - ResolvedProviderSettings { - provider_id: "xxxxx".to_string(), - wire_api: ProviderWireApi::OpenAIResponses, - model: "gpt-5.4".to_string(), - base_url: Some("https://xxxxx/v1".to_string()), - api_key: Some("secret-value".to_string()), - proxy_url: None, - no_proxy: None, - headers: None, - model_auto_compact_token_limit: Some(970000), - model_context_window: Some(997500), - model_reasoning_effort_selection: Some("medium".to_string()), - disable_response_storage: true, - preferred_auth_method: Some(PreferredAuthMethod::Apikey), - } - ); - } - - #[test] - fn resolving_new_style_provider_requires_user_auth_credential() { - let config = toml::from_str::( - r#" -[defaults] -model_binding = "gpt54-xxxxx" - -[providers.xxxxx] -name = "xxxxx" -credential = "xxxxx_api_key" - -[model_bindings.gpt54-xxxxx] -model_slug = "gpt-5.4" -provider = "xxxxx" -request_model = "gpt-5.4" -"#, - ) - .expect("parse config"); - - let error = - resolve_provider_settings_from_config_and_auth(&config, &UserAuthConfigFile::default()) - .expect_err("missing credential should fail"); - - assert!(error.to_string().contains("xxxxx_api_key")); - assert!(error.to_string().contains("auth.json")); - } - - #[test] - fn enabled_model_binding_resolves_requested_model_slug() { - let config = provider_config_with_bindings(); - - let binding = - resolve_enabled_model_binding(&config, Some("catalog-two")).expect("resolve binding"); - - assert_eq!(binding, expected_resolved_binding("two")); - } - - #[test] - fn enabled_model_binding_resolves_requested_request_model() { - let config = provider_config_with_bindings(); - - let binding = - resolve_enabled_model_binding(&config, Some("vendor/two")).expect("resolve binding"); - - assert_eq!(binding, expected_resolved_binding("two")); - } - - #[test] - fn model_binding_config_accepts_legacy_model_name() { - let config = toml::from_str::( - r#" -[model_bindings.glm-zai] -model_slug = "glm-4.5" -provider = "zai" -model_name = "renamed-provider-model" -"#, - ) - .expect("parse legacy binding"); - - assert_eq!( - config.model_bindings["glm-zai"], - ModelBindingConfig { - model_slug: "glm-4.5".to_string(), - provider: "zai".to_string(), - request_model: "renamed-provider-model".to_string(), - ..ModelBindingConfig::default() - } - ); - } - - #[test] - fn enabled_model_binding_resolves_requested_binding_id_before_slug() { - let mut config = provider_config_with_bindings(); - config.model_bindings.insert( - "catalog-two".to_string(), - ModelBindingConfig { - enabled: true, - model_slug: "catalog-one".to_string(), - provider: "direct".to_string(), - request_model: "direct/one".to_string(), - invocation_method: ProviderWireApi::OpenAIChatCompletions, - ..ModelBindingConfig::default() - }, - ); - - let binding = - resolve_enabled_model_binding(&config, Some("catalog-two")).expect("resolve binding"); - - assert_eq!( - binding, - ResolvedModelBinding { - binding_id: "catalog-two".to_string(), - model_slug: "catalog-one".to_string(), - request_model: "direct/one".to_string(), - provider_id: "direct".to_string(), - invocation_method: ProviderWireApi::OpenAIChatCompletions, - default_reasoning_effort: None, - enabled: true, - } - ); - } - - #[test] - fn enabled_model_binding_uses_default_binding_without_requested_model() { - let config = provider_config_with_bindings(); - - let binding = resolve_enabled_model_binding(&config, /*requested_model*/ None) - .expect("resolve binding"); - - assert_eq!(binding, expected_resolved_binding("one")); - } - - #[test] - fn enabled_model_binding_skips_disabled_default_binding() { - let mut config = provider_config_with_bindings(); - config - .model_bindings - .get_mut("one") - .expect("default binding") - .enabled = false; - - let binding = resolve_enabled_model_binding(&config, /*requested_model*/ None) - .expect("resolve binding"); - - assert_eq!(binding, expected_resolved_binding("two")); - } - - #[test] - fn configured_model_binding_keeps_disabled_model_for_validation() { - let mut config = provider_config_with_bindings(); - config.model = Some("catalog-one".to_string()); - config.defaults.model_binding = None; - config - .model_bindings - .get_mut("one") - .expect("configured binding") - .enabled = false; - - let binding = - resolve_model_binding(&config, /*requested_model*/ None).expect("resolve binding"); - - assert_eq!( - binding, - ResolvedModelBinding { - enabled: false, - ..expected_resolved_binding("one") - } - ); - } - - fn provider_config_with_bindings() -> ProviderConfigSection { - ProviderConfigSection { - defaults: ProviderDefaultsConfig { - model_binding: Some("one".to_string()), - }, - model_bindings: [ - ( - "one".to_string(), - ModelBindingConfig { - enabled: true, - model_slug: "catalog-one".to_string(), - provider: "openrouter".to_string(), - request_model: "vendor/one".to_string(), - invocation_method: ProviderWireApi::OpenAIResponses, - ..ModelBindingConfig::default() - }, - ), - ( - "two".to_string(), - ModelBindingConfig { - enabled: true, - model_slug: "catalog-two".to_string(), - provider: "openrouter".to_string(), - request_model: "vendor/two".to_string(), - invocation_method: ProviderWireApi::OpenAIResponses, - ..ModelBindingConfig::default() - }, - ), - ] - .into_iter() - .collect(), - ..ProviderConfigSection::default() - } - } - - fn expected_resolved_binding(binding_id: &str) -> ResolvedModelBinding { - let model_suffix = match binding_id { - "one" => "one", - "two" => "two", - _ => panic!("unexpected binding id"), - }; - ResolvedModelBinding { - binding_id: binding_id.to_string(), - model_slug: format!("catalog-{model_suffix}"), - request_model: format!("vendor/{model_suffix}"), - provider_id: "openrouter".to_string(), - invocation_method: ProviderWireApi::OpenAIResponses, - default_reasoning_effort: None, - enabled: true, - } - } - - #[test] - fn user_auth_api_key_round_trips_through_auth_json() { - let dir = tempfile::tempdir().expect("temp dir"); - - upsert_user_auth_api_key(dir.path(), "openrouter_api_key", "sk-or-test") - .expect("write credential"); - let auth = - read_user_auth_config(&dir.path().join(AUTH_CONFIG_FILE_NAME)).expect("load auth"); - - assert_eq!( - auth, - UserAuthConfigFile { - credentials: [( - "openrouter_api_key".to_string(), - AuthCredentialConfig { - kind: AuthCredentialKind::ApiKey, - value: "sk-or-test".to_string(), - }, - )] - .into_iter() - .collect(), - ..UserAuthConfigFile::default() - } - ); - } - - #[test] - fn legacy_model_providers_do_not_resolve_provider_settings() { - let config = toml::from_str::( - r#" -model_provider = "api.example.com" -model = "qwen3-coder-next" - -[model_providers."api.example.com"] -name = "api.example.com" -base_url = "https://api.example.com" -api_key = "profile-key" -last_model = "qwen3-coder-next" -"#, - ) - .expect("parse config"); - - let error = - resolve_provider_settings_from_config_and_auth(&config, &UserAuthConfigFile::default()) - .expect_err("legacy-only provider settings should not resolve"); - - assert_eq!( - error.to_string(), - "No provider configured. Run `devo onboard` to complete setup." - ); - } - - #[test] - fn write_provider_config_preserves_unrelated_toml() { - let dir = tempfile::tempdir().expect("temp dir"); - let config_file = dir.path().join(super::CONFIG_FILE_NAME); - std::fs::write( - &config_file, - r#" -schema_version = 1 -model = "old-model" - -[logging] -level = "debug" - -[providers.existing] -name = "Old Name" -base_url = "https://old.example/v1" -custom_provider_key = "keep-me" - -[providers.other] -name = "Other" -"#, - ) - .expect("write initial config"); - - write_provider_config( - &config_file, - &ProviderConfigSection { - model: Some("new-model".to_string()), - providers: [( - "existing".to_string(), - ModelProviderConfig { - name: "New Name".to_string(), - base_url: Some("https://new.example/v1".to_string()), - wire_apis: vec![ProviderWireApi::OpenAIResponses], - ..ModelProviderConfig::default() - }, - )] - .into_iter() - .collect(), - ..ProviderConfigSection::default() - }, - ) - .expect("write provider config"); - - let written = std::fs::read_to_string(&config_file).expect("read written config"); - let document: toml::Value = toml::from_str(&written).expect("parse written config"); - - assert_eq!(document["schema_version"].as_integer(), Some(1)); - assert_eq!(document["logging"]["level"].as_str(), Some("debug")); - assert_eq!(document["model"].as_str(), Some("new-model")); - assert_eq!( - document["providers"]["existing"]["name"].as_str(), - Some("New Name") - ); - assert_eq!( - document["providers"]["existing"]["base_url"].as_str(), - Some("https://new.example/v1") - ); - assert_eq!( - document["providers"]["existing"]["custom_provider_key"].as_str(), - Some("keep-me") - ); - assert_eq!( - document["providers"]["other"]["name"].as_str(), - Some("Other") - ); - } - - #[test] - fn write_provider_config_preserves_model_override_tables() { - let dir = tempfile::tempdir().expect("temp dir"); - let config_file = dir.path().join(super::CONFIG_FILE_NAME); - std::fs::write( - &config_file, - r#" -[model.grok-4] -display_name = "Grok 4" -context_window = 256000 -custom_model_key = "keep-me" -"#, - ) - .expect("write initial config"); - - let mut config = read_provider_config(&config_file).expect("read provider config"); - config.providers.insert( - "xai".to_string(), - ModelProviderConfig { - name: "xAI".to_string(), - wire_apis: vec![ProviderWireApi::OpenAIResponses], - ..ModelProviderConfig::default() - }, - ); - - write_provider_config(&config_file, &config).expect("write provider config"); - - let written = std::fs::read_to_string(&config_file).expect("read written config"); - let document: toml::Value = toml::from_str(&written).expect("parse written config"); - assert_eq!( - config.model_overrides, - [( - "grok-4".to_string(), - ModelOverrideConfig { - display_name: Some("Grok 4".to_string()), - context_window: Some(256_000), - ..ModelOverrideConfig::default() - }, - )] - .into_iter() - .collect() - ); - assert_eq!(document["model"].as_str(), None); - assert_eq!( - document["model"]["grok-4"]["display_name"].as_str(), - Some("Grok 4") - ); - assert_eq!( - document["model"]["grok-4"]["custom_model_key"].as_str(), - Some("keep-me") - ); - } - - #[test] - fn write_provider_config_removes_cleared_known_fields() { - let dir = tempfile::tempdir().expect("temp dir"); - let config_file = dir.path().join(super::CONFIG_FILE_NAME); - std::fs::write( - &config_file, - r#" -[defaults] -model_binding = "old-binding" - -[providers.existing] -name = "Old Name" -credential = "old-key" -base_url = "https://old.example/v1" - -[model_bindings.existing-binding] -model_slug = "old-model" -provider = "existing" -request_model = "old-model" -invocation_method = "openai_chat_completions" -"#, - ) - .expect("write initial config"); - - write_provider_config( - &config_file, - &ProviderConfigSection { - defaults: ProviderDefaultsConfig { - model_binding: Some("existing-binding".to_string()), - }, - providers: [( - "existing".to_string(), - ModelProviderConfig { - name: "New Name".to_string(), - ..ModelProviderConfig::default() - }, - )] - .into_iter() - .collect(), - model_bindings: [( - "existing-binding".to_string(), - ModelBindingConfig { - model_slug: "new-model".to_string(), - provider: "existing".to_string(), - request_model: "new-provider-model".to_string(), - invocation_method: ProviderWireApi::OpenAIResponses, - ..ModelBindingConfig::default() - }, - )] - .into_iter() - .collect(), - ..ProviderConfigSection::default() - }, - ) - .expect("write provider config"); - - let written = std::fs::read_to_string(&config_file).expect("read written config"); - let document: toml::Value = toml::from_str(&written).expect("parse written config"); - - assert_eq!( - document["defaults"]["model_binding"].as_str(), - Some("existing-binding") - ); - assert!( - document["providers"]["existing"] - .get("credential") - .is_none() - ); - assert!(document["providers"]["existing"].get("base_url").is_none()); - assert_eq!( - document["model_bindings"]["existing-binding"]["request_model"].as_str(), - Some("new-provider-model") - ); - } -} +pub use persistence::write_provider_catalog_config; diff --git a/crates/config/src/provider/auth.rs b/crates/config/src/provider/auth.rs index adfdff84..1069d060 100644 --- a/crates/config/src/provider/auth.rs +++ b/crates/config/src/provider/auth.rs @@ -1,8 +1,6 @@ use std::fs; use std::path::Path; -use devo_util_paths::current_user_config_file; - use crate::ProviderConfigError; use super::persistence::write_atomic; @@ -13,6 +11,28 @@ use super::schema::UserAuthConfigFile; pub const AUTH_CONFIG_FILE_NAME: &str = "auth.json"; +/// Returns the stable credential id generated when onboarding receives an API +/// key without an explicit credential id. +pub fn default_provider_credential_id(provider_id: &str) -> String { + let normalized = provider_id + .trim() + .chars() + .map(|character| { + if character.is_ascii_alphanumeric() { + character.to_ascii_lowercase() + } else { + '_' + } + }) + .collect::(); + let normalized = normalized.trim_matches('_'); + if normalized.is_empty() { + "provider_api_key".to_string() + } else { + format!("{normalized}_api_key") + } +} + /// Upserts one API key credential into user-scoped `auth.json`. pub fn upsert_user_auth_api_key( user_config_dir: &Path, @@ -31,17 +51,21 @@ pub fn upsert_user_auth_api_key( write_user_auth_config(&auth_file, &auth) } -pub(crate) fn current_user_auth_config() -> Result { - let config_file = - current_user_config_file().map_err(|error| ProviderConfigError::ConfigPath { - message: format!("could not determine user config path: {error}"), - })?; - let config_dir = config_file - .parent() - .ok_or_else(|| ProviderConfigError::ConfigPath { - message: "user config path has no parent directory".to_string(), - })?; - read_user_auth_config(&config_dir.join(AUTH_CONFIG_FILE_NAME)) +/// Removes a user-scoped credential from auth.json. +/// +/// Returning Ok(false) means the credential id was not present. The file is +/// still written when a credential was removed so the operation is durable. +pub fn remove_user_auth_credential( + user_config_dir: &Path, + credential_id: &str, +) -> Result { + let auth_file = user_config_dir.join(AUTH_CONFIG_FILE_NAME); + let mut auth = read_user_auth_config(&auth_file)?; + let removed = auth.credentials.remove(credential_id).is_some(); + if removed { + write_user_auth_config(&auth_file, &auth)?; + } + Ok(removed) } pub fn read_user_auth_config(auth_file: &Path) -> Result { diff --git a/crates/config/src/provider/catalog.rs b/crates/config/src/provider/catalog.rs new file mode 100644 index 00000000..36608ad6 --- /dev/null +++ b/crates/config/src/provider/catalog.rs @@ -0,0 +1,1164 @@ +//! The canonical JSON provider/model configuration and catalog shape. +//! +//! Provider identity and model identity are deliberately expressed by map keys: +//! a model is addressed as `provider/model`. This removes the old binding layer +//! from the user-facing format while retaining a TOML projection for backward +//! compatibility inside the runtime. + +use std::collections::BTreeMap; + +use devo_protocol::{ + InputModality, ProviderWireApi, ReasoningCapability, ReasoningEffort, ReasoningImplementation, + TruncationPolicyConfig, find_effort_variant_key, normalize_reasoning_effort_literal, +}; +use serde::{Deserialize, Serialize}; + +use super::schema::{ + LegacyModelBindingConfig, LegacyProviderConfig, ModelOverrideConfig, ProviderConfigSection, + ProviderDefaultsConfig, +}; +use crate::{WebFetchConfig, WebSearchConfig}; + +/// The canonical provider/model selection resolved from `providers.json`. +/// +/// A selection contains the Connection id, the model map key, an optional +/// named variant, and the wire API used to invoke it. It deliberately has no +/// binding id: the map path is the identity. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ProviderModelSelection { + pub provider_id: String, + pub model_id: String, + pub variant_id: Option, + pub wire_api: ProviderWireApi, +} + +/// Current version of the standalone provider/model JSON format. +pub const PROVIDER_CONFIG_FILE_VERSION: u32 = 1; + +/// Standalone provider/model configuration. +#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)] +pub struct ProviderConfigFile { + /// Default primary model for normal turns in `provider/model` form. + /// + /// The built-in directory leaves this unset; user or workspace overlays + /// may set it when they want an explicit default. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub model: Option, + /// Optional lower-cost model for lightweight background tasks, such as + /// session-title generation. It falls back to the primary model when unset. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub small_model: Option, + /// User's global logical reasoning selection. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub reasoning_effort: Option, + /// Providers keyed by their stable provider id. + #[serde( + default, + rename = "provider", + alias = "providers", + skip_serializing_if = "BTreeMap::is_empty" + )] + pub providers: BTreeMap, +} + +/// One provider connection plus its model directory entries. +#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)] +pub struct ProviderConfigEntry { + /// Optional display name. The provider id is the fallback name. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub name: Option, + /// Short subtitle shown in provider lists (for example region or product line). + #[serde(default, skip_serializing_if = "Option::is_none")] + pub description: Option, + /// Provider endpoint override. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub base_url: Option, + /// Credential id resolved from the separate user-scoped `auth.json` file. + /// + /// The credential value itself is never stored in this catalog. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub credential: Option, + /// Additional HTTP headers sent to this provider. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub headers: Option>, + /// Provider-level SDK/request options. The object is passed through + /// unchanged so custom integrations can use provider-specific settings. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub options: Option, + /// Provider-level request-body defaults merged into model requests. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub request: Option, + /// Wire protocol used by models unless a model overrides it. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub wire_api: Option, + /// Whether this provider is available for selection. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub enabled: Option, + /// Environment variable names that may provide credentials in integrations. + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub env: Vec, + /// Optional provider-hosted web search behavior. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub web_search: Option, + /// Optional provider-hosted web fetch behavior. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub web_fetch: Option, + /// Models keyed by the model id sent to the provider. + #[serde(default, skip_serializing_if = "BTreeMap::is_empty")] + pub models: BTreeMap, +} + +/// Optional catalog metadata and per-model request defaults. +/// +/// `name` is intentionally the only human-facing identifier. The model id is +/// already supplied by the map key, so no `slug`, `model_name`, `model_id`, or +/// description field is needed in the persisted format. +#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)] +pub struct ProviderModelConfig { + /// Optional display name shown in model pickers. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub name: Option, + /// Optional wire protocol override for this model. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub wire_api: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub context_window: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub effective_context_window_percent: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub max_tokens: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub temperature: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub top_p: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub top_k: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub reasoning_capability: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub reasoning_implementation: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub default_reasoning_effort: Option, + /// Exact UI selection used as the model default, including `on` or + /// `off` for toggle-capable models. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub default_reasoning_selection: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub base_instructions: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub input_modalities: Option>, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub channel: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub truncation_policy: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub supports_image_detail_original: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub enabled: Option, + /// Optional model-specific web search behavior. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub web_search: Option, + /// Optional model-specific web fetch behavior. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub web_fetch: Option, + /// Higher values are preferred when selecting an implicit default model. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub priority: Option, + /// Provider family, release metadata, and availability status. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub family: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub release_date: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub status: Option, + /// Open-ended model capability metadata, such as tools and modalities. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub capabilities: Option, + /// Pricing and provider-defined metadata from a directory source. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub cost: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub metadata: Option, + /// Arbitrary model options and request-body defaults. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub request: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub options: Option, + #[serde(default, skip_serializing_if = "BTreeMap::is_empty")] + pub headers: BTreeMap, + #[serde(default, skip_serializing_if = "BTreeMap::is_empty")] + pub variants: BTreeMap, + /// Variant selected when a turn does not provide an explicit variant. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub default_variant: Option, +} + +/// A model variant in the standalone provider catalog. +#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)] +pub struct ProviderModelVariantConfig { + #[serde(default, skip_serializing_if = "Option::is_none")] + pub label: Option, + #[serde(default)] + pub disabled: bool, + /// Optional wire model id override when this variant is selected. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub request_model: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub request: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub options: Option, + #[serde(default, skip_serializing_if = "BTreeMap::is_empty")] + pub headers: BTreeMap, +} + +impl ProviderConfigFile { + /// Resolves a `provider/model[/variant]` reference from this catalog. + /// + /// When `requested` is absent, the persisted `model` default is used; if + /// that is also absent, the first enabled model in map order is selected. + pub fn resolve_model( + &self, + requested: Option<&str>, + ) -> Result { + let requested = requested.or(self.model.as_deref()); + if let Some(requested) = requested { + return self.resolve_model_reference(requested); + } + + self.providers + .iter() + .filter(|(_, provider)| provider.enabled != Some(false)) + .flat_map(|(provider_id, provider)| { + provider + .models + .iter() + .filter(|(_, model)| model.enabled != Some(false)) + .map(move |(model_id, model)| { + self.selection_for_model(provider_id, model_id, model, None) + }) + }) + .next() + .ok_or_else(|| crate::ProviderConfigError::Validation { + message: "no enabled provider model is configured".to_string(), + }) + } + + fn resolve_model_reference( + &self, + requested: &str, + ) -> Result { + let Some((provider_id, requested_model_id)) = requested.split_once('/') else { + return Err(crate::ProviderConfigError::Validation { + message: format!( + "model `{requested}` must use `provider/model` or `provider/model/variant` form" + ), + }); + }; + let Some(provider) = self.providers.get(provider_id) else { + return Err(crate::ProviderConfigError::Validation { + message: format!("provider Connection `{provider_id}` is not configured"), + }); + }; + if provider.enabled == Some(false) { + return Err(crate::ProviderConfigError::Validation { + message: format!("provider Connection `{provider_id}` is disabled"), + }); + } + + let (model_id, variant_id) = if provider.models.contains_key(requested_model_id) { + (requested_model_id, None) + } else if let Some((model_id, variant_id)) = requested_model_id.rsplit_once('/') { + if provider + .models + .get(model_id) + .is_some_and(|model| model.variants.contains_key(variant_id)) + { + (model_id, Some(variant_id)) + } else { + return Err(crate::ProviderConfigError::Validation { + message: format!("model `{requested}` is not configured"), + }); + } + } else { + return Err(crate::ProviderConfigError::Validation { + message: format!("model `{requested}` is not configured"), + }); + }; + let model = provider + .models + .get(model_id) + .expect("model was checked above"); + if model.enabled == Some(false) { + return Err(crate::ProviderConfigError::Validation { + message: format!("model `{requested}` is disabled"), + }); + } + if let Some(variant_id) = variant_id + && provider + .models + .get(model_id) + .and_then(|model| model.variants.get(variant_id)) + .is_some_and(|variant| variant.disabled) + { + return Err(crate::ProviderConfigError::Validation { + message: format!("model variant `{requested}` is disabled"), + }); + } + + Ok(self.selection_for_model(provider_id, model_id, model, variant_id)) + } + + fn selection_for_model( + &self, + provider_id: &str, + model_id: &str, + model: &ProviderModelConfig, + variant_id: Option<&str>, + ) -> ProviderModelSelection { + let provider = self + .providers + .get(provider_id) + .expect("provider selection must belong to this catalog"); + ProviderModelSelection { + provider_id: provider_id.to_string(), + model_id: model_id.to_string(), + variant_id: variant_id + .map(ToOwned::to_owned) + .or_else(|| model.default_variant.clone()), + wire_api: model + .wire_api + .or(provider.wire_api) + .unwrap_or(ProviderWireApi::OpenAIChatCompletions), + } + } + + /// Merges a higher-priority provider file over this one. + pub fn merge_overlay(&mut self, overlay: Self) { + if overlay.model.is_some() { + self.model = overlay.model; + } + if overlay.small_model.is_some() { + self.small_model = overlay.small_model; + } + if overlay.reasoning_effort.is_some() { + self.reasoning_effort = overlay.reasoning_effort; + } + for (provider_id, overlay_provider) in overlay.providers { + let provider = self.providers.entry(provider_id).or_default(); + merge_provider_entry(provider, overlay_provider); + } + } + + /// Applies `[model.]` / `[model."/"]` overlays onto + /// matching provider catalog models. + /// + /// Overlay keys may be bare model ids or full `provider/model` references. + /// Bare ids update every matching model id across providers. + pub fn apply_model_overrides(&mut self, overrides: &BTreeMap) { + for (model_reference, override_config) in overrides { + let matches = self + .providers + .iter() + .flat_map(|(provider_id, provider)| { + provider.models.keys().filter_map(move |model_id| { + let exact_reference = format!("{provider_id}/{model_id}"); + (model_reference == model_id || model_reference == &exact_reference) + .then(|| (provider_id.clone(), model_id.clone())) + }) + }) + .collect::>(); + for (provider_id, model_id) in matches { + if let Some(model) = self + .providers + .get_mut(&provider_id) + .and_then(|provider| provider.models.get_mut(&model_id)) + { + model.apply_model_override(override_config); + } + } + } + } + + /// Projects the JSON shape into the legacy normalized runtime config. + pub fn to_provider_config_section(&self) -> ProviderConfigSection { + let default_model_binding = self + .model + .as_deref() + .map(|model| self.base_model_reference(model)); + let mut section = ProviderConfigSection { + model: self.model.clone(), + model_reasoning_effort_selection: self.reasoning_effort.clone(), + defaults: ProviderDefaultsConfig { + model_binding: default_model_binding, + }, + ..ProviderConfigSection::default() + }; + + for (provider_id, provider) in &self.providers { + let wire_api = provider + .wire_api + .unwrap_or(ProviderWireApi::OpenAIChatCompletions); + section.providers.insert( + provider_id.clone(), + LegacyProviderConfig { + name: provider.name.clone().unwrap_or_else(|| provider_id.clone()), + base_url: provider.base_url.clone(), + credential: provider.credential.clone(), + api_key: None, + headers: provider + .headers + .as_ref() + .and_then(|headers| serde_json::to_string(headers).ok()), + wire_apis: vec![wire_api], + web_search: provider.web_search.clone(), + web_fetch: provider.web_fetch.clone(), + enabled: provider.enabled.unwrap_or(true), + }, + ); + + for (model_id, model) in &provider.models { + let model_ref = model_reference(provider_id, model_id); + let model_wire_api = model.wire_api.unwrap_or(wire_api); + section.model_bindings.insert( + model_ref.clone(), + LegacyModelBindingConfig { + model_slug: model_ref.clone(), + provider: provider_id.clone(), + request_model: model_id.clone(), + display_name: model.name.clone(), + invocation_method: model_wire_api, + default_reasoning_effort: model + .default_reasoning_selection + .clone() + .or_else(|| { + model + .default_reasoning_effort + .map(|effort| effort.to_string()) + }), + web_search: model.web_search.clone(), + web_fetch: model.web_fetch.clone(), + enabled: model.enabled.unwrap_or(provider.enabled.unwrap_or(true)), + }, + ); + section + .model_overrides + .insert(model_ref, model.to_model_override(model_wire_api)); + } + } + + if let Some(model_ref) = self.model.as_deref() + && let Some((provider_id, requested_model_id)) = model_ref.split_once('/') + && let Some(provider) = self.providers.get(provider_id) + { + let model_id = provider + .models + .contains_key(requested_model_id) + .then_some(requested_model_id) + .or_else(|| { + requested_model_id + .rsplit_once('/') + .filter(|(model_id, variant_id)| { + provider + .models + .get(*model_id) + .is_some_and(|model| model.variants.contains_key(*variant_id)) + }) + .map(|(model_id, _)| model_id) + }) + .unwrap_or(requested_model_id); + let model_ref = model_reference(provider_id, model_id); + let binding = section + .model_bindings + .entry(model_ref.to_string()) + .or_insert_with(|| LegacyModelBindingConfig { + model_slug: model_ref.to_string(), + provider: provider_id.to_string(), + request_model: model_id.to_string(), + invocation_method: provider + .wire_api + .unwrap_or(ProviderWireApi::OpenAIChatCompletions), + ..LegacyModelBindingConfig::default() + }); + section + .model_overrides + .entry(model_ref.to_string()) + .or_default(); + if section.defaults.model_binding.is_none() { + section.defaults.model_binding = Some(binding.model_slug.clone()); + } + } + + section + } + + fn base_model_reference(&self, model_ref: &str) -> String { + let Some((provider_id, requested_model_id)) = model_ref.split_once('/') else { + return model_ref.to_string(); + }; + let Some(provider) = self.providers.get(provider_id) else { + return model_ref.to_string(); + }; + if provider.models.contains_key(requested_model_id) { + return model_ref.to_string(); + } + let Some((model_id, variant_id)) = requested_model_id.rsplit_once('/') else { + return model_ref.to_string(); + }; + if provider + .models + .get(model_id) + .is_some_and(|model| model.variants.contains_key(variant_id)) + { + return model_reference(provider_id, model_id); + } + model_ref.to_string() + } + + /// Converts the old normalized shape into the new canonical JSON shape. + /// This is used only as an in-memory compatibility fallback for callers + /// that construct `AppConfig` directly in tests or integrations. + pub fn from_provider_config_section(section: &ProviderConfigSection) -> Self { + let mut file = Self { + model: selected_model_reference(section), + reasoning_effort: section.model_reasoning_effort_selection.clone(), + ..Self::default() + }; + for (provider_id, provider) in §ion.providers { + let entry = file + .providers + .entry(provider_id.clone()) + .or_insert_with(|| ProviderConfigEntry { + name: Some(provider.name.clone()), + base_url: provider.base_url.clone(), + credential: provider.credential.clone(), + wire_api: provider.wire_apis.first().copied(), + enabled: Some(provider.enabled), + web_search: provider.web_search.clone(), + web_fetch: provider.web_fetch.clone(), + ..ProviderConfigEntry::default() + }); + entry.name = Some(provider.name.clone()); + entry.base_url = provider.base_url.clone(); + entry.credential = provider.credential.clone(); + entry.wire_api = provider.wire_apis.first().copied(); + entry.enabled = Some(provider.enabled); + entry.web_search = provider.web_search.clone(); + entry.web_fetch = provider.web_fetch.clone(); + } + for binding in section.model_bindings.values() { + let provider_id = if binding.provider.is_empty() { + "default" + } else { + binding.provider.as_str() + }; + let model_id = model_id_from_reference(provider_id, &binding.request_model); + let entry = file.providers.entry(provider_id.to_string()).or_default(); + let model = entry.models.entry(model_id).or_default(); + model.name = binding.display_name.clone(); + model.wire_api = Some(binding.invocation_method); + model.web_search = binding.web_search.clone(); + model.web_fetch = binding.web_fetch.clone(); + model.enabled = Some(binding.enabled); + model.default_reasoning_effort = + binding + .default_reasoning_effort + .as_deref() + .and_then(|value| { + serde_json::from_value(serde_json::Value::String(value.to_string())).ok() + }); + model.default_reasoning_selection = binding.default_reasoning_effort.clone(); + if let Some(override_config) = section.model_overrides.get(&binding.model_slug) { + model.apply_model_override(override_config); + } + } + file + } +} + +fn selected_model_reference(section: &ProviderConfigSection) -> Option { + let selected = section + .model + .as_deref() + .filter(|selected| section.model_bindings.contains_key(*selected)) + .or(section.defaults.model_binding.as_deref())?; + let binding = section.model_bindings.get(selected)?; + let model_id = if binding.request_model.trim().is_empty() { + binding.model_slug.trim() + } else { + binding.request_model.trim() + }; + Some(model_reference(&binding.provider, model_id)) +} + +impl ProviderModelConfig { + /// Projects legacy `reasoning_implementation: model_variant` entries into + /// catalog `variants` when the variants map is empty. + /// + /// Selection values are normalized to canonical `off`/`on` (from + /// `disabled`/`enabled`). The legacy implementation field is cleared after + /// projection so Adapter vs CatalogVariant mode is derived from variants. + pub fn migrate_reasoning_implementation_into_variants(&mut self) { + if !self.variants.is_empty() { + return; + } + let Some(ReasoningImplementation::ModelVariant(config)) = + self.reasoning_implementation.clone() + else { + return; + }; + for variant in config.variants { + let key = normalize_reasoning_effort_literal(&variant.selection_value); + self.variants.insert( + key, + ProviderModelVariantConfig { + label: Some(variant.label).filter(|label| !label.is_empty()), + disabled: false, + request_model: Some(variant.model).filter(|model| !model.is_empty()), + request: variant.extra_body, + options: None, + headers: BTreeMap::new(), + }, + ); + } + self.reasoning_implementation = None; + } + + /// Resolves the catalog variant id used for a turn given an optional + /// explicit model-reference variant and a logical reasoning selection. + /// + /// Explicit variants that are not effort option values stay as static + /// overlays. Otherwise the matching effort-keyed variant wins, then + /// `default_variant`. + pub fn resolve_turn_variant_id( + &self, + explicit_variant: Option<&str>, + reasoning_selection: Option<&str>, + ) -> Option { + let capability_option_values: Vec = self + .reasoning_capability + .as_ref() + .map(|capability| { + capability + .options() + .into_iter() + .map(|option| option.value) + .collect() + }) + .unwrap_or_default(); + + if let Some(explicit) = explicit_variant + .map(str::trim) + .filter(|value| !value.is_empty()) + { + let normalized_explicit = normalize_reasoning_effort_literal(explicit); + let is_effort_option = capability_option_values + .iter() + .any(|value| value == &normalized_explicit); + if !is_effort_option { + return Some(explicit.to_string()); + } + } + + if let Some(selection) = reasoning_selection + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(normalize_reasoning_effort_literal) + && let Some(key) = find_effort_variant_key(&self.variants, &selection) + { + let variant = self.variants.get(key)?; + if !variant.disabled { + return Some(key.to_string()); + } + } + + self.default_variant + .as_deref() + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(str::to_string) + .filter(|id| { + self.variants + .get(id) + .is_some_and(|variant| !variant.disabled) + }) + } + + /// Applies the fields present in a higher-priority model configuration. + /// + /// This is also used when the onboarding protocol carries a sparse JSON + /// model-settings object. Absent fields preserve the existing catalog + /// value, matching the normal provider catalog merge semantics. + pub fn apply_overlay(&mut self, overlay: Self) { + macro_rules! replace_some { + ($field:ident) => { + if overlay.$field.is_some() { + self.$field = overlay.$field; + } + }; + } + replace_some!(name); + replace_some!(wire_api); + replace_some!(context_window); + replace_some!(effective_context_window_percent); + replace_some!(max_tokens); + replace_some!(temperature); + replace_some!(top_p); + replace_some!(top_k); + replace_some!(reasoning_capability); + replace_some!(reasoning_implementation); + replace_some!(default_reasoning_effort); + replace_some!(default_reasoning_selection); + replace_some!(base_instructions); + replace_some!(input_modalities); + replace_some!(channel); + replace_some!(truncation_policy); + replace_some!(supports_image_detail_original); + replace_some!(enabled); + replace_some!(web_search); + replace_some!(web_fetch); + replace_some!(priority); + replace_some!(family); + replace_some!(release_date); + replace_some!(status); + replace_some!(capabilities); + merge_optional_json(&mut self.cost, overlay.cost); + merge_optional_json(&mut self.metadata, overlay.metadata); + merge_optional_json(&mut self.request, overlay.request); + merge_optional_json(&mut self.options, overlay.options); + self.headers.extend(overlay.headers); + for (variant_id, overlay_variant) in overlay.variants { + let variant = self.variants.entry(variant_id).or_default(); + if overlay_variant.label.is_some() { + variant.label = overlay_variant.label; + } + if overlay_variant.disabled { + variant.disabled = true; + } + if overlay_variant.request_model.is_some() { + variant.request_model = overlay_variant.request_model; + } + merge_optional_json(&mut variant.request, overlay_variant.request); + merge_optional_json(&mut variant.options, overlay_variant.options); + variant.headers.extend(overlay_variant.headers); + } + replace_some!(default_variant); + } + + fn to_model_override(&self, provider_wire_api: ProviderWireApi) -> ModelOverrideConfig { + ModelOverrideConfig { + display_name: self.name.clone(), + context_window: self.context_window, + effective_context_window_percent: self.effective_context_window_percent, + max_tokens: self.max_tokens, + temperature: self.temperature, + top_p: self.top_p, + top_k: self.top_k, + provider: Some(self.wire_api.unwrap_or(provider_wire_api)), + reasoning_capability: self.reasoning_capability.clone(), + reasoning_implementation: self.reasoning_implementation.clone(), + default_reasoning_effort: self.default_reasoning_effort, + base_instructions: self.base_instructions.clone(), + input_modalities: self.input_modalities.clone(), + channel: self.channel.clone(), + truncation_policy: self.truncation_policy, + supports_image_detail_original: self.supports_image_detail_original, + ..ModelOverrideConfig::default() + } + } + + fn apply_model_override(&mut self, override_config: &ModelOverrideConfig) { + if override_config.display_name.is_some() { + self.name = override_config.display_name.clone(); + } + if override_config.context_window.is_some() { + self.context_window = override_config.context_window; + } + if override_config.effective_context_window_percent.is_some() { + self.effective_context_window_percent = + override_config.effective_context_window_percent; + } + if override_config.max_tokens.is_some() { + self.max_tokens = override_config.max_tokens; + } + if override_config.temperature.is_some() { + self.temperature = override_config.temperature; + } + if override_config.top_p.is_some() { + self.top_p = override_config.top_p; + } + if override_config.top_k.is_some() { + self.top_k = override_config.top_k; + } + if override_config.provider.is_some() { + self.wire_api = override_config.provider; + } + if override_config.reasoning_capability.is_some() { + self.reasoning_capability = override_config.reasoning_capability.clone(); + } + if override_config.reasoning_implementation.is_some() { + self.reasoning_implementation = override_config.reasoning_implementation.clone(); + } + if override_config.default_reasoning_effort.is_some() { + self.default_reasoning_effort = override_config.default_reasoning_effort; + } + if override_config.base_instructions.is_some() { + self.base_instructions = override_config.base_instructions.clone(); + } + if override_config.input_modalities.is_some() { + self.input_modalities = override_config.input_modalities.clone(); + } + if override_config.channel.is_some() { + self.channel = override_config.channel.clone(); + } + if override_config.truncation_policy.is_some() { + self.truncation_policy = override_config.truncation_policy; + } + if override_config.supports_image_detail_original.is_some() { + self.supports_image_detail_original = override_config.supports_image_detail_original; + } + } +} + +fn merge_provider_entry(base: &mut ProviderConfigEntry, overlay: ProviderConfigEntry) { + if overlay.name.is_some() { + base.name = overlay.name; + } + if overlay.description.is_some() { + base.description = overlay.description; + } + if overlay.base_url.is_some() { + base.base_url = overlay.base_url; + } + if overlay.credential.is_some() { + base.credential = overlay.credential; + } + if let Some(headers) = overlay.headers { + base.headers + .get_or_insert_with(BTreeMap::new) + .extend(headers); + } + merge_optional_json(&mut base.options, overlay.options); + merge_optional_json(&mut base.request, overlay.request); + if overlay.wire_api.is_some() { + base.wire_api = overlay.wire_api; + } + if overlay.enabled.is_some() { + base.enabled = overlay.enabled; + } + if !overlay.env.is_empty() { + base.env = overlay.env; + } + if overlay.web_search.is_some() { + base.web_search = overlay.web_search; + } + if overlay.web_fetch.is_some() { + base.web_fetch = overlay.web_fetch; + } + for (model_id, overlay_model) in overlay.models { + merge_model_entry(base.models.entry(model_id).or_default(), overlay_model); + } +} + +fn merge_model_entry(base: &mut ProviderModelConfig, overlay: ProviderModelConfig) { + base.apply_overlay(overlay); +} + +fn merge_optional_json(base: &mut Option, overlay: Option) { + let Some(overlay) = overlay else { + return; + }; + match overlay { + serde_json::Value::Object(overlay) => { + if let Some(serde_json::Value::Object(base)) = base.as_mut() { + for (key, value) in overlay { + let entry = base.entry(key).or_insert(serde_json::Value::Null); + merge_json_value(entry, value); + } + } else { + *base = Some(serde_json::Value::Object(overlay)); + } + } + overlay => *base = Some(overlay), + } +} + +fn merge_json_value(base: &mut serde_json::Value, overlay: serde_json::Value) { + match overlay { + serde_json::Value::Object(overlay) => { + if let serde_json::Value::Object(base) = base { + for (key, value) in overlay { + let entry = base.entry(key).or_insert(serde_json::Value::Null); + merge_json_value(entry, value); + } + } else { + *base = serde_json::Value::Object(overlay); + } + } + overlay => { + *base = overlay; + } + } +} + +/// Forms the only stable model reference accepted by the new configuration. +pub fn model_reference(provider_id: &str, model_id: &str) -> String { + format!("{provider_id}/{model_id}") +} + +fn model_id_from_reference(provider_id: &str, model: &str) -> String { + model + .strip_prefix(&format!("{provider_id}/")) + .unwrap_or(model) + .to_string() +} + +#[cfg(test)] +mod tests { + use pretty_assertions::assert_eq; + + use super::{ + ProviderConfigFile, ProviderModelConfig, ProviderModelVariantConfig, ReasoningCapability, + ReasoningEffort, ReasoningImplementation, + }; + + #[test] + fn canonical_json_uses_nested_model_keys_without_legacy_identifiers() { + let file: ProviderConfigFile = serde_json::from_str( + r#" +{ + "model": "local/qwen3", + "provider": { + "local": { + "models": { + "qwen3": {"name": "Qwen 3"} + } + } + } +} +"#, + ) + .expect("parse provider config"); + + assert_eq!( + file.providers["local"].models["qwen3"], + ProviderModelConfig { + name: Some("Qwen 3".to_string()), + ..ProviderModelConfig::default() + } + ); + let rendered = serde_json::to_string(&file).expect("serialize provider config"); + assert!(!rendered.contains("model_slug")); + assert!(!rendered.contains("model_name")); + assert!(!rendered.contains("description")); + } + + #[test] + fn json_provider_overlay_projects_a_top_level_model_without_nested_metadata() { + let file: ProviderConfigFile = serde_json::from_str( + r#" +{ + "model": "openai/gpt-5.5", + "provider": { + "openai": { + "base_url": "https://example.com/v1", + "wire_api": "openai_responses" + } + } +} +"#, + ) + .expect("parse provider config"); + let section = file.to_provider_config_section(); + let binding = §ion.model_bindings["openai/gpt-5.5"]; + + assert_eq!(binding.provider, "openai"); + assert_eq!(binding.request_model, "gpt-5.5"); + assert_eq!(binding.invocation_method.to_string(), "openai_responses"); + } + + #[test] + fn json_disabled_entries_remain_disabled_in_the_compatibility_projection() { + let file: ProviderConfigFile = serde_json::from_str( + r#" +{ + "model": "local/qwen3", + "provider": { + "local": { + "enabled": false, + "models": { + "qwen3": {"enabled": false} + } + } + } +} +"#, + ) + .expect("parse provider config"); + let section = file.to_provider_config_section(); + + assert!(!section.providers["local"].enabled); + assert!(!section.model_bindings["local/qwen3"].enabled); + } + + #[test] + fn model_overlay_replaces_present_fields_and_preserves_omitted_fields() { + let mut model = ProviderModelConfig { + name: Some("Catalog model".to_string()), + context_window: Some(128_000), + temperature: Some(0.2), + ..ProviderModelConfig::default() + }; + model.apply_overlay(ProviderModelConfig { + context_window: Some(256_000), + priority: Some(10), + ..ProviderModelConfig::default() + }); + + assert_eq!( + model, + ProviderModelConfig { + name: Some("Catalog model".to_string()), + context_window: Some(256_000), + temperature: Some(0.2), + priority: Some(10), + ..ProviderModelConfig::default() + } + ); + } + + #[test] + fn canonical_catalog_preserves_open_ended_model_settings_and_variants() { + let file: ProviderConfigFile = serde_json::from_str( + r#" +{ + "provider": { + "custom": { + "options": {"timeout": 30, "enterprise": true}, + "request": {"extra_body": {"provider_flag": true}}, + "headers": {"X-Provider": "one"}, + "models": { + "reasoning-model": { + "family": "custom-family", + "release_date": "2026-01-01", + "cost": {"input": 1.2, "output": 4.8}, + "options": {"thinking": {"budget": 4096}}, + "request": {"reasoning_effort": "high"}, + "headers": {"X-Model": "two"}, + "variants": { + "fast": { + "label": "Fast", + "options": {"thinking": {"budget": 1024}}, + "request": {"speed": "fast"}, + "headers": {"X-Variant": "three"} + } + } + } + } + } + } +} +"#, + ) + .expect("parse rich provider catalog"); + let model = &file.providers["custom"].models["reasoning-model"]; + assert_eq!(model.family.as_deref(), Some("custom-family")); + assert_eq!(model.release_date.as_deref(), Some("2026-01-01")); + assert_eq!(model.variants["fast"].label.as_deref(), Some("Fast")); + assert_eq!(model.headers["X-Model"], "two"); + assert_eq!( + model.options, + Some(serde_json::json!({"thinking": {"budget": 4096}})) + ); + assert!( + !serde_json::to_string(&file) + .expect("serialize rich provider catalog") + .contains("model_slug") + ); + } + + #[test] + fn migrate_reasoning_implementation_projects_model_variants() { + use devo_protocol::{ReasoningVariant, ReasoningVariantConfig}; + + let mut model = ProviderModelConfig { + reasoning_capability: Some(ReasoningCapability::Toggle), + reasoning_implementation: Some(ReasoningImplementation::ModelVariant( + ReasoningVariantConfig { + variants: vec![ + ReasoningVariant { + selection_value: "disabled".to_string(), + model: "chat".to_string(), + reasoning_effort: None, + label: "Off".to_string(), + description: "Off".to_string(), + extra_body: Some(serde_json::json!({"mode": "chat"})), + }, + ReasoningVariant { + selection_value: "enabled".to_string(), + model: "reasoner".to_string(), + reasoning_effort: None, + label: "On".to_string(), + description: "On".to_string(), + extra_body: Some(serde_json::json!({"mode": "think"})), + }, + ], + }, + )), + ..ProviderModelConfig::default() + }; + model.migrate_reasoning_implementation_into_variants(); + + assert!(model.reasoning_implementation.is_none()); + assert_eq!(model.variants["off"].request_model.as_deref(), Some("chat")); + assert_eq!( + model.variants["on"].request_model.as_deref(), + Some("reasoner") + ); + assert_eq!( + model + .resolve_turn_variant_id(None, Some("enabled")) + .as_deref(), + Some("on") + ); + } + + #[test] + fn resolve_turn_variant_id_keeps_non_effort_explicit_variant() { + let model = ProviderModelConfig { + reasoning_capability: Some(ReasoningCapability::Levels(vec![ + ReasoningEffort::Low.into(), + ReasoningEffort::High.into(), + ])), + variants: [ + ( + "low".to_string(), + ProviderModelVariantConfig { + request: Some(serde_json::json!({"effort": "L"})), + ..ProviderModelVariantConfig::default() + }, + ), + ( + "fast".to_string(), + ProviderModelVariantConfig { + request: Some(serde_json::json!({"speed": "fast"})), + ..ProviderModelVariantConfig::default() + }, + ), + ] + .into_iter() + .collect(), + ..ProviderModelConfig::default() + }; + + assert_eq!( + model + .resolve_turn_variant_id(Some("fast"), Some("low")) + .as_deref(), + Some("fast") + ); + assert_eq!( + model.resolve_turn_variant_id(None, Some("low")).as_deref(), + Some("low") + ); + } +} diff --git a/crates/config/src/provider/migration.rs b/crates/config/src/provider/migration.rs new file mode 100644 index 00000000..3f4fd51e --- /dev/null +++ b/crates/config/src/provider/migration.rs @@ -0,0 +1,480 @@ +use std::collections::BTreeMap; +use std::collections::BTreeSet; +use std::path::Path; + +use devo_protocol::ProviderWireApi; +use toml::Value; + +use crate::ProviderConfigError; + +use super::auth::read_user_auth_config; +use super::auth::upsert_user_auth_api_key; +use super::catalog::ProviderConfigEntry; +use super::catalog::ProviderConfigFile; +use super::catalog::ProviderModelConfig; +use super::persistence::non_empty_string; +use super::persistence::read_provider_catalog_config; +use super::persistence::read_provider_config_document; +use super::persistence::write_atomic; +use super::persistence::write_provider_catalog_config; +use super::schema::ConfiguredModel; +use super::schema::LegacyModelProviderConfig; +use super::schema::ModelOverrideConfig; +use super::schema::ProviderConfigSection; + +/// Migrates provider-owned settings from the legacy TOML file into the +/// standalone JSON catalog during config loading. +/// +/// The migration runs before the caller parses the application config. The +/// JSON file is the durable destination, while old provider tables are removed +/// only after the catalog and legacy API keys have been written successfully. +/// Existing JSON values take precedence over TOML values, so restarting cannot +/// regress a newer configuration. +pub(crate) fn migrate_legacy_provider_config_on_startup( + legacy_config_file: &Path, + target_config_file: &Path, + user_config_dir: &Path, +) -> Result<(), ProviderConfigError> { + if !legacy_config_file.exists() { + return Ok(()); + } + + let document = read_provider_config_document(legacy_config_file)?; + let legacy = document + .clone() + .try_into::() + .map_err(|error| ProviderConfigError::ParseTomlFile { + path: legacy_config_file.to_path_buf(), + message: error.to_string(), + })?; + if !has_legacy_provider_config(&legacy) { + return Ok(()); + } + + let mut migrated = ProviderConfigFile::from_provider_config_section(&legacy); + restore_legacy_provider_fields(&document, &mut migrated); + merge_legacy_model_providers(&mut migrated, &legacy.model_providers); + if migrated.model.is_none() { + migrated.model = legacy_model_selection(&legacy, &migrated); + } + + let consumed_model_overrides = + migrate_legacy_model_overrides(&mut migrated, &legacy.model_overrides)?; + + migrate_legacy_api_keys(&document, &legacy, &mut migrated, user_config_dir)?; + + let existing = read_provider_catalog_config(target_config_file)?; + let target_exists = target_config_file.exists(); + let migrated_snapshot = migrated.clone(); + let mut merged = migrated; + if target_exists { + merged.merge_overlay(existing.clone()); + } + if has_persistable_provider_config(&merged) && (!target_exists || merged != existing) { + write_provider_catalog_config(target_config_file, &merged)?; + } + + remove_migrated_legacy_config( + legacy_config_file, + document, + &legacy, + &migrated_snapshot, + &consumed_model_overrides, + ) +} + +fn has_legacy_provider_config(config: &ProviderConfigSection) -> bool { + config.model_provider.is_some() + || config.model.is_some() + || config.model_reasoning_effort_selection.is_some() + || config.defaults.model_binding.is_some() + || !config.providers.is_empty() + || !config.model_bindings.is_empty() + || !config.model_overrides.is_empty() + || !config.model_providers.is_empty() +} + +fn has_persistable_provider_config(config: &ProviderConfigFile) -> bool { + config.model.is_some() + || config.small_model.is_some() + || config.reasoning_effort.is_some() + || !config.providers.is_empty() +} + +fn restore_legacy_provider_fields(document: &Value, catalog: &mut ProviderConfigFile) { + let Some(providers) = document + .as_table() + .and_then(|table| table.get("providers")) + .and_then(Value::as_table) + else { + return; + }; + for (provider_id, provider_value) in providers { + let Some(provider_table) = provider_value.as_table() else { + continue; + }; + let Some(provider) = catalog.providers.get_mut(provider_id) else { + continue; + }; + if provider.headers.is_none() + && let Some(headers) = provider_table.get("headers").and_then(Value::as_str) + && let Ok(headers) = serde_json::from_str(headers) + { + provider.headers = Some(headers); + } + if !provider_table.contains_key("enabled") { + provider.enabled = None; + } + for (model_id, model) in &mut provider.models { + let Some(binding_table) = find_legacy_binding_table(document, provider_id, model_id) + else { + continue; + }; + if !binding_table.contains_key("enabled") { + model.enabled = None; + } + if !binding_table.contains_key("invocation_method") { + model.wire_api = None; + } + } + } +} + +fn find_legacy_binding_table<'a>( + document: &'a Value, + provider_id: &str, + model_id: &str, +) -> Option<&'a toml::map::Map> { + let bindings = document + .as_table() + .and_then(|table| table.get("model_bindings")) + .and_then(Value::as_table)?; + bindings.values().find_map(|binding| { + let binding = binding.as_table()?; + let binding_provider = binding.get("provider").and_then(Value::as_str)?; + let request_model = binding + .get("request_model") + .or_else(|| binding.get("model_name")) + .and_then(Value::as_str)?; + let request_model = request_model + .strip_prefix(&format!("{provider_id}/")) + .unwrap_or(request_model); + (binding_provider == provider_id && request_model == model_id).then_some(binding) + }) +} + +fn merge_legacy_model_providers( + catalog: &mut ProviderConfigFile, + legacy_providers: &BTreeMap, +) { + for (provider_id, legacy_provider) in legacy_providers { + let provider = catalog + .providers + .entry(provider_id.clone()) + .or_insert_with(|| ProviderConfigEntry { + name: Some( + legacy_provider + .name + .clone() + .unwrap_or_else(|| provider_id.clone()), + ), + base_url: legacy_provider.base_url.clone(), + wire_api: Some( + legacy_provider + .wire_api + .unwrap_or(ProviderWireApi::OpenAIChatCompletions), + ), + enabled: Some(true), + ..ProviderConfigEntry::default() + }); + if provider.name.is_none() { + provider.name = Some( + legacy_provider + .name + .clone() + .unwrap_or_else(|| provider_id.clone()), + ); + } + if provider.base_url.is_none() { + provider.base_url = legacy_provider.base_url.clone(); + } + if provider.wire_api.is_none() { + provider.wire_api = Some( + legacy_provider + .wire_api + .unwrap_or(ProviderWireApi::OpenAIChatCompletions), + ); + } + if provider.enabled.is_none() { + provider.enabled = Some(true); + } + + let provider_wire_api = provider + .wire_api + .unwrap_or(ProviderWireApi::OpenAIChatCompletions); + for model in &legacy_provider.models { + let Some(model_id) = legacy_model_id(provider_id, model) else { + continue; + }; + let entry = provider.models.entry(model_id).or_default(); + if entry.wire_api.is_none() { + entry.wire_api = Some(provider_wire_api); + } + if provider.base_url.is_none() { + provider.base_url = model.base_url.clone(); + } + } + } +} + +fn legacy_model_id(provider_id: &str, model: &ConfiguredModel) -> Option { + let model_id = model.model.trim(); + if model_id.is_empty() { + return None; + } + Some( + model_id + .strip_prefix(&format!("{provider_id}/")) + .unwrap_or(model_id) + .to_string(), + ) +} + +fn legacy_model_selection( + legacy: &ProviderConfigSection, + catalog: &ProviderConfigFile, +) -> Option { + let provider_id = legacy.model_provider.as_deref().or_else(|| { + (legacy.model_providers.len() == 1) + .then(|| legacy.model_providers.keys().next().map(String::as_str)) + .flatten() + })?; + let legacy_provider = legacy.model_providers.get(provider_id); + let model_id = legacy + .model + .as_deref() + .or_else(|| legacy_provider.and_then(|provider| provider.default_model.as_deref())) + .or_else(|| legacy_provider.and_then(|provider| provider.last_model.as_deref()))?; + let model_id = model_id + .strip_prefix(&format!("{provider_id}/")) + .unwrap_or(model_id) + .trim(); + if model_id.is_empty() { + return None; + } + catalog + .providers + .get(provider_id) + .map(|_| format!("{provider_id}/{model_id}")) +} + +fn migrate_legacy_model_overrides( + catalog: &mut ProviderConfigFile, + overrides: &BTreeMap, +) -> Result, ProviderConfigError> { + let mut consumed = BTreeSet::new(); + for (model_reference, override_config) in overrides { + let matches = catalog + .providers + .iter() + .flat_map(|(provider_id, provider)| { + provider.models.keys().filter_map(move |model_id| { + let exact_reference = format!("{provider_id}/{model_id}"); + (model_reference == model_id || model_reference == &exact_reference) + .then(|| (provider_id.clone(), model_id.clone())) + }) + }) + .collect::>(); + if matches.is_empty() { + continue; + } + + let overlay = model_override_as_catalog_model(override_config)?; + for (provider_id, model_id) in matches { + if let Some(model) = catalog + .providers + .get_mut(&provider_id) + .and_then(|provider| provider.models.get_mut(&model_id)) + { + model.apply_overlay(overlay.clone()); + } + } + consumed.insert(model_reference.clone()); + } + Ok(consumed) +} + +fn model_override_as_catalog_model( + override_config: &ModelOverrideConfig, +) -> Result { + let mut value = + serde_json::to_value(override_config).map_err(|error| ProviderConfigError::Serialize { + message: error.to_string(), + })?; + if let (Some(object), Some(wire_api)) = (value.as_object_mut(), override_config.provider) { + object.insert( + "wire_api".to_string(), + serde_json::to_value(wire_api).map_err(|error| ProviderConfigError::Serialize { + message: error.to_string(), + })?, + ); + } + serde_json::from_value(value).map_err(|error| ProviderConfigError::Serialize { + message: error.to_string(), + }) +} + +fn migrate_legacy_api_keys( + document: &Value, + legacy: &ProviderConfigSection, + catalog: &mut ProviderConfigFile, + user_config_dir: &Path, +) -> Result<(), ProviderConfigError> { + let mut credentials = BTreeMap::new(); + collect_provider_api_keys(document, "providers", &mut credentials); + collect_provider_api_keys(document, "model_providers", &mut credentials); + + for (provider_id, provider) in &legacy.model_providers { + if credentials.contains_key(provider_id) { + continue; + } + if let Some(api_key) = provider.api_key.as_deref().and_then(non_empty_string) { + credentials.insert(provider_id.clone(), api_key); + continue; + } + if let Some(api_key) = provider + .models + .iter() + .find_map(|model| model.api_key.as_deref().and_then(non_empty_string)) + { + credentials.insert(provider_id.clone(), api_key); + } + } + + if credentials.is_empty() { + return Ok(()); + } + + let auth_file = user_config_dir.join(super::auth::AUTH_CONFIG_FILE_NAME); + let auth = read_user_auth_config(&auth_file)?; + let mut existing_credentials = auth.credentials.into_keys().collect::>(); + for (provider_id, api_key) in credentials { + let credential_id = legacy + .providers + .get(&provider_id) + .and_then(|provider| provider.credential.clone()) + .unwrap_or_else(|| super::auth::default_provider_credential_id(&provider_id)); + if let Some(provider) = catalog.providers.get_mut(&provider_id) { + provider.credential = Some(credential_id.clone()); + } + if existing_credentials.insert(credential_id.clone()) { + upsert_user_auth_api_key(user_config_dir, &credential_id, &api_key)?; + } + } + Ok(()) +} + +fn collect_provider_api_keys( + document: &Value, + table_name: &str, + credentials: &mut BTreeMap, +) { + let Some(providers) = document + .as_table() + .and_then(|table| table.get(table_name)) + .and_then(Value::as_table) + else { + return; + }; + for (provider_id, provider) in providers { + let Some(api_key) = provider + .as_table() + .and_then(|table| table.get("api_key")) + .and_then(Value::as_str) + .and_then(non_empty_string) + else { + continue; + }; + credentials.entry(provider_id.clone()).or_insert(api_key); + } +} + +fn remove_migrated_legacy_config( + config_file: &Path, + mut document: Value, + legacy: &ProviderConfigSection, + migrated: &ProviderConfigFile, + consumed_model_overrides: &BTreeSet, +) -> Result<(), ProviderConfigError> { + let table = ensure_table(&mut document); + let mut changed = false; + if !legacy.providers.is_empty() { + changed |= table.remove("providers").is_some(); + } + if !legacy.model_bindings.is_empty() { + changed |= table.remove("model_bindings").is_some(); + } + if !legacy.model_providers.is_empty() { + changed |= table.remove("model_providers").is_some(); + } + if legacy.model_provider.is_some() + && migrated + .providers + .contains_key(legacy.model_provider.as_deref().unwrap_or_default()) + { + changed |= table.remove("model_provider").is_some(); + } + if legacy.model_reasoning_effort_selection.is_some() && migrated.reasoning_effort.is_some() { + for key in [ + "model_reasoning_effort_selection", + "model_thinking_selection", + "model_thinking", + ] { + changed |= table.remove(key).is_some(); + } + } + if legacy.defaults.model_binding.is_some() + && migrated.model.is_some() + && let Some(defaults) = table.get_mut("defaults").and_then(Value::as_table_mut) + { + changed |= defaults.remove("model_binding").is_some(); + if defaults.is_empty() { + changed |= table.remove("defaults").is_some(); + } + } + + if legacy.model.is_some() { + match table.get_mut("model") { + Some(Value::String(_)) if migrated.model.is_some() => { + changed |= table.remove("model").is_some(); + } + Some(Value::Table(model_overrides)) => { + for model_reference in consumed_model_overrides { + changed |= model_overrides.remove(model_reference).is_some(); + } + if model_overrides.is_empty() { + changed |= table.remove("model").is_some(); + } + } + _ => {} + } + } + + if changed { + let data = + toml::to_string_pretty(&document).map_err(|error| ProviderConfigError::Serialize { + message: error.to_string(), + })?; + write_atomic(config_file, data.as_bytes())?; + } + Ok(()) +} + +fn ensure_table(value: &mut Value) -> &mut toml::map::Map { + if !value.is_table() { + *value = Value::Table(Default::default()); + } + value + .as_table_mut() + .expect("value should be a TOML table after normalization") +} diff --git a/crates/config/src/provider/persistence.rs b/crates/config/src/provider/persistence.rs index 44289a07..737ff8ac 100644 --- a/crates/config/src/provider/persistence.rs +++ b/crates/config/src/provider/persistence.rs @@ -4,37 +4,36 @@ use std::path::Path; use std::time::SystemTime; use std::time::UNIX_EPOCH; -use devo_protocol::ProviderVendor; use toml::Value; use crate::ProviderConfigError; -use super::schema::ProviderConfigSection; -use super::schema::ProviderVendorConfig; +use super::ProviderConfigFile; pub const CONFIG_FILE_NAME: &str = "config.toml"; +pub const PROVIDER_CONFIG_FILE_NAME: &str = "providers.json"; -pub(crate) fn read_provider_config( +pub fn read_provider_catalog_config( config_file: &Path, -) -> Result { +) -> Result { if !config_file.exists() { - return Ok(ProviderConfigSection::default()); + return Ok(ProviderConfigFile::default()); } - let data = std::fs::read_to_string(config_file).map_err(|source| ProviderConfigError::Io { + let data = fs::read_to_string(config_file).map_err(|source| ProviderConfigError::Io { action: "read", path: config_file.to_path_buf(), source, })?; - toml::from_str(&data).map_err(|error| ProviderConfigError::ParseTomlFile { + serde_json::from_str(&data).map_err(|error| ProviderConfigError::ParseJsonFile { path: config_file.to_path_buf(), message: error.to_string(), }) } -pub(crate) fn write_provider_config( +pub fn write_provider_catalog_config( config_file: &Path, - config: &ProviderConfigSection, + config: &ProviderConfigFile, ) -> Result<(), ProviderConfigError> { if let Some(parent) = config_file.parent() { fs::create_dir_all(parent).map_err(|source| ProviderConfigError::Io { @@ -43,16 +42,16 @@ pub(crate) fn write_provider_config( source, })?; } - let mut document = read_provider_config_document(config_file)?; - update_provider_config_document(&mut document, config)?; let data = - toml::to_string_pretty(&document).map_err(|error| ProviderConfigError::Serialize { + serde_json::to_vec_pretty(config).map_err(|error| ProviderConfigError::Serialize { message: error.to_string(), })?; - write_atomic(config_file, data.as_bytes()) + let mut data = data; + data.push(b'\n'); + write_atomic(config_file, &data) } -/// Reads raw TOML so provider writes can preserve unrelated app config sections. +/// Reads raw TOML so unrelated app configuration sections can be updated safely. pub(crate) fn read_provider_config_document( config_file: &Path, ) -> Result { @@ -71,169 +70,6 @@ pub(crate) fn read_provider_config_document( }) } -/// Overlays provider-owned fields onto an existing TOML document. -fn update_provider_config_document( - document: &mut Value, - config: &ProviderConfigSection, -) -> Result<(), ProviderConfigError> { - let replacement = - toml::Value::try_from(config).map_err(|error| ProviderConfigError::Serialize { - message: error.to_string(), - })?; - let document = ensure_table(document); - let replacement = replacement - .as_table() - .expect("provider config must serialize to a TOML table"); - - overlay_optional_key(document, replacement, "model_provider"); - if config.model_overrides.is_empty() { - overlay_optional_key(document, replacement, "model"); - } else { - let model_overrides = document - .entry("model".to_string()) - .or_insert_with(|| Value::Table(Default::default())); - let model_overrides = ensure_table(model_overrides); - let replacement_overrides = replacement.get("model").and_then(Value::as_table); - - for model_slug in config.model_overrides.keys() { - let model_override = model_overrides - .entry(model_slug.clone()) - .or_insert_with(|| Value::Table(Default::default())); - let model_override = ensure_table(model_override); - let replacement_override = replacement_overrides - .and_then(|overrides| overrides.get(model_slug)) - .and_then(Value::as_table); - - if let Some(replacement_override) = replacement_override { - overlay_optional_key(model_override, replacement_override, "display_name"); - overlay_optional_key(model_override, replacement_override, "description"); - overlay_optional_key(model_override, replacement_override, "context_window"); - overlay_optional_key( - model_override, - replacement_override, - "effective_context_window_percent", - ); - overlay_optional_key(model_override, replacement_override, "max_tokens"); - overlay_optional_key(model_override, replacement_override, "temperature"); - overlay_optional_key(model_override, replacement_override, "top_p"); - overlay_optional_key(model_override, replacement_override, "top_k"); - overlay_optional_key(model_override, replacement_override, "provider"); - overlay_optional_key(model_override, replacement_override, "reasoning_capability"); - overlay_optional_key( - model_override, - replacement_override, - "reasoning_implementation", - ); - overlay_optional_key( - model_override, - replacement_override, - "default_reasoning_effort", - ); - overlay_optional_key(model_override, replacement_override, "base_instructions"); - overlay_optional_key(model_override, replacement_override, "input_modalities"); - overlay_optional_key(model_override, replacement_override, "channel"); - overlay_optional_key(model_override, replacement_override, "truncation_policy"); - overlay_optional_key( - model_override, - replacement_override, - "supports_image_detail_original", - ); - } - } - } - overlay_optional_key(document, replacement, "model_reasoning_effort_selection"); - document.remove("model_thinking_selection"); - document.remove("model_thinking"); - overlay_optional_key(document, replacement, "model_auto_compact_token_limit"); - overlay_optional_key(document, replacement, "model_context_window"); - overlay_optional_key(document, replacement, "disable_response_storage"); - overlay_optional_key(document, replacement, "preferred_auth_method"); - overlay_optional_key(document, replacement, "defaults"); - - let providers = document - .entry("providers".to_string()) - .or_insert_with(|| Value::Table(Default::default())); - let providers = ensure_table(providers); - let replacement_providers = replacement.get("providers").and_then(Value::as_table); - - for provider_id in config.providers.keys() { - let provider = providers - .entry(provider_id.clone()) - .or_insert_with(|| Value::Table(Default::default())); - let provider = ensure_table(provider); - let replacement_provider = replacement_providers - .and_then(|providers| providers.get(provider_id)) - .and_then(Value::as_table); - - if let Some(replacement_provider) = replacement_provider { - overlay_optional_key(provider, replacement_provider, "name"); - overlay_optional_key(provider, replacement_provider, "base_url"); - overlay_optional_key(provider, replacement_provider, "credential"); - overlay_optional_key(provider, replacement_provider, "headers"); - overlay_optional_key(provider, replacement_provider, "wire_apis"); - overlay_optional_key(provider, replacement_provider, "enabled"); - } - } - - if providers.is_empty() { - document.remove("providers"); - } - - let model_bindings = document - .entry("model_bindings".to_string()) - .or_insert_with(|| Value::Table(Default::default())); - let model_bindings = ensure_table(model_bindings); - let replacement_bindings = replacement.get("model_bindings").and_then(Value::as_table); - - for binding_id in config.model_bindings.keys() { - let binding = model_bindings - .entry(binding_id.clone()) - .or_insert_with(|| Value::Table(Default::default())); - let binding = ensure_table(binding); - let replacement_binding = replacement_bindings - .and_then(|bindings| bindings.get(binding_id)) - .and_then(Value::as_table); - - if let Some(replacement_binding) = replacement_binding { - overlay_optional_key(binding, replacement_binding, "model_slug"); - overlay_optional_key(binding, replacement_binding, "provider"); - overlay_optional_key(binding, replacement_binding, "request_model"); - binding.remove("model_name"); - overlay_optional_key(binding, replacement_binding, "display_name"); - overlay_optional_key(binding, replacement_binding, "invocation_method"); - overlay_optional_key(binding, replacement_binding, "default_reasoning_effort"); - overlay_optional_key(binding, replacement_binding, "enabled"); - } - } - - if model_bindings.is_empty() { - document.remove("model_bindings"); - } - - Ok(()) -} - -fn ensure_table(value: &mut Value) -> &mut toml::map::Map { - if !value.is_table() { - *value = Value::Table(Default::default()); - } - value - .as_table_mut() - .expect("value should be a TOML table after normalization") -} - -fn overlay_optional_key( - document: &mut toml::map::Map, - replacement: &toml::map::Map, - key: &str, -) { - if let Some(value) = replacement.get(key) { - document.insert(key.to_string(), value.clone()); - } else { - document.remove(key); - } -} - pub(crate) fn write_atomic(path: &Path, data: &[u8]) -> Result<(), ProviderConfigError> { let parent = path.parent().unwrap_or_else(|| Path::new(".")); let file_name = path @@ -294,24 +130,6 @@ pub(crate) fn write_atomic(path: &Path, data: &[u8]) -> Result<(), ProviderConfi }) } -pub(crate) fn provider_vendor_from_config( - provider_id: &str, - provider_config: &ProviderVendorConfig, -) -> ProviderVendor { - ProviderVendor { - name: if provider_config.name.is_empty() { - provider_id.to_string() - } else { - provider_config.name.clone() - }, - base_url: provider_config.base_url.clone(), - credential: provider_config.credential.clone(), - headers: provider_config.headers.clone(), - wire_apis: provider_config.wire_apis.clone(), - enabled: provider_config.enabled, - } -} - pub(crate) fn non_empty_string(value: &str) -> Option { let trimmed = value.trim(); if trimmed.is_empty() { diff --git a/crates/config/src/provider/request.rs b/crates/config/src/provider/request.rs new file mode 100644 index 00000000..ceaf1981 --- /dev/null +++ b/crates/config/src/provider/request.rs @@ -0,0 +1,147 @@ +//! Resolution of arbitrary provider/model request settings. + +use std::collections::BTreeMap; + +use serde_json::Value; + +use super::catalog::ProviderConfigFile; + +/// Resolves provider/model request defaults and headers. +/// +/// Provider options are intentionally merged into the request object for the +/// built-in HTTP adapters. This gives custom providers an escape hatch for +/// wire-specific fields while preserving deterministic precedence: +/// provider < model < selected variant, and options < request at each level. +pub fn provider_request_config( + config: &ProviderConfigFile, + provider_id: &str, + model_id: &str, + variant_id: Option<&str>, +) -> (Option, BTreeMap) { + let Some(provider) = config.providers.get(provider_id) else { + return (None, BTreeMap::new()); + }; + let model_id = model_id + .strip_prefix(&format!("{provider_id}/")) + .unwrap_or(model_id); + let model = provider.models.get(model_id); + let selected_variant = model.and_then(|model| { + variant_id + .or(model.default_variant.as_deref()) + .and_then(|id| model.variants.get(id)) + .filter(|variant| !variant.disabled) + }); + let mut request = None; + merge_json_option(&mut request, provider.options.clone()); + merge_json_option(&mut request, provider.request.clone()); + if let Some(model) = model { + merge_json_option(&mut request, model.options.clone()); + merge_json_option(&mut request, model.request.clone()); + if let Some(variant) = selected_variant { + merge_json_option(&mut request, variant.options.clone()); + merge_json_option(&mut request, variant.request.clone()); + } + } + let mut headers = provider.headers.clone().unwrap_or_default(); + if let Some(model) = model { + headers.extend(model.headers.clone()); + if let Some(variant) = selected_variant { + headers.extend(variant.headers.clone()); + } + } + (request, headers) +} + +fn merge_json_option(base: &mut Option, overlay: Option) { + let Some(overlay) = overlay else { + return; + }; + match overlay { + Value::Object(overlay) => { + if let Some(Value::Object(base)) = base.as_mut() { + for (key, value) in overlay { + merge_json_value(base.entry(key).or_insert(Value::Null), value); + } + } else { + *base = Some(Value::Object(overlay)); + } + } + overlay => *base = Some(overlay), + } +} + +fn merge_json_value(base: &mut Value, overlay: Value) { + match overlay { + Value::Object(overlay) => { + if let Value::Object(base) = base { + for (key, value) in overlay { + merge_json_value(base.entry(key).or_insert(Value::Null), value); + } + } else { + *base = Value::Object(overlay); + } + } + overlay => *base = overlay, + } +} + +#[cfg(test)] +mod tests { + use pretty_assertions::assert_eq; + + use super::provider_request_config; + use crate::{ + ProviderConfigEntry, ProviderConfigFile, ProviderModelConfig, ProviderModelVariantConfig, + }; + + #[test] + fn request_defaults_merge_provider_model_and_variant_layers() { + let config = ProviderConfigFile { + providers: [( + "custom".to_string(), + ProviderConfigEntry { + options: Some(serde_json::json!({"timeout": 10, "nested": {"a": true}})), + request: Some(serde_json::json!({"provider_field": "p"})), + models: [( + "model".to_string(), + ProviderModelConfig { + options: Some(serde_json::json!({"nested": {"b": true}})), + request: Some(serde_json::json!({"model_field": "m"})), + variants: [( + "fast".to_string(), + ProviderModelVariantConfig { + options: Some(serde_json::json!({"timeout": 5})), + request: Some(serde_json::json!({"variant_field": "v"})), + ..ProviderModelVariantConfig::default() + }, + )] + .into_iter() + .collect(), + ..ProviderModelConfig::default() + }, + )] + .into_iter() + .collect(), + ..ProviderConfigEntry::default() + }, + )] + .into_iter() + .collect(), + ..ProviderConfigFile::default() + }; + + assert_eq!( + provider_request_config(&config, "custom", "model", Some("fast")), + ( + Some(serde_json::json!({ + "timeout": 5, + "nested": {"a": true, "b": true}, + "provider_field": "p", + "model_field": "m", + "variant_field": "v" + })), + std::collections::BTreeMap::new() + ) + ); + } +} diff --git a/crates/config/src/provider/resolve.rs b/crates/config/src/provider/resolve.rs deleted file mode 100644 index 043544ec..00000000 --- a/crates/config/src/provider/resolve.rs +++ /dev/null @@ -1,292 +0,0 @@ -//! Provider and model-binding resolution from serialized config plus user auth. -//! -//! This module keeps the TOML/auth shapes separate from runtime settings: -//! callers get owned resolved values, while validation can still distinguish -//! missing, disabled, and unsupported provider/binding states for user-facing -//! errors. - -use devo_util_paths::current_user_config_file; - -use crate::ProviderConfigError; - -use super::auth::current_user_auth_config; -use super::schema::AuthCredentialKind; -use super::schema::ModelBindingConfig; -use super::schema::ProviderConfigSection; -use super::schema::ProviderVendorConfig; -use super::schema::ResolvedProviderSettings; -use super::schema::UserAuthConfigFile; - -#[derive(Debug, Clone, PartialEq, Eq)] -pub struct ResolvedModelBinding { - pub binding_id: String, - pub model_slug: String, - pub request_model: String, - pub provider_id: String, - pub invocation_method: devo_protocol::ProviderWireApi, - pub default_reasoning_effort: Option, - pub enabled: bool, -} - -impl ResolvedModelBinding { - fn from_config(binding_id: &str, binding: &ModelBindingConfig) -> Self { - Self { - binding_id: binding_id.to_string(), - model_slug: binding.model_slug.clone(), - request_model: binding.request_model.clone(), - provider_id: binding.provider.clone(), - invocation_method: binding.invocation_method, - default_reasoning_effort: binding.default_reasoning_effort.clone(), - enabled: binding.enabled, - } - } -} - -#[derive(Clone, Copy)] -enum BindingVisibility { - IncludeDisabled, - EnabledOnly, -} - -impl BindingVisibility { - fn allows(self, binding: &ModelBindingConfig) -> bool { - match self { - Self::IncludeDisabled => true, - Self::EnabledOnly => binding.enabled, - } - } -} - -/// Loads the user's provider config file from the standard config path. -pub fn load_config() -> Result { - let path = current_user_config_file().map_err(|error| ProviderConfigError::ConfigPath { - message: format!("could not determine user config path: {error}"), - })?; - if path.exists() { - let data = std::fs::read_to_string(&path).map_err(|source| ProviderConfigError::Io { - action: "read", - path: path.clone(), - source, - })?; - return toml::from_str(&data).map_err(|error| ProviderConfigError::ParseTomlFile { - path, - message: error.to_string(), - }); - } - - Ok(ProviderConfigSection::default()) -} - -/// Resolves provider settings without constructing a local provider instance. -pub fn resolve_provider_settings() -> Result { - let auth = current_user_auth_config()?; - resolve_provider_settings_from_config_and_auth(&load_config().unwrap_or_default(), &auth) -} - -/// Resolves provider settings using user-scoped auth material for new providers. -pub fn resolve_provider_settings_from_config_and_auth( - file: &ProviderConfigSection, - auth: &UserAuthConfigFile, -) -> Result { - if let Some(binding) = resolve_model_binding(file, None) { - let provider_config = file.providers.get(&binding.provider_id).ok_or_else(|| { - ProviderConfigError::Validation { - message: format!( - "configured provider `{}` was not found", - binding.provider_id - ), - } - })?; - if !provider_config.enabled { - return Err(ProviderConfigError::Validation { - message: format!("configured provider `{}` is disabled", binding.provider_id), - }); - } - if !binding.enabled { - return Err(ProviderConfigError::Validation { - message: format!( - "configured model binding `{}` is disabled", - binding.binding_id - ), - }); - } - if !provider_config.wire_apis.is_empty() - && !provider_config - .wire_apis - .contains(&binding.invocation_method) - { - return Err(ProviderConfigError::Validation { - message: format!( - "model binding `{}` uses unsupported provider wire API `{}`", - binding.binding_id, binding.invocation_method - ), - }); - } - - let api_key = resolve_provider_api_key(&binding.provider_id, provider_config, auth)?; - let model_reasoning_effort_selection = file - .model_reasoning_effort_selection - .clone() - .or(binding.default_reasoning_effort); - - return Ok(ResolvedProviderSettings { - provider_id: binding.provider_id, - wire_api: binding.invocation_method, - model: binding.request_model, - base_url: provider_config.base_url.clone(), - api_key, - proxy_url: None, - no_proxy: None, - headers: provider_config.headers.clone(), - model_auto_compact_token_limit: file.model_auto_compact_token_limit, - model_context_window: file.model_context_window, - model_reasoning_effort_selection, - disable_response_storage: file.disable_response_storage.unwrap_or(false), - preferred_auth_method: file.preferred_auth_method, - }); - } - - Err(ProviderConfigError::Validation { - message: "No provider configured. Run `devo onboard` to complete setup.".to_string(), - }) -} - -fn resolve_provider_api_key( - provider_id: &str, - provider_config: &ProviderVendorConfig, - auth: &UserAuthConfigFile, -) -> Result, ProviderConfigError> { - let Some(credential_id) = provider_config.credential.as_deref() else { - return Ok(None); - }; - let credential = - auth.credentials - .get(credential_id) - .ok_or_else(|| ProviderConfigError::Validation { - message: format!( - "provider `{provider_id}` references missing credential `{credential_id}` in user auth.json" - ), - })?; - match credential.kind { - AuthCredentialKind::ApiKey => Ok(Some(credential.value.clone())), - } -} - -pub fn resolve_model_binding( - config: &ProviderConfigSection, - requested_model: Option<&str>, -) -> Option { - // This resolver is used for configuration validation. It intentionally keeps - // a configured-but-disabled binding visible so the caller can report - // "binding is disabled" instead of silently selecting another model. - if let Some(requested_model) = requested_model { - return requested_model_binding( - config, - requested_model, - BindingVisibility::IncludeDisabled, - ); - } - - if let Some(binding) = config - .defaults - .model_binding - .as_deref() - .and_then(|binding_id| { - config - .model_bindings - .get(binding_id) - .map(|binding| ResolvedModelBinding::from_config(binding_id, binding)) - }) - { - return Some(binding); - } - - config - .model - .as_deref() - .and_then(|model| resolve_model_binding(config, Some(model))) - .or_else(|| { - config - .model_bindings - .iter() - .find(|(_, binding)| binding.enabled) - .map(|(binding_id, binding)| ResolvedModelBinding::from_config(binding_id, binding)) - }) -} - -pub fn resolve_enabled_model_binding( - config: &ProviderConfigSection, - requested_model: Option<&str>, -) -> Option { - // Runtime turn selection only uses enabled bindings. A user-facing model - // override may name the binding id, the local catalog slug, or the provider - // wire name: e.g. `deepseek-main`, `deepseek-v4-pro`, or - // `deepseek/deepseek-v4-pro`. - if let Some(requested_model) = requested_model { - return requested_model_binding(config, requested_model, BindingVisibility::EnabledOnly); - } - - config - .defaults - .model_binding - .as_deref() - .and_then(|binding_id| { - config - .model_bindings - .get(binding_id) - .filter(|binding| binding.enabled) - .map(|binding| ResolvedModelBinding::from_config(binding_id, binding)) - }) - .or_else(|| { - config - .model_bindings - .iter() - .find(|(_, binding)| binding.enabled) - .map(|(binding_id, binding)| ResolvedModelBinding::from_config(binding_id, binding)) - }) -} - -fn requested_model_binding( - config: &ProviderConfigSection, - requested_model: &str, - visibility: BindingVisibility, -) -> Option { - config - .model_bindings - .iter() - .find(|(binding_id, binding)| { - visibility.allows(binding) && binding_id.as_str() == requested_model - }) - .or_else(|| { - config.model_bindings.iter().find(|(_, binding)| { - visibility.allows(binding) - && (binding.model_slug == requested_model - || binding.request_model == requested_model) - }) - }) - .map(|(binding_id, binding)| ResolvedModelBinding::from_config(binding_id, binding)) -} - -pub fn provider_request_model_map_for_binding( - config: &ProviderConfigSection, - binding: &ResolvedModelBinding, -) -> std::collections::HashMap { - // Reasoning model variants are catalog slugs first. When `kimi-k2.5` resolves - // to variant slug `kimi-k2.5-thinking`, the provider request must use the - // matching binding's `request_model`, such as `moonshotai/kimi-k2.5-thinking`. - // Scope this map to the selected provider so another provider with the same - // variant slug cannot hijack the wire model name. - let mut request_model_map = - std::collections::HashMap::with_capacity(config.model_bindings.len()); - for candidate in config - .model_bindings - .values() - .filter(|candidate| candidate.enabled && candidate.provider == binding.provider_id) - { - request_model_map.insert( - candidate.model_slug.clone(), - candidate.request_model.clone(), - ); - } - request_model_map -} diff --git a/crates/config/src/provider/runtime.rs b/crates/config/src/provider/runtime.rs new file mode 100644 index 00000000..39e6aad2 --- /dev/null +++ b/crates/config/src/provider/runtime.rs @@ -0,0 +1,121 @@ +//! Provider fields that require rebuilding the live HTTP adapter. + +use super::{ProviderConfigEntry, ProviderConfigFile}; + +/// Returns whether two provider catalogs require a new provider router. +/// +/// Model metadata, default selections, and request/options overlays are +/// resolved per turn and therefore do not require rebuilding the HTTP +/// adapters. Only fields used to construct adapters or their route table are +/// compared here. +pub fn provider_runtime_config_changed( + current: &ProviderConfigFile, + inherited: &ProviderConfigFile, +) -> bool { + if current.providers.len() != inherited.providers.len() { + return true; + } + + current.providers.iter().any(|(provider_id, provider)| { + inherited + .providers + .get(provider_id) + .is_none_or(|inherited_provider| { + provider_runtime_fields_changed(provider, inherited_provider) + }) + }) +} + +fn provider_runtime_fields_changed( + current: &ProviderConfigEntry, + inherited: &ProviderConfigEntry, +) -> bool { + current.base_url != inherited.base_url + || current.credential != inherited.credential + || current.headers != inherited.headers + || current.wire_api != inherited.wire_api + || current.enabled != inherited.enabled + || model_wire_apis(current) != model_wire_apis(inherited) +} + +fn model_wire_apis(provider: &ProviderConfigEntry) -> Vec { + let mut wire_apis = provider + .models + .values() + .filter_map(|model| model.wire_api) + .map(|wire_api| wire_api.as_str().to_string()) + .collect::>(); + wire_apis.sort(); + wire_apis.dedup(); + wire_apis +} + +#[cfg(test)] +mod tests { + use pretty_assertions::assert_eq; + + use super::provider_runtime_config_changed; + use crate::{ProviderConfigEntry, ProviderConfigFile, ProviderModelConfig}; + + #[test] + fn ignores_model_metadata_and_default_selection_changes() { + let current = ProviderConfigFile { + model: Some("custom/model".to_string()), + providers: [( + "custom".to_string(), + ProviderConfigEntry { + models: [("model".to_string(), ProviderModelConfig::default())] + .into_iter() + .collect(), + ..ProviderConfigEntry::default() + }, + )] + .into_iter() + .collect(), + ..ProviderConfigFile::default() + }; + let inherited = ProviderConfigFile { + model: Some("custom/other-model".to_string()), + providers: [( + "custom".to_string(), + ProviderConfigEntry { + models: [( + "model".to_string(), + ProviderModelConfig { + name: Some("Old display name".to_string()), + context_window: Some(32_000), + ..ProviderModelConfig::default() + }, + )] + .into_iter() + .collect(), + ..ProviderConfigEntry::default() + }, + )] + .into_iter() + .collect(), + ..ProviderConfigFile::default() + }; + + assert_eq!(provider_runtime_config_changed(¤t, &inherited), false); + } + + #[test] + fn detects_endpoint_credential_and_route_changes() { + let base = ProviderConfigFile { + providers: [("custom".to_string(), ProviderConfigEntry::default())] + .into_iter() + .collect(), + ..ProviderConfigFile::default() + }; + let mut changed = base.clone(); + changed.providers.get_mut("custom").unwrap().base_url = + Some("https://example.com".to_string()); + assert_eq!(provider_runtime_config_changed(&changed, &base), true); + + changed = base.clone(); + changed.providers.get_mut("custom").unwrap().credential = + Some("custom_api_key".to_string()); + assert_eq!(provider_runtime_config_changed(&changed, &base), true); + } +} diff --git a/crates/config/src/provider/runtime_equivalence.rs b/crates/config/src/provider/runtime_equivalence.rs deleted file mode 100644 index 283d1647..00000000 --- a/crates/config/src/provider/runtime_equivalence.rs +++ /dev/null @@ -1,51 +0,0 @@ -use super::ProviderConfigSection; - -impl ProviderConfigSection { - /// Returns whether both sections have identical provider runtime settings. - /// - /// This compares every field that historically participated in full section - /// equality except `model_overrides`. Those overrides shape catalog metadata - /// only, so changing them does not require rebuilding a provider or router. - pub fn is_operationally_equivalent_to(&self, other: &Self) -> bool { - let Self { - defaults: left_defaults, - model_provider: left_model_provider, - model: left_model, - model_reasoning_effort_selection: left_reasoning_effort, - model_auto_compact_token_limit: left_auto_compact_token_limit, - model_context_window: left_context_window, - disable_response_storage: left_disable_response_storage, - preferred_auth_method: left_preferred_auth_method, - providers: left_providers, - model_bindings: left_model_bindings, - model_overrides: _, - model_providers: left_model_providers, - } = self; - let Self { - defaults: right_defaults, - model_provider: right_model_provider, - model: right_model, - model_reasoning_effort_selection: right_reasoning_effort, - model_auto_compact_token_limit: right_auto_compact_token_limit, - model_context_window: right_context_window, - disable_response_storage: right_disable_response_storage, - preferred_auth_method: right_preferred_auth_method, - providers: right_providers, - model_bindings: right_model_bindings, - model_overrides: _, - model_providers: right_model_providers, - } = other; - - left_defaults == right_defaults - && left_model_provider == right_model_provider - && left_model == right_model - && left_reasoning_effort == right_reasoning_effort - && left_auto_compact_token_limit == right_auto_compact_token_limit - && left_context_window == right_context_window - && left_disable_response_storage == right_disable_response_storage - && left_preferred_auth_method == right_preferred_auth_method - && left_providers == right_providers - && left_model_bindings == right_model_bindings - && left_model_providers == right_model_providers - } -} diff --git a/crates/config/src/provider/schema.rs b/crates/config/src/provider/schema.rs index 32be0398..27005eb8 100644 --- a/crates/config/src/provider/schema.rs +++ b/crates/config/src/provider/schema.rs @@ -51,15 +51,21 @@ pub struct ConfiguredModel { pub api_key: Option, } -/// One persisted provider vendor record stored under `[providers.]`. +/// Migration-only provider record read from the old `[providers.]` table. #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] -pub struct ProviderVendorConfig { +pub struct LegacyProviderConfig { pub name: String, #[serde(skip_serializing_if = "Option::is_none")] pub base_url: Option, /// Credential id in user-scoped `auth.json`. #[serde(skip_serializing_if = "Option::is_none")] pub credential: Option, + /// Transient API key compatibility value used only by in-memory callers. + /// + /// Canonical provider configuration never loads or persists this value; + /// API keys are resolved from the credential id and user-scoped auth.json. + #[serde(skip)] + pub api_key: Option, /// Raw JSON object string containing provider-specific HTTP headers. #[serde(skip_serializing_if = "Option::is_none")] pub headers: Option, @@ -73,12 +79,13 @@ pub struct ProviderVendorConfig { pub enabled: bool, } -impl Default for ProviderVendorConfig { +impl Default for LegacyProviderConfig { fn default() -> Self { Self { name: String::new(), base_url: None, credential: None, + api_key: None, headers: None, wire_apis: Vec::new(), web_search: None, @@ -88,12 +95,13 @@ impl Default for ProviderVendorConfig { } } -impl ProviderVendorConfig { +impl LegacyProviderConfig { /// Returns whether the profile has no configured values. pub fn is_empty(&self) -> bool { self.name.is_empty() && self.base_url.is_none() && self.credential.is_none() + && self.api_key.is_none() && self.headers.is_none() && self.wire_apis.is_empty() && self.web_search.is_none() @@ -102,12 +110,9 @@ impl ProviderVendorConfig { } } -/// Backward-compatible public name for provider vendor config. -pub type ModelProviderConfig = ProviderVendorConfig; - -/// One invocable model binding stored under `[model_bindings.]`. +/// Migration-only model record read from the old `[model_bindings.]` table. #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] -pub struct ModelBindingConfig { +pub struct LegacyModelBindingConfig { pub model_slug: String, pub provider: String, #[serde(alias = "model_name")] @@ -126,7 +131,7 @@ pub struct ModelBindingConfig { pub enabled: bool, } -impl Default for ModelBindingConfig { +impl Default for LegacyModelBindingConfig { fn default() -> Self { Self { model_slug: String::new(), @@ -155,7 +160,7 @@ pub struct ModelOverrideConfig { #[serde(skip_serializing_if = "Option::is_none")] pub context_window: Option, #[serde(skip_serializing_if = "Option::is_none")] - pub effective_context_window_percent: Option, + pub effective_context_window_percent: Option, #[serde(skip_serializing_if = "Option::is_none")] pub max_tokens: Option, #[serde(skip_serializing_if = "Option::is_none")] @@ -261,8 +266,8 @@ pub struct ProviderConfigSection { pub model_context_window: Option, pub disable_response_storage: Option, pub preferred_auth_method: Option, - pub providers: BTreeMap, - pub model_bindings: BTreeMap, + pub providers: BTreeMap, + pub model_bindings: BTreeMap, pub model_overrides: BTreeMap, pub model_providers: BTreeMap, } @@ -281,9 +286,9 @@ struct ProviderConfigSectionWire { disable_response_storage: Option, preferred_auth_method: Option, #[serde(default)] - providers: BTreeMap, + providers: BTreeMap, #[serde(default)] - model_bindings: BTreeMap, + model_bindings: BTreeMap, #[serde(default)] model_providers: BTreeMap, } @@ -321,9 +326,9 @@ struct ProviderConfigSectionSerialize<'a> { #[serde(skip_serializing_if = "Option::is_none")] preferred_auth_method: &'a Option, #[serde(default, skip_serializing_if = "BTreeMap::is_empty")] - providers: &'a BTreeMap, + providers: &'a BTreeMap, #[serde(default, skip_serializing_if = "BTreeMap::is_empty")] - model_bindings: &'a BTreeMap, + model_bindings: &'a BTreeMap, #[serde(default, skip_serializing_if = "BTreeMap::is_empty")] model_providers: &'a BTreeMap, } @@ -432,6 +437,9 @@ impl ProviderConfigSection { if overlay_provider.credential.is_some() { provider.credential = overlay_provider.credential; } + if overlay_provider.api_key.is_some() { + provider.api_key = overlay_provider.api_key; + } if overlay_provider.headers.is_some() { provider.headers = overlay_provider.headers; } @@ -571,37 +579,6 @@ impl ProviderHttpConfig { } } -/// The fully-resolved provider settings that can be forwarded to a server process. -#[derive(Debug, Clone, PartialEq, Eq)] -pub struct ResolvedProviderSettings { - /// Selected provider identifier from `[providers.]`. - pub provider_id: String, - /// Selected provider transport implementation. - pub wire_api: ProviderWireApi, - /// Final model identifier. - pub model: String, - /// Optional provider base URL override. - pub base_url: Option, - /// Optional provider API key override. - pub api_key: Option, - /// Optional global provider HTTP proxy URL. - pub proxy_url: Option, - /// Optional provider HTTP proxy bypass list. - pub no_proxy: Option, - /// Optional raw provider custom header JSON object string. - pub headers: Option, - /// Optional active model auto-compaction threshold in tokens. - pub model_auto_compact_token_limit: Option, - /// Optional active model context window override in tokens. - pub model_context_window: Option, - /// Optional logical reasoning effort selection for the active model. - pub model_reasoning_effort_selection: Option, - /// Whether provider-side response storage should be disabled. - pub disable_response_storage: bool, - /// Preferred authentication method for the active provider. - pub preferred_auth_method: Option, -} - fn default_true() -> bool { true } @@ -719,7 +696,7 @@ supports_image_detail_original = true display_name: Some("Grok 4".to_string()), description: Some("Fast reasoning model".to_string()), context_window: Some(256_000), - effective_context_window_percent: Some(90), + effective_context_window_percent: Some(90.0), max_tokens: Some(8_192), temperature: Some(0.7), top_p: Some(0.95), @@ -741,52 +718,4 @@ supports_image_detail_original = true assert!(serialized.contains("[model.grok-4]")); assert!(!serialized.contains("model = \"")); } - - #[test] - fn provider_config_operational_equality_ignores_model_overrides() { - let baseline = ProviderConfigSection::default(); - let metadata_override = ProviderConfigSection { - model_overrides: BTreeMap::from([( - "custom-model".to_string(), - ModelOverrideConfig { - display_name: Some("Custom Model".to_string()), - ..ModelOverrideConfig::default() - }, - )]), - ..baseline.clone() - }; - - assert_ne!(baseline, metadata_override); - assert!(baseline.is_operationally_equivalent_to(&metadata_override)); - } - - #[test] - fn provider_config_operational_equality_detects_provider_and_binding_changes() { - let baseline = ProviderConfigSection::default(); - let provider_change = ProviderConfigSection { - providers: BTreeMap::from([( - "openai".to_string(), - ProviderVendorConfig { - name: "OpenAI".to_string(), - ..ProviderVendorConfig::default() - }, - )]), - ..baseline.clone() - }; - let binding_change = ProviderConfigSection { - model_bindings: BTreeMap::from([( - "main".to_string(), - ModelBindingConfig { - model_slug: "gpt-5.5".to_string(), - provider: "openai".to_string(), - request_model: "gpt-5.5".to_string(), - ..ModelBindingConfig::default() - }, - )]), - ..baseline.clone() - }; - - assert!(!baseline.is_operationally_equivalent_to(&provider_change)); - assert!(!baseline.is_operationally_equivalent_to(&binding_change)); - } } diff --git a/crates/config/src/provider_connection.rs b/crates/config/src/provider_connection.rs new file mode 100644 index 00000000..fb63492e --- /dev/null +++ b/crates/config/src/provider_connection.rs @@ -0,0 +1,390 @@ +//! Persistence for the canonical provider Connection contract. + +use std::collections::BTreeMap; + +use devo_protocol::{ProviderInfo, ProviderModelInfo, ProviderModelVariant}; + +use super::{AppConfigLoader, AppConfigStore}; +use crate::{ + ProviderConfigEntry, ProviderConfigFile, ProviderModelConfig, ProviderModelVariantConfig, + default_provider_credential_id, migrate_legacy_provider_config_file, non_empty_string, + read_provider_catalog_config, upsert_user_auth_api_key, write_provider_catalog_config, +}; + +impl AppConfigStore { + /// Returns the effective user provider Connections in canonical form. + /// + /// Built-in directory templates are supplied by `ModelCatalog`; this + /// method only exposes persisted user entries and their explicit models. + pub fn provider_connections(&self) -> anyhow::Result> { + Ok(self + .load_editable_provider_catalog()? + .providers + .iter() + .map(|(provider_id, provider)| provider_info_from_config(provider_id, provider)) + .collect()) + } + + /// Returns the model directory explicitly stored for each user Connection. + /// + /// The embedded provider directory is deliberately excluded. This keeps + /// Connection management separate from read-only provider templates. + pub fn provider_connection_models( + &self, + ) -> anyhow::Result>> { + let config = self.load_editable_provider_catalog()?; + + Ok(config + .providers + .into_iter() + .map(|(provider_id, provider)| { + let models = provider + .models + .iter() + .map(|(model_id, model)| { + (model_id.clone(), provider_model_info_from_config(model)) + }) + .collect(); + (provider_id, models) + }) + .collect()) + } + + /// Removes one model from a user-created provider Connection. + pub fn remove_provider_model( + &mut self, + provider_id: &str, + model_id: &str, + ) -> anyhow::Result<()> { + let provider_id = non_empty_string(provider_id) + .ok_or_else(|| anyhow::anyhow!("provider id must not be empty"))?; + let model_id = non_empty_string(model_id) + .ok_or_else(|| anyhow::anyhow!("model id must not be empty"))?; + let model_id = model_id + .strip_prefix(&format!("{provider_id}/")) + .unwrap_or(&model_id) + .to_string(); + if !self + .provider_connection_ids()? + .iter() + .any(|connected_id| connected_id == &provider_id) + { + anyhow::bail!("provider {provider_id} is not a user Connection"); + } + + let target_config_file = self.user_provider_config_file(); + let mut config = self.load_editable_provider_catalog()?; + + if let Some(provider) = config.providers.get_mut(&provider_id) { + provider.models.remove(&model_id); + } + let model_prefix = format!("{provider_id}/{model_id}"); + if config.model.as_deref().is_some_and(|model| { + model == model_prefix || model.starts_with(&format!("{model_prefix}/")) + }) { + config.model = None; + } + if config.small_model.as_deref().is_some_and(|model| { + model == model_prefix || model.starts_with(&format!("{model_prefix}/")) + }) { + config.small_model = None; + } + + write_provider_catalog_config(&target_config_file, &config)?; + migrate_legacy_provider_config_file(&self.user_config_file)?; + self.config = self + .loader + .load(self.workspace_root.as_deref()) + .map_err(|error| anyhow::anyhow!(error))?; + Ok(()) + } + + /// Persists one canonical provider Connection, including its complete + /// nested model directory, without exposing the legacy binding shape. + pub fn upsert_provider_connection( + &mut self, + provider: ProviderInfo, + default_model: Option, + small_model: Option, + api_key: Option, + ) -> anyhow::Result { + let provider_id = non_empty_string(&provider.id) + .or_else(|| non_empty_string(&provider.name)) + .ok_or_else(|| anyhow::anyhow!("provider id or name must not be empty"))?; + if provider.wire_apis.is_empty() { + anyhow::bail!("wire_apis must contain at least one wire API"); + } + for (model_id, model) in &provider.models { + validate_model(&provider_id, &provider.wire_apis, model_id, model)?; + } + + let target_config_file = self.user_provider_config_file(); + let mut config = self.load_editable_provider_catalog()?; + + let api_key = api_key.as_deref().and_then(non_empty_string); + let credential = provider + .credential + .as_deref() + .and_then(non_empty_string) + .or_else(|| { + config + .providers + .get(&provider_id) + .and_then(|entry| entry.credential.clone()) + }) + .or_else(|| { + api_key + .as_ref() + .map(|_| default_provider_credential_id(&provider_id)) + }); + if let (Some(api_key), Some(credential_id)) = (api_key, credential.as_deref()) { + upsert_user_auth_api_key(self.user_config_dir(), credential_id, &api_key) + .map_err(|error| anyhow::anyhow!(error))?; + } + + let entry = config.providers.entry(provider_id.clone()).or_default(); + apply_provider_info(entry, &provider, credential.clone()); + for (model_id, model_info) in provider.models { + entry + .models + .insert(model_id, provider_model_config_from_info(model_info)); + } + if let Some(model) = default_model.as_deref().and_then(non_empty_string) { + if !provider_has_model(entry, &provider_id, &model) { + anyhow::bail!("default model `{model}` is not in provider `{provider_id}`"); + } + config.model = Some(model); + } + if let Some(model) = small_model.as_deref().and_then(non_empty_string) { + if !provider_has_model(entry, &provider_id, &model) { + anyhow::bail!("small model `{model}` is not in provider `{provider_id}`"); + } + config.small_model = Some(model); + } + + write_provider_catalog_config(&target_config_file, &config)?; + migrate_legacy_provider_config_file(&self.user_config_file)?; + self.config = self + .loader + .load(self.workspace_root.as_deref()) + .map_err(|error| anyhow::anyhow!(error))?; + + let effective = self + .config + .provider_catalog + .providers + .get(&provider_id) + .ok_or_else(|| anyhow::anyhow!("provider `{provider_id}` was not persisted"))?; + Ok(provider_info_from_config(&provider_id, effective)) + } + + fn load_editable_provider_catalog(&self) -> anyhow::Result { + let target_config_file = self.user_provider_config_file(); + Ok(read_provider_catalog_config(&target_config_file)?) + } +} + +fn provider_has_model(provider: &ProviderConfigEntry, provider_id: &str, model: &str) -> bool { + let model_id = model + .strip_prefix(&format!("{provider_id}/")) + .unwrap_or(model); + if provider.models.contains_key(model_id) { + return true; + } + model_id + .rsplit_once('/') + .filter(|(base_model_id, variant_id)| { + provider + .models + .get(*base_model_id) + .is_some_and(|model| model.variants.contains_key(*variant_id)) + }) + .is_some() +} + +fn validate_model( + provider_id: &str, + wire_apis: &[devo_protocol::ProviderWireApi], + model_id: &str, + model: &ProviderModelInfo, +) -> anyhow::Result<()> { + if model_id.trim().is_empty() { + anyhow::bail!("model id cannot be empty"); + } + if let Some(wire_api) = model.wire_api + && !wire_apis.contains(&wire_api) + { + anyhow::bail!("model `{provider_id}/{model_id}` uses an unsupported wire API"); + } + if let Some(default_variant) = model.default_variant.as_deref() + && !model.variants.contains_key(default_variant) + { + anyhow::bail!( + "model `{provider_id}/{model_id}` refers to missing default variant `{default_variant}`" + ); + } + Ok(()) +} + +fn apply_provider_info( + entry: &mut ProviderConfigEntry, + provider: &ProviderInfo, + credential: Option, +) { + entry.name = non_empty_string(&provider.name); + entry.description = provider.description.as_deref().and_then(non_empty_string); + entry.base_url = provider.base_url.as_deref().and_then(non_empty_string); + entry.credential = credential; + entry.headers = (!provider.headers.is_empty()).then(|| provider.headers.clone()); + entry.options = provider.options.clone(); + entry.request = provider.request.clone(); + entry.wire_api = provider.wire_apis.first().copied(); + entry.enabled = Some(provider.enabled); +} + +fn provider_model_config_from_info(info: ProviderModelInfo) -> ProviderModelConfig { + ProviderModelConfig { + name: info.name, + family: info.family, + release_date: info.release_date, + status: info.status, + capabilities: info.capabilities, + wire_api: info.wire_api, + context_window: info.context_window, + effective_context_window_percent: info.effective_context_window_percent, + max_tokens: info.max_tokens, + temperature: info.temperature, + top_p: info.top_p, + top_k: info.top_k, + reasoning_capability: info.reasoning_capability, + reasoning_implementation: info.reasoning_implementation, + default_reasoning_effort: info.default_reasoning_effort, + default_reasoning_selection: info.default_reasoning_selection, + base_instructions: info.base_instructions, + input_modalities: info.input_modalities, + channel: info.channel, + truncation_policy: info + .truncation_policy + .and_then(|value| serde_json::from_value(value).ok()), + supports_image_detail_original: info.supports_image_detail_original, + web_search: info + .web_search + .and_then(|value| serde_json::from_value(value).ok()), + web_fetch: info + .web_fetch + .and_then(|value| serde_json::from_value(value).ok()), + cost: info.cost, + metadata: info.metadata, + request: info.request, + options: info.options, + headers: info.headers, + variants: info + .variants + .into_iter() + .map(|(variant_id, variant)| { + ( + variant_id, + ProviderModelVariantConfig { + label: variant.label, + disabled: variant.disabled, + request_model: variant.request_model, + request: variant.request, + options: variant.options, + headers: variant.headers, + }, + ) + }) + .collect(), + default_variant: info.default_variant, + enabled: info.enabled, + priority: info.priority, + } +} + +fn provider_info_from_config(provider_id: &str, config: &ProviderConfigEntry) -> ProviderInfo { + ProviderInfo { + id: provider_id.to_string(), + name: config + .name + .clone() + .unwrap_or_else(|| provider_id.to_string()), + description: config.description.clone(), + base_url: config.base_url.clone(), + credential: config.credential.clone(), + headers: config.headers.clone().unwrap_or_default(), + options: config.options.clone(), + request: config.request.clone(), + wire_apis: vec![ + config + .wire_api + .unwrap_or(devo_protocol::ProviderWireApi::OpenAIChatCompletions), + ], + models: config + .models + .iter() + .map(|(model_id, model)| (model_id.clone(), provider_model_info_from_config(model))) + .collect(), + enabled: config.enabled.unwrap_or(true), + } +} + +fn provider_model_info_from_config(config: &ProviderModelConfig) -> ProviderModelInfo { + ProviderModelInfo { + name: config.name.clone(), + family: config.family.clone(), + release_date: config.release_date.clone(), + status: config.status.clone(), + capabilities: config.capabilities.clone(), + wire_api: config.wire_api, + context_window: config.context_window, + effective_context_window_percent: config.effective_context_window_percent, + max_tokens: config.max_tokens, + temperature: config.temperature, + top_p: config.top_p, + top_k: config.top_k, + reasoning_capability: config.reasoning_capability.clone(), + reasoning_implementation: config.reasoning_implementation.clone(), + default_reasoning_effort: config.default_reasoning_effort, + default_reasoning_selection: config.default_reasoning_selection.clone(), + base_instructions: config.base_instructions.clone(), + input_modalities: config.input_modalities.clone(), + channel: config.channel.clone(), + truncation_policy: config + .truncation_policy + .and_then(|policy| serde_json::to_value(policy).ok()), + supports_image_detail_original: config.supports_image_detail_original, + web_search: config + .web_search + .as_ref() + .and_then(|value| serde_json::to_value(value).ok()), + web_fetch: config + .web_fetch + .as_ref() + .and_then(|value| serde_json::to_value(value).ok()), + cost: config.cost.clone(), + metadata: config.metadata.clone(), + request: config.request.clone(), + options: config.options.clone(), + headers: config.headers.clone(), + variants: config + .variants + .iter() + .map(|(variant_id, variant)| { + ( + variant_id.clone(), + ProviderModelVariant { + label: variant.label.clone(), + disabled: variant.disabled, + request_model: variant.request_model.clone(), + request: variant.request.clone(), + options: variant.options.clone(), + headers: variant.headers.clone(), + }, + ) + }) + .collect(), + default_variant: config.default_variant.clone(), + enabled: config.enabled, + priority: config.priority, + } +} diff --git a/crates/config/src/tests.rs b/crates/config/src/tests.rs index 2b66ba77..80dc27c4 100644 --- a/crates/config/src/tests.rs +++ b/crates/config/src/tests.rs @@ -27,7 +27,6 @@ use super::McpServerRecord; use super::McpStartupPolicy; use super::McpTransportConfig; use super::McpTrustPolicy; -use super::ModelBindingConfig; use super::ModelOverrideConfig; use super::OAuthCredentialsStoreMode; use super::PatternMode; @@ -35,10 +34,9 @@ use super::PermissionConfig; use super::PermissionRule; use super::ProjectConfig; use super::PromptPolicy; +use super::ProviderConfigFile; use super::ProviderConfigSection; -use super::ProviderDefaultsConfig; use super::ProviderHttpConfig; -use super::ProviderVendorConfig; use super::RuleAction; use super::SummaryModelSelection; use super::ToolFilter; @@ -46,10 +44,12 @@ use super::ToolsConfig; use super::UpdatesConfig; use crate::BundledSkillsConfig; use crate::SkillsConfig; -use devo_protocol::ProviderModelBinding; -use devo_protocol::ProviderVendor; +use devo_protocol::ProviderInfo; +use devo_protocol::ProviderModelInfo; use devo_protocol::ProviderWireApi; +use devo_protocol::ReasoningCapability; use devo_protocol::ReasoningEffort; +use devo_protocol::ReasoningLevelChoice; use devo_protocol::TruncationPolicyConfig; fn unique_temp_dir(name: &str) -> PathBuf { @@ -151,6 +151,7 @@ check_interval_hours = 48 hooks: HooksConfig::default(), permission: PermissionConfig::default(), provider: ProviderConfigSection::default(), + provider_catalog: ProviderConfigFile::default(), provider_http: super::ProviderHttpConfig::default(), updates: UpdatesConfig { enabled: false, @@ -791,36 +792,30 @@ invocation_method = "openai_responses" } ); assert_eq!( - config.provider, - ProviderConfigSection { - defaults: ProviderDefaultsConfig { - model_binding: Some("main".to_string()), - }, - providers: BTreeMap::from([( - "main".to_string(), - ProviderVendorConfig { - name: "Project Provider".to_string(), - base_url: Some("https://user.example/v1".to_string()), - credential: Some("user_api_key".to_string()), - headers: Some(r#"{"X-User":"yes"}"#.to_string()), - wire_apis: vec![ProviderWireApi::OpenAIResponses], - web_search: None, - web_fetch: None, - enabled: true, - }, - )]), - model_bindings: BTreeMap::from([( - "main".to_string(), - ModelBindingConfig { - model_slug: "project-model".to_string(), - provider: "main".to_string(), - request_model: "project/model".to_string(), - invocation_method: ProviderWireApi::OpenAIResponses, - ..ModelBindingConfig::default() - }, - )]), - ..ProviderConfigSection::default() - } + config.provider_http.proxy_url.as_deref(), + Some("http://workspace-proxy.example:8080") + ); + assert_eq!( + config.provider_catalog.providers["main"].name.as_deref(), + Some("Project Provider") + ); + assert_eq!( + config.provider_catalog.providers["main"] + .base_url + .as_deref(), + Some("https://user.example/v1") + ); + assert_eq!( + config.provider_catalog.providers["main"].headers, + Some(BTreeMap::from([("X-User".to_string(), "yes".to_string())])) + ); + assert_eq!( + config.provider_catalog.providers["main"].models["user/model"].wire_api, + Some(ProviderWireApi::OpenAIResponses) + ); + assert_eq!( + config.provider_catalog.providers["main"].models["project/model"].wire_api, + Some(ProviderWireApi::OpenAIResponses) ); let _ = std::fs::remove_dir_all(root); @@ -877,42 +872,100 @@ request_model = "project/model" let config = loader.load(Some(&workspace)).expect("load config"); assert_eq!( - config.provider, - ProviderConfigSection { - defaults: ProviderDefaultsConfig { - model_binding: Some("main".to_string()), - }, - providers: BTreeMap::from([( - "main".to_string(), - ProviderVendorConfig { - name: "Project Provider".to_string(), - base_url: Some("https://user.example/v1".to_string()), - credential: Some("user_api_key".to_string()), - headers: Some(r#"{"X-User":"yes"}"#.to_string()), - wire_apis: vec![ProviderWireApi::OpenAIResponses], - web_search: None, - web_fetch: None, - enabled: false, - }, - )]), - model_bindings: BTreeMap::from([( - "main".to_string(), - ModelBindingConfig { - model_slug: "project-model".to_string(), - provider: "main".to_string(), - request_model: "project/model".to_string(), - invocation_method: ProviderWireApi::OpenAIResponses, - enabled: false, - ..ModelBindingConfig::default() - }, - )]), - ..ProviderConfigSection::default() - } + config.provider_catalog.providers["main"].enabled, + Some(false) + ); + assert_eq!( + config.provider_catalog.providers["main"].models["user/model"].enabled, + Some(false) + ); + assert_eq!( + config.provider_catalog.providers["main"].models["project/model"].enabled, + None ); let _ = std::fs::remove_dir_all(root); } +#[test] +fn loader_applies_workspace_model_overrides_onto_provider_catalog() { + let root = unique_temp_dir("config-workspace-model-override-catalog"); + let home = root.join("home").join(".devo"); + let workspace = root.join("workspace"); + std::fs::create_dir_all(&home).expect("home config dir"); + std::fs::create_dir_all(workspace.join(".devo")).expect("workspace config dir"); + + std::fs::write( + home.join("config.toml"), + r#" +[defaults] +model_binding = "test-openai" + +[providers.openai] +enabled = true +name = "OpenAI" +wire_apis = ["openai_chat_completions"] + +[model_bindings.test-openai] +enabled = true +model_slug = "test-model" +provider = "openai" +model_name = "test-model" +invocation_method = "openai_chat_completions" + +[model_bindings.alt-openai] +enabled = true +model_slug = "alt-model" +provider = "openai" +model_name = "alt-model" +invocation_method = "openai_chat_completions" +"#, + ) + .expect("write user config"); + std::fs::write( + workspace.join(".devo").join("config.toml"), + r#" +[model.test-model] +display_name = "Test Model" +reasoning_capability = { levels = ["low", "medium", "high"] } +default_reasoning_effort = "medium" +base_instructions = "Test model instructions" + +[model.alt-model] +display_name = "Alt Model" +base_instructions = "Alt model instructions" +"#, + ) + .expect("write workspace config"); + + let loader = FileSystemAppConfigLoader::new(home.clone()); + // Simulate server bootstrap (migrates home bindings) then a later + // workspace-scoped session load. + let _ = loader + .load(/*workspace_root*/ None) + .expect("bootstrap load"); + let config = loader.load(Some(&workspace)).expect("session load"); + let test_model = &config.provider_catalog.providers["openai"].models["test-model"]; + assert_eq!(test_model.name.as_deref(), Some("Test Model")); + assert_eq!( + test_model.reasoning_capability, + Some(ReasoningCapability::Levels(vec![ + ReasoningLevelChoice::Effort(ReasoningEffort::Low), + ReasoningLevelChoice::Effort(ReasoningEffort::Medium), + ReasoningLevelChoice::Effort(ReasoningEffort::High), + ])) + ); + assert_eq!( + test_model.default_reasoning_effort, + Some(ReasoningEffort::Medium) + ); + let alt_model = &config.provider_catalog.providers["openai"].models["alt-model"]; + assert_eq!(alt_model.name.as_deref(), Some("Alt Model")); + assert_eq!(alt_model.reasoning_capability, None); + + let _ = std::fs::remove_dir_all(root); +} + #[test] fn loader_merges_model_overrides_field_by_field_across_layers() { let root = unique_temp_dir("config-model-overrides-overlay"); @@ -1038,37 +1091,20 @@ enabled = false let config = loader.load(None).expect("load config"); assert_eq!( - config.provider, - ProviderConfigSection { - defaults: ProviderDefaultsConfig { - model_binding: Some("main".to_string()), - }, - providers: BTreeMap::from([( - "main".to_string(), - ProviderVendorConfig { - name: "CLI Provider".to_string(), - base_url: Some("https://user.example/v1".to_string()), - credential: Some("user_api_key".to_string()), - headers: None, - wire_apis: vec![ProviderWireApi::OpenAIResponses], - web_search: None, - web_fetch: None, - enabled: false, - }, - )]), - model_bindings: BTreeMap::from([( - "main".to_string(), - ModelBindingConfig { - model_slug: "cli-model".to_string(), - provider: "main".to_string(), - request_model: "cli/model".to_string(), - invocation_method: ProviderWireApi::OpenAIResponses, - enabled: false, - ..ModelBindingConfig::default() - }, - )]), - ..ProviderConfigSection::default() - } + config.provider_catalog.providers["main"].name.as_deref(), + Some("CLI Provider") + ); + assert_eq!( + config.provider_catalog.providers["main"].enabled, + Some(false) + ); + assert_eq!( + config.provider_catalog.providers["main"].models["user/model"].enabled, + None + ); + assert_eq!( + config.provider_catalog.providers["main"].models["cli/model"].enabled, + Some(false) ); let _ = std::fs::remove_dir_all(root); @@ -1086,52 +1122,76 @@ fn provider_upsert_writes_user_config_when_workspace_is_active() { let mut store = AppConfigStore::load(home.clone(), Some(&workspace)).expect("load store"); let written_provider = store - .upsert_provider_vendor( - "openrouter".to_string(), - ProviderVendor { + .upsert_provider_connection( + ProviderInfo { + id: "openrouter".to_string(), name: "openrouter".to_string(), + description: None, base_url: Some("https://openrouter.ai/api/v1".to_string()), credential: None, - headers: Some(r#"{"X-Devo":"yes"}"#.to_string()), + headers: BTreeMap::from([("X-Devo".to_string(), "yes".to_string())]), + options: None, + request: None, wire_apis: vec![ProviderWireApi::OpenAIChatCompletions], + models: BTreeMap::from([( + "qwen/qwen3".to_string(), + ProviderModelInfo { + name: Some("Qwen".to_string()), + wire_api: Some(ProviderWireApi::OpenAIChatCompletions), + default_reasoning_effort: Some(ReasoningEffort::Medium), + ..ProviderModelInfo::default() + }, + )]), enabled: true, }, - Some(ProviderModelBinding { - binding_id: "qwen-openrouter".to_string(), - model_slug: "qwen".to_string(), - provider: "openrouter".to_string(), - request_model: "qwen/qwen3".to_string(), - display_name: Some("Qwen".to_string()), - invocation_method: ProviderWireApi::OpenAIChatCompletions, - default_reasoning_effort: Some("medium".to_string()), - enabled: true, - }), - Some("qwen-openrouter".to_string()), + Some("openrouter/qwen/qwen3".to_string()), + None, Some("sk-test".to_string()), ) .expect("upsert provider"); - let user_config = std::fs::read_to_string(home.join("config.toml")).expect("user config"); + let user_config = + std::fs::read_to_string(home.join("providers.json")).expect("provider config"); let workspace_config = workspace.join(".devo").join("config.toml"); - let document: toml::Value = toml::from_str(&user_config).expect("parse user config"); + let document: serde_json::Value = + serde_json::from_str(&user_config).expect("parse provider config"); - assert!(user_config.contains("[providers.openrouter]")); - assert!(user_config.contains("[model_bindings.qwen-openrouter]")); - assert!(user_config.contains("model_binding = \"qwen-openrouter\"")); - assert!(document.get("model").is_none()); + assert!(user_config.contains("\"openrouter\"")); + assert!(user_config.contains("\"qwen/qwen3\"")); + assert_eq!(document["model"].as_str(), Some("openrouter/qwen/qwen3")); assert_eq!( - document["providers"]["openrouter"]["headers"].as_str(), - Some(r#"{"X-Devo":"yes"}"#) + document["provider"]["openrouter"]["headers"]["X-Devo"].as_str(), + Some("yes") + ); + assert_eq!( + document["provider"]["openrouter"]["credential"].as_str(), + Some("openrouter_api_key") + ); + assert!(document["provider"]["openrouter"].get("options").is_none()); + assert_eq!( + written_provider.credential.as_deref(), + Some("openrouter_api_key") ); assert_eq!( written_provider.headers, - Some(r#"{"X-Devo":"yes"}"#.to_string()) + BTreeMap::from([("X-Devo".to_string(), "yes".to_string())]) ); assert_eq!( - store.provider_vendors()[0].headers, - Some(r#"{"X-Devo":"yes"}"#.to_string()) + store.provider_connections().expect("list connections")[0].headers, + BTreeMap::from([("X-Devo".to_string(), "yes".to_string())]) ); assert!(!workspace_config.exists()); + let auth_config = std::fs::read_to_string(home.join("auth.json")).expect("auth config"); + let auth_document: serde_json::Value = + serde_json::from_str(&auth_config).expect("parse auth config"); + assert_eq!( + auth_document["credentials"]["openrouter_api_key"]["kind"].as_str(), + Some("api_key") + ); + assert_eq!( + auth_document["credentials"]["openrouter_api_key"]["value"].as_str(), + Some("sk-test") + ); let _ = std::fs::remove_dir_all(root); } @@ -1169,40 +1229,54 @@ provider = "Deepseek" let mut store = AppConfigStore::load(home.clone(), /*workspace_root*/ None).expect("load store"); store - .upsert_provider_vendor( - "Deepseek".to_string(), - ProviderVendor { + .upsert_provider_connection( + ProviderInfo { + id: "Deepseek".to_string(), name: "Deepseek".to_string(), + description: None, base_url: Some("https://api.deepseek.com".to_string()), credential: Some("deepseek_api_key".to_string()), - headers: None, + headers: BTreeMap::new(), + options: None, + request: None, wire_apis: vec![ProviderWireApi::OpenAIChatCompletions], + models: BTreeMap::from([( + "DeepSeek-V4-Flash".to_string(), + ProviderModelInfo { + name: Some("DeepSeek-V4-Flash".to_string()), + wire_api: Some(ProviderWireApi::OpenAIChatCompletions), + ..ProviderModelInfo::default() + }, + )]), enabled: true, }, - Some(ProviderModelBinding { - binding_id: "deepseek-v4-flash-deepseek".to_string(), - model_slug: "deepseek-v4-flash".to_string(), - provider: "Deepseek".to_string(), - request_model: "DeepSeek-V4-Flash".to_string(), - display_name: Some("DeepSeek-V4-Flash".to_string()), - invocation_method: ProviderWireApi::OpenAIChatCompletions, - default_reasoning_effort: None, - enabled: true, - }), - Some("deepseek-v4-flash-deepseek".to_string()), + Some("Deepseek/DeepSeek-V4-Flash".to_string()), + None, /*api_key*/ None, ) .expect("upsert provider"); - let user_config = std::fs::read_to_string(home.join("config.toml")).expect("user config"); - let document: toml::Value = toml::from_str(&user_config).expect("parse user config"); - let binding = &document["model_bindings"]["deepseek-v4-flash-deepseek"]; + let user_config = + std::fs::read_to_string(home.join("providers.json")).expect("provider config"); + let document: serde_json::Value = + serde_json::from_str(&user_config).expect("parse provider config"); + let provider = &document["provider"]["Deepseek"]; + let model = &provider["models"]["DeepSeek-V4-Flash"]; - assert_eq!(binding["model_slug"].as_str(), Some("deepseek-v4-flash")); - assert_eq!(binding["request_model"].as_str(), Some("DeepSeek-V4-Flash")); - assert_eq!(binding.get("model_name"), None); - assert_eq!(binding["custom_binding_key"].as_str(), Some("preserved")); - assert_eq!(binding["display_name"].as_str(), Some("DeepSeek-V4-Flash")); + assert_eq!( + provider["base_url"].as_str(), + Some("https://api.deepseek.com") + ); + assert_eq!(model["name"].as_str(), Some("DeepSeek-V4-Flash")); + assert_eq!(model["wire_api"].as_str(), Some("openai_chat_completions")); + assert_eq!( + document["model"].as_str(), + Some("Deepseek/DeepSeek-V4-Flash") + ); + let legacy_config = std::fs::read_to_string(home.join("config.toml")).expect("legacy config"); + let legacy_document: toml::Value = toml::from_str(&legacy_config).expect("parse legacy config"); + assert!(legacy_document.get("providers").is_none()); + assert!(legacy_document.get("model_bindings").is_none()); let _ = std::fs::remove_dir_all(root); } diff --git a/crates/config/src/tools.rs b/crates/config/src/tools.rs index 09edf84a..fee36fc9 100644 --- a/crates/config/src/tools.rs +++ b/crates/config/src/tools.rs @@ -153,10 +153,10 @@ pub struct ResolvedLocalWebSearchConfig { pub fn resolve_web_search_config( global: &WebSearchConfig, provider_override: Option<&WebSearchConfig>, - binding_override: Option<&WebSearchConfig>, + model_override: Option<&WebSearchConfig>, auth: &UserAuthConfigFile, ) -> Result { - let effective = binding_override.or(provider_override).unwrap_or(global); + let effective = model_override.or(provider_override).unwrap_or(global); match effective.mode { WebSearchMode::Disabled => Ok(ResolvedWebSearchConfig::Disabled), WebSearchMode::Provider => Ok(ResolvedWebSearchConfig::Provider), @@ -169,9 +169,9 @@ pub fn resolve_web_search_config( pub fn resolve_web_fetch_config( global: &WebFetchConfig, provider_override: Option<&WebFetchConfig>, - binding_override: Option<&WebFetchConfig>, + model_override: Option<&WebFetchConfig>, ) -> ResolvedWebFetchConfig { - let effective = binding_override.or(provider_override).unwrap_or(global); + let effective = model_override.or(provider_override).unwrap_or(global); match effective.mode { WebFetchMode::Disabled => ResolvedWebFetchConfig::Disabled, WebFetchMode::Provider => ResolvedWebFetchConfig::Provider, @@ -303,13 +303,13 @@ mod tests { } #[test] - fn provider_and_binding_overrides_global() { + fn provider_and_model_overrides_global() { let global = WebSearchConfig::default(); let provider = WebSearchConfig { mode: WebSearchMode::Provider, ..WebSearchConfig::default() }; - let binding = WebSearchConfig { + let model = WebSearchConfig { mode: WebSearchMode::Disabled, ..WebSearchConfig::default() }; @@ -320,8 +320,8 @@ mod tests { ResolvedWebSearchConfig::Provider ); assert_eq!( - resolve_web_search_config(&global, Some(&provider), Some(&binding), &auth()) - .expect("binding override"), + resolve_web_search_config(&global, Some(&provider), Some(&model), &auth()) + .expect("model override"), ResolvedWebSearchConfig::Disabled ); } From adbf27bbd37351ec198871ed5064c7a79e4304d2 Mon Sep 17 00:00:00 2001 From: wangtsiao Date: Sat, 5 Sep 2026 02:34:31 +0800 Subject: [PATCH 03/13] feat(core): resolve turns from provider/model catalog Replace model_binding/preset paths with catalog-backed selection, provider request maps, and updated conversation/query fixtures. --- crates/core/src/config_resolution.rs | 308 -------- crates/core/src/context/mod.rs | 24 +- crates/core/src/conversation/history.rs | 26 +- .../core/src/conversation/legacy_projector.rs | 3 + crates/core/src/conversation/records.rs | 6 +- crates/core/src/conversation/v2_inverse.rs | 8 +- crates/core/src/lib.rs | 10 +- crates/core/src/model_binding.rs | 357 --------- crates/core/src/model_catalog.rs | 675 +++++++++++------- crates/core/src/model_preset.rs | 413 ----------- crates/core/src/provider_request.rs | 60 ++ crates/core/src/query/event.rs | 8 + crates/core/src/query/mod.rs | 12 + crates/core/src/query/provider_retry.rs | 12 +- crates/core/src/query/stream_consumer.rs | 13 +- crates/core/src/query/tests.rs | 21 +- crates/core/src/session.rs | 63 +- crates/core/src/small_model.rs | 193 +++++ crates/core/src/tools/registry_plan.rs | 30 +- crates/core/tests/context_limit_compaction.rs | 1 + .../fixtures/rollout_v1/internal_lines.jsonl | 6 +- crates/core/tests/legacy_projector.rs | 4 +- crates/core/tests/v2_roundtrip.rs | 9 +- 23 files changed, 839 insertions(+), 1423 deletions(-) delete mode 100644 crates/core/src/config_resolution.rs delete mode 100644 crates/core/src/model_binding.rs delete mode 100644 crates/core/src/model_preset.rs create mode 100644 crates/core/src/provider_request.rs create mode 100644 crates/core/src/small_model.rs diff --git a/crates/core/src/config_resolution.rs b/crates/core/src/config_resolution.rs deleted file mode 100644 index 492819bd..00000000 --- a/crates/core/src/config_resolution.rs +++ /dev/null @@ -1,308 +0,0 @@ -use std::collections::BTreeMap; -use std::path::PathBuf; - -use serde::{Deserialize, Serialize}; - -// ── Scope / Path Types ──────────────────────────────────────────────── - -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] -pub enum ConfigScope { - User, - Workspace { workspace_root: PathBuf }, -} - -#[derive(Debug, Clone, PartialEq, Eq)] -pub struct ConfigInputPaths { - pub user_config: ConfigFilePath, - pub workspace_config: Option, - pub user_auth: UserAuthPath, -} - -#[derive(Debug, Clone, PartialEq, Eq)] -pub struct ConfigFilePath { - pub scope: ConfigScope, - pub config_dir: PathBuf, - pub config_path: PathBuf, -} - -#[derive(Debug, Clone, PartialEq, Eq)] -pub struct UserAuthPath { - /// ~/.devo on macOS/Linux, C:\Users\username\.devo on Windows - pub config_dir: PathBuf, - /// Always /auth.json - pub auth_path: PathBuf, -} - -// ── Loaded Sources ───────────────────────────────────────────────────── - -pub struct LoadedConfigInputs { - pub user_config: LoadedConfigFile, - pub workspace_config: Option, - pub user_auth: LoadedUserAuth, -} - -pub struct LoadedConfigFile { - pub path: ConfigFilePath, - /// None if file is missing; malformed content is a diagnostic, not None. - pub config: Option, - pub diagnostics: Vec, -} - -pub struct LoadedUserAuth { - pub path: UserAuthPath, - /// None if file is missing (empty credential set). - pub document: Option, - pub diagnostics: Vec, -} - -// ── Resolution Output ────────────────────────────────────────────────── - -pub struct ConfigurationResolution { - pub effective: EffectiveConfig, - pub user_auth: UserAuthStore, - pub diagnostics: Vec, -} - -#[derive(Debug, Clone, Default)] -pub struct EffectiveConfig { - pub providers: BTreeMap, - pub model_bindings: BTreeMap, - pub defaults: EffectiveDefaults, - pub provenance: ConfigProvenance, -} - -#[derive(Debug, Clone, Default)] -pub struct EffectiveDefaults { - pub model: Option, - pub model_provider: Option, - pub reasoning_effort: Option, -} - -#[derive(Debug, Clone)] -pub struct EffectiveProvider { - pub name: String, - pub base_url: Option, - pub api_key: Option, - pub wire_api: Option, - pub models: Vec, - pub enabled: bool, -} - -#[derive(Debug, Clone)] -pub struct EffectiveModelBinding { - pub provider_id: String, - pub model_slug: String, - pub display_name: Option, - pub invocation_method: Option, - pub reasoning_effort: Option, - pub enabled: bool, -} - -#[derive(Debug, Clone)] -pub struct UserAuthStore { - pub path: UserAuthPath, - pub credentials: BTreeMap, - pub provenance: BTreeMap, -} - -// ── Provenance ───────────────────────────────────────────────────────── - -#[derive(Debug, Clone, Default)] -pub struct ConfigProvenance { - pub values: BTreeMap, - pub merged_records: BTreeMap, - pub credential_refs: BTreeMap, -} - -#[derive(Debug, Clone)] -pub struct ConfigValueSource { - pub scope: ConfigScope, - pub file: PathBuf, - pub path: String, -} - -#[derive(Debug, Clone)] -pub struct MergedRecordSource { - pub record_path: String, - pub identity_key: String, - pub contributing_scopes: Vec, - pub field_sources: BTreeMap, -} - -#[derive(Debug, Clone)] -pub struct AuthValueSource { - pub file: PathBuf, - pub credential_id: String, -} - -#[derive(Debug, Clone)] -pub struct CredentialResolutionSource { - pub credential_id: String, - pub auth_source: AuthValueSource, -} - -// ── Write Target ─────────────────────────────────────────────────────── - -#[derive(Debug, Clone)] -pub enum ConfigWriteTarget { - UserConfig, - WorkspaceConfig { workspace_root: PathBuf }, - UserAuth, -} - -// ── Diagnostics ──────────────────────────────────────────────────────── - -#[derive(Debug, Clone)] -pub struct ConfigDiagnostic { - pub severity: DiagnosticSeverity, - pub source: String, - pub message: String, - pub path: Option, - pub recovery_hint: Option, -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum DiagnosticSeverity { - Error, - Warning, - Info, -} - -// ── Document Placeholders ────────────────────────────────────────────── -// Concrete TOML/JSON parsing deferred to implementation. - -#[derive(Debug, Clone)] -pub struct ConfigDocument { - _private: (), -} - -#[derive(Debug, Clone)] -pub struct AuthDocument { - _private: (), -} - -// ── Merge Algorithm (B4) ─────────────────────────────────────────────── - -/// Merge user and workspace config into EffectiveConfig using field-level -/// merge semantics. Auth data is user-only. -pub fn merge_into_effective( - user_config: &LoadedConfigFile, - workspace_config: Option<&LoadedConfigFile>, - user_auth: &LoadedUserAuth, -) -> ConfigurationResolution { - let mut diagnostics = Vec::new(); - diagnostics.extend_from_slice(&user_config.diagnostics); - if let Some(ws) = workspace_config { - diagnostics.extend_from_slice(&ws.diagnostics); - } - diagnostics.extend_from_slice(&user_auth.diagnostics); - - // Build user auth store (user-only scope). - let user_auth_store = build_auth_store(user_auth); - - // Start with user config as base. - let mut effective = EffectiveConfig::default(); - - // Merge user scalar defaults. - apply_scalar_defaults(&mut effective, user_config); - - // Apply workspace scalar defaults (workspace overrides user for same field). - if let Some(ws) = workspace_config { - apply_scalar_defaults(&mut effective, ws); - } - - ConfigurationResolution { - effective, - user_auth: user_auth_store, - diagnostics, - } -} - -/// Apply scalar defaults from a loaded config file. -/// Later calls override earlier values for the same field. -fn apply_scalar_defaults(effective: &mut EffectiveConfig, source: &LoadedConfigFile) { - // Placeholder: actual TOML parsing walks the document tree. - // When both sources define the same scalar, the last write (workspace) wins. - _ = (effective, source); -} - -/// Build user auth store from loaded auth file. -fn build_auth_store(auth: &LoadedUserAuth) -> UserAuthStore { - UserAuthStore { - path: auth.path.clone(), - credentials: BTreeMap::new(), - provenance: BTreeMap::new(), - } -} - -// ── Tests ───────────────────────────────────────────────────────────── - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn empty_configuration_resolution() { - let user_cfg = LoadedConfigFile { - path: ConfigFilePath { - scope: ConfigScope::User, - config_dir: PathBuf::from("/home/user/.devo"), - config_path: PathBuf::from("/home/user/.devo/config.toml"), - }, - config: None, - diagnostics: vec![], - }; - let user_auth = LoadedUserAuth { - path: UserAuthPath { - config_dir: PathBuf::from("/home/user/.devo"), - auth_path: PathBuf::from("/home/user/.devo/auth.json"), - }, - document: None, - diagnostics: vec![], - }; - - let resolution = merge_into_effective(&user_cfg, None, &user_auth); - assert!(resolution.diagnostics.is_empty()); - assert!(resolution.effective.providers.is_empty()); - assert!(resolution.effective.defaults.model.is_none()); - assert!(resolution.user_auth.credentials.is_empty()); - } - - #[test] - fn user_auth_path_always_user_scope() { - let auth_path = UserAuthPath { - config_dir: PathBuf::from("/home/user/.devo"), - auth_path: PathBuf::from("/home/user/.devo/auth.json"), - }; - assert!(auth_path.auth_path.ends_with("auth.json")); - assert!(auth_path.config_dir.ends_with(".devo")); - } - - #[test] - fn workspace_scope_has_no_auth() { - // ConfigScope::Workspace has no auth variant. - let ws = ConfigScope::Workspace { - workspace_root: PathBuf::from("/tmp/project"), - }; - assert!(matches!(ws, ConfigScope::Workspace { .. })); - } - - #[test] - fn config_write_target_auth_is_user_only() { - let t = ConfigWriteTarget::UserAuth; - assert!(matches!(t, ConfigWriteTarget::UserAuth)); - } - - #[test] - fn diagnostic_severity_levels() { - assert!(matches!( - DiagnosticSeverity::Error, - DiagnosticSeverity::Error - )); - assert!(matches!( - DiagnosticSeverity::Warning, - DiagnosticSeverity::Warning - )); - assert!(matches!(DiagnosticSeverity::Info, DiagnosticSeverity::Info)); - } -} diff --git a/crates/core/src/context/mod.rs b/crates/core/src/context/mod.rs index c2c47a6e..9216f05a 100644 --- a/crates/core/src/context/mod.rs +++ b/crates/core/src/context/mod.rs @@ -447,7 +447,7 @@ mod tests { fn token_budget_for_model_uses_effective_context_as_auto_compact_limit() { let model = Model { context_window: 1_000_000, - effective_context_window_percent: Some(95), + effective_context_window_percent: Some(95.0), max_tokens: Some(384_000), ..Model::default() }; @@ -467,7 +467,7 @@ mod tests { fn model_token_budget_does_not_compact_before_effective_context_limit() { let model = Model { context_window: 1_000_000, - effective_context_window_percent: Some(95), + effective_context_window_percent: Some(95.0), max_tokens: Some(384_000), ..Model::default() }; @@ -478,23 +478,23 @@ mod tests { } #[test] - fn session_override_sets_effective_context_window_and_auto_compact() { + fn token_budget_for_session_ignores_override() { let model = Model { context_window: 200_000, - effective_context_window_percent: Some(95), + effective_context_window_percent: Some(95.0), max_tokens: Some(8_192), ..Model::default() }; let turn = crate::TurnConfig::new(model, None); let budget = turn.token_budget_for_session(Some(100_000)); - assert_eq!(budget.context_window, 100_000); - assert_eq!(budget.auto_compact_token_limit, Some(100_000)); - assert!(!budget.should_compact(100_000)); - assert!(budget.should_compact(100_001)); - - let clamped = turn.token_budget_for_session(Some(500_000)); - assert_eq!(clamped.context_window, 200_000); - assert_eq!(clamped.auto_compact_token_limit, Some(200_000)); + assert_eq!(budget.context_window, 190_000); + assert_eq!(budget.auto_compact_token_limit, Some(190_000)); + assert!(!budget.should_compact(190_000)); + assert!(budget.should_compact(190_001)); + + let ignored_clamp = turn.token_budget_for_session(Some(500_000)); + assert_eq!(ignored_clamp.context_window, 190_000); + assert_eq!(ignored_clamp.auto_compact_token_limit, Some(190_000)); } #[test] diff --git a/crates/core/src/conversation/history.rs b/crates/core/src/conversation/history.rs index 5d1e9b80..cbad03da 100644 --- a/crates/core/src/conversation/history.rs +++ b/crates/core/src/conversation/history.rs @@ -216,9 +216,9 @@ fn apply_settings_to_canonical_session( } SessionSettingsField::ReasoningEffortSelection => { // The stored value is the user's selection literal, including the - // toggle keywords (`enabled`/`disabled`) the `ReasoningEffort` - // enum cannot express — keep it as-is (normalized) instead of - // parsing, which silently dropped those and broke restore. + // toggle keywords (`on`/`off`, plus legacy `enabled`/`disabled`) + // the `ReasoningEffort` enum cannot express — keep it normalized + // instead of parsing, which silently dropped those and broke restore. if let Ok(Some(raw)) = serde_json::from_value::>(value) { let normalized = devo_protocol::normalize_reasoning_effort_literal(&raw); if !normalized.is_empty() { @@ -293,14 +293,14 @@ mod tests { write_lines( &dir.path().join("rollout.jsonl"), &[ - RolloutLine::Item(ItemLine { + RolloutLine::Item(Box::new(ItemLine { timestamp: kept_item.timestamp, item: kept_item, - }), - RolloutLine::Item(ItemLine { + })), + RolloutLine::Item(Box::new(ItemLine { timestamp: dropped_item.timestamp, item: dropped_item, - }), + })), RolloutLine::SessionRollback(Box::new(SessionRollbackLine { timestamp: Utc.with_ymd_and_hms(2026, 7, 1, 12, 1, 0).unwrap(), session_id, @@ -328,10 +328,10 @@ mod tests { let item = item_record(1, session_id, turn_id, "ok"); let mut text = String::new(); text.push_str( - &serde_json::to_string(&RolloutLine::Item(ItemLine { + &serde_json::to_string(&RolloutLine::Item(Box::new(ItemLine { timestamp: item.timestamp, item, - })) + }))) .expect("serialize"), ); text.push('\n'); @@ -350,10 +350,10 @@ mod tests { let item = item_record(1, session_id, turn_id, "ok"); let mut text = String::new(); text.push_str( - &serde_json::to_string(&RolloutLine::Item(ItemLine { + &serde_json::to_string(&RolloutLine::Item(Box::new(ItemLine { timestamp: item.timestamp, item, - })) + }))) .expect("serialize"), ); text.push('\n'); @@ -524,8 +524,8 @@ mod tests { history.session.expect("session").settings.reasoning_effort }; - assert_eq!(fold_one("enabled").as_deref(), Some("enabled")); - assert_eq!(fold_one("disabled").as_deref(), Some("disabled")); + assert_eq!(fold_one("enabled").as_deref(), Some("on")); + assert_eq!(fold_one("disabled").as_deref(), Some("off")); assert_eq!(fold_one(" High ").as_deref(), Some("high")); } } diff --git a/crates/core/src/conversation/legacy_projector.rs b/crates/core/src/conversation/legacy_projector.rs index 21bf2f11..a4fb4f0c 100644 --- a/crates/core/src/conversation/legacy_projector.rs +++ b/crates/core/src/conversation/legacy_projector.rs @@ -444,6 +444,7 @@ impl LegacyProjector { // Sessions that never recorded a resolved model keep an // explicitly empty slug: unknown, not fabricated. model: record.model.clone().unwrap_or_default(), + variant: None, reasoning_effort: record .reasoning_effort_selection .as_deref() @@ -890,6 +891,7 @@ pub fn canonical_turn_from_record(record: &TurnRecord) -> Result Result { /// Rebuilds a legacy approval request payload from the canonical approval /// parts (the inverse of [`approval_request_item`]); used when hydrating the /// fold map from an on-disk v2 approval envelope. +#[allow(clippy::too_many_arguments)] fn approval_request_from_parts( approval_id: &str, action_summary: &str, diff --git a/crates/core/src/conversation/records.rs b/crates/core/src/conversation/records.rs index 5c3e6e72..61dd92fc 100644 --- a/crates/core/src/conversation/records.rs +++ b/crates/core/src/conversation/records.rs @@ -639,7 +639,7 @@ pub enum RolloutLine { /// Turn metadata line. Turn(Box), /// Item record line. - Item(ItemLine), + Item(Box), /// Session-title update line. SessionTitleUpdated(SessionTitleUpdatedLine), /// Locked session-context update line. @@ -1032,10 +1032,10 @@ mod tests { timestamp: Utc::now(), turn: turn.clone(), })), - RolloutLine::Item(ItemLine { + RolloutLine::Item(Box::new(ItemLine { timestamp: Utc::now(), item: item.clone(), - }), + })), RolloutLine::SessionTitleUpdated(SessionTitleUpdatedLine { timestamp: Utc::now(), session_id: session.id, diff --git a/crates/core/src/conversation/v2_inverse.rs b/crates/core/src/conversation/v2_inverse.rs index 64c86f82..a22d3858 100644 --- a/crates/core/src/conversation/v2_inverse.rs +++ b/crates/core/src/conversation/v2_inverse.rs @@ -528,7 +528,7 @@ impl V2InverseProjector { .then_some(decision.decision_source), }), )?; - return Ok(Some(RolloutLine::Item(record))); + return Ok(Some(RolloutLine::Item(Box::new(record)))); } let (path, host, target) = target.as_ref().map_or((None, None, None), |t| match t { @@ -560,7 +560,7 @@ impl V2InverseProjector { }; let record = self.item_record(legacy_item_id(&envelope.id)?, envelope, payload)?; - Ok(Some(RolloutLine::Item(record))) + Ok(Some(RolloutLine::Item(Box::new(record)))) } /// Builds one legacy `ItemRecord` mirroring the live write path @@ -625,7 +625,7 @@ impl V2InverseProjector { // Identity and position travel on the line (exact); only the // record id is synthesized, since internal entries have no // item id of their own (replay only needs uniqueness). - Ok(vec![RolloutLine::Item(ItemLine { + Ok(vec![RolloutLine::Item(Box::new(ItemLine { timestamp, item: ItemRecord { id: ItemId::new(), @@ -643,7 +643,7 @@ impl V2InverseProjector { error: None, schema_version: CURRENT_ITEM_SCHEMA_VERSION, }, - })]) + }))]) } InternalRecordV2::SessionContext(context) => { Ok(vec![RolloutLine::SessionContextUpdated(Box::new( diff --git a/crates/core/src/lib.rs b/crates/core/src/lib.rs index aa0c4b92..a8c65193 100644 --- a/crates/core/src/lib.rs +++ b/crates/core/src/lib.rs @@ -1,7 +1,5 @@ mod approval_review_prompt; mod collaboration_mode_prompts; -#[allow(dead_code)] -mod config_resolution; mod context; mod context_pipeline; mod conversation; @@ -19,16 +17,16 @@ mod logging; pub mod mcp; pub mod memory; mod message_edit; -mod model_binding; mod model_catalog; -mod model_preset; mod permission; +mod provider_request; mod query; mod replay; mod response_item; mod session; mod session_store; mod skills; +mod small_model; mod state; pub mod tools; mod update_check; @@ -79,10 +77,9 @@ pub use mcp::*; pub use memory::*; pub use message_edit::*; #[allow(ambiguous_glob_reexports)] -pub use model_binding::*; pub use model_catalog::*; -pub use model_preset::ModelPreset; pub use permission::*; +pub use provider_request::{add_model_request_headers, merge_model_request_body}; pub use query::*; #[allow(ambiguous_glob_reexports)] pub use replay::*; @@ -99,4 +96,5 @@ pub use skills::{ collect_explicit_skill_mentions, default_skill_metadata_budget, normalize_native_path, render_available_skills_body, }; +pub use small_model::resolve_small_model; pub use update_check::*; diff --git a/crates/core/src/model_binding.rs b/crates/core/src/model_binding.rs deleted file mode 100644 index 02fa90a4..00000000 --- a/crates/core/src/model_binding.rs +++ /dev/null @@ -1,357 +0,0 @@ -//! Model provider binding — three-tier data model. -//! -//! Implements L3-BEH-PROVIDER-001 and L2-DES-MODEL-001. Defines -//! SupportedModelDefinition (pure capability profile), UserProvider -//! (connection config), ModelProviderBinding (links model to provider), -//! and ResolvedModelProfile (runtime merge). - -use serde::{Deserialize, Serialize}; - -use devo_protocol::ReasoningEffort; - -use crate::durable_record::{InvocationMethod, ModelBindingId, ProviderId}; - -// ── SupportedModelDefinition ──────────────────────────────────────── - -/// Pure capability profile for a model — must NOT contain provider names, URLs, -/// API keys, or invocation methods. -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] -pub struct SupportedModelDefinition { - pub canonical_model_slug: String, - pub display_name: String, - pub base_instructions: String, - pub context_window: u64, - pub effective_context_window: u64, - pub modalities: Vec, - pub reasoning_capability: ModelReasoningCapability, - pub default_reasoning_effort: Option, - pub supports_tool_use: bool, - pub supports_parallel_tool_use: bool, - pub supports_images: bool, - pub supports_streaming: bool, - pub supports_prompt_caching: bool, - pub max_output_tokens: Option, -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] -#[serde(rename_all = "snake_case")] -pub enum ModelModality { - Text, - Image, - Audio, - Video, -} - -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] -#[serde(rename_all = "snake_case")] -pub enum ModelReasoningCapability { - None, - Enabled, - Required, - Toggleable, - Levels(Vec), -} - -impl SupportedModelDefinition {} - -// ── UserProvider ──────────────────────────────────────────────────── - -/// User-configured provider connection. Identifies a provider instance -/// with its connection details and credential reference. -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] -pub struct UserProvider { - pub provider_id: ProviderId, - pub provider_name: String, - pub provider_kind: ProviderKind, - pub base_url: Option, - pub credential_ref: String, - pub availability_status: ProviderAvailabilityStatus, - pub supports: Vec, -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] -#[serde(rename_all = "snake_case")] -pub enum ProviderKind { - Anthropic, - OpenAi, - OpenAiCompatible, - Custom, -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] -#[serde(rename_all = "snake_case")] -pub enum ProviderAvailabilityStatus { - Available, - Degraded, - Unavailable, - Unknown, -} - -// ── ModelProviderBinding ──────────────────────────────────────────── - -/// Links a SupportedModelDefinition to a UserProvider with concrete -/// invocation details. -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] -pub struct ModelProviderBinding { - pub binding_id: ModelBindingId, - pub canonical_model_slug: String, - pub provider_id: ProviderId, - pub request_model: String, - pub display_name: String, - pub invocation_method: InvocationMethod, - pub reasoning_effort: Option, - pub rate_limit: Option, - pub priority: u32, -} - -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] -pub struct RateLimitConfig { - pub requests_per_minute: Option, - pub tokens_per_minute: Option, - pub tokens_per_day: Option, - pub concurrent_requests: Option, -} - -// ── ResolvedModelProfile ──────────────────────────────────────────── - -/// Runtime merge of SupportedModelDefinition + ModelProviderBinding + -/// session overrides. -#[allow(dead_code)] -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] -pub struct ResolvedModelProfile { - pub canonical_model_slug: String, - pub provider_id: ProviderId, - pub model_binding_id: ModelBindingId, - pub display_name: String, - pub context_window: u64, - pub effective_context_window: u64, - pub reasoning_effort: Option, - pub modalities: Vec, - pub invocation_method: InvocationMethod, - pub request_model: String, - pub base_url: Option, - pub supports_tool_use: bool, - pub supports_streaming: bool, - pub supports_prompt_caching: bool, - pub max_output_tokens: Option, -} - -// ── Provider Error Classification ─────────────────────────────────── - -/// Structured provider error (L3-BEH-PROVIDER-001 §B6). -#[derive(Debug, Clone, thiserror::Error)] -pub enum ProviderError { - #[error("authentication failed: {message}")] - AuthenticationError { - message: String, - provider_id: Option, - }, - #[error("rate limited: {message}")] - RateLimitError { - message: String, - retry_after: Option, - }, - #[error("provider server error: {message}")] - ProviderServerError { - message: String, - status_code: Option, - }, - #[error("provider timeout: {message}")] - ProviderTimeoutError { message: String }, - #[error("context limit exceeded: {message}")] - ContextLimitError { - message: String, - current_tokens: Option, - limit: Option, - }, - #[error("model not found: {message}")] - ModelNotFoundError { - message: String, - request_model: Option, - }, - #[error("quota exceeded: {message}")] - QuotaExceededError { message: String }, - #[error("unknown provider error: {message}")] - UnknownError { message: String }, -} - -impl ProviderError { - /// Whether the error is recoverable (retry may succeed). - pub fn is_recoverable(&self) -> bool { - matches!( - self, - Self::RateLimitError { .. } - | Self::ProviderServerError { .. } - | Self::ProviderTimeoutError { .. } - ) - } - - /// Suggested retry delay in seconds, if provided by the provider. - pub fn retry_after_seconds(&self) -> Option { - match self { - Self::RateLimitError { retry_after, .. } => *retry_after, - _ => None, - } - } -} - -// ── Tests ─────────────────────────────────────────────────────────── - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn supported_model_definition_is_pure_capability() { - let def = SupportedModelDefinition { - canonical_model_slug: "claude-opus-4-7".into(), - display_name: "Claude Opus 4.7".into(), - base_instructions: "You are helpful.".into(), - context_window: 200000, - effective_context_window: 180000, - modalities: vec![ModelModality::Text, ModelModality::Image], - reasoning_capability: ModelReasoningCapability::Levels(vec![ - ReasoningEffort::Low, - ReasoningEffort::High, - ]), - default_reasoning_effort: Some(ReasoningEffort::High), - supports_tool_use: true, - supports_parallel_tool_use: true, - supports_images: true, - supports_streaming: true, - supports_prompt_caching: true, - max_output_tokens: Some(32000), - }; - // Verify no provider-specific fields - assert_eq!(def.canonical_model_slug, "claude-opus-4-7"); - assert_eq!(def.context_window, 200000); - } - - #[test] - fn user_provider_has_credential_ref() { - let provider = UserProvider { - provider_id: ProviderId::new(), - provider_name: "my-anthropic".into(), - provider_kind: ProviderKind::Anthropic, - base_url: Some("https://api.anthropic.com".into()), - credential_ref: "auth.json#anthropic".into(), - availability_status: ProviderAvailabilityStatus::Available, - supports: vec!["messages".into()], - }; - assert_eq!(provider.provider_kind, ProviderKind::Anthropic); - assert_eq!(provider.credential_ref, "auth.json#anthropic"); - } - - #[test] - fn model_provider_binding_links_model_to_provider() { - let binding = ModelProviderBinding { - binding_id: ModelBindingId::new(), - canonical_model_slug: "claude-opus-4-7".into(), - provider_id: ProviderId::new(), - request_model: "claude-opus-4-7".into(), - display_name: "Claude Opus 4.7".into(), - invocation_method: InvocationMethod::AnthropicMessages, - reasoning_effort: Some(ReasoningEffort::High), - rate_limit: Some(RateLimitConfig { - requests_per_minute: Some(50), - tokens_per_minute: Some(100000), - tokens_per_day: None, - concurrent_requests: Some(5), - }), - priority: 1, - }; - assert_eq!(binding.request_model, "claude-opus-4-7"); - assert_eq!( - binding.invocation_method, - InvocationMethod::AnthropicMessages - ); - } - - #[test] - fn resolved_model_profile_merges_sources() { - let profile = ResolvedModelProfile { - canonical_model_slug: "claude-sonnet-4-6".into(), - provider_id: ProviderId::new(), - model_binding_id: ModelBindingId::new(), - display_name: "Claude Sonnet 4.6".into(), - context_window: 200000, - effective_context_window: 180000, - reasoning_effort: Some(ReasoningEffort::Medium), - modalities: vec![ModelModality::Text, ModelModality::Image], - invocation_method: InvocationMethod::AnthropicMessages, - request_model: "claude-sonnet-4-6".into(), - base_url: None, - supports_tool_use: true, - supports_streaming: true, - supports_prompt_caching: true, - max_output_tokens: Some(32000), - }; - assert_eq!(profile.canonical_model_slug, "claude-sonnet-4-6"); - } - - #[test] - fn provider_error_recoverability() { - assert!( - ProviderError::RateLimitError { - message: "slow down".into(), - retry_after: Some(30) - } - .is_recoverable() - ); - assert!( - ProviderError::ProviderTimeoutError { - message: "timed out".into() - } - .is_recoverable() - ); - assert!( - !ProviderError::AuthenticationError { - message: "bad key".into(), - provider_id: None - } - .is_recoverable() - ); - } - - #[test] - fn provider_error_retry_after() { - let err = ProviderError::RateLimitError { - message: "slow down".into(), - retry_after: Some(30), - }; - assert_eq!(err.retry_after_seconds(), Some(30)); - - let err = ProviderError::ProviderTimeoutError { - message: "timeout".into(), - }; - assert_eq!(err.retry_after_seconds(), None); - } - - #[test] - fn model_modality_serde_roundtrip() { - for m in &[ - ModelModality::Text, - ModelModality::Image, - ModelModality::Audio, - ModelModality::Video, - ] { - let json = serde_json::to_string(m).expect("serialize"); - let restored: ModelModality = serde_json::from_str(&json).expect("deserialize"); - assert_eq!(restored, *m); - } - } - - #[test] - fn provider_kind_serde_roundtrip() { - for k in &[ - ProviderKind::Anthropic, - ProviderKind::OpenAi, - ProviderKind::OpenAiCompatible, - ProviderKind::Custom, - ] { - let json = serde_json::to_string(k).expect("serialize"); - let restored: ProviderKind = serde_json::from_str(&json).expect("deserialize"); - assert_eq!(restored, *k); - } - } -} diff --git a/crates/core/src/model_catalog.rs b/crates/core/src/model_catalog.rs index f3629272..266ef7a8 100644 --- a/crates/core/src/model_catalog.rs +++ b/crates/core/src/model_catalog.rs @@ -1,62 +1,160 @@ //! Builtin model catalog loading and resolution for core. //! -//! The embedded `models.json` asset is the catalog base. Configuration can -//! override individual metadata fields or add custom models without creating -//! or reading a filesystem catalog. +//! The embedded `providers.json` asset is the canonical provider/model +//! directory. Provider and model identity are resolved from the same map. use std::collections::BTreeMap; -use crate::{Model, ModelCatalog, ModelError, ModelPreset}; -use devo_config::ModelOverrideConfig; +use crate::{ + InputModality, Model, ModelCatalog, ModelEffortVariant, ModelError, ProviderInfo, + ProviderModelInfo, ProviderModelVariant, ProviderWireApi, ReasoningCapability, +}; +use devo_config::{ModelOverrideConfig, ProviderConfigFile, ProviderModelConfig, model_reference}; -const BUILTIN_MODELS_JSON: &str = include_str!("../models.json"); +const BUILTIN_PROVIDERS_JSON: &str = include_str!("../providers.json"); +const DEFAULT_BASE_INSTRUCTIONS: &str = include_str!("../default_base_instructions.txt"); -pub use crate::model_preset::default_base_instructions; +/// Returns the shared fallback base instructions used when a catalog model +/// omits `base_instructions`, or when a custom model has no instructions. +pub fn default_base_instructions() -> &'static str { + DEFAULT_BASE_INSTRUCTIONS +} /// A catalog resolved from embedded presets and configuration overrides. #[derive(Debug, Clone, Default)] pub struct PresetModelCatalog { models: Vec, + providers: Vec, + provider_models: BTreeMap>, + builtin_provider_ids: Vec, } impl PresetModelCatalog { - /// Loads the built-in embedded catalog only. + /// Loads the embedded provider/model directory without user overlays. pub fn load() -> Result { - Ok(Self { - models: load_builtin_models()?, - }) + Self::load_from_provider_config(&ProviderConfigFile::default()) } - /// Loads the embedded catalog with configured metadata overrides. - pub fn load_from_config( - model_overrides: &BTreeMap, + /// Loads the embedded provider/model directory and overlays user-defined + /// providers and models on top of it. + pub fn load_from_provider_config( + provider_config: &ProviderConfigFile, ) -> Result { - Self::with_model_overrides(model_overrides) + Self::load_from_provider_config_with_overrides(provider_config, &BTreeMap::new()) } - /// Loads embedded presets and applies overrides by model slug. - pub fn with_model_overrides( + /// Loads the embedded catalog, merges the user provider file, then applies + /// `[model.]` overlays onto matching builtin or user models. + pub fn load_from_provider_config_with_overrides( + provider_config: &ProviderConfigFile, model_overrides: &BTreeMap, ) -> Result { - let mut presets = load_builtin_model_presets()?; - for (slug, overrides) in model_overrides { - if let Some(preset) = presets.iter_mut().find(|preset| preset.slug == *slug) { - preset.apply_overrides(overrides); + let mut directory = load_builtin_provider_config()?; + let builtin_provider_ids = directory.providers.keys().cloned().collect(); + directory.merge_overlay(provider_config.clone()); + directory.apply_model_overrides(model_overrides); + let providers = directory + .providers + .iter() + .map(|(provider_id, provider)| provider_info_from_config(provider_id, provider)) + .collect(); + let referenced_models = [directory.model.clone(), directory.small_model.clone()] + .into_iter() + .flatten() + .collect::>(); + for model_ref in referenced_models { + let Some((provider_id, requested_model_id)) = model_ref.split_once('/') else { + continue; + }; + let Some(provider) = directory.providers.get(provider_id) else { + continue; + }; + let model_id = if provider.models.contains_key(requested_model_id) { + requested_model_id + } else if let Some((model_id, variant_id)) = requested_model_id.rsplit_once('/') + && provider + .models + .get(model_id) + .is_some_and(|model| model.variants.contains_key(variant_id)) + { + model_id } else { - presets.push(ModelPreset::from_overrides(slug, overrides)); + requested_model_id + }; + if let Some(provider) = directory.providers.get_mut(provider_id) { + provider.models.entry(model_id.to_string()).or_default(); } } - // `sort_by` is stable, keeping custom zero-priority entries after the - // embedded entries that were loaded first. - presets.sort_by(|left, right| right.priority.cmp(&left.priority)); + let mut models = directory + .providers + .iter() + .flat_map(|(provider_id, provider)| { + let provider_wire_api = provider + .wire_api + .unwrap_or(ProviderWireApi::OpenAIChatCompletions); + provider.models.iter().filter_map(move |(model_id, model)| { + if provider.enabled == Some(false) || model.enabled == Some(false) { + return None; + } + Some(( + model.priority.unwrap_or(0), + model_from_provider_config(provider_id, model_id, model, provider_wire_api), + )) + }) + }) + .collect::>(); + let provider_models = directory + .providers + .iter() + .map(|(provider_id, provider)| { + let provider_wire_api = provider + .wire_api + .unwrap_or(ProviderWireApi::OpenAIChatCompletions); + // Include disabled models so settings UIs can show them with + // enabled=false. Visible turn selection still uses `models` + // above, which filters disabled entries out. + let models = provider + .models + .iter() + .map(|(model_id, model)| { + ( + model_id.clone(), + provider_model_info_from_config(model, provider_wire_api), + ) + }) + .collect(); + (provider_id.clone(), models) + }) + .collect(); + models.sort_by(|left, right| right.0.cmp(&left.0)); + let mut models = models + .into_iter() + .map(|(_, model)| model) + .collect::>(); + + if let Some(default_model) = directory.model.as_deref() + && let Some(index) = models.iter().position(|model| model.slug == default_model) + { + let model = models.remove(index); + models.insert(0, model); + } + Ok(Self { - models: presets.into_iter().map(Model::from).collect(), + models, + providers, + provider_models, + builtin_provider_ids, }) } /// Creates a catalog from an already-loaded model list. pub fn new(models: Vec) -> Self { - Self { models } + Self { + models, + providers: Vec::new(), + provider_models: BTreeMap::new(), + builtin_provider_ids: Vec::new(), + } } /// Returns the loaded models by value. @@ -70,6 +168,21 @@ impl ModelCatalog for PresetModelCatalog { self.models.iter().collect() } + fn list_providers(&self) -> Vec { + self.providers.clone() + } + + fn list_template_provider_ids(&self) -> Vec { + self.builtin_provider_ids.clone() + } + + fn list_provider_models(&self, provider_id: &str) -> BTreeMap { + self.provider_models + .get(provider_id) + .cloned() + .unwrap_or_default() + } + fn get(&self, slug: &str) -> Option<&Model> { self.models.iter().find(|model| model.slug == slug) } @@ -89,23 +202,157 @@ impl ModelCatalog for PresetModelCatalog { } } -/// Loads the built-in raw model preset list bundled with the crate. -pub fn load_builtin_model_presets() -> Result, PresetModelCatalogError> { - serde_json::from_str(BUILTIN_MODELS_JSON).map_err(Into::into) +fn load_builtin_provider_config() -> Result { + serde_json::from_str(BUILTIN_PROVIDERS_JSON).map_err(Into::into) +} + +fn model_from_provider_config( + provider_id: &str, + model_id: &str, + config: &ProviderModelConfig, + provider_wire_api: ProviderWireApi, +) -> Model { + let mut config = config.clone(); + config.migrate_reasoning_implementation_into_variants(); + Model { + slug: model_reference(provider_id, model_id), + display_name: config.name.clone().unwrap_or_else(|| model_id.to_string()), + provider: config.wire_api.unwrap_or(provider_wire_api), + reasoning_capability: config + .reasoning_capability + .clone() + .unwrap_or(ReasoningCapability::Unsupported), + default_reasoning_effort: config.default_reasoning_effort, + default_reasoning_selection: config.default_reasoning_selection.clone(), + reasoning_implementation: config.reasoning_implementation.clone(), + catalog_variants: config + .variants + .iter() + .map(|(variant_id, variant)| { + ( + variant_id.clone(), + ModelEffortVariant { + request_model: variant.request_model.clone(), + disabled: variant.disabled, + }, + ) + }) + .collect(), + base_instructions: config + .base_instructions + .clone() + .unwrap_or_else(|| default_base_instructions().to_string()), + context_window: config.context_window.unwrap_or(200_000), + effective_context_window_percent: config.effective_context_window_percent, + truncation_policy: config.truncation_policy.unwrap_or_default(), + input_modalities: config + .input_modalities + .clone() + .unwrap_or_else(|| vec![InputModality::Text]), + supports_image_detail_original: config.supports_image_detail_original.unwrap_or(false), + channel: config.channel.clone(), + temperature: config.temperature, + top_p: config.top_p, + top_k: config.top_k, + max_tokens: config.max_tokens, + ..Model::default() + } +} + +fn provider_info_from_config( + provider_id: &str, + config: &devo_config::ProviderConfigEntry, +) -> ProviderInfo { + let wire_api = config + .wire_api + .unwrap_or(ProviderWireApi::OpenAIChatCompletions); + ProviderInfo { + id: provider_id.to_string(), + name: config + .name + .clone() + .unwrap_or_else(|| provider_id.to_string()), + description: config.description.clone(), + base_url: config.base_url.clone(), + credential: config.credential.clone(), + headers: config.headers.clone().unwrap_or_default(), + options: config.options.clone(), + request: config.request.clone(), + wire_apis: vec![wire_api], + models: BTreeMap::new(), + enabled: config.enabled.unwrap_or(true), + } } -/// Loads the built-in model list bundled with the crate. -pub fn load_builtin_models() -> Result, PresetModelCatalogError> { - let mut presets = load_builtin_model_presets()?; - presets.sort_by(|left, right| right.priority.cmp(&left.priority)); - Ok(presets.into_iter().map(Model::from).collect()) +fn provider_model_info_from_config( + config: &ProviderModelConfig, + provider_wire_api: ProviderWireApi, +) -> ProviderModelInfo { + ProviderModelInfo { + name: config.name.clone(), + family: config.family.clone(), + release_date: config.release_date.clone(), + status: config.status.clone(), + capabilities: config.capabilities.clone(), + wire_api: Some(config.wire_api.unwrap_or(provider_wire_api)), + context_window: config.context_window, + effective_context_window_percent: config.effective_context_window_percent, + max_tokens: config.max_tokens, + temperature: config.temperature, + top_p: config.top_p, + top_k: config.top_k, + reasoning_capability: config.reasoning_capability.clone(), + reasoning_implementation: config.reasoning_implementation.clone(), + default_reasoning_effort: config.default_reasoning_effort, + default_reasoning_selection: config.default_reasoning_selection.clone(), + base_instructions: config.base_instructions.clone(), + input_modalities: config.input_modalities.clone(), + channel: config.channel.clone(), + truncation_policy: config + .truncation_policy + .and_then(|policy| serde_json::to_value(policy).ok()), + supports_image_detail_original: config.supports_image_detail_original, + web_search: config + .web_search + .as_ref() + .and_then(|value| serde_json::to_value(value).ok()), + web_fetch: config + .web_fetch + .as_ref() + .and_then(|value| serde_json::to_value(value).ok()), + cost: config.cost.clone(), + metadata: config.metadata.clone(), + request: config.request.clone(), + options: config.options.clone(), + headers: config.headers.clone(), + variants: config + .variants + .iter() + .map(|(variant_id, variant)| { + ( + variant_id.clone(), + ProviderModelVariant { + label: variant.label.clone(), + disabled: variant.disabled, + request_model: variant.request_model.clone(), + request: variant.request.clone(), + options: variant.options.clone(), + headers: variant.headers.clone(), + }, + ) + }) + .collect(), + default_variant: config.default_variant.clone(), + enabled: config.enabled, + priority: config.priority, + } } /// Errors produced while loading the builtin catalog. #[derive(Debug, thiserror::Error)] pub enum PresetModelCatalogError { - /// Parsing the bundled JSON file failed. - #[error("failed to parse builtin model catalog: {0}")] + /// Parsing the bundled provider directory failed. + #[error("failed to parse builtin provider catalog: {0}")] Parse(#[from] serde_json::Error), } @@ -115,20 +362,14 @@ mod tests { use pretty_assertions::assert_eq; - use super::{ - PresetModelCatalog, default_base_instructions, load_builtin_model_presets, - load_builtin_models, - }; - use crate::{ - InputModality, Model, ModelCatalog, ModelOverrideConfig, ProviderWireApi, - ReasoningCapability, ReasoningEffort, ReasoningImplementation, TruncationPolicyConfig, - }; + use super::{PresetModelCatalog, default_base_instructions}; + use crate::{Model, ModelCatalog, ProviderInfo, ProviderWireApi}; #[test] - fn builtin_models_load_from_bundled_json() { - let models = load_builtin_models().expect("load builtin models"); - assert!(!models.is_empty()); - assert_eq!(models[0].slug, "kimi-k3"); + fn builtin_models_load_from_provider_directory() { + let catalog = PresetModelCatalog::load().expect("load provider catalog"); + assert!(!catalog.list_visible().is_empty()); + assert_eq!(catalog.list_visible()[0].slug, "kimi/kimi-k3"); } #[test] @@ -145,234 +386,174 @@ mod tests { #[test] fn builtin_models_have_channel_fields() { - let models = load_builtin_models().expect("load builtin models"); + let catalog = PresetModelCatalog::load().expect("load provider catalog"); assert!( - models + catalog + .list_visible() .iter() .any(|model| model.channel.as_deref() == Some("DeepSeek")) ); } #[test] - fn load_from_config_applies_partial_builtin_override_without_replacing_metadata() { - let builtin = load_builtin_models() - .expect("load builtins") - .into_iter() - .find(|model| model.slug == "kimi-k3") - .expect("kimi model"); - let catalog = PresetModelCatalog::load_from_config(&BTreeMap::from([( - "kimi-k3".to_string(), - ModelOverrideConfig { - display_name: Some("Configured Kimi".to_string()), - ..ModelOverrideConfig::default() - }, - )])) - .expect("load catalog"); - + fn provider_catalog_uses_provider_model_references_and_accepts_custom_models() { + let config = crate::ProviderConfigFile { + providers: BTreeMap::from([( + "local".to_string(), + crate::ProviderConfigEntry { + wire_api: Some(ProviderWireApi::OpenAIResponses), + models: BTreeMap::from([( + "qwen3".to_string(), + crate::ProviderModelConfig { + name: Some("Qwen 3".to_string()), + context_window: Some(131_072), + ..crate::ProviderModelConfig::default() + }, + )]), + ..crate::ProviderConfigEntry::default() + }, + )]), + model: Some("local/qwen3".to_string()), + ..crate::ProviderConfigFile::default() + }; + + let catalog = + PresetModelCatalog::load_from_provider_config(&config).expect("load provider catalog"); assert_eq!( - catalog.get("kimi-k3").expect("configured kimi"), - &Model { - display_name: "Configured Kimi".to_string(), - ..builtin - } + catalog + .resolve_for_turn(None) + .expect("resolve default") + .slug, + "local/qwen3" ); - } - - #[test] - fn load_from_config_keeps_explicit_toggle_from_legacy_toggle_with_levels() { - let builtin = load_builtin_models() - .expect("load builtins") - .into_iter() - .find(|model| model.slug == "glm-5.2") - .expect("glm model"); - assert!(matches!( - builtin.reasoning_capability, - ReasoningCapability::ToggleWithLevels(_) - )); - - let catalog = PresetModelCatalog::load_from_config(&BTreeMap::from([( - "glm-5.2".to_string(), - ModelOverrideConfig { - reasoning_capability: Some(ReasoningCapability::Toggle), - ..ModelOverrideConfig::default() - }, - )])) - .expect("load catalog"); - assert_eq!( - catalog.get("glm-5.2").expect("configured glm"), - &Model { - reasoning_capability: ReasoningCapability::Toggle, - ..builtin - } + catalog + .get("local/qwen3") + .expect("custom model") + .display_name, + "Qwen 3" ); - } - - #[test] - fn load_from_config_applies_complete_metadata_override() { - let catalog = PresetModelCatalog::load_from_config(&BTreeMap::from([( - "qwen3-coder-next".to_string(), - ModelOverrideConfig { - display_name: Some("Configured Qwen".to_string()), - description: Some("Configured description".to_string()), - context_window: Some(128_000), - effective_context_window_percent: Some(80), - max_tokens: Some(8_192), - temperature: Some(0.4), - top_p: Some(0.7), - top_k: Some(24.0), - provider: Some(ProviderWireApi::AnthropicMessages), - reasoning_capability: Some(ReasoningCapability::Levels(vec![ - ReasoningEffort::Low, - ReasoningEffort::High, - ])), - reasoning_implementation: Some(ReasoningImplementation::RequestParameter), - default_reasoning_effort: Some(ReasoningEffort::High), - base_instructions: Some("Configured instructions".to_string()), - input_modalities: Some(vec![InputModality::Image]), - channel: Some("Configured channel".to_string()), - truncation_policy: Some(TruncationPolicyConfig::tokens(4_096)), - supports_image_detail_original: Some(true), - }, - )])) - .expect("load catalog"); - assert_eq!( - catalog.get("qwen3-coder-next").expect("configured qwen"), - &Model { - slug: "qwen3-coder-next".to_string(), - display_name: "Configured Qwen".to_string(), - provider: ProviderWireApi::AnthropicMessages, - description: Some("Configured description".to_string()), - reasoning_capability: ReasoningCapability::Levels(vec![ - ReasoningEffort::Low, - ReasoningEffort::High, - ]), - default_reasoning_effort: Some(ReasoningEffort::High), - reasoning_implementation: Some(ReasoningImplementation::RequestParameter), - base_instructions: "Configured instructions".to_string(), - context_window: 128_000, - effective_context_window_percent: Some(80), - truncation_policy: TruncationPolicyConfig::tokens(4_096), - input_modalities: vec![InputModality::Image], - supports_image_detail_original: true, - channel: Some("Configured channel".to_string()), - temperature: Some(0.4), - top_p: Some(0.7), - top_k: Some(24.0), - max_tokens: Some(8_192), - } + catalog.get("local/qwen3").expect("custom model").provider, + ProviderWireApi::OpenAIResponses ); } #[test] - fn load_from_config_creates_minimal_custom_model_with_fallback_instructions() { - let catalog = PresetModelCatalog::load_from_config(&BTreeMap::from([( - "custom".to_string(), - ModelOverrideConfig::default(), - )])) - .expect("load catalog"); + fn builtin_provider_catalog_contains_current_cloud_and_local_models() { + let catalog = + PresetModelCatalog::load_from_provider_config(&crate::ProviderConfigFile::default()) + .expect("load builtin provider catalog"); assert_eq!( - catalog.get("custom").expect("custom model"), - &Model { - slug: "custom".to_string(), - display_name: "custom".to_string(), - base_instructions: default_base_instructions().to_string(), - ..Model::default() - } + catalog + .resolve_for_turn(None) + .expect("resolve builtin default") + .slug, + "kimi/kimi-k3" + ); + for model in [ + "deepseek/deepseek-v4-flash-vision-exp", + "zai/glm-5.3-flash", + "zhipu/glm-5.3-flash", + "qwen/qwen3.7-plus", + "minimax/MiniMax-M3", + "xiaomi/mimo-v2.5-pro", + "tencent/hunyuan-a13b", + ] { + assert!( + catalog.get(model).is_some(), + "missing builtin model {model}" + ); + } + assert!( + catalog + .list_providers() + .iter() + .any(|provider| provider.id == "ollama"), + "missing builtin ollama provider" + ); + assert!( + catalog.list_provider_models("ollama").is_empty(), + "ollama template must not ship placeholder models" ); - } - #[test] - fn load_from_config_creates_fully_specified_custom_model() { - let catalog = PresetModelCatalog::with_model_overrides(&BTreeMap::from([( - "custom".to_string(), - ModelOverrideConfig { - display_name: Some("Custom".to_string()), - description: Some("Custom description".to_string()), - context_window: Some(64_000), - effective_context_window_percent: Some(75), - max_tokens: Some(4_096), - temperature: Some(0.2), - top_p: Some(0.6), - top_k: Some(12.0), - provider: Some(ProviderWireApi::OpenAIResponses), - reasoning_capability: Some(ReasoningCapability::Toggle), - reasoning_implementation: Some(ReasoningImplementation::RequestParameter), - default_reasoning_effort: Some(ReasoningEffort::Medium), - base_instructions: Some("Custom instructions".to_string()), - input_modalities: Some(vec![InputModality::Text, InputModality::Image]), - channel: Some("Custom channel".to_string()), - truncation_policy: Some(TruncationPolicyConfig::tokens(2_048)), - supports_image_detail_original: Some(true), - }, - )])) - .expect("load catalog"); + let zai_models = catalog + .list_visible() + .into_iter() + .filter(|model| model.slug.starts_with("zai/")) + .map(|model| model.slug.clone()) + .collect::>(); + assert_eq!(zai_models, ["zai/glm-5.3", "zai/glm-5.3-flash"]); + + let zhipu_models = catalog + .list_visible() + .into_iter() + .filter(|model| model.slug.starts_with("zhipu/")) + .map(|model| model.slug.clone()) + .collect::>(); + assert_eq!(zhipu_models, ["zhipu/glm-5.3", "zhipu/glm-5.3-flash"]); assert_eq!( - catalog.get("custom").expect("custom model"), - &Model { - slug: "custom".to_string(), - display_name: "Custom".to_string(), - provider: ProviderWireApi::OpenAIResponses, - description: Some("Custom description".to_string()), - reasoning_capability: ReasoningCapability::Toggle, - default_reasoning_effort: Some(ReasoningEffort::Medium), - reasoning_implementation: Some(ReasoningImplementation::RequestParameter), - base_instructions: "Custom instructions".to_string(), - context_window: 64_000, - effective_context_window_percent: Some(75), - truncation_policy: TruncationPolicyConfig::tokens(2_048), - input_modalities: vec![InputModality::Text, InputModality::Image], - supports_image_detail_original: true, - channel: Some("Custom channel".to_string()), - temperature: Some(0.2), - top_p: Some(0.6), - top_k: Some(12.0), - max_tokens: Some(4_096), - } + catalog + .get("deepseek/deepseek-v4-flash") + .expect("deepseek model") + .provider, + ProviderWireApi::AnthropicMessages ); - } - - #[test] - fn load_from_config_keeps_explicit_empty_base_instructions() { - let catalog = PresetModelCatalog::load_from_config(&BTreeMap::from([( - "custom".to_string(), - ModelOverrideConfig { - base_instructions: Some(String::new()), - ..ModelOverrideConfig::default() - }, - )])) - .expect("load catalog"); + let providers = catalog.list_providers(); assert_eq!( - catalog - .get("custom") - .expect("custom model") - .base_instructions, - "" + providers + .iter() + .find(|provider| provider.id == "zhipu") + .cloned(), + Some(ProviderInfo { + id: "zhipu".to_string(), + name: "Zhipu AI".to_string(), + description: Some("China BigModel GLM API".to_string()), + base_url: Some("https://open.bigmodel.cn/api/paas/v4".to_string()), + credential: None, + headers: BTreeMap::new(), + options: None, + request: None, + wire_apis: vec![ProviderWireApi::OpenAIChatCompletions], + models: BTreeMap::new(), + enabled: true, + }) + ); + assert_eq!( + providers + .iter() + .find(|provider| provider.id == "deepseek") + .map(|provider| provider.wire_apis.clone()), + Some(vec![ProviderWireApi::AnthropicMessages]) ); } #[test] - fn custom_models_follow_builtins_with_equal_priority() { - let catalog = PresetModelCatalog::load_from_config(&BTreeMap::from([( - "custom".to_string(), - ModelOverrideConfig::default(), - )])) - .expect("load catalog"); - let models = catalog.into_inner(); - - assert_eq!(models.last().expect("custom model").slug, "custom"); - } + fn provider_catalog_materializes_a_minimal_referenced_custom_model() { + let config = crate::ProviderConfigFile { + model: Some("local/qwen3".to_string()), + providers: BTreeMap::from([( + "local".to_string(), + crate::ProviderConfigEntry::default(), + )]), + ..crate::ProviderConfigFile::default() + }; + + let catalog = + PresetModelCatalog::load_from_provider_config(&config).expect("load provider catalog"); - #[test] - fn embedded_presets_remain_the_only_catalog_base() { - assert!( - !load_builtin_model_presets() - .expect("load embedded presets") - .is_empty() + assert_eq!( + catalog.get("local/qwen3").expect("referenced custom model"), + &Model { + slug: "local/qwen3".to_string(), + display_name: "qwen3".to_string(), + default_reasoning_effort: None, + base_instructions: default_base_instructions().to_string(), + ..Model::default() + } ); } } diff --git a/crates/core/src/model_preset.rs b/crates/core/src/model_preset.rs deleted file mode 100644 index ec284962..00000000 --- a/crates/core/src/model_preset.rs +++ /dev/null @@ -1,413 +0,0 @@ -//! Raw model preset types used to load the builtin catalog. -//! -//! Main focus: -//! - deserialize bundled model definitions from `models.json` -//! - preserve JSON compatibility and catalog-only metadata such as priority and API-config flags -//! - convert raw presets into runtime `devo_protocol::Model` values -//! -//! Design: -//! - `ModelPreset` is intentionally a core-only type because it exists to support catalog loading -//! - serde adapters and legacy field aliases live here so they do not leak into the runtime model -//! - conversion into `Model` is the handoff point from config data to executable runtime data -//! -//! Boundary: -//! - this module should not act as the runtime model API seen by server, client, or query code -//! - turn execution should consume `Model`, not `ModelPreset` -//! - loading policy and catalog access live in `model_catalog.rs`; this file only defines the raw shape -//! -use devo_config::ModelOverrideConfig; -use devo_protocol::InputModality; -use devo_protocol::Model; -use devo_protocol::ProviderWireApi; -use devo_protocol::ReasoningCapability; -use devo_protocol::ReasoningEffort; -use devo_protocol::ReasoningImplementation; -use devo_protocol::TruncationPolicyConfig; -use serde::Deserialize; -use serde::Serialize; - -const DEFAULT_BASE_INSTRUCTIONS: &str = include_str!("../default_base_instructions.txt"); - -/// Returns the shared fallback base instructions used when a catalog preset -/// omits `base_instructions`, or when a model has no catalog entry. -pub fn default_base_instructions() -> &'static str { - DEFAULT_BASE_INSTRUCTIONS -} - -#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] -#[serde(default)] -/// Raw catalog preset loaded from the bundled model JSON. -pub struct ModelPreset { - /// Stable model identifier used in config and requests. such as `claude-sonnet-20250425` - pub slug: String, - /// Human-readable display name shown in the UI. such as `claude-sonnet-4.6` - pub display_name: String, - /// Provider selection that serves this model. - pub provider: ProviderWireApi, - /// Optional short description of the model. - #[serde(default, deserialize_with = "deserialize_optional_string")] - pub description: Option, - /// Reasoning control available for this model. - #[serde( - default = "default_reasoning_capability", - alias = "thinking_capability", - deserialize_with = "deserialize_reasoning_capability" - )] - pub reasoning_capability: ReasoningCapability, - /// Legacy list of supported reasoning levels used by some bundled presets. - #[serde(default, alias = "supported_reasoning_levels")] - pub supported_reasoning_levels: Vec, - /// Default reasoning effort selected for the model when no levels are exposed. - #[serde( - default = "default_reasoning_effort", - alias = "default_reasoning_level", - deserialize_with = "deserialize_reasoning_effort_option" - )] - pub default_reasoning_effort: Option, - /// How the selected reasoning effort should be applied to requests. - #[serde(default, alias = "thinking_implementation")] - pub reasoning_implementation: Option, - /// Base system instructions bundled with the model. - /// - /// Absent in JSON (`None`) falls back to [`default_base_instructions`] when - /// converting to [`Model`]. An explicit empty string keeps empty instructions. - pub base_instructions: Option, - /// Maximum context window in tokens. - #[serde(default = "default_context_window")] - pub context_window: u32, - /// Percentage of the context window treated as effectively usable. - pub effective_context_window_percent: Option, - /// Policy used when truncating content for requests. - #[serde( - default, - deserialize_with = "devo_protocol::deserialize_truncation_policy_config" - )] - pub truncation_policy: TruncationPolicyConfig, - /// Input types accepted by the model. - #[serde(default = "default_input_modalities")] - pub input_modalities: Vec, - /// Whether the model supports original-resolution image detail. - pub supports_image_detail_original: bool, - /// Grouping label used to organize models by vendor or family. - pub channel: Option, - /// Whether the user configured API access for this model. - #[serde(rename = "supported_in_api")] - pub api_configured: bool, - /// Default temperature to use when the model does not override it. - pub temperature: Option, - /// Default nucleus sampling value to use when the model does not override it. - pub top_p: Option, - /// Default top-k sampling value to use when the model does not override it. - pub top_k: Option, - /// Default maximum token limit for responses from this model. - pub max_tokens: Option, - /// Relative priority used when choosing a default visible model. - pub priority: i32, -} - -impl Default for ModelPreset { - fn default() -> Self { - Self { - slug: String::new(), - display_name: String::new(), - provider: ProviderWireApi::OpenAIChatCompletions, - description: None, - reasoning_capability: ReasoningCapability::Unsupported, - supported_reasoning_levels: Vec::new(), - default_reasoning_effort: Some(ReasoningEffort::default()), - reasoning_implementation: None, - base_instructions: None, - context_window: 200_000, - effective_context_window_percent: None, - truncation_policy: TruncationPolicyConfig::default(), - input_modalities: vec![InputModality::default()], - supports_image_detail_original: false, - channel: None, - api_configured: false, - temperature: None, - top_p: None, - top_k: None, - max_tokens: None, - priority: 0, - } - } -} - -impl ModelPreset { - /// Applies configured metadata overrides while preserving fields that were omitted. - pub fn apply_overrides(&mut self, overrides: &ModelOverrideConfig) { - if let Some(display_name) = &overrides.display_name { - self.display_name.clone_from(display_name); - } - if let Some(description) = &overrides.description { - self.description = Some(description.clone()); - } - if let Some(context_window) = overrides.context_window { - self.context_window = context_window; - } - if let Some(effective_context_window_percent) = overrides.effective_context_window_percent { - self.effective_context_window_percent = Some(effective_context_window_percent); - } - if let Some(max_tokens) = overrides.max_tokens { - self.max_tokens = Some(max_tokens); - } - if let Some(temperature) = overrides.temperature { - self.temperature = Some(temperature); - } - if let Some(top_p) = overrides.top_p { - self.top_p = Some(top_p); - } - if let Some(top_k) = overrides.top_k { - self.top_k = Some(top_k); - } - if let Some(provider) = overrides.provider { - self.provider = provider; - } - if let Some(reasoning_capability) = &overrides.reasoning_capability { - self.reasoning_capability = reasoning_capability.clone(); - self.supported_reasoning_levels.clear(); - } - if let Some(reasoning_implementation) = &overrides.reasoning_implementation { - self.reasoning_implementation = Some(reasoning_implementation.clone()); - } - if let Some(default_reasoning_effort) = overrides.default_reasoning_effort { - self.default_reasoning_effort = Some(default_reasoning_effort); - } - if let Some(base_instructions) = &overrides.base_instructions { - self.base_instructions = Some(base_instructions.clone()); - } - if let Some(input_modalities) = &overrides.input_modalities { - self.input_modalities.clone_from(input_modalities); - } - if let Some(channel) = &overrides.channel { - self.channel = Some(channel.clone()); - } - if let Some(truncation_policy) = overrides.truncation_policy { - self.truncation_policy = truncation_policy; - } - if let Some(supports_image_detail_original) = overrides.supports_image_detail_original { - self.supports_image_detail_original = supports_image_detail_original; - } - } - - /// Creates a safe custom preset and applies its configured metadata overrides. - pub fn from_overrides(slug: &str, overrides: &ModelOverrideConfig) -> Self { - let mut preset = Self { - slug: slug.to_string(), - display_name: slug.to_string(), - ..Self::default() - }; - preset.apply_overrides(overrides); - preset - } -} - -impl From for Model { - fn from(value: ModelPreset) -> Self { - let supported_reasoning_levels = value.supported_reasoning_levels; - let default_reasoning_effort = value.default_reasoning_effort; - - // Legacy presets express "toggle with selectable levels" as a plain - // toggle plus a non-empty level list. Move that list into the runtime - // shape once; catalog loading can convert many presets at startup. - let reasoning_capability = match value.reasoning_capability { - ReasoningCapability::Toggle if !supported_reasoning_levels.is_empty() => { - ReasoningCapability::ToggleWithLevels(supported_reasoning_levels) - } - capability => capability, - }; - let default_reasoning_effort = match &reasoning_capability { - ReasoningCapability::ToggleWithLevels(levels) => { - default_reasoning_effort.or_else(|| levels.first().copied()) - } - _ => default_reasoning_effort, - }; - - Self { - slug: value.slug, - display_name: value.display_name, - provider: value.provider, - description: value.description, - reasoning_capability, - default_reasoning_effort, - reasoning_implementation: value.reasoning_implementation, - base_instructions: value - .base_instructions - .unwrap_or_else(|| default_base_instructions().to_string()), - context_window: value.context_window, - effective_context_window_percent: value.effective_context_window_percent, - truncation_policy: value.truncation_policy, - input_modalities: value.input_modalities, - supports_image_detail_original: value.supports_image_detail_original, - channel: value.channel, - temperature: value.temperature, - top_p: value.top_p, - top_k: value.top_k, - max_tokens: value.max_tokens, - } - } -} - -fn default_reasoning_effort() -> Option { - Some(ReasoningEffort::default()) -} - -fn default_context_window() -> u32 { - 200_000 -} - -fn default_input_modalities() -> Vec { - vec![InputModality::Text, InputModality::Image] -} - -fn default_reasoning_capability() -> ReasoningCapability { - ReasoningCapability::Unsupported -} - -fn deserialize_optional_string<'de, D>(deserializer: D) -> Result, D::Error> -where - D: serde::Deserializer<'de>, -{ - let value = Option::::deserialize(deserializer)?; - Ok(value.and_then(|value| { - let trimmed = value.trim(); - if trimmed.is_empty() { - None - } else { - Some(value) - } - })) -} - -fn deserialize_reasoning_effort_option<'de, D>( - deserializer: D, -) -> Result, D::Error> -where - D: serde::Deserializer<'de>, -{ - let value = serde_json::Value::deserialize(deserializer)?; - match value { - serde_json::Value::Null => Ok(default_reasoning_effort()), - serde_json::Value::String(text) if text.trim().is_empty() => Ok(default_reasoning_effort()), - other => serde_json::from_value(other) - .map(Some) - .map_err(serde::de::Error::custom), - } -} - -fn deserialize_reasoning_capability<'de, D>( - deserializer: D, -) -> Result -where - D: serde::Deserializer<'de>, -{ - let value = serde_json::Value::deserialize(deserializer)?; - match value { - serde_json::Value::Null => Ok(default_reasoning_capability()), - serde_json::Value::String(text) if text.trim().is_empty() => { - Ok(default_reasoning_capability()) - } - other => serde_json::from_value(other).map_err(serde::de::Error::custom), - } -} - -#[cfg(test)] -mod tests { - use pretty_assertions::assert_eq; - - use super::*; - - #[test] - fn conversion_promotes_legacy_toggle_levels() { - let preset = ModelPreset { - slug: "legacy-toggle".to_string(), - display_name: "Legacy Toggle".to_string(), - reasoning_capability: ReasoningCapability::Toggle, - supported_reasoning_levels: vec![ReasoningEffort::High, ReasoningEffort::Max], - default_reasoning_effort: None, - base_instructions: Some(String::new()), - ..ModelPreset::default() - }; - - let model = Model::from(preset); - - assert_eq!( - model, - Model { - slug: "legacy-toggle".to_string(), - display_name: "Legacy Toggle".to_string(), - reasoning_capability: ReasoningCapability::ToggleWithLevels(vec![ - ReasoningEffort::High, - ReasoningEffort::Max, - ]), - default_reasoning_effort: Some(ReasoningEffort::High), - ..Model::default() - } - ); - } - - #[test] - fn model_preset_reads_legacy_reasoning_keys() { - let preset: ModelPreset = serde_json::from_value(serde_json::json!({ - "slug": "legacy", - "display_name": "Legacy", - "provider": "openai_chat_completions", - "thinking_capability": "toggle", - "thinking_implementation": "request_parameter", - "base_instructions": "", - "supported_in_api": true - })) - .expect("deserialize legacy preset"); - - assert_eq!(preset.reasoning_capability, ReasoningCapability::Toggle); - assert_eq!( - preset.reasoning_implementation, - Some(ReasoningImplementation::RequestParameter) - ); - assert_eq!(preset.base_instructions, Some(String::new())); - } - - #[test] - fn missing_base_instructions_fall_back_to_default() { - let preset: ModelPreset = serde_json::from_value(serde_json::json!({ - "slug": "missing-base", - "display_name": "Missing Base", - })) - .expect("deserialize preset without base_instructions"); - - assert_eq!(preset.base_instructions, None); - let model = Model::from(preset); - assert_eq!(model.base_instructions, default_base_instructions()); - } - - #[test] - fn explicit_empty_base_instructions_stay_empty() { - let preset: ModelPreset = serde_json::from_value(serde_json::json!({ - "slug": "empty-base", - "display_name": "Empty Base", - "base_instructions": "", - })) - .expect("deserialize preset with empty base_instructions"); - - assert_eq!(preset.base_instructions, Some(String::new())); - let model = Model::from(preset); - assert_eq!(model.base_instructions, ""); - } - - #[test] - fn non_empty_base_instructions_are_preserved() { - let preset: ModelPreset = serde_json::from_value(serde_json::json!({ - "slug": "custom-base", - "display_name": "Custom Base", - "base_instructions": "Custom instructions", - })) - .expect("deserialize preset with custom base_instructions"); - - assert_eq!( - preset.base_instructions.as_deref(), - Some("Custom instructions") - ); - let model = Model::from(preset); - assert_eq!(model.base_instructions, "Custom instructions"); - } -} diff --git a/crates/core/src/provider_request.rs b/crates/core/src/provider_request.rs new file mode 100644 index 00000000..442da46c --- /dev/null +++ b/crates/core/src/provider_request.rs @@ -0,0 +1,60 @@ +//! Runtime composition of arbitrary model request defaults. + +use std::collections::BTreeMap; + +use serde_json::Value; + +const REQUEST_HEADERS_KEY: &str = "__devo_request_headers"; + +/// Merges configured provider/model defaults with per-turn reasoning fields. +/// +/// Both values are JSON objects in normal use. Object members are merged +/// recursively and the turn-specific value wins on scalar conflicts. +pub fn merge_model_request_body( + defaults: Option<&Value>, + turn_extra: Option, +) -> Option { + let mut result = defaults.cloned(); + let Some(turn_extra) = turn_extra else { + return result; + }; + let Some(result) = result.as_mut() else { + return Some(turn_extra); + }; + merge_json_value(result, turn_extra); + Some(result.clone()) +} + +/// Adds resolved model/variant headers to the internal request envelope. +/// Provider adapters strip this envelope before serializing the JSON body. +pub fn add_model_request_headers( + body: Option, + headers: &BTreeMap, +) -> Option { + if headers.is_empty() { + return body; + } + let mut body = body.unwrap_or_else(|| Value::Object(Default::default())); + if let Value::Object(object) = &mut body { + object.insert( + REQUEST_HEADERS_KEY.to_string(), + serde_json::to_value(headers).expect("serialize model request headers"), + ); + } + Some(body) +} + +fn merge_json_value(base: &mut Value, overlay: Value) { + match overlay { + Value::Object(overlay) => { + if let Value::Object(base) = base { + for (key, value) in overlay { + merge_json_value(base.entry(key).or_insert(Value::Null), value); + } + } else { + *base = Value::Object(overlay); + } + } + overlay => *base = overlay, + } +} diff --git a/crates/core/src/query/event.rs b/crates/core/src/query/event.rs index 3f5371b3..9f13c2ab 100644 --- a/crates/core/src/query/event.rs +++ b/crates/core/src/query/event.rs @@ -33,6 +33,14 @@ pub enum QueryEvent { /// Human-readable reason the compaction did not complete. message: String, }, + /// Assembled request context estimate (before / between model legs). + /// + /// Emitted after each prompt build so UIs can refresh context occupancy + /// when tools finish and the next request is assembled — not only at + /// turn end. + ContextEstimate { + breakdown: crate::RawContextBreakdown, + }, /// Incremental text from the assistant. TextDelta(String), /// Incremental reasoning text from the assistant. diff --git a/crates/core/src/query/mod.rs b/crates/core/src/query/mod.rs index c88b266f..41d57a03 100644 --- a/crates/core/src/query/mod.rs +++ b/crates/core/src/query/mod.rs @@ -588,6 +588,16 @@ pub async fn query( let catalog_request_model = request_model.clone(); let provider_request_model = active_turn_config.provider_request_model(&catalog_request_model); + let extra_body = crate::merge_model_request_body( + active_turn_config + .provider_request_models + .request_defaults(), + extra_body, + ); + let extra_body = crate::add_model_request_headers( + extra_body, + active_turn_config.provider_request_models.request_headers(), + ); let prompt_source_message_count = session.prompt_source_messages().len(); let history_items = session @@ -651,6 +661,7 @@ pub async fn query( let breakdown = estimate_request_context_breakdown(&request); session.prompt_token_estimate = breakdown.total().try_into().unwrap_or(usize::MAX); session.raw_context_breakdown = Some(breakdown); + emit_query_event(&on_event, QueryEvent::ContextEstimate { breakdown }).await; debug!( prompt_source_messages = prompt_source_message_count, prompt_source_items = prompt_source_item_count, @@ -749,6 +760,7 @@ pub async fn query( &turn_config.model.slug, retry_count, backoff, + &retry_error.to_string(), ) .await?; session.turn_count -= 1; diff --git a/crates/core/src/query/provider_retry.rs b/crates/core/src/query/provider_retry.rs index 6b541c06..029e4247 100644 --- a/crates/core/src/query/provider_retry.rs +++ b/crates/core/src/query/provider_retry.rs @@ -267,8 +267,15 @@ pub(crate) async fn wait_for_provider_retry( model: &str, attempt: usize, backoff: Duration, + reason: &str, ) -> Result<(), AgentError> { let backoff_ms = backoff.as_millis().min(u128::from(u64::MAX)) as u64; + let reason = reason.trim(); + let reason = if reason.is_empty() { + "Provider request failed" + } else { + reason + }; emit_query_event( on_event, QueryEvent::ProviderRetryStatus(ProviderRetryStatus { @@ -278,7 +285,8 @@ pub(crate) async fn wait_for_provider_retry( max_attempts: MAX_RETRIES, backoff_ms, phase: QueryProviderRetryPhase::Scheduled, - message: format!("Retrying provider request in {:.1}s", backoff.as_secs_f64()), + // Failure cause for UI disclosure; countdown is carried by backoff_ms. + message: reason.to_string(), }), ) .await; @@ -302,7 +310,7 @@ pub(crate) async fn wait_for_provider_retry( max_attempts: MAX_RETRIES, backoff_ms: 0, phase: QueryProviderRetryPhase::Resumed, - message: "Retrying provider request now".to_string(), + message: reason.to_string(), }), ) .await; diff --git a/crates/core/src/query/stream_consumer.rs b/crates/core/src/query/stream_consumer.rs index d1822ca4..ea2bc650 100644 --- a/crates/core/src/query/stream_consumer.rs +++ b/crates/core/src/query/stream_consumer.rs @@ -552,17 +552,8 @@ async fn assemble_model_turn( } else { final_tool_inputs.get(&id).cloned().unwrap_or(initial_input) }; - if emitted_early_tool_use_starts.contains(&id) { - emit_query_event( - on_event, - QueryEvent::ToolUseStart { - id: id.clone(), - name: name.clone(), - input: input.clone(), - }, - ) - .await; - } else if emitted_tool_use_starts.insert(id.clone()) { + if emitted_early_tool_use_starts.contains(&id) || emitted_tool_use_starts.insert(id.clone()) + { emit_query_event( on_event, QueryEvent::ToolUseStart { diff --git a/crates/core/src/query/tests.rs b/crates/core/src/query/tests.rs index 83b3a44f..ddc84f37 100644 --- a/crates/core/src/query/tests.rs +++ b/crates/core/src/query/tests.rs @@ -1275,6 +1275,7 @@ fn recorded_compaction_events(events: &[QueryEvent]) -> Vec, /// Effective web search behavior for this turn. pub web_search: ResolvedWebSearchConfig, /// Effective web fetch behavior for this turn. @@ -108,11 +112,36 @@ pub struct TurnConfig { #[derive(Debug, Clone, Default)] pub struct ProviderRequestModelMap { by_model_slug: HashMap, + request_defaults: Option, + request_headers: BTreeMap, } impl ProviderRequestModelMap { pub fn new(by_model_slug: HashMap) -> Self { - Self { by_model_slug } + Self { + by_model_slug, + request_defaults: None, + request_headers: BTreeMap::new(), + } + } + + /// Attaches provider/model request defaults resolved from providers.json. + pub fn with_request_config( + mut self, + request_defaults: Option, + request_headers: BTreeMap, + ) -> Self { + self.request_defaults = request_defaults; + self.request_headers = request_headers; + self + } + + pub fn request_defaults(&self) -> Option<&Value> { + self.request_defaults.as_ref() + } + + pub fn request_headers(&self) -> &BTreeMap { + &self.request_headers } pub fn get(&self, model_slug: &str) -> Option<&str> { @@ -131,22 +160,16 @@ impl TurnConfig { TokenBudget::for_model(&self.model) } - /// Builds the turn token budget, applying a session effective-context - /// override when present so hot updates survive turn start reassignment. + /// Builds the turn token budget from the model effective window. /// - /// Resolved value is `min(override, model.context_window)` and is written to - /// both `TokenBudget.context_window` and `auto_compact_token_limit`. + /// Session effective-context overrides are ignored (product: one Context + /// window stored as a ratio on the model). The parameter is retained so + /// call sites can keep passing the config field without churn. pub fn token_budget_for_session( &self, - effective_context_window_override: Option, + _effective_context_window_override: Option, ) -> TokenBudget { - let mut budget = self.token_budget(); - if let Some(limit) = effective_context_window_override { - let resolved = limit.min(self.model.context_window as usize).max(1); - budget.context_window = resolved; - budget.auto_compact_token_limit = Some(resolved); - } - budget + self.token_budget() } pub fn new(model: Model, reasoning_effort_selection: Option) -> Self { @@ -159,6 +182,7 @@ impl TurnConfig { model_binding_id: None, provider_request_models: ProviderRequestModelMap::default(), provider_route: ProviderRoute::Default, + variant: None, web_search: ResolvedWebSearchConfig::Disabled, web_fetch: ResolvedWebFetchConfig::Local, reasoning_effort_selection, @@ -233,6 +257,7 @@ impl TurnConfig { model_binding_id: None, provider_request_models, provider_route, + variant: None, web_search, web_fetch, reasoning_effort_selection, @@ -547,10 +572,12 @@ mod tests { let model = Model { slug: "deepseek-v4-flash".to_string(), display_name: "deepseek-v4-flash".to_string(), - reasoning_capability: ReasoningCapability::ToggleWithLevels(vec![ - ReasoningEffort::High, - ReasoningEffort::Max, - ]), + reasoning_capability: ReasoningCapability::Levels( + devo_protocol::levels_with_leading_off([ + ReasoningEffort::High, + ReasoningEffort::Max, + ]), + ), default_reasoning_effort: Some(ReasoningEffort::High), ..Model::default() }; @@ -610,7 +637,7 @@ mod tests { slug: "deepseek-v4-pro".to_string(), display_name: "deepseek-v4-pro".to_string(), context_window: 1_000_000, - effective_context_window_percent: Some(95), + effective_context_window_percent: Some(95.0), max_tokens: Some(384_000), ..Model::default() }; diff --git a/crates/core/src/small_model.rs b/crates/core/src/small_model.rs new file mode 100644 index 00000000..ad76b666 --- /dev/null +++ b/crates/core/src/small_model.rs @@ -0,0 +1,193 @@ +//! Selection of a lightweight model for background tasks. +//! +//! The policy mirrors the useful part of OpenCode's model split: an explicit +//! `small_model` wins, while the automatic path only considers models exposed +//! by the same provider as the primary model. The catalog metadata and model +//! id are both considered so custom provider directories work without a new +//! protocol field. + +use crate::{ModelCatalog, ProviderModelInfo}; + +const SMALL_MODEL_SCORE_THRESHOLD: i32 = 30; + +/// Finds a suitable lightweight model for `primary_model` in the same provider. +/// +/// This is an automatic fallback used by background tasks such as session-title +/// generation. It returns a canonical `provider/model` reference and returns +/// `None` when the provider has no model that is recognizably lightweight. +pub fn resolve_small_model(catalog: &dyn ModelCatalog, primary_model: &str) -> Option { + let (provider_id, requested_model_id) = primary_model.split_once('/')?; + let provider_models = catalog.list_provider_models(provider_id); + let primary_model_id = base_model_id(&provider_models, requested_model_id); + let mut best: Option<(String, i32, i32)> = None; + + for (model_id, model) in provider_models { + if model_id == primary_model_id || model_is_unavailable(&model) { + continue; + } + let score = small_model_score(&model_id, &model); + if score < SMALL_MODEL_SCORE_THRESHOLD { + continue; + } + + let priority = model.priority.unwrap_or_default(); + let should_replace = best.as_ref().is_none_or(|(_, best_score, best_priority)| { + score > *best_score || (score == *best_score && priority > *best_priority) + }); + if should_replace { + best = Some((format!("{provider_id}/{model_id}"), score, priority)); + } + } + + best.map(|(model, _, _)| model) +} + +fn base_model_id<'a>( + provider_models: &std::collections::BTreeMap, + requested_model_id: &'a str, +) -> &'a str { + if provider_models.contains_key(requested_model_id) { + return requested_model_id; + } + + requested_model_id + .rsplit_once('/') + .filter(|(model_id, variant_id)| { + provider_models + .get(*model_id) + .is_some_and(|model| model.variants.contains_key(*variant_id)) + }) + .map(|(model_id, _)| model_id) + .unwrap_or(requested_model_id) +} + +fn model_is_unavailable(model: &ProviderModelInfo) -> bool { + model.enabled == Some(false) + || model.status.as_deref().is_some_and(|status| { + matches!( + status.trim().to_ascii_lowercase().as_str(), + "disabled" | "deprecated" | "unavailable" | "offline" + ) + }) +} + +fn small_model_score(model_id: &str, model: &ProviderModelInfo) -> i32 { + let model_id = model_id.to_ascii_lowercase(); + let family = model + .family + .as_deref() + .unwrap_or_default() + .to_ascii_lowercase(); + let mut score = 0; + + if family.contains("flash") + || family.contains("nano") + || family.contains("haiku") + || family.contains("mini") + { + score += 100; + } + if model_id.contains("flash") + || model_id.contains("nano") + || model_id.contains("haiku") + || model_id.contains("small") + || model_id.contains("lite") + || model_id.contains("fast") + || model_id.contains("instant") + { + score += 90; + } + if model_id.contains("mini") && !model_id.contains("minimax") { + score += 70; + } + if model_id.contains("a3b") { + score += 60; + } + if ["3b", "4b", "7b", "8b", "1b", "2b"] + .iter() + .any(|size| model_id.contains(size)) + { + score += 35; + } + if model_id.contains("pro") + || model_id.contains("max") + || model_id.contains("ultra") + || model_id.contains("opus") + { + score -= 40; + } + + score +} + +#[cfg(test)] +mod tests { + use pretty_assertions::assert_eq; + + use super::resolve_small_model; + use crate::{InMemoryModelCatalog, Model, ProviderModelVariantConfig, ProviderWireApi}; + + fn model(slug: &str) -> Model { + Model { + slug: slug.to_string(), + display_name: slug.to_string(), + provider: ProviderWireApi::OpenAIChatCompletions, + ..Model::default() + } + } + + #[test] + fn selects_a_lightweight_model_from_the_primary_provider() { + let catalog = InMemoryModelCatalog::new(vec![ + model("qwen/qwen3.8-max"), + model("qwen/qwen3.8-flash"), + model("qwen/qwen3:4b"), + ]); + + assert_eq!( + resolve_small_model(&catalog, "qwen/qwen3.8-max"), + Some("qwen/qwen3.8-flash".to_string()) + ); + } + + #[test] + fn does_not_cross_provider_boundaries_or_select_the_primary_model() { + let catalog = InMemoryModelCatalog::new(vec![ + model("openai/gpt-5.5"), + model("openai/gpt-5.5-mini"), + model("ollama/qwen3:4b"), + ]); + + assert_eq!(resolve_small_model(&catalog, "openai/gpt-5.5-mini"), None); + } + + #[test] + fn recognizes_variants_when_excluding_the_primary_model() { + let catalog = + crate::PresetModelCatalog::load_from_provider_config(&crate::ProviderConfigFile { + providers: std::collections::BTreeMap::from([( + "deepseek".to_string(), + crate::ProviderConfigEntry { + models: std::collections::BTreeMap::from([( + "deepseek-v4-flash".to_string(), + crate::ProviderModelConfig { + variants: std::collections::BTreeMap::from([( + "reasoning".to_string(), + ProviderModelVariantConfig::default(), + )]), + ..crate::ProviderModelConfig::default() + }, + )]), + ..crate::ProviderConfigEntry::default() + }, + )]), + ..crate::ProviderConfigFile::default() + }) + .expect("load provider catalog"); + + assert_eq!( + resolve_small_model(&catalog, "deepseek/deepseek-v4-flash/reasoning"), + Some("deepseek/deepseek-v4-flash-vision-exp".to_string()) + ); + } +} diff --git a/crates/core/src/tools/registry_plan.rs b/crates/core/src/tools/registry_plan.rs index d8a97b71..2664105a 100644 --- a/crates/core/src/tools/registry_plan.rs +++ b/crates/core/src/tools/registry_plan.rs @@ -463,34 +463,40 @@ fn webfetch_schema() -> JsonSchema { } fn app_config_uses_local_web_search(config: &AppConfig) -> bool { + let provider_catalog = config.provider_catalog_config(); config.tools.web_search.mode == devo_config::WebSearchMode::Local - || config.provider.providers.values().any(|provider| { + || provider_catalog.providers.values().any(|provider| { provider .web_search .as_ref() .is_some_and(|web_search| web_search.mode == devo_config::WebSearchMode::Local) }) - || config.provider.model_bindings.values().any(|binding| { - binding - .web_search - .as_ref() - .is_some_and(|web_search| web_search.mode == devo_config::WebSearchMode::Local) + || provider_catalog.providers.values().any(|provider| { + provider.models.values().any(|model| { + model + .web_search + .as_ref() + .is_some_and(|web_search| web_search.mode == devo_config::WebSearchMode::Local) + }) }) } fn app_config_uses_local_web_fetch(config: &AppConfig) -> bool { + let provider_catalog = config.provider_catalog_config(); config.tools.web_fetch.mode == devo_config::WebFetchMode::Local - || config.provider.providers.values().any(|provider| { + || provider_catalog.providers.values().any(|provider| { provider .web_fetch .as_ref() .is_some_and(|web_fetch| web_fetch.mode == devo_config::WebFetchMode::Local) }) - || config.provider.model_bindings.values().any(|binding| { - binding - .web_fetch - .as_ref() - .is_some_and(|web_fetch| web_fetch.mode == devo_config::WebFetchMode::Local) + || provider_catalog.providers.values().any(|provider| { + provider.models.values().any(|model| { + model + .web_fetch + .as_ref() + .is_some_and(|web_fetch| web_fetch.mode == devo_config::WebFetchMode::Local) + }) }) } diff --git a/crates/core/tests/context_limit_compaction.rs b/crates/core/tests/context_limit_compaction.rs index c9b4c1a6..a5029d80 100644 --- a/crates/core/tests/context_limit_compaction.rs +++ b/crates/core/tests/context_limit_compaction.rs @@ -131,6 +131,7 @@ async fn context_limit_error_compacts_and_retries_query() { | QueryEvent::TextDelta(_) | QueryEvent::ReasoningDelta(_) | QueryEvent::ReasoningCompleted + | QueryEvent::ContextEstimate { .. } | QueryEvent::UsageDelta { .. } | QueryEvent::ToolUseStart { .. } | QueryEvent::ToolUseInputDelta { .. } diff --git a/crates/core/tests/fixtures/rollout_v1/internal_lines.jsonl b/crates/core/tests/fixtures/rollout_v1/internal_lines.jsonl index e1548df5..0d65ce19 100644 --- a/crates/core/tests/fixtures/rollout_v1/internal_lines.jsonl +++ b/crates/core/tests/fixtures/rollout_v1/internal_lines.jsonl @@ -1,5 +1,5 @@ -{"SessionMeta":{"timestamp":"2026-07-01T12:00:00Z","session":{"id":"00000000-0000-0000-0000-0000000000c1","rollout_path":"rollout.jsonl","created_at":"2026-07-01T12:00:00Z","updated_at":"2026-07-01T12:00:01Z","source":"cli","agent_nickname":"scout","agent_role":"explorer","agent_path":null,"model_provider":"openai","model":null,"model_binding_id":"binding-1","reasoning_effort_selection":"high","cwd":"/tmp/legacy-project","additional_directories":["/tmp/legacy-extra"],"cli_version":"0.1.37","title":"Legacy Session","title_state":{"Final":"ModelGenerated"},"sandbox_policy":"workspace-write","approval_mode":"full-access","tokens_used":12345,"first_user_message":null,"archived_at":null,"git_sha":null,"git_branch":null,"git_origin_url":null,"parent_session_id":"00000000-0000-0000-0000-0000000000c0","session_context":{"base_instructions":"base","available_skills":null,"workspace_instructions":null,"locked_agents_snapshot":null,"environment":{"cwd":".","shell":"bash","current_date":"2026-07-01","timezone":"UTC"},"language":{"language_preference":"Reply in the same natural language as the user's latest message. If the latest user message mixes languages, use the primary language of that message. Preserve technical terms, code identifiers, file paths, commands, API names, and quoted text in their original form unless the user explicitly asks to translate them. This language rule also applies to Proposed Plan and Goal: any content inside and must follow the same natural language as the user's latest message."},"persona":"default","model":{"slug":"gpt-5.2","display_name":"","provider":"openai_chat_completions","description":null,"reasoning_capability":"unsupported","default_reasoning_effort":"medium","reasoning_implementation":null,"base_instructions":"","context_window":200000,"effective_context_window_percent":null,"truncation_policy":{"mode":"bytes","limit":8000},"input_modalities":["text"],"supports_image_detail_original":false,"channel":null,"temperature":null,"top_p":null,"top_k":null,"max_tokens":null},"reasoning_effort_selection":null,"reasoning_effort":null,"system_prompt_mode":"coding_agent"},"schema_version":2}}} -{"Turn":{"timestamp":"2026-07-01T12:00:02Z","turn":{"id":"00000000-0000-0000-0000-0000000000c2","session_id":"00000000-0000-0000-0000-0000000000c1","sequence":1,"started_at":"2026-07-01T12:00:02Z","completed_at":null,"status":"Failed","kind":"manual_compaction","model":"gpt-5.2","model_binding_id":"binding-1","reasoning_effort_selection":"high","request_model":"gpt-5.2-codex","request_thinking":"enabled","input_token_estimate":42,"usage":null,"latest_query_usage":{"input_tokens":10,"output_tokens":5,"cache_creation_input_tokens":null,"cache_read_input_tokens":null,"total_tokens":15},"error":{"code":"PROVIDER_SERVER_ERROR","message":"provider request failed","recovery_hint":"retry later"},"session_context":{"base_instructions":"base","available_skills":null,"workspace_instructions":null,"locked_agents_snapshot":null,"environment":{"cwd":".","shell":"bash","current_date":"2026-07-01","timezone":"UTC"},"language":{"language_preference":"Reply in the same natural language as the user's latest message. If the latest user message mixes languages, use the primary language of that message. Preserve technical terms, code identifiers, file paths, commands, API names, and quoted text in their original form unless the user explicitly asks to translate them. This language rule also applies to Proposed Plan and Goal: any content inside and must follow the same natural language as the user's latest message."},"persona":"default","model":{"slug":"gpt-5.2","display_name":"","provider":"openai_chat_completions","description":null,"reasoning_capability":"unsupported","default_reasoning_effort":"medium","reasoning_implementation":null,"base_instructions":"","context_window":200000,"effective_context_window_percent":null,"truncation_policy":{"mode":"bytes","limit":8000},"input_modalities":["text"],"supports_image_detail_original":false,"channel":null,"temperature":null,"top_p":null,"top_k":null,"max_tokens":null},"reasoning_effort_selection":null,"reasoning_effort":null,"system_prompt_mode":"coding_agent"},"turn_context":{"environment":{"cwd":"/tmp/legacy-project","shell":"bash","current_date":"2026-07-01","timezone":"UTC"},"persona":"default","model":{"slug":"gpt-5.2","display_name":"","provider":"openai_chat_completions","description":null,"reasoning_capability":"unsupported","default_reasoning_effort":"medium","reasoning_implementation":null,"base_instructions":"","context_window":200000,"effective_context_window_percent":null,"truncation_policy":{"mode":"bytes","limit":8000},"input_modalities":["text"],"supports_image_detail_original":false,"channel":null,"temperature":null,"top_p":null,"top_k":null,"max_tokens":null},"reasoning_effort_selection":null,"reasoning_effort":null,"observed_agents_snapshot":null,"collaboration_mode":"build"},"schema_version":4}}} +{"SessionMeta":{"timestamp":"2026-07-01T12:00:00Z","session":{"id":"00000000-0000-0000-0000-0000000000c1","rollout_path":"rollout.jsonl","created_at":"2026-07-01T12:00:00Z","updated_at":"2026-07-01T12:00:01Z","source":"cli","agent_nickname":"scout","agent_role":"explorer","agent_path":null,"model_provider":"openai","model":null,"model_binding_id":"binding-1","reasoning_effort_selection":"high","cwd":"/tmp/legacy-project","additional_directories":["/tmp/legacy-extra"],"cli_version":"0.1.37","title":"Legacy Session","title_state":{"Final":"ModelGenerated"},"sandbox_policy":"workspace-write","approval_mode":"full-access","tokens_used":12345,"first_user_message":null,"archived_at":null,"git_sha":null,"git_branch":null,"git_origin_url":null,"parent_session_id":"00000000-0000-0000-0000-0000000000c0","session_context":{"base_instructions":"base","available_skills":null,"workspace_instructions":null,"locked_agents_snapshot":null,"environment":{"cwd":".","shell":"bash","current_date":"2026-07-01","timezone":"UTC"},"language":{"language_preference":"Reply in the same natural language as the user's latest message. If the latest user message mixes languages, use the primary language of that message. Preserve technical terms, code identifiers, file paths, commands, API names, and quoted text in their original form unless the user explicitly asks to translate them. This language rule also applies to Proposed Plan and Goal: any content inside and must follow the same natural language as the user's latest message."},"persona":"default","model":{"slug":"gpt-5.2","display_name":"","provider":"openai_chat_completions","description":null,"reasoning_capability":"unsupported","default_reasoning_effort":"medium","default_reasoning_selection":null,"reasoning_implementation":null,"base_instructions":"","context_window":200000,"effective_context_window_percent":null,"truncation_policy":{"mode":"bytes","limit":8000},"input_modalities":["text"],"supports_image_detail_original":false,"channel":null,"temperature":null,"top_p":null,"top_k":null,"max_tokens":null},"reasoning_effort_selection":null,"reasoning_effort":null,"system_prompt_mode":"coding_agent"},"schema_version":2}}} +{"Turn":{"timestamp":"2026-07-01T12:00:02Z","turn":{"id":"00000000-0000-0000-0000-0000000000c2","session_id":"00000000-0000-0000-0000-0000000000c1","sequence":1,"started_at":"2026-07-01T12:00:02Z","completed_at":null,"status":"Failed","kind":"manual_compaction","model":"gpt-5.2","model_binding_id":"binding-1","reasoning_effort_selection":"high","request_model":"gpt-5.2-codex","request_thinking":"enabled","input_token_estimate":42,"usage":null,"latest_query_usage":{"input_tokens":10,"output_tokens":5,"cache_creation_input_tokens":null,"cache_read_input_tokens":null,"total_tokens":15},"error":{"code":"PROVIDER_SERVER_ERROR","message":"provider request failed","recovery_hint":"retry later"},"session_context":{"base_instructions":"base","available_skills":null,"workspace_instructions":null,"locked_agents_snapshot":null,"environment":{"cwd":".","shell":"bash","current_date":"2026-07-01","timezone":"UTC"},"language":{"language_preference":"Reply in the same natural language as the user's latest message. If the latest user message mixes languages, use the primary language of that message. Preserve technical terms, code identifiers, file paths, commands, API names, and quoted text in their original form unless the user explicitly asks to translate them. This language rule also applies to Proposed Plan and Goal: any content inside and must follow the same natural language as the user's latest message."},"persona":"default","model":{"slug":"gpt-5.2","display_name":"","provider":"openai_chat_completions","description":null,"reasoning_capability":"unsupported","default_reasoning_effort":"medium","default_reasoning_selection":null,"reasoning_implementation":null,"base_instructions":"","context_window":200000,"effective_context_window_percent":null,"truncation_policy":{"mode":"bytes","limit":8000},"input_modalities":["text"],"supports_image_detail_original":false,"channel":null,"temperature":null,"top_p":null,"top_k":null,"max_tokens":null},"reasoning_effort_selection":null,"reasoning_effort":null,"system_prompt_mode":"coding_agent"},"turn_context":{"environment":{"cwd":"/tmp/legacy-project","shell":"bash","current_date":"2026-07-01","timezone":"UTC"},"persona":"default","model":{"slug":"gpt-5.2","display_name":"","provider":"openai_chat_completions","description":null,"reasoning_capability":"unsupported","default_reasoning_effort":"medium","default_reasoning_selection":null,"reasoning_implementation":null,"base_instructions":"","context_window":200000,"effective_context_window_percent":null,"truncation_policy":{"mode":"bytes","limit":8000},"input_modalities":["text"],"supports_image_detail_original":false,"channel":null,"temperature":null,"top_p":null,"top_k":null,"max_tokens":null},"reasoning_effort_selection":null,"reasoning_effort":null,"observed_agents_snapshot":null,"collaboration_mode":"build"},"schema_version":4}}} {"Item":{"timestamp":"2026-07-01T12:00:11Z","item":{"id":"00000000-0000-0000-0000-0000000000c3","session_id":"00000000-0000-0000-0000-0000000000c1","turn_id":"00000000-0000-0000-0000-0000000000c2","seq":1,"timestamp":"2026-07-01T12:00:11Z","attempt_placement":null,"turn_status":"Running","sibling_turn_ids":[],"input_items":[{"HookPrompt":{"text":"hook text"}}],"output_items":[{"ToolProgress":{"tool_call_id":"call-9","message":"working"}},{"TurnSummary":{"text":"3"}}],"worklog":null,"error":null,"schema_version":1}}} {"MessageEditRecorded":{"timestamp":"2026-07-01T12:00:40Z","record":{"schema_version":1,"session_id":"00000000-0000-0000-0000-0000000000c1","edit_id":"00000000-0000-0000-0000-0000000000c4","target_message_id":"00000000-0000-0000-0000-0000000000c5","replacement_message_id":"00000000-0000-0000-0000-0000000000c6","target_turn_id":"00000000-0000-0000-0000-0000000000c2","replacement_turn_id":null,"queue_item_id":null,"edited_content_parts":[{"content_type":"text","value":"edited"}],"edited_mentions":[],"workspace_restore_policy":"skip","edit_state":"accepted","requested_by_client_id":null,"created_at":"2026-07-01T12:00:40Z"}}} -{"SessionContextUpdated":{"timestamp":"2026-07-01T12:00:41Z","session_id":"00000000-0000-0000-0000-0000000000c1","session_context":{"base_instructions":"base","available_skills":null,"workspace_instructions":null,"locked_agents_snapshot":null,"environment":{"cwd":".","shell":"bash","current_date":"2026-07-01","timezone":"UTC"},"language":{"language_preference":"Reply in the same natural language as the user's latest message. If the latest user message mixes languages, use the primary language of that message. Preserve technical terms, code identifiers, file paths, commands, API names, and quoted text in their original form unless the user explicitly asks to translate them. This language rule also applies to Proposed Plan and Goal: any content inside and must follow the same natural language as the user's latest message."},"persona":"default","model":{"slug":"gpt-5.2","display_name":"","provider":"openai_chat_completions","description":null,"reasoning_capability":"unsupported","default_reasoning_effort":"medium","reasoning_implementation":null,"base_instructions":"","context_window":200000,"effective_context_window_percent":null,"truncation_policy":{"mode":"bytes","limit":8000},"input_modalities":["text"],"supports_image_detail_original":false,"channel":null,"temperature":null,"top_p":null,"top_k":null,"max_tokens":null},"reasoning_effort_selection":null,"reasoning_effort":null,"system_prompt_mode":"coding_agent"},"schema_version":1}} +{"SessionContextUpdated":{"timestamp":"2026-07-01T12:00:41Z","session_id":"00000000-0000-0000-0000-0000000000c1","session_context":{"base_instructions":"base","available_skills":null,"workspace_instructions":null,"locked_agents_snapshot":null,"environment":{"cwd":".","shell":"bash","current_date":"2026-07-01","timezone":"UTC"},"language":{"language_preference":"Reply in the same natural language as the user's latest message. If the latest user message mixes languages, use the primary language of that message. Preserve technical terms, code identifiers, file paths, commands, API names, and quoted text in their original form unless the user explicitly asks to translate them. This language rule also applies to Proposed Plan and Goal: any content inside and must follow the same natural language as the user's latest message."},"persona":"default","model":{"slug":"gpt-5.2","display_name":"","provider":"openai_chat_completions","description":null,"reasoning_capability":"unsupported","default_reasoning_effort":"medium","default_reasoning_selection":null,"reasoning_implementation":null,"base_instructions":"","context_window":200000,"effective_context_window_percent":null,"truncation_policy":{"mode":"bytes","limit":8000},"input_modalities":["text"],"supports_image_detail_original":false,"channel":null,"temperature":null,"top_p":null,"top_k":null,"max_tokens":null},"reasoning_effort_selection":null,"reasoning_effort":null,"system_prompt_mode":"coding_agent"},"schema_version":1}} diff --git a/crates/core/tests/legacy_projector.rs b/crates/core/tests/legacy_projector.rs index 90c72618..e715d402 100644 --- a/crates/core/tests/legacy_projector.rs +++ b/crates/core/tests/legacy_projector.rs @@ -153,10 +153,10 @@ fn item_record(n: u128, session: u128, turn: u128, seq: u64) -> ItemRecord { } fn item_line(record: ItemRecord) -> RolloutLine { - RolloutLine::Item(ItemLine { + RolloutLine::Item(Box::new(ItemLine { timestamp: record.timestamp, item: record, - }) + })) } fn sample_session_context() -> SessionContext { diff --git a/crates/core/tests/v2_roundtrip.rs b/crates/core/tests/v2_roundtrip.rs index 2153e2eb..a640b362 100644 --- a/crates/core/tests/v2_roundtrip.rs +++ b/crates/core/tests/v2_roundtrip.rs @@ -200,7 +200,7 @@ impl Normalizer { } fn item_line(id: ItemId, original: &ItemRecord, seq: u64, payload: TurnItem) -> RolloutLine { - RolloutLine::Item(ItemLine { + RolloutLine::Item(Box::new(ItemLine { timestamp: original.timestamp, item: ItemRecord { id, @@ -218,7 +218,7 @@ fn item_line(id: ItemId, original: &ItemRecord, seq: u64, payload: TurnItem) -> error: None, schema_version: 1, }, - }) + })) } fn normalize_payload(payload: &TurnItem) -> TurnItem { @@ -392,7 +392,7 @@ fn live_write_lines() -> Vec { let session_id = devo_core::SessionId::new(); let turn_id = devo_core::TurnId::new(); let item = |seq: u64, payload: TurnItem| { - RolloutLine::Item(ItemLine { + RolloutLine::Item(Box::new(ItemLine { timestamp: ts(10 + seq as u32), item: ItemRecord { id: ItemId::new(), @@ -410,7 +410,7 @@ fn live_write_lines() -> Vec { error: None, schema_version: 1, }, - }) + })) }; vec![ RolloutLine::SessionMeta(Box::new(devo_core::SessionMetaLine { @@ -587,6 +587,7 @@ fn inverse_rejects_prefixed_canonical_ids() { model: devo_protocol::native::model::ModelBinding { provider: "openai".into(), model: "gpt-5.2".into(), + variant: None, reasoning_effort: None, }, settings: devo_protocol::native::session::SessionSettings { From ee086954ca99db2d5605f67290a099ae50023658 Mon Sep 17 00:00:00 2001 From: wangtsiao Date: Sat, 5 Sep 2026 02:34:32 +0800 Subject: [PATCH 04/13] feat(provider): catalog-aware routing and wire API updates Update OpenAI/Anthropic adapters and router for provider/model selection; include related safety/mcp clippy cleanups. --- crates/mcp/src/manager.rs | 5 +-- crates/provider/src/anthropic/messages.rs | 37 +++++++++++---- crates/provider/src/http.rs | 26 +++++++++++ crates/provider/src/lib.rs | 2 +- crates/provider/src/openai/capabilities.rs | 45 ++++++++++++++++++- .../provider/src/openai/chat_completions.rs | 30 ++++++++++--- .../src/openai/chat_completions/stream.rs | 7 ++- crates/provider/src/openai/responses.rs | 37 +++++++++++---- crates/provider/src/request.rs | 21 +++++++++ crates/provider/src/router.rs | 18 ++++---- crates/safety/src/permission/shell_access.rs | 13 +++--- crates/windows-sandbox/src/lib.rs | 2 +- 12 files changed, 193 insertions(+), 50 deletions(-) diff --git a/crates/mcp/src/manager.rs b/crates/mcp/src/manager.rs index fdcb396e..d9a0d7fd 100644 --- a/crates/mcp/src/manager.rs +++ b/crates/mcp/src/manager.rs @@ -540,10 +540,7 @@ fn non_empty_map(map: &BTreeMap) -> Option) -> Option { - match auth { - Some(McpAuthConfig::BearerToken { token }) => Some(token.clone()), - None => None, - } + auth.map(|McpAuthConfig::BearerToken { token }| token.clone()) } #[cfg(test)] diff --git a/crates/provider/src/anthropic/messages.rs b/crates/provider/src/anthropic/messages.rs index 8dcedd2a..3edd2afb 100644 --- a/crates/provider/src/anthropic/messages.rs +++ b/crates/provider/src/anthropic/messages.rs @@ -90,11 +90,19 @@ impl AnthropicProvider { format!("{}/v1/messages", self.base_url.trim_end_matches('/')) } - fn post_builder(&self, client: &Client, body: &Value) -> reqwest::RequestBuilder { + fn post_builder( + &self, + client: &Client, + body: &Value, + headers: &BTreeMap, + ) -> reqwest::RequestBuilder { let builder = client .post(self.endpoint()) .header("anthropic-version", "2023-06-01") .header(CONTENT_TYPE, HeaderValue::from_static("application/json")); + let builder = self + .http_options + .apply_request_headers(self.http_options.apply_custom_headers(builder), headers); let builder = if let Some(api_key) = &self.api_key { builder @@ -103,15 +111,23 @@ impl AnthropicProvider { } else { builder }; - self.http_options.apply_custom_headers(builder).json(body) + builder.json(body) } - fn request_builder(&self, body: &Value) -> reqwest::RequestBuilder { - self.post_builder(&self.client, body) + fn request_builder( + &self, + body: &Value, + headers: &BTreeMap, + ) -> reqwest::RequestBuilder { + self.post_builder(&self.client, body, headers) } - fn streaming_request_builder(&self, body: &Value) -> reqwest::RequestBuilder { - self.post_builder(&self.streaming_client, body) + fn streaming_request_builder( + &self, + body: &Value, + headers: &BTreeMap, + ) -> reqwest::RequestBuilder { + self.post_builder(&self.streaming_client, body, headers) .header(CACHE_CONTROL, HeaderValue::from_static("no-cache")) .header(ACCEPT_ENCODING, HeaderValue::from_static("identity")) } @@ -328,7 +344,7 @@ impl ModelProviderSDK for AnthropicProvider { ); let response = self - .request_builder(&body) + .request_builder(&body, &crate::request_headers(request.extra_body.as_ref())) .send() .await .context("failed to send anthropic request")?; @@ -371,8 +387,11 @@ impl ModelProviderSDK for AnthropicProvider { ); let dsml_healer = DsmlToolCallHealer::for_request(&request); - let event_source = EventSource::new(self.streaming_request_builder(&body)) - .context("failed to create anthropic event source")?; + let event_source = EventSource::new(self.streaming_request_builder( + &body, + &crate::request_headers(request.extra_body.as_ref()), + )) + .context("failed to create anthropic event source")?; let stream = async_stream::try_stream! { let mut message_id = String::new(); let mut stream_usage = AnthropicStreamUsage::default(); diff --git a/crates/provider/src/http.rs b/crates/provider/src/http.rs index 5fe2512f..161f1c31 100644 --- a/crates/provider/src/http.rs +++ b/crates/provider/src/http.rs @@ -9,6 +9,7 @@ use reqwest::header::HeaderMap; use reqwest::header::HeaderName; use reqwest::header::HeaderValue; use serde_json::Value; +use std::collections::BTreeMap; use std::sync::Mutex; use std::sync::OnceLock; use tracing::warn; @@ -128,6 +129,31 @@ impl ProviderHttpOptions { builder.headers(self.custom_headers.clone()) } } + + /// Applies model/variant headers after provider defaults. + pub(crate) fn apply_request_headers( + &self, + builder: RequestBuilder, + headers: &BTreeMap, + ) -> RequestBuilder { + let mut request_headers = HeaderMap::new(); + for (name, value) in headers { + let Ok(name) = HeaderName::try_from(name) else { + warn!(header = %name, "ignoring invalid model request header name"); + continue; + }; + let Ok(value) = HeaderValue::try_from(value) else { + warn!(header = %name, "ignoring invalid model request header value"); + continue; + }; + request_headers.insert(name, value); + } + if request_headers.is_empty() { + builder + } else { + builder.headers(request_headers) + } + } } pub(crate) async fn invalid_status_error( diff --git a/crates/provider/src/lib.rs b/crates/provider/src/lib.rs index dc4dba71..542dee3b 100644 --- a/crates/provider/src/lib.rs +++ b/crates/provider/src/lib.rs @@ -23,5 +23,5 @@ pub use recovery_hint::{ AUTH_HINT, MODEL_NOT_FOUND_HINT, NETWORK_PROXY_HINT, recovery_hint_for_anyhow, recovery_hint_for_message, }; -pub(crate) use request::merge_extra_body; +pub(crate) use request::{merge_extra_body, request_headers}; pub use router::*; diff --git a/crates/provider/src/openai/capabilities.rs b/crates/provider/src/openai/capabilities.rs index ac3f7afb..a268a2d1 100644 --- a/crates/provider/src/openai/capabilities.rs +++ b/crates/provider/src/openai/capabilities.rs @@ -59,6 +59,7 @@ enum ModelMatcher { impl ModelMatcher { fn matches(self, model: &str) -> bool { + let model = model.rsplit_once('/').map_or(model, |(_, model)| model); match self { // Capability resolution uses the catalog slug, not the provider's // configurable wire model name. Avoid lowercasing the whole slug @@ -147,6 +148,18 @@ const OPENAI_PROFILE_RULES: &[ProfileRule] = &[ true, ), }, + ProfileRule { + matcher: ModelMatcher::Prefix("kimi-"), + transport: OpenAITransport::ChatCompletions, + profile: OpenAIRequestProfile::new( + OpenAIReasoningMode::Thinking, + ROLES_WITHOUT_DEVELOPER, + true, + true, + true, + true, + ), + }, ProfileRule { matcher: ModelMatcher::Prefix("minimax-"), transport: OpenAITransport::ChatCompletions, @@ -160,7 +173,7 @@ const OPENAI_PROFILE_RULES: &[ProfileRule] = &[ ), }, ProfileRule { - matcher: ModelMatcher::Prefix("qwen-"), + matcher: ModelMatcher::Prefix("qwen"), transport: OpenAITransport::ChatCompletions, profile: OpenAIRequestProfile::new( OpenAIReasoningMode::Effort, @@ -224,6 +237,36 @@ mod tests { assert_eq!(profile.reasoning_mode, OpenAIReasoningMode::Thinking); } + #[test] + fn resolve_request_profile_matches_provider_catalog_slugs() { + let profile = resolve_request_profile( + &ModelProfileKey::CatalogSlug("deepseek/deepseek-v4-flash".to_string()), + OpenAITransport::ChatCompletions, + ); + assert_eq!( + profile.reasoning_mode, + OpenAIReasoningMode::ThinkingWithEffort + ); + } + + #[test] + fn resolve_request_profile_matches_qwen3_models() { + let profile = resolve_request_profile( + &ModelProfileKey::CatalogSlug("ollama/qwen3:8b".to_string()), + OpenAITransport::ChatCompletions, + ); + assert_eq!(profile.reasoning_mode, OpenAIReasoningMode::Effort); + } + + #[test] + fn resolve_request_profile_matches_kimi_models() { + let profile = resolve_request_profile( + &ModelProfileKey::CatalogSlug("kimi/kimi-k2.6".to_string()), + OpenAITransport::ChatCompletions, + ); + assert_eq!(profile.reasoning_mode, OpenAIReasoningMode::Thinking); + } + #[test] fn resolve_request_profile_defaults_to_effort_for_responses() { let profile = resolve_request_profile( diff --git a/crates/provider/src/openai/chat_completions.rs b/crates/provider/src/openai/chat_completions.rs index f92e2cf2..25faffd8 100644 --- a/crates/provider/src/openai/chat_completions.rs +++ b/crates/provider/src/openai/chat_completions.rs @@ -84,24 +84,40 @@ impl OpenAIProvider { format!("{}/chat/completions", self.base_url.trim_end_matches('/')) } - fn post_builder(&self, client: &Client, body: &Value) -> reqwest::RequestBuilder { + fn post_builder( + &self, + client: &Client, + body: &Value, + headers: &std::collections::BTreeMap, + ) -> reqwest::RequestBuilder { let builder = client .post(self.endpoint()) .header(CONTENT_TYPE, "application/json"); + let builder = self + .http_options + .apply_request_headers(self.http_options.apply_custom_headers(builder), headers); let builder = if let Some(api_key) = &self.api_key { builder.header(AUTHORIZATION, format!("Bearer {api_key}")) } else { builder }; - self.http_options.apply_custom_headers(builder).json(body) + builder.json(body) } - fn request_builder(&self, body: &Value) -> reqwest::RequestBuilder { - self.post_builder(&self.client, body) + fn request_builder( + &self, + body: &Value, + headers: &std::collections::BTreeMap, + ) -> reqwest::RequestBuilder { + self.post_builder(&self.client, body, headers) } - pub(super) fn streaming_request_builder(&self, body: &Value) -> reqwest::RequestBuilder { - self.post_builder(&self.streaming_client, body) + pub(super) fn streaming_request_builder( + &self, + body: &Value, + headers: &std::collections::BTreeMap, + ) -> reqwest::RequestBuilder { + self.post_builder(&self.streaming_client, body, headers) } } @@ -1102,7 +1118,7 @@ impl ModelProviderSDK for OpenAIProvider { ); let response = self - .request_builder(&body) + .request_builder(&body, &crate::request_headers(request.extra_body.as_ref())) .send() .await .context("failed to send openai request")?; diff --git a/crates/provider/src/openai/chat_completions/stream.rs b/crates/provider/src/openai/chat_completions/stream.rs index 2a0a9adb..87e65c1b 100644 --- a/crates/provider/src/openai/chat_completions/stream.rs +++ b/crates/provider/src/openai/chat_completions/stream.rs @@ -82,8 +82,11 @@ pub(super) async fn completion_stream( "sending openai streaming request" ); - let event_source = EventSource::new(provider.streaming_request_builder(&body)) - .context("failed to create openai event source")?; + let event_source = EventSource::new( + provider + .streaming_request_builder(&body, &crate::request_headers(request.extra_body.as_ref())), + ) + .context("failed to create openai event source")?; let stream = async_stream::try_stream! { let mut state = ChatCompletionStreamState::for_request(&request); diff --git a/crates/provider/src/openai/responses.rs b/crates/provider/src/openai/responses.rs index b362f254..93911811 100644 --- a/crates/provider/src/openai/responses.rs +++ b/crates/provider/src/openai/responses.rs @@ -70,24 +70,40 @@ impl OpenAIResponsesProvider { format!("{}/responses", self.base_url.trim_end_matches('/')) } - fn post_builder(&self, client: &Client, body: &Value) -> reqwest::RequestBuilder { + fn post_builder( + &self, + client: &Client, + body: &Value, + headers: &std::collections::BTreeMap, + ) -> reqwest::RequestBuilder { let builder = client .post(self.endpoint()) .header(CONTENT_TYPE, "application/json"); + let builder = self + .http_options + .apply_request_headers(self.http_options.apply_custom_headers(builder), headers); let builder = if let Some(api_key) = &self.api_key { builder.header(AUTHORIZATION, format!("Bearer {api_key}")) } else { builder }; - self.http_options.apply_custom_headers(builder).json(body) + builder.json(body) } - fn request_builder(&self, body: &Value) -> reqwest::RequestBuilder { - self.post_builder(&self.client, body) + fn request_builder( + &self, + body: &Value, + headers: &std::collections::BTreeMap, + ) -> reqwest::RequestBuilder { + self.post_builder(&self.client, body, headers) } - fn streaming_request_builder(&self, body: &Value) -> reqwest::RequestBuilder { - self.post_builder(&self.streaming_client, body) + fn streaming_request_builder( + &self, + body: &Value, + headers: &std::collections::BTreeMap, + ) -> reqwest::RequestBuilder { + self.post_builder(&self.streaming_client, body, headers) } } @@ -498,7 +514,7 @@ impl ModelProviderSDK for OpenAIResponsesProvider { ); let response = self - .request_builder(&body) + .request_builder(&body, &crate::request_headers(request.extra_body.as_ref())) .send() .await .context("failed to send openai responses request")?; @@ -540,8 +556,11 @@ impl ModelProviderSDK for OpenAIResponsesProvider { "sending openai responses streaming request" ); - let event_source = EventSource::new(self.streaming_request_builder(&body)) - .context("failed to create openai responses event source")?; + let event_source = EventSource::new(self.streaming_request_builder( + &body, + &crate::request_headers(request.extra_body.as_ref()), + )) + .context("failed to create openai responses event source")?; let stream = async_stream::try_stream! { let mut text_buf = String::new(); let mut reasoning_buf = String::new(); diff --git a/crates/provider/src/request.rs b/crates/provider/src/request.rs index 4489283a..897d666e 100644 --- a/crates/provider/src/request.rs +++ b/crates/provider/src/request.rs @@ -4,8 +4,12 @@ //! `extra_body` so caller-supplied escape-hatch fields keep their documented //! precedence without each adapter reimplementing the merge contract. +use std::collections::BTreeMap; + use serde_json::Value; +pub(crate) const REQUEST_HEADERS_KEY: &str = "__devo_request_headers"; + /// Merges an extra JSON object into a provider request body. pub fn merge_extra_body(body: &mut Value, extra_body: Option<&Value>) { let Some(extra_body) = extra_body else { @@ -19,10 +23,27 @@ pub fn merge_extra_body(body: &mut Value, extra_body: Option<&Value>) { }; for (key, value) in extra_object { + if key == REQUEST_HEADERS_KEY { + continue; + } body_object.insert(key.clone(), value.clone()); } } +/// Reads the reserved internal header envelope from request defaults. +pub(crate) fn request_headers(extra_body: Option<&Value>) -> BTreeMap { + extra_body + .and_then(|body| body.get(REQUEST_HEADERS_KEY)) + .and_then(Value::as_object) + .map(|headers| { + headers + .iter() + .filter_map(|(name, value)| Some((name.clone(), value.as_str()?.to_string()))) + .collect() + }) + .unwrap_or_default() +} + #[cfg(test)] mod tests { use crate::merge_extra_body; diff --git a/crates/provider/src/router.rs b/crates/provider/src/router.rs index b3b03e97..7c4adc08 100644 --- a/crates/provider/src/router.rs +++ b/crates/provider/src/router.rs @@ -14,16 +14,16 @@ use crate::provider::ModelProviderSDK; pub enum ProviderRoute { /// Use the default provider selected during server bootstrap. Default, - /// Use a provider selected through a model-provider binding. - Binding { + /// Use a provider selected through a configured Connection. + Connection { provider_id: String, wire_api: ProviderWireApi, }, } impl ProviderRoute { - pub fn binding(provider_id: impl Into, wire_api: ProviderWireApi) -> Self { - Self::Binding { + pub fn connection(provider_id: impl Into, wire_api: ProviderWireApi) -> Self { + Self::Connection { provider_id: provider_id.into(), wire_api, } @@ -132,7 +132,7 @@ impl MultiProviderRouter { // selected adapter instead of cloning an Arc for each request. match route { ProviderRoute::Default => Ok(self.default_provider.as_ref()), - ProviderRoute::Binding { provider_id, wire_api } => self + ProviderRoute::Connection { provider_id, wire_api } => self .providers .get(route) .map(Arc::as_ref) @@ -271,13 +271,13 @@ mod tests { let selected = Arc::new(CapturingProvider::default()); let mut router = MultiProviderRouter::new(default.clone()); router.insert_route( - ProviderRoute::binding("openrouter", ProviderWireApi::OpenAIChatCompletions), + ProviderRoute::connection("openrouter", ProviderWireApi::OpenAIChatCompletions), selected.clone(), ); router .complete( - ProviderRoute::binding("openrouter", ProviderWireApi::OpenAIChatCompletions), + ProviderRoute::connection("openrouter", ProviderWireApi::OpenAIChatCompletions), request("vendor/model"), ) .await @@ -294,7 +294,7 @@ mod tests { let error = router .complete( - ProviderRoute::binding("missing", ProviderWireApi::AnthropicMessages), + ProviderRoute::connection("missing", ProviderWireApi::AnthropicMessages), request("claude"), ) .await @@ -313,7 +313,7 @@ mod tests { router .complete( - ProviderRoute::binding("other", ProviderWireApi::OpenAIResponses), + ProviderRoute::connection("other", ProviderWireApi::OpenAIResponses), request("any-model"), ) .await diff --git a/crates/safety/src/permission/shell_access.rs b/crates/safety/src/permission/shell_access.rs index 08bd9b56..8d80f458 100644 --- a/crates/safety/src/permission/shell_access.rs +++ b/crates/safety/src/permission/shell_access.rs @@ -231,15 +231,14 @@ fn evaluate_access_with_roots( #[cfg(windows)] { if (path.starts_with('\\') || path.starts_with('/')) - && !path.as_bytes().get(1).is_some_and(|b| *b == b':') - { - if let Some(prefix) = cwd.components().find_map(|c| match c { + && path.as_bytes().get(1).is_none_or(|b| *b != b':') + && let Some(prefix) = cwd.components().find_map(|c| match c { Component::Prefix(p) => Some(p.as_os_str().to_string_lossy().into_owned()), _ => None, - }) { - let rest = path.trim_start_matches(['\\', '/']); - path = format!("{prefix}\\{rest}"); - } + }) + { + let rest = path.trim_start_matches(['\\', '/']); + path = format!("{prefix}\\{rest}"); } } let path = Path::new(&path); diff --git a/crates/windows-sandbox/src/lib.rs b/crates/windows-sandbox/src/lib.rs index 49337932..54b9e377 100644 --- a/crates/windows-sandbox/src/lib.rs +++ b/crates/windows-sandbox/src/lib.rs @@ -76,7 +76,7 @@ pub fn prepare_windows_sandbox_launch( ) -> anyhow::Result> { #[cfg(windows)] { - return Ok(Some(launch::prepare_launch(req)?)); + Ok(Some(launch::prepare_launch(req)?)) } #[cfg(not(windows))] { From 80ab71d2797ccf48ada47d5c458ff76643f2a01a Mon Sep 17 00:00:00 2001 From: wangtsiao Date: Sat, 5 Sep 2026 02:34:32 +0800 Subject: [PATCH 05/13] feat(server): provider discover/upsert and catalog turn routing Wire provider APIs, discovery, session model resolution, and e2e tests onto provider/model catalog selections. --- Cargo.lock | 1 + crates/server/Cargo.toml | 1 + crates/server/src/approval_reviewer.rs | 2 +- crates/server/src/bootstrap.rs | 11 +- crates/server/src/execution.rs | 162 ++-- crates/server/src/persistence.rs | 27 +- crates/server/src/provider_config.rs | 401 +++------- crates/server/src/runtime.rs | 5 +- crates/server/src/runtime/agents/lifecycle.rs | 2 - crates/server/src/runtime/connection.rs | 151 +++- .../server/src/runtime/context_occupancy.rs | 74 +- crates/server/src/runtime/context_usage.rs | 103 ++- .../runtime/handlers/acp_config_options.rs | 115 ++- .../server/src/runtime/handlers/compaction.rs | 69 +- crates/server/src/runtime/handlers/queue.rs | 1 + crates/server/src/runtime/handlers/session.rs | 177 ++--- .../src/runtime/handlers/session_fork.rs | 1 - crates/server/src/runtime/handlers/turn.rs | 21 +- crates/server/src/runtime/model_api.rs | 58 +- crates/server/src/runtime/provider_api.rs | 692 ++++++++++++++++++ .../server/src/runtime/provider_discovery.rs | 681 +++++++++++++++++ .../server/src/runtime/provider_vendor_api.rs | 466 ------------ .../src/runtime/session_actor/actor_loop.rs | 24 +- .../server/src/runtime/session_actor/turn.rs | 13 +- crates/server/src/runtime/session_title.rs | 47 +- .../src/runtime/turn_exec/approval_resume.rs | 16 +- .../runtime/turn_exec/context_compaction.rs | 42 +- .../src/runtime/turn_exec/event_stream.rs | 84 ++- .../server/src/runtime/turn_exec/finalize.rs | 21 +- crates/server/src/runtime/turn_exec/mod.rs | 3 +- crates/server/src/runtime/turn_exec/tests.rs | 3 + crates/server/src/runtime/turn_exec/trace.rs | 4 + crates/server/src/runtime/turn_reservation.rs | 2 - crates/server/src/session_context.rs | 178 +++-- crates/server/src/usage_ledger.rs | 2 + crates/server/tests/acp_available_commands.rs | 2 - .../acp_permission_tool_status_contract.rs | 2 - .../server/tests/acp_session_contract_e2e.rs | 12 +- crates/server/tests/acp_session_delete.rs | 3 - crates/server/tests/acp_session_lifecycle.rs | 2 - crates/server/tests/cli_log_failures.rs | 90 ++- crates/server/tests/command_exec.rs | 2 - crates/server/tests/end_to_end.rs | 3 - crates/server/tests/goal_title_generation.rs | 2 - crates/server/tests/model_config_e2e.rs | 18 +- .../tests/model_selection_persistence.rs | 58 +- crates/server/tests/persistence_resume.rs | 137 ++-- .../tests/provider_failure_reporting.rs | 13 +- crates/server/tests/provider_routing.rs | 60 +- .../server/tests/queue_drain_notification.rs | 2 - .../tests/session_compaction_threshold.rs | 58 +- .../server/tests/session_fork_persistence.rs | 2 - .../tests/session_rollback_persistence.rs | 2 - .../server/tests/session_sandbox_profile.rs | 2 - crates/server/tests/settings_mid_turn.rs | 8 +- crates/server/tests/skills_integration.rs | 2 - .../server/tests/support/goal_continuation.rs | 2 - .../tests/support/subagent_lifecycle.rs | 2 - .../server/tests/tool_call_param_refresh.rs | 2 - crates/server/tests/turn_start_persistence.rs | 2 - crates/server/tests/websocket_client_e2e.rs | 2 - 61 files changed, 2770 insertions(+), 1380 deletions(-) create mode 100644 crates/server/src/runtime/provider_api.rs create mode 100644 crates/server/src/runtime/provider_discovery.rs delete mode 100644 crates/server/src/runtime/provider_vendor_api.rs diff --git a/Cargo.lock b/Cargo.lock index 562bb706..85b2db59 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2131,6 +2131,7 @@ dependencies = [ "jsonrepair", "lru", "pretty_assertions", + "reqwest 0.12.28", "rusqlite", "serde", "serde_json", diff --git a/crates/server/Cargo.toml b/crates/server/Cargo.toml index 0d6e0cb9..54b65267 100644 --- a/crates/server/Cargo.toml +++ b/crates/server/Cargo.toml @@ -34,6 +34,7 @@ fs2 = { workspace = true } jsonrepair = { workspace = true } lru = { workspace = true } rusqlite = { workspace = true } +reqwest = { workspace = true } serde = { workspace = true } serde_json = { workspace = true } sha2 = { workspace = true } diff --git a/crates/server/src/approval_reviewer.rs b/crates/server/src/approval_reviewer.rs index 26dc96c9..953532ed 100644 --- a/crates/server/src/approval_reviewer.rs +++ b/crates/server/src/approval_reviewer.rs @@ -234,7 +234,7 @@ fn append_list_section(prompt: &mut String, heading: &str, items: &[String]) { if items.is_empty() { return; } - write!(prompt, "## {heading}\n").expect("writing to a String cannot fail"); + writeln!(prompt, "## {heading}").expect("writing to a String cannot fail"); for line in items { prompt.push_str(line); prompt.push('\n'); diff --git a/crates/server/src/bootstrap.rs b/crates/server/src/bootstrap.rs index 8e55a762..21db7c0c 100644 --- a/crates/server/src/bootstrap.rs +++ b/crates/server/src/bootstrap.rs @@ -8,7 +8,6 @@ use devo_core::AppConfigStore; use devo_core::FileSystemSkillCatalog; use devo_core::ModelCatalog; use devo_core::PresetModelCatalog; -use devo_core::ProviderVendorCatalog; use devo_core::tools::ToolPlanConfig; use devo_core::tools::handlers; use devo_mcp::manager::RmcpMcpManager; @@ -227,9 +226,12 @@ pub async fn run_server_process( let tool_plan = ToolPlanConfig::from_app_config(&config); let registry = handlers::build_registry_from_plan_with_mcp(&tool_plan, Arc::clone(&mcp_manager)).await; - let model_catalog: Arc = Arc::new(PresetModelCatalog::load_from_config( - &config.provider.model_overrides, - )?); + let model_catalog: Arc = Arc::new( + PresetModelCatalog::load_from_provider_config_with_overrides( + &config.provider_catalog_config(), + &config.provider.model_overrides, + )?, + ); let default_model = model_catalog.resolve_for_turn(None)?.slug.clone(); if !config.has_provider_configuration() { tracing::warn!( @@ -263,7 +265,6 @@ pub async fn run_server_process( mcp_manager, provider.default_model, model_catalog, - Arc::new(ProviderVendorCatalog::default()), skill_catalog, AgentsMdConfig { project_root_markers: config.project_root_markers.clone(), diff --git a/crates/server/src/execution.rs b/crates/server/src/execution.rs index 3bafb499..0f266ece 100644 --- a/crates/server/src/execution.rs +++ b/crates/server/src/execution.rs @@ -8,7 +8,6 @@ use std::sync::Arc; use std::sync::Mutex as StdMutex; use devo_core::AppConfigStore; -use devo_core::ProviderVendorCatalog; use devo_core::normalize_native_path; use lru::LruCache; use tokio::sync::Mutex; @@ -142,9 +141,6 @@ pub struct ServerRuntimeDependencies { /// Provider router facade for model invocation dispatch. #[allow(dead_code)] pub(crate) provider_router: Arc, - /// ProviderVendor catalog used to resolve current provider. - #[allow(dead_code)] - pub(crate) provider_vendor_catalog: Arc, /// Model catalog used to resolve builtin prompt metadata. pub(crate) model_catalog: Arc, /// SQLite database for session metadata, token stats, and pending queues. @@ -181,7 +177,6 @@ impl ServerRuntimeDependencies { mcp_manager: Arc, default_model: String, model_catalog: Arc, - provider_vendor_catalog: Arc, skill_catalog: Box, agents_md: AgentsMdConfig, db: Arc, @@ -202,7 +197,6 @@ impl ServerRuntimeDependencies { Self { provider_router, model_catalog, - provider_vendor_catalog, db, config_store, process_context, @@ -382,12 +376,12 @@ mod tests { use devo_core::FileSystemSkillCatalog; use devo_core::Model; use devo_core::PresetModelCatalog; - use devo_core::ProviderVendorCatalog; use devo_core::SkillsConfig; use devo_core::tools::ToolRegistry; use devo_protocol::InputItem; use devo_protocol::ModelRequest; use devo_protocol::ModelResponse; + use devo_protocol::ProviderInfo; use devo_protocol::ProviderWireApi; use devo_protocol::StreamEvent; use devo_provider::ModelProviderSDK; @@ -454,7 +448,6 @@ mod tests { ..Model::default() }, ])), - Arc::new(ProviderVendorCatalog::default()), Box::new(FileSystemSkillCatalog::new(SkillsConfig { bundled: Some(BundledSkillsConfig { enabled: false }), ..SkillsConfig::default() @@ -495,34 +488,45 @@ mod tests { } #[tokio::test] - async fn context_for_workspace_loads_distinct_project_model_catalogs() { + async fn context_for_workspace_loads_distinct_project_provider_catalogs() { let deps = test_deps(""); let root = unique_temp_dir("session-context-project-models"); let workspace_a = root.join("workspace-a"); let workspace_b = root.join("workspace-b"); std::fs::create_dir_all(workspace_a.join(".devo")).expect("create workspace a config dir"); std::fs::create_dir_all(workspace_b.join(".devo")).expect("create workspace b config dir"); - let legacy_models_path = workspace_a.join(".devo").join("models.json"); - let legacy_models = - br#"[{"slug":"legacy-only-workspace-model","display_name":"Legacy Only"}]"#; - std::fs::write(&legacy_models_path, legacy_models).expect("write legacy workspace models"); - let absent_legacy_models_path = workspace_b.join(".devo").join("models.json"); std::fs::write( - workspace_a.join(".devo").join("config.toml"), + workspace_a.join(".devo").join("providers.json"), r#" -[model.workspace-a-model] -display_name = "Workspace A" +{ + "provider": { + "workspace-a": { + "name": "Workspace A", + "models": { + "workspace-a-model": { "name": "Workspace A" } + } + } + } +} "#, ) - .expect("write workspace a config"); + .expect("write workspace a provider catalog"); std::fs::write( - workspace_b.join(".devo").join("config.toml"), + workspace_b.join(".devo").join("providers.json"), r#" -[model.workspace-b-model] -display_name = "Workspace B" +{ + "provider": { + "workspace-b": { + "name": "Workspace B", + "models": { + "workspace-b-model": { "name": "Workspace B" } + } + } + } +} "#, ) - .expect("write workspace b config"); + .expect("write workspace b provider catalog"); let context_a = deps .context_for_workspace(&workspace_a) @@ -533,12 +537,10 @@ display_name = "Workspace B" .await .expect("load workspace b context"); - assert_eq!(context_a.provider.name(), "noop"); - assert_eq!(context_b.provider.name(), "noop"); assert_eq!( context_a .model_catalog - .get("workspace-a-model") + .get("workspace-a/workspace-a-model") .expect("workspace a model") .display_name, "Workspace A" @@ -546,31 +548,23 @@ display_name = "Workspace B" assert_eq!( context_b .model_catalog - .get("workspace-b-model") + .get("workspace-b/workspace-b-model") .expect("workspace b model") .display_name, "Workspace B" ); - assert!(context_a.model_catalog.get("workspace-b-model").is_none()); - assert!(context_b.model_catalog.get("workspace-a-model").is_none()); assert!( context_a .model_catalog - .get("legacy-only-workspace-model") + .get("workspace-b/workspace-b-model") .is_none() ); assert!( context_b .model_catalog - .get("legacy-only-workspace-model") + .get("workspace-a/workspace-a-model") .is_none() ); - assert_eq!( - std::fs::read(&legacy_models_path).expect("read legacy workspace models"), - legacy_models - ); - assert!(!absent_legacy_models_path.exists()); - let _ = std::fs::remove_dir_all(root); } @@ -590,20 +584,27 @@ wire_apis = ["openai_chat_completions"] enabled = true model_slug = "catalog-slug" provider = "openrouter" -request_model = "vendor/model-name" +request_model = "catalog-slug" invocation_method = "openai_chat_completions" "#, ); let workspace = unique_temp_dir("session-context-model-metadata"); std::fs::create_dir_all(workspace.join(".devo")).expect("create workspace config dir"); std::fs::write( - workspace.join(".devo").join("config.toml"), + workspace.join(".devo").join("providers.json"), r#" -[model.catalog-slug] -display_name = "Workspace Catalog Model" +{ + "provider": { + "openrouter": { + "models": { + "catalog-slug": { "name": "Workspace Catalog Model" } + } + } + } +} "#, ) - .expect("write workspace config"); + .expect("write workspace provider catalog"); let context = deps .context_for_workspace(&workspace) @@ -621,7 +622,7 @@ display_name = "Workspace Catalog Model" assert_eq!( context .model_catalog - .get("catalog-slug") + .get("openrouter/catalog-slug") .expect("workspace catalog model") .display_name, "Workspace Catalog Model" @@ -671,6 +672,63 @@ proxy_url = "http://workspace-proxy.example:8080" let _ = std::fs::remove_dir_all(workspace); } + #[tokio::test] + async fn context_for_workspace_rebuilds_after_shared_provider_store_mutation() { + let deps = test_deps( + r#" +[defaults] +model_binding = "main" + +[providers.openrouter] +enabled = true +name = "OpenRouter" +wire_apis = ["openai_chat_completions"] + +[model_bindings.main] +enabled = true +model_slug = "catalog-slug" +provider = "openrouter" +request_model = "vendor/model-name" +invocation_method = "openai_chat_completions" +"#, + ); + let workspace = unique_temp_dir("session-context-provider-store-mutation"); + std::fs::create_dir_all(&workspace).expect("create workspace"); + + let initial = deps + .context_for_workspace(&workspace) + .await + .expect("load initial workspace context"); + + let mut provider = deps + .config_store + .lock() + .expect("config store") + .provider_connections() + .expect("read provider Connection") + .into_iter() + .next() + .expect("migrated provider Connection"); + provider.base_url = Some("https://updated.example/v1".to_string()); + deps.config_store + .lock() + .expect("config store") + .upsert_provider_connection(ProviderInfo { ..provider }, None, None, None) + .expect("persist provider Connection update"); + deps.invalidate_workspace_contexts(); + + let updated = deps + .context_for_workspace(&workspace) + .await + .expect("load updated workspace context"); + assert!(!Arc::ptr_eq( + &initial.provider_router, + &updated.provider_router + )); + + let _ = std::fs::remove_dir_all(workspace); + } + #[tokio::test] async fn context_for_workspace_caches_same_canonical_cwd() { let deps = test_deps(""); @@ -788,7 +846,7 @@ invocation_method = "openai_chat_completions" } #[test] - fn resolve_turn_config_preserves_catalog_slug_and_uses_binding_request_model() { + fn resolve_turn_config_uses_canonical_model_reference_and_request_model() { let deps = test_deps( r#" [defaults] @@ -830,16 +888,16 @@ invocation_method = "openai_chat_completions" /*reasoning_effort_selection*/ None, ); - assert_eq!(turn_config.model.slug, "catalog-slug"); + assert_eq!(turn_config.model.slug, "openrouter/vendor/model-name"); assert_eq!(turn_config.request_model, "vendor/model-name"); assert_eq!( turn_config.provider_route, - ProviderRoute::binding("openrouter", ProviderWireApi::OpenAIChatCompletions) + ProviderRoute::connection("openrouter", ProviderWireApi::OpenAIChatCompletions) ); } #[test] - fn resolve_turn_config_maps_variant_slug_to_binding_request_model() { + fn resolve_turn_config_maps_canonical_variant_ref_to_binding_request_model() { let deps = test_deps( r#" [defaults] @@ -852,36 +910,36 @@ wire_apis = ["openai_chat_completions"] [model_bindings.main] enabled = true -model_slug = "catalog-slug" +model_slug = "openrouter/vendor/model-name" provider = "openrouter" request_model = "vendor/model-name" invocation_method = "openai_chat_completions" [model_bindings.thinking] enabled = true -model_slug = "catalog-slug-thinking" +model_slug = "openrouter/vendor/model-name-thinking" provider = "openrouter" request_model = "vendor/model-name-thinking" invocation_method = "openai_chat_completions" [model_bindings.other-thinking] enabled = true -model_slug = "catalog-slug-thinking" +model_slug = "other/other-provider/model-name-thinking" provider = "other" request_model = "other-provider/model-name-thinking" invocation_method = "openai_chat_completions" "#, ); - let turn_config = deps.resolve_turn_config(Some("catalog-slug"), None); + let turn_config = deps.resolve_turn_config(Some("openrouter/vendor/model-name"), None); assert_eq!( - turn_config.provider_request_model("catalog-slug-thinking"), + turn_config.provider_request_model("openrouter/vendor/model-name-thinking"), "vendor/model-name-thinking" ); assert_eq!( turn_config.provider_route, - ProviderRoute::binding("openrouter", ProviderWireApi::OpenAIChatCompletions) + ProviderRoute::connection("openrouter", ProviderWireApi::OpenAIChatCompletions) ); } diff --git a/crates/server/src/persistence.rs b/crates/server/src/persistence.rs index 1954207e..76a4bb51 100644 --- a/crates/server/src/persistence.rs +++ b/crates/server/src/persistence.rs @@ -163,6 +163,7 @@ impl RolloutStore { ) } + #[allow(clippy::too_many_arguments)] pub(crate) fn create_session_record_with_fork( &self, id: SessionId, @@ -278,10 +279,10 @@ impl RolloutStore { pub(crate) fn append_item(&self, record: &SessionRecord, item: ItemRecord) -> Result<()> { self.append_line( &record.rollout_path, - &RolloutLine::Item(ItemLine { + &RolloutLine::Item(Box::new(ItemLine { timestamp: Utc::now(), item, - }), + })), ) } @@ -2693,6 +2694,7 @@ fn turn_metadata_from_record(turn: &TurnRecord) -> TurnMetadata { } /// Creates one canonical persisted item record from a normalized turn item payload. +#[allow(clippy::too_many_arguments)] pub(crate) fn build_item_record( session_id: SessionId, turn_id: TurnId, @@ -2783,7 +2785,7 @@ mod tests { let mut replay = ReplayState::default(); replay - .apply_line(RolloutLine::Item(ItemLine { + .apply_line(RolloutLine::Item(Box::new(ItemLine { timestamp: earlier, item: ItemRecord { id: ItemId::new(), @@ -2805,10 +2807,10 @@ mod tests { error: None, schema_version: 1, }, - })) + }))) .expect("replay later-seq line"); replay - .apply_line(RolloutLine::Item(ItemLine { + .apply_line(RolloutLine::Item(Box::new(ItemLine { timestamp: later, item: ItemRecord { id: ItemId::new(), @@ -2828,7 +2830,7 @@ mod tests { error: None, schema_version: 1, }, - })) + }))) .expect("replay earlier-seq line"); let mut items = replay.pending_items; @@ -3142,7 +3144,7 @@ mod tests { }))) .expect("apply original turn"); replay - .apply_line(RolloutLine::Item(ItemLine { + .apply_line(RolloutLine::Item(Box::new(ItemLine { timestamp: now, item: ItemRecord { id: original_item_id, @@ -3162,7 +3164,7 @@ mod tests { error: None, schema_version: 1, }, - })) + }))) .expect("apply original item"); replay .apply_line(RolloutLine::MessageEditRecorded(Box::new( @@ -3232,7 +3234,7 @@ mod tests { }))) .expect("apply replacement turn"); replay - .apply_line(RolloutLine::Item(ItemLine { + .apply_line(RolloutLine::Item(Box::new(ItemLine { timestamp: now, item: ItemRecord { id: replacement_item_id, @@ -3252,7 +3254,7 @@ mod tests { error: None, schema_version: 1, }, - })) + }))) .expect("apply replacement item"); let projected_items = replay @@ -4285,7 +4287,6 @@ mod tests { crate::empty_mcp_manager(), "test-model".to_string(), Arc::new(devo_core::PresetModelCatalog::default()), - Arc::new(devo_core::ProviderVendorCatalog::default()), Box::new(devo_core::FileSystemSkillCatalog::new( devo_core::SkillsConfig { bundled: Some(devo_core::BundledSkillsConfig { enabled: false }), @@ -4781,7 +4782,7 @@ mod tests { timestamp: Utc::now(), turn: super::build_turn_record(&metadata, None, None, None, None), })), - RolloutLine::Item(ItemLine { + RolloutLine::Item(Box::new(ItemLine { timestamp: Utc::now(), item: super::build_item_record( record.id, @@ -4795,7 +4796,7 @@ mod tests { None, None, ), - }), + })), ]; write_raw_lines( &record.rollout_path, diff --git a/crates/server/src/provider_config.rs b/crates/server/src/provider_config.rs index 7a042023..e778929c 100644 --- a/crates/server/src/provider_config.rs +++ b/crates/server/src/provider_config.rs @@ -9,15 +9,14 @@ use anyhow::Result; use devo_core::AUTH_CONFIG_FILE_NAME; use devo_core::AppConfig; -use devo_core::LegacyModelProviderConfig; use devo_core::ModelCatalog; use devo_core::PresetModelCatalog; -use devo_core::ProviderConfigSection; +use devo_core::ProviderConfigEntry; +use devo_core::ProviderConfigFile; use devo_core::ProviderHttpConfig; use devo_core::ProviderWireApi; use devo_core::UserAuthConfigFile; use devo_core::read_user_auth_config; -use devo_core::resolve_model_binding; use devo_protocol::ModelRequest; use devo_protocol::ModelResponse; use devo_protocol::StreamEvent; @@ -69,38 +68,36 @@ pub fn load_server_provider( }); } - if app_config.provider.model_providers.is_empty() { - let auth = read_user_auth_config(&user_config_dir.join(AUTH_CONFIG_FILE_NAME))?; - let resolved = app_config.resolve_provider_settings(user_config_dir)?; - let default_model = - resolve_model_binding(&app_config.provider, /*requested_model*/ None) - .map(|binding| binding.model_slug) - .or_else(|| default_model.map(ToOwned::to_owned)) - .unwrap_or(resolved.model); - let provider = build_provider_adapter( - resolved.wire_api, - resolved.base_url, - resolved.api_key, - ProviderHttpOptions::from_raw_with_no_proxy( - resolved.proxy_url, - resolved.no_proxy, - resolved.headers, - )?, - )?; - let provider_router = build_multi_provider_router( - &app_config.provider, - &app_config.provider_http, - &auth, - Arc::clone(&provider), - )?; - return Ok(ResolvedServerProvider { - provider, - provider_router, - default_model, - }); - } - - load_legacy_server_provider(app_config, default_model) + let provider_config = app_config.provider_catalog_config(); + let auth = read_user_auth_config(&user_config_dir.join(AUTH_CONFIG_FILE_NAME))?; + let selection = resolve_server_model(&provider_config, default_model)?; + let provider_config_entry = provider_config + .providers + .get(&selection.provider_id) + .with_context(|| { + format!( + "configured provider Connection `{}` was not found", + selection.provider_id + ) + })?; + let provider = build_provider_route( + selection.wire_api, + &selection.provider_id, + provider_config_entry, + &auth, + &app_config.provider_http, + )?; + let provider_router = build_multi_provider_router( + &provider_config, + &app_config.provider_http, + &auth, + Arc::clone(&provider), + )?; + Ok(ResolvedServerProvider { + provider, + provider_router, + default_model: format!("{}/{}", selection.provider_id, selection.model_id), + }) } struct MissingProvider; @@ -151,40 +148,7 @@ impl ModelProviderSDK for UnavailableProvider { } } -fn load_legacy_server_provider( - app_config: &AppConfig, - default_model: Option<&str>, -) -> Result { - let resolved = resolve_legacy_server_provider_settings(&app_config.provider, default_model)?; - build_server_provider( - resolved.wire_api, - resolved.model, - resolved.base_url, - resolved.api_key, - ProviderHttpOptions::from_raw_with_no_proxy( - app_config.provider_http.proxy_url.clone(), - app_config.provider_http.no_proxy.clone(), - None, - )?, - ) -} - -fn build_server_provider( - wire_api: ProviderWireApi, - model: String, - base_url: Option, - api_key: Option, - http_options: ProviderHttpOptions, -) -> Result { - let provider = build_provider_adapter(wire_api, base_url, api_key, http_options)?; - Ok(ResolvedServerProvider { - provider: Arc::clone(&provider), - provider_router: Arc::new(SingleProviderRouter::new(provider)), - default_model: model, - }) -} - -fn build_provider_adapter( +pub(crate) fn build_provider_adapter( wire_api: ProviderWireApi, base_url: Option, api_key: Option, @@ -227,42 +191,42 @@ fn build_provider_adapter( } fn build_multi_provider_router( - provider_config: &ProviderConfigSection, + provider_config: &ProviderConfigFile, provider_http: &ProviderHttpConfig, auth: &UserAuthConfigFile, default_provider: Arc, ) -> Result> { let mut router = MultiProviderRouter::new(default_provider); - for binding in provider_config - .model_bindings - .values() - .filter(|binding| binding.enabled) - { - let Some(provider) = provider_config.providers.get(&binding.provider) else { - continue; - }; - if !provider.enabled - || (!provider.wire_apis.is_empty() - && !provider.wire_apis.contains(&binding.invocation_method)) - { + for (provider_id, provider) in &provider_config.providers { + if provider.enabled == Some(false) { continue; } - let provider = match build_provider_route( - binding.invocation_method, - provider.base_url.clone(), - &binding.provider, - provider, - auth, - provider_http, - ) { - Ok(provider) => provider, - Err(error) => Arc::new(UnavailableProvider::new(error.to_string())), - }; - router.insert_route( - ProviderRoute::binding(binding.provider.clone(), binding.invocation_method), - provider, - ); + let mut wire_apis = Vec::new(); + if let Some(wire_api) = provider.wire_api { + wire_apis.push(wire_api); + } + for model in provider.models.values() { + if let Some(wire_api) = model.wire_api + && !wire_apis.contains(&wire_api) + { + wire_apis.push(wire_api); + } + } + if wire_apis.is_empty() { + wire_apis.push(ProviderWireApi::OpenAIChatCompletions); + } + for wire_api in wire_apis { + let provider_instance = + match build_provider_route(wire_api, provider_id, provider, auth, provider_http) { + Ok(provider) => provider, + Err(error) => Arc::new(UnavailableProvider::new(error.to_string())), + }; + router.insert_route( + ProviderRoute::connection(provider_id.clone(), wire_api), + provider_instance, + ); + } } Ok(Arc::new(router)) @@ -270,80 +234,45 @@ fn build_multi_provider_router( fn build_provider_route( wire_api: ProviderWireApi, - base_url: Option, provider_id: &str, - provider: &devo_core::ProviderVendorConfig, + provider: &ProviderConfigEntry, auth: &UserAuthConfigFile, provider_http: &ProviderHttpConfig, ) -> Result> { build_provider_adapter( wire_api, - base_url, + provider.base_url.clone(), resolve_provider_api_key(provider_id, provider, auth)?, ProviderHttpOptions::from_raw_with_no_proxy( provider_http.proxy_url.clone(), provider_http.no_proxy.clone(), - provider.headers.clone(), + provider + .headers + .as_ref() + .map(serde_json::to_string) + .transpose()?, )?, ) } -#[derive(Debug, PartialEq, Eq)] -struct ServerProviderSettings { - wire_api: ProviderWireApi, - model: String, - base_url: Option, - api_key: Option, -} - -#[cfg(test)] -fn resolve_server_provider_settings( - file_config: &ProviderConfigSection, +fn resolve_server_model( + provider_config: &ProviderConfigFile, default_model: Option<&str>, - auth: &devo_core::UserAuthConfigFile, -) -> Result { - if let Some(binding) = resolve_model_binding(file_config, /*requested_model*/ None) { - let provider = file_config - .providers - .get(&binding.provider_id) - .with_context(|| { - format!( - "configured provider `{}` was not found", - binding.provider_id - ) - })?; - if !provider.enabled { - anyhow::bail!("configured provider `{}` is disabled", binding.provider_id); - } - if !binding.enabled { - anyhow::bail!( - "configured model binding `{}` is disabled", - binding.binding_id - ); - } - if !provider.wire_apis.is_empty() - && !provider.wire_apis.contains(&binding.invocation_method) - { - anyhow::bail!( - "model binding `{}` uses unsupported provider wire API `{}`", - binding.model_slug, - binding.invocation_method - ); - } - return Ok(ServerProviderSettings { - wire_api: binding.invocation_method, - model: binding.request_model.clone(), - base_url: provider.base_url.clone(), - api_key: resolve_provider_api_key(&binding.provider_id, provider, auth)?, - }); +) -> Result { + if provider_config.model.is_some() { + return provider_config.resolve_model(None).map_err(Into::into); } - - resolve_legacy_server_provider_settings(file_config, default_model) + if let Some(default_model) = default_model + && let Ok(selection) = provider_config.resolve_model(Some(default_model)) + { + return Ok(selection); + } + provider_config.resolve_model(None).map_err(Into::into) } fn resolve_provider_api_key( provider_id: &str, - provider: &devo_core::ProviderVendorConfig, + provider: &ProviderConfigEntry, auth: &devo_core::UserAuthConfigFile, ) -> Result> { let Some(credential_id) = provider.credential.as_deref() else { @@ -357,90 +286,6 @@ fn resolve_provider_api_key( Ok(Some(credential.value.clone())) } -fn resolve_legacy_server_provider_settings( - file_config: &ProviderConfigSection, - default_model: Option<&str>, -) -> Result { - let requested_model = file_config.model.as_deref(); - let provider_id = provider_id_for_model(file_config, requested_model) - .or_else(|| { - file_config - .model_provider - .as_deref() - .filter(|provider| file_config.model_providers.contains_key(*provider)) - }) - .or_else(|| { - file_config - .model_providers - .keys() - .next() - .map(String::as_str) - }); - let provider_config = - provider_id.and_then(|provider_id| file_config.model_providers.get(provider_id)); - let selected_model = - provider_config.and_then(|provider| select_configured_model(provider, requested_model)); - let wire_api = provider_config - .and_then(|provider| provider.wire_api) - .unwrap_or(ProviderWireApi::OpenAIChatCompletions); - let model = selected_model - .map(|model| model.model.clone()) - .or_else(|| file_config.model.clone()) - .or_else(|| default_model.map(ToOwned::to_owned)) - .or_else(|| provider_config.and_then(|provider| provider.default_model.clone())) - .or_else(|| { - provider_config - .and_then(|provider| provider.models.first().map(|model| model.model.clone())) - }) - .context("no model configured for server provider")?; - let base_url = selected_model - .and_then(|model| model.base_url.clone()) - .or_else(|| provider_config.and_then(|provider| provider.base_url.clone())); - let api_key = selected_model - .and_then(|model| model.api_key.clone()) - .or_else(|| provider_config.and_then(|provider| provider.api_key.clone())); - - Ok(ServerProviderSettings { - wire_api, - model, - base_url, - api_key, - }) -} - -fn select_configured_model<'a>( - profile: &'a LegacyModelProviderConfig, - requested: Option<&str>, -) -> Option<&'a devo_core::ConfiguredModel> { - match requested { - Some(model) => profile.models.iter().find(|entry| entry.model == model), - None => profile - .default_model - .as_deref() - .and_then(|default| profile.models.iter().find(|entry| entry.model == default)) - .or_else(|| profile.models.first()), - } -} - -fn provider_id_for_model<'a>( - config: &'a ProviderConfigSection, - requested_model: Option<&str>, -) -> Option<&'a str> { - let requested_model = requested_model?; - config - .model_providers - .iter() - .find(|(_, provider)| { - provider.last_model.as_deref() == Some(requested_model) - || provider.default_model.as_deref() == Some(requested_model) - || provider - .models - .iter() - .any(|entry| entry.model == requested_model) - }) - .map(|(provider_id, _)| provider_id.as_str()) -} - pub(crate) fn normalize_openai_base_url(url: &str) -> String { let trimmed = url.trim_end_matches('/'); let Some(scheme_sep) = trimmed.find("://") else { @@ -461,16 +306,15 @@ mod tests { use devo_core::AppConfig; use devo_core::AuthCredentialConfig; use devo_core::AuthCredentialKind; - use devo_core::ModelBindingConfig; - use devo_core::ProviderConfigSection; - use devo_core::ProviderDefaultsConfig; - use devo_core::ProviderVendorConfig; + use devo_core::ProviderConfigEntry; + use devo_core::ProviderConfigFile; + use devo_core::ProviderModelConfig; use devo_core::UserAuthConfigFile; use pretty_assertions::assert_eq; use super::load_server_provider; use super::normalize_openai_base_url; - use super::resolve_server_provider_settings; + use super::resolve_provider_api_key; use devo_protocol::ProviderWireApi; #[test] @@ -528,31 +372,25 @@ mod tests { #[test] fn load_server_provider_rejects_invalid_custom_headers() { let config = AppConfig { - provider: ProviderConfigSection { - defaults: ProviderDefaultsConfig { - model_binding: Some("main".to_string()), - }, + provider_catalog: ProviderConfigFile { + model: Some("openai/test-model".to_string()), providers: BTreeMap::from([( "openai".to_string(), - ProviderVendorConfig { - name: "OpenAI".to_string(), - headers: Some(r#"{"bad header":"value"}"#.to_string()), - wire_apis: vec![ProviderWireApi::OpenAIChatCompletions], - enabled: true, - ..ProviderVendorConfig::default() + ProviderConfigEntry { + name: Some("OpenAI".to_string()), + headers: Some(BTreeMap::from([( + "bad header".to_string(), + "value".to_string(), + )])), + wire_api: Some(ProviderWireApi::OpenAIChatCompletions), + models: BTreeMap::from([( + "test-model".to_string(), + ProviderModelConfig::default(), + )]), + ..ProviderConfigEntry::default() }, )]), - model_bindings: BTreeMap::from([( - "main".to_string(), - ModelBindingConfig { - model_slug: "test-model".to_string(), - provider: "openai".to_string(), - request_model: "test-model".to_string(), - invocation_method: ProviderWireApi::OpenAIChatCompletions, - ..ModelBindingConfig::default() - }, - )]), - ..ProviderConfigSection::default() + ..ProviderConfigFile::default() }, ..AppConfig::default() }; @@ -571,31 +409,9 @@ mod tests { #[test] fn resolves_provider_credential_id_through_user_auth() { - let config = ProviderConfigSection { - defaults: ProviderDefaultsConfig { - model_binding: Some("gpt-test-openrouter".to_string()), - }, - providers: BTreeMap::from([( - "openrouter".to_string(), - ProviderVendorConfig { - name: "openrouter".to_string(), - credential: Some("openrouter_api_key".to_string()), - wire_apis: vec![ProviderWireApi::OpenAIResponses], - enabled: true, - ..ProviderVendorConfig::default() - }, - )]), - model_bindings: BTreeMap::from([( - "gpt-test-openrouter".to_string(), - ModelBindingConfig { - model_slug: "gpt-test".to_string(), - provider: "openrouter".to_string(), - request_model: "openai/gpt-test".to_string(), - invocation_method: ProviderWireApi::OpenAIResponses, - ..ModelBindingConfig::default() - }, - )]), - ..ProviderConfigSection::default() + let provider = ProviderConfigEntry { + credential: Some("openrouter_api_key".to_string()), + ..ProviderConfigEntry::default() }; let auth = UserAuthConfigFile { credentials: BTreeMap::from([( @@ -608,17 +424,10 @@ mod tests { ..UserAuthConfigFile::default() }; - let actual = resolve_server_provider_settings(&config, None, &auth) - .expect("resolve server provider settings"); - assert_eq!( - actual, - super::ServerProviderSettings { - wire_api: ProviderWireApi::OpenAIResponses, - model: "openai/gpt-test".to_string(), - base_url: None, - api_key: Some("sk-or-secret".to_string()), - } + resolve_provider_api_key("openrouter", &provider, &auth) + .expect("resolve provider credential"), + Some("sk-or-secret".to_string()) ); } } diff --git a/crates/server/src/runtime.rs b/crates/server/src/runtime.rs index 09fb30dc..3e966d98 100644 --- a/crates/server/src/runtime.rs +++ b/crates/server/src/runtime.rs @@ -64,7 +64,6 @@ use crate::ServerEvent; use crate::ServerProtocol; use crate::ServerRequestResolvedPayload; use crate::SessionCompactionFailedPayload; -use crate::SessionEffectiveContextWindowUpdatedPayload; use crate::SessionEventPayload; use crate::SessionForkResult; use crate::SessionMetadata; @@ -137,7 +136,8 @@ mod model_api; mod outbound; mod permission_decision; mod proposed_plan; -mod provider_vendor_api; +mod provider_api; +mod provider_discovery; mod reference_search; mod session_actor; mod session_cache; @@ -305,6 +305,7 @@ fn session_model_selection(session: &SessionMetadata) -> Option<&str> { session .model_binding_id .as_deref() + .or_else(|| session.model.as_deref().filter(|model| model.contains('/'))) .or(session.model.as_deref()) } diff --git a/crates/server/src/runtime/agents/lifecycle.rs b/crates/server/src/runtime/agents/lifecycle.rs index c51531bb..00ac81e3 100644 --- a/crates/server/src/runtime/agents/lifecycle.rs +++ b/crates/server/src/runtime/agents/lifecycle.rs @@ -140,7 +140,6 @@ mod tests { use devo_core::BundledSkillsConfig; use devo_core::FileSystemSkillCatalog; use devo_core::PresetModelCatalog; - use devo_core::ProviderVendorCatalog; use devo_core::SkillsConfig; use devo_core::tools::AgentToolCoordinator; use devo_core::tools::ToolRegistry; @@ -190,7 +189,6 @@ mod tests { crate::empty_mcp_manager(), "test-model".to_string(), Arc::new(PresetModelCatalog::default()), - Arc::new(ProviderVendorCatalog::default()), Box::new(FileSystemSkillCatalog::new(SkillsConfig { bundled: Some(BundledSkillsConfig { enabled: false }), ..SkillsConfig::default() diff --git a/crates/server/src/runtime/connection.rs b/crates/server/src/runtime/connection.rs index cc8b049f..6c89444f 100644 --- a/crates/server/src/runtime/connection.rs +++ b/crates/server/src/runtime/connection.rs @@ -370,7 +370,14 @@ impl ServerRuntime { "workspace/changes/read" => Some(self.handle_workspace_changes_read(id?, params).await), "provider/list" => Some(self.handle_native_provider_list(id?).await), "provider/validate" => Some(self.handle_native_provider_validate(id?, params).await), + "provider/discover" => Some(self.handle_native_provider_discover(id?, params).await), "provider/upsert" => Some(self.handle_native_provider_upsert(id?, params).await), + "provider/disconnect" => { + Some(self.handle_native_provider_disconnect(id?, params).await) + } + "provider/model/remove" => { + Some(self.handle_native_provider_model_remove(id?, params).await) + } // Paged history reads of the new Native API (native types). "session/turns/list" => Some(self.handle_session_turns_list(id?, params).await), "session/items/list" => Some(self.handle_session_items_list(id?, params).await), @@ -1388,7 +1395,6 @@ mod tests { use devo_core::BundledSkillsConfig; use devo_core::FileSystemSkillCatalog; use devo_core::PresetModelCatalog; - use devo_core::ProviderVendorCatalog; use devo_core::SkillsConfig; use devo_core::tools::ToolRegistry; use devo_protocol::DEVO_ACTIVITY_AT_META; @@ -1472,7 +1478,6 @@ mod tests { crate::empty_mcp_manager(), "test-model".to_string(), model_catalog, - Arc::new(ProviderVendorCatalog::default()), Box::new(FileSystemSkillCatalog::new(SkillsConfig { bundled: Some(BundledSkillsConfig { enabled: false }), ..SkillsConfig::default() @@ -1609,7 +1614,6 @@ mod tests { mcp_manager, "test-model".to_string(), Arc::new(PresetModelCatalog::default()), - Arc::new(ProviderVendorCatalog::default()), Box::new(FileSystemSkillCatalog::new(SkillsConfig { bundled: Some(BundledSkillsConfig { enabled: false }), ..SkillsConfig::default() @@ -7347,8 +7351,8 @@ mod tests { } /// Trace: L2-DES-APP-008 - /// Verifies: native provider/list answers with camelCase vendor - /// entries and native provider/upsert (dual-shape via providerVendor) + /// Verifies: native provider/list answers with camelCase provider + /// entries and native provider/upsert /// writes through the legacy store path (ratified #11). #[tokio::test] async fn native_provider_list_and_upsert_round_trip() -> Result<()> { @@ -7367,6 +7371,23 @@ mod tests { let listed: devo_protocol::native::rpc_admin::ProviderListResult = serde_json::from_value(listed["result"].clone()).expect("native provider/list result"); assert!(listed.providers.is_empty()); + assert!(listed.template_provider_ids.is_empty()); + assert!(listed.connected_provider_ids.is_empty()); + let rejected = history_request( + &runtime, + connection_id, + 6, + "provider/model/remove", + serde_json::json!({ + "providerId": "test-provider", + "modelId": "test-model", + }), + ) + .await; + assert!( + rejected.get("error").is_some(), + "provider templates and unconnected providers cannot remove models: {rejected}" + ); let upserted = history_request( &runtime, @@ -7374,12 +7395,18 @@ mod tests { 8, "provider/upsert", serde_json::json!({ - "providerVendor": { + "provider": { "name": "test-provider", "baseUrl": "https://example.com/v1", "wireApis": ["openai_chat_completions"], "enabled": true, + "models": { + "test-model": { + "name": "Test model" + } + } }, + "defaultModel": "test-provider/test-model", }), ) .await; @@ -7405,6 +7432,118 @@ mod tests { Some("https://example.com/v1"), "native vendors must use camelCase keys: {listed}" ); + assert_eq!( + listed["result"]["connectedProviderIds"], + serde_json::json!(["test-provider"]) + ); + assert_eq!( + listed["result"]["connectionModels"]["test-provider"]["test-model"]["name"], + serde_json::json!("Test model") + ); + + let removed = history_request( + &runtime, + connection_id, + 10, + "provider/model/remove", + serde_json::json!({ + "providerId": "test-provider", + "modelId": "test-model", + }), + ) + .await; + assert!( + removed.get("error").is_none(), + "native provider/model/remove failed: {removed}" + ); + let listed = history_request( + &runtime, + connection_id, + 11, + "provider/list", + serde_json::json!({}), + ) + .await; + assert_eq!( + listed["result"]["connectionModels"]["test-provider"], + serde_json::json!({}) + ); + + let disconnected = history_request( + &runtime, + connection_id, + 12, + "provider/disconnect", + serde_json::json!({ "providerId": "test-provider" }), + ) + .await; + assert!( + disconnected.get("error").is_none(), + "native provider/disconnect failed: {disconnected}" + ); + let listed = history_request( + &runtime, + connection_id, + 13, + "provider/list", + serde_json::json!({}), + ) + .await; + assert_eq!( + listed["result"]["connectedProviderIds"], + serde_json::json!([]) + ); + assert!( + listed["result"]["providers"] + .as_array() + .is_some_and(Vec::is_empty) + ); + Ok(()) + } + + /// Trace: L2-DES-MODEL-002 + /// Verifies: native provider/list exposes the embedded provider directory + /// used by onboarding before any user provider has been configured. + #[tokio::test] + async fn native_provider_list_includes_embedded_provider_directory() -> Result<()> { + let data_root = TempDir::new()?; + let catalog = Arc::new(PresetModelCatalog::load_from_provider_config( + &devo_core::ProviderConfigFile::default(), + )?); + let runtime = build_runtime_with_provider_and_catalog( + data_root.path(), + Arc::new(NoopProvider), + catalog, + ); + let connection_id = initialized_native_connection(&runtime).await; + + let listed = history_request( + &runtime, + connection_id, + 7, + "provider/list", + serde_json::json!({}), + ) + .await; + let listed: devo_protocol::native::rpc_admin::ProviderListResult = + serde_json::from_value(listed["result"].clone()).expect("native provider/list result"); + + assert!(listed.providers.iter().any(|provider| { + provider.id == "deepseek" + && provider.name == "DeepSeek" + && provider.wire_apis == vec![devo_core::ProviderWireApi::AnthropicMessages] + })); + assert!(listed.providers.iter().any(|provider| { + provider.id == "zhipu" + && provider.base_url.as_deref() == Some("https://open.bigmodel.cn/api/paas/v4") + })); + assert!( + listed + .template_provider_ids + .contains(&"deepseek".to_string()) + ); + assert!(listed.template_provider_ids.contains(&"zhipu".to_string())); + assert!(listed.connected_provider_ids.is_empty()); Ok(()) } diff --git a/crates/server/src/runtime/context_occupancy.rs b/crates/server/src/runtime/context_occupancy.rs index d6a4baa1..79cab8bb 100644 --- a/crates/server/src/runtime/context_occupancy.rs +++ b/crates/server/src/runtime/context_occupancy.rs @@ -5,24 +5,38 @@ use devo_protocol::Model; use devo_protocol::native::item::ContextCategoryId; use devo_protocol::native::item::ContextOccupancy; -/// Resolve the applied compaction / effective-context limit for a model. +/// Resolve the applied compaction / occupancy limit for a model. /// -/// When a global `compaction_token_limit` is set, clamp it to the model's hard -/// `context_window`. Otherwise use the model's effective context window. -pub(crate) fn resolved_compaction_limit(global: Option, model: &Model) -> u64 { +/// Global `compaction_token_limit` and session overrides are ignored: the only +/// user-facing limit is the model's usable window +/// (`context_window × effective_context_window_percent / 100`). +pub(crate) fn resolved_compaction_limit(_global: Option, model: &Model) -> u64 { let model_window = u64::from(model.context_window.max(1)); - let model_effective = u64::from(model.effective_context_window()) + u64::from(model.effective_context_window()) .min(model_window) - .max(1); - match global.filter(|limit| *limit > 0) { - Some(limit) => limit.min(model_window).max(1), - None => model_effective, - } + .max(1) +} + +/// Window used for occupancy percent (bar denominator). +/// +/// Always the model effective window. Session overrides and global compaction +/// preferences are not applied (product: one Context window, stored as ratio). +pub(crate) fn occupancy_window_tokens( + _override_limit: Option, + model: Option<&Model>, + global_compaction_token_limit: Option, +) -> u64 { + let Some(model) = model else { + return 1; + }; + resolved_compaction_limit(global_compaction_token_limit, model) } /// Apply an absolute compaction limit onto session token-budget fields. pub(crate) fn apply_resolved_compaction_limit(config: &mut devo_core::SessionConfig, limit: usize) { - config.effective_context_window_override = Some(limit); + // Keep override cleared so mid-turn / resume paths do not revive a stale + // global threshold; the budget itself carries the applied model window. + config.effective_context_window_override = None; config.token_budget.context_window = limit; config.token_budget.auto_compact_token_limit = Some(limit); } @@ -77,9 +91,8 @@ mod tests { use super::*; - fn sample_model(context_window: u32, percent: u8) -> Model { + fn sample_model(context_window: u32, percent: f64) -> Model { Model { - slug: "test-model".to_string(), context_window, effective_context_window_percent: Some(percent), ..Model::default() @@ -87,23 +100,36 @@ mod tests { } #[test] - fn resolved_compaction_limit_uses_model_effective_when_global_unset() { - let model = sample_model(/*context_window*/ 200_000, /*percent*/ 95); - assert_eq!(resolved_compaction_limit(/*global*/ None, &model), 190_000); + fn resolved_compaction_limit_ignores_global() { + let model = sample_model(/*context_window*/ 200_000, /*percent*/ 95.0); + assert_eq!(resolved_compaction_limit(Some(100_000), &model), 190_000); + } + + #[test] + fn resolved_compaction_limit_uses_model_effective() { + let model = sample_model(/*context_window*/ 200_000, /*percent*/ 95.0); + assert_eq!(resolved_compaction_limit(None, &model), 190_000); } #[test] - fn resolved_compaction_limit_clamps_global_to_model_window() { - let model = sample_model(/*context_window*/ 200_000, /*percent*/ 95); - assert_eq!(resolved_compaction_limit(Some(250_000), &model), 200_000); - assert_eq!(resolved_compaction_limit(Some(100_000), &model), 100_000); + fn occupancy_window_tokens_uses_model_effective_only() { + let model = sample_model(/*context_window*/ 200_000, /*percent*/ 95.0); + assert_eq!( + occupancy_window_tokens(Some(100_000), Some(&model), Some(50_000)), + 190_000 + ); + assert_eq!(occupancy_window_tokens(None, Some(&model), None), 190_000); + assert_ne!(occupancy_window_tokens(None, Some(&model), None), 200_000); } #[test] - fn apply_resolved_compaction_limit_updates_token_budget() { - let mut config = devo_core::SessionConfig::default(); - apply_resolved_compaction_limit(&mut config, /*limit*/ 250_000); - assert_eq!(config.effective_context_window_override, Some(250_000)); + fn apply_resolved_compaction_limit_updates_budget_clears_override() { + let mut config = devo_core::SessionConfig { + effective_context_window_override: Some(50_000), + ..Default::default() + }; + apply_resolved_compaction_limit(&mut config, 250_000); + assert_eq!(config.effective_context_window_override, None); assert_eq!(config.token_budget.context_window, 250_000); assert_eq!(config.token_budget.auto_compact_token_limit, Some(250_000)); } diff --git a/crates/server/src/runtime/context_usage.rs b/crates/server/src/runtime/context_usage.rs index 93d691b3..c0055d60 100644 --- a/crates/server/src/runtime/context_usage.rs +++ b/crates/server/src/runtime/context_usage.rs @@ -1,5 +1,6 @@ -//! `context/usage/read` RPC handler. +//! `context/usage/read` RPC handler and mid-turn occupancy broadcasts. +use devo_core::RawContextBreakdown; use devo_core::SessionId; use devo_protocol::SuccessResponse; use devo_protocol::native::item::ContextOccupancy; @@ -7,9 +8,109 @@ use devo_protocol::native::rpc_admin::ContextUsageReadParams; use devo_protocol::native::rpc_admin::ContextUsageReadResult; use super::ServerRuntime; +use crate::ContextUsageUpdatedPayload; use crate::ProtocolErrorCode; +use crate::ServerEvent; impl ServerRuntime { + /// Publish a live context occupancy snapshot during an in-flight turn. + /// + /// Call only with a provider-anchored total (from `Usage` / `UsageDelta`). + /// The window always resolves to the same effective/compaction limit used + /// at finalize (and by TUI), never the raw hard model context window. + pub(super) async fn publish_live_context_occupancy( + &self, + session_id: SessionId, + context_window_hint: Option, + raw: RawContextBreakdown, + anchor_total: u64, + ) { + let window = self + .live_occupancy_window(session_id, context_window_hint) + .await + .max(1); + let occupancy = + super::context_occupancy::occupancy_from_raw(window, raw, anchor_total.max(1)); + if let Some(stream) = self.active_stream_state(session_id).await { + let mut stream = stream.lock().await; + if let Some(inline) = stream.turn_inline.as_mut() { + inline.summary.last_query_total_tokens = occupancy.total_tokens as usize; + inline.summary.last_context_occupancy = Some(occupancy.clone()); + inline.hook_context.summary = inline.summary.clone(); + } + } + self.broadcast_event(ServerEvent::ContextUsageUpdated( + ContextUsageUpdatedPayload { + session_id, + occupancy, + }, + )) + .await; + } + + async fn live_occupancy_window( + &self, + session_id: SessionId, + context_window_hint: Option, + ) -> u64 { + let global = self + .deps + .config_store + .lock() + .expect("app config store mutex should not be poisoned") + .effective_config() + .compaction_token_limit; + + if let Some(stream) = self.active_stream_state(session_id).await { + let stream = stream.lock().await; + if let Some(inline) = stream.turn_inline.as_ref() { + let model = inline + .summary + .model + .as_deref() + .and_then(|slug| self.deps.model_catalog.get(slug)) + .or_else(|| { + inline + .summary + .model_binding_id + .as_deref() + .and_then(|binding| self.deps.model_catalog.get(binding)) + }); + return super::context_occupancy::occupancy_window_tokens( + inline.hook_context.config.effective_context_window_override, + model, + global, + ); + } + } + + if let Some(summary) = self.session_summary_snapshot(session_id).await { + let model = summary + .model + .as_deref() + .and_then(|slug| self.deps.model_catalog.get(slug)) + .or_else(|| { + summary + .model_binding_id + .as_deref() + .and_then(|binding| self.deps.model_catalog.get(binding)) + }); + if let Some(occupancy) = summary.last_context_occupancy.as_ref() + && occupancy.context_window_tokens > 0 + && model.is_none() + { + return occupancy.context_window_tokens; + } + return super::context_occupancy::occupancy_window_tokens( + summary.effective_context_window.map(|limit| limit as usize), + model, + global, + ); + } + + context_window_hint.unwrap_or(1).max(1) + } + pub(super) async fn handle_context_usage_read( &self, request_id: serde_json::Value, diff --git a/crates/server/src/runtime/handlers/acp_config_options.rs b/crates/server/src/runtime/handlers/acp_config_options.rs index 51a71e75..fb1d6ee0 100644 --- a/crates/server/src/runtime/handlers/acp_config_options.rs +++ b/crates/server/src/runtime/handlers/acp_config_options.rs @@ -109,8 +109,8 @@ impl ServerRuntime { let updated = session_arc .update_session_metadata( - Some(turn_config.model.slug.clone()), - turn_config.model_binding_id.clone(), + Some(value.clone()), + None, turn_config.reasoning_effort_selection.clone(), None, ) @@ -178,8 +178,8 @@ impl ServerRuntime { let updated = session_arc .update_session_metadata( - Some(turn_config.model.slug.clone()), - turn_config.model_binding_id.clone(), + Some(canonical_model_selection(&turn_config)), + None, turn_config.reasoning_effort_selection.clone(), None, ) @@ -408,10 +408,7 @@ fn acp_model_config_option_for_turn_config( runtime_context: &SessionRuntimeContext, turn_config: &TurnConfig, ) -> AcpSessionConfigOption { - let current_value = turn_config - .model_binding_id - .clone() - .unwrap_or_else(|| turn_config.model.slug.clone()); + let current_value = canonical_model_selection(turn_config); let config = runtime_context .config_store .lock() @@ -419,40 +416,72 @@ fn acp_model_config_option_for_turn_config( .effective_config() .clone(); + let provider_catalog = config.provider_catalog_config(); let mut options = Vec::new(); let mut seen_values = BTreeSet::new(); - for (binding_id, binding) in &config.provider.model_bindings { - if !binding.enabled || !seen_values.insert(binding_id.clone()) { + for (provider_id, provider) in &provider_catalog.providers { + if provider.enabled == Some(false) { continue; } - let model_display_name = runtime_context - .model_catalog - .get(&binding.model_slug) - .map(|model| model.display_name.as_str()) - .and_then(non_empty_str); - let name = binding - .display_name + let provider_name = provider + .name .as_deref() .and_then(non_empty_str) - .or(model_display_name) - .unwrap_or(binding.model_slug.as_str()) - .to_string(); - let provider_name = config - .provider - .providers - .get(&binding.provider) - .map(|provider| provider.name.as_str()) - .and_then(non_empty_str) - .unwrap_or(binding.provider.as_str()); - options.push(AcpSessionConfigSelectOption { - value: binding_id.clone(), - name, - description: Some(format!( - "{provider_name}: {} via {}", - binding.request_model, binding.invocation_method - )), - meta: None, - }); + .unwrap_or(provider_id.as_str()); + let provider_wire_api = provider + .wire_api + .unwrap_or(devo_protocol::ProviderWireApi::OpenAIChatCompletions); + for (model_id, model) in &provider.models { + if model.enabled == Some(false) { + continue; + } + let model_value = format!("{provider_id}/{model_id}"); + if !seen_values.insert(model_value.clone()) { + continue; + } + let model_display_name = runtime_context + .model_catalog + .get(&model_value) + .map(|model| model.display_name.as_str()) + .and_then(non_empty_str); + let name = model + .name + .as_deref() + .and_then(non_empty_str) + .or(model_display_name) + .unwrap_or(model_id.as_str()) + .to_string(); + let wire_api = model.wire_api.unwrap_or(provider_wire_api); + options.push(AcpSessionConfigSelectOption { + value: model_value.clone(), + name: name.clone(), + description: Some(format!("{provider_name}: {model_id} via {wire_api}")), + meta: None, + }); + + for (variant_id, variant) in &model.variants { + if variant.disabled { + continue; + } + let value = format!("{model_value}/{variant_id}"); + if !seen_values.insert(value.clone()) { + continue; + } + let variant_name = variant + .label + .clone() + .filter(|label| !label.trim().is_empty()) + .unwrap_or_else(|| variant_id.replace(['-', '_'], " ")); + options.push(AcpSessionConfigSelectOption { + value, + name: format!("{name} ({variant_name})"), + description: Some(format!( + "{provider_name}: {variant_id} variant for {model_id}" + )), + meta: None, + }); + } + } } if !seen_values.contains(¤t_value) { @@ -480,6 +509,20 @@ fn acp_model_config_option_for_turn_config( } } +fn canonical_model_selection(turn_config: &TurnConfig) -> String { + let base = match &turn_config.provider_route { + devo_provider::ProviderRoute::Connection { provider_id, .. } => { + format!("{provider_id}/{}", turn_config.request_model) + } + devo_provider::ProviderRoute::Default => turn_config.model.slug.clone(), + }; + turn_config + .variant + .as_deref() + .map(|variant| format!("{base}/{variant}")) + .unwrap_or(base) +} + fn acp_reasoning_effort_config_option_for_session( runtime_context: &SessionRuntimeContext, summary: &SessionMetadata, diff --git a/crates/server/src/runtime/handlers/compaction.rs b/crates/server/src/runtime/handlers/compaction.rs index 1a59bfed..661ceca7 100644 --- a/crates/server/src/runtime/handlers/compaction.rs +++ b/crates/server/src/runtime/handlers/compaction.rs @@ -220,6 +220,7 @@ impl ServerRuntime { CompactionTurnOutcome::Failed { message: "compaction failed: panicked".to_string(), }, + /*compaction_item_id*/ None, ) .await; // If the panic happened after claim, finalize is a no-op — still @@ -343,6 +344,7 @@ impl ServerRuntime { CompactionTurnOutcome::Failed { message: "compaction failed: session unavailable".to_string(), }, + /*compaction_item_id*/ None, ) .await; return; @@ -355,6 +357,16 @@ impl ServerRuntime { }, )) .await; + // Surface "Compacting context" in the Desktop transcript as soon as + // manual compaction begins — not only after summarization finishes. + let compaction_item_id = devo_core::ItemId::new(); + self.broadcast_event(super::super::turn_exec::manual_compaction_started_event( + session_id, + turn.turn_id, + compaction_item_id, + /*item_seq*/ None, + )) + .await; self.run_session_hook( session_id, devo_core::HookEvent::PreCompact, @@ -384,6 +396,7 @@ impl ServerRuntime { CompactionTurnOutcome::Failed { message: "compaction failed: session unavailable".to_string(), }, + Some(compaction_item_id), ) .await; return; @@ -454,6 +467,7 @@ impl ServerRuntime { CompactionTurnOutcome::Failed { message: "compaction failed: session unavailable".to_string(), }, + Some(compaction_item_id), ) .await; return; @@ -504,6 +518,7 @@ impl ServerRuntime { session_id, turn, CompactionTurnOutcome::Canceled, + Some(compaction_item_id), ) .await; } @@ -515,6 +530,7 @@ impl ServerRuntime { session_id, turn, CompactionTurnOutcome::Canceled, + Some(compaction_item_id), ) .await; return; @@ -529,6 +545,7 @@ impl ServerRuntime { CompactionTurnOutcome::Failed { message: "compaction failed: session unavailable".to_string(), }, + Some(compaction_item_id), ) .await; return; @@ -684,16 +701,11 @@ impl ServerRuntime { } let turn_id = turn.turn_id; - let item_id = devo_core::ItemId::new(); + let item_id = compaction_item_id; let item_seq = runtime_session.next_item_seq; runtime_session.loaded_item_count += 1; runtime_session.next_item_seq += 1; - self.broadcast_event(super::super::turn_exec::manual_compaction_started_event( - session_id, turn_id, item_id, item_seq, - )) - .await; - self.broadcast_event(super::super::turn_exec::manual_compaction_completed_event( session_id, turn_id, item_id, item_seq, )) @@ -833,6 +845,7 @@ impl ServerRuntime { session_id, turn, CompactionTurnOutcome::Skipped, + Some(compaction_item_id), ) .await; } @@ -851,6 +864,7 @@ impl ServerRuntime { CompactionTurnOutcome::Failed { message: format!("compaction failed: {error}"), }, + Some(compaction_item_id), ) .await; } @@ -867,6 +881,7 @@ impl ServerRuntime { session_id: SessionId, mut turn: TurnMetadata, outcome: CompactionTurnOutcome, + compaction_item_id: Option, ) { // Ensure interrupt abort cannot drop us between claim and event emit. self.detach_active_turn_abort(session_id).await; @@ -902,6 +917,46 @@ impl ServerRuntime { ); } + // Close the early-emitted started item so Desktop does not leave a + // dangling "Compacting context" divider when compact does not replace. + if let Some(item_id) = compaction_item_id { + match &outcome { + CompactionTurnOutcome::Skipped => { + self.broadcast_event( + super::super::turn_exec::manual_compaction_completed_event( + session_id, + turn.turn_id, + item_id, + /*item_seq*/ 0, + ), + ) + .await; + } + CompactionTurnOutcome::Failed { message } => { + self.broadcast_event( + super::super::turn_exec::manual_compaction_item_failed_event( + session_id, + turn.turn_id, + item_id, + message.clone(), + ), + ) + .await; + } + CompactionTurnOutcome::Canceled => { + self.broadcast_event( + super::super::turn_exec::manual_compaction_item_failed_event( + session_id, + turn.turn_id, + item_id, + "compaction canceled".to_string(), + ), + ) + .await; + } + } + } + match outcome { CompactionTurnOutcome::Skipped => { let Some(summary) = session_handle.summary().await else { @@ -942,7 +997,7 @@ impl ServerRuntime { devo_protocol::SessionCompactionCompletedPayload { session: summary, turn_id: turn.turn_id, - item_id: None, + item_id: compaction_item_id, }, )) .await; diff --git a/crates/server/src/runtime/handlers/queue.rs b/crates/server/src/runtime/handlers/queue.rs index 69a9c859..3c102d2c 100644 --- a/crates/server/src/runtime/handlers/queue.rs +++ b/crates/server/src/runtime/handlers/queue.rs @@ -896,6 +896,7 @@ pub(crate) fn native_turn_from_metadata(turn: &crate::turn::TurnMetadata) -> Nat } else { turn.request_model.clone() }, + variant: None, reasoning_effort: turn .reasoning_effort_selection .as_deref() diff --git a/crates/server/src/runtime/handlers/session.rs b/crates/server/src/runtime/handlers/session.rs index 610d9aa4..cf4e6026 100644 --- a/crates/server/src/runtime/handlers/session.rs +++ b/crates/server/src/runtime/handlers/session.rs @@ -754,37 +754,11 @@ impl ServerRuntime { overlay_mode = Some(mode); } if settings.effective_context_window != current.effective_context_window - && let Some(window) = settings.effective_context_window + && settings.effective_context_window.is_some() { - if window == 0 { - return self.error_response( - request_id, - ProtocolErrorCode::InvalidParams, - "effectiveContextWindow must be at least 1", - ); - } - // Durability target is the global config.toml (L2-DES-CONV-002 - // DD-6); no field line is written. The canonical path applies - // only to the addressed session — the legacy compaction - // handler keeps the all-sessions fan-out until Phase C. - { - let mut store = self - .deps - .config_store - .lock() - .expect("app config store mutex should not be poisoned"); - if let Err(error) = store.set_compaction_token_limit(window) { - return self.error_response( - request_id, - ProtocolErrorCode::InternalError, - format!("failed to persist compaction_token_limit: {error}"), - ); - } - } - self.deps.invalidate_workspace_contexts(); - // Resolve the model through the same two-catalog chain the - // legacy handler used: the workspace runtime context's - // catalog first, then the deps catalog (mailbox-free). + // Product: auto-compact threshold is removed. Ignore patches that + // try to set a global/session absolute limit; echo the model + // effective window for older clients. let workspace_catalog = self .deps .context_for_workspace(&session_cwd) @@ -797,8 +771,7 @@ impl ServerRuntime { .or_else(|| self.deps.model_catalog.get(&session_model_slug).cloned()); if let Some(model) = model { let applied = crate::runtime::context_occupancy::resolved_compaction_limit( - Some(window), - &model, + /*global*/ None, &model, ); if let Some(handle) = session_handle.as_ref() { handle.notify_effective_context_window(applied as usize); @@ -808,17 +781,31 @@ impl ServerRuntime { } } } - if let Some(binding) = ¶ms.model - && binding.model != session_model_slug - { - if rollout_path.is_some() { - settings_changes.push(( - SessionSettingsField::Model, - serde_json::to_value(Some(binding.model.clone())) - .expect("serialize model setting"), - )); + if let Some(binding) = ¶ms.model { + let mut model_selection = if binding.provider.trim().is_empty() + || binding + .model + .starts_with(&format!("{}/", binding.provider.trim())) + { + binding.model.clone() + } else { + format!("{}/{}", binding.provider.trim(), binding.model) + }; + if let Some(variant) = binding.variant.as_deref() + && !model_selection.ends_with(&format!("/{variant}")) + { + model_selection = format!("{model_selection}/{variant}"); + } + if model_selection != session_model_slug { + if rollout_path.is_some() { + settings_changes.push(( + SessionSettingsField::Model, + serde_json::to_value(Some(model_selection.clone())) + .expect("serialize model setting"), + )); + } + overlay_model = Some(model_selection); } - overlay_model = Some(binding.model.clone()); } // A model slug and its provider binding are one logical selection. A @@ -831,14 +818,13 @@ impl ServerRuntime { } else { params.model_binding_id.clone().map(Some) }; - if let Some(model_binding_id) = &model_binding_update { - if rollout_path.is_some() { - settings_changes.push(( - SessionSettingsField::ModelBindingId, - serde_json::to_value(model_binding_id) - .expect("serialize model binding setting"), - )); - } + if let Some(model_binding_id) = &model_binding_update + && rollout_path.is_some() + { + settings_changes.push(( + SessionSettingsField::ModelBindingId, + serde_json::to_value(model_binding_id).expect("serialize model binding setting"), + )); } if let Some(path) = rollout_path.as_ref() && !settings_changes.is_empty() @@ -866,26 +852,25 @@ impl ServerRuntime { // One consolidated metadata notification carrying every field's new // or current value: the actor overwrites absent fields on non- // mode-only updates, so partial notifications would wipe them. - if overlay_model.is_some() + if (overlay_model.is_some() || overlay_effort.is_some() || overlay_mode.is_some() - || model_binding_update.is_some() + || model_binding_update.is_some()) + && let Some(handle) = session_handle.as_ref() { - if let Some(handle) = session_handle.as_ref() { - handle.notify_session_metadata( - Some( - overlay_model - .clone() - .or(current_model_slug) - .unwrap_or_else(|| session_model_slug.clone()), - ), - model_binding_update + handle.notify_session_metadata( + Some( + overlay_model .clone() - .unwrap_or_else(|| current_binding_id.clone()), - overlay_effort.clone().or(current_effort), - overlay_mode, - ); - } + .or(current_model_slug) + .unwrap_or_else(|| session_model_slug.clone()), + ), + model_binding_update + .clone() + .unwrap_or_else(|| current_binding_id.clone()), + overlay_effort.clone().or(current_effort), + overlay_mode, + ); } // Phase 3: deliver the override to the running turn's inline state, @@ -1042,50 +1027,11 @@ impl ServerRuntime { }; Self::native_session_from_index_metadata(index_metadata, legacy_session_id) }; - // The compaction limit's durability target is config.toml, so the - // rollout re-read does not reflect it; echo the clamped applied value. + // Echo applied model effective window for older clients. Do not fan out + // a global compaction preference — that product surface is removed. if let Some(applied) = applied_window { session.settings.effective_context_window = Some(applied); } - // Compaction settings are global, so update loaded sibling sessions - // after the addressed session has been persisted. This keeps the - // canonical settings patch behavior identical for every session - // without reintroducing a standalone compaction RPC. - if let Some(global) = params - .settings - .as_ref() - .and_then(|settings| settings.effective_context_window) - { - for handle in self.list_session_handles().await { - if handle.id() == legacy_session_id { - continue; - } - let session_model = self - .deps - .db - .get_session_index(&handle.id()) - .ok() - .flatten() - .and_then(|index| index.metadata.model.or(index.metadata.model_binding_id)) - .and_then(|slug| self.deps.model_catalog.get(&slug).cloned()); - let Some(session_model) = session_model else { - continue; - }; - let applied_for_session = - crate::runtime::context_occupancy::resolved_compaction_limit( - Some(global), - &session_model, - ); - handle.notify_effective_context_window(applied_for_session as usize); - self.broadcast_event(ServerEvent::SessionEffectiveContextWindowUpdated( - SessionEffectiveContextWindowUpdatedPayload { - session_id: handle.id(), - effective_context_window: applied_for_session, - }, - )) - .await; - } - } serde_json::to_value(SuccessResponse { id: request_id, result: devo_protocol::native::rpc_session::SessionMetadataUpdateResult { @@ -1154,6 +1100,7 @@ impl ServerRuntime { .clone() .unwrap_or_else(|| "unknown".to_string()), model: metadata.model.clone().unwrap_or_default(), + variant: None, reasoning_effort: metadata .reasoning_effort_selection .as_deref() @@ -1841,14 +1788,14 @@ impl ServerRuntime { ); }; - if let Some(legacy_turn_id) = fork_at_turn_id { - if self.runtime_active_turn_id(legacy_session_id).await == Some(legacy_turn_id) { - return self.error_response( - request_id, - ProtocolErrorCode::ForkTurnNotStable, - "atTurnId names an in-progress turn", - ); - } + if let Some(legacy_turn_id) = fork_at_turn_id + && self.runtime_active_turn_id(legacy_session_id).await == Some(legacy_turn_id) + { + return self.error_response( + request_id, + ProtocolErrorCode::ForkTurnNotStable, + "atTurnId names an in-progress turn", + ); } let user_turn_index = match fork_at_turn_id { diff --git a/crates/server/src/runtime/handlers/session_fork.rs b/crates/server/src/runtime/handlers/session_fork.rs index 6d1ea92a..f13b6cb4 100644 --- a/crates/server/src/runtime/handlers/session_fork.rs +++ b/crates/server/src/runtime/handlers/session_fork.rs @@ -7,7 +7,6 @@ use std::collections::HashSet; use chrono::Utc; -use tracing; use super::super::*; use crate::execution::PersistedTurnItem; diff --git a/crates/server/src/runtime/handlers/turn.rs b/crates/server/src/runtime/handlers/turn.rs index fd8bd516..08168159 100644 --- a/crates/server/src/runtime/handlers/turn.rs +++ b/crates/server/src/runtime/handlers/turn.rs @@ -270,23 +270,10 @@ impl ServerRuntime { .config_store .lock() .expect("app config store mutex should not be poisoned"); - let provider_config = &config_store.effective_config().provider; - match provider_config.model_bindings.get(binding_id) { - None => Some(format!("model binding `{binding_id}` does not exist")), - Some(binding) if !binding.enabled => { - Some(format!("model binding `{binding_id}` is disabled")) - } - Some(binding) => match provider_config.providers.get(&binding.provider) { - None => Some(format!( - "model binding `{binding_id}` references missing provider `{}`", - binding.provider - )), - Some(provider) if !provider.enabled => Some(format!( - "model binding `{binding_id}` references disabled provider `{}`", - binding.provider - )), - Some(_) => None, - }, + let provider_config = config_store.effective_config().provider_catalog_config(); + match provider_config.resolve_model(Some(binding_id)) { + Ok(_) => None, + Err(error) => Some(error.to_string()), } }; if let Some(error) = binding_error { diff --git a/crates/server/src/runtime/model_api.rs b/crates/server/src/runtime/model_api.rs index 10ce913b..ac484867 100644 --- a/crates/server/src/runtime/model_api.rs +++ b/crates/server/src/runtime/model_api.rs @@ -1,4 +1,6 @@ +use devo_core::ModelCatalog; use devo_core::ModelCatalogEntry; +use devo_core::PresetModelCatalog; use devo_protocol::native::rpc_admin::ModelPreferences; use devo_protocol::native::rpc_admin::PreferencesOption; @@ -217,7 +219,7 @@ impl ServerRuntime { .expect("app config store mutex should not be poisoned"); store .user_config_dir() - .join("config.toml") + .join(devo_core::PROVIDER_CONFIG_FILE_NAME) .display() .to_string() }; @@ -274,15 +276,36 @@ impl ServerRuntime { format!("invalid canonical model/list params: {error}"), ); } - let models = self - .deps - .model_catalog - .list_visible() - .into_iter() - .map(|model| { - devo_protocol::native::rpc_admin::ModelInfo::from(ModelCatalogEntry::from(model)) - }) - .collect(); + let configured = { + let store = self + .deps + .config_store + .lock() + .expect("app config store mutex should not be poisoned"); + let config = store.effective_config(); + ( + config.provider_catalog.clone(), + config.provider.model_overrides.clone(), + ) + }; + let models = if let Ok(catalog) = + PresetModelCatalog::load_from_provider_config_with_overrides( + &configured.0, + &configured.1, + ) { + catalog + .list_visible() + .into_iter() + .map(|model| model_info_from_catalog_model(model, &catalog)) + .collect() + } else { + self.deps + .model_catalog + .list_visible() + .into_iter() + .map(|model| model_info_from_catalog_model(model, self.deps.model_catalog.as_ref())) + .collect() + }; serde_json::to_value(SuccessResponse { id: request_id, result: devo_protocol::native::rpc_admin::ModelListResult { models }, @@ -290,3 +313,18 @@ impl ServerRuntime { .expect("serialize canonical model/list response") } } + +fn model_info_from_catalog_model( + model: &devo_protocol::Model, + catalog: &dyn ModelCatalog, +) -> devo_protocol::native::rpc_admin::ModelInfo { + let info = devo_protocol::native::rpc_admin::ModelInfo::from(ModelCatalogEntry::from(model)); + let Some((provider_id, model_id)) = model.slug.split_once('/') else { + return info; + }; + let mut provider_models = catalog.list_provider_models(provider_id); + let Some(metadata) = provider_models.remove(model_id) else { + return info; + }; + info.with_provider_metadata(provider_id.to_string(), model_id.to_string(), metadata) +} diff --git a/crates/server/src/runtime/provider_api.rs b/crates/server/src/runtime/provider_api.rs new file mode 100644 index 00000000..769d6a15 --- /dev/null +++ b/crates/server/src/runtime/provider_api.rs @@ -0,0 +1,692 @@ +use anyhow::Context; +use devo_core::AUTH_CONFIG_FILE_NAME; +use devo_core::Model; +use devo_core::ModelCatalog; +use devo_core::PROVIDER_CONFIG_FILE_NAME; +use devo_core::PresetModelCatalog; +use devo_core::ProviderHttpConfig; +use devo_core::UserAuthConfigFile; +use devo_core::read_user_auth_config; +use devo_core::test_model_connection; +use devo_protocol::ModelProfileKey; +use devo_provider::ProviderHttpOptions; +use devo_util_paths::current_user_config_file; + +use crate::ProtocolErrorCode; +use crate::SuccessResponse; + +use super::ServerRuntime; + +impl ServerRuntime { + /// Native `provider/list` (ratified #11), backed by the bundled provider + /// directory plus the effective config store and projected into the + /// canonical camelCase result. + pub(super) async fn handle_native_provider_list( + &self, + request_id: serde_json::Value, + ) -> serde_json::Value { + let mut providers = self.deps.model_catalog.list_providers(); + let template_provider_ids = self.deps.model_catalog.list_template_provider_ids(); + let store = self + .deps + .config_store + .lock() + .expect("app config store mutex should not be poisoned"); + let config = store.effective_config(); + let live_catalog = PresetModelCatalog::load_from_provider_config_with_overrides( + &config.provider_catalog, + &config.provider.model_overrides, + ) + .ok(); + let catalog: &dyn ModelCatalog = live_catalog + .as_ref() + .map(|catalog| catalog as &dyn ModelCatalog) + .unwrap_or(self.deps.model_catalog.as_ref()); + let connected_provider_ids = match store.provider_connection_ids() { + Ok(provider_ids) => provider_ids, + Err(error) => { + return self.error_response( + request_id, + ProtocolErrorCode::InternalError, + format!("failed to read provider connections: {error}"), + ); + } + }; + let connection_models = match store.provider_connection_models() { + Ok(models) => models, + Err(error) => { + return self.error_response( + request_id, + ProtocolErrorCode::InternalError, + format!("failed to read provider Connection models: {error}"), + ); + } + }; + let configured_providers = match store.provider_connections() { + Ok(providers) => providers, + Err(error) => { + return self.error_response( + request_id, + ProtocolErrorCode::InternalError, + format!("failed to read provider Connections: {error}"), + ); + } + }; + for configured in configured_providers { + if let Some(directory_entry) = + providers.iter_mut().find(|entry| entry.id == configured.id) + { + *directory_entry = configured; + } else { + providers.push(configured); + } + } + let providers = providers + .into_iter() + .map(|provider| { + let mut info = canonical_provider_info(provider, catalog); + if let Some(config) = store + .effective_config() + .provider_catalog + .providers + .get(&info.id) + { + info.options = config.options.clone(); + info.request = config.request.clone(); + if let Some(headers) = &config.headers { + info.headers = headers.clone(); + } + } + info + }) + .collect(); + serde_json::to_value(SuccessResponse { + id: request_id, + result: devo_protocol::native::rpc_admin::ProviderListResult { + providers, + template_provider_ids, + connected_provider_ids, + connection_models, + }, + }) + .expect("serialize canonical provider/list response") + } + + /// Native `provider/upsert` (ratified #11). + pub(super) async fn handle_native_provider_upsert( + &self, + request_id: serde_json::Value, + params: serde_json::Value, + ) -> serde_json::Value { + let params: devo_protocol::native::rpc_admin::ProviderUpsertParams = + match serde_json::from_value(params) { + Ok(params) => params, + Err(error) => { + return self.error_response( + request_id, + ProtocolErrorCode::InvalidParams, + format!("invalid canonical provider/upsert params: {error}"), + ); + } + }; + let Some(_provider_id) = normalized_provider_id(¶ms.provider.id) + .or_else(|| normalized_provider_id(¶ms.provider.name)) + else { + return self.error_response( + request_id, + ProtocolErrorCode::InvalidParams, + "provider name cannot be empty", + ); + }; + let config_file = { + let store = self + .deps + .config_store + .lock() + .expect("app config store mutex should not be poisoned"); + store + .user_config_dir() + .join(PROVIDER_CONFIG_FILE_NAME) + .display() + .to_string() + }; + if let Some(reason) = self + .config_change_hook_block_reason("user_settings", Some(config_file)) + .await + { + return self.error_response( + request_id, + ProtocolErrorCode::PolicyDenied, + format!("config change blocked by hook: {reason}"), + ); + } + + let mut store = self + .deps + .config_store + .lock() + .expect("app config store mutex should not be poisoned"); + let default_model = params.default_model.clone(); + let small_model = params.small_model.clone(); + let provider = match store.upsert_provider_connection( + params.provider, + params.default_model, + params.small_model, + params.api_key, + ) { + Ok(provider) => provider, + Err(error) => { + return self.error_response( + request_id, + ProtocolErrorCode::InternalError, + error.to_string(), + ); + } + }; + drop(store); + self.deps.invalidate_workspace_contexts(); + + serde_json::to_value(SuccessResponse { + id: request_id, + result: devo_protocol::native::rpc_admin::ProviderUpsertResult { + provider, + default_model, + small_model, + }, + }) + .expect("serialize canonical provider/upsert response") + } + + /// Native provider/disconnect removes one user Connection while leaving + /// the built-in provider directory untouched. + pub(super) async fn handle_native_provider_disconnect( + &self, + request_id: serde_json::Value, + params: serde_json::Value, + ) -> serde_json::Value { + let params: devo_protocol::native::rpc_admin::ProviderDisconnectParams = + match serde_json::from_value(params) { + Ok(params) => params, + Err(error) => { + return self.error_response( + request_id, + ProtocolErrorCode::InvalidParams, + format!("invalid canonical provider/disconnect params: {error}"), + ); + } + }; + let provider_id = params.provider_id.trim(); + if provider_id.is_empty() { + return self.error_response( + request_id, + ProtocolErrorCode::InvalidParams, + "provider_id cannot be empty", + ); + } + let config_file = { + let store = self + .deps + .config_store + .lock() + .expect("app config store mutex should not be poisoned"); + store + .user_config_dir() + .join(PROVIDER_CONFIG_FILE_NAME) + .display() + .to_string() + }; + if let Some(reason) = self + .config_change_hook_block_reason("user_settings", Some(config_file)) + .await + { + return self.error_response( + request_id, + ProtocolErrorCode::PolicyDenied, + format!("config change blocked by hook: {reason}"), + ); + } + + let mut store = self + .deps + .config_store + .lock() + .expect("app config store mutex should not be poisoned"); + if let Err(error) = store.disconnect_provider(provider_id) { + return self.error_response( + request_id, + ProtocolErrorCode::InternalError, + error.to_string(), + ); + } + drop(store); + self.deps.invalidate_workspace_contexts(); + + serde_json::to_value(SuccessResponse { + id: request_id, + result: devo_protocol::native::rpc_admin::ProviderDisconnectResult { + provider_id: provider_id.to_string(), + }, + }) + .expect("serialize canonical provider/disconnect response") + } + + /// Native provider/model/remove removes one model from a user Connection + /// while leaving the provider template and its built-in models untouched. + pub(super) async fn handle_native_provider_model_remove( + &self, + request_id: serde_json::Value, + params: serde_json::Value, + ) -> serde_json::Value { + let params: devo_protocol::native::rpc_admin::ProviderModelRemoveParams = + match serde_json::from_value(params) { + Ok(params) => params, + Err(error) => { + return self.error_response( + request_id, + ProtocolErrorCode::InvalidParams, + format!("invalid canonical provider/model/remove params: {error}"), + ); + } + }; + let provider_id = params.provider_id.trim(); + let model_id = params.model_id.trim(); + if provider_id.is_empty() || model_id.is_empty() { + return self.error_response( + request_id, + ProtocolErrorCode::InvalidParams, + "provider_id and model_id cannot be empty", + ); + } + let config_file = { + let store = self + .deps + .config_store + .lock() + .expect("app config store mutex should not be poisoned"); + store + .user_config_dir() + .join(PROVIDER_CONFIG_FILE_NAME) + .display() + .to_string() + }; + if let Some(reason) = self + .config_change_hook_block_reason("user_settings", Some(config_file)) + .await + { + return self.error_response( + request_id, + ProtocolErrorCode::PolicyDenied, + format!("config change blocked by hook: {reason}"), + ); + } + + let mut store = self + .deps + .config_store + .lock() + .expect("app config store mutex should not be poisoned"); + let connected_provider_ids = match store.provider_connection_ids() { + Ok(provider_ids) => provider_ids, + Err(error) => { + return self.error_response( + request_id, + ProtocolErrorCode::InternalError, + format!("failed to read provider connections: {error}"), + ); + } + }; + if !connected_provider_ids.iter().any(|id| id == provider_id) { + return self.error_response( + request_id, + ProtocolErrorCode::InvalidParams, + format!("provider {provider_id} is not a user Connection"), + ); + } + if let Err(error) = store.remove_provider_model(provider_id, model_id) { + return self.error_response( + request_id, + ProtocolErrorCode::InternalError, + error.to_string(), + ); + } + drop(store); + self.deps.invalidate_workspace_contexts(); + + serde_json::to_value(SuccessResponse { + id: request_id, + result: devo_protocol::native::rpc_admin::ProviderModelRemoveResult { + provider_id: provider_id.to_string(), + model_id: model_id.to_string(), + }, + }) + .expect("serialize canonical provider/model/remove response") + } + + /// Native `provider/validate` (ratified #11). + pub(super) async fn handle_native_provider_validate( + &self, + request_id: serde_json::Value, + params: serde_json::Value, + ) -> serde_json::Value { + let params: devo_protocol::native::rpc_admin::ProviderValidateParams = + match serde_json::from_value(params) { + Ok(params) => params, + Err(error) => { + return self.error_response( + request_id, + ProtocolErrorCode::InvalidParams, + format!("invalid canonical provider/validate params: {error}"), + ); + } + }; + let Some(provider_id) = normalized_provider_id(¶ms.provider.id) + .or_else(|| normalized_provider_id(¶ms.provider.name)) + else { + return self.error_response( + request_id, + ProtocolErrorCode::InvalidParams, + "provider name cannot be empty", + ); + }; + if params.model.trim().is_empty() { + return self.error_response( + request_id, + ProtocolErrorCode::InvalidParams, + "model cannot be empty", + ); + } + let _ = provider_id; + let provider_http = { + let store = self + .deps + .config_store + .lock() + .expect("app config store mutex should not be poisoned"); + store.effective_config().provider_http.clone() + }; + + match validate_provider_candidate(params, self.deps.model_catalog.as_ref(), provider_http) + .await + { + Ok(reply_preview) => serde_json::to_value(SuccessResponse { + id: request_id, + result: devo_protocol::native::rpc_admin::ProviderValidateResult { reply_preview }, + }) + .expect("serialize canonical provider/validate response"), + Err(error) => self.error_response( + request_id, + ProtocolErrorCode::InternalError, + error.to_string(), + ), + } + } +} + +/// Completes a provider entry with the model metadata from the bundled +/// directory when the caller supplied only a connection and model id. +/// +/// Connection overlays win on key conflicts. Disabled models remain present +/// so settings UIs can show them with `enabled=false`. +fn canonical_provider_info( + mut provider: devo_protocol::ProviderInfo, + catalog: &dyn ModelCatalog, +) -> devo_protocol::ProviderInfo { + let catalog_models = catalog.list_provider_models(&provider.id); + if provider.models.is_empty() { + provider.models = catalog_models; + } else if !catalog_models.is_empty() { + let mut merged = catalog_models; + for (model_id, model) in provider.models { + merged.insert(model_id, model); + } + provider.models = merged; + } + provider +} +fn normalized_provider_id(name: &str) -> Option { + let trimmed = name.trim(); + if trimmed.is_empty() { + None + } else { + Some(trimmed.to_string()) + } +} + +async fn validate_provider_candidate( + params: devo_protocol::native::rpc_admin::ProviderValidateParams, + catalog: &dyn ModelCatalog, + provider_http: ProviderHttpConfig, +) -> anyhow::Result { + let provider_id = normalized_provider_id(¶ms.provider.id) + .or_else(|| normalized_provider_id(¶ms.provider.name)) + .context("provider name cannot be empty")?; + if params.provider.wire_apis.is_empty() { + anyhow::bail!("wire_apis must contain at least one wire API"); + } + + let requested_model_id = params + .model + .strip_prefix(&format!("{provider_id}/")) + .unwrap_or(¶ms.model); + let (model_id, model_info) = if let Some(model) = params.provider.models.get(requested_model_id) + { + (requested_model_id.to_string(), model) + } else if let Some((base_model_id, variant_id)) = requested_model_id.rsplit_once('/') { + let model = params + .provider + .models + .get(base_model_id) + .filter(|model| model.variants.contains_key(variant_id)) + .context("model variant is not present in provider directory")?; + (base_model_id.to_string(), model) + } else { + anyhow::bail!( + "model {} is not present in provider directory", + params.model + ); + }; + let wire_api = model_info + .wire_api + .or_else(|| params.provider.wire_apis.first().copied()) + .context("provider has no usable wire API")?; + if !params.provider.wire_apis.contains(&wire_api) { + anyhow::bail!("model wire API must be supported by provider"); + } + + let model_ref = format!("{provider_id}/{model_id}"); + let (validation_model, model_profile) = resolve_validation_model(catalog, wire_api, &model_ref); + let api_key = resolve_validation_api_key(&provider_id, ¶ms)?; + let headers = (!params.provider.headers.is_empty()) + .then(|| serde_json::to_string(¶ms.provider.headers)) + .transpose()?; + let provider = crate::provider_config::build_provider_adapter( + wire_api, + params.provider.base_url.clone(), + api_key, + ProviderHttpOptions::from_raw_with_no_proxy( + provider_http.proxy_url, + provider_http.no_proxy, + headers, + )?, + )?; + + test_model_connection( + provider.as_ref(), + &validation_model, + model_profile, + requested_model_id, + "Reply with OK only.", + ) + .await + .map_err(Into::into) +} +fn resolve_validation_model( + catalog: &dyn ModelCatalog, + wire_api: devo_core::ProviderWireApi, + model_slug: &str, +) -> (Model, ModelProfileKey) { + if let Some(entry) = catalog.get(model_slug) { + let mut model = entry.clone(); + model.provider = wire_api; + return (model, ModelProfileKey::CatalogSlug(model_slug.to_string())); + } + ( + Model { + slug: model_slug.to_string(), + display_name: model_slug.to_string(), + provider: wire_api, + ..Model::default() + }, + ModelProfileKey::Generic, + ) +} + +fn resolve_validation_api_key( + provider_id: &str, + params: &devo_protocol::native::rpc_admin::ProviderValidateParams, +) -> anyhow::Result> { + if let Some(api_key) = params.api_key.as_deref() { + let trimmed = api_key.trim(); + if !trimmed.is_empty() { + return Ok(Some(trimmed.to_string())); + } + } + + let Some(credential_id) = params.provider.credential.as_deref() else { + return Ok(None); + }; + let auth = current_server_user_auth_config()?; + let credential = auth.credentials.get(credential_id).with_context(|| { + format!( + "provider {provider_id} references missing credential {credential_id} in user auth.json" + ) + })?; + Ok(Some(credential.value.clone())) +} + +fn current_server_user_auth_config() -> anyhow::Result { + let config_file = current_user_config_file().context("could not determine user config path")?; + let config_dir = config_file + .parent() + .context("user config path has no parent directory")?; + read_user_auth_config(&config_dir.join(AUTH_CONFIG_FILE_NAME)).map_err(Into::into) +} + +#[cfg(test)] +mod tests { + use std::collections::BTreeMap; + + use devo_core::PresetModelCatalog; + use devo_core::ProviderWireApi; + use devo_protocol::ProviderInfo; + use devo_protocol::ProviderModelInfo; + use devo_protocol::native::rpc_admin::ProviderValidateParams; + use pretty_assertions::assert_eq; + + use super::*; + + #[test] + fn normalized_provider_id_trims_and_rejects_empty_names() { + assert_eq!( + normalized_provider_id(" openai "), + Some("openai".to_string()) + ); + assert_eq!(normalized_provider_id(" "), None); + } + + #[test] + fn resolve_validation_model_preserves_runtime_catalog_profile() { + let catalog = PresetModelCatalog::new(vec![Model { + slug: "catalog-slug".to_string(), + display_name: "Catalog Model".to_string(), + context_window: 123_456, + effective_context_window_percent: Some(70.0), + max_tokens: Some(7_654), + provider: ProviderWireApi::AnthropicMessages, + ..Model::default() + }]); + + let resolved = resolve_validation_model( + &catalog, + ProviderWireApi::OpenAIChatCompletions, + "catalog-slug", + ); + + assert_eq!( + resolved, + ( + Model { + slug: "catalog-slug".to_string(), + display_name: "Catalog Model".to_string(), + context_window: 123_456, + effective_context_window_percent: Some(70.0), + max_tokens: Some(7_654), + provider: ProviderWireApi::OpenAIChatCompletions, + ..Model::default() + }, + ModelProfileKey::CatalogSlug("catalog-slug".to_string()), + ) + ); + } + + #[test] + fn resolve_validation_model_uses_generic_profile_for_unknown_slug() { + let resolved = resolve_validation_model( + &PresetModelCatalog::default(), + ProviderWireApi::OpenAIChatCompletions, + "custom-catalog-slug", + ); + + assert_eq!( + resolved, + ( + Model { + slug: "custom-catalog-slug".to_string(), + display_name: "custom-catalog-slug".to_string(), + provider: ProviderWireApi::OpenAIChatCompletions, + ..Model::default() + }, + ModelProfileKey::Generic, + ) + ); + } + + /// Trace: L2-DES-APP-005, L2-DES-MODEL-001 + /// Verifies: provider validation applies provider custom header parsing before sending a validation request. + #[tokio::test] + async fn validate_provider_candidate_rejects_invalid_custom_headers() { + let params = ProviderValidateParams { + provider: ProviderInfo { + id: "openai".to_string(), + name: "openai".to_string(), + description: None, + base_url: Some("http://provider.example/v1".to_string()), + credential: None, + headers: BTreeMap::from([("bad header".to_string(), "value".to_string())]), + options: None, + request: None, + wire_apis: vec![ProviderWireApi::OpenAIChatCompletions], + models: BTreeMap::from([( + "test-model".to_string(), + ProviderModelInfo { + wire_api: Some(ProviderWireApi::OpenAIChatCompletions), + ..ProviderModelInfo::default() + }, + )]), + enabled: true, + }, + model: "test-model".to_string(), + api_key: None, + }; + let catalog = PresetModelCatalog::new(Vec::new()); + + let error = validate_provider_candidate(params, &catalog, ProviderHttpConfig::default()) + .await + .expect_err("invalid headers should reject validation"); + + assert_eq!( + error.to_string(), + "invalid provider custom header name `bad header`" + ); + } +} diff --git a/crates/server/src/runtime/provider_discovery.rs b/crates/server/src/runtime/provider_discovery.rs new file mode 100644 index 00000000..d8a0c6a2 --- /dev/null +++ b/crates/server/src/runtime/provider_discovery.rs @@ -0,0 +1,681 @@ +//! Provider model-directory discovery. + +use std::collections::BTreeMap; +use std::sync::{Mutex, OnceLock}; +use std::time::{Duration, Instant}; + +use anyhow::Context; +use devo_core::{ModelCatalog, PresetModelCatalog, read_user_auth_config}; +use devo_protocol::{InputModality, ProviderModelInfo, ReasoningCapability}; +use reqwest::header::{AUTHORIZATION, HeaderMap, HeaderName, HeaderValue}; +use serde_json::Value; + +use crate::{ProtocolErrorCode, SuccessResponse}; + +use super::ServerRuntime; + +const DISCOVERY_CACHE_TTL: Duration = Duration::from_secs(10 * 60); +type DiscoveryCache = Mutex)>>; +static DISCOVERY_CACHE: OnceLock = OnceLock::new(); + +impl ServerRuntime { + /// Discovers models from a connected provider standard /models endpoint + /// and persists the result into that Connection directory. + pub(super) async fn handle_native_provider_discover( + &self, + request_id: Value, + params: Value, + ) -> Value { + let params: devo_protocol::native::rpc_admin::ProviderDiscoverParams = + match serde_json::from_value(params) { + Ok(params) => params, + Err(error) => { + return self.error_response( + request_id, + ProtocolErrorCode::InvalidParams, + format!("invalid provider/discover params: {error}"), + ); + } + }; + let Some(provider_id) = non_empty(¶ms.provider_id) else { + return self.error_response( + request_id, + ProtocolErrorCode::InvalidParams, + "provider_id cannot be empty", + ); + }; + + let (mut provider, connected, user_config_dir) = { + let store = self + .deps + .config_store + .lock() + .expect("app config store mutex should not be poisoned"); + let connected = store + .provider_connection_ids() + .map(|ids| ids.contains(&provider_id)) + .unwrap_or(false); + let config = store.effective_config(); + let live_catalog = PresetModelCatalog::load_from_provider_config_with_overrides( + &config.provider_catalog, + &config.provider.model_overrides, + ) + .ok(); + let catalog: &dyn ModelCatalog = live_catalog + .as_ref() + .map(|catalog| catalog as &dyn ModelCatalog) + .unwrap_or(self.deps.model_catalog.as_ref()); + let configured_providers = match store.provider_connections() { + Ok(providers) => providers, + Err(error) => { + return self.error_response( + request_id, + ProtocolErrorCode::InternalError, + format!("failed to read provider Connections: {error}"), + ); + } + }; + let provider = configured_providers + .into_iter() + .find(|provider| provider.id == provider_id) + .or_else(|| { + self.deps + .model_catalog + .list_providers() + .into_iter() + .find(|provider| provider.id == provider_id) + }); + let Some(provider) = provider else { + return self.error_response( + request_id, + ProtocolErrorCode::InvalidParams, + format!("provider {provider_id} does not exist"), + ); + }; + let mut provider = canonical_provider(provider, catalog); + if let Some(config) = store + .effective_config() + .provider_catalog + .providers + .get(&provider_id) + { + provider.options = config.options.clone(); + provider.request = config.request.clone(); + if let Some(headers) = &config.headers { + provider.headers = headers.clone(); + } + } + (provider, connected, store.user_config_dir().to_path_buf()) + }; + if !connected { + return self.error_response( + request_id, + ProtocolErrorCode::PolicyDenied, + format!("provider {provider_id} is a template; connect it before discovery"), + ); + } + + let api_key = match provider_api_key(&provider, &user_config_dir) { + Ok(api_key) => api_key, + Err(error) => { + return self.error_response( + request_id, + ProtocolErrorCode::InternalError, + error.to_string(), + ); + } + }; + let discovered = + match discover_models(&provider, api_key.as_deref(), params.force_refresh).await { + Ok(models) => models, + Err(error) => { + return self.error_response( + request_id, + ProtocolErrorCode::InternalError, + error.to_string(), + ); + } + }; + // Discovery is the live directory: keep prior overlays only for models + // that still exist remotely. Stale template / renamed ids must not + // remain selectable after a refresh (e.g. Ollama placeholder catalog + // entries that 404 on chat completions). + let mut next_models = BTreeMap::new(); + for (model_id, discovered_model) in discovered { + let model = match provider.models.remove(&model_id) { + Some(existing) => merge_discovered_model(existing, discovered_model), + None => discovered_model, + }; + next_models.insert(model_id, model); + } + provider.models = next_models; + + let config_file = { + let store = self + .deps + .config_store + .lock() + .expect("app config store mutex should not be poisoned"); + store + .user_config_dir() + .join(devo_core::PROVIDER_CONFIG_FILE_NAME) + .display() + .to_string() + }; + if let Some(reason) = self + .config_change_hook_block_reason("user_settings", Some(config_file)) + .await + { + return self.error_response( + request_id, + ProtocolErrorCode::PolicyDenied, + format!("config change blocked by hook: {reason}"), + ); + } + + let mut store = self + .deps + .config_store + .lock() + .expect("app config store mutex should not be poisoned"); + let provider = match store.upsert_provider_connection(provider, None, None, None) { + Ok(provider) => provider, + Err(error) => { + return self.error_response( + request_id, + ProtocolErrorCode::InternalError, + error.to_string(), + ); + } + }; + drop(store); + self.deps.invalidate_workspace_contexts(); + + serde_json::to_value(SuccessResponse { + id: request_id, + result: devo_protocol::native::rpc_admin::ProviderDiscoverResult { + provider_id, + models: provider.models, + }, + }) + .expect("serialize provider/discover response") + } +} + +fn canonical_provider( + mut provider: devo_protocol::ProviderInfo, + catalog: &dyn ModelCatalog, +) -> devo_protocol::ProviderInfo { + if provider.models.is_empty() { + provider.models = catalog.list_provider_models(&provider.id); + } + provider +} + +fn provider_api_key( + provider: &devo_protocol::ProviderInfo, + config_dir: &std::path::Path, +) -> anyhow::Result> { + let auth = read_user_auth_config(&config_dir.join(devo_core::AUTH_CONFIG_FILE_NAME))?; + let Some(credential_id) = provider.credential.as_deref() else { + return Ok(None); + }; + Ok(Some( + auth.credentials + .get(credential_id) + .with_context(|| format!("missing credential {credential_id} in auth.json"))? + .value + .clone(), + )) +} + +async fn discover_models( + provider: &devo_protocol::ProviderInfo, + api_key: Option<&str>, + force_refresh: bool, +) -> anyhow::Result> { + let cache_key = format!( + "{}|{}|{}", + provider.id, + provider.base_url.as_deref().unwrap_or_default(), + provider.credential.as_deref().unwrap_or_default(), + ); + if !force_refresh + && let Some((cached_at, models)) = DISCOVERY_CACHE + .get_or_init(|| Mutex::new(BTreeMap::new())) + .lock() + .expect("provider discovery cache mutex should not be poisoned") + .get(&cache_key) + && cached_at.elapsed() < DISCOVERY_CACHE_TTL + { + return Ok(models.clone()); + } + + let models = fetch_discovered_models(provider, api_key).await?; + DISCOVERY_CACHE + .get_or_init(|| Mutex::new(BTreeMap::new())) + .lock() + .expect("provider discovery cache mutex should not be poisoned") + .insert(cache_key, (Instant::now(), models.clone())); + Ok(models) +} + +async fn fetch_discovered_models( + provider: &devo_protocol::ProviderInfo, + api_key: Option<&str>, +) -> anyhow::Result> { + let client = reqwest::Client::builder() + .timeout(std::time::Duration::from_secs(15)) + .build() + .context("failed to build model discovery client")?; + let mut headers = HeaderMap::new(); + for (name, value) in &provider.headers { + headers.insert( + HeaderName::try_from(name).with_context(|| format!("invalid header name {name}"))?, + HeaderValue::try_from(value) + .with_context(|| format!("invalid value for header {name}"))?, + ); + } + if let Some(api_key) = api_key.filter(|key| !key.trim().is_empty()) { + if provider + .wire_apis + .first() + .is_some_and(|wire_api| *wire_api == devo_core::ProviderWireApi::AnthropicMessages) + { + headers.insert( + HeaderName::from_static("x-api-key"), + HeaderValue::try_from(api_key).context("invalid provider API key")?, + ); + } else { + headers.insert( + AUTHORIZATION, + HeaderValue::try_from(format!("Bearer {api_key}")) + .context("invalid provider API key")?, + ); + } + } + + let mut last_status = None; + for url in discovery_urls(provider) { + let response = client.get(&url).headers(headers.clone()).send().await?; + last_status = Some(response.status()); + if response.status().is_success() { + return parse_models(response.json().await?); + } + if response.status() != reqwest::StatusCode::NOT_FOUND { + let status = response.status(); + let body = response.text().await.unwrap_or_default(); + anyhow::bail!("model discovery failed with {status}: {body}"); + } + } + anyhow::bail!("model discovery endpoint not found (last status: {last_status:?})") +} + +fn discovery_urls(provider: &devo_protocol::ProviderInfo) -> Vec { + let base = provider + .base_url + .clone() + .unwrap_or_else(|| "https://api.openai.com/v1".to_string()) + .trim_end_matches('/') + .to_string(); + let is_ollama = provider.id.eq_ignore_ascii_case("ollama"); + let mut urls = Vec::new(); + // Prefer Ollama's native tag list first — older OpenAI-compat /v1/models + // responses could expose ids that chat completions reject with 404. + if is_ollama && let Some(root) = base.strip_suffix("/v1") { + urls.push(format!("{root}/api/tags")); + } + urls.push(format!("{base}/models")); + if !base.ends_with("/v1") { + urls.push(format!("{base}/v1/models")); + } + if base.ends_with("/anthropic") { + urls.push(format!("{}/models", base.trim_end_matches("/anthropic"))); + } + if !is_ollama && let Some(root) = base.strip_suffix("/v1") { + urls.push(format!("{root}/api/tags")); + } + urls.dedup(); + urls +} + +fn parse_models(value: Value) -> anyhow::Result> { + let entries = value + .get("data") + .or_else(|| value.get("models")) + .and_then(Value::as_array) + .context("provider model discovery response has no data array")?; + let mut models = BTreeMap::new(); + for entry in entries { + // Prefer Ollama's canonical `model` field over display-oriented `name` + // when both are present (see ollama /api/tags). + let Some(id) = entry + .get("id") + .or_else(|| entry.get("model")) + .or_else(|| entry.get("name")) + .and_then(Value::as_str) + .map(str::trim) + .filter(|id| !id.is_empty()) + else { + continue; + }; + let name = entry + .get("display_name") + .or_else(|| entry.get("displayName")) + .or_else(|| entry.get("name")) + .and_then(Value::as_str) + .map(ToOwned::to_owned); + let release_date = entry + .get("created_at") + .or_else(|| entry.get("createdAt")) + .and_then(Value::as_str) + .map(ToOwned::to_owned); + let family = entry + .get("family") + .and_then(Value::as_str) + .map(ToOwned::to_owned); + let context_window = number_field( + entry, + &[ + "context_window", + "contextWindow", + "context_length", + "contextLength", + ], + ) + .or_else(|| nested_number_field(entry, "limit", &["context", "context_window"])); + let max_tokens = number_field( + entry, + &[ + "max_tokens", + "maxTokens", + "max_output_tokens", + "maxOutputTokens", + ], + ) + .or_else(|| nested_number_field(entry, "limit", &["output", "max_tokens"])); + let cost = entry.get("cost").or_else(|| entry.get("pricing")).cloned(); + let status = entry + .get("status") + .and_then(Value::as_str) + .map(ToOwned::to_owned); + let capabilities = entry.get("capabilities").cloned(); + let reasoning_capability = entry + .get("reasoning") + .and_then(Value::as_bool) + .filter(|reasoning| *reasoning) + .map(|_| ReasoningCapability::Toggle); + let input_modalities = entry + .get("input_modalities") + .or_else(|| entry.get("inputModalities")) + .or_else(|| entry.get("modalities")) + .and_then(Value::as_array) + .map(|modalities| { + modalities + .iter() + .filter_map(Value::as_str) + .filter_map(|modality| match modality.to_ascii_lowercase().as_str() { + "text" => Some(InputModality::Text), + "image" => Some(InputModality::Image), + _ => None, + }) + .collect::>() + }) + .filter(|modalities| !modalities.is_empty()); + models.insert( + id.to_string(), + ProviderModelInfo { + name, + family, + release_date, + status, + capabilities, + context_window, + max_tokens, + cost, + reasoning_capability, + input_modalities, + metadata: Some(entry.clone()), + enabled: Some(true), + ..ProviderModelInfo::default() + }, + ); + } + Ok(models) +} + +fn merge_discovered_model( + existing: ProviderModelInfo, + mut discovered: ProviderModelInfo, +) -> ProviderModelInfo { + macro_rules! fill_if_missing { + ($field:ident) => { + if discovered.$field.is_none() { + discovered.$field = existing.$field; + } + }; + } + + fill_if_missing!(name); + fill_if_missing!(family); + fill_if_missing!(release_date); + fill_if_missing!(status); + fill_if_missing!(capabilities); + fill_if_missing!(wire_api); + fill_if_missing!(context_window); + fill_if_missing!(effective_context_window_percent); + fill_if_missing!(max_tokens); + fill_if_missing!(temperature); + fill_if_missing!(top_p); + fill_if_missing!(top_k); + fill_if_missing!(reasoning_capability); + fill_if_missing!(reasoning_implementation); + fill_if_missing!(default_reasoning_effort); + fill_if_missing!(default_reasoning_selection); + fill_if_missing!(base_instructions); + fill_if_missing!(input_modalities); + fill_if_missing!(channel); + fill_if_missing!(supports_image_detail_original); + fill_if_missing!(truncation_policy); + fill_if_missing!(web_search); + fill_if_missing!(web_fetch); + fill_if_missing!(cost); + fill_if_missing!(metadata); + fill_if_missing!(request); + fill_if_missing!(options); + if discovered.headers.is_empty() { + discovered.headers = existing.headers; + } + for (variant_id, variant) in existing.variants { + discovered.variants.entry(variant_id).or_insert(variant); + } + fill_if_missing!(default_variant); + if existing.enabled == Some(false) { + discovered.enabled = existing.enabled; + } else { + fill_if_missing!(enabled); + } + fill_if_missing!(priority); + discovered +} + +fn number_field(value: &Value, names: &[&str]) -> Option { + names + .iter() + .find_map(|name| value.get(name).and_then(Value::as_u64)) + .and_then(|value| u32::try_from(value).ok()) +} + +fn nested_number_field(value: &Value, object: &str, names: &[&str]) -> Option { + value + .get(object) + .and_then(|value| number_field(value, names)) +} + +fn non_empty(value: &str) -> Option { + let value = value.trim(); + (!value.is_empty()).then(|| value.to_string()) +} + +#[cfg(test)] +mod tests { + use devo_protocol::{InputModality, ProviderModelInfo, ReasoningCapability}; + use pretty_assertions::assert_eq; + + use super::{discovery_urls, merge_discovered_model, parse_models}; + + #[test] + fn parses_openai_and_anthropic_style_directory_entries() { + let models = parse_models(serde_json::json!({ + "data": [ + {"id": "model-a", "owned_by": "team-a"}, + {"id": "model-b", "display_name": "Model B", "created_at": "2026-09-01"} + ] + })) + .expect("parse model directory"); + + assert_eq!(models["model-a"].name, None); + assert_eq!(models["model-b"].name.as_deref(), Some("Model B")); + assert_eq!( + models["model-b"].release_date.as_deref(), + Some("2026-09-01") + ); + assert_eq!( + models["model-b"].metadata, + Some(serde_json::json!({ + "id": "model-b", + "display_name": "Model B", + "created_at": "2026-09-01" + })) + ); + } + + #[test] + fn discovery_urls_cover_v1_and_deepseek_anthropic_bases() { + let provider = devo_protocol::ProviderInfo { + id: "deepseek".to_string(), + name: "DeepSeek".to_string(), + base_url: Some("https://api.deepseek.com/anthropic".to_string()), + wire_apis: vec![devo_core::ProviderWireApi::AnthropicMessages], + ..devo_protocol::ProviderInfo::default() + }; + let urls = discovery_urls(&provider); + + assert!(urls.contains(&"https://api.deepseek.com/anthropic/models".to_string())); + assert!(urls.contains(&"https://api.deepseek.com/anthropic/v1/models".to_string())); + assert!(urls.contains(&"https://api.deepseek.com/models".to_string())); + } + + #[test] + fn discovery_urls_include_ollama_native_tags() { + let provider = devo_protocol::ProviderInfo { + id: "ollama".to_string(), + name: "Ollama".to_string(), + base_url: Some("http://localhost:11434/v1".to_string()), + wire_apis: vec![devo_core::ProviderWireApi::OpenAIChatCompletions], + ..devo_protocol::ProviderInfo::default() + }; + let urls = discovery_urls(&provider); + assert_eq!( + urls, + vec![ + "http://localhost:11434/api/tags".to_string(), + "http://localhost:11434/v1/models".to_string(), + ] + ); + } + + #[test] + fn parse_models_prefers_ollama_model_field_over_name() { + let models = parse_models(serde_json::json!({ + "models": [{ + "name": "display-only", + "model": "qwen3:8b", + "size": 1 + }] + })) + .expect("parse ollama tags"); + assert!(models.contains_key("qwen3:8b")); + assert!(!models.contains_key("display-only")); + } + + #[test] + fn parses_common_model_metadata_without_losing_the_raw_entry() { + let models = parse_models(serde_json::json!({ + "models": [{ + "id": "reasoning-model", + "family": "reasoning", + "contextWindow": 128000, + "limit": {"output": 8192}, + "cost": {"input": 1.0, "output": 4.0}, + "reasoning": true, + "inputModalities": ["text", "image", "audio"], + "status": "active" + }] + })) + .expect("parse model directory"); + + let model = &models["reasoning-model"]; + assert_eq!(model.family.as_deref(), Some("reasoning")); + assert_eq!(model.context_window, Some(128_000)); + assert_eq!(model.max_tokens, Some(8_192)); + assert_eq!( + model.cost, + Some(serde_json::json!({"input": 1.0, "output": 4.0})) + ); + assert_eq!( + model.reasoning_capability, + Some(ReasoningCapability::Toggle) + ); + assert_eq!( + model.input_modalities, + Some(vec![InputModality::Text, InputModality::Image]) + ); + assert_eq!( + model.metadata, + Some(serde_json::json!({ + "id": "reasoning-model", + "family": "reasoning", + "contextWindow": 128000, + "limit": {"output": 8192}, + "cost": {"input": 1.0, "output": 4.0}, + "reasoning": true, + "inputModalities": ["text", "image", "audio"], + "status": "active" + })) + ); + } + + #[test] + fn discovery_preserves_connection_overrides() { + let merged = merge_discovered_model( + ProviderModelInfo { + options: Some(serde_json::json!({"timeout": 30})), + variants: [( + "fast".to_string(), + devo_protocol::ProviderModelVariant { + label: Some("Fast".to_string()), + ..devo_protocol::ProviderModelVariant::default() + }, + )] + .into_iter() + .collect(), + enabled: Some(false), + ..ProviderModelInfo::default() + }, + ProviderModelInfo { + name: Some("Discovered model".to_string()), + context_window: Some(128_000), + enabled: Some(true), + metadata: Some(serde_json::json!({"id": "model"})), + ..ProviderModelInfo::default() + }, + ); + + assert_eq!(merged.name.as_deref(), Some("Discovered model")); + assert_eq!(merged.context_window, Some(128_000)); + assert_eq!(merged.options, Some(serde_json::json!({"timeout": 30}))); + assert_eq!(merged.enabled, Some(false)); + assert_eq!(merged.variants["fast"].label.as_deref(), Some("Fast")); + } +} diff --git a/crates/server/src/runtime/provider_vendor_api.rs b/crates/server/src/runtime/provider_vendor_api.rs deleted file mode 100644 index 3c6da798..00000000 --- a/crates/server/src/runtime/provider_vendor_api.rs +++ /dev/null @@ -1,466 +0,0 @@ -use anyhow::Context; -use devo_core::AUTH_CONFIG_FILE_NAME; -use devo_core::Model; -use devo_core::ModelCatalog; -use devo_core::ProviderHttpConfig; -use devo_core::ProviderValidateParams; -use devo_core::ProviderVendorConfig; -use devo_core::UserAuthConfigFile; -use devo_core::read_user_auth_config; -use devo_core::test_model_connection; -use devo_protocol::ModelProfileKey; -use devo_provider::ModelProviderSDK; -use devo_provider::ProviderHttpOptions; -use devo_provider::anthropic::AnthropicProvider; -use devo_provider::openai::OpenAIProvider; -use devo_provider::openai::OpenAIResponsesProvider; -use devo_util_paths::current_user_config_file; - -use crate::ProtocolErrorCode; -use crate::SuccessResponse; -use crate::provider_config::normalize_openai_base_url; - -use super::ServerRuntime; - -impl ServerRuntime { - /// Native `provider/list` (ratified #11), backed by the shared config - /// store and projected into the canonical camelCase result. - pub(super) async fn handle_native_provider_list( - &self, - request_id: serde_json::Value, - ) -> serde_json::Value { - let store = self - .deps - .config_store - .lock() - .expect("app config store mutex should not be poisoned"); - let providers = store - .provider_vendors() - .into_iter() - .map(devo_protocol::native::rpc_admin::ProviderVendorInfo::from) - .collect(); - serde_json::to_value(SuccessResponse { - id: request_id, - result: devo_protocol::native::rpc_admin::ProviderListResult { providers }, - }) - .expect("serialize canonical provider/list response") - } - - /// Native `provider/upsert` (ratified #11). - pub(super) async fn handle_native_provider_upsert( - &self, - request_id: serde_json::Value, - params: serde_json::Value, - ) -> serde_json::Value { - let params: devo_protocol::native::rpc_admin::ProviderUpsertParams = - match serde_json::from_value(params) { - Ok(params) => params, - Err(error) => { - return self.error_response( - request_id, - ProtocolErrorCode::InvalidParams, - format!("invalid canonical provider/upsert params: {error}"), - ); - } - }; - let Some(provider_id) = normalized_provider_id(¶ms.provider_vendor.name) else { - return self.error_response( - request_id, - ProtocolErrorCode::InvalidParams, - "provider name cannot be empty", - ); - }; - let config_file = { - let store = self - .deps - .config_store - .lock() - .expect("app config store mutex should not be poisoned"); - store - .user_config_dir() - .join("config.toml") - .display() - .to_string() - }; - if let Some(reason) = self - .config_change_hook_block_reason("user_settings", Some(config_file)) - .await - { - return self.error_response( - request_id, - ProtocolErrorCode::PolicyDenied, - format!("config change blocked by hook: {reason}"), - ); - } - - let mut store = self - .deps - .config_store - .lock() - .expect("app config store mutex should not be poisoned"); - let model_binding = params.model_binding.map(Into::into); - let provider_vendor = match store.upsert_provider_vendor( - provider_id, - params.provider_vendor.into(), - model_binding.clone(), - params.default_model_binding, - params.api_key, - ) { - Ok(provider_vendor) => provider_vendor, - Err(error) => { - return self.error_response( - request_id, - ProtocolErrorCode::InternalError, - error.to_string(), - ); - } - }; - drop(store); - self.deps.invalidate_workspace_contexts(); - - serde_json::to_value(SuccessResponse { - id: request_id, - result: devo_protocol::native::rpc_admin::ProviderUpsertResult { - provider_vendor: provider_vendor.into(), - model_binding: model_binding.map(Into::into), - }, - }) - .expect("serialize canonical provider/upsert response") - } - - /// Native `provider/validate` (ratified #11). - pub(super) async fn handle_native_provider_validate( - &self, - request_id: serde_json::Value, - params: serde_json::Value, - ) -> serde_json::Value { - let params: devo_protocol::native::rpc_admin::ProviderValidateParams = - match serde_json::from_value(params) { - Ok(params) => params, - Err(error) => { - return self.error_response( - request_id, - ProtocolErrorCode::InvalidParams, - format!("invalid canonical provider/validate params: {error}"), - ); - } - }; - let params = ProviderValidateParams { - provider_vendor: params.provider_vendor.into(), - model_binding: params.model_binding.into(), - api_key: params.api_key, - }; - let provider_http = { - let store = self - .deps - .config_store - .lock() - .expect("app config store mutex should not be poisoned"); - store.effective_config().provider_http.clone() - }; - - match validate_provider_candidate(params, self.deps.model_catalog.as_ref(), provider_http) - .await - { - Ok(reply_preview) => serde_json::to_value(SuccessResponse { - id: request_id, - result: devo_protocol::native::rpc_admin::ProviderValidateResult { reply_preview }, - }) - .expect("serialize canonical provider/validate response"), - Err(error) => self.error_response( - request_id, - ProtocolErrorCode::InternalError, - error.to_string(), - ), - } - } -} - -fn normalized_provider_id(name: &str) -> Option { - let trimmed = name.trim(); - if trimmed.is_empty() { - None - } else { - Some(trimmed.to_string()) - } -} - -async fn validate_provider_candidate( - params: ProviderValidateParams, - catalog: &dyn ModelCatalog, - provider_http: ProviderHttpConfig, -) -> anyhow::Result { - let provider_id = normalized_provider_id(¶ms.provider_vendor.name) - .context("provider name cannot be empty")?; - if params.model_binding.provider.trim() != provider_id { - anyhow::bail!("model binding provider must match provider vendor"); - } - if params.model_binding.request_model.trim().is_empty() { - anyhow::bail!("model binding request_model cannot be empty"); - } - if params.provider_vendor.wire_apis.is_empty() { - anyhow::bail!("wire_apis must contain at least one wire API"); - } - if !params - .provider_vendor - .wire_apis - .contains(¶ms.model_binding.invocation_method) - { - anyhow::bail!("model binding invocation_method must be supported by provider vendor"); - } - - let (validation_model, model_profile) = resolve_validation_model( - catalog, - params.model_binding.invocation_method, - ¶ms.model_binding.model_slug, - ); - let api_key = resolve_validation_api_key(&provider_id, ¶ms)?; - let provider = build_validation_provider( - params.model_binding.invocation_method, - params.provider_vendor.base_url, - api_key, - ProviderHttpOptions::from_raw_with_no_proxy( - provider_http.proxy_url, - provider_http.no_proxy, - params.provider_vendor.headers.clone(), - )?, - )?; - - test_model_connection( - provider.as_ref(), - &validation_model, - model_profile, - ¶ms.model_binding.request_model, - "Reply with OK only.", - ) - .await - .map_err(Into::into) -} - -fn resolve_validation_model( - catalog: &dyn ModelCatalog, - wire_api: devo_core::ProviderWireApi, - model_slug: &str, -) -> (Model, ModelProfileKey) { - if let Some(entry) = catalog.get(model_slug) { - let mut model = entry.clone(); - model.provider = wire_api; - return (model, ModelProfileKey::CatalogSlug(model_slug.to_string())); - } - ( - Model { - slug: model_slug.to_string(), - display_name: model_slug.to_string(), - provider: wire_api, - ..Model::default() - }, - ModelProfileKey::Generic, - ) -} - -fn resolve_validation_api_key( - provider_id: &str, - params: &ProviderValidateParams, -) -> anyhow::Result> { - if let Some(api_key) = params.api_key.as_deref() { - let trimmed = api_key.trim(); - if !trimmed.is_empty() { - return Ok(Some(trimmed.to_string())); - } - } - - let provider_config = ProviderVendorConfig { - name: params.provider_vendor.name.clone(), - base_url: params.provider_vendor.base_url.clone(), - credential: params.provider_vendor.credential.clone(), - headers: params.provider_vendor.headers.clone(), - wire_apis: params.provider_vendor.wire_apis.clone(), - web_search: None, - web_fetch: None, - enabled: params.provider_vendor.enabled, - }; - resolve_provider_api_key( - provider_id, - &provider_config, - ¤t_server_user_auth_config()?, - ) -} - -fn build_validation_provider( - wire_api: devo_core::ProviderWireApi, - base_url: Option, - api_key: Option, - http_options: ProviderHttpOptions, -) -> anyhow::Result> { - match wire_api { - devo_core::ProviderWireApi::AnthropicMessages => { - let api_key = api_key.context("anthropic provider requires an API key")?; - let base_url = base_url.unwrap_or_else(|| "https://api.anthropic.com".to_string()); - Ok(Box::new( - AnthropicProvider::new(base_url) - .with_http_options(http_options)? - .with_api_key(api_key), - )) - } - devo_core::ProviderWireApi::OpenAIChatCompletions => { - let base_url = normalize_openai_base_url( - &base_url.unwrap_or_else(|| "https://api.openai.com".to_string()), - ); - let mut provider = OpenAIProvider::new(base_url).with_http_options(http_options)?; - if let Some(api_key) = api_key { - provider = provider.with_api_key(api_key); - } - Ok(Box::new(provider)) - } - devo_core::ProviderWireApi::OpenAIResponses => { - let base_url = normalize_openai_base_url( - &base_url.unwrap_or_else(|| "https://api.openai.com".to_string()), - ); - let mut provider = - OpenAIResponsesProvider::new(base_url).with_http_options(http_options)?; - if let Some(api_key) = api_key { - provider = provider.with_api_key(api_key); - } - Ok(Box::new(provider)) - } - } -} - -fn current_server_user_auth_config() -> anyhow::Result { - let config_file = current_user_config_file().context("could not determine user config path")?; - let config_dir = config_file - .parent() - .context("user config path has no parent directory")?; - read_user_auth_config(&config_dir.join(AUTH_CONFIG_FILE_NAME)).map_err(Into::into) -} - -fn resolve_provider_api_key( - provider_id: &str, - provider: &ProviderVendorConfig, - auth: &UserAuthConfigFile, -) -> anyhow::Result> { - let Some(credential_id) = provider.credential.as_deref() else { - return Ok(None); - }; - let credential = auth.credentials.get(credential_id).with_context(|| { - format!( - "provider `{provider_id}` references missing credential `{credential_id}` in user auth.json" - ) - })?; - Ok(Some(credential.value.clone())) -} - -#[cfg(test)] -mod tests { - use devo_core::PresetModelCatalog; - use devo_core::ProviderWireApi; - use devo_protocol::ProviderModelBinding; - use devo_protocol::ProviderValidateParams; - use devo_protocol::ProviderVendor; - use pretty_assertions::assert_eq; - - use super::*; - - #[test] - fn normalized_provider_id_trims_and_rejects_empty_names() { - assert_eq!( - normalized_provider_id(" openai "), - Some("openai".to_string()) - ); - assert_eq!(normalized_provider_id(" "), None); - } - - #[test] - fn resolve_validation_model_preserves_runtime_catalog_profile() { - let catalog = PresetModelCatalog::new(vec![Model { - slug: "catalog-slug".to_string(), - display_name: "Catalog Model".to_string(), - context_window: 123_456, - effective_context_window_percent: Some(70), - max_tokens: Some(7_654), - provider: ProviderWireApi::AnthropicMessages, - ..Model::default() - }]); - - let resolved = resolve_validation_model( - &catalog, - ProviderWireApi::OpenAIChatCompletions, - "catalog-slug", - ); - - assert_eq!( - resolved, - ( - Model { - slug: "catalog-slug".to_string(), - display_name: "Catalog Model".to_string(), - context_window: 123_456, - effective_context_window_percent: Some(70), - max_tokens: Some(7_654), - provider: ProviderWireApi::OpenAIChatCompletions, - ..Model::default() - }, - ModelProfileKey::CatalogSlug("catalog-slug".to_string()), - ) - ); - } - - #[test] - fn resolve_validation_model_uses_generic_profile_for_unknown_slug() { - let resolved = resolve_validation_model( - &PresetModelCatalog::default(), - ProviderWireApi::OpenAIChatCompletions, - "custom-catalog-slug", - ); - - assert_eq!( - resolved, - ( - Model { - slug: "custom-catalog-slug".to_string(), - display_name: "custom-catalog-slug".to_string(), - provider: ProviderWireApi::OpenAIChatCompletions, - ..Model::default() - }, - ModelProfileKey::Generic, - ) - ); - } - - /// Trace: L2-DES-APP-005, L2-DES-MODEL-001 - /// Verifies: provider validation applies provider custom header parsing before sending a validation request. - #[tokio::test] - async fn validate_provider_candidate_rejects_invalid_custom_headers() { - let params = ProviderValidateParams { - provider_vendor: ProviderVendor { - name: "openai".to_string(), - base_url: Some("http://provider.example/v1".to_string()), - credential: None, - headers: Some(r#"{"bad header":"value"}"#.to_string()), - wire_apis: vec![ProviderWireApi::OpenAIChatCompletions], - enabled: true, - }, - model_binding: ProviderModelBinding { - binding_id: "main".to_string(), - model_slug: "test-model".to_string(), - provider: "openai".to_string(), - request_model: "test-model".to_string(), - display_name: None, - invocation_method: ProviderWireApi::OpenAIChatCompletions, - default_reasoning_effort: None, - enabled: true, - }, - api_key: None, - }; - let catalog = PresetModelCatalog::new(Vec::new()); - - let error = validate_provider_candidate(params, &catalog, ProviderHttpConfig::default()) - .await - .expect_err("invalid headers should reject validation"); - - assert_eq!( - error.to_string(), - "invalid provider custom header name `bad header`" - ); - } -} diff --git a/crates/server/src/runtime/session_actor/actor_loop.rs b/crates/server/src/runtime/session_actor/actor_loop.rs index 3b8df312..e5a09fca 100644 --- a/crates/server/src/runtime/session_actor/actor_loop.rs +++ b/crates/server/src/runtime/session_actor/actor_loop.rs @@ -443,14 +443,14 @@ pub(super) async fn run_session_actor( let _ = reply.send(()); } SessionCommand::ApplyEffectiveContextWindow { limit, reply } => { - state.core.config.effective_context_window_override = Some(limit); + // Applied value is the model usable window; do not keep a + // sticky session override that could diverge from the model. + state.core.config.effective_context_window_override = None; state.core.config.token_budget.context_window = limit; state.core.config.token_budget.auto_compact_token_limit = Some(limit); - state.config.effective_context_window_override = Some(limit); + state.config.effective_context_window_override = None; state.config.token_budget.context_window = limit; state.config.token_budget.auto_compact_token_limit = Some(limit); - // Applied window is session-local runtime state derived from the - // global config preference; do not persist as a session override. state.summary.effective_context_window = Some(limit as u64); let _ = reply.send(Ok(())); } @@ -584,8 +584,20 @@ fn apply_turn_config_to_session_summary( summary: &mut crate::session::SessionMetadata, turn_config: &TurnConfig, ) { - summary.model = Some(turn_config.model.slug.clone()); - summary.model_binding_id = turn_config.model_binding_id.clone(); + let model = match &turn_config.provider_route { + devo_provider::ProviderRoute::Connection { provider_id, .. } => { + format!("{provider_id}/{}", turn_config.request_model) + } + devo_provider::ProviderRoute::Default => turn_config.model.slug.clone(), + }; + summary.model = Some( + turn_config + .variant + .as_deref() + .map(|variant| format!("{model}/{variant}")) + .unwrap_or(model), + ); + summary.model_binding_id = None; summary.reasoning_effort_selection = turn_config.reasoning_effort_selection.clone(); } diff --git a/crates/server/src/runtime/session_actor/turn.rs b/crates/server/src/runtime/session_actor/turn.rs index 81ec31a4..9d9d2c30 100644 --- a/crates/server/src/runtime/session_actor/turn.rs +++ b/crates/server/src/runtime/session_actor/turn.rs @@ -53,7 +53,18 @@ pub(crate) async fn execute_turn_task( let (event_tx, event_rx) = mpsc::channel(QUERY_EVENT_CHANNEL_CAPACITY); let event_tool_registry = runtime.tool_registry_for_actor_state(&working.state); let usage_parent_session_id = working.state.parent_session_id(); - let usage_context_window = Some(turn_config.model.context_window as u64); + let global_compaction = runtime + .deps + .config_store + .lock() + .expect("app config store mutex should not be poisoned") + .effective_config() + .compaction_token_limit; + let usage_context_window = Some(crate::runtime::context_occupancy::occupancy_window_tokens( + working.state.core.config.effective_context_window_override, + Some(&turn_config.model), + global_compaction, + )); if usage_parent_session_id.is_none() { runtime .begin_parent_usage_turn_with_base( diff --git a/crates/server/src/runtime/session_title.rs b/crates/server/src/runtime/session_title.rs index 36cf2b5b..cd5e4f83 100644 --- a/crates/server/src/runtime/session_title.rs +++ b/crates/server/src/runtime/session_title.rs @@ -1,6 +1,8 @@ use std::sync::Arc; use std::time::Duration; +use devo_core::resolve_small_model; + use crate::titles::build_title_generation_request; use crate::titles::heuristic_title_from_user_input; use crate::titles::normalize_generated_title; @@ -280,15 +282,52 @@ impl ServerRuntime { return true; } - let model_selection = title_context + let configured_small_model = title_context + .runtime_context + .config_store + .lock() + .expect("app config store mutex should not be poisoned") + .effective_config() + .provider_catalog_config() + .small_model; + let primary_selection = title_context .model_selection .clone() .unwrap_or_else(|| title_context.runtime_context.default_model.clone()); let reasoning_effort_selection = title_context.reasoning_effort_selection.clone(); let runtime_context = title_context.runtime_context; - - let turn_config = runtime_context - .resolve_turn_config(Some(model_selection.as_str()), reasoning_effort_selection); + let primary_turn_config = runtime_context.resolve_turn_config( + Some(primary_selection.as_str()), + reasoning_effort_selection.clone(), + ); + let configured_small_model = configured_small_model.filter(|model_ref| { + let Some((provider_id, model_id)) = model_ref.split_once('/') else { + return false; + }; + let provider_models = runtime_context + .model_catalog + .list_provider_models(provider_id); + provider_models.contains_key(model_id) + || model_id + .rsplit_once('/') + .is_some_and(|(base_model_id, variant_id)| { + provider_models + .get(base_model_id) + .is_some_and(|model| model.variants.contains_key(variant_id)) + }) + }); + let small_model_selection = configured_small_model.or_else(|| { + resolve_small_model( + runtime_context.model_catalog.as_ref(), + primary_turn_config.model.slug.as_str(), + ) + }); + let turn_config = if let Some(model_selection) = small_model_selection { + runtime_context + .resolve_turn_config(Some(model_selection.as_str()), reasoning_effort_selection) + } else { + primary_turn_config + }; let resolved_request = turn_config .model .resolve_reasoning_effort_selection(turn_config.reasoning_effort_selection.as_deref()); diff --git a/crates/server/src/runtime/turn_exec/approval_resume.rs b/crates/server/src/runtime/turn_exec/approval_resume.rs index 632962dd..e98766e1 100644 --- a/crates/server/src/runtime/turn_exec/approval_resume.rs +++ b/crates/server/src/runtime/turn_exec/approval_resume.rs @@ -167,6 +167,7 @@ impl ServerRuntime { } /// Handles an approval decision from a restored or live interactive lane. + #[allow(clippy::too_many_arguments)] pub(crate) async fn resolve_approval_and_resume_turn( self: &Arc, host_session_id: SessionId, @@ -405,6 +406,7 @@ impl ServerRuntime { continuation_result } + #[allow(clippy::too_many_arguments)] async fn run_approval_continuation( self: &Arc, _host_session_id: SessionId, @@ -456,7 +458,19 @@ impl ServerRuntime { self.tool_registry_for_actor_state(&working.state) }; let usage_parent_session_id = working.state.parent_session_id(); - let usage_context_window = Some(turn_config.model.context_window as u64); + let global_compaction = self + .deps + .config_store + .lock() + .expect("app config store mutex should not be poisoned") + .effective_config() + .compaction_token_limit; + let usage_context_window = + Some(crate::runtime::context_occupancy::occupancy_window_tokens( + working.state.core.config.effective_context_window_override, + Some(&turn_config.model), + global_compaction, + )); let stream = Arc::clone(&working.state.stream); let event_task = spawn_turn_event_stream( Arc::clone(self), diff --git a/crates/server/src/runtime/turn_exec/context_compaction.rs b/crates/server/src/runtime/turn_exec/context_compaction.rs index 8d3cbcdd..6a869c92 100644 --- a/crates/server/src/runtime/turn_exec/context_compaction.rs +++ b/crates/server/src/runtime/turn_exec/context_compaction.rs @@ -143,12 +143,21 @@ fn compaction_failed_item(message: &str) -> Item { } } -fn manual_compaction_item() -> Item { +fn manual_compaction_started_item() -> Item { Item::ContextCompaction { trigger: CompactionTrigger::Manual, before: compaction_usage(), after: None, - summary: Some("Context Compaction".to_string()), + summary: Some("Compaction started".to_string()), + } +} + +fn manual_compaction_completed_item() -> Item { + Item::ContextCompaction { + trigger: CompactionTrigger::Manual, + before: compaction_usage(), + after: None, + summary: Some("Context compacted".to_string()), } } @@ -211,15 +220,15 @@ pub(crate) fn manual_compaction_started_event( session_id: SessionId, turn_id: TurnId, item_id: ItemId, - item_seq: u64, + item_seq: Option, ) -> ServerEvent { item_event_from_native( session_id, turn_id, item_id, - Some(item_seq), + item_seq, ServerEvent::ItemStarted, - manual_compaction_item(), + manual_compaction_started_item(), ) } @@ -235,7 +244,28 @@ pub(crate) fn manual_compaction_completed_event( item_id, Some(item_seq), ServerEvent::ItemCompleted, - manual_compaction_item(), + manual_compaction_completed_item(), + ) +} + +pub(crate) fn manual_compaction_item_failed_event( + session_id: SessionId, + turn_id: TurnId, + item_id: ItemId, + message: String, +) -> ServerEvent { + item_event_from_native( + session_id, + turn_id, + item_id, + None, + ServerEvent::ItemCompleted, + Item::ContextCompaction { + trigger: CompactionTrigger::Manual, + before: compaction_usage(), + after: None, + summary: Some(format!("Compaction failed: {message}")), + }, ) } diff --git a/crates/server/src/runtime/turn_exec/event_stream.rs b/crates/server/src/runtime/turn_exec/event_stream.rs index 36d42e1c..16d85a27 100644 --- a/crates/server/src/runtime/turn_exec/event_stream.rs +++ b/crates/server/src/runtime/turn_exec/event_stream.rs @@ -94,6 +94,7 @@ pub(crate) fn spawn_turn_event_stream( let mut latest_query_usage = None; let mut stop_reason = None; let mut context_compaction = ContextCompactionLifecycle::default(); + let mut last_context_breakdown: Option = None; while let Some(event) = event_rx.recv().await { log_dequeued_query_event(&event); match event { @@ -141,6 +142,15 @@ pub(crate) fn spawn_turn_event_stream( .fail(&runtime, session_id, turn_for_events.turn_id, message) .await; } + devo_core::QueryEvent::ContextEstimate { breakdown } => { + // Keep the latest category mix for provider-anchored + // occupancy on Usage / UsageDelta. Do not broadcast here: + // raw heuristic totals are not on the same scale as + // provider display totals, and publishing them makes the + // context bar drop then snap back (TUI prefers live + // TurnUsageUpdated for the fill amount for the same reason). + last_context_breakdown = Some(breakdown); + } devo_core::QueryEvent::TextDelta(text) => { if let Some(parser) = proposed_plan_parser.as_mut() { let segments = parser.push_str(&text); @@ -299,18 +309,30 @@ pub(crate) fn spawn_turn_event_stream( kind, ) .await; - } else if let Some(snapshot) = runtime - .publish_parent_turn_usage( - session_id, - turn_for_events.turn_id, - usage, - usage_context_window, - kind, - ) - .await - { - turn_usage = Some(snapshot.turn_usage.to_turn_usage()); - latest_query_usage = Some(snapshot.latest_query_usage.to_turn_usage()); + } else { + if let Some(snapshot) = runtime + .publish_parent_turn_usage( + session_id, + turn_for_events.turn_id, + usage.clone(), + usage_context_window, + kind, + ) + .await + { + turn_usage = Some(snapshot.turn_usage.to_turn_usage()); + latest_query_usage = Some(snapshot.latest_query_usage.to_turn_usage()); + } + if let Some(raw) = last_context_breakdown { + runtime + .publish_live_context_occupancy( + session_id, + usage_context_window, + raw, + usage.display_total_tokens() as u64, + ) + .await; + } } } devo_core::QueryEvent::Usage { usage } => { @@ -327,18 +349,30 @@ pub(crate) fn spawn_turn_event_stream( kind, ) .await; - } else if let Some(snapshot) = runtime - .publish_parent_turn_usage( - session_id, - turn_for_events.turn_id, - usage, - usage_context_window, - kind, - ) - .await - { - turn_usage = Some(snapshot.turn_usage.to_turn_usage()); - latest_query_usage = Some(snapshot.latest_query_usage.to_turn_usage()); + } else { + if let Some(snapshot) = runtime + .publish_parent_turn_usage( + session_id, + turn_for_events.turn_id, + usage.clone(), + usage_context_window, + kind, + ) + .await + { + turn_usage = Some(snapshot.turn_usage.to_turn_usage()); + latest_query_usage = Some(snapshot.latest_query_usage.to_turn_usage()); + } + if let Some(raw) = last_context_breakdown { + runtime + .publish_live_context_occupancy( + session_id, + usage_context_window, + raw, + usage.display_total_tokens() as u64, + ) + .await; + } } } devo_core::QueryEvent::TurnComplete { @@ -781,7 +815,7 @@ async fn handle_tool_input_delta( let Some(pending) = pending_tool_calls.get(&tool_use_id) else { return; }; - let Some(item_id) = pending.item_id.clone() else { + let Some(item_id) = pending.item_id else { return; }; let chunk_index = tool_input_delta_seqs diff --git a/crates/server/src/runtime/turn_exec/finalize.rs b/crates/server/src/runtime/turn_exec/finalize.rs index b6b47712..189599d3 100644 --- a/crates/server/src/runtime/turn_exec/finalize.rs +++ b/crates/server/src/runtime/turn_exec/finalize.rs @@ -485,15 +485,6 @@ fn effective_context_window_tokens(state: &SessionActorState, runtime: &ServerRu .as_deref() .and_then(|slug| runtime.deps.model_catalog.get(slug)) }); - let Some(model) = model else { - return state - .core - .config - .effective_context_window_override - .or(state.core.config.token_budget.auto_compact_token_limit) - .map(|limit| limit as u64) - .unwrap_or(0); - }; let global = runtime .deps .config_store @@ -501,13 +492,11 @@ fn effective_context_window_tokens(state: &SessionActorState, runtime: &ServerRu .expect("app config store mutex should not be poisoned") .effective_config() .compaction_token_limit; - // Live session override (from a hot global apply) wins; otherwise resolve - // from the global preference / model default. - if let Some(limit) = state.core.config.effective_context_window_override { - let model_window = u64::from(model.context_window.max(1)); - return (limit as u64).min(model_window).max(1); - } - crate::runtime::context_occupancy::resolved_compaction_limit(global, model) + super::super::context_occupancy::occupancy_window_tokens( + state.core.config.effective_context_window_override, + model, + global, + ) } fn append_terminal_history_items( diff --git a/crates/server/src/runtime/turn_exec/mod.rs b/crates/server/src/runtime/turn_exec/mod.rs index 9153d31c..69f67b66 100644 --- a/crates/server/src/runtime/turn_exec/mod.rs +++ b/crates/server/src/runtime/turn_exec/mod.rs @@ -12,7 +12,8 @@ mod trace; mod types; pub(crate) use context_compaction::{ - manual_compaction_completed_event, manual_compaction_started_event, + manual_compaction_completed_event, manual_compaction_item_failed_event, + manual_compaction_started_event, }; pub(crate) use event_stream::{QUERY_EVENT_CHANNEL_CAPACITY, spawn_turn_event_stream}; pub(crate) use finalize::FinalizeTurnParams; diff --git a/crates/server/src/runtime/turn_exec/tests.rs b/crates/server/src/runtime/turn_exec/tests.rs index 8b86cd3f..ba55cc33 100644 --- a/crates/server/src/runtime/turn_exec/tests.rs +++ b/crates/server/src/runtime/turn_exec/tests.rs @@ -421,6 +421,9 @@ fn lifecycle_and_control_query_events_are_must_deliver() { devo_core::QueryEvent::ContextCompactionFailed { message: "context limit".to_string(), }, + devo_core::QueryEvent::ContextEstimate { + breakdown: devo_core::RawContextBreakdown::default(), + }, devo_core::QueryEvent::ReasoningCompleted, devo_core::QueryEvent::ToolUseStart { id: "tool-1".to_string(), diff --git a/crates/server/src/runtime/turn_exec/trace.rs b/crates/server/src/runtime/turn_exec/trace.rs index d23b48d5..5780edf7 100644 --- a/crates/server/src/runtime/turn_exec/trace.rs +++ b/crates/server/src/runtime/turn_exec/trace.rs @@ -24,6 +24,7 @@ pub(super) fn query_event_delivery_policy(event: &QueryEvent) -> QueryEventDeliv | QueryEvent::ContextCompactionStarted | QueryEvent::ContextCompactionCompleted { .. } | QueryEvent::ContextCompactionFailed { .. } + | QueryEvent::ContextEstimate { .. } | QueryEvent::TextDelta(_) | QueryEvent::ReasoningDelta(_) | QueryEvent::ReasoningCompleted @@ -49,6 +50,7 @@ pub(super) fn query_event_trace_kind(event: &QueryEvent) -> &'static str { QueryEvent::ContextCompactionStarted => "context_compaction_started", QueryEvent::ContextCompactionCompleted { .. } => "context_compaction_completed", QueryEvent::ContextCompactionFailed { .. } => "context_compaction_failed", + QueryEvent::ContextEstimate { .. } => "context_estimate", QueryEvent::TextDelta(_) => "text_delta", QueryEvent::ReasoningDelta(_) => "reasoning_delta", QueryEvent::ReasoningCompleted => "reasoning_completed", @@ -77,6 +79,7 @@ pub(super) fn query_event_trace_delta_len(event: &QueryEvent) -> usize { | QueryEvent::ContextCompactionStarted | QueryEvent::ContextCompactionCompleted { .. } | QueryEvent::ContextCompactionFailed { .. } + | QueryEvent::ContextEstimate { .. } | QueryEvent::ReasoningCompleted | QueryEvent::ToolUseStart { .. } | QueryEvent::ToolUseInputDelta { .. } @@ -95,6 +98,7 @@ pub(super) fn query_event_trace_token_preview(event: &QueryEvent) -> Option>>, pub(crate) agents_md: AgentsMdConfig, pub(crate) config_store: Arc>, + /// Provider settings used to build this context's live adapters. + /// + /// `config_store` is shared and is intentionally mutable for settings + /// writes, so runtime reuse must compare against this immutable snapshot + /// instead of reading the current store through the inherited context. + pub(crate) provider_catalog_snapshot: ProviderConfigFile, + pub(crate) provider_http_snapshot: ProviderHttpConfig, } struct RoutedModelProvider { @@ -122,6 +131,16 @@ impl SessionRuntimeContext { agents_md: AgentsMdConfig, config_store: Arc>, ) -> Self { + let (provider_catalog_snapshot, provider_http_snapshot) = { + let config_store = config_store + .lock() + .expect("app config store mutex should not be poisoned"); + let config = config_store.effective_config(); + ( + config.provider_catalog_config(), + config.provider_http.clone(), + ) + }; Self { provider, provider_router, @@ -132,6 +151,8 @@ impl SessionRuntimeContext { skill_catalog, agents_md, config_store, + provider_catalog_snapshot, + provider_http_snapshot, } } @@ -165,17 +186,18 @@ impl SessionRuntimeContext { .expect("app config store mutex should not be poisoned") .effective_config() .clone(); - let has_provider_configuration = config.has_provider_configuration(); let inherited_config = inherited_context .config_store .lock() .expect("inherited app config store mutex should not be poisoned") .effective_config() .clone(); - let provider_runtime_config_changed = !config - .provider - .is_operationally_equivalent_to(&inherited_config.provider) - || config.provider_http != inherited_config.provider_http; + let provider_catalog = config.provider_catalog_config(); + let provider_runtime_config_changed = provider_runtime_config_changed( + &provider_catalog, + &inherited_context.provider_catalog_snapshot, + ) || config.provider_http + != inherited_context.provider_http_snapshot; let workspace_cwd = workspace_root .map(Path::to_path_buf) .unwrap_or_else(|| std::env::current_dir().unwrap_or_else(|_| PathBuf::from("."))); @@ -226,33 +248,30 @@ impl SessionRuntimeContext { ))); (registry, mcp_manager) }; - let model_catalog: Arc = Arc::new(PresetModelCatalog::load_from_config( - &config.provider.model_overrides, - )?); + let model_catalog: Arc = Arc::new( + PresetModelCatalog::load_from_provider_config_with_overrides( + &provider_catalog, + &config.provider.model_overrides, + )?, + ); let default_model = model_catalog.resolve_for_turn(None)?.slug.clone(); - let (provider, provider_router, provider_default_model) = - if has_provider_configuration && provider_runtime_config_changed { - let provider = - load_server_provider(&config, Some(default_model.as_str()), &user_config_dir) - .context("load server provider for session workspace")?; - ( - provider.provider, - provider.provider_router, - provider.default_model, - ) - } else if has_provider_configuration { - ( - Arc::clone(&inherited_context.provider), - Arc::clone(&inherited_context.provider_router), - inherited_context.default_model.clone(), - ) - } else { - ( - Arc::clone(&inherited_context.provider), - Arc::clone(&inherited_context.provider_router), - default_model, - ) - }; + let (provider, provider_router, provider_default_model) = if provider_runtime_config_changed + { + let provider = + load_server_provider(&config, Some(default_model.as_str()), &user_config_dir) + .context("load server provider for session workspace")?; + ( + provider.provider, + provider.provider_router, + provider.default_model, + ) + } else { + ( + Arc::clone(&inherited_context.provider), + Arc::clone(&inherited_context.provider_router), + default_model, + ) + }; let skill_workspace_root = workspace_root .map(Path::to_path_buf) .unwrap_or_else(|| std::env::current_dir().unwrap_or_else(|_| PathBuf::from("."))); @@ -278,13 +297,15 @@ impl SessionRuntimeContext { ..AgentsMdConfig::default() }, config_store, + provider_catalog_snapshot: provider_catalog, + provider_http_snapshot: config.provider_http.clone(), })) } pub(crate) fn provider_for_route(&self, route: ProviderRoute) -> Arc { let provider_name = match &route { ProviderRoute::Default => self.provider.name().to_owned(), - ProviderRoute::Binding { provider_id, .. } => provider_id.clone(), + ProviderRoute::Connection { provider_id, .. } => provider_id.clone(), }; Arc::new(RoutedModelProvider::new( Arc::clone(&self.provider_router), @@ -390,8 +411,13 @@ impl SessionRuntimeContext { } pub(crate) fn resolve_turn_model(&self, requested_model: Option<&str>) -> Model { - if let Some(model) = requested_model.and_then(|requested| self.model_catalog.get(requested)) - { + if let Some(model) = requested_model.and_then(|requested| { + self.model_catalog.get(requested).or_else(|| { + requested + .rsplit_once('/') + .and_then(|(base, _)| self.model_catalog.get(base)) + }) + }) { return model.clone(); } @@ -421,36 +447,80 @@ impl SessionRuntimeContext { config_store.user_config_dir().to_path_buf(), ) }; - let provider_config = config.provider.clone(); - - if let Some(binding) = resolve_enabled_model_binding(&provider_config, requested_model) { - let provider = provider_config.providers.get(&binding.provider_id); - let binding_config = provider_config.model_bindings.get(&binding.binding_id); + let provider_config = config.provider_catalog_config(); + let selected_model = requested_model + .or(provider_config.model.as_deref()) + .or(Some(self.default_model.as_str())); + let selection = selected_model + .and_then(|model| provider_config.resolve_model(Some(model)).ok()) + .or_else(|| provider_config.resolve_model(None).ok()); + + if let Some(selection) = selection { + let provider = provider_config.providers.get(&selection.provider_id); + let model_config = + provider.and_then(|provider| provider.models.get(&selection.model_id)); let web_search = self.resolve_turn_web_search( &config, &user_config_dir, provider.and_then(|provider| provider.web_search.as_ref()), - binding_config.and_then(|binding| binding.web_search.as_ref()), + model_config + .as_ref() + .and_then(|model| model.web_search.as_ref()), ); let web_fetch = self.resolve_turn_web_fetch( &config, provider.and_then(|provider| provider.web_fetch.as_ref()), - binding_config.and_then(|binding| binding.web_fetch.as_ref()), + model_config + .as_ref() + .and_then(|model| model.web_fetch.as_ref()), ); - let provider_request_models = ProviderRequestModelMap::new( - provider_request_model_map_for_binding(&provider_config, &binding), + let mut model_config = model_config.cloned(); + if let Some(model) = model_config.as_mut() { + model.migrate_reasoning_implementation_into_variants(); + } + let effort_for_variant = reasoning_effort_selection.clone().or_else(|| { + model_config + .as_ref() + .and_then(|model| model.default_reasoning_selection.clone()) + }); + let variant_id = model_config.as_ref().and_then(|model| { + model.resolve_turn_variant_id( + selection.variant_id.as_deref(), + effort_for_variant.as_deref(), + ) + }); + let (request_defaults, request_headers) = provider_request_config( + &provider_config, + &selection.provider_id, + &selection.model_id, + variant_id.as_deref(), ); - let binding_id = binding.binding_id.clone(); + let provider_request_models = provider_config + .providers + .get(&selection.provider_id) + .into_iter() + .flat_map(|provider| provider.models.keys()) + .map(|model_id| { + ( + format!("{}/{model_id}", selection.provider_id), + model_id.clone(), + ) + }) + .collect::>(); + let provider_request_models = ProviderRequestModelMap::new(provider_request_models) + .with_request_config(request_defaults, request_headers); + let model_reference = format!("{}/{}", selection.provider_id, selection.model_id); let mut turn_config = TurnConfig::with_provider_route_and_web_tools( - self.catalog_model_or_fallback(&binding.model_slug), - binding.request_model, + self.catalog_model_or_fallback(&model_reference), + selection.model_id, provider_request_models, - ProviderRoute::binding(binding.provider_id, binding.invocation_method), + ProviderRoute::connection(selection.provider_id.clone(), selection.wire_api), web_search, web_fetch, reasoning_effort_selection, ); - turn_config.model_binding_id = Some(binding_id); + turn_config.model_binding_id = Some(model_reference); + turn_config.variant = variant_id; return turn_config; } @@ -468,7 +538,7 @@ impl SessionRuntimeContext { config: &AppConfig, user_config_dir: &Path, provider_override: Option<&WebSearchConfig>, - binding_override: Option<&WebSearchConfig>, + model_override: Option<&WebSearchConfig>, ) -> ResolvedWebSearchConfig { let auth = match read_user_auth_config(&user_config_dir.join(AUTH_CONFIG_FILE_NAME)) { Ok(auth) => auth, @@ -480,7 +550,7 @@ impl SessionRuntimeContext { match resolve_web_search_config( &config.tools.web_search, provider_override, - binding_override, + model_override, &auth, ) { Ok(web_search) => web_search, @@ -495,9 +565,9 @@ impl SessionRuntimeContext { &self, config: &AppConfig, provider_override: Option<&WebFetchConfig>, - binding_override: Option<&WebFetchConfig>, + model_override: Option<&WebFetchConfig>, ) -> ResolvedWebFetchConfig { - resolve_web_fetch_config(&config.tools.web_fetch, provider_override, binding_override) + resolve_web_fetch_config(&config.tools.web_fetch, provider_override, model_override) } pub(crate) fn discover_skills( diff --git a/crates/server/src/usage_ledger.rs b/crates/server/src/usage_ledger.rs index af498f00..89608b9f 100644 --- a/crates/server/src/usage_ledger.rs +++ b/crates/server/src/usage_ledger.rs @@ -80,6 +80,7 @@ impl UsageLedger { model: ModelBinding { provider: provider.to_owned(), model: request.model.clone(), + variant: None, reasoning_effort: request.reasoning_effort, }, outcome, @@ -343,6 +344,7 @@ mod tests { model: ModelBinding { provider: "test-provider".into(), model: "wire-model".into(), + variant: None, reasoning_effort: None, }, outcome: UsageCallOutcome::Succeeded, diff --git a/crates/server/tests/acp_available_commands.rs b/crates/server/tests/acp_available_commands.rs index 669a0749..58dabecf 100644 --- a/crates/server/tests/acp_available_commands.rs +++ b/crates/server/tests/acp_available_commands.rs @@ -8,7 +8,6 @@ use devo_core::AppConfigStore; use devo_core::BundledSkillsConfig; use devo_core::FileSystemSkillCatalog; use devo_core::PresetModelCatalog; -use devo_core::ProviderVendorCatalog; use devo_core::SkillsConfig; use devo_core::tools::ToolRegistry; use devo_protocol::AcpNewSessionResult; @@ -218,7 +217,6 @@ fn build_runtime(data_root: &Path) -> Result> { display_name: "test-model".to_string(), ..Model::default() }])), - Arc::new(ProviderVendorCatalog::default()), Box::new(FileSystemSkillCatalog::new(SkillsConfig { bundled: Some(BundledSkillsConfig { enabled: false }), ..SkillsConfig::default() diff --git a/crates/server/tests/acp_permission_tool_status_contract.rs b/crates/server/tests/acp_permission_tool_status_contract.rs index 311a9c1e..8841d2e4 100644 --- a/crates/server/tests/acp_permission_tool_status_contract.rs +++ b/crates/server/tests/acp_permission_tool_status_contract.rs @@ -14,7 +14,6 @@ use devo_core::AppConfigStore; use devo_core::BundledSkillsConfig; use devo_core::FileSystemSkillCatalog; use devo_core::PresetModelCatalog; -use devo_core::ProviderVendorCatalog; use devo_core::SkillsConfig; use devo_core::tools::ToolCallError; use devo_core::tools::ToolHandler; @@ -548,7 +547,6 @@ fn build_runtime( display_name: "test-model".to_string(), ..Model::default() }])), - Arc::new(ProviderVendorCatalog::default()), Box::new(FileSystemSkillCatalog::new(SkillsConfig { bundled: Some(BundledSkillsConfig { enabled: false }), ..SkillsConfig::default() diff --git a/crates/server/tests/acp_session_contract_e2e.rs b/crates/server/tests/acp_session_contract_e2e.rs index e41ed86d..6b7d63a7 100644 --- a/crates/server/tests/acp_session_contract_e2e.rs +++ b/crates/server/tests/acp_session_contract_e2e.rs @@ -408,9 +408,9 @@ base_instructions = "Catalog-only model instructions" assert_eq!(model_option["category"], serde_json::json!("model")); assert_eq!( model_option["currentValue"], - serde_json::json!("test-openai") + serde_json::json!("openai/test-model") ); - assert_model_config_option_values(model_option, &["alt-openai", "test-openai"])?; + assert_model_config_option_values(model_option, &["openai/alt-model", "openai/test-model"])?; assert_config_option_lacks_value(model_option, "catalog-only-model")?; let reasoning_effort_option = acp_config_option(&session_new_response["result"], "thought_level")?; @@ -467,7 +467,7 @@ base_instructions = "Catalog-only model instructions" let model_option = acp_model_config_option(&set_reasoning_effort_response["result"])?; assert_eq!( model_option["currentValue"], - serde_json::json!("test-openai") + serde_json::json!("openai/test-model") ); let reasoning_effort_prompt = "use the selected ACP reasoning effort"; @@ -505,7 +505,7 @@ base_instructions = "Catalog-only model instructions" "params": { "sessionId": session_id, "configId": "model", - "value": "alt-openai" + "value": "openai/alt-model" } }), ) @@ -521,7 +521,7 @@ base_instructions = "Catalog-only model instructions" let model_option = acp_model_config_option(&set_config_response["result"])?; assert_eq!( model_option["currentValue"], - serde_json::json!("alt-openai") + serde_json::json!("openai/alt-model") ); assert!(acp_config_option_optional(&set_config_response["result"], "thought_level").is_none()); let mode_option = acp_config_option(&set_config_response["result"], "mode")?; @@ -560,7 +560,7 @@ base_instructions = "Catalog-only model instructions" let model_option = acp_model_config_option(&set_mode_response["result"])?; assert_eq!( model_option["currentValue"], - serde_json::json!("alt-openai") + serde_json::json!("openai/alt-model") ); let prompt = "use the selected ACP model binding"; diff --git a/crates/server/tests/acp_session_delete.rs b/crates/server/tests/acp_session_delete.rs index 0cd74fe5..940399ca 100644 --- a/crates/server/tests/acp_session_delete.rs +++ b/crates/server/tests/acp_session_delete.rs @@ -9,7 +9,6 @@ use devo_core::AppConfigStore; use devo_core::BundledSkillsConfig; use devo_core::FileSystemSkillCatalog; use devo_core::PresetModelCatalog; -use devo_core::ProviderVendorCatalog; use devo_core::SkillsConfig; use devo_core::tools::ToolRegistry; use devo_protocol::Model; @@ -129,7 +128,6 @@ async fn acp_session_delete_removes_session_from_history_and_is_idempotent() -> display_name: "test-model".to_string(), ..Model::default() }])), - Arc::new(ProviderVendorCatalog::default()), Box::new(FileSystemSkillCatalog::new(SkillsConfig { bundled: Some(BundledSkillsConfig { enabled: false }), ..SkillsConfig::default() @@ -436,7 +434,6 @@ fn build_runtime_with_provider( display_name: "test-model".to_string(), ..Model::default() }])), - Arc::new(ProviderVendorCatalog::default()), Box::new(FileSystemSkillCatalog::new(SkillsConfig { bundled: Some(BundledSkillsConfig { enabled: false }), ..SkillsConfig::default() diff --git a/crates/server/tests/acp_session_lifecycle.rs b/crates/server/tests/acp_session_lifecycle.rs index 0d37a202..24cc1d94 100644 --- a/crates/server/tests/acp_session_lifecycle.rs +++ b/crates/server/tests/acp_session_lifecycle.rs @@ -10,7 +10,6 @@ use devo_core::AppConfigStore; use devo_core::BundledSkillsConfig; use devo_core::FileSystemSkillCatalog; use devo_core::PresetModelCatalog; -use devo_core::ProviderVendorCatalog; use devo_core::SkillsConfig; use devo_core::tools::ToolRegistry; use devo_protocol::AcpAuthMethod; @@ -929,7 +928,6 @@ fn build_runtime(data_root: &Path) -> Result> { display_name: "test-model".to_string(), ..Model::default() }])), - Arc::new(ProviderVendorCatalog::default()), Box::new(FileSystemSkillCatalog::new(SkillsConfig { bundled: Some(BundledSkillsConfig { enabled: false }), ..SkillsConfig::default() diff --git a/crates/server/tests/cli_log_failures.rs b/crates/server/tests/cli_log_failures.rs index 31182cad..faa53423 100644 --- a/crates/server/tests/cli_log_failures.rs +++ b/crates/server/tests/cli_log_failures.rs @@ -8,8 +8,8 @@ use async_trait::async_trait; use devo_core::AppConfigStore; use devo_core::BundledSkillsConfig; use devo_core::FileSystemSkillCatalog; +use devo_core::ModelCatalog; use devo_core::PresetModelCatalog; -use devo_core::ProviderVendorCatalog; use devo_core::SkillsConfig; use devo_core::tools::ToolRegistry; use devo_protocol::ModelRequest; @@ -112,6 +112,12 @@ async fn restore_seeds_sqlite_metadata_before_initial_stats() -> Result<()> { "source.db", Arc::clone(&provider), Arc::new(SingleProviderRouter::new(Arc::clone(&provider))), + Arc::new(PresetModelCatalog::load()?), + "kimi/kimi-k3".to_string(), + Arc::new(Mutex::new(AppConfigStore::load( + data_root.path().to_path_buf(), + None, + )?)), )? .0; let (connection_id, _notifications_rx) = initialize_connection(&source_runtime).await?; @@ -142,6 +148,12 @@ async fn restore_seeds_sqlite_metadata_before_initial_stats() -> Result<()> { "restored.db", Arc::clone(&provider), Arc::new(SingleProviderRouter::new(Arc::clone(&provider))), + Arc::new(PresetModelCatalog::load()?), + "kimi/kimi-k3".to_string(), + Arc::new(Mutex::new(AppConfigStore::load( + data_root.path().to_path_buf(), + None, + )?)), )?; restored_runtime.load_persisted_sessions().await?; @@ -162,22 +174,56 @@ async fn restore_seeds_sqlite_metadata_before_initial_stats() -> Result<()> { #[tokio::test] async fn title_generation_uses_resolved_provider_request_model() -> Result<()> { let data_root = TempDir::new()?; - std::fs::create_dir_all(data_root.path().join(".devo"))?; + let user_config_dir = data_root.path().join(".devo"); + std::fs::create_dir_all(&user_config_dir)?; std::fs::write( - data_root.path().join(".devo").join("config.toml"), - r#" -[model.kimi-k3] -display_name = "Catalog Title Model" -provider = "openai_chat_completions" -reasoning_capability = "toggle" -reasoning_implementation = { model_variant = { variants = [ - { selection_value = "disabled", model_slug = "kimi-k3", label = "Off", description = "Disable reasoning effort" }, - { selection_value = "enabled", model_slug = "vendor/title-model", reasoning_effort = "medium", label = "On", description = "Enable reasoning effort" }, -] } } -base_instructions = "Test title model" -"#, + user_config_dir.join("providers.json"), + r#"{ + "model": "kimi/kimi-k3", + "providers": { + "kimi": { + "name": "Kimi", + "wire_api": "openai_chat_completions", + "models": { + "kimi-k3": { + "name": "Catalog Title Model", + "reasoning_capability": "toggle", + "reasoning_implementation": { + "model_variant": { + "variants": [ + { + "selection_value": "disabled", + "model": "kimi-k3", + "label": "Off", + "description": "Disable reasoning effort" + }, + { + "selection_value": "enabled", + "model": "vendor/title-model", + "reasoning_effort": "medium", + "label": "On", + "description": "Enable reasoning effort" + } + ] + } + }, + "base_instructions": "Test title model" + } + } + } + } +}"#, )?; + let config_store = AppConfigStore::load(user_config_dir.clone(), Some(data_root.path()))?; + let model_catalog = Arc::new(PresetModelCatalog::load_from_provider_config( + &config_store.effective_config().provider_catalog_config(), + )?); + let default_model = model_catalog + .resolve_for_turn(Some("kimi/kimi-k3"))? + .slug + .clone(); + let provider: Arc = Arc::new(TestProvider); let recording_router = Arc::new(RecordingRouter::default()); let (runtime, _db) = build_runtime( @@ -185,6 +231,9 @@ base_instructions = "Test title model" "title.db", provider, Arc::clone(&recording_router) as Arc, + model_catalog, + default_model, + Arc::new(Mutex::new(config_store)), )?; let (connection_id, mut notifications_rx) = initialize_connection(&runtime).await?; @@ -243,6 +292,9 @@ fn build_runtime( db_name: &str, provider: Arc, provider_router: Arc, + model_catalog: Arc, + default_model: String, + config_store: Arc>, ) -> Result<( Arc, Arc, @@ -255,19 +307,15 @@ fn build_runtime( provider_router, Arc::new(ToolRegistry::new()), devo_server::empty_mcp_manager(), - "test-model".to_string(), - Arc::new(PresetModelCatalog::default()), - Arc::new(ProviderVendorCatalog::default()), + default_model, + model_catalog, Box::new(FileSystemSkillCatalog::new(SkillsConfig { bundled: Some(BundledSkillsConfig { enabled: false }), ..SkillsConfig::default() })), devo_core::AgentsMdConfig::default(), Arc::clone(&db), - Arc::new(Mutex::new(AppConfigStore::load( - data_root.to_path_buf(), - None, - )?)), + config_store, ), ); Ok((runtime, db)) diff --git a/crates/server/tests/command_exec.rs b/crates/server/tests/command_exec.rs index 2e840255..a4bb5b38 100644 --- a/crates/server/tests/command_exec.rs +++ b/crates/server/tests/command_exec.rs @@ -15,7 +15,6 @@ use devo_core::AppConfigStore; use devo_core::BundledSkillsConfig; use devo_core::FileSystemSkillCatalog; use devo_core::PresetModelCatalog; -use devo_core::ProviderVendorCatalog; use devo_core::SkillsConfig; use devo_core::tools::ToolRegistry; #[cfg(unix)] @@ -281,7 +280,6 @@ fn build_runtime(data_root: &std::path::Path) -> Result> { devo_server::empty_mcp_manager(), "test-model".to_string(), Arc::new(PresetModelCatalog::default()), - Arc::new(ProviderVendorCatalog::default()), Box::new(FileSystemSkillCatalog::new(SkillsConfig { bundled: Some(BundledSkillsConfig { enabled: false }), ..SkillsConfig::default() diff --git a/crates/server/tests/end_to_end.rs b/crates/server/tests/end_to_end.rs index b7d423d6..12e24dca 100644 --- a/crates/server/tests/end_to_end.rs +++ b/crates/server/tests/end_to_end.rs @@ -9,7 +9,6 @@ use anyhow::Context; use anyhow::Result; use async_trait::async_trait; use devo_core::AppConfigStore; -use devo_core::ProviderVendorCatalog; use futures::SinkExt; use futures::StreamExt; use pretty_assertions::assert_eq; @@ -465,7 +464,6 @@ async fn websocket_listener_supports_handshake_subscription_and_turn_lifecycle() devo_server::empty_mcp_manager(), "test-model".to_string(), Arc::new(PresetModelCatalog::default()), - Arc::new(ProviderVendorCatalog::default()), Box::new(FileSystemSkillCatalog::new(SkillsConfig::default())), devo_core::AgentsMdConfig::default(), db, @@ -668,7 +666,6 @@ async fn websocket_turn_streams_final_tool_metadata_for_read_and_glob() -> Resul devo_server::empty_mcp_manager(), "test-model".to_string(), Arc::new(PresetModelCatalog::default()), - Arc::new(ProviderVendorCatalog::default()), Box::new(FileSystemSkillCatalog::new(SkillsConfig::default())), devo_core::AgentsMdConfig::default(), db, diff --git a/crates/server/tests/goal_title_generation.rs b/crates/server/tests/goal_title_generation.rs index a3e6bf1a..34347895 100644 --- a/crates/server/tests/goal_title_generation.rs +++ b/crates/server/tests/goal_title_generation.rs @@ -12,7 +12,6 @@ use devo_core::AppConfigStore; use devo_core::BundledSkillsConfig; use devo_core::FileSystemSkillCatalog; use devo_core::PresetModelCatalog; -use devo_core::ProviderVendorCatalog; use devo_core::SkillsConfig; use devo_core::tools::ToolRegistry; use devo_protocol::Model; @@ -238,7 +237,6 @@ fn build_runtime( display_name: "test-model".to_string(), ..Model::default() }])), - Arc::new(ProviderVendorCatalog::default()), Box::new(FileSystemSkillCatalog::new(SkillsConfig { bundled: Some(BundledSkillsConfig { enabled: false }), ..SkillsConfig::default() diff --git a/crates/server/tests/model_config_e2e.rs b/crates/server/tests/model_config_e2e.rs index 0c7a2c08..e071a6bf 100644 --- a/crates/server/tests/model_config_e2e.rs +++ b/crates/server/tests/model_config_e2e.rs @@ -75,7 +75,11 @@ base_instructions = "Catalog-only model instructions" assert!( preferences["availableModels"] .as_array() - .is_some_and(|models| { models.iter().any(|model| model["value"] == "test-openai") }) + .is_some_and(|models| { + models + .iter() + .any(|model| model["value"] == "openai/test-model") + }) ); assert!( preferences["availableEfforts"] @@ -88,21 +92,21 @@ base_instructions = "Catalog-only model instructions" .expect("availableModels array"); let test_model = available_models .iter() - .find(|model| model["value"] == "test-openai") - .expect("test-openai binding"); + .find(|model| model["value"] == "openai/test-model") + .expect("openai/test-model option"); let alt_model = available_models .iter() - .find(|model| model["value"] == "alt-openai") - .expect("alt-openai binding"); + .find(|model| model["value"] == "openai/alt-model") + .expect("openai/alt-model option"); let test_efforts: Vec<&str> = test_model["availableEfforts"] .as_array() - .expect("test-openai availableEfforts") + .expect("openai/test-model availableEfforts") .iter() .map(|effort| effort["value"].as_str().expect("effort value")) .collect(); let alt_efforts: Vec<&str> = alt_model["availableEfforts"] .as_array() - .expect("alt-openai availableEfforts") + .expect("openai/alt-model availableEfforts") .iter() .map(|effort| effort["value"].as_str().expect("effort value")) .collect(); diff --git a/crates/server/tests/model_selection_persistence.rs b/crates/server/tests/model_selection_persistence.rs index 73d1fcb9..aee8791b 100644 --- a/crates/server/tests/model_selection_persistence.rs +++ b/crates/server/tests/model_selection_persistence.rs @@ -9,7 +9,6 @@ use devo_core::AppConfigStore; use devo_core::BundledSkillsConfig; use devo_core::FileSystemSkillCatalog; use devo_core::PresetModelCatalog; -use devo_core::ProviderVendorCatalog; use devo_core::SkillsConfig; use devo_core::tools::ToolRegistry; use devo_protocol::Model; @@ -222,20 +221,19 @@ fn build_runtime( provider_router, Arc::new(ToolRegistry::new()), devo_server::empty_mcp_manager(), - "default-model".to_string(), + "default/vendor/default-model".to_string(), Arc::new(PresetModelCatalog::new(vec![ Model { - slug: "default-model".to_string(), + slug: "default/vendor/default-model".to_string(), display_name: "Default Model".to_string(), ..Model::default() }, Model { - slug: "alt-model".to_string(), + slug: "alternate/vendor/alt-model".to_string(), display_name: "Alt Model".to_string(), ..Model::default() }, ])), - Arc::new(ProviderVendorCatalog::default()), Box::new(FileSystemSkillCatalog::new(SkillsConfig { bundled: Some(BundledSkillsConfig { enabled: false }), ..SkillsConfig::default() @@ -413,8 +411,14 @@ async fn model_update_during_turn_applies_to_next_turn() -> Result<()> { .await .context("first turn should reach the provider")?; - let update = update_model(&runtime, connection_id, session_id, "alt-model").await?; - assert_eq!(update.session.model.model, "alt-model"); + let update = update_model( + &runtime, + connection_id, + session_id, + "alternate/vendor/alt-model", + ) + .await?; + assert_eq!(update.session.model.model, "alternate/vendor/alt-model"); assert!(update.applied_to_active_turn); router.release_first_stream.notify_one(); @@ -433,13 +437,18 @@ async fn model_update_during_turn_applies_to_next_turn() -> Result<()> { router.stream_requests(), vec![ RecordedRequest { - route: ProviderRoute::binding("default", ProviderWireApi::OpenAIChatCompletions), - model_slug: ModelProfileKey::CatalogSlug("default-model".to_string()), + route: ProviderRoute::connection("default", ProviderWireApi::OpenAIChatCompletions), + model_slug: ModelProfileKey::CatalogSlug( + "default/vendor/default-model".to_string(), + ), request_model: "vendor/default-model".to_string(), }, RecordedRequest { - route: ProviderRoute::binding("alternate", ProviderWireApi::OpenAIChatCompletions,), - model_slug: ModelProfileKey::CatalogSlug("alt-model".to_string()), + route: ProviderRoute::connection( + "alternate", + ProviderWireApi::OpenAIChatCompletions, + ), + model_slug: ModelProfileKey::CatalogSlug("alternate/vendor/alt-model".to_string(),), request_model: "vendor/alt-model".to_string(), }, ] @@ -482,8 +491,14 @@ async fn cold_session_model_update_survives_resume_and_turn() -> Result<()> { )); let runtime = build_runtime(data_root.path(), Arc::clone(&router))?; let (connection_id, mut notifications_rx) = initialize_connection(&runtime).await?; - let update = update_model(&runtime, connection_id, session_id, "alt-model").await?; - assert_eq!(update.session.model.model, "alt-model"); + let update = update_model( + &runtime, + connection_id, + session_id, + "alternate/vendor/alt-model", + ) + .await?; + assert_eq!(update.session.model.model, "alternate/vendor/alt-model"); let resume_response = runtime .handle_incoming( @@ -501,7 +516,10 @@ async fn cold_session_model_update_survives_resume_and_turn() -> Result<()> { devo_protocol::native::rpc_session::SessionResumeResult, > = serde_json::from_value(resume_response) .with_context(|| format!("decode session/resume response: {resume_value}"))?; - assert_eq!(resume.result.session.model.model, "alt-model"); + assert_eq!( + resume.result.session.model.model, + "alternate/vendor/alt-model" + ); start_turn( &runtime, @@ -515,8 +533,8 @@ async fn cold_session_model_update_survives_resume_and_turn() -> Result<()> { assert_eq!( router.stream_requests(), vec![RecordedRequest { - route: ProviderRoute::binding("alternate", ProviderWireApi::OpenAIChatCompletions), - model_slug: ModelProfileKey::CatalogSlug("alt-model".to_string()), + route: ProviderRoute::connection("alternate", ProviderWireApi::OpenAIChatCompletions), + model_slug: ModelProfileKey::CatalogSlug("alternate/vendor/alt-model".to_string()), request_model: "vendor/alt-model".to_string(), }] ); @@ -575,7 +593,7 @@ async fn effort_selection_round_trips_through_all_reads(effort: &str) -> Result< assert_eq!( update.result.session.settings.reasoning_effort.as_deref(), Some(effort), - "metadata/update response must echo the raw selection" + "metadata/update response must echo the normalized selection" ); drop(initial_runtime); @@ -645,7 +663,7 @@ async fn effort_selection_round_trips_through_all_reads(effort: &str) -> Result< /// every read path after a restart. #[tokio::test] async fn metadata_update_toggle_selection_round_trips_through_all_reads() -> Result<()> { - effort_selection_round_trips_through_all_reads("enabled").await + effort_selection_round_trips_through_all_reads("on").await } /// Trace: L2-DES-CONV-002 DD-10 @@ -682,7 +700,7 @@ async fn repeated_identical_effort_patch_does_not_append_field_line() -> Result< "params": { "sessionId": session_id, "expectedVersion": 0, - "settings": { "reasoningEffort": "enabled" } + "settings": { "reasoningEffort": "on" } } }), ) @@ -691,7 +709,7 @@ async fn repeated_identical_effort_patch_does_not_append_field_line() -> Result< let second: serde_json::Value = send_patch(32).await.context("second patch")?; assert_eq!( first["result"]["session"]["settings"]["reasoningEffort"].as_str(), - Some("enabled") + Some("on") ); let first_version = first["result"]["session"]["version"].as_u64(); let second_version = second["result"]["session"]["version"].as_u64(); diff --git a/crates/server/tests/persistence_resume.rs b/crates/server/tests/persistence_resume.rs index 3c77c6b8..1273d501 100644 --- a/crates/server/tests/persistence_resume.rs +++ b/crates/server/tests/persistence_resume.rs @@ -12,7 +12,6 @@ use anyhow::Result; use async_trait::async_trait; use devo_core::AppConfigStore; use devo_core::BundledSkillsConfig; -use devo_core::ProviderVendorCatalog; use futures::stream::Stream; use futures::stream::{self}; use pretty_assertions::assert_eq; @@ -25,6 +24,7 @@ use tokio::time::timeout; use devo_core::FileSystemSkillCatalog; use devo_core::ItemLine; use devo_core::ItemRecord; +use devo_core::ModelCatalog; use devo_core::PresetModelCatalog; use devo_core::RolloutLine; use devo_core::SessionMetaLine; @@ -36,11 +36,8 @@ use devo_core::TurnItem; use devo_core::TurnLine; use devo_core::TurnRecord; use devo_core::tools::ToolRegistry; -use devo_protocol::Model; use devo_protocol::ModelRequest; use devo_protocol::ModelResponse; -use devo_protocol::ReasoningCapability; -use devo_protocol::ReasoningEffort; use devo_protocol::ResponseContent; use devo_protocol::ResponseMetadata; use devo_protocol::ServerEvent; @@ -1104,7 +1101,7 @@ async fn failed_turn_resume_restores_terminal_history_without_prompt_contaminati timestamp: now, turn: failed_running, })), - RolloutLine::Item(ItemLine { + RolloutLine::Item(Box::new(ItemLine { timestamp: now, item: item_record( failed_turn_id, @@ -1114,8 +1111,8 @@ async fn failed_turn_resume_restores_terminal_history_without_prompt_contaminati text: "failing prompt".into(), }), ), - }), - RolloutLine::Item(ItemLine { + })), + RolloutLine::Item(Box::new(ItemLine { timestamp: now + chrono::Duration::seconds(1), item: item_record( failed_turn_id, @@ -1125,7 +1122,7 @@ async fn failed_turn_resume_restores_terminal_history_without_prompt_contaminati text: "partial response".into(), }), ), - }), + })), RolloutLine::Turn(Box::new(TurnLine { timestamp: now + chrono::Duration::seconds(2), turn: failed_terminal, @@ -1134,7 +1131,7 @@ async fn failed_turn_resume_restores_terminal_history_without_prompt_contaminati timestamp: now + chrono::Duration::seconds(3), turn: completed_running, })), - RolloutLine::Item(ItemLine { + RolloutLine::Item(Box::new(ItemLine { timestamp: now + chrono::Duration::seconds(3), item: item_record( completed_turn_id, @@ -1144,8 +1141,8 @@ async fn failed_turn_resume_restores_terminal_history_without_prompt_contaminati text: "next prompt".into(), }), ), - }), - RolloutLine::Item(ItemLine { + })), + RolloutLine::Item(Box::new(ItemLine { timestamp: now + chrono::Duration::seconds(4), item: item_record( completed_turn_id, @@ -1155,7 +1152,7 @@ async fn failed_turn_resume_restores_terminal_history_without_prompt_contaminati text: "next response".into(), }), ), - }), + })), RolloutLine::Turn(Box::new(TurnLine { timestamp: now + chrono::Duration::seconds(5), turn: completed_terminal, @@ -1677,7 +1674,7 @@ impl ModelProviderSDK for AutoCompactTestProvider { async fn auto_compaction_persists_snapshot_and_survives_resume() -> Result<()> { let data_root = TempDir::new()?; let provider = Arc::new(AutoCompactTestProvider::new( - /*input_tokens*/ 80_000, /*output_tokens*/ 1_000, + /*input_tokens*/ 96_000, /*output_tokens*/ 1_000, )); let runtime = build_runtime_with_provider(data_root.path(), provider.clone())?; let (connection_id, mut notifications_rx) = initialize_connection(&runtime).await?; @@ -1814,21 +1811,6 @@ async fn auto_compaction_persists_snapshot_and_survives_resume() -> Result<()> { #[tokio::test] async fn configured_request_model_is_used_for_turn_metadata_and_provider_request() -> Result<()> { let data_root = TempDir::new()?; - std::fs::create_dir_all(data_root.path().join(".devo"))?; - std::fs::write( - data_root.path().join(".devo").join("config.toml"), - r#" -[model.test-model] -display_name = "test-model" -provider = "openai_chat_completions" -reasoning_capability = "toggle" -reasoning_implementation = { model_variant = { variants = [ - { selection_value = "disabled", model_slug = "test-model", label = "Off", description = "Disable reasoning effort" }, - { selection_value = "enabled", model_slug = "vendor/test-model", reasoning_effort = "medium", label = "On", description = "Enable reasoning effort" }, -] } } -base_instructions = "Test model" -"#, - )?; let provider = Arc::new(CapturingProvider::default()); let runtime = build_runtime_with_provider(data_root.path(), provider.clone())?; let (connection_id, mut notifications_rx) = initialize_connection(&runtime).await?; @@ -1840,7 +1822,7 @@ base_instructions = "Test model" data_root.path(), "persistence-request-model-session", Some("Request model session"), - Some("test-model"), + Some("test/test-model"), ) .await?; let session_id = session.id; @@ -1854,7 +1836,7 @@ base_instructions = "Test model" "params": { "sessionId": session_id, "expectedVersion": 0, - "settings": { "reasoningEffort": "medium" } + "settings": { "reasoningEffort": "on" } } }), ) @@ -1890,9 +1872,8 @@ base_instructions = "Test model" assert_eq!( turn_started["params"]["turn"]["model"], serde_json::json!({ - "provider": "unknown", + "provider": "test/test-model", "model": "vendor/test-model", - "reasoningEffort": "medium" }) ); let requests = provider.requests.lock().expect("lock requests"); @@ -1964,10 +1945,77 @@ fn build_runtime(data_root: &std::path::Path) -> Result> { build_runtime_with_provider(data_root, Arc::new(SingleReplyProvider)) } +fn ensure_test_provider_catalog(data_root: &std::path::Path) -> Result<()> { + let providers_path = data_root.join("providers.json"); + if providers_path.exists() { + return Ok(()); + } + std::fs::write( + providers_path, + r#"{ + "model": "test/test-model", + "providers": { + "test": { + "name": "Test", + "wire_api": "openai_chat_completions", + "models": { + "test-model": { + "name": "test-model", + "context_window": 100000, + "effective_context_window_percent": 95.0, + "reasoning_capability": "toggle", + "reasoning_implementation": { + "model_variant": { + "variants": [ + { + "selection_value": "disabled", + "model": "test-model", + "label": "Off", + "description": "Disable reasoning effort" + }, + { + "selection_value": "enabled", + "model": "vendor/test-model", + "reasoning_effort": "medium", + "label": "On", + "description": "Enable reasoning effort" + } + ] + } + }, + "base_instructions": "Test model" + }, + "deepseek-v4-flash": { + "name": "deepseek-v4-flash", + "context_window": 100000, + "reasoning_capability": { "levels": ["off", "high", "max"] }, + "default_reasoning_effort": "high", + "base_instructions": "Flash model" + } + } + } + } +}"#, + )?; + Ok(()) +} + fn build_runtime_with_provider( data_root: &std::path::Path, provider: Arc, ) -> Result> { + ensure_test_provider_catalog(data_root)?; + let config_store = AppConfigStore::load(data_root.to_path_buf(), None)?; + let model_catalog = Arc::new( + PresetModelCatalog::load_from_provider_config_with_overrides( + &config_store.effective_config().provider_catalog_config(), + &config_store.effective_config().provider.model_overrides, + )?, + ); + let default_model = model_catalog + .resolve_for_turn(Some("test/test-model")) + .map(|model| model.slug.clone()) + .unwrap_or_else(|_| "test/test-model".to_string()); let db_path = data_root.join("test_persistence.db"); let db = Arc::new(devo_server::db::Database::open(db_path).expect("open test database")); Ok(ServerRuntime::new( @@ -1977,34 +2025,15 @@ fn build_runtime_with_provider( Arc::new(SingleProviderRouter::new(provider)), Arc::new(ToolRegistry::new()), devo_server::empty_mcp_manager(), - "test-model".to_string(), - Arc::new(PresetModelCatalog::new(vec![ - Model { - slug: "test-model".to_string(), - display_name: "test-model".to_string(), - ..Model::default() - }, - Model { - slug: "deepseek-v4-flash".to_string(), - display_name: "deepseek-v4-flash".to_string(), - reasoning_capability: ReasoningCapability::ToggleWithLevels(vec![ - ReasoningEffort::High, - ReasoningEffort::Max, - ]), - default_reasoning_effort: Some(ReasoningEffort::High), - ..Model::default() - }, - ])), - Arc::new(ProviderVendorCatalog::default()), + default_model, + model_catalog, Box::new(FileSystemSkillCatalog::new(SkillsConfig { bundled: Some(BundledSkillsConfig { enabled: false }), ..SkillsConfig::default() })), devo_core::AgentsMdConfig::default(), db, - Arc::new(std::sync::Mutex::new( - AppConfigStore::load(data_root.to_path_buf(), None).expect("load app config store"), - )), + Arc::new(std::sync::Mutex::new(config_store)), ), )) } diff --git a/crates/server/tests/provider_failure_reporting.rs b/crates/server/tests/provider_failure_reporting.rs index 1fffa57a..ea61ee6b 100644 --- a/crates/server/tests/provider_failure_reporting.rs +++ b/crates/server/tests/provider_failure_reporting.rs @@ -15,7 +15,6 @@ use devo_core::AppConfigStore; use devo_core::BundledSkillsConfig; use devo_core::FileSystemSkillCatalog; use devo_core::PresetModelCatalog; -use devo_core::ProviderVendorCatalog; use devo_core::SkillsConfig; use devo_core::tools::ToolRegistry; use devo_protocol::Model; @@ -275,15 +274,14 @@ fn expected_retry_statuses(session_id: SessionId, turn_id: TurnId) -> Vec Vec let runtime = build_runtime_with_models( data_root.path(), router.clone(), - "glm-5.2", + "zai/renamed-provider-model", vec![Model { - slug: "glm-5.2".to_string(), + slug: "zai/renamed-provider-model".to_string(), display_name: "GLM 5.2".to_string(), reasoning_capability: ReasoningCapability::Toggle, ..Model::default() @@ -252,8 +259,8 @@ async fn explicit_binding_controls_route_request_model_and_catalog_profile() -> &runtime, connection_id, data_root.path(), - "glm-5.2", - Some("glm-zai"), + "zai/renamed-provider-model", + Some("zai/renamed-provider-model"), ) .await?; @@ -263,7 +270,7 @@ async fn explicit_binding_controls_route_request_model_and_catalog_profile() -> session_id, 4, Some("legacy-wrong-model"), - Some("glm-zai"), + Some("zai/renamed-provider-model"), Some("enabled"), ) .await? @@ -274,21 +281,21 @@ async fn explicit_binding_controls_route_request_model_and_catalog_profile() -> wait_for_notification_value(&mut notifications_rx, "turn/completed").await?; wait_for_complete_request(&router).await?; - let expected_route = ProviderRoute::binding("zai", ProviderWireApi::OpenAIChatCompletions); + let expected_route = ProviderRoute::connection("zai", ProviderWireApi::OpenAIChatCompletions); assert_eq!( router.stream_requests(), vec![RecordedRequest { route: expected_route.clone(), - model_slug: ModelProfileKey::CatalogSlug("glm-5.2".to_string()), + model_slug: ModelProfileKey::CatalogSlug("zai/renamed-provider-model".to_string()), request_model: "renamed-provider-model".to_string(), - request_thinking: Some("enabled".to_string()), + request_thinking: None, }] ); assert_eq!( router.complete_requests(), vec![RecordedRequest { route: expected_route, - model_slug: ModelProfileKey::CatalogSlug("glm-5.2".to_string()), + model_slug: ModelProfileKey::CatalogSlug("zai/renamed-provider-model".to_string()), request_model: "renamed-provider-model".to_string(), request_thinking: Some("disabled".to_string()), }] @@ -504,15 +511,15 @@ fn build_runtime( build_runtime_with_models( data_root, router, - "default-model", + "default/vendor/default-model", vec![ Model { - slug: "default-model".to_string(), + slug: "default/vendor/default-model".to_string(), display_name: "Default Model".to_string(), ..Model::default() }, Model { - slug: "alt-model".to_string(), + slug: "alternate/vendor/alt-model".to_string(), display_name: "Alt Model".to_string(), ..Model::default() }, @@ -540,7 +547,6 @@ fn build_runtime_with_models( devo_server::empty_mcp_manager(), default_model.to_string(), Arc::new(PresetModelCatalog::new(models)), - Arc::new(ProviderVendorCatalog::default()), Box::new(FileSystemSkillCatalog::new(SkillsConfig { bundled: Some(BundledSkillsConfig { enabled: false }), ..SkillsConfig::default() @@ -599,7 +605,7 @@ async fn start_session( runtime, connection_id, cwd, - "default-model", + "default/vendor/default-model", /*model_binding_id*/ None, ) .await diff --git a/crates/server/tests/queue_drain_notification.rs b/crates/server/tests/queue_drain_notification.rs index 9394b390..97e93af4 100644 --- a/crates/server/tests/queue_drain_notification.rs +++ b/crates/server/tests/queue_drain_notification.rs @@ -13,7 +13,6 @@ use anyhow::Context; use anyhow::Result; use async_trait::async_trait; use devo_core::AppConfigStore; -use devo_core::ProviderVendorCatalog; use futures::stream; use pretty_assertions::assert_eq; use serde_json::json; @@ -187,7 +186,6 @@ fn build_runtime( devo_server::empty_mcp_manager(), "test-model".to_string(), Arc::new(PresetModelCatalog::default()), - Arc::new(ProviderVendorCatalog::default()), Box::new(FileSystemSkillCatalog::new(SkillsConfig { enabled: false, user_roots: Vec::new(), diff --git a/crates/server/tests/session_compaction_threshold.rs b/crates/server/tests/session_compaction_threshold.rs index f3161ffb..97a41695 100644 --- a/crates/server/tests/session_compaction_threshold.rs +++ b/crates/server/tests/session_compaction_threshold.rs @@ -1,5 +1,6 @@ -//! Global compaction threshold via the canonical session settings patch and -//! config.toml. +//! Session effective context window uses the model usable window only. +//! Global `compaction_token_limit` and `effectiveContextWindow` patches do not +//! change applied policy. use std::path::Path; use std::pin::Pin; @@ -13,7 +14,6 @@ use devo_core::AppConfigStore; use devo_core::BundledSkillsConfig; use devo_core::FileSystemSkillCatalog; use devo_core::PresetModelCatalog; -use devo_core::ProviderVendorCatalog; use devo_core::SkillsConfig; use devo_core::tools::ToolRegistry; use devo_protocol::ModelRequest; @@ -74,7 +74,6 @@ fn build_runtime(data_root: &Path) -> Result> { devo_server::empty_mcp_manager(), "test-model".to_string(), Arc::new(PresetModelCatalog::load()?), - Arc::new(ProviderVendorCatalog::default()), Box::new(FileSystemSkillCatalog::new(SkillsConfig { bundled: Some(BundledSkillsConfig { enabled: false }), ..SkillsConfig::default() @@ -173,7 +172,7 @@ async fn compaction_update( } #[tokio::test] -async fn compaction_update_writes_global_config_and_applies_to_session() -> Result<()> { +async fn effective_context_window_patch_echoes_model_and_skips_global_config() -> Result<()> { let data_root = TempDir::new()?; let cwd = data_root.path().join("workspace"); std::fs::create_dir_all(&cwd)?; @@ -187,11 +186,11 @@ async fn compaction_update_writes_global_config_and_applies_to_session() -> Resu ) .await?; let started_id = SessionId::try_from(started.session.id.as_str())?; - assert!( - started.session.settings.effective_context_window.is_some(), - "new session should expose an applied effective window" - ); - let model_default = started.session.settings.effective_context_window; + let model_default = started + .session + .settings + .effective_context_window + .expect("new session should expose an applied effective window"); let updated = compaction_update( &runtime, @@ -202,15 +201,19 @@ async fn compaction_update_writes_global_config_and_applies_to_session() -> Resu .await?; assert_eq!( updated.session.settings.effective_context_window, - Some(250_000) + Some(model_default), + "patch must echo model effective window, not the requested absolute" ); - let config_text = std::fs::read_to_string(data_root.path().join("config.toml"))?; - let document: toml::Value = toml::from_str(&config_text)?; - assert_eq!( - document["compaction_token_limit"].as_integer(), - Some(250_000) - ); + let config_path = data_root.path().join("config.toml"); + if config_path.exists() { + let config_text = std::fs::read_to_string(&config_path)?; + let document: toml::Value = toml::from_str(&config_text)?; + assert!( + document.get("compaction_token_limit").is_none(), + "effectiveContextWindow must not write compaction_token_limit" + ); + } let second = start_session( &runtime, @@ -221,18 +224,14 @@ async fn compaction_update_writes_global_config_and_applies_to_session() -> Resu .await?; assert_eq!( second.session.settings.effective_context_window, - Some(250_000), - "new sessions inherit the global compaction preference" - ); - assert_ne!( - second.session.settings.effective_context_window, - model_default + Some(model_default), + "new sessions keep the model effective window" ); Ok(()) } #[tokio::test] -async fn new_session_reads_existing_global_compaction_limit() -> Result<()> { +async fn new_session_ignores_existing_global_compaction_limit() -> Result<()> { let data_root = TempDir::new()?; let cwd = data_root.path().join("workspace"); std::fs::create_dir_all(&cwd)?; @@ -250,9 +249,14 @@ async fn new_session_reads_existing_global_compaction_limit() -> Result<()> { "compaction-threshold-session-existing", ) .await?; - assert_eq!( - started.session.settings.effective_context_window, - Some(100_000) + let applied = started + .session + .settings + .effective_context_window + .expect("new session should expose an applied effective window"); + assert_ne!( + applied, 100_000, + "stale compaction_token_limit must not become the applied window" ); Ok(()) } diff --git a/crates/server/tests/session_fork_persistence.rs b/crates/server/tests/session_fork_persistence.rs index 3f9edf74..ab6a3952 100644 --- a/crates/server/tests/session_fork_persistence.rs +++ b/crates/server/tests/session_fork_persistence.rs @@ -9,7 +9,6 @@ use devo_core::AppConfigStore; use devo_core::BundledSkillsConfig; use devo_core::FileSystemSkillCatalog; use devo_core::PresetModelCatalog; -use devo_core::ProviderVendorCatalog; use devo_core::SkillsConfig; use devo_core::tools::ToolRegistry; use devo_protocol::Model; @@ -245,7 +244,6 @@ fn build_runtime(data_root: &Path) -> Result> { display_name: "Test Model".to_string(), ..Model::default() }])), - Arc::new(ProviderVendorCatalog::default()), Box::new(FileSystemSkillCatalog::new(SkillsConfig { bundled: Some(BundledSkillsConfig { enabled: false }), ..SkillsConfig::default() diff --git a/crates/server/tests/session_rollback_persistence.rs b/crates/server/tests/session_rollback_persistence.rs index 6dfb62de..2ed73fef 100644 --- a/crates/server/tests/session_rollback_persistence.rs +++ b/crates/server/tests/session_rollback_persistence.rs @@ -11,7 +11,6 @@ use devo_core::AppConfigStore; use devo_core::BundledSkillsConfig; use devo_core::FileSystemSkillCatalog; use devo_core::PresetModelCatalog; -use devo_core::ProviderVendorCatalog; use devo_core::SkillsConfig; use devo_core::tools::ToolRegistry; use devo_protocol::Model; @@ -258,7 +257,6 @@ fn build_runtime( display_name: "Test Model".to_string(), ..Model::default() }])), - Arc::new(ProviderVendorCatalog::default()), Box::new(FileSystemSkillCatalog::new(SkillsConfig { bundled: Some(BundledSkillsConfig { enabled: false }), ..SkillsConfig::default() diff --git a/crates/server/tests/session_sandbox_profile.rs b/crates/server/tests/session_sandbox_profile.rs index 714d2e41..f655da6e 100644 --- a/crates/server/tests/session_sandbox_profile.rs +++ b/crates/server/tests/session_sandbox_profile.rs @@ -15,7 +15,6 @@ use devo_core::AppConfigStore; use devo_core::BundledSkillsConfig; use devo_core::FileSystemSkillCatalog; use devo_core::PresetModelCatalog; -use devo_core::ProviderVendorCatalog; use devo_core::SkillsConfig; use devo_core::tools::ToolRegistry; use devo_protocol::ModelRequest; @@ -76,7 +75,6 @@ fn build_runtime(data_root: &Path) -> Result> { devo_server::empty_mcp_manager(), "test-model".to_string(), Arc::new(PresetModelCatalog::default()), - Arc::new(ProviderVendorCatalog::default()), Box::new(FileSystemSkillCatalog::new(SkillsConfig { bundled: Some(BundledSkillsConfig { enabled: false }), ..SkillsConfig::default() diff --git a/crates/server/tests/settings_mid_turn.rs b/crates/server/tests/settings_mid_turn.rs index d202cb4a..45117fec 100644 --- a/crates/server/tests/settings_mid_turn.rs +++ b/crates/server/tests/settings_mid_turn.rs @@ -15,7 +15,6 @@ use anyhow::Context; use anyhow::Result; use async_trait::async_trait; use devo_core::AppConfigStore; -use devo_core::ProviderVendorCatalog; use futures::StreamExt; use futures::stream; use pretty_assertions::assert_eq; @@ -324,7 +323,6 @@ fn build_runtime( devo_server::empty_mcp_manager(), "test-model".to_string(), Arc::new(PresetModelCatalog::default()), - Arc::new(ProviderVendorCatalog::default()), Box::new(FileSystemSkillCatalog::new(SkillsConfig { enabled: false, user_roots: Vec::new(), @@ -656,7 +654,7 @@ async fn mid_turn_model_switch_reaches_next_model_request() -> Result<()> { "params": { "sessionId": session_id.to_string(), "expectedVersion": 1, - "model": { "provider": "builtin", "model": "gpt-5.5" } + "model": { "provider": "openai", "model": "gpt-5.5" } } }), ) @@ -666,7 +664,7 @@ async fn mid_turn_model_switch_reaches_next_model_request() -> Result<()> { serde_json::from_value(switch_response["result"].clone()) .with_context(|| format!("model switch response: {switch_response}"))?; assert!(switch_result.applied_to_active_turn); - assert_eq!(switch_result.session.model.model, "gpt-5.5"); + assert_eq!(switch_result.session.model.model, "openai/gpt-5.5"); // Releasing the tool lets the loop build the next request, which must // already use the switched model. @@ -694,7 +692,7 @@ async fn mid_turn_model_switch_reaches_next_model_request() -> Result<()> { .context("second model request should arrive")?; assert_eq!( second_request_model.as_deref(), - Some("gpt-5.5"), + Some("openai/gpt-5.5"), "the next model request must use the switched model" ); diff --git a/crates/server/tests/skills_integration.rs b/crates/server/tests/skills_integration.rs index 57f87aa6..6f1b742e 100644 --- a/crates/server/tests/skills_integration.rs +++ b/crates/server/tests/skills_integration.rs @@ -9,7 +9,6 @@ use anyhow::Context; use anyhow::Result; use async_trait::async_trait; use devo_core::AppConfigStore; -use devo_core::ProviderVendorCatalog; use futures::stream; use pretty_assertions::assert_eq; use serde_json::json; @@ -161,7 +160,6 @@ fn build_runtime_with_registry( devo_server::empty_mcp_manager(), "test-model".to_string(), Arc::new(PresetModelCatalog::default()), - Arc::new(ProviderVendorCatalog::default()), Box::new(FileSystemSkillCatalog::new(SkillsConfig { enabled: true, user_roots: vec![user_skill_root], diff --git a/crates/server/tests/support/goal_continuation.rs b/crates/server/tests/support/goal_continuation.rs index 4691085c..da808b13 100644 --- a/crates/server/tests/support/goal_continuation.rs +++ b/crates/server/tests/support/goal_continuation.rs @@ -14,7 +14,6 @@ use devo_core::AppConfigStore; use devo_core::BundledSkillsConfig; use devo_core::FileSystemSkillCatalog; use devo_core::PresetModelCatalog; -use devo_core::ProviderVendorCatalog; use devo_core::SkillsConfig; use devo_core::tools::ToolRegistry; use devo_protocol::Model; @@ -291,7 +290,6 @@ pub fn build_runtime_with_registry( display_name: "test-model".to_string(), ..Model::default() }])), - Arc::new(ProviderVendorCatalog::default()), Box::new(FileSystemSkillCatalog::new(SkillsConfig { bundled: Some(BundledSkillsConfig { enabled: false }), ..SkillsConfig::default() diff --git a/crates/server/tests/support/subagent_lifecycle.rs b/crates/server/tests/support/subagent_lifecycle.rs index 0c98bc27..d50dc0b3 100644 --- a/crates/server/tests/support/subagent_lifecycle.rs +++ b/crates/server/tests/support/subagent_lifecycle.rs @@ -13,7 +13,6 @@ use devo_core::AppConfigStore; use devo_core::BundledSkillsConfig; use devo_core::FileSystemSkillCatalog; use devo_core::PresetModelCatalog; -use devo_core::ProviderVendorCatalog; use devo_core::SkillsConfig; use devo_core::tools::AgentToolCoordinator; use devo_core::tools::create_default_tool_registry; @@ -278,7 +277,6 @@ pub fn build_runtime( devo_server::empty_mcp_manager(), "test-model".to_string(), Arc::new(PresetModelCatalog::default()), - Arc::new(ProviderVendorCatalog::default()), Box::new(FileSystemSkillCatalog::new(SkillsConfig { bundled: Some(BundledSkillsConfig { enabled: false }), ..SkillsConfig::default() diff --git a/crates/server/tests/tool_call_param_refresh.rs b/crates/server/tests/tool_call_param_refresh.rs index 4f810f58..4169f502 100644 --- a/crates/server/tests/tool_call_param_refresh.rs +++ b/crates/server/tests/tool_call_param_refresh.rs @@ -12,7 +12,6 @@ use anyhow::Context; use anyhow::Result; use async_trait::async_trait; use devo_core::AppConfigStore; -use devo_core::ProviderVendorCatalog; use futures::stream; use pretty_assertions::assert_eq; use serde_json::json; @@ -183,7 +182,6 @@ fn build_runtime(data_root: &Path) -> Arc { devo_server::empty_mcp_manager(), "test-model".to_string(), Arc::new(PresetModelCatalog::default()), - Arc::new(ProviderVendorCatalog::default()), Box::new(FileSystemSkillCatalog::new(SkillsConfig { enabled: false, user_roots: Vec::new(), diff --git a/crates/server/tests/turn_start_persistence.rs b/crates/server/tests/turn_start_persistence.rs index c12f3b83..ce1ff962 100644 --- a/crates/server/tests/turn_start_persistence.rs +++ b/crates/server/tests/turn_start_persistence.rs @@ -11,7 +11,6 @@ use devo_core::AppConfigStore; use devo_core::BundledSkillsConfig; use devo_core::FileSystemSkillCatalog; use devo_core::PresetModelCatalog; -use devo_core::ProviderVendorCatalog; use devo_core::SkillsConfig; use devo_core::tools::ToolRegistry; use devo_protocol::Model; @@ -634,7 +633,6 @@ fn build_runtime_with_router( display_name: "Test Model".to_string(), ..Model::default() }])), - Arc::new(ProviderVendorCatalog::default()), Box::new(FileSystemSkillCatalog::new(SkillsConfig { bundled: Some(BundledSkillsConfig { enabled: false }), ..SkillsConfig::default() diff --git a/crates/server/tests/websocket_client_e2e.rs b/crates/server/tests/websocket_client_e2e.rs index ec1c2409..4d75f306 100644 --- a/crates/server/tests/websocket_client_e2e.rs +++ b/crates/server/tests/websocket_client_e2e.rs @@ -8,7 +8,6 @@ use async_trait::async_trait; use devo_core::AppConfigStore; use devo_core::FileSystemSkillCatalog; use devo_core::PresetModelCatalog; -use devo_core::ProviderVendorCatalog; use devo_core::SkillsConfig; use devo_core::tools::ToolRegistry; use devo_protocol::ModelRequest; @@ -63,7 +62,6 @@ async fn websocket_server_client_drives_listener_session_and_notifications() -> devo_server::empty_mcp_manager(), "test-model".to_string(), Arc::new(PresetModelCatalog::default()), - Arc::new(ProviderVendorCatalog::default()), Box::new(FileSystemSkillCatalog::new(SkillsConfig::default())), devo_core::AgentsMdConfig::default(), db, From f209c8ff3449b0f3bfa2e02dc7a80a5163516fac Mon Sep 17 00:00:00 2001 From: wangtsiao Date: Sat, 5 Sep 2026 02:34:32 +0800 Subject: [PATCH 06/13] feat(cli,tui): adopt provider/model selection and onboarding Update prompt/doctor routing, client surfaces, and TUI onboarding to the catalog Connection contract. --- crates/cli/src/agent_command.rs | 541 +- crates/cli/src/doctor_command.rs | 114 +- crates/cli/src/prompt_command.rs | 63 +- .../prompt_command/prompt_routing_tests.rs | 120 +- crates/client/src/client_core.rs | 23 + crates/client/src/stdio.rs | 21 + crates/client/src/websocket.rs | 21 + crates/tui/README.md | 13 +- crates/tui/src/app_command.rs | 31 +- crates/tui/src/app_event.rs | 3 +- crates/tui/src/bottom_pane/command_popup.rs | 3 +- .../bottom_pane/compaction_threshold_view.rs | 9 +- crates/tui/src/bottom_pane/mod.rs | 11 - .../tui/src/bottom_pane/settings_hub_view.rs | 30 +- crates/tui/src/chatwidget/configuration.rs | 87 +- crates/tui/src/chatwidget/history_commit.rs | 34 +- crates/tui/src/chatwidget/input.rs | 5 +- crates/tui/src/chatwidget/reasoning_effort.rs | 19 +- crates/tui/src/chatwidget/session_header.rs | 26 +- crates/tui/src/chatwidget/transcript_sync.rs | 2 +- crates/tui/src/chatwidget/worker_events.rs | 91 +- crates/tui/src/chatwidget_tests.rs | 205 +- crates/tui/src/events.rs | 52 +- crates/tui/src/exec_cell/render.rs | 2 +- crates/tui/src/history_cell.rs | 22 +- crates/tui/src/inline_onboarding_tests.rs | 85 +- crates/tui/src/interactive.rs | 286 +- crates/tui/src/onboarding.rs | 1131 +--- crates/tui/src/onboarding_widget.rs | 4801 +++++++++++++++-- crates/tui/src/onboarding_widget_tests.rs | 561 +- crates/tui/src/transcript/model.rs | 1 + crates/tui/src/transcript/presentation.rs | 30 +- crates/tui/src/transcript/restore_session.rs | 8 +- crates/tui/src/worker.rs | 243 +- crates/tui/src/worker/typed_events.rs | 1 + crates/tui/src/worker_event_test_helpers.rs | 1 + 36 files changed, 5713 insertions(+), 2983 deletions(-) diff --git a/crates/cli/src/agent_command.rs b/crates/cli/src/agent_command.rs index 86179e91..74ee6de3 100644 --- a/crates/cli/src/agent_command.rs +++ b/crates/cli/src/agent_command.rs @@ -5,10 +5,8 @@ use devo_core::AppConfigLoader; use devo_core::FileSystemAppConfigLoader; use devo_core::ModelCatalog; use devo_core::PresetModelCatalog; -use devo_core::ResolvedProviderSettings; use devo_core::SessionId; use devo_core::project_config_key; -use devo_core::resolve_model_binding; use devo_protocol::PermissionPreset; use devo_protocol::ProviderWireApi; use devo_tui::InitialTuiSession; @@ -34,62 +32,56 @@ pub(crate) async fn run_agent( let cwd = std::env::current_dir()?; let config_home = find_devo_home().context("could not determine devo home directory")?; let app_config = FileSystemAppConfigLoader::new(config_home.clone()).load(Some(&cwd))?; - let model_catalog = PresetModelCatalog::load_from_config(&app_config.provider.model_overrides)?; + let model_catalog = PresetModelCatalog::load_from_provider_config_with_overrides( + &app_config.provider_catalog_config(), + &app_config.provider.model_overrides, + )?; let project_key = project_config_key(&cwd); let permission_preset = initial_permission_preset(&app_config, &project_key, dangerously_skip_permissions); let sandbox_profile = initial_sandbox_profile(&app_config, &project_key, dangerously_skip_permissions); - let (onboarding_mode, resolved) = resolve_initial_provider_settings( - force_onboarding, - &app_config, - &config_home, - &model_catalog, - )?; + let onboarding_mode = force_onboarding || !app_config.has_provider_configuration(); + let provider_config = app_config.provider_catalog_config(); + let configured_selection = provider_config.resolve_model(None).ok(); + let fallback_model = model_catalog + .resolve_for_turn(None) + .context("builtin model catalog does not contain a visible onboarding model")?; + let model = if onboarding_mode { + fallback_model.slug.clone() + } else { + provider_config + .model + .clone() + .or_else(|| { + configured_selection + .as_ref() + .map(|selection| format!("{}/{}", selection.provider_id, selection.model_id)) + }) + .unwrap_or_else(|| fallback_model.slug.clone()) + }; + let model_metadata = model_catalog.get(&model).unwrap_or(fallback_model); + let provider = configured_selection + .as_ref() + .map(|selection| selection.wire_api) + .unwrap_or_else(|| model_metadata.provider_wire_api()); + let model_binding_id = (!onboarding_mode).then(|| model.clone()); // convert to TUI `SavedModelEntry` type. // the `SaveModelEntry` seems utilized to display model at TUI. // TODO: Investigate whether we could simplify it, unify model structure. let saved_models = saved_model_entries(&app_config); - let ResolvedProviderSettings { - wire_api, - model, - base_url: _, - api_key: _, - model_reasoning_effort_selection, - .. - } = resolved; - let active_model_binding = if onboarding_mode { - None - } else { - resolve_model_binding(&app_config.provider, /*requested_model*/ None) - }; - let request_model = active_model_binding.as_ref().and_then(|binding| { - if binding.request_model == binding.model_slug { - None - } else { - Some(binding.request_model.clone()) - } - }); - let model_binding_id = active_model_binding - .as_ref() - .map(|binding| binding.binding_id.clone()); - let model = active_model_binding - .as_ref() - .map(|binding| binding.model_slug.clone()) - .unwrap_or(model); - tracing::info!("starting interactive tui"); let exit = run_interactive_tui(InteractiveTuiConfig { // initial_session corresponding fields at top of `config.toml`. initial_session: InitialTuiSession { session_id: initial_session_id, model, - request_model, + request_model: None, model_binding_id, - provider: wire_api, - reasoning_effort_selection: model_reasoning_effort_selection, + provider, + reasoning_effort_selection: provider_config.reasoning_effort.clone(), permission_preset, sandbox_profile, compaction_token_limit: app_config.compaction_token_limit, @@ -153,177 +145,62 @@ fn initial_sandbox_profile( ) } -/// Resolves the initial provider settings and whether onboarding should be shown. -/// -/// `force_onboarding` requests onboarding regardless of stored configuration. -/// `stored_config` is the persisted provider config used to decide whether this -/// is a first-run session. `model_catalog` supplies the fallback onboarding -/// model when no usable provider settings should be resolved yet. -fn resolve_initial_provider_settings( - force_onboarding: bool, - app_config: &AppConfig, - user_config_dir: &std::path::Path, - model_catalog: &PresetModelCatalog, -) -> Result<(bool, ResolvedProviderSettings)> { - let onboarding_mode = force_onboarding || !app_config.has_provider_configuration(); - let resolved = if onboarding_mode { - // falls back to the first visible preset model. - let fallback_model = model_catalog - .resolve_for_turn(None) - .context("builtin model catalog does not contain a visible onboarding model")?; - - ResolvedProviderSettings { - provider_id: fallback_model.provider.as_str().to_string(), - wire_api: fallback_model.provider, - model: fallback_model.slug.clone(), - base_url: None, - api_key: None, - proxy_url: None, - no_proxy: None, - headers: None, - model_auto_compact_token_limit: None, - model_context_window: None, - model_reasoning_effort_selection: None, - disable_response_storage: false, - preferred_auth_method: None, - } - } else { - app_config - .resolve_provider_settings(user_config_dir) - .with_context(|| "failed to resolve provider settings outside onboarding mode")? - }; - Ok((onboarding_mode, resolved)) -} - -/// Converts persisted model bindings into TUI model-picker entries. +/// Converts persisted Connection models into TUI model-picker entries. fn saved_model_entries(app_config: &AppConfig) -> Vec { - let stored_config = &app_config.provider; - let mut entries = stored_config - .model_bindings - .iter() - .filter(|(_, binding)| binding.enabled) - .filter_map(|(binding_id, binding)| { - let provider = stored_config.providers.get(&binding.provider)?; - let request_model = if binding.request_model == binding.model_slug { - None - } else { - Some(binding.request_model.clone()) - }; - let display_name = binding - .display_name + app_config + .provider_catalog_config() + .providers + .into_iter() + .filter(|(_, provider)| provider.enabled != Some(false)) + .flat_map(|(provider_id, provider)| { + let provider_name = provider + .name .clone() - .or_else(|| request_model.clone()); - let provider_name = if provider.name.trim().is_empty() { - binding.provider.clone() - } else { - provider.name.clone() - }; - Some(SavedModelEntry { - binding_id: Some(binding_id.clone()), - model: binding.model_slug.clone(), - request_model, - display_name, - provider_id: Some(binding.provider.clone()), - provider_name: Some(provider_name), - wire_api: binding.invocation_method, - base_url: provider.base_url.clone(), - api_key: None, - }) - }) - .collect::>(); - - entries.extend(stored_config.model_providers.iter().flat_map( - |(provider_id, provider_config)| { - // Older config entries may not have persisted `wire_api`; keep them - // on the historical OpenAI-compatible chat-completions default. - let wire_api = provider_config + .filter(|name| !name.trim().is_empty()) + .unwrap_or_else(|| provider_id.clone()); + let provider_wire_api = provider .wire_api .unwrap_or(ProviderWireApi::OpenAIChatCompletions); - let provider_name = provider_config - .name - .as_deref() - .map(str::trim) - .filter(|name| !name.is_empty()) - .map(ToOwned::to_owned) - .unwrap_or_else(|| provider_id.clone()); - provider_config + provider .models - .iter() - .map(move |model| SavedModelEntry { - binding_id: None, - model: model.model.clone(), - request_model: None, - display_name: None, - provider_id: Some(provider_id.clone()), - provider_name: Some(provider_name.clone()), - wire_api, - base_url: model - .base_url - .clone() - .or_else(|| provider_config.base_url.clone()), - api_key: model - .api_key - .clone() - .or_else(|| provider_config.api_key.clone()), + .into_iter() + .filter(|(_, model)| model.enabled != Some(false)) + .map(move |(model_id, model)| { + let model_ref = format!("{provider_id}/{model_id}"); + SavedModelEntry { + binding_id: Some(model_ref.clone()), + model: model_ref, + request_model: None, + display_name: model.name, + provider_id: Some(provider_id.clone()), + provider_name: Some(provider_name.clone()), + wire_api: model.wire_api.unwrap_or(provider_wire_api), + base_url: provider.base_url.clone(), + api_key: None, + } }) - }, - )); - entries + }) + .collect() } #[cfg(test)] mod tests { use std::collections::BTreeMap; - use std::path::PathBuf; - use std::time::SystemTime; - use std::time::UNIX_EPOCH; use pretty_assertions::assert_eq; use super::initial_permission_preset; use super::initial_sandbox_profile; - use super::resolve_initial_provider_settings; use super::saved_model_entries; use devo_core::AppConfig; - use devo_core::ConfiguredModel; - use devo_core::LegacyModelProviderConfig; - use devo_core::Model; - use devo_core::ModelBindingConfig; - use devo_core::PresetModelCatalog; use devo_core::ProjectConfig; - use devo_core::ProviderConfigSection; - use devo_core::ProviderDefaultsConfig; - use devo_core::ProviderVendorConfig; - use devo_core::ResolvedProviderSettings; + use devo_core::ProviderConfigEntry; + use devo_core::ProviderConfigFile; + use devo_core::ProviderModelConfig; use devo_protocol::PermissionPreset; use devo_protocol::ProviderWireApi; use devo_tui::SavedModelEntry; - fn test_catalog() -> PresetModelCatalog { - PresetModelCatalog::new(vec![Model { - slug: "test-onboard-model".to_string(), - provider: ProviderWireApi::OpenAIChatCompletions, - ..Model::default() - }]) - } - - fn test_user_config_dir() -> PathBuf { - let nanos = SystemTime::now() - .duration_since(UNIX_EPOCH) - .expect("system time") - .as_nanos(); - let path = std::env::temp_dir().join(format!("devo-cli-test-{nanos}")); - std::fs::create_dir_all(&path).expect("create temp dir"); - path - } - - fn test_app_config(provider: ProviderConfigSection) -> AppConfig { - AppConfig { - provider, - ..AppConfig::default() - } - } - #[test] fn initial_permission_preset_defaults_to_auto_review_when_unset() { let app_config = AppConfig::default(); @@ -443,275 +320,41 @@ mod tests { } #[test] - fn resolve_initial_provider_settings_uses_catalog_fallback_during_onboarding() { - let actual = resolve_initial_provider_settings( - false, - &test_app_config(ProviderConfigSection::default()), - &test_user_config_dir(), - &test_catalog(), - ) - .expect("resolve initial provider settings"); - - assert_eq!( - actual, - ( - true, - ResolvedProviderSettings { - provider_id: "openai_chat_completions".to_string(), - wire_api: ProviderWireApi::OpenAIChatCompletions, - model: "test-onboard-model".to_string(), - base_url: None, - api_key: None, - proxy_url: None, - no_proxy: None, - headers: None, - model_auto_compact_token_limit: None, - model_context_window: None, - model_reasoning_effort_selection: None, - disable_response_storage: false, - preferred_auth_method: None, - } - ) - ); - } - - #[test] - fn resolve_initial_provider_settings_honors_forced_onboarding_with_existing_config() { - let mut provider = ProviderConfigSection::default(); - provider.providers.insert( - "openai_chat_completions".to_string(), - ProviderVendorConfig::default(), - ); - - let actual = resolve_initial_provider_settings( - true, - &test_app_config(provider), - &test_user_config_dir(), - &test_catalog(), - ) - .expect("resolve initial provider settings"); - - assert_eq!( - actual, - ( - true, - ResolvedProviderSettings { - provider_id: "openai_chat_completions".to_string(), - wire_api: ProviderWireApi::OpenAIChatCompletions, - model: "test-onboard-model".to_string(), - base_url: None, - api_key: None, - proxy_url: None, - no_proxy: None, - headers: None, - model_auto_compact_token_limit: None, - model_context_window: None, - model_reasoning_effort_selection: None, - disable_response_storage: false, - preferred_auth_method: None, - } - ) - ); - } - - #[test] - fn resolve_initial_provider_settings_uses_merged_project_provider_config() { - let provider = ProviderConfigSection { - defaults: ProviderDefaultsConfig { - model_binding: Some("deepseek-binding".to_string()), + fn saved_model_entries_use_canonical_connection_model_references() { + let app_config = AppConfig { + provider_catalog: ProviderConfigFile { + providers: BTreeMap::from([( + "openai".to_string(), + ProviderConfigEntry { + name: Some("OpenAI".to_string()), + base_url: Some("https://provider.example".to_string()), + wire_api: Some(ProviderWireApi::OpenAIResponses), + models: BTreeMap::from([( + "gpt-test".to_string(), + ProviderModelConfig { + name: Some("GPT Test".to_string()), + ..ProviderModelConfig::default() + }, + )]), + ..ProviderConfigEntry::default() + }, + )]), + ..ProviderConfigFile::default() }, - providers: BTreeMap::from([( - "deepseek".to_string(), - ProviderVendorConfig { - name: "deepseek".to_string(), - base_url: Some("https://api.deepseek.com".to_string()), - wire_apis: vec![ProviderWireApi::OpenAIChatCompletions], - enabled: true, - ..ProviderVendorConfig::default() - }, - )]), - model_bindings: BTreeMap::from([( - "deepseek-binding".to_string(), - ModelBindingConfig { - model_slug: "deepseek-v4-flash".to_string(), - provider: "deepseek".to_string(), - request_model: "deepseek-v4-flash".to_string(), - invocation_method: ProviderWireApi::OpenAIChatCompletions, - ..ModelBindingConfig::default() - }, - )]), - ..ProviderConfigSection::default() + ..AppConfig::default() }; - let actual = resolve_initial_provider_settings( - false, - &test_app_config(provider), - &test_user_config_dir(), - &test_catalog(), - ) - .expect("resolve initial provider settings"); - - assert_eq!( - actual, - ( - false, - ResolvedProviderSettings { - provider_id: "deepseek".to_string(), - wire_api: ProviderWireApi::OpenAIChatCompletions, - model: "deepseek-v4-flash".to_string(), - base_url: Some("https://api.deepseek.com".to_string()), - api_key: None, - proxy_url: None, - no_proxy: None, - headers: None, - model_auto_compact_token_limit: None, - model_context_window: None, - model_reasoning_effort_selection: None, - disable_response_storage: false, - preferred_auth_method: None, - } - ) - ); - } - - #[test] - fn saved_model_entries_inherit_provider_defaults_and_preserve_model_overrides() { - let app_config = test_app_config(ProviderConfigSection { - providers: BTreeMap::from([( - "openai".to_string(), - ProviderVendorConfig { - base_url: Some("https://provider.example".to_string()), - credential: Some("provider-key".to_string()), - wire_apis: vec![ProviderWireApi::OpenAIResponses], - enabled: true, - ..ProviderVendorConfig::default() - }, - )]), - model_bindings: BTreeMap::from([( - "openai".to_string(), - ModelBindingConfig { - model_slug: "provider-defaults".to_string(), - provider: "openai".to_string(), - request_model: "provider-defaults".to_string(), - invocation_method: ProviderWireApi::OpenAIResponses, - ..ModelBindingConfig::default() - }, - )]), - model_providers: BTreeMap::from([( - "legacy".to_string(), - LegacyModelProviderConfig { - base_url: Some("https://provider.example".to_string()), - api_key: Some("provider-key".to_string()), - wire_api: Some(ProviderWireApi::OpenAIResponses), - models: vec![ConfiguredModel { - model: "model-overrides".to_string(), - base_url: Some("https://model.example".to_string()), - api_key: Some("model-key".to_string()), - }], - ..LegacyModelProviderConfig::default() - }, - )]), - ..ProviderConfigSection::default() - }); - - assert_eq!( - saved_model_entries(&app_config), - vec![ - SavedModelEntry { - binding_id: Some("openai".to_string()), - model: "provider-defaults".to_string(), - request_model: None, - display_name: None, - provider_id: Some("openai".to_string()), - provider_name: Some("openai".to_string()), - wire_api: ProviderWireApi::OpenAIResponses, - base_url: Some("https://provider.example".to_string()), - api_key: None, - }, - SavedModelEntry { - binding_id: None, - model: "model-overrides".to_string(), - request_model: None, - display_name: None, - provider_id: Some("legacy".to_string()), - provider_name: Some("legacy".to_string()), - wire_api: ProviderWireApi::OpenAIResponses, - base_url: Some("https://model.example".to_string()), - api_key: Some("model-key".to_string()), - }, - ] - ); - } - - #[test] - fn saved_model_entries_preserve_binding_request_and_display_names() { - let app_config = test_app_config(ProviderConfigSection { - providers: BTreeMap::from([( - "deepseek".to_string(), - ProviderVendorConfig { - base_url: Some("https://api.deepseek.com".to_string()), - wire_apis: vec![ProviderWireApi::OpenAIChatCompletions], - enabled: true, - ..ProviderVendorConfig::default() - }, - )]), - model_bindings: BTreeMap::from([( - "deepseek".to_string(), - ModelBindingConfig { - model_slug: "deepseek-v4-flash".to_string(), - provider: "deepseek".to_string(), - request_model: "DeepSeek-V4-Flash".to_string(), - display_name: Some("DeepSeek-V4-Flash".to_string()), - invocation_method: ProviderWireApi::OpenAIChatCompletions, - ..ModelBindingConfig::default() - }, - )]), - ..ProviderConfigSection::default() - }); - - assert_eq!( - saved_model_entries(&app_config), - vec![SavedModelEntry { - binding_id: Some("deepseek".to_string()), - model: "deepseek-v4-flash".to_string(), - request_model: Some("DeepSeek-V4-Flash".to_string()), - display_name: Some("DeepSeek-V4-Flash".to_string()), - provider_id: Some("deepseek".to_string()), - provider_name: Some("deepseek".to_string()), - wire_api: ProviderWireApi::OpenAIChatCompletions, - base_url: Some("https://api.deepseek.com".to_string()), - api_key: None, - }] - ); - } - - #[test] - fn saved_model_entries_defaults_wire_api_to_openai_chat_completions() { - let app_config = test_app_config(ProviderConfigSection { - model_providers: BTreeMap::from([( - "openai".to_string(), - LegacyModelProviderConfig { - models: vec![ConfiguredModel { - model: "default-wire-api".to_string(), - ..ConfiguredModel::default() - }], - ..LegacyModelProviderConfig::default() - }, - )]), - ..ProviderConfigSection::default() - }); - assert_eq!( saved_model_entries(&app_config), vec![SavedModelEntry { - binding_id: None, - model: "default-wire-api".to_string(), + binding_id: Some("openai/gpt-test".to_string()), + model: "openai/gpt-test".to_string(), request_model: None, - display_name: None, + display_name: Some("GPT Test".to_string()), provider_id: Some("openai".to_string()), - provider_name: Some("openai".to_string()), - wire_api: ProviderWireApi::OpenAIChatCompletions, - base_url: None, + provider_name: Some("OpenAI".to_string()), + wire_api: ProviderWireApi::OpenAIResponses, + base_url: Some("https://provider.example".to_string()), api_key: None, }] ); diff --git a/crates/cli/src/doctor_command.rs b/crates/cli/src/doctor_command.rs index b69e2e9c..cdc3d5f8 100644 --- a/crates/cli/src/doctor_command.rs +++ b/crates/cli/src/doctor_command.rs @@ -7,6 +7,8 @@ use anyhow::Result; use devo_core::AppConfigLoader; use devo_core::FileSystemAppConfigLoader; +use devo_core::PROVIDER_CONFIG_FILE_NAME; +use devo_core::read_provider_catalog_config; use devo_util_paths::find_devo_home; pub(crate) async fn run_doctor() -> Result<()> { @@ -44,31 +46,54 @@ pub(crate) async fn run_doctor() -> Result<()> { } println!(); - println!("{} Config file:", "✓".green().bold()); + println!("{} Provider config:", "✓".green().bold()); if let Ok(home) = find_devo_home() { - let config_path = home.join("config.toml"); - if config_path.exists() { - println!(" {} {}", "found".green(), config_path.display()); - let content = std::fs::read_to_string(&config_path).unwrap_or_default(); - if has_provider_credentials(&content) { - println!(" {} api_key and base_url configured", "✓".green()); - } else { - println!(" {} api_key or base_url missing", "!".yellow()); - all_ok = false; + let provider_path = home.join(PROVIDER_CONFIG_FILE_NAME); + if provider_path.exists() { + println!(" {} {}", "found".green(), provider_path.display()); + match read_provider_catalog_config(&provider_path) { + Ok(config) => { + println!(" providers: {}", config.providers.len()); + if let Some(model) = config.model { + println!(" default model: {model}"); + } else { + println!(" {} no default model set", "!".yellow()); + } + } + Err(error) => { + println!(" {} failed to parse: {error}", "✗".red()); + all_ok = false; + } } - if let Some(line) = default_model_line(&content) { - println!(" default model: {}", line.trim()); + } else { + let config_path = home.join("config.toml"); + if config_path.exists() { + println!( + " {} {} (legacy; provider writes now use providers.json)", + "found".yellow(), + config_path.display() + ); + let content = std::fs::read_to_string(&config_path).unwrap_or_default(); + if has_provider_credentials(&content) { + println!(" {} legacy api_key and base_url configured", "✓".green()); + } else { + println!(" {} legacy provider settings incomplete", "!".yellow()); + all_ok = false; + } + if let Some(line) = default_model_line(&content) { + println!(" default model: {}", line.trim()); + } else { + println!(" {} no default model set", "!".yellow()); + } } else { - println!(" {} no default model set", "!".yellow()); + println!( + " {} not found at {}", + "missing".yellow(), + provider_path.display() + ); + println!(" Run `devo onboard` to create it."); + all_ok = false; } - } else { - println!( - " {} not found at {}", - "missing".yellow(), - config_path.display() - ); - println!(" Run `devo onboard` to create it."); - all_ok = false; } } println!(); @@ -78,16 +103,31 @@ pub(crate) async fn run_doctor() -> Result<()> { Ok(home) => { let cwd = std::env::current_dir()?; let app_config = FileSystemAppConfigLoader::new(home.clone()).load(Some(&cwd))?; - match app_config.resolve_provider_settings(&home) { - Ok(resolved) => { - println!(" provider: {}", resolved.provider_id); - println!(" model: {}", resolved.model); + let provider_config = app_config.provider_catalog_config(); + match provider_config.resolve_model(None) { + Ok(selection) => { + let provider = provider_config + .providers + .get(&selection.provider_id) + .expect("resolved provider should be present"); + let auth = devo_core::read_user_auth_config( + &home.join(devo_core::AUTH_CONFIG_FILE_NAME), + )?; + let api_key = provider + .credential + .as_deref() + .and_then(|credential| auth.credentials.get(credential)); + println!(" provider: {}", selection.provider_id); + println!( + " model: {}/{}", + selection.provider_id, selection.model_id + ); println!( " base_url: {}", - resolved.base_url.as_deref().unwrap_or("default") + provider.base_url.as_deref().unwrap_or("default") ); - println!(" wire_api: {:?}", resolved.wire_api); - if resolved.api_key.is_some() { + println!(" wire_api: {:?}", selection.wire_api); + if api_key.is_some() { println!(" api_key: {} (set)", "✓".green()); } else { println!(" api_key: {} (not set)", "✗".red()); @@ -108,10 +148,24 @@ pub(crate) async fn run_doctor() -> Result<()> { println!(); println!("{} Model catalog:", "✓".green().bold()); - match devo_core::PresetModelCatalog::load() { + let catalog_result: anyhow::Result<_> = find_devo_home() + .map_err(anyhow::Error::from) + .and_then(|home| { + FileSystemAppConfigLoader::new(home) + .load(None) + .map_err(anyhow::Error::from) + }) + .and_then(|config| { + devo_core::PresetModelCatalog::load_from_provider_config_with_overrides( + &config.provider_catalog_config(), + &config.provider.model_overrides, + ) + .map_err(anyhow::Error::from) + }); + match catalog_result { Ok(catalog) => { let count = catalog.into_inner().len(); - println!(" {} builtin models loaded", count); + println!(" {} provider/model entries loaded", count); } Err(e) => { println!(" {} failed to load: {}", "✗".red(), e); diff --git a/crates/cli/src/prompt_command.rs b/crates/cli/src/prompt_command.rs index 3bc03dd3..7c603f06 100644 --- a/crates/cli/src/prompt_command.rs +++ b/crates/cli/src/prompt_command.rs @@ -10,8 +10,7 @@ use devo_core::PresetModelCatalog; use devo_core::QueryEvent; use devo_core::TurnConfig; use devo_core::default_base_instructions; -use devo_core::provider_request_model_map_for_binding; -use devo_core::resolve_enabled_model_binding; +use devo_core::provider_request_config; use devo_core::tools::ToolPlanConfig; use devo_core::tools::handlers; use devo_mcp::manager::RmcpMcpManager; @@ -55,7 +54,10 @@ pub(crate) async fn run_prompt( .unwrap_or_else(|_| AppConfig::default()); let resolved_provider = devo_server::load_server_provider(&app_config, model_override, &home_dir)?; - let model_catalog = PresetModelCatalog::load_from_config(&app_config.provider.model_overrides)?; + let model_catalog = PresetModelCatalog::load_from_provider_config_with_overrides( + &app_config.provider_catalog_config(), + &app_config.provider.model_overrides, + )?; let turn_config = prompt_turn_config( &app_config, &model_catalog, @@ -256,30 +258,58 @@ fn prompt_turn_config( }) }; - if let Some(binding) = resolve_enabled_model_binding(&app_config.provider, requested_model) { - let provider_request_models = devo_core::ProviderRequestModelMap::new( - provider_request_model_map_for_binding(&app_config.provider, &binding), + let provider_config = app_config.provider_catalog_config(); + let selected_model = requested_model + .or(provider_config.model.as_deref()) + .or(Some(default_model)); + if let Some(selection) = selected_model + .and_then(|model| provider_config.resolve_model(Some(model)).ok()) + .or_else(|| provider_config.resolve_model(None).ok()) + { + let model_reference = format!("{}/{}", selection.provider_id, selection.model_id); + let mut model_config = provider_config + .providers + .get(&selection.provider_id) + .and_then(|provider| provider.models.get(&selection.model_id)) + .cloned(); + if let Some(model) = model_config.as_mut() { + model.migrate_reasoning_implementation_into_variants(); + } + let reasoning_effort_selection = provider_config.reasoning_effort.clone().or_else(|| { + model_config + .as_ref() + .and_then(|model| model.default_reasoning_selection.clone()) + }); + let variant_id = model_config.as_ref().and_then(|model| { + model.resolve_turn_variant_id( + selection.variant_id.as_deref(), + reasoning_effort_selection.as_deref(), + ) + }); + let (request_defaults, request_headers) = provider_request_config( + &provider_config, + &selection.provider_id, + &selection.model_id, + variant_id.as_deref(), ); - let reasoning_effort_selection = app_config - .provider - .model_reasoning_effort_selection - .clone() - .or(binding.default_reasoning_effort.clone()); + let provider_request_models = devo_core::ProviderRequestModelMap::default() + .with_request_config(request_defaults, request_headers); let mut turn_config = TurnConfig::with_provider_route( - catalog_model(&binding.model_slug), - binding.request_model.clone(), + catalog_model(&model_reference), + selection.model_id, provider_request_models, - ProviderRoute::binding(binding.provider_id.clone(), binding.invocation_method), + ProviderRoute::connection(selection.provider_id.clone(), selection.wire_api), reasoning_effort_selection, ); - turn_config.model_binding_id = Some(binding.binding_id); + turn_config.model_binding_id = Some(model_reference); + turn_config.variant = variant_id; return turn_config; } let selected_model = requested_model.unwrap_or(default_model); TurnConfig::new( catalog_model(selected_model), - app_config.provider.model_reasoning_effort_selection.clone(), + provider_config.reasoning_effort.clone(), ) } @@ -516,6 +546,7 @@ fn write_query_event_jsonl(session_id: &str, event: &QueryEvent) -> Result<()> { message, }) } + QueryEvent::ContextEstimate { .. } => Ok(()), QueryEvent::UsageDelta { usage } => write_jsonl(&PromptJsonlEvent::UsageDelta { session_id, usage: PromptUsageDelta::new(usage), diff --git a/crates/cli/src/prompt_command/prompt_routing_tests.rs b/crates/cli/src/prompt_command/prompt_routing_tests.rs index 34367645..aee56b06 100644 --- a/crates/cli/src/prompt_command/prompt_routing_tests.rs +++ b/crates/cli/src/prompt_command/prompt_routing_tests.rs @@ -12,11 +12,11 @@ use super::RoutedPromptProvider; use super::prompt_turn_config; use devo_core::AppConfig; use devo_core::Model; -use devo_core::ModelBindingConfig; use devo_core::PresetModelCatalog; -use devo_core::ProviderConfigSection; -use devo_core::ProviderDefaultsConfig; -use devo_core::ProviderVendorConfig; +use devo_core::ProviderConfigEntry; +use devo_core::ProviderConfigFile; +use devo_core::ProviderModelConfig; +use devo_core::ProviderModelVariantConfig; use devo_protocol::ModelRequest; use devo_protocol::ModelResponse; use devo_protocol::ProviderWireApi; @@ -99,108 +99,72 @@ impl ProviderRouter for CapturingRouter { } #[test] -fn prompt_turn_config_routes_requested_binding_to_provider_route() { +fn prompt_turn_config_routes_requested_connection_model_to_provider_route() { let app_config = AppConfig { - provider: ProviderConfigSection { - defaults: ProviderDefaultsConfig { - model_binding: Some("main".to_string()), - }, + provider_catalog: ProviderConfigFile { providers: BTreeMap::from([ ( "default-provider".to_string(), - ProviderVendorConfig { - enabled: true, - wire_apis: vec![ProviderWireApi::OpenAIChatCompletions], - ..ProviderVendorConfig::default() + ProviderConfigEntry { + wire_api: Some(ProviderWireApi::OpenAIChatCompletions), + models: BTreeMap::from([( + "main".to_string(), + ProviderModelConfig::default(), + )]), + ..ProviderConfigEntry::default() }, ), ( "anthropic-provider".to_string(), - ProviderVendorConfig { - enabled: true, - wire_apis: vec![ProviderWireApi::AnthropicMessages], - ..ProviderVendorConfig::default() - }, - ), - ( - "other-provider".to_string(), - ProviderVendorConfig { - enabled: true, - wire_apis: vec![ProviderWireApi::AnthropicMessages], - ..ProviderVendorConfig::default() + ProviderConfigEntry { + wire_api: Some(ProviderWireApi::AnthropicMessages), + models: BTreeMap::from([( + "alt".to_string(), + ProviderModelConfig { + variants: BTreeMap::from([( + "fast".to_string(), + ProviderModelVariantConfig::default(), + )]), + ..ProviderModelConfig::default() + }, + )]), + ..ProviderConfigEntry::default() }, ), ]), - model_bindings: BTreeMap::from([ - ( - "main".to_string(), - ModelBindingConfig { - model_slug: "catalog-main".to_string(), - provider: "default-provider".to_string(), - request_model: "vendor/main".to_string(), - invocation_method: ProviderWireApi::OpenAIChatCompletions, - ..ModelBindingConfig::default() - }, - ), - ( - "alt".to_string(), - ModelBindingConfig { - model_slug: "catalog-alt".to_string(), - provider: "anthropic-provider".to_string(), - request_model: "vendor/alt".to_string(), - invocation_method: ProviderWireApi::AnthropicMessages, - ..ModelBindingConfig::default() - }, - ), - ( - "alt-thinking".to_string(), - ModelBindingConfig { - model_slug: "catalog-alt-thinking".to_string(), - provider: "anthropic-provider".to_string(), - request_model: "vendor/alt-thinking".to_string(), - invocation_method: ProviderWireApi::AnthropicMessages, - ..ModelBindingConfig::default() - }, - ), - ( - "other-thinking".to_string(), - ModelBindingConfig { - model_slug: "catalog-alt-thinking".to_string(), - provider: "other-provider".to_string(), - request_model: "other/alt-thinking".to_string(), - invocation_method: ProviderWireApi::AnthropicMessages, - ..ModelBindingConfig::default() - }, - ), - ]), - ..ProviderConfigSection::default() + ..ProviderConfigFile::default() }, ..AppConfig::default() }; let model_catalog = PresetModelCatalog::new(vec![Model { - slug: "catalog-alt".to_string(), + slug: "anthropic-provider/alt".to_string(), provider: ProviderWireApi::AnthropicMessages, ..Model::default() }]); - let turn_config = prompt_turn_config(&app_config, &model_catalog, Some("alt"), "catalog-main"); + let turn_config = prompt_turn_config( + &app_config, + &model_catalog, + Some("anthropic-provider/alt/fast"), + "default-provider/main", + ); - assert_eq!(turn_config.model.slug, "catalog-alt"); - assert_eq!(turn_config.request_model, "vendor/alt"); - assert_eq!(turn_config.model_binding_id, Some("alt".to_string())); + assert_eq!(turn_config.model.slug, "anthropic-provider/alt"); + assert_eq!(turn_config.request_model, "alt"); assert_eq!( - turn_config.provider_route, - ProviderRoute::binding("anthropic-provider", ProviderWireApi::AnthropicMessages) + turn_config.model_binding_id, + Some("anthropic-provider/alt".to_string()) ); assert_eq!( - turn_config.provider_request_model("catalog-alt-thinking"), - "vendor/alt-thinking" + turn_config.provider_route, + ProviderRoute::connection("anthropic-provider", ProviderWireApi::AnthropicMessages) ); + assert_eq!(turn_config.variant, Some("fast".to_string())); } #[tokio::test] async fn routed_prompt_provider_forwards_configured_route() { - let route = ProviderRoute::binding("anthropic-provider", ProviderWireApi::AnthropicMessages); + let route = ProviderRoute::connection("anthropic-provider", ProviderWireApi::AnthropicMessages); let router = Arc::new(CapturingRouter::default()); let provider = RoutedPromptProvider::new(router.clone(), route.clone()); diff --git a/crates/client/src/client_core.rs b/crates/client/src/client_core.rs index 5a85d9c7..ba1de93c 100644 --- a/crates/client/src/client_core.rs +++ b/crates/client/src/client_core.rs @@ -893,6 +893,7 @@ impl ServerClientCore { // provider label is response-side information. provider: String::new(), model: slug, + variant: None, reasoning_effort: None, }), model_binding_id, @@ -952,6 +953,20 @@ impl ServerClientCore { self.request("provider/upsert", params).await } + pub(crate) async fn provider_disconnect( + &mut self, + params: devo_protocol::native::rpc_admin::ProviderDisconnectParams, + ) -> Result { + self.request("provider/disconnect", params).await + } + + pub(crate) async fn provider_model_remove( + &mut self, + params: devo_protocol::native::rpc_admin::ProviderModelRemoveParams, + ) -> Result { + self.request("provider/model/remove", params).await + } + pub(crate) async fn provider_validate( &mut self, params: devo_protocol::native::rpc_admin::ProviderValidateParams, @@ -960,6 +975,14 @@ impl ServerClientCore { .await } + pub(crate) async fn provider_discover( + &mut self, + params: devo_protocol::native::rpc_admin::ProviderDiscoverParams, + ) -> Result { + self.request_without_timeout("provider/discover", params) + .await + } + pub(crate) async fn command_exec( &mut self, params: CommandExecParams, diff --git a/crates/client/src/stdio.rs b/crates/client/src/stdio.rs index 48abfc47..3d44d510 100644 --- a/crates/client/src/stdio.rs +++ b/crates/client/src/stdio.rs @@ -198,6 +198,20 @@ impl StdioServerClient { self.core.provider_upsert(params).await } + pub async fn provider_disconnect( + &mut self, + params: devo_protocol::native::rpc_admin::ProviderDisconnectParams, + ) -> Result { + self.core.provider_disconnect(params).await + } + + pub async fn provider_model_remove( + &mut self, + params: devo_protocol::native::rpc_admin::ProviderModelRemoveParams, + ) -> Result { + self.core.provider_model_remove(params).await + } + pub async fn provider_validate( &mut self, params: devo_protocol::native::rpc_admin::ProviderValidateParams, @@ -205,6 +219,13 @@ impl StdioServerClient { self.core.provider_validate(params).await } + pub async fn provider_discover( + &mut self, + params: devo_protocol::native::rpc_admin::ProviderDiscoverParams, + ) -> Result { + self.core.provider_discover(params).await + } + pub async fn command_exec(&mut self, params: CommandExecParams) -> Result { self.core.command_exec(params).await } diff --git a/crates/client/src/websocket.rs b/crates/client/src/websocket.rs index cbe2c41c..5a8519e0 100644 --- a/crates/client/src/websocket.rs +++ b/crates/client/src/websocket.rs @@ -157,6 +157,20 @@ impl WebSocketServerClient { self.core.provider_upsert(params).await } + pub async fn provider_disconnect( + &mut self, + params: devo_protocol::native::rpc_admin::ProviderDisconnectParams, + ) -> Result { + self.core.provider_disconnect(params).await + } + + pub async fn provider_model_remove( + &mut self, + params: devo_protocol::native::rpc_admin::ProviderModelRemoveParams, + ) -> Result { + self.core.provider_model_remove(params).await + } + pub async fn provider_validate( &mut self, params: devo_protocol::native::rpc_admin::ProviderValidateParams, @@ -164,6 +178,13 @@ impl WebSocketServerClient { self.core.provider_validate(params).await } + pub async fn provider_discover( + &mut self, + params: devo_protocol::native::rpc_admin::ProviderDiscoverParams, + ) -> Result { + self.core.provider_discover(params).await + } + pub async fn command_exec(&mut self, params: CommandExecParams) -> Result { self.core.request("command/exec", params).await } diff --git a/crates/tui/README.md b/crates/tui/README.md index 86b86a76..3221cfb0 100644 --- a/crates/tui/README.md +++ b/crates/tui/README.md @@ -111,6 +111,17 @@ The alternate screen is a separate terminal screen buffer. Full-screen terminal This crate uses the alternate screen both for the main TUI and for full-screen overlays such as the transcript pager. +### Model onboarding + +First-run model setup temporarily uses the alternate screen so the onboarding +view has the full terminal height and never competes with the composer. The +flow is deliberately linear: choose a provider, choose a catalog or custom +model, configure the common model fields, optionally expand advanced +overrides, review the result, and then validate and save it. Custom provider +and custom model actions remain visible beside the normal selections. API keys +are handled as transient input and persisted through `auth.json`; the provider +and model directory contains only non-secret catalog data. + #### Bracketed paste Bracketed paste lets the terminal distinguish typed input from pasted input. Without it, a multi-line paste looks like a rapid sequence of key presses. With bracketed paste, the application can detect paste boundaries and handle large pasted content more safely. @@ -462,7 +473,7 @@ Full-screen overlays rendered in the terminal alternate screen: | `exec_command.rs` | Shell command execution helpers. | | `get_git_diff.rs` | Extracts git diffs for display. | | `clipboard_copy.rs` / `clipboard_paste.rs` | System clipboard integration via `arboard`. | -| `onboarding.rs` | Persists onboarding configuration (model, provider, API key) to `config.toml`. | +| `onboarding.rs` | Persists onboarding configuration across provider/model JSON and user-scoped `auth.json`. | | `ui_consts.rs` | Shared UI constants (prefix columns, layout widths). | | `version.rs` | Version tracking and display. | | `test_backend.rs` | Test backend for snapshot testing. | diff --git a/crates/tui/src/app_command.rs b/crates/tui/src/app_command.rs index 28b9af04..ae29f48d 100644 --- a/crates/tui/src/app_command.rs +++ b/crates/tui/src/app_command.rs @@ -41,11 +41,30 @@ pub(crate) enum GoalObjectiveMode { /// Thin wrapper around protocol-wide operations. Claw's /// protocol is RPC-shaped instead, so the TUI owns a small command enum and the /// host/worker adapter converts the relevant variants into protocol params. -#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +#[derive(Debug, Clone, PartialEq, Serialize)] pub(crate) enum AppCommand { RunUserShellCommand { command: String, }, + /// Validate a provider Connection and model through the canonical Native RPC. + ProviderValidate { + params: devo_protocol::native::rpc_admin::ProviderValidateParams, + }, + /// Load provider Connections and directory templates through the canonical Native RPC. + ProviderList, + /// Persist a provider Connection and its model directory through the canonical Native RPC. + ProviderUpsert { + params: devo_protocol::native::rpc_admin::ProviderUpsertParams, + }, + /// Disconnect a configured provider Connection. + DisconnectProvider { + provider_id: String, + }, + /// Remove one model from a configured provider Connection. + RemoveProviderModel { + provider_id: String, + model_id: String, + }, SubmitShellInput { command: String, }, @@ -476,6 +495,11 @@ impl AppCommand { pub(crate) fn kind(&self) -> &'static str { match self { Self::RunUserShellCommand { .. } => "run_user_shell_command", + Self::ProviderValidate { .. } => "provider_validate", + Self::ProviderList => "provider_list", + Self::ProviderUpsert { .. } => "provider_upsert", + Self::DisconnectProvider { .. } => "disconnect_provider", + Self::RemoveProviderModel { .. } => "remove_provider_model", Self::SubmitShellInput { .. } => "submit_shell_input", Self::ExecuteShellCommand { .. } => "execute_shell_command", Self::Compact => "compact", @@ -519,6 +543,11 @@ impl AppCommand { Self::RunUserShellCommand { command } => { AppCommandView::RunUserShellCommand { command } } + Self::ProviderValidate { .. } | Self::ProviderList | Self::ProviderUpsert { .. } => { + AppCommandView::ReloadUserConfig + } + Self::DisconnectProvider { .. } => AppCommandView::ReloadUserConfig, + Self::RemoveProviderModel { .. } => AppCommandView::ReloadUserConfig, Self::SubmitShellInput { command } => AppCommandView::SubmitShellInput { command }, Self::ExecuteShellCommand { command } => { AppCommandView::ExecuteShellCommand { command } diff --git a/crates/tui/src/app_event.rs b/crates/tui/src/app_event.rs index d6e187d2..8b006666 100644 --- a/crates/tui/src/app_event.rs +++ b/crates/tui/src/app_event.rs @@ -73,7 +73,8 @@ pub(crate) enum SubagentDebugStep { }, } -#[derive(Debug, Clone, PartialEq, Eq)] +#[derive(Debug, Clone, PartialEq)] +#[allow(clippy::large_enum_variant)] pub(crate) enum AppEvent { /// Request a redraw on the next frame. Redraw, diff --git a/crates/tui/src/bottom_pane/command_popup.rs b/crates/tui/src/bottom_pane/command_popup.rs index 7b6027dc..a756e10e 100644 --- a/crates/tui/src/bottom_pane/command_popup.rs +++ b/crates/tui/src/bottom_pane/command_popup.rs @@ -179,8 +179,7 @@ impl CommandPopup { ) -> Vec { matches .into_iter() - .enumerate() - .map(|(_, (item, indices))| { + .map(|(item, indices)| { let CommandItem::Builtin(cmd) = item; let name = format!("/{}", cmd.command()); let description = cmd.description().to_string(); diff --git a/crates/tui/src/bottom_pane/compaction_threshold_view.rs b/crates/tui/src/bottom_pane/compaction_threshold_view.rs index e8886459..dce37546 100644 --- a/crates/tui/src/bottom_pane/compaction_threshold_view.rs +++ b/crates/tui/src/bottom_pane/compaction_threshold_view.rs @@ -1,8 +1,9 @@ -//! Absolute-token compaction threshold picker for Settings › Compaction. +//! Absolute-token compaction threshold picker (legacy). //! -//! Lists preset limits clamped to the model context window, annotating -//! `(recommended)` and `(current)`. Applying a row emits -//! [`AppCommand::UpdateEffectiveContextWindow`]. +//! The Settings › Compaction UI is removed; usable context is edited per model. +//! This module still exports [`format_token_limit`] for status/settings labels. + +#![allow(dead_code)] use crossterm::event::KeyCode; use crossterm::event::KeyEvent; diff --git a/crates/tui/src/bottom_pane/mod.rs b/crates/tui/src/bottom_pane/mod.rs index f60f369c..22dde287 100644 --- a/crates/tui/src/bottom_pane/mod.rs +++ b/crates/tui/src/bottom_pane/mod.rs @@ -53,10 +53,7 @@ pub(crate) use approval_overlay::ApprovalOverlayRequest; pub(crate) use chat_composer::ChatComposer; use chat_composer::ChatComposerConfig; use chat_composer::InputResult as ComposerInputResult; -pub(crate) use compaction_threshold_view::CompactionThresholdSnapshot; -use compaction_threshold_view::CompactionThresholdView; pub(crate) use compaction_threshold_view::format_token_limit; -pub(crate) use compaction_threshold_view::recommended_compaction_token_limit; use context_occupancy_view::ContextOccupancyView; pub(crate) use context_occupancy_view::SessionTokenTotals; pub(crate) use context_occupancy_view::StatusPanelSnapshot; @@ -701,14 +698,6 @@ impl BottomPane { )); } - pub(crate) fn open_compaction_threshold(&mut self, snapshot: CompactionThresholdSnapshot) { - self.push_view(Box::new(CompactionThresholdView::new( - snapshot, - self.app_event_tx.clone(), - self.accent_color, - ))); - } - pub(crate) fn refresh_settings_hub(&mut self, snapshot: SettingsHubSnapshot) { for view in self.view_stack.iter_mut().rev() { if view.update_settings_hub_snapshot(snapshot.clone()) { diff --git a/crates/tui/src/bottom_pane/settings_hub_view.rs b/crates/tui/src/bottom_pane/settings_hub_view.rs index ecc21784..6387697f 100644 --- a/crates/tui/src/bottom_pane/settings_hub_view.rs +++ b/crates/tui/src/bottom_pane/settings_hub_view.rs @@ -121,7 +121,7 @@ impl SettingsHubView { fn row_count(&self) -> usize { match self.tab { - SettingsHubTab::Session => 4, + SettingsHubTab::Session => 3, SettingsHubTab::Appearance => 2, SettingsHubTab::Agent => 0, } @@ -146,7 +146,6 @@ impl SettingsHubView { 0 => self.app_event_tx.send(AppEvent::SettingsOpenModel), 1 => self.app_event_tx.send(AppEvent::SettingsOpenPermissions), 2 => self.app_event_tx.send(AppEvent::SettingsCycleMode), - 3 => self.app_event_tx.send(AppEvent::SettingsOpenCompaction), _ => {} }, SettingsHubTab::Appearance => match self.selected_row { @@ -197,14 +196,6 @@ impl SettingsHubView { self.selected_row == 2, /*cycleable*/ false, )); - lines.push(Line::from("")); - lines.push(self.setting_row( - &pad, - "Default Compaction Limit", - &self.snapshot.compaction_threshold_label, - self.selected_row == 3, - /*cycleable*/ false, - )); } SettingsHubTab::Appearance => { lines.push(self.setting_row( @@ -406,7 +397,7 @@ impl Renderable for SettingsHubView { fn desired_height(&self, _width: u16) -> u16 { // title + blank + tabs + separator + blank + content + blank + footer let content = match self.tab { - SettingsHubTab::Session => 13, + SettingsHubTab::Session => 11, SettingsHubTab::Appearance => 10, SettingsHubTab::Agent => 10, }; @@ -451,14 +442,21 @@ mod tests { .map(ToString::to_string) .collect::>() .join("\n"); + let model_line = text + .lines() + .find(|line| line.contains("Default Model")) + .expect("session tab should render Default Model"); + assert!( + model_line.contains("deepseek-v4-flash"), + "model value missing on label row: {model_line}" + ); assert!( - text.contains("Default Compaction Limit 250K") - || text.contains("Default Compaction Limit 250K"), - "label must not run into value: {text}" + !model_line.contains("Default Modeldeepseek-v4-flash"), + "missing gap between model label and value: {model_line}" ); assert!( - !text.contains("Default Compaction Limit250K"), - "missing gap between compaction label and value: {text}" + !text.contains("Default Compaction Limit"), + "compaction threshold row must be removed: {text}" ); } diff --git a/crates/tui/src/chatwidget/configuration.rs b/crates/tui/src/chatwidget/configuration.rs index f034a159..cec747f0 100644 --- a/crates/tui/src/chatwidget/configuration.rs +++ b/crates/tui/src/chatwidget/configuration.rs @@ -4,7 +4,6 @@ //! changes stay separate from transcript and input handling. use devo_protocol::Model; -use devo_protocol::ProviderModelBinding; use devo_protocol::ProviderWireApi; use devo_protocol::ReasoningEffort; use ratatui::style::Color; @@ -148,17 +147,6 @@ impl ChatWidget { self.frame_requester.schedule_frame(); } - pub(super) fn apply_session_model_binding(&mut self, binding: &ProviderModelBinding) { - self.apply_session_request_model( - binding.model_slug.clone(), - binding.request_model.clone(), - binding - .display_name - .clone() - .unwrap_or_else(|| binding.request_model.clone()), - ); - } - pub(super) fn user_turn_model(&self) -> Option { self.session .request_model @@ -544,15 +532,22 @@ impl ChatWidget { } let label = crate::bottom_pane::format_token_limit(effective_context_window); self.add_to_history(history_cell::new_info_event( - format!("Compaction threshold updated to {label}"), + format!("Context window updated to {label}"), None, )); - self.set_status_message(format!("Compaction threshold updated to {label}")); + self.set_status_message(format!("Context window updated to {label}")); self.sync_bottom_pane_summary(); self.refresh_status_panel_if_open(); self.refresh_settings_hub_if_open(); } + pub(super) fn open_compaction_threshold_picker(&mut self) { + self.set_status_message( + "Context limit is set per model (usable window). No separate compaction threshold." + .to_string(), + ); + } + fn refresh_status_panel_if_open(&mut self) { self.bottom_pane.refresh_status_panel( self.last_context_occupancy.clone(), @@ -666,12 +661,6 @@ impl ChatWidget { self.set_status_message("Settings"); } - pub(super) fn open_compaction_threshold_picker(&mut self) { - let snapshot = self.compaction_threshold_snapshot(); - self.bottom_pane.open_compaction_threshold(snapshot); - self.set_status_message("Select compaction threshold"); - } - pub(super) fn refresh_settings_hub_if_open(&mut self) { let snapshot = self.settings_hub_snapshot(); self.bottom_pane.refresh_settings_hub(snapshot); @@ -690,17 +679,7 @@ impl ChatWidget { self.default_collaboration_mode, ), compaction_threshold_label: crate::bottom_pane::format_token_limit( - self.default_compaction_token_limit - .map(|limit| { - let model_window = self - .session - .model - .as_ref() - .map(|model| u64::from(model.context_window.max(1))) - .unwrap_or(u64::MAX); - limit.min(model_window).max(1) - }) - .unwrap_or_else(|| self.effective_compaction_threshold_tokens()), + self.effective_compaction_threshold_tokens(), ), theme_label: self.active_theme_name.clone(), reasoning_view_label: super::reasoning_view::reasoning_view_label( @@ -710,48 +689,12 @@ impl ChatWidget { } } - fn compaction_threshold_snapshot(&self) -> crate::bottom_pane::CompactionThresholdSnapshot { - let model = self.session.model.as_ref(); - let context_window_tokens = model - .map(|model| u64::from(model.context_window.max(1))) - .unwrap_or(1); - let model_effective = model - .map(|model| u64::from(model.effective_context_window())) - .unwrap_or(context_window_tokens); - let recommended_token_limit = crate::bottom_pane::recommended_compaction_token_limit( - context_window_tokens, - model_effective, - ); - // Current / hub label follow the applied (model-clamped) window so they - // stay consistent with the status bar. The stored global preference is - // kept separately for picker memory when this client initiated an update. - let current_token_limit = self - .effective_context_window - .unwrap_or(model_effective) - .min(context_window_tokens) - .max(1); - crate::bottom_pane::CompactionThresholdSnapshot { - model_label: model - .map(|model| model.slug.clone()) - .unwrap_or_else(|| "unknown".to_string()), - context_window_tokens, - recommended_token_limit, - current_token_limit, - } - } - fn effective_compaction_threshold_tokens(&self) -> u64 { - let model = self.session.model.as_ref(); - let model_window = model - .map(|model| u64::from(model.context_window.max(1))) - .unwrap_or(u64::MAX); - let model_effective = model - .map(|model| u64::from(model.effective_context_window())) - .unwrap_or(1); - self.effective_context_window - .unwrap_or(model_effective) - .min(model_window) - .max(1) + self.session + .model + .as_ref() + .map(|model| u64::from(model.effective_context_window().max(1))) + .unwrap_or(1) } pub(super) fn active_accent_color(&self) -> Color { diff --git a/crates/tui/src/chatwidget/history_commit.rs b/crates/tui/src/chatwidget/history_commit.rs index a45ff236..e5be60db 100644 --- a/crates/tui/src/chatwidget/history_commit.rs +++ b/crates/tui/src/chatwidget/history_commit.rs @@ -93,13 +93,14 @@ impl ChatWidget { let history_cell = crate::transcript::render::committed_cell_to_history( &CommittedCellModel::Tool(tool), &self.session.cwd, - |title| Self::ran_tool_line(title), + Self::ran_tool_line, dot_prefix, Self::tool_text_style(), ); self.add_history_entry_without_redraw(history_cell); } + #[allow(clippy::too_many_arguments)] pub(crate) fn commit_exploration_tool_from_history_item( &mut self, tool_use_id: String, @@ -348,23 +349,22 @@ impl ChatWidget { .as_mut() .and_then(|cell| cell.as_any_mut().downcast_mut::()) && cell.set_tool_io_input(tool_use_id, tool_name.clone(), input.clone()) + && let Some(output) = tool_output_for_commit(tool) { - if let Some(output) = tool_output_for_commit(tool) { - let display_content = tool.tool_display_content.clone(); - let output_text = display_content - .clone() - .unwrap_or_else(|| value_text(&output)); - cell.complete_tool_io(tool_use_id, output, display_content.clone()); - cell.complete_call( - tool_use_id, - CommandOutput { - exit_code: if tool.is_error { 1 } else { 0 }, - aggregated_output: output_text.clone(), - formatted_output: output_text.clone(), - }, - Duration::from_millis(0), - ); - } + let display_content = tool.tool_display_content.clone(); + let output_text = display_content + .clone() + .unwrap_or_else(|| value_text(&output)); + cell.complete_tool_io(tool_use_id, output, display_content.clone()); + cell.complete_call( + tool_use_id, + CommandOutput { + exit_code: if tool.is_error { 1 } else { 0 }, + aggregated_output: output_text.clone(), + formatted_output: output_text.clone(), + }, + Duration::from_millis(0), + ); } } diff --git a/crates/tui/src/chatwidget/input.rs b/crates/tui/src/chatwidget/input.rs index ed4a3c39..bc750a7b 100644 --- a/crates/tui/src/chatwidget/input.rs +++ b/crates/tui/src/chatwidget/input.rs @@ -343,7 +343,10 @@ impl ChatWidget { self.open_reasoning_view_picker(); } AppEvent::SettingsOpenCompaction => { - self.open_compaction_threshold_picker(); + self.set_status_message( + "Context limit is set per model (usable window). No separate compaction threshold." + .to_string(), + ); } AppEvent::SettingsCycleMode => { self.bottom_pane.cycle_build_plan_mode(); diff --git a/crates/tui/src/chatwidget/reasoning_effort.rs b/crates/tui/src/chatwidget/reasoning_effort.rs index e49c8f65..7faea8cf 100644 --- a/crates/tui/src/chatwidget/reasoning_effort.rs +++ b/crates/tui/src/chatwidget/reasoning_effort.rs @@ -5,6 +5,7 @@ use devo_protocol::Model; use devo_protocol::ReasoningEffort; +use devo_protocol::normalize_reasoning_effort_literal; #[derive(Clone, Debug, PartialEq, Eq)] pub(crate) struct ReasoningEffortListEntry { @@ -45,7 +46,7 @@ pub(super) fn current_reasoning_effort_selection_for_model( .map(str::trim) .filter(|selection| !selection.is_empty()) .filter(|selection| !selection.eq_ignore_ascii_case("default")) - .map(str::to_ascii_lowercase); + .map(normalize_reasoning_effort_literal); if let Some(selection) = normalized_selection.as_deref() { if options.iter().any(|option| option.value == selection) { @@ -106,14 +107,16 @@ mod tests { } #[test] - fn entries_preserve_explicit_toggle_with_levels_selection() { + fn entries_preserve_explicit_levels_with_off_selection() { let model = Model { slug: "deepseek-v4".to_string(), display_name: "Deepseek V4".to_string(), - reasoning_capability: ReasoningCapability::ToggleWithLevels(vec![ - ReasoningEffort::High, - ReasoningEffort::Max, - ]), + reasoning_capability: ReasoningCapability::Levels( + devo_protocol::levels_with_leading_off([ + ReasoningEffort::High, + ReasoningEffort::Max, + ]), + ), default_reasoning_effort: Some(ReasoningEffort::High), ..Model::default() }; @@ -125,7 +128,7 @@ mod tests { is_current: true, label: "Off".to_string(), description: "Disable reasoning effort for this turn".to_string(), - value: "disabled".to_string(), + value: "off".to_string(), }, ReasoningEffortListEntry { is_current: false, @@ -149,7 +152,7 @@ mod tests { is_current: false, label: "Off".to_string(), description: "Disable reasoning effort for this turn".to_string(), - value: "disabled".to_string(), + value: "off".to_string(), }, ReasoningEffortListEntry { is_current: false, diff --git a/crates/tui/src/chatwidget/session_header.rs b/crates/tui/src/chatwidget/session_header.rs index 7e582288..5d133d7d 100644 --- a/crates/tui/src/chatwidget/session_header.rs +++ b/crates/tui/src/chatwidget/session_header.rs @@ -236,14 +236,14 @@ impl ChatWidget { /// Context length for the status bar. /// /// `used` follows the latest query display total from `TurnUsageUpdated` so - /// the bar moves mid-turn. The denominator prefers the live session - /// effective-context override (Settings › Compaction threshold), then the - /// occupancy snapshot window, then the model effective window. Occupancy - /// `total_tokens` is only a fallback when no last-query usage has arrived - /// yet (for example a hydrate that set occupancy alone). + /// the bar moves mid-turn. The denominator prefers a session-level + /// effective context window override, then the occupancy snapshot window, + /// then the model effective window. Occupancy `total_tokens` is only a + /// fallback when no last-query usage has arrived yet (for example a + /// hydrate that set occupancy alone). pub(super) fn context_usage(&self) -> Option<(usize, usize, usize)> { - let total = if let Some(limit) = self.effective_context_window { - limit as usize + let total = if let Some(effective) = self.effective_context_window { + effective as usize } else if let Some(occupancy) = self.last_context_occupancy.as_ref() { occupancy.context_window_tokens as usize } else { @@ -639,7 +639,7 @@ mod tests { slug: "test-model".to_string(), display_name: "Test Model".to_string(), context_window: 200_000, - effective_context_window_percent: Some(95), + effective_context_window_percent: Some(95.0), ..Model::default() }; let (app_event_tx, _app_event_rx) = mpsc::unbounded_channel(); @@ -676,10 +676,12 @@ mod tests { let model = Model { slug: "deepseek-v4-flash".to_string(), display_name: "deepseek-v4-flash".to_string(), - reasoning_capability: ReasoningCapability::ToggleWithLevels(vec![ - ReasoningEffort::High, - ReasoningEffort::Max, - ]), + reasoning_capability: ReasoningCapability::Levels( + devo_protocol::levels_with_leading_off([ + ReasoningEffort::High, + ReasoningEffort::Max, + ]), + ), default_reasoning_effort: Some(ReasoningEffort::High), ..Model::default() }; diff --git a/crates/tui/src/chatwidget/transcript_sync.rs b/crates/tui/src/chatwidget/transcript_sync.rs index 9c94b843..a10079a5 100644 --- a/crates/tui/src/chatwidget/transcript_sync.rs +++ b/crates/tui/src/chatwidget/transcript_sync.rs @@ -198,7 +198,7 @@ impl ChatWidget { let history_cell = crate::transcript::render::committed_cell_to_history( &CommittedCellModel::Tool(tool), &self.session.cwd, - |title| Self::ran_tool_line(title), + Self::ran_tool_line, dot_prefix, Self::tool_text_style(), ); diff --git a/crates/tui/src/chatwidget/worker_events.rs b/crates/tui/src/chatwidget/worker_events.rs index 21416e87..8cad591a 100644 --- a/crates/tui/src/chatwidget/worker_events.rs +++ b/crates/tui/src/chatwidget/worker_events.rs @@ -265,19 +265,18 @@ impl ChatWidget { }; let duration = std::time::Duration::from_millis(0); - if let (Some(tool_name), Some(input)) = (&tool.tool_name, &tool.input) { - if let Some(cell) = self + if let (Some(tool_name), Some(input)) = (&tool.tool_name, &tool.input) + && let Some(cell) = self .active_cell .as_mut() .and_then(|cell| cell.as_any_mut().downcast_mut::()) - { - cell.set_tool_io_input(tool_use_id, tool_name.clone(), input.clone()); - cell.complete_tool_io( - tool_use_id, - output.clone(), - tool.tool_display_content.clone(), - ); - } + { + cell.set_tool_io_input(tool_use_id, tool_name.clone(), input.clone()); + cell.complete_tool_io( + tool_use_id, + output.clone(), + tool.tool_display_content.clone(), + ); } if let Some(cell) = self @@ -915,23 +914,30 @@ impl ChatWidget { )); self.set_status_message("Provider validation failed"); } - WorkerEvent::ProviderVendorsListed { provider_vendors } => { + WorkerEvent::ProvidersListed { + providers, + template_provider_ids, + connected_provider_ids, + connection_models, + } => { if let Some(onboarding) = self.onboarding.as_mut() { - onboarding.on_provider_vendors_listed(provider_vendors); + onboarding.on_providers_listed_with_status_and_models( + providers, + template_provider_ids, + connected_provider_ids, + connection_models, + ); } self.drain_onboarding_transcript_events(); } - WorkerEvent::ProviderVendorUpserted { - provider_vendor, - model_binding, + WorkerEvent::ProviderUpserted { + provider, + default_model, } => { let onboarding_was_active = self.onboarding.is_some(); - if let Some(binding) = model_binding.as_ref() { - self.apply_session_model_binding(binding); - } if self.onboarding.is_some() { if let Some(onboarding) = self.onboarding.as_mut() { - onboarding.on_provider_saved(model_binding.as_ref()); + onboarding.on_provider_upserted(&provider, default_model.as_deref()); } self.drain_onboarding_transcript_events(); if let Some(result) = self @@ -944,12 +950,12 @@ impl ChatWidget { } if !onboarding_was_active { self.add_to_history(history_cell::new_info_event( - format!("Provider saved: {}", provider_vendor.name), + format!("Provider saved: {}", provider.name), Some("provider upserted".to_string()), )); } } - WorkerEvent::ProviderVendorUpsertFailed { message } => { + WorkerEvent::ProviderUpsertFailed { message } => { if let Some(onboarding) = self.onboarding.as_mut() { onboarding.on_provider_save_failed(message.clone()); } @@ -961,6 +967,49 @@ impl ChatWidget { )); self.set_status_message("Provider save failed"); } + WorkerEvent::ProviderDisconnected { provider_id } => { + if let Some(onboarding) = self.onboarding.as_mut() { + onboarding.on_provider_disconnected(&provider_id); + } + self.drain_onboarding_transcript_events(); + self.busy = false; + self.set_status_message("Provider disconnected"); + } + WorkerEvent::ProviderDisconnectFailed { message } => { + if let Some(onboarding) = self.onboarding.as_mut() { + onboarding.on_provider_disconnect_failed(); + } + self.drain_onboarding_transcript_events(); + self.busy = false; + self.add_to_history(history_cell::new_error_event_with_hint( + message, + Some("provider disconnect failed".to_string()), + )); + self.set_status_message("Provider disconnect failed"); + } + WorkerEvent::ProviderModelRemoved { + provider_id, + model_id, + } => { + if let Some(onboarding) = self.onboarding.as_mut() { + onboarding.on_provider_model_removed(&provider_id, &model_id); + } + self.drain_onboarding_transcript_events(); + self.busy = false; + self.set_status_message("Model removed"); + } + WorkerEvent::ProviderModelRemoveFailed { message } => { + if let Some(onboarding) = self.onboarding.as_mut() { + onboarding.on_provider_model_remove_failed(); + } + self.drain_onboarding_transcript_events(); + self.busy = false; + self.add_to_history(history_cell::new_error_event_with_hint( + message, + Some("provider model removal failed".to_string()), + )); + self.set_status_message("Model removal failed"); + } WorkerEvent::SessionsListed { sessions } => { self.bottom_pane.update_resume_sessions(sessions); self.set_status_message("Resume session"); diff --git a/crates/tui/src/chatwidget_tests.rs b/crates/tui/src/chatwidget_tests.rs index 404b572a..cfed03ec 100644 --- a/crates/tui/src/chatwidget_tests.rs +++ b/crates/tui/src/chatwidget_tests.rs @@ -1,3 +1,4 @@ +use std::collections::BTreeMap; use std::path::PathBuf; use crossterm::event::KeyCode; @@ -11,8 +12,8 @@ use devo_protocol::InputItem; use devo_protocol::ItemId; use devo_protocol::Model; use devo_protocol::PermissionPreset; -use devo_protocol::ProviderModelBinding; -use devo_protocol::ProviderVendor; +use devo_protocol::ProviderInfo; +use devo_protocol::ProviderModelInfo; use devo_protocol::ProviderWireApi; use devo_protocol::ReasoningCapability; use devo_protocol::ReasoningEffort; @@ -27,6 +28,28 @@ use ratatui::style::Color; use ratatui::text::Line; use tokio::sync::mpsc; +fn deepseek_provider_info() -> ProviderInfo { + ProviderInfo { + id: "deepseek".to_string(), + name: "Deepseek".to_string(), + description: None, + base_url: Some("https://api.deepseek.com".to_string()), + credential: Some("deepseek_api_key".to_string()), + headers: BTreeMap::new(), + options: None, + request: None, + wire_apis: vec![ProviderWireApi::OpenAIChatCompletions], + models: BTreeMap::from([( + "deepseek-v4-flash".to_string(), + ProviderModelInfo { + name: Some("DeepSeek-V4-Flash".to_string()), + wire_api: Some(ProviderWireApi::OpenAIChatCompletions), + ..ProviderModelInfo::default() + }, + )]), + enabled: true, + } +} use crate::app_command::AppCommand; use crate::app_event::AppEvent; use crate::app_event::ExitMode; @@ -2145,8 +2168,8 @@ fn reasoning_effort_entries_are_generated_from_model_capability_options() { slug: "test-model".to_string(), display_name: "Test Model".to_string(), reasoning_capability: ReasoningCapability::Levels(vec![ - ReasoningEffort::Low, - ReasoningEffort::Medium, + ReasoningEffort::Low.into(), + ReasoningEffort::Medium.into(), ]), default_reasoning_effort: Some(ReasoningEffort::Medium), ..Model::default() @@ -2178,8 +2201,8 @@ fn initial_reasoning_effort_selection_overrides_model_default() { slug: "test-model".to_string(), display_name: "Test Model".to_string(), reasoning_capability: ReasoningCapability::Levels(vec![ - ReasoningEffort::Low, - ReasoningEffort::Medium, + ReasoningEffort::Low.into(), + ReasoningEffort::Medium.into(), ]), default_reasoning_effort: Some(ReasoningEffort::Medium), ..Model::default() @@ -2717,14 +2740,13 @@ fn theme_selection_applies_header_accent_immediately() { } #[test] -fn toggle_with_levels_treats_enabled_as_default_effort_in_picker() { +fn levels_with_off_treats_enabled_as_default_effort_in_picker() { let model = Model { slug: "deepseek-v4".to_string(), display_name: "Deepseek V4".to_string(), - reasoning_capability: ReasoningCapability::ToggleWithLevels(vec![ - ReasoningEffort::High, - ReasoningEffort::Max, - ]), + reasoning_capability: ReasoningCapability::Levels(devo_protocol::levels_with_leading_off( + [ReasoningEffort::High, ReasoningEffort::Max], + )), default_reasoning_effort: Some(ReasoningEffort::High), ..Model::default() }; @@ -2741,7 +2763,7 @@ fn toggle_with_levels_treats_enabled_as_default_effort_in_picker() { is_current: false, label: "Off".to_string(), description: "Disable reasoning effort for this turn".to_string(), - value: "disabled".to_string(), + value: "off".to_string(), }, ReasoningEffortListEntry { is_current: true, @@ -2760,16 +2782,16 @@ fn toggle_with_levels_treats_enabled_as_default_effort_in_picker() { } #[test] -fn reasoning_effort_entries_show_off_and_levels_for_toggle_models_with_supported_levels() { - let model = devo_core::ModelPreset { +fn reasoning_effort_entries_show_off_and_levels_when_levels_include_off() { + let model = devo_core::Model { slug: "deepseek-v4".to_string(), display_name: "Deepseek V4".to_string(), - reasoning_capability: ReasoningCapability::Toggle, - supported_reasoning_levels: vec![ReasoningEffort::High, ReasoningEffort::Max], + reasoning_capability: ReasoningCapability::Levels(devo_protocol::levels_with_leading_off( + [ReasoningEffort::High, ReasoningEffort::Max], + )), default_reasoning_effort: None, - ..devo_core::ModelPreset::default() - } - .into(); + ..devo_core::Model::default() + }; let (widget, _app_event_rx) = widget_with_model(model, PathBuf::from(".")); assert_eq!( @@ -2779,7 +2801,7 @@ fn reasoning_effort_entries_show_off_and_levels_for_toggle_models_with_supported is_current: false, label: "Off".to_string(), description: "Disable reasoning effort for this turn".to_string(), - value: "disabled".to_string(), + value: "off".to_string(), }, ReasoningEffortListEntry { is_current: true, @@ -4368,21 +4390,20 @@ fn onboarding_validation_succeeded_waits_for_provider_upsert() { let (mut widget, mut app_event_rx) = onboarding_widget_with_available_model(model, cwd); let _ = app_event_rx.try_recv().expect("provider list command"); - widget.handle_worker_event(crate::events::WorkerEvent::ProviderVendorsListed { - provider_vendors: vec![ProviderVendor { - name: "Deepseek".to_string(), - base_url: Some("https://api.deepseek.com".to_string()), - credential: Some("deepseek_api_key".to_string()), - headers: None, - wire_apis: vec![ProviderWireApi::OpenAIChatCompletions], - enabled: true, - }], + widget.handle_worker_event(crate::events::WorkerEvent::ProvidersListed { + providers: vec![deepseek_provider_info()], + template_provider_ids: Vec::new(), + connected_provider_ids: Vec::new(), + connection_models: BTreeMap::new(), }); widget.handle_key_event(press_key(KeyCode::Enter)); widget.handle_key_event(press_key(KeyCode::Enter)); widget.handle_key_event(press_key(KeyCode::Enter)); widget.handle_key_event(press_key(KeyCode::Enter)); widget.handle_key_event(press_key(KeyCode::Enter)); + widget.handle_key_event(press_key(KeyCode::Enter)); + widget.handle_key_event(press_key(KeyCode::Enter)); + widget.handle_key_event(press_key(KeyCode::Enter)); let _ = app_event_rx.try_recv().expect("onboard command"); widget.handle_worker_event(crate::events::WorkerEvent::ProviderValidationSucceeded { @@ -4391,25 +4412,9 @@ fn onboarding_validation_succeeded_waits_for_provider_upsert() { assert_eq!(widget.is_onboarding_active(), true); - widget.handle_worker_event(crate::events::WorkerEvent::ProviderVendorUpserted { - provider_vendor: ProviderVendor { - name: "Deepseek".to_string(), - base_url: Some("https://api.deepseek.com".to_string()), - credential: Some("deepseek_api_key".to_string()), - headers: None, - wire_apis: vec![ProviderWireApi::OpenAIChatCompletions], - enabled: true, - }, - model_binding: Some(ProviderModelBinding { - binding_id: "deepseek-v4-flash-deepseek".to_string(), - model_slug: "deepseek-v4-flash".to_string(), - provider: "Deepseek".to_string(), - request_model: "DeepSeek-V4-Flash".to_string(), - display_name: Some("DeepSeek-V4-Flash".to_string()), - invocation_method: ProviderWireApi::OpenAIChatCompletions, - default_reasoning_effort: None, - enabled: true, - }), + widget.handle_worker_event(crate::events::WorkerEvent::ProviderUpserted { + provider: deepseek_provider_info(), + default_model: Some("deepseek/deepseek-v4-flash".to_string()), }); assert_eq!( @@ -4442,45 +4447,28 @@ fn onboarding_validation_succeeded_exits_when_configured() { ); let _ = app_event_rx.try_recv().expect("provider list command"); - widget.handle_worker_event(crate::events::WorkerEvent::ProviderVendorsListed { - provider_vendors: vec![ProviderVendor { - name: "Deepseek".to_string(), - base_url: Some("https://api.deepseek.com".to_string()), - credential: Some("deepseek_api_key".to_string()), - headers: None, - wire_apis: vec![ProviderWireApi::OpenAIChatCompletions], - enabled: true, - }], + widget.handle_worker_event(crate::events::WorkerEvent::ProvidersListed { + providers: vec![deepseek_provider_info()], + template_provider_ids: Vec::new(), + connected_provider_ids: Vec::new(), + connection_models: BTreeMap::new(), }); widget.handle_key_event(press_key(KeyCode::Enter)); widget.handle_key_event(press_key(KeyCode::Enter)); widget.handle_key_event(press_key(KeyCode::Enter)); widget.handle_key_event(press_key(KeyCode::Enter)); widget.handle_key_event(press_key(KeyCode::Enter)); + widget.handle_key_event(press_key(KeyCode::Enter)); + widget.handle_key_event(press_key(KeyCode::Enter)); + widget.handle_key_event(press_key(KeyCode::Enter)); let _ = app_event_rx.try_recv().expect("onboard command"); widget.handle_worker_event(crate::events::WorkerEvent::ProviderValidationSucceeded { reply_preview: "OK".to_string(), }); - widget.handle_worker_event(crate::events::WorkerEvent::ProviderVendorUpserted { - provider_vendor: ProviderVendor { - name: "Deepseek".to_string(), - base_url: Some("https://api.deepseek.com".to_string()), - credential: Some("deepseek_api_key".to_string()), - headers: None, - wire_apis: vec![ProviderWireApi::OpenAIChatCompletions], - enabled: true, - }, - model_binding: Some(ProviderModelBinding { - binding_id: "deepseek-v4-flash-deepseek".to_string(), - model_slug: "deepseek-v4-flash".to_string(), - provider: "Deepseek".to_string(), - request_model: "DeepSeek-V4-Flash".to_string(), - display_name: Some("DeepSeek-V4-Flash".to_string()), - invocation_method: ProviderWireApi::OpenAIChatCompletions, - default_reasoning_effort: None, - enabled: true, - }), + widget.handle_worker_event(crate::events::WorkerEvent::ProviderUpserted { + provider: deepseek_provider_info(), + default_model: Some("deepseek/deepseek-v4-flash".to_string()), }); assert_eq!(widget.is_onboarding_active(), false); @@ -4508,21 +4496,20 @@ fn onboarding_validation_bypassed_exits_when_configured() { ); let _ = app_event_rx.try_recv().expect("provider list command"); - widget.handle_worker_event(crate::events::WorkerEvent::ProviderVendorsListed { - provider_vendors: vec![ProviderVendor { - name: "Deepseek".to_string(), - base_url: Some("https://api.deepseek.com".to_string()), - credential: Some("deepseek_api_key".to_string()), - headers: None, - wire_apis: vec![ProviderWireApi::OpenAIChatCompletions], - enabled: true, - }], + widget.handle_worker_event(crate::events::WorkerEvent::ProvidersListed { + providers: vec![deepseek_provider_info()], + template_provider_ids: Vec::new(), + connected_provider_ids: Vec::new(), + connection_models: BTreeMap::new(), }); widget.handle_key_event(press_key(KeyCode::Enter)); widget.handle_key_event(press_key(KeyCode::Enter)); widget.handle_key_event(press_key(KeyCode::Enter)); widget.handle_key_event(press_key(KeyCode::Enter)); widget.handle_key_event(press_key(KeyCode::Enter)); + widget.handle_key_event(press_key(KeyCode::Enter)); + widget.handle_key_event(press_key(KeyCode::Enter)); + widget.handle_key_event(press_key(KeyCode::Enter)); let _ = app_event_rx.try_recv().expect("onboard command"); widget.handle_worker_event(crate::events::WorkerEvent::ProviderValidationFailed { @@ -4530,32 +4517,20 @@ fn onboarding_validation_bypassed_exits_when_configured() { hint: None, }); widget.handle_key_event(press_key(KeyCode::Enter)); - match app_event_rx.try_recv().expect("skip validation command") { - AppEvent::Command(AppCommand::RunUserShellCommand { command }) => { - assert_eq!(command.starts_with("onboard-skip-validation "), true); + match app_event_rx.try_recv().expect("provider upsert command") { + AppEvent::Command(AppCommand::ProviderUpsert { params }) => { + assert_eq!(params.provider.id, "deepseek"); + assert_eq!( + params.default_model, + Some("deepseek/deepseek-v4-flash".to_string()) + ); } - other => panic!("expected skip validation command, got {other:?}"), + other => panic!("expected provider upsert command, got {other:?}"), } - widget.handle_worker_event(crate::events::WorkerEvent::ProviderVendorUpserted { - provider_vendor: ProviderVendor { - name: "Deepseek".to_string(), - base_url: Some("https://api.deepseek.com".to_string()), - credential: Some("deepseek_api_key".to_string()), - headers: None, - wire_apis: vec![ProviderWireApi::OpenAIChatCompletions], - enabled: true, - }, - model_binding: Some(ProviderModelBinding { - binding_id: "deepseek-v4-flash-deepseek".to_string(), - model_slug: "deepseek-v4-flash".to_string(), - provider: "Deepseek".to_string(), - request_model: "DeepSeek-V4-Flash".to_string(), - display_name: Some("DeepSeek-V4-Flash".to_string()), - invocation_method: ProviderWireApi::OpenAIChatCompletions, - default_reasoning_effort: None, - enabled: true, - }), + widget.handle_worker_event(crate::events::WorkerEvent::ProviderUpserted { + provider: deepseek_provider_info(), + default_model: Some("deepseek/deepseek-v4-flash".to_string()), }); assert_eq!(widget.is_onboarding_active(), false); @@ -7352,8 +7327,8 @@ fn slash_model_opens_model_picker_instead_of_printing_current_model() { slug: "second-model".to_string(), display_name: "Second Model".to_string(), reasoning_capability: ReasoningCapability::Levels(vec![ - ReasoningEffort::High, - ReasoningEffort::Max, + ReasoningEffort::High.into(), + ReasoningEffort::Max.into(), ]), default_reasoning_effort: Some(ReasoningEffort::High), ..Model::default() @@ -8983,7 +8958,7 @@ fn new_session_prepared_restores_default_compaction_limit() { slug: "test-model".to_string(), display_name: "Test Model".to_string(), context_window: 200_000, - effective_context_window_percent: Some(95), + effective_context_window_percent: Some(95.0), ..Model::default() }; let (app_event_tx, _app_event_rx) = mpsc::unbounded_channel(); @@ -9182,8 +9157,8 @@ fn model_selection_updates_session_projection_and_emits_context_override() { slug: "second-model".to_string(), display_name: "Second Model".to_string(), reasoning_capability: ReasoningCapability::Levels(vec![ - ReasoningEffort::High, - ReasoningEffort::Max, + ReasoningEffort::High.into(), + ReasoningEffort::Max.into(), ]), default_reasoning_effort: Some(ReasoningEffort::High), ..Model::default() @@ -9265,8 +9240,8 @@ fn model_selection_with_reasoning_effort_support_applies_default_immediately() { slug: "second-model".to_string(), display_name: "Second Model".to_string(), reasoning_capability: ReasoningCapability::Levels(vec![ - ReasoningEffort::High, - ReasoningEffort::Max, + ReasoningEffort::High.into(), + ReasoningEffort::Max.into(), ]), default_reasoning_effort: Some(ReasoningEffort::High), ..Model::default() diff --git a/crates/tui/src/events.rs b/crates/tui/src/events.rs index e57d0fed..9870938d 100644 --- a/crates/tui/src/events.rs +++ b/crates/tui/src/events.rs @@ -12,9 +12,8 @@ use devo_protocol::AcpAvailableCommand; use devo_protocol::AcpCost; use devo_protocol::AcpSessionConfigOption; use devo_protocol::CollaborationMode; -use devo_protocol::ProviderModelBinding; +use devo_protocol::ProviderInfo; use devo_protocol::ProviderRetryPhase; -use devo_protocol::ProviderVendor; use devo_protocol::ProviderWireApi; use devo_protocol::ReasoningEffort; use devo_protocol::ReferenceSearchSnapshot; @@ -360,21 +359,40 @@ pub(crate) enum WorkerEvent { /// Optional user-facing next step for recovering from this failure. hint: Option, }, - /// Current provider vendors were listed from the server. - ProviderVendorsListed { - /// Structured provider vendors returned by `provider/list`. - provider_vendors: Vec, - }, - /// A provider vendor was upserted through the server. - ProviderVendorUpserted { - /// The provider vendor returned by `provider/upsert`. - provider_vendor: ProviderVendor, - /// Optional model binding returned by `provider/upsert`. - model_binding: Option, - }, - /// Provider vendor upsert failed during onboarding or provider updates. - ProviderVendorUpsertFailed { - /// Human-readable failure reason from `provider/upsert`. + /// Current provider Connections and directory templates from the canonical Native RPC. + ProvidersListed { + providers: Vec, + template_provider_ids: Vec, + connected_provider_ids: Vec, + connection_models: std::collections::BTreeMap< + String, + std::collections::BTreeMap, + >, + }, + /// A provider Connection was persisted through the canonical Native RPC. + ProviderUpserted { + provider: ProviderInfo, + default_model: Option, + }, + /// Canonical provider upsert failure during onboarding or provider updates. + ProviderUpsertFailed { + message: String, + }, + /// A provider Connection was disconnected. + ProviderDisconnected { + provider_id: String, + }, + /// Disconnecting a provider Connection failed. + ProviderDisconnectFailed { + message: String, + }, + /// A model was removed from a provider Connection. + ProviderModelRemoved { + provider_id: String, + model_id: String, + }, + /// Removing a provider Connection model failed. + ProviderModelRemoveFailed { message: String, }, /// Current known sessions were listed from the server. diff --git a/crates/tui/src/exec_cell/render.rs b/crates/tui/src/exec_cell/render.rs index 9b46708a..f91c1a9d 100644 --- a/crates/tui/src/exec_cell/render.rs +++ b/crates/tui/src/exec_cell/render.rs @@ -570,7 +570,7 @@ impl ExecCell { let command_text = call .tool_input .as_ref() - .and_then(|input| shell_command_from_input(input)) + .and_then(shell_command_from_input) .unwrap_or_else(|| strip_bash_lc_and_escape(&call.command)); if !command_text.is_empty() { let command_line = Line::from(Span::styled(command_text, Style::default().dim())); diff --git a/crates/tui/src/history_cell.rs b/crates/tui/src/history_cell.rs index f05dc78b..5feb549b 100644 --- a/crates/tui/src/history_cell.rs +++ b/crates/tui/src/history_cell.rs @@ -1401,21 +1401,14 @@ impl HeaderHistoryCell { match &self.reasoning_capability { ReasoningCapability::Toggle => Some("reasoning"), - ReasoningCapability::ToggleWithLevels(levels) => self - .default_reasoning_effort - .or_else(|| levels.first().copied()) - .map(|effort| match effort { - ReasoningEffort::None => "none", - ReasoningEffort::Minimal => "minimal", - ReasoningEffort::Low => "low", - ReasoningEffort::Medium => "medium", - ReasoningEffort::High => "high", - ReasoningEffort::XHigh => "xhigh", - ReasoningEffort::Max => "max", - }), ReasoningCapability::Levels(levels) => self .default_reasoning_effort - .or_else(|| levels.first().copied()) + .or_else(|| { + levels + .iter() + .copied() + .find_map(devo_protocol::ReasoningLevelChoice::effort) + }) .map(|effort| match effort { ReasoningEffort::None => "none", ReasoningEffort::Minimal => "minimal", @@ -1424,7 +1417,8 @@ impl HeaderHistoryCell { ReasoningEffort::High => "high", ReasoningEffort::XHigh => "xhigh", ReasoningEffort::Max => "max", - }), + }) + .or_else(|| levels.first().map(|choice| choice.selection_value())), ReasoningCapability::Unsupported => None, } } diff --git a/crates/tui/src/inline_onboarding_tests.rs b/crates/tui/src/inline_onboarding_tests.rs index 15d62106..56e0d762 100644 --- a/crates/tui/src/inline_onboarding_tests.rs +++ b/crates/tui/src/inline_onboarding_tests.rs @@ -1,5 +1,6 @@ //! Inline onboarding transcript and header behavior tests. +use std::collections::BTreeMap; use std::path::PathBuf; use crossterm::event::KeyCode; @@ -8,8 +9,8 @@ use crossterm::event::KeyEventKind; use crossterm::event::KeyEventState; use crossterm::event::KeyModifiers; use devo_protocol::Model; -use devo_protocol::ProviderModelBinding; -use devo_protocol::ProviderVendor; +use devo_protocol::ProviderInfo; +use devo_protocol::ProviderModelInfo; use devo_protocol::ProviderWireApi; use pretty_assertions::assert_eq; use tokio::sync::mpsc; @@ -67,13 +68,25 @@ fn test_model() -> Model { } } -fn deepseek_vendor() -> ProviderVendor { - ProviderVendor { +fn deepseek_provider() -> ProviderInfo { + ProviderInfo { + id: "deepseek".to_string(), name: "Deepseek".to_string(), + description: None, base_url: Some("https://api.deepseek.com".to_string()), credential: Some("deepseek_api_key".to_string()), - headers: None, + headers: BTreeMap::new(), + options: None, + request: None, wire_apis: vec![ProviderWireApi::OpenAIChatCompletions], + models: BTreeMap::from([( + "deepseek-v4-flash".to_string(), + ProviderModelInfo { + name: Some("DeepSeek-V4-Flash".to_string()), + wire_api: Some(ProviderWireApi::OpenAIChatCompletions), + ..ProviderModelInfo::default() + }, + )]), enabled: true, } } @@ -124,14 +137,15 @@ fn first_run_onboarding_starts_with_logo_and_hides_composer() { assert!(!scrollback.contains("Model deepseek-v4-flash")); let rows = rendered_rows(&widget, 100, 24).join("\n"); - assert!(rows.contains("Choose model profile")); - assert!(rows.contains("Enter select · Esc cancel")); + assert!(rows.contains("Choose a provider")); + assert!(rows.contains("Add custom provider")); + assert!(rows.contains("Enter select Esc cancel")); assert!(!rows.contains("Complete onboarding to start chatting")); assert!(!rows.contains("SHIFT+TAB switch")); assert!(widget.desired_height(100) < u16::MAX); let rows = rendered_rows(&widget, 100, widget.desired_height(100)).join("\n"); - assert!(rows.contains("Enter select · Esc cancel")); + assert!(rows.contains("Enter select")); } #[test] @@ -146,6 +160,24 @@ fn model_selection_footer_stays_visible_in_short_viewport() { .collect::>(); let initial_model = models.first().cloned(); let (mut widget, _app_event_rx) = onboarding_widget_with_models(models, initial_model, cwd); + let connection_models = (0..12) + .map(|idx| { + ( + format!("model-{idx:02}"), + ProviderModelInfo { + name: Some(format!("Model {idx:02} Display Name")), + ..ProviderModelInfo::default() + }, + ) + }) + .collect(); + widget.handle_worker_event(WorkerEvent::ProvidersListed { + providers: vec![deepseek_provider()], + template_provider_ids: vec!["deepseek".to_string()], + connected_provider_ids: vec!["deepseek".to_string()], + connection_models: BTreeMap::from([("deepseek".to_string(), connection_models)]), + }); + widget.handle_key_event(press_key(KeyCode::Enter)); for _ in 0..10 { widget.handle_key_event(press_key(KeyCode::Down)); @@ -158,15 +190,11 @@ fn model_selection_footer_stays_visible_in_short_viewport() { "expected selected model in:\n{rows}" ); assert!( - rows.contains("↓ more") || rows.contains("↑ more"), - "expected scroll overflow marker in:\n{rows}" - ); - assert!( - !rows.contains("Model 10 Display Name"), - "display name subtitle should not render:\n{rows}" + rows.contains("Model 10 Display Name"), + "display name should remain visible for catalog models:\n{rows}" ); assert!( - rows.contains("Enter select · Esc cancel"), + rows.contains("Enter select"), "expected fixed onboarding footer in:\n{rows}" ); assert!(!rows.contains("Complete onboarding to start chatting")); @@ -178,7 +206,7 @@ fn model_selection_footer_stays_visible_in_short_viewport() { "expected selected model in short viewport:\n{short}" ); assert!( - short.contains("Enter select · Esc cancel"), + short.contains("Enter select"), "expected fixed onboarding footer in short viewport:\n{short}" ); } @@ -189,14 +217,20 @@ fn onboarding_completion_appends_header_after_success_record() { let (mut widget, mut app_event_rx) = onboarding_widget_with_available_model(test_model(), cwd); let _ = app_event_rx.try_recv().expect("provider list command"); - widget.handle_worker_event(WorkerEvent::ProviderVendorsListed { - provider_vendors: vec![deepseek_vendor()], + widget.handle_worker_event(WorkerEvent::ProvidersListed { + providers: vec![deepseek_provider()], + template_provider_ids: Vec::new(), + connected_provider_ids: Vec::new(), + connection_models: BTreeMap::new(), }); widget.handle_key_event(press_key(KeyCode::Enter)); widget.handle_key_event(press_key(KeyCode::Enter)); widget.handle_key_event(press_key(KeyCode::Enter)); widget.handle_key_event(press_key(KeyCode::Enter)); widget.handle_key_event(press_key(KeyCode::Enter)); + widget.handle_key_event(press_key(KeyCode::Enter)); + widget.handle_key_event(press_key(KeyCode::Enter)); + widget.handle_key_event(press_key(KeyCode::Enter)); let _ = app_event_rx.try_recv().expect("onboard command"); widget.handle_worker_event(WorkerEvent::ProviderValidationSucceeded { @@ -204,18 +238,9 @@ fn onboarding_completion_appends_header_after_success_record() { }); assert_eq!(widget.is_onboarding_active(), true); - widget.handle_worker_event(WorkerEvent::ProviderVendorUpserted { - provider_vendor: deepseek_vendor(), - model_binding: Some(ProviderModelBinding { - binding_id: "deepseek-v4-flash-deepseek".to_string(), - model_slug: "deepseek-v4-flash".to_string(), - provider: "Deepseek".to_string(), - request_model: "DeepSeek-V4-Flash".to_string(), - display_name: Some("DeepSeek-V4-Flash".to_string()), - invocation_method: ProviderWireApi::OpenAIChatCompletions, - default_reasoning_effort: None, - enabled: true, - }), + widget.handle_worker_event(WorkerEvent::ProviderUpserted { + provider: deepseek_provider(), + default_model: Some("deepseek/deepseek-v4-flash".to_string()), }); assert_eq!(widget.is_onboarding_active(), false); diff --git a/crates/tui/src/interactive.rs b/crates/tui/src/interactive.rs index cd513f1d..37265e31 100644 --- a/crates/tui/src/interactive.rs +++ b/crates/tui/src/interactive.rs @@ -32,9 +32,6 @@ use crate::chatwidget::TuiSessionState; use crate::chatwidget::UserMessage; use crate::events::WorkerEvent; use crate::host_overlay::OverlayState; -use crate::onboarding::OnboardingModelBinding; -use crate::onboarding::onboarding_provider_model_binding; -use crate::onboarding::onboarding_provider_vendor; use crate::onboarding::save_default_collaboration_mode; use crate::onboarding::save_last_used_model; use crate::onboarding::save_project_permission_preset; @@ -49,76 +46,16 @@ use crate::worker::QueryWorkerHandle; const APP_EVENT_CHANNEL_CAPACITY: usize = 1024; -#[derive(Debug, Clone)] -struct PendingOnboarding { - binding: OnboardingModelBinding, - base_url: Option, - api_key: Option, - provider_credential_id: Option, -} - -#[derive(Debug, serde::Deserialize)] -struct OnboardingCommandPayload { - model_slug: String, - request_model: String, - display_name: String, - provider_id: String, - provider_name: String, - provider_credential_id: Option, - invocation_method: ProviderWireApi, - default_reasoning_effort: Option, - base_url: Option, - api_key: Option, -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -enum OnboardingCommandAction { - Validate, - SkipValidation, -} - -fn parse_onboarding_command( - command: &str, -) -> Option<(OnboardingCommandAction, OnboardingCommandPayload)> { - let (action, payload) = if let Some(payload) = command.strip_prefix("onboard-skip-validation ") - { - (OnboardingCommandAction::SkipValidation, payload) - } else { - ( - OnboardingCommandAction::Validate, - command.strip_prefix("onboard ")?, - ) - }; - serde_json::from_str(payload) - .ok() - .map(|payload| (action, payload)) -} - -fn normalized_display_name( - model_catalog: &impl ModelCatalog, - model_slug: &str, - selected_display_name: &str, -) -> String { - let selected = selected_display_name.trim(); - if !selected.is_empty() && selected != model_slug { - return selected.to_string(); - } - model_catalog - .get(model_slug) - .map(|model| model.display_name.clone()) - .unwrap_or_else(|| model_slug.to_string()) -} - #[derive(Debug, Default)] struct InteractiveLoopState { session_id: Option, onboarding_completed: bool, + onboarding_alt_screen: bool, turn_count: usize, total_input_tokens: usize, total_output_tokens: usize, total_tokens: usize, total_cache_read_tokens: usize, - pending_onboarding: Option, // indicate whther LLM worker is working, is started by TurnStarted, // it ended by TurnFailed/TurnFinished busy: bool, @@ -349,6 +286,11 @@ pub async fn run_interactive_tui(config: InteractiveTuiConfig) -> Result Result { - match handle_tui_event( + let action = handle_tui_event( tui_event, &mut tui, &worker, &mut chat_widget, &mut loop_state, - )? { + )?; + finish_onboarding_alt_screen(&mut tui, &mut chat_widget, &mut loop_state)?; + match action { LoopAction::Continue => {} LoopAction::ClearAndExit => { tracing::info!("interactive loop exiting from tui event"); @@ -378,7 +322,7 @@ pub async fn run_interactive_tui(config: InteractiveTuiConfig) -> Result { - match handle_app_event( + let action = handle_app_event( app_event, &worker, &mut chat_widget, @@ -391,7 +335,9 @@ pub async fn run_interactive_tui(config: InteractiveTuiConfig) -> Result {} LoopAction::ClearAndExit => { tracing::info!("interactive loop exiting from app event"); @@ -400,12 +346,14 @@ pub async fn run_interactive_tui(config: InteractiveTuiConfig) -> Result { - match handle_worker_event( + let action = handle_worker_event( worker_event, &worker, &mut chat_widget, &mut loop_state, - )? { + )?; + finish_onboarding_alt_screen(&mut tui, &mut chat_widget, &mut loop_state)?; + match action { LoopAction::Continue => {} LoopAction::ClearAndExit => { tracing::info!("interactive loop exiting from worker event"); @@ -437,6 +385,18 @@ pub async fn run_interactive_tui(config: InteractiveTuiConfig) -> Result Result<()> { + if loop_state.onboarding_alt_screen && !chat_widget.is_onboarding_active() { + tui.leave_alt_screen()?; + loop_state.onboarding_alt_screen = false; + } + Ok(()) +} + pub(crate) fn available_models_with_saved_metadata(config: &InteractiveTuiConfig) -> Vec { let mut available_models = config .model_catalog @@ -605,20 +565,26 @@ fn handle_tui_event( // Update time-sensitive widget state before measuring or rendering. chat_widget.pre_draw_tick(); - // Wrap pending scrollback using the current terminal width. + // Keep startup scrollback out of the dedicated onboarding screen. The + // startup logo belongs to the inline chat surface; flushing it here + // would leave logo fragments behind the full-screen onboarding view. let width = tui.terminal.size()?.width.max(1); - // Completed transcript lines are written directly above the live inline viewport. - let scrollback_lines = chat_widget.drain_scrollback_lines(width); - - if !scrollback_lines.is_empty() { - tui.insert_history_lines(scrollback_lines); + if !loop_state.onboarding_alt_screen { + let scrollback_lines = chat_widget.drain_scrollback_lines(width); + if !scrollback_lines.is_empty() { + tui.insert_history_lines(scrollback_lines); + } } // Size the chat area within the visible terminal and render the frame. - let height = chat_widget - .desired_height(width) - .min(tui.terminal.size()?.height.saturating_sub(1)) - .max(3); + let height = if loop_state.onboarding_alt_screen { + tui.terminal.size()?.height.max(3) + } else { + chat_widget + .desired_height(width) + .min(tui.terminal.size()?.height.saturating_sub(1)) + .max(3) + }; tui.draw(height, |frame| { let area = frame.area(); @@ -638,7 +604,6 @@ fn handle_tui_event( TuiEvent::Key(key) => { if key.code == KeyCode::Esc && chat_widget.is_onboarding_validating() { worker.cancel_provider_validation(); - loop_state.pending_onboarding = None; } if chat_widget.handle_onboarding_key_event(key) { return Ok(LoopAction::Continue); @@ -930,46 +895,32 @@ fn handle_worker_event( loop_state.total_tokens = *next_total_tokens; loop_state.total_cache_read_tokens = *next_total_cache_read_tokens; } - WorkerEvent::ProviderValidationSucceeded { .. } => { - if let Some(pending) = loop_state.pending_onboarding.as_ref() { - let mut provider_vendor = onboarding_provider_vendor( - &pending.binding, - pending.base_url.as_deref(), - pending.api_key.as_deref(), - ); - if pending.api_key.as_deref().is_none() { - provider_vendor.credential = pending.provider_credential_id.clone(); - } - let model_binding = onboarding_provider_model_binding( - &pending.binding, - pending.base_url.as_deref(), - ); - worker.upsert_provider_vendor( - provider_vendor, - Some(model_binding.clone()), - Some(model_binding.binding_id), - pending.api_key.clone(), - )?; - } - } - WorkerEvent::ProviderVendorUpserted { model_binding, .. } => { - if let Some(pending) = loop_state.pending_onboarding.take() { - let request_model = model_binding - .as_ref() - .map(|binding| binding.request_model.clone()) - .unwrap_or_else(|| pending.binding.request_model.clone()); - worker.reconfigure_provider( - pending.binding.invocation_method, - request_model, - pending.base_url, - pending.api_key, - )?; + WorkerEvent::ProviderValidationSucceeded { .. } => {} + WorkerEvent::ProviderUpserted { + provider, + default_model, + } => { + if let Some(wire_api) = provider.wire_apis.first().copied() { + let model = default_model + .clone() + .or_else(|| { + provider + .models + .keys() + .next() + .map(|model_id| format!("{}/{}", provider.id, model_id)) + }) + .unwrap_or_else(|| provider.id.clone()); + worker.reconfigure_provider(wire_api, model, provider.base_url.clone(), None)?; } } WorkerEvent::ProviderValidationFailed { .. } - | WorkerEvent::ProviderVendorUpsertFailed { .. } => { - loop_state.pending_onboarding = None; - } + | WorkerEvent::ProviderUpsertFailed { .. } + | WorkerEvent::ProviderDisconnected { .. } + | WorkerEvent::ProviderDisconnectFailed { .. } + | WorkerEvent::ProviderModelRemoved { .. } + | WorkerEvent::ProviderModelRemoveFailed { .. } + | WorkerEvent::ProvidersListed { .. } => {} WorkerEvent::SessionCompactionStarted => { loop_state.busy = true; } @@ -1012,7 +963,6 @@ fn handle_worker_event( | WorkerEvent::AssistantMessageCompleted(_) | WorkerEvent::ReasoningCompleted(_) | WorkerEvent::PlanUpdated { .. } - | WorkerEvent::ProviderVendorsListed { .. } | WorkerEvent::SessionsListed { .. } | WorkerEvent::SessionsListFailed { .. } | WorkerEvent::SessionPreviewLoaded { .. } @@ -1180,12 +1130,12 @@ fn handle_app_command( } chat_widget.note_permissions_updated(*preset); } - AppCommand::UpdateEffectiveContextWindow { - effective_context_window, - } => { - crate::onboarding::save_compaction_token_limit(*effective_context_window)?; - chat_widget.note_effective_context_window_updated(*effective_context_window); - worker.update_effective_context_window(*effective_context_window)?; + AppCommand::UpdateEffectiveContextWindow { .. } => { + // Global auto-compact threshold removed; model Context window (ratio) + // is the only user-facing limit. Ignore legacy commands. + chat_widget.set_status_message( + "Compaction threshold removed; edit the model Context window instead.".to_string(), + ); } AppCommand::UpdateSandboxProfile { profile } => { worker.update_sandbox_profile(profile.clone())?; @@ -1228,9 +1178,21 @@ fn handle_app_command( worker.set_collaboration_mode(*collaboration_mode, *persist_scope)?; chat_widget.apply_collaboration_mode(*collaboration_mode, *persist_scope); } + AppCommand::ProviderList => { + worker.list_providers()?; + chat_widget.set_status_message("Loading providers"); + } + AppCommand::ProviderValidate { params } => { + worker.validate_provider(params.clone())?; + chat_widget.set_status_message("Validating provider"); + } + AppCommand::ProviderUpsert { params } => { + worker.upsert_provider(params.clone())?; + chat_widget.set_status_message("Saving provider"); + } AppCommand::RunUserShellCommand { command } => { if command == "provider list" { - worker.list_provider_vendors()?; + worker.list_providers()?; } else if command == "skills list" { worker.list_skills()?; chat_widget.set_status_message("Loading skills"); @@ -1260,71 +1222,21 @@ fn handle_app_command( } } else if command == "session new" { worker.start_new_session()?; - } else if let Some((onboarding_action, payload)) = parse_onboarding_command(command) { - if context.model_catalog.get(&payload.model_slug).is_none() { - chat_widget.set_status_message(format!( - "Unsupported model slug: {}", - payload.model_slug - )); - return Ok(()); - } - let display_name = normalized_display_name( - context.model_catalog, - &payload.model_slug, - &payload.display_name, - ); - let base_url = payload.base_url; - let api_key = payload.api_key; - let provider_credential_id = payload.provider_credential_id; - let binding = OnboardingModelBinding { - model_slug: payload.model_slug, - request_model: payload.request_model, - display_name, - provider_id: payload.provider_id, - provider_name: payload.provider_name, - invocation_method: payload.invocation_method, - default_reasoning_effort: payload.default_reasoning_effort, - }; - worker.list_provider_vendors()?; - let mut provider_vendor = - onboarding_provider_vendor(&binding, base_url.as_deref(), api_key.as_deref()); - if api_key.as_deref().is_none() { - provider_vendor.credential = provider_credential_id.clone(); - } - let model_binding = - onboarding_provider_model_binding(&binding, base_url.as_deref()); - let pending = PendingOnboarding { - binding, - base_url, - api_key, - provider_credential_id, - }; - match onboarding_action { - OnboardingCommandAction::Validate => { - worker.validate_provider( - provider_vendor, - model_binding, - pending.api_key.clone(), - )?; - loop_state.pending_onboarding = Some(pending); - chat_widget.set_status_message("Validating provider"); - } - OnboardingCommandAction::SkipValidation => { - let default_model_binding = Some(model_binding.binding_id.clone()); - worker.upsert_provider_vendor( - provider_vendor, - Some(model_binding), - default_model_binding, - pending.api_key.clone(), - )?; - loop_state.pending_onboarding = Some(pending); - chat_widget.set_status_message("Adding provider without validation"); - } - } } else { chat_widget.set_status_message(format!("Unsupported command: {}", command)); } } + AppCommand::DisconnectProvider { provider_id } => { + worker.disconnect_provider(provider_id.clone())?; + chat_widget.set_status_message("Disconnecting provider"); + } + AppCommand::RemoveProviderModel { + provider_id, + model_id, + } => { + worker.remove_provider_model(provider_id.clone(), model_id.clone())?; + chat_widget.set_status_message("Removing model"); + } AppCommand::Compact => { worker.compact_session()?; } diff --git a/crates/tui/src/onboarding.rs b/crates/tui/src/onboarding.rs index 5232ff67..c39cf004 100644 --- a/crates/tui/src/onboarding.rs +++ b/crates/tui/src/onboarding.rs @@ -1,111 +1,13 @@ use anyhow::Context; use anyhow::Result; -use devo_core::ModelBindingConfig; -use devo_core::ProviderConfigSection; -use devo_core::ProviderDefaultsConfig; -use devo_core::ProviderVendorConfig; -use devo_core::provider_id_for_endpoint; -use devo_core::upsert_user_auth_api_key; +use devo_core::model_reference; +use devo_core::read_provider_catalog_config; +use devo_core::write_provider_catalog_config; use devo_protocol::PermissionPreset; -use devo_protocol::ProviderModelBinding; -use devo_protocol::ProviderVendor; use devo_protocol::ProviderWireApi; use devo_util_paths::find_devo_home; -use std::collections::BTreeMap; use toml::Value; -#[derive(Debug, Clone, PartialEq, Eq)] -pub(crate) struct OnboardingModelBinding { - pub model_slug: String, - pub request_model: String, - pub display_name: String, - pub provider_id: String, - pub provider_name: String, - pub invocation_method: ProviderWireApi, - pub default_reasoning_effort: Option, -} - -/// Persists the onboarding choice into the user's `config.toml`. -pub(crate) fn save_onboarding_config( - binding: &OnboardingModelBinding, - base_url: Option<&str>, - api_key: Option<&str>, -) -> Result<()> { - let config_home = find_devo_home().context("could not determine user config path")?; - save_onboarding_config_to_dir(&config_home, binding, base_url, api_key) -} - -pub(crate) fn onboarding_provider_vendor( - binding: &OnboardingModelBinding, - base_url: Option<&str>, - api_key: Option<&str>, -) -> ProviderVendor { - let provider_id = provider_id_for_binding(binding, base_url); - ProviderVendor { - name: provider_id.clone(), - base_url: normalized_optional(base_url).map(ToOwned::to_owned), - credential: normalized_optional(api_key).map(|_| credential_id_for_provider(&provider_id)), - headers: None, - wire_apis: vec![binding.invocation_method], - enabled: true, - } -} - -pub(crate) fn onboarding_provider_model_binding( - binding: &OnboardingModelBinding, - base_url: Option<&str>, -) -> ProviderModelBinding { - let provider_id = provider_id_for_binding(binding, base_url); - ProviderModelBinding { - binding_id: model_binding_id(&binding.model_slug, &provider_id), - model_slug: binding.model_slug.clone(), - provider: provider_id, - request_model: binding.request_model.clone(), - display_name: Some(binding.display_name.clone()), - invocation_method: binding.invocation_method, - default_reasoning_effort: binding.default_reasoning_effort.clone(), - enabled: true, - } -} - -fn save_onboarding_config_to_dir( - config_home: &std::path::Path, - binding: &OnboardingModelBinding, - base_url: Option<&str>, - api_key: Option<&str>, -) -> Result<()> { - let path = config_home.join("config.toml"); - let provider_id = provider_id_for_binding(binding, base_url); - let credential_id = normalized_optional(api_key) - .map(|api_key| { - let credential_id = credential_id_for_provider(&provider_id); - upsert_user_auth_api_key(config_home, &credential_id, api_key).map(|()| credential_id) - }) - .transpose()?; - - let mut root = if path.exists() { - let data = std::fs::read_to_string(&path) - .with_context(|| format!("failed to read {}", path.display()))?; - data.parse::() - .with_context(|| format!("failed to parse {}", path.display()))? - } else { - Value::Table(Default::default()) - }; - - root = merge_onboarding_config(root, binding, base_url, credential_id.as_deref())?; - - if let Some(parent) = path.parent() { - std::fs::create_dir_all(parent) - .with_context(|| format!("failed to create {}", parent.display()))?; - } - - let rendered = toml::to_string_pretty(&root)?; - - std::fs::write(&path, rendered) - .with_context(|| format!("failed to write {}", path.display()))?; - Ok(()) -} - pub(crate) fn save_last_used_model( wire_api: Option, provider: ProviderWireApi, @@ -113,26 +15,25 @@ pub(crate) fn save_last_used_model( ) -> Result<()> { let path = find_devo_home() .context("could not determine user config path")? - .join("config.toml"); - let mut root = if path.exists() { - let data = std::fs::read_to_string(&path) - .with_context(|| format!("failed to read {}", path.display()))?; - data.parse::() - .with_context(|| format!("failed to parse {}", path.display()))? + .join(devo_core::PROVIDER_CONFIG_FILE_NAME); + let mut config = read_provider_catalog_config(&path).map_err(anyhow::Error::msg)?; + let model_ref = if model.contains('/') { + model.to_string() + } else if let Some((provider_id, _)) = config + .providers + .iter() + .find(|(_, provider_config)| provider_config.models.contains_key(model)) + { + model_reference(provider_id, model) } else { - Value::Table(Default::default()) + let provider_id = wire_api + .or(Some(provider)) + .map(|wire_api| wire_api.as_str()) + .unwrap_or("default"); + model_reference(provider_id, model) }; - root = merge_last_used_model(root, wire_api, provider, model)?; - - if let Some(parent) = path.parent() { - std::fs::create_dir_all(parent) - .with_context(|| format!("failed to create {}", parent.display()))?; - } - let rendered = toml::to_string_pretty(&root)?; - - std::fs::write(&path, rendered) - .with_context(|| format!("failed to write {}", path.display()))?; - + config.model = Some(model_ref); + write_provider_catalog_config(&path, &config).map_err(anyhow::Error::msg)?; Ok(()) } @@ -140,35 +41,10 @@ pub(crate) fn save_last_used_model( pub(crate) fn save_reasoning_effort_selection(selection: Option<&str>) -> Result<()> { let path = find_devo_home() .context("could not determine user config path")? - .join("config.toml"); - let mut root = if path.exists() { - let data = std::fs::read_to_string(&path) - .with_context(|| format!("failed to read {}", path.display()))?; - data.parse::() - .with_context(|| format!("failed to parse {}", path.display()))? - } else { - Value::Table(Default::default()) - }; - root = merge_reasoning_effort_selection(root, selection)?; - - if let Some(parent) = path.parent() { - std::fs::create_dir_all(parent) - .with_context(|| format!("failed to create {}", parent.display()))?; - } - let rendered = toml::to_string_pretty(&root)?; - - std::fs::write(&path, rendered) - .with_context(|| format!("failed to write {}", path.display()))?; - Ok(()) -} - -pub(crate) fn save_compaction_token_limit(limit: u64) -> Result<()> { - let config_home = find_devo_home().context("could not determine user config path")?; - let mut store = devo_core::AppConfigStore::load(config_home, /*workspace_root*/ None) - .map_err(|error| anyhow::anyhow!(error))?; - store - .set_compaction_token_limit(limit) - .context("failed to persist compaction_token_limit")?; + .join(devo_core::PROVIDER_CONFIG_FILE_NAME); + let mut config = read_provider_catalog_config(&path).map_err(anyhow::Error::msg)?; + config.reasoning_effort = selection.map(str::to_string); + write_provider_catalog_config(&path, &config).map_err(anyhow::Error::msg)?; Ok(()) } @@ -348,970 +224,11 @@ fn permission_preset_to_config_value(preset: PermissionPreset) -> &'static str { } } -#[allow(dead_code)] -fn merge_reasoning_effort_selection(mut root: Value, selection: Option<&str>) -> Result { - let table = root - .as_table_mut() - .context("config root must be a TOML table")?; - match normalized_optional(selection) { - Some(value) => { - table.insert( - "model_reasoning_effort_selection".to_string(), - Value::String(value.to_string()), - ); - } - None => { - table.remove("model_reasoning_effort_selection"); - } - } - Ok(root) -} - -fn merge_onboarding_config( - mut root: Value, - binding_config: &OnboardingModelBinding, - base_url: Option<&str>, - credential_id: Option<&str>, -) -> Result { - // Preserve unrelated config keys while updating only the onboarding-selected - // provider profile. - let table = root - .as_table_mut() - .context("config root must be a TOML table")?; - let provider_id = provider_id_for_binding(binding_config, base_url); - let binding_id = model_binding_id(&binding_config.model_slug, &provider_id); - let provider_name = normalized_optional(Some(&binding_config.provider_name)) - .map(ToOwned::to_owned) - .unwrap_or_else(|| provider_id.clone()); - let provider_section = ProviderConfigSection { - defaults: ProviderDefaultsConfig { - model_binding: Some(binding_id.clone()), - }, - model_provider: Some(provider_id.clone()), - model: Some(binding_config.model_slug.clone()), - providers: BTreeMap::from([( - provider_id.clone(), - ProviderVendorConfig { - name: provider_name, - base_url: normalized_optional(base_url).map(ToOwned::to_owned), - credential: normalized_optional(credential_id).map(ToOwned::to_owned), - headers: None, - wire_apis: vec![binding_config.invocation_method], - web_search: None, - web_fetch: None, - enabled: true, - }, - )]), - model_bindings: BTreeMap::from([( - binding_id, - ModelBindingConfig { - model_slug: binding_config.model_slug.clone(), - provider: provider_id, - request_model: binding_config.request_model.clone(), - display_name: Some(binding_config.display_name.clone()), - invocation_method: binding_config.invocation_method, - default_reasoning_effort: binding_config.default_reasoning_effort.clone(), - web_search: None, - web_fetch: None, - enabled: true, - }, - )]), - ..ProviderConfigSection::default() - }; - overlay_provider_section(table, &provider_section)?; - Ok(root) -} - -fn overlay_provider_section( - table: &mut toml::map::Map, - section: &ProviderConfigSection, -) -> Result<()> { - let replacement = - toml::Value::try_from(section).context("failed to serialize onboarding provider config")?; - let replacement = replacement - .as_table() - .context("provider config section must serialize to a TOML table")?; - - overlay_optional_key(table, replacement, "model_provider"); - overlay_optional_key(table, replacement, "model"); - overlay_optional_key(table, replacement, "defaults"); - - overlay_nested_known_fields( - table, - replacement, - "providers", - &["name", "base_url", "credential", "wire_apis", "enabled"], - )?; - overlay_nested_known_fields( - table, - replacement, - "model_bindings", - &[ - "model_slug", - "provider", - "request_model", - "display_name", - "invocation_method", - "default_reasoning_effort", - "enabled", - ], - )?; - if let Some(model_bindings) = table - .get_mut("model_bindings") - .and_then(Value::as_table_mut) - { - for binding_id in section.model_bindings.keys() { - if let Some(binding) = model_bindings - .get_mut(binding_id) - .and_then(Value::as_table_mut) - { - binding.remove("model_name"); - } - } - } - Ok(()) -} - -fn overlay_nested_known_fields( - table: &mut toml::map::Map, - replacement: &toml::map::Map, - section_key: &str, - field_keys: &[&str], -) -> Result<()> { - let replacement_entries = replacement - .get(section_key) - .and_then(Value::as_table) - .cloned() - .unwrap_or_default(); - let section = table - .entry(section_key.to_string()) - .or_insert_with(|| Value::Table(Default::default())) - .as_table_mut() - .with_context(|| format!("{section_key} must be a TOML table"))?; - for (entry_id, replacement_entry) in replacement_entries { - let replacement_entry = replacement_entry - .as_table() - .with_context(|| format!("{section_key}.{entry_id} must be a TOML table"))?; - let entry = section - .entry(entry_id) - .or_insert_with(|| Value::Table(Default::default())) - .as_table_mut() - .with_context(|| format!("{section_key} entry must be a TOML table"))?; - for key in field_keys { - overlay_optional_key(entry, replacement_entry, key); - } - } - Ok(()) -} - -fn overlay_optional_key( - table: &mut toml::map::Map, - replacement: &toml::map::Map, - key: &str, -) { - if let Some(value) = replacement.get(key) { - table.insert(key.to_string(), value.clone()); - } else { - table.remove(key); - } -} - -fn merge_last_used_model( - mut root: Value, - wire_api: Option, - provider: ProviderWireApi, - model: &str, -) -> Result { - let table = root - .as_table_mut() - .context("config root must be a TOML table")?; - if let Some((provider_id, model_slug)) = existing_model_binding_selection(table, model) { - let defaults = table - .entry("defaults".to_string()) - .or_insert_with(|| Value::Table(Default::default())) - .as_table_mut() - .context("defaults must be a TOML table")?; - defaults.insert( - "model_binding".to_string(), - Value::String(model.to_string()), - ); - table.insert("model_provider".to_string(), Value::String(provider_id)); - table.insert("model".to_string(), Value::String(model_slug)); - return Ok(root); - } - let provider_id = current_provider_id(table, &provider, model); - let binding_id = current_model_binding_id(table, &provider_id, model) - .unwrap_or_else(|| model_binding_id(model, &provider_id)); - let defaults = table - .entry("defaults".to_string()) - .or_insert_with(|| Value::Table(Default::default())) - .as_table_mut() - .context("defaults must be a TOML table")?; - defaults.insert( - "model_binding".to_string(), - Value::String(binding_id.clone()), - ); - table.insert( - "model_provider".to_string(), - Value::String(provider_id.clone()), - ); - table.insert("model".to_string(), Value::String(model.to_string())); - - let providers = table - .entry("providers".to_string()) - .or_insert_with(|| Value::Table(Default::default())); - let providers_table = providers - .as_table_mut() - .context("providers must be a TOML table")?; - let profile = providers_table - .entry(provider_id.clone()) - .or_insert_with(|| Value::Table(Default::default())); - let profile_table = profile - .as_table_mut() - .context("provider config must be a TOML table")?; - if let Some(wire_api) = wire_api.or_else(|| { - profile_table - .get("wire_apis") - .and_then(Value::as_array) - .and_then(|apis| apis.first()) - .and_then(Value::as_str) - .and_then(provider_wire_api_from_str) - }) { - profile_table.insert( - "wire_apis".to_string(), - Value::Array(vec![Value::String( - wire_api_to_string(wire_api).to_string(), - )]), - ); - let model_bindings = table - .entry("model_bindings".to_string()) - .or_insert_with(|| Value::Table(Default::default())) - .as_table_mut() - .context("model_bindings must be a TOML table")?; - let binding = model_bindings - .entry(binding_id) - .or_insert_with(|| Value::Table(Default::default())) - .as_table_mut() - .context("model binding must be a TOML table")?; - binding.insert("enabled".to_string(), Value::Boolean(true)); - binding.insert("model_slug".to_string(), Value::String(model.to_string())); - binding.insert("provider".to_string(), Value::String(provider_id)); - binding.insert( - "request_model".to_string(), - Value::String(model.to_string()), - ); - binding.remove("model_name"); - binding.insert( - "invocation_method".to_string(), - Value::String(wire_api_to_string(wire_api).to_string()), - ); - } - Ok(root) -} - -fn current_provider_id( - table: &toml::map::Map, - provider: &ProviderWireApi, - model: &str, -) -> String { - table - .get("model_bindings") - .and_then(Value::as_table) - .and_then(|bindings| { - bindings.values().find_map(|value| { - let binding = value.as_table()?; - let matches_model = binding.get("model_slug").and_then(Value::as_str) - == Some(model) - || binding - .get("request_model") - .or_else(|| binding.get("model_name")) - .and_then(Value::as_str) - == Some(model); - let matches_provider = binding - .get("invocation_method") - .and_then(Value::as_str) - .and_then(provider_wire_api_from_str) - == Some(*provider); - (matches_model && matches_provider) - .then(|| { - binding - .get("provider") - .and_then(Value::as_str) - .map(str::to_string) - }) - .flatten() - }) - }) - .or_else(|| { - table - .get("providers") - .and_then(Value::as_table) - .and_then(|providers| { - providers.iter().find_map(|(provider_id, value)| { - let profile = value.as_table()?; - let matches_provider = profile - .get("wire_apis") - .and_then(Value::as_array) - .is_some_and(|wire_apis| { - wire_apis.iter().any(|wire_api| { - wire_api.as_str().and_then(provider_wire_api_from_str) - == Some(*provider) - }) - }); - matches_provider.then(|| provider_id.clone()) - }) - }) - }) - .or_else(|| { - table - .get("model_providers") - .and_then(Value::as_table) - .and_then(|providers| { - providers.iter().find_map(|(provider_id, value)| { - let profile = value.as_table()?; - let contains_model = profile - .get("models") - .and_then(Value::as_array) - .is_some_and(|models| { - models.iter().any(|entry| { - entry - .as_table() - .and_then(|model_entry| model_entry.get("model")) - .and_then(Value::as_str) - == Some(model) - }) - }); - let matches_last_model = - profile.get("last_model").and_then(Value::as_str) == Some(model); - let matches_default_model = - profile.get("default_model").and_then(Value::as_str) == Some(model); - (contains_model || matches_last_model || matches_default_model) - .then(|| provider_id.clone()) - }) - }) - .or_else(|| { - table - .get("model_provider") - .and_then(Value::as_str) - .map(ToOwned::to_owned) - }) - .or_else(|| { - table - .get("model_providers") - .and_then(Value::as_table) - .and_then(|providers| { - providers.iter().find_map(|(provider_id, value)| { - let profile = value.as_table()?; - let wire_api = profile.get("wire_api")?.as_str()?; - let matches_provider = match provider { - ProviderWireApi::AnthropicMessages => { - wire_api == ProviderWireApi::AnthropicMessages.as_str() - } - ProviderWireApi::OpenAIResponses => { - wire_api == ProviderWireApi::OpenAIResponses.as_str() - } - ProviderWireApi::OpenAIChatCompletions => { - wire_api == ProviderWireApi::OpenAIChatCompletions.as_str() - } - }; - matches_provider.then(|| provider_id.clone()) - }) - }) - }) - }) - .unwrap_or_else(|| provider_id_for_endpoint(provider, None)) -} - -fn current_model_binding_id( - table: &toml::map::Map, - provider_id: &str, - model: &str, -) -> Option { - table - .get("model_bindings") - .and_then(Value::as_table) - .and_then(|bindings| { - bindings.iter().find_map(|(binding_id, value)| { - let binding = value.as_table()?; - let matches_provider = - binding.get("provider").and_then(Value::as_str) == Some(provider_id); - let matches_model = binding.get("model_slug").and_then(Value::as_str) - == Some(model) - || binding - .get("request_model") - .or_else(|| binding.get("model_name")) - .and_then(Value::as_str) - == Some(model); - (matches_provider && matches_model).then(|| binding_id.clone()) - }) - }) -} - -fn existing_model_binding_selection( - table: &toml::map::Map, - binding_id: &str, -) -> Option<(String, String)> { - let binding = table - .get("model_bindings") - .and_then(Value::as_table)? - .get(binding_id)? - .as_table()?; - let provider_id = binding.get("provider").and_then(Value::as_str)?; - let model_slug = binding.get("model_slug").and_then(Value::as_str)?; - Some((provider_id.to_string(), model_slug.to_string())) -} - -fn model_binding_id(model: &str, provider_id: &str) -> String { - format!("{}-{}", slug_component(model), slug_component(provider_id)) - .trim_matches('-') - .to_string() -} - -fn provider_id_for_binding(binding: &OnboardingModelBinding, base_url: Option<&str>) -> String { - normalized_optional(Some(&binding.provider_id)) - .map(ToOwned::to_owned) - .unwrap_or_else(|| { - provider_id_for_endpoint(&binding.invocation_method, normalized_optional(base_url)) - }) -} - -fn credential_id_for_provider(provider_id: &str) -> String { - format!("{}_api_key", slug_component(provider_id).replace('-', "_")) -} - -fn slug_component(value: &str) -> String { - let mut out = String::new(); - for ch in value.chars() { - if ch.is_ascii_alphanumeric() { - out.push(ch.to_ascii_lowercase()); - } else if !out.ends_with('-') { - out.push('-'); - } - } - out.trim_matches('-').to_string() -} - -fn normalized_optional(value: Option<&str>) -> Option<&str> { - value.and_then(|value| { - let trimmed = value.trim(); - if trimmed.is_empty() { - None - } else { - Some(trimmed) - } - }) -} - -fn provider_wire_api_from_str(value: &str) -> Option { - match value.trim().to_ascii_lowercase().as_str() { - "chat_completion" - | "chat_completions" - | "openai" - | "openai_chat_completion" - | "openai_chat_completions" => Some(ProviderWireApi::OpenAIChatCompletions), - "responses" | "openai_responses" => Some(ProviderWireApi::OpenAIResponses), - "anthropic" | "messages" | "anthropic_messages" => Some(ProviderWireApi::AnthropicMessages), - _ => None, - } -} - -fn wire_api_to_string(wire_api: ProviderWireApi) -> &'static str { - wire_api.as_str() -} - -fn upsert_model_entry( - models: &mut Vec, - model: &str, - base_url: Option<&str>, - api_key: Option<&str>, -) { - // Keep exactly one entry per model slug so repeated onboarding runs replace - // the existing profile instead of appending duplicates. - let mut entry = toml::map::Map::new(); - entry.insert("model".to_string(), Value::String(model.to_string())); - if let Some(base_url) = base_url { - entry.insert("base_url".to_string(), Value::String(base_url.to_string())); - } - if let Some(api_key) = api_key { - entry.insert("api_key".to_string(), Value::String(api_key.to_string())); - } - - if let Some(existing) = models.iter_mut().find(|value| { - value - .as_table() - .and_then(|table| table.get("model")) - .and_then(Value::as_str) - == Some(model) - }) { - *existing = Value::Table(entry); - } else { - models.push(Value::Table(entry)); - } -} - #[cfg(test)] mod tests { use super::*; - use devo_core::AuthCredentialConfig; - use devo_core::AuthCredentialKind; - use devo_core::UserAuthConfigFile; - use devo_core::read_user_auth_config; use pretty_assertions::assert_eq; - #[test] - fn normalized_optional_trims_and_drops_empty_values() { - assert_eq!( - normalized_optional(Some(" https://example.com ")), - Some("https://example.com") - ); - assert_eq!(normalized_optional(Some(" ")), None); - assert_eq!(normalized_optional(None), None); - } - - #[test] - fn onboarding_provider_vendor_uses_provider_id_and_auth_reference() { - let binding_config = OnboardingModelBinding { - model_slug: "qwen3-coder-next".to_string(), - request_model: "qwen3-coder-next".to_string(), - display_name: "Qwen3 Coder Next".to_string(), - provider_id: "openai_chat_completions".to_string(), - provider_name: "OpenAI".to_string(), - invocation_method: ProviderWireApi::OpenAIChatCompletions, - default_reasoning_effort: None, - }; - - assert_eq!( - onboarding_provider_vendor( - &binding_config, - Some(" https://example.com/v1 "), - Some("sk-test-secret") - ), - ProviderVendor { - name: "openai_chat_completions".to_string(), - base_url: Some("https://example.com/v1".to_string()), - credential: Some("openai_chat_completions_api_key".to_string()), - headers: None, - wire_apis: vec![ProviderWireApi::OpenAIChatCompletions], - enabled: true, - } - ); - } - - #[test] - fn merge_onboarding_config_creates_provider_and_model_binding() { - let root = Value::Table(Default::default()); - let binding_config = OnboardingModelBinding { - model_slug: "qwen3-coder-next".to_string(), - request_model: "qwen3-coder-next".to_string(), - display_name: "Qwen3 Coder Next".to_string(), - provider_id: "openai_chat_completions".to_string(), - provider_name: "OpenAI".to_string(), - invocation_method: ProviderWireApi::OpenAIChatCompletions, - default_reasoning_effort: Some("medium".to_string()), - }; - let merged = merge_onboarding_config( - root, - &binding_config, - Some("https://example.com/v1"), - Some("openai_chat_completions_api_key"), - ) - .expect("merge"); - - let table = merged.as_table().expect("table"); - assert_eq!( - table - .get("defaults") - .and_then(Value::as_table) - .and_then(|defaults| defaults.get("model_binding")) - .and_then(Value::as_str), - Some("qwen3-coder-next-openai-chat-completions") - ); - - let profile = table - .get("providers") - .and_then(Value::as_table) - .and_then(|providers| providers.get("openai_chat_completions")) - .and_then(Value::as_table) - .expect("provider profile"); - assert_eq!(profile.get("name").and_then(Value::as_str), Some("OpenAI")); - assert_eq!( - profile - .get("wire_apis") - .and_then(Value::as_array) - .and_then(|wire_apis| wire_apis.first()) - .and_then(Value::as_str), - Some("openai_chat_completions") - ); - assert_eq!( - profile.get("base_url").and_then(Value::as_str), - Some("https://example.com/v1") - ); - assert_eq!( - profile.get("credential").and_then(Value::as_str), - Some("openai_chat_completions_api_key") - ); - - let binding = table - .get("model_bindings") - .and_then(Value::as_table) - .and_then(|bindings| bindings.get("qwen3-coder-next-openai-chat-completions")) - .and_then(Value::as_table) - .expect("model binding"); - assert_eq!( - binding.get("model_slug").and_then(Value::as_str), - Some("qwen3-coder-next") - ); - assert_eq!( - binding.get("provider").and_then(Value::as_str), - Some("openai_chat_completions") - ); - assert_eq!( - binding.get("request_model").and_then(Value::as_str), - Some("qwen3-coder-next") - ); - assert_eq!( - binding.get("display_name").and_then(Value::as_str), - Some("Qwen3 Coder Next") - ); - assert_eq!( - binding - .get("default_reasoning_effort") - .and_then(Value::as_str), - Some("medium") - ); - } - - #[test] - fn merge_onboarding_config_migrates_legacy_model_name_without_losing_custom_fields() { - let mut root = Value::Table(Default::default()); - { - let table = root.as_table_mut().expect("table"); - let mut providers = toml::map::Map::new(); - providers.insert( - "openai_chat_completions".to_string(), - Value::Table(Default::default()), - ); - table.insert("providers".to_string(), Value::Table(providers)); - let mut binding = toml::map::Map::new(); - binding.insert( - "model_slug".to_string(), - Value::String("qwen3-coder-next".to_string()), - ); - binding.insert( - "provider".to_string(), - Value::String("openai_chat_completions".to_string()), - ); - binding.insert( - "model_name".to_string(), - Value::String("old-provider-name".to_string()), - ); - binding.insert( - "custom_binding_key".to_string(), - Value::String("preserved".to_string()), - ); - binding.insert( - "invocation_method".to_string(), - Value::String("openai_chat_completions".to_string()), - ); - let mut bindings = toml::map::Map::new(); - bindings.insert( - "qwen3-coder-next-openai-chat-completions".to_string(), - Value::Table(binding), - ); - table.insert("model_bindings".to_string(), Value::Table(bindings)); - } - - let binding_config = OnboardingModelBinding { - model_slug: "qwen3-coder-next".to_string(), - request_model: "qwen3-coder-next".to_string(), - display_name: "Qwen3 Coder Next".to_string(), - provider_id: "openai_chat_completions".to_string(), - provider_name: "OpenAI".to_string(), - invocation_method: ProviderWireApi::OpenAIChatCompletions, - default_reasoning_effort: None, - }; - let merged = merge_onboarding_config( - root, - &binding_config, - Some("https://new.example/v1"), - Some("openai_chat_completions_api_key"), - ) - .expect("merge"); - - let table = merged.as_table().expect("table"); - let profile = table - .get("providers") - .and_then(Value::as_table) - .and_then(|providers| providers.get("openai_chat_completions")) - .and_then(Value::as_table) - .expect("provider"); - assert_eq!( - profile.get("base_url").and_then(Value::as_str), - Some("https://new.example/v1") - ); - assert_eq!( - profile.get("credential").and_then(Value::as_str), - Some("openai_chat_completions_api_key") - ); - let binding = table - .get("model_bindings") - .and_then(Value::as_table) - .and_then(|bindings| bindings.get("qwen3-coder-next-openai-chat-completions")) - .and_then(Value::as_table) - .expect("binding"); - assert_eq!( - binding.get("request_model").and_then(Value::as_str), - Some("qwen3-coder-next") - ); - assert_eq!(binding.get("model_name"), None); - assert_eq!( - binding.get("custom_binding_key").and_then(Value::as_str), - Some("preserved") - ); - assert_eq!( - table - .get("defaults") - .and_then(Value::as_table) - .and_then(|defaults| defaults.get("model_binding")) - .and_then(Value::as_str), - Some("qwen3-coder-next-openai-chat-completions") - ); - } - - #[test] - fn save_onboarding_config_writes_config_reference_and_user_auth_secret() { - let dir = tempfile::tempdir().expect("temp dir"); - let binding_config = OnboardingModelBinding { - model_slug: "qwen3-coder-next".to_string(), - request_model: "qwen3-coder-next".to_string(), - display_name: "Qwen3 Coder Next".to_string(), - provider_id: "openai_chat_completions".to_string(), - provider_name: "OpenAI".to_string(), - invocation_method: ProviderWireApi::OpenAIChatCompletions, - default_reasoning_effort: None, - }; - - save_onboarding_config_to_dir( - dir.path(), - &binding_config, - Some("https://example.com/v1"), - Some("sk-test-secret"), - ) - .expect("save onboarding config"); - - let config = std::fs::read_to_string(dir.path().join("config.toml")).expect("read config"); - let config: Value = config.parse().expect("parse config"); - assert_eq!( - config["providers"]["openai_chat_completions"]["credential"].as_str(), - Some("openai_chat_completions_api_key") - ); - assert!( - config["providers"]["openai_chat_completions"] - .get("api_key") - .is_none() - ); - assert_eq!( - read_user_auth_config(&dir.path().join("auth.json")).expect("load auth"), - UserAuthConfigFile { - credentials: [( - "openai_chat_completions_api_key".to_string(), - AuthCredentialConfig { - kind: AuthCredentialKind::ApiKey, - value: "sk-test-secret".to_string(), - }, - )] - .into_iter() - .collect(), - ..UserAuthConfigFile::default() - } - ); - } - - #[test] - fn legacy_model_name_remains_discoverable_for_provider_and_binding_lookup() { - let root: Value = r#" -[providers.openrouter] -wire_apis = ["openai_chat_completions"] - -[model_bindings.main] -model_slug = "catalog-model" -provider = "openrouter" -model_name = "vendor/model" -invocation_method = "openai_chat_completions" -"# - .parse() - .expect("parse"); - let table = root.as_table().expect("table"); - - assert_eq!( - current_provider_id( - table, - &ProviderWireApi::OpenAIChatCompletions, - "vendor/model", - ), - "openrouter" - ); - assert_eq!( - current_model_binding_id(table, "openrouter", "vendor/model"), - Some("main".to_string()) - ); - } - - #[test] - fn merge_last_used_model_prefers_profile_that_contains_model() { - let root: Value = r#" -model_provider = "anthropic" - -[model_providers.anthropic] -wire_api = "anthropic_messages" - -[[model_providers.anthropic.models]] -model = "claude-sonnet-4" - -[model_providers.openai] -wire_api = "openai_chat_completions" - -[[model_providers.openai.models]] -model = "gpt-5.4" -"# - .parse() - .expect("parse"); - - let merged = - merge_last_used_model(root, None, ProviderWireApi::AnthropicMessages, "gpt-5.4") - .expect("merge"); - - let table = merged.as_table().expect("table"); - assert_eq!( - table.get("model_provider").and_then(Value::as_str), - Some("openai") - ); - assert_eq!( - table - .get("model_providers") - .and_then(Value::as_table) - .and_then(|providers| providers.get("openai")) - .and_then(Value::as_table) - .and_then(|profile| profile.get("wire_api")) - .and_then(Value::as_str), - Some("openai_chat_completions") - ); - } - - #[test] - fn merge_last_used_model_accepts_existing_model_binding_id() { - let root: Value = r#" -model_provider = "deepseek" -model = "deepseek-v4-flash" - -[defaults] -model_binding = "deepseek-v4-flash-deepseek" - -[providers.deepseek] -wire_apis = ["openai_chat_completions"] - -[providers.openrouter] -wire_apis = ["openai_chat_completions"] - -[model_bindings.deepseek-v4-flash-deepseek] -model_slug = "deepseek-v4-flash" -provider = "deepseek" -request_model = "deepseek-v4-flash" -invocation_method = "openai_chat_completions" - -[model_bindings.deepseek-v4-flash-openrouter] -model_slug = "deepseek-v4-flash" -provider = "openrouter" -request_model = "deepseek-v4-flash" -invocation_method = "openai_chat_completions" -"# - .parse() - .expect("parse"); - - let merged = merge_last_used_model( - root, - None, - ProviderWireApi::OpenAIChatCompletions, - "deepseek-v4-flash-openrouter", - ) - .expect("merge"); - - let table = merged.as_table().expect("table"); - assert_eq!( - table - .get("defaults") - .and_then(Value::as_table) - .and_then(|defaults| defaults.get("model_binding")) - .and_then(Value::as_str), - Some("deepseek-v4-flash-openrouter") - ); - assert_eq!( - table.get("model_provider").and_then(Value::as_str), - Some("openrouter") - ); - assert_eq!( - table.get("model").and_then(Value::as_str), - Some("deepseek-v4-flash") - ); - assert_eq!( - table - .get("model_bindings") - .and_then(Value::as_table) - .map(toml::map::Map::len), - Some(2) - ); - } - - #[test] - fn merge_last_used_model_preserves_existing_wire_api_when_not_provided() { - let root: Value = r#" -[model_providers.openai] -wire_api = "openai_responses" - -[[model_providers.openai.models]] -model = "gpt-5.4" -"# - .parse() - .expect("parse"); - - let merged = merge_last_used_model(root, None, ProviderWireApi::OpenAIResponses, "gpt-5.4") - .expect("merge"); - - assert_eq!( - merged - .as_table() - .and_then(|table| table.get("model_providers")) - .and_then(Value::as_table) - .and_then(|providers| providers.get("openai")) - .and_then(Value::as_table) - .and_then(|profile| profile.get("wire_api")) - .and_then(Value::as_str), - Some("openai_responses") - ); - } - - #[test] - fn merge_reasoning_effort_selection_updates_and_removes_value() { - let merged = - merge_reasoning_effort_selection(Value::Table(Default::default()), Some("medium")) - .expect("merge"); - assert_eq!( - merged - .as_table() - .and_then(|table| table.get("model_reasoning_effort_selection")) - .and_then(Value::as_str), - Some("medium") - ); - - let removed = merge_reasoning_effort_selection(merged, None).expect("remove"); - assert_eq!( - removed - .as_table() - .and_then(|table| table.get("model_reasoning_effort_selection")), - None - ); - } - #[test] fn merge_project_permission_preset_preserves_unrelated_config() { let root: Value = r#" diff --git a/crates/tui/src/onboarding_widget.rs b/crates/tui/src/onboarding_widget.rs index 440ceaca..3c2b51d6 100644 --- a/crates/tui/src/onboarding_widget.rs +++ b/crates/tui/src/onboarding_widget.rs @@ -1,17 +1,18 @@ //! Standalone onboarding widget for first-run model setup. //! -//! This widget renders inline in the TUI bottom area. It handles all keyboard -//! input directly during onboarding, and is owned by `ChatWidget` — not by -//! `BottomPane` — keeping it decoupled from the composer and popup system. +//! This widget owns the onboarding flow and renders it in the TUI alternate +//! screen. It handles all keyboard input directly during onboarding, and is +//! owned by `ChatWidget` — not by `BottomPane` — keeping it decoupled from the +//! composer and popup system. //! //! Follows L2-DES-TUI-001 flow: -//! 1. Model slug selection (searchable popup) -//! 2. Provider selection (existing or "Add provider...") -//! 3. Inline setup with vertical rail (* / | markers) -//! 4. Invocation method popup -//! 5. Reasoning effort popup (if model supports reasoning) -//! 6. Validation +//! 1. Provider selection (existing or custom) +//! 2. Model selection (provider catalog or custom) +//! 3. Model settings (basic fields plus expandable advanced overrides) +//! 4. Review and confirmation +//! 5. Validation +use std::collections::BTreeMap; use std::time::Instant; use crossterm::event::KeyCode; @@ -19,6 +20,8 @@ use crossterm::event::KeyEvent; use crossterm::event::KeyEventKind; use crossterm::event::KeyModifiers; use ratatui::buffer::Buffer; +use ratatui::layout::Constraint; +use ratatui::layout::Layout; use ratatui::layout::Rect; use ratatui::style::Style; use ratatui::style::Stylize; @@ -29,10 +32,13 @@ use ratatui::widgets::Widget; use ratatui::widgets::Wrap; use devo_protocol::Model; -use devo_protocol::ProviderModelBinding; -use devo_protocol::ProviderVendor; +use devo_protocol::ProviderInfo; +use devo_protocol::ProviderModelInfo; use devo_protocol::ProviderWireApi; +use devo_protocol::ReasoningCapability; +use devo_protocol::ReasoningEffort; use devo_protocol::ReasoningEffortOption; +use devo_protocol::ReasoningImplementation; use crate::app_command::AppCommand; use crate::app_event::AppEvent; @@ -42,7 +48,6 @@ use crate::bottom_pane::scroll_state::ScrollState; use crate::exec_cell::spinner; use crate::onboarding_viewport::ViewportAnchor; use crate::onboarding_viewport::render_lines_with_anchor; -use crate::onboarding_viewport::render_lines_with_fixed_footer; use crate::render::renderable::Renderable; use crate::tui::frame_requester::FrameRequester; use crate::ui_consts::FOOTER_INDENT_COLS; @@ -62,11 +67,12 @@ fn onboarding_content_area(area: Rect) -> Rect { if area.height < 2 || area.width < 2 { return area; } + let padding = u16::from(area.height >= 12); Rect { - x: area.x + 1, - y: area.y + 1, - width: area.width.saturating_sub(2), - height: area.height.saturating_sub(2), + x: area.x + padding, + y: area.y + padding, + width: area.width.saturating_sub(padding * 2), + height: area.height.saturating_sub(padding * 2), } } @@ -120,29 +126,762 @@ enum InlineField { DisplayName, } +/// Fields shown when a user adds a model that is not in the provider catalog. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum CustomModelField { + ModelId, + DisplayName, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum SelectionFocus { + List, + Custom, +} + +#[derive(Debug, Clone)] +struct ProviderDraft { + provider: ProviderWireApi, + provider_id: String, + provider_name: String, + provider_credential_id: Option, + base_url: String, + api_key: String, + is_custom: bool, +} + +impl Default for ProviderDraft { + fn default() -> Self { + Self { + provider: ProviderWireApi::OpenAIChatCompletions, + provider_id: String::new(), + provider_name: String::new(), + provider_credential_id: None, + base_url: String::new(), + api_key: String::new(), + is_custom: true, + } + } +} + +#[derive(Debug, Clone, Default)] +struct ModelSettingsDraft { + display_name: String, + /// Absolute usable tokens shown in the Context window field. + context_window: String, + /// Hard model capacity used to convert usable tokens → percent on save. + context_window_hard: String, + max_tokens: String, + temperature: String, + input_modalities: String, + reasoning_capability: String, + reasoning_levels: String, + effective_context_window_percent: String, + top_p: String, + top_k: String, + family: String, + release_date: String, + status: String, + capabilities_json: String, + channel: String, + base_instructions: String, + reasoning_implementation: String, + reasoning_variants_json: String, + default_variant: String, + cost_json: String, + metadata_json: String, + request_json: String, + options_json: String, + headers_json: String, + variants_json: String, + web_search_json: String, + web_fetch_json: String, + truncation_mode: String, + truncation_limit: String, + supports_image_detail_original: Option, + enabled: Option, + priority: String, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum ModelSettingsField { + DisplayName, + ContextWindow, + MaxTokens, + Temperature, + InputModalities, + ReasoningCapability, + ReasoningLevels, + DefaultReasoning, + AdvancedToggle, + EffectiveContext, + TopP, + TopK, + Family, + ReleaseDate, + Status, + CapabilitiesJson, + Channel, + BaseInstructions, + ReasoningImplementation, + ReasoningVariantsJson, + DefaultVariant, + CostJson, + MetadataJson, + RequestJson, + OptionsJson, + HeadersJson, + VariantsJson, + WebSearchJson, + WebFetchJson, + TruncationMode, + TruncationLimit, + OriginalImageDetail, + Enabled, + Priority, +} + +impl ModelSettingsDraft { + fn from_value(value: Option<&serde_json::Value>, display_name: &str) -> Self { + let Some(object) = value.and_then(serde_json::Value::as_object) else { + return Self { + display_name: display_name.to_string(), + ..Self::default() + }; + }; + let string_value = |name: &str| { + object + .get(name) + .and_then(serde_json::Value::as_str) + .unwrap_or_default() + .to_string() + }; + let json_value = |name: &str| { + object + .get(name) + .map(|value| serde_json::to_string(value).unwrap_or_default()) + .unwrap_or_default() + }; + let number_value = |name: &str| { + object + .get(name) + .map(ToString::to_string) + .unwrap_or_default() + .trim_matches('"') + .to_string() + }; + let truncation = object + .get("truncation_policy") + .and_then(serde_json::Value::as_object); + let (reasoning_capability, reasoning_levels) = object + .get("reasoning_capability") + .map(Self::reasoning_capability_fields) + .unwrap_or_default(); + let (reasoning_implementation, reasoning_variants_json) = object + .get("reasoning_implementation") + .map(Self::reasoning_implementation_fields) + .unwrap_or_default(); + Self { + display_name: object + .get("name") + .and_then(serde_json::Value::as_str) + .unwrap_or(display_name) + .to_string(), + context_window: { + let raw = number_value("context_window"); + match raw.trim().parse::() { + Ok(window) if !raw.trim().is_empty() => { + let percent = number_value("effective_context_window_percent") + .trim() + .parse::() + .unwrap_or(95.0) + .clamp(0.0, 100.0); + ((f64::from(window) * percent) / 100.0).floor().to_string() + } + _ => raw, + } + }, + context_window_hard: number_value("context_window"), + max_tokens: number_value("max_tokens"), + temperature: number_value("temperature"), + input_modalities: object + .get("input_modalities") + .and_then(serde_json::Value::as_array) + .map(|values| { + values + .iter() + .filter_map(serde_json::Value::as_str) + .collect::>() + .join(", ") + }) + .unwrap_or_default(), + reasoning_capability, + reasoning_levels, + // Percent is derived on save from usable absolute ÷ hard window. + effective_context_window_percent: String::new(), + top_p: number_value("top_p"), + top_k: number_value("top_k"), + family: string_value("family"), + release_date: string_value("release_date"), + status: string_value("status"), + capabilities_json: json_value("capabilities"), + channel: string_value("channel"), + base_instructions: string_value("base_instructions"), + reasoning_implementation, + reasoning_variants_json, + default_variant: string_value("default_variant"), + cost_json: json_value("cost"), + metadata_json: json_value("metadata"), + request_json: json_value("request"), + options_json: json_value("options"), + headers_json: json_value("headers"), + variants_json: json_value("variants"), + web_search_json: json_value("web_search"), + web_fetch_json: json_value("web_fetch"), + truncation_mode: truncation + .and_then(|value| value.get("mode")) + .and_then(serde_json::Value::as_str) + .unwrap_or_default() + .to_string(), + truncation_limit: truncation + .and_then(|value| value.get("limit")) + .map(ToString::to_string) + .unwrap_or_default() + .trim_matches('"') + .to_string(), + supports_image_detail_original: object + .get("supports_image_detail_original") + .and_then(serde_json::Value::as_bool), + enabled: object.get("enabled").and_then(serde_json::Value::as_bool), + priority: number_value("priority"), + } + } + + fn reasoning_capability_fields(value: &serde_json::Value) -> (String, String) { + match serde_json::from_value::(value.clone()) { + Ok(ReasoningCapability::Unsupported) => ("unsupported".to_string(), String::new()), + Ok(ReasoningCapability::Toggle) => ("toggle".to_string(), String::new()), + Ok(ReasoningCapability::Levels(levels)) => ( + "levels".to_string(), + levels + .iter() + .map(|level| level.selection_value()) + .collect::>() + .join(", "), + ), + Err(_) => ( + serde_json::to_string(value).unwrap_or_default(), + String::new(), + ), + } + } + + fn reasoning_implementation_fields(value: &serde_json::Value) -> (String, String) { + match serde_json::from_value::(value.clone()) { + Ok(ReasoningImplementation::Disabled) => ("disabled".to_string(), String::new()), + Ok(ReasoningImplementation::RequestParameter) => { + ("request_parameter".to_string(), String::new()) + } + Ok(ReasoningImplementation::ModelVariant(config)) => ( + "model_variant".to_string(), + serde_json::to_string(&config.variants).unwrap_or_default(), + ), + Err(_) => ( + serde_json::to_string(value).unwrap_or_default(), + String::new(), + ), + } + } + + fn insert_json_field( + object: &mut serde_json::Map, + name: &str, + input: &str, + ) { + if let Ok(value) = serde_json::from_str(input.trim()) { + object.insert(name.to_string(), value); + } + } + + fn to_value(&self, model_id: &str) -> Option { + let mut object = serde_json::Map::new(); + let display_name = self.display_name.trim(); + if !display_name.is_empty() && display_name != model_id { + object.insert( + "name".to_string(), + serde_json::Value::String(display_name.to_string()), + ); + } + macro_rules! insert_number { + ($field:ident, $name:literal, $ty:ty) => { + if let Ok(value) = self.$field.trim().parse::<$ty>() { + object.insert($name.to_string(), serde_json::json!(value)); + } + }; + } + let usable = self.context_window.trim().parse::().ok(); + let hard = self + .context_window_hard + .trim() + .parse::() + .ok() + .filter(|value| *value > 0); + match (usable, hard) { + (Some(user_tokens), Some(hard_window)) => { + object.insert("context_window".to_string(), serde_json::json!(hard_window)); + let percent = + ((user_tokens as f64) * 100.0 / (hard_window as f64)).clamp(1.0, 100.0); + object.insert( + "effective_context_window_percent".to_string(), + serde_json::json!(percent), + ); + } + (Some(user_tokens), None) => { + // Custom model with no hard window yet. + object.insert("context_window".to_string(), serde_json::json!(user_tokens)); + object.insert( + "effective_context_window_percent".to_string(), + serde_json::json!(100.0), + ); + } + (None, Some(hard_window)) => { + // Cleared usable field: keep hard, omit percent → default 95%. + object.insert("context_window".to_string(), serde_json::json!(hard_window)); + } + (None, None) => {} + } + insert_number!(max_tokens, "max_tokens", u32); + insert_number!(temperature, "temperature", f64); + insert_number!(top_p, "top_p", f64); + insert_number!(top_k, "top_k", f64); + insert_number!(priority, "priority", i32); + for (field, name) in [ + (&self.family, "family"), + (&self.release_date, "release_date"), + (&self.status, "status"), + (&self.channel, "channel"), + (&self.base_instructions, "base_instructions"), + (&self.default_variant, "default_variant"), + ] { + if !field.trim().is_empty() { + object.insert( + name.to_string(), + serde_json::Value::String(field.trim().to_string()), + ); + } + } + let modalities = self + .input_modalities + .split(',') + .map(str::trim) + .filter(|value| !value.is_empty()) + .filter(|value| matches!(*value, "text" | "image")) + .map(|value| serde_json::Value::String(value.to_string())) + .collect::>(); + if !modalities.is_empty() { + object.insert( + "input_modalities".to_string(), + serde_json::Value::Array(modalities), + ); + } + if !self.reasoning_capability.trim().is_empty() { + let capability = self.reasoning_capability.trim().to_ascii_lowercase(); + let levels = self + .reasoning_levels + .split(',') + .map(str::trim) + .filter(|value| !value.is_empty()) + .filter_map(|value| { + serde_json::from_value::( + serde_json::Value::String(value.to_string()), + ) + .ok() + }) + .collect::>(); + let capability = match capability.as_str() { + "unsupported" => Some(ReasoningCapability::Unsupported), + "toggle" => Some(ReasoningCapability::Toggle), + "levels" | "toggle_with_levels" if !levels.is_empty() => { + let choices = if capability == "toggle_with_levels" + && !levels.iter().any(|choice| { + matches!(choice, devo_protocol::ReasoningLevelChoice::Off) + }) { + let mut choices = vec![devo_protocol::ReasoningLevelChoice::Off]; + choices.extend(levels); + choices + } else { + levels + }; + Some(ReasoningCapability::Levels(choices)) + } + _ => None, + }; + if let Some(capability) = capability { + object.insert( + "reasoning_capability".to_string(), + serde_json::to_value(capability).expect("reasoning capability serializes"), + ); + } + } + if !self.reasoning_implementation.trim().is_empty() { + let implementation = match self + .reasoning_implementation + .trim() + .to_ascii_lowercase() + .as_str() + { + "disabled" => Some(ReasoningImplementation::Disabled), + "request_parameter" => Some(ReasoningImplementation::RequestParameter), + "model_variant" => serde_json::from_str::>( + self.reasoning_variants_json.trim(), + ) + .ok() + .map(|variants| { + ReasoningImplementation::ModelVariant(devo_protocol::ReasoningVariantConfig { + variants, + }) + }), + _ => None, + }; + if let Some(implementation) = implementation { + object.insert( + "reasoning_implementation".to_string(), + serde_json::to_value(implementation) + .expect("reasoning implementation serializes"), + ); + } + } + if let Some(value) = self.supports_image_detail_original { + object.insert( + "supports_image_detail_original".to_string(), + serde_json::Value::Bool(value), + ); + } + if let Some(value) = self.enabled { + object.insert("enabled".to_string(), serde_json::Value::Bool(value)); + } + if let Ok(limit) = self.truncation_limit.trim().parse::() { + let mode = match self.truncation_mode.trim() { + "tokens" => "tokens", + _ => "bytes", + }; + object.insert( + "truncation_policy".to_string(), + serde_json::json!({"mode": mode, "limit": limit}), + ); + } + for (field, name) in [ + (&self.cost_json, "cost"), + (&self.metadata_json, "metadata"), + (&self.request_json, "request"), + (&self.options_json, "options"), + (&self.headers_json, "headers"), + (&self.variants_json, "variants"), + (&self.web_search_json, "web_search"), + (&self.web_fetch_json, "web_fetch"), + (&self.capabilities_json, "capabilities"), + ] { + Self::insert_json_field(&mut object, name, field); + } + (!object.is_empty()).then_some(serde_json::Value::Object(object)) + } + + fn validation_error(&self, model_id: &str) -> Option { + macro_rules! validate_number { + ($field:ident, $type:ty, $label:literal) => { + if !self.$field.trim().is_empty() && self.$field.trim().parse::<$type>().is_err() { + return Some(format!("{} must be a valid {}", $label, stringify!($type))); + } + }; + } + validate_number!(context_window, u32, "Context window"); + validate_number!(max_tokens, u32, "Max output tokens"); + validate_number!(temperature, f64, "Temperature"); + validate_number!(top_p, f64, "Top P"); + validate_number!(top_k, f64, "Top K"); + validate_number!(priority, i32, "Priority"); + if self + .context_window + .trim() + .parse::() + .is_ok_and(|value| value == 0) + { + return Some("Context window must be greater than 0".to_string()); + } + if self + .max_tokens + .trim() + .parse::() + .is_ok_and(|value| value == 0) + { + return Some("Max output tokens must be greater than 0".to_string()); + } + if self + .temperature + .trim() + .parse::() + .is_ok_and(|value| !value.is_finite() || value < 0.0) + { + return Some( + "Temperature must be a finite number greater than or equal to 0".to_string(), + ); + } + if self + .top_p + .trim() + .parse::() + .is_ok_and(|value| !value.is_finite() || !(0.0..=1.0).contains(&value)) + { + return Some("Top P must be between 0 and 1".to_string()); + } + if self + .top_k + .trim() + .parse::() + .is_ok_and(|value| !value.is_finite() || value < 0.0) + { + return Some("Top K must be a finite number greater than or equal to 0".to_string()); + } + if !self.input_modalities.trim().is_empty() + && self + .input_modalities + .split(',') + .map(str::trim) + .any(|value| !matches!(value, "text" | "image")) + { + return Some("Input modalities must contain only text and image".to_string()); + } + if !self.truncation_mode.trim().is_empty() + && !matches!(self.truncation_mode.trim(), "bytes" | "tokens") + { + return Some("Truncation mode must be bytes or tokens".to_string()); + } + validate_number!(truncation_limit, i64, "Truncation limit"); + if self + .truncation_limit + .trim() + .parse::() + .is_ok_and(|value| value <= 0) + { + return Some("Truncation limit must be greater than 0".to_string()); + } + if let Some(error) = Self::validate_json_fields([ + ("Cost", &self.cost_json), + ("Metadata", &self.metadata_json), + ("Capabilities", &self.capabilities_json), + ("Request", &self.request_json), + ("Options", &self.options_json), + ("Headers", &self.headers_json), + ("Variants", &self.variants_json), + ("Reasoning variants", &self.reasoning_variants_json), + ("Web search", &self.web_search_json), + ("Web fetch", &self.web_fetch_json), + ]) { + return Some(error); + } + if let Some(error) = Self::validate_json_objects([ + ("Metadata", &self.metadata_json), + ("Capabilities", &self.capabilities_json), + ("Request", &self.request_json), + ("Options", &self.options_json), + ("Headers", &self.headers_json), + ("Variants", &self.variants_json), + ("Web search", &self.web_search_json), + ("Web fetch", &self.web_fetch_json), + ]) { + return Some(error); + } + if !self.headers_json.trim().is_empty() + && serde_json::from_str::>(&self.headers_json).is_err() + { + return Some("Headers JSON must be an object whose values are strings".to_string()); + } + if !self.variants_json.trim().is_empty() + && serde_json::from_str::>( + &self.variants_json, + ) + .is_err() + { + return Some( + "Variants JSON must map effort keys (off/on/levels) to label, disabled, request_model, request, options, or headers" + .to_string(), + ); + } + if let Some(error) = Self::validate_reasoning(self) { + return Some(error); + } + self.to_value(model_id).and_then(|value| { + serde_json::from_value::(value) + .err() + .map(|error| format!("Model settings are invalid: {error}")) + }) + } + + fn validate_json_fields(fields: [(&str, &String); N]) -> Option { + for (label, input) in fields { + if !input.trim().is_empty() && serde_json::from_str::(input).is_err() + { + return Some(format!("{label} JSON must be valid JSON")); + } + } + None + } + + fn validate_json_objects(fields: [(&str, &String); N]) -> Option { + for (label, input) in fields { + if input.trim().is_empty() { + continue; + } + let Ok(value) = serde_json::from_str::(input) else { + continue; + }; + if !value.is_object() { + return Some(format!("{label} JSON must be an object")); + } + } + None + } + + fn validate_reasoning(&self) -> Option { + let capability = self.reasoning_capability.trim().to_ascii_lowercase(); + if !capability.is_empty() { + let levels = self + .reasoning_levels + .split(',') + .map(str::trim) + .filter(|value| !value.is_empty()) + .collect::>(); + if !matches!(capability.as_str(), "unsupported" | "toggle" | "levels") { + return Some( + "Reasoning capability must be unsupported, toggle, or levels".to_string(), + ); + } + if !matches!(capability.as_str(), "unsupported" | "toggle") && levels.is_empty() { + return Some("Reasoning levels are required for levels mode".to_string()); + } + if levels.iter().any(|value| { + serde_json::from_value::( + serde_json::Value::String((*value).to_string()), + ) + .is_err() + }) { + return Some( + "Reasoning levels must be comma-separated: off, none, minimal, low, medium, high, xhigh, or max" + .to_string(), + ); + } + } + let implementation = self.reasoning_implementation.trim().to_ascii_lowercase(); + if !implementation.is_empty() + && !matches!( + implementation.as_str(), + "disabled" | "request_parameter" | "model_variant" + ) + { + return Some( + "Reasoning implementation must be disabled, request_parameter, or model_variant" + .to_string(), + ); + } + if implementation == "model_variant" && self.reasoning_variants_json.trim().is_empty() { + return Some("Reasoning variant rules must be a valid JSON array".to_string()); + } + if !self.reasoning_variants_json.trim().is_empty() { + if implementation != "model_variant" { + return Some( + "Reasoning variant rules require model_variant implementation".to_string(), + ); + } + if serde_json::from_str::>( + self.reasoning_variants_json.trim(), + ) + .is_err() + { + return Some("Reasoning variant rules must be a valid JSON array".to_string()); + } + } + None + } +} + /// Onboarding state machine following L2-DES-TUI-001. #[derive(Debug)] enum OnboardingState { - /// Step 1: Select a model from catalog or enter custom. + /// Step 2: Select a model from the selected provider or enter custom. ModelSelection { + provider: ProviderDraft, items: Vec, state: ScrollState, search_query: String, filtered_indices: Vec, + focus: SelectionFocus, + manage_connection: bool, }, - /// Step 1b: Enter a custom catalog model slug. - CustomModelSlug { input: String, cursor_pos: usize }, - /// Step 2: Select an existing provider or add one. - ProviderSelection { - model: String, + /// Step 2b: Define a custom model that is not in the provider catalog. + CustomModelForm { + provider: ProviderDraft, + model_id: String, display_name: String, + active_field: CustomModelField, + input: String, + cursor_pos: usize, + manage_connection: bool, + }, + /// Step 1: Select an existing provider or add one. + ProviderSelection { items: Vec, selected_idx: usize, + focus: SelectionFocus, + }, + /// Step 1b: Enter a custom provider's connection details. + ProviderSetup { + draft: ProviderDraft, + active_field: InlineField, + input: String, + cursor_pos: usize, + }, + /// Confirm disconnecting an existing provider Connection. + DisconnectConfirmation { provider: ProviderInfo }, + /// Waiting for the server to remove a provider Connection. + Disconnecting { provider_name: String }, + /// Confirm removing one model from a provider Connection. + ModelDeleteConfirmation { + provider: ProviderDraft, + model_id: String, + model_name: String, + }, + /// Waiting for the server to remove one provider Connection model. + ModelDeleting { + provider: ProviderDraft, + model_name: String, + }, + /// Step 3: Configure basic and optional advanced model settings. + ModelSettings { + model: String, + provider: ProviderWireApi, + provider_id: String, + provider_name: String, + provider_credential_id: Option, + base_url: String, + api_key: String, + request_model: String, + display_name: String, + invocation_method: ProviderWireApi, + default_reasoning_effort: Option, + settings: Box, + advanced_open: bool, + active_field: ModelSettingsField, + input: String, + cursor_pos: usize, + settings_error: Option, }, + /// Final confirmation before the server probes and persists the binding. + Review { params: ValidationParams }, /// Steps 3-8: Inline setup for provider vendor and model binding fields. InlineSetup { model: String, provider: ProviderWireApi, + provider_id: String, provider_name: String, provider_credential_id: Option, base_url: String, @@ -157,6 +896,7 @@ enum OnboardingState { InvocationMethod { model: String, provider: ProviderWireApi, + provider_id: String, provider_name: String, provider_credential_id: Option, base_url: String, @@ -165,11 +905,14 @@ enum OnboardingState { display_name: String, items: Vec, selected_idx: usize, + initial_model_settings: Option, + default_reasoning_effort: Option, }, /// Step 10: Select reasoning effort. ReasoningEffort { model: String, provider: ProviderWireApi, + provider_id: String, provider_name: String, provider_credential_id: Option, base_url: String, @@ -179,6 +922,8 @@ enum OnboardingState { invocation_method: ProviderWireApi, items: Vec, selected_idx: usize, + initial_model_settings: Option, + default_reasoning_effort: Option, }, /// Validating connection. Validating { @@ -190,6 +935,7 @@ enum OnboardingState { provider_credential_id: Option, invocation_method: ProviderWireApi, default_reasoning_effort: Option, + model_settings: Option, base_url: Option, api_key: Option, started_at: Instant, @@ -204,6 +950,7 @@ enum OnboardingState { provider_credential_id: Option, invocation_method: ProviderWireApi, default_reasoning_effort: Option, + model_settings: Option, base_url: Option, api_key: Option, bypassed: bool, @@ -215,9 +962,11 @@ enum OnboardingState { request_model: String, display_name: String, provider: ProviderWireApi, + provider_id: String, provider_name: String, provider_credential_id: Option, default_reasoning_effort: Option, + model_settings: Option, base_url: Option, api_key: Option, error_message: String, @@ -229,21 +978,29 @@ enum OnboardingState { #[derive(Debug)] struct ModelSelectionItem { slug: String, + model_id: String, display_name: String, is_custom: bool, + /// Saved model metadata used to prefill the editor when a Connection + /// model is opened again. Built-in directory rows do not need this copy. + initial_settings: Option, + wire_api: Option, + default_reasoning_effort: Option, } #[derive(Debug)] struct ProviderSelectionItem { label: String, description: String, - kind: ProviderSelectionKind, + provider: ProviderInfo, + section: ProviderSelectionSection, + is_custom: bool, } -#[derive(Debug, Clone)] -enum ProviderSelectionKind { - Vendor(ProviderVendor), - AddProvider, +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum ProviderSelectionSection { + Connections, + Templates, } #[derive(Debug)] @@ -264,9 +1021,13 @@ pub(crate) struct OnboardingWidget { state: OnboardingState, complete: bool, result: Option, - /// Models from the catalog, stored so `go_back_to_model_selection` can restore them. + /// Models from the catalog, stored so the provider/model screens can rebuild their lists. original_models: Vec, - provider_vendors: Vec, + providers: Vec, + template_provider_ids: Vec, + connected_provider_ids: Vec, + connection_models: BTreeMap>, + provider_status_known: bool, transcript_events: Vec, app_event_tx: AppEventSender, frame_requester: FrameRequester, @@ -280,44 +1041,205 @@ impl OnboardingWidget { frame_requester: FrameRequester, animations_enabled: bool, ) -> Self { - let items = Self::build_model_items(models); - let filtered_indices = (0..items.len()).collect(); - let mut state = ScrollState::new(); - state.selected_idx = Some(0); + let items = Self::provider_selection_items(&[], &[], &[], false); let this = Self { - state: OnboardingState::ModelSelection { + state: OnboardingState::ProviderSelection { items, - state, - search_query: String::new(), - filtered_indices, + selected_idx: 0, + focus: SelectionFocus::Custom, }, complete: false, result: None, original_models: models.to_vec(), - provider_vendors: Vec::new(), + providers: Vec::new(), + template_provider_ids: Vec::new(), + connected_provider_ids: Vec::new(), + connection_models: BTreeMap::new(), + provider_status_known: false, transcript_events: Vec::new(), app_event_tx, frame_requester, animations_enabled, }; this.app_event_tx - .send(AppEvent::Command(AppCommand::RunUserShellCommand { - command: "provider list".to_string(), - })); + .send(AppEvent::Command(AppCommand::ProviderList)); this } - /// Build `ModelSelectionItem` list from the catalog models. - fn build_model_items(models: &[Model]) -> Vec { - models + /// Build the model list for the selected provider and keep the custom entry visible. + fn build_model_items(models: &[Model], provider_id: &str) -> Vec { + let provider_prefix = format!("{provider_id}/"); + let mut items = models .iter() + .filter(|model| provider_id.is_empty() || model.slug.starts_with(&provider_prefix)) .map(|m| ModelSelectionItem { slug: m.slug.clone(), + model_id: m + .slug + .strip_prefix(&provider_prefix) + .unwrap_or(&m.slug) + .to_string(), display_name: m.display_name.clone(), is_custom: false, + initial_settings: None, + wire_api: None, + default_reasoning_effort: None, + }) + .collect::>(); + if items.is_empty() && !provider_id.is_empty() { + // Test fixtures and older catalog adapters may still expose + // provider models without the provider prefix. Keep those usable + // while prefixed catalog entries remain the canonical form. + items = models + .iter() + .filter(|model| !model.slug.contains('/')) + .map(|m| ModelSelectionItem { + slug: m.slug.clone(), + model_id: m.slug.clone(), + display_name: m.display_name.clone(), + is_custom: false, + initial_settings: None, + wire_api: None, + default_reasoning_effort: None, + }) + .collect(); + } + items.push(ModelSelectionItem { + slug: String::new(), + model_id: String::new(), + display_name: "Add custom model profile".to_string(), + is_custom: true, + initial_settings: None, + wire_api: None, + default_reasoning_effort: None, + }); + items + } + + /// Builds the models explicitly saved on a Connection. Template models + /// are intentionally not mixed into this list. + fn build_connection_model_items( + provider_id: &str, + models: &BTreeMap, + ) -> Vec { + let mut items = models + .iter() + .map(|(model_id, model)| ModelSelectionItem { + slug: format!("{provider_id}/{model_id}"), + model_id: model_id.clone(), + display_name: model.name.clone().unwrap_or_else(|| model_id.clone()), + is_custom: false, + initial_settings: Self::provider_model_settings_value(model), + wire_api: model.wire_api, + default_reasoning_effort: Self::provider_model_default_reasoning_effort(model), + }) + .collect::>(); + items.push(ModelSelectionItem { + slug: String::new(), + model_id: String::new(), + display_name: "Add custom model profile".to_string(), + is_custom: true, + initial_settings: None, + wire_api: None, + default_reasoning_effort: None, + }); + items + } + + /// Converts the protocol projection of a saved Connection model back to + /// the canonical snake_case shape consumed by `ModelSettingsDraft`. + /// + /// The protocol uses camelCase for RPC clients, while the persisted + /// provider catalog intentionally uses snake_case. Keeping this boundary + /// explicit prevents an edit-and-save cycle from silently dropping model + /// metadata or provider-specific extensions. + fn provider_model_settings_value(model: &ProviderModelInfo) -> Option { + let mut object = serde_json::Map::new(); + macro_rules! insert_option { + ($field:ident, $name:literal) => { + if let Some(value) = &model.$field { + object.insert( + $name.to_string(), + serde_json::to_value(value).expect("provider model metadata serializes"), + ); + } + }; + } + + insert_option!(name, "name"); + insert_option!(family, "family"); + insert_option!(release_date, "release_date"); + insert_option!(status, "status"); + insert_option!(capabilities, "capabilities"); + insert_option!(context_window, "context_window"); + insert_option!( + effective_context_window_percent, + "effective_context_window_percent" + ); + insert_option!(max_tokens, "max_tokens"); + insert_option!(temperature, "temperature"); + insert_option!(top_p, "top_p"); + insert_option!(top_k, "top_k"); + insert_option!(reasoning_capability, "reasoning_capability"); + insert_option!(reasoning_implementation, "reasoning_implementation"); + insert_option!(default_reasoning_selection, "default_reasoning_selection"); + insert_option!(base_instructions, "base_instructions"); + insert_option!(input_modalities, "input_modalities"); + insert_option!(channel, "channel"); + insert_option!( + supports_image_detail_original, + "supports_image_detail_original" + ); + insert_option!(truncation_policy, "truncation_policy"); + insert_option!(web_search, "web_search"); + insert_option!(web_fetch, "web_fetch"); + insert_option!(cost, "cost"); + insert_option!(metadata, "metadata"); + insert_option!(request, "request"); + insert_option!(options, "options"); + insert_option!(default_variant, "default_variant"); + insert_option!(enabled, "enabled"); + insert_option!(priority, "priority"); + if !model.headers.is_empty() { + object.insert( + "headers".to_string(), + serde_json::to_value(&model.headers).expect("provider model headers serialize"), + ); + } + if !model.variants.is_empty() { + object.insert( + "variants".to_string(), + serde_json::to_value(&model.variants).expect("provider model variants serialize"), + ); + } + (!object.is_empty()).then_some(serde_json::Value::Object(object)) + } + + fn provider_model_default_reasoning_effort(model: &ProviderModelInfo) -> Option { + model + .default_reasoning_selection + .as_deref() + .map(devo_protocol::normalize_reasoning_effort_literal) + .or_else(|| { + model + .default_reasoning_effort + .map(|effort| effort.label().to_ascii_lowercase()) + }) + .or_else(|| match model.reasoning_capability.as_ref()? { + ReasoningCapability::Unsupported => None, + ReasoningCapability::Toggle => Some("on".to_string()), + ReasoningCapability::Levels(levels) => levels + .iter() + .copied() + .find_map(devo_protocol::ReasoningLevelChoice::effort) + .map(|effort| effort.label().to_ascii_lowercase()) + .or_else(|| { + levels + .first() + .map(|choice| choice.selection_value().to_string()) + }), }) - .collect() } pub(crate) fn take_result(&mut self) -> Option { @@ -341,10 +1263,59 @@ impl OnboardingWidget { self.result = Some(OnboardingResult::Cancelled); } - pub(crate) fn on_provider_vendors_listed(&mut self, provider_vendors: Vec) { - self.provider_vendors = provider_vendors; - if let OnboardingState::ProviderSelection { items, .. } = &mut self.state { - *items = Self::provider_selection_items(&self.provider_vendors); + pub(crate) fn on_providers_listed(&mut self, providers: Vec) { + self.providers = providers; + self.template_provider_ids.clear(); + self.connected_provider_ids.clear(); + self.connection_models.clear(); + self.provider_status_known = false; + if let OnboardingState::ProviderSelection { items, focus, .. } = &mut self.state { + let was_empty = items.is_empty(); + *items = Self::provider_selection_items(&self.providers, &[], &[], false); + if was_empty && !items.is_empty() { + *focus = SelectionFocus::List; + } + } + self.frame_requester.schedule_frame(); + } + + pub(crate) fn on_providers_listed_with_status( + &mut self, + providers: Vec, + template_provider_ids: Vec, + connected_provider_ids: Vec, + ) { + self.on_providers_listed_with_status_and_models( + providers, + template_provider_ids, + connected_provider_ids, + BTreeMap::new(), + ); + } + + pub(crate) fn on_providers_listed_with_status_and_models( + &mut self, + providers: Vec, + template_provider_ids: Vec, + connected_provider_ids: Vec, + connection_models: BTreeMap>, + ) { + self.providers = providers; + self.template_provider_ids = template_provider_ids; + self.connected_provider_ids = connected_provider_ids; + self.connection_models = connection_models; + self.provider_status_known = true; + if let OnboardingState::ProviderSelection { items, focus, .. } = &mut self.state { + let was_empty = items.is_empty(); + *items = Self::provider_selection_items( + &self.providers, + &self.template_provider_ids, + &self.connected_provider_ids, + true, + ); + if was_empty && !items.is_empty() { + *focus = SelectionFocus::List; + } } self.frame_requester.schedule_frame(); } @@ -360,6 +1331,7 @@ impl OnboardingWidget { provider_credential_id, invocation_method, default_reasoning_effort, + model_settings, base_url, api_key, .. @@ -374,6 +1346,7 @@ impl OnboardingWidget { provider_credential_id: provider_credential_id.clone(), invocation_method: *invocation_method, default_reasoning_effort: default_reasoning_effort.clone(), + model_settings: model_settings.clone(), base_url: base_url.clone(), api_key: api_key.clone(), bypassed: false, @@ -382,7 +1355,11 @@ impl OnboardingWidget { } } - pub(crate) fn on_provider_saved(&mut self, model_binding: Option<&ProviderModelBinding>) { + pub(crate) fn on_provider_upserted( + &mut self, + provider: &devo_protocol::ProviderInfo, + default_model: Option<&str>, + ) { if let OnboardingState::Saving { model_slug, request_model, @@ -391,14 +1368,27 @@ impl OnboardingWidget { .. } = &self.state { - let result_model_slug = model_binding - .map(|binding| binding.model_slug.clone()) + let model_prefix = format!("{}/", provider.id); + let result_model_slug = default_model + .map(|model| { + model + .strip_prefix(&model_prefix) + .unwrap_or(model) + .to_string() + }) .unwrap_or_else(|| model_slug.clone()); - let result_request_model = model_binding - .map(|binding| binding.request_model.clone()) + let result_request_model = default_model + .map(|model| { + model + .strip_prefix(&model_prefix) + .unwrap_or(model) + .to_string() + }) .unwrap_or_else(|| request_model.clone()); - let result_display_name = model_binding - .and_then(|binding| binding.display_name.clone()) + let result_display_name = provider + .models + .get(&result_model_slug) + .and_then(|model| model.name.clone()) .unwrap_or_else(|| display_name.clone()); self.result = Some(if *bypassed { OnboardingResult::ValidationBypassed { @@ -417,15 +1407,74 @@ impl OnboardingWidget { } } - pub(crate) fn on_provider_save_failed(&mut self, error_message: String) { - if let OnboardingState::Saving { - model_slug, - request_model, - display_name, - invocation_method, + pub(crate) fn on_provider_disconnected(&mut self, provider_id: &str) { + self.connected_provider_ids + .retain(|connected_id| connected_id != provider_id); + self.connection_models.remove(provider_id); + if matches!(&self.state, OnboardingState::Disconnecting { .. }) { + self.state = OnboardingState::ProviderSelection { + items: Self::provider_selection_items( + &self.providers, + &self.template_provider_ids, + &self.connected_provider_ids, + self.provider_status_known, + ), + selected_idx: 0, + focus: SelectionFocus::List, + }; + self.app_event_tx + .send(AppEvent::Command(AppCommand::ProviderList)); + } + self.frame_requester.schedule_frame(); + } + + pub(crate) fn on_provider_model_removed(&mut self, provider_id: &str, model_id: &str) { + if let Some(models) = self.connection_models.get_mut(provider_id) { + models.remove(model_id); + } + if let OnboardingState::ModelDeleting { provider, .. } = &self.state { + let provider = provider.clone(); + self.state = self.model_selection_state_for_connection(provider); + self.app_event_tx + .send(AppEvent::Command(AppCommand::ProviderList)); + } + self.frame_requester.schedule_frame(); + } + + pub(crate) fn on_provider_model_remove_failed(&mut self) { + if let OnboardingState::ModelDeleting { provider, .. } = &self.state { + self.state = self.model_selection_state_for_connection(provider.clone()); + } + self.frame_requester.schedule_frame(); + } + + pub(crate) fn on_provider_disconnect_failed(&mut self) { + if matches!(&self.state, OnboardingState::Disconnecting { .. }) { + self.state = OnboardingState::ProviderSelection { + items: Self::provider_selection_items( + &self.providers, + &self.template_provider_ids, + &self.connected_provider_ids, + self.provider_status_known, + ), + selected_idx: 0, + focus: SelectionFocus::List, + }; + } + self.frame_requester.schedule_frame(); + } + + pub(crate) fn on_provider_save_failed(&mut self, error_message: String) { + if let OnboardingState::Saving { + model_slug, + request_model, + display_name, + invocation_method, + provider_id, provider_name, provider_credential_id, default_reasoning_effort, + model_settings, base_url, api_key, .. @@ -437,9 +1486,11 @@ impl OnboardingWidget { request_model: request_model.clone(), display_name: display_name.clone(), provider: *invocation_method, + provider_id: provider_id.clone(), provider_name: provider_name.clone(), provider_credential_id: provider_credential_id.clone(), default_reasoning_effort: default_reasoning_effort.clone(), + model_settings: model_settings.clone(), base_url: base_url.clone(), api_key: api_key.clone(), error_message, @@ -460,9 +1511,11 @@ impl OnboardingWidget { request_model, display_name, invocation_method, + provider_id, provider_name, provider_credential_id, default_reasoning_effort, + model_settings, base_url, api_key, .. @@ -473,9 +1526,11 @@ impl OnboardingWidget { request_model: request_model.clone(), display_name: display_name.clone(), provider: *invocation_method, + provider_id: provider_id.clone(), provider_name: provider_name.clone(), provider_credential_id: provider_credential_id.clone(), default_reasoning_effort: default_reasoning_effort.clone(), + model_settings: model_settings.clone(), base_url: base_url.clone(), api_key: api_key.clone(), error_message, @@ -492,22 +1547,39 @@ impl OnboardingWidget { state, search_query, filtered_indices, + focus, + .. } => { - search_query.push_str(&text); - Self::model_apply_filter(items, search_query, filtered_indices, state); + if *focus == SelectionFocus::List { + search_query.push_str(&text); + Self::model_apply_filter(items, search_query, filtered_indices, state); + } + } + OnboardingState::CustomModelForm { + input, cursor_pos, .. + } + | OnboardingState::ProviderSetup { + input, cursor_pos, .. + } + | OnboardingState::ModelSettings { + input, cursor_pos, .. } - OnboardingState::CustomModelSlug { input, cursor_pos } | OnboardingState::InlineSetup { input, cursor_pos, .. } => { Self::insert_at_cursor(input, cursor_pos, &text); } OnboardingState::ProviderSelection { .. } + | OnboardingState::DisconnectConfirmation { .. } + | OnboardingState::Disconnecting { .. } + | OnboardingState::ModelDeleteConfirmation { .. } + | OnboardingState::ModelDeleting { .. } | OnboardingState::InvocationMethod { .. } | OnboardingState::ReasoningEffort { .. } | OnboardingState::Validating { .. } | OnboardingState::Saving { .. } - | OnboardingState::ValidationFailed { .. } => {} + | OnboardingState::ValidationFailed { .. } + | OnboardingState::Review { .. } => {} } } @@ -559,6 +1631,28 @@ impl OnboardingWidget { } } + fn provider_id_from_name(name: &str) -> String { + let mut id = String::new(); + let mut previous_separator = false; + for ch in name.trim().chars() { + if ch.is_ascii_alphanumeric() { + id.push(ch.to_ascii_lowercase()); + previous_separator = false; + } else if !previous_separator && !id.is_empty() { + id.push('-'); + previous_separator = true; + } + } + let id = id.trim_matches('-').to_string(); + if id.is_empty() { + // Keep non-ASCII names stable too; using one shared fallback would + // make two custom providers overwrite each other in the catalog. + name.trim().to_string() + } else { + id + } + } + fn provider_display_name(provider: ProviderWireApi) -> &'static str { match provider { ProviderWireApi::AnthropicMessages => "Anthropic", @@ -588,6 +1682,26 @@ impl OnboardingWidget { }) } + fn reasoning_capability_from_settings( + settings: Option<&serde_json::Value>, + ) -> Option { + settings + .and_then(serde_json::Value::as_object) + .and_then(|object| object.get("reasoning_capability")) + .and_then(|value| serde_json::from_value(value.clone()).ok()) + } + + fn model_supports_reasoning_with_settings( + &self, + slug: &str, + settings: Option<&serde_json::Value>, + ) -> bool { + Self::reasoning_capability_from_settings(settings).map_or_else( + || self.model_supports_reasoning(slug), + |capability| !matches!(capability, ReasoningCapability::Unsupported), + ) + } + fn reasoning_effort_items(&self, slug: &str) -> Vec { self.model_by_slug(slug) .map(|model| model.effective_reasoning_capability().options()) @@ -597,6 +1711,22 @@ impl OnboardingWidget { .collect() } + fn reasoning_effort_items_with_settings( + &self, + slug: &str, + settings: Option<&serde_json::Value>, + ) -> Vec { + if let Some(capability) = Self::reasoning_capability_from_settings(settings) { + capability + .options() + .into_iter() + .map(Self::reasoning_effort_item) + .collect() + } else { + self.reasoning_effort_items(slug) + } + } + fn reasoning_effort_item(option: ReasoningEffortOption) -> ReasoningEffortItem { ReasoningEffortItem { label: option.label, @@ -612,6 +1742,37 @@ impl OnboardingWidget { .unwrap_or(0) } + fn default_reasoning_effort_index_with_settings( + &self, + slug: &str, + settings: Option<&serde_json::Value>, + default_reasoning_effort: Option<&str>, + items: &[ReasoningEffortItem], + ) -> usize { + let selection = default_reasoning_effort.map(str::to_string).or_else(|| { + Self::reasoning_capability_from_settings(settings).and_then(|capability| { + match capability { + ReasoningCapability::Unsupported => None, + ReasoningCapability::Toggle => Some("on".to_string()), + ReasoningCapability::Levels(levels) => levels + .iter() + .copied() + .find_map(devo_protocol::ReasoningLevelChoice::effort) + .map(|effort| effort.label().to_ascii_lowercase()) + .or_else(|| { + levels + .first() + .map(|choice| choice.selection_value().to_string()) + }), + } + }) + }); + selection + .as_deref() + .and_then(|value| items.iter().position(|item| item.value == value)) + .unwrap_or_else(|| self.default_reasoning_effort_index(slug, items)) + } + fn invocation_method_selection_index( provider: ProviderWireApi, items: &[InvocationMethodItem], @@ -630,20 +1791,196 @@ impl OnboardingWidget { .unwrap_or_else(|| provider.as_str().to_string()) } - fn go_back_to_model_selection(&mut self) { - let items = Self::build_model_items(&self.original_models); + fn go_back_to_provider_selection(&mut self) { + let items = Self::provider_selection_items( + &self.providers, + &self.template_provider_ids, + &self.connected_provider_ids, + self.provider_status_known, + ); + self.state = OnboardingState::ProviderSelection { + items, + selected_idx: 0, + focus: if self.providers.is_empty() { + SelectionFocus::Custom + } else { + SelectionFocus::List + }, + }; + } + + fn model_selection_state(models: &[Model], provider: ProviderDraft) -> OnboardingState { + let items = Self::build_model_items(models, &provider.provider_id); + Self::model_selection_state_with_items(provider, items, false) + } + + fn model_selection_state_for_connection(&self, provider: ProviderDraft) -> OnboardingState { + let items = if let Some(models) = self.connection_models.get(&provider.provider_id) { + Self::build_connection_model_items(&provider.provider_id, models) + } else if self.provider_status_known { + Self::build_connection_model_items(&provider.provider_id, &BTreeMap::new()) + } else { + Self::build_model_items(&self.original_models, &provider.provider_id) + }; + Self::model_selection_state_with_items(provider, items, true) + } + + fn model_selection_state_with_items( + provider: ProviderDraft, + items: Vec, + manage_connection: bool, + ) -> OnboardingState { let filtered_indices = (0..items.len()).collect(); let mut state = ScrollState::new(); state.selected_idx = Some(0); - self.state = OnboardingState::ModelSelection { + OnboardingState::ModelSelection { + provider, items, state, search_query: String::new(), filtered_indices, + focus: SelectionFocus::List, + manage_connection, + } + } + + fn model_configuration_state_with_request_model( + provider: ProviderDraft, + model: String, + request_model: String, + display_name: String, + ) -> OnboardingState { + Self::model_configuration_state_with_initial_settings( + provider, + model, + request_model, + display_name, + None, + None, + None, + ) + } + + fn model_configuration_state_with_initial_settings( + provider: ProviderDraft, + model: String, + request_model: String, + display_name: String, + initial_model_settings: Option, + model_wire_api: Option, + default_reasoning_effort: Option, + ) -> OnboardingState { + let items = Self::invocation_method_items(); + let selected_idx = Self::invocation_method_selection_index( + model_wire_api.unwrap_or(provider.provider), + &items, + ); + OnboardingState::InvocationMethod { + model, + provider: provider.provider, + provider_id: provider.provider_id, + provider_name: provider.provider_name, + provider_credential_id: provider.provider_credential_id, + base_url: provider.base_url, + api_key: provider.api_key, + request_model, + display_name, + items, + selected_idx, + initial_model_settings, + default_reasoning_effort, + } + } + + fn inline_setup_state( + provider: ProviderDraft, + model: String, + display_name: String, + ) -> OnboardingState { + let request_model = model + .split_once('/') + .map_or_else(|| model.clone(), |(_, model_id)| model_id.to_string()); + let (active_field, input) = if provider.base_url.trim().is_empty() { + (InlineField::BaseUrl, provider.base_url.clone()) + } else { + (InlineField::RequestModel, request_model.clone()) }; + Self::inline_setup_state_with_values_and_field( + provider, + model, + request_model, + display_name, + active_field, + input, + ) + } + + fn inline_setup_state_with_values_and_field( + provider: ProviderDraft, + model: String, + request_model: String, + display_name: String, + active_field: InlineField, + input: String, + ) -> OnboardingState { + let cursor_pos = Self::char_count(&input); + OnboardingState::InlineSetup { + model, + provider: provider.provider, + provider_id: provider.provider_id, + provider_name: provider.provider_name, + provider_credential_id: provider.provider_credential_id, + base_url: provider.base_url, + api_key: provider.api_key, + request_model, + display_name, + active_field, + input, + cursor_pos, + } + } + + #[allow(clippy::too_many_arguments)] + fn model_settings_state( + model: String, + provider: ProviderWireApi, + provider_id: String, + provider_name: String, + provider_credential_id: Option, + base_url: String, + api_key: String, + request_model: String, + display_name: String, + invocation_method: ProviderWireApi, + initial_model_settings: Option, + default_reasoning_effort: Option, + ) -> OnboardingState { + OnboardingState::ModelSettings { + model, + provider, + provider_id, + provider_name, + provider_credential_id, + base_url, + api_key, + request_model, + settings: Box::new(ModelSettingsDraft::from_value( + initial_model_settings.as_ref(), + &display_name, + )), + display_name: display_name.clone(), + invocation_method, + default_reasoning_effort, + advanced_open: false, + active_field: ModelSettingsField::DisplayName, + input: display_name.clone(), + cursor_pos: Self::char_count(&display_name), + settings_error: None, + } } } +#[derive(Debug, Clone)] struct ValidationParams { model_slug: String, request_model: String, @@ -653,6 +1990,7 @@ struct ValidationParams { provider_credential_id: Option, invocation_method: ProviderWireApi, default_reasoning_effort: Option, + model_settings: Option, base_url: Option, api_key: Option, } @@ -695,37 +2033,70 @@ impl OnboardingWidget { }); } + fn provider_info_from_validation( + params: &ValidationParams, + display_name: &str, + ) -> (ProviderInfo, String) { + let model_prefix = format!("{}/", params.provider_id); + let model_id = params + .request_model + .strip_prefix(&model_prefix) + .unwrap_or(params.request_model.as_str()) + .to_string(); + let mut model = params + .model_settings + .as_ref() + .and_then(|settings| serde_json::from_value::(settings.clone()).ok()) + .unwrap_or_default(); + model.name = Some(display_name.to_string()); + model.wire_api = Some(params.invocation_method); + model.default_reasoning_selection = params.default_reasoning_effort.clone(); + model.default_reasoning_effort = params + .default_reasoning_effort + .as_deref() + .and_then(|value| value.parse().ok()); + + let provider = ProviderInfo { + id: params.provider_id.clone(), + name: params.provider_name.clone(), + description: None, + base_url: params.base_url.clone(), + credential: params.provider_credential_id.clone(), + headers: BTreeMap::new(), + options: None, + request: None, + wire_apis: vec![params.invocation_method], + models: [(model_id.clone(), model)].into_iter().collect(), + enabled: true, + }; + (provider, model_id) + } + fn start_validation(&mut self, params: ValidationParams) { let display_name = self.validation_display_name(¶ms); + let (provider, model_id) = Self::provider_info_from_validation(¶ms, &display_name); self.state = OnboardingState::Validating { model_slug: params.model_slug.clone(), request_model: params.request_model.clone(), - display_name: display_name.clone(), + display_name, provider_id: params.provider_id.clone(), provider_name: params.provider_name.clone(), provider_credential_id: params.provider_credential_id.clone(), invocation_method: params.invocation_method, default_reasoning_effort: params.default_reasoning_effort.clone(), + model_settings: params.model_settings.clone(), base_url: params.base_url.clone(), api_key: params.api_key.clone(), started_at: Instant::now(), }; - let payload = serde_json::json!({ - "model_slug": params.model_slug, - "request_model": params.request_model, - "display_name": display_name, - "provider_id": params.provider_id, - "provider_name": params.provider_name, - "provider_credential_id": params.provider_credential_id, - "invocation_method": params.invocation_method, - "default_reasoning_effort": params.default_reasoning_effort, - "base_url": params.base_url, - "api_key": params.api_key, - }); self.app_event_tx - .send(AppEvent::Command(AppCommand::RunUserShellCommand { - command: format!("onboard {payload}"), + .send(AppEvent::Command(AppCommand::ProviderValidate { + params: devo_protocol::native::rpc_admin::ProviderValidateParams { + provider, + model: model_id, + api_key: params.api_key, + }, })); } @@ -733,73 +2104,151 @@ impl OnboardingWidget { fn model_selection_handle_key(&mut self, key: KeyEvent) { let OnboardingState::ModelSelection { + provider, items, state, search_query, filtered_indices, + focus, + manage_connection, } = &mut self.state else { return; }; match key.code { - KeyCode::Up | KeyCode::Char('p') if key.modifiers.contains(KeyModifiers::CONTROL) => { + KeyCode::Tab => { + *focus = match focus { + SelectionFocus::List => SelectionFocus::Custom, + SelectionFocus::Custom => SelectionFocus::List, + }; + } + KeyCode::Left | KeyCode::Right => { + *focus = match focus { + SelectionFocus::List => SelectionFocus::Custom, + SelectionFocus::Custom => SelectionFocus::List, + }; + } + KeyCode::Up | KeyCode::Char('p') + if key.modifiers.contains(KeyModifiers::CONTROL) + && *focus == SelectionFocus::List => + { Self::model_move_up(state, filtered_indices); } - KeyCode::Up => { + KeyCode::Up if *focus == SelectionFocus::List => { Self::model_move_up(state, filtered_indices); } - KeyCode::Char('k') if key.modifiers.is_empty() => { + KeyCode::Char('k') if key.modifiers.is_empty() && *focus == SelectionFocus::List => { Self::model_move_up(state, filtered_indices); } - KeyCode::Down | KeyCode::Char('n') if key.modifiers.contains(KeyModifiers::CONTROL) => { + KeyCode::Down | KeyCode::Char('n') + if key.modifiers.contains(KeyModifiers::CONTROL) + && *focus == SelectionFocus::List => + { Self::model_move_down(state, filtered_indices); } - KeyCode::Down => { + KeyCode::Down if *focus == SelectionFocus::List => { Self::model_move_down(state, filtered_indices); } - KeyCode::Char('j') if key.modifiers.is_empty() => { + KeyCode::Char('j') if key.modifiers.is_empty() && *focus == SelectionFocus::List => { Self::model_move_down(state, filtered_indices); } + KeyCode::Char('d') if key.modifiers.is_empty() && *focus == SelectionFocus::List => { + if *manage_connection + && let Some(visible_idx) = state.selected_idx + && let Some(&actual_idx) = filtered_indices.get(visible_idx) + && let Some(item) = items.get(actual_idx) + && !item.is_custom + { + self.state = OnboardingState::ModelDeleteConfirmation { + provider: provider.clone(), + model_id: item.model_id.clone(), + model_name: item.display_name.clone(), + }; + } + } + KeyCode::Delete if *focus == SelectionFocus::List => { + if *manage_connection + && let Some(visible_idx) = state.selected_idx + && let Some(&actual_idx) = filtered_indices.get(visible_idx) + && let Some(item) = items.get(actual_idx) + && !item.is_custom + { + self.state = OnboardingState::ModelDeleteConfirmation { + provider: provider.clone(), + model_id: item.model_id.clone(), + model_name: item.display_name.clone(), + }; + } + } KeyCode::Char(c) - if key.modifiers.is_empty() || key.modifiers.contains(KeyModifiers::SHIFT) => + if *focus == SelectionFocus::List + && (key.modifiers.is_empty() + || key.modifiers.contains(KeyModifiers::SHIFT)) => { search_query.push(c); Self::model_apply_filter(items, search_query, filtered_indices, state); } - KeyCode::Backspace => { + KeyCode::Backspace if *focus == SelectionFocus::List => { search_query.pop(); Self::model_apply_filter(items, search_query, filtered_indices, state); } KeyCode::Enter => { - if let Some(visible_idx) = state.selected_idx + if *focus == SelectionFocus::Custom { + let provider = provider.clone(); + self.state = OnboardingState::CustomModelForm { + provider, + model_id: String::new(), + display_name: String::new(), + active_field: CustomModelField::ModelId, + input: String::new(), + cursor_pos: 0, + manage_connection: *manage_connection, + }; + } else if let Some(visible_idx) = state.selected_idx && let Some(&actual_idx) = filtered_indices.get(visible_idx) && let Some(item) = items.get(actual_idx) { if item.is_custom { - self.state = OnboardingState::CustomModelSlug { + self.state = OnboardingState::CustomModelForm { + provider: provider.clone(), + model_id: String::new(), + display_name: String::new(), + active_field: CustomModelField::ModelId, input: String::new(), cursor_pos: 0, + manage_connection: *manage_connection, }; } else { let slug = item.slug.clone(); + let model_id = item.model_id.clone(); + let display_name = item.display_name.clone(); + let initial_model_settings = item.initial_settings.clone(); + let model_wire_api = item.wire_api; + let default_reasoning_effort = item.default_reasoning_effort.clone(); self.transcript_events .push(OnboardingTranscriptEvent::ModelSelected { model_slug: slug.clone(), - display_name: item.display_name.clone(), + display_name: display_name.clone(), }); - self.state = OnboardingState::ProviderSelection { - model: slug, - display_name: item.display_name.clone(), - items: Self::provider_selection_items(&self.provider_vendors), - selected_idx: 0, + self.state = if self.provider_status_known { + Self::model_configuration_state_with_initial_settings( + provider.clone(), + slug, + model_id, + display_name, + initial_model_settings, + model_wire_api, + default_reasoning_effort, + ) + } else { + Self::inline_setup_state(provider.clone(), slug, display_name) }; } } } KeyCode::Esc => { - self.complete = true; - self.result = Some(OnboardingResult::Cancelled); + self.go_back_to_provider_selection(); } _ => {} } @@ -853,8 +2302,20 @@ impl OnboardingWidget { state.scroll_top = 0; } - fn custom_model_slug_handle_key(&mut self, key: KeyEvent) { - let OnboardingState::CustomModelSlug { input, cursor_pos } = &mut self.state else { + fn custom_model_form_handle_key(&mut self, key: KeyEvent) { + let connection_models = self.connection_models.clone(); + let original_models = self.original_models.clone(); + let provider_status_known = self.provider_status_known; + let OnboardingState::CustomModelForm { + provider, + model_id, + display_name, + active_field, + input, + cursor_pos, + manage_connection, + } = &mut self.state + else { return; }; @@ -886,153 +2347,403 @@ impl OnboardingWidget { KeyCode::End => { *cursor_pos = Self::char_count(input); } - KeyCode::Enter => { - let model = input.trim().to_string(); - if model.is_empty() { - return; + KeyCode::Enter | KeyCode::Tab => match active_field { + CustomModelField::ModelId => { + if input.trim().is_empty() { + return; + } + *model_id = input.trim().to_string(); + *active_field = CustomModelField::DisplayName; + input.clear(); + *cursor_pos = 0; } - self.transcript_events - .push(OnboardingTranscriptEvent::ModelSelected { - model_slug: model.clone(), - display_name: model.clone(), - }); - self.state = OnboardingState::ProviderSelection { - model: model.clone(), - display_name: model, - items: Self::provider_selection_items(&self.provider_vendors), - selected_idx: 0, - }; - } - KeyCode::Esc => { - self.go_back_to_model_selection(); - } + CustomModelField::DisplayName => { + *display_name = input.trim().to_string(); + let model = model_id.clone(); + let display_name = if display_name.trim().is_empty() { + model.clone() + } else { + display_name.clone() + }; + self.transcript_events + .push(OnboardingTranscriptEvent::ModelSelected { + model_slug: model.clone(), + display_name: display_name.clone(), + }); + self.state = if provider_status_known { + Self::model_configuration_state_with_request_model( + provider.clone(), + model.clone(), + model, + display_name, + ) + } else { + Self::inline_setup_state_with_values_and_field( + provider.clone(), + model.clone(), + model, + display_name.clone(), + InlineField::DisplayName, + display_name, + ) + }; + } + }, + KeyCode::Esc => match active_field { + CustomModelField::DisplayName => { + *active_field = CustomModelField::ModelId; + *input = model_id.clone(); + *cursor_pos = Self::char_count(input); + } + CustomModelField::ModelId => { + self.state = if *manage_connection { + let items = connection_models + .get(&provider.provider_id) + .map(|models| { + Self::build_connection_model_items(&provider.provider_id, models) + }) + .or_else(|| { + provider_status_known.then(|| { + Self::build_connection_model_items( + &provider.provider_id, + &BTreeMap::new(), + ) + }) + }) + .unwrap_or_else(|| { + Self::build_model_items(&original_models, &provider.provider_id) + }); + Self::model_selection_state_with_items(provider.clone(), items, true) + } else { + Self::model_selection_state(&original_models, provider.clone()) + }; + } + }, _ => {} } } - fn provider_selection_items(provider_vendors: &[ProviderVendor]) -> Vec { - let mut items = provider_vendors - .iter() - .cloned() - .map(|provider_vendor| { - let description = provider_vendor + fn provider_selection_items( + providers: &[ProviderInfo], + template_provider_ids: &[String], + connected_provider_ids: &[String], + status_known: bool, + ) -> Vec { + let mut items = Vec::new(); + let mut add_item = + |provider: &ProviderInfo, section: ProviderSelectionSection, is_custom: bool| { + let endpoint = provider .base_url .clone() - .unwrap_or_else(|| "Configured provider vendor".to_string()); - ProviderSelectionItem { - label: provider_vendor.name.clone(), + .unwrap_or_else(|| "Endpoint required".to_string()); + let description = match section { + ProviderSelectionSection::Connections => { + format!("Saved Connection · {endpoint}") + } + ProviderSelectionSection::Templates => { + format!("Read-only template · {endpoint}") + } + }; + items.push(ProviderSelectionItem { + label: provider.name.clone(), description, - kind: ProviderSelectionKind::Vendor(provider_vendor), - } - }) - .collect::>(); - items.push(ProviderSelectionItem { - label: "Add provider...".to_string(), - description: "Enter custom provider settings".to_string(), - kind: ProviderSelectionKind::AddProvider, - }); + provider: provider.clone(), + section, + is_custom, + }); + }; + + for provider in providers { + let is_connected = connected_provider_ids + .iter() + .any(|provider_id| provider_id == &provider.id); + let is_template = template_provider_ids + .iter() + .any(|provider_id| provider_id == &provider.id); + if status_known && is_connected { + add_item( + provider, + ProviderSelectionSection::Connections, + !is_template, + ); + } + } + for provider in providers { + let is_connected = connected_provider_ids + .iter() + .any(|provider_id| provider_id == &provider.id); + let is_template = template_provider_ids + .iter() + .any(|provider_id| provider_id == &provider.id); + if !status_known || is_template { + add_item(provider, ProviderSelectionSection::Templates, !is_template); + } else if status_known && !is_connected { + add_item(provider, ProviderSelectionSection::Templates, true); + } + } items } fn provider_selection_handle_key(&mut self, key: KeyEvent) { let OnboardingState::ProviderSelection { - model, - display_name, items, selected_idx, + focus, } = &mut self.state else { return; }; match key.code { - KeyCode::Up => { + KeyCode::Tab | KeyCode::Left | KeyCode::Right => { + *focus = match focus { + SelectionFocus::List => SelectionFocus::Custom, + SelectionFocus::Custom => SelectionFocus::List, + }; + } + KeyCode::Up if *focus == SelectionFocus::List && !items.is_empty() => { *selected_idx = if *selected_idx == 0 { items.len() - 1 } else { *selected_idx - 1 }; } - KeyCode::Down => { + KeyCode::Down if *focus == SelectionFocus::List && !items.is_empty() => { *selected_idx = (*selected_idx + 1) % items.len(); } - KeyCode::Enter => { + KeyCode::Enter if *focus == SelectionFocus::Custom => { + self.transcript_events + .push(OnboardingTranscriptEvent::ProviderSelected { + provider_name: "Custom provider".to_string(), + base_url: None, + credential_summary: "new provider credentials".to_string(), + }); + self.state = OnboardingState::ProviderSetup { + draft: ProviderDraft::default(), + active_field: InlineField::ProviderName, + input: String::new(), + cursor_pos: 0, + }; + } + KeyCode::Enter if *focus == SelectionFocus::List => { if let Some(item) = items.get(*selected_idx) { - let model_slug = model.clone(); - let selected_display_name = display_name.clone(); - match &item.kind { - ProviderSelectionKind::Vendor(provider_vendor) => { - let provider = provider_vendor - .wire_apis - .first() - .copied() - .unwrap_or_else(|| Self::infer_provider(&model_slug)); - let base_url = provider_vendor.base_url.clone().unwrap_or_default(); - self.transcript_events.push( - OnboardingTranscriptEvent::ProviderSelected { - provider_name: provider_vendor.name.clone(), - base_url: provider_vendor.base_url.clone(), - credential_summary: Self::credential_summary( - provider_vendor.credential.as_deref(), - None, - ), - }, - ); - let (active_field, input, cursor_pos) = if base_url.trim().is_empty() { - ( - InlineField::BaseUrl, - base_url.clone(), - Self::char_count(&base_url), - ) + let vendor = &item.provider; + let provider = vendor + .wire_apis + .first() + .copied() + .unwrap_or_else(|| Self::infer_provider(vendor.name.as_str())); + let provider_id = if vendor.id.trim().is_empty() { + vendor.name.clone() + } else { + vendor.id.clone() + }; + self.transcript_events + .push(OnboardingTranscriptEvent::ProviderSelected { + provider_name: vendor.name.clone(), + base_url: vendor.base_url.clone(), + credential_summary: Self::credential_summary( + vendor.credential.as_deref(), + None, + ), + }); + let is_connection = item.section == ProviderSelectionSection::Connections; + let is_custom = item.is_custom; + let provider = ProviderDraft { + provider, + provider_id, + provider_name: vendor.name.clone(), + provider_credential_id: vendor.credential.clone(), + base_url: vendor.base_url.clone().unwrap_or_default(), + api_key: String::new(), + is_custom, + }; + if is_connection { + self.state = self.model_selection_state_for_connection(provider); + return; + } + let input = provider.base_url.clone(); + if self.provider_status_known { + self.state = OnboardingState::ProviderSetup { + draft: provider, + active_field: if is_custom { + InlineField::BaseUrl } else { - ( - InlineField::RequestModel, - model_slug.clone(), - Self::char_count(&model_slug), - ) - }; - self.state = OnboardingState::InlineSetup { - model: model_slug.clone(), - provider, - provider_name: provider_vendor.name.clone(), - provider_credential_id: provider_vendor.credential.clone(), - base_url, - api_key: String::new(), - request_model: model_slug.clone(), - display_name: selected_display_name, - active_field, - input, - cursor_pos, - }; - } - ProviderSelectionKind::AddProvider => { - self.transcript_events.push( - OnboardingTranscriptEvent::ProviderSelected { - provider_name: "Add provider...".to_string(), - base_url: None, - credential_summary: "new provider credentials".to_string(), - }, - ); - self.state = OnboardingState::InlineSetup { - model: model_slug.clone(), - provider: ProviderWireApi::OpenAIChatCompletions, - provider_name: String::new(), - provider_credential_id: None, - base_url: String::new(), - api_key: String::new(), - request_model: model_slug.clone(), - display_name: selected_display_name, - active_field: InlineField::ProviderName, - input: String::new(), - cursor_pos: 0, - }; - } + InlineField::ApiKey + }, + cursor_pos: Self::char_count(&input), + input: if is_custom { input } else { String::new() }, + }; + } else { + let models = self.original_models.clone(); + self.state = Self::model_selection_state(&models, provider); } } } + KeyCode::Char('d') if key.modifiers.is_empty() && *focus == SelectionFocus::List => { + if let Some(item) = items.get(*selected_idx) + && item.section == ProviderSelectionSection::Connections + && self.provider_status_known + { + self.state = OnboardingState::DisconnectConfirmation { + provider: item.provider.clone(), + }; + } + } + KeyCode::Delete if *focus == SelectionFocus::List => { + if let Some(item) = items.get(*selected_idx) + && item.section == ProviderSelectionSection::Connections + && self.provider_status_known + { + self.state = OnboardingState::DisconnectConfirmation { + provider: item.provider.clone(), + }; + } + } + KeyCode::Esc => { + self.complete = true; + self.result = Some(OnboardingResult::Cancelled); + } + _ => {} + } + } + + fn disconnect_confirmation_handle_key(&mut self, key: KeyEvent) { + let OnboardingState::DisconnectConfirmation { provider } = &self.state else { + return; + }; + match key.code { + KeyCode::Enter => { + let provider_id = provider.id.clone(); + let provider_name = provider.name.clone(); + self.state = OnboardingState::Disconnecting { provider_name }; + self.app_event_tx + .send(AppEvent::Command(AppCommand::DisconnectProvider { + provider_id, + })); + } + KeyCode::Esc => self.go_back_to_provider_selection(), + _ => {} + } + } + + fn model_delete_confirmation_handle_key(&mut self, key: KeyEvent) { + let OnboardingState::ModelDeleteConfirmation { + provider, + model_id, + model_name, + } = &self.state + else { + return; + }; + match key.code { + KeyCode::Enter => { + let provider_id = provider.provider_id.clone(); + let model_id = model_id.clone(); + let provider = provider.clone(); + let model_name = model_name.clone(); + self.state = OnboardingState::ModelDeleting { + provider, + model_name, + }; + self.app_event_tx + .send(AppEvent::Command(AppCommand::RemoveProviderModel { + provider_id, + model_id, + })); + } KeyCode::Esc => { - self.go_back_to_model_selection(); + let provider = provider.clone(); + self.state = self.model_selection_state_for_connection(provider); + } + _ => {} + } + } + + fn provider_setup_handle_key(&mut self, key: KeyEvent) { + let OnboardingState::ProviderSetup { + draft, + active_field, + input, + cursor_pos, + } = &mut self.state + else { + return; + }; + + match key.code { + KeyCode::Char(c) + if key.modifiers.is_empty() || key.modifiers.contains(KeyModifiers::SHIFT) => + { + Self::insert_at_cursor(input, cursor_pos, &c.to_string()); } + KeyCode::Backspace => Self::remove_char_before_cursor(input, cursor_pos), + KeyCode::Delete => Self::remove_char_at_cursor(input, *cursor_pos), + KeyCode::Left => *cursor_pos = cursor_pos.saturating_sub(1), + KeyCode::Right => *cursor_pos = (*cursor_pos + 1).min(Self::char_count(input)), + KeyCode::Home => *cursor_pos = 0, + KeyCode::End => *cursor_pos = Self::char_count(input), + KeyCode::Enter => match active_field { + InlineField::ProviderName => { + if !draft.is_custom { + return; + } + if input.trim().is_empty() { + return; + } + draft.provider_name = input.trim().to_string(); + *active_field = InlineField::BaseUrl; + input.clear(); + *cursor_pos = 0; + } + InlineField::BaseUrl => { + if !draft.is_custom { + *active_field = InlineField::ApiKey; + input.clear(); + *cursor_pos = 0; + return; + } + if input.trim().is_empty() { + return; + } + draft.base_url = input.trim().to_string(); + *active_field = InlineField::ApiKey; + input.clear(); + *cursor_pos = 0; + } + InlineField::ApiKey => { + draft.api_key = input.trim().to_string(); + if draft.provider_id.trim().is_empty() { + draft.provider_id = Self::provider_id_from_name(&draft.provider_name); + } + let draft = draft.clone(); + let models = self.original_models.clone(); + self.state = Self::model_selection_state(&models, draft); + } + InlineField::RequestModel | InlineField::DisplayName => {} + }, + KeyCode::Esc => match active_field { + InlineField::ProviderName => self.go_back_to_provider_selection(), + InlineField::BaseUrl => { + if draft.is_custom { + *active_field = InlineField::ProviderName; + *input = draft.provider_name.clone(); + *cursor_pos = Self::char_count(input); + } else { + self.go_back_to_provider_selection(); + } + } + InlineField::ApiKey => { + if draft.is_custom { + *active_field = InlineField::BaseUrl; + *input = draft.base_url.clone(); + *cursor_pos = Self::char_count(input); + } else { + self.go_back_to_provider_selection(); + } + } + InlineField::RequestModel | InlineField::DisplayName => {} + }, _ => {} } } @@ -1043,6 +2754,7 @@ impl OnboardingWidget { let OnboardingState::InlineSetup { model, provider, + provider_id, provider_name, provider_credential_id, base_url, @@ -1128,6 +2840,7 @@ impl OnboardingWidget { // Move to invocation method selection. let model = model.clone(); let provider = *provider; + let provider_id = provider_id.clone(); let provider_name = provider_name.clone(); let provider_credential_id = provider_credential_id.clone(); let base_url = base_url.clone(); @@ -1140,6 +2853,7 @@ impl OnboardingWidget { self.state = OnboardingState::InvocationMethod { model, provider, + provider_id, provider_name, provider_credential_id, base_url, @@ -1148,6 +2862,8 @@ impl OnboardingWidget { display_name, items, selected_idx, + initial_model_settings: None, + default_reasoning_effort: None, }; } } @@ -1156,15 +2872,18 @@ impl OnboardingWidget { // Go back to previous field or provider selection. match active_field { InlineField::ProviderName => { - // Go back to provider selection. - let model = model.clone(); - let display_name = display_name.clone(); - self.state = OnboardingState::ProviderSelection { - model, - display_name, - items: Self::provider_selection_items(&self.provider_vendors), - selected_idx: 0, + // Go back to model selection for the current provider. + let provider = ProviderDraft { + provider: *provider, + provider_id: provider_id.clone(), + provider_name: provider_name.clone(), + provider_credential_id: provider_credential_id.clone(), + base_url: base_url.clone(), + api_key: api_key.clone(), + is_custom: true, }; + let models = self.original_models.clone(); + self.state = Self::model_selection_state(&models, provider); } InlineField::BaseUrl => { *active_field = InlineField::ProviderName; @@ -1216,6 +2935,7 @@ impl OnboardingWidget { let OnboardingState::InvocationMethod { model, provider, + provider_id, provider_name, provider_credential_id, base_url, @@ -1224,7 +2944,8 @@ impl OnboardingWidget { display_name, items, selected_idx, - .. + initial_model_settings, + default_reasoning_effort, } = &mut self.state else { return; @@ -1246,20 +2967,35 @@ impl OnboardingWidget { let invocation = item.provider; let model = model.clone(); let provider = *provider; + let provider_id = provider_id.clone(); let provider_name = provider_name.clone(); let provider_credential_id = provider_credential_id.clone(); let base_url = base_url.clone(); let api_key = api_key.clone(); let request_model = request_model.clone(); let display_name = display_name.clone(); + let initial_model_settings = initial_model_settings.clone(); + let default_reasoning_effort = default_reasoning_effort.clone(); - if self.model_supports_reasoning(&model) { - let reasoning_items = self.reasoning_effort_items(&model); - let selected_reasoning_idx = - self.default_reasoning_effort_index(&model, &reasoning_items); + if self.model_supports_reasoning_with_settings( + &model, + initial_model_settings.as_ref(), + ) { + let reasoning_items = self.reasoning_effort_items_with_settings( + &model, + initial_model_settings.as_ref(), + ); + let selected_reasoning_idx = self + .default_reasoning_effort_index_with_settings( + &model, + initial_model_settings.as_ref(), + default_reasoning_effort.as_deref(), + &reasoning_items, + ); self.state = OnboardingState::ReasoningEffort { model, provider, + provider_id, provider_name, provider_credential_id, base_url, @@ -1269,33 +3005,24 @@ impl OnboardingWidget { invocation_method: invocation, items: reasoning_items, selected_idx: selected_reasoning_idx, + initial_model_settings, + default_reasoning_effort, }; } else { - // No reasoning — go straight to validation. - let base_url_opt = if base_url.is_empty() { - None - } else { - Some(base_url) - }; - let api_key_opt = if api_key.is_empty() { - None - } else { - Some(api_key) - }; - let params = ValidationParams { - model_slug: model, - request_model, - display_name, - provider_id: provider_name.clone(), + self.state = Self::model_settings_state( + model, + provider, + provider_id, provider_name, provider_credential_id, - invocation_method: invocation, - default_reasoning_effort: None, - base_url: base_url_opt, - api_key: api_key_opt, - }; - self.record_settings_confirmed(¶ms); - self.start_validation(params); + base_url, + api_key, + request_model, + display_name, + invocation, + initial_model_settings, + default_reasoning_effort, + ); } } } @@ -1303,6 +3030,7 @@ impl OnboardingWidget { // Go back to inline setup, display name field. let model = model.clone(); let provider = *provider; + let provider_id = provider_id.clone(); let provider_name = provider_name.clone(); let provider_credential_id = provider_credential_id.clone(); let base_url = base_url.clone(); @@ -1312,6 +3040,7 @@ impl OnboardingWidget { self.state = OnboardingState::InlineSetup { model, provider, + provider_id, provider_name, provider_credential_id, base_url, @@ -1330,6 +3059,8 @@ impl OnboardingWidget { fn reasoning_effort_handle_key(&mut self, key: KeyEvent) { let OnboardingState::ReasoningEffort { model, + provider, + provider_id, provider_credential_id, base_url, api_key, @@ -1339,7 +3070,8 @@ impl OnboardingWidget { invocation_method, items, selected_idx, - .. + initial_model_settings, + default_reasoning_effort: _, } = &mut self.state else { return; @@ -1362,69 +3094,69 @@ impl OnboardingWidget { let request_model = request_model.clone(); let display_name = display_name.clone(); let provider_name = provider_name.clone(); + let provider_id = provider_id.clone(); let provider_credential_id = provider_credential_id.clone(); let default_reasoning_effort = items.get(*selected_idx).map(|item| item.value.clone()); + let initial_model_settings = initial_model_settings.clone(); let base_url = base_url.clone(); let api_key = api_key.clone(); - let base_url_opt = if base_url.is_empty() { - None - } else { - Some(base_url) - }; - let api_key_opt = if api_key.is_empty() { - None - } else { - Some(api_key) - }; - let params = ValidationParams { - model_slug: model, - request_model, - display_name, - provider_id: provider_name.clone(), + self.state = Self::model_settings_state( + model, + *provider, + provider_id, provider_name, provider_credential_id, + base_url, + api_key, + request_model, + display_name, invocation_method, + initial_model_settings, default_reasoning_effort, - base_url: base_url_opt, - api_key: api_key_opt, - }; - self.record_settings_confirmed(¶ms); - self.start_validation(params); + ); } KeyCode::Esc => { // Go back to invocation method selection. // Extract values before reassigning self.state. - let (m, prov, pn, pc, bu, ak, mn, dn, invocation) = match &self.state { - OnboardingState::ReasoningEffort { - model, - provider, - provider_name, - provider_credential_id, - base_url, - api_key, - request_model, - display_name, - invocation_method, - .. - } => ( - model.clone(), - *provider, - provider_name.clone(), - provider_credential_id.clone(), - base_url.clone(), - api_key.clone(), - request_model.clone(), - display_name.clone(), - *invocation_method, - ), - _ => return, - }; + let (m, prov, pid, pn, pc, bu, ak, mn, dn, invocation, settings, default) = + match &self.state { + OnboardingState::ReasoningEffort { + model, + provider, + provider_id, + provider_name, + provider_credential_id, + base_url, + api_key, + request_model, + display_name, + invocation_method, + initial_model_settings, + default_reasoning_effort, + .. + } => ( + model.clone(), + *provider, + provider_id.clone(), + provider_name.clone(), + provider_credential_id.clone(), + base_url.clone(), + api_key.clone(), + request_model.clone(), + display_name.clone(), + *invocation_method, + initial_model_settings.clone(), + default_reasoning_effort.clone(), + ), + _ => return, + }; let items = Self::invocation_method_items(); let selected_idx = Self::invocation_method_selection_index(invocation, &items); self.state = OnboardingState::InvocationMethod { model: m, provider: prov, + provider_id: pid, provider_name: pn, provider_credential_id: pc, base_url: bu, @@ -1433,138 +3165,663 @@ impl OnboardingWidget { display_name: dn, items, selected_idx, + initial_model_settings: settings, + default_reasoning_effort: default, }; } _ => {} } } - // ── Validation Failed ── - - fn validation_failed_handle_key(&mut self, key: KeyEvent) { - let OnboardingState::ValidationFailed { + fn model_settings_handle_key(&mut self, key: KeyEvent) { + let OnboardingState::ModelSettings { model, - request_model, - display_name, provider, + provider_id, provider_name, provider_credential_id, - default_reasoning_effort, base_url, api_key, - error_message: _, - recovery_hint: _, - selected_action, + request_model, + display_name, + invocation_method, + default_reasoning_effort, + settings, + advanced_open, + active_field, + input, + cursor_pos, + settings_error, } = &mut self.state else { return; }; - let actions = VALIDATION_FAILED_ACTIONS; + *settings_error = None; + + if *active_field == ModelSettingsField::AdvancedToggle + && matches!(key.code, KeyCode::Char(' ')) + { + *advanced_open = true; + *active_field = ModelSettingsField::ContextWindow; + *input = settings.context_window.clone(); + *cursor_pos = Self::char_count(input); + return; + } + if matches!(key.code, KeyCode::Char(' ')) + && matches!( + active_field, + ModelSettingsField::OriginalImageDetail | ModelSettingsField::Enabled + ) + { + match active_field { + ModelSettingsField::OriginalImageDetail => { + settings.supports_image_detail_original = + Some(!settings.supports_image_detail_original.unwrap_or(false)); + } + ModelSettingsField::Enabled => { + settings.enabled = Some(!settings.enabled.unwrap_or(true)); + } + _ => {} + } + return; + } + + if key.code == KeyCode::Enter && *active_field == ModelSettingsField::DisplayName { + settings.display_name = input.trim().to_string(); + *display_name = settings.display_name.clone(); + *active_field = ModelSettingsField::AdvancedToggle; + input.clear(); + *cursor_pos = 0; + return; + } match key.code { - KeyCode::Up => { - *selected_action = if *selected_action == 0 { - actions.len() - 1 - } else { - *selected_action - 1 - }; + KeyCode::Char(c) + if key.modifiers.is_empty() || key.modifiers.contains(KeyModifiers::SHIFT) => + { + Self::insert_at_cursor(input, cursor_pos, &c.to_string()); } - KeyCode::Down => { - *selected_action = (*selected_action + 1) % actions.len(); + KeyCode::Backspace => Self::remove_char_before_cursor(input, cursor_pos), + KeyCode::Delete => Self::remove_char_at_cursor(input, *cursor_pos), + KeyCode::Left => *cursor_pos = (*cursor_pos).saturating_sub(1), + KeyCode::Right => *cursor_pos = (*cursor_pos + 1).min(Self::char_count(input)), + KeyCode::Home => *cursor_pos = 0, + KeyCode::End => *cursor_pos = Self::char_count(input), + KeyCode::Esc => { + let model = model.clone(); + let provider = *provider; + let provider_name = provider_name.clone(); + let provider_credential_id = provider_credential_id.clone(); + let base_url = base_url.clone(); + let api_key = api_key.clone(); + let request_model = request_model.clone(); + let display_name = display_name.clone(); + let initial_model_settings = settings.to_value(&request_model); + let default_reasoning_effort = default_reasoning_effort.clone(); + let items = Self::invocation_method_items(); + let selected_idx = + Self::invocation_method_selection_index(*invocation_method, &items); + self.state = OnboardingState::InvocationMethod { + model, + provider, + provider_id: provider_id.clone(), + provider_name, + provider_credential_id, + base_url, + api_key, + request_model, + display_name, + items, + selected_idx, + initial_model_settings, + default_reasoning_effort, + }; } - KeyCode::Enter => match *selected_action { - 0 => { - let result_model_slug = model.clone(); - let result_request_model = request_model.clone(); - let result_display_name = display_name.clone(); - let provider = *provider; - let provider_name = provider_name.clone(); - let provider_credential_id = provider_credential_id.clone(); - let default_reasoning_effort = default_reasoning_effort.clone(); - let base_url = base_url.clone(); - let api_key = api_key.clone(); - let payload = serde_json::json!({ - "model_slug": result_model_slug.clone(), - "request_model": result_request_model.clone(), - "display_name": result_display_name.clone(), - "provider_id": provider_name.clone(), - "provider_name": provider_name.clone(), - "provider_credential_id": provider_credential_id.clone(), - "invocation_method": provider, - "default_reasoning_effort": default_reasoning_effort.clone(), - "base_url": base_url.clone(), - "api_key": api_key.clone(), - }); - self.app_event_tx - .send(AppEvent::Command(AppCommand::RunUserShellCommand { - command: format!("onboard-skip-validation {payload}"), - })); - self.state = OnboardingState::Saving { - model_slug: result_model_slug, - request_model: result_request_model, - display_name: result_display_name, - provider_id: provider_name.clone(), + KeyCode::Enter | KeyCode::Tab => { + if *active_field == ModelSettingsField::AdvancedToggle && !*advanced_open { + let params = match Self::validation_params_from_settings( + model, + provider_id, provider_name, provider_credential_id, - invocation_method: provider, - default_reasoning_effort, base_url, api_key, - bypassed: true, - started_at: Instant::now(), + request_model, + display_name, + *invocation_method, + default_reasoning_effort, + settings, + ) { + Ok(params) => params, + Err(error) => { + *settings_error = Some(error); + return; + } }; + self.record_settings_confirmed(¶ms); + self.state = OnboardingState::Review { params }; + return; } - 1 => { - // Retry. - let model = model.clone(); - let request_model = request_model.clone(); - let display_name = display_name.clone(); - let provider = *provider; - let provider_name = provider_name.clone(); + + match active_field { + ModelSettingsField::DisplayName => { + settings.display_name = input.trim().to_string(); + *display_name = settings.display_name.clone(); + *active_field = ModelSettingsField::ContextWindow; + *input = settings.context_window.clone(); + *cursor_pos = Self::char_count(input); + } + ModelSettingsField::ContextWindow => { + settings.context_window = input.trim().to_string(); + *active_field = ModelSettingsField::MaxTokens; + *input = settings.max_tokens.clone(); + *cursor_pos = Self::char_count(input); + } + ModelSettingsField::MaxTokens => { + settings.max_tokens = input.trim().to_string(); + *active_field = ModelSettingsField::Temperature; + *input = settings.temperature.clone(); + *cursor_pos = Self::char_count(input); + } + ModelSettingsField::Temperature => { + settings.temperature = input.trim().to_string(); + *active_field = ModelSettingsField::InputModalities; + *input = settings.input_modalities.clone(); + *cursor_pos = Self::char_count(input); + } + ModelSettingsField::InputModalities => { + settings.input_modalities = input.trim().to_string(); + *active_field = ModelSettingsField::ReasoningCapability; + *input = settings.reasoning_capability.clone(); + *cursor_pos = Self::char_count(input); + } + ModelSettingsField::ReasoningCapability => { + settings.reasoning_capability = input.trim().to_string(); + *active_field = ModelSettingsField::DefaultReasoning; + *input = default_reasoning_effort.clone().unwrap_or_default(); + *cursor_pos = Self::char_count(input); + } + ModelSettingsField::DefaultReasoning => { + let value = input.trim(); + *default_reasoning_effort = (!value.is_empty()).then(|| value.to_string()); + *active_field = ModelSettingsField::AdvancedToggle; + input.clear(); + *cursor_pos = 0; + } + ModelSettingsField::AdvancedToggle => { + *active_field = ModelSettingsField::TopP; + *input = settings.top_p.clone(); + *cursor_pos = Self::char_count(input); + } + ModelSettingsField::EffectiveContext => { + // Percent editing removed; jump to Top P. + *active_field = ModelSettingsField::TopP; + *input = settings.top_p.clone(); + *cursor_pos = Self::char_count(input); + } + ModelSettingsField::TopP => { + settings.top_p = input.trim().to_string(); + *active_field = ModelSettingsField::TopK; + *input = settings.top_k.clone(); + *cursor_pos = Self::char_count(input); + } + ModelSettingsField::TopK => { + settings.top_k = input.trim().to_string(); + *active_field = ModelSettingsField::Family; + *input = settings.family.clone(); + *cursor_pos = Self::char_count(input); + } + ModelSettingsField::Family => { + settings.family = input.trim().to_string(); + *active_field = ModelSettingsField::ReleaseDate; + *input = settings.release_date.clone(); + *cursor_pos = Self::char_count(input); + } + ModelSettingsField::ReleaseDate => { + settings.release_date = input.trim().to_string(); + *active_field = ModelSettingsField::Status; + *input = settings.status.clone(); + *cursor_pos = Self::char_count(input); + } + ModelSettingsField::Status => { + settings.status = input.trim().to_string(); + *active_field = ModelSettingsField::CapabilitiesJson; + *input = settings.capabilities_json.clone(); + *cursor_pos = Self::char_count(input); + } + ModelSettingsField::CapabilitiesJson => { + settings.capabilities_json = input.trim().to_string(); + *active_field = ModelSettingsField::Channel; + *input = settings.channel.clone(); + *cursor_pos = Self::char_count(input); + } + ModelSettingsField::Channel => { + settings.channel = input.trim().to_string(); + *active_field = ModelSettingsField::BaseInstructions; + *input = settings.base_instructions.clone(); + *cursor_pos = Self::char_count(input); + } + ModelSettingsField::BaseInstructions => { + settings.base_instructions = input.to_string(); + *active_field = ModelSettingsField::ReasoningImplementation; + *input = settings.reasoning_implementation.clone(); + *cursor_pos = Self::char_count(input); + } + ModelSettingsField::ReasoningImplementation => { + settings.reasoning_implementation = input.trim().to_string(); + *active_field = ModelSettingsField::ReasoningLevels; + *input = settings.reasoning_levels.clone(); + *cursor_pos = Self::char_count(input); + } + ModelSettingsField::ReasoningLevels => { + settings.reasoning_levels = input.trim().to_string(); + *active_field = ModelSettingsField::ReasoningVariantsJson; + *input = settings.reasoning_variants_json.clone(); + *cursor_pos = Self::char_count(input); + } + ModelSettingsField::ReasoningVariantsJson => { + settings.reasoning_variants_json = input.trim().to_string(); + *active_field = ModelSettingsField::DefaultVariant; + *input = settings.default_variant.clone(); + *cursor_pos = Self::char_count(input); + } + ModelSettingsField::DefaultVariant => { + settings.default_variant = input.trim().to_string(); + *active_field = ModelSettingsField::CostJson; + *input = settings.cost_json.clone(); + *cursor_pos = Self::char_count(input); + } + ModelSettingsField::CostJson => { + settings.cost_json = input.trim().to_string(); + *active_field = ModelSettingsField::MetadataJson; + *input = settings.metadata_json.clone(); + *cursor_pos = Self::char_count(input); + } + ModelSettingsField::MetadataJson => { + settings.metadata_json = input.trim().to_string(); + *active_field = ModelSettingsField::RequestJson; + *input = settings.request_json.clone(); + *cursor_pos = Self::char_count(input); + } + ModelSettingsField::RequestJson => { + settings.request_json = input.trim().to_string(); + *active_field = ModelSettingsField::OptionsJson; + *input = settings.options_json.clone(); + *cursor_pos = Self::char_count(input); + } + ModelSettingsField::OptionsJson => { + settings.options_json = input.trim().to_string(); + *active_field = ModelSettingsField::HeadersJson; + *input = settings.headers_json.clone(); + *cursor_pos = Self::char_count(input); + } + ModelSettingsField::HeadersJson => { + settings.headers_json = input.trim().to_string(); + *active_field = ModelSettingsField::VariantsJson; + *input = settings.variants_json.clone(); + *cursor_pos = Self::char_count(input); + } + ModelSettingsField::VariantsJson => { + settings.variants_json = input.trim().to_string(); + *active_field = ModelSettingsField::WebSearchJson; + *input = settings.web_search_json.clone(); + *cursor_pos = Self::char_count(input); + } + ModelSettingsField::WebSearchJson => { + settings.web_search_json = input.trim().to_string(); + *active_field = ModelSettingsField::WebFetchJson; + *input = settings.web_fetch_json.clone(); + *cursor_pos = Self::char_count(input); + } + ModelSettingsField::WebFetchJson => { + settings.web_fetch_json = input.trim().to_string(); + *active_field = ModelSettingsField::TruncationMode; + *input = settings.truncation_mode.clone(); + *cursor_pos = Self::char_count(input); + } + ModelSettingsField::TruncationMode => { + settings.truncation_mode = input.trim().to_string(); + *active_field = ModelSettingsField::TruncationLimit; + *input = settings.truncation_limit.clone(); + *cursor_pos = Self::char_count(input); + } + ModelSettingsField::TruncationLimit => { + settings.truncation_limit = input.trim().to_string(); + *active_field = ModelSettingsField::OriginalImageDetail; + input.clear(); + *cursor_pos = 0; + } + ModelSettingsField::OriginalImageDetail => { + *active_field = ModelSettingsField::Enabled; + input.clear(); + *cursor_pos = 0; + } + ModelSettingsField::Enabled => { + *active_field = ModelSettingsField::Priority; + *input = settings.priority.clone(); + *cursor_pos = Self::char_count(input); + } + ModelSettingsField::Priority => { + settings.priority = input.trim().to_string(); + let params = match Self::validation_params_from_settings( + model, + provider_id, + provider_name, + provider_credential_id, + base_url, + api_key, + request_model, + display_name, + *invocation_method, + default_reasoning_effort, + settings, + ) { + Ok(params) => params, + Err(error) => { + *settings_error = Some(error); + return; + } + }; + self.record_settings_confirmed(¶ms); + self.state = OnboardingState::Review { params }; + } + } + } + _ => {} + } + } + + #[allow(clippy::too_many_arguments)] + fn validation_params_from_settings( + model: &str, + provider_id: &str, + provider_name: &str, + provider_credential_id: &Option, + base_url: &str, + api_key: &str, + request_model: &str, + display_name: &str, + invocation_method: ProviderWireApi, + default_reasoning_effort: &Option, + settings: &ModelSettingsDraft, + ) -> Result { + if let Some(error) = settings.validation_error(request_model) { + return Err(error); + } + if let Some(reasoning) = default_reasoning_effort.as_deref() + && !matches!(reasoning, "on" | "off" | "enabled" | "disabled") + && reasoning.parse::().is_err() + { + return Err("Default reasoning must be on, off, or an effort level".to_string()); + } + if let Some(error) = Self::default_reasoning_error(settings, default_reasoning_effort) { + return Err(error); + } + Ok(ValidationParams { + model_slug: model.to_string(), + request_model: request_model.to_string(), + display_name: display_name.to_string(), + provider_id: provider_id.to_string(), + provider_name: provider_name.to_string(), + provider_credential_id: provider_credential_id.clone(), + invocation_method, + default_reasoning_effort: default_reasoning_effort.clone(), + model_settings: settings.to_value(request_model), + base_url: (!base_url.is_empty()).then(|| base_url.to_string()), + api_key: (!api_key.is_empty()).then(|| api_key.to_string()), + }) + } + + fn default_reasoning_error( + settings: &ModelSettingsDraft, + default_reasoning_effort: &Option, + ) -> Option { + let selection = default_reasoning_effort + .as_deref()? + .trim() + .to_ascii_lowercase(); + let capability = settings.reasoning_capability.trim().to_ascii_lowercase(); + if capability.is_empty() { + return None; + } + let levels = settings + .reasoning_levels + .split(',') + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(str::to_ascii_lowercase) + .collect::>(); + match capability.as_str() { + "unsupported" => { + Some("Unsupported reasoning cannot have a default selection".to_string()) + } + "toggle" if !matches!(selection.as_str(), "on" | "off" | "enabled" | "disabled") => { + Some("Toggle reasoning default must be on or off".to_string()) + } + "levels" if !levels.contains(&selection) => Some(format!( + "Default reasoning must be one of the configured levels: {}", + levels.join(", ") + )), + _ => None, + } + } + + fn review_handle_key(&mut self, key: KeyEvent) { + let OnboardingState::Review { params } = &self.state else { + return; + }; + match key.code { + KeyCode::Enter => { + self.start_validation(params.clone()); + } + KeyCode::Esc => { + let params = params.clone(); + let settings = ModelSettingsDraft::from_value( + params.model_settings.as_ref(), + ¶ms.display_name, + ); + let advanced_open = params.model_settings.is_some(); + let input = if advanced_open { + settings.context_window.clone() + } else { + settings.display_name.clone() + }; + let active_field = if advanced_open { + ModelSettingsField::ContextWindow + } else { + ModelSettingsField::DisplayName + }; + self.state = OnboardingState::ModelSettings { + model: params.model_slug, + provider: params.invocation_method, + provider_id: params.provider_id, + provider_name: params.provider_name, + provider_credential_id: params.provider_credential_id, + base_url: params.base_url.unwrap_or_default(), + api_key: params.api_key.unwrap_or_default(), + request_model: params.request_model, + display_name: settings.display_name.clone(), + invocation_method: params.invocation_method, + default_reasoning_effort: params.default_reasoning_effort, + settings: Box::new(settings), + advanced_open, + active_field, + cursor_pos: Self::char_count(&input), + input, + settings_error: None, + }; + } + _ => {} + } + } + + // ── Validation Failed ── + + fn validation_failed_handle_key(&mut self, key: KeyEvent) { + let OnboardingState::ValidationFailed { + model, + request_model, + display_name, + provider, + provider_id, + provider_name, + provider_credential_id, + default_reasoning_effort, + base_url, + api_key, + error_message: _, + recovery_hint: _, + selected_action, + model_settings, + } = &mut self.state + else { + return; + }; + + let actions = VALIDATION_FAILED_ACTIONS; + + match key.code { + KeyCode::Up => { + *selected_action = if *selected_action == 0 { + actions.len() - 1 + } else { + *selected_action - 1 + }; + } + KeyCode::Down => { + *selected_action = (*selected_action + 1) % actions.len(); + } + KeyCode::Enter => match *selected_action { + 0 => { + let result_model_slug = model.clone(); + let result_request_model = request_model.clone(); + let result_display_name = display_name.clone(); + let provider = *provider; + let provider_id = provider_id.clone(); + let provider_name = provider_name.clone(); + let provider_credential_id = provider_credential_id.clone(); + let default_reasoning_effort = default_reasoning_effort.clone(); + let model_settings = model_settings.clone(); + let base_url = base_url.clone(); + let api_key = api_key.clone(); + let onboarding_params = ValidationParams { + model_slug: result_model_slug.clone(), + request_model: result_request_model.clone(), + display_name: result_display_name.clone(), + provider_id: provider_id.clone(), + provider_name: provider_name.clone(), + provider_credential_id: provider_credential_id.clone(), + invocation_method: provider, + default_reasoning_effort: default_reasoning_effort.clone(), + model_settings: model_settings.clone(), + base_url: base_url.clone(), + api_key: api_key.clone(), + }; + let (provider_info, model_id) = Self::provider_info_from_validation( + &onboarding_params, + &result_display_name, + ); + self.app_event_tx + .send(AppEvent::Command(AppCommand::ProviderUpsert { + params: devo_protocol::native::rpc_admin::ProviderUpsertParams { + provider: provider_info, + default_model: Some(format!("{provider_id}/{model_id}")), + small_model: None, + api_key: api_key.clone(), + }, + })); + self.state = OnboardingState::Saving { + model_slug: result_model_slug, + request_model: result_request_model, + display_name: result_display_name, + provider_id, + provider_name, + provider_credential_id, + invocation_method: provider, + default_reasoning_effort, + model_settings, + base_url, + api_key, + bypassed: true, + started_at: Instant::now(), + }; + } + 1 => { + // Retry. + let model = model.clone(); + let request_model = request_model.clone(); + let display_name = display_name.clone(); + let provider = *provider; + let provider_id = provider_id.clone(); + let provider_name = provider_name.clone(); let provider_credential_id = provider_credential_id.clone(); let default_reasoning_effort = default_reasoning_effort.clone(); + let model_settings = model_settings.clone(); let base_url = base_url.clone(); let api_key = api_key.clone(); self.start_validation(ValidationParams { model_slug: model, request_model, display_name, - provider_id: provider_name.clone(), + provider_id, provider_name, provider_credential_id, invocation_method: provider, default_reasoning_effort, + model_settings, base_url, api_key, }); } 2 => { - // Edit settings — go back to inline setup API key field. + // Edit settings, preserving any advanced values already entered. let model_slug = model.clone(); let request_model = request_model.clone(); let display_name = display_name.clone(); let provider = *provider; + let provider_id = provider_id.clone(); let provider_name = provider_name.clone(); let provider_credential_id = provider_credential_id.clone(); + let default_reasoning_effort = default_reasoning_effort.clone(); let base_url = base_url.clone().unwrap_or_default(); let api_key = api_key.clone().unwrap_or_default(); - self.state = OnboardingState::InlineSetup { - model: model_slug.clone(), + let settings = + ModelSettingsDraft::from_value(model_settings.as_ref(), &display_name); + self.state = OnboardingState::ModelSettings { + model: model_slug, provider, + provider_id, provider_name, provider_credential_id, base_url, - api_key: api_key.clone(), + api_key, request_model, - display_name, - active_field: InlineField::ApiKey, - input: api_key.clone(), - cursor_pos: Self::char_count(&api_key), + display_name: settings.display_name.clone(), + invocation_method: provider, + default_reasoning_effort, + advanced_open: model_settings.is_some(), + active_field: if model_settings.is_some() { + ModelSettingsField::ContextWindow + } else { + ModelSettingsField::DisplayName + }, + input: if model_settings.is_some() { + settings.context_window.clone() + } else { + settings.display_name.clone() + }, + cursor_pos: if model_settings.is_some() { + Self::char_count(&settings.context_window) + } else { + Self::char_count(&settings.display_name) + }, + settings: Box::new(settings), + settings_error: None, }; } 3 => { - self.go_back_to_model_selection(); + self.go_back_to_provider_selection(); } _ => {} }, @@ -1615,7 +3872,7 @@ impl OnboardingWidget { lines.push(Line::from(vec![ Span::raw(" ".repeat(LIST_LEFT_PAD)), Span::styled(primary.to_string(), Style::default().dim()), - Span::styled(" · ", Style::default().dim()), + Span::styled(" / ", Style::default().dim()), Span::styled(secondary.to_string(), Style::default().dim()), ])); } @@ -1626,7 +3883,7 @@ impl OnboardingWidget { description: Option, is_selected: bool, ) { - let marker = if is_selected { "›" } else { " " }; + let marker = if is_selected { ">" } else { " " }; let marker_style = if is_selected { Style::default().cyan().bold() } else { @@ -1655,7 +3912,11 @@ impl OnboardingWidget { fn scroll_overflow_line(more_above: bool) -> Line<'static> { // Align with option labels: LIST_LEFT_PAD + marker + following space. - let label = if more_above { "↑ more" } else { "↓ more" }; + let label = if more_above { + "... more above" + } else { + "... more below" + }; Line::from(vec![ Span::raw(" ".repeat(LIST_LEFT_PAD + 2)), Span::styled(label.to_string(), Style::default().dim()), @@ -1664,7 +3925,7 @@ impl OnboardingWidget { fn render_inline_setup_header(lines: &mut Vec>, model: &str) { lines.push(Line::from(vec![Span::styled( - "Configure provider binding", + "Configure Connection", Style::default().bold(), )])); lines.push(Line::from(vec![Span::styled( @@ -1824,8 +4085,7 @@ impl OnboardingWidget { }; lines.push(Line::from(vec![ - Span::styled("● ", rail_style), - Span::raw(" "), + Span::styled(if is_active { "> " } else { " " }, rail_style), Span::styled(format!("{label}: "), label_style), Span::styled( shown_value, @@ -1837,10 +4097,10 @@ impl OnboardingWidget { ), ])); lines.push(Line::from(vec![ - Span::styled("| ", rail_style), + Span::raw(" "), Span::styled(hint.to_string(), Style::default().dim()), ])); - lines.push(Line::from(vec![Span::styled("|", rail_style)])); + lines.push(Line::from("")); if is_active { Some(ViewportAnchor { start, @@ -1869,8 +4129,14 @@ impl OnboardingWidget { WorkflowStepState::Completed => Style::default(), }; lines.push(Line::from(vec![ - Span::styled("● ", rail_style), - Span::raw(" "), + Span::styled( + match step_state { + WorkflowStepState::Active => "> ", + WorkflowStepState::Completed => " ", + WorkflowStepState::Pending => " ", + }, + rail_style, + ), Span::styled(format!("{label}: "), label_style), Span::styled( value.to_string(), @@ -1883,11 +4149,11 @@ impl OnboardingWidget { ])); if !hint.is_empty() { lines.push(Line::from(vec![ - Span::styled("| ", rail_style), + Span::raw(" "), Span::styled(hint.to_string(), Style::default().dim()), ])); } - lines.push(Line::from(vec![Span::styled("|", rail_style)])); + lines.push(Line::from("")); } fn render_inline_popup_option( @@ -1908,15 +4174,14 @@ impl OnboardingWidget { }; lines.push(Line::from(vec![ - Span::styled("| ", Style::default().cyan().bold()), - Span::styled(if is_selected { "›" } else { " " }, marker_style), + Span::raw(" "), + Span::styled(if is_selected { ">" } else { " " }, marker_style), Span::raw(" "), Span::styled(label.to_string(), label_style), ])); if !description.is_empty() { lines.push(Line::from(vec![ - Span::styled("| ", Style::default().cyan().bold()), - Span::styled(" ", Style::default().dim()), + Span::raw(" "), Span::styled(description.to_string(), Style::default().dim()), ])); } @@ -2047,7 +4312,7 @@ impl OnboardingWidget { fn input_with_cursor(input: &str, cursor_pos: usize) -> String { let byte_pos = Self::byte_index_for_char(input, cursor_pos); - format!("{}▌{}", &input[..byte_pos], &input[byte_pos..]) + format!("{}|{}", &input[..byte_pos], &input[byte_pos..]) } fn inline_field_index(field: InlineField) -> usize { @@ -2062,11 +4327,1103 @@ impl OnboardingWidget { // ── Rendering: Popup Lists ── - fn render_model_selection( - items: &[ModelSelectionItem], - state: &ScrollState, - search_query: &str, - filtered_indices: &[usize], + fn render_onboarding_header( + title: &str, + subtitle: &str, + active_step: &str, + area: Rect, + buf: &mut Buffer, + ) { + let provider_active = active_step == "Provider"; + let model_active = active_step == "Model"; + let settings_active = active_step == "Settings"; + let review_active = active_step == "Review"; + let lines = vec![ + Line::from(vec![Span::styled( + "devo / Set up a model", + Style::default().bold(), + )]), + Line::from(vec![ + Span::styled( + "1 Provider", + if provider_active { + Style::default().cyan().bold() + } else { + Style::default().green() + }, + ), + Span::styled(" / ", Style::default().dim()), + Span::styled( + "2 Model", + if model_active { + Style::default().cyan().bold() + } else if settings_active || review_active { + Style::default().green() + } else { + Style::default().dim() + }, + ), + Span::styled(" / ", Style::default().dim()), + Span::styled( + "3 Settings", + if settings_active { + Style::default().cyan().bold() + } else if review_active { + Style::default().green() + } else { + Style::default().dim() + }, + ), + Span::styled(" / ", Style::default().dim()), + Span::styled( + "4 Review", + if review_active { + Style::default().cyan().bold() + } else { + Style::default().dim() + }, + ), + ]), + Line::from(vec![Span::styled( + title.to_string(), + Style::default().bold(), + )]), + Line::from(vec![Span::styled( + subtitle.to_string(), + Style::default().dim(), + )]), + ]; + Paragraph::new(lines) + .wrap(Wrap { trim: false }) + .render(area, buf); + } + + fn render_custom_card( + title: &str, + description: &str, + focused: bool, + area: Rect, + buf: &mut Buffer, + ) { + let accent = if focused { + Style::default().cyan().bold() + } else { + Style::default().dim() + }; + let title_style = if focused { + Style::default().cyan().bold().underlined() + } else { + Style::default().bold() + }; + let lines = vec![ + Line::from(vec![ + Span::styled(if focused { "> " } else { " " }, accent), + Span::styled(title.to_string(), title_style), + ]), + Line::from(vec![ + Span::styled(" ", accent), + Span::styled(description.to_string(), Style::default().dim()), + ]), + Line::from(vec![Span::styled(" ", accent)]), + Line::from(vec![ + Span::styled(" ", accent), + Span::styled("Press Enter", Style::default().bold()), + Span::styled(" to configure", Style::default().dim()), + ]), + ]; + Paragraph::new(lines) + .wrap(Wrap { trim: false }) + .render(area, buf); + } + + fn selection_areas(body_area: Rect) -> (Rect, Rect) { + if body_area.width >= 80 { + let [list_area, _, custom_area] = Layout::horizontal([ + Constraint::Percentage(58), + Constraint::Length(3), + Constraint::Fill(1), + ]) + .areas(body_area); + (list_area, custom_area) + } else { + let custom_height = body_area.height.min(6); + let [list_area, custom_area] = + Layout::vertical([Constraint::Fill(1), Constraint::Length(custom_height)]) + .areas(body_area); + (list_area, custom_area) + } + } + + #[allow(clippy::too_many_arguments)] + fn render_model_selection( + items: &[ModelSelectionItem], + state: &ScrollState, + search_query: &str, + filtered_indices: &[usize], + focus: SelectionFocus, + manage_connection: bool, + area: Rect, + buf: &mut Buffer, + ) { + if area.height < 3 { + return; + } + let content_area = onboarding_content_area(area); + let [header_area, body_area, footer_area] = Layout::vertical([ + Constraint::Length(4), + Constraint::Fill(1), + Constraint::Length(2), + ]) + .areas(content_area); + Self::render_onboarding_header( + if manage_connection { + "Models in this Connection" + } else { + "Choose a model" + }, + if manage_connection { + "Choose an existing model, remove it, or add a custom model." + } else { + "Select a model for the chosen provider, or use a custom model id." + }, + "Model", + header_area, + buf, + ); + + let max_visible = MAX_POPUP_ROWS.min(filtered_indices.len().max(1)); + let scroll_offset = state + .scroll_top + .min(filtered_indices.len().saturating_sub(max_visible)); + let has_more_above = scroll_offset > 0; + let has_more_below = scroll_offset + max_visible < filtered_indices.len(); + let mut list_lines: Vec> = vec![Line::from(vec![ + Span::styled("Search ", Style::default().dim()), + Span::styled( + if search_query.is_empty() { + "type to filter" + } else { + search_query + } + .to_string(), + if search_query.is_empty() { + Style::default().dim() + } else { + Style::default() + }, + ), + ])]; + let mut anchor = None; + + if has_more_above { + list_lines.push(Self::scroll_overflow_line(/*more_above*/ true)); + } + for (vis_idx, &actual_idx) in filtered_indices + .iter() + .enumerate() + .skip(scroll_offset) + .take(max_visible) + { + if let Some(item) = items.get(actual_idx) { + let is_selected = state.selected_idx == Some(vis_idx); + let start = list_lines.len(); + Self::render_option_row( + &mut list_lines, + item.slug.clone(), + Some(item.display_name.clone()), + is_selected && focus == SelectionFocus::List, + ); + if is_selected { + anchor = Some(ViewportAnchor { + start, + end: list_lines.len(), + }); + } + } + } + if has_more_below { + list_lines.push(Self::scroll_overflow_line(/*more_above*/ false)); + } + let (list_area, custom_area) = Self::selection_areas(body_area); + render_lines_with_anchor(list_lines, anchor, list_area, buf); + Self::render_custom_card( + "Add custom model profile", + "Define the provider model ID, limits, capabilities, and request behavior.", + focus == SelectionFocus::Custom, + custom_area, + buf, + ); + let mut footer_spans = vec![ + Span::styled("Up/Down", Style::default().bold()), + Span::styled(" navigate ", Style::default().dim()), + Span::styled("Tab", Style::default().bold()), + Span::styled(" custom ", Style::default().dim()), + Span::styled("Enter", Style::default().bold()), + Span::styled(" select ", Style::default().dim()), + ]; + if manage_connection { + footer_spans.extend([ + Span::styled("d/Delete", Style::default().bold()), + Span::styled(" remove model ", Style::default().dim()), + ]); + } + footer_spans.extend([ + Span::styled("Esc", Style::default().bold()), + Span::styled(" back", Style::default().dim()), + ]); + let footer_lines = vec![Line::from(footer_spans)]; + Paragraph::new(footer_lines).render(footer_area, buf); + } + + #[allow(clippy::too_many_arguments)] + fn render_custom_model_form( + provider_name: &str, + model_id: &str, + display_name: &str, + active_field: CustomModelField, + input: &str, + cursor_pos: usize, + area: Rect, + buf: &mut Buffer, + ) { + if area.height < 3 { + return; + } + let content_area = onboarding_content_area(area); + let [header_area, body_area, footer_area] = Layout::vertical([ + Constraint::Length(4), + Constraint::Fill(1), + Constraint::Length(2), + ]) + .areas(content_area); + Self::render_onboarding_header( + "Add a custom model", + &format!( + "For {provider_name} · next configure API, limits, capabilities, and options." + ), + "Custom model", + header_area, + buf, + ); + + let mut lines = Vec::new(); + Self::render_custom_model_field( + &mut lines, + CustomModelField::ModelId, + "Provider model ID", + model_id, + active_field, + input, + cursor_pos, + "Required · sent verbatim to the provider; this is not a Devo slug", + ); + Self::render_custom_model_field( + &mut lines, + CustomModelField::DisplayName, + "Display name", + display_name, + active_field, + input, + cursor_pos, + "Optional · a friendly name shown in devo", + ); + lines.push(Line::from(vec![Span::styled( + "Next: API method, limits, capabilities, reasoning, variants, and request controls", + Style::default().dim(), + )])); + Paragraph::new(lines) + .wrap(Wrap { trim: false }) + .render(body_area, buf); + + let footer = vec![Line::from(vec![ + Span::styled("Enter/Tab", Style::default().bold()), + Span::styled(" next ", Style::default().dim()), + Span::styled("Esc", Style::default().bold()), + Span::styled(" back", Style::default().dim()), + ])]; + Paragraph::new(footer).render(footer_area, buf); + } + + #[allow(clippy::too_many_arguments)] + fn render_custom_model_field( + lines: &mut Vec>, + field: CustomModelField, + label: &str, + value: &str, + active_field: CustomModelField, + input: &str, + cursor_pos: usize, + hint: &str, + ) { + let active = field == active_field; + let displayed = if active { + let byte_pos = Self::byte_index_for_char(input, cursor_pos); + format!("{}|{}", &input[..byte_pos], &input[byte_pos..]) + } else if value.is_empty() { + "(optional)".to_string() + } else { + value.to_string() + }; + lines.push(Line::from(vec![ + Span::styled( + if active { "> " } else { " " }, + if active { + Style::default().cyan() + } else { + Style::default().dim() + }, + ), + Span::styled(format!("{label}: "), Style::default().bold()), + Span::raw(displayed), + ])); + lines.push(Line::from(vec![ + Span::styled(" ", Style::default()), + Span::styled(hint.to_string(), Style::default().dim()), + ])); + lines.push(Line::from("")); + } + + fn render_provider_setup( + draft: &ProviderDraft, + active_field: InlineField, + input: &str, + cursor_pos: usize, + area: Rect, + buf: &mut Buffer, + ) { + if area.height < 3 { + return; + } + let content_area = onboarding_content_area(area); + let [header_area, body_area, footer_area] = Layout::vertical([ + Constraint::Length(4), + Constraint::Fill(1), + Constraint::Length(2), + ]) + .areas(content_area); + let title = if draft.is_custom { + "Add a custom provider".to_string() + } else { + format!("Connect to {}", draft.provider_name) + }; + let subtitle = if draft.is_custom { + "Enter the connection details for this provider." + } else { + "Review the template, then create your Connection." + }; + Self::render_onboarding_header(&title, subtitle, "Provider", header_area, buf); + + let values = [ + ( + InlineField::ProviderName, + "Provider Name", + if draft.is_custom { + "A short name you will recognize later." + } else { + "From the provider directory (read-only)." + }, + draft.provider_name.as_str(), + ), + ( + InlineField::BaseUrl, + "Base URL", + if draft.is_custom { + "The provider API endpoint." + } else { + "Fixed by the provider directory template." + }, + draft.base_url.as_str(), + ), + ( + InlineField::ApiKey, + "API Key", + if !draft.is_custom { + "Enter once to create this Connection; change it by disconnecting first." + } else if draft.provider_credential_id.is_some() { + "Leave blank to keep the current key. Stored securely in auth.json." + } else { + "Stored securely in auth.json." + }, + draft.api_key.as_str(), + ), + ]; + let mut lines = Vec::new(); + for (field, label, hint, value) in values { + let is_active = field == active_field; + let shown = if is_active { + if field == InlineField::ApiKey { + format!("{}|", "*".repeat(Self::char_count(input))) + } else { + Self::input_with_cursor(input, cursor_pos) + } + } else if field == InlineField::ApiKey && !value.is_empty() { + Self::SAVED_SECRET_MASK.to_string() + } else if value.is_empty() { + "...".to_string() + } else { + value.to_string() + }; + let style = if is_active { + Style::default().cyan().bold() + } else { + Style::default().dim() + }; + lines.push(Line::from(vec![ + Span::styled(if is_active { "> " } else { " " }, style), + Span::styled( + format!("{label}: "), + if is_active { + Style::default().bold() + } else { + Style::default() + }, + ), + Span::styled( + shown, + if is_active { + Style::default() + } else { + Style::default().dim() + }, + ), + ])); + lines.push(Line::from(vec![ + Span::raw(" "), + Span::styled(hint.to_string(), Style::default().dim()), + ])); + lines.push(Line::from("")); + } + Paragraph::new(lines) + .wrap(Wrap { trim: false }) + .render(body_area, buf); + Paragraph::new(vec![Line::from(vec![ + Span::styled("Enter", Style::default().bold()), + Span::styled( + if draft.is_custom { + " next " + } else { + " create Connection " + }, + Style::default().dim(), + ), + Span::styled("Esc", Style::default().bold()), + Span::styled(" back", Style::default().dim()), + ])]) + .render(footer_area, buf); + } + + fn render_setting_line( + lines: &mut Vec>, + field: ModelSettingsField, + label: &str, + value: String, + active_field: ModelSettingsField, + input: &str, + cursor_pos: usize, + ) { + let active = field == active_field; + let shown = if active + && !matches!(field, ModelSettingsField::AdvancedToggle) + && !matches!( + field, + ModelSettingsField::OriginalImageDetail | ModelSettingsField::Enabled + ) { + Self::input_with_cursor(input, cursor_pos) + } else if value.is_empty() { + "default".to_string() + } else { + value + }; + let marker_style = if active { + Style::default().cyan().bold() + } else { + Style::default().dim() + }; + lines.push(Line::from(vec![ + Span::styled(if active { "> " } else { " " }, marker_style), + Span::styled( + format!("{label}: "), + if active { + Style::default().bold() + } else { + Style::default() + }, + ), + Span::styled( + shown, + if active { + Style::default().cyan() + } else { + Style::default().dim() + }, + ), + ])); + } + + fn model_settings_basic_anchor(active_field: ModelSettingsField) -> Option { + let start = match active_field { + ModelSettingsField::DisplayName => 3, + ModelSettingsField::ContextWindow => 5, + ModelSettingsField::MaxTokens => 7, + ModelSettingsField::Temperature => 8, + ModelSettingsField::InputModalities => 9, + ModelSettingsField::ReasoningCapability => 10, + ModelSettingsField::DefaultReasoning => 11, + ModelSettingsField::AdvancedToggle => 13, + _ => return None, + }; + Some(ViewportAnchor { + start, + end: start.saturating_add(1), + }) + } + + fn model_settings_advanced_anchor( + active_field: ModelSettingsField, + basic_line_count: usize, + ) -> Option { + let advanced_fields = [ + ModelSettingsField::TopP, + ModelSettingsField::TopK, + ModelSettingsField::Family, + ModelSettingsField::ReleaseDate, + ModelSettingsField::Status, + ModelSettingsField::CapabilitiesJson, + ModelSettingsField::Channel, + ModelSettingsField::BaseInstructions, + ModelSettingsField::ReasoningImplementation, + ModelSettingsField::ReasoningLevels, + ModelSettingsField::ReasoningVariantsJson, + ModelSettingsField::DefaultVariant, + ModelSettingsField::CostJson, + ModelSettingsField::MetadataJson, + ModelSettingsField::RequestJson, + ModelSettingsField::OptionsJson, + ModelSettingsField::HeadersJson, + ModelSettingsField::VariantsJson, + ModelSettingsField::WebSearchJson, + ModelSettingsField::WebFetchJson, + ModelSettingsField::TruncationMode, + ModelSettingsField::TruncationLimit, + ModelSettingsField::OriginalImageDetail, + ModelSettingsField::Enabled, + ModelSettingsField::Priority, + ]; + let index = advanced_fields + .iter() + .position(|field| *field == active_field)?; + let start = basic_line_count.saturating_add(3).saturating_add(index); + Some(ViewportAnchor { + start, + end: start.saturating_add(1), + }) + } + + #[allow(clippy::too_many_arguments)] + fn render_model_settings( + model: &str, + provider_name: &str, + request_model: &str, + display_name: &str, + invocation_method: ProviderWireApi, + default_reasoning_effort: Option<&str>, + settings: &ModelSettingsDraft, + advanced_open: bool, + active_field: ModelSettingsField, + input: &str, + cursor_pos: usize, + settings_error: Option<&str>, + area: Rect, + buf: &mut Buffer, + ) { + if area.height < 3 { + return; + } + let content_area = onboarding_content_area(area); + let [header_area, body_area, footer_area] = Layout::vertical([ + Constraint::Length(4), + Constraint::Fill(1), + Constraint::Length(2), + ]) + .areas(content_area); + let subtitle = format!( + "Profile: {model} / Keep common choices simple; expand advanced settings when needed." + ); + Self::render_onboarding_header("Configure model", &subtitle, "Settings", header_area, buf); + + let mut basic_lines = Vec::new(); + basic_lines.push(Line::from(vec![ + Span::styled("Model ID: ", Style::default().bold()), + Span::styled(request_model.to_string(), Style::default().dim()), + ])); + basic_lines.push(Line::from(vec![ + Span::styled("Provider: ", Style::default().bold()), + Span::styled(provider_name.to_string(), Style::default().dim()), + ])); + basic_lines.push(Line::from("")); + Self::render_setting_line( + &mut basic_lines, + ModelSettingsField::DisplayName, + "Display label", + display_name.to_string(), + active_field, + input, + cursor_pos, + ); + basic_lines.push(Line::from(vec![ + Span::styled(" Protocol: ", Style::default()), + Span::styled( + Self::invocation_method_label(invocation_method), + Style::default().dim(), + ), + ])); + Self::render_setting_line( + &mut basic_lines, + ModelSettingsField::ContextWindow, + "Context window (tokens)", + settings.context_window.clone(), + active_field, + input, + cursor_pos, + ); + basic_lines.push(Line::from(vec![Span::styled( + " Usable tokens; stored as % of model capacity. Occupancy and auto-compact follow it.", + Style::default().dim(), + )])); + Self::render_setting_line( + &mut basic_lines, + ModelSettingsField::MaxTokens, + "Max output (tokens)", + settings.max_tokens.clone(), + active_field, + input, + cursor_pos, + ); + Self::render_setting_line( + &mut basic_lines, + ModelSettingsField::Temperature, + "Temperature", + settings.temperature.clone(), + active_field, + input, + cursor_pos, + ); + Self::render_setting_line( + &mut basic_lines, + ModelSettingsField::InputModalities, + "Input modalities (text, image)", + settings.input_modalities.clone(), + active_field, + input, + cursor_pos, + ); + Self::render_setting_line( + &mut basic_lines, + ModelSettingsField::ReasoningCapability, + "Reasoning capability (unsupported, toggle, levels)", + settings.reasoning_capability.clone(), + active_field, + input, + cursor_pos, + ); + Self::render_setting_line( + &mut basic_lines, + ModelSettingsField::DefaultReasoning, + "Default reasoning (enabled, disabled, or effort)", + default_reasoning_effort.unwrap_or_default().to_string(), + active_field, + input, + cursor_pos, + ); + basic_lines.push(Line::from("")); + Self::render_setting_line( + &mut basic_lines, + ModelSettingsField::AdvancedToggle, + "Advanced settings", + if advanced_open { + "[open]".to_string() + } else { + "[closed] optional overrides".to_string() + }, + active_field, + input, + cursor_pos, + ); + + let mut advanced_lines = Vec::new(); + if advanced_open { + advanced_lines.push(Line::from(vec![Span::styled( + "Optional provider defaults", + Style::default().bold(), + )])); + advanced_lines.push(Line::from(vec![Span::styled( + "Leave blank for defaults. JSON: request/options/headers/variants are provider escape hatches.", + Style::default().dim(), + )])); + advanced_lines.push(Line::from("")); + Self::render_setting_line( + &mut advanced_lines, + ModelSettingsField::TopP, + "Top P", + settings.top_p.clone(), + active_field, + input, + cursor_pos, + ); + Self::render_setting_line( + &mut advanced_lines, + ModelSettingsField::TopK, + "Top K", + settings.top_k.clone(), + active_field, + input, + cursor_pos, + ); + Self::render_setting_line( + &mut advanced_lines, + ModelSettingsField::Family, + "Family", + settings.family.clone(), + active_field, + input, + cursor_pos, + ); + Self::render_setting_line( + &mut advanced_lines, + ModelSettingsField::ReleaseDate, + "Release date", + settings.release_date.clone(), + active_field, + input, + cursor_pos, + ); + Self::render_setting_line( + &mut advanced_lines, + ModelSettingsField::Status, + "Status", + settings.status.clone(), + active_field, + input, + cursor_pos, + ); + Self::render_setting_line( + &mut advanced_lines, + ModelSettingsField::CapabilitiesJson, + "Capabilities JSON", + settings.capabilities_json.clone(), + active_field, + input, + cursor_pos, + ); + Self::render_setting_line( + &mut advanced_lines, + ModelSettingsField::Channel, + "Channel", + settings.channel.clone(), + active_field, + input, + cursor_pos, + ); + Self::render_setting_line( + &mut advanced_lines, + ModelSettingsField::BaseInstructions, + "Base instructions", + settings.base_instructions.clone(), + active_field, + input, + cursor_pos, + ); + Self::render_setting_line( + &mut advanced_lines, + ModelSettingsField::ReasoningImplementation, + "Reasoning implementation (legacy; prefer Variants JSON)", + settings.reasoning_implementation.clone(), + active_field, + input, + cursor_pos, + ); + Self::render_setting_line( + &mut advanced_lines, + ModelSettingsField::ReasoningLevels, + "Reasoning levels (off, low, medium, high, max)", + settings.reasoning_levels.clone(), + active_field, + input, + cursor_pos, + ); + Self::render_setting_line( + &mut advanced_lines, + ModelSettingsField::ReasoningVariantsJson, + "Reasoning variant rules JSON (legacy model_variant)", + settings.reasoning_variants_json.clone(), + active_field, + input, + cursor_pos, + ); + Self::render_setting_line( + &mut advanced_lines, + ModelSettingsField::DefaultVariant, + "Default variant (static fallback when effort has no matching key)", + settings.default_variant.clone(), + active_field, + input, + cursor_pos, + ); + Self::render_setting_line( + &mut advanced_lines, + ModelSettingsField::CostJson, + "Cost JSON", + settings.cost_json.clone(), + active_field, + input, + cursor_pos, + ); + Self::render_setting_line( + &mut advanced_lines, + ModelSettingsField::MetadataJson, + "Metadata JSON", + settings.metadata_json.clone(), + active_field, + input, + cursor_pos, + ); + Self::render_setting_line( + &mut advanced_lines, + ModelSettingsField::RequestJson, + "Request JSON", + settings.request_json.clone(), + active_field, + input, + cursor_pos, + ); + Self::render_setting_line( + &mut advanced_lines, + ModelSettingsField::OptionsJson, + "Options JSON", + settings.options_json.clone(), + active_field, + input, + cursor_pos, + ); + Self::render_setting_line( + &mut advanced_lines, + ModelSettingsField::HeadersJson, + "Headers JSON", + settings.headers_json.clone(), + active_field, + input, + cursor_pos, + ); + Self::render_setting_line( + &mut advanced_lines, + ModelSettingsField::VariantsJson, + "Variants JSON (keys = off/on/effort; request_model/request/options/headers)", + settings.variants_json.clone(), + active_field, + input, + cursor_pos, + ); + Self::render_setting_line( + &mut advanced_lines, + ModelSettingsField::WebSearchJson, + "Web search JSON", + settings.web_search_json.clone(), + active_field, + input, + cursor_pos, + ); + Self::render_setting_line( + &mut advanced_lines, + ModelSettingsField::WebFetchJson, + "Web fetch JSON", + settings.web_fetch_json.clone(), + active_field, + input, + cursor_pos, + ); + Self::render_setting_line( + &mut advanced_lines, + ModelSettingsField::TruncationMode, + "Truncation mode", + settings.truncation_mode.clone(), + active_field, + input, + cursor_pos, + ); + Self::render_setting_line( + &mut advanced_lines, + ModelSettingsField::TruncationLimit, + "Truncation limit", + settings.truncation_limit.clone(), + active_field, + input, + cursor_pos, + ); + Self::render_setting_line( + &mut advanced_lines, + ModelSettingsField::OriginalImageDetail, + "Original image detail", + settings + .supports_image_detail_original + .map_or_else(|| "default".to_string(), |value| value.to_string()), + active_field, + input, + cursor_pos, + ); + Self::render_setting_line( + &mut advanced_lines, + ModelSettingsField::Enabled, + "Enabled", + settings + .enabled + .map_or_else(|| "default".to_string(), |value| value.to_string()), + active_field, + input, + cursor_pos, + ); + Self::render_setting_line( + &mut advanced_lines, + ModelSettingsField::Priority, + "Priority", + settings.priority.clone(), + active_field, + input, + cursor_pos, + ); + } + + if let Some(error) = settings_error { + basic_lines.push(Line::from(vec![ + Span::styled("Error: ", Style::default().red().bold()), + Span::styled(error.to_string(), Style::default().red()), + ])); + } + + let basic_anchor = Self::model_settings_basic_anchor(active_field); + let advanced_anchor = Self::model_settings_advanced_anchor(active_field, 0); + if body_area.width >= 88 { + let [basic_area, advanced_area] = + Layout::horizontal([Constraint::Percentage(44), Constraint::Fill(1)]) + .areas(body_area); + render_lines_with_anchor(basic_lines, basic_anchor, basic_area, buf); + render_lines_with_anchor(advanced_lines, advanced_anchor, advanced_area, buf); + } else { + let advanced_anchor = advanced_anchor.map(|anchor| ViewportAnchor { + start: basic_lines.len().saturating_add(anchor.start), + end: basic_lines.len().saturating_add(anchor.end), + }); + basic_lines.extend(advanced_lines); + let anchor = if advanced_open { + advanced_anchor + } else { + basic_anchor + }; + render_lines_with_anchor(basic_lines, anchor, body_area, buf); + } + + let footer = if advanced_open { + vec![Line::from(vec![ + Span::styled("Enter", Style::default().bold()), + Span::styled(" next ", Style::default().dim()), + Span::styled("Space", Style::default().bold()), + Span::styled(" toggle ", Style::default().dim()), + Span::styled("Esc", Style::default().bold()), + Span::styled(" back", Style::default().dim()), + ])] + } else { + vec![Line::from(vec![ + Span::styled("Enter", Style::default().bold()), + Span::styled(" review ", Style::default().dim()), + Span::styled("Tab", Style::default().bold()), + Span::styled(" edit common fields ", Style::default().dim()), + Span::styled("Space", Style::default().bold()), + Span::styled(" expand advanced ", Style::default().dim()), + Span::styled("Esc", Style::default().bold()), + Span::styled(" back", Style::default().dim()), + ])] + }; + Paragraph::new(footer).render(footer_area, buf); + } + + fn render_review(params: &ValidationParams, area: Rect, buf: &mut Buffer) { + if area.height < 3 { + return; + } + let content_area = onboarding_content_area(area); + let [header_area, body_area, footer_area] = Layout::vertical([ + Constraint::Length(4), + Constraint::Fill(1), + Constraint::Length(2), + ]) + .areas(content_area); + Self::render_onboarding_header( + "Review model setup", + "Everything looks ready. Confirm to test the connection and save it.", + "Review", + header_area, + buf, + ); + let credential = if params.api_key.is_some() { + "configured" + } else { + "environment or existing auth" + }; + let advanced = if params.model_settings.is_some() { + "custom overrides" + } else { + "catalog defaults" + }; + let lines = vec![ + Line::from(vec![ + Span::styled("Provider ", Style::default().bold()), + Span::raw(params.provider_name.clone()), + ]), + Line::from(vec![ + Span::styled("Model profile ", Style::default().bold()), + Span::raw(params.model_slug.clone()), + ]), + Line::from(vec![ + Span::styled("Request model ", Style::default().bold()), + Span::raw(params.request_model.clone()), + ]), + Line::from(vec![ + Span::styled("Protocol ", Style::default().bold()), + Span::raw(Self::invocation_method_label(params.invocation_method)), + ]), + Line::from(vec![ + Span::styled("Reasoning ", Style::default().bold()), + Span::raw( + params + .default_reasoning_effort + .as_deref() + .unwrap_or("unsupported"), + ), + ]), + Line::from(vec![ + Span::styled("Credential ", Style::default().bold()), + Span::styled(credential, Style::default().green()), + ]), + Line::from(vec![ + Span::styled("Model settings ", Style::default().bold()), + Span::raw(advanced), + ]), + ]; + Paragraph::new(lines) + .wrap(Wrap { trim: false }) + .render(body_area, buf); + Paragraph::new(vec![Line::from(vec![ + Span::styled("Enter", Style::default().bold()), + Span::styled(" test & save ", Style::default().dim()), + Span::styled("Esc", Style::default().bold()), + Span::styled(" edit settings", Style::default().dim()), + ])]) + .render(footer_area, buf); + } + + fn render_provider_selection( + items: &[ProviderSelectionItem], + selected_idx: usize, + focus: SelectionFocus, area: Rect, buf: &mut Buffer, ) { @@ -2074,111 +5431,150 @@ impl OnboardingWidget { return; } let content_area = onboarding_content_area(area); - let mut lines: Vec> = Vec::new(); + let [header_area, body_area, footer_area] = Layout::vertical([ + Constraint::Length(4), + Constraint::Fill(1), + Constraint::Length(2), + ]) + .areas(content_area); + Self::render_onboarding_header( + "Choose a provider", + "Select a configured endpoint, or add your own provider.", + "Provider", + header_area, + buf, + ); + + let (list_area, custom_area) = Self::selection_areas(body_area); + let mut lines = Vec::new(); + let connection_count = items + .iter() + .filter(|item| item.section == ProviderSelectionSection::Connections) + .count(); + let template_count = items + .iter() + .filter(|item| item.section == ProviderSelectionSection::Templates) + .count(); lines.push(Line::from(vec![Span::styled( - "Choose model profile", + "Connections", Style::default().bold(), )])); + if connection_count == 0 { + lines.push(Line::from(vec![ + Span::raw(" "), + Span::styled("No saved Connections yet.", Style::default().dim()), + ])); + } else { + for (idx, item) in items.iter().enumerate() { + if item.section == ProviderSelectionSection::Connections { + Self::render_option_row( + &mut lines, + item.label.clone(), + Some(item.description.clone()), + idx == selected_idx && focus == SelectionFocus::List, + ); + } + } + } + + lines.push(Line::from("")); lines.push(Line::from(vec![Span::styled( - "Type to filter built-in model capabilities.", - Style::default().dim(), + "Provider templates", + Style::default().bold(), )])); - lines.push(Line::from("")); - - if search_query.is_empty() { + if template_count == 0 { lines.push(Line::from(vec![Span::styled( - "filter: all", + "No provider templates available.", Style::default().dim(), )])); } else { - lines.push(Line::from(vec![ - Span::styled("filter: ", Style::default().dim()), - Span::styled(search_query.to_string(), Style::default()), - ])); - } - lines.push(Line::from("")); - - let max_visible = MAX_POPUP_ROWS.min(filtered_indices.len().max(1)); - let scroll_offset = state - .scroll_top - .min(filtered_indices.len().saturating_sub(max_visible)); - let has_more_above = scroll_offset > 0; - let has_more_below = scroll_offset + max_visible < filtered_indices.len(); - let mut anchor = None; - - if has_more_above { - lines.push(Self::scroll_overflow_line(/*more_above*/ true)); - } - for (vis_idx, &actual_idx) in filtered_indices - .iter() - .enumerate() - .skip(scroll_offset) - .take(max_visible) - { - if let Some(item) = items.get(actual_idx) { - let is_selected = state.selected_idx == Some(vis_idx); - let start = lines.len(); - // Slug-only rows: display_name is used for filtering, not shown. - Self::render_option_row( - &mut lines, - item.slug.clone(), - /*description*/ None, - is_selected, - ); - if is_selected { - anchor = Some(ViewportAnchor { - start, - end: lines.len(), - }); + for (idx, item) in items.iter().enumerate() { + if item.section == ProviderSelectionSection::Templates { + Self::render_option_row( + &mut lines, + item.label.clone(), + Some(item.description.clone()), + idx == selected_idx && focus == SelectionFocus::List, + ); } } } - if has_more_below { - lines.push(Self::scroll_overflow_line(/*more_above*/ false)); - } - - let mut footer_lines = Vec::new(); - Self::render_footer(&mut footer_lines, "Enter select", "Esc cancel"); - render_lines_with_fixed_footer(lines, footer_lines, anchor, content_area, buf); + Paragraph::new(lines) + .wrap(Wrap { trim: false }) + .render(list_area, buf); + Self::render_custom_card( + "Add custom provider", + "Configure an endpoint, protocol and credential.", + focus == SelectionFocus::Custom, + custom_area, + buf, + ); + Paragraph::new(vec![Line::from(vec![ + Span::styled("Up/Down", Style::default().bold()), + Span::styled(" navigate ", Style::default().dim()), + Span::styled("Tab", Style::default().bold()), + Span::styled(" custom ", Style::default().dim()), + Span::styled("Enter", Style::default().bold()), + Span::styled(" select ", Style::default().dim()), + Span::styled("Esc", Style::default().bold()), + Span::styled(" cancel", Style::default().dim()), + Span::styled(" ", Style::default().dim()), + Span::styled("d/Delete", Style::default().bold()), + Span::styled(" disconnect selected Connection", Style::default().dim()), + ])]) + .render(footer_area, buf); } - fn render_custom_model_slug(input: &str, cursor_pos: usize, area: Rect, buf: &mut Buffer) { + fn render_disconnect_confirmation(provider: &ProviderInfo, area: Rect, buf: &mut Buffer) { if area.height < 3 { return; } let content_area = onboarding_content_area(area); - let mut lines: Vec> = Vec::new(); - - lines.push(Line::from(vec![Span::styled( - "Custom model profile", - Style::default().bold(), - )])); - lines.push(Line::from(vec![Span::styled( - "Enter the model slug to use as the local capability profile.", - Style::default().dim(), - )])); - lines.push(Line::from("")); - - let byte_pos = Self::byte_index_for_char(input, cursor_pos); - lines.push(Line::from(vec![ - Span::styled("› ", Style::default().cyan()), - Span::styled( - format!("{}▌{}", &input[..byte_pos], &input[byte_pos..]), - Style::default(), - ), - ])); - Self::render_footer(&mut lines, "Enter confirm", "Esc back"); + let mut lines = vec![ + Line::from(vec![Span::styled( + format!("Disconnect {}", provider.name), + Style::default().bold(), + )]), + Line::from(vec![Span::styled( + "This removes the saved Connection and its unshared credential.", + Style::default().dim(), + )]), + Line::from(vec![Span::styled( + "The provider directory template will remain available.", + Style::default().dim(), + )]), + Line::from(""), + ]; + Self::render_footer(&mut lines, "Enter disconnect", "Esc cancel"); + Paragraph::new(lines) + .wrap(Wrap { trim: false }) + .render(content_area, buf); + } + fn render_disconnecting(provider_name: &str, area: Rect, buf: &mut Buffer) { + if area.height < 3 { + return; + } + let content_area = onboarding_content_area(area); + let lines = vec![ + Line::from(vec![Span::styled( + format!("Disconnecting {provider_name}"), + Style::default().bold(), + )]), + Line::from(vec![Span::styled( + "Removing the user Connection and refreshing the provider directory...", + Style::default().dim(), + )]), + ]; Paragraph::new(lines) .wrap(Wrap { trim: false }) .render(content_area, buf); } - fn render_provider_selection( - _model: &str, - items: &[ProviderSelectionItem], - selected_idx: usize, + fn render_model_delete_confirmation( + model_name: &str, + provider_name: &str, area: Rect, buf: &mut Buffer, ) { @@ -2186,30 +5582,42 @@ impl OnboardingWidget { return; } let content_area = onboarding_content_area(area); - let mut lines: Vec> = Vec::new(); - - lines.push(Line::from(vec![Span::styled( - "Choose provider vendor", - Style::default().bold(), - )])); - lines.push(Line::from(vec![Span::styled( - "Select a configured endpoint, or add a new vendor. Wire API comes next.", - Style::default().dim(), - )])); - lines.push(Line::from("")); + let mut lines = vec![ + Line::from(vec![Span::styled( + format!("Remove {model_name} from {provider_name}"), + Style::default().bold(), + )]), + Line::from(vec![Span::styled( + "This removes the model from the saved Connection.", + Style::default().dim(), + )]), + Line::from(vec![Span::styled( + "The provider template and its built-in directory remain unchanged.", + Style::default().dim(), + )]), + Line::from(""), + ]; + Self::render_footer(&mut lines, "Enter remove", "Esc cancel"); + Paragraph::new(lines) + .wrap(Wrap { trim: false }) + .render(content_area, buf); + } - for (idx, item) in items.iter().enumerate() { - let is_selected = idx == selected_idx; - Self::render_option_row( - &mut lines, - item.label.clone(), - Some(item.description.clone()), - is_selected, - ); + fn render_model_deleting(model_name: &str, area: Rect, buf: &mut Buffer) { + if area.height < 3 { + return; } - - Self::render_footer(&mut lines, "Enter select", "Esc back"); - + let content_area = onboarding_content_area(area); + let lines = vec![ + Line::from(vec![Span::styled( + format!("Removing {model_name}"), + Style::default().bold(), + )]), + Line::from(vec![Span::styled( + "Updating the saved Connection…", + Style::default().dim(), + )]), + ]; Paragraph::new(lines) .wrap(Wrap { trim: false }) .render(content_area, buf); @@ -2271,7 +5679,7 @@ impl OnboardingWidget { Style::default().bold(), )])); lines.push(Line::from(vec![Span::styled( - "Choose the effort stored on this model binding.", + "Choose the effort stored on this model profile.", Style::default().dim(), )])); lines.push(Line::from("")); @@ -2306,28 +5714,28 @@ impl OnboardingWidget { let mut lines: Vec> = Vec::new(); lines.push(Line::from(vec![Span::styled( - "Testing provider binding", + "Testing Connection", Style::default().bold(), )])); lines.push(Line::from(vec![Span::styled( - format!("model: {model} · wire API: {provider_name}"), + format!("model: {model} / wire API: {provider_name}"), Style::default().dim(), )])); lines.push(Line::from("")); lines.push(Line::from(vec![ - Span::styled("│ ", Style::default().cyan()), + Span::styled("> ", Style::default().cyan()), spinner(Some(started_at), animations_enabled), Span::raw(" server validation in progress"), ])); lines.push(Line::from(vec![ - Span::styled("│ ", Style::default().cyan()), + Span::styled(" ", Style::default().cyan()), Span::styled( "resolving config, auth, provider SDK, and request model", Style::default().dim(), ), ])); lines.push(Line::from(vec![Span::styled( - format!("│ timeout: {remaining}s remaining"), + format!(" timeout: {remaining}s remaining"), Style::default().dim(), )])); Self::render_footer(&mut lines, "Esc cancel", ""); @@ -2354,25 +5762,25 @@ impl OnboardingWidget { let mut lines: Vec> = Vec::new(); lines.push(Line::from(vec![Span::styled( - "Saving provider binding", + "Saving Connection", Style::default().bold(), )])); lines.push(Line::from(vec![Span::styled( format!( - "model: {model} · request model: {request_model} · wire API: {provider_name}" + "model: {model} / request model: {request_model} / wire API: {provider_name}" ), Style::default().dim(), )])); lines.push(Line::from("")); lines.push(Line::from(vec![ - Span::styled("│ ", Style::default().cyan()), + Span::styled("> ", Style::default().cyan()), spinner(Some(started_at), animations_enabled), Span::raw(" waiting for server confirmation"), ])); lines.push(Line::from(vec![ - Span::styled("│ ", Style::default().cyan()), + Span::styled(" ", Style::default().cyan()), Span::styled( - "provider/upsert is persisting the provider and model binding", + "provider/upsert is persisting the Connection and model", Style::default().dim(), ), ])); @@ -2402,7 +5810,7 @@ impl OnboardingWidget { Style::default().bold().red(), )]), Line::from(vec![Span::styled( - "The server could not build or probe this provider binding.", + "The server could not build or probe this Connection.", Style::default().dim(), )]), Line::from(vec![Span::styled( @@ -2440,10 +5848,21 @@ impl OnboardingWidget { } match &self.state { OnboardingState::ModelSelection { .. } => self.model_selection_handle_key(key_event), - OnboardingState::CustomModelSlug { .. } => self.custom_model_slug_handle_key(key_event), + OnboardingState::CustomModelForm { .. } => self.custom_model_form_handle_key(key_event), OnboardingState::ProviderSelection { .. } => { self.provider_selection_handle_key(key_event) } + OnboardingState::ProviderSetup { .. } => self.provider_setup_handle_key(key_event), + OnboardingState::DisconnectConfirmation { .. } => { + self.disconnect_confirmation_handle_key(key_event) + } + OnboardingState::Disconnecting { .. } => {} + OnboardingState::ModelDeleteConfirmation { .. } => { + self.model_delete_confirmation_handle_key(key_event) + } + OnboardingState::ModelDeleting { .. } => {} + OnboardingState::ModelSettings { .. } => self.model_settings_handle_key(key_event), + OnboardingState::Review { .. } => self.review_handle_key(key_event), OnboardingState::InlineSetup { .. } => self.inline_setup_handle_key(key_event), OnboardingState::InvocationMethod { .. } => { self.invocation_method_handle_key(key_event) @@ -2484,8 +5903,32 @@ impl Renderable for OnboardingWidget { // title + hint + blank + filter + blank + options + overflow + footer spacing option_rows + overflow_rows + 9 } - OnboardingState::CustomModelSlug { .. } => 8, - OnboardingState::ProviderSelection { items, .. } => items.len() as u16 * 2 + 6, + OnboardingState::CustomModelForm { .. } => 12, + OnboardingState::ProviderSelection { items, .. } => { + let connection_count = items + .iter() + .filter(|item| item.section == ProviderSelectionSection::Connections) + .count(); + let template_count = items + .iter() + .filter(|item| item.section == ProviderSelectionSection::Templates) + .count(); + let empty_rows = u16::from(connection_count == 0) + u16::from(template_count == 0); + items.len() as u16 * 2 + 9 + empty_rows + } + OnboardingState::ProviderSetup { .. } => 14, + OnboardingState::DisconnectConfirmation { .. } => 9, + OnboardingState::Disconnecting { .. } => 7, + OnboardingState::ModelDeleteConfirmation { .. } => 9, + OnboardingState::ModelDeleting { .. } => 7, + OnboardingState::ModelSettings { advanced_open, .. } => { + if *advanced_open { + 46 + } else { + 20 + } + } + OnboardingState::Review { .. } => 15, OnboardingState::InlineSetup { model, .. } => { if self.model_supports_reasoning(model) { 31 @@ -2493,16 +5936,30 @@ impl Renderable for OnboardingWidget { 28 } } - OnboardingState::InvocationMethod { model, items, .. } => { - let base_height = if self.model_supports_reasoning(model) { + OnboardingState::InvocationMethod { + model, + initial_model_settings, + items, + .. + } => { + let base_height = if self + .model_supports_reasoning_with_settings(model, initial_model_settings.as_ref()) + { 31 } else { 28 }; base_height + items.len() as u16 * 2 } - OnboardingState::ReasoningEffort { model, items, .. } => { - let base_height = if self.model_supports_reasoning(model) { + OnboardingState::ReasoningEffort { + model, + initial_model_settings, + items, + .. + } => { + let base_height = if self + .model_supports_reasoning_with_settings(model, initial_model_settings.as_ref()) + { 31 } else { 28 @@ -2531,26 +5988,111 @@ impl Renderable for OnboardingWidget { state, search_query, filtered_indices, + focus, + manage_connection, + .. } => { Self::render_model_selection( items, state, search_query, filtered_indices, + *focus, + *manage_connection, area, buf, ); } - OnboardingState::CustomModelSlug { input, cursor_pos } => { - Self::render_custom_model_slug(input, *cursor_pos, area, buf); + OnboardingState::CustomModelForm { + provider, + model_id, + display_name, + active_field, + input, + cursor_pos, + .. + } => { + Self::render_custom_model_form( + &provider.provider_name, + model_id, + display_name, + *active_field, + input, + *cursor_pos, + area, + buf, + ); } OnboardingState::ProviderSelection { - model: _, - display_name: _, items, selected_idx, + focus, + } => { + Self::render_provider_selection(items, *selected_idx, *focus, area, buf); + } + OnboardingState::ProviderSetup { + draft, + active_field, + input, + cursor_pos, + } => { + Self::render_provider_setup(draft, *active_field, input, *cursor_pos, area, buf); + } + OnboardingState::DisconnectConfirmation { provider } => { + Self::render_disconnect_confirmation(provider, area, buf); + } + OnboardingState::Disconnecting { provider_name } => { + Self::render_disconnecting(provider_name, area, buf); + } + OnboardingState::ModelDeleteConfirmation { + provider, + model_name, + .. + } => { + Self::render_model_delete_confirmation( + model_name, + &provider.provider_name, + area, + buf, + ); + } + OnboardingState::ModelDeleting { model_name, .. } => { + Self::render_model_deleting(model_name, area, buf); + } + OnboardingState::ModelSettings { + model, + provider_name, + request_model, + display_name, + invocation_method, + default_reasoning_effort, + settings, + advanced_open, + active_field, + input, + cursor_pos, + settings_error, + .. } => { - Self::render_provider_selection("", items, *selected_idx, area, buf); + Self::render_model_settings( + model, + provider_name, + request_model, + display_name, + *invocation_method, + default_reasoning_effort.as_deref(), + settings, + *advanced_open, + *active_field, + input, + *cursor_pos, + settings_error.as_deref(), + area, + buf, + ); + } + OnboardingState::Review { params } => { + Self::render_review(params, area, buf); } OnboardingState::InlineSetup { model, @@ -2585,6 +6127,7 @@ impl Renderable for OnboardingWidget { } OnboardingState::InvocationMethod { model, + initial_model_settings, provider_name, provider_credential_id, base_url, @@ -2598,7 +6141,10 @@ impl Renderable for OnboardingWidget { Self::render_invocation_method_inline( &InlineSetupRenderParams { model, - supports_reasoning: self.model_supports_reasoning(model), + supports_reasoning: self.model_supports_reasoning_with_settings( + model, + initial_model_settings.as_ref(), + ), provider_name, provider_credential_id: provider_credential_id.as_deref(), base_url, @@ -2617,6 +6163,7 @@ impl Renderable for OnboardingWidget { } OnboardingState::ReasoningEffort { model, + initial_model_settings, provider_name, provider_credential_id, base_url, @@ -2631,7 +6178,10 @@ impl Renderable for OnboardingWidget { Self::render_reasoning_effort_inline( &InlineSetupRenderParams { model, - supports_reasoning: self.model_supports_reasoning(model), + supports_reasoning: self.model_supports_reasoning_with_settings( + model, + initial_model_settings.as_ref(), + ), provider_name, provider_credential_id: provider_credential_id.as_deref(), base_url, @@ -2708,3 +6258,216 @@ impl Renderable for OnboardingWidget { None } } + +#[cfg(test)] +mod model_settings_tests { + use pretty_assertions::assert_eq; + + use super::{ModelSettingsDraft, OnboardingWidget}; + + #[test] + fn model_settings_form_preserves_full_custom_model_profile() { + let draft = ModelSettingsDraft { + display_name: "Acme Reasoner".to_string(), + context_window: "200000".to_string(), + context_window_hard: "200000".to_string(), + max_tokens: "32000".to_string(), + temperature: "0.2".to_string(), + input_modalities: "text, image".to_string(), + reasoning_capability: "levels".to_string(), + reasoning_levels: "low, medium, high".to_string(), + effective_context_window_percent: String::new(), + top_p: "0.9".to_string(), + top_k: "40".to_string(), + family: "acme-reasoner".to_string(), + release_date: "2026-01-15".to_string(), + status: "active".to_string(), + capabilities_json: + r#"{"tools":true,"input":["text","image"],"output":["text"],"interleaved":"reasoning_content"}"# + .to_string(), + channel: "Acme".to_string(), + base_instructions: "Be precise.".to_string(), + reasoning_implementation: "model_variant".to_string(), + reasoning_variants_json: r#"[{"selection_value":"high","model_slug":"acme-reasoner-high","reasoning_effort":"high","label":"High","description":"Deliberate"}]"#.to_string(), + default_variant: "balanced".to_string(), + cost_json: r#"{"input":1,"output":2}"#.to_string(), + metadata_json: r#"{"source":"custom"}"#.to_string(), + request_json: r#"{"stream":true}"#.to_string(), + options_json: r#"{"timeout_ms":120000}"#.to_string(), + headers_json: r#"{"X-Model-Mode":"reasoning"}"#.to_string(), + variants_json: r#"{"balanced":{"label":"Balanced"},"fast":{"label":"Fast","options":{"reasoning_effort":"low"}}}"#.to_string(), + web_search_json: r#"{"mode":"provider"}"#.to_string(), + web_fetch_json: r#"{"mode":"provider"}"#.to_string(), + truncation_mode: "tokens".to_string(), + truncation_limit: "4096".to_string(), + supports_image_detail_original: Some(true), + enabled: Some(true), + priority: "10".to_string(), + }; + + let value = draft + .to_value("acme-reasoner") + .expect("full profile should produce model settings"); + assert_eq!(draft.validation_error("acme-reasoner"), None); + assert_eq!(value["context_window"], 200000); + assert_eq!( + value["effective_context_window_percent"].as_f64(), + Some(100.0) + ); + assert_eq!( + value["reasoning_capability"], + serde_json::json!({"levels":["low", "medium", "high"]}) + ); + assert_eq!( + value["reasoning_implementation"]["model_variant"]["variants"][0]["model"], + "acme-reasoner-high" + ); + assert_eq!(value["request"]["stream"], true); + assert_eq!(value["capabilities"]["tools"], true); + assert_eq!(value["headers"]["X-Model-Mode"], "reasoning"); + assert_eq!( + value["variants"]["fast"]["options"]["reasoning_effort"], + "low" + ); + assert_eq!(value["web_search"]["mode"], "provider"); + assert_eq!(value["web_fetch"]["mode"], "provider"); + + let restored = ModelSettingsDraft::from_value(Some(&value), "acme-reasoner"); + assert_eq!(restored.context_window, draft.context_window); + assert_eq!(restored.effective_context_window_percent, ""); + assert_eq!(restored.reasoning_capability, draft.reasoning_capability); + assert_eq!(restored.reasoning_levels, draft.reasoning_levels); + assert_eq!(restored.request_json, draft.request_json); + assert_eq!(restored.variants_json, draft.variants_json); + } + + #[test] + fn model_settings_form_rejects_invalid_extension_json() { + let draft = ModelSettingsDraft { + request_json: "{not json}".to_string(), + ..ModelSettingsDraft::default() + }; + + assert_eq!( + draft.validation_error("custom-model"), + Some("Request JSON must be valid JSON".to_string()) + ); + } + + #[test] + fn model_settings_form_loads_effective_context_window_tokens() { + let value = serde_json::json!({ + "name": "Acme", + "context_window": 200_000, + "effective_context_window_percent": 95, + }); + let draft = ModelSettingsDraft::from_value(Some(&value), "acme"); + assert_eq!(draft.context_window, "190000"); + assert_eq!(draft.context_window_hard, "200000"); + assert_eq!(draft.effective_context_window_percent, ""); + + let saved = draft.to_value("acme").expect("settings value"); + assert_eq!(saved["context_window"], 200000); + assert_eq!( + saved["effective_context_window_percent"].as_f64(), + Some(95.0) + ); + } + + #[test] + fn model_settings_form_stores_absolute_as_percent_of_hard_window() { + let draft = ModelSettingsDraft { + context_window: "250000".to_string(), + context_window_hard: "1000000".to_string(), + ..ModelSettingsDraft::default() + }; + let saved = draft.to_value("flash").expect("settings value"); + assert_eq!(saved["context_window"], 1_000_000); + assert_eq!( + saved["effective_context_window_percent"].as_f64(), + Some(25.0) + ); + } + + #[test] + fn model_settings_form_stores_fractional_percent_precisely() { + let draft = ModelSettingsDraft { + context_window: "333333".to_string(), + context_window_hard: "1000000".to_string(), + ..ModelSettingsDraft::default() + }; + let saved = draft.to_value("flash").expect("settings value"); + assert_eq!(saved["context_window"], 1_000_000); + let percent = saved["effective_context_window_percent"] + .as_f64() + .expect("fractional percent"); + assert!((percent - 33.3333).abs() < 0.0001); + let restored = ModelSettingsDraft::from_value(Some(&saved), "flash"); + assert_eq!(restored.context_window, "333333"); + } + + #[test] + fn model_settings_form_rejects_non_positive_limits() { + let draft = ModelSettingsDraft { + context_window: "0".to_string(), + ..ModelSettingsDraft::default() + }; + assert_eq!( + draft.validation_error("custom-model"), + Some("Context window must be greater than 0".to_string()) + ); + + let draft = ModelSettingsDraft { + truncation_limit: "-1".to_string(), + ..ModelSettingsDraft::default() + }; + assert_eq!( + draft.validation_error("custom-model"), + Some("Truncation limit must be greater than 0".to_string()) + ); + } + + #[test] + fn model_settings_form_does_not_silently_ignore_reasoning_variant_rules() { + let draft = ModelSettingsDraft { + reasoning_implementation: "request_parameter".to_string(), + reasoning_variants_json: "[]".to_string(), + ..ModelSettingsDraft::default() + }; + assert_eq!( + draft.validation_error("custom-model"), + Some("Reasoning variant rules require model_variant implementation".to_string()) + ); + + let draft = ModelSettingsDraft { + reasoning_implementation: "model_variant".to_string(), + ..ModelSettingsDraft::default() + }; + assert_eq!( + draft.validation_error("custom-model"), + Some("Reasoning variant rules must be a valid JSON array".to_string()) + ); + } + + #[test] + fn default_reasoning_must_match_model_capability() { + let levels = ModelSettingsDraft { + reasoning_capability: "levels".to_string(), + reasoning_levels: "low, high".to_string(), + ..ModelSettingsDraft::default() + }; + assert_eq!( + OnboardingWidget::default_reasoning_error(&levels, &Some("medium".to_string())), + Some("Default reasoning must be one of the configured levels: low, high".to_string()) + ); + + let unsupported = ModelSettingsDraft { + reasoning_capability: "unsupported".to_string(), + ..ModelSettingsDraft::default() + }; + assert_eq!( + OnboardingWidget::default_reasoning_error(&unsupported, &Some("high".to_string())), + Some("Unsupported reasoning cannot have a default selection".to_string()) + ); + } +} diff --git a/crates/tui/src/onboarding_widget_tests.rs b/crates/tui/src/onboarding_widget_tests.rs index f7ee26e6..1ac57db6 100644 --- a/crates/tui/src/onboarding_widget_tests.rs +++ b/crates/tui/src/onboarding_widget_tests.rs @@ -1,11 +1,13 @@ +use std::collections::BTreeMap; + use crossterm::event::KeyCode; use crossterm::event::KeyEvent; use crossterm::event::KeyEventKind; use crossterm::event::KeyEventState; use crossterm::event::KeyModifiers; use devo_protocol::Model; -use devo_protocol::ProviderModelBinding; -use devo_protocol::ProviderVendor; +use devo_protocol::ProviderInfo; +use devo_protocol::ProviderModelInfo; use devo_protocol::ProviderWireApi; use devo_protocol::ReasoningCapability; use devo_protocol::ReasoningEffort; @@ -54,13 +56,6 @@ fn type_text(widget: &mut OnboardingWidget, text: &str) { } } -fn command_payload(command: &str, prefix: &str) -> serde_json::Value { - let payload = command - .strip_prefix(prefix) - .expect("command should have expected prefix"); - serde_json::from_str(payload).expect("command payload should be JSON") -} - fn rendered_rows(widget: &OnboardingWidget, width: u16, height: u16) -> Vec { let area = ratatui::layout::Rect::new(0, 0, width, height); let mut buf = ratatui::buffer::Buffer::empty(area); @@ -74,59 +69,82 @@ fn rendered_rows(widget: &OnboardingWidget, width: u16, height: u16) -> Vec) -> String { - loop { - if let AppEvent::Command(AppCommand::RunUserShellCommand { command }) = - app_event_rx.try_recv().expect("expected queued app event") - { - return command; - } +fn next_command(app_event_rx: &mut mpsc::UnboundedReceiver) -> AppCommand { + match app_event_rx.try_recv().expect("expected queued app event") { + AppEvent::Command(command) => command, + event => panic!("expected queued app command, got {event:?}"), + } +} + +fn next_provider_validate( + app_event_rx: &mut mpsc::UnboundedReceiver, +) -> devo_protocol::native::rpc_admin::ProviderValidateParams { + match next_command(app_event_rx) { + AppCommand::ProviderValidate { params } => params, + command => panic!("expected provider validation command, got {command:?}"), + } +} + +fn next_provider_upsert( + app_event_rx: &mut mpsc::UnboundedReceiver, +) -> devo_protocol::native::rpc_admin::ProviderUpsertParams { + match next_command(app_event_rx) { + AppCommand::ProviderUpsert { params } => params, + command => panic!("expected provider upsert command, got {command:?}"), } } fn deepseek_model() -> Model { - devo_core::ModelPreset { + Model { slug: "deepseek-v4-flash".to_string(), display_name: "Deepseek V4 Flash".to_string(), - reasoning_capability: ReasoningCapability::Toggle, - supported_reasoning_levels: vec![ReasoningEffort::High, ReasoningEffort::Max], + reasoning_capability: ReasoningCapability::Levels(devo_protocol::levels_with_leading_off( + [ReasoningEffort::High, ReasoningEffort::Max], + )), default_reasoning_effort: Some(ReasoningEffort::High), - ..devo_core::ModelPreset::default() + ..Model::default() } - .into() } fn toggle_only_model() -> Model { - devo_core::ModelPreset { + Model { slug: "laguna-s-2.1".to_string(), display_name: "laguna-s-2.1".to_string(), reasoning_capability: ReasoningCapability::Toggle, - supported_reasoning_levels: Vec::new(), default_reasoning_effort: Some(ReasoningEffort::Medium), - ..devo_core::ModelPreset::default() + ..Model::default() } - .into() } -fn toggle_only_provider_vendor() -> ProviderVendor { - ProviderVendor { +fn toggle_only_provider() -> ProviderInfo { + ProviderInfo { + id: "poolside".to_string(), name: "Poolside".to_string(), + description: None, base_url: Some("https://api.poolside.ai".to_string()), credential: Some("poolside_api_key".to_string()), - headers: None, + headers: BTreeMap::new(), + options: None, + request: None, wire_apis: vec![ProviderWireApi::OpenAIChatCompletions], enabled: true, + models: BTreeMap::new(), } } -fn deepseek_provider_vendor() -> ProviderVendor { - ProviderVendor { +fn deepseek_provider() -> ProviderInfo { + ProviderInfo { + id: "deepseek".to_string(), name: "Deepseek".to_string(), + description: None, base_url: Some("https://api.deepseek.com".to_string()), credential: Some("deepseek_api_key".to_string()), - headers: None, + headers: BTreeMap::new(), + options: None, + request: None, wire_apis: vec![ProviderWireApi::OpenAIChatCompletions], enabled: true, + models: BTreeMap::new(), } } @@ -139,12 +157,9 @@ fn widget_at_invocation_method_popup() -> OnboardingWidget { FrameRequester::test_dummy(), true, ); - assert_eq!( - next_shell_command(&mut app_event_rx), - "provider list".to_string() - ); + assert_eq!(next_command(&mut app_event_rx), AppCommand::ProviderList); - widget.on_provider_vendors_listed(vec![deepseek_provider_vendor()]); + widget.on_providers_listed(vec![deepseek_provider()]); widget.handle_key_event(press(KeyCode::Enter)); widget.handle_key_event(press(KeyCode::Enter)); widget.handle_key_event(press(KeyCode::Enter)); @@ -167,21 +182,15 @@ fn failed_validation_widget() -> (OnboardingWidget, mpsc::UnboundedReceiver (OnboardingWidget, mpsc::UnboundedReceiv FrameRequester::test_dummy(), true, ); - assert_eq!( - next_shell_command(&mut app_event_rx), - "provider list".to_string() - ); + assert_eq!(next_command(&mut app_event_rx), AppCommand::ProviderList); - widget.on_provider_vendors_listed(vec![deepseek_provider_vendor()]); + widget.on_providers_listed(vec![deepseek_provider()]); widget.handle_key_event(press(KeyCode::Enter)); widget.handle_key_event(press(KeyCode::Enter)); for _ in 0.."deepseek-v4-flash".chars().count() { @@ -211,6 +217,11 @@ fn edited_existing_provider_widget() -> (OnboardingWidget, mpsc::UnboundedReceiv widget.handle_key_event(press(KeyCode::Enter)); widget.handle_key_event(press(KeyCode::Enter)); widget.handle_key_event(press(KeyCode::Enter)); + widget.handle_key_event(press(KeyCode::Enter)); + widget.handle_key_event(press(KeyCode::Enter)); + widget.handle_key_event(press(KeyCode::Enter)); + widget.handle_key_event(press(KeyCode::Enter)); + widget.handle_key_event(press(KeyCode::Enter)); (widget, app_event_rx) } @@ -224,12 +235,9 @@ fn edited_display_name_widget() -> (OnboardingWidget, mpsc::UnboundedReceiver (OnboardingWidget, mpsc::UnboundedReceiver Add model anyway"), true); assert_eq!(view.contains(" Retry with current settings"), true); } @@ -288,28 +296,35 @@ fn onboarding_validation_failure_defaults_to_add_model_anyway() { fn onboarding_existing_provider_validation_payload_preserves_edited_model_name() { let (_widget, mut app_event_rx) = edited_existing_provider_widget(); - let command = next_shell_command(&mut app_event_rx); - let payload = command_payload(&command, "onboard "); - - assert_eq!(payload["model_slug"], "deepseek-v4-flash"); - assert_eq!(payload["request_model"], "DeepSeek-V4-Flash"); - assert_eq!(payload["display_name"], "Deepseek V4 Flash"); + let params = next_provider_validate(&mut app_event_rx); + assert_eq!(params.provider.id, "deepseek"); + assert_eq!(params.model, "DeepSeek-V4-Flash"); + assert_eq!(params.api_key, None); + assert_eq!( + params.provider.models["DeepSeek-V4-Flash"].name, + Some("Deepseek V4 Flash".to_string()) + ); } #[test] fn onboarding_existing_provider_bypass_payload_preserves_edited_model_name() { let (mut widget, mut app_event_rx) = edited_existing_provider_widget(); - let _ = next_shell_command(&mut app_event_rx); + let _ = next_provider_validate(&mut app_event_rx); widget.on_validation_failed("probe failed".to_string(), /*recovery_hint*/ None); widget.handle_key_event(press(KeyCode::Enter)); - let command = next_shell_command(&mut app_event_rx); - let payload = command_payload(&command, "onboard-skip-validation "); - - assert_eq!(payload["model_slug"], "deepseek-v4-flash"); - assert_eq!(payload["request_model"], "DeepSeek-V4-Flash"); - assert_eq!(payload["display_name"], "Deepseek V4 Flash"); + let params = next_provider_upsert(&mut app_event_rx); + assert_eq!(params.provider.id, "deepseek"); + assert_eq!( + params.default_model, + Some("deepseek/DeepSeek-V4-Flash".to_string()) + ); + assert_eq!(params.api_key, None); + assert_eq!( + params.provider.models["DeepSeek-V4-Flash"].name, + Some("Deepseek V4 Flash".to_string()) + ); assert_eq!(widget.take_result(), None); } @@ -317,12 +332,12 @@ fn onboarding_existing_provider_bypass_payload_preserves_edited_model_name() { fn onboarding_existing_provider_validation_payload_preserves_edited_display_name() { let (_widget, mut app_event_rx) = edited_display_name_widget(); - let command = next_shell_command(&mut app_event_rx); - let payload = command_payload(&command, "onboard "); - - assert_eq!(payload["model_slug"], "deepseek-v4-flash"); - assert_eq!(payload["request_model"], "deepseek-v4-flash"); - assert_eq!(payload["display_name"], "DeepSeek V4 Flash Custom"); + let params = next_provider_validate(&mut app_event_rx); + assert_eq!(params.model, "deepseek-v4-flash"); + assert_eq!( + params.provider.models["deepseek-v4-flash"].name, + Some("DeepSeek V4 Flash Custom".to_string()) + ); } #[test] @@ -331,26 +346,20 @@ fn onboarding_validation_failure_can_bypass_validation() { widget.handle_key_event(press(KeyCode::Enter)); - let command = next_shell_command(&mut app_event_rx); - assert_eq!(command.starts_with("onboard-skip-validation "), true); + let params = next_provider_upsert(&mut app_event_rx); + assert_eq!( + params.default_model, + Some("deepseek/deepseek-v4-flash".to_string()) + ); assert_eq!(widget.take_result(), None); - widget.on_provider_saved(Some(&ProviderModelBinding { - binding_id: "deepseek-v4-flash-deepseek".to_string(), - model_slug: "deepseek-v4-flash".to_string(), - provider: "Deepseek".to_string(), - request_model: "deepseek-v4-flash".to_string(), - display_name: Some("deepseek-v4-flash".to_string()), - invocation_method: ProviderWireApi::OpenAIChatCompletions, - default_reasoning_effort: Some("high".to_string()), - enabled: true, - })); + widget.on_provider_upserted(&deepseek_provider(), Some("deepseek/deepseek-v4-flash")); assert_eq!( widget.take_result(), Some(OnboardingResult::ValidationBypassed { model_slug: "deepseek-v4-flash".to_string(), request_model: "deepseek-v4-flash".to_string(), - display_name: "deepseek-v4-flash".to_string(), + display_name: "Deepseek V4 Flash".to_string(), }) ); } @@ -362,9 +371,8 @@ fn onboarding_validation_failure_retry_still_validates() { widget.handle_key_event(press(KeyCode::Down)); widget.handle_key_event(press(KeyCode::Enter)); - let command = next_shell_command(&mut app_event_rx); - assert_eq!(command.starts_with("onboard "), true); - assert_eq!(command.starts_with("onboard-skip-validation "), false); + let command = next_command(&mut app_event_rx); + assert!(matches!(command, AppCommand::ProviderValidate { .. })); assert_eq!(widget.take_result(), None); } @@ -378,11 +386,8 @@ fn onboarding_settings_summary_masks_entered_api_key() { FrameRequester::test_dummy(), true, ); - assert_eq!( - next_shell_command(&mut app_event_rx), - "provider list".to_string() - ); - widget.on_provider_vendors_listed(Vec::new()); + assert_eq!(next_command(&mut app_event_rx), AppCommand::ProviderList); + widget.on_providers_listed(Vec::new()); widget.handle_key_event(press(KeyCode::Enter)); let _ = widget.take_transcript_events(); @@ -396,6 +401,10 @@ fn onboarding_settings_summary_masks_entered_api_key() { type_text(&mut widget, "secret-key"); widget.handle_key_event(press(KeyCode::Enter)); widget.handle_key_event(press(KeyCode::Enter)); + let _ = widget.take_transcript_events(); + widget.handle_key_event(press(KeyCode::Enter)); + widget.handle_key_event(press(KeyCode::Enter)); + widget.handle_key_event(press(KeyCode::Enter)); widget.handle_key_event(press(KeyCode::Enter)); widget.handle_key_event(press(KeyCode::Enter)); widget.handle_key_event(press(KeyCode::Enter)); @@ -416,6 +425,68 @@ fn onboarding_settings_summary_masks_entered_api_key() { assert!(!format!("{events:?}").contains("secret-key")); } +#[test] +fn onboarding_custom_provider_and_model_can_use_advanced_settings() { + let (app_event_tx, mut app_event_rx) = mpsc::unbounded_channel(); + let mut widget = OnboardingWidget::new( + &[], + AppEventSender::new(app_event_tx), + FrameRequester::test_dummy(), + true, + ); + assert_eq!(next_command(&mut app_event_rx), AppCommand::ProviderList); + widget.on_providers_listed(Vec::new()); + + widget.handle_key_event(press(KeyCode::Enter)); + let setup_view = rendered_rows(&widget, 100, 24).join("\n"); + assert!(setup_view.contains("Enter the connection details for this provider.")); + assert!(setup_view.contains("Stored securely in auth.json.")); + for glyph in ["█", "╚", "╝", "═", "▌", "─", "│", "●"] { + assert!( + !setup_view.contains(glyph), + "unexpected decorative glyph: {glyph}" + ); + } + type_text(&mut widget, "Acme Gateway"); + widget.handle_key_event(press(KeyCode::Enter)); + type_text(&mut widget, "https://api.example.com/v1"); + widget.handle_key_event(press(KeyCode::Enter)); + widget.handle_key_event(press(KeyCode::Enter)); + + widget.handle_key_event(press(KeyCode::Enter)); + type_text(&mut widget, "custom-model"); + let custom_model_form = rendered_rows(&widget, 100, 24).join(" "); + assert!(custom_model_form.contains("Add a custom model")); + assert!(custom_model_form.contains("Provider model ID")); + assert!(custom_model_form.contains("Display name")); + assert!(!custom_model_form.contains("model slug")); + widget.handle_key_event(press(KeyCode::Enter)); + type_text(&mut widget, "Custom Model"); + widget.handle_key_event(press(KeyCode::Enter)); + widget.handle_key_event(press(KeyCode::Enter)); + widget.handle_key_event(press(KeyCode::Enter)); + widget.handle_key_event(press(KeyCode::Enter)); + widget.handle_key_event(press(KeyCode::Char(' '))); + type_text(&mut widget, "128000"); + for _ in 0..33 { + widget.handle_key_event(press(KeyCode::Enter)); + } + widget.handle_key_event(press(KeyCode::Enter)); + + let params = next_provider_validate(&mut app_event_rx); + assert_eq!(params.provider.id, "acme-gateway"); + assert_eq!(params.model, "custom-model"); + assert_eq!(params.api_key, None); + assert_eq!( + params.provider.models["custom-model"].name, + Some("Custom Model".to_string()) + ); + assert_eq!( + params.provider.models["custom-model"].context_window, + Some(128000) + ); +} + #[test] fn onboarding_existing_provider_renders_values_after_labels_and_masks_saved_key() { let models = vec![deepseek_model()]; @@ -426,12 +497,9 @@ fn onboarding_existing_provider_renders_values_after_labels_and_masks_saved_key( FrameRequester::test_dummy(), true, ); - assert_eq!( - next_shell_command(&mut app_event_rx), - "provider list".to_string() - ); + assert_eq!(next_command(&mut app_event_rx), AppCommand::ProviderList); - widget.on_provider_vendors_listed(vec![deepseek_provider_vendor()]); + widget.on_providers_listed(vec![deepseek_provider()]); widget.handle_key_event(press(KeyCode::Enter)); widget.handle_key_event(press(KeyCode::Enter)); @@ -478,12 +546,9 @@ fn onboarding_required_provider_name_and_base_url_do_not_advance_when_empty() { FrameRequester::test_dummy(), true, ); - assert_eq!( - next_shell_command(&mut app_event_rx), - "provider list".to_string() - ); + assert_eq!(next_command(&mut app_event_rx), AppCommand::ProviderList); - widget.on_provider_vendors_listed(Vec::new()); + widget.on_providers_listed(Vec::new()); widget.handle_key_event(press(KeyCode::Enter)); widget.handle_key_event(press(KeyCode::Enter)); @@ -520,12 +585,9 @@ fn onboarding_invocation_and_reasoning_popups_render_inline_and_use_model_preset FrameRequester::test_dummy(), true, ); - assert_eq!( - next_shell_command(&mut app_event_rx), - "provider list".to_string() - ); + assert_eq!(next_command(&mut app_event_rx), AppCommand::ProviderList); - widget.on_provider_vendors_listed(vec![deepseek_provider_vendor()]); + widget.on_providers_listed(vec![deepseek_provider()]); widget.handle_key_event(press(KeyCode::Enter)); widget.handle_key_event(press(KeyCode::Enter)); @@ -533,40 +595,39 @@ fn onboarding_invocation_and_reasoning_popups_render_inline_and_use_model_preset widget.handle_key_event(press(KeyCode::Enter)); let invocation_view = rendered_rows(&widget, 160, 60).join("\n"); - assert_eq!(invocation_view.contains("Configure provider binding"), true); + assert_eq!(invocation_view.contains("Configure Connection"), true); assert_eq!( invocation_view.contains("Invocation Method: OpenAI Chat Completions"), true ); - assert_eq!(invocation_view.contains("› OpenAI Chat Completions"), true); + assert_eq!(invocation_view.contains("> OpenAI Chat Completions"), true); widget.handle_key_event(press(KeyCode::Enter)); let reasoning_view = rendered_rows(&widget, 160, 60).join("\n"); assert_eq!(reasoning_view.contains("Reason Effort: High"), true); assert_eq!(reasoning_view.contains(" Off"), true); - assert_eq!(reasoning_view.contains("› High"), true); + assert_eq!(reasoning_view.contains("> High"), true); assert_eq!(reasoning_view.contains(" Max"), true); assert_eq!(reasoning_view.contains("Medium"), false); assert_eq!(reasoning_view.contains("XHigh"), false); + widget.handle_key_event(press(KeyCode::Enter)); + widget.handle_key_event(press(KeyCode::Enter)); + widget.handle_key_event(press(KeyCode::Enter)); widget.handle_key_event(press(KeyCode::Enter)); - let command = next_shell_command(&mut app_event_rx); - let payload = command - .strip_prefix("onboard ") - .expect("onboard command prefix"); - let payload: serde_json::Value = serde_json::from_str(payload).expect("valid onboarding json"); - + let params = next_provider_validate(&mut app_event_rx); assert_eq!( - payload["provider_credential_id"], - serde_json::Value::String("deepseek_api_key".to_string()) + params.provider.credential, + Some("deepseek_api_key".to_string()) ); + assert_eq!(params.model, "deepseek-v4-flash"); + assert_eq!(params.api_key, None); assert_eq!( - payload["default_reasoning_effort"], - serde_json::Value::String("high".to_string()) + params.provider.models["deepseek-v4-flash"].default_reasoning_selection, + Some("high".to_string()) ); - assert_eq!(payload["api_key"], serde_json::Value::Null); } #[test] @@ -579,12 +640,9 @@ fn onboarding_toggle_model_reasoning_popup_shows_off_and_on() { FrameRequester::test_dummy(), true, ); - assert_eq!( - next_shell_command(&mut app_event_rx), - "provider list".to_string() - ); + assert_eq!(next_command(&mut app_event_rx), AppCommand::ProviderList); - widget.on_provider_vendors_listed(vec![toggle_only_provider_vendor()]); + widget.on_providers_listed(vec![toggle_only_provider()]); widget.handle_key_event(press(KeyCode::Enter)); widget.handle_key_event(press(KeyCode::Enter)); widget.handle_key_event(press(KeyCode::Enter)); @@ -594,20 +652,18 @@ fn onboarding_toggle_model_reasoning_popup_shows_off_and_on() { let reasoning_view = rendered_rows(&widget, 160, 60).join("\n"); assert_eq!(reasoning_view.contains("Reason Effort: On"), true); assert_eq!(reasoning_view.contains(" Off"), true); - assert_eq!(reasoning_view.contains("› On"), true); + assert_eq!(reasoning_view.contains("> On"), true); assert_eq!(reasoning_view.contains("Medium"), false); + widget.handle_key_event(press(KeyCode::Enter)); + widget.handle_key_event(press(KeyCode::Enter)); + widget.handle_key_event(press(KeyCode::Enter)); widget.handle_key_event(press(KeyCode::Enter)); - let command = next_shell_command(&mut app_event_rx); - let payload = command - .strip_prefix("onboard ") - .expect("onboard command prefix"); - let payload: serde_json::Value = serde_json::from_str(payload).expect("valid onboarding json"); - + let params = next_provider_validate(&mut app_event_rx); assert_eq!( - payload["default_reasoning_effort"], - serde_json::Value::String("enabled".to_string()) + params.provider.models["laguna-s-2.1"].default_reasoning_selection, + Some("on".to_string()) ); } @@ -626,7 +682,7 @@ fn onboarding_invocation_popup_keeps_active_section_visible_when_short() { "expected invocation hint in short viewport:\n{invocation_view}" ); assert!( - invocation_view.contains("› OpenAI Chat Completions"), + invocation_view.contains("> OpenAI Chat Completions"), "expected selected invocation option in short viewport:\n{invocation_view}" ); } @@ -646,7 +702,212 @@ fn onboarding_reasoning_popup_keeps_active_section_visible_when_short_and_narrow "expected wrapped reasoning hint in short viewport:\n{reasoning_view}" ); assert!( - reasoning_view.contains("› High"), + reasoning_view.contains("> High"), "expected selected reasoning effort in short viewport:\n{reasoning_view}" ); } + +#[test] +fn unconnected_builtin_provider_collects_api_key_without_editing_template() { + let models = vec![deepseek_model()]; + let (app_event_tx, mut app_event_rx) = mpsc::unbounded_channel(); + let mut widget = OnboardingWidget::new( + &models, + AppEventSender::new(app_event_tx), + FrameRequester::test_dummy(), + true, + ); + let _ = app_event_rx.try_recv().expect("provider list command"); + widget.on_providers_listed_with_status( + vec![deepseek_provider()], + vec!["deepseek".to_string()], + Vec::new(), + ); + + widget.handle_key_event(press(KeyCode::Enter)); + let setup = rendered_rows(&widget, 120, 30).join("\n"); + assert!(setup.contains("Connect to Deepseek")); + assert!(setup.contains("Base URL: https://api.deepseek.com")); + assert!(setup.contains("Fixed by the provider directory template.")); + assert!(setup.contains("Enter once to create this Connection")); + type_text(&mut widget, "new-secret"); + let entered = rendered_rows(&widget, 120, 30).join("\n"); + assert!(entered.contains("API Key: **********|")); + assert!(!entered.contains("new-secret")); + + widget.handle_key_event(press(KeyCode::Enter)); + let model_selection = rendered_rows(&widget, 120, 30).join("\n"); + assert!(model_selection.contains("Choose a model")); +} + +#[test] +fn connected_builtin_provider_goes_to_model_selection_without_editing_connection() { + let models = vec![deepseek_model()]; + let (app_event_tx, mut app_event_rx) = mpsc::unbounded_channel(); + let mut widget = OnboardingWidget::new( + &models, + AppEventSender::new(app_event_tx), + FrameRequester::test_dummy(), + true, + ); + let _ = app_event_rx.try_recv().expect("provider list command"); + widget.on_providers_listed_with_status( + vec![deepseek_provider()], + vec!["deepseek".to_string()], + vec!["deepseek".to_string()], + ); + + widget.handle_key_event(press(KeyCode::Enter)); + let model_selection = rendered_rows(&widget, 120, 30).join("\n"); + assert!(model_selection.contains("Models in this Connection")); + assert!(!model_selection.contains("Connect to Deepseek")); +} + +#[test] +fn connection_model_screen_lists_only_saved_models_and_can_remove_one() { + let models = vec![deepseek_model()]; + let (app_event_tx, mut app_event_rx) = mpsc::unbounded_channel(); + let mut widget = OnboardingWidget::new( + &models, + AppEventSender::new(app_event_tx), + FrameRequester::test_dummy(), + true, + ); + let _ = app_event_rx.try_recv().expect("provider list command"); + widget.on_providers_listed_with_status_and_models( + vec![deepseek_provider()], + vec!["deepseek".to_string()], + vec!["deepseek".to_string()], + BTreeMap::from([( + "deepseek".to_string(), + BTreeMap::from([ + ( + "saved-model".to_string(), + ProviderModelInfo { + name: Some("Saved model".to_string()), + ..ProviderModelInfo::default() + }, + ), + ( + "second-model".to_string(), + ProviderModelInfo { + name: Some("Second model".to_string()), + ..ProviderModelInfo::default() + }, + ), + ]), + )]), + ); + + widget.handle_key_event(press(KeyCode::Enter)); + let model_selection = rendered_rows(&widget, 120, 30).join("\n"); + assert!(model_selection.contains("Models in this Connection")); + assert!(model_selection.contains("saved-model")); + assert!(model_selection.contains("second-model")); + assert!(model_selection.contains("Add custom model profile")); + assert!(!model_selection.contains("deepseek-v4-flash")); + + widget.handle_key_event(press(KeyCode::Char('d'))); + let confirmation = rendered_rows(&widget, 120, 20).join("\n"); + assert!(confirmation.contains("Remove Saved model from Deepseek")); + widget.handle_key_event(press(KeyCode::Enter)); + assert_eq!( + app_event_rx.try_recv().expect("remove model command"), + AppEvent::Command(AppCommand::RemoveProviderModel { + provider_id: "deepseek".to_string(), + model_id: "saved-model".to_string(), + }) + ); + + widget.on_provider_model_removed("deepseek", "saved-model"); + assert_eq!( + app_event_rx.try_recv().expect("provider refresh command"), + AppEvent::Command(AppCommand::ProviderList) + ); + let after_remove = rendered_rows(&widget, 120, 30).join("\n"); + assert!(!after_remove.contains("saved-model")); + assert!(after_remove.contains("second-model")); + assert!(after_remove.contains("Add custom model profile")); + + widget.handle_key_event(press(KeyCode::Tab)); + widget.handle_key_event(press(KeyCode::Enter)); + let custom_model = rendered_rows(&widget, 120, 20).join(" "); + assert!(custom_model.contains("Add a custom model")); + assert!(custom_model.contains("Provider model ID")); + assert!(custom_model.contains("Display name")); + widget.handle_key_event(press(KeyCode::Esc)); + let back_to_models = rendered_rows(&widget, 120, 30).join(" "); + assert!(back_to_models.contains("Models in this Connection")); +} + +#[test] +fn connected_provider_can_be_disconnected_without_removing_the_template() { + let models = vec![deepseek_model()]; + let (app_event_tx, mut app_event_rx) = mpsc::unbounded_channel(); + let mut widget = OnboardingWidget::new( + &models, + AppEventSender::new(app_event_tx), + FrameRequester::test_dummy(), + true, + ); + let _ = app_event_rx.try_recv().expect("provider list command"); + widget.on_providers_listed_with_status( + vec![deepseek_provider()], + vec!["deepseek".to_string()], + vec!["deepseek".to_string()], + ); + + let provider_selection = rendered_rows(&widget, 120, 30).join("\n"); + assert!(provider_selection.contains("Connections")); + assert!(provider_selection.contains("Provider templates")); + assert!(provider_selection.contains("Saved Connection · https://api.deepseek.com")); + assert!(provider_selection.contains("Read-only template · https://api.deepseek.com")); + + widget.handle_key_event(press(KeyCode::Char('d'))); + let confirmation = rendered_rows(&widget, 120, 20).join("\n"); + assert!(confirmation.contains("Disconnect Deepseek")); + widget.handle_key_event(press(KeyCode::Enter)); + assert_eq!( + app_event_rx.try_recv().expect("disconnect command"), + AppEvent::Command(AppCommand::DisconnectProvider { + provider_id: "deepseek".to_string(), + }) + ); + + widget.on_provider_disconnected("deepseek"); + assert_eq!( + app_event_rx.try_recv().expect("provider refresh command"), + AppEvent::Command(AppCommand::ProviderList) + ); + let disconnected = rendered_rows(&widget, 120, 20).join("\n"); + assert!(disconnected.contains("Provider templates")); + assert!(disconnected.contains("Read-only template · https://api.deepseek.com")); + assert!(disconnected.contains("No saved Connections yet.")); +} + +#[test] +fn provider_template_cannot_be_disconnected() { + let models = vec![deepseek_model()]; + let (app_event_tx, mut app_event_rx) = mpsc::unbounded_channel(); + let mut widget = OnboardingWidget::new( + &models, + AppEventSender::new(app_event_tx), + FrameRequester::test_dummy(), + true, + ); + let _ = app_event_rx.try_recv().expect("provider list command"); + widget.on_providers_listed_with_status( + vec![deepseek_provider()], + vec!["deepseek".to_string()], + vec!["deepseek".to_string()], + ); + + widget.handle_key_event(press(KeyCode::Down)); + widget.handle_key_event(press(KeyCode::Char('d'))); + + let view = rendered_rows(&widget, 120, 24).join("\n"); + assert!(view.contains("Connections")); + assert!(view.contains("Provider templates")); + assert!(!view.contains("Disconnect Deepseek")); + assert!(app_event_rx.try_recv().is_err()); +} diff --git a/crates/tui/src/transcript/model.rs b/crates/tui/src/transcript/model.rs index 6bd12429..76f84262 100644 --- a/crates/tui/src/transcript/model.rs +++ b/crates/tui/src/transcript/model.rs @@ -200,6 +200,7 @@ pub(crate) struct LiveTextCellModel { /// One committed transcript entry produced by the projector. #[derive(Debug, Clone)] +#[allow(clippy::large_enum_variant)] pub(crate) enum CommittedCellModel { Tool(ToolCellModel), Text(TextCellModel), diff --git a/crates/tui/src/transcript/presentation.rs b/crates/tui/src/transcript/presentation.rs index eb88b8e5..67714e32 100644 --- a/crates/tui/src/transcript/presentation.rs +++ b/crates/tui/src/transcript/presentation.rs @@ -155,27 +155,25 @@ pub(crate) fn tool_title_parts( }; } - if tool_name == Some("web_search") || tool_name == Some("websearch") { - if let Some(query) = input + if (tool_name == Some("web_search") || tool_name == Some("websearch")) + && let Some(query) = input .and_then(|value| value.get("query")) .and_then(serde_json::Value::as_str) - { - return ToolTitleParts { - verb: String::new(), - detail: format!("Web Search(\"{query}\")"), - }; - } + { + return ToolTitleParts { + verb: String::new(), + detail: format!("Web Search(\"{query}\")"), + }; } - if tool_name == Some("web_fetch") || tool_name == Some("webfetch") { - if let Some(url) = input + if (tool_name == Some("web_fetch") || tool_name == Some("webfetch")) + && let Some(url) = input .and_then(|value| value.get("url")) .and_then(serde_json::Value::as_str) - { - return ToolTitleParts { - verb: String::new(), - detail: format!("Web Fetch(\"{url}\")"), - }; - } + { + return ToolTitleParts { + verb: String::new(), + detail: format!("Web Fetch(\"{url}\")"), + }; } let tool_name = tool_name.unwrap_or("tool"); diff --git a/crates/tui/src/transcript/restore_session.rs b/crates/tui/src/transcript/restore_session.rs index 0aab3df6..236a02d6 100644 --- a/crates/tui/src/transcript/restore_session.rs +++ b/crates/tui/src/transcript/restore_session.rs @@ -250,11 +250,9 @@ fn edited_changes_from_history_item( if let Some(SessionHistoryMetadata::Edited { changes }) = &item.metadata { return Some(changes.clone()); } - item.tool_io.as_ref().and_then(|io| { - io.output - .as_ref() - .and_then(|output| parse_file_changes_from_json(output)) - }) + item.tool_io + .as_ref() + .and_then(|io| io.output.as_ref().and_then(parse_file_changes_from_json)) } fn parse_file_changes_from_json( diff --git a/crates/tui/src/worker.rs b/crates/tui/src/worker.rs index 04c06ea8..3a863cf9 100644 --- a/crates/tui/src/worker.rs +++ b/crates/tui/src/worker.rs @@ -24,8 +24,6 @@ use devo_core::TurnId; use devo_protocol::AgentToolPolicy; use devo_protocol::CommandExecParams; use devo_protocol::CommandExecProgram; -use devo_protocol::ProviderModelBinding; -use devo_protocol::ProviderVendor; use devo_protocol::ReferenceSearchId; use devo_protocol::ReferenceSearchSnapshot; use devo_protocol::SessionHistoryMetadata; @@ -68,6 +66,7 @@ mod history; mod item_dispatch; mod native_items; mod plan_items; + mod session_preview; mod session_restore; mod skills; @@ -324,23 +323,27 @@ enum OperationCommand { model: String, /// Optional provider base URL override. base_url: Option, - /// Optional provider API key override. + /// Transient provider API key input; persistence belongs to auth.json. api_key: Option, }, - /// Validates provider settings with a temporary probe request. + /// Validates a provider Connection with a temporary probe request. ValidateProvider { - provider_vendor: ProviderVendor, - model_binding: ProviderModelBinding, - api_key: Option, + params: devo_protocol::native::rpc_admin::ProviderValidateParams, }, - /// Request configured provider vendors from the server. - ListProviderVendors, - /// Add or update one provider vendor through the server. - UpsertProviderVendor { - provider_vendor: ProviderVendor, - model_binding: Option, - default_model_binding: Option, - api_key: Option, + /// Request configured provider Connections and directory templates from the server. + ListProviders, + /// Add or update one provider Connection through the server. + ProviderUpsert { + params: devo_protocol::native::rpc_admin::ProviderUpsertParams, + }, + /// Disconnect one user-created provider Connection. + DisconnectProvider { + provider_id: String, + }, + /// Remove one model from a user-created provider Connection. + RemoveProviderModel { + provider_id: String, + model_id: String, }, /// Request a session list from the server. ListSessions, @@ -677,19 +680,13 @@ impl QueryWorkerHandle { .map_err(|_| anyhow::anyhow!("interactive worker is no longer running")) } - /// Validates provider settings with a temporary probe request. + /// Validates a provider Connection with a temporary probe request. pub(crate) fn validate_provider( &self, - provider_vendor: ProviderVendor, - model_binding: ProviderModelBinding, - api_key: Option, + params: devo_protocol::native::rpc_admin::ProviderValidateParams, ) -> Result<()> { self.command_tx - .send(OperationCommand::ValidateProvider { - provider_vendor, - model_binding, - api_key, - }) + .send(OperationCommand::ValidateProvider { params }) .map_err(|_| anyhow::anyhow!("interactive worker is no longer running")) } @@ -698,27 +695,40 @@ impl QueryWorkerHandle { self.provider_validation_cancel.cancel(); } - /// Requests the current configured provider vendors from the background worker. - pub(crate) fn list_provider_vendors(&self) -> Result<()> { + /// Requests the current provider Connections and directory templates. + pub(crate) fn list_providers(&self) -> Result<()> { self.command_tx - .send(OperationCommand::ListProviderVendors) + .send(OperationCommand::ListProviders) .map_err(|_| anyhow::anyhow!("interactive worker is no longer running")) } - /// Adds or updates a provider vendor through the background worker. - pub(crate) fn upsert_provider_vendor( + /// Adds or updates a provider Connection through the background worker. + pub(crate) fn upsert_provider( &self, - provider_vendor: ProviderVendor, - model_binding: Option, - default_model_binding: Option, - api_key: Option, + params: devo_protocol::native::rpc_admin::ProviderUpsertParams, ) -> Result<()> { self.command_tx - .send(OperationCommand::UpsertProviderVendor { - provider_vendor, - model_binding, - default_model_binding, - api_key, + .send(OperationCommand::ProviderUpsert { params }) + .map_err(|_| anyhow::anyhow!("interactive worker is no longer running")) + } + + /// Disconnects one provider Connection through the server. + pub(crate) fn disconnect_provider(&self, provider_id: String) -> Result<()> { + self.command_tx + .send(OperationCommand::DisconnectProvider { provider_id }) + .map_err(|_| anyhow::anyhow!("interactive worker is no longer running")) + } + + /// Removes one model from a provider Connection through the server. + pub(crate) fn remove_provider_model( + &self, + provider_id: String, + model_id: String, + ) -> Result<()> { + self.command_tx + .send(OperationCommand::RemoveProviderModel { + provider_id, + model_id, }) .map_err(|_| anyhow::anyhow!("interactive worker is no longer running")) } @@ -1494,29 +1504,51 @@ async fn run_worker_inner( .await; } } - Some(OperationCommand::ValidateProvider { - provider_vendor, - model_binding, - api_key, - }) => { + Some(OperationCommand::ValidateProvider { params }) => { let cancellation = provider_validation_cancel.start(); - let validation = client.provider_validate( - devo_protocol::native::rpc_admin::ProviderValidateParams { - provider_vendor: provider_vendor.into(), - model_binding: model_binding.into(), - api_key, - }, - ); - tokio::pin!(validation); - let validation_result = tokio::select! { - result = &mut validation => Some(result), - _ = cancellation.cancelled() => None, + let upsert_params = + devo_protocol::native::rpc_admin::ProviderUpsertParams { + provider: params.provider.clone(), + default_model: Some(format!("{}/{}", params.provider.id, params.model)), + small_model: None, + api_key: params.api_key.clone(), + }; + let validation_result = { + let validation = client.provider_validate(params); + tokio::pin!(validation); + tokio::select! { + result = &mut validation => Some(result), + _ = cancellation.cancelled() => None, + } }; match validation_result { Some(Ok(result)) => { let _ = event_tx.send(WorkerEvent::ProviderValidationSucceeded { reply_preview: result.reply_preview, }); + match tokio::time::timeout( + Duration::from_secs(5), + client.provider_upsert(upsert_params), + ) + .await + { + Ok(Ok(result)) => { + let _ = event_tx.send(WorkerEvent::ProviderUpserted { + provider: result.provider, + default_model: result.default_model, + }); + } + Ok(Err(error)) => { + let _ = event_tx.send(WorkerEvent::ProviderUpsertFailed { + message: error.to_string(), + }); + } + Err(_) => { + let _ = event_tx.send(WorkerEvent::ProviderUpsertFailed { + message: "provider upsert request timed out".to_string(), + }); + } + } } Some(Err(error)) => { let message = error.to_string(); @@ -1530,7 +1562,7 @@ async fn run_worker_inner( None => {} } } - Some(OperationCommand::ListProviderVendors) => { + Some(OperationCommand::ListProviders) => { match tokio::time::timeout( Duration::from_secs(5), client.provider_list(), @@ -1538,12 +1570,11 @@ async fn run_worker_inner( .await { Ok(Ok(result)) => { - let _ = event_tx.send(WorkerEvent::ProviderVendorsListed { - provider_vendors: result - .providers - .into_iter() - .map(Into::into) - .collect(), + let _ = event_tx.send(WorkerEvent::ProvidersListed { + providers: result.providers, + template_provider_ids: result.template_provider_ids, + connected_provider_ids: result.connected_provider_ids, + connection_models: result.connection_models, }); } Ok(Err(error)) => { @@ -1574,44 +1605,93 @@ async fn run_worker_inner( } } } - Some(OperationCommand::UpsertProviderVendor { - provider_vendor, - model_binding, - default_model_binding, - api_key, + Some(OperationCommand::ProviderUpsert { params }) => { + match tokio::time::timeout( + Duration::from_secs(5), + client.provider_upsert(params), + ) + .await + { + Ok(Ok(result)) => { + let _ = event_tx.send(WorkerEvent::ProviderUpserted { + provider: result.provider, + default_model: result.default_model, + }); + } + Ok(Err(error)) => { + let _ = event_tx.send(WorkerEvent::ProviderUpsertFailed { + message: error.to_string(), + }); + } + Err(_) => { + let _ = event_tx.send(WorkerEvent::ProviderUpsertFailed { + message: "provider upsert request timed out".to_string(), + }); + } + } + } + Some(OperationCommand::DisconnectProvider { provider_id }) => { + match tokio::time::timeout( + Duration::from_secs(5), + client.provider_disconnect( + devo_protocol::native::rpc_admin::ProviderDisconnectParams { + provider_id, + }, + ), + ) + .await + { + Ok(Ok(result)) => { + let _ = event_tx.send(WorkerEvent::ProviderDisconnected { + provider_id: result.provider_id, + }); + } + Ok(Err(error)) => { + let _ = event_tx.send(WorkerEvent::ProviderDisconnectFailed { + message: error.to_string(), + }); + } + Err(_) => { + let _ = event_tx.send(WorkerEvent::ProviderDisconnectFailed { + message: "provider disconnect request timed out".to_string(), + }); + } + } + } + Some(OperationCommand::RemoveProviderModel { + provider_id, + model_id, }) => { match tokio::time::timeout( Duration::from_secs(5), - client.provider_upsert( - devo_protocol::native::rpc_admin::ProviderUpsertParams { - provider_vendor: provider_vendor.into(), - model_binding: model_binding.map(Into::into), - default_model_binding, - api_key, + client.provider_model_remove( + devo_protocol::native::rpc_admin::ProviderModelRemoveParams { + provider_id, + model_id, }, ), ) .await { Ok(Ok(result)) => { - let _ = event_tx.send(WorkerEvent::ProviderVendorUpserted { - provider_vendor: result.provider_vendor.into(), - model_binding: result.model_binding.map(Into::into), + let _ = event_tx.send(WorkerEvent::ProviderModelRemoved { + provider_id: result.provider_id, + model_id: result.model_id, }); } Ok(Err(error)) => { - let _ = event_tx.send(WorkerEvent::ProviderVendorUpsertFailed { + let _ = event_tx.send(WorkerEvent::ProviderModelRemoveFailed { message: error.to_string(), }); } Err(_) => { - let _ = event_tx.send(WorkerEvent::ProviderVendorUpsertFailed { - message: "provider upsert request timed out".to_string(), + let _ = event_tx.send(WorkerEvent::ProviderModelRemoveFailed { + message: "provider model removal request timed out".to_string(), }); } } } - Some(OperationCommand::ReconfigureProvider { + Some(OperationCommand::ReconfigureProvider { wire_api: _, model: next_model, base_url: _, @@ -2706,10 +2786,10 @@ async fn run_worker_inner( let _ = event_tx.send(WorkerEvent::InterruptFailed { message: error.to_string(), }); - } } } } + } Some(OperationCommand::RunBtwQuestion { question }) => { let Some(active_session_id) = session_id else { let _ = event_tx.send(WorkerEvent::BtwFailed { @@ -5135,7 +5215,7 @@ pub(crate) fn dispatch_legacy_item_event_for_test( let item_id = payload.item.item_id; let session_id = payload.context.session_id; - let turn_id = payload.context.turn_id.unwrap_or_else(TurnId::new); + let turn_id = payload.context.turn_id.unwrap_or_default(); let projected_at = Utc::now(); let native_item = project_wire_item(&payload.item.item_kind, &payload.item.payload, projected_at) @@ -6680,6 +6760,7 @@ mod tests { model: devo_protocol::native::model::ModelBinding { provider: "test".to_string(), model: "test-model".to_string(), + variant: None, reasoning_effort: None, }, collaboration_mode: Some(devo_protocol::CollaborationMode::Plan), diff --git a/crates/tui/src/worker/typed_events.rs b/crates/tui/src/worker/typed_events.rs index ffa0eb29..7167f66e 100644 --- a/crates/tui/src/worker/typed_events.rs +++ b/crates/tui/src/worker/typed_events.rs @@ -228,6 +228,7 @@ mod tests { model: devo_protocol::native::model::ModelBinding { provider: "test".to_string(), model: "test-model".to_string(), + variant: None, reasoning_effort: None, }, collaboration_mode: Some(devo_protocol::CollaborationMode::Plan), diff --git a/crates/tui/src/worker_event_test_helpers.rs b/crates/tui/src/worker_event_test_helpers.rs index 4437ccc4..a27c009f 100644 --- a/crates/tui/src/worker_event_test_helpers.rs +++ b/crates/tui/src/worker_event_test_helpers.rs @@ -248,6 +248,7 @@ pub(crate) fn tool_result( } /// Shim for removed `WorkerEvent::ToolResultIo`. +#[allow(clippy::too_many_arguments)] pub(crate) fn tool_result_io( tool_use_id: String, tool_name: String, From 68a0590c0f08a98985327689a1ea07419a6b944b Mon Sep 17 00:00:00 2001 From: wangtsiao Date: Sat, 5 Sep 2026 02:34:32 +0800 Subject: [PATCH 07/13] feat(desktop): provider settings, logos, and chat polish Ship Desktop Connection settings, provider logos, migration helpers, SDK wiring, and chat streaming/error UX for the catalog model. --- .../devo-ai-sdk/src/v2/client-compact.test.ts | 152 ++- .../src/v2/client-config-options.test.ts | 97 +- .../src/v2/client-native-interactions.test.ts | 112 ++ .../src/v2/client-session-not-found.test.ts | 184 +++ .../packages/devo-ai-sdk/src/v2/client.ts | 559 +++++---- .../src/v2/native-client-support.ts | 21 +- .../components/ai-elements/conversation.tsx | 17 +- .../ui/src/components/ai-elements/message.tsx | 20 +- .../src/components/ai-elements/reasoning.tsx | 32 +- .../packages/ui/src/components/command.tsx | 2 +- .../src/main/canonical-provider-migration.ts | 14 + .../claude-code-provider-migration.test.ts | 42 +- .../main/claude-code-provider-migration.ts | 86 +- apps/desktop/src/main/ipc-handlers.ts | 23 +- apps/desktop/src/main/native-stdio-client.ts | 12 +- apps/desktop/src/main/notification-watcher.ts | 20 +- apps/desktop/src/main/notifications.ts | 6 + .../main/opencode-provider-migration.test.ts | 50 +- .../src/main/opencode-provider-migration.ts | 99 +- apps/desktop/src/main/settings-store.test.ts | 2 +- .../assets/provider-logos/alibaba.svg | 1 + .../assets/provider-logos/deepseek.svg | 1 + .../renderer/assets/provider-logos/kimi.svg | 1 + .../assets/provider-logos/minimax.svg | 1 + .../assets/provider-logos/moonshot.svg | 1 + .../assets/provider-logos/moonshotai.svg | 3 + .../renderer/assets/provider-logos/ollama.svg | 1 + .../renderer/assets/provider-logos/openai.svg | 1 + .../assets/provider-logos/poolside.svg | 1 + .../renderer/assets/provider-logos/qwen.svg | 1 + .../assets/provider-logos/tencent.svg | 1 + .../renderer/assets/provider-logos/xiaomi.svg | 1 + .../renderer/assets/provider-logos/zai.svg | 1 + .../renderer/assets/provider-logos/zhipu.svg | 1 + .../renderer/atoms/actions/event-processor.ts | 59 +- .../src/renderer/atoms/session-native.test.ts | 138 ++- apps/desktop/src/renderer/atoms/sessions.ts | 85 +- apps/desktop/src/renderer/atoms/streaming.ts | 14 +- .../renderer/components/chat/activity-cue.tsx | 27 + .../components/chat/chat-tool-call.tsx | 47 +- .../components/chat/chat-turn.test.ts | 71 +- .../renderer/components/chat/chat-turn.tsx | 221 ++-- .../renderer/components/chat/chat-view.tsx | 49 +- .../chat/message-response-style.test.ts | 20 + .../chat/model-selector-variant-label.test.ts | 3 + .../chat/model-selector-variant-label.ts | 6 +- .../components/chat/process-timeline-view.tsx | 174 ++- .../components/chat/process-timeline.test.ts | 17 +- .../components/chat/process-timeline.ts | 6 +- .../components/chat/provider-error-row.tsx | 79 ++ .../components/chat/sub-agent-card.tsx | 29 +- .../renderer/components/chat/thought-row.tsx | 22 +- .../components/context-usage-button.tsx | 21 +- .../components/customize/customize-view.tsx | 25 +- .../onboarding/steps/provider-setup-step.tsx | 14 +- .../components/session-metrics-bar.test.ts | 4 + .../components/settings/about-settings.tsx | 2 +- .../settings/connect-provider-dialog.tsx | 5 +- .../settings/connection-detail-dialog.tsx | 205 ++++ .../settings/custom-provider-dialog.tsx | 351 ++++++ .../components/settings/general-settings.tsx | 2 +- .../components/settings/mcp-settings.tsx | 6 +- .../components/settings/model-edit-dialog.tsx | 1002 +++++++++++++++++ .../components/settings/model-settings.tsx | 395 +++++++ .../settings/notification-settings.tsx | 2 +- .../components/settings/provider-icon.tsx | 97 +- .../settings/provider-settings.test.tsx | 249 ---- .../components/settings/provider-settings.tsx | 448 +++++--- .../settings/provider-vendor-dialog.tsx | 504 --------- .../components/settings/rule-settings.tsx | 2 +- .../components/settings/server-settings.tsx | 6 +- .../components/settings/settings-header.tsx | 6 +- .../components/settings/settings-page.tsx | 9 +- .../components/settings/settings-row.tsx | 6 +- .../components/settings/settings-section.tsx | 15 +- .../components/settings/settings-surface.ts | 25 + .../components/settings/setup-settings.tsx | 4 +- .../components/settings/skill-settings.tsx | 2 +- .../settings/template-connect-dialog.tsx | 191 ++++ .../components/settings/worktree-settings.tsx | 24 +- .../src/renderer/hooks/use-devo-data.ts | 139 ++- apps/desktop/src/renderer/index.css | 42 + apps/desktop/src/renderer/index.html | 4 +- .../lib/invalidate-provider-queries.test.ts | 33 + .../lib/invalidate-provider-queries.ts | 19 + apps/desktop/src/renderer/lib/providers.ts | 56 +- .../src/renderer/lib/settings-sync.test.ts | 2 +- apps/desktop/src/renderer/router.tsx | 10 +- .../renderer/services/connection-manager.ts | 6 + apps/desktop/src/shared/app-settings.ts | 2 +- .../src/shared/native-ipc-error.test.ts | 29 + apps/desktop/src/shared/native-ipc-error.ts | 52 + 92 files changed, 4930 insertions(+), 1719 deletions(-) create mode 100644 apps/desktop/packages/devo-ai-sdk/src/v2/client-session-not-found.test.ts create mode 100644 apps/desktop/src/main/canonical-provider-migration.ts create mode 100644 apps/desktop/src/renderer/assets/provider-logos/alibaba.svg create mode 100644 apps/desktop/src/renderer/assets/provider-logos/deepseek.svg create mode 100644 apps/desktop/src/renderer/assets/provider-logos/kimi.svg create mode 100644 apps/desktop/src/renderer/assets/provider-logos/minimax.svg create mode 100644 apps/desktop/src/renderer/assets/provider-logos/moonshot.svg create mode 100644 apps/desktop/src/renderer/assets/provider-logos/moonshotai.svg create mode 100644 apps/desktop/src/renderer/assets/provider-logos/ollama.svg create mode 100644 apps/desktop/src/renderer/assets/provider-logos/openai.svg create mode 100644 apps/desktop/src/renderer/assets/provider-logos/poolside.svg create mode 100644 apps/desktop/src/renderer/assets/provider-logos/qwen.svg create mode 100644 apps/desktop/src/renderer/assets/provider-logos/tencent.svg create mode 100644 apps/desktop/src/renderer/assets/provider-logos/xiaomi.svg create mode 100644 apps/desktop/src/renderer/assets/provider-logos/zai.svg create mode 100644 apps/desktop/src/renderer/assets/provider-logos/zhipu.svg create mode 100644 apps/desktop/src/renderer/components/chat/activity-cue.tsx create mode 100644 apps/desktop/src/renderer/components/chat/provider-error-row.tsx create mode 100644 apps/desktop/src/renderer/components/settings/connection-detail-dialog.tsx create mode 100644 apps/desktop/src/renderer/components/settings/custom-provider-dialog.tsx create mode 100644 apps/desktop/src/renderer/components/settings/model-edit-dialog.tsx create mode 100644 apps/desktop/src/renderer/components/settings/model-settings.tsx delete mode 100644 apps/desktop/src/renderer/components/settings/provider-settings.test.tsx delete mode 100644 apps/desktop/src/renderer/components/settings/provider-vendor-dialog.tsx create mode 100644 apps/desktop/src/renderer/components/settings/settings-surface.ts create mode 100644 apps/desktop/src/renderer/components/settings/template-connect-dialog.tsx create mode 100644 apps/desktop/src/renderer/lib/invalidate-provider-queries.test.ts create mode 100644 apps/desktop/src/renderer/lib/invalidate-provider-queries.ts create mode 100644 apps/desktop/src/shared/native-ipc-error.test.ts create mode 100644 apps/desktop/src/shared/native-ipc-error.ts diff --git a/apps/desktop/packages/devo-ai-sdk/src/v2/client-compact.test.ts b/apps/desktop/packages/devo-ai-sdk/src/v2/client-compact.test.ts index ddb6ff6b..43e965fe 100644 --- a/apps/desktop/packages/devo-ai-sdk/src/v2/client-compact.test.ts +++ b/apps/desktop/packages/devo-ai-sdk/src/v2/client-compact.test.ts @@ -3,6 +3,7 @@ import { createDevoClient, type DevoNativeTransport, type DevoNativeTransportEve class FakeTransport implements DevoNativeTransport { readonly requests: Array<{ method: string; params: unknown }> = [] + private listeners: Array<(event: DevoNativeTransportEvent) => void> = [] constructor(private readonly handler: (method: string, params: unknown) => unknown) {} @@ -14,13 +15,19 @@ class FakeTransport implements DevoNativeTransport { async respond(): Promise {} subscribe(listener: (event: DevoNativeTransportEvent) => void): () => void { - void listener - return () => {} + this.listeners.push(listener) + return () => { + this.listeners = this.listeners.filter((candidate) => candidate !== listener) + } } connected(): boolean { return true } + + emit(event: DevoNativeTransportEvent): void { + for (const listener of this.listeners) listener(event) + } } describe("session.summarize", () => { @@ -59,4 +66,145 @@ describe("session.summarize", () => { expect(transport.requests.at(-1)?.params).toEqual({ sessionId: "session-1" }) expect(transport.requests.some((request) => request.method === "turn/start")).toBe(false) }) + + test("persists distinct start and complete compaction transcript markers", async () => { + const session = { + id: "session-1", + version: 1, + cwd: "/repo", + title: "Compact session", + parent: null, + createdAt: "2026-08-24T00:00:00Z", + lastActivityAt: "2026-08-24T00:00:00Z", + status: "idle", + flags: [], + archived: false, + ephemeral: false, + model: { provider: "test", model: "test-model" }, + settings: { permissionProfile: "default" }, + preview: "", + queuedCount: 0, + usage: { + total: { + inputTokens: 0, + outputTokens: 0, + cacheCreationInputTokens: 0, + cacheReadInputTokens: 0, + reasoningTokens: 0, + totalTokens: 0, + callCount: 0, + meteredCallCount: 0, + failedCallCount: 0, + cancelledCallCount: 0, + }, + byPurpose: [], + updatedAt: "2026-08-24T00:00:00Z", + }, + } + const transport = new FakeTransport((method) => { + if (method === "initialize") { + return { protocolVersion: 1, agentCapabilities: {}, authMethods: [] } + } + if (method === "session/list") { + return { data: [session], nextCursor: null } + } + if (method === "subscription/create") { + return { subscriptionId: "sub-1", snapshots: [], replay: [], cursors: [] } + } + if (method === "session/resume") { + return { session, lastContextOccupancy: null } + } + if (method === "session/messages/list" || method === "session/items/list") { + return { data: [], nextCursor: null } + } + if (method === "session/queue/list") { + return { entries: [] } + } + if (method === "context/usage/read") { + return { occupancy: null } + } + throw new Error(`unexpected method ${method}`) + }) + + const client = createDevoClient({ directory: "/repo", transport }) + await client.event.subscribe() + + transport.emit({ + type: "notification", + method: "context/compactionStarted", + params: { + sessionId: "session-1", + turnId: "turn-compact", + trigger: "manual", + }, + }) + transport.emit({ + type: "notification", + method: "item/started", + params: { + item: { + id: "item-compact-1", + sessionId: "session-1", + turnId: "turn-compact", + seq: 1, + revision: 1, + createdAt: "2026-08-24T00:00:00.000Z", + updatedAt: "2026-08-24T00:00:00.000Z", + state: "running", + item: { + type: "contextCompaction", + trigger: "manual", + summary: "Compaction started", + }, + }, + }, + }) + transport.emit({ + type: "notification", + method: "item/completed", + params: { + item: { + id: "item-compact-1", + sessionId: "session-1", + turnId: "turn-compact", + seq: 1, + revision: 2, + createdAt: "2026-08-24T00:00:00.000Z", + updatedAt: "2026-08-24T00:00:02.000Z", + state: "completed", + item: { + type: "contextCompaction", + trigger: "manual", + summary: "Context compacted", + }, + }, + }, + }) + + const { data } = await client.session.messages({ sessionID: "session-1" }) + const texts = data.flatMap((entry) => + entry.parts + .filter((part) => part.type === "text") + .map((part) => ({ + id: entry.info.id, + text: part.text, + status: (part as { metadata?: Record }).metadata?.[ + "devo/compactionStatus" + ], + })), + ) + + expect(texts).toEqual([ + { + id: "compaction-item-compact-1-started", + text: "Compacting context", + status: "started", + }, + { + id: "compaction-item-compact-1-completed", + text: "Context compacted", + status: "completed", + }, + ]) + }) }) diff --git a/apps/desktop/packages/devo-ai-sdk/src/v2/client-config-options.test.ts b/apps/desktop/packages/devo-ai-sdk/src/v2/client-config-options.test.ts index ac977323..ebba6610 100644 --- a/apps/desktop/packages/devo-ai-sdk/src/v2/client-config-options.test.ts +++ b/apps/desktop/packages/devo-ai-sdk/src/v2/client-config-options.test.ts @@ -2,9 +2,10 @@ import { describe, expect, test } from "bun:test" import { createDevoClient, type DevoNativeTransport, type DevoNativeTransportEvent } from "./client" import type { SessionConfigOption } from "./native-client-support" import type { + ProviderInfo, + ProviderModelInfo, + ProviderUpsertParams, ProviderValidateParams, - ProviderVendor, - ProviderVendorUpsertParams, } from "./generated/native" class FakeTransport implements DevoNativeTransport { @@ -90,52 +91,31 @@ const configOptions = [ }, ] satisfies SessionConfigOption[] -const providerVendor = { - name: "openai", - base_url: "https://api.openai.com/v1", - credential: "openai_api_key", - headers: null, - wire_apis: ["openai_chat_completions"], - enabled: true, -} satisfies ProviderVendor - -const canonicalProviderVendor = { +const provider = { + id: "openai", name: "openai", baseUrl: "https://api.openai.com/v1", credential: "openai_api_key", wireApis: ["openai_chat_completions"], + models: { + "gpt-4o": { + name: "GPT-4o", + wireApi: "openai_chat_completions", + } satisfies ProviderModelInfo, + }, enabled: true, -} - -const canonicalModelBinding = { - bindingId: "openai-gpt-4o", - modelSlug: "gpt-4o", - provider: "openai", - requestModel: "gpt-4o", - displayName: "GPT-4o", - invocationMethod: "openai_chat_completions", - enabled: true, -} +} satisfies ProviderInfo const providerValidateParams = { - provider_vendor: providerVendor, - model_binding: { - binding_id: "openai-gpt-4o", - model_slug: "gpt-4o", - provider: "openai", - request_model: "gpt-4o", - display_name: "GPT-4o", - invocation_method: "openai_chat_completions", - default_reasoning_effort: null, - enabled: true, - }, - api_key: "secret", + provider, + model: "gpt-4o", + apiKey: "secret", } satisfies ProviderValidateParams const providerUpsertParams = { ...providerValidateParams, - default_model_binding: "openai-gpt-4o", -} satisfies ProviderVendorUpsertParams + defaultModel: "openai/gpt-4o", +} satisfies ProviderUpsertParams describe("Native desktop SDK config option cache", () => { test("loads cold-start config options from model/preferences/read when no session cache exists", async () => { @@ -195,11 +175,17 @@ describe("Native desktop SDK config option cache", () => { ]) }) - test("lists provider vendors through the server provider API", async () => { + test("lists provider Connections and templates through the server provider API", async () => { const transport = new FakeTransport((method, params) => { + if (method === "initialize") return initializeResult if (method === "provider/list") { expect(params).toEqual({}) - return { providers: [canonicalProviderVendor] } + return { + providers: [provider], + templateProviderIds: [], + connectedProviderIds: ["openai"], + connectionModels: { openai: { "gpt-4o": provider.models["gpt-4o"] } }, + } } throw new Error(`unexpected request ${method}`) }) @@ -207,18 +193,20 @@ describe("Native desktop SDK config option cache", () => { const result = await client.provider.list() - expect(result.data).toEqual({ provider_vendors: [providerVendor] }) - expect(transport.requests.map((request) => request.method)).toEqual(["provider/list"]) + expect(result.data).toEqual({ + providers: [provider], + templateProviderIds: [], + connectedProviderIds: ["openai"], + connectionModels: { openai: { "gpt-4o": provider.models["gpt-4o"] } }, + }) + expect(transport.requests.map((request) => request.method)).toEqual(["initialize", "provider/list"]) }) test("validates provider candidates through the server provider API", async () => { const transport = new FakeTransport((method, params) => { + if (method === "initialize") return initializeResult if (method === "provider/validate") { - expect(params).toEqual({ - providerVendor: canonicalProviderVendor, - modelBinding: canonicalModelBinding, - apiKey: "secret", - }) + expect(params).toEqual(providerValidateParams) return { replyPreview: "OK" } } throw new Error(`unexpected request ${method}`) @@ -227,11 +215,11 @@ describe("Native desktop SDK config option cache", () => { const result = await client.provider.validate(providerValidateParams) - expect(result.data).toEqual({ reply_preview: "OK" }) - expect(transport.requests.map((request) => request.method)).toEqual(["provider/validate"]) + expect(result.data).toEqual({ replyPreview: "OK" }) + expect(transport.requests.map((request) => request.method)).toEqual(["initialize", "provider/validate"]) }) - test("upserts provider vendors and clears cached model config", async () => { + test("upserts a provider Connection and clears cached model config", async () => { let modelPreferencesReadCalls = 0 const updatedPreferences = { model: "openai-gpt-4o", @@ -250,15 +238,10 @@ describe("Native desktop SDK config option cache", () => { } } if (method === "provider/upsert") { - expect(params).toEqual({ - providerVendor: canonicalProviderVendor, - modelBinding: canonicalModelBinding, - defaultModelBinding: "openai-gpt-4o", - apiKey: "secret", - }) + expect(params).toEqual(providerUpsertParams) return { - providerVendor: canonicalProviderVendor, - modelBinding: canonicalModelBinding, + provider, + defaultModel: "openai/gpt-4o", } } throw new Error(`unexpected request ${method}`) diff --git a/apps/desktop/packages/devo-ai-sdk/src/v2/client-native-interactions.test.ts b/apps/desktop/packages/devo-ai-sdk/src/v2/client-native-interactions.test.ts index 8d333c70..88450c67 100644 --- a/apps/desktop/packages/devo-ai-sdk/src/v2/client-native-interactions.test.ts +++ b/apps/desktop/packages/devo-ai-sdk/src/v2/client-native-interactions.test.ts @@ -1139,6 +1139,83 @@ describe("Native desktop SDK interactions", () => { expect((await client.session.status()).data["session-1"]).toEqual({ type: "idle" }) }) + test("turn/completed with turn.error emits session.error for Desktop UI", async () => { + const transport = new FakeNativeTransport() + const client = createDevoClient({ directory: "/repo", transport }) + const stream = (await client.global.event()).stream[Symbol.asyncIterator]() + await client.session.create() + + await client.session.promptAsync({ + sessionID: "session-1", + parts: [{ type: "text", text: "hello" }], + }) + + transport.emit({ + type: "notification", + method: "item/assistantMessage/delta", + params: { + sessionId: nativeSession.id, + itemId: "item-assistant-1", + delta: "partial", + }, + }) + await nextPayloadOfType(stream, "message.part.updated") + + transport.emit({ + type: "notification", + method: "turn/completed", + params: { + turn: { + ...nativeTurnInProgress, + status: "failed", + completedAt: "2026-08-24T00:00:08Z", + error: { + errorCode: "PROVIDER_TEMPORARY_FAILURE", + message: "HTTP 429: rate limit exceeded", + retryable: true, + }, + }, + }, + }) + + const errorEvent = await nextPayloadOfType(stream, "session.error") + expect(errorEvent.properties).toEqual({ + sessionID: nativeSession.id, + error: { + name: "PROVIDER_TEMPORARY_FAILURE", + data: { + message: "HTTP 429: rate limit exceeded", + code: "PROVIDER_TEMPORARY_FAILURE", + }, + }, + }) + + const updated = await nextPayloadOfType(stream, "message.updated") + expect(updated.properties.info.role).toBe("assistant") + expect(updated.properties.info.error).toEqual({ + name: "PROVIDER_TEMPORARY_FAILURE", + data: { + message: "HTTP 429: rate limit exceeded", + code: "PROVIDER_TEMPORARY_FAILURE", + }, + }) + expect(updated.properties.info.time?.completed).toEqual(expect.any(Number)) + + // Follow-up completed projection without error must not wipe the failure. + transport.emit({ + type: "notification", + method: "turn/completed", + params: { + turn: { + ...nativeTurnInProgress, + status: "failed", + completedAt: "2026-08-24T00:00:08Z", + }, + }, + }) + expect((await client.session.status()).data["session-1"]).toEqual({ type: "idle" }) + }) + test("queues follow-up input without forcing idle when a turn is already active", async () => { const transport = new FakeNativeTransport() const client = createDevoClient({ directory: "/repo", transport }) @@ -1248,6 +1325,41 @@ describe("Native desktop SDK interactions", () => { }) }) + test("projects last-query display total from turn/usage/updated", async () => { + const transport = new FakeNativeTransport() + const client = createDevoClient({ directory: "/repo", transport }) + const stream = (await client.global.event()).stream[Symbol.asyncIterator]() + + transport.emit({ + type: "notification", + method: "turn/usage/updated", + params: { + sessionId: nativeSession.id, + turnId: "turn-1", + usage: { + query: { + totalTokens: 48_000, + inputTokens: 40_000, + outputTokens: 8_000, + }, + overhead: { totalTokens: 0 }, + }, + lastQueryInputTokens: 40_000, + contextWindow: 190_000, + }, + }) + + expect(await nextPayloadOfType(stream, "session.usage.updated")).toEqual({ + type: "session.usage.updated", + properties: { + sessionID: nativeSession.id, + used: 48_000, + size: 190_000, + cost: 0, + }, + }) + }) + test("reads context occupancy through context/usage/read", async () => { const transport = new FakeNativeTransport() const client = createDevoClient({ directory: "/repo", transport }) diff --git a/apps/desktop/packages/devo-ai-sdk/src/v2/client-session-not-found.test.ts b/apps/desktop/packages/devo-ai-sdk/src/v2/client-session-not-found.test.ts new file mode 100644 index 00000000..8a86f987 --- /dev/null +++ b/apps/desktop/packages/devo-ai-sdk/src/v2/client-session-not-found.test.ts @@ -0,0 +1,184 @@ +import { describe, expect, test } from "bun:test" +import { + createDevoClient, + isSessionNotFoundError, + type DevoNativeTransport, + type DevoNativeTransportEvent, +} from "./client" + +class FakeTransport implements DevoNativeTransport { + readonly requests: Array<{ method: string; params: unknown }> = [] + + constructor(private readonly handler: (method: string, params: unknown) => unknown) {} + + async request(method: string, params?: unknown): Promise { + this.requests.push({ method, params }) + return this.handler(method, params) + } + + async respond(): Promise {} + + subscribe(_listener: (event: DevoNativeTransportEvent) => void): () => void { + return () => {} + } + + connected(): boolean { + return true + } +} + +const nativeSession = { + id: "missing-session", + version: 1, + cwd: "/repo", + title: "Missing", + parent: null, + createdAt: "2026-01-01T00:00:00.000Z", + lastActivityAt: "2026-01-01T00:00:00.000Z", + status: "idle", + flags: [], + archived: false, + ephemeral: false, + model: { provider: "test", model: "test-model" }, + settings: { permissionProfile: "default" }, + preview: "", + queuedCount: 0, + usage: { + total: { + inputTokens: 0, + outputTokens: 0, + cacheCreationInputTokens: 0, + cacheReadInputTokens: 0, + reasoningTokens: 0, + totalTokens: 0, + callCount: 0, + meteredCallCount: 0, + failedCallCount: 0, + cancelledCallCount: 0, + }, + byPurpose: [], + updatedAt: "2026-01-01T00:00:00.000Z", + }, +} + +describe("isSessionNotFoundError", () => { + test("matches server message and SessionNotFound code", () => { + expect(isSessionNotFoundError(new Error("session does not exist"))).toBe(true) + const coded = new Error("gone") as Error & { code?: string } + coded.code = "SessionNotFound" + expect(isSessionNotFoundError(coded)).toBe(true) + expect(isSessionNotFoundError(new Error("timeout"))).toBe(false) + }) +}) + +describe("session.messages soft-handles missing sessions", () => { + test("returns empty messages and emits session.deleted when resume fails", async () => { + const transport = new FakeTransport((method) => { + if (method === "initialize") { + return { protocolVersion: 1, agentCapabilities: {}, authMethods: [] } + } + if (method === "session/list") { + return { data: [nativeSession], nextOffset: null } + } + if (method === "subscription/create") { + return { subscriptionId: "sub-1", snapshots: [], replay: [], cursors: [] } + } + if (method === "session/resume") { + const error = new Error("session does not exist") as Error & { code?: string } + error.code = "SessionNotFound" + throw error + } + throw new Error(`unexpected method ${method}`) + }) + + const client = createDevoClient({ directory: "/repo", transport }) + const deletedIds: string[] = [] + const subscription = await client.event.subscribe() + const consumer = (async () => { + for await (const globalEvent of subscription.stream) { + if (globalEvent.payload?.type === "session.deleted") { + deletedIds.push(String(globalEvent.payload.properties?.info?.id ?? "")) + break + } + } + })() + + const result = await client.session.messages({ sessionID: "missing-session" }) + expect(result.data).toEqual([]) + await consumer + expect(deletedIds).toEqual(["missing-session"]) + expect(transport.requests.some((request) => request.method === "session/resume")).toBe(true) + }) +}) + +describe("session.queue.list resumes cold historical sessions", () => { + test("loads via session/resume before queue/list", async () => { + let resumed = false + const transport = new FakeTransport((method) => { + if (method === "initialize") { + return { protocolVersion: 1, agentCapabilities: {}, authMethods: [] } + } + if (method === "session/list") { + return { data: [nativeSession], nextOffset: null } + } + if (method === "session/resume") { + resumed = true + return { session: nativeSession } + } + if (method === "session/items/list") { + return { data: [], nextCursor: null } + } + if (method === "session/queue/list") { + if (!resumed) { + const error = new Error("session does not exist") as Error & { code?: string } + error.code = "SessionNotFound" + throw error + } + return { + entries: [ + { + queueItemId: "q1", + position: 0, + preview: "hello", + input: [{ type: "text", text: "hello" }], + enqueuedAt: "2026-01-01T00:00:00.000Z", + }, + ], + } + } + if (method === "subscription/create") { + return { subscriptionId: "sub-1", snapshots: [], replay: [], cursors: [] } + } + throw new Error(`unexpected method ${method}`) + }) + + const client = createDevoClient({ directory: "/repo", transport }) + const result = await client.session.queue.list({ sessionID: "missing-session" }) + expect(resumed).toBe(true) + expect(result.data.entries).toHaveLength(1) + expect(result.data.entries[0]?.queueItemId).toBe("q1") + const methods = transport.requests.map((request) => request.method) + expect(methods.indexOf("session/resume")).toBeLessThan(methods.indexOf("session/queue/list")) + }) + + test("returns empty entries when the session is truly gone", async () => { + const transport = new FakeTransport((method) => { + if (method === "initialize") { + return { protocolVersion: 1, agentCapabilities: {}, authMethods: [] } + } + if (method === "session/list") { + return { data: [nativeSession], nextOffset: null } + } + if (method === "session/resume") { + const error = new Error("session does not exist") as Error & { code?: string } + error.code = "SessionNotFound" + throw error + } + throw new Error(`unexpected method ${method}`) + }) + + const client = createDevoClient({ directory: "/repo", transport }) + const result = await client.session.queue.list({ sessionID: "missing-session" }) + expect(result.data.entries).toEqual([]) + }) +}) diff --git a/apps/desktop/packages/devo-ai-sdk/src/v2/client.ts b/apps/desktop/packages/devo-ai-sdk/src/v2/client.ts index f805fe8f..06f1efac 100644 --- a/apps/desktop/packages/devo-ai-sdk/src/v2/client.ts +++ b/apps/desktop/packages/devo-ai-sdk/src/v2/client.ts @@ -17,11 +17,21 @@ import { toolPartFromUpdate, } from "./native-client-support" import type { + ProviderDisconnectParams, + ProviderDisconnectResult, + ProviderDiscoverParams, + ProviderDiscoverResult, + ProviderInfo, + ProviderListResult, + ProviderModelInfo, + ProviderModelRemoveParams, + ProviderModelRemoveResult, + ProviderModelVariant, + ProviderUpsertParams, + ProviderUpsertResult, ProviderValidateParams, ProviderValidateResult, - ProviderVendorListResult, - ProviderVendorUpsertParams, - ProviderVendorUpsertResult, + InputModality, InputItem, TurnStartResult, WorkspaceChangeCoverage, @@ -163,14 +173,25 @@ export type ToolStateCompleted = any export type UserMessage = any export type Worktree = any export type { - ProviderModelBinding, + ProviderDisconnectParams, + ProviderDisconnectResult, + ProviderDiscoverParams, + ProviderDiscoverResult, + ProviderInfo, + ProviderListResult, + ProviderModelInfo, + ProviderModelRemoveParams, + ProviderModelRemoveResult, + ProviderModelVariant, + ProviderUpsertParams, + ProviderUpsertResult, ProviderValidateParams, ProviderValidateResult, - ProviderVendor, - ProviderVendorListResult, - ProviderVendorUpsertParams, - ProviderVendorUpsertResult, ProviderWireApi, + InputModality, + ReasoningCapability, + ReasoningEffort, + ReasoningLevelChoice, WorkspaceChangeAttribution, WorkspaceChangeBase, WorkspaceChangeCoverage, @@ -187,6 +208,24 @@ export type { WorkspaceDiffDetail, } from "./generated/native" +// ── Canonical provider/model catalog types (L2-DES-MODEL-002) ── + +/** Canonical provider/model types generated from the Native protocol schema. */ +export type CatalogWireApi = ProviderWireApi +export type CatalogModelVariant = ProviderModelVariant +export type CatalogModelInfo = ProviderModelInfo +export type CatalogProviderInfo = ProviderInfo +export type ProviderCatalogListResult = ProviderListResult +export type CatalogProviderUpsertParams = ProviderUpsertParams +export type CatalogProviderUpsertResult = ProviderUpsertResult +export type CatalogProviderDisconnectParams = ProviderDisconnectParams +export type CatalogProviderDisconnectResult = ProviderDisconnectResult +export type CatalogProviderModelRemoveParams = ProviderModelRemoveParams +export type CatalogProviderModelRemoveResult = ProviderModelRemoveResult +export type CatalogProviderValidateParams = ProviderValidateParams +export type CatalogProviderValidateResult = ProviderValidateResult +export type CatalogProviderDiscoverParams = ProviderDiscoverParams +export type CatalogProviderDiscoverResult = ProviderDiscoverResult export type WorkspaceChangesReadOptions = { sessionID: string cwd?: string @@ -579,58 +618,6 @@ function workspaceChangeStats(value: unknown): WorkspaceChangeStats { } } -// ── Canonical provider conversions (ratified #11) ── - -function canonicalProviderVendorWire(vendor: ProviderVendor): Record { - return { - name: vendor.name, - ...(vendor.base_url != null ? { baseUrl: vendor.base_url } : {}), - ...(vendor.credential != null ? { credential: vendor.credential } : {}), - ...(vendor.headers != null ? { headers: vendor.headers } : {}), - wireApis: vendor.wire_apis, - enabled: vendor.enabled, - } -} - -function canonicalModelBindingWire(binding: ProviderModelBinding): Record { - return { - bindingId: binding.binding_id, - modelSlug: binding.model_slug, - provider: binding.provider, - requestModel: binding.request_model, - ...(binding.display_name != null ? { displayName: binding.display_name } : {}), - invocationMethod: binding.invocation_method, - ...(binding.default_reasoning_effort != null - ? { defaultReasoningEffort: binding.default_reasoning_effort } - : {}), - enabled: binding.enabled, - } -} - -function legacyProviderVendorFromCanonical(vendor: Record): ProviderVendor { - return { - name: String(vendor.name ?? ""), - base_url: (vendor.baseUrl as string | null) ?? null, - credential: (vendor.credential as string | null) ?? null, - headers: (vendor.headers as string | null) ?? null, - wire_apis: (vendor.wireApis ?? []) as ProviderVendor["wire_apis"], - enabled: Boolean(vendor.enabled), - } -} - -function legacyModelBindingFromCanonical(binding: Record): ProviderModelBinding { - return { - binding_id: String(binding.bindingId ?? ""), - model_slug: String(binding.modelSlug ?? ""), - provider: String(binding.provider ?? ""), - request_model: String(binding.requestModel ?? ""), - display_name: (binding.displayName as string | null) ?? null, - invocation_method: binding.invocationMethod as ProviderModelBinding["invocation_method"], - default_reasoning_effort: (binding.defaultReasoningEffort as string | null) ?? null, - enabled: Boolean(binding.enabled), - } -} - /** Canonical `model/preferences` wire shape (ratified #12). */ type PreferencesOptionWire = { value: string @@ -942,6 +929,39 @@ function errorRecord(error: unknown): Record | undefined { } } +/** Map a native turn failure onto the Desktop session/assistant error shape. */ +function assistantErrorFromTurnFailure( + turnStatus: string, + turnError: Record | undefined, +): { name: string; data: Record } | undefined { + const message = + typeof turnError?.message === "string" && turnError.message.trim() + ? turnError.message.trim() + : undefined + if (!message) { + // Follow-up `turn/completed` after TurnFailed has status failed but no + // error payload — do not invent a generic message that would clobber UI. + return undefined + } + const code = + typeof turnError?.errorCode === "string" + ? turnError.errorCode + : typeof turnError?.error_code === "string" + ? turnError.error_code + : turnStatus === "failed" + ? "TurnFailed" + : "Error" + const details = objectRecord(turnError?.details) + return { + name: code, + data: { + message, + ...(code !== "Error" ? { code } : {}), + ...(details ?? {}), + }, + } +} + function settingsErrorCode(error: unknown): string | undefined { const record = errorRecord(error) if (typeof record?.code === "string") return record.code @@ -956,6 +976,24 @@ function settingsErrorCode(error: unknown): string | undefined { return undefined } +/** True when the Native server reports the session is gone / never existed. */ +export function isSessionNotFoundError(error: unknown): boolean { + const code = settingsErrorCode(error) + const normalizedCode = code?.replace(/([a-z0-9])([A-Z])/g, "$1_$2").toLowerCase() + if (normalizedCode === "session_not_found") return true + const record = errorRecord(error) + if (typeof record?.code === "string") { + const recordCode = record.code.replace(/([a-z0-9])([A-Z])/g, "$1_$2").toLowerCase() + if (recordCode === "session_not_found") return true + } + const message = error instanceof Error ? error.message : String(error) + return ( + /session does not exist/i.test(message) || + /^session .+ not found$/i.test(message) || + /session id is not addressable by this server/i.test(message) + ) +} + function isTransientSessionSettingsError(error: unknown): boolean { const record = errorRecord(error) const code = settingsErrorCode(error) @@ -1111,6 +1149,8 @@ class NativeClient { private sessionSettingsQueues = new Map() private lastEventTime = 0 private referenceSearchSession: ReferenceSearchSession | null = null + /** >0 while applying subscription create replay — must not bump sidebar sort keys. */ + private subscriptionReplayDepth = 0 constructor(private readonly options: CreateDevoClientOptions) {} project = { @@ -1169,13 +1209,24 @@ class NativeClient { }, queue: { list: async (params: { sessionID: string }) => { - await this.ensureSessionSubscription(params.sessionID) - const result = (await this.requestCanonical("session/queue/list", { - sessionId: params.sessionID, - })) as { entries?: unknown } - const entries = parseQueueWireEntries(result.entries) - this.emitQueueSnapshot(params.sessionID, entries, "sync") - return { data: { entries } } + // Historical sessions show up in session/list but are not + // addressable until session/resume. Composer refresh races + // message load on open, so wait for load before queue/list. + try { + await this.loadSession(params.sessionID) + const result = (await this.requestCanonical("session/queue/list", { + sessionId: params.sessionID, + })) as { entries?: unknown } + const entries = parseQueueWireEntries(result.entries) + this.emitQueueSnapshot(params.sessionID, entries, "sync") + return { data: { entries } } + } catch (error) { + if (isSessionNotFoundError(error)) { + this.emitQueueSnapshot(params.sessionID, [], "sync") + return { data: { entries: [] } } + } + throw error + } }, push: async (params: { sessionID: string @@ -1312,17 +1363,25 @@ class NativeClient { data: await this.getSessionById(params.sessionID), }), diff: async (params: { sessionID: string }) => { - const result = (await this.requestCanonical("workspace/changes/read", { - sessionId: params.sessionID, - scopes: ["uncommitted"], - diffDetail: "full", - maxDiffBytes: 2_000_000, - })) as { views?: Array> } - return { - data: (result.views ?? []) - .map((view) => view.unifiedDiff) - .filter((diff): diff is string => typeof diff === "string" && diff.length > 0) - .map((diff) => ({ diff })), + try { + const result = (await this.requestCanonical("workspace/changes/read", { + sessionId: params.sessionID, + scopes: ["uncommitted"], + diffDetail: "full", + maxDiffBytes: 2_000_000, + })) as { views?: Array> } + return { + data: (result.views ?? []) + .map((view) => view.unifiedDiff) + .filter((diff): diff is string => typeof diff === "string" && diff.length > 0) + .map((diff) => ({ diff })), + } + } catch (error) { + if (isSessionNotFoundError(error)) { + this.dropMissingSession(params.sessionID) + return { data: [] } + } + throw error } }, revert: async (params: { sessionID: string }) => ({ @@ -1498,7 +1557,13 @@ class NativeClient { const canonical = (await this.requestCanonical( "workspace/changes/read", wireParams, - )) as { views?: Array> } + ).catch((error) => { + if (isSessionNotFoundError(error)) { + this.dropMissingSession(params.sessionID) + return { views: [] } + } + throw error + })) as { views?: Array> } const data: WorkspaceChangesReadResult = { views: (canonical.views ?? []).map(legacyWorkspaceChangeViewFromCanonical), } @@ -1527,10 +1592,18 @@ class NativeClient { goal = { status: async (params: { sessionID: string }) => { - const result = (await this.requestCanonical("session/goal/read", { - sessionId: params.sessionID, - })) as { goal?: unknown } - return { data: result.goal } + try { + const result = (await this.requestCanonical("session/goal/read", { + sessionId: params.sessionID, + })) as { goal?: unknown } + return { data: result.goal } + } catch (error) { + if (isSessionNotFoundError(error)) { + this.dropMissingSession(params.sessionID) + return { data: null } + } + throw error + } }, pause: async (params: { sessionID: string }) => { const result = (await this.canonicalGoalTransition( @@ -1649,68 +1722,41 @@ class NativeClient { provider = { list: async () => { - // Canonical provider/list (ratified #11): camelCase wire; vendors - // convert back to the generated snake shape for callers. - const result = (await this.requestCanonical("provider/list", {})) as { - providers?: Array> - } - const data: ProviderVendorListResult = { - provider_vendors: (result.providers ?? []).map(legacyProviderVendorFromCanonical), - } + const data = (await this.requestCanonical("provider/list", {})) as ProviderListResult return { data } }, validate: async (params: ProviderValidateParams) => { - const result = (await this.requestCanonical("provider/validate", { - providerVendor: canonicalProviderVendorWire(params.provider_vendor), - modelBinding: canonicalModelBindingWire(params.model_binding), - ...(params.api_key !== undefined && params.api_key !== null - ? { apiKey: params.api_key } - : {}), - })) as { replyPreview?: string } - const data: ProviderValidateResult = { reply_preview: result.replyPreview ?? "" } + const data = (await this.requestCanonical("provider/validate", params)) as ProviderValidateResult return { data } }, - upsert: async (params: ProviderVendorUpsertParams) => { - const result = (await this.requestCanonical("provider/upsert", { - providerVendor: canonicalProviderVendorWire(params.provider_vendor), - ...(params.model_binding - ? { modelBinding: canonicalModelBindingWire(params.model_binding) } - : {}), - ...(params.default_model_binding !== undefined && params.default_model_binding !== null - ? { defaultModelBinding: params.default_model_binding } - : {}), - ...(params.api_key !== undefined && params.api_key !== null - ? { apiKey: params.api_key } - : {}), - })) as { - providerVendor?: Record - modelBinding?: Record - } - const data: ProviderVendorUpsertResult = { - provider_vendor: legacyProviderVendorFromCanonical(result.providerVendor ?? {}), - ...(result.modelBinding - ? { model_binding: legacyModelBindingFromCanonical(result.modelBinding) } - : {}), - } as ProviderVendorUpsertResult + upsert: async (params: ProviderUpsertParams) => { + const data = (await this.requestCanonical("provider/upsert", params)) as ProviderUpsertResult this.invalidateConfigOptionCaches() return { data } }, + disconnect: async (params: ProviderDisconnectParams): Promise => { + const result = (await this.requestCanonical("provider/disconnect", params)) as ProviderDisconnectResult + this.invalidateConfigOptionCaches() + return result + }, + modelRemove: async (params: ProviderModelRemoveParams): Promise => { + const result = (await this.requestCanonical("provider/model/remove", params)) as ProviderModelRemoveResult + this.invalidateConfigOptionCaches() + return result + }, + discover: async (params: ProviderDiscoverParams): Promise => { + const result = (await this.requestCanonical("provider/discover", params)) as ProviderDiscoverResult + // Discover mutates the connection model directory; composer selectors + // read model/preferences which must not keep a pre-discover snapshot. + this.invalidateConfigOptionCaches() + return result + }, auth: async () => ({ data: [] }), oauth: { authorize: async (_params: unknown) => ({ data: null }), callback: async (_params: unknown) => ({ data: null }), }, } - - auth = { - set: async (_params: unknown) => ({ data: null }), - remove: async (_params: unknown) => ({ data: null }), - } - - part = { - delete: async (_params: unknown) => ({ data: null }), - } - private async listProjects(): Promise { const sessions = await this.listSessions() const byDirectory = new Map() @@ -1790,7 +1836,12 @@ class NativeClient { return session } private async sessionMessages(sessionId: string, limit?: number): Promise> { - await this.loadSession(sessionId, limit) + try { + await this.loadSession(sessionId, limit) + } catch (error) { + if (isSessionNotFoundError(error)) return [] + throw error + } const messages = recentMessages(this.messages.get(sessionId) ?? [], limit) return messages.map((info) => ({ info, @@ -1811,46 +1862,61 @@ class NativeClient { } } + private dropMissingSession(sessionId: string): void { + const { directory } = this.forgetSession(sessionId) + this.subscriptions.delete(sessionId) + this.sessionSettingsQueues.delete(sessionId) + this.emitSessionDeleted(sessionId, directory) + } + private async loadSessionOnce(sessionId: string, limit?: number): Promise { await this.ensureInitialized() - const session = await this.getSessionById(sessionId) - const cwd = session?.directory ?? this.sessionDirectories.get(sessionId) - if (!cwd) throw new Error(`session ${sessionId} not found`) - const resumed = (await this.requestCanonical("session/resume", { - sessionId, - })) as { session: Record; lastContextOccupancy?: unknown; last_context_occupancy?: unknown } - const enriched = this.rememberNativeSession(resumed.session) - // The resume response carries the authoritative persisted model / - // settings for the session; cold `session/list` snapshots may lack - // them. Surface the enrichment so renderer session stores re-seed the - // composer — without this, the enriched snapshot stays buried in this - // client's internal cache and restored sessions fall back to defaults. - this.emit(enriched.directory ?? cwd, { - type: "session.updated", - properties: { info: enriched, session: enriched }, - }) - this.emitContextUsage( - sessionId, - resumed.lastContextOccupancy ?? resumed.last_context_occupancy, - ) - let cursor: string | undefined - do { - const page = (await this.requestCanonical("session/items/list", { + try { + const session = await this.getSessionById(sessionId) + const cwd = session?.directory ?? this.sessionDirectories.get(sessionId) + if (!cwd) throw new Error(`session ${sessionId} not found`) + const resumed = (await this.requestCanonical("session/resume", { sessionId, - ...(cursor ? { cursor } : {}), - limit: 500, - })) as { data?: Array>; nextCursor?: string | null } - for (const item of page.data ?? []) { - this.handleNativeItemEnvelope(item, nativeItemNotificationMethod(item)) + })) as { session: Record; lastContextOccupancy?: unknown; last_context_occupancy?: unknown } + const enriched = this.rememberNativeSession(resumed.session) + // The resume response carries the authoritative persisted model / + // settings for the session; cold `session/list` snapshots may lack + // them. Surface the enrichment so renderer session stores re-seed the + // composer — without this, the enriched snapshot stays buried in this + // client's internal cache and restored sessions fall back to defaults. + this.emit(enriched.directory ?? cwd, { + type: "session.updated", + properties: { info: enriched, session: enriched }, + }) + this.emitContextUsage( + sessionId, + resumed.lastContextOccupancy ?? resumed.last_context_occupancy, + ) + let cursor: string | undefined + do { + const page = (await this.requestCanonical("session/items/list", { + sessionId, + ...(cursor ? { cursor } : {}), + limit: 500, + })) as { data?: Array>; nextCursor?: string | null } + for (const item of page.data ?? []) { + this.handleNativeItemEnvelope(item, nativeItemNotificationMethod(item)) + } + cursor = page.nextCursor ?? undefined + } while (cursor) + const queueResult = (await this.requestCanonical("session/queue/list", { + sessionId, + })) as { entries?: unknown } + this.emitQueueSnapshot(sessionId, parseQueueWireEntries(queueResult.entries), "sync") + await this.ensureSessionSubscription(sessionId) + this.loadedSessionLimits.set(sessionId, null) + } catch (error) { + if (isSessionNotFoundError(error)) { + this.dropMissingSession(sessionId) + throw error } - cursor = page.nextCursor ?? undefined - } while (cursor) - const queueResult = (await this.requestCanonical("session/queue/list", { - sessionId, - })) as { entries?: unknown } - this.emitQueueSnapshot(sessionId, parseQueueWireEntries(queueResult.entries), "sync") - await this.ensureSessionSubscription(sessionId) - this.loadedSessionLimits.set(sessionId, null) + throw error + } } private async getSessionById(sessionId: string): Promise { @@ -1878,7 +1944,16 @@ class NativeClient { const usage = objectRecord(info.usage) const total = objectRecord(usage?.total) const created = parseTimestampMs(info.createdAt) ?? existing?.time.created ?? Date.now() - const updated = parseTimestampMs(info.lastActivityAt) ?? existing?.time.updated ?? created + const wireActivity = parseTimestampMs(info.lastActivityAt) + const existingActivity = Math.max(existing?.time.lastActivity ?? 0, existing?.time.updated ?? 0) + // Never let resume/snapshot lower a known activity timestamp — that alone + // can reshuffle the sidebar. Prefer the newer of wire vs in-memory. + const updated = + wireActivity != null + ? Math.max(wireActivity, existingActivity) + : existingActivity > 0 + ? existingActivity + : created const parent = objectRecord(info.parent) const forkFromId = typeof info.forkFromId === "string" @@ -1966,8 +2041,12 @@ class NativeClient { * (agentsAtom / sidebar) recompute without a session/list refresh. * Native turn/item traffic does not carry session/metadataUpdated for * activity-only bumps — only title updates do — so the client owns live sync. + * + * Skipped during subscription replay: historical turn/item envelopes must + * not reshuffle the sidebar when the user merely opens a session. */ private touchNativeSessionActivity(sessionId: string, at = Date.now()): void { + if (this.subscriptionReplayDepth > 0) return const session = this.sessions.get(sessionId) if (!session) return const previous = Math.max(session.time.lastActivity ?? 0, session.time.updated ?? 0) @@ -2259,7 +2338,18 @@ class NativeClient { }) const startedAt = this.promptStartedAtBySession.get(sessionId) ?? 0 this.promptStartedAtBySession.delete(sessionId) - this.completeOpenAssistantMessages(sessionId, directory, startedAt) + // Native `TurnFailed` projects as `turn/completed` with `turn.error` + // (often followed by a completed notification without error). Surface + // the payload so Desktop can render session/assistant failure UI. + const turnError = objectRecord(turn?.error) + const assistantError = assistantErrorFromTurnFailure(turnStatus, turnError) + if (assistantError) { + this.emit(directory, { + type: "session.error", + properties: { sessionID: sessionId, error: assistantError }, + }) + } + this.completeOpenAssistantMessages(sessionId, directory, startedAt, assistantError) this.pendingQuestions.forEach((pending, requestId) => { if (pending.sessionId === sessionId) this.pendingQuestions.delete(requestId) }) @@ -2287,6 +2377,36 @@ class NativeClient { } return true } + if ( + method === "context/compactionStarted" || + method === "context/compactionCompleted" || + method === "context/compactionFailed" + ) { + const sessionId = String(value.sessionId ?? "") + if (!sessionId) return true + const directory = this.sessionDirectories.get(sessionId) ?? this.options.directory ?? defaultCwd() + const status = + method === "context/compactionFailed" + ? "failed" + : method === "context/compactionCompleted" + ? "completed" + : "started" + this.emit(directory, { + type: `session.compaction.${status}`, + properties: { sessionID: sessionId }, + }) + // Prefer item/started|completed for durable transcript markers. + // context/compactionStarted has no itemId — only update session atom. + const itemId = String(value.itemId ?? "") + if (itemId && status !== "failed") { + this.upsertCompaction(sessionId, directory, { + itemId, + status, + turnId: String(value.turnId ?? ""), + }) + } + return true + } if (method === "item/assistantMessage/delta" || method === "item/reasoning/delta") { const sessionId = String(value.sessionId ?? "") const itemId = String(value.itemId ?? "") @@ -2354,14 +2474,20 @@ class NativeClient { if (method === "turn/usage/updated" || method === "session/usage/updated") { const sessionId = String(value.sessionId ?? "") const usage = objectRecord(value.usage) ?? {} - const total = objectRecord(usage.total) ?? usage + // Native wire shape matches TUI: usage.query.totalTokens is the + // last-query display total. Fall back to older/flat shapes. + const query = objectRecord(usage.query) ?? objectRecord(usage.total) ?? usage + const used = Number( + query.totalTokens ?? query.total_tokens ?? value.lastQueryTotalTokens ?? 0, + ) + const size = Number(value.contextWindow ?? value.context_window ?? 0) if (sessionId) { this.emit(this.sessionDirectories.get(sessionId) ?? this.options.directory ?? defaultCwd(), { type: "session.usage.updated", properties: { sessionID: sessionId, - used: Number(total.totalTokens ?? value.lastQueryInputTokens ?? 0), - size: Number(value.contextWindow ?? 0), + used, + size, cost: 0, }, }) @@ -2677,32 +2803,46 @@ class NativeClient { private async ensureSessionSubscription(sessionId: string): Promise { if (this.subscriptions.has(sessionId)) return const after = this.subscriptionCursors.get(sessionId) ?? [] - const result = (await this.requestCanonical("subscription/create", { - selectors: [{ kind: "session", sessionId }], - includeSnapshot: true, - after, - })) as { + let result: { subscriptionId: string snapshots?: Array> replay?: Array> cursors?: Array<{ streamId: string; seq: number }> pendingControlRequests?: Array> } + try { + result = (await this.requestCanonical("subscription/create", { + selectors: [{ kind: "session", sessionId }], + includeSnapshot: true, + after, + })) as typeof result + } catch (error) { + if (isSessionNotFoundError(error)) { + this.dropMissingSession(sessionId) + return + } + throw error + } const cursors = result.cursors ?? [] this.subscriptions.set(sessionId, { subscriptionId: result.subscriptionId, cursors }) this.subscriptionCursors.set(sessionId, cursors) for (const snapshot of result.snapshots ?? []) { this.applySubscriptionSessionSnapshot(snapshot) } - for (const envelope of result.replay ?? []) { - const notification = objectRecord(envelope.notification) - if (notification && typeof notification.method === "string") { - this.handleNativeNotification(notification.method, notification.params) + this.subscriptionReplayDepth += 1 + try { + for (const envelope of result.replay ?? []) { + const notification = objectRecord(envelope.notification) + if (notification && typeof notification.method === "string") { + this.handleNativeNotification(notification.method, notification.params) + } } - } - for (const pending of result.pendingControlRequests ?? []) { - const item = objectRecord(pending.item) - if (item) this.handleNativeItemEnvelope(item, "item/started") + for (const pending of result.pendingControlRequests ?? []) { + const item = objectRecord(pending.item) + if (item) this.handleNativeItemEnvelope(item, "item/started") + } + } finally { + this.subscriptionReplayDepth -= 1 } if (cursors.length > 0) { await this.requestCanonical("subscription/ack", { subscriptionId: result.subscriptionId, cursors }) @@ -2711,7 +2851,14 @@ class NativeClient { private async ensureKnownSessionSubscriptions(): Promise { const sessions = await this.listSessions() - for (const session of sessions) await this.ensureSessionSubscription(session.id) + for (const session of sessions) { + try { + await this.ensureSessionSubscription(session.id) + } catch (error) { + if (isSessionNotFoundError(error)) continue + throw error + } + } } private emitPermissionAsked( @@ -3042,15 +3189,30 @@ class NativeClient { }, ): void { if (update.status === "failed") return - const messageId = `compaction-${update.itemId}` + const metaBase = { + [DEVO_ITEM_KIND_META]: "context_compaction", + ...(update.turnId ? { [DEVO_TURN_ID_META]: update.turnId } : {}), + } + // Keep started and completed as distinct transcript markers so both + // remain visible after the lifecycle finishes. + if (update.status === "completed") { + this.replaceText(sessionId, directory, "assistant", { + messageId: `compaction-${update.itemId}-started`, + content: { text: COMPACTION_STARTED_LABEL }, + _meta: { + ...metaBase, + [DEVO_COMPACTION_STATUS_META]: "started", + }, + }) + } + const messageId = `compaction-${update.itemId}-${update.status}` const label = update.status === "completed" ? COMPACTION_COMPLETED_LABEL : COMPACTION_STARTED_LABEL this.replaceText(sessionId, directory, "assistant", { messageId, content: { text: label }, _meta: { - [DEVO_ITEM_KIND_META]: "context_compaction", + ...metaBase, [DEVO_COMPACTION_STATUS_META]: update.status, - ...(update.turnId ? { [DEVO_TURN_ID_META]: update.turnId } : {}), }, }) } @@ -3283,22 +3445,29 @@ class NativeClient { sessionId: string, directory: string, promptStartedAt: number, + error?: { name: string; data: Record }, ): void { const messages = this.messages.get(sessionId) if (!messages) return let completedAt: number | null = null for (let index = 0; index < messages.length; index++) { const message = messages[index] - if (message.role !== "assistant" || message.time.completed != null) continue + if (message.role !== "assistant") continue if (message.time.created < promptStartedAt) continue - completedAt ??= this.nextEventTime() + const needsComplete = message.time.completed == null + const needsError = error != null && message.error == null + if (!needsComplete && !needsError) continue + completedAt ??= message.time.completed ?? this.nextEventTime() const updated = { ...message, time: { ...message.time, completed: completedAt }, + ...(needsError ? { error } : {}), } as Message messages[index] = updated this.emit(directory, { type: "message.updated", properties: { info: updated, message: updated } }) - this.completeInFlightToolParts(sessionId, directory, updated.id, completedAt) + if (needsComplete) { + this.completeInFlightToolParts(sessionId, directory, updated.id, completedAt) + } } } @@ -3553,6 +3722,10 @@ class NativeClient { if (!result.session) throw new Error("session/metadata/update returned no session") return this.rememberNativeSession(result.session) } catch (error) { + if (isSessionNotFoundError(error)) { + this.dropMissingSession(sessionId) + throw error + } const delay = SESSION_SETTINGS_RETRY_DELAYS_MS[retry] if (delay === undefined || !isTransientSessionSettingsError(error)) throw error await waitForSessionSettingsRetry(delay) diff --git a/apps/desktop/packages/devo-ai-sdk/src/v2/native-client-support.ts b/apps/desktop/packages/devo-ai-sdk/src/v2/native-client-support.ts index 8adff505..9804cc06 100644 --- a/apps/desktop/packages/devo-ai-sdk/src/v2/native-client-support.ts +++ b/apps/desktop/packages/devo-ai-sdk/src/v2/native-client-support.ts @@ -58,13 +58,32 @@ export function defaultCwd(): string { let sharedIpcTransport: DevoNativeTransport | null = null +/** Must match `DEVO_NATIVE_IPC_ERROR` in apps/desktop/src/shared/native-ipc-error.ts */ +const DEVO_NATIVE_IPC_ERROR = "$devoNativeError" + +function throwIfNativeIpcError(result: unknown): void { + if (!result || typeof result !== "object") return + const envelope = (result as Record)[DEVO_NATIVE_IPC_ERROR] + if (!envelope || typeof envelope !== "object") return + const record = envelope as Record + const error = new Error( + typeof record.message === "string" ? record.message : "Devo Native request failed", + ) as Error & { code?: string } + if (typeof record.code === "string") error.code = record.code + throw error +} + export function createIpcTransport(): DevoNativeTransport { if (sharedIpcTransport) return sharedIpcTransport const api = globalThis.window?.devo?.native if (!api) throw new Error("window.devo.native is not available") sharedIpcTransport = { - request: (method, params, directory) => api.request({ method, params, directory }), + request: async (method, params, directory) => { + const result = await api.request({ method, params, directory }) + throwIfNativeIpcError(result) + return result + }, notify: (method, params, directory) => api.notify({ method, params, directory }), respond: (id, result) => api.respond({ id, result }), subscribe: (listener) => api.subscribe(listener), diff --git a/apps/desktop/packages/ui/src/components/ai-elements/conversation.tsx b/apps/desktop/packages/ui/src/components/ai-elements/conversation.tsx index 72b75377..4a045aef 100644 --- a/apps/desktop/packages/ui/src/components/ai-elements/conversation.tsx +++ b/apps/desktop/packages/ui/src/components/ai-elements/conversation.tsx @@ -9,13 +9,24 @@ import { StickToBottom, useStickToBottomContext } from "use-stick-to-bottom" export { useStickToBottomContext } -export type ConversationProps = ComponentProps +export type ConversationProps = ComponentProps & { + /** + * While true, use spring resize so continuous stream height growth + * coalesces instead of fighting `instant` scroll every layout tick. + */ + streaming?: boolean +} -export const Conversation = ({ className, ...props }: ConversationProps) => ( +export const Conversation = ({ + className, + streaming = false, + resize, + ...props +}: ConversationProps) => ( diff --git a/apps/desktop/packages/ui/src/components/ai-elements/message.tsx b/apps/desktop/packages/ui/src/components/ai-elements/message.tsx index ca22f314..4322df7a 100644 --- a/apps/desktop/packages/ui/src/components/ai-elements/message.tsx +++ b/apps/desktop/packages/ui/src/components/ai-elements/message.tsx @@ -270,9 +270,17 @@ export const MessageBranchPage = ({ className, ...props }: MessageBranchPageProp ) } -export type MessageResponseProps = ComponentProps +export type MessageResponseProps = Omit, "plugins"> & { + plugins?: ComponentProps["plugins"] + /** + * Live streaming surface: skip Streamdown enter animation and heavy plugins + * (math / mermaid) until the turn is idle. + */ + streaming?: boolean +} const streamdownPlugins = { cjk, code, math, mermaid } +const streamdownPluginsStreaming = { cjk, code } // Product requirement: regular transcript Markdown tables should keep copy and // download controls, but not show a fullscreen control. Code blocks keep copy @@ -325,20 +333,24 @@ const transcriptMarkdownComponents: NonNullable ( + ({ className, streaming = false, animated, plugins, ...props }: MessageResponseProps) => ( *:first-child]:mt-0 [&>*:last-child]:mb-0", + streaming && "devo-message-response--streaming", className, )} + animated={streaming ? false : animated} components={transcriptMarkdownComponents} controls={transcriptMarkdownControls} - plugins={streamdownPlugins} + plugins={plugins ?? (streaming ? streamdownPluginsStreaming : streamdownPlugins)} {...props} /> ), (prevProps, nextProps) => - prevProps.children === nextProps.children && prevProps.animated === nextProps.animated, + prevProps.children === nextProps.children && + prevProps.animated === nextProps.animated && + prevProps.streaming === nextProps.streaming, ) MessageResponse.displayName = "MessageResponse" diff --git a/apps/desktop/packages/ui/src/components/ai-elements/reasoning.tsx b/apps/desktop/packages/ui/src/components/ai-elements/reasoning.tsx index 09af2293..199a2172 100644 --- a/apps/desktop/packages/ui/src/components/ai-elements/reasoning.tsx +++ b/apps/desktop/packages/ui/src/components/ai-elements/reasoning.tsx @@ -191,18 +191,34 @@ export const ReasoningTrigger = memo( export type ReasoningContentProps = ComponentProps & { children: ReactNode animated?: boolean + streaming?: boolean } const streamdownPlugins = { cjk, code, math, mermaid } +const streamdownPluginsStreaming = { cjk, code } -export const ReasoningText = memo(({ children, animated }: { children: string; animated?: boolean }) => ( - - {children} - -)) +export const ReasoningText = memo( + ({ + children, + animated, + streaming = false, + }: { + children: string + animated?: boolean + streaming?: boolean + }) => ( + + {children} + + ), +) export const ReasoningContent = memo( - ({ className, children, animated, ...props }: ReasoningContentProps) => ( + ({ className, children, animated, streaming, ...props }: ReasoningContentProps) => ( {typeof children === "string" ? ( - {children} + + {children} + ) : ( children )} diff --git a/apps/desktop/packages/ui/src/components/command.tsx b/apps/desktop/packages/ui/src/components/command.tsx index 42f9bf65..ebcc6f05 100644 --- a/apps/desktop/packages/ui/src/components/command.tsx +++ b/apps/desktop/packages/ui/src/components/command.tsx @@ -42,7 +42,7 @@ function CommandDialog({ return ( diff --git a/apps/desktop/src/main/canonical-provider-migration.ts b/apps/desktop/src/main/canonical-provider-migration.ts new file mode 100644 index 00000000..0cb6c4a0 --- /dev/null +++ b/apps/desktop/src/main/canonical-provider-migration.ts @@ -0,0 +1,14 @@ +/** Canonical Native provider/upsert payload shared by external config importers. */ +export interface CanonicalProviderUpsertParams { + provider: { + id: string + name: string + baseUrl?: string + wireApis: string[] + models: Record + enabled: true + } + defaultModel?: string + smallModel?: string + apiKey?: string +} diff --git a/apps/desktop/src/main/claude-code-provider-migration.test.ts b/apps/desktop/src/main/claude-code-provider-migration.test.ts index dcc348af..d8c07ca6 100644 --- a/apps/desktop/src/main/claude-code-provider-migration.test.ts +++ b/apps/desktop/src/main/claude-code-provider-migration.test.ts @@ -43,7 +43,7 @@ describe("Claude Code provider migration", () => { expect(result.category?.files[0].content).not.toContain("sk-xxxxxxxx") }) - test("executes provider upserts for each unique imported model", async () => { + test("executes one canonical provider upsert for the imported Connection", async () => { const calls: Array<{ method: string; params: Record }> = [] const result = await executeClaudeCodeProviderMigration( @@ -56,36 +56,24 @@ describe("Claude Code provider migration", () => { expect(result.errors).toEqual([]) expect(result.filesWritten).toEqual([ - "provider/upsert:claude-code/deepseek-v4-pro-1m-claude-code", - "provider/upsert:claude-code/deepseek-v4-flash-claude-code", + "provider/upsert:claude-code", ]) - expect(calls).toHaveLength(2) + expect(calls).toHaveLength(1) expect(calls[0].method).toBe("provider/upsert") expect(calls[0].params).toEqual({ - provider_vendor: { + provider: { + id: "claude-code", name: "claude-code", - base_url: "https://api.deepseek.com/anthropic", - credential: null, - headers: null, - wire_apis: ["anthropic_messages"], - enabled: true, - }, - model_binding: { - binding_id: "deepseek-v4-pro-1m-claude-code", - model_slug: "deepseek-v4-pro[1m]", - provider: "claude-code", - request_model: "deepseek-v4-pro[1m]", - display_name: "deepseek-v4-pro[1m]", - invocation_method: "anthropic_messages", - default_reasoning_effort: null, + baseUrl: "https://api.deepseek.com/anthropic", + wireApis: ["anthropic_messages"], + models: { + "deepseek-v4-pro[1m]": { name: "deepseek-v4-pro[1m]" }, + "deepseek-v4-flash": { name: "deepseek-v4-flash" }, + }, enabled: true, }, - default_model_binding: "deepseek-v4-pro-1m-claude-code", - api_key: "sk-xxxxxxxx", - }) - expect(calls[1].params).toMatchObject({ - default_model_binding: undefined, - api_key: "sk-xxxxxxxx", + defaultModel: "claude-code/deepseek-v4-pro[1m]", + apiKey: "sk-xxxxxxxx", }) }) @@ -104,7 +92,7 @@ describe("Claude Code provider migration", () => { expect(calls).toEqual([]) }) - test("imports model bindings without an API key and reports manual action", async () => { + test("imports models without an API key and reports manual action", async () => { const calls: Array> = [] const result = await executeClaudeCodeProviderMigration( @@ -121,6 +109,6 @@ describe("Claude Code provider migration", () => { expect(result.errors).toEqual([]) expect(result.manualActions.some((item) => item.includes("ANTHROPIC_AUTH_TOKEN"))).toBe(true) expect(calls).toHaveLength(1) - expect(calls[0].api_key).toBeUndefined() + expect(calls[0].apiKey).toBeUndefined() }) }) diff --git a/apps/desktop/src/main/claude-code-provider-migration.ts b/apps/desktop/src/main/claude-code-provider-migration.ts index 66d6033d..2fc706af 100644 --- a/apps/desktop/src/main/claude-code-provider-migration.ts +++ b/apps/desktop/src/main/claude-code-provider-migration.ts @@ -3,6 +3,7 @@ import { formatClaudeCodeProviderSettingsPreview, } from "@devo/configconv" import type { ClaudeCodeProviderSettings, ClaudeSettings } from "@devo/configconv" +import type { CanonicalProviderUpsertParams } from "./canonical-provider-migration" interface MigrationFilePreview { path: string @@ -73,13 +74,12 @@ export async function executeClaudeCodeProviderMigration( const errors: string[] = [...diagnostics.errors] for (const params of buildProviderUpsertParams(settings)) { - const bindingId = params.model_binding.binding_id try { await requestProviderUpsert("provider/upsert", params) - filesWritten.push(`provider/upsert:${settings.providerId}/${bindingId}`) + filesWritten.push(`provider/upsert:${settings.providerId}`) } catch (error) { errors.push( - `Claude Code provider migration failed for ${params.model_binding.request_model}: ${error instanceof Error ? error.message : String(error)}`, + `Claude Code provider migration failed for ${settings.providerId}: ${error instanceof Error ? error.message : String(error)}`, ) } } @@ -92,54 +92,27 @@ export async function executeClaudeCodeProviderMigration( } } -function buildProviderUpsertParams(settings: ClaudeCodeProviderSettings): Array<{ - provider_vendor: { - name: string - base_url: string | null - credential: null - headers: null - wire_apis: string[] - enabled: true - } - model_binding: { - binding_id: string - model_slug: string - provider: string - request_model: string - display_name: string - invocation_method: string - default_reasoning_effort: null - enabled: true - } - default_model_binding?: string - api_key?: string -}> { - return settings.models.map((model) => { - const bindingId = `${slugComponent(model)}-${settings.providerId}` - const params = { - provider_vendor: { +function buildProviderUpsertParams( + settings: ClaudeCodeProviderSettings, +): CanonicalProviderUpsertParams[] { + if (settings.models.length === 0) return [] + + return [ + { + provider: { + id: settings.providerId, name: settings.providerId, - base_url: settings.baseUrl ?? null, - credential: null, - headers: null, - wire_apis: [settings.wireApi], - enabled: true as const, + ...(settings.baseUrl ? { baseUrl: settings.baseUrl } : {}), + wireApis: [settings.wireApi], + models: Object.fromEntries(settings.models.map((model) => [model, { name: model }])), + enabled: true, }, - model_binding: { - binding_id: bindingId, - model_slug: model, - provider: settings.providerId, - request_model: model, - display_name: model, - invocation_method: settings.wireApi, - default_reasoning_effort: null, - enabled: true as const, - }, - default_model_binding: model === settings.defaultModel ? bindingId : undefined, - api_key: settings.apiKey, - } - return params - }) + ...(settings.defaultModel + ? { defaultModel: `${settings.providerId}/${settings.defaultModel}` } + : {}), + ...(settings.apiKey ? { apiKey: settings.apiKey } : {}), + }, + ] } function diagnosticsFor(settings: ClaudeCodeProviderSettings): { @@ -152,7 +125,7 @@ function diagnosticsFor(settings: ClaudeCodeProviderSettings): { if (settings.models.length === 0) { warnings.push( - "Claude Code settings did not include ANTHROPIC_MODEL or default Anthropic model env vars; no provider model bindings were imported.", + "Claude Code settings did not include ANTHROPIC_MODEL or default Anthropic model env vars; no provider Connection was imported.", ) } if (!settings.apiKey) { @@ -173,19 +146,6 @@ function readClaudeCodeSettings(scanResult: unknown): ClaudeSettings | undefined return isRecord(global.settings) ? (global.settings as ClaudeSettings) : undefined } -function slugComponent(value: string): string { - let out = "" - for (const ch of value) { - if (/[a-zA-Z0-9]/.test(ch)) { - out += ch.toLowerCase() - } else if (!out.endsWith("-")) { - out += "-" - } - } - const slug = out.replace(/^-+|-+$/g, "") - return slug || "model" -} - function isRecord(value: unknown): value is Record { return typeof value === "object" && value !== null && !Array.isArray(value) } diff --git a/apps/desktop/src/main/ipc-handlers.ts b/apps/desktop/src/main/ipc-handlers.ts index 6641d3d8..eb4071bf 100644 --- a/apps/desktop/src/main/ipc-handlers.ts +++ b/apps/desktop/src/main/ipc-handlers.ts @@ -59,6 +59,10 @@ import { stopServer, subscribeNative, } from "./devo-manager" +import { + isSessionNotFoundError, + nativeIpcErrorEnvelope, +} from "../shared/native-ipc-error" import { getOpaqueWindows, getSettings, onSettingsChanged, updateSettings } from "./settings-store" import { desktopTerminalManager } from "./terminal-manager" import { @@ -209,8 +213,23 @@ export function registerIpcHandlers(): void { "native:request", withLogging( "native:request", - async (_, request: { method: string; params?: unknown; directory?: string }) => - await requestNative(request.method, request.params, request.directory), + async (_, request: { method: string; params?: unknown; directory?: string }) => { + try { + return await requestNative(request.method, request.params, request.directory) + } catch (err) { + // SessionNotFound is an expected race (stale route / deleted session). + // Returning an envelope avoids Electron treating the handler rejection + // as an unhandled promise rejection; the renderer transport rethrows. + if (isSessionNotFoundError(err)) { + log.debug("native:request session not found", { + method: request.method, + message: err instanceof Error ? err.message : String(err), + }) + return nativeIpcErrorEnvelope(err) + } + throw err + } + }, ), ) diff --git a/apps/desktop/src/main/native-stdio-client.ts b/apps/desktop/src/main/native-stdio-client.ts index a0e730db..2513752f 100644 --- a/apps/desktop/src/main/native-stdio-client.ts +++ b/apps/desktop/src/main/native-stdio-client.ts @@ -316,6 +316,10 @@ export class StdioNativeClient implements NativeTransport { }, timeoutMs) this.pending.set(id, { resolve, reject, timer }) }) + // Server may reply (and reject) before the caller awaits — attach a + // no-op handler so Node does not log UnhandledPromiseRejectionWarning. + // Real callers still observe the rejection via await / .catch. + void response.catch(() => {}) this.pendingMethods.set(id, method) try { await this.writeJson(child, payload) @@ -414,9 +418,13 @@ export class StdioNativeClient implements NativeTransport { if (!pending) return this.pending.delete(routed.id) if (pending.timer !== undefined) clearTimeout(pending.timer) - const error = routed.message.error as { message?: string } | undefined + const error = routed.message.error as { message?: string; code?: string } | undefined if (error) { - pending.reject(new Error(error.message ?? "Devo Native request failed")) + const rejected = new Error(error.message ?? "Devo Native request failed") as Error & { + code?: string + } + if (typeof error.code === "string") rejected.code = error.code + pending.reject(rejected) } else { pending.resolve(routed.message.result) } diff --git a/apps/desktop/src/main/notification-watcher.ts b/apps/desktop/src/main/notification-watcher.ts index a060540d..bf83b4bd 100644 --- a/apps/desktop/src/main/notification-watcher.ts +++ b/apps/desktop/src/main/notification-watcher.ts @@ -57,15 +57,23 @@ export function startNotificationWatcher(transport: DevoNativeTransport): void { const client = createDevoClient({ transport }) setPermissionResponder(async ({ sessionId, permissionId, response }) => { - await client.permission.respond({ - sessionID: sessionId, - permissionID: permissionId, - response, - }) + try { + await client.permission.respond({ + sessionID: sessionId, + permissionID: permissionId, + response, + }) + } catch (err) { + log.error("Permission reply failed", { sessionId, permissionId, response }, err) + } }) log.info("Starting notification watcher") - connectWithRetry(client, abortController.signal) + void connectWithRetry(client, abortController.signal).catch((err) => { + if (!abortController?.signal.aborted) { + log.error("Notification watcher stopped unexpectedly", {}, err) + } + }) } /** diff --git a/apps/desktop/src/main/notifications.ts b/apps/desktop/src/main/notifications.ts index 1e663d05..cb73c712 100644 --- a/apps/desktop/src/main/notifications.ts +++ b/apps/desktop/src/main/notifications.ts @@ -193,6 +193,12 @@ function fireNotification(request: NotificationRequest): void { permissionId, response, directory: request.directory, + }).catch((err) => { + log.error( + "Permission notification action failed", + { sessionId: request.sessionId, permissionId, response }, + err, + ) }) }) } diff --git a/apps/desktop/src/main/opencode-provider-migration.test.ts b/apps/desktop/src/main/opencode-provider-migration.test.ts index f454ff96..afde16bc 100644 --- a/apps/desktop/src/main/opencode-provider-migration.test.ts +++ b/apps/desktop/src/main/opencode-provider-migration.test.ts @@ -23,6 +23,7 @@ function scanResultWithConfig( const exampleConfig = { model: "deepseek/deepseek-v4-pro", + small_model: "deepseek/deepseek-v4-flash", provider: { deepseek: { npm: "@ai-sdk/openai-compatible", @@ -78,7 +79,7 @@ describe("OpenCode provider migration", () => { expect(result.category?.files[1].content).not.toContain("tp-xxxxxx") }) - test("executes provider upserts for each imported model and sets explicit default only", async () => { + test("executes one canonical provider upsert per imported provider", async () => { const calls: Array<{ method: string; params: Record }> = [] const result = await executeOpenCodeProviderMigration( @@ -90,43 +91,30 @@ describe("OpenCode provider migration", () => { ) expect(result.errors).toEqual([]) - expect(result.filesWritten).toEqual([ - "provider/upsert:deepseek/deepseek-v4-flash-deepseek", - "provider/upsert:deepseek/deepseek-v4-pro-deepseek", - "provider/upsert:mimo/mimo-v2-5-mimo", - "provider/upsert:mimo/mimo-v2-5-pro-mimo", - ]) + expect(result.filesWritten).toEqual(["provider/upsert:deepseek", "provider/upsert:mimo"]) expect(calls.map((call) => call.method)).toEqual([ "provider/upsert", "provider/upsert", - "provider/upsert", - "provider/upsert", ]) expect(calls[1].params).toEqual({ - provider_vendor: { - name: "deepseek", - base_url: "https://api.deepseek.com/v1", - credential: null, - headers: null, - wire_apis: ["openai_chat_completions"], - enabled: true, - }, - model_binding: { - binding_id: "deepseek-v4-pro-deepseek", - model_slug: "deepseek-v4-pro", - provider: "deepseek", - request_model: "deepseek-v4-pro", - display_name: "DeepSeek V4 Pro", - invocation_method: "openai_chat_completions", - default_reasoning_effort: null, + provider: { + id: "mimo", + name: "Xiaomi MiMo", + baseUrl: "https://token-plan-cn.xiaomimimo.com/v1", + wireApis: ["openai_chat_completions"], + models: { + "mimo-v2.5": { name: "Mimo v2.5" }, + "mimo-v2.5-pro": { name: "Mimo v2.5 Pro" }, + }, enabled: true, }, - default_model_binding: "deepseek-v4-pro-deepseek", - api_key: "sk-xxxx", + apiKey: "tp-xxxxxx", }) - expect(calls[0].params.default_model_binding).toBeUndefined() - expect(calls[2].params.default_model_binding).toBeUndefined() - expect(calls[2].params.api_key).toBe("tp-xxxxxx") + expect(calls[0].params.defaultModel).toBe("deepseek/deepseek-v4-pro") + expect(calls[0].params.smallModel).toBe("deepseek/deepseek-v4-flash") + expect(calls[0].params.apiKey).toBe("sk-xxxx") + expect(calls[1].params.defaultModel).toBeUndefined() + expect(calls[1].params.smallModel).toBeUndefined() }) test("does not guess a default model when OpenCode does not define one", async () => { @@ -152,7 +140,7 @@ describe("OpenCode provider migration", () => { ) expect(calls).toHaveLength(1) - expect(calls[0].default_model_binding).toBeUndefined() + expect(calls[0].defaultModel).toBeUndefined() }) test("reports missing config, parse errors, unsupported providers, and missing key/base URL without crashing", async () => { diff --git a/apps/desktop/src/main/opencode-provider-migration.ts b/apps/desktop/src/main/opencode-provider-migration.ts index cfe5a5b0..5693b494 100644 --- a/apps/desktop/src/main/opencode-provider-migration.ts +++ b/apps/desktop/src/main/opencode-provider-migration.ts @@ -7,6 +7,7 @@ import type { OpenCodeProviderSettings, OpenCodeScanResult, } from "@devo/configconv" +import type { CanonicalProviderUpsertParams } from "./canonical-provider-migration" interface MigrationFilePreview { path: string @@ -84,15 +85,13 @@ export async function executeOpenCodeProviderMigration( const errors: string[] = [...diagnostics.errors] for (const params of buildProviderUpsertParams(settings)) { - const providerName = params.model_binding.provider - const requestModel = params.model_binding.request_model - const bindingId = params.model_binding.binding_id + const providerName = params.provider.id try { await requestProviderUpsert("provider/upsert", params) - filesWritten.push(`provider/upsert:${providerName}/${bindingId}`) + filesWritten.push(`provider/upsert:${providerName}`) } catch (error) { errors.push( - `OpenCode provider migration failed for ${providerName}/${requestModel}: ${error instanceof Error ? error.message : String(error)}`, + `OpenCode provider migration failed for ${providerName}: ${error instanceof Error ? error.message : String(error)}`, ) } } @@ -105,59 +104,28 @@ export async function executeOpenCodeProviderMigration( } } -function buildProviderUpsertParams(settings: OpenCodeProviderSettings): Array<{ - provider_vendor: { - name: string - base_url: string | null - credential: null - headers: null - wire_apis: string[] - enabled: true - } - model_binding: { - binding_id: string - model_slug: string - provider: string - request_model: string - display_name: string - invocation_method: string - default_reasoning_effort: null - enabled: true - } - default_model_binding?: string - api_key?: string -}> { - const params = [] - - for (const provider of settings.providers) { - for (const model of provider.models) { - const bindingId = `${slugComponent(model.modelId)}-${slugComponent(provider.providerId)}` - params.push({ - provider_vendor: { - name: provider.providerId, - base_url: provider.baseUrl ?? null, - credential: null, - headers: null, - wire_apis: [provider.wireApi], - enabled: true as const, - }, - model_binding: { - binding_id: bindingId, - model_slug: model.modelId, - provider: provider.providerId, - request_model: model.modelId, - display_name: model.displayName, - invocation_method: provider.wireApi, - default_reasoning_effort: null, - enabled: true as const, - }, - default_model_binding: model.isDefault ? bindingId : undefined, - api_key: provider.apiKey, - }) +function buildProviderUpsertParams( + settings: OpenCodeProviderSettings, +): CanonicalProviderUpsertParams[] { + return settings.providers.map((provider) => { + const defaultModel = provider.models.find((model) => model.isDefault)?.modelId + const smallModel = provider.models.find((model) => model.isSmall)?.modelId + return { + provider: { + id: provider.providerId, + name: provider.displayName, + ...(provider.baseUrl ? { baseUrl: provider.baseUrl } : {}), + wireApis: [provider.wireApi], + models: Object.fromEntries( + provider.models.map((model) => [model.modelId, { name: model.displayName }]), + ), + enabled: true, + }, + ...(defaultModel ? { defaultModel: `${provider.providerId}/${defaultModel}` } : {}), + ...(smallModel ? { smallModel: `${provider.providerId}/${smallModel}` } : {}), + ...(provider.apiKey ? { apiKey: provider.apiKey } : {}), } - } - - return params + }) } function diagnosticsFor(settings: OpenCodeProviderSettings): { @@ -186,7 +154,7 @@ function diagnosticsFor(settings: OpenCodeProviderSettings): { if (settings.providers.reduce((sum, provider) => sum + provider.models.length, 0) === 0) { warnings.push( - "OpenCode settings did not include any importable OpenAI-compatible provider models; no provider model bindings were imported.", + "OpenCode settings did not include any importable OpenAI-compatible provider models; no provider Connections were imported.", ) } @@ -205,7 +173,7 @@ function pushProviderDiagnostics( } if (provider.models.length === 0) { warnings.push( - `OpenCode provider ${provider.providerId} did not include model definitions; no model bindings were imported for this provider.`, + `OpenCode provider ${provider.providerId} did not include model definitions; no models were imported for this provider.`, ) } if (!provider.apiKey) { @@ -234,19 +202,6 @@ function readOpenCodeScanResult(scanResult: unknown): OpenCodeScanResult | undef } } -function slugComponent(value: string): string { - let out = "" - for (const ch of value) { - if (/[a-zA-Z0-9]/.test(ch)) { - out += ch.toLowerCase() - } else if (!out.endsWith("-")) { - out += "-" - } - } - const slug = out.replace(/^-+|-+$/g, "") - return slug || "model" -} - function isRecord(value: unknown): value is Record { return typeof value === "object" && value !== null && !Array.isArray(value) } diff --git a/apps/desktop/src/main/settings-store.test.ts b/apps/desktop/src/main/settings-store.test.ts index c189e97d..7d9e6487 100644 --- a/apps/desktop/src/main/settings-store.test.ts +++ b/apps/desktop/src/main/settings-store.test.ts @@ -28,7 +28,7 @@ function expectedSettings() { }, opaqueWindows: false, appearance: { - colorScheme: "dark", + colorScheme: "light", themeId: "default", displayMode: "default", hideThinkingWhileWorking: true, diff --git a/apps/desktop/src/renderer/assets/provider-logos/alibaba.svg b/apps/desktop/src/renderer/assets/provider-logos/alibaba.svg new file mode 100644 index 00000000..56c20781 --- /dev/null +++ b/apps/desktop/src/renderer/assets/provider-logos/alibaba.svg @@ -0,0 +1 @@ +AlibabaCloud \ No newline at end of file diff --git a/apps/desktop/src/renderer/assets/provider-logos/deepseek.svg b/apps/desktop/src/renderer/assets/provider-logos/deepseek.svg new file mode 100644 index 00000000..3fc23024 --- /dev/null +++ b/apps/desktop/src/renderer/assets/provider-logos/deepseek.svg @@ -0,0 +1 @@ +DeepSeek \ No newline at end of file diff --git a/apps/desktop/src/renderer/assets/provider-logos/kimi.svg b/apps/desktop/src/renderer/assets/provider-logos/kimi.svg new file mode 100644 index 00000000..83878fa2 --- /dev/null +++ b/apps/desktop/src/renderer/assets/provider-logos/kimi.svg @@ -0,0 +1 @@ +Kimi \ No newline at end of file diff --git a/apps/desktop/src/renderer/assets/provider-logos/minimax.svg b/apps/desktop/src/renderer/assets/provider-logos/minimax.svg new file mode 100644 index 00000000..beb7adb8 --- /dev/null +++ b/apps/desktop/src/renderer/assets/provider-logos/minimax.svg @@ -0,0 +1 @@ +Minimax \ No newline at end of file diff --git a/apps/desktop/src/renderer/assets/provider-logos/moonshot.svg b/apps/desktop/src/renderer/assets/provider-logos/moonshot.svg new file mode 100644 index 00000000..fb56ac10 --- /dev/null +++ b/apps/desktop/src/renderer/assets/provider-logos/moonshot.svg @@ -0,0 +1 @@ +MoonshotAI \ No newline at end of file diff --git a/apps/desktop/src/renderer/assets/provider-logos/moonshotai.svg b/apps/desktop/src/renderer/assets/provider-logos/moonshotai.svg new file mode 100644 index 00000000..3cdf7c86 --- /dev/null +++ b/apps/desktop/src/renderer/assets/provider-logos/moonshotai.svg @@ -0,0 +1,3 @@ + + + diff --git a/apps/desktop/src/renderer/assets/provider-logos/ollama.svg b/apps/desktop/src/renderer/assets/provider-logos/ollama.svg new file mode 100644 index 00000000..cc887e3d --- /dev/null +++ b/apps/desktop/src/renderer/assets/provider-logos/ollama.svg @@ -0,0 +1 @@ +Ollama \ No newline at end of file diff --git a/apps/desktop/src/renderer/assets/provider-logos/openai.svg b/apps/desktop/src/renderer/assets/provider-logos/openai.svg new file mode 100644 index 00000000..78caf4fa --- /dev/null +++ b/apps/desktop/src/renderer/assets/provider-logos/openai.svg @@ -0,0 +1 @@ +OpenAI \ No newline at end of file diff --git a/apps/desktop/src/renderer/assets/provider-logos/poolside.svg b/apps/desktop/src/renderer/assets/provider-logos/poolside.svg new file mode 100644 index 00000000..7969f594 --- /dev/null +++ b/apps/desktop/src/renderer/assets/provider-logos/poolside.svg @@ -0,0 +1 @@ +Poolside \ No newline at end of file diff --git a/apps/desktop/src/renderer/assets/provider-logos/qwen.svg b/apps/desktop/src/renderer/assets/provider-logos/qwen.svg new file mode 100644 index 00000000..f2d0ada1 --- /dev/null +++ b/apps/desktop/src/renderer/assets/provider-logos/qwen.svg @@ -0,0 +1 @@ +Qwen \ No newline at end of file diff --git a/apps/desktop/src/renderer/assets/provider-logos/tencent.svg b/apps/desktop/src/renderer/assets/provider-logos/tencent.svg new file mode 100644 index 00000000..42edd6cd --- /dev/null +++ b/apps/desktop/src/renderer/assets/provider-logos/tencent.svg @@ -0,0 +1 @@ +Hunyuan \ No newline at end of file diff --git a/apps/desktop/src/renderer/assets/provider-logos/xiaomi.svg b/apps/desktop/src/renderer/assets/provider-logos/xiaomi.svg new file mode 100644 index 00000000..eb97d8e1 --- /dev/null +++ b/apps/desktop/src/renderer/assets/provider-logos/xiaomi.svg @@ -0,0 +1 @@ +Xiaomi \ No newline at end of file diff --git a/apps/desktop/src/renderer/assets/provider-logos/zai.svg b/apps/desktop/src/renderer/assets/provider-logos/zai.svg new file mode 100644 index 00000000..b03d6018 --- /dev/null +++ b/apps/desktop/src/renderer/assets/provider-logos/zai.svg @@ -0,0 +1 @@ +ChatGLM \ No newline at end of file diff --git a/apps/desktop/src/renderer/assets/provider-logos/zhipu.svg b/apps/desktop/src/renderer/assets/provider-logos/zhipu.svg new file mode 100644 index 00000000..0c6e61ce --- /dev/null +++ b/apps/desktop/src/renderer/assets/provider-logos/zhipu.svg @@ -0,0 +1 @@ +Zhipu \ No newline at end of file diff --git a/apps/desktop/src/renderer/atoms/actions/event-processor.ts b/apps/desktop/src/renderer/atoms/actions/event-processor.ts index 1b6a472e..b6a78cf4 100644 --- a/apps/desktop/src/renderer/atoms/actions/event-processor.ts +++ b/apps/desktop/src/renderer/atoms/actions/event-processor.ts @@ -20,7 +20,7 @@ import { import { setSessionActiveTurnAtom, setSessionQueueAtom } from "../queue" import { sessionNativeFamily } from "../session-native" import { appStore } from "../store" -import { isStreamingField, streamingVersionFamily } from "../streaming" +import { isStreamingField, getStreamingPart, streamingVersionFamily } from "../streaming" import { todosFamily } from "../todos" import { setSessionDiffAtom } from "../ui" import { applyWorkspaceChangesUpdatedAtom } from "../workspace-changes" @@ -181,7 +181,9 @@ export function processEvent(event: Event): void { case "session/compaction/completed": { const sessionID = event.properties.sessionID ?? event.properties.session_id if (sessionID) { - set(compactionStatusFamily(sessionID), "completed") + // Transcript markers carry the durable "completed" row; clear the + // live atom so a later compaction can show "started" again. + set(compactionStatusFamily(sessionID), null) } break } @@ -245,11 +247,15 @@ export function processEvent(event: Event): void { const part = event.properties.part set(upsertPartAtom, part) // useSessionChat reads partsFamily imperatively through appStore.get, - // so every visible part update must bump the per-session version. - // Streaming text/reasoning parts may also be present in the streaming - // overlay, but the main-store write still needs to invalidate renders - // when it is the event that reaches the UI after a tool call. - set(streamingVersionFamily(part.sessionID), (v) => v + 1) + // so visible part updates must bump the per-session version — except + // when the streaming buffer already owns this text/reasoning part and + // has scheduled a throttled notify (avoids ~RAF double bumps). + const bufferedStreaming = + (part.type === "text" || part.type === "reasoning") && + Boolean(getStreamingPart(part.sessionID, part.messageID, part.id)) + if (!bufferedStreaming) { + set(streamingVersionFamily(part.sessionID), (v) => v + 1) + } break } @@ -314,11 +320,24 @@ export function processEvent(event: Event): void { const sessionID = event.properties.sessionID if (!sessionID) break const current = appStore.get(sessionNativeFamily(sessionID)) + const nextUsed = Number(event.properties.used ?? 0) + const nextSize = Number(event.properties.size ?? 0) + const previousSize = Number(current.usage?.size ?? 0) + const occupancyWindow = Number(current.occupancy?.contextWindowTokens ?? 0) + // Server size is already the model effective window. Keep the + // denominator in sync with live turn updates (including increases + // after the user raises usable context). + const stableSize = nextSize > 0 ? nextSize : occupancyWindow > 0 ? occupancyWindow : previousSize + const nextOccupancy = + current.occupancy && stableSize > 0 && current.occupancy.contextWindowTokens !== stableSize + ? { ...current.occupancy, contextWindowTokens: stableSize } + : current.occupancy set(sessionNativeFamily(sessionID), { ...current, + occupancy: nextOccupancy, usage: { - used: event.properties.used, - size: event.properties.size, + used: nextUsed, + size: stableSize, cost: event.properties.cost, }, }) @@ -328,10 +347,30 @@ export function processEvent(event: Event): void { case "context.usage.updated": { const sessionID = event.properties.sessionID if (!sessionID) break + const occupancy = event.properties.occupancy as + | { + totalTokens?: number + contextWindowTokens?: number + categories?: unknown + } + | undefined const current = appStore.get(sessionNativeFamily(sessionID)) + const occupancyTotal = Number(occupancy?.totalTokens ?? 0) + const occupancyWindow = Number(occupancy?.contextWindowTokens ?? 0) + const previousUsed = Number(current.usage?.used ?? 0) + const previousOccupancyWindow = Number(current.occupancy?.contextWindowTokens ?? 0) + // Trust the server window (model effective). Shrinks and increases + // both apply immediately so the Context usage popover denominator + // stays current. + const nextWindow = occupancyWindow > 0 ? occupancyWindow : previousOccupancyWindow set(sessionNativeFamily(sessionID), { ...current, - occupancy: event.properties.occupancy, + occupancy: occupancy, + usage: { + used: occupancyTotal > 0 ? occupancyTotal : previousUsed, + size: nextWindow > 0 ? nextWindow : Number(current.usage?.size ?? 0), + cost: current.usage?.cost, + }, }) break } diff --git a/apps/desktop/src/renderer/atoms/session-native.test.ts b/apps/desktop/src/renderer/atoms/session-native.test.ts index 435ff2c3..6a86ad44 100644 --- a/apps/desktop/src/renderer/atoms/session-native.test.ts +++ b/apps/desktop/src/renderer/atoms/session-native.test.ts @@ -4,7 +4,7 @@ import { partsFamily, partStorageKey } from "./parts" import { sessionNativeFamily } from "./session-native" import { sessionFamily, upsertSessionAtom } from "./sessions" import { appStore } from "./store" -import { streamingVersionFamily } from "./streaming" +import { streamingVersionFamily, updateStreamingPart, flushStreamingParts } from "./streaming" describe("Native session renderer state", () => { test("deduplicates replayed Native approvals by approval id", () => { @@ -82,8 +82,8 @@ describe("Native session renderer state", () => { configOptions: [{ id: "model", currentValue: "test-model" }], modeID: "plan", usage: { - used: 42, - size: 100, + used: 48_000, + size: 190_000, cost: { amount: 1, currency: "USD" }, }, occupancy: { @@ -97,6 +97,70 @@ describe("Native session renderer state", () => { }) }) + test("live turn usage advances the fill amount and syncs the window", () => { + const sessionID = "session-native-usage-ahead" + processEvent({ + type: "context.usage.updated", + properties: { + sessionID, + occupancy: { + totalTokens: 16_700, + contextWindowTokens: 190_000, + categories: [ + { id: "base", tokens: 10_000, shareBps: 5988 }, + { id: "conversation", tokens: 6_700, shareBps: 4012 }, + ], + }, + }, + }) + processEvent({ + type: "session.usage.updated", + properties: { + sessionID, + used: 48_000, + size: 250_000, + }, + }) + + const native = appStore.get(sessionNativeFamily(sessionID)) + expect(native.usage?.used).toBe(48_000) + // Denominator follows the live effective window from the server. + expect(native.usage?.size).toBe(250_000) + expect(native.occupancy?.contextWindowTokens).toBe(250_000) + expect(native.occupancy?.totalTokens).toBe(16_700) + }) + + test("applies occupancy window increases immediately", () => { + const sessionID = "session-native-window-increase" + processEvent({ + type: "context.usage.updated", + properties: { + sessionID, + occupancy: { + totalTokens: 50_000, + contextWindowTokens: 190_000, + categories: [], + }, + }, + }) + processEvent({ + type: "context.usage.updated", + properties: { + sessionID, + occupancy: { + totalTokens: 52_000, + contextWindowTokens: 1_000_000, + categories: [], + }, + }, + }) + + const native = appStore.get(sessionNativeFamily(sessionID)) + expect(native.occupancy?.contextWindowTokens).toBe(1_000_000) + expect(native.usage?.used).toBe(52_000) + expect(native.usage?.size).toBe(1_000_000) + }) + test("notifies session chat renders when text parts update", () => { const sessionID = "session-text-part-update" const messageID = "message-text-part-update" @@ -129,6 +193,32 @@ describe("Native session renderer state", () => { expect(appStore.get(streamingVersionFamily(sessionID))).toBe(initialVersion + 1) }) + test("skips version bump when text part is already in the streaming buffer", () => { + const sessionID = "session-buffered-text-part" + const messageID = "message-buffered-text-part" + const part = { + id: "buffered-text", + sessionID, + messageID, + type: "text" as const, + text: "hello", + time: { start: 1 }, + } + updateStreamingPart(part) + const versionAfterBuffer = appStore.get(streamingVersionFamily(sessionID)) + + processEvent({ + type: "message.part.updated", + properties: { part: { ...part, text: "hello world" } }, + }) + + expect(appStore.get(streamingVersionFamily(sessionID))).toBe(versionAfterBuffer) + expect(appStore.get(partsFamily(partStorageKey(sessionID, messageID)))).toEqual([ + { ...part, text: "hello world" }, + ]) + flushStreamingParts() + }) + test("stores scheduled retries, clears resumed retries, and reports transient failures", () => { const sessionID = "session-provider-retry" appStore.set(upsertSessionAtom, { @@ -146,19 +236,32 @@ describe("Native session renderer state", () => { provider: "openai", model: "test-model", phase: "scheduled", - message: "Retrying provider request in 1.0s", + message: "Internal server error", }, }) - expect(appStore.get(sessionFamily(sessionID))?.retryStatus).toEqual({ + const scheduled = appStore.get(sessionFamily(sessionID)) + expect(scheduled?.retryStatus).toEqual({ turnId: "turn-1", attempt: 2, backoffMs: 1000, provider: "openai", model: "test-model", phase: "scheduled", - message: "Retrying provider request in 1.0s", + message: "Internal server error", }) + expect(scheduled?.providerErrors).toEqual([ + { + id: "retry-turn-1-2", + turnId: "turn-1", + message: "Internal server error", + phase: "scheduled", + attempt: 2, + backoffMs: 1000, + scheduledAtMs: scheduled?.providerErrors?.[0]?.scheduledAtMs, + }, + ]) + expect(typeof scheduled?.providerErrors?.[0]?.scheduledAtMs).toBe("number") processEvent({ type: "turn.provider_retry_status", @@ -170,7 +273,7 @@ describe("Native session renderer state", () => { provider: "openai", model: "test-model", phase: "resumed", - message: "Retrying provider request now", + message: "Internal server error", }, }) processEvent({ @@ -184,13 +287,32 @@ describe("Native session renderer state", () => { }, }) - expect(appStore.get(sessionFamily(sessionID))).toEqual({ + const failed = appStore.get(sessionFamily(sessionID)) + expect(failed).toEqual({ session: { id: sessionID, title: "Retry test" }, directory: "/repo", status: { type: "idle" }, permissions: [], questions: [], retryStatus: undefined, + providerErrors: [ + { + id: "retry-turn-1-2", + turnId: "turn-1", + message: "Internal server error", + phase: "scheduled", + attempt: 2, + backoffMs: 1000, + scheduledAtMs: failed?.providerErrors?.[0]?.scheduledAtMs, + }, + { + id: "failed-turn-1-PROVIDER_SERVER_ERROR-Internal server error", + turnId: "turn-1", + message: "Internal server error", + phase: "failed", + code: "PROVIDER_SERVER_ERROR", + }, + ], error: { name: "PROVIDER_SERVER_ERROR", data: { message: "Internal server error" }, diff --git a/apps/desktop/src/renderer/atoms/sessions.ts b/apps/desktop/src/renderer/atoms/sessions.ts index fef86584..d9b5061b 100644 --- a/apps/desktop/src/renderer/atoms/sessions.ts +++ b/apps/desktop/src/renderer/atoms/sessions.ts @@ -32,6 +32,20 @@ export type ProviderRetryStatus = { message: string } +/** Expandable provider/LLM failure rows for the chat process timeline. */ +export type ProviderErrorEntry = { + id: string + turnId: string + message: string + phase: "scheduled" | "failed" | string + attempt?: number + code?: string + /** Backoff budget when this retry was scheduled (ms). */ + backoffMs?: number + /** Client clock when the scheduled retry was observed. */ + scheduledAtMs?: number +} + /** Phases of worktree setup shown in the chat view's empty state */ export type SessionSetupPhase = "creating-worktree" | "starting-session" | null @@ -70,6 +84,8 @@ export interface SessionEntry { hasUnreadCompletion?: boolean /** Active provider retry status for the current turn. */ retryStatus?: ProviderRetryStatus + /** Accumulated provider retry / failure rows for expandable display. */ + providerErrors?: ProviderErrorEntry[] } // ============================================================ @@ -114,6 +130,7 @@ export const upsertSessionAtom = atom( setupPhase: existing?.setupPhase, hasUnreadCompletion: existing?.hasUnreadCompletion, retryStatus: existing?.retryStatus, + providerErrors: existing?.providerErrors, }) // Add to index @@ -169,11 +186,15 @@ export const setSessionStatusAtom = atom( : completedTurn ? get(viewedSessionIdAtom) !== args.sessionId : entry.hasUnreadCompletion + // New turn: drop prior expandable error rows so they stay scoped to one attempt. + const startingFreshTurn = + isWorking && (entry.status.type === "idle" || entry.status.type === "error") set(sessionFamily(args.sessionId), { ...entry, status: args.status, hasUnreadCompletion, retryStatus: args.status.type === "idle" || args.status.type === "error" ? undefined : entry.retryStatus, + providerErrors: startingFreshTurn ? [] : entry.providerErrors, }) }, ) @@ -191,7 +212,37 @@ export const setProviderRetryStatusAtom = atom( ) => { const entry = get(sessionFamily(args.sessionId)) if (!entry) return - set(sessionFamily(args.sessionId), { ...entry, retryStatus: args.status }) + const status = args.status + if (!status) { + set(sessionFamily(args.sessionId), { ...entry, retryStatus: undefined }) + return + } + if (status.phase === "resumed") { + set(sessionFamily(args.sessionId), { ...entry, retryStatus: undefined }) + return + } + const message = status.message.trim() + const nextErrors = [...(entry.providerErrors ?? [])] + if (message || status.phase === "scheduled") { + const id = `retry-${status.turnId}-${status.attempt}` + const nextEntry: ProviderErrorEntry = { + id, + turnId: status.turnId, + message: message || "Provider request failed", + phase: status.phase || "scheduled", + attempt: status.attempt, + backoffMs: status.backoffMs, + scheduledAtMs: Date.now(), + } + const existingIndex = nextErrors.findIndex((item) => item.id === id) + if (existingIndex >= 0) nextErrors[existingIndex] = nextEntry + else nextErrors.push(nextEntry) + } + set(sessionFamily(args.sessionId), { + ...entry, + retryStatus: status, + providerErrors: nextErrors, + }) }, ) @@ -213,7 +264,34 @@ export const setSessionErrorAtom = atom( ) => { const entry = get(sessionFamily(args.sessionId)) if (!entry) return - set(sessionFamily(args.sessionId), { ...entry, error: args.error }) + if (!args.error) { + set(sessionFamily(args.sessionId), { ...entry, error: undefined }) + return + } + const message = + typeof args.error.data.message === "string" && args.error.data.message.trim() + ? args.error.data.message.trim() + : `${args.error.name}: ${JSON.stringify(args.error.data)}` + const turnId = + entry.retryStatus?.turnId || + [...(entry.providerErrors ?? [])].reverse().find((item) => item.turnId)?.turnId || + "" + const id = `failed-${turnId || "session"}-${args.error.name}-${message.slice(0, 48)}` + const nextErrors = [...(entry.providerErrors ?? [])] + if (!nextErrors.some((item) => item.message === message && item.phase === "failed")) { + nextErrors.push({ + id, + turnId, + message, + phase: "failed", + code: args.error.name, + }) + } + set(sessionFamily(args.sessionId), { + ...entry, + error: args.error, + providerErrors: nextErrors, + }) }, ) @@ -395,7 +473,8 @@ export const setSessionsAtom = atom( error: existing?.error, setupPhase: existing?.setupPhase, hasUnreadCompletion: existing?.hasUnreadCompletion, - retryStatus: existing?.retryStatus, + retryStatus: existing?.retryStatus, + providerErrors: existing?.providerErrors, }) nextIds.add(session.id) } diff --git a/apps/desktop/src/renderer/atoms/streaming.ts b/apps/desktop/src/renderer/atoms/streaming.ts index 7eae036e..56eb1ae2 100644 --- a/apps/desktop/src/renderer/atoms/streaming.ts +++ b/apps/desktop/src/renderer/atoms/streaming.ts @@ -11,8 +11,8 @@ import { appStore } from "./store" // session's components re-render. // ============================================================ -/** Throttle interval for React notifications — ~20 updates/sec */ -const FLUSH_THROTTLE_MS = 50 +/** Throttle interval for React notifications — ~12.5 updates/sec */ +const FLUSH_THROTTLE_MS = 80 /** Parts keyed by sessionID + messageID -> Part object, only for actively-streaming parts */ let buffer: Record> = {} @@ -194,10 +194,14 @@ export function hasStreamingParts(): boolean { } /** - * Get the current streaming part for a given message + part ID. + * Get the current streaming part for a given session + message + part ID. */ -export function getStreamingPart(messageId: string, partId: string): Part | undefined { - return buffer[messageId]?.[partId] +export function getStreamingPart( + sessionId: string, + messageId: string, + partId: string, +): Part | undefined { + return buffer[partStorageKey(sessionId, messageId)]?.[partId] } /** diff --git a/apps/desktop/src/renderer/components/chat/activity-cue.tsx b/apps/desktop/src/renderer/components/chat/activity-cue.tsx new file mode 100644 index 00000000..d442bb52 --- /dev/null +++ b/apps/desktop/src/renderer/components/chat/activity-cue.tsx @@ -0,0 +1,27 @@ +import { cn } from "@devo/ui/lib/utils" +import type { ReactNode } from "react" + +/** + * Quiet live status: muted typography only. + * Matches Working for / transcript row type: 13px / leading-5. + */ +export function ActivityCue({ + children, + className, +}: { + children: ReactNode + /** Kept for call-site clarity; live vs idle is expressed by surrounding copy. */ + active?: boolean + className?: string +}) { + return ( + + {children} + + ) +} diff --git a/apps/desktop/src/renderer/components/chat/chat-tool-call.tsx b/apps/desktop/src/renderer/components/chat/chat-tool-call.tsx index 4059a87b..6a2c3832 100644 --- a/apps/desktop/src/renderer/components/chat/chat-tool-call.tsx +++ b/apps/desktop/src/renderer/components/chat/chat-tool-call.tsx @@ -18,7 +18,6 @@ import { FileCodeIcon, FileIcon, GlobeIcon, - Loader2Icon, MessageCircleQuestionIcon, PlugIcon, SearchIcon, @@ -702,7 +701,10 @@ function TodoContent({ part }: { part: ToolPart }) { {todo.status === "completed" ? ( ) : todo.status === "in_progress" ? ( - + } - trailing={ - running ? ( - - ) : undefined - } - /> + {description}} /> {tools.map((tool) => ( {children} +}) + +const ProcessTimelineThoughtRow = memo(function ProcessTimelineThoughtRow({ + rowId, + part, + isStreaming, + defaultExpandAll, + expanded, + onToggleRow, +}: { + rowId: string + part: Extract["part"] + isStreaming: boolean + defaultExpandAll: boolean + expanded?: boolean + onToggleRow?: (rowId: string, open: boolean) => void +}) { + const handleOpenChange = useCallback( + (open: boolean) => { + onToggleRow?.(rowId, open) + }, + [onToggleRow, rowId], + ) + + return ( + + ) +}) + +const ProcessTimelineToolRow = memo(function ProcessTimelineToolRow({ + rowId, + part, + defaultExpandAll, + expanded, + onToggleRow, + onDeleteToolPart, + projectRoot, + turnHasError, + working, +}: { + rowId: string + part: ToolPart + defaultExpandAll: boolean + expanded?: boolean + onToggleRow?: (rowId: string, open: boolean) => void + onDeleteToolPart?: (part: ToolPart) => Promise + projectRoot?: string | null + turnHasError?: boolean + working: boolean +}) { + const handleOpenChange = useCallback( + (open: boolean) => { + onToggleRow?.(rowId, open) + }, + [onToggleRow, rowId], + ) + + return ( + + ) +}) + +const ProcessTimelineToolGroupRow = memo(function ProcessTimelineToolGroupRow({ + rowId, + category, + tools, + defaultOpen, + expanded, + onToggleRow, + projectRoot, + working, +}: { + rowId: string + category: ToolCategory + tools: ToolPart[] + defaultOpen: boolean + expanded?: boolean + onToggleRow?: (rowId: string, open: boolean) => void + projectRoot?: string | null + working: boolean +}) { + const handleOpenChange = useCallback( + (open: boolean) => { + onToggleRow?.(rowId, open) + }, + [onToggleRow, rowId], + ) + + return ( + + ) +}) + export interface ProcessTimelineViewProps { items: ProcessTimelineItem[] orderedParts: ProcessTimelineInput[] @@ -104,53 +217,54 @@ export const ProcessTimelineView = memo(function ProcessTimelineView({ const rowId = processTimelineRowId(item, index) if (item.kind === "text") { - return
{renderText(item)}
+ return ( + {renderText(item)} + ) } if (item.kind === "thought") { const isStreaming = working && isReasoningPartActivelyStreaming(orderedParts, item.part) return ( - onToggleRow(rowId, open) : undefined - } - open={expandedRowIds ? expandedRowIds.has(rowId) : undefined} + onToggleRow={onToggleRow} part={item.part} + rowId={rowId} /> ) } if (item.kind === "tool") { return ( - onToggleRow(rowId, open) : undefined - } + defaultExpandAll={defaultExpandAll} + expanded={expandedRowIds ? expandedRowIds.has(rowId) : undefined} + onDeleteToolPart={onDeleteToolPart} + onToggleRow={onToggleRow} part={item.part} projectRoot={projectRoot} + rowId={rowId} turnHasError={turnHasError} - turnWorking={working} + working={working} /> ) } return ( - onToggleRow(rowId, open) : undefined} - open={expandedRowIds ? expandedRowIds.has(rowId) : undefined} + expanded={expandedRowIds ? expandedRowIds.has(rowId) : undefined} + onToggleRow={onToggleRow} projectRoot={projectRoot} + rowId={rowId} tools={item.tools} - turnWorking={working} + working={working} /> ) })} diff --git a/apps/desktop/src/renderer/components/chat/process-timeline.test.ts b/apps/desktop/src/renderer/components/chat/process-timeline.test.ts index 6ff45702..df13f7b6 100644 --- a/apps/desktop/src/renderer/components/chat/process-timeline.test.ts +++ b/apps/desktop/src/renderer/components/chat/process-timeline.test.ts @@ -1,6 +1,10 @@ import { describe, expect, test } from "bun:test" import type { ReasoningPart, ToolPart } from "../../lib/types" -import { buildProcessTimeline, isReasoningPartActivelyStreaming } from "./process-timeline" +import { + buildProcessTimeline, + isReasoningPartActivelyStreaming, + processTimelineRowId, +} from "./process-timeline" function reasoning(id: string): { kind: "reasoning"; part: ReasoningPart } { return { @@ -82,3 +86,14 @@ describe("isReasoningPartActivelyStreaming", () => { expect(isReasoningPartActivelyStreaming(parts, reasoningWithoutEnd("r1").part)).toBe(false) }) }) + +describe("processTimelineRowId", () => { + test("uses stable tool-group ids from category and tool ids", () => { + const items = buildProcessTimeline([tool("t1"), tool("t2")]) + const group = items.find((item) => item.kind === "tool-group") + expect(group).toBeTruthy() + if (!group || group.kind !== "tool-group") return + expect(processTimelineRowId(group, 0)).toBe(`group-${group.category}-t1+t2`) + expect(processTimelineRowId(group, 99)).toBe(`group-${group.category}-t1+t2`) + }) +}) diff --git a/apps/desktop/src/renderer/components/chat/process-timeline.ts b/apps/desktop/src/renderer/components/chat/process-timeline.ts index 21e41818..6ea86165 100644 --- a/apps/desktop/src/renderer/components/chat/process-timeline.ts +++ b/apps/desktop/src/renderer/components/chat/process-timeline.ts @@ -94,7 +94,9 @@ export function processTimelineRowId(item: ProcessTimelineItem, index: number): return item.part.id case "tool": return item.part.id - case "tool-group": - return `group-${index}-${item.tools[0]?.id ?? index}` + case "tool-group": { + const toolIds = item.tools.map((tool) => tool.id).join("+") + return `group-${item.category}-${toolIds || index}` + } } } diff --git a/apps/desktop/src/renderer/components/chat/provider-error-row.tsx b/apps/desktop/src/renderer/components/chat/provider-error-row.tsx new file mode 100644 index 00000000..c383b28a --- /dev/null +++ b/apps/desktop/src/renderer/components/chat/provider-error-row.tsx @@ -0,0 +1,79 @@ +/** + * Expandable provider/LLM error row — same disclosure chrome as tool calls. + * Scheduled retries show a live countdown; expand reveals the failure reason. + */ +import { memo, useEffect, useState } from "react" +import type { ProviderErrorEntry } from "../../atoms/sessions" +import { + TranscriptDisclosure, + TranscriptDisclosureContent, + TranscriptDisclosureTrigger, +} from "./transcript-disclosure" + +export type { ProviderErrorEntry } + +function remainingBackoffMs(entry: ProviderErrorEntry, nowMs: number): number { + if (entry.phase !== "scheduled") return 0 + const backoff = entry.backoffMs ?? 0 + if (backoff <= 0) return 0 + const started = entry.scheduledAtMs ?? nowMs + return Math.max(0, backoff - (nowMs - started)) +} + +function formatCountdown(ms: number): string { + const totalSeconds = ms / 1000 + if (totalSeconds >= 10) return `${Math.ceil(totalSeconds)}s` + return `${totalSeconds.toFixed(1)}s` +} + +function summaryLabel(entry: ProviderErrorEntry, remainingMs: number, pending: boolean): string { + if (entry.phase === "scheduled") { + const attempt = + entry.attempt != null && entry.attempt > 0 ? ` (attempt ${entry.attempt})` : "" + if (pending && remainingMs > 0) { + return `Provider retry${attempt} · ${formatCountdown(remainingMs)}` + } + return `Provider retry${attempt}` + } + if (entry.code && entry.code !== "Error" && entry.code !== "TurnFailed") { + return entry.code + } + return "Request failed" +} + +export const ProviderErrorRow = memo(function ProviderErrorRow({ + entry, + pending = false, +}: { + entry: ProviderErrorEntry + pending?: boolean +}) { + const [nowMs, setNowMs] = useState(() => Date.now()) + const remainingMs = remainingBackoffMs(entry, nowMs) + const liveCountdown = pending && entry.phase === "scheduled" && (entry.backoffMs ?? 0) > 0 + + useEffect(() => { + if (!liveCountdown) return + setNowMs(Date.now()) + const id = window.setInterval(() => setNowMs(Date.now()), 100) + return () => window.clearInterval(id) + }, [liveCountdown, entry.id, entry.scheduledAtMs, entry.backoffMs]) + + const label = summaryLabel(entry, remainingMs, pending) + return ( + + {label}} + aria-label={`${label}: expand to view details`} + /> + + {entry.code ? ( +

{entry.code}

+ ) : null} +
+					{entry.message}
+				
+
+
+ ) +}) diff --git a/apps/desktop/src/renderer/components/chat/sub-agent-card.tsx b/apps/desktop/src/renderer/components/chat/sub-agent-card.tsx index 9e05f428..199994e8 100644 --- a/apps/desktop/src/renderer/components/chat/sub-agent-card.tsx +++ b/apps/desktop/src/renderer/components/chat/sub-agent-card.tsx @@ -7,7 +7,6 @@ import { ChevronDownIcon, ChevronRightIcon, ChevronUpIcon, - Loader2Icon, MessageCircleQuestionIcon, ShieldAlertIcon, ZapIcon, @@ -232,27 +231,27 @@ export const SubAgentCard = memo(function SubAgentCard({ if (p.type === "tool") { switch (p.tool) { case "task": - lastStatus = "Delegating..." + lastStatus = "Delegating" break case "todowrite": case "todoread": - lastStatus = "Planning..." + lastStatus = "Planning next moves" break case "read": - lastStatus = "Reading files..." + lastStatus = "Reading files" break case "list": case "grep": case "glob": - lastStatus = "Searching codebase..." + lastStatus = "Searching the codebase" break case "webfetch": - lastStatus = "Fetching web content..." + lastStatus = "Fetching from the web" break case "edit": case "write": case "apply_patch": - lastStatus = "Making edits..." + lastStatus = "Editing files" break case "bash": case "shell_command": @@ -260,13 +259,13 @@ export const SubAgentCard = memo(function SubAgentCard({ lastStatus = "" break default: - lastStatus = `Running ${p.tool}...` + lastStatus = `Running ${p.tool}` break } } else if (p.type === "reasoning") { - lastStatus = "Thinking..." + lastStatus = "Planning next moves" } else if (p.type === "text") { - lastStatus = "Composing response..." + lastStatus = "Writing response" } } } @@ -274,7 +273,7 @@ export const SubAgentCard = memo(function SubAgentCard({ return { latestToolParts: toolParts.slice(-3), latestText, - childStatus: lastStatus ?? "Working...", + childStatus: lastStatus ?? "Planning next moves", } }, [childMessages, streamingVersion, sessionId]) @@ -313,7 +312,7 @@ export const SubAgentCard = memo(function SubAgentCard({ @@ -344,8 +343,6 @@ export const SubAgentCard = memo(function SubAgentCard({ {elapsedTime} )} - {isRunning && !childIsWaiting && } - {childIsWaiting && } {sessionId && ( diff --git a/apps/desktop/src/renderer/components/onboarding/steps/provider-setup-step.tsx b/apps/desktop/src/renderer/components/onboarding/steps/provider-setup-step.tsx index bfb05245..d219bbc5 100644 --- a/apps/desktop/src/renderer/components/onboarding/steps/provider-setup-step.tsx +++ b/apps/desktop/src/renderer/components/onboarding/steps/provider-setup-step.tsx @@ -71,7 +71,8 @@ export function ProviderSetupStep({ onComplete, onSkip }: ProviderSetupStepProps }, [allProviders, connectedIds]) const zenIsConnected = connectedIds.has(ZEN_PROVIDER_ID) - const zenHasApiKey = zenIsConnected && zenProvider !== null && !isZenFreeTier(zenProvider.models) + const zenHasApiKey = + zenIsConnected && zenProvider !== null && !isZenFreeTier(zenProvider.models ?? {}) const reload = useCallback(() => { reloadCatalog() @@ -163,8 +164,13 @@ export function ProviderSetupStep({ onComplete, onSkip }: ProviderSetupStepProps className="group flex items-center gap-3 rounded-xl border border-border bg-background px-4 py-3 text-left transition-all hover:border-primary/50 hover:bg-accent" > -
+
{provider.name} + {typeof provider.description === "string" && provider.description.trim() ? ( + + {provider.description} + + ) : null} {isConnected && ( Connected @@ -221,10 +227,10 @@ function ZenFeaturedCard({ hasApiKey: boolean onConnect: () => void }) { - const freeModelCount = Object.values(provider.models).filter( + const freeModelCount = Object.values(provider.models ?? {}).filter( (m) => (m as { cost?: { input?: number } }).cost?.input === 0, ).length - const totalModelCount = Object.keys(provider.models).length + const totalModelCount = Object.keys(provider.models ?? {}).length return ( { replacesOverviewButtonInHeader: !agentDetailSource.includes(" 0 ? usageUsed") && + contextUsageSource.includes("occupancy?.totalTokens"), alignsToConversationSurface: contextUsageSource.includes("data-conversation-surface=") && chatViewSource.includes("data-conversation-surface={agent.sessionId}") && @@ -95,6 +98,7 @@ describe("SessionMetricsBar top timer wiring", () => { replacesOverviewButtonInHeader: true, opensPromptBreakdown: true, usesOccupancyCategories: true, + prefersLiveUsageForFill: true, alignsToConversationSurface: true, sitsFlushToTop: true, matchesAppPopoverChrome: true, diff --git a/apps/desktop/src/renderer/components/settings/about-settings.tsx b/apps/desktop/src/renderer/components/settings/about-settings.tsx index be048b16..086ac723 100644 --- a/apps/desktop/src/renderer/components/settings/about-settings.tsx +++ b/apps/desktop/src/renderer/components/settings/about-settings.tsx @@ -23,7 +23,7 @@ export function AboutSettings() { }, []) return ( -
+
diff --git a/apps/desktop/src/renderer/components/settings/connect-provider-dialog.tsx b/apps/desktop/src/renderer/components/settings/connect-provider-dialog.tsx index 3e4f3b45..195a3a61 100644 --- a/apps/desktop/src/renderer/components/settings/connect-provider-dialog.tsx +++ b/apps/desktop/src/renderer/components/settings/connect-provider-dialog.tsx @@ -1083,10 +1083,11 @@ function ZenSetupView({ const [apiKey, setApiKey] = useState("") const isLoading = state.status === "loading" - const freeModelCount = Object.values(provider.models).filter( + const models = provider.models ?? {} + const freeModelCount = Object.values(models).filter( (m) => (m as { cost?: { input?: number } }).cost?.input === 0, ).length - const totalModelCount = Object.keys(provider.models).length + const totalModelCount = Object.keys(models).length const handleSubmit = useCallback( (e: React.FormEvent) => { diff --git a/apps/desktop/src/renderer/components/settings/connection-detail-dialog.tsx b/apps/desktop/src/renderer/components/settings/connection-detail-dialog.tsx new file mode 100644 index 00000000..40472b49 --- /dev/null +++ b/apps/desktop/src/renderer/components/settings/connection-detail-dialog.tsx @@ -0,0 +1,205 @@ +/** + * Dialog showing a connected provider's model list. + * + * Allows adding models, removing individual models, and triggering + * model discovery from the provider's /models endpoint. + */ + +import type { CatalogModelInfo, CatalogProviderInfo } from "@devo-ai/sdk/v2/client" +import { Badge } from "@devo/ui/components/badge" +import { Button } from "@devo/ui/components/button" +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@devo/ui/components/dialog" +import { Input } from "@devo/ui/components/input" +import { Spinner } from "@devo/ui/components/spinner" +import { PlusIcon, RefreshCwIcon, SearchIcon, Trash2Icon } from "lucide-react" +import { useCallback, useMemo, useState } from "react" +import { getBaseClient } from "../../services/connection-manager" +import { + effectiveContextWindowTokens, + formatContextWindowLabel, +} from "../../lib/providers" +import { ModelEditDialog } from "./model-edit-dialog" +import { ProviderIcon } from "./provider-icon" + +interface ConnectionDetailDialogProps { + provider: CatalogProviderInfo + connectionModels: Record + open: boolean + onOpenChange: (open: boolean) => void + onChanged: () => void +} + +export function ConnectionDetailDialog({ + provider, + connectionModels, + open, + onOpenChange, + onChanged, +}: ConnectionDetailDialogProps) { + const [search, setSearch] = useState("") + const [discovering, setDiscovering] = useState(false) + const [removingModel, setRemovingModel] = useState(null) + const [addingModel, setAddingModel] = useState(false) + + const models = useMemo(() => { + const entries = Object.entries(connectionModels) + if (!search.trim()) return entries + const lower = search.toLowerCase() + return entries.filter( + ([id, m]) => + id.toLowerCase().includes(lower) || + (m.name ?? "").toLowerCase().includes(lower), + ) + }, [connectionModels, search]) + + const handleDiscover = useCallback(async () => { + setDiscovering(true) + try { + const client = getBaseClient() + if (!client) return + await client.provider.discover({ + providerId: provider.id, + forceRefresh: true, + }) + onChanged() + } finally { + setDiscovering(false) + } + }, [provider.id, onChanged]) + + const handleRemoveModel = useCallback( + async (modelId: string) => { + setRemovingModel(modelId) + try { + const client = getBaseClient() + if (!client) return + await client.provider.modelRemove({ + providerId: provider.id, + modelId, + }) + onChanged() + } finally { + setRemovingModel(null) + } + }, + [provider.id, onChanged], + ) + + return ( + <> + + + +
+ + {provider.name} + Connected +
+ + {Object.keys(connectionModels).length} model + {Object.keys(connectionModels).length !== 1 ? "s" : ""} in this Connection. + {provider.baseUrl ? ` Endpoint: ${provider.baseUrl}` : ""} + +
+ + {/* Toolbar */} +
+
+ + setSearch(e.target.value)} + className="h-8 pl-8 text-[13px]" + /> +
+ + +
+ + {/* Model list */} +
+ {models.length === 0 ? ( +
+ {search.trim() ? `No models match "${search}"` : "No models in this Connection."} +
+ ) : ( + models.map(([modelId, model]) => { + const contextLabel = formatContextWindowLabel( + effectiveContextWindowTokens(model), + ) + return ( +
+
+

{model.name ?? modelId}

+

+ {provider.id}/{modelId} +

+
+ {contextLabel && ( + + {contextLabel} + + )} + +
+ ) + }) + )} +
+ + + + +
+
+ + {addingModel && ( + { if (!open) setAddingModel(false) }} + onSaved={onChanged} + /> + )} + + ) +} diff --git a/apps/desktop/src/renderer/components/settings/custom-provider-dialog.tsx b/apps/desktop/src/renderer/components/settings/custom-provider-dialog.tsx new file mode 100644 index 00000000..d3155bc9 --- /dev/null +++ b/apps/desktop/src/renderer/components/settings/custom-provider-dialog.tsx @@ -0,0 +1,351 @@ +/** + * Dialog for creating or editing a custom provider Connection. + * + * All fields are user-editable: id, name, base URL, wire API, API key, + * models, and optional request headers. + */ + +import type { CatalogModelInfo, CatalogProviderInfo, CatalogWireApi } from "@devo-ai/sdk/v2/client" +import { Button } from "@devo/ui/components/button" +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@devo/ui/components/dialog" +import { Input } from "@devo/ui/components/input" +import { Label } from "@devo/ui/components/label" +import { + Select, + SelectContent, + SelectGroup, + SelectItem, + SelectTrigger, + SelectValue, +} from "@devo/ui/components/select" +import { Spinner } from "@devo/ui/components/spinner" +import { PlusIcon, Trash2Icon } from "lucide-react" +import { useCallback, useState } from "react" +import { getBaseClient } from "../../services/connection-manager" + +const WIRE_API_OPTIONS: Array<{ value: CatalogWireApi; label: string }> = [ + { value: "openai_chat_completions", label: "OpenAI Chat Completions" }, + { value: "openai_responses", label: "OpenAI Responses" }, + { value: "anthropic_messages", label: "Anthropic Messages" }, +] + +interface CustomProviderDialogProps { + /** Pass an existing provider to edit; omit for new. */ + provider?: CatalogProviderInfo + open: boolean + onOpenChange: (open: boolean) => void + onSaved: () => void +} + +interface ModelEntry { + id: string + name: string +} + +interface HeaderEntry { + key: string + value: string +} + +export function CustomProviderDialog({ + provider: existing, + open, + onOpenChange, + onSaved, +}: CustomProviderDialogProps) { + const isEdit = !!existing + + const [providerId, setProviderId] = useState(existing?.id ?? "") + const [displayName, setDisplayName] = useState(existing?.name ?? "") + const [baseUrl, setBaseUrl] = useState(existing?.baseUrl ?? "") + const [wireApi, setWireApi] = useState( + existing?.wireApis[0] ?? "openai_chat_completions", + ) + const [apiKey, setApiKey] = useState("") + const [models, setModels] = useState(() => { + if (!existing) return [{ id: "", name: "" }] + return Object.entries(existing.models ?? {}).map(([id, m]) => ({ + id, + name: m.name ?? "", + })) + }) + const [headers, setHeaders] = useState(() => { + if (!existing?.headers) return [] + return Object.entries(existing.headers).map(([key, value]) => ({ key, value })) + }) + const [saving, setSaving] = useState(false) + const [error, setError] = useState(null) + + const addModel = useCallback(() => { + setModels((prev) => [...prev, { id: "", name: "" }]) + }, []) + + const removeModel = useCallback((index: number) => { + setModels((prev) => prev.filter((_, i) => i !== index)) + }, []) + + const updateModel = useCallback((index: number, field: "id" | "name", value: string) => { + setModels((prev) => prev.map((m, i) => (i === index ? { ...m, [field]: value } : m))) + }, []) + + const addHeader = useCallback(() => { + setHeaders((prev) => [...prev, { key: "", value: "" }]) + }, []) + + const removeHeader = useCallback((index: number) => { + setHeaders((prev) => prev.filter((_, i) => i !== index)) + }, []) + + const updateHeader = useCallback((index: number, field: "key" | "value", value: string) => { + setHeaders((prev) => prev.map((h, i) => (i === index ? { ...h, [field]: value } : h))) + }, []) + + const handleSave = useCallback(async () => { + const id = providerId.trim() + if (!id) { + setError("Provider ID is required") + return + } + if (!baseUrl.trim()) { + setError("Base URL is required") + return + } + + setSaving(true) + setError(null) + try { + const client = getBaseClient() + if (!client) throw new Error("Not connected to server") + + const catalogModels: Record = {} + for (const m of models) { + const mid = m.id.trim() + if (mid) { + catalogModels[mid] = { name: m.name.trim() || undefined } + } + } + + const catalogHeaders: Record = {} + for (const h of headers) { + if (h.key.trim()) { + catalogHeaders[h.key.trim()] = h.value + } + } + + const provider: CatalogProviderInfo = { + id, + name: displayName.trim() || id, + baseUrl: baseUrl.trim(), + wireApis: [wireApi], + models: catalogModels, + enabled: true, + ...(Object.keys(catalogHeaders).length > 0 ? { headers: catalogHeaders } : {}), + } + + await client.provider.upsert({ + provider, + ...(apiKey.trim() ? { apiKey: apiKey.trim() } : {}), + }) + + onOpenChange(false) + onSaved() + } catch (err) { + setError(err instanceof Error ? err.message : "Failed to save provider") + } finally { + setSaving(false) + } + }, [providerId, displayName, baseUrl, wireApi, apiKey, models, headers, onOpenChange, onSaved]) + + return ( + + + + {isEdit ? "Edit Custom Provider" : "Add Custom Provider"} + + Configure an OpenAI-compatible or Anthropic-compatible provider. + + + +
+ {/* Provider ID */} +
+ + setProviderId(e.target.value)} + disabled={isEdit || saving} + /> +

+ Lowercase letters, numbers, hyphens, or underscores. +

+
+ + {/* Display name */} +
+ + setDisplayName(e.target.value)} + disabled={saving} + /> +
+ + {/* Base URL */} +
+ + setBaseUrl(e.target.value)} + disabled={saving} + /> +
+ + {/* Wire API */} +
+ + +
+ + {/* API key */} +
+ + setApiKey(e.target.value)} + disabled={saving} + /> +

+ Optional if you authenticate via request headers. +

+
+ + {/* Models */} +
+ + {models.map((m, i) => ( +
+ updateModel(i, "id", e.target.value)} + className="flex-1" + disabled={saving} + /> + updateModel(i, "name", e.target.value)} + className="flex-1" + disabled={saving} + /> + +
+ ))} + +
+ + {/* Headers */} +
+ + {headers.map((h, i) => ( +
+ updateHeader(i, "key", e.target.value)} + className="flex-1" + disabled={saving} + /> + updateHeader(i, "value", e.target.value)} + className="flex-1" + disabled={saving} + /> + +
+ ))} + +
+ + {error &&

{error}

} +
+ + + + + +
+
+ ) +} diff --git a/apps/desktop/src/renderer/components/settings/general-settings.tsx b/apps/desktop/src/renderer/components/settings/general-settings.tsx index e2890550..c41dc5f4 100644 --- a/apps/desktop/src/renderer/components/settings/general-settings.tsx +++ b/apps/desktop/src/renderer/components/settings/general-settings.tsx @@ -22,7 +22,7 @@ const isElectron = typeof window !== "undefined" && "devo" in window export function GeneralSettings() { return ( -
+
diff --git a/apps/desktop/src/renderer/components/settings/mcp-settings.tsx b/apps/desktop/src/renderer/components/settings/mcp-settings.tsx index f2e6a262..40685488 100644 --- a/apps/desktop/src/renderer/components/settings/mcp-settings.tsx +++ b/apps/desktop/src/renderer/components/settings/mcp-settings.tsx @@ -129,7 +129,7 @@ export function McpSettings({ type="button" size="sm" variant="secondary" - className="h-8 rounded-full px-3" + className="h-7" disabled={openingConfig} onClick={() => void openMcpConfig()} aria-label="Add MCP" @@ -140,7 +140,7 @@ export function McpSettings({ ) return ( -
+
{!embedded && ( {enabled && expandedThis && ( -
+
{toolsError ? (

Failed to load tools: {String(toolsError)}

) : toolsLoading ? ( diff --git a/apps/desktop/src/renderer/components/settings/model-edit-dialog.tsx b/apps/desktop/src/renderer/components/settings/model-edit-dialog.tsx new file mode 100644 index 00000000..e4bddbc0 --- /dev/null +++ b/apps/desktop/src/renderer/components/settings/model-edit-dialog.tsx @@ -0,0 +1,1002 @@ +/** + * Model editor dialog — basic fields + collapsible advanced settings. + * + * Wire API is a model-level property (overrides provider default). + * Model ID is editable; renaming rewrites the nested models map key. + * Persists via provider/upsert. + */ + +import type { + CatalogModelInfo, + CatalogProviderInfo, + CatalogWireApi, + InputModality, + ProviderModelVariant, + ReasoningCapability, + ReasoningEffort, + ReasoningLevelChoice, +} from "@devo-ai/sdk/v2/client" +import { Button } from "@devo/ui/components/button" +import { + Dialog, + DialogContent, + DialogDescription, + DialogHeader, + DialogTitle, +} from "@devo/ui/components/dialog" +import { Input } from "@devo/ui/components/input" +import { Label } from "@devo/ui/components/label" +import { + Select, + SelectContent, + SelectGroup, + SelectItem, + SelectTrigger, + SelectValue, +} from "@devo/ui/components/select" +import { Spinner } from "@devo/ui/components/spinner" +import { Switch } from "@devo/ui/components/switch" +import { Textarea } from "@devo/ui/components/textarea" +import { cn } from "@devo/ui/lib/utils" +import { ChevronDownIcon, PlusIcon, Trash2Icon } from "lucide-react" +import { useCallback, useMemo, useState } from "react" +import { getBaseClient } from "../../services/connection-manager" +import { effectiveContextWindowTokens, contextWindowPercentFromAbsolute } from "../../lib/providers" + +const WIRE_API_OPTIONS: Array<{ value: CatalogWireApi; label: string }> = [ + { value: "openai_chat_completions", label: "OpenAI Chat Completions" }, + { value: "openai_responses", label: "OpenAI Responses" }, + { value: "anthropic_messages", label: "Anthropic Messages" }, +] + +const MODALITY_OPTIONS: InputModality[] = ["text", "image"] +const MODEL_ID_PATTERN = /^[a-z0-9][a-z0-9._:/-]*$/i +const EFFORT_LEVELS: ReasoningEffort[] = ["none", "minimal", "low", "medium", "high", "xhigh", "max"] +const LEVEL_CHOICES: ReasoningLevelChoice[] = ["off", ...EFFORT_LEVELS] + +interface HeaderEntry { + key: string + value: string +} + +type ReasoningMode = "unsupported" | "toggle" | "levels" + +interface EffortEncodingDraft { + requestModel: string + requestBody: string + headers: HeaderEntry[] +} + +function isReasoningLevelChoice(value: string): value is ReasoningLevelChoice { + return value === "off" || EFFORT_LEVELS.includes(value as ReasoningEffort) +} + +function capabilityMode(capability: ReasoningCapability | null | undefined): ReasoningMode { + if (capability == null || capability === "unsupported") return "unsupported" + if (capability === "toggle") return "toggle" + if (typeof capability === "object" && "levels" in capability) return "levels" + // Legacy wire form before server/protocol migration. + if (typeof capability === "object" && "toggle_with_levels" in (capability as object)) { + return "levels" + } + return "unsupported" +} + +function capabilityLevels( + capability: ReasoningCapability | null | undefined, +): ReasoningLevelChoice[] { + if (capability == null || typeof capability !== "object") return [] + if ("levels" in capability) { + return capability.levels.filter(isReasoningLevelChoice) + } + const legacy = (capability as { toggle_with_levels?: ReasoningEffort[] }).toggle_with_levels + if (Array.isArray(legacy)) { + const levels: ReasoningLevelChoice[] = ["off"] + for (const effort of legacy) { + if (!levels.includes(effort)) levels.push(effort) + } + return levels + } + return [] +} + +function buildReasoningCapability( + mode: ReasoningMode, + levels: ReasoningLevelChoice[], +): ReasoningCapability | undefined { + switch (mode) { + case "unsupported": + return "unsupported" + case "toggle": + return "toggle" + case "levels": + return { levels: levels.length > 0 ? levels : ["medium"] } + } +} + +function effortOptionValues(mode: ReasoningMode, levels: ReasoningLevelChoice[]): string[] { + switch (mode) { + case "unsupported": + return [] + case "toggle": + return ["off", "on"] + case "levels": + return levels + } +} + +function emptyEffortEncoding(): EffortEncodingDraft { + return { requestModel: "", requestBody: "", headers: [] } +} + +function encodingFromVariant(variant: ProviderModelVariant | undefined): EffortEncodingDraft { + if (!variant) return emptyEffortEncoding() + return { + requestModel: variant.requestModel ?? "", + requestBody: formatJsonValue(variant.request), + headers: Object.entries(variant.headers ?? {}).map(([key, value]) => ({ key, value })), + } +} + +function encodingIsEmpty(encoding: EffortEncodingDraft): boolean { + return ( + !encoding.requestModel.trim() && + !encoding.requestBody.trim() && + !encoding.headers.some((header) => header.key.trim()) + ) +} + +function parseEffortEncoding(encoding: EffortEncodingDraft): ProviderModelVariant | undefined { + if (encodingIsEmpty(encoding)) return undefined + const nextHeaders: Record = {} + for (const header of encoding.headers) { + const key = header.key.trim() + if (!key) continue + nextHeaders[key] = header.value + } + return { + requestModel: encoding.requestModel.trim() || undefined, + request: parseJsonObject(encoding.requestBody), + headers: Object.keys(nextHeaders).length > 0 ? nextHeaders : undefined, + } +} + +function wireLabel(api: CatalogWireApi | undefined, fallback?: CatalogWireApi): string { + const value = api ?? fallback + if (!value) return "—" + return WIRE_API_OPTIONS.find((o) => o.value === value)?.label ?? value.replace(/_/g, " ") +} + +function parseOptionalNumber(raw: string): number | undefined { + const trimmed = raw.trim() + if (!trimmed) return undefined + const n = Number(trimmed) + return Number.isFinite(n) ? n : undefined +} + +function formatJsonValue(value: unknown): string { + if (value == null) return "" + try { + return JSON.stringify(value, null, 2) + } catch { + return "" + } +} + +/** Empty string clears the field; otherwise require a JSON object. */ +function parseJsonObject(raw: string): unknown | undefined { + const trimmed = raw.trim() + if (!trimmed) return undefined + const parsed: unknown = JSON.parse(trimmed) + if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) { + throw new Error("Request body must be a JSON object") + } + return parsed +} + +export function effectiveWireApi( + model: CatalogModelInfo, + provider: CatalogProviderInfo, +): CatalogWireApi { + return model.wireApi ?? provider.wireApis[0] ?? "openai_chat_completions" +} + +interface ModelEditDialogProps { + provider: CatalogProviderInfo + /** Omit or leave empty when creating a new model. */ + modelId?: string + model?: CatalogModelInfo + mode?: "edit" | "create" + open: boolean + onOpenChange: (open: boolean) => void + onSaved: () => void +} + +export function ModelEditDialog({ + provider, + modelId: originalModelId = "", + model = {}, + mode = "edit", + open, + onOpenChange, + onSaved, +}: ModelEditDialogProps) { + const isCreate = mode === "create" + const providerDefaultWire = provider.wireApis[0] ?? "openai_chat_completions" + const existingModels = provider.models ?? {} + + const [modelId, setModelId] = useState(originalModelId) + const [displayName, setDisplayName] = useState(model.name ?? "") + const [wireApi, setWireApi] = useState( + isCreate ? providerDefaultWire : effectiveWireApi(model, provider), + ) + const [enabled, setEnabled] = useState(model.enabled !== false) + const [advancedOpen, setAdvancedOpen] = useState(false) + + const [contextWindow, setContextWindow] = useState(() => { + const effective = effectiveContextWindowTokens(model) + return effective != null ? String(effective) : "" + }) + const [maxTokens, setMaxTokens] = useState(model.maxTokens != null ? String(model.maxTokens) : "") + const [temperature, setTemperature] = useState( + model.temperature != null ? String(model.temperature) : "", + ) + const [topP, setTopP] = useState(model.topP != null ? String(model.topP) : "") + const [topK, setTopK] = useState(model.topK != null ? String(model.topK) : "") + const [modalities, setModalities] = useState( + model.inputModalities?.length ? [...model.inputModalities] : ["text"], + ) + const [headers, setHeaders] = useState(() => + Object.entries(model.headers ?? {}).map(([key, value]) => ({ key, value })), + ) + const [requestBody, setRequestBody] = useState(() => formatJsonValue(model.request)) + const [optionsBody, setOptionsBody] = useState(() => formatJsonValue(model.options)) + const [reasoningMode, setReasoningMode] = useState(() => + capabilityMode(model.reasoningCapability), + ) + const [reasoningLevels, setReasoningLevels] = useState(() => + capabilityLevels(model.reasoningCapability), + ) + const [defaultReasoningSelection, setDefaultReasoningSelection] = useState( + () => model.defaultReasoningSelection ?? "", + ) + const [effortEncodings, setEffortEncodings] = useState>( + () => { + const next: Record = {} + for (const [key, variant] of Object.entries(model.variants ?? {})) { + next[key] = encodingFromVariant(variant) + } + return next + }, + ) + + const [saving, setSaving] = useState(false) + const [error, setError] = useState(null) + + const addHeader = useCallback(() => { + setHeaders((prev) => [...prev, { key: "", value: "" }]) + }, []) + + const removeHeader = useCallback((index: number) => { + setHeaders((prev) => prev.filter((_, i) => i !== index)) + }, []) + + const updateHeader = useCallback((index: number, field: "key" | "value", value: string) => { + setHeaders((prev) => prev.map((h, i) => (i === index ? { ...h, [field]: value } : h))) + }, []) + + const toggleModality = useCallback((mod: InputModality) => { + setModalities((prev) => { + if (prev.includes(mod)) { + const next = prev.filter((m) => m !== mod) + return next.length > 0 ? next : prev + } + return [...prev, mod] + }) + }, []) + + const toggleReasoningLevel = useCallback((level: ReasoningLevelChoice) => { + setReasoningLevels((prev) => { + if (prev.includes(level)) return prev.filter((item) => item !== level) + return [...prev, level] + }) + }, []) + + const effortOptions = useMemo( + () => effortOptionValues(reasoningMode, reasoningLevels), + [reasoningMode, reasoningLevels], + ) + + const updateEffortEncoding = useCallback( + (selection: string, patch: Partial) => { + setEffortEncodings((prev) => ({ + ...prev, + [selection]: { + ...(prev[selection] ?? emptyEffortEncoding()), + ...patch, + }, + })) + }, + [], + ) + + const handleSave = useCallback(async () => { + const nextId = modelId.trim() + if (!nextId) { + setError("Model ID is required") + return + } + if (!MODEL_ID_PATTERN.test(nextId)) { + setError("Model ID may use letters, numbers, and . _ : / -") + return + } + if (nextId !== originalModelId && existingModels[nextId]) { + setError(`Model ID "${nextId}" already exists on this provider`) + return + } + if (isCreate && existingModels[nextId]) { + setError(`Model ID "${nextId}" already exists on this provider`) + return + } + + let parsedRequest: unknown | undefined + let parsedOptions: unknown | undefined + try { + parsedRequest = parseJsonObject(requestBody) + parsedOptions = parseJsonObject(optionsBody) + } catch (err) { + setError(err instanceof Error ? err.message : "Invalid JSON") + return + } + + const nextHeaders: Record = {} + for (const header of headers) { + const key = header.key.trim() + if (!key) continue + nextHeaders[key] = header.value + } + + const nextVariants: Record = { + ...(model.variants ?? {}), + } + for (const selection of effortOptions) { + try { + const parsed = parseEffortEncoding(effortEncodings[selection] ?? emptyEffortEncoding()) + if (parsed) nextVariants[selection] = parsed + else delete nextVariants[selection] + } catch (err) { + setError( + err instanceof Error + ? `Effort encoding "${selection}": ${err.message}` + : `Invalid effort encoding for ${selection}`, + ) + return + } + } + + const capability = buildReasoningCapability(reasoningMode, reasoningLevels) + const normalizedDefault = defaultReasoningSelection.trim().toLowerCase() + if ( + normalizedDefault && + effortOptions.length > 0 && + !effortOptions.includes(normalizedDefault) && + normalizedDefault !== "enabled" && + normalizedDefault !== "disabled" + ) { + setError(`Default reasoning must be one of: ${effortOptions.join(", ")}`) + return + } + + setSaving(true) + setError(null) + try { + const client = getBaseClient() + if (!client) throw new Error("Not connected to server") + + const userContextTokens = parseOptionalNumber(contextWindow) + const hardWindow = model.contextWindow + let nextContextWindow: number | undefined + let nextPercent: number | undefined + if (userContextTokens == null) { + // Clear percent overlay → default 95% of hard capacity. + nextContextWindow = hardWindow ?? undefined + nextPercent = undefined + } else if (hardWindow != null && hardWindow > 0) { + nextContextWindow = hardWindow + nextPercent = contextWindowPercentFromAbsolute(hardWindow, userContextTokens) + } else { + // Custom model with no hard window yet: treat entry as hard @ 100%. + nextContextWindow = userContextTokens + nextPercent = 100 + } + const nextModel: CatalogModelInfo = { + ...model, + name: displayName.trim() || undefined, + wireApi, + enabled, + contextWindow: nextContextWindow, + effectiveContextWindowPercent: nextPercent, + maxTokens: parseOptionalNumber(maxTokens), + temperature: parseOptionalNumber(temperature), + topP: parseOptionalNumber(topP), + topK: parseOptionalNumber(topK), + inputModalities: modalities.length > 0 ? modalities : undefined, + headers: Object.keys(nextHeaders).length > 0 ? nextHeaders : undefined, + request: parsedRequest, + options: parsedOptions, + reasoningCapability: capability, + defaultReasoningSelection: normalizedDefault + ? normalizedDefault === "enabled" + ? "on" + : normalizedDefault === "disabled" + ? "off" + : normalizedDefault + : undefined, + defaultReasoningEffort: + normalizedDefault && EFFORT_LEVELS.includes(normalizedDefault as ReasoningEffort) + ? (normalizedDefault as ReasoningEffort) + : model.defaultReasoningEffort, + variants: Object.keys(nextVariants).length > 0 ? nextVariants : undefined, + } + + const wireApis = provider.wireApis.includes(wireApi) + ? provider.wireApis + : [...provider.wireApis, wireApi] + + const updatedModels: Record = { + [nextId]: nextModel, + } + + await client.provider.upsert({ + provider: { + ...provider, + wireApis: wireApis.length > 0 ? wireApis : [wireApi], + // Server merges models by insert; send only this model so we + // do not copy the full template+connection catalog into the overlay. + models: updatedModels, + }, + }) + + // Upsert never deletes missing keys — rename must remove the old id. + if (!isCreate && nextId !== originalModelId) { + await client.provider.modelRemove({ + providerId: provider.id, + modelId: originalModelId, + }) + } + + onOpenChange(false) + onSaved() + } catch (err) { + setError(err instanceof Error ? err.message : "Failed to save model") + } finally { + setSaving(false) + } + }, [ + modelId, + originalModelId, + existingModels, + isCreate, + model, + displayName, + wireApi, + enabled, + contextWindow, + maxTokens, + temperature, + topP, + topK, + modalities, + headers, + requestBody, + optionsBody, + reasoningMode, + reasoningLevels, + defaultReasoningSelection, + effortEncodings, + effortOptions, + provider, + onOpenChange, + onSaved, + ]) + + const advancedSummary = useMemo(() => { + const parts: string[] = [] + if (reasoningMode !== "unsupported") parts.push(`reasoning:${reasoningMode}`) + if (contextWindow.trim()) parts.push(`${Math.round(Number(contextWindow) / 1000)}k ctx`) + if (modalities.length) parts.push(modalities.join("+")) + const headerCount = headers.filter((h) => h.key.trim()).length + if (headerCount > 0) parts.push(`${headerCount} header${headerCount === 1 ? "" : "s"}`) + if (requestBody.trim()) parts.push("request body") + if (optionsBody.trim()) parts.push("options") + const encodingCount = effortOptions.filter( + (selection) => !encodingIsEmpty(effortEncodings[selection] ?? emptyEffortEncoding()), + ).length + if (encodingCount > 0) parts.push(`${encodingCount} effort encoding${encodingCount === 1 ? "" : "s"}`) + return parts.join(" · ") + }, [ + reasoningMode, + contextWindow, + modalities, + headers, + requestBody, + optionsBody, + effortOptions, + effortEncodings, + ]) + + return ( + + +
+ + + {isCreate ? "Add model" : "Edit model"} + + + {provider.name} + {!isCreate && + (originalModelId !== modelId.trim() && modelId.trim() ? ( + <> + {" "} + · renaming{" "} + {originalModelId} + {" → "} + {modelId.trim()} + + ) : ( + <> + {" "} + · {originalModelId} + + ))} + + +
+ +
+
+
+ + setModelId(e.target.value)} + placeholder="provider-facing-model-id" + disabled={saving} + className="h-9 font-mono text-[13px]" + /> +

+ {isCreate + ? "Sent to the provider as the request model." + : "Sent to the provider as the request model. Changing it renames this entry."} +

+
+ +
+ + setDisplayName(e.target.value)} + placeholder={modelId.trim() || originalModelId} + disabled={saving} + className="h-9" + /> +
+ +
+ + +

+ Per-model override. Provider default is {wireLabel(undefined, providerDefaultWire)}. +

+
+ +
+
+

Enabled

+

Show this model in the picker

+
+ +
+ +
+ + +

+ Controls the effort chips in chat. Include off in levels to allow + disabling. Encoding for custom gateways is configured under Advanced. +

+
+ + {reasoningMode === "levels" && ( +
+ +
+ {LEVEL_CHOICES.map((level) => { + const active = reasoningLevels.includes(level) + return ( + + ) + })} +
+
+ )} + + {reasoningMode !== "unsupported" && ( +
+ + +
+ )} +
+ +
+ + + {advancedOpen && ( +
+ + + + + +
+ +
+ {MODALITY_OPTIONS.map((mod) => { + const active = modalities.includes(mod) + return ( + + ) + })} +
+
+ +
+ + {headers.length === 0 ? ( +

+ No custom headers. Merged into HTTP requests for this model. +

+ ) : ( + headers.map((header, index) => ( +
+ updateHeader(index, "key", e.target.value)} + disabled={saving} + className="h-9 flex-1 font-mono text-[13px]" + /> + updateHeader(index, "value", e.target.value)} + disabled={saving} + className="h-9 flex-1 font-mono text-[13px]" + /> + +
+ )) + )} + +
+ +
+ +