diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 2adda6b2..cf1cc90c 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -27,6 +27,12 @@ jobs: - name: Install dependencies run: npm ci || npm install + # The same CLI version the Dockerfile pins: test/llm-bridge-contract.test.js drives the + # real binary through the provider bridge against a fake OpenAI endpoint (no key, no + # network) and self-skips only when the binary is missing — this keeps it from skipping. + - name: Install Claude Code CLI (pinned) + run: npm install -g @anthropic-ai/claude-code@2.1.281 + # APP_DIR / CCS_DESKTOP repoint the data directory and turn the auth wall # off. Nothing sets them on a runner, but the suites are written to be run # without them and this keeps CI identical to the documented local command. @@ -38,6 +44,7 @@ jobs: - name: Syntax check every entry point run: | for f in server.js auth.js claude-cli.js claude-ssh.js claude-interactive.js \ - claude-code-manager.js agent-dag.js config-resolve.js terminal-bridge.js; do + claude-code-manager.js agent-dag.js config-resolve.js terminal-bridge.js \ + providers.js providers-store.js providers-catalog.js llm-bridge/*.js; do [ -f "$f" ] && node -c "$f" done diff --git a/CHANGELOG.md b/CHANGELOG.md index a70b8321..726570f1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,47 @@ # Changelog +## Unreleased + +### Providers: any model, any mode (not only Claude) + +The studio could reach exactly one endpoint — whatever `ANTHROPIC_BASE_URL` said at boot — +and offered four hardcoded aliases. It now keeps a list of **providers** and every run picks +its own: chat, multi-agent, rooms, `@@bots`, Kanban tasks, schedules, Telegram and the +utility calls. Every run is still the `claude` CLI (tools, MCP, hooks, resume, compaction +unchanged); `llm-bridge/` translates for OpenAI-compatible endpoints. See `docs/providers.md`. + +- **Types:** the CLI login (Claude subscription), Anthropic API, Anthropic-compatible + endpoints (gateways, DeepSeek/Kimi/GLM/MiniMax), OpenAI-compatible endpoints (OpenAI, + OpenRouter, Gemini, Qwen, Groq, Mistral, xAI, Ollama, LM Studio…), with presets. +- **Nothing to migrate by hand.** `ANTHROPIC_BASE_URL` becomes the default provider on first + start; a bare `sonnet` in an existing row keeps meaning what it meant. +- **A model is one string** (`provider::model`), so it travels through every existing column, + WS frame, MCP tool and plan file. +- **UI:** a Providers section (test before save, catalogue import, capabilities and prices, + probe, utility model, 30-day usage); a `⋯` model picker next to the toolbar chips; the bot + editor, Kanban and Schedule list every provider's models; Telegram `/model` and `/effort`. +- **Every parameter reaches the model:** role models for the CLI's own background calls and + sub-agents, the real context window (compaction), the output cap, effort taken from the + run (the CLI sends "high" by itself), images inside tool results, reasoning echo, tool + schema sanitising, stop reasons, cached-token usage, error mapping the CLI understands. +- **Keys never enter the agent's environment** — the CLI gets a short-lived run token for a + loopback bridge. Cost and context % off Anthropic come from the bridge's usage ledger. +- **Found on the way:** the Subscription engine now honours `--effort` and a bot's model on a + task; its spawn script unsets provider variables tmux could hand the pane; `/api/translate` + no longer inherits server secrets; `set_ui_state` knows `fable`; Dispatch and Telegram send + the chat's effort; a routing refusal stops the turn instead of burning the auto-continues. +- **No engine dial.** The toolbar's API/Subscription switch (and ★ default), the mobile one, + Kanban's, Schedule's and the bot editor's are gone: the chosen model's provider decides — + the CLI login ("Claude (CLI login)", marked *subscription*) runs on tmux, every other + provider (marked *API*, e.g. the "Claude API" preset with an Anthropic key) headless. A + one-time boot migration turns every explicit "Subscription" into a `claude::` pin, + and the `defaultEngine` setting / `PUT /api/default-engine` are removed. +- **Bot editor:** one Model button with the toolbar's picker ("Same as chat" on top); the + starter-prompt templates (Analyst, Writer, Reviewer, Explainer) are gone — they overwrote + the bot's own prompt. +- The Claude Code CLI is pinned (`CLAUDE_CODE_VERSION=2.1.281`) and CI runs a contract test + that drives the real binary through the bridge. + ## 7.17.0 ### Add project from a Git URL (#94) diff --git a/CLAUDE.md b/CLAUDE.md index fd18ba1d..1632dea1 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -81,7 +81,7 @@ Environment (`.env`, see `.env.example`): - `MULTI_AGENT_CONCURRENCY` — max `claude` subprocesses alive at once inside one wave, default 3. A wave still runs every member; this only bounds how many at a time. -**Models** — the UI exposes four choices: `haiku`, `sonnet` (default), `opus`, `fable`. The short aliases are mapped in `claude-cli.js` (`MODEL_MAP`, lines 81-89) and passed to the `claude` CLI **as-is** (`opus`→`opus`, `sonnet`→`sonnet`, `haiku`→`haiku`, `fable`→`fable`); the CLI resolves each alias internally. `server.js` defines no model map — it defers to `claude-cli.js`. Dated model IDs are kept commented out in `MODEL_MAP` and are not used. +**Models** — a model is ONE string: a bare alias (`haiku`, `sonnet` (default), `opus`, `fable`, or an old gateway id) resolved on the **default provider**, or a provider ref `provider::model` (`deepseek::deepseek-chat`). The toolbar's one model button opens a picker that sends a bare alias for the default provider's aliases and a ref for everything else. `providers.js` resolves a value per run (`resolveModel`), `server.js resolveRunTarget()` turns that into the child's `--model` + environment, and `claude-cli.js` asks it through `setRunTargetHook` on EVERY `send()` — do not add a second routing path. See **Providers & LLM bridge** below and `docs/providers.md`. ## MCP & Skills @@ -135,8 +135,9 @@ splice the room's rounds into the thread the bot uses when mentioned normally. Not built: the room's artifact is not yet consumed by a DAG node (roadmap item 6b). ### Engines -- `api` (default) — headless `claude -p` via `runCliSingle` in server.js -- `subscription` — persistent interactive tmux session (one per chat) via `claude-interactive.js`, billed on the Claude Max subscription (UI button "Subscription"). MCP servers passed via `--mcp-config` at spawn; systemPrompt via `--append-system-prompt` at spawn; config changes (skills/mode/MCP/model) auto-respawn the tmux session with `--resume` — verified to keep writing to the same transcript jsonl. Image attachments saved to temp files, paths appended to the prompt. maxTurns is not applicable. Choice persisted per session in `sessions.run_engine` (the `engine` column belongs to telegram-bot.js — do not reuse it). +Which engine a run uses is decided by its MODEL's provider (`runEngineFor`, see Providers below) — there is no engine button. +- `api` — headless `claude -p` via `runCliSingle` in server.js; every provider except the CLI login +- `subscription` — persistent interactive tmux session (one per chat) via `claude-interactive.js`, billed on the Claude Max subscription; the models of the CLI-login provider when tmux exists. MCP servers passed via `--mcp-config` at spawn; systemPrompt via `--append-system-prompt` at spawn; config changes (skills/mode/MCP/model) auto-respawn the tmux session with `--resume` — verified to keep writing to the same transcript jsonl. Image attachments saved to temp files, paths appended to the prompt. maxTurns is not applicable. The engine of the last turn is recorded in `sessions.run_engine` (the `engine` column belongs to telegram-bot.js — do not reuse it). **Interactive prompts (#20).** A subscription turn can stop on a blocking select widget (permission question, plan approval, AskUserQuestion). `paneAwaitingInput()` @@ -204,7 +205,7 @@ shrink its xterm to that pane. The user saw a 46-column strip and nothing else. can pin deterministically. -**Platform support — capability-checked, not OS-sniffed.** `/api/version` returns `tmuxAvailable` (server runs `tmux -V` once at boot); the UI disables the "Subscription" button when false. Works on macOS / Linux / Docker (tmux in Dockerfile) / Windows-via-WSL or Git-Bash. Native Windows without tmux → button disabled, user stays on `api`. There is intentionally no Windows special-casing — the capability flag covers every case. +**Platform support — capability-checked, not OS-sniffed.** `/api/version` returns `tmuxAvailable` (server runs `tmux -V` once at boot); without tmux the CLI-login models run headless (`runEngineFor`, `engineOfModel` in the SPA). Works on macOS / Linux / Docker (tmux in Dockerfile) / Windows-via-WSL or Git-Bash. Native Windows without tmux → every run is `api`. There is intentionally no Windows special-casing — the capability flag covers every case. ### WebSocket Protocol — Do Not Break The entire UI depends on this exact message contract: @@ -245,7 +246,20 @@ sonnet haiku fable ``` -These short aliases are exactly what `claude-cli.js` (`MODEL_MAP`) passes to the CLI — pass them through **as-is**; the `claude` binary resolves each one internally. Do not use dated model-ID suffixes in CLI flags (the dated IDs are kept commented out in `MODEL_MAP`). +On a provider that serves Claude (the CLI login, Anthropic, a Claude gateway) these aliases are passed to the CLI **as-is** and the `claude` binary resolves them. On any other provider they map through that provider's roles (`haiku→fast`, `sonnet/fable→main`, `opus→strong`) — `providers.resolveModel()` does it, nothing else should. Do not use dated model-ID suffixes in CLI flags. + +### Providers & LLM bridge + +`providers.js` (pure rules) · `providers-store.js` (SQLite: `providers`, `provider_models`, `provider_settings`, `llm_usage`) · `providers-catalog.js` (GET /models) · `llm-bridge/` (a separate `node` process on 127.0.0.1, spawned like the MCP helpers). Full picture: `docs/providers.md`. + +- **One choke point.** `ClaudeCLI.send()` calls the routing hook for every headless run; `/api/translate` (the only raw spawn) asks the same hook. A new call site that spawns `claude` some other way skips routing, the bridge token and the usage ledger. +- **The child never sees a provider key.** A bridge run gets `ANTHROPIC_BASE_URL=` and a short-lived `ccsr_` run token; `buildRunEnv()` strips every inherited `ANTHROPIC_*` first. Keep new provider variables in `PROVIDER_ENV_VARS` or they leak from the server env. +- **The engine is the model's provider, never a dial.** `server.js runEngineFor(model)` (`providers.engineForModel`) is the one rule for chats, tasks, bots and Telegram: the CLI-login provider (`claude-subscription`, marked *subscription* in every list) runs on the tmux engine when tmux exists, everything else headless. Do not reintroduce an engine field — `sessions.run_engine` only records the last turn's engine, `tasks`/`bots.run_engine` are no longer read (boot migration `migrateEngineDial`). The spawn script unsets every provider variable because tmux hands a pane its SERVER's environment. +- **A test server that drives runs through a fake headless `claude` spreads `test/_api-provider-env.js` into its env.** With no gateway the default provider is the CLI login, and in CI (tmux installed, no `ANTHROPIC_BASE_URL`) its models go to the tmux engine — the suite then passes on a developer box whose shell exports `ANTHROPIC_BASE_URL` and fails only in CI. +- **Effort comes from the run, not the request.** The CLI sends `effort: "high"` even with no flag, so the bridge takes `RunCtx.effort`; `Auto` reaches it as `'auto'` (send none). +- **A routing refusal is a stop, not a retry.** `auth-errors.js` classifies it (`provider_unavailable`, `provider_key`) so the auto-continue ladders stop at once. +- **Cost and window off Anthropic come from the ledger**, not from the CLI (it prices unknown ids with its Claude table and assumes 200K): `withProviderUsage()`. +- **The CLI version is pinned** (Dockerfile `CLAUDE_CODE_VERSION`, CI). `test/llm-bridge-contract.test.js` drives the real binary through the bridge; bump the pin and that test together. ### Remote shell environment (issue #59) @@ -610,9 +624,10 @@ user had just picked in the modal — and silent about a bot's model, which OVER the session's inside the runner. `kbRunBadges()` in `public/kanban.html` replaces it. - **The card mirrors `startTask`, it does not re-derive.** Model is - `taskBot?.model || session?.model || task.model || 'sonnet'`; effort and engine come - off the TASK row (`task.effort`, `task.run_engine`), which is the #79 rule that those - dials drive every run regardless of which session the task uses. The two live in + `taskBot?.model || session?.model || task.model || 'sonnet'`; effort comes off the + TASK row (`task.effort`), which is the #79 rule that it drives every run regardless of + which session the task uses; the engine is derived from that same model + (`runEngineFor` ↔ `kbEngineOf`) — `task.run_engine` is no longer read. The two live in different files, so `test/kanban-run-badges.test.js` pins the runner's expression as source text as well as the helper's behaviour — a reorder there makes the card lie and nothing else notices. @@ -629,11 +644,11 @@ the session's inside the runner. `kbRunBadges()` in `public/kanban.html` replace run is exactly the thing a silent badge would hide. - **The chain card (`renderChainCard`) still shows `chain.model` alone.** Deliberately out of scope; a chain has no bot and no per-task engine. -- **Choosing a non-Claude PROVIDER per task is NOT this.** The Kanban worker parses - `claude`'s `stream-json`, and external agents (`config.externalAgents`) reach the - studio only through `/api/delegate` and the terminal, which is fire-and-forget with - no turn budget, no retry and no session resume. Wiring one into `taskWorker` is a new - execution backend, not a dropdown. +- **External agents are still NOT a task backend.** A task can run any provider's model + (the model picker), but external agents (`config.externalAgents`) reach the studio only + through `/api/delegate` and the terminal, which is fire-and-forget with no turn budget, + no retry and no session resume. Wiring one into `taskWorker` is a new execution + backend, not a dropdown. ### Add project from a Git URL (issue #94) diff --git a/Dockerfile b/Dockerfile index 5282be05..1f87d177 100644 --- a/Dockerfile +++ b/Dockerfile @@ -5,8 +5,12 @@ RUN apt-get update && apt-get install -y \ git curl python3 python3-pip build-essential tmux \ && rm -rf /var/lib/apt/lists/* -# Install Claude Code CLI globally -RUN npm install -g @anthropic-ai/claude-code +# Install Claude Code CLI globally — PINNED. The provider bridge (llm-bridge/) translates +# the requests this exact CLI sends; an unpinned install lets any rebuild pull a CLI whose +# request shape nobody has run the contract test against (test/llm-bridge-contract.test.js). +# Bump deliberately, together with that test: docker build --build-arg CLAUDE_CODE_VERSION=… +ARG CLAUDE_CODE_VERSION=2.1.281 +RUN npm install -g @anthropic-ai/claude-code@${CLAUDE_CODE_VERSION} # Install opencode CLI globally — an externalAgents engine (config.json), so it # must be baked into the image rather than installed live: /app is an image diff --git a/auth-errors.js b/auth-errors.js index 013e397b..2039e35f 100644 --- a/auth-errors.js +++ b/auth-errors.js @@ -56,6 +56,10 @@ const AUTH_ERROR_RE = new RegExp([ "run\\s+`?claude\\s+login`?\\s+to\\s+(?:re-?)?authenticate", "not\\s+logged\\s+in\\s+to\\s+claude", "credentials\\s+(?:are\\s+)?(?:invalid|expired)", + // The studio's own provider router refusing a run (server.js resolveRunTarget): the + // run never started, and retrying cannot start it — same class as a dead login. + "needs\\s+the\\s+llm\\s+bridge,\\s+which\\s+is\\s+not\\s+running", + "model\\s+\"[^\"\\n]{1,200}\"\\s+cannot\\s+run:", ].join('|'), 'i'); /** @@ -72,6 +76,21 @@ function isAuthError(text = '') { // Each carries the ACTION that resolves it, because "authentication failed" without // a next step is the complaint the issue was filed about. const AUTH_KINDS = [ + { + kind: 'provider_unavailable', + re: /needs\s+the\s+llm\s+bridge,\s+which\s+is\s+not\s+running|model\s+"[^"\n]{1,200}"\s+cannot\s+run:/i, + label: 'The chat\'s model cannot run right now', + hint: 'Open Providers in the sidebar: the provider needs the LLM bridge or has no model enabled — or pick a model of another provider.', + }, + { + // A provider from the registry, reached through llm-bridge: the bridge prefixes every + // upstream error with `provider "