From e4cf95e774691b399a235fec1592e483c86e0c81 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=98=93=E8=89=AF?= <1204183885@qq.com> Date: Tue, 3 Nov 2026 11:26:02 +0800 Subject: [PATCH] feat(config): add operator model catalog refresh switch (#16066) * feat(config): add operator model catalog refresh switch Honor a settings-level refresh switch from operator scopes, including bare mode, and skip the background models.dev request when usage statistics are disabled. Workspace settings cannot re-enable the refresh, and the existing environment-variable off switch still wins. Co-authored-by: Qwen-Coder * style(config): make catalog refresh change set prettier-clean `Lint & Static` failed in the incremental Prettier step (run 41303341242, job 124838743484) on six files that this PR itself touches: - docs/users/configuration/settings.md - packages/core/src/config/config.ts - packages/core/src/config/config.test.ts - packages/cli/src/config/config.test.ts - packages/cli/src/config/execution-sandbox-settings.test.ts - packages/cli/src/config/settingsSchema.test.ts Applied `prettier --write` with the repo config (prettier 3.6.1, the version pinned in pnpm-lock.yaml). Formatting only: no behavior change. Co-authored-by: Qwen-Coder Patrol-Run: qwen-pr-closeout/jmvy5ts5g7c * fix(cli): mirror modelCatalogRefreshEnabled in mcp reconnect The throwaway Config built by qwen mcp reconnect passed usageStatisticsEnabled but not modelCatalogRefreshEnabled, so the catalog-refresh gate defaulted to on and the command still contacted https://models.dev/api.json even when the user had model.catalogRefresh: false. The env var has no catalog equivalent, so only the settings value is mirrored, matching loadCliConfig's resolution. The reconnect suite passes at this head (38 passed). * fix(config): keep the operator catalog refresh opt-out when a project replaces `model` A trusted workspace that replaced the whole `model` section (`null`, `false`, `[]`, or a string) left `stripSettingKeys` nothing to strip, and `customDeepMerge` then assigned that value over the merged object. The operator's `model.catalogRefresh: false` disappeared, `loadCliConfig` read `undefined`, and core fell back to `true`, so a host that opted out of the models.dev download fetched it anyway. Restore the operator value in `mergeSettings` the same way `tools.executionSandbox` already is, and state that the `privacy.usageStatisticsEnabled` route is not workspace-restricted. Co-authored-by: Qwen-Coder Patrol-Run: qwen-pr-closeout/jmvzl9mtg9d * fix(cli): carry the operator catalog refresh opt-out into the sandbox The container mounts only the user settings dir and builds its env passthrough from `process.env`, so a System-scope `model.catalogRefresh: false` never reached the sandboxed CLI and it still fetched models.dev. Set the refresh env var at the sandbox handoff, which covers both the docker/podman passthrough and the seatbelt child env. Also move the operator scope reduce into a shared `selectOperatorCatalogRefresh` beside `selectOperatorExecutionSandbox` instead of keeping a second copy of the rule, and pin that a project which replaced the whole `model` section keeps its own keys. Co-authored-by: Qwen-Coder Patrol-Run: qwen-pr-closeout/jmvzpjyde9j * fix(cli): carry the usage-statistics opt-out into the sandbox handoff Core refreshes the models.dev catalog only when both `modelCatalogRefreshEnabled` and `usageStatisticsEnabled` are true, but the sandbox handoff exported the opt-out for `model.catalogRefresh` alone. A System/SystemDefaults-scope `privacy.usageStatisticsEnabled: false` is neither mounted into the container nor forwarded, so the containerized CLI still fetched models.dev despite the documented second opt-out. Resolve the privacy switch with the same helper `loadCliConfig` uses, so the env precedence matches, and export the opt-out when either half is off. Tests: cover the usage-statistics direction and the "no opt-out" negative case, and pin the `model.catalogRefresh` workspace strip by key so deleting the restricted-list entry fails instead of removing an iteration. Co-authored-by: Qwen-Coder Patrol-Run: qwen-pr-closeout/jmvztu9wu9q --------- Co-authored-by: Qwen-Coder Co-authored-by: yiliang114 --- ...026-11-01-model-catalog-refresh-setting.md | 23 +++++ ...-01-model-catalog-refresh-setting.zh-CN.md | 23 +++++ docs/users/configuration/settings.md | 81 +++++++++--------- packages/cli/src/commands/mcp/reconnect.ts | 10 +++ packages/cli/src/config/config.test.ts | 19 +++++ packages/cli/src/config/config.ts | 4 + .../config/execution-sandbox-settings.test.ts | 35 ++++++++ .../src/config/execution-sandbox-settings.ts | 22 ++++- packages/cli/src/config/settings.test.ts | 20 +++++ packages/cli/src/config/settings.ts | 18 ++++ .../cli/src/config/settingsSchema.test.ts | 18 ++++ packages/cli/src/config/settingsSchema.ts | 13 ++- packages/cli/src/config/settingsUtils.ts | 1 + packages/cli/src/llm.test.tsx | 83 +++++++++++++++++++ packages/cli/src/llm.tsx | 14 ++++ packages/core/src/config/config.test.ts | 25 ++++++ packages/core/src/config/config.ts | 8 +- .../schemas/settings.schema.json | 7 +- 18 files changed, 382 insertions(+), 42 deletions(-) create mode 100644 docs/design/2026-11-01-model-catalog-refresh-setting.md create mode 100644 docs/design/2026-11-01-model-catalog-refresh-setting.zh-CN.md diff --git a/docs/design/2026-11-01-model-catalog-refresh-setting.md b/docs/design/2026-11-01-model-catalog-refresh-setting.md new file mode 100644 index 00000000000..2156845f741 --- /dev/null +++ b/docs/design/2026-11-01-model-catalog-refresh-setting.md @@ -0,0 +1,23 @@ +# Operator-controlled models.dev catalog refresh + +[English](2026-11-01-model-catalog-refresh-setting.md) | [简体中文](2026-11-01-model-catalog-refresh-setting.zh-CN.md) + +## Problem + +Every session initializes the model catalog refresh, which may contact `models.dev` once every 24 hours. The only current disable switches are process environment variables. Managed installations cannot reliably set those variables across terminal, desktop, and service launch paths, and workspace environment values are deliberately rejected. + +## Decision + +Add `model.catalogRefresh`, a boolean with default `true`, to the existing `model` settings section. Honor it only from User, System, and SystemDefaults scopes by adding it to `WORKSPACE_RESTRICTED_SETTINGS`. Resolve that operator value before constructing the session `Config`, including in bare mode. + +The CLI passes the setting to core as `modelCatalogRefreshEnabled`. Core starts the background refresh only when the setting is not `false` and the resolved usage-statistics setting is enabled. The env-only refresh gate remains unchanged, so `QWEN_CODE_MODELS_DEV_REFRESH=off` still wins. The bundled and previously downloaded catalog remain available; this switch controls network refresh, not local catalog reads. + +Treat `privacy.usageStatisticsEnabled: false` as an opt-out from this background third-party request. Unlike the dedicated setting, that privacy choice is already preserved across operator scopes and bare mode. Document both controls so neither appears to promise traffic suppression while another path sends it. + +## Scope and risks + +Do not expose the setting in the settings dialog, introduce a three-state mode, or allow workspace settings to re-enable refresh. Do not change catalog lookup or the separate `QWEN_CODE_MODELS_DEV=off` behavior. The main compatibility risk is operators expecting a privacy opt-out to suppress only telemetry; the setting is documented explicitly to avoid that ambiguity. + +## Verification + +Test that the schema requires restart and defaults on, workspace values are stripped, all operator scopes resolve with normal precedence, bare mode preserves the choice, and CLI construction forwards only a boolean. Test that core skips the refresh for either the settings switch or the usage-statistics opt-out and that the environment-variable gate remains intact. Synchronize the generated settings schema and both language versions of the design. diff --git a/docs/design/2026-11-01-model-catalog-refresh-setting.zh-CN.md b/docs/design/2026-11-01-model-catalog-refresh-setting.zh-CN.md new file mode 100644 index 00000000000..96fbf578edf --- /dev/null +++ b/docs/design/2026-11-01-model-catalog-refresh-setting.zh-CN.md @@ -0,0 +1,23 @@ +# 由运维控制的 models.dev 目录刷新 + +[English](2026-11-01-model-catalog-refresh-setting.md) | [简体中文](2026-11-01-model-catalog-refresh-setting.zh-CN.md) + +## 问题 + +每个会话初始化时都可能启动模型目录刷新,进而每 24 小时访问一次 `models.dev`。当前只能用进程环境变量关闭。托管安装无法保证终端、桌面端和服务端每条启动路径都设置这些变量,而工作区环境值会被有意拒绝。 + +## 决策 + +在现有 `model` 设置分组中增加布尔开关 `model.catalogRefresh`,默认值为 `true`。通过加入 `WORKSPACE_RESTRICTED_SETTINGS`,仅允许 User、System 和 SystemDefaults 作用域生效。在构造会话 `Config` 之前完成该运维值的解析,`--bare` 模式同样保留该选择。 + +CLI 以 `modelCatalogRefreshEnabled` 传给 core。只有该设置不为 `false` 且解析后的使用统计设置为开启时,core 才启动后台刷新。仅读环境变量的门禁保持不变,因此 `QWEN_CODE_MODELS_DEV_REFRESH=off` 仍优先。内置及此前下载的目录继续可用;该开关只控制网络刷新,不控制本地目录读取。 + +将 `privacy.usageStatisticsEnabled: false` 作为该后台第三方请求的退出方式。与专用设置不同,这一隐私选择已经跨运维作用域并在 `--bare` 模式下保留。两处都要写入文档,避免其中一个看似承诺抑制流量、另一个路径却继续发送。 + +## 范围与风险 + +不把设置暴露到设置对话框,不引入三态模式,也不允许工作区设置重新开启刷新。不改变目录查找逻辑,也不改变独立的 `QWEN_CODE_MODELS_DEV=off` 行为。主要兼容性风险是运维者以为隐私退出只会关闭遥测;文档需明确说明该关联,避免歧义。 + +## 验证 + +验证 schema 要求重启且默认开启,工作区值会被剥离,所有运维作用域按正常优先级解析,`--bare` 保留选择,CLI 构造只透传布尔值。验证 core 在设置关闭或使用统计退出时都跳过刷新,且环境变量门禁保持有效。同步生成的设置 schema 和中英文设计文档。 diff --git a/docs/users/configuration/settings.md b/docs/users/configuration/settings.md index 4dd9582f54f..7f81c972542 100644 --- a/docs/users/configuration/settings.md +++ b/docs/users/configuration/settings.md @@ -184,9 +184,9 @@ label is shown, including on narrow screens, the parentheses are omitted. #### privacy -| Setting | Type | Description | Default | -| -------------------------------- | ------- | -------------------------------------- | ------- | -| `privacy.usageStatisticsEnabled` | boolean | Enable collection of usage statistics. | `true` | +| Setting | Type | Description | Default | +| -------------------------------- | ------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------- | +| `privacy.usageStatisticsEnabled` | boolean | Enable collection of usage statistics. Disabling this also stops the models.dev catalog refresh. Unlike `model.catalogRefresh`, this key is not workspace-restricted. | `true` | #### model @@ -195,6 +195,7 @@ label is shown, including on narrow screens, the parentheses are omitted. | `model.name` | string | The Qwen model to use for conversations. | `undefined` | | `model.reasoningEffort` | enum | How hard reasoning-capable models think, applied across all providers. Set with the [`/effort`](../features/commands) command (`low`, `medium`, `high`, `xhigh`, `max`). Each provider maps and clamps this to what the active model supports (e.g. Gemini caps at `high`; Anthropic clamps tiers a model lacks). Leave unset to use the model/provider default. | `undefined` | | `model.baseUrl` | string | Persisted automatically by the model picker to disambiguate when multiple `modelProviders` entries share the same model id. Not intended to be set by hand — use the `/model` picker or a `modelProviders` entry instead; a stale hand-edited value can silently route requests to a different same-id provider. | `undefined` | +| `model.catalogRefresh` | boolean | Download the models.dev model catalog in the background at most once every 24 hours. Disable it in managed installations that must not contact models.dev; the bundled and previously downloaded catalog data remain available. Only honored from User, System, and SystemDefaults settings scopes; values set in Workspace settings are ignored. The `QWEN_CODE_MODELS_DEV_REFRESH=off` process environment variable still wins. Usage-statistics opt-out also disables the refresh. Changes take effect after restart. | `true` | | `model.sessionTokenLimit` | number | Maximum recorded prompt token count allowed before sending the next message. `-1` means unlimited; `0` is also treated as unlimited (unlike `model.maxToolCalls`, where `0` disallows all calls). When the recorded prompt count exceeds the limit, the next send is dropped (the session is not aborted). | `-1` | | `model.maxSessionTurns` | integer | Maximum number of user/model/tool turns to keep in a session. -1 means unlimited. | `-1` | | `model.maxWallTimeSeconds` | number | Wall-clock budget for headless / unattended runs, in seconds. `-1` means unlimited. Overridable per-invocation via `--max-wall-time`, which requires a positive duration (`90`, `30s`, `5m`, `1h`, `1.5h`); the minimum is 1 second — sub-second values (`500ms`, `0.5`) are rejected as typos. Omit the flag to fall back to this setting. Aborts with exit code 55 when exceeded. | `-1` | @@ -854,40 +855,40 @@ For authentication-related variables (like `OPENAI_*`) and the recommended `.qwe ### Environment Variables Table -| Variable | Description | Notes | -| ---------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `QWEN_HOME` | Customizes the global configuration directory (default: `~/.qwen`). Accepts an absolute or relative path (relative paths are resolved from the current working directory). Leading `~` is expanded to the user's home directory. | Stores credentials, settings, memory, skills, and other global state. When set, project-level `.qwen/` directories are unaffected. An empty string is treated as unset. | -| `QWEN_RUNTIME_DIR` | Overrides the runtime output directory (conversations, logs, todos). When unset, defaults to the `QWEN_HOME` directory. | Use this to separate ephemeral runtime data from persistent config. Useful when `QWEN_HOME` is on a shared/slow filesystem. | -| `QWEN_USAGE_STATISTICS_ENABLED` | Set to `true` or `1` to enable usage statistics. Any other value is treated as disabling them. | Overrides the `privacy.usageStatisticsEnabled` setting. Defaults to enabled when neither is configured. | -| `QWEN_TELEMETRY_ENABLED` | Set to `true` or `1` to enable telemetry. Any other value is treated as disabling it. | Overrides the `telemetry.enabled` setting. | -| `QWEN_TELEMETRY_TARGET` | Sets an informational label for the telemetry destination (`local` or `gcp`). Does not control routing; use `QWEN_TELEMETRY_OTLP_ENDPOINT` or `QWEN_TELEMETRY_OUTFILE` to configure where data is sent. | Overrides the `telemetry.target` setting. | -| `QWEN_TELEMETRY_OTLP_ENDPOINT` | Sets the OTLP endpoint for telemetry. | Overrides the `telemetry.otlpEndpoint` setting. | -| `QWEN_TELEMETRY_OTLP_PROTOCOL` | Sets the OTLP exporter protocol: `grpc` (gRPC/protobuf) or `http` (HTTP/JSON, `application/json`). | Overrides the `telemetry.otlpProtocol` setting. | -| `QWEN_TELEMETRY_LOG_PROMPTS` | Set to `true` or `1` to enable or disable logging of user prompts. Any other value is treated as disabling it. | Overrides the `telemetry.logPrompts` setting. | -| `QWEN_TELEMETRY_USER_ID` | Sets a stable end-user identifier on interaction, LLM, Tool, and Agent spans as `gen_ai.user.id`. Prefer a pseudonymous value. | Overrides `telemetry.userId` after trimming. A blank value falls back to settings. This is process-wide and must not be used as per-request identity in a shared multi-user process. | -| `QWEN_TELEMETRY_INCLUDE_SENSITIVE_SPAN_ATTRIBUTES` | Set to `true` or `1` to attach verbatim user prompts, system prompts, tool I/O, and model responses to native OTel span attributes (and keep `function_args`, `error`, `error.message`, and `error_message` on log-to-span bridge spans, plus `prompt` / `request_text` / `response_text` when `telemetry.logPrompts` is also enabled). Any other value disables it. | Overrides the `telemetry.includeSensitiveSpanAttributes` setting. ⚠️ Streams sensitive data to your OTLP backend. | -| `QWEN_TELEMETRY_SENSITIVE_SPAN_ATTRIBUTE_MAX_LENGTH` | Sets the maximum JavaScript string length for each sensitive native OTel span attribute content payload. Must be a positive integer no greater than `104857600` (100 MiB). | Overrides the `telemetry.sensitiveSpanAttributeMaxLength` setting. Default is `1048576` (1 MiB); lower it if your collector or backend rejects large span attributes. | -| `QWEN_TELEMETRY_OUTFILE` | Sets the file path to write telemetry to. When set, overrides OTLP export. | Overrides the `telemetry.outfile` setting. | -| `QWEN_SANDBOX` | Alternative to the `sandbox` setting in `settings.json`. | Accepts `true`, `false`, `docker`, `podman`, or a custom command string. | -| `QWEN_SANDBOX_IMAGE` | Overrides sandbox image selection for Docker/Podman. | Takes precedence over `tools.sandboxImage`. | -| `SEATBELT_PROFILE` | (macOS specific) Switches the Seatbelt (`sandbox-exec`) profile on macOS. | `permissive-open`: (Default) Restricts writes to the project folder (and a few other folders, see `packages/cli/src/serve/sandbox-macos-permissive-open.sb`) but allows other operations. `strict`: Uses a strict profile that declines operations by default. ``: Uses a custom profile. To define a custom profile, create a file named `sandbox-macos-.sb` in your project's `.qwen/` directory (e.g., `my-project/.qwen/sandbox-macos-custom.sb`). | -| `DEBUG` or `DEBUG_MODE` | (often used by underlying libraries or the CLI itself) Set to `true` or `1` to enable verbose debug logging, which can be helpful for troubleshooting. | **Note:** These variables are automatically excluded from project `.env` files by default to prevent interference with the CLI behavior. Use `.qwen/.env` files if you need to set these for Qwen Code specifically. | -| `NO_COLOR` | Set to any value to disable all color output in the CLI. | | -| `FORCE_HYPERLINK` | Override the OSC 8 clickable-link detection in the markdown renderer. Set to `1` (or any non-zero integer, or empty string) to force-enable; set to `0` or a non-numeric value such as `false` / `off` to force-disable. Honors `NO_COLOR` / `QWEN_DISABLE_HYPERLINKS` opt-outs above it. | Use this to opt into OSC 8 inside `tmux` / GNU `screen` (auto-detection refuses by default because the host terminal's capabilities are hidden behind the multiplexer). Requires `set -g allow-passthrough on` on tmux 3.3+. Also enables Hyper, which isn't auto-detected. | -| `QWEN_DISABLE_HYPERLINKS` | Set to `1` to hard-disable OSC 8 clickable hyperlinks in the markdown renderer even on terminals that auto-detect as capable. | Useful when a terminal advertises support but breaks on long URLs, or when piping output through an intermediary that mangles escape sequences. The renderer falls back to plain `label (url)` rendering. | -| `CLI_TITLE` | Set to a string to customize the title of the CLI. | | -| `CODE_ASSIST_ENDPOINT` | Specifies the endpoint for the code assist server. | This is useful for development and testing. | -| `QWEN_CODE_MAX_OUTPUT_TOKENS` | Overrides the default maximum output tokens per response. When not set, Qwen Code defaults to the model's declared output limit and, if a response is truncated, automatically escalates (64K floor) and recovers across turns. Set this to a specific value (e.g., `16000`) to use a fixed limit instead — useful for capacity-constrained self-hosted backends that want a lower per-request slot reservation. | Takes precedence over the model-limit default but is overridden by `samplingParams.max_tokens` in settings. Disables automatic escalation when set. Example: `export QWEN_CODE_MAX_OUTPUT_TOKENS=16000` | -| `QWEN_CODE_UNATTENDED_RETRY` | Set to `true` or `1` to enable persistent retry mode. When enabled, transient API capacity errors (HTTP 429 Rate Limit and 529 Overloaded) are retried indefinitely with exponential backoff (capped at 5 minutes per retry) and heartbeat keepalives every 30 seconds on stderr. | Designed for CI/CD pipelines and background automation where long-running tasks should survive temporary API outages. Must be set explicitly — `CI=true` alone does **not** activate this mode. See [Headless Mode](../features/headless#persistent-retry-mode) for details. Example: `export QWEN_CODE_UNATTENDED_RETRY=1` | -| `QWEN_CODE_ACP_REPEATED_TOOL_FAILURE_GUARD` | Operator rollout mode for ACP repeated tool-execution failure protection. Accepts `off`, `shadow`, `warn`, or `enforce`; missing or invalid values default to `shadow`. | Applies only to interactive foreground ACP prompts; channel-driven and automatic routes remain off. Project and workspace environment files cannot set this operator policy. Shadow leaves model continuation and messages unchanged but adds the queued-prompt watch flag to `craft/drainMidTurnQueue`; every non-off mode requires reliable queued-prompt state. Non-empty invalid values emit a diagnostic; export the variable in the process environment or a user-level file. | -| `QWEN_CODE_PROFILE_STARTUP` | Set to `1` to enable startup performance profiling. Writes a JSON timing report to `~/.qwen/startup-perf/` with per-phase durations. | Only active inside the sandbox child process (or with `QWEN_CODE_PROFILE_STARTUP_OUTER=1`). Zero overhead when not set. Example: `export QWEN_CODE_PROFILE_STARTUP=1` | -| `QWEN_CODE_PROFILE_STARTUP_OUTER` | Set to `1` together with `QWEN_CODE_PROFILE_STARTUP=1` to also collect a startup profile in the outer (pre-sandbox) process. Outer-process reports get an `outer-` filename prefix to keep them distinct from the sandbox child's report. | Off by default — only the sandbox child collects, to avoid duplicate reports. Useful for local development where the cli isn't relaunched into a sandbox. | -| `QWEN_CODE_PROFILE_STARTUP_NO_HEAP` | Set to `1` together with `QWEN_CODE_PROFILE_STARTUP=1` to skip the per-checkpoint `process.memoryUsage()` snapshots. Useful when measuring the profiler's own Heisenberg overhead. | Off by default. Heap snapshots cost ~50 µs each (well below 1% of total startup) so most users should leave this alone. | -| `QWEN_CODE_LEGACY_MCP_BLOCKING` | Set to `1` to restore the pre-progressive-MCP behavior where `Config.initialize()` waits synchronously for every configured MCP server's discover handshake before returning. | Off by default. Modern qwen-code lets MCP servers come online in the background while the UI is already interactive; the model sees each batch of new tools within ~16 ms of the server settling. This flag is kept as a rollback escape hatch for ≥ 1 release. Example: `export QWEN_CODE_LEGACY_MCP_BLOCKING=1` | -| `QWEN_CODE_MODELS_DEV` | Set to `off` to ignore the bundled [models.dev](https://models.dev) model catalog and resolve context windows, output limits, and input modalities from the built-in model-name tables only. | On by default. Only the exact value `off` disables the catalog; it also suppresses the background refresh below. Project-level `.env` files and `settings.json` `env` cannot set this key, and it cannot be changed by a reload; use the shell environment. Example: `export QWEN_CODE_MODELS_DEV=off` | -| `QWEN_CODE_MODELS_DEV_REFRESH` | Set to `off` to stop the daily models.dev download into `~/.qwen/model-registry.json` (air-gapped or proxy-restricted environments). Refreshed data is available to new CLI processes; running processes keep their selected catalog. | On by default (24-hour interval). Stops only the download — a previously downloaded `~/.qwen/model-registry.json` is used while its `fetchedAt` timestamp is newer than the snapshot bundled with the installed release; after an upgrade ships a newer snapshot the bundled data wins until you refresh again. Use `QWEN_CODE_MODELS_DEV=off` to ignore downloaded catalog data entirely. Project-level `.env` files and `settings.json` `env` cannot set this key, and it cannot be changed by a reload; use the shell environment. | -| `QWEN_CODE_MODELS_DEV_URL` | Replaces the models.dev download URL with a full mirror of `https://models.dev/api.json` (same provider filtering applies). To add or correct individual models instead, use explicit `modelProviders` generation settings. | Unset — downloads from `https://models.dev/api.json`. Project-level `.env` files and `settings.json` `env` cannot set this key; use the shell environment or a user-level `.env` file. Example: `export QWEN_CODE_MODELS_DEV_URL=https://mirror.corp/api.json` | -| `QWEN_CODE_LEGACY_ERASE_LINES` | `=1` force-disables the terminal redraw optimizer (restores per-line erase sequences); `=0` force-enables it even on WSL, where it is skipped by default because ConPTY mishandles the optimizer's batched cursor moves (issue #7634). Unset = platform default (skip when `WSL_DISTRO_NAME` or `WSL_INTEROP` is set). | Escape hatch for streaming-output regressions. Because it is read from the environment, launchers that scrub env (e.g. `sudo`) drop it too — pass it at launch instead: `sudo QWEN_CODE_LEGACY_ERASE_LINES=1 qwen`. Example: `export QWEN_CODE_LEGACY_ERASE_LINES=1` | +| Variable | Description | Notes | +| ---------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `QWEN_HOME` | Customizes the global configuration directory (default: `~/.qwen`). Accepts an absolute or relative path (relative paths are resolved from the current working directory). Leading `~` is expanded to the user's home directory. | Stores credentials, settings, memory, skills, and other global state. When set, project-level `.qwen/` directories are unaffected. An empty string is treated as unset. | +| `QWEN_RUNTIME_DIR` | Overrides the runtime output directory (conversations, logs, todos). When unset, defaults to the `QWEN_HOME` directory. | Use this to separate ephemeral runtime data from persistent config. Useful when `QWEN_HOME` is on a shared/slow filesystem. | +| `QWEN_USAGE_STATISTICS_ENABLED` | Set to `true` or `1` to enable usage statistics. Any other value is treated as disabling them. | Overrides the `privacy.usageStatisticsEnabled` setting. Defaults to enabled when neither is configured. | +| `QWEN_TELEMETRY_ENABLED` | Set to `true` or `1` to enable telemetry. Any other value is treated as disabling it. | Overrides the `telemetry.enabled` setting. | +| `QWEN_TELEMETRY_TARGET` | Sets an informational label for the telemetry destination (`local` or `gcp`). Does not control routing; use `QWEN_TELEMETRY_OTLP_ENDPOINT` or `QWEN_TELEMETRY_OUTFILE` to configure where data is sent. | Overrides the `telemetry.target` setting. | +| `QWEN_TELEMETRY_OTLP_ENDPOINT` | Sets the OTLP endpoint for telemetry. | Overrides the `telemetry.otlpEndpoint` setting. | +| `QWEN_TELEMETRY_OTLP_PROTOCOL` | Sets the OTLP exporter protocol: `grpc` (gRPC/protobuf) or `http` (HTTP/JSON, `application/json`). | Overrides the `telemetry.otlpProtocol` setting. | +| `QWEN_TELEMETRY_LOG_PROMPTS` | Set to `true` or `1` to enable or disable logging of user prompts. Any other value is treated as disabling it. | Overrides the `telemetry.logPrompts` setting. | +| `QWEN_TELEMETRY_USER_ID` | Sets a stable end-user identifier on interaction, LLM, Tool, and Agent spans as `gen_ai.user.id`. Prefer a pseudonymous value. | Overrides `telemetry.userId` after trimming. A blank value falls back to settings. This is process-wide and must not be used as per-request identity in a shared multi-user process. | +| `QWEN_TELEMETRY_INCLUDE_SENSITIVE_SPAN_ATTRIBUTES` | Set to `true` or `1` to attach verbatim user prompts, system prompts, tool I/O, and model responses to native OTel span attributes (and keep `function_args`, `error`, `error.message`, and `error_message` on log-to-span bridge spans, plus `prompt` / `request_text` / `response_text` when `telemetry.logPrompts` is also enabled). Any other value disables it. | Overrides the `telemetry.includeSensitiveSpanAttributes` setting. ⚠️ Streams sensitive data to your OTLP backend. | +| `QWEN_TELEMETRY_SENSITIVE_SPAN_ATTRIBUTE_MAX_LENGTH` | Sets the maximum JavaScript string length for each sensitive native OTel span attribute content payload. Must be a positive integer no greater than `104857600` (100 MiB). | Overrides the `telemetry.sensitiveSpanAttributeMaxLength` setting. Default is `1048576` (1 MiB); lower it if your collector or backend rejects large span attributes. | +| `QWEN_TELEMETRY_OUTFILE` | Sets the file path to write telemetry to. When set, overrides OTLP export. | Overrides the `telemetry.outfile` setting. | +| `QWEN_SANDBOX` | Alternative to the `sandbox` setting in `settings.json`. | Accepts `true`, `false`, `docker`, `podman`, or a custom command string. | +| `QWEN_SANDBOX_IMAGE` | Overrides sandbox image selection for Docker/Podman. | Takes precedence over `tools.sandboxImage`. | +| `SEATBELT_PROFILE` | (macOS specific) Switches the Seatbelt (`sandbox-exec`) profile on macOS. | `permissive-open`: (Default) Restricts writes to the project folder (and a few other folders, see `packages/cli/src/serve/sandbox-macos-permissive-open.sb`) but allows other operations. `strict`: Uses a strict profile that declines operations by default. ``: Uses a custom profile. To define a custom profile, create a file named `sandbox-macos-.sb` in your project's `.qwen/` directory (e.g., `my-project/.qwen/sandbox-macos-custom.sb`). | +| `DEBUG` or `DEBUG_MODE` | (often used by underlying libraries or the CLI itself) Set to `true` or `1` to enable verbose debug logging, which can be helpful for troubleshooting. | **Note:** These variables are automatically excluded from project `.env` files by default to prevent interference with the CLI behavior. Use `.qwen/.env` files if you need to set these for Qwen Code specifically. | +| `NO_COLOR` | Set to any value to disable all color output in the CLI. | | +| `FORCE_HYPERLINK` | Override the OSC 8 clickable-link detection in the markdown renderer. Set to `1` (or any non-zero integer, or empty string) to force-enable; set to `0` or a non-numeric value such as `false` / `off` to force-disable. Honors `NO_COLOR` / `QWEN_DISABLE_HYPERLINKS` opt-outs above it. | Use this to opt into OSC 8 inside `tmux` / GNU `screen` (auto-detection refuses by default because the host terminal's capabilities are hidden behind the multiplexer). Requires `set -g allow-passthrough on` on tmux 3.3+. Also enables Hyper, which isn't auto-detected. | +| `QWEN_DISABLE_HYPERLINKS` | Set to `1` to hard-disable OSC 8 clickable hyperlinks in the markdown renderer even on terminals that auto-detect as capable. | Useful when a terminal advertises support but breaks on long URLs, or when piping output through an intermediary that mangles escape sequences. The renderer falls back to plain `label (url)` rendering. | +| `CLI_TITLE` | Set to a string to customize the title of the CLI. | | +| `CODE_ASSIST_ENDPOINT` | Specifies the endpoint for the code assist server. | This is useful for development and testing. | +| `QWEN_CODE_MAX_OUTPUT_TOKENS` | Overrides the default maximum output tokens per response. When not set, Qwen Code defaults to the model's declared output limit and, if a response is truncated, automatically escalates (64K floor) and recovers across turns. Set this to a specific value (e.g., `16000`) to use a fixed limit instead — useful for capacity-constrained self-hosted backends that want a lower per-request slot reservation. | Takes precedence over the model-limit default but is overridden by `samplingParams.max_tokens` in settings. Disables automatic escalation when set. Example: `export QWEN_CODE_MAX_OUTPUT_TOKENS=16000` | +| `QWEN_CODE_UNATTENDED_RETRY` | Set to `true` or `1` to enable persistent retry mode. When enabled, transient API capacity errors (HTTP 429 Rate Limit and 529 Overloaded) are retried indefinitely with exponential backoff (capped at 5 minutes per retry) and heartbeat keepalives every 30 seconds on stderr. | Designed for CI/CD pipelines and background automation where long-running tasks should survive temporary API outages. Must be set explicitly — `CI=true` alone does **not** activate this mode. See [Headless Mode](../features/headless#persistent-retry-mode) for details. Example: `export QWEN_CODE_UNATTENDED_RETRY=1` | +| `QWEN_CODE_ACP_REPEATED_TOOL_FAILURE_GUARD` | Operator rollout mode for ACP repeated tool-execution failure protection. Accepts `off`, `shadow`, `warn`, or `enforce`; missing or invalid values default to `shadow`. | Applies only to interactive foreground ACP prompts; channel-driven and automatic routes remain off. Project and workspace environment files cannot set this operator policy. Shadow leaves model continuation and messages unchanged but adds the queued-prompt watch flag to `craft/drainMidTurnQueue`; every non-off mode requires reliable queued-prompt state. Non-empty invalid values emit a diagnostic; export the variable in the process environment or a user-level file. | +| `QWEN_CODE_PROFILE_STARTUP` | Set to `1` to enable startup performance profiling. Writes a JSON timing report to `~/.qwen/startup-perf/` with per-phase durations. | Only active inside the sandbox child process (or with `QWEN_CODE_PROFILE_STARTUP_OUTER=1`). Zero overhead when not set. Example: `export QWEN_CODE_PROFILE_STARTUP=1` | +| `QWEN_CODE_PROFILE_STARTUP_OUTER` | Set to `1` together with `QWEN_CODE_PROFILE_STARTUP=1` to also collect a startup profile in the outer (pre-sandbox) process. Outer-process reports get an `outer-` filename prefix to keep them distinct from the sandbox child's report. | Off by default — only the sandbox child collects, to avoid duplicate reports. Useful for local development where the cli isn't relaunched into a sandbox. | +| `QWEN_CODE_PROFILE_STARTUP_NO_HEAP` | Set to `1` together with `QWEN_CODE_PROFILE_STARTUP=1` to skip the per-checkpoint `process.memoryUsage()` snapshots. Useful when measuring the profiler's own Heisenberg overhead. | Off by default. Heap snapshots cost ~50 µs each (well below 1% of total startup) so most users should leave this alone. | +| `QWEN_CODE_LEGACY_MCP_BLOCKING` | Set to `1` to restore the pre-progressive-MCP behavior where `Config.initialize()` waits synchronously for every configured MCP server's discover handshake before returning. | Off by default. Modern qwen-code lets MCP servers come online in the background while the UI is already interactive; the model sees each batch of new tools within ~16 ms of the server settling. This flag is kept as a rollback escape hatch for ≥ 1 release. Example: `export QWEN_CODE_LEGACY_MCP_BLOCKING=1` | +| `QWEN_CODE_MODELS_DEV` | Set to `off` to ignore the bundled [models.dev](https://models.dev) model catalog and resolve context windows, output limits, and input modalities from the built-in model-name tables only. | On by default. Only the exact value `off` disables the catalog; it also suppresses the background refresh below. Project-level `.env` files and `settings.json` `env` cannot set this key, and it cannot be changed by a reload; use the shell environment. Example: `export QWEN_CODE_MODELS_DEV=off` | +| `QWEN_CODE_MODELS_DEV_REFRESH` | Set to `off` to stop the daily models.dev download into `~/.qwen/model-registry.json` (air-gapped or proxy-restricted environments). Refreshed data is available to new CLI processes; running processes keep their selected catalog. | On by default (24-hour interval). Stops only the download — a previously downloaded `~/.qwen/model-registry.json` is used while its `fetchedAt` timestamp is newer than the snapshot bundled with the installed release; after an upgrade ships a newer snapshot the bundled data wins until you refresh again. Use `QWEN_CODE_MODELS_DEV=off` to ignore downloaded catalog data entirely. Project-level `.env` files and `settings.json` `env` cannot set this key, and it cannot be changed by a reload; use the shell environment or `model.catalogRefresh` in User, System, or SystemDefaults settings. | +| `QWEN_CODE_MODELS_DEV_URL` | Replaces the models.dev download URL with a full mirror of `https://models.dev/api.json` (same provider filtering applies). To add or correct individual models instead, use explicit `modelProviders` generation settings. | Unset — downloads from `https://models.dev/api.json`. Project-level `.env` files and `settings.json` `env` cannot set this key; use the shell environment or a user-level `.env` file. Example: `export QWEN_CODE_MODELS_DEV_URL=https://mirror.corp/api.json` | +| `QWEN_CODE_LEGACY_ERASE_LINES` | `=1` force-disables the terminal redraw optimizer (restores per-line erase sequences); `=0` force-enables it even on WSL, where it is skipped by default because ConPTY mishandles the optimizer's batched cursor moves (issue #7634). Unset = platform default (skip when `WSL_DISTRO_NAME` or `WSL_INTEROP` is set). | Escape hatch for streaming-output regressions. Because it is read from the environment, launchers that scrub env (e.g. `sudo`) drop it too — pass it at launch instead: `sudo QWEN_CODE_LEGACY_ERASE_LINES=1 qwen`. Example: `export QWEN_CODE_LEGACY_ERASE_LINES=1` | When both user-level `.env` files define the same variable, the Qwen-specific file wins: `/.env` (or `~/.qwen/.env` when `QWEN_HOME` is unset) is @@ -1078,6 +1079,12 @@ Alternatively, set `QWEN_USAGE_STATISTICS_ENABLED=false` (or `0`) in the environment. The environment variable takes precedence over the setting. Restart Qwen Code after changing either value. +Disabling usage statistics also stops the models.dev catalog refresh. Unlike +`model.catalogRefresh`, `privacy.usageStatisticsEnabled` is not restricted to +User, System, and SystemDefaults scopes, so a trusted workspace's +`.qwen/settings.json` can set it back to `true`; operators who need an opt-out a +repository cannot override should use `model.catalogRefresh`. + > [!note] > > When usage statistics are enabled, events are sent to an Alibaba Cloud RUM collection endpoint. diff --git a/packages/cli/src/commands/mcp/reconnect.ts b/packages/cli/src/commands/mcp/reconnect.ts index 6970b7889dd..8f9f79af79b 100644 --- a/packages/cli/src/commands/mcp/reconnect.ts +++ b/packages/cli/src/commands/mcp/reconnect.ts @@ -112,6 +112,16 @@ async function createMinimalConfig(): Promise { parseBooleanEnvFlag(process.env['QWEN_USAGE_STATISTICS_ENABLED']) ?? settings.merged.privacy?.usageStatisticsEnabled ?? true, + // Mirror loadCliConfig's catalog-refresh resolution: without it this + // throwaway Config leaves `modelCatalogRefreshEnabled` undefined, so the + // model-catalog refresh gate defaults to on and this command contacts + // `https://models.dev/api.json` even when the user disabled it (issue + // #16006). The env var has no catalog equivalent, so only the settings + // value is mirrored. + modelCatalogRefreshEnabled: + typeof settings.merged.model?.catalogRefresh === 'boolean' + ? settings.merged.model.catalogRefresh + : undefined, // Mirror loadCliConfig's proxy resolution (minus the --proxy flag this // command does not take): without it the session_start flush above // bypasses the proxy the user configured (issue #12844). diff --git a/packages/cli/src/config/config.test.ts b/packages/cli/src/config/config.test.ts index b1acdb3d1e9..6ae0b0b5dcb 100644 --- a/packages/cli/src/config/config.test.ts +++ b/packages/cli/src/config/config.test.ts @@ -2085,6 +2085,25 @@ describe('loadCliConfig', () => { }); }); + it.each([undefined, false, true])( + 'passes the model catalog refresh setting to Config: %s', + async (catalogRefresh) => { + process.argv = ['node', 'script.js']; + const argv = await parseArguments(); + + await loadCliConfig( + catalogRefresh === undefined ? {} : { model: { catalogRefresh } }, + argv, + ); + + expect(mockConfigConstructorParams).toHaveBeenLastCalledWith( + expect.objectContaining({ + modelCatalogRefreshEnabled: catalogRefresh, + }), + ); + }, + ); + describe('model.goalTokenBudget', () => { it('carries the setting into the Goal budget grant', async () => { process.argv = ['node', 'script.js']; diff --git a/packages/cli/src/config/config.ts b/packages/cli/src/config/config.ts index 743195b231e..2726ee9768d 100755 --- a/packages/cli/src/config/config.ts +++ b/packages/cli/src/config/config.ts @@ -2711,6 +2711,10 @@ export async function loadCliConfig( usageStatisticsEnabled: resolveUsageStatisticsEnabled( settings.privacy?.usageStatisticsEnabled, ), + modelCatalogRefreshEnabled: + typeof settings.model?.catalogRefresh === 'boolean' + ? settings.model.catalogRefresh + : undefined, clearContextOnIdle: settings.context?.clearContextOnIdle, fileFiltering: settings.context?.fileFiltering, plansDirectory: settings.plansDirectory, diff --git a/packages/cli/src/config/execution-sandbox-settings.test.ts b/packages/cli/src/config/execution-sandbox-settings.test.ts index df393d7f5f7..32d9136fd95 100644 --- a/packages/cli/src/config/execution-sandbox-settings.test.ts +++ b/packages/cli/src/config/execution-sandbox-settings.test.ts @@ -101,6 +101,23 @@ describe('operator execution sandbox policy', () => { ).toEqual(restricted); }, ); + it.each([null, false, 'replace', [], { name: 'x', catalogRefresh: true }])( + 'project model=%j cannot re-enable the operator catalog refresh opt-out', + (model) => { + write(user, { model: { catalogRefresh: false } }); + write(path.join(workspace, '.qwen', 'settings.json'), { model }); + const merged = loadSettings(workspace, { + skipLoadEnvironment: true, + workspaceTrusted: true, + }).merged; + expect(merged.model?.catalogRefresh).toBe(false); + if (model && typeof model === 'object' && !Array.isArray(model)) { + // The restore re-adds only the operator key: a project that replaced + // the whole section keeps its own model keys. + expect(merged.model?.name).toBe('x'); + } + }, + ); it('workspace cannot enable the mode', () => { write(path.join(workspace, '.qwen', 'settings.json'), { tools: { executionSandbox: writable }, @@ -195,6 +212,24 @@ describe('operator execution sandbox policy', () => { expect(merged.privacy?.usageStatisticsEnabled).toBe(true); } }); + it('bare mode resolves the model catalog refresh with normal scope precedence', () => { + write(defaults, { model: { catalogRefresh: false } }); + write(user, { model: { catalogRefresh: true } }); + write(system, { model: { catalogRefresh: false } }); + for (const merged of normalAndBareMerged()) { + expect(merged.model?.catalogRefresh).toBe(false); + } + write(system, {}); + for (const merged of normalAndBareMerged()) { + expect(merged.model?.catalogRefresh).toBe(true); + } + }); + it('bare mode leaves the model catalog refresh unset when no scope configures it', () => { + write(user, { model: { catalogRefresh: 'no' } }); + expect( + createMinimalSettings().merged.model?.catalogRefresh, + ).toBeUndefined(); + }); it('bare mode leaves usage statistics unset when no scope configures it', () => { write(user, { privacy: { usageStatisticsEnabled: 'no' } }); expect( diff --git a/packages/cli/src/config/execution-sandbox-settings.ts b/packages/cli/src/config/execution-sandbox-settings.ts index 7c8c177cd94..f23030c9b35 100644 --- a/packages/cli/src/config/execution-sandbox-settings.ts +++ b/packages/cli/src/config/execution-sandbox-settings.ts @@ -66,6 +66,7 @@ type OperatorSettingsScope = SandboxSettingsInput & { /** Settings format version, used to date the legacy usage-statistics key. */ $version?: unknown; privacy?: { usageStatisticsEnabled?: unknown }; + model?: { catalogRefresh?: unknown }; /** Pre-v2 location, migrated to `privacy.*` by a normal settings load. */ usageStatisticsEnabled?: unknown; }; @@ -97,18 +98,33 @@ export function selectOperatorExecutionSandbox( return selected; } +/** Operator scopes resolve like a normal load: system over user over defaults. */ +export function selectOperatorCatalogRefresh( + ...scopes: OperatorSettingsScope[] +): boolean | undefined { + let selected: boolean | undefined; + for (const scope of scopes) { + if (typeof scope.model?.catalogRefresh === 'boolean') { + selected = scope.model.catalogRefresh; + } + } + return selected; +} + /** Bare mode still honors operator confinement without loading project/env data. */ export function readOperatorSandboxSettings(): SandboxSettingsInput { return sandboxSettingsFromScopes(readOperatorSettingsScopes()); } /** - * The operator settings bare mode keeps: sandbox confinement, plus the - * usage-statistics choice so `--bare` cannot turn a privacy opt-out back on. + * The operator settings bare mode keeps: sandbox confinement, the + * usage-statistics choice so `--bare` cannot turn a privacy opt-out back on, + * and the models.dev refresh choice. * Scopes resolve like a normal load (system over user over system defaults). */ export function readBareModeOperatorSettings(): SandboxSettingsInput & { privacy?: { usageStatisticsEnabled: boolean }; + model?: { catalogRefresh: boolean }; } { const scopes = readOperatorSettingsScopes(); const usageStatisticsEnabled = scopes.reduce( @@ -125,11 +141,13 @@ export function readBareModeOperatorSettings(): SandboxSettingsInput & { }, undefined, ); + const catalogRefresh = selectOperatorCatalogRefresh(...scopes); return { ...sandboxSettingsFromScopes(scopes), ...(usageStatisticsEnabled === undefined ? {} : { privacy: { usageStatisticsEnabled } }), + ...(catalogRefresh === undefined ? {} : { model: { catalogRefresh } }), }; } diff --git a/packages/cli/src/config/settings.test.ts b/packages/cli/src/config/settings.test.ts index 0fdc5b18468..356332c834d 100644 --- a/packages/cli/src/config/settings.test.ts +++ b/packages/cli/src/config/settings.test.ts @@ -4486,6 +4486,26 @@ describe('Settings Loading and Merging', () => { 'tools.workflowsEnabled', ); }); + + // Named key, not list iteration: dropping the entry must red here. + it('strips a workspace-only catalog refresh opt-out', () => { + (mockFsExistsSync as Mock).mockReturnValue(true); + (fs.readFileSync as Mock).mockImplementation( + (p: fs.PathOrFileDescriptor) => { + if (p === MOCK_WORKSPACE_SETTINGS_PATH) + return JSON.stringify({ model: { catalogRefresh: false } }); + return '{}'; + }, + ); + + const settings = loadSettings(MOCK_WORKSPACE_DIR); + expect(settings.merged.model?.catalogRefresh).toBeUndefined(); + expect( + getSettingsWarnings(settings).some((warning) => + warning.includes('model.catalogRefresh'), + ), + ).toBe(true); + }); }); describe('goals.modelProposed scope handling', () => { diff --git a/packages/cli/src/config/settings.ts b/packages/cli/src/config/settings.ts index 6df220ee93c..2ff07349bf6 100644 --- a/packages/cli/src/config/settings.ts +++ b/packages/cli/src/config/settings.ts @@ -25,6 +25,7 @@ import { parseExecutionSandboxSettings, readBareModeOperatorSettings, readOperatorSandboxSettings, + selectOperatorCatalogRefresh, selectOperatorExecutionSandbox, stripUtf8Bom, } from './execution-sandbox-settings.js'; @@ -752,6 +753,22 @@ function mergeSettings( } else if (merged.tools && typeof merged.tools === 'object') { delete merged.tools.executionSandbox; } + const operatorCatalogRefresh = selectOperatorCatalogRefresh( + systemDefaults, + user, + system, + ); + // Same shape as the executionSandbox restore: a project that replaces the + // whole `model` section cannot reset the operator's refresh opt-out. + if (operatorCatalogRefresh !== undefined) { + const model = merged.model; + merged.model = { + ...(model && typeof model === 'object' && !Array.isArray(model) + ? model + : {}), + catalogRefresh: operatorCatalogRefresh, + } as Settings['model']; + } return merged; } @@ -1028,6 +1045,7 @@ export function createMinimalSettings(): LoadedSettings { } : {}), ...(operator.privacy ? { privacy: operator.privacy } : {}), + ...(operator.model ? { model: operator.model } : {}), }; const emptySettingsFile: SettingsFile = { path: '', diff --git a/packages/cli/src/config/settingsSchema.test.ts b/packages/cli/src/config/settingsSchema.test.ts index d931ce1ba32..956326d8701 100644 --- a/packages/cli/src/config/settingsSchema.test.ts +++ b/packages/cli/src/config/settingsSchema.test.ts @@ -68,6 +68,24 @@ describe('SettingsSchema', () => { }); describe('getSettingsSchema', () => { + it('declares the operator-scoped model catalog refresh switch', () => { + expect(getSettingsSchema().model.properties.catalogRefresh).toMatchObject( + { + type: 'boolean', + default: true, + requiresRestart: true, + showInDialog: false, + }, + ); + expect( + getSettingsSchema().model.properties.catalogRefresh.description, + ).toMatch(/values set in Workspace settings are ignored/); + expect( + getSettingsSchema().privacy.properties.usageStatisticsEnabled + .description, + ).toMatch(/also stops the models.dev catalog refresh/); + }); + it('should describe prompt hooks supported by the runtime', () => { const hookProperties = getSettingsSchema().hooks.properties.PreToolUse.items.properties?.[ diff --git a/packages/cli/src/config/settingsSchema.ts b/packages/cli/src/config/settingsSchema.ts index 2a7a72a6048..0aa36f7b2ae 100644 --- a/packages/cli/src/config/settingsSchema.ts +++ b/packages/cli/src/config/settingsSchema.ts @@ -1533,7 +1533,8 @@ const SETTINGS_SCHEMA = { category: 'Privacy', requiresRestart: true, default: true, - description: 'Enable collection of usage statistics', + description: + 'Enable collection of usage statistics. Disabling this also stops the models.dev catalog refresh.', showInDialog: true, }, }, @@ -1862,6 +1863,16 @@ const SETTINGS_SCHEMA = { enum: ['none', ...REASONING_EFFORT_TIERS], }, }, + catalogRefresh: { + type: 'boolean', + label: 'Refresh Models.dev Catalog', + category: 'Model', + requiresRestart: true, + default: true, + description: + 'Download the models.dev model catalog in the background at most once every 24 hours. Disable it in managed installations that must not contact models.dev; the bundled and previously downloaded catalog data remain available. Only honored from User, System, and SystemDefaults settings scopes; values set in Workspace settings are ignored. QWEN_CODE_MODELS_DEV_REFRESH=off in the process environment still wins, and disabling privacy.usageStatisticsEnabled also disables the refresh.', + showInDialog: false, + }, maxSessionTurns: { type: 'integer', label: 'Max Session Turns', diff --git a/packages/cli/src/config/settingsUtils.ts b/packages/cli/src/config/settingsUtils.ts index c07d8c05d77..a878068499f 100644 --- a/packages/cli/src/config/settingsUtils.ts +++ b/packages/cli/src/config/settingsUtils.ts @@ -279,6 +279,7 @@ export const WORKSPACE_RESTRICTED_SETTINGS = [ { section: 'memory', key: 'mem0' }, { section: 'memory', key: 'agentMaxTurns' }, { section: 'memory', key: 'agentTimeoutMinutes' }, + { section: 'model', key: 'catalogRefresh' }, { section: 'tools', key: 'executionSandbox' }, { section: 'tools', key: 'workflowsEnabled' }, { section: 'security', key: 'allowPrivateNetworkHooks' }, diff --git a/packages/cli/src/llm.test.tsx b/packages/cli/src/llm.test.tsx index 2194e11669d..749d1262d71 100644 --- a/packages/cli/src/llm.test.tsx +++ b/packages/cli/src/llm.test.tsx @@ -43,6 +43,7 @@ import { OutputFormat, Storage, } from '@qwen-code/qwen-code-core'; +import { MODEL_CATALOG_REFRESH_ENV } from '@qwen-code/qwen-code-core/models/model-catalog-refresh.js'; import { EXTERNAL_TOOL_GUARD_REQUIRED_VALUE } from '@qwen-code/acp-bridge/externalToolGuard'; const mockPrepareFileWatchersForProcessExit = vi.hoisted(() => vi.fn()); @@ -1177,6 +1178,88 @@ describe('llm.tsx main function', () => { }); }); + // The container mounts only the user settings dir and reads passthrough + // vars from `process.env`, so an operator opt-out that lives in a System + // settings file never reaches the sandboxed CLI through settings: the + // handoff itself has to carry it. + it.each([ + ['model opt-out', { model: { catalogRefresh: false } }, 'off'], + [ + 'usage-statistics opt-out', + { privacy: { usageStatisticsEnabled: false } }, + 'off', + ], + ['no opt-out', {}, undefined], + ])( + 'carries the operator catalog refresh opt-out into the sandbox handoff (%s)', + async (_label, extraSettings, expected) => { + const originalArgv = process.argv; + process.argv = ['node', 'script.js', '--acp']; + const processExitSpy = vi + .spyOn(process, 'exit') + .mockImplementation((code) => { + throw new MockProcessExitError(code); + }); + const { loadCliConfig, parseArguments } = await import( + './config/config.js' + ); + const { loadSettings } = await import('./config/settings.js'); + const { loadSandboxConfig } = await import('./config/sandboxConfig.js'); + const { start_sandbox } = await import('./serve/sandbox.js'); + vi.mocked(start_sandbox).mockClear(); + vi.mocked(parseArguments).mockResolvedValue({ acp: true } as CliArgs); + delete process.env[MODEL_CATALOG_REFRESH_ENV]; + + vi.mocked(loadSettings).mockReturnValue({ + errors: [], + merged: { + advanced: {}, + security: { auth: {} }, + ui: {}, + ...extraSettings, + }, + setValue: vi.fn(), + forScope: () => ({ settings: {}, originalSettings: {}, path: '' }), + migrationWarnings: [], + getSystemHooks: () => undefined, + getUserHooks: () => undefined, + getProjectHooks: () => undefined, + } as never); + vi.mocked(loadSandboxConfig).mockResolvedValue({ + command: 'docker', + image: 'ghcr.io/qwenlm/qwen-code:1.0.0', + }); + vi.mocked(loadCliConfig).mockImplementation( + async () => + ({ + getModelsConfig: () => ({ getCurrentAuthType: () => null }), + getSessionId: () => '123e4567-e89b-12d3-a456-426614174000', + }) as unknown as Config, + ); + + let refreshAtSpawn: string | undefined; + vi.mocked(start_sandbox).mockImplementation((async () => { + refreshAtSpawn = process.env[MODEL_CATALOG_REFRESH_ENV]; + return 0; + }) as unknown as typeof start_sandbox); + + try { + try { + await main(); + } catch (e) { + if (!(e instanceof MockProcessExitError)) throw e; + } + } finally { + process.argv = originalArgv; + delete process.env[MODEL_CATALOG_REFRESH_ENV]; + processExitSpy.mockRestore(); + } + + expect(start_sandbox).toHaveBeenCalledOnce(); + expect(refreshAtSpawn).toBe(expected); + }, + ); + it('follows its relaunch supervisor before reading any arguments', async () => { vi.clearAllMocks(); vi.spyOn(process, 'exit').mockImplementation((code) => { diff --git a/packages/cli/src/llm.tsx b/packages/cli/src/llm.tsx index 7e20a86a02d..9b787199a23 100644 --- a/packages/cli/src/llm.tsx +++ b/packages/cli/src/llm.tsx @@ -9,6 +9,7 @@ import { hasLoadedEnvironmentValues, } from './config/environment.js'; import { prepareFileWatchersForProcessExit } from '@qwen-code/qwen-code-core/utils/file-watcher-cleanup.js'; +import { MODEL_CATALOG_REFRESH_ENV } from '@qwen-code/qwen-code-core/models/model-catalog-refresh.js'; import { validateExecutionSandboxSelection } from './config/execution-sandbox-settings.js'; import { AuthType, @@ -29,6 +30,7 @@ import { PRIVATE_ACP_CAPABILITY_ENV, PRIVATE_CONVERSATIONS_RUNTIME_ENABLE, PRIVATE_CONVERSATIONS_RUNTIME_ENV, + resolveUsageStatisticsEnabled, uiTelemetryService, } from '@qwen-code/qwen-code-core'; import { @@ -855,6 +857,18 @@ export async function main() { // provenance travels in `privateAcpChildEnv`, which is spread last. delete process.env[PRIVATE_CONVERSATIONS_RUNTIME_ENV]; + // The container mounts only the user settings dir and builds its env + // passthrough from `process.env`, so a System-scope opt-out would never + // reach the sandboxed CLI and it would fetch models.dev. + if ( + settings.merged.model?.catalogRefresh === false || + resolveUsageStatisticsEnabled( + settings.merged.privacy?.usageStatisticsEnabled, + ) === false + ) { + process.env[MODEL_CATALOG_REFRESH_ENV] = 'off'; + } + await relaunchOnExitCode( () => start_sandbox( diff --git a/packages/core/src/config/config.test.ts b/packages/core/src/config/config.test.ts index f3645cb6efd..d493f2291f6 100644 --- a/packages/core/src/config/config.test.ts +++ b/packages/core/src/config/config.test.ts @@ -49,6 +49,7 @@ import * as fs from 'node:fs'; import * as os from 'node:os'; import * as path from 'node:path'; import { setMemoryFilename as mockSetMemoryFilename } from '../utils/memory-constants.js'; +import { refreshModelCatalog } from '../models/model-catalog-refresh.js'; import { DEFAULT_TELEMETRY_TARGET, DEFAULT_OTLP_ENDPOINT, @@ -495,6 +496,9 @@ vi.mock('../core/baseLlmClient.js'); vi.mock('../core/toolHookTriggers.js', () => ({ fireNotificationHook: vi.fn().mockResolvedValue({}), })); +vi.mock('../models/model-catalog-refresh.js', () => ({ + refreshModelCatalog: vi.fn().mockResolvedValue(undefined), +})); type MemoryLoad = Awaited>; const PROJECT_RULES = '--- Context from: QWEN.md ---\nProject rules'; @@ -5306,6 +5310,27 @@ describe('Server Config (config.ts)', () => { }); describe('initialize', () => { + it.each([ + ['the default', { usageStatisticsEnabled: true }, true], + [ + 'the operator refresh switch', + { usageStatisticsEnabled: true, modelCatalogRefreshEnabled: false }, + false, + ], + [ + 'the usage-statistics opt-out', + { usageStatisticsEnabled: false }, + false, + ], + ])( + 'gates the model catalog refresh on %s', + async (_name, params, expected) => { + await initConfig(params, SKIP_ALL_INIT); + + expect(refreshModelCatalog).toHaveBeenCalledTimes(expected ? 1 : 0); + }, + ); + it('accepts managed handoff only after certified takeover is configured', async () => { const standalone = makeConfig(); await standalone.closeSessionWriter({ handoff: true }); diff --git a/packages/core/src/config/config.ts b/packages/core/src/config/config.ts index 0649d3e5e47..a2ca5689b7a 100644 --- a/packages/core/src/config/config.ts +++ b/packages/core/src/config/config.ts @@ -1329,6 +1329,8 @@ export interface ConfigParameters { outboundCorrelation?: OutboundCorrelationSettings; gitCoAuthor?: GitCoAuthorParam; usageStatisticsEnabled?: boolean; + /** Whether to refresh the models.dev catalog during initialization. */ + modelCatalogRefreshEnabled?: boolean; /** * If true, disables the per-session FileReadCache short-circuit * (file_unchanged placeholder). Useful for sessions that may undergo @@ -3099,6 +3101,7 @@ export class Config { private readonly outboundCorrelationSettings: OutboundCorrelationSettings; private readonly gitCoAuthor: GitCoAuthorSettings; private readonly usageStatisticsEnabled: boolean; + private readonly modelCatalogRefreshEnabled: boolean; private readonly fileReadCacheDisabled: boolean; private activeTodoReminders = new Map(); private activeTodoWorkChainOwners = new Map(); @@ -3569,6 +3572,7 @@ export class Config { email: 'qwen-coder@alibabacloud.com', }; this.usageStatisticsEnabled = params.usageStatisticsEnabled ?? true; + this.modelCatalogRefreshEnabled = params.modelCatalogRefreshEnabled ?? true; // A Managed session reads in its Runtime worker, so this process could // neither elide a repeated read nor prove the read an edit requires. this.fileReadCacheDisabled = @@ -4239,7 +4243,9 @@ export class Config { ): Promise { this.debugLogger.info('Config initialization started'); await this.proxyDispatcherReady; - void refreshModelCatalog(); + if (this.modelCatalogRefreshEnabled && this.usageStatisticsEnabled) { + void refreshModelCatalog(); + } options?.signal?.throwIfAborted(); // Omni multimodal support declares ffmpeg/ffprobe as hard runtime // prerequisites: fail fast at startup with an actionable message diff --git a/packages/vscode-ide-companion/schemas/settings.schema.json b/packages/vscode-ide-companion/schemas/settings.schema.json index 26f72174dce..03dfb53ee35 100644 --- a/packages/vscode-ide-companion/schemas/settings.schema.json +++ b/packages/vscode-ide-companion/schemas/settings.schema.json @@ -651,7 +651,7 @@ "type": "object", "properties": { "usageStatisticsEnabled": { - "description": "Enable collection of usage statistics", + "description": "Enable collection of usage statistics. Disabling this also stops the models.dev catalog refresh.", "type": "boolean", "default": true } @@ -853,6 +853,11 @@ ], "description": "How hard reasoning-capable models think, applied across all providers. Set with /effort. Each provider maps and clamps this to what the active model supports (e.g. Gemini caps at \"high\"; Anthropic clamps tiers a model lacks). Leave unset to use the model/provider default." }, + "catalogRefresh": { + "description": "Download the models.dev model catalog in the background at most once every 24 hours. Disable it in managed installations that must not contact models.dev; the bundled and previously downloaded catalog data remain available. Only honored from User, System, and SystemDefaults settings scopes; values set in Workspace settings are ignored. QWEN_CODE_MODELS_DEV_REFRESH=off in the process environment still wins, and disabling privacy.usageStatisticsEnabled also disables the refresh.", + "type": "boolean", + "default": true + }, "maxSessionTurns": { "description": "Maximum number of user/model/tool turns to keep in a session. Must be an integer; -1 means unlimited.", "type": "integer",