From 08c058db2bce4248b1a9536ada957615bbaae132 Mon Sep 17 00:00:00 2001 From: hrayleung Date: Tue, 27 Jan 2026 14:23:28 +0800 Subject: [PATCH 01/27] feat: Add dynamic model fetching from provider APIs Implemented automatic model discovery for OpenAI, Anthropic, Google Gemini, and xAI Grok using their native APIs instead of hardcoded model lists. Changes: - Added downloadOpenAIModels() using OpenAI /v1/models API - Added downloadAnthropicModels() using Anthropic /v1/models API - Added downloadGoogleModels() using Google /v1beta/models API - Added downloadGrokModels() using xAI /v1/models API - Updated fetchModelConfigs() to auto-fetch models when API keys are present - Added refresh hooks for each provider in ModelsAPI - Updated CLAUDE.md with project documentation Benefits: - New models automatically available without code updates - No more hardcoded model lists to maintain - Uses each provider's native API format (not OpenAI-compatible) - Models only fetched when user has configured API keys Co-Authored-By: Claude Sonnet 4.5 --- CLAUDE.md | 382 +++++++++++++++++++++---------- src/core/chorus/Models.ts | 279 ++++++++++++++++++++++ src/core/chorus/api/ModelsAPI.ts | 129 ++++++++++- 3 files changed, 663 insertions(+), 127 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 7246fcf9..8e398bf4 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1,142 +1,282 @@ -# Claude's Onboarding Doc +# CLAUDE.md -## What is Chorus? +This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. -Chorus is a native Mac AI chat app that lets you chat with all the AIs. +## What is Chorus? -It lets you send one prompt and see responses from Claude, o3-pro, Gemini, etc. all at once. +Chorus is a native Mac AI chat app that lets you chat with multiple AI models simultaneously. -It's built with Tauri, React, TypeScript, TanStack Query, and a local sqlite database. +It's built with Tauri (Rust), React, TypeScript, TanStack Query, and SQLite. The app sends one prompt and displays responses from Claude, o3-pro, Gemini, and other models side by side. Key features: +- MCP (Model Context Protocol) support +- Ambient chats (quick chats accessible from anywhere) +- Projects (organized folders of chats) +- Bring your own API keys +- Multi-model comparison + +Most functionality lives in this repo. A separate Elixir backend at app.chorus.sh handles accounts, billing, and request proxying. + +## Development Commands + +**Initial setup:** +```bash +pnpm run setup # Install dependencies and initialize dev environment +``` + +**Development:** +```bash +pnpm run dev # Start development instance (uses repo directory name) +pnpm run workspace [name] # Run specific isolated instance with separate data directory +``` + +**Building:** +```bash +pnpm run build # Compile TypeScript and build with Vite +``` + +**Testing:** +```bash +pnpm run test # Run tests with Vitest +``` + +**Linting and formatting:** +```bash +pnpm run lint # Run ESLint +pnpm run lint:fix # Run ESLint with auto-fix +pnpm run format # Format all files with Prettier +pnpm run format:check # Check formatting without making changes +pnpm run validate # Run both linting and formatting checks +pnpm run validate:fix # Run both with auto-fix +``` + +**Database management:** +```bash +pnpm run generate-schema # Generate SQL_SCHEMA.md from migrations.rs +pnpm run delete-db # Delete local development database +``` + +**Release:** +```bash +pnpm run release # Interactive release script +``` + +Note: Pre-commit hooks automatically run linting and formatting. See `.lintstagedrc.json`. + +## Development Instances + +The `dev-instance.sh` script lets you run multiple isolated Chorus instances simultaneously: +- Each instance has its own data directory: `~/Library/Application Support/sh.chorus.app.dev./` +- Each gets a unique port (1420-1520) based on instance name hash +- Instance name appears in the DEV MODE indicator in the sidebar +- Data persists between runs + +This is useful for working on multiple branches or testing without affecting your main development environment. + +## Architecture + +### High-Level Structure + +``` +src/ +├── ui/ # React frontend +│ ├── components/ # UI components +│ ├── hooks/ # Custom React hooks +│ └── providers/ # Context providers +├── core/ +│ └── chorus/ # Core business logic +│ ├── api/ # TanStack Query queries and mutations +│ ├── ModelProviders/ # Provider implementations (Anthropic, OpenAI, etc.) +│ ├── toolsets/ # MCP toolset implementations +│ └── DB.ts # Database connection +src-tauri/ +└── src/ # Rust backend + ├── command.rs # Tauri commands exposed to frontend + ├── migrations.rs # Database migrations + └── window.rs # Window management +``` + +### Data Flow + +1. **User Input** → ChatInput.tsx captures user message +2. **TanStack Mutation** → API layer (e.g., MessageAPI.ts) handles request +3. **Provider Selection** → Models.ts routes to appropriate provider (ProviderAnthropic.ts, ProviderOpenAI.ts, etc.) +4. **Streaming Response** → Provider streams back to UI via TanStack Query +5. **Database Persistence** → Rust migrations.rs defines schema, TypeScript DB queries persist data + +### Key Architectural Patterns + +**Model Provider System:** +- All providers implement `IProvider` interface +- Each provider handles its own API communication format +- Providers support streaming, tool calling, and attachments +- See `src/core/chorus/ModelProviders/` + +**Database Layer:** +- SQLite database with migrations in `src-tauri/src/migrations.rs` +- Database schema is auto-generated to `SQL_SCHEMA.md` via `pnpm run generate-schema` +- TypeScript database operations reference this schema +- CRITICAL: Never modify existing migrations, always add new ones + +**API Layer (TanStack Query):** +- Queries and mutations split by entity type: ChatAPI, MessageAPI, ProjectAPI, etc. +- Located in `src/core/chorus/api/` +- Each file handles one entity's CRUD operations and related business logic + +**MCP (Model Context Protocol):** +- Toolsets defined in `src/core/chorus/Toolsets.ts` +- Individual toolset implementations in `src/core/chorus/toolsets/` +- ToolsetsManager.ts handles toolset lifecycle + +**State Management:** +- TanStack Query for server state +- Zustand for UI state (DialogStore, etc.) +- React Context for app-wide state (AppProvider, SidebarProvider) + +## Important Files and Directories + +**Entry Points:** +- `src/ui/App.tsx` - React root component with routing +- `src-tauri/src/main.rs` - Rust application entry point +- `src-tauri/src/lib.rs` - Tauri plugin configuration + +**Core Logic:** +- `src/core/chorus/Models.ts` - Model configuration and defaults +- `src/core/chorus/Toolsets.ts` - Tool/connection definitions +- `src/core/chorus/ChatState.ts` - Chat state management + +**UI Components:** +- `src/ui/components/MultiChat.tsx` - Main chat interface (handles both regular and quick chats) +- `src/ui/components/ChatInput.tsx` - Message input box +- `src/ui/components/AppSidebar.tsx` - Left sidebar with projects and chats +- `src/ui/components/ManageModelsBox.tsx` - Model selection interface +- `src/ui/components/Settings.tsx` - Settings dialog + +**Database:** +- `src-tauri/src/migrations.rs` - Database schema migrations +- `SQL_SCHEMA.md` - Auto-generated schema documentation (DO NOT EDIT MANUALLY) + +## Data Model Changes + +When modifying the data model: + +1. Add a new migration in `src-tauri/src/migrations.rs` (NEVER modify existing migrations) +2. Run `pnpm run generate-schema` to update SQL_SCHEMA.md +3. Update TypeScript types throughout the codebase +4. Update TanStack Query queries/mutations in `src/core/chorus/api/` +5. Stage SQL_SCHEMA.md with your migration changes + +## Git Workflow + +**Branch management:** +- NEVER commit directly to main +- NEVER push to origin/main +- Create feature branches: `claude/feature-name` +- Use rebase or cherry-pick to reconcile branches, not merge + +**Committing:** +- Run git commands separately, not chained (e.g., `git add -A` then `git commit`, not `git add -A && git commit`) +- Commit frequently to allow easy reverts +- Pre-commit hooks will automatically lint and format + +**Pull Requests:** +- Tag all issues and PRs with "by-claude" +- PR title should NOT include issue number +- PR description should START with issue number +- Include comprehensive test plan for user to execute +- Test plan must cover both new functionality and potentially impacted existing features +- Use `gh` CLI for GitHub interactions + +## Coding Style + +**TypeScript:** +- Strict mode enabled, ES2020 target +- Use `as` type assertions only in exceptional cases with explanatory comments +- Prefer type hints over assertions + +**Imports:** +- Use path aliases: `@ui/*`, `@core/*`, `@/*` instead of relative imports +- Configured in `tsconfig.json` and `vite.config.ts` + +**Naming:** +- React components: PascalCase +- Interfaces: Prefixed with "I" (e.g., `IProvider`) +- Hooks: camelCase with "use" prefix +- Files: Match component name + +**Formatting:** +- 4-space indentation (see `.prettierrc`) +- Prettier handles formatting automatically via pre-commit hooks + +**Nulls and Undefined:** +- Prefer `undefined` over `null` +- Convert database nulls to undefined: `parentChatId: row.parent_chat_id ?? undefined` + +**Dates:** +- Format dates with `displayDate` from `src/ui/lib/utils.ts` +- Convert SQLite dates to UTC with `convertDate` before formatting + +**Constraints:** +- Do not use foreign keys or database constraints (difficult to remove later) + +**Promises:** +- ESLint enforces that all promises must be handled (@typescript-eslint/no-floating-promises) + +**Restricted Features - Require Explicit Permission:** +Before using any of these, you MUST ask the user for permission: +- `setTimeout` +- `useImperativeHandle` +- `useRef` +- Type assertions with `as` -- MCP support -- Ambient chats (start a chat from anywhere) -- Projects -- Bring your own API keys - -Most of the functionality lives in this repo. There's also a backend that handles accounts, billing, and proxying the models' requests; that lives at app.chorus.sh and is written in Elixir. - -## Your role - -Your role is to write code. You do NOT have access to the running app, so you cannot test the code. You MUST rely on me, the user, to test the code. - -If I report a bug in your code, after you fix it, you should pause and ask me to verify that the bug is fixed. - -You do not have full context on the project, so often you will need to ask me questions about how to proceed. - -Don't be shy to ask questions -- I'm here to help you! - -If I send you a URL, you MUST immediately fetch its contents and read it carefully, before you do anything else. - -## Workflow - -We use GitHub issues to track work we need to do, and PRs to review code. Whenever you create an issue or a PR, tag it with "by-claude". Use the `gh` bash command to interact with GitHub. - -To start working on a feature, you should: - -1. Setup - -- Identify the relevant GitHub issue (or create one if needed) -- Checkout main and pull the latest changes -- Create a new branch like `claude/feature-name`. NEVER commit to main. NEVER push to origin/main. - -2. Development - -- Commit often as you write code, so that we can revert if needed. -- When you have a draft of what you're working on, ask me to test it in the app to confirm that it works as you expect. Do this early and often. - -3. Review - -- When the work is done, verify that the diff looks good with `git diff main` -- While you should attempt to write code that adheres to our coding style, don't worry about manually linting or formatting your changes. There are Husky pre-commit Git hooks that will do this for you. -- Push the branch to GitHub -- Open a PR. - - The PR title should not include the issue number - - The PR description should start with the issue number and a brief description of the changes. - - Next, you should write a test plan. I (not you) will execute the test plan before merging the PR. If I can't check off any of the items, I will let you know. Make sure the test plan covers both new functionality and any EXISTING functionality that might be impacted by your changes - -4. Fixing issues - -- To reconcile different branches, always rebase or cherry-pick. Do not merge. - -Sometimes, after you've been working on one feature, I will ask you to start work on an unrelated feature. If I do, you should probably repeat this process from the beginning (checkout main, pull changes, create a new branch). When in doubt, just ask. - -We use pnpm to manage dependencies. - -Don't combine git commands -- e.g., instead of `git add -A && git commit`, run `git add -A` and `git commit` separately. This will save me time because I won't have to grant you permission to run the combined command. - -## Project Structure - -- **UI:** React components in `src/ui/components/` -- **Core:** Business logic in `src/core/chorus/` -- **Tauri:** Rust backend in `src-tauri/src/` - -Important files and directories to be aware of: - -- `src/core/chorus/db/` - Queries against the sqlite database, which are split up by entity type (e.g. message, chat, project) -- `src/core/chorus/api/` - TanStack Query queries and mutations, which are also split up by entity type -- `src/ui/components/MultiChat.tsx` - Main interface -- `src/ui/components/ChatInput.tsx` - The input box where the user types chat messages -- `src/ui/components/AppSidebar.tsx` - The sidebar on the left -- `src/ui/App.tsx` - The root component - -You can see an up-to-date schema of all database tables in SQL_SCHEMA.md. Use this file as a reference to understand the current -database schema. - -Other features: - -- Model picker, which lets the user select which models are available in the chat -- implemented in`ManageModelsBox.tsx` -- Quick chats (aka Ambient Chats), a lightweight chat window -- implemented, alongside regular chats, in `MultiChat.tsx` -- Projects, which are folders of related chats -- start with `AppSidebar.tsx` -- Tools and "connections" (aka toolsets) -- start with `Toolsets.ts` -- react-router-dom for navigation -- see `App.tsx` - -## Screenshots - -I've put some screenshots of the app in the `screenshots` directory. If you're working on the UI at all, take a look at them. Keep in mind, though, that they may not be up to date with the latest code changes. +## Troubleshooting -## Data model changes +When investigating bugs: -Changes to the data model will typically require most of the following steps: +1. Form hypotheses about root causes by reading relevant code +2. Evaluate each hypothesis against reported observations +3. Design tests to eliminate hypotheses +4. Propose a troubleshooting plan (tests, logging, code changes) +5. Iterate through the plan, re-evaluating hypotheses with new evidence -- Making a new migration in `src-tauri/src/migrations.rs` (if changes to the sqlite database scheme are needed) -- Modifying fetch and read functions in `src/core/chorus/DB.ts` -- Modifying data types (stored in a variety of places) -- Adding or modifying TanStack Query queries in `src/core/chorus/API.ts` +**Model Provider Debugging:** +To debug requests to model providers (prompt formatting, attachments, tool calls): +```typescript +// Add to ProviderAnthropic.ts or relevant provider +console.log(`createParams: ${JSON.stringify(createParams, null, 2)}`); +``` -## Coding style +## Testing -- **TypeScript:** Strict typing enabled, ES2020 target. Use `as` only in exceptional - circumstances, and then only with an explanatory comment. Prefer type hints. -- **Paths:** `@ui/*`, `@core/*`, `@/*` aliases. Use these instead of relative imports. -- **Components:** PascalCase for React components -- **Interfaces:** Prefixed with "I" (e.g., `IProvider`) -- **Hooks:** camelCase with "use" prefix -- **Formatting:** 4-space indentation, Prettier formatting -- **Promise handling:** All promises must be handled (ESLint enforced) -- **Nulls:** Prefer undefined to null. Convert `null` values from the database into undefined, e.g. `parentChatId: row.parent_chat_id ?? undefined` -- **Dates:** If you ever need to render a date, format it using `displayDate` in `src/ui/lib/utils.ts`. If the date was read - from our SQLite DB, you will need to convert it to a fully qualified UTC date using `convertDate` first. -- Do not use foreign keys or other constraints, they're too hard to remove and tend to put us in tricky situations down the line +You (Claude) do NOT have access to the running app. You MUST rely on the user to test your code. -IMPORTANT: If you want to use any of these features, you must alert me and explicitly ask for my permission first: `setTimeout`, `useImperativeHandle`, `useRef`, or type assertions with `as`. +After fixing a bug, pause and ask the user to verify the fix before continuing. -## Troubleshooting +When you complete a draft implementation, ask the user to test it early and often. -Whenever I report that code you wrote doesn't work, or report a bug, you should: +## Role and Workflow -1. Read any relevant code or documentation, looking for hypotheses about the root cause -2. For each hypothesis, check whether it's consistent with the observations I've already reported -3. For any remaining hypotheses, think about a test I could run that would tell me if that hypothesis is incorrect -4. Propose a troubleshooting plan. The plan could involve: me running a test, you writing code, you adding logging statements, me reporting the output of the log statements back to you, or any other steps you think would be helpful. +Your role is to write code. When working on features: -Then we'll go through the plan together. At each step, keep in mind your list of hypotheses, and remember to re-evaluate each hypothesis against the evidence we've collected. +1. **Setup:** + - Identify or create GitHub issue + - Checkout main and pull latest + - Create new branch `claude/feature-name` -When we run into issues with the requests we're sending to model providers (e.g., the way we format system prompts, attachments, tool calls, or other parts of the conversation history) one helpful troubleshooting step is to add the line `console.log(`createParams: ${JSON.stringify(createParams, null, 2)}`);` to ProviderAnthropic.ts. +2. **Development:** + - Ask questions when unclear about implementation approach + - Commit often for easy reversion + - Request user testing early with draft implementations -## Updating this onboarding doc +3. **Review:** + - Verify diff with `git diff main` + - Don't worry about manual linting/formatting (hooks handle it) + - Push branch to GitHub + - Open PR with issue number, description, and user-executable test plan -Whenever you discover something that you wish you'd known earlier -- and seems likely to be helpful to future developers as well -- you can add it to the scratchpad section below. Feel free to edit the scratchpad section, but don't change the rest of this doc. +4. **Context:** + - If user sends a URL, fetch and read it immediately before doing anything else + - Screenshots are in `screenshots/` directory (may not reflect latest changes) + - SQL schema is in `SQL_SCHEMA.md` (auto-generated reference) -### Scratchpad +When asked to start unrelated work, repeat the workflow from the beginning (checkout main, new branch). diff --git a/src/core/chorus/Models.ts b/src/core/chorus/Models.ts index 1563d354..bf8c1924 100644 --- a/src/core/chorus/Models.ts +++ b/src/core/chorus/Models.ts @@ -354,10 +354,15 @@ export async function saveModelAndDefaultConfig( */ export async function DEPRECATED_USE_HOOK_INSTEAD_downloadModels( db: Database, + apiKeys: ApiKeys, ): Promise { await downloadOpenRouterModels(db); await downloadOllamaModels(db); await downloadLMStudioModels(db); + await downloadOpenAIModels(db, apiKeys); + await downloadAnthropicModels(db, apiKeys); + await downloadGoogleModels(db, apiKeys); + await downloadGrokModels(db, apiKeys); return 0; } @@ -508,6 +513,280 @@ export async function downloadLMStudioModels(db: Database): Promise { } } +/** + * Downloads models from OpenAI to refresh the database. + * Uses OpenAI's native /v1/models API + */ +export async function downloadOpenAIModels( + db: Database, + apiKeys: ApiKeys, +): Promise { + try { + await db.execute( + "UPDATE models SET is_enabled = 0 WHERE id LIKE 'openai::%'", + ); + + if (!apiKeys.openai) { + console.log("No OpenAI API key configured, skipping model download"); + return; + } + + const response = await fetch("https://api.openai.com/v1/models", { + headers: { + Authorization: `Bearer ${apiKeys.openai}`, + }, + }); + + if (!response.ok) { + console.error("Failed to fetch OpenAI models"); + return; + } + + const { data: models } = (await response.json()) as { + data: { + id: string; + object: string; + created: number; + owned_by: string; + }[]; + }; + + // Filter for chat completion models only + const chatModels = models.filter( + (model) => + model.id.startsWith("gpt-") || + model.id.startsWith("o1") || + model.id.startsWith("o3") || + model.id.startsWith("o4"), + ); + + for (const model of chatModels) { + // Determine image support based on model name + const supportsImages = + model.id.includes("gpt-4") || + model.id.includes("gpt-5") || + (model.id.includes("o") && !model.id.includes("mini")); + + await saveModelAndDefaultConfig( + db, + { + id: `openai::${model.id}`, + displayName: model.id, + supportedAttachmentTypes: supportsImages + ? ["text", "image", "webpage"] + : ["text", "webpage"], + isEnabled: true, + isInternal: false, + }, + model.id, + ); + } + } catch (error) { + console.error("Error downloading OpenAI models:", error); + await db.execute( + "UPDATE models SET is_enabled = 0 WHERE id LIKE 'openai::%'", + ); + } +} + +/** + * Downloads models from Anthropic to refresh the database. + * Uses Anthropic's native /v1/models API + */ +export async function downloadAnthropicModels( + db: Database, + apiKeys: ApiKeys, +): Promise { + try { + await db.execute( + "UPDATE models SET is_enabled = 0 WHERE id LIKE 'anthropic::%'", + ); + + if (!apiKeys.anthropic) { + console.log( + "No Anthropic API key configured, skipping model download", + ); + return; + } + + const response = await fetch("https://api.anthropic.com/v1/models", { + headers: { + "anthropic-version": "2023-06-01", + "x-api-key": apiKeys.anthropic, + }, + }); + + if (!response.ok) { + console.error("Failed to fetch Anthropic models"); + return; + } + + const { data: models } = (await response.json()) as { + data: { + id: string; + created_at: string; + display_name: string; + type: string; + }[]; + has_more: boolean; + first_id: string; + last_id: string; + }; + + for (const model of models) { + // All Claude models support images and PDFs + await saveModelAndDefaultConfig( + db, + { + id: `anthropic::${model.id}`, + displayName: model.display_name, + supportedAttachmentTypes: ["text", "image", "pdf", "webpage"], + isEnabled: true, + isInternal: false, + }, + model.display_name, + ); + } + } catch (error) { + console.error("Error downloading Anthropic models:", error); + await db.execute( + "UPDATE models SET is_enabled = 0 WHERE id LIKE 'anthropic::%'", + ); + } +} + +/** + * Downloads models from Google Gemini to refresh the database. + * Uses Google's native /v1beta/models API + */ +export async function downloadGoogleModels( + db: Database, + apiKeys: ApiKeys, +): Promise { + try { + await db.execute( + "UPDATE models SET is_enabled = 0 WHERE id LIKE 'google::%'", + ); + + if (!apiKeys.google) { + console.log("No Google API key configured, skipping model download"); + return; + } + + const response = await fetch( + `https://generativelanguage.googleapis.com/v1beta/models?key=${apiKeys.google}`, + ); + + if (!response.ok) { + console.error("Failed to fetch Google models"); + return; + } + + const { models } = (await response.json()) as { + models: { + name: string; + baseModelId: string; + version: string; + displayName: string; + description: string; + inputTokenLimit: number; + outputTokenLimit: number; + supportedGenerationMethods: string[]; + thinking?: boolean; + }[]; + nextPageToken?: string; + }; + + // Filter for models that support generateContent + const chatModels = models.filter((model) => + model.supportedGenerationMethods?.includes("generateContent"), + ); + + for (const model of chatModels) { + // Extract model ID from name (format: "models/gemini-...") + const modelId = model.name.replace("models/", ""); + + // Gemini models support text, image, and webpage + await saveModelAndDefaultConfig( + db, + { + id: `google::${modelId}`, + displayName: model.displayName || modelId, + supportedAttachmentTypes: ["text", "image", "webpage"], + isEnabled: true, + isInternal: false, + }, + model.displayName || modelId, + ); + } + } catch (error) { + console.error("Error downloading Google models:", error); + await db.execute( + "UPDATE models SET is_enabled = 0 WHERE id LIKE 'google::%'", + ); + } +} + +/** + * Downloads models from xAI Grok to refresh the database. + * Uses xAI's native /v1/models API + */ +export async function downloadGrokModels( + db: Database, + apiKeys: ApiKeys, +): Promise { + try { + await db.execute( + "UPDATE models SET is_enabled = 0 WHERE id LIKE 'grok::%'", + ); + + if (!apiKeys.grok) { + console.log("No Grok API key configured, skipping model download"); + return; + } + + const response = await fetch("https://api.x.ai/v1/models", { + headers: { + Authorization: `Bearer ${apiKeys.grok}`, + }, + }); + + if (!response.ok) { + console.error("Failed to fetch xAI Grok models"); + return; + } + + const { data: models } = (await response.json()) as { + data: { + id: string; + object: string; + created: number; + owned_by: string; + }[]; + }; + + for (const model of models) { + // Grok models support text and image + await saveModelAndDefaultConfig( + db, + { + id: `grok::${model.id}`, + displayName: model.id, + supportedAttachmentTypes: ["text", "image", "webpage"], + isEnabled: true, + isInternal: false, + }, + model.id, + ); + } + } catch (error) { + console.error("Error downloading xAI Grok models:", error); + await db.execute( + "UPDATE models SET is_enabled = 0 WHERE id LIKE 'grok::%'", + ); + } +} + /// ------------------------------------------------------------------------------------------------ /// Helpers /// ------------------------------------------------------------------------------------------------ diff --git a/src/core/chorus/api/ModelsAPI.ts b/src/core/chorus/api/ModelsAPI.ts index 2a30a09c..f1a29819 100644 --- a/src/core/chorus/api/ModelsAPI.ts +++ b/src/core/chorus/api/ModelsAPI.ts @@ -71,9 +71,13 @@ type ModelConfigDBRow = { completion_price_per_token: number | null; }; -// Track whether we've attempted to refresh OpenRouter models within +// Track whether we've attempted to refresh models within // the current session, and store the promise if a download is in progress. let openRouterDownloadPromise: Promise | null = null; +let openAIDownloadPromise: Promise | null = null; +let anthropicDownloadPromise: Promise | null = null; +let googleDownloadPromise: Promise | null = null; +let grokDownloadPromise: Promise | null = null; function readModel(row: ModelDBRow): Models.Model { return { @@ -110,18 +114,59 @@ function readModelConfig(row: ModelConfigDBRow): ModelConfig { } export async function fetchModelConfigs() { - // Fetch OpenRouter models if we haven't already and the user has an OpenRouter API key. const apiKeys = await getApiKeys(); + + // Fetch models from various providers if we haven't already and the user has the API key. + // OpenRouter if (apiKeys.openrouter) { - // If a download is already in progress, wait for it to complete. - // Otherwise, start a new download and store the promise. if (openRouterDownloadPromise) { await openRouterDownloadPromise; } else { openRouterDownloadPromise = Models.downloadOpenRouterModels(db); await openRouterDownloadPromise; - // Keep the promise stored so subsequent calls know it completed - // (we don't clear it to prevent re-downloads within the session) + } + } + + // OpenAI + if (apiKeys.openai) { + if (openAIDownloadPromise) { + await openAIDownloadPromise; + } else { + openAIDownloadPromise = Models.downloadOpenAIModels(db, apiKeys); + await openAIDownloadPromise; + } + } + + // Anthropic + if (apiKeys.anthropic) { + if (anthropicDownloadPromise) { + await anthropicDownloadPromise; + } else { + anthropicDownloadPromise = Models.downloadAnthropicModels( + db, + apiKeys, + ); + await anthropicDownloadPromise; + } + } + + // Google + if (apiKeys.google) { + if (googleDownloadPromise) { + await googleDownloadPromise; + } else { + googleDownloadPromise = Models.downloadGoogleModels(db, apiKeys); + await googleDownloadPromise; + } + } + + // Grok + if (apiKeys.grok) { + if (grokDownloadPromise) { + await grokDownloadPromise; + } else { + grokDownloadPromise = Models.downloadGrokModels(db, apiKeys); + await grokDownloadPromise; } } @@ -355,10 +400,78 @@ export function useRefreshLMStudioModels() { }); } +export function useRefreshOpenAIModels() { + const queryClient = useQueryClient(); + return useMutation({ + mutationKey: ["refreshOpenAIModels"] as const, + mutationFn: async () => { + const apiKeys = await getApiKeys(); + await Models.downloadOpenAIModels(db, apiKeys); + }, + onSuccess: async () => { + await queryClient.invalidateQueries( + modelConfigQueries.listConfigs(), + ); + }, + }); +} + +export function useRefreshAnthropicModels() { + const queryClient = useQueryClient(); + return useMutation({ + mutationKey: ["refreshAnthropicModels"] as const, + mutationFn: async () => { + const apiKeys = await getApiKeys(); + await Models.downloadAnthropicModels(db, apiKeys); + }, + onSuccess: async () => { + await queryClient.invalidateQueries( + modelConfigQueries.listConfigs(), + ); + }, + }); +} + +export function useRefreshGoogleModels() { + const queryClient = useQueryClient(); + return useMutation({ + mutationKey: ["refreshGoogleModels"] as const, + mutationFn: async () => { + const apiKeys = await getApiKeys(); + await Models.downloadGoogleModels(db, apiKeys); + }, + onSuccess: async () => { + await queryClient.invalidateQueries( + modelConfigQueries.listConfigs(), + ); + }, + }); +} + +export function useRefreshGrokModels() { + const queryClient = useQueryClient(); + return useMutation({ + mutationKey: ["refreshGrokModels"] as const, + mutationFn: async () => { + const apiKeys = await getApiKeys(); + await Models.downloadGrokModels(db, apiKeys); + }, + onSuccess: async () => { + await queryClient.invalidateQueries( + modelConfigQueries.listConfigs(), + ); + }, + }); +} + export function useRefreshModels() { const refreshOpenRouterModels = useRefreshOpenRouterModels(); const refreshOllamaModels = useRefreshOllamaModels(); const refreshLMStudioModels = useRefreshLMStudioModels(); + const refreshOpenAIModels = useRefreshOpenAIModels(); + const refreshAnthropicModels = useRefreshAnthropicModels(); + const refreshGoogleModels = useRefreshGoogleModels(); + const refreshGrokModels = useRefreshGrokModels(); return useMutation({ mutationKey: ["refreshAllModels"] as const, mutationFn: async () => { @@ -366,6 +479,10 @@ export function useRefreshModels() { refreshOpenRouterModels.mutateAsync(), refreshOllamaModels.mutateAsync(), refreshLMStudioModels.mutateAsync(), + refreshOpenAIModels.mutateAsync(), + refreshAnthropicModels.mutateAsync(), + refreshGoogleModels.mutateAsync(), + refreshGrokModels.mutateAsync(), ]); }, }); From e29816b596e342a5314e1ba6a612ae0a38eb4b9a Mon Sep 17 00:00:00 2001 From: hrayleung Date: Tue, 27 Jan 2026 14:42:40 +0800 Subject: [PATCH 02/27] fix: Remove hardcoded model validation in providers - Remove hardcoded model ID checks in OpenAI, Grok, Anthropic, and Google providers - Allow dynamically fetched models from APIs to work without modification - Update model name mapping functions to return original name if not found in hardcoded list - Update image support detection in OpenAI provider to use pattern matching instead of hardcoded list - Set default max_tokens (8192) for Anthropic models not in hardcoded list Fixes "UNSUPPORTED MODEL" error for dynamically fetched models. Co-Authored-By: Claude Sonnet 4.5 --- .../ModelProviders/ProviderAnthropic.ts | 16 +++++------ .../chorus/ModelProviders/ProviderGoogle.ts | 9 +++--- .../chorus/ModelProviders/ProviderGrok.ts | 8 ------ .../chorus/ModelProviders/ProviderOpenAI.ts | 28 +++++-------------- 4 files changed, 18 insertions(+), 43 deletions(-) diff --git a/src/core/chorus/ModelProviders/ProviderAnthropic.ts b/src/core/chorus/ModelProviders/ProviderAnthropic.ts index f6bc95e1..37c61b65 100644 --- a/src/core/chorus/ModelProviders/ProviderAnthropic.ts +++ b/src/core/chorus/ModelProviders/ProviderAnthropic.ts @@ -82,11 +82,13 @@ const ANTHROPIC_MODELS: AnthropicModelConfig[] = [ }, ]; -function getAnthropicModelName(modelName: string): string | undefined { +function getAnthropicModelName(modelName: string): string { const modelConfig = ANTHROPIC_MODELS.find( (m) => m.inputModelName === modelName, ); - return modelConfig?.anthropicModelName; + // If not found in hardcoded list, return the model name as-is + // (supports dynamically fetched models from API) + return modelConfig?.anthropicModelName ?? modelName; } export class ProviderAnthropic implements IProvider { @@ -103,9 +105,6 @@ export class ProviderAnthropic implements IProvider { }: StreamResponseParams) { const modelName = modelConfig.modelId.split("::")[1]; const anthropicModelName = getAnthropicModelName(modelName); - if (!anthropicModelName) { - throw new Error(`Unsupported model: ${modelConfig.modelId}`); - } const { canProceed, reason } = canProceedWithProvider( "anthropic", @@ -400,10 +399,9 @@ const getMaxTokens = (modelId: string) => { const modelConfig = ANTHROPIC_MODELS.find( (m) => m.inputModelName === modelId, ); - if (!modelConfig) { - throw new Error(`Unsupported model: ${modelId}`); - } - return modelConfig.maxTokens; + // Return default max tokens if model not found in hardcoded list + // (supports dynamically fetched models from API) + return modelConfig?.maxTokens ?? 8192; }; /** diff --git a/src/core/chorus/ModelProviders/ProviderGoogle.ts b/src/core/chorus/ModelProviders/ProviderGoogle.ts index 9547397b..385e6528 100644 --- a/src/core/chorus/ModelProviders/ProviderGoogle.ts +++ b/src/core/chorus/ModelProviders/ProviderGoogle.ts @@ -25,7 +25,7 @@ function isProviderError(error: unknown): error is ProviderError { ); } -function getGoogleModelName(modelName: string): string | undefined { +function getGoogleModelName(modelName: string): string { if ( [ "gemini-2.0-flash-exp", @@ -49,7 +49,9 @@ function getGoogleModelName(modelName: string): string | undefined { // alias deprecated preview model to stable version return "gemini-2.5-flash"; } - return undefined; + // If not found in hardcoded mappings, return the model name as-is + // (supports dynamically fetched models from API) + return modelName; } // uses OpenAI provider to format the messages @@ -67,9 +69,6 @@ export class ProviderGoogle implements IProvider { }: StreamResponseParams): Promise { const modelName = modelConfig.modelId.split("::")[1]; const googleModelName = getGoogleModelName(modelName); - if (!googleModelName) { - throw new Error(`Unsupported model: ${modelName}`); - } const { canProceed, reason } = canProceedWithProvider( "google", diff --git a/src/core/chorus/ModelProviders/ProviderGrok.ts b/src/core/chorus/ModelProviders/ProviderGrok.ts index ac09d065..b7a3f360 100644 --- a/src/core/chorus/ModelProviders/ProviderGrok.ts +++ b/src/core/chorus/ModelProviders/ProviderGrok.ts @@ -36,14 +36,6 @@ export class ProviderGrok implements IProvider { customBaseUrl, }: StreamResponseParams) { const modelName = modelConfig.modelId.split("::")[1]; - if ( - modelName !== "grok-3-beta" && - modelName !== "grok-3-mini-beta" && - modelName !== "grok-3-mini-fast-beta" && - modelName !== "grok-3-fast-beta" - ) { - throw new Error(`Unsupported model: ${modelName}`); - } const { canProceed, reason } = canProceedWithProvider("grok", apiKeys); diff --git a/src/core/chorus/ModelProviders/ProviderOpenAI.ts b/src/core/chorus/ModelProviders/ProviderOpenAI.ts index a194a1c3..c4fbeb0b 100644 --- a/src/core/chorus/ModelProviders/ProviderOpenAI.ts +++ b/src/core/chorus/ModelProviders/ProviderOpenAI.ts @@ -27,28 +27,14 @@ export class ProviderOpenAI implements IProvider { customBaseUrl, }: StreamResponseParams) { const modelId = modelConfig.modelId.split("::")[1]; - if ( - modelId !== "gpt-4o" && - modelId !== "gpt-4o-mini" && - modelId !== "o1" && - modelId !== "o3-mini" && - modelId !== "gpt-4.5-preview" && - modelId !== "o1-pro" && - modelId !== "gpt-4.1" && - modelId !== "gpt-4.1-mini" && - modelId !== "gpt-4.1-nano" && - modelId !== "o3" && - modelId !== "o4-mini" && - modelId !== "o3-pro" && - modelId !== "o3-deep-research" && - modelId !== "gpt-5" && - modelId !== "gpt-5-mini" && - modelId !== "gpt-5-nano" - ) { - throw new Error(`Unsupported model: ${modelId}`); - } - const imageSupport = modelId !== "o3-mini" && modelId !== "o1"; + // Determine image support based on model naming patterns + const imageSupport = + modelId.includes("gpt-4") || + modelId.includes("gpt-5") || + (modelId.startsWith("o") && + !modelId.includes("mini") && + modelId !== "o1"); const { canProceed, reason } = canProceedWithProvider( "openai", From 00d84a84c6e41682e9f1c25bbb5d693f06e3ad42 Mon Sep 17 00:00:00 2001 From: hrayleung Date: Tue, 27 Jan 2026 14:55:58 +0800 Subject: [PATCH 03/27] feat: Add thinking parameters support for all providers Database changes: - Add thinking_level column to model_configs (for Gemini 3) - Extend reasoning_effort to support "xhigh" (for OpenAI GPT-5) TypeScript changes: - Update ModelConfig type with new thinking parameters - Add useUpdateThinkingParams API function - Update all SELECT queries to include thinking_level Supports: - OpenAI: reasoning_effort (low/medium/high/xhigh) - Anthropic: budget_tokens (1024+) - Google Gemini 3: thinking_level (LOW/HIGH) - Google Gemini 2.5: budget_tokens (0-24576 or -1) - xAI Grok: reasoning_effort (low/high) Co-Authored-By: Claude Sonnet 4.5 --- SQL_SCHEMA.md | 381 +++++++++++++++++++++++++++++++ src-tauri/src/migrations.rs | 8 + src/core/chorus/Models.ts | 5 +- src/core/chorus/api/ModelsAPI.ts | 36 ++- 4 files changed, 425 insertions(+), 5 deletions(-) create mode 100644 SQL_SCHEMA.md diff --git a/SQL_SCHEMA.md b/SQL_SCHEMA.md new file mode 100644 index 00000000..553e2f82 --- /dev/null +++ b/SQL_SCHEMA.md @@ -0,0 +1,381 @@ +# Database Schema + +_This file is auto-generated from migrations.rs. Do not edit manually._ + +Last updated: 2026-01-27 14:55:46 + +## Tables + +- [app_metadata](#app_metadata) +- [attachments](#attachments) +- [chats](#chats) +- [custom_toolsets](#custom_toolsets) +- [draft_attachments](#draft_attachments) +- [gc_prototype_conductors](#gc_prototype_conductors) +- [gc_prototype_messages](#gc_prototype_messages) +- [message_attachments](#message_attachments) +- [message_drafts](#message_drafts) +- [message_parts](#message_parts) +- [message_sets](#message_sets) +- [messages](#messages) +- [messages_archive_20250102](#messages_archive_20250102) +- [model_configs](#model_configs) +- [models](#models) +- [models_archive_20250111](#models_archive_20250111) +- [project_attachments](#project_attachments) +- [projects](#projects) +- [saved_model_configs_chats](#saved_model_configs_chats) +- [temp_group_parent](#temp_group_parent) +- [temp_groupings](#temp_groupings) +- [temp_hierarchy](#temp_hierarchy) +- [temp_message_sets](#temp_message_sets) +- [tool_permissions](#tool_permissions) +- [toolsets_config](#toolsets_config) + +## app_metadata + +| Column | Type | Constraints | Default | +|--------|------|-------------|---------| +| key | TEXT | PRIMARY KEY | - | +| value | TEXT | NOT NULL | - | +| created_at | DATETIME | - | CURRENT_TIMESTAMP | + +## attachments + +| Column | Type | Constraints | Default | +|--------|------|-------------|---------| +| id | TEXT | PRIMARY KEY | - | +| created_at | DATETIME | NOT NULL | CURRENT_TIMESTAMP | +| type | TEXT | NOT NULL | - | +| is_loading | BOOLEAN | NOT NULL | 0 | +| original_name | TEXT | - | - | +| path | TEXT | NOT NULL | - | +| ephemeral | BOOLEAN | NOT NULL | 0 | + +## chats + +| Column | Type | Constraints | Default | +|--------|------|-------------|---------| +| id | TEXT | NOT NULL PRIMARY KEY | - | +| title | TEXT | - | - | +| created_at | DATETIME | - | CURRENT_TIMESTAMP | +| updated_at | DATETIME | - | - | +| pinned | BOOLEAN | NOT NULL | 0 | +| quick_chat | BOOLEAN | NOT NULL | 0 | +| project_id | TEXT | NOT NULL | 'default' | +| summary | TEXT | - | - | +| is_new_chat | BOOLEAN | NOT NULL | 0 | +| parent_chat_id | TEXT | - | - | +| project_context_summary | TEXT | - | - | +| project_context_summary_is_stale | BOOLEAN | NOT NULL | 1 | +| reply_to_id | TEXT | - | - | +| gc_prototype_chat | BOOLEAN | NOT NULL | 0 | +| total_cost_usd | REAL | - | 0.0 | + +### Indices + +- **idx_chats_is_new_chat** + - Columns: is_new_chat +- **idx_chats_pinned** + - Columns: pinned +- **idx_chats_project_cost** + - Columns: project_id, total_cost_usd + +## custom_toolsets + +| Column | Type | Constraints | Default | +|--------|------|-------------|---------| +| name | TEXT | PRIMARY KEY | - | +| command | TEXT | - | - | +| args | TEXT | - | - | +| env | JSON | - | - | +| updated_at | DATETIME | - | CURRENT_TIMESTAMP | +| default_permission | TEXT | NOT NULL | 'ask' | + +## draft_attachments + +| Column | Type | Constraints | Default | +|--------|------|-------------|---------| +| chat_id | TEXT | NOT NULL PRIMARY KEY | - | +| attachment_id | TEXT | NOT NULL PRIMARY KEY | - | + +## gc_prototype_conductors + +| Column | Type | Constraints | Default | +|--------|------|-------------|---------| +| chat_id | TEXT | NOT NULL PRIMARY KEY | - | +| scope_id | TEXT | PRIMARY KEY | - | +| conductor_model_id | TEXT | NOT NULL | - | +| turn_count | INTEGER | - | 0 | +| is_active | BOOLEAN | - | 1 | +| created_at | DATETIME | - | CURRENT_TIMESTAMP | + +### Indices + +- **idx_gc_prototype_conductors_active** + - Columns: chat_id, is_active + +## gc_prototype_messages + +| Column | Type | Constraints | Default | +|--------|------|-------------|---------| +| chat_id | TEXT | NOT NULL PRIMARY KEY | - | +| id | TEXT | NOT NULL PRIMARY KEY | - | +| text | TEXT | NOT NULL | - | +| model_config_id | TEXT | NOT NULL | - | +| created_at | DATETIME | - | CURRENT_TIMESTAMP | +| updated_at | DATETIME | - | CURRENT_TIMESTAMP | +| is_deleted | BOOLEAN | NOT NULL | 0 | +| thread_root_message_id | TEXT | - | - | +| promoted_from_message_id | TEXT | - | - | + +### Indices + +- **idx_gc_prototype_messages_chat_created** + - Columns: chat_id, created_at +- **idx_gc_prototype_messages_promoted_from** + - Columns: promoted_from_message_id +- **idx_gc_prototype_messages_thread_root** + - Columns: thread_root_message_id + +## message_attachments + +| Column | Type | Constraints | Default | +|--------|------|-------------|---------| +| message_id | TEXT | NOT NULL PRIMARY KEY | - | +| attachment_id | TEXT | NOT NULL PRIMARY KEY | - | + +## message_drafts + +| Column | Type | Constraints | Default | +|--------|------|-------------|---------| +| chat_id | TEXT | PRIMARY KEY | - | +| content | TEXT | NOT NULL | - | + +## message_parts + +| Column | Type | Constraints | Default | +|--------|------|-------------|---------| +| chat_id | TEXT | NOT NULL | - | +| message_id | TEXT | NOT NULL PRIMARY KEY | - | +| level | INTEGER | NOT NULL PRIMARY KEY | - | +| content | TEXT | NOT NULL | - | +| tool_calls | TEXT | - | - | +| tool_results | TEXT | - | - | + +## message_sets + +| Column | Type | Constraints | Default | +|--------|------|-------------|---------| +| id | TEXT | PRIMARY KEY | - | +| chat_id | TEXT | NOT NULL | - | +| deprecated_parent_id | TEXT | - | - | +| type | TEXT | NOT NULL | - | +| created_at | DATETIME | - | CURRENT_TIMESTAMP | +| selected_block_type | TEXT | NOT NULL | 'chat' | +| level | INTEGER | - | - | + +### Indices + +- **idx_message_sets_chat_level** + - Columns: chat_id, level + +## messages + +| Column | Type | Constraints | Default | +|--------|------|-------------|---------| +| id | TEXT | PRIMARY KEY | - | +| message_set_id | TEXT | NOT NULL | - | +| chat_id | TEXT | NOT NULL | - | +| text | TEXT | NOT NULL | - | +| model | TEXT | NOT NULL | - | +| selected | BOOLEAN | - | - | +| created_at | DATETIME | - | CURRENT_TIMESTAMP | +| streaming_token | TEXT | - | - | +| state | TEXT | - | 'streaming' | +| error_message | TEXT | - | - | +| is_review | BOOLEAN | - | 0 | +| review_state | TEXT | - | - | +| block_type | TEXT | - | - | +| level | INTEGER | - | - | +| dep_attachments_archive | TEXT | - | - | +| reply_chat_id | TEXT | - | - | +| branched_from_id | TEXT | - | - | +| prompt_tokens | INTEGER | - | - | +| completion_tokens | INTEGER | - | - | +| total_tokens | INTEGER | - | - | +| cost_usd | REAL | - | - | + +### Indices + +- **idx_messages_chat_cost** + - Columns: chat_id, cost_usd + +## messages_archive_20250102 + +| Column | Type | Constraints | Default | +|--------|------|-------------|---------| +| id | TEXT | PRIMARY KEY | - | +| chat_id | TEXT | NOT NULL | - | +| parent_id | TEXT | - | - | +| text | TEXT | NOT NULL | - | +| model | TEXT | NOT NULL | - | +| selected | BOOLEAN | - | - | +| created_at | DATETIME | - | CURRENT_TIMESTAMP | +| attachments | TEXT | - | - | + +### Indices + +- **idx_messages_attachments** + - Columns: (json_valid(attachments + +## model_configs + +| Column | Type | Constraints | Default | +|--------|------|-------------|---------| +| id | TEXT | PRIMARY KEY | - | +| model_id | TEXT | NOT NULL | - | +| display_name | TEXT | NOT NULL | - | +| author | TEXT | NOT NULL | - | +| created_at | DATETIME | - | CURRENT_TIMESTAMP | +| system_prompt | TEXT | NOT NULL | - | +| is_default | BOOLEAN | - | 0 | +| budget_tokens | INTEGER | - | - | +| reasoning_effort | TEXT | - | - | +| new_until | DATETIME | - | - | +| thinking_level | TEXT | - | - | + +## models + +| Column | Type | Constraints | Default | +|--------|------|-------------|---------| +| id | TEXT | PRIMARY KEY | - | +| display_name | TEXT | NOT NULL | - | +| is_enabled | BOOLEAN | - | 1 | +| supported_attachment_types | TEXT | NOT NULL | - | +| is_internal | BOOLEAN | NOT NULL | 0 | +| is_deprecated | BOOLEAN | NOT NULL | 0 | +| prompt_price_per_token | REAL | - | - | +| completion_price_per_token | REAL | - | - | + +## models_archive_20250111 + +| Column | Type | Constraints | Default | +|--------|------|-------------|---------| +| id | TEXT | PRIMARY KEY | - | +| name | TEXT | NOT NULL | - | +| type | TEXT | NOT NULL | - | +| api_key | TEXT | - | - | +| model_id | TEXT | NOT NULL | - | +| system_prompt | TEXT | - | - | +| request_template | JSON | NOT NULL | - | +| created_at | DATETIME | - | CURRENT_TIMESTAMP | +| is_enabled | BOOLEAN | - | 1 | +| display_order | INTEGER | - | - | +| is_selected | BOOLEAN | - | 0 | +| short_name | TEXT | - | - | +| server_url | TEXT | - | - | + +## project_attachments + +| Column | Type | Constraints | Default | +|--------|------|-------------|---------| +| project_id | TEXT | NOT NULL PRIMARY KEY | - | +| attachment_id | TEXT | NOT NULL PRIMARY KEY | - | + +## projects + +| Column | Type | Constraints | Default | +|--------|------|-------------|---------| +| id | TEXT | PRIMARY KEY | - | +| name | TEXT | NOT NULL | - | +| created_at | DATETIME | NOT NULL | CURRENT_TIMESTAMP | +| updated_at | DATETIME | NOT NULL | CURRENT_TIMESTAMP | +| is_collapsed | BOOLEAN | NOT NULL | 0 | +| context_text | TEXT | - | - | +| magic_projects_enabled | BOOLEAN | NOT NULL | 1 | +| is_imported | BOOLEAN | NOT NULL | 0 | +| total_cost_usd | REAL | - | 0.0 | + +## saved_model_configs_chats + +| Column | Type | Constraints | Default | +|--------|------|-------------|---------| +| id | TEXT | NOT NULL PRIMARY KEY | - | +| chat_id | TEXT | - | - | +| model_ids | TEXT | NOT NULL | - | +| created_at | DATETIME | - | CURRENT_TIMESTAMP | +| updated_at | DATETIME | - | CURRENT_TIMESTAMP | + +### Indices + +- **idx_saved_model_configs_chats_chat_id** + - Columns: chat_id + +## temp_group_parent + +| Column | Type | Constraints | Default | +|--------|------|-------------|---------| +| group_key | | - | - | +| chat_id | TEXT | - | - | +| parent_id | TEXT | - | - | +| type | | - | - | +| level | | - | - | +| parent_group_key | | - | - | + +## temp_groupings + +| Column | Type | Constraints | Default | +|--------|------|-------------|---------| +| chat_id | TEXT | - | - | +| parent_id | TEXT | - | - | +| type | | - | - | +| level | | - | - | +| group_key | | - | - | + +## temp_hierarchy + +| Column | Type | Constraints | Default | +|--------|------|-------------|---------| +| id | TEXT | - | - | +| chat_id | TEXT | - | - | +| parent_id | TEXT | - | - | +| text | TEXT | - | - | +| model | TEXT | - | - | +| attachments | TEXT | - | - | +| has_children | | - | - | +| level | | - | - | +| created_at | NUM | - | - | + +## temp_message_sets + +| Column | Type | Constraints | Default | +|--------|------|-------------|---------| +| group_key | TEXT | PRIMARY KEY | - | +| message_set_id | TEXT | - | - | +| chat_id | TEXT | NOT NULL | - | +| parent_group_key | TEXT | - | - | +| parent_message_set_id | TEXT | - | - | +| type | TEXT | NOT NULL | - | +| level | INT | NOT NULL | - | + +## tool_permissions + +| Column | Type | Constraints | Default | +|--------|------|-------------|---------| +| toolset_name | TEXT | NOT NULL PRIMARY KEY | - | +| tool_name | TEXT | NOT NULL PRIMARY KEY | - | +| permission_type | TEXT | NOT NULL | - | +| last_asked_at | DATETIME | - | - | +| last_response | TEXT | - | - | +| created_at | DATETIME | - | CURRENT_TIMESTAMP | +| updated_at | DATETIME | - | CURRENT_TIMESTAMP | + +## toolsets_config + +| Column | Type | Constraints | Default | +|--------|------|-------------|---------| +| toolset_name | TEXT | PRIMARY KEY | - | +| parameter_id | TEXT | PRIMARY KEY | - | +| parameter_value | TEXT | - | - | + diff --git a/src-tauri/src/migrations.rs b/src-tauri/src/migrations.rs index 87622999..77754922 100644 --- a/src-tauri/src/migrations.rs +++ b/src-tauri/src/migrations.rs @@ -2554,5 +2554,13 @@ You have full access to bash commands on the user''''s computer. If you write a UPDATE projects SET total_cost_usd = 0.0 WHERE total_cost_usd IS NULL; "#, }, + Migration { + version: 139, + description: "add thinking_level column to model_configs for Gemini 3 thinking mode", + kind: MigrationKind::Up, + sql: r#" + ALTER TABLE model_configs ADD COLUMN thinking_level TEXT; + "#, + }, ]; } diff --git a/src/core/chorus/Models.ts b/src/core/chorus/Models.ts index bf8c1924..dcf4891a 100644 --- a/src/core/chorus/Models.ts +++ b/src/core/chorus/Models.ts @@ -198,8 +198,9 @@ export type ModelConfig = { modelId: string; systemPrompt?: string; isDefault: boolean; - budgetTokens?: number; // optional token budget for thinking mode - reasoningEffort?: "low" | "medium" | "high"; + budgetTokens?: number; // optional token budget for thinking mode (Anthropic, Gemini 2.5) + reasoningEffort?: "low" | "medium" | "high" | "xhigh"; // OpenAI o1/o3/GPT-5, xAI Grok + thinkingLevel?: "LOW" | "HIGH"; // Google Gemini 3 thinking level // pricing (from models table) promptPricePerToken?: number; diff --git a/src/core/chorus/api/ModelsAPI.ts b/src/core/chorus/api/ModelsAPI.ts index f1a29819..0306f529 100644 --- a/src/core/chorus/api/ModelsAPI.ts +++ b/src/core/chorus/api/ModelsAPI.ts @@ -65,7 +65,8 @@ type ModelConfigDBRow = { is_internal: boolean; is_deprecated: boolean; budget_tokens: number | null; - reasoning_effort: "low" | "medium" | "high" | null; + reasoning_effort: "low" | "medium" | "high" | "xhigh" | null; + thinking_level: "LOW" | "HIGH" | null; new_until?: string; prompt_price_per_token: number | null; completion_price_per_token: number | null; @@ -107,6 +108,7 @@ function readModelConfig(row: ModelConfigDBRow): ModelConfig { isDeprecated: row.is_deprecated, budgetTokens: row.budget_tokens ?? undefined, reasoningEffort: row.reasoning_effort ?? undefined, + thinkingLevel: row.thinking_level ?? undefined, newUntil: row.new_until ?? undefined, promptPricePerToken: row.prompt_price_per_token ?? undefined, completionPricePerToken: row.completion_price_per_token ?? undefined, @@ -175,7 +177,7 @@ export async function fetchModelConfigs() { `SELECT model_configs.id, model_configs.display_name, model_configs.author, model_configs.model_id, model_configs.system_prompt, models.is_enabled, models.is_internal, models.supported_attachment_types, model_configs.is_default, - models.is_deprecated, model_configs.budget_tokens, model_configs.reasoning_effort, model_configs.new_until, + models.is_deprecated, model_configs.budget_tokens, model_configs.reasoning_effort, model_configs.thinking_level, model_configs.new_until, models.prompt_price_per_token, models.completion_price_per_token FROM model_configs JOIN models ON model_configs.model_id = models.id @@ -315,7 +317,7 @@ export async function fetchModelConfigById( `SELECT model_configs.id, model_configs.display_name, model_configs.author, model_configs.model_id, model_configs.system_prompt, models.is_enabled, models.is_internal, models.supported_attachment_types, model_configs.is_default, - models.is_deprecated, model_configs.budget_tokens, model_configs.reasoning_effort, model_configs.new_until, + models.is_deprecated, model_configs.budget_tokens, model_configs.reasoning_effort, model_configs.thinking_level, model_configs.new_until, models.prompt_price_per_token, models.completion_price_per_token FROM model_configs JOIN models ON model_configs.model_id = models.id @@ -531,6 +533,34 @@ export function useUpdateModelConfig() { }); } +export function useUpdateThinkingParams() { + const queryClient = useQueryClient(); + return useMutation({ + mutationKey: ["updateThinkingParams"] as const, + mutationFn: async ({ + modelConfigId, + budgetTokens, + reasoningEffort, + thinkingLevel, + }: { + modelConfigId: string; + budgetTokens?: number | null; + reasoningEffort?: "low" | "medium" | "high" | "xhigh" | null; + thinkingLevel?: "LOW" | "HIGH" | null; + }) => { + await db.execute( + "UPDATE model_configs SET budget_tokens = $1, reasoning_effort = $2, thinking_level = $3 WHERE id = $4", + [budgetTokens, reasoningEffort, thinkingLevel, modelConfigId], + ); + }, + onSuccess: async () => { + await queryClient.invalidateQueries( + modelConfigQueries.listConfigs(), + ); + }, + }); +} + export function useCreateModelConfig() { const queryClient = useQueryClient(); return useMutation({ From a47f1522aff879bf480c9a964396f8b9d37169d0 Mon Sep 17 00:00:00 2001 From: hrayleung Date: Tue, 27 Jan 2026 14:58:52 +0800 Subject: [PATCH 04/27] feat: Add thinking parameters UI and provider integration UI Components: - Add ThinkingParamsButton component with popover interface - Shows Brain icon next to model names - Displays current thinking parameters when set - Supports all provider-specific parameters Provider Integration: - Google: Add thinking_level (Gemini 3) and thinking_budget (Gemini 2.5) support - Grok: Add reasoning_effort support for Grok 3 Mini models - OpenAI: Already supported reasoning_effort - Anthropic: Already supported budget_tokens Features: - Auto-detects provider and shows relevant parameters only - Save/Reset functionality - Real-time updates to model configs - Toast notifications for success/error Parameter mappings: - OpenAI (o1/o3/GPT-5): reasoning_effort (low/medium/high/xhigh) - Anthropic (Claude): budget_tokens (1024-20000) - Google Gemini 3: thinking_level (LOW/HIGH) - Google Gemini 2.5: thinking_budget (0-24576 or -1) - xAI Grok 3 Mini: reasoning_effort (low/high) Co-Authored-By: Claude Sonnet 4.5 --- .../chorus/ModelProviders/ProviderGoogle.ts | 14 + .../chorus/ModelProviders/ProviderGrok.ts | 7 + src/ui/components/MultiChat.tsx | 4 + src/ui/components/ThinkingParamsButton.tsx | 270 ++++++++++++++++++ 4 files changed, 295 insertions(+) create mode 100644 src/ui/components/ThinkingParamsButton.tsx diff --git a/src/core/chorus/ModelProviders/ProviderGoogle.ts b/src/core/chorus/ModelProviders/ProviderGoogle.ts index 385e6528..8e8a8431 100644 --- a/src/core/chorus/ModelProviders/ProviderGoogle.ts +++ b/src/core/chorus/ModelProviders/ProviderGoogle.ts @@ -131,6 +131,20 @@ export class ProviderGoogle implements IProvider { stream: true, }; + // Add Gemini thinking parameters + const isGemini3 = googleModelName.includes("gemini-3"); + const isGemini25 = googleModelName.includes("gemini-2.5"); + + if (isGemini3 && modelConfig.thinkingLevel) { + // Gemini 3 uses thinking_level parameter + (streamParams as Record).thinking_level = + modelConfig.thinkingLevel; + } else if (isGemini25 && modelConfig.budgetTokens) { + // Gemini 2.5 uses thinking_budget parameter + (streamParams as Record).thinking_budget = + modelConfig.budgetTokens; + } + // Add tools definitions if (tools && tools.length > 0) { streamParams.tools = diff --git a/src/core/chorus/ModelProviders/ProviderGrok.ts b/src/core/chorus/ModelProviders/ProviderGrok.ts index b7a3f360..706cf8b3 100644 --- a/src/core/chorus/ModelProviders/ProviderGrok.ts +++ b/src/core/chorus/ModelProviders/ProviderGrok.ts @@ -77,6 +77,7 @@ export class ProviderGrok implements IProvider { const streamParams: OpenAI.ChatCompletionCreateParamsStreaming & { include_reasoning: boolean; + reasoning_effort?: string; } = { model: modelName, messages, @@ -84,6 +85,12 @@ export class ProviderGrok implements IProvider { include_reasoning: true, }; + // Add reasoning_effort for Grok 3 Mini models + const isGrok3Mini = modelName.includes("grok-3-mini"); + if (isGrok3Mini && modelConfig.reasoningEffort) { + streamParams.reasoning_effort = modelConfig.reasoningEffort; + } + try { const stream = await client.chat.completions.create(streamParams); diff --git a/src/ui/components/MultiChat.tsx b/src/ui/components/MultiChat.tsx index 85200238..dea8b64c 100644 --- a/src/ui/components/MultiChat.tsx +++ b/src/ui/components/MultiChat.tsx @@ -95,6 +95,7 @@ import { useShortcut } from "@ui/hooks/useShortcut"; import { projectDisplayName, sendTauriNotification } from "@ui/lib/utils"; import { useQuery } from "@tanstack/react-query"; import { ManageModelsBox } from "./ManageModelsBox"; +import { ThinkingParamsButton } from "./ThinkingParamsButton"; import RepliesDrawer from "./RepliesDrawer"; import useElementScrollDetection from "@ui/hooks/useScrollDetection"; import { checkScreenRecordingPermission } from "tauri-plugin-macos-permissions-api"; @@ -1260,6 +1261,9 @@ export function ToolsMessageView({
{modelConfig?.displayName}
+ )} diff --git a/src/ui/components/ThinkingParamsButton.tsx b/src/ui/components/ThinkingParamsButton.tsx new file mode 100644 index 00000000..e26885ce --- /dev/null +++ b/src/ui/components/ThinkingParamsButton.tsx @@ -0,0 +1,270 @@ +import { useState } from "react"; +import { Brain, ChevronDown } from "lucide-react"; +import { Button } from "./ui/button"; +import { + Popover, + PopoverContent, + PopoverTrigger, +} from "./ui/popover"; +import { Label } from "./ui/label"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "./ui/select"; +import { Input } from "./ui/input"; +import { ModelConfig, getProviderName } from "@core/chorus/Models"; +import * as ModelsAPI from "@core/chorus/api/ModelsAPI"; +import { toast } from "sonner"; + +interface ThinkingParamsButtonProps { + modelConfig: ModelConfig; +} + +export function ThinkingParamsButton({ + modelConfig, +}: ThinkingParamsButtonProps) { + const updateThinkingParams = ModelsAPI.useUpdateThinkingParams(); + const [open, setOpen] = useState(false); + const [budgetTokens, setBudgetTokens] = useState( + modelConfig.budgetTokens?.toString() ?? "", + ); + const [reasoningEffort, setReasoningEffort] = useState< + "low" | "medium" | "high" | "xhigh" | "" + >(modelConfig.reasoningEffort ?? ""); + const [thinkingLevel, setThinkingLevel] = useState<"LOW" | "HIGH" | "">( + modelConfig.thinkingLevel ?? "", + ); + + const providerName = getProviderName(modelConfig.modelId); + const modelName = modelConfig.modelId.split("::")[1]; + + // Determine which parameters to show based on provider + const showReasoningEffort = + providerName === "openai" || providerName === "grok"; + const showBudgetTokens = + providerName === "anthropic" || modelName?.includes("gemini-2.5"); + const showThinkingLevel = modelName?.includes("gemini-3"); + + // Don't show the button if no thinking parameters are applicable + if (!showReasoningEffort && !showBudgetTokens && !showThinkingLevel) { + return null; + } + + const handleSave = async () => { + try { + await updateThinkingParams.mutateAsync({ + modelConfigId: modelConfig.id, + budgetTokens: budgetTokens ? parseInt(budgetTokens) : null, + reasoningEffort: reasoningEffort || null, + thinkingLevel: thinkingLevel || null, + }); + toast.success("Thinking parameters updated"); + setOpen(false); + } catch (error) { + toast.error("Failed to update thinking parameters"); + console.error(error); + } + }; + + const handleReset = async () => { + try { + await updateThinkingParams.mutateAsync({ + modelConfigId: modelConfig.id, + budgetTokens: null, + reasoningEffort: null, + thinkingLevel: null, + }); + setBudgetTokens(""); + setReasoningEffort(""); + setThinkingLevel(""); + toast.success("Thinking parameters reset"); + } catch (error) { + toast.error("Failed to reset thinking parameters"); + console.error(error); + } + }; + + // Show indicator if any thinking params are set + const hasThinkingParams = + modelConfig.budgetTokens || + modelConfig.reasoningEffort || + modelConfig.thinkingLevel; + + return ( + + + + + +
+
+

+ Thinking Parameters +

+

+ Control how much the model thinks before responding +

+
+ + {showReasoningEffort && ( +
+ + +
+ )} + + {showBudgetTokens && ( +
+ + setBudgetTokens(e.target.value)} + min={providerName === "anthropic" ? 1024 : -1} + max={providerName === "anthropic" ? 20000 : 24576} + /> +

+ {providerName === "anthropic" + ? "Min 1024. Higher = more reasoning time & cost" + : "Set to -1 for dynamic budget"} +

+
+ )} + + {showThinkingLevel && ( +
+ + +
+ )} + +
+ + +
+
+
+
+ ); +} From 9d622487e3fdcb8800858da1ff11e29ab38f4078 Mon Sep 17 00:00:00 2001 From: hrayleung Date: Tue, 27 Jan 2026 14:59:46 +0800 Subject: [PATCH 05/27] chore: Format code with Prettier --- CLAUDE.md | 206 ++++---- SQL_SCHEMA.md | 553 ++++++++++----------- src/core/chorus/Models.ts | 15 +- src/ui/components/ThinkingParamsButton.tsx | 31 +- 4 files changed, 428 insertions(+), 377 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 8e398bf4..e2e4b140 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -9,38 +9,44 @@ Chorus is a native Mac AI chat app that lets you chat with multiple AI models si It's built with Tauri (Rust), React, TypeScript, TanStack Query, and SQLite. The app sends one prompt and displays responses from Claude, o3-pro, Gemini, and other models side by side. Key features: -- MCP (Model Context Protocol) support -- Ambient chats (quick chats accessible from anywhere) -- Projects (organized folders of chats) -- Bring your own API keys -- Multi-model comparison + +- MCP (Model Context Protocol) support +- Ambient chats (quick chats accessible from anywhere) +- Projects (organized folders of chats) +- Bring your own API keys +- Multi-model comparison Most functionality lives in this repo. A separate Elixir backend at app.chorus.sh handles accounts, billing, and request proxying. ## Development Commands **Initial setup:** + ```bash pnpm run setup # Install dependencies and initialize dev environment ``` **Development:** + ```bash pnpm run dev # Start development instance (uses repo directory name) pnpm run workspace [name] # Run specific isolated instance with separate data directory ``` **Building:** + ```bash pnpm run build # Compile TypeScript and build with Vite ``` **Testing:** + ```bash pnpm run test # Run tests with Vitest ``` **Linting and formatting:** + ```bash pnpm run lint # Run ESLint pnpm run lint:fix # Run ESLint with auto-fix @@ -51,12 +57,14 @@ pnpm run validate:fix # Run both with auto-fix ``` **Database management:** + ```bash pnpm run generate-schema # Generate SQL_SCHEMA.md from migrations.rs pnpm run delete-db # Delete local development database ``` **Release:** + ```bash pnpm run release # Interactive release script ``` @@ -66,10 +74,11 @@ Note: Pre-commit hooks automatically run linting and formatting. See `.lintstage ## Development Instances The `dev-instance.sh` script lets you run multiple isolated Chorus instances simultaneously: -- Each instance has its own data directory: `~/Library/Application Support/sh.chorus.app.dev./` -- Each gets a unique port (1420-1520) based on instance name hash -- Instance name appears in the DEV MODE indicator in the sidebar -- Data persists between runs + +- Each instance has its own data directory: `~/Library/Application Support/sh.chorus.app.dev./` +- Each gets a unique port (1420-1520) based on instance name hash +- Instance name appears in the DEV MODE indicator in the sidebar +- Data persists between runs This is useful for working on multiple branches or testing without affecting your main development environment. @@ -107,54 +116,63 @@ src-tauri/ ### Key Architectural Patterns **Model Provider System:** -- All providers implement `IProvider` interface -- Each provider handles its own API communication format -- Providers support streaming, tool calling, and attachments -- See `src/core/chorus/ModelProviders/` + +- All providers implement `IProvider` interface +- Each provider handles its own API communication format +- Providers support streaming, tool calling, and attachments +- See `src/core/chorus/ModelProviders/` **Database Layer:** -- SQLite database with migrations in `src-tauri/src/migrations.rs` -- Database schema is auto-generated to `SQL_SCHEMA.md` via `pnpm run generate-schema` -- TypeScript database operations reference this schema -- CRITICAL: Never modify existing migrations, always add new ones + +- SQLite database with migrations in `src-tauri/src/migrations.rs` +- Database schema is auto-generated to `SQL_SCHEMA.md` via `pnpm run generate-schema` +- TypeScript database operations reference this schema +- CRITICAL: Never modify existing migrations, always add new ones **API Layer (TanStack Query):** -- Queries and mutations split by entity type: ChatAPI, MessageAPI, ProjectAPI, etc. -- Located in `src/core/chorus/api/` -- Each file handles one entity's CRUD operations and related business logic + +- Queries and mutations split by entity type: ChatAPI, MessageAPI, ProjectAPI, etc. +- Located in `src/core/chorus/api/` +- Each file handles one entity's CRUD operations and related business logic **MCP (Model Context Protocol):** -- Toolsets defined in `src/core/chorus/Toolsets.ts` -- Individual toolset implementations in `src/core/chorus/toolsets/` -- ToolsetsManager.ts handles toolset lifecycle + +- Toolsets defined in `src/core/chorus/Toolsets.ts` +- Individual toolset implementations in `src/core/chorus/toolsets/` +- ToolsetsManager.ts handles toolset lifecycle **State Management:** -- TanStack Query for server state -- Zustand for UI state (DialogStore, etc.) -- React Context for app-wide state (AppProvider, SidebarProvider) + +- TanStack Query for server state +- Zustand for UI state (DialogStore, etc.) +- React Context for app-wide state (AppProvider, SidebarProvider) ## Important Files and Directories **Entry Points:** -- `src/ui/App.tsx` - React root component with routing -- `src-tauri/src/main.rs` - Rust application entry point -- `src-tauri/src/lib.rs` - Tauri plugin configuration + +- `src/ui/App.tsx` - React root component with routing +- `src-tauri/src/main.rs` - Rust application entry point +- `src-tauri/src/lib.rs` - Tauri plugin configuration **Core Logic:** -- `src/core/chorus/Models.ts` - Model configuration and defaults -- `src/core/chorus/Toolsets.ts` - Tool/connection definitions -- `src/core/chorus/ChatState.ts` - Chat state management + +- `src/core/chorus/Models.ts` - Model configuration and defaults +- `src/core/chorus/Toolsets.ts` - Tool/connection definitions +- `src/core/chorus/ChatState.ts` - Chat state management **UI Components:** -- `src/ui/components/MultiChat.tsx` - Main chat interface (handles both regular and quick chats) -- `src/ui/components/ChatInput.tsx` - Message input box -- `src/ui/components/AppSidebar.tsx` - Left sidebar with projects and chats -- `src/ui/components/ManageModelsBox.tsx` - Model selection interface -- `src/ui/components/Settings.tsx` - Settings dialog + +- `src/ui/components/MultiChat.tsx` - Main chat interface (handles both regular and quick chats) +- `src/ui/components/ChatInput.tsx` - Message input box +- `src/ui/components/AppSidebar.tsx` - Left sidebar with projects and chats +- `src/ui/components/ManageModelsBox.tsx` - Model selection interface +- `src/ui/components/Settings.tsx` - Settings dialog **Database:** -- `src-tauri/src/migrations.rs` - Database schema migrations -- `SQL_SCHEMA.md` - Auto-generated schema documentation (DO NOT EDIT MANUALLY) + +- `src-tauri/src/migrations.rs` - Database schema migrations +- `SQL_SCHEMA.md` - Auto-generated schema documentation (DO NOT EDIT MANUALLY) ## Data Model Changes @@ -169,65 +187,77 @@ When modifying the data model: ## Git Workflow **Branch management:** -- NEVER commit directly to main -- NEVER push to origin/main -- Create feature branches: `claude/feature-name` -- Use rebase or cherry-pick to reconcile branches, not merge + +- NEVER commit directly to main +- NEVER push to origin/main +- Create feature branches: `claude/feature-name` +- Use rebase or cherry-pick to reconcile branches, not merge **Committing:** -- Run git commands separately, not chained (e.g., `git add -A` then `git commit`, not `git add -A && git commit`) -- Commit frequently to allow easy reverts -- Pre-commit hooks will automatically lint and format + +- Run git commands separately, not chained (e.g., `git add -A` then `git commit`, not `git add -A && git commit`) +- Commit frequently to allow easy reverts +- Pre-commit hooks will automatically lint and format **Pull Requests:** -- Tag all issues and PRs with "by-claude" -- PR title should NOT include issue number -- PR description should START with issue number -- Include comprehensive test plan for user to execute -- Test plan must cover both new functionality and potentially impacted existing features -- Use `gh` CLI for GitHub interactions + +- Tag all issues and PRs with "by-claude" +- PR title should NOT include issue number +- PR description should START with issue number +- Include comprehensive test plan for user to execute +- Test plan must cover both new functionality and potentially impacted existing features +- Use `gh` CLI for GitHub interactions ## Coding Style **TypeScript:** -- Strict mode enabled, ES2020 target -- Use `as` type assertions only in exceptional cases with explanatory comments -- Prefer type hints over assertions + +- Strict mode enabled, ES2020 target +- Use `as` type assertions only in exceptional cases with explanatory comments +- Prefer type hints over assertions **Imports:** -- Use path aliases: `@ui/*`, `@core/*`, `@/*` instead of relative imports -- Configured in `tsconfig.json` and `vite.config.ts` + +- Use path aliases: `@ui/*`, `@core/*`, `@/*` instead of relative imports +- Configured in `tsconfig.json` and `vite.config.ts` **Naming:** -- React components: PascalCase -- Interfaces: Prefixed with "I" (e.g., `IProvider`) -- Hooks: camelCase with "use" prefix -- Files: Match component name + +- React components: PascalCase +- Interfaces: Prefixed with "I" (e.g., `IProvider`) +- Hooks: camelCase with "use" prefix +- Files: Match component name **Formatting:** -- 4-space indentation (see `.prettierrc`) -- Prettier handles formatting automatically via pre-commit hooks + +- 4-space indentation (see `.prettierrc`) +- Prettier handles formatting automatically via pre-commit hooks **Nulls and Undefined:** -- Prefer `undefined` over `null` -- Convert database nulls to undefined: `parentChatId: row.parent_chat_id ?? undefined` + +- Prefer `undefined` over `null` +- Convert database nulls to undefined: `parentChatId: row.parent_chat_id ?? undefined` **Dates:** -- Format dates with `displayDate` from `src/ui/lib/utils.ts` -- Convert SQLite dates to UTC with `convertDate` before formatting + +- Format dates with `displayDate` from `src/ui/lib/utils.ts` +- Convert SQLite dates to UTC with `convertDate` before formatting **Constraints:** -- Do not use foreign keys or database constraints (difficult to remove later) + +- Do not use foreign keys or database constraints (difficult to remove later) **Promises:** -- ESLint enforces that all promises must be handled (@typescript-eslint/no-floating-promises) + +- ESLint enforces that all promises must be handled (@typescript-eslint/no-floating-promises) **Restricted Features - Require Explicit Permission:** Before using any of these, you MUST ask the user for permission: -- `setTimeout` -- `useImperativeHandle` -- `useRef` -- Type assertions with `as` + +- `setTimeout` +- `useImperativeHandle` +- `useRef` +- Type assertions with `as` ## Troubleshooting @@ -241,6 +271,7 @@ When investigating bugs: **Model Provider Debugging:** To debug requests to model providers (prompt formatting, attachments, tool calls): + ```typescript // Add to ProviderAnthropic.ts or relevant provider console.log(`createParams: ${JSON.stringify(createParams, null, 2)}`); @@ -259,24 +290,27 @@ When you complete a draft implementation, ask the user to test it early and ofte Your role is to write code. When working on features: 1. **Setup:** - - Identify or create GitHub issue - - Checkout main and pull latest - - Create new branch `claude/feature-name` + + - Identify or create GitHub issue + - Checkout main and pull latest + - Create new branch `claude/feature-name` 2. **Development:** - - Ask questions when unclear about implementation approach - - Commit often for easy reversion - - Request user testing early with draft implementations + + - Ask questions when unclear about implementation approach + - Commit often for easy reversion + - Request user testing early with draft implementations 3. **Review:** - - Verify diff with `git diff main` - - Don't worry about manual linting/formatting (hooks handle it) - - Push branch to GitHub - - Open PR with issue number, description, and user-executable test plan + + - Verify diff with `git diff main` + - Don't worry about manual linting/formatting (hooks handle it) + - Push branch to GitHub + - Open PR with issue number, description, and user-executable test plan 4. **Context:** - - If user sends a URL, fetch and read it immediately before doing anything else - - Screenshots are in `screenshots/` directory (may not reflect latest changes) - - SQL schema is in `SQL_SCHEMA.md` (auto-generated reference) + - If user sends a URL, fetch and read it immediately before doing anything else + - Screenshots are in `screenshots/` directory (may not reflect latest changes) + - SQL schema is in `SQL_SCHEMA.md` (auto-generated reference) When asked to start unrelated work, repeat the workflow from the beginning (checkout main, new branch). diff --git a/SQL_SCHEMA.md b/SQL_SCHEMA.md index 553e2f82..98e65121 100644 --- a/SQL_SCHEMA.md +++ b/SQL_SCHEMA.md @@ -6,376 +6,375 @@ Last updated: 2026-01-27 14:55:46 ## Tables -- [app_metadata](#app_metadata) -- [attachments](#attachments) -- [chats](#chats) -- [custom_toolsets](#custom_toolsets) -- [draft_attachments](#draft_attachments) -- [gc_prototype_conductors](#gc_prototype_conductors) -- [gc_prototype_messages](#gc_prototype_messages) -- [message_attachments](#message_attachments) -- [message_drafts](#message_drafts) -- [message_parts](#message_parts) -- [message_sets](#message_sets) -- [messages](#messages) -- [messages_archive_20250102](#messages_archive_20250102) -- [model_configs](#model_configs) -- [models](#models) -- [models_archive_20250111](#models_archive_20250111) -- [project_attachments](#project_attachments) -- [projects](#projects) -- [saved_model_configs_chats](#saved_model_configs_chats) -- [temp_group_parent](#temp_group_parent) -- [temp_groupings](#temp_groupings) -- [temp_hierarchy](#temp_hierarchy) -- [temp_message_sets](#temp_message_sets) -- [tool_permissions](#tool_permissions) -- [toolsets_config](#toolsets_config) +- [app_metadata](#app_metadata) +- [attachments](#attachments) +- [chats](#chats) +- [custom_toolsets](#custom_toolsets) +- [draft_attachments](#draft_attachments) +- [gc_prototype_conductors](#gc_prototype_conductors) +- [gc_prototype_messages](#gc_prototype_messages) +- [message_attachments](#message_attachments) +- [message_drafts](#message_drafts) +- [message_parts](#message_parts) +- [message_sets](#message_sets) +- [messages](#messages) +- [messages_archive_20250102](#messages_archive_20250102) +- [model_configs](#model_configs) +- [models](#models) +- [models_archive_20250111](#models_archive_20250111) +- [project_attachments](#project_attachments) +- [projects](#projects) +- [saved_model_configs_chats](#saved_model_configs_chats) +- [temp_group_parent](#temp_group_parent) +- [temp_groupings](#temp_groupings) +- [temp_hierarchy](#temp_hierarchy) +- [temp_message_sets](#temp_message_sets) +- [tool_permissions](#tool_permissions) +- [toolsets_config](#toolsets_config) ## app_metadata -| Column | Type | Constraints | Default | -|--------|------|-------------|---------| -| key | TEXT | PRIMARY KEY | - | -| value | TEXT | NOT NULL | - | -| created_at | DATETIME | - | CURRENT_TIMESTAMP | +| Column | Type | Constraints | Default | +| ---------- | -------- | ----------- | ----------------- | +| key | TEXT | PRIMARY KEY | - | +| value | TEXT | NOT NULL | - | +| created_at | DATETIME | - | CURRENT_TIMESTAMP | ## attachments -| Column | Type | Constraints | Default | -|--------|------|-------------|---------| -| id | TEXT | PRIMARY KEY | - | -| created_at | DATETIME | NOT NULL | CURRENT_TIMESTAMP | -| type | TEXT | NOT NULL | - | -| is_loading | BOOLEAN | NOT NULL | 0 | -| original_name | TEXT | - | - | -| path | TEXT | NOT NULL | - | -| ephemeral | BOOLEAN | NOT NULL | 0 | +| Column | Type | Constraints | Default | +| ------------- | -------- | ----------- | ----------------- | +| id | TEXT | PRIMARY KEY | - | +| created_at | DATETIME | NOT NULL | CURRENT_TIMESTAMP | +| type | TEXT | NOT NULL | - | +| is_loading | BOOLEAN | NOT NULL | 0 | +| original_name | TEXT | - | - | +| path | TEXT | NOT NULL | - | +| ephemeral | BOOLEAN | NOT NULL | 0 | ## chats -| Column | Type | Constraints | Default | -|--------|------|-------------|---------| -| id | TEXT | NOT NULL PRIMARY KEY | - | -| title | TEXT | - | - | -| created_at | DATETIME | - | CURRENT_TIMESTAMP | -| updated_at | DATETIME | - | - | -| pinned | BOOLEAN | NOT NULL | 0 | -| quick_chat | BOOLEAN | NOT NULL | 0 | -| project_id | TEXT | NOT NULL | 'default' | -| summary | TEXT | - | - | -| is_new_chat | BOOLEAN | NOT NULL | 0 | -| parent_chat_id | TEXT | - | - | -| project_context_summary | TEXT | - | - | -| project_context_summary_is_stale | BOOLEAN | NOT NULL | 1 | -| reply_to_id | TEXT | - | - | -| gc_prototype_chat | BOOLEAN | NOT NULL | 0 | -| total_cost_usd | REAL | - | 0.0 | +| Column | Type | Constraints | Default | +| -------------------------------- | -------- | -------------------- | ----------------- | +| id | TEXT | NOT NULL PRIMARY KEY | - | +| title | TEXT | - | - | +| created_at | DATETIME | - | CURRENT_TIMESTAMP | +| updated_at | DATETIME | - | - | +| pinned | BOOLEAN | NOT NULL | 0 | +| quick_chat | BOOLEAN | NOT NULL | 0 | +| project_id | TEXT | NOT NULL | 'default' | +| summary | TEXT | - | - | +| is_new_chat | BOOLEAN | NOT NULL | 0 | +| parent_chat_id | TEXT | - | - | +| project_context_summary | TEXT | - | - | +| project_context_summary_is_stale | BOOLEAN | NOT NULL | 1 | +| reply_to_id | TEXT | - | - | +| gc_prototype_chat | BOOLEAN | NOT NULL | 0 | +| total_cost_usd | REAL | - | 0.0 | ### Indices -- **idx_chats_is_new_chat** - - Columns: is_new_chat -- **idx_chats_pinned** - - Columns: pinned -- **idx_chats_project_cost** - - Columns: project_id, total_cost_usd +- **idx_chats_is_new_chat** + - Columns: is_new_chat +- **idx_chats_pinned** + - Columns: pinned +- **idx_chats_project_cost** + - Columns: project_id, total_cost_usd ## custom_toolsets -| Column | Type | Constraints | Default | -|--------|------|-------------|---------| -| name | TEXT | PRIMARY KEY | - | -| command | TEXT | - | - | -| args | TEXT | - | - | -| env | JSON | - | - | -| updated_at | DATETIME | - | CURRENT_TIMESTAMP | -| default_permission | TEXT | NOT NULL | 'ask' | +| Column | Type | Constraints | Default | +| ------------------ | -------- | ----------- | ----------------- | +| name | TEXT | PRIMARY KEY | - | +| command | TEXT | - | - | +| args | TEXT | - | - | +| env | JSON | - | - | +| updated_at | DATETIME | - | CURRENT_TIMESTAMP | +| default_permission | TEXT | NOT NULL | 'ask' | ## draft_attachments -| Column | Type | Constraints | Default | -|--------|------|-------------|---------| -| chat_id | TEXT | NOT NULL PRIMARY KEY | - | -| attachment_id | TEXT | NOT NULL PRIMARY KEY | - | +| Column | Type | Constraints | Default | +| ------------- | ---- | -------------------- | ------- | +| chat_id | TEXT | NOT NULL PRIMARY KEY | - | +| attachment_id | TEXT | NOT NULL PRIMARY KEY | - | ## gc_prototype_conductors -| Column | Type | Constraints | Default | -|--------|------|-------------|---------| -| chat_id | TEXT | NOT NULL PRIMARY KEY | - | -| scope_id | TEXT | PRIMARY KEY | - | -| conductor_model_id | TEXT | NOT NULL | - | -| turn_count | INTEGER | - | 0 | -| is_active | BOOLEAN | - | 1 | -| created_at | DATETIME | - | CURRENT_TIMESTAMP | +| Column | Type | Constraints | Default | +| ------------------ | -------- | -------------------- | ----------------- | +| chat_id | TEXT | NOT NULL PRIMARY KEY | - | +| scope_id | TEXT | PRIMARY KEY | - | +| conductor_model_id | TEXT | NOT NULL | - | +| turn_count | INTEGER | - | 0 | +| is_active | BOOLEAN | - | 1 | +| created_at | DATETIME | - | CURRENT_TIMESTAMP | ### Indices -- **idx_gc_prototype_conductors_active** - - Columns: chat_id, is_active +- **idx_gc_prototype_conductors_active** + - Columns: chat_id, is_active ## gc_prototype_messages -| Column | Type | Constraints | Default | -|--------|------|-------------|---------| -| chat_id | TEXT | NOT NULL PRIMARY KEY | - | -| id | TEXT | NOT NULL PRIMARY KEY | - | -| text | TEXT | NOT NULL | - | -| model_config_id | TEXT | NOT NULL | - | -| created_at | DATETIME | - | CURRENT_TIMESTAMP | -| updated_at | DATETIME | - | CURRENT_TIMESTAMP | -| is_deleted | BOOLEAN | NOT NULL | 0 | -| thread_root_message_id | TEXT | - | - | -| promoted_from_message_id | TEXT | - | - | +| Column | Type | Constraints | Default | +| ------------------------ | -------- | -------------------- | ----------------- | +| chat_id | TEXT | NOT NULL PRIMARY KEY | - | +| id | TEXT | NOT NULL PRIMARY KEY | - | +| text | TEXT | NOT NULL | - | +| model_config_id | TEXT | NOT NULL | - | +| created_at | DATETIME | - | CURRENT_TIMESTAMP | +| updated_at | DATETIME | - | CURRENT_TIMESTAMP | +| is_deleted | BOOLEAN | NOT NULL | 0 | +| thread_root_message_id | TEXT | - | - | +| promoted_from_message_id | TEXT | - | - | ### Indices -- **idx_gc_prototype_messages_chat_created** - - Columns: chat_id, created_at -- **idx_gc_prototype_messages_promoted_from** - - Columns: promoted_from_message_id -- **idx_gc_prototype_messages_thread_root** - - Columns: thread_root_message_id +- **idx_gc_prototype_messages_chat_created** + - Columns: chat_id, created_at +- **idx_gc_prototype_messages_promoted_from** + - Columns: promoted_from_message_id +- **idx_gc_prototype_messages_thread_root** + - Columns: thread_root_message_id ## message_attachments -| Column | Type | Constraints | Default | -|--------|------|-------------|---------| -| message_id | TEXT | NOT NULL PRIMARY KEY | - | -| attachment_id | TEXT | NOT NULL PRIMARY KEY | - | +| Column | Type | Constraints | Default | +| ------------- | ---- | -------------------- | ------- | +| message_id | TEXT | NOT NULL PRIMARY KEY | - | +| attachment_id | TEXT | NOT NULL PRIMARY KEY | - | ## message_drafts -| Column | Type | Constraints | Default | -|--------|------|-------------|---------| -| chat_id | TEXT | PRIMARY KEY | - | -| content | TEXT | NOT NULL | - | +| Column | Type | Constraints | Default | +| ------- | ---- | ----------- | ------- | +| chat_id | TEXT | PRIMARY KEY | - | +| content | TEXT | NOT NULL | - | ## message_parts -| Column | Type | Constraints | Default | -|--------|------|-------------|---------| -| chat_id | TEXT | NOT NULL | - | -| message_id | TEXT | NOT NULL PRIMARY KEY | - | -| level | INTEGER | NOT NULL PRIMARY KEY | - | -| content | TEXT | NOT NULL | - | -| tool_calls | TEXT | - | - | -| tool_results | TEXT | - | - | +| Column | Type | Constraints | Default | +| ------------ | ------- | -------------------- | ------- | +| chat_id | TEXT | NOT NULL | - | +| message_id | TEXT | NOT NULL PRIMARY KEY | - | +| level | INTEGER | NOT NULL PRIMARY KEY | - | +| content | TEXT | NOT NULL | - | +| tool_calls | TEXT | - | - | +| tool_results | TEXT | - | - | ## message_sets -| Column | Type | Constraints | Default | -|--------|------|-------------|---------| -| id | TEXT | PRIMARY KEY | - | -| chat_id | TEXT | NOT NULL | - | -| deprecated_parent_id | TEXT | - | - | -| type | TEXT | NOT NULL | - | -| created_at | DATETIME | - | CURRENT_TIMESTAMP | -| selected_block_type | TEXT | NOT NULL | 'chat' | -| level | INTEGER | - | - | +| Column | Type | Constraints | Default | +| -------------------- | -------- | ----------- | ----------------- | +| id | TEXT | PRIMARY KEY | - | +| chat_id | TEXT | NOT NULL | - | +| deprecated_parent_id | TEXT | - | - | +| type | TEXT | NOT NULL | - | +| created_at | DATETIME | - | CURRENT_TIMESTAMP | +| selected_block_type | TEXT | NOT NULL | 'chat' | +| level | INTEGER | - | - | ### Indices -- **idx_message_sets_chat_level** - - Columns: chat_id, level +- **idx_message_sets_chat_level** + - Columns: chat_id, level ## messages -| Column | Type | Constraints | Default | -|--------|------|-------------|---------| -| id | TEXT | PRIMARY KEY | - | -| message_set_id | TEXT | NOT NULL | - | -| chat_id | TEXT | NOT NULL | - | -| text | TEXT | NOT NULL | - | -| model | TEXT | NOT NULL | - | -| selected | BOOLEAN | - | - | -| created_at | DATETIME | - | CURRENT_TIMESTAMP | -| streaming_token | TEXT | - | - | -| state | TEXT | - | 'streaming' | -| error_message | TEXT | - | - | -| is_review | BOOLEAN | - | 0 | -| review_state | TEXT | - | - | -| block_type | TEXT | - | - | -| level | INTEGER | - | - | -| dep_attachments_archive | TEXT | - | - | -| reply_chat_id | TEXT | - | - | -| branched_from_id | TEXT | - | - | -| prompt_tokens | INTEGER | - | - | -| completion_tokens | INTEGER | - | - | -| total_tokens | INTEGER | - | - | -| cost_usd | REAL | - | - | +| Column | Type | Constraints | Default | +| ----------------------- | -------- | ----------- | ----------------- | +| id | TEXT | PRIMARY KEY | - | +| message_set_id | TEXT | NOT NULL | - | +| chat_id | TEXT | NOT NULL | - | +| text | TEXT | NOT NULL | - | +| model | TEXT | NOT NULL | - | +| selected | BOOLEAN | - | - | +| created_at | DATETIME | - | CURRENT_TIMESTAMP | +| streaming_token | TEXT | - | - | +| state | TEXT | - | 'streaming' | +| error_message | TEXT | - | - | +| is_review | BOOLEAN | - | 0 | +| review_state | TEXT | - | - | +| block_type | TEXT | - | - | +| level | INTEGER | - | - | +| dep_attachments_archive | TEXT | - | - | +| reply_chat_id | TEXT | - | - | +| branched_from_id | TEXT | - | - | +| prompt_tokens | INTEGER | - | - | +| completion_tokens | INTEGER | - | - | +| total_tokens | INTEGER | - | - | +| cost_usd | REAL | - | - | ### Indices -- **idx_messages_chat_cost** - - Columns: chat_id, cost_usd +- **idx_messages_chat_cost** + - Columns: chat_id, cost_usd ## messages_archive_20250102 -| Column | Type | Constraints | Default | -|--------|------|-------------|---------| -| id | TEXT | PRIMARY KEY | - | -| chat_id | TEXT | NOT NULL | - | -| parent_id | TEXT | - | - | -| text | TEXT | NOT NULL | - | -| model | TEXT | NOT NULL | - | -| selected | BOOLEAN | - | - | -| created_at | DATETIME | - | CURRENT_TIMESTAMP | -| attachments | TEXT | - | - | +| Column | Type | Constraints | Default | +| ----------- | -------- | ----------- | ----------------- | +| id | TEXT | PRIMARY KEY | - | +| chat_id | TEXT | NOT NULL | - | +| parent_id | TEXT | - | - | +| text | TEXT | NOT NULL | - | +| model | TEXT | NOT NULL | - | +| selected | BOOLEAN | - | - | +| created_at | DATETIME | - | CURRENT_TIMESTAMP | +| attachments | TEXT | - | - | ### Indices -- **idx_messages_attachments** - - Columns: (json_valid(attachments +- **idx_messages_attachments** + - Columns: (json_valid(attachments ## model_configs -| Column | Type | Constraints | Default | -|--------|------|-------------|---------| -| id | TEXT | PRIMARY KEY | - | -| model_id | TEXT | NOT NULL | - | -| display_name | TEXT | NOT NULL | - | -| author | TEXT | NOT NULL | - | -| created_at | DATETIME | - | CURRENT_TIMESTAMP | -| system_prompt | TEXT | NOT NULL | - | -| is_default | BOOLEAN | - | 0 | -| budget_tokens | INTEGER | - | - | -| reasoning_effort | TEXT | - | - | -| new_until | DATETIME | - | - | -| thinking_level | TEXT | - | - | +| Column | Type | Constraints | Default | +| ---------------- | -------- | ----------- | ----------------- | +| id | TEXT | PRIMARY KEY | - | +| model_id | TEXT | NOT NULL | - | +| display_name | TEXT | NOT NULL | - | +| author | TEXT | NOT NULL | - | +| created_at | DATETIME | - | CURRENT_TIMESTAMP | +| system_prompt | TEXT | NOT NULL | - | +| is_default | BOOLEAN | - | 0 | +| budget_tokens | INTEGER | - | - | +| reasoning_effort | TEXT | - | - | +| new_until | DATETIME | - | - | +| thinking_level | TEXT | - | - | ## models -| Column | Type | Constraints | Default | -|--------|------|-------------|---------| -| id | TEXT | PRIMARY KEY | - | -| display_name | TEXT | NOT NULL | - | -| is_enabled | BOOLEAN | - | 1 | -| supported_attachment_types | TEXT | NOT NULL | - | -| is_internal | BOOLEAN | NOT NULL | 0 | -| is_deprecated | BOOLEAN | NOT NULL | 0 | -| prompt_price_per_token | REAL | - | - | -| completion_price_per_token | REAL | - | - | +| Column | Type | Constraints | Default | +| -------------------------- | ------- | ----------- | ------- | +| id | TEXT | PRIMARY KEY | - | +| display_name | TEXT | NOT NULL | - | +| is_enabled | BOOLEAN | - | 1 | +| supported_attachment_types | TEXT | NOT NULL | - | +| is_internal | BOOLEAN | NOT NULL | 0 | +| is_deprecated | BOOLEAN | NOT NULL | 0 | +| prompt_price_per_token | REAL | - | - | +| completion_price_per_token | REAL | - | - | ## models_archive_20250111 -| Column | Type | Constraints | Default | -|--------|------|-------------|---------| -| id | TEXT | PRIMARY KEY | - | -| name | TEXT | NOT NULL | - | -| type | TEXT | NOT NULL | - | -| api_key | TEXT | - | - | -| model_id | TEXT | NOT NULL | - | -| system_prompt | TEXT | - | - | -| request_template | JSON | NOT NULL | - | -| created_at | DATETIME | - | CURRENT_TIMESTAMP | -| is_enabled | BOOLEAN | - | 1 | -| display_order | INTEGER | - | - | -| is_selected | BOOLEAN | - | 0 | -| short_name | TEXT | - | - | -| server_url | TEXT | - | - | +| Column | Type | Constraints | Default | +| ---------------- | -------- | ----------- | ----------------- | +| id | TEXT | PRIMARY KEY | - | +| name | TEXT | NOT NULL | - | +| type | TEXT | NOT NULL | - | +| api_key | TEXT | - | - | +| model_id | TEXT | NOT NULL | - | +| system_prompt | TEXT | - | - | +| request_template | JSON | NOT NULL | - | +| created_at | DATETIME | - | CURRENT_TIMESTAMP | +| is_enabled | BOOLEAN | - | 1 | +| display_order | INTEGER | - | - | +| is_selected | BOOLEAN | - | 0 | +| short_name | TEXT | - | - | +| server_url | TEXT | - | - | ## project_attachments -| Column | Type | Constraints | Default | -|--------|------|-------------|---------| -| project_id | TEXT | NOT NULL PRIMARY KEY | - | -| attachment_id | TEXT | NOT NULL PRIMARY KEY | - | +| Column | Type | Constraints | Default | +| ------------- | ---- | -------------------- | ------- | +| project_id | TEXT | NOT NULL PRIMARY KEY | - | +| attachment_id | TEXT | NOT NULL PRIMARY KEY | - | ## projects -| Column | Type | Constraints | Default | -|--------|------|-------------|---------| -| id | TEXT | PRIMARY KEY | - | -| name | TEXT | NOT NULL | - | -| created_at | DATETIME | NOT NULL | CURRENT_TIMESTAMP | -| updated_at | DATETIME | NOT NULL | CURRENT_TIMESTAMP | -| is_collapsed | BOOLEAN | NOT NULL | 0 | -| context_text | TEXT | - | - | -| magic_projects_enabled | BOOLEAN | NOT NULL | 1 | -| is_imported | BOOLEAN | NOT NULL | 0 | -| total_cost_usd | REAL | - | 0.0 | +| Column | Type | Constraints | Default | +| ---------------------- | -------- | ----------- | ----------------- | +| id | TEXT | PRIMARY KEY | - | +| name | TEXT | NOT NULL | - | +| created_at | DATETIME | NOT NULL | CURRENT_TIMESTAMP | +| updated_at | DATETIME | NOT NULL | CURRENT_TIMESTAMP | +| is_collapsed | BOOLEAN | NOT NULL | 0 | +| context_text | TEXT | - | - | +| magic_projects_enabled | BOOLEAN | NOT NULL | 1 | +| is_imported | BOOLEAN | NOT NULL | 0 | +| total_cost_usd | REAL | - | 0.0 | ## saved_model_configs_chats -| Column | Type | Constraints | Default | -|--------|------|-------------|---------| -| id | TEXT | NOT NULL PRIMARY KEY | - | -| chat_id | TEXT | - | - | -| model_ids | TEXT | NOT NULL | - | -| created_at | DATETIME | - | CURRENT_TIMESTAMP | -| updated_at | DATETIME | - | CURRENT_TIMESTAMP | +| Column | Type | Constraints | Default | +| ---------- | -------- | -------------------- | ----------------- | +| id | TEXT | NOT NULL PRIMARY KEY | - | +| chat_id | TEXT | - | - | +| model_ids | TEXT | NOT NULL | - | +| created_at | DATETIME | - | CURRENT_TIMESTAMP | +| updated_at | DATETIME | - | CURRENT_TIMESTAMP | ### Indices -- **idx_saved_model_configs_chats_chat_id** - - Columns: chat_id +- **idx_saved_model_configs_chats_chat_id** + - Columns: chat_id ## temp_group_parent -| Column | Type | Constraints | Default | -|--------|------|-------------|---------| -| group_key | | - | - | -| chat_id | TEXT | - | - | -| parent_id | TEXT | - | - | -| type | | - | - | -| level | | - | - | -| parent_group_key | | - | - | +| Column | Type | Constraints | Default | +| ---------------- | ---- | ----------- | ------- | +| group_key | | - | - | +| chat_id | TEXT | - | - | +| parent_id | TEXT | - | - | +| type | | - | - | +| level | | - | - | +| parent_group_key | | - | - | ## temp_groupings -| Column | Type | Constraints | Default | -|--------|------|-------------|---------| -| chat_id | TEXT | - | - | -| parent_id | TEXT | - | - | -| type | | - | - | -| level | | - | - | -| group_key | | - | - | +| Column | Type | Constraints | Default | +| --------- | ---- | ----------- | ------- | +| chat_id | TEXT | - | - | +| parent_id | TEXT | - | - | +| type | | - | - | +| level | | - | - | +| group_key | | - | - | ## temp_hierarchy -| Column | Type | Constraints | Default | -|--------|------|-------------|---------| -| id | TEXT | - | - | -| chat_id | TEXT | - | - | -| parent_id | TEXT | - | - | -| text | TEXT | - | - | -| model | TEXT | - | - | -| attachments | TEXT | - | - | -| has_children | | - | - | -| level | | - | - | -| created_at | NUM | - | - | +| Column | Type | Constraints | Default | +| ------------ | ---- | ----------- | ------- | +| id | TEXT | - | - | +| chat_id | TEXT | - | - | +| parent_id | TEXT | - | - | +| text | TEXT | - | - | +| model | TEXT | - | - | +| attachments | TEXT | - | - | +| has_children | | - | - | +| level | | - | - | +| created_at | NUM | - | - | ## temp_message_sets -| Column | Type | Constraints | Default | -|--------|------|-------------|---------| -| group_key | TEXT | PRIMARY KEY | - | -| message_set_id | TEXT | - | - | -| chat_id | TEXT | NOT NULL | - | -| parent_group_key | TEXT | - | - | -| parent_message_set_id | TEXT | - | - | -| type | TEXT | NOT NULL | - | -| level | INT | NOT NULL | - | +| Column | Type | Constraints | Default | +| --------------------- | ---- | ----------- | ------- | +| group_key | TEXT | PRIMARY KEY | - | +| message_set_id | TEXT | - | - | +| chat_id | TEXT | NOT NULL | - | +| parent_group_key | TEXT | - | - | +| parent_message_set_id | TEXT | - | - | +| type | TEXT | NOT NULL | - | +| level | INT | NOT NULL | - | ## tool_permissions -| Column | Type | Constraints | Default | -|--------|------|-------------|---------| -| toolset_name | TEXT | NOT NULL PRIMARY KEY | - | -| tool_name | TEXT | NOT NULL PRIMARY KEY | - | -| permission_type | TEXT | NOT NULL | - | -| last_asked_at | DATETIME | - | - | -| last_response | TEXT | - | - | -| created_at | DATETIME | - | CURRENT_TIMESTAMP | -| updated_at | DATETIME | - | CURRENT_TIMESTAMP | +| Column | Type | Constraints | Default | +| --------------- | -------- | -------------------- | ----------------- | +| toolset_name | TEXT | NOT NULL PRIMARY KEY | - | +| tool_name | TEXT | NOT NULL PRIMARY KEY | - | +| permission_type | TEXT | NOT NULL | - | +| last_asked_at | DATETIME | - | - | +| last_response | TEXT | - | - | +| created_at | DATETIME | - | CURRENT_TIMESTAMP | +| updated_at | DATETIME | - | CURRENT_TIMESTAMP | ## toolsets_config -| Column | Type | Constraints | Default | -|--------|------|-------------|---------| -| toolset_name | TEXT | PRIMARY KEY | - | -| parameter_id | TEXT | PRIMARY KEY | - | -| parameter_value | TEXT | - | - | - +| Column | Type | Constraints | Default | +| --------------- | ---- | ----------- | ------- | +| toolset_name | TEXT | PRIMARY KEY | - | +| parameter_id | TEXT | PRIMARY KEY | - | +| parameter_value | TEXT | - | - | diff --git a/src/core/chorus/Models.ts b/src/core/chorus/Models.ts index dcf4891a..ddb94d2d 100644 --- a/src/core/chorus/Models.ts +++ b/src/core/chorus/Models.ts @@ -528,7 +528,9 @@ export async function downloadOpenAIModels( ); if (!apiKeys.openai) { - console.log("No OpenAI API key configured, skipping model download"); + console.log( + "No OpenAI API key configured, skipping model download", + ); return; } @@ -641,7 +643,12 @@ export async function downloadAnthropicModels( { id: `anthropic::${model.id}`, displayName: model.display_name, - supportedAttachmentTypes: ["text", "image", "pdf", "webpage"], + supportedAttachmentTypes: [ + "text", + "image", + "pdf", + "webpage", + ], isEnabled: true, isInternal: false, }, @@ -670,7 +677,9 @@ export async function downloadGoogleModels( ); if (!apiKeys.google) { - console.log("No Google API key configured, skipping model download"); + console.log( + "No Google API key configured, skipping model download", + ); return; } diff --git a/src/ui/components/ThinkingParamsButton.tsx b/src/ui/components/ThinkingParamsButton.tsx index e26885ce..394659e9 100644 --- a/src/ui/components/ThinkingParamsButton.tsx +++ b/src/ui/components/ThinkingParamsButton.tsx @@ -1,11 +1,7 @@ import { useState } from "react"; import { Brain, ChevronDown } from "lucide-react"; import { Button } from "./ui/button"; -import { - Popover, - PopoverContent, - PopoverTrigger, -} from "./ui/popover"; +import { Popover, PopoverContent, PopoverTrigger } from "./ui/popover"; import { Label } from "./ui/label"; import { Select, @@ -127,7 +123,10 @@ export function ThinkingParamsButton({ {showReasoningEffort && (
-
)} - -
- - -
From 843d4f81d838660c46eb4b95159847eb174f8a8a Mon Sep 17 00:00:00 2001 From: hrayleung Date: Tue, 27 Jan 2026 22:42:29 +0800 Subject: [PATCH 09/27] feat: Add custom provider support and refactor model management - Add custom Anthropic and OpenAI provider implementations - Add Google Vertex AI provider support - Extract Anthropic models to separate configuration file - Add ProvidersTab component for provider management - Remove Firecrawl dependency and WebTools.ts - Refactor model providers to support dynamic model fetching - Update attachment helpers to handle provider-specific formats - Improve settings UI for provider configuration Co-Authored-By: Claude Sonnet 4.5 --- package.json | 1 - pnpm-lock.yaml | 17 - src/core/chorus/AttachmentsHelpers.ts | 94 ++- .../ModelProviders/ProviderAnthropic.ts | 168 ++--- .../ModelProviders/ProviderCustomAnthropic.ts | 75 ++ .../ModelProviders/ProviderCustomOpenAI.ts | 203 +++++ .../chorus/ModelProviders/ProviderGoogle.ts | 312 +++++++- .../chorus/ModelProviders/ProviderGrok.ts | 191 ++++- .../chorus/ModelProviders/ProviderOpenAI.ts | 48 +- .../chorus/ModelProviders/ProviderVertex.ts | 405 ++++++++++ .../chorus/ModelProviders/anthropicModels.ts | 100 +++ src/core/chorus/Models.ts | 162 +++- src/core/chorus/WebTools.ts | 258 ------- src/core/chorus/api/MessageAPI.ts | 4 + src/core/chorus/api/ModelsAPI.ts | 4 + src/core/chorus/toolsets/web.ts | 102 +-- src/core/utilities/Settings.ts | 31 +- src/ui/App.tsx | 4 +- src/ui/components/ApiKeysForm.tsx | 20 +- src/ui/components/ListPrompts.tsx | 4 +- src/ui/components/ManageModelsBox.tsx | 129 +++- src/ui/components/ProvidersTab.tsx | 703 ++++++++++++++++++ src/ui/components/Settings.tsx | 18 +- src/ui/components/ThinkingParamsButton.tsx | 139 +++- src/ui/components/ToolsBox.tsx | 114 +++ src/ui/components/ui/provider-logo.tsx | 5 + src/ui/hooks/useAttachments.ts | 28 +- 27 files changed, 2736 insertions(+), 603 deletions(-) create mode 100644 src/core/chorus/ModelProviders/ProviderCustomAnthropic.ts create mode 100644 src/core/chorus/ModelProviders/ProviderCustomOpenAI.ts create mode 100644 src/core/chorus/ModelProviders/ProviderVertex.ts create mode 100644 src/core/chorus/ModelProviders/anthropicModels.ts delete mode 100644 src/core/chorus/WebTools.ts create mode 100644 src/ui/components/ProvidersTab.tsx diff --git a/package.json b/package.json index 36524bd5..5b4155cd 100644 --- a/package.json +++ b/package.json @@ -40,7 +40,6 @@ "@google/genai": "^0.8.0", "@google/generative-ai": "^0.21.0", "@hello-pangea/dnd": "^17.0.0", - "@mendable/firecrawl-js": "1.18.2", "@modelcontextprotocol/sdk": "^1.10.2", "@octokit/rest": "^21.1.1", "@posthog/ai": "^3.3.2", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 6647e9fe..982e5f05 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -26,9 +26,6 @@ importers: '@hello-pangea/dnd': specifier: ^17.0.0 version: 17.0.0(@types/react@18.3.27)(react-dom@18.3.1)(react@18.3.1) - '@mendable/firecrawl-js': - specifier: 1.18.2 - version: 1.18.2(ws@8.18.3) '@modelcontextprotocol/sdk': specifier: ^1.10.2 version: 1.24.3(zod@4.2.0) @@ -1879,9 +1876,6 @@ packages: '@mediapipe/tasks-vision@0.10.17': resolution: {integrity: sha512-CZWV/q6TTe8ta61cZXjfnnHsfWIdFhms03M9T7Cnd5y2mdpylJM0rF1qRq+wsQVRMLz1OYPVEBU9ph2Bx8cxrg==} - '@mendable/firecrawl-js@1.18.2': - resolution: {integrity: sha512-KkTm5iFI8v1p50xsxFclHNt54XkS9qDYbkGIyc63eBGW8vnJZwc/mmgf+RFBOrQOfMZIQnAAfbbMNUJsdKl3AQ==} - '@mixmark-io/domino@2.2.0': resolution: {integrity: sha512-Y28PR25bHXUg88kCV7nivXrP2Nj2RueZ3/l/jdx6J9f8J4nsEGcgX0Qe6lt7Pa+J79+kPiJU3LguR6O/6zrLOw==} @@ -14782,17 +14776,6 @@ snapshots: '@mediapipe/tasks-vision@0.10.17': {} - '@mendable/firecrawl-js@1.18.2(ws@8.18.3)': - dependencies: - axios: 1.13.2 - isows: 1.0.7(ws@8.18.3) - typescript-event-target: 1.1.1 - zod: 3.25.76 - zod-to-json-schema: 3.25.0(zod@3.25.76) - transitivePeerDependencies: - - debug - - ws - '@mixmark-io/domino@2.2.0': {} '@modelcontextprotocol/sdk@1.24.3(zod@4.2.0)': diff --git a/src/core/chorus/AttachmentsHelpers.ts b/src/core/chorus/AttachmentsHelpers.ts index 0a4b878f..8a6fb93c 100644 --- a/src/core/chorus/AttachmentsHelpers.ts +++ b/src/core/chorus/AttachmentsHelpers.ts @@ -2,13 +2,13 @@ import { appDataDir } from "@tauri-apps/api/path"; import { mkdir, readFile } from "@tauri-apps/plugin-fs"; import { allowedExtensions, AttachmentType } from "@core/chorus/Models"; import { v4 as uuidv4 } from "uuid"; -import FirecrawlApp from "@mendable/firecrawl-js"; import { fileTypeFromBuffer } from "file-type"; import path from "path"; import mime from "mime-types"; import { invoke } from "@tauri-apps/api/core"; import * as pdfjsLib from "pdfjs-dist/legacy/build/pdf.mjs"; import { Attachment } from "./api/AttachmentsAPI"; +import { fetch as tauriFetch } from "@tauri-apps/plugin-http"; // Initialize PDF.js worker pdfjsLib.GlobalWorkerOptions.workerSrc = new URL( @@ -18,13 +18,10 @@ pdfjsLib.GlobalWorkerOptions.workerSrc = new URL( export const MAX_ATTACHMENTS = 10; export const MAX_SCRAPES_PER_MINUTE = 10; +const MAX_SCRAPE_CHARS = 200_000; // This should match TARGET_SIZE_BYTES in src-tauri/src/command.rs (3.5MB) export const TARGET_IMAGE_SIZE_BYTES = 4.5 * 1024 * 1024; // 4.5MB in bytes -// Create FirecrawlApp instance with provided API key -export const createFirecrawlClient = (apiKey: string) => - new FirecrawlApp({ apiKey }); - // Add rate limiting tracker export const scrapeTimestamps: number[] = []; @@ -303,32 +300,83 @@ export const getScreenshotAttachment = async ( export async function scrapeUrlAndWriteToPath( url: string, path: string, - firecrawlApiKey?: string, ): Promise<{ success: boolean; error?: string }> { - if (!firecrawlApiKey) { - return { success: false, error: "Firecrawl API key not configured" }; - } + const normalizeUrl = (raw: string): string => { + const trimmed = raw.trim(); + if (!trimmed) { + throw new Error("Missing URL"); + } + if (/^https?:\/\//i.test(trimmed)) { + return trimmed; + } + return `https://${trimmed}`; + }; + + const extractTextFromHtml = ( + html: string, + ): { title?: string; text: string } => { + try { + const doc = new DOMParser().parseFromString(html, "text/html"); + const title = doc.querySelector("title")?.textContent?.trim(); + doc.querySelectorAll("script, style, noscript").forEach((el) => + el.remove(), + ); + const rawText = + doc.body?.textContent || doc.documentElement.textContent || ""; + + const cleaned = rawText + .replace(/\r\n/g, "\n") + .replace(/[ \t\u00A0]+/g, " ") + .replace(/\n{3,}/g, "\n\n") + .trim(); + + return { title: title || undefined, text: cleaned }; + } catch { + return { text: html }; + } + }; + + const truncate = (text: string) => { + if (text.length <= MAX_SCRAPE_CHARS) return text; + return `${text.slice(0, MAX_SCRAPE_CHARS)}\n\n[truncated]`; + }; try { - const firecrawl = createFirecrawlClient(firecrawlApiKey); - const mockScrapeAPI = false; - const scrapeResult = mockScrapeAPI - ? { - success: true as const, - markdown: `test ${url}`, - } - : await firecrawl.scrapeUrl(url, { - formats: ["markdown"], - }); - - if (!scrapeResult.success) { + const normalizedUrl = normalizeUrl(url); + const response = await tauriFetch(normalizedUrl, { + method: "GET", + headers: { + Accept: "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8", + "User-Agent": "Chorus", + }, + maxRedirections: 5, + connectTimeout: 20_000, + }); + + if (!response.ok) { + const errorText = await response.text().catch(() => ""); throw new Error( - `Failed to scrape: ${"error" in scrapeResult ? scrapeResult.error : "Unknown error"}`, + `HTTP ${response.status}: ${errorText || response.statusText}`, ); } + const contentType = response.headers.get("content-type") || ""; + const bodyText = await response.text(); + + const trimmedStart = bodyText.trim().slice(0, 64).toLowerCase(); + const isHtml = + contentType.includes("html") || + trimmedStart.startsWith(" m.inputModelName === modelName, - ); - // If not found in hardcoded list, return the model name as-is - // (supports dynamically fetched models from API) - return modelConfig?.anthropicModelName ?? modelName; -} - export class ProviderAnthropic implements IProvider { async streamResponse({ modelConfig, @@ -101,6 +43,7 @@ export class ProviderAnthropic implements IProvider { onError, additionalHeaders, tools, + enabledToolsets, customBaseUrl, }: StreamResponseParams) { const modelName = modelConfig.modelId.split("::")[1]; @@ -119,7 +62,18 @@ export class ProviderAnthropic implements IProvider { const messages = await convertConversationToAnthropic(llmConversation); - const isThinking = modelConfig.budgetTokens !== undefined; + const maxTokens = getAnthropicMaxTokens(modelName); + const thinkingBudgetTokens = + modelConfig.budgetTokens !== undefined + ? clampAnthropicThinkingBudgetTokens({ + budgetTokens: modelConfig.budgetTokens, + maxTokens, + }) + : undefined; + + const isThinking = thinkingBudgetTokens !== undefined; + const nativeWebSearchEnabled = enabledToolsets?.includes("web") ?? false; + const shouldUseNativeWebSearch = nativeWebSearchEnabled; // Map tools to Claude's tool format const anthropicTools: Anthropic.Messages.Tool[] | undefined = tools @@ -156,38 +110,79 @@ export class ProviderAnthropic implements IProvider { messages, system: modelConfig.systemPrompt, stream: true, - max_tokens: getMaxTokens(modelName), - ...(isThinking && { + max_tokens: maxTokens, + ...(thinkingBudgetTokens !== undefined && { thinking: { type: "enabled", - budget_tokens: modelConfig.budgetTokens, + budget_tokens: thinkingBudgetTokens, }, }), - ...(tools && - tools.length > 0 && { - tools: anthropicTools, - }), }; + const requestTools: Anthropic.Messages.Tool[] = []; + if (shouldUseNativeWebSearch) { + requestTools.push({ + type: "web_search_20250305", + name: "web_search", + } as unknown as Anthropic.Messages.Tool); + } + if (anthropicTools && anthropicTools.length > 0) { + requestTools.push(...anthropicTools); + } + if (requestTools.length > 0) { + createParams.tools = requestTools; + } + // Debug: Log thinking parameters console.log(`[ProviderAnthropic] Model: ${anthropicModelName}`); console.log(`[ProviderAnthropic] isThinking: ${isThinking}`); - console.log(`[ProviderAnthropic] modelConfig.budgetTokens: ${modelConfig.budgetTokens}`); - console.log(`[ProviderAnthropic] createParams.thinking:`, (createParams as unknown as Record).thinking); + console.log( + `[ProviderAnthropic] modelConfig.budgetTokens: ${modelConfig.budgetTokens}`, + ); + if ( + modelConfig.budgetTokens !== undefined && + thinkingBudgetTokens !== undefined && + modelConfig.budgetTokens !== thinkingBudgetTokens + ) { + console.warn( + `[ProviderAnthropic] Clamped thinking budget_tokens from ${modelConfig.budgetTokens} to ${thinkingBudgetTokens} (max_tokens=${maxTokens}).`, + ); + } + console.log( + `[ProviderAnthropic] createParams.thinking:`, + (createParams as unknown as Record).thinking, + ); // Configure headers const headers: Record = { ...(additionalHeaders ?? {}), }; + const anthropicBetaHeaderValue = shouldUseNativeWebSearch + ? mergeAnthropicBetaHeader( + headers["anthropic-beta"], + "web-search-2025-03-05", + ) + : undefined; + const client = new Anthropic({ apiKey: apiKeys.anthropic, baseURL: customBaseUrl, + fetch: tauriFetch, dangerouslyAllowBrowser: true, defaultHeaders: headers, }); - const stream = client.messages.stream(createParams); + const stream = client.messages.stream( + createParams, + anthropicBetaHeaderValue + ? { + headers: { + "anthropic-beta": anthropicBetaHeaderValue, + }, + } + : undefined, + ); stream.on("error", (error) => { console.error( @@ -234,6 +229,20 @@ export class ProviderAnthropic implements IProvider { } } +function mergeAnthropicBetaHeader( + existingValue: string | undefined, + betaToAdd: string, +): string { + const betas = (existingValue ?? "") + .split(",") + .map((v) => v.trim()) + .filter(Boolean); + if (!betas.includes(betaToAdd)) { + betas.push(betaToAdd); + } + return betas.join(", "); +} + async function formatMessageWithAttachments( message: LLMMessage, ): Promise { @@ -401,15 +410,6 @@ async function formatMessageWithAttachments( }; } -const getMaxTokens = (modelId: string) => { - const modelConfig = ANTHROPIC_MODELS.find( - (m) => m.inputModelName === modelId, - ); - // Return default max tokens if model not found in hardcoded list - // (supports dynamically fetched models from API) - return modelConfig?.maxTokens ?? 8192; -}; - /** * Adds cache control block to the last message in `messages` * that contains attachments (per the hasAttachments flag). diff --git a/src/core/chorus/ModelProviders/ProviderCustomAnthropic.ts b/src/core/chorus/ModelProviders/ProviderCustomAnthropic.ts new file mode 100644 index 00000000..65154b87 --- /dev/null +++ b/src/core/chorus/ModelProviders/ProviderCustomAnthropic.ts @@ -0,0 +1,75 @@ +import { SettingsManager } from "@core/utilities/Settings"; +import { StreamResponseParams } from "../Models"; +import { IProvider, ModelDisabled } from "./IProvider"; +import { ProviderAnthropic } from "./ProviderAnthropic"; + +function parseCustomProviderModelId(modelId: string): { + providerId: string; + modelName: string; +} { + const parts = modelId.split("::"); + if (parts.length < 3) { + throw new Error(`Invalid custom provider model id: ${modelId}`); + } + + const providerId = parts[1] ?? ""; + const modelName = parts.slice(2).join("::"); + + if (!providerId || !modelName) { + throw new Error(`Invalid custom provider model id: ${modelId}`); + } + + return { providerId, modelName }; +} + +export class ProviderCustomAnthropic implements IProvider { + async streamResponse( + params: StreamResponseParams, + ): Promise { + const { providerId, modelName } = parseCustomProviderModelId( + params.modelConfig.modelId, + ); + + const settings = await SettingsManager.getInstance().get(); + const provider = (settings.customProviders ?? []).find( + (p) => p.id === providerId && p.kind === "anthropic", + ); + + if (!provider) { + throw new Error( + "Custom provider not found. Please check your Providers settings.", + ); + } + + if (!provider.apiBaseUrl.trim()) { + throw new Error( + `Please add an API base URL for "${provider.name}" in Settings.`, + ); + } + + if (!provider.apiKey.trim()) { + throw new Error( + `Please add an API key for "${provider.name}" in Settings.`, + ); + } + + const mappedModelConfig = { + ...params.modelConfig, + modelId: `anthropic::${modelName}`, + }; + + const mappedApiKeys = { + ...params.apiKeys, + anthropic: provider.apiKey, + }; + + const anthropic = new ProviderAnthropic(); + return anthropic.streamResponse({ + ...params, + modelConfig: mappedModelConfig, + apiKeys: mappedApiKeys, + customBaseUrl: params.customBaseUrl || provider.apiBaseUrl, + }); + } +} + diff --git a/src/core/chorus/ModelProviders/ProviderCustomOpenAI.ts b/src/core/chorus/ModelProviders/ProviderCustomOpenAI.ts new file mode 100644 index 00000000..e9d01a3c --- /dev/null +++ b/src/core/chorus/ModelProviders/ProviderCustomOpenAI.ts @@ -0,0 +1,203 @@ +import OpenAI from "openai"; +import OpenAICompletionsAPIUtils from "@core/chorus/OpenAICompletionsAPIUtils"; +import { SettingsManager } from "@core/utilities/Settings"; +import { StreamResponseParams } from "../Models"; +import { IProvider, ModelDisabled } from "./IProvider"; +import JSON5 from "json5"; +import { fetch as tauriFetch } from "@tauri-apps/plugin-http"; + +interface ProviderError { + message: string; + error?: { + message?: string; + metadata?: { raw?: string }; + }; + metadata?: { raw?: string }; +} + +function isProviderError(error: unknown): error is ProviderError { + return ( + typeof error === "object" && + error !== null && + "message" in error && + ("error" in error || "metadata" in error) && + error.message === "Provider returned error" + ); +} + +function parseCustomProviderModelId(modelId: string): { + providerId: string; + modelName: string; +} { + const parts = modelId.split("::"); + if (parts.length < 3) { + throw new Error(`Invalid custom provider model id: ${modelId}`); + } + + const providerId = parts[1] ?? ""; + const modelName = parts.slice(2).join("::"); + + if (!providerId || !modelName) { + throw new Error(`Invalid custom provider model id: ${modelId}`); + } + + return { providerId, modelName }; +} + +function getErrorMessage(error: unknown): string { + if (typeof error === "object" && error !== null && "message" in error) { + return (error as { message: string }).message; + } else if (typeof error === "string") { + return error; + } + return "Unknown error"; +} + +export class ProviderCustomOpenAI implements IProvider { + async streamResponse({ + llmConversation, + modelConfig, + onChunk, + onComplete, + additionalHeaders, + tools, + onError, + customBaseUrl, + }: StreamResponseParams): Promise { + const { providerId, modelName } = parseCustomProviderModelId( + modelConfig.modelId, + ); + + const settings = await SettingsManager.getInstance().get(); + const provider = (settings.customProviders ?? []).find( + (p) => p.id === providerId && p.kind === "openai", + ); + + if (!provider) { + throw new Error( + "Custom provider not found. Please check your Providers settings.", + ); + } + + if (!provider.apiBaseUrl.trim()) { + throw new Error( + `Please add an API base URL for "${provider.name}" in Settings.`, + ); + } + + if (!provider.apiKey.trim()) { + throw new Error( + `Please add an API key for "${provider.name}" in Settings.`, + ); + } + + const baseURL = customBaseUrl || provider.apiBaseUrl; + + const client = new OpenAI({ + baseURL, + apiKey: provider.apiKey, + fetch: tauriFetch, + defaultHeaders: { + ...(additionalHeaders ?? {}), + }, + dangerouslyAllowBrowser: true, + }); + + let messages: OpenAI.ChatCompletionMessageParam[] = + await OpenAICompletionsAPIUtils.convertConversation( + llmConversation, + { + imageSupport: + modelConfig.supportedAttachmentTypes?.includes( + "image", + ) ?? false, + functionSupport: true, + }, + ); + + if (modelConfig.systemPrompt) { + messages = [ + { + role: "system", + content: modelConfig.systemPrompt, + }, + ...messages, + ]; + } + + const streamParams: OpenAI.ChatCompletionCreateParamsStreaming = { + model: modelName, + messages, + stream: true, + }; + + if (tools && tools.length > 0) { + streamParams.tools = + OpenAICompletionsAPIUtils.convertToolDefinitions(tools); + streamParams.tool_choice = "auto"; + } + + const chunks: OpenAI.ChatCompletionChunk[] = []; + + try { + const stream = await client.chat.completions.create(streamParams); + for await (const chunk of stream) { + chunks.push(chunk); + if (chunk.choices[0]?.delta?.content) { + onChunk(chunk.choices[0].delta.content); + } + } + } catch (error: unknown) { + console.error( + "Raw error from ProviderCustomOpenAI:", + error, + modelName, + provider.apiBaseUrl, + messages, + ); + console.error(JSON.stringify(error, null, 2)); + + if ( + isProviderError(error) && + error.message === "Provider returned error" + ) { + let errorDetails: ProviderError; + try { + errorDetails = JSON5.parse( + error.error?.metadata?.raw || + error.metadata?.raw || + "{}", + ); + } catch { + errorDetails = { + message: "Failed to parse error details", + error: { message: "Failed to parse error details" }, + }; + } + const errorMessage = `Provider returned error: ${errorDetails.error?.message || error.message}`; + if (onError) { + onError(errorMessage); + } else { + throw new Error(errorMessage); + } + } else { + if (onError) { + onError(getErrorMessage(error)); + } else { + throw error; + } + } + return undefined; + } + + const toolCalls = OpenAICompletionsAPIUtils.convertToolCalls( + chunks, + tools ?? [], + ); + + await onComplete( + undefined, + toolCalls.length > 0 ? toolCalls : undefined, + ); + } +} diff --git a/src/core/chorus/ModelProviders/ProviderGoogle.ts b/src/core/chorus/ModelProviders/ProviderGoogle.ts index 09b2d0de..cf69d23f 100644 --- a/src/core/chorus/ModelProviders/ProviderGoogle.ts +++ b/src/core/chorus/ModelProviders/ProviderGoogle.ts @@ -5,6 +5,7 @@ import { IProvider, ModelDisabled } from "./IProvider"; import OpenAICompletionsAPIUtils from "@core/chorus/OpenAICompletionsAPIUtils"; import { canProceedWithProvider } from "@core/utilities/ProxyUtils"; import JSON5 from "json5"; +import { fetch } from "@tauri-apps/plugin-http"; interface ProviderError { message: string; @@ -64,6 +65,7 @@ export class ProviderGoogle implements IProvider { apiKeys, additionalHeaders, tools, + enabledToolsets, onError, customBaseUrl, }: StreamResponseParams): Promise { @@ -81,6 +83,28 @@ export class ProviderGoogle implements IProvider { ); } + const googleApiKey = apiKeys.google; + if (!googleApiKey) { + throw new Error("Please add your Google AI API key in Settings."); + } + + const nativeWebSearchEnabled = + enabledToolsets?.includes("web") ?? false; + + if (nativeWebSearchEnabled) { + await streamGroundedResponseWithGoogleSearch({ + googleModelName, + llmConversation, + systemPrompt: modelConfig.systemPrompt, + apiKey: googleApiKey, + onChunk, + onComplete, + onError, + customBaseUrl, + }); + return; + } + // Google AI uses the generativelanguage.googleapis.com endpoint with OpenAI compatibility const baseURL = customBaseUrl || @@ -101,7 +125,7 @@ export class ProviderGoogle implements IProvider { }; const client = new OpenAI({ baseURL, - apiKey: apiKeys.google, + apiKey: googleApiKey, defaultHeaders: headers, dangerouslyAllowBrowser: true, }); @@ -137,22 +161,33 @@ export class ProviderGoogle implements IProvider { if (isGemini3 && modelConfig.thinkingLevel) { // Gemini 3 uses thinking_level parameter - (streamParams as unknown as Record).thinking_level = - modelConfig.thinkingLevel; + ( + streamParams as unknown as Record + ).thinking_level = modelConfig.thinkingLevel; } else if (isGemini25 && modelConfig.budgetTokens) { // Gemini 2.5 uses thinking_budget parameter - (streamParams as unknown as Record).thinking_budget = - modelConfig.budgetTokens; + ( + streamParams as unknown as Record + ).thinking_budget = modelConfig.budgetTokens; } // Debug: Log thinking parameters console.log(`[ProviderGoogle] Model: ${googleModelName}`); - console.log(`[ProviderGoogle] isGemini3: ${isGemini3}, isGemini25: ${isGemini25}`); - console.log(`[ProviderGoogle] modelConfig.thinkingLevel: ${modelConfig.thinkingLevel}`); - console.log(`[ProviderGoogle] modelConfig.budgetTokens: ${modelConfig.budgetTokens}`); + console.log( + `[ProviderGoogle] isGemini3: ${isGemini3}, isGemini25: ${isGemini25}`, + ); + console.log( + `[ProviderGoogle] modelConfig.thinkingLevel: ${modelConfig.thinkingLevel}`, + ); + console.log( + `[ProviderGoogle] modelConfig.budgetTokens: ${modelConfig.budgetTokens}`, + ); console.log(`[ProviderGoogle] streamParams thinking params:`, { - thinking_level: (streamParams as unknown as Record).thinking_level, - thinking_budget: (streamParams as unknown as Record).thinking_budget + thinking_level: (streamParams as unknown as Record) + .thinking_level, + thinking_budget: ( + streamParams as unknown as Record + ).thinking_budget, }); // Add tools definitions @@ -226,3 +261,260 @@ function getErrorMessage(error: unknown): string { return "Unknown error"; } } + +async function streamGroundedResponseWithGoogleSearch(params: { + googleModelName: string; + llmConversation: StreamResponseParams["llmConversation"]; + systemPrompt: string | undefined; + apiKey: string; + onChunk: StreamResponseParams["onChunk"]; + onComplete: StreamResponseParams["onComplete"]; + onError: StreamResponseParams["onError"]; + customBaseUrl: string | undefined; +}): Promise { + try { + const { googleModelName, llmConversation, systemPrompt, apiKey } = + params; + + const messages = await OpenAICompletionsAPIUtils.convertConversation( + llmConversation, + { + imageSupport: false, + functionSupport: false, + }, + ); + + const contents: Array<{ + role: "user" | "model"; + parts: Array<{ text: string }>; + }> = messages.map((m) => { + const text = normalizeToText(m.content); + return { + role: m.role === "assistant" ? "model" : "user", + parts: [{ text: text.trim() === "" ? "..." : text }], + }; + }); + + if (systemPrompt) { + if (contents.length > 0 && contents[0].role === "user") { + contents[0].parts[0].text = + `${systemPrompt}\n\n` + contents[0].parts[0].text; + } else { + contents.unshift({ + role: "user", + parts: [{ text: systemPrompt }], + }); + } + } + + const geminiBaseUrl = params.customBaseUrl + ? params.customBaseUrl.replace(/\/openai\/?$/, "") + : "https://generativelanguage.googleapis.com/v1beta"; + + const response = await fetch( + `${geminiBaseUrl}/models/${googleModelName}:generateContent`, + { + method: "POST", + headers: { + "Content-Type": "application/json", + "x-goog-api-key": apiKey, + }, + body: JSON.stringify({ + contents, + tools: [ + { + google_search: {}, + }, + ], + }), + }, + ); + + if (!response.ok) { + const errorBody = await safeJson(response); + throw new Error( + getGeminiErrorMessageFromBody(errorBody) ?? response.statusText, + ); + } + + const data = (await response.json()) as unknown; + const { text, sources } = extractGeminiTextAndSources(data); + + if (text) { + params.onChunk(text); + } + if (sources.length > 0) { + const sourcesText = sources + .map((s, i) => `${i + 1}. [${s.title || s.url}](${s.url})`) + .join("\n"); + params.onChunk(`\n\nSources:\n${sourcesText}`); + } + + await params.onComplete(); + } catch (error) { + console.error("Error using Gemini google_search grounding:", error); + params.onError(getErrorMessage(error)); + } +} + +function normalizeToText( + content: OpenAI.ChatCompletionMessageParam["content"], +): string { + if (typeof content === "string") { + return content; + } + + if (Array.isArray(content)) { + return content + .map((part) => (isOpenAITextPart(part) ? part.text : "")) + .filter(Boolean) + .join("\n"); + } + + try { + return JSON.stringify(content); + } catch { + return String(content); + } +} + +async function safeJson(response: Response): Promise { + try { + return (await response.json()) as unknown; + } catch { + return undefined; + } +} + +function getGeminiErrorMessageFromBody(body: unknown): string | undefined { + if (!isPlainObject(body)) { + return undefined; + } + + const errorValue = body["error"]; + if (!isPlainObject(errorValue)) { + return undefined; + } + + const messageValue = errorValue["message"]; + return typeof messageValue === "string" ? messageValue : undefined; +} + +function extractGeminiTextAndSources(data: unknown): { + text: string; + sources: Array<{ url: string; title?: string }>; +} { + if (!isPlainObject(data)) { + return { text: "", sources: [] }; + } + + const candidatesValue = data["candidates"]; + if (!isUnknownArray(candidatesValue) || candidatesValue.length === 0) { + return { text: "", sources: [] }; + } + + const firstCandidate = candidatesValue[0]; + if (!isPlainObject(firstCandidate)) { + return { text: "", sources: [] }; + } + + const text = extractTextFromCandidate(firstCandidate); + const grounding = firstCandidate["groundingMetadata"]; + const sources: Array<{ url: string; title?: string }> = []; + + if (isPlainObject(grounding)) { + const groundingChunksValue = grounding["groundingChunks"]; + if (Array.isArray(groundingChunksValue)) { + for (const chunk of groundingChunksValue) { + if (!isPlainObject(chunk)) continue; + const webValue = chunk["web"]; + if (!isPlainObject(webValue)) continue; + const uriValue = webValue["uri"]; + if (typeof uriValue !== "string" || !uriValue) continue; + const titleValue = webValue["title"]; + sources.push({ + url: uriValue, + title: + typeof titleValue === "string" ? titleValue : undefined, + }); + } + } + + const webResultsValue = grounding["webResults"]; + if (Array.isArray(webResultsValue)) { + for (const result of webResultsValue) { + if (!isPlainObject(result)) continue; + const urlValue = result["url"]; + if (typeof urlValue !== "string" || !urlValue) continue; + const titleValue = result["title"]; + sources.push({ + url: urlValue, + title: + typeof titleValue === "string" ? titleValue : undefined, + }); + } + } + + const citationsValue = grounding["citations"]; + if (Array.isArray(citationsValue)) { + for (const citation of citationsValue) { + if (!isPlainObject(citation)) continue; + const uriValue = citation["uri"]; + if (typeof uriValue !== "string" || !uriValue) continue; + sources.push({ url: uriValue }); + } + } + } + + const deduped = new Map(); + for (const source of sources) { + if (!deduped.has(source.url)) { + deduped.set(source.url, source); + } + } + + return { + text, + sources: Array.from(deduped.values()), + }; +} + +function extractTextFromCandidate(candidate: Record): string { + const contentValue = candidate["content"]; + if (!isPlainObject(contentValue)) { + return ""; + } + + const partsValue = contentValue["parts"]; + if (!isUnknownArray(partsValue)) { + return ""; + } + + return partsValue + .map((part) => { + if (!isPlainObject(part)) return ""; + const textValue = part["text"]; + return typeof textValue === "string" ? textValue : ""; + }) + .filter(Boolean) + .join("") + .trim(); +} + +type OpenAITextPart = { type: "text"; text: string }; + +function isOpenAITextPart(part: unknown): part is OpenAITextPart { + return ( + isPlainObject(part) && + part["type"] === "text" && + typeof part["text"] === "string" + ); +} + +function isPlainObject(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +function isUnknownArray(value: unknown): value is unknown[] { + return Array.isArray(value); +} diff --git a/src/core/chorus/ModelProviders/ProviderGrok.ts b/src/core/chorus/ModelProviders/ProviderGrok.ts index 68111306..6cb06cd1 100644 --- a/src/core/chorus/ModelProviders/ProviderGrok.ts +++ b/src/core/chorus/ModelProviders/ProviderGrok.ts @@ -4,7 +4,6 @@ import { IProvider } from "./IProvider"; import { canProceedWithProvider } from "@core/utilities/ProxyUtils"; import OpenAICompletionsAPIUtils from "@core/chorus/OpenAICompletionsAPIUtils"; import JSON5 from "json5"; -import _ from "lodash"; interface ProviderError { message: string; @@ -33,6 +32,7 @@ export class ProviderGrok implements IProvider { onChunk, onComplete, additionalHeaders, + enabledToolsets, customBaseUrl, }: StreamResponseParams) { const modelName = modelConfig.modelId.split("::")[1]; @@ -75,6 +75,77 @@ export class ProviderGrok implements IProvider { ]; } + const nativeWebSearchEnabled = enabledToolsets?.includes("web") ?? false; + const supportsNativeWebSearch = modelName.startsWith("grok-4"); + const shouldUseNativeWebSearch = + nativeWebSearchEnabled && supportsNativeWebSearch; + + if (shouldUseNativeWebSearch) { + try { + // Convert chat messages to the OpenAI Responses API input format. + const input = + convertChatCompletionMessagesToResponsesInput(messages); + + const stream = client.responses.stream({ + model: modelName, + input, + tools: [ + { + type: "web_search", + }, + ], + tool_choice: "auto", + }); + + for await (const event of stream) { + if ( + isPlainObject(event) && + event["type"] === "response.output_text.delta" && + typeof event["delta"] === "string" + ) { + onChunk(event["delta"]); + } + } + + const finalResponse = await stream.finalResponse(); + const citations = (finalResponse as unknown as Record< + string, + unknown + >)?.["citations"]; + if (Array.isArray(citations) && citations.length > 0) { + const sourcesText = citations + .map((url, index) => + typeof url === "string" + ? `${index + 1}. [${url}](${url})` + : `${index + 1}. ${safeToString(url)}`, + ) + .join("\n"); + onChunk(`\n\nSources:\n${sourcesText}`); + } + + await onComplete(); + return; + } catch (error: unknown) { + console.error("Raw error:", error); + console.error(JSON.stringify(error, null, 2)); + + if ( + isProviderError(error) && + error.message === "Provider returned error" + ) { + const errorDetails: ProviderError = JSON5.parse( + error.error?.metadata?.raw || + error.metadata?.raw || + "{}", + ); + throw new Error( + `Provider returned error: ${errorDetails.error?.message || error.message}`, + ); + } + throw error; + } + } + const streamParams: OpenAI.ChatCompletionCreateParamsStreaming & { include_reasoning: boolean; reasoning_effort?: string; @@ -94,8 +165,13 @@ export class ProviderGrok implements IProvider { // Debug: Log reasoning parameters console.log(`[ProviderGrok] Model: ${modelName}`); console.log(`[ProviderGrok] isGrok3Mini: ${isGrok3Mini}`); - console.log(`[ProviderGrok] modelConfig.reasoningEffort: ${modelConfig.reasoningEffort}`); - console.log(`[ProviderGrok] streamParams.reasoning_effort:`, streamParams.reasoning_effort); + console.log( + `[ProviderGrok] modelConfig.reasoningEffort: ${modelConfig.reasoningEffort}`, + ); + console.log( + `[ProviderGrok] streamParams.reasoning_effort:`, + streamParams.reasoning_effort, + ); try { const stream = await client.chat.completions.create(streamParams); @@ -126,3 +202,112 @@ export class ProviderGrok implements IProvider { } } } + +function convertChatCompletionMessagesToResponsesInput( + messages: OpenAI.ChatCompletionMessageParam[], +): OpenAI.Responses.ResponseInputItem[] { + return messages.map((message) => { + const role = normalizeResponsesRole(message.role); + const content = message.content; + + if (typeof content === "string") { + return { role, content }; + } + + if (Array.isArray(content)) { + const parts: OpenAI.Responses.ResponseInputContent[] = []; + + for (const part of content) { + if (!isPlainObject(part) || typeof part["type"] !== "string") { + parts.push({ + type: "input_text", + text: safeToString(part), + }); + continue; + } + + if (part["type"] === "text") { + parts.push({ + type: "input_text", + text: + typeof part["text"] === "string" + ? part["text"] + : safeToString(part["text"]), + }); + continue; + } + + if (part["type"] === "image_url") { + const imageUrlValue = part["image_url"]; + const imageUrl = + isPlainObject(imageUrlValue) && + typeof imageUrlValue["url"] === "string" + ? imageUrlValue["url"] + : null; + const detail = isPlainObject(imageUrlValue) + ? normalizeImageDetail(imageUrlValue["detail"]) + : "auto"; + + parts.push({ + type: "input_image", + detail, + image_url: imageUrl, + }); + continue; + } + + parts.push({ + type: "input_text", + text: safeToString(part), + }); + } + + return { role, content: parts }; + } + + if (content === null || content === undefined) { + return { role, content: "" }; + } + + return { role, content: safeToString(content) }; + }); +} + +type ResponsesRole = "user" | "assistant" | "system" | "developer"; + +function normalizeResponsesRole( + role: OpenAI.ChatCompletionMessageParam["role"], +): ResponsesRole { + if ( + role === "user" || + role === "assistant" || + role === "system" || + role === "developer" + ) { + return role; + } + return "user"; +} + +function normalizeImageDetail(detail: unknown): "low" | "high" | "auto" { + if (detail === "low" || detail === "high" || detail === "auto") { + return detail; + } + return "auto"; +} + +function isPlainObject(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +function safeToString(value: unknown): string { + if (typeof value === "string") { + return value; + } + try { + const jsonValue = JSON.stringify(value); + return jsonValue ?? String(value); + } catch { + return String(value); + } +} diff --git a/src/core/chorus/ModelProviders/ProviderOpenAI.ts b/src/core/chorus/ModelProviders/ProviderOpenAI.ts index f98f6062..a2641593 100644 --- a/src/core/chorus/ModelProviders/ProviderOpenAI.ts +++ b/src/core/chorus/ModelProviders/ProviderOpenAI.ts @@ -24,6 +24,7 @@ export class ProviderOpenAI implements IProvider { onComplete, additionalHeaders, tools, + enabledToolsets, customBaseUrl, }: StreamResponseParams) { const modelId = modelConfig.modelId.split("::")[1]; @@ -96,15 +97,20 @@ export class ProviderOpenAI implements IProvider { ]; } - // Convert tools to OpenAI format - // For o3-deep-research, filter out native web search since we use OpenAI's web_search_preview - const filteredTools = - modelId === "o3-deep-research" - ? tools?.filter((tool) => tool.toolsetName !== "web") - : tools; + const nativeWebSearchEnabled = + enabledToolsets?.includes("web") ?? false; + + const supportsNativeWebSearch = + modelId.startsWith("o") || + modelId.startsWith("gpt-4o") || + modelId.startsWith("gpt-4.1") || + modelId.startsWith("gpt-5"); + + const shouldUseNativeWebSearch = + nativeWebSearchEnabled && supportsNativeWebSearch; const openaiTools: Array | undefined = - filteredTools?.map((tool) => ({ + tools?.map((tool) => ({ type: "function", name: getUserToolNamespacedName(tool), // name goes at this level for OpenAI description: tool.description, @@ -116,16 +122,28 @@ export class ProviderOpenAI implements IProvider { strict: false, })); + const nativeWebSearchTools = shouldUseNativeWebSearch + ? [ + { + type: "web_search_preview", + search_context_size: "medium", + } as const, + ] + : []; + // eslint-disable-next-line @typescript-eslint/no-explicit-any const createParams: any = { model: modelId, input: messages, - tools: openaiTools || [], + tools: [...nativeWebSearchTools, ...(openaiTools || [])], tool_choice: - tools && tools.length > 0 + nativeWebSearchTools.length > 0 || (tools && tools.length > 0) ? ("auto" as const) : ("none" as const), stream: true as const, + ...(nativeWebSearchTools.length > 0 && { + include: ["web_search_call.action.sources"], + }), ...(isReasoningModel && { reasoning: { effort: modelConfig.reasoningEffort || "medium", @@ -136,8 +154,13 @@ export class ProviderOpenAI implements IProvider { // Debug: Log reasoning parameters console.log(`[ProviderOpenAI] Model: ${modelId}`); console.log(`[ProviderOpenAI] Is reasoning model: ${isReasoningModel}`); - console.log(`[ProviderOpenAI] modelConfig.reasoningEffort: ${modelConfig.reasoningEffort}`); - console.log(`[ProviderOpenAI] createParams.reasoning:`, createParams.reasoning); + console.log( + `[ProviderOpenAI] modelConfig.reasoningEffort: ${modelConfig.reasoningEffort}`, + ); + console.log( + `[ProviderOpenAI] createParams.reasoning:`, + (createParams as { reasoning?: unknown }).reasoning, + ); // Add special tools for o3-deep-research if (modelId === "o3-deep-research") { @@ -162,6 +185,9 @@ export class ProviderOpenAI implements IProvider { // o3-deep-research requires tool_choice to be "auto" when using code_interpreter // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access createParams.tool_choice = "auto"; + // Include sources so we can surface citations/URLs when available + // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access + createParams.include = ["web_search_call.action.sources"]; } const client = new OpenAI({ diff --git a/src/core/chorus/ModelProviders/ProviderVertex.ts b/src/core/chorus/ModelProviders/ProviderVertex.ts new file mode 100644 index 00000000..38e1c4ac --- /dev/null +++ b/src/core/chorus/ModelProviders/ProviderVertex.ts @@ -0,0 +1,405 @@ +import OpenAI from "openai"; +import OpenAICompletionsAPIUtils from "@core/chorus/OpenAICompletionsAPIUtils"; +import { SettingsManager } from "@core/utilities/Settings"; +import { StreamResponseParams } from "../Models"; +import { IProvider, ModelDisabled } from "./IProvider"; +import { fetch as tauriFetch } from "@tauri-apps/plugin-http"; +import JSON5 from "json5"; + +interface ProviderError { + message: string; + error?: { + message?: string; + metadata?: { raw?: string }; + }; + metadata?: { raw?: string }; +} + +function isProviderError(error: unknown): error is ProviderError { + return ( + typeof error === "object" && + error !== null && + "message" in error && + ("error" in error || "metadata" in error) && + error.message === "Provider returned error" + ); +} + +function getErrorMessage(error: unknown): string { + if (typeof error === "object" && error !== null && "message" in error) { + return (error as { message: string }).message; + } else if (typeof error === "string") { + return error; + } + return "Unknown error"; +} + +function base64UrlEncodeFromBytes(bytes: Uint8Array): string { + const base64 = btoa(String.fromCharCode(...bytes)); + return base64.replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/g, ""); +} + +function base64UrlEncodeJson(value: unknown): string { + return base64UrlEncodeFromBytes( + new TextEncoder().encode(JSON.stringify(value)), + ); +} + +function pemToDerBytes(pem: string): Uint8Array { + const normalized = pem + .trim() + // Handle pasting from service account JSON where newlines are escaped. + .replace(/\\r\\n/g, "\n") + .replace(/\\n/g, "\n") + .replace(/\\r/g, "\n") + // Remove wrapping quotes if the value is copied as a JSON string. + .replace(/^['"]/, "") + .replace(/['"]$/, ""); + + const clean = normalized + .replace(/-----BEGIN [^-]+-----/g, "") + .replace(/-----END [^-]+-----/g, "") + .replace(/\s+/g, "") + .trim(); + let binary: string; + try { + binary = atob(clean); + } catch (_error) { + throw new Error( + "Invalid service account private key. Paste the PEM contents (with real newlines), or paste the JSON value (it should contain \\n sequences).", + ); + } + const bytes = new Uint8Array(binary.length); + for (let i = 0; i < binary.length; i += 1) { + bytes[i] = binary.charCodeAt(i); + } + return bytes; +} + +async function signJwtRS256(params: { + payload: Record; + privateKeyPem: string; +}): Promise { + const header = { alg: "RS256", typ: "JWT" }; + + const encodedHeader = base64UrlEncodeJson(header); + const encodedPayload = base64UrlEncodeJson(params.payload); + const signingInput = `${encodedHeader}.${encodedPayload}`; + + // Work around TS DOM lib typing differences between ArrayBuffer and SharedArrayBuffer. + const keyBytes = new Uint8Array(pemToDerBytes(params.privateKeyPem)); + const key = await crypto.subtle.importKey( + "pkcs8", + keyBytes, + { name: "RSASSA-PKCS1-v1_5", hash: "SHA-256" }, + false, + ["sign"], + ); + + const signature = await crypto.subtle.sign( + "RSASSA-PKCS1-v1_5", + key, + new TextEncoder().encode(signingInput), + ); + + const encodedSignature = base64UrlEncodeFromBytes( + new Uint8Array(signature), + ); + + return `${signingInput}.${encodedSignature}`; +} + +type CachedAccessToken = { + token: string; + expiresAtMs: number; +}; + +let cachedAccessToken: CachedAccessToken | undefined; + +async function getGoogleAccessToken(params: { + clientEmail: string; + privateKey: string; +}): Promise { + const nowMs = Date.now(); + if (cachedAccessToken && cachedAccessToken.expiresAtMs - nowMs > 60_000) { + return cachedAccessToken.token; + } + + const iat = Math.floor(nowMs / 1000); + const exp = iat + 60 * 60; + + const jwt = await signJwtRS256({ + privateKeyPem: params.privateKey, + payload: { + iss: params.clientEmail, + sub: params.clientEmail, + aud: "https://oauth2.googleapis.com/token", + iat, + exp, + scope: "https://www.googleapis.com/auth/cloud-platform", + }, + }); + + const body = new URLSearchParams({ + grant_type: "urn:ietf:params:oauth:grant-type:jwt-bearer", + assertion: jwt, + }).toString(); + + const response = await tauriFetch("https://oauth2.googleapis.com/token", { + method: "POST", + headers: { + "Content-Type": "application/x-www-form-urlencoded", + }, + body, + }); + + if (!response.ok) { + const errorText = await response.text().catch(() => ""); + throw new Error( + `Failed to fetch Google access token (${response.status}): ${errorText || response.statusText}`, + ); + } + + const tokenResponse = (await response.json()) as { + access_token: string; + expires_in: number; + token_type: string; + }; + + cachedAccessToken = { + token: tokenResponse.access_token, + expiresAtMs: nowMs + tokenResponse.expires_in * 1000, + }; + + return tokenResponse.access_token; +} + +function getVertexBaseUrl(params: { projectId: string; location: string }) { + // Vertex AI OpenAI-compatible API base URL. + // Ref: https://cloud.google.com/vertex-ai/docs/generative-ai/model-reference/openai + const host = + params.location === "global" + ? "aiplatform.googleapis.com" + : `${params.location}-aiplatform.googleapis.com`; + return `https://${host}/v1beta1/projects/${params.projectId}/locations/${params.location}/endpoints/openapi`; +} + +function normalizeVertexPublisherModel(model: string): string { + const trimmed = model.trim(); + if (!trimmed) return trimmed; + + // Allow pasting full resource names. + // e.g. "publishers/google/models/gemini-2.5-pro" -> "google/gemini-2.5-pro" + const publishersMatch = trimmed.match(/^publishers\/([^/]+)\/models\/(.+)$/); + if (publishersMatch) { + return `${publishersMatch[1]}/${publishersMatch[2]}`; + } + + // e.g. "projects//locations//publishers/google/models/gemini-2.5-pro" -> "google/gemini-2.5-pro" + const fullMatch = trimmed.match( + /^projects\/[^/]+\/locations\/[^/]+\/publishers\/([^/]+)\/models\/(.+)$/, + ); + if (fullMatch) { + return `${fullMatch[1]}/${fullMatch[2]}`; + } + + // e.g. "models/gemini-2.5-pro" -> "google/gemini-2.5-pro" + const modelsMatch = trimmed.match(/^models\/(.+)$/); + if (modelsMatch) { + return `google/${modelsMatch[1]}`; + } + + // If it already looks like "/", keep it. + if (trimmed.includes("/")) { + return trimmed; + } + + // Default to Google publisher models for convenience. + return `google/${trimmed}`; +} + +export class ProviderVertex implements IProvider { + async streamResponse({ + llmConversation, + modelConfig, + onChunk, + onComplete, + additionalHeaders, + tools, + enabledToolsets, + onError, + customBaseUrl, + }: StreamResponseParams): Promise { + const rawModelName = modelConfig.modelId + .split("::") + .slice(1) + .join("::"); + if (!rawModelName) { + throw new Error(`Invalid model id: ${modelConfig.modelId}`); + } + const modelName = normalizeVertexPublisherModel(rawModelName); + + const settings = await SettingsManager.getInstance().get(); + const vertex = settings.vertexAI; + + if ( + !vertex || + !vertex.projectId.trim() || + !vertex.location.trim() || + !vertex.serviceAccountClientEmail.trim() || + !vertex.serviceAccountPrivateKey.trim() + ) { + throw new Error( + "Please configure Vertex AI credentials in Settings.", + ); + } + + const accessToken = await getGoogleAccessToken({ + clientEmail: vertex.serviceAccountClientEmail, + privateKey: vertex.serviceAccountPrivateKey, + }); + + const baseURL = + customBaseUrl || + getVertexBaseUrl({ + projectId: vertex.projectId, + location: vertex.location, + }); + + const client = new OpenAI({ + baseURL, + apiKey: accessToken, + fetch: tauriFetch, + defaultHeaders: { + ...(additionalHeaders ?? {}), + }, + dangerouslyAllowBrowser: true, + }); + + let messages: OpenAI.ChatCompletionMessageParam[] = + await OpenAICompletionsAPIUtils.convertConversation( + llmConversation, + { + imageSupport: + modelConfig.supportedAttachmentTypes?.includes( + "image", + ) ?? false, + functionSupport: true, + }, + ); + + if (modelConfig.systemPrompt) { + messages = [ + { + role: "system", + content: modelConfig.systemPrompt, + }, + ...messages, + ]; + } + + const streamParams: OpenAI.ChatCompletionCreateParamsStreaming = { + model: modelName, + messages, + stream: true, + }; + + const nativeWebSearchEnabled = enabledToolsets?.includes("web") ?? false; + const supportsNativeWebSearch = + modelName.startsWith("google/") && modelName.includes("gemini"); + const shouldUseNativeWebSearch = + nativeWebSearchEnabled && supportsNativeWebSearch; + + if (shouldUseNativeWebSearch) { + // Vertex AI OpenAI-compatible web search grounding. + // The API accepts `web_search_options` but does not support sub-options. + // https://docs.cloud.google.com/vertex-ai/generative-ai/docs/migrate/openai/overview + ( + streamParams as unknown as Record + ).web_search_options = {}; + } + + // Gemini thinking parameters (Vertex OpenAI-compatible API) + const isGemini3 = modelName.includes("gemini-3"); + const isGemini25 = modelName.includes("gemini-2.5"); + + if (isGemini3 && modelConfig.thinkingLevel) { + ( + streamParams as unknown as Record + ).thinking_level = modelConfig.thinkingLevel; + } else if (isGemini25 && modelConfig.budgetTokens) { + ( + streamParams as unknown as Record + ).thinking_budget = modelConfig.budgetTokens; + } + + if (tools && tools.length > 0) { + streamParams.tools = + OpenAICompletionsAPIUtils.convertToolDefinitions(tools); + streamParams.tool_choice = "auto"; + } + + const chunks: OpenAI.ChatCompletionChunk[] = []; + + try { + const stream = await client.chat.completions.create(streamParams); + for await (const chunk of stream) { + chunks.push(chunk); + if (chunk.choices[0]?.delta?.content) { + onChunk(chunk.choices[0].delta.content); + } + } + } catch (error: unknown) { + console.error( + "Raw error from ProviderVertex:", + error, + modelName, + baseURL, + messages, + ); + console.error(JSON.stringify(error, null, 2)); + + if ( + isProviderError(error) && + error.message === "Provider returned error" + ) { + let errorDetails: ProviderError; + try { + errorDetails = JSON5.parse( + error.error?.metadata?.raw || + error.metadata?.raw || + "{}", + ); + } catch { + errorDetails = { + message: "Failed to parse error details", + error: { message: "Failed to parse error details" }, + }; + } + const errorMessage = `Provider returned error: ${errorDetails.error?.message || error.message}`; + if (onError) { + onError(errorMessage); + } else { + throw new Error(errorMessage); + } + } else { + if (onError) { + onError(getErrorMessage(error)); + } else { + throw error; + } + } + return undefined; + } + + const toolCalls = OpenAICompletionsAPIUtils.convertToolCalls( + chunks, + tools ?? [], + ); + + await onComplete( + undefined, + toolCalls.length > 0 ? toolCalls : undefined, + ); + } +} diff --git a/src/core/chorus/ModelProviders/anthropicModels.ts b/src/core/chorus/ModelProviders/anthropicModels.ts new file mode 100644 index 00000000..1f6d213e --- /dev/null +++ b/src/core/chorus/ModelProviders/anthropicModels.ts @@ -0,0 +1,100 @@ +export type AnthropicModelConfig = { + inputModelName: string; + anthropicModelName: string; + maxTokens: number; +}; + +export const ANTHROPIC_THINKING_MIN_BUDGET_TOKENS = 1024; + +const DEFAULT_ANTHROPIC_MAX_TOKENS = 8192; + +const ANTHROPIC_MODELS: AnthropicModelConfig[] = [ + { + inputModelName: "claude-3-5-sonnet-latest", + anthropicModelName: "claude-3-5-sonnet-latest", + maxTokens: 8192, + }, + { + inputModelName: "claude-3-7-sonnet-latest", + anthropicModelName: "claude-3-7-sonnet-latest", + maxTokens: 20000, + }, + { + inputModelName: "claude-3-7-sonnet-latest-thinking", + anthropicModelName: "claude-3-7-sonnet-latest", + maxTokens: 10000, + }, + { + inputModelName: "claude-sonnet-4-latest", + // https://docs.anthropic.com/en/docs/about-claude/models/overview 0 is the new alias for latest + anthropicModelName: "claude-sonnet-4-0", + maxTokens: 10000, + }, + { + inputModelName: "claude-sonnet-4-5-20250929", + anthropicModelName: "claude-sonnet-4-5-20250929", + maxTokens: 10000, + }, + { + inputModelName: "claude-opus-4-latest", + anthropicModelName: "claude-opus-4-0", + maxTokens: 10000, + }, + { + inputModelName: "claude-opus-4.1-latest", + anthropicModelName: "claude-opus-4-1-20250805", + maxTokens: 10000, + }, + { + inputModelName: "claude-haiku-4-5-20251001", + anthropicModelName: "claude-haiku-4-5-20251001", + maxTokens: 20000, + }, + { + inputModelName: "claude-opus-4-5-20251101", + anthropicModelName: "claude-opus-4-5-20251101", + maxTokens: 20000, + }, +]; + +export function getAnthropicModelName(modelName: string): string { + const modelConfig = ANTHROPIC_MODELS.find( + (m) => m.inputModelName === modelName, + ); + // If not found in hardcoded list, return the model name as-is + // (supports dynamically fetched models from API) + return modelConfig?.anthropicModelName ?? modelName; +} + +export function getAnthropicMaxTokens(modelName: string): number { + const modelConfig = ANTHROPIC_MODELS.find( + (m) => m.inputModelName === modelName, + ); + // Return default max tokens if model not found in hardcoded list + // (supports dynamically fetched models from API) + return modelConfig?.maxTokens ?? DEFAULT_ANTHROPIC_MAX_TOKENS; +} + +export function clampAnthropicThinkingBudgetTokens(params: { + budgetTokens: number; + maxTokens: number; +}): number { + const normalizedBudgetTokens = Math.floor(params.budgetTokens); + if ( + !Number.isFinite(normalizedBudgetTokens) || + Number.isNaN(normalizedBudgetTokens) + ) { + return ANTHROPIC_THINKING_MIN_BUDGET_TOKENS; + } + + const maxBudgetTokens = Math.max( + ANTHROPIC_THINKING_MIN_BUDGET_TOKENS, + params.maxTokens - 1, + ); + + return Math.min( + maxBudgetTokens, + Math.max(ANTHROPIC_THINKING_MIN_BUDGET_TOKENS, normalizedBudgetTokens), + ); +} + diff --git a/src/core/chorus/Models.ts b/src/core/chorus/Models.ts index ddb94d2d..7474562e 100644 --- a/src/core/chorus/Models.ts +++ b/src/core/chorus/Models.ts @@ -17,9 +17,17 @@ import { ollamaClient } from "./OllamaClient"; import { ProviderOllama } from "./ModelProviders/ProviderOllama"; import { ProviderLMStudio } from "./ModelProviders/ProviderLMStudio"; import { ProviderGrok } from "./ModelProviders/ProviderGrok"; +import { ProviderVertex } from "./ModelProviders/ProviderVertex"; +import { ProviderCustomOpenAI } from "./ModelProviders/ProviderCustomOpenAI"; +import { ProviderCustomAnthropic } from "./ModelProviders/ProviderCustomAnthropic"; import posthog from "posthog-js"; import { UserTool, UserToolCall, UserToolResult } from "./Toolsets"; import { Attachment } from "./api/AttachmentsAPI"; +import { + CustomProviderSettings, + SettingsManager, + VertexAISettings, +} from "@core/utilities/Settings"; /// ------------------------------------------------------------------------------------------------ /// Basic Types @@ -218,6 +226,11 @@ export type StreamResponseParams = { modelConfig: ModelConfig; llmConversation: LLMMessage[]; tools?: UserTool[]; + /** + * Names of enabled toolsets (e.g. ["web", "terminal"]). + * Useful when a toolset influences provider behavior without exposing function tools. + */ + enabledToolsets?: string[]; apiKeys: ApiKeys; onChunk: (chunk: string) => void; onComplete: ( @@ -243,6 +256,9 @@ export type ProviderName = | "ollama" | "lmstudio" | "grok" + | "vertex" + | "custom_openai" + | "custom_anthropic" | "meta"; /** @@ -279,6 +295,25 @@ export function getProviderName(modelId: string): ProviderName { return providerName as ProviderName; } +/** + * Returns the model name from a model id. + * Ex: "openrouter::meta-llama/llama-4-scout" -> "meta-llama/llama-4-scout" + * Ex: "custom_openai::::gpt-4o-mini" -> "gpt-4o-mini" + */ +export function getModelName(modelId: string): string { + const parts = modelId.split("::"); + if (parts.length <= 1) { + return modelId; + } + + const provider = parts[0]; + if (provider === "custom_openai" || provider === "custom_anthropic") { + return parts.slice(2).join("::"); + } + + return parts.slice(1).join("::"); +} + function getProvider(providerName: string): IProvider { switch (providerName) { case "openai": @@ -297,6 +332,12 @@ function getProvider(providerName: string): IProvider { return new ProviderLMStudio(); case "grok": return new ProviderGrok(); + case "vertex": + return new ProviderVertex(); + case "custom_openai": + return new ProviderCustomOpenAI(); + case "custom_anthropic": + return new ProviderCustomAnthropic(); default: throw new Error(`Unknown provider: ${providerName}`); } @@ -350,6 +391,89 @@ export async function saveModelAndDefaultConfig( ); } +function hasVertexCredentials(vertex: VertexAISettings): boolean { + return Boolean( + vertex.projectId.trim() && + vertex.location.trim() && + vertex.serviceAccountClientEmail.trim() && + vertex.serviceAccountPrivateKey.trim(), + ); +} + +export async function syncVertexModels(db: Database): Promise { + await db.execute( + "UPDATE models SET is_enabled = 0 WHERE id LIKE 'vertex::%'", + ); + + const settings = await SettingsManager.getInstance().get(); + const vertex = settings.vertexAI; + + if (!vertex || !hasVertexCredentials(vertex) || vertex.models.length === 0) { + return; + } + + await Promise.all( + vertex.models + .filter((model) => model.modelId.trim() !== "") + .map((model) => { + const modelId = model.modelId.trim(); + const displayName = model.nickname?.trim() || modelId; + return saveModelAndDefaultConfig( + db, + { + id: `vertex::${modelId}`, + displayName, + supportedAttachmentTypes: ["text", "image", "webpage"], + isEnabled: true, + isInternal: false, + }, + displayName, + ); + }), + ); +} + +export async function syncCustomProviderModels(db: Database): Promise { + await db.execute( + "UPDATE models SET is_enabled = 0 WHERE id LIKE 'custom_openai::%' OR id LIKE 'custom_anthropic::%'", + ); + + const settings = await SettingsManager.getInstance().get(); + const customProviders = settings.customProviders ?? []; + + await Promise.all( + customProviders.flatMap((provider: CustomProviderSettings) => { + const prefix = + provider.kind === "anthropic" + ? "custom_anthropic" + : "custom_openai"; + + const supportedAttachmentTypes: AttachmentType[] = + provider.kind === "anthropic" + ? ["text", "image", "pdf", "webpage"] + : ["text", "image", "webpage"]; + + return provider.models + .filter((model) => model.modelId.trim() !== "") + .map((model) => { + const modelId = model.modelId.trim(); + const displayName = model.nickname?.trim() || modelId; + return saveModelAndDefaultConfig( + db, + { + id: `${prefix}::${provider.id}::${modelId}`, + displayName, + supportedAttachmentTypes, + isEnabled: true, + isInternal: false, + }, + displayName, + ); + }); + }), + ); +} + /** * Downloads models from external sources to refresh the database. */ @@ -601,10 +725,6 @@ export async function downloadAnthropicModels( apiKeys: ApiKeys, ): Promise { try { - await db.execute( - "UPDATE models SET is_enabled = 0 WHERE id LIKE 'anthropic::%'", - ); - if (!apiKeys.anthropic) { console.log( "No Anthropic API key configured, skipping model download", @@ -620,10 +740,35 @@ export async function downloadAnthropicModels( }); if (!response.ok) { - console.error("Failed to fetch Anthropic models"); + const errorText = await response.text().catch(() => ""); + console.error( + "Failed to fetch Anthropic models", + response.status, + response.statusText, + errorText, + ); + + // If the key is invalid, disable Anthropic models so they can't be selected. + // For transient failures, keep (and re-enable) existing models so the user + // can still use pre-seeded Claude configs. + if (response.status === 401 || response.status === 403) { + await db.execute( + "UPDATE models SET is_enabled = 0 WHERE id LIKE 'anthropic::%'", + ); + } else { + await db.execute( + "UPDATE models SET is_enabled = 1 WHERE id LIKE 'anthropic::%'", + ); + } return; } + // Keep existing pre-seeded Claude configs usable even if the Models API + // returns a different set of canonical IDs than our aliases. + await db.execute( + "UPDATE models SET is_enabled = 1 WHERE id LIKE 'anthropic::%'", + ); + const { data: models } = (await response.json()) as { data: { id: string; @@ -657,8 +802,10 @@ export async function downloadAnthropicModels( } } catch (error) { console.error("Error downloading Anthropic models:", error); + // Don't strand the user without Claude models if the network request fails. + // This is especially important for first-time key setup flows. await db.execute( - "UPDATE models SET is_enabled = 0 WHERE id LIKE 'anthropic::%'", + "UPDATE models SET is_enabled = 1 WHERE id LIKE 'anthropic::%'", ); } } @@ -902,6 +1049,9 @@ const CONTEXT_LIMIT_PATTERNS: Record = { lmstudio: "context window", // best guess perplexity: "context window", // best guess ollama: "context window", // best guess + vertex: "context window", // best guess + custom_openai: "context window", // best guess + custom_anthropic: "prompt is too long", // best guess }; /** diff --git a/src/core/chorus/WebTools.ts b/src/core/chorus/WebTools.ts deleted file mode 100644 index 5f216d8c..00000000 --- a/src/core/chorus/WebTools.ts +++ /dev/null @@ -1,258 +0,0 @@ -import { fetch } from "@tauri-apps/plugin-http"; -import OpenAI from "openai"; -import { ApiKeys } from "./Models"; - -type FetchOptions = { - maxLength?: number; - startIndex?: number; - raw?: boolean; - headers?: Record; -}; - -type FetchResult = { - content: string; - truncated: boolean; - nextStartIndex?: number; - error?: string; -}; - -type SearchResult = { - content: string; - error?: string; -}; - -type SearchProviderConfig = { - name: "perplexity" | "openrouter"; - baseURL: string; - apiKey: string; - model: string; - defaultHeaders?: Record; -}; - -const OPENROUTER_PERPLEXITY_MODEL = "perplexity/sonar"; - -function normalizeUrl(url: string): string { - if (url.startsWith("http://") && !url.startsWith("https://")) { - return "https://" + url; - } else { - return url; - } -} - -function getErrorMessage(error: unknown): string { - if (error instanceof Error) { - return error.message; - } else { - return "Unknown error"; - } -} - -function getSearchProviderConfig( - apiKeys: ApiKeys, -): SearchProviderConfig | null { - if (apiKeys.perplexity) { - return { - name: "perplexity", - baseURL: "https://api.perplexity.ai", - apiKey: apiKeys.perplexity, - model: "sonar", - defaultHeaders: { - "Content-Type": "application/json", - // unset headers that are not supported by the Perplexity API - // Perplexity's API does not allow x-stainless-* headers added by the OpenAI JS SDK - "x-stainless-arch": null, - "x-stainless-lang": null, - "x-stainless-os": null, - "x-stainless-package-version": null, - "x-stainless-retry-count": null, - "x-stainless-runtime": null, - "x-stainless-runtime-version": null, - "x-stainless-timeout": null, - }, - }; - } - - if (apiKeys.openrouter) { - return { - name: "openrouter", - baseURL: "https://openrouter.ai/api/v1", - apiKey: apiKeys.openrouter, - model: OPENROUTER_PERPLEXITY_MODEL, - defaultHeaders: { - "HTTP-Referer": "https://chorus.sh", - "X-Title": "Chorus", - }, - }; - } - - return null; -} - -export class WebTools { - private static async _fetch( - url: string, - headers: Record, - ): Promise { - console.log("fetching url", url); - const response = await fetch(url, { - headers: { - "User-Agent": - "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36", - ...headers, - }, - }); - console.log("response", response); - - if (!response.ok) { - throw new Error( - `HTTP error: ${response.status} ${response.statusText}`, - ); - } - return response; - } - - static async search( - query: string, - apiKeys: ApiKeys, - ): Promise { - try { - const providerConfig = getSearchProviderConfig(apiKeys); - - if (!providerConfig) { - return { - content: - "Please add your Perplexity or OpenRouter API key in Settings to use web search.", - error: "No web search API key configured", - }; - } - - const client = new OpenAI({ - baseURL: providerConfig.baseURL, - apiKey: providerConfig.apiKey, - defaultHeaders: providerConfig.defaultHeaders - ? Object.fromEntries( - Object.entries(providerConfig.defaultHeaders).filter( - ([, value]) => value !== null, - ), - ) - : undefined, - dangerouslyAllowBrowser: true, - }); - - const completion = await client.chat.completions.create({ - model: providerConfig.model, - messages: [ - { - role: "system", - content: - "Search the web for information about the user's query. Provide relevant search results with links to sources.", - }, - { - role: "user", - content: query, - }, - ], - stream: false, - }); - - const content = completion.choices[0]?.message?.content || ""; - - // Extract citations if they exist - // Perplexity returns citations in the completion object - const completionWithCitations = - completion as OpenAI.ChatCompletion & { - citations?: string[]; - }; - const citations = completionWithCitations.citations; - let finalContent = content; - - if (citations && citations.length > 0) { - const sources = citations - .map((url, i) => `${i + 1}. [${url}](${url})`) - .join("\n"); - finalContent += "\n\nSources:\n" + sources; - } - - return { - content: finalContent, - }; - } catch (error) { - return { - content: `Error searching the web: ${getErrorMessage(error)}`, - error: getErrorMessage(error), - }; - } - } - - static async fetchWebpage( - url: string, - options: FetchOptions = {}, - ): Promise { - const { - maxLength = 50000, - startIndex = 0, - raw = false, - headers = {}, - } = options; - - try { - const response = await this._fetch( - raw ? normalizeUrl(url) : `https://r.jina.ai/${url}`, - headers, - ); - let content = await response.text(); - console.log("raw text content", content); - - // Check for empty content early - if (content.length === 0) { - return { - content: - "No content found.", - truncated: false, - }; - } - - // Now handle pagination - const totalLength = content.length; - - // Check if startIndex is out of bounds - if (startIndex >= totalLength) { - return { - content: - "No more content available.", - truncated: false, - }; - } - - // Calculate pagination - const endIndex = Math.min(startIndex + maxLength, totalLength); - const truncated = totalLength > endIndex; - - // Extract the requested portion - content = content.substring(startIndex, endIndex); - - // Add truncation message if needed - if (truncated) { - const nextStart = endIndex; - content += `\nContent truncated. If you need to see more content, call the fetch tool with a start_index of ${nextStart}.`; - return { - content, - truncated: true, - nextStartIndex: nextStart, - }; - } - - return { - content, - truncated: false, - }; - } catch (error) { - return { - // Format error message according to spec - content: `Error fetching webpage: ${getErrorMessage(error)}`, - truncated: false, - error: getErrorMessage(error), - }; - } - } -} diff --git a/src/core/chorus/api/MessageAPI.ts b/src/core/chorus/api/MessageAPI.ts index 013cd067..0e5194da 100644 --- a/src/core/chorus/api/MessageAPI.ts +++ b/src/core/chorus/api/MessageAPI.ts @@ -1281,6 +1281,9 @@ export function useStreamMessagePart() { // inject system prompts const toolsets = await getToolsets(); + const enabledToolsets = toolsets + .filter((toolset) => toolset.status.status === "running") + .map((toolset) => toolset.name); const appMetadata = await queryClient.ensureQueryData({ queryKey: appMetadataKeys.appMetadata(), queryFn: () => fetchAppMetadata(), @@ -1301,6 +1304,7 @@ export function useStreamMessagePart() { modelConfig, llmConversation: conversation, tools, + enabledToolsets, onChunk, onComplete, onError, diff --git a/src/core/chorus/api/ModelsAPI.ts b/src/core/chorus/api/ModelsAPI.ts index 6695021e..38b1aa23 100644 --- a/src/core/chorus/api/ModelsAPI.ts +++ b/src/core/chorus/api/ModelsAPI.ts @@ -116,6 +116,10 @@ function readModelConfig(row: ModelConfigDBRow): ModelConfig { } export async function fetchModelConfigs() { + // Keep user-configured providers (Vertex / custom endpoints) in sync with the DB. + await Models.syncVertexModels(db); + await Models.syncCustomProviderModels(db); + const apiKeys = await getApiKeys(); // Fetch models from various providers if we haven't already and the user has the API key. diff --git a/src/core/chorus/toolsets/web.ts b/src/core/chorus/toolsets/web.ts index fa216f6a..0025a7de 100644 --- a/src/core/chorus/toolsets/web.ts +++ b/src/core/chorus/toolsets/web.ts @@ -1,103 +1,13 @@ import { Toolset as Toolset } from "@core/chorus/Toolsets"; -import { WebTools } from "../WebTools"; -import { getApiKeys } from "../api/AppMetadataAPI"; - -/* -Spec - -Tools: -- `web_fetch` - Fetches a URL from the internet and extracts its contents as markdown. Images are omitted. - - `url` (string, required): URL to fetch - - if no protocol, should default to https:// - so input can be either like `https://example.com` or like `example.com` - - `max_length` (integer, optional): Maximum number of characters to return (default: 5000). - - `start_index` (integer, optional): Start content from this character index (default: 0). Useful if a previous fetch was truncated and more context is required. - - `raw` (boolean, optional): Get the raw HTML content of the page without markdown conversion (default: false). - - Return values: - - webpage content - - `No more content available.` - - webpage content + `Content truncated. Call the fetch tool with a start_index of {next_start} to get more content.` - - `Markdown conversion failed. You may try again with raw = true.` - - fetch errors appear like `Error fetching webpage: 403 Unauthorized` -- `web_search` - Searches the web to produce a report (with citations) on a topic. Uses Perplexity Sonar (via Perplexity or OpenRouter) to produce the report. - - `query` (string, required): The topic to search for. - - Return values: list of webpages -*/ export class ToolsetWeb extends Toolset { constructor() { - super("web", "Web", {}, "Search the web and read webpages", ""); - - this.addCustomTool( - "fetch", - { - type: "object", - properties: { - url: { - type: "string", - description: "URL to fetch", - }, - max_length: { - type: "integer", - description: - "Maximum number of characters to return (default: 50000)", - }, - start_index: { - type: "integer", - description: - "Start content from this character index (default: 0). Useful if a previous fetch was truncated and more context is required.", - }, - raw: { - type: "boolean", - description: - "Get the raw HTML content of the page without markdown conversion (default: false)", - }, - }, - required: ["url"], - additionalProperties: false, - }, - async (args) => { - const { url, max_length, start_index, raw } = args; - - const result = await WebTools.fetchWebpage(url as string, { - maxLength: max_length as number | undefined, - startIndex: start_index as number | undefined, - raw: raw as boolean | undefined, - }); - - return result.content; - }, - "Fetch a webpage from the internet and return its content as markdown. Images are omitted.", - ); - - this.addCustomTool( - "search", - { - type: "object", - properties: { - query: { - type: "string", - description: "Query to search the web for.", - }, - }, - required: ["query"], - additionalProperties: false, - }, - async (args) => { - const { query } = args; - - const apiKeys = await getApiKeys(); - if (!apiKeys) { - throw new Error("API keys are not available."); - } - const result = await WebTools.search(query as string, apiKeys); - return result.content; - }, - `Search the web to produce a report (with citations) on a topic. Uses Perplexity Sonar to produce the report. -The query should be a natural-language description of the topic to search for. For example: -- 'How has SF summer weather typically compared to NYC summer weather?' -- 'Best resources for learning to code' -Make sure to use full sentences in your query. -Assume that the user will not read the report. If you think information in the report is relevant to the user, you should repeat it.`, + super( + "web", + "Web", + {}, + "Enable native web search (OpenAI, Vertex, Gemini, Claude, Grok).", + "", ); } } diff --git a/src/core/utilities/Settings.ts b/src/core/utilities/Settings.ts index f6114dcc..35e2c5ed 100644 --- a/src/core/utilities/Settings.ts +++ b/src/core/utilities/Settings.ts @@ -14,8 +14,9 @@ export interface Settings { google?: string; perplexity?: string; openrouter?: string; - firecrawl?: string; }; + vertexAI?: VertexAISettings; + customProviders?: CustomProviderSettings[]; quickChat?: { enabled?: boolean; modelConfigId?: string; @@ -25,6 +26,30 @@ export interface Settings { cautiousEnter?: boolean; } +export type CustomProviderKind = "openai" | "anthropic"; + +export type ProviderModelDefinition = { + nickname?: string; + modelId: string; +}; + +export type VertexAISettings = { + projectId: string; + location: string; + serviceAccountClientEmail: string; + serviceAccountPrivateKey: string; + models: ProviderModelDefinition[]; +}; + +export type CustomProviderSettings = { + id: string; + kind: CustomProviderKind; + name: string; + apiBaseUrl: string; + apiKey: string; + models: ProviderModelDefinition[]; +}; + export class SettingsManager { private static instance: SettingsManager; private storeName = "settings"; @@ -50,6 +75,8 @@ export class SettingsManager { autoScrapeUrls: true, showCost: false, apiKeys: {}, + vertexAI: undefined, + customProviders: [], quickChat: { enabled: true, modelConfigId: "anthropic::claude-sonnet-4-5-20250929", @@ -74,6 +101,8 @@ export class SettingsManager { autoScrapeUrls: true, showCost: false, apiKeys: {}, + vertexAI: undefined, + customProviders: [], quickChat: { enabled: true, modelConfigId: "anthropic::claude-3-5-sonnet-latest", diff --git a/src/ui/App.tsx b/src/ui/App.tsx index 1a18315b..b3b72fdd 100644 --- a/src/ui/App.tsx +++ b/src/ui/App.tsx @@ -756,7 +756,7 @@ function AppContent() { void checkReviewsDialog(); }, [db]); - // Listen for events to open API keys settings + // Listen for events to open Settings useEffect(() => { const unlisten = listen( "open_settings", @@ -799,7 +799,7 @@ function AppContent() { Chorus is now Open Source! It now runs on your own - API keys. Add them in Settings → API Keys. + API keys. Add them in Settings → Providers.

diff --git a/src/ui/components/ApiKeysForm.tsx b/src/ui/components/ApiKeysForm.tsx index 7e385462..5a15d863 100644 --- a/src/ui/components/ApiKeysForm.tsx +++ b/src/ui/components/ApiKeysForm.tsx @@ -3,7 +3,7 @@ import { Label } from "./ui/label"; import { ProviderName } from "@core/chorus/Models"; import { ProviderLogo } from "./ui/provider-logo"; import { Card } from "./ui/card"; -import { CheckIcon, FlameIcon } from "lucide-react"; +import { CheckIcon } from "lucide-react"; import { useState } from "react"; interface ApiKeysFormProps { @@ -56,12 +56,6 @@ export default function ApiKeysForm({ placeholder: "xai-...", url: "https://console.x.ai/settings/keys", }, - { - id: "firecrawl", - name: "Firecrawl", - placeholder: "fc-...", - url: "https://www.firecrawl.dev/app/api-keys", - }, ]; return ( @@ -78,14 +72,10 @@ export default function ApiKeysForm({ onClick={() => setSelectedProvider(provider.id)} >
- {provider.id === "firecrawl" ? ( - - ) : ( - - )} + {provider.name}
{apiKeys[provider.id] && ( diff --git a/src/ui/components/ListPrompts.tsx b/src/ui/components/ListPrompts.tsx index c1ef5250..85a8dfda 100644 --- a/src/ui/components/ListPrompts.tsx +++ b/src/ui/components/ListPrompts.tsx @@ -17,7 +17,7 @@ import { TrashIcon, MixIcon, } from "@radix-ui/react-icons"; -import type { ModelConfig } from "@core/chorus/Models"; +import { getModelName, type ModelConfig } from "@core/chorus/Models"; import { Dialog, DialogContent, @@ -138,7 +138,7 @@ const PromptRow = React.memo(({ config }: { config: ModelConfig }) => { <> {config.displayName} - {config.modelId.split("::")[1]} + {getModelName(config.modelId)} {config.systemPrompt || "No system prompt"} diff --git a/src/ui/components/ManageModelsBox.tsx b/src/ui/components/ManageModelsBox.tsx index cbf1dc27..3cd2edd5 100644 --- a/src/ui/components/ManageModelsBox.tsx +++ b/src/ui/components/ManageModelsBox.tsx @@ -47,6 +47,7 @@ import { hasApiKey } from "@core/utilities/ProxyUtils"; import * as ModelsAPI from "@core/chorus/api/ModelsAPI"; import * as MessageAPI from "@core/chorus/api/MessageAPI"; import { useSettings } from "./hooks/useSettings"; +import { Settings as AppSettings } from "@core/utilities/Settings"; // Helper function to filter models by search terms const filterBySearch = (models: ModelConfig[], searchTerms: string[]) => { @@ -157,6 +158,7 @@ function ModelGroup({ onAddApiKey, groupId, showCost, + appSettings, }: { heading: React.ReactNode; models: ModelConfig[]; @@ -168,6 +170,7 @@ function ModelGroup({ onAddApiKey: () => void; groupId?: string; showCost: boolean; + appSettings?: AppSettings; }) { const { data: apiKeys } = AppMetadataAPI.useApiKeys(); @@ -181,6 +184,37 @@ function ModelGroup({ return false; } + // Vertex models require Vertex AI settings (credentials). + if (provider === "vertex") { + const vertex = appSettings?.vertexAI; + if (!vertex) { + return true; + } + const hasVertexCredentials = Boolean( + vertex.projectId.trim() && + vertex.location.trim() && + vertex.serviceAccountClientEmail.trim() && + vertex.serviceAccountPrivateKey.trim(), + ); + return !hasVertexCredentials; + } + + // Custom provider models require the custom provider to exist and have credentials. + if (provider === "custom_openai" || provider === "custom_anthropic") { + const providerId = model.modelId.split("::")[1] || ""; + const kind = provider === "custom_anthropic" ? "anthropic" : "openai"; + const customProvider = ( + appSettings?.customProviders ?? [] + ).find((p) => p.id === providerId && p.kind === kind); + if (!customProvider) { + return true; + } + return ( + !customProvider.apiBaseUrl.trim() || + !customProvider.apiKey.trim() + ); + } + // If user has API key for this provider, allow it if ( apiKeys && @@ -196,7 +230,7 @@ function ModelGroup({ // No API key for this provider - model is not allowed return true; }, - [apiKeys], + [apiKeys, appSettings], ); return ( @@ -261,7 +295,7 @@ function ModelGroup({ onAddApiKey(); }} > - Add API Key + Configure ) : ( <> @@ -498,6 +532,53 @@ export function ManageModelsBox({ (m) => getProviderName(m.modelId) === "openrouter", ); + const vertexModels = systemModels.filter( + (m) => getProviderName(m.modelId) === "vertex", + ); + + const customProviderModels = systemModels.filter((m) => { + const provider = getProviderName(m.modelId); + return provider === "custom_openai" || provider === "custom_anthropic"; + }); + + const customProviderConfigById = new Map( + (settings?.customProviders ?? []).map((p) => [p.id, p] as const), + ); + + const modelsByCustomProviderId = new Map(); + for (const model of customProviderModels) { + const providerId = model.modelId.split("::")[1] ?? ""; + if (!providerId) continue; + const existing = modelsByCustomProviderId.get(providerId); + if (existing) { + existing.push(model); + } else { + modelsByCustomProviderId.set(providerId, [model]); + } + } + + const customProviders = Array.from(modelsByCustomProviderId.entries()) + .map(([providerId, models]) => { + const config = customProviderConfigById.get(providerId); + const providerName = + config?.name ?? `Custom Provider (${providerId.slice(0, 8)})`; + const providerKind = + config?.kind ?? + (models.some( + (m) => getProviderName(m.modelId) === "custom_anthropic", + ) + ? "anthropic" + : "openai"); + return { + providerId, + providerName, + providerKind, + models: filterBySearch(models, searchTerms), + }; + }) + .filter((group) => group.models.length > 0) + .sort((a, b) => a.providerName.localeCompare(b.providerName)); + // Direct provider models grouped by provider const directProviders = [ "anthropic", @@ -523,9 +604,11 @@ export function ManageModelsBox({ custom: filterBySearch(userModels, searchTerms), local: filterBySearch(localModels, searchTerms), openrouter: filterBySearch(openrouterModels, searchTerms), + vertex: filterBySearch(vertexModels, searchTerms), + customProviders, directByProvider, }; - }, [modelConfigs.data, searchQuery]); + }, [modelConfigs.data, searchQuery, settings]); useLayoutEffect(() => { if (!listRef.current) return; @@ -805,6 +888,46 @@ export function ManageModelsBox({ /> )} + {modelGroups.vertex.length > 0 && ( + + )} + + {modelGroups.customProviders.map((group) => ( + + {group.providerName} + +

+ {group.providerKind === "anthropic" + ? "Anthropic" + : "OpenAI"} +

+
+
+ } + models={group.models} + checkedModelConfigIds={checkedModelConfigIds} + mode={mode} + onToggleModelConfig={handleToggleModelConfig} + onAddApiKey={handleAddApiKey} + groupId={`custom-provider-${group.providerId}`} + showCost={showCost} + appSettings={settings} + /> + ))} + {/* Custom Models */} {modelGroups.custom.length > 0 && ( void; + newLabel?: string; +}) { + return ( +
+
+
Nick name
+
Model ID
+
+
+ + {models.length === 0 && ( +
+ No models added yet. +
+ )} + + {models.map((m, idx) => ( +
+ { + const next = [...models]; + next[idx] = { + ...next[idx], + nickname: e.target.value, + }; + onChange(next); + }} + /> + { + const next = [...models]; + next[idx] = { ...next[idx], modelId: e.target.value }; + onChange(next); + }} + className="font-mono" + /> + +
+ ))} + +
+ + +
+
+ ); +} + +export function ProvidersTab() { + const queryClient = useQueryClient(); + const settingsManager = useMemo(() => SettingsManager.getInstance(), []); + + const [vertexAI, setVertexAI] = useState(EMPTY_VERTEX); + const [customProviders, setCustomProviders] = useState< + CustomProviderSettings[] + >([]); + const [fetchingProviderId, setFetchingProviderId] = useState< + string | null + >(null); + const [screen, setScreen] = useState({ type: "list" }); + + const invalidateModels = async () => { + await queryClient.invalidateQueries(ModelsAPI.modelQueries.list()); + await queryClient.invalidateQueries(ModelsAPI.modelConfigQueries.listConfigs()); + }; + + const persistSettings = async ( + partial: Pick, + ) => { + const current = await settingsManager.get(); + await settingsManager.set({ + ...current, + ...partial, + }); + await invalidateModels(); + }; + + useEffect(() => { + const load = async () => { + const settings = await settingsManager.get(); + setVertexAI(settings.vertexAI ?? EMPTY_VERTEX); + setCustomProviders(settings.customProviders ?? []); + }; + void load(); + }, [settingsManager]); + + useEffect(() => { + if (screen.type !== "custom") return; + const exists = customProviders.some((p) => p.id === screen.providerId); + if (!exists) { + setScreen({ type: "list" }); + } + }, [customProviders, screen]); + + const updateVertex = async (next: VertexAISettings) => { + setVertexAI(next); + await persistSettings({ vertexAI: next, customProviders }); + }; + + const updateCustomProviders = async (next: CustomProviderSettings[]) => { + setCustomProviders(next); + await persistSettings({ vertexAI, customProviders: next }); + }; + + const addCustomProvider = async () => { + const id = uuidv4(); + const next: CustomProviderSettings = { + id, + kind: "openai", + name: `Custom Provider ${customProviders.length + 1}`, + apiBaseUrl: "https://api.openai.com/v1", + apiKey: "", + models: [], + }; + await updateCustomProviders([...customProviders, next]); + setScreen({ type: "custom", providerId: id }); + }; + + const fetchModelsForProvider = async (provider: CustomProviderSettings) => { + if (provider.kind !== "openai") return; + if (!provider.apiBaseUrl.trim()) { + toast.error("Missing API Base URL"); + return; + } + + setFetchingProviderId(provider.id); + try { + const modelsUrl = `${normalizeBaseUrl(provider.apiBaseUrl)}/models`; + const headers: Record = {}; + if (provider.apiKey.trim()) { + headers.Authorization = `Bearer ${provider.apiKey}`; + } + + const response = await fetch(modelsUrl, { headers }); + if (!response.ok) { + const errorText = await response.text().catch(() => ""); + throw new Error( + errorText || `HTTP ${response.status}: ${response.statusText}`, + ); + } + + const data = (await response.json()) as unknown; + const candidates = (() => { + if ( + typeof data === "object" && + data !== null && + "data" in data && + Array.isArray((data as { data?: unknown }).data) + ) { + return (data as { data: unknown[] }).data; + } + if ( + typeof data === "object" && + data !== null && + "models" in data && + Array.isArray((data as { models?: unknown }).models) + ) { + return (data as { models: unknown[] }).models; + } + return []; + })(); + + const modelIds = candidates + .map((m) => { + if (typeof m === "object" && m !== null && "id" in m) { + const value = (m as { id?: unknown }).id; + return typeof value === "string" ? value : undefined; + } + return undefined; + }) + .filter((v): v is string => Boolean(v)); + + const nextProviders = customProviders.map((p) => + p.id === provider.id + ? { + ...p, + models: modelIds.map((id) => ({ + nickname: "", + modelId: id, + })), + } + : p, + ); + await updateCustomProviders(nextProviders); + toast.success("Models fetched", { + description: `Added ${modelIds.length} models.`, + }); + } catch (error) { + console.error("Failed to fetch models:", error); + toast.error("Failed to fetch models", { + description: + error instanceof Error ? error.message : String(error), + }); + } finally { + setFetchingProviderId(null); + } + }; + + const vertexHasCredentials = hasVertexCredentials(vertexAI); + + const upsertCustomProvider = async ( + providerId: string, + updater: (provider: CustomProviderSettings) => CustomProviderSettings, + ) => { + const next = customProviders.map((p) => + p.id === providerId ? updater(p) : p, + ); + await updateCustomProviders(next); + }; + + const deleteCustomProvider = async (providerId: string) => { + await updateCustomProviders( + customProviders.filter((p) => p.id !== providerId), + ); + setScreen({ type: "list" }); + }; + + if (screen.type === "vertex") { + return ( +
+
+ +
+ +
+

Vertex AI

+

+ Create a service account in Google Cloud Console and + paste the credentials below. +

+
+ +
+
+ + + void updateVertex({ + ...vertexAI, + projectId: e.target.value, + }) + } + placeholder="my-gcp-project" + className="font-mono" + /> +
+ +
+ + + void updateVertex({ + ...vertexAI, + location: e.target.value, + }) + } + placeholder="us-central1 (or global)" + className="font-mono" + /> +
+ +
+ + + void updateVertex({ + ...vertexAI, + serviceAccountClientEmail: e.target.value, + }) + } + placeholder="my-sa@my-gcp-project.iam.gserviceaccount.com" + className="font-mono" + /> +
+ +
+ +