diff --git a/CHANGELOG.md b/CHANGELOG.md index 3265b93..6829b80 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,16 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added + +- **`--vanilla`.** Serves every request as a plain model call instead of a full + Claude Code agent: no built-in tools, no MCP servers, no `CLAUDE.md`, and the + caller's system message replaces the agent prompt instead of extending it. + For callers that run their own tool loop (opencode being the motivating + case) and would otherwise end up fighting a second agent underneath ccal. + Off by default; existing agentic behaviour is unchanged. `GET /v1/models` + drops `"tools"` from `supported_parameters` in this mode. + ## [1.4.0] - 2026-08-06 ### Added diff --git a/CLAUDE.md b/CLAUDE.md index 178aa2f..804e904 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -27,12 +27,12 @@ endpoint. | File | Responsibility | |---|---| -| `cli.ts` | The `ccal` entry point (has the `#!/usr/bin/env node` shebang). Parses the `serve` subcommand flags (`--port`, `--host`, `--cwd`, `--permission-mode`, `--help`), probes for the `claude` binary at startup, and starts the Hono server via `@hono/node-server`. | +| `cli.ts` | The `ccal` entry point (has the `#!/usr/bin/env node` shebang). Parses the `serve` subcommand flags (`--port`, `--host`, `--cwd`, `--permission-mode`, `--allow-origin`, `--vanilla`, `--help`), probes for the `claude` binary at startup, and starts the Hono server via `@hono/node-server`. | | `server.ts` | Builds the Hono app. Mounts `GET /health`, `GET /v1/models`, and `POST /v1/chat/completions`; sets up permissive CORS; orchestrates prompt prep → spawn → stream translation for both the streaming (SSE) and non-streaming paths; reaps the child process. | | `prompt.ts` | Flattens an OpenAI `messages` array into a single stdin text payload plus a combined system prompt. System messages are joined; user/assistant turns become a labeled `User:` / `Assistant:` transcript (a lone message is sent verbatim). | -| `claude.ts` | Spawns the `claude` CLI subprocess with the exact agentic flags, writes the prompt to stdin, ensures the binary's directory is on `PATH`, and exposes stdout as an async iterator of decoded lines. | +| `claude.ts` | Spawns the `claude` CLI subprocess with the exact agentic (or, in vanilla mode, plain-model) flags, writes the prompt to stdin, ensures the binary's directory is on `PATH`, and exposes stdout as an async iterator of decoded lines. | | `stream.ts` | Translates the CLI's `stream-json` events into user-visible text: text deltas pass through; tool calls are accumulated and rendered as a compact one-line italic note. Holds the per-response `StreamState`. | -| `models.ts` | The static model list for `GET /v1/models` — `sonnet`, `opus`, `haiku` — and the default model (`sonnet`). | +| `models.ts` | The static model list for `GET /v1/models` — `sonnet`, `opus`, `haiku` — and the default model (`sonnet`). In vanilla mode, drops `"tools"` from each model's `supported_parameters`. | | `claude-binary.ts` | Resolves the absolute path to the `claude` binary across several strategies (see below), and probes its `--version` at startup. | ## The `claude` CLI contract ccal depends on @@ -46,7 +46,8 @@ claude -p \ --verbose \ --model \ --no-session-persistence \ - [--append-system-prompt ] \ + [--append-system-prompt ] # agentic mode, system content only + [--tools "" --safe-mode --strict-mcp-config --system-prompt ] # vanilla mode only [--permission-mode ] ``` @@ -57,12 +58,44 @@ claude -p \ - `--model` takes the alias from the request (`sonnet` / `opus` / `haiku`). - `--no-session-persistence` keeps each request stateless. - `--append-system-prompt` is added only when the request has system content, so - the default agent prompt (and tool use) is preserved. + the default agent prompt (and tool use) is preserved. **Agentic mode only.** - `--permission-mode` is forwarded only for an explicit non-default value. -It runs as a **full Claude Code agent** — tools, MCP servers, and `CLAUDE.md` -load based on `--cwd`. `--bare`, `--strict-mcp-config`, and `--setting-sources` -are deliberately not passed. +It runs as a **full Claude Code agent by default** — tools, MCP servers, and +`CLAUDE.md` load based on `--cwd`. `--bare`, `--strict-mcp-config`, and +`--setting-sources` are deliberately not passed in this mode. + +**Vanilla mode** (`--vanilla` on `ccal serve`, `vanilla: true` in `SpawnOptions`) +runs a plain model call instead, for callers (opencode being the motivating one) +that run their own tool loop and would otherwise end up with two agents fighting +over the same turn. Four flags change, each verified against `claude --help` +rather than assumed — the reasoning lives in the comment on the vanilla block in +`spawnClaude()` (`claude.ts`), and is worth reading in full before touching any +of them: + +- **`--tools ""`** disables every built-in tool. On its own this does *not* stop + `CLAUDE.md` or MCP servers from loading. +- **`--safe-mode`, not `--bare`.** `--bare` looks like the obvious flag for + killing `CLAUDE.md` auto-discovery, but its own help text says Anthropic auth + becomes strictly `ANTHROPIC_API_KEY` / `apiKeyHelper` — OAuth and keychain are + never read. Verified: `claude -p --bare` on a subscription-logged-in box fails + with "Not logged in - Please run /login", which would break ccal for exactly + the users it exists for. `--safe-mode` disables `CLAUDE.md`, skills, plugins, + hooks and MCP servers while explicitly leaving auth working normally. +- **`--strict-mcp-config`** is mostly redundant with what `--safe-mode` already + does (verified via the `stream-json` init event's `mcp_servers` field), but is + kept as a second, low-cost guarantee — `--safe-mode`'s own help text carves out + an exception for admin-managed settings, and it's unclear whether that could + include a managed MCP server. +- **`--system-prompt`, not `--append-system-prompt`.** Appending leaves the + agent's default system prompt in place, and even with zero tools available the + model still believes it's an agent with tools — verified: it emitted fake + `...` text instead of a plain answer. `--system-prompt` + replaces the prompt outright. When the request has no system message, ccal + substitutes a small neutral default rather than passing `--system-prompt ""` + or omitting the flag — both were tested and both fall back to the same broken + agent-framing behaviour (and a whitespace-only prompt is rejected outright by + the API). **Binary resolution order** (`claude-binary.ts`): `CCAL_CLAUDE_PATH` → scan `PATH` → ask the login shell (`$SHELL -lic 'command -v claude'`) → diff --git a/README.md b/README.md index 51f6aa2..159a60f 100644 --- a/README.md +++ b/README.md @@ -104,7 +104,9 @@ provider with base URL `http://127.0.0.1:8787/v1`, any API key, and pick one of - `GET /health` → `{ "ok": true }` - `GET /v1/models` → the OpenAI model list. Three ids are advertised, matching - the `claude` CLI's `--model` aliases: `sonnet`, `opus`, `haiku`. + the `claude` CLI's `--model` aliases: `sonnet`, `opus`, `haiku`. When the + server was started with `--vanilla`, `supported_parameters` drops `"tools"` + for every model, since the backing agent has none to use. - `POST /v1/chat/completions` → OpenAI-compatible chat completions. Supports both `stream: true` (SSE `data: {choices:[{delta:{content}}]}` frames terminated by `data: [DONE]`) and the non-streaming case (a single assembled @@ -116,7 +118,9 @@ provider with base URL `http://127.0.0.1:8787/v1`, any API key, and pick one of - `system` messages are combined and passed to claude via `--append-system-prompt` (appended to the default agent prompt, so tool use - still works). + still works). In [`--vanilla`](#vanilla-mode-no-tools-no-mcp-no-claudemd) + mode they go through `--system-prompt` instead, replacing the prompt rather + than extending it. - `user` / `assistant` messages are flattened into a single text transcript sent on stdin. A lone message is sent verbatim; a multi-turn conversation is rendered as a `User:` / `Assistant:` labeled transcript. This is one prompt in, @@ -142,14 +146,55 @@ ccal serve [options] --permission-mode Permission mode passed to claude (non-default only) --allow-origin Let a site on the public internet call this server (repeatable) + --vanilla Serve plain model calls instead of a full Claude + Code agent (no tools, no MCP servers, no CLAUDE.md) --help Show this help ``` - `--cwd` controls where claude runs, which determines the tools, MCP servers, - and `CLAUDE.md` it loads. + and `CLAUDE.md` it loads (agentic mode only — `--vanilla` turns all three off + regardless of `--cwd`). - `CCAL_CLAUDE_PATH` (environment variable) forces a specific `claude` binary when auto-detection does not find the right one. +### Vanilla mode: no tools, no MCP, no CLAUDE.md + +By default ccal runs `claude` as a **full Claude Code agent**: it has its +built-in tools, connects to whatever MCP servers your `claude` config defines, +and loads `CLAUDE.md` from `--cwd`. That is the right shape for most uses of +ccal — a chat UI or SDK that just wants the model to actually do things. + +It is the wrong shape when the *caller* is itself an agent with its own tool +loop — for example [opencode](https://opencode.ai). Point opencode at ccal +without `--vanilla` and you get two independent agents fighting over the same +turn: opencode issues tool calls of its own while the `claude` process +underneath is separately reading files, running bash, and picking up whatever +`CLAUDE.md` sits in `--cwd`. + +```sh +ccal serve --vanilla +``` + +turns every request into a plain model call: + +- No built-in tools (`--tools ""` on the `claude` invocation). +- No `CLAUDE.md`, skills, plugins, hooks, or MCP servers (`--safe-mode`, plus + `--strict-mcp-config` as a second guarantee against MCP). +- The caller's `system` message **replaces** the agent's default system prompt + (`--system-prompt`) instead of being appended to it — so the model isn't + told it's a coding agent with tools it no longer has. If a request carries + no system message, ccal substitutes a small neutral one rather than leaving + the agent framing in place. + +Your own subscription login still works — vanilla mode does *not* use +`--bare`, which would require `ANTHROPIC_API_KEY` and stop reading your +OAuth/keychain login. `GET /v1/models` also drops `"tools"` from +`supported_parameters` in this mode, since the backing model genuinely can't +use any. + +Off by default; existing consumers that rely on the agentic behaviour are +unaffected. + ### Calling ccal from a website If the page calling ccal is served over the public internet — a deployed app diff --git a/docs/architecture.md b/docs/architecture.md index f4f0df4..7e5609f 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -128,7 +128,65 @@ Notably, `--bare`, `--strict-mcp-config`, and `--setting-sources` are **not** passed. ccal runs claude as a full Claude Code agent: tools, MCP servers, and `CLAUDE.md` all load relative to `--cwd`. -Two practical details: +#### Vanilla mode + +When the server was started with `--vanilla` (`AdapterConfig.vanilla` → +`SpawnOptions.vanilla`), `spawnClaude()` builds a different invocation — a plain +model call instead of an agent: + +``` +claude -p \ + --output-format stream-json \ + --include-partial-messages \ + --verbose \ + --model \ + --no-session-persistence \ + --tools "" \ + --safe-mode \ + --strict-mcp-config \ + --system-prompt \ + [--permission-mode ] +``` + +- **`--tools ""`** disables every built-in tool (`""` is the CLI's own syntax + for "no tools"). By itself this does not stop `CLAUDE.md` or MCP servers from + loading — verified: with only `--tools ""`, a `CLAUDE.md` in `--cwd` still + leaked its content into the response. +- **`--safe-mode`** (not `--bare`) is what actually stops `CLAUDE.md` + auto-discovery, along with skills, plugins, and hooks, plus MCP servers. + `--bare` was the first instinct and is wrong here: its help text says + Anthropic auth becomes strictly `ANTHROPIC_API_KEY` / `apiKeyHelper`, and + OAuth/keychain are never read. Verified by running `claude -p --bare` on a + subscription-logged-in machine: it failed with "Not logged in - Please run + /login" even though the identical prompt without `--bare` succeeded. That + would defeat ccal's whole reason for existing — driving your existing + subscription login, no API key. `--safe-mode` gets the CLAUDE.md/MCP + suppression without that cost, while its own help text confirms auth stays + untouched. +- **`--strict-mcp-config`** is largely redundant with what `--safe-mode` + already does — verified by inspecting the `stream-json` init event's + `mcp_servers` field, which comes back empty under `--safe-mode` alone — but + is kept as a second, essentially free guarantee: `--safe-mode`'s own help + text notes that admin-managed (policy) settings still apply, and it's + unclear whether that could include a managed MCP server. +- **`--system-prompt `**, not `--append-system-prompt`. Appending leaves + the default agent system prompt in place, and the model still believes it + has tools even when none are actually wired up — verified: with + `--safe-mode --tools ""` but the prompt still appended, a "list the files" + request came back emitting fake `...` text instead of a + plain-English answer. `--system-prompt` replaces the prompt outright, which + fixes that. When the request carries no system message, ccal does **not** + pass `--system-prompt ""` or omit the flag — both were tested and both fall + back to the same broken agent-framing behaviour as appending (and a + whitespace-only prompt is rejected outright by the underlying API: "system: + text content blocks must contain non-whitespace text"). Instead it + substitutes a small neutral default system prompt. + +`GET /v1/models` also reflects vanilla mode: `supported_parameters` drops +`"tools"` for every model, since the backing agent has none in this mode (see +below). + +Two practical details (both modes): - **PATH augmentation.** claude shells out to helper binaries that may live alongside it, so ccal prepends the binary's own directory to `PATH` for the @@ -185,6 +243,15 @@ Returns a static OpenAI-shaped list of exactly three ids — `sonnet`, `opus`, `object: "model"`, `owned_by: "ccal"`, and a stable `created: 0` so responses are deterministic. The default model when a request omits `model` is `sonnet`. +`supported_parameters` is catalog metadata about what the backing agent can do +while producing a response, not a literal OpenAI tool-calling contract ccal +implements (it never reads a `tools` field off the request). `listModels()` +takes the server's `vanilla` flag and, when set, filters `"tools"` out of every +model's `supported_parameters` — advertising it would describe a capability +that mode's request path can't actually exercise. `"reasoning"` (extended +thinking) is unaffected by tool availability and stays either way — verified: +thinking tokens still show up in vanilla responses from sonnet/opus. + ## CORS and Private Network Access By default CORS is intentionally permissive (`origin: "*"`) so local browser apps diff --git a/docs/development.md b/docs/development.md index 90ce191..db1444c 100644 --- a/docs/development.md +++ b/docs/development.md @@ -87,6 +87,27 @@ You should see `data: {...}` SSE frames followed by `data: [DONE]`. > chat endpoint needs `claude` installed and logged in; if it can't be found you > get a `502` with a clear message. +**Vanilla mode:** start the server with `--vanilla` and a request that would +otherwise use a tool should instead get a plain-English answer with no tool +activity: + +```sh +node dist/cli.js serve --port 8787 --vanilla +curl http://127.0.0.1:8787/v1/chat/completions \ + -H 'Content-Type: application/json' \ + -d '{ + "model": "sonnet", + "messages": [{ "role": "user", "content": "List the files in the current directory." }] + }' +``` + +Compare against the same request without `--vanilla`: the agentic server +actually runs a tool and returns real file names (plus a `*↳ Bash: ...*` note); +the vanilla server says it can't do that and asks you to run the command +yourself. That contrast is the whole point of the flag — see [the README +section](../README.md#vanilla-mode-no-tools-no-mcp-no-claudemd) for what +changes and why. + If auto-detection doesn't find your binary, set the override: ```sh diff --git a/src/claude.ts b/src/claude.ts index 87031e9..048052c 100644 --- a/src/claude.ts +++ b/src/claude.ts @@ -8,13 +8,19 @@ import { dirname, delimiter } from "node:path"; import type { ImageContent } from "./prompt.js"; +// The system prompt used in vanilla mode when the request carries no system +// message of its own. See the vanilla-mode block in spawnClaude() for why an +// empty or omitted --system-prompt is not good enough here. +const VANILLA_DEFAULT_SYSTEM_PROMPT = "You are a helpful, honest assistant."; + // Options controlling how the claude process is launched for one request. export interface SpawnOptions { // Absolute path to the resolved `claude` binary. claudePath: string; // The model alias to pass to `--model` (e.g. "sonnet"). model: string; - // Combined system prompt to append via `--append-system-prompt`, or null. + // Combined system prompt from the request, or null. In agentic mode this is + // appended to the default agent prompt; in vanilla mode it replaces it. system: string | null; // The flattened transcript to write to claude's stdin. stdinPayload: string; @@ -23,11 +29,18 @@ export interface SpawnOptions { // text input cannot carry them). Empty/omitted keeps the default text input. images?: ImageContent[]; // Working directory for the process. When set, claude loads tools, MCP - // servers, and CLAUDE.md relative to it. Defaults to the current directory. + // servers, and CLAUDE.md relative to it (agentic mode only — vanilla mode + // disables all three regardless of cwd). Defaults to the current directory. cwd?: string; // Optional permission mode. Only forwarded when it is a non-empty, non-default - // value, matching the Rust adapter. + // value, matching the Rust adapter. Meaningless in vanilla mode (there are no + // tools to grant permission for), but still forwarded — it's harmless and it + // keeps the flag-building logic in one place. permissionMode?: string; + // Run this request as a plain model call instead of a full Claude Code agent: + // no built-in tools, no MCP servers, no CLAUDE.md, no agent system prompt. See + // the vanilla-mode block below for exactly what that changes and why. + vanilla?: boolean; } // A running claude process together with a line iterator over its stdout. @@ -38,14 +51,18 @@ export interface ClaudeProcess { lines: AsyncIterable; } -// Build an agentic claude invocation and start writing the prompt to its stdin. +// Build a claude invocation and start writing the prompt to its stdin. +// +// By default this runs as a full Claude Code agent: tools, MCP servers, and +// CLAUDE.md all load based on `cwd` (we deliberately do not pass `--bare`, +// `--strict-mcp-config`, or restrict `--setting-sources`). Any system content is +// *appended* to the default agent prompt via `--append-system-prompt` so tool +// use survives. Input is a single text prompt on stdin (the CLI's default input +// format) — one prompt in, one response out. // -// Runs as a full Claude Code agent: tools, MCP servers, and CLAUDE.md all load -// based on `cwd` (we deliberately do not pass `--bare`, `--strict-mcp-config`, -// or restrict `--setting-sources`). Any system content is *appended* to the -// default agent prompt via `--append-system-prompt` so tool use survives. Input -// is a single text prompt on stdin (the CLI's default input format) — one prompt -// in, one response out. +// `options.vanilla` switches all of that off — see the block below. This is for +// callers (like opencode) that run their own tool loop and would otherwise end +// up fighting a second, independent agent underneath ccal. export function spawnClaude(options: SpawnOptions): ClaudeProcess { const hasImages = !!options.images && options.images.length > 0; @@ -68,7 +85,70 @@ export function spawnClaude(options: SpawnOptions): ClaudeProcess { args.push("--input-format", "stream-json"); } - if (options.system !== null && options.system.length > 0) { + if (options.vanilla) { + // --- Vanilla mode: a plain model call, no agent framing. --- + // + // Four flags, each earning its place (verified against `claude --help`, + // not assumed): + // + // 1. `--tools ""` disables every built-in tool. Per the CLI's own help, + // "" means "disable all tools". This alone does NOT stop CLAUDE.md or + // MCP servers from loading (verified: with only --tools "" a CLAUDE.md + // in --cwd still leaked into the response) — it just means the model + // has nothing to call. + // + // 2. `--safe-mode`, NOT `--bare`. The obvious pick here is `--bare` (it + // turns off CLAUDE.md auto-discovery, hooks, plugins, ...), and that + // was the starting assumption. It's wrong: --bare's own help says + // Anthropic auth becomes strictly ANTHROPIC_API_KEY / apiKeyHelper, + // "OAuth and keychain are never read". Verified by running `claude -p + // --bare` on a box authenticated via subscription login (no API key + // set): it fails with "Not logged in - Please run /login", even though + // the identical prompt without --bare succeeds. That would break ccal + // for exactly the users it exists for — the whole point of ccal (see + // README) is driving the CLI's existing subscription login without an + // API key. `--safe-mode` gets us the part we actually need: its help + // text says it disables CLAUDE.md, skills, plugins, hooks, and MCP + // servers, while explicitly leaving "auth, model selection, built-in + // tools, and permissions" working normally. Verified: a CLAUDE.md that + // leaks under plain --tools "" no longer does under --safe-mode + // --tools "", and the request still succeeds against a subscription + // login. + // + // 3. `--strict-mcp-config`. In this environment --safe-mode alone already + // empties the MCP server list (verified via the stream-json init + // event), which would make this redundant. But --safe-mode's own help + // text carves out an exception — "Admin-managed (policy) settings + // still apply" — and it's not clear that carve-out excludes an + // org-managed MCP server. --strict-mcp-config costs nothing when there + // is nothing to filter (no --mcp-config is passed), so it stays as a + // second, explicit guarantee rather than leaning on --safe-mode's + // behavior alone. + // + // 4. `--system-prompt` instead of `--append-system-prompt`. Appending + // leaves Claude Code's own agent system prompt in place, and even with + // tools disabled the model still believes it's an agent with tools — + // verified: with --safe-mode --tools "" but the prompt still appended, + // a "list the files" request came back emitting fake + // `...` blocks instead of a plain-English answer. + // `--system-prompt` replaces the prompt outright, which fixes that. + // + // The subtlety: when the caller sends no system message, DO NOT pass + // `--system-prompt ""` or omit the flag. Both were tested and both + // fall back to the same broken behavior as appending — the model still + // hallucinates tool calls (and in one run, invented files that don't + // exist). A single whitespace-only string is worse still — the + // underlying API rejects it outright ("system: text content blocks + // must contain non-whitespace text"). So when there's no system + // message from the caller, we substitute a small neutral default + // (VANILLA_DEFAULT_SYSTEM_PROMPT) rather than leaving the flag out. + args.push("--tools", "", "--safe-mode", "--strict-mcp-config"); + const vanillaSystem = + options.system !== null && options.system.length > 0 + ? options.system + : VANILLA_DEFAULT_SYSTEM_PROMPT; + args.push("--system-prompt", vanillaSystem); + } else if (options.system !== null && options.system.length > 0) { args.push("--append-system-prompt", options.system); } diff --git a/src/cli.ts b/src/cli.ts index fdf8986..2be700d 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -2,7 +2,7 @@ // The `ccal` command-line entry point. It supports a single `serve` subcommand // that starts the OpenAI-compatible HTTP server: // -// ccal serve [--port ] [--host ] [--cwd ] [--permission-mode ] +// ccal serve [--port ] [--host ] [--cwd ] [--permission-mode ] [--vanilla] // // We keep argument parsing deliberately small and dependency-free — the surface // is tiny, so a hand-rolled parser is clearer than pulling in a CLI framework. @@ -74,6 +74,11 @@ interface ServeOptions { // Repeatable, empty by default — see the Private Network Access note in // server.ts for why this is opt-in rather than always on. allowedOrigins: string[]; + // Serve every request as a plain model call instead of a full Claude Code + // agent — no tools, no MCP servers, no CLAUDE.md, no agent system prompt. + // Off by default; see the vanilla-mode block in claude.ts's spawnClaude() + // for exactly what it changes and why. + vanilla: boolean; } // Default port and host, chosen to match the value documented in the README and @@ -88,6 +93,7 @@ function parseServeArgs(args: string[]): ServeOptions { port: DEFAULT_PORT, host: DEFAULT_HOST, allowedOrigins: [], + vanilla: false, }; for (let i = 0; i < args.length; i++) { @@ -132,6 +138,13 @@ function parseServeArgs(args: string[]): ServeOptions { case "--permission-mode": options.permissionMode = takeValue(); break; + case "--vanilla": + // Boolean flag: presence means on. It never consumes the next argv + // item as a value (unlike --port/--cwd/etc above) — only the + // --vanilla=false inline form can turn it back off, for symmetry with + // the other flags' --flag=value syntax. + options.vanilla = inlineValue === undefined ? true : inlineValue !== "false"; + break; case "--allow-origin": { // Validated here rather than at use, so a typo is a startup error and // not a silent "why is my site still blocked" ten minutes later. An @@ -195,6 +208,11 @@ function printHelp(): void { " Access rules; without it such a request just hangs.", " Only name sites you trust — they can drive your", " claude CLI. e.g. --allow-origin https://example.com", + " --vanilla Serve plain model calls instead of a full Claude", + " Code agent: no tools, no MCP servers, no CLAUDE.md,", + " no agent system prompt. For callers that run their", + " own tool loop (e.g. opencode) and would otherwise", + " fight a second agent underneath ccal. Off by default.", " --help Show this help", "", "Once running, point any OpenAI-compatible client at:", @@ -246,6 +264,7 @@ async function main(): Promise { cwd: options.cwd, permissionMode: options.permissionMode, allowedOrigins: options.allowedOrigins, + vanilla: options.vanilla, }; const app = createServer(config); @@ -260,6 +279,13 @@ async function main(): Promise { `Allowing public sites to reach this server: ${options.allowedOrigins.join(", ")}`, ); } + if (options.vanilla) { + // Printed for the same reason as the allow-origin note above: it is a + // deliberate, visible behaviour change, not a silent default. + console.log( + "Vanilla mode: serving plain model calls (no tools, no MCP servers, no CLAUDE.md).", + ); + } if (claude.available) { const version = claude.version ? ` (${claude.version})` : ""; console.log(`Using claude CLI at ${claude.path}${version}`); diff --git a/src/models.ts b/src/models.ts index f862885..b9e0cea 100644 --- a/src/models.ts +++ b/src/models.ts @@ -66,11 +66,25 @@ export interface ModelObject { } // Build the full OpenAI-shaped model list payload. -export function listModels(): { object: "list"; data: ModelObject[] } { +// +// `vanilla` mirrors the server's --vanilla flag. `supported_parameters` is +// catalog metadata about what the *backing agent* can do while producing a +// response, not a literal OpenAI tool-calling contract ccal implements itself +// (it doesn't read a `tools` field off the request at all). In vanilla mode +// the claude invocation runs with all tools disabled (see spawnClaude()'s +// vanilla block in claude.ts), so advertising "tools" there would describe a +// capability that request path cannot actually exercise. We drop it rather +// than leave a metadata-aware client to discover the gap by trying. Reasoning +// (extended thinking) is unaffected by tool availability — verified: thinking +// tokens still show up in vanilla responses from sonnet/opus — so it stays. +export function listModels(vanilla = false): { object: "list"; data: ModelObject[] } { return { object: "list", data: MODEL_IDS.map((id) => { const meta = MODEL_META[id]; + const supportedParameters = vanilla + ? meta.supportedParameters.filter((p) => p !== "tools") + : meta.supportedParameters; return { id, object: "model", @@ -81,7 +95,7 @@ export function listModels(): { object: "list"; data: ModelObject[] } { input_modalities: meta.inputModalities, output_modalities: ["text"], }, - supported_parameters: meta.supportedParameters, + supported_parameters: supportedParameters, description: meta.description, }; }), diff --git a/src/server.ts b/src/server.ts index 2e1fdac..d459988 100644 --- a/src/server.ts +++ b/src/server.ts @@ -32,6 +32,12 @@ export interface AdapterConfig { // internet — see the Private Network Access note in `createServer`. Empty or // omitted turns the feature off, which is the default and the safe posture. allowedOrigins?: string[]; + // Run every request as a plain model call instead of a full Claude Code + // agent: no tools, no MCP servers, no CLAUDE.md, no agent system prompt. See + // the vanilla-mode block in claude.ts's spawnClaude() for exactly what that + // changes and why. Off by default — existing consumers rely on the agentic + // behaviour, which stays completely unchanged when this is unset. + vanilla?: boolean; } // Generate a unique-ish completion id. We use the high-resolution clock in @@ -115,8 +121,10 @@ export function createServer(config: AdapterConfig = {}): Hono { // Simple liveness probe. app.get("/health", (c) => c.json({ ok: true })); - // The OpenAI model list. Mirrors the ids the CLI understands. - app.get("/v1/models", (c) => c.json(listModels())); + // The OpenAI model list. Mirrors the ids the CLI understands. In vanilla + // mode the `tools` capability is dropped from `supported_parameters` — see + // the comment on listModels() in models.ts for why. + app.get("/v1/models", (c) => c.json(listModels(config.vanilla))); // The OpenAI chat-completions endpoint. app.post("/v1/chat/completions", async (c) => { @@ -151,6 +159,7 @@ export function createServer(config: AdapterConfig = {}): Hono { images, cwd: config.cwd, permissionMode: config.permissionMode, + vanilla: config.vanilla, }); const id = genId();