- CONTEXT.md glossary - README reduced to intro, quickstart and doc index - new architecture.md, gateway.md (from proxy-key-onboarding.md), operations.md; network-access.md rewritten - coding-cli-setup: stale OMNIROUTE_PORT:-4000 endpoint, terms and duplicated per-slot/classifier explanations fixed - research/README.md index; proxy-request-priority.md moved there as superseded Every doc names keys instead of restating values and links its source. Refs #55 (#56, #57, #59, #60) Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
128 lines
7.0 KiB
Markdown
128 lines
7.0 KiB
Markdown
# Qwen Code CLI
|
|
|
|
[← back to overview](index.md)
|
|
|
|
> **Source of truth:** [Qwen Code's repo](https://github.com/QwenLM/qwen-code) for config keys; the [overview](index.md) for the gateway endpoint and context size. If this page disagrees with them, they win.
|
|
|
|
Qwen Code speaks plain **OpenAI Chat Completions**. Unlike the other CLIs, it needs *two* models: the main model, and the classifier model as its `fastModel` for Auto Mode's action classifier. Each has its own provider connection in the gateway, and both are reached through the same gateway URL.
|
|
|
|
## Why a second model exists
|
|
|
|
Auto Mode's action classifier (`permissions.autoMode`) is qwen-code's safety gate for every tool call: it decides whether to auto-approve or block a shell command or tool call before it runs. It runs on its own backend, `qwen-classifier`. Why that backend exists and how it's sized: [architecture → qwen-classifier](../architecture.md#qwen-classifier).
|
|
|
|
## `~/.qwen/settings.json`
|
|
|
|
```json
|
|
{
|
|
"modelProviders": {
|
|
"openai": [
|
|
{
|
|
"id": "<main-model-provider-id-in-omniroute>",
|
|
"name": "qwen3.8-27b-local",
|
|
"envKey": "OMNIROUTE_API_KEY",
|
|
"baseUrl": "http://<server>:<OMNIROUTE_API_PORT>/v1",
|
|
"generationConfig": { "contextWindowSize": <LLAMA_CTX_SIZE / LLAMA_PARALLEL> }
|
|
},
|
|
{
|
|
"id": "<classifier-provider-id-in-omniroute>",
|
|
"name": "qwen3-4b-classifier",
|
|
"envKey": "OMNIROUTE_API_KEY",
|
|
"baseUrl": "http://<server>:<OMNIROUTE_API_PORT>/v1",
|
|
"generationConfig": {
|
|
"contextWindowSize": <qwen-classifier --ctx-size>,
|
|
"extra_body": { "chat_template_kwargs": { "enable_thinking": false } }
|
|
}
|
|
}
|
|
]
|
|
},
|
|
"security": { "auth": { "selectedType": "openai" } },
|
|
"model": {
|
|
"name": "<main-model-provider-id-in-omniroute>",
|
|
"baseUrl": "http://<server>:<OMNIROUTE_API_PORT>/v1"
|
|
},
|
|
"fastModel": "<classifier-provider-id-in-omniroute>"
|
|
}
|
|
```
|
|
|
|
- `envKey` names the environment variable Qwen Code reads the API key from — set `OMNIROUTE_API_KEY=<qwen-code-cli API key>` before launching. Both providers can share one API key (as above); split it into two if you want separate usage tracking for chat vs. classifier calls.
|
|
- **The main model's `contextWindowSize` is the per-slot context**, not `LLAMA_CTX_SIZE` itself ([overview](index.md)).
|
|
- **The classifier's `contextWindowSize` is its whole `--ctx-size`.** It has a single slot, so there's no per-slot division ([architecture → qwen-classifier](../architecture.md#qwen-classifier)).
|
|
- `enable_thinking: false` on the classifier matters for parseability, though `Qwen3-4B-Instruct-2507` is already architecturally non-thinking (see [`research/fast-model-choice.md`](../research/fast-model-choice.md) §3) — this is belt-and-suspenders for any future fast-model swap that isn't.
|
|
- Qwen Code also recognizes `advisorModel`, `visionModel`, `compactionModel`, `imageModel` for other model roles — none are wired up in this stack; only `fastModel` is required.
|
|
|
|
## Web search via OmniRoute
|
|
|
|
Qwen Code's own built-in web search (`tools.webSearch.enabled`) has nothing to search with here — leave it `false`. Instead this stack's SearXNG-backed search ([gateway → Web search](../gateway.md#web-search)) is exposed through a thin stdio MCP wrapper around OmniRoute's `/v1/search` REST endpoint (that endpoint isn't itself MCP — OmniRoute's real MCP surface is admin-only/LOCAL_ONLY-gated). Save this as e.g. `~/.qwen/mcp-servers/omniroute-search/index.mjs` (needs `@modelcontextprotocol/sdk` and `zod`: `npm init -y && npm i @modelcontextprotocol/sdk zod` in that directory):
|
|
|
|
```js
|
|
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
|
import { z } from "zod";
|
|
|
|
const BASE_URL = process.env.OMNIROUTE_BASE_URL || "http://proxy-ai.home";
|
|
const API_KEY = process.env.OMNIROUTE_API_KEY;
|
|
|
|
if (!API_KEY) {
|
|
console.error("OMNIROUTE_API_KEY is not set in the environment.");
|
|
process.exit(1);
|
|
}
|
|
|
|
const server = new McpServer({ name: "omniroute-search", version: "1.0.0" });
|
|
|
|
server.registerTool(
|
|
"search",
|
|
{
|
|
description: "Web/news search via OmniRoute's /v1/search endpoint.",
|
|
inputSchema: { query: z.string().describe("Search query") },
|
|
},
|
|
async ({ query }) => {
|
|
const res = await fetch(`${BASE_URL}/v1/search`, {
|
|
method: "POST",
|
|
headers: { "Content-Type": "application/json", Authorization: `Bearer ${API_KEY}` },
|
|
body: JSON.stringify({ query }),
|
|
});
|
|
const text = await res.text();
|
|
if (!res.ok) return { content: [{ type: "text", text: `HTTP ${res.status}: ${text}` }], isError: true };
|
|
return { content: [{ type: "text", text }] };
|
|
}
|
|
);
|
|
|
|
await server.connect(new StdioServerTransport());
|
|
```
|
|
|
|
Register it in `~/.qwen/settings.json`:
|
|
|
|
```json
|
|
{
|
|
"mcpServers": {
|
|
"omniroute-search": { "command": "node", "args": ["<path-to>/index.mjs"] }
|
|
},
|
|
"tools": { "webSearch": { "enabled": false } }
|
|
}
|
|
```
|
|
|
|
It reuses the same `OMNIROUTE_API_KEY` env var as the model providers above — the API key needs search permission in OmniRoute, not just chat-completions.
|
|
|
|
**Non-interactive mode (`qwen -p ...`) needs this tool explicitly allow-listed.** MCP tools require interactive confirmation by default; `--approval-mode auto` alone doesn't bypass that for a non-interactive run — pass `--allowed-tools mcp__omniroute-search__search` (or `-y` for full YOLO) alongside `-p`, or the search call never reaches the classifier at all and silently no-ops. Confirmed live: without the allow-list, only the tool calls the CLI's non-interactive gate lets through end up as classifier requests.
|
|
|
|
## Auto Mode tuning
|
|
|
|
Auto Mode's action classifier calls the classifier model above. Even on its own GPU-resident backend, give it real timeout headroom rather than trusting OmniRoute's default. And since the gateway is a single trusted local endpoint, it's reasonable to pre-approve requests to it rather than confirm every call:
|
|
|
|
```json
|
|
{
|
|
"permissions": {
|
|
"autoMode": {
|
|
"classifier": { "timeouts": { "stage1Ms": 600000 } },
|
|
"hints": { "allow": ["Requests to proxy-ai.home, my own local omniroute model proxy"] }
|
|
}
|
|
}
|
|
}
|
|
```
|
|
|
|
`hints.allow` entries are free-text descriptions the classifier matches against, not exact strings — capped at 150 entries/200 chars each.
|
|
|
|
Also set a generous per-connection timeout on the classifier's own OmniRoute provider connection (`providerSpecificData.timeoutMs`, dashboard or `PATCH /api/providers/{id}` — not a `.env` value, see [gateway → Dashboard](../gateway.md#dashboard)). 120000ms is comfortable for the current GPU-resident setup (real measured latency: well under a second for a short check, low seconds for the largest realistic transcript) — this isn't the 20-minute figure the main 27B connection needs, since the classifier isn't competing for a contended GPU slot the way the main model can.
|
|
|
|
Everything else in `~/.qwen/settings.json` (`hooks`, `security.auth`'s underlying tooling, editor prefs) is per-machine, not part of pointing at this stack — don't copy it wholesale between machines.
|