Skip to content

[Bug]: ZCode 3.14 integration writes to the legacy provider file and is silently ineffective — injected models never appear #5348

Closed

Description

@zhangpeilin

Client or integration

Other — ZCode desktop (Z.ai's desktop client). Note the dropdown has no ZCode option even though ocx zcode / the "ZCode injection" integration is a shipped, documented feature (cf. #2022, #4147, #4295).

Area

CLI — client integration config export (src/clients/config-export/zcode.ts).

Summary

On ZCode 3.14.0, the ZCode integration reports a successful apply (correct fingerprints, block present on disk) but none of the injected models ever appear in the app's model picker. The injection is silently ineffective.

Root cause: the exporter writes the provider block to ~/.zcode/v2/config.json, but ZCode ≥ 3.14 no longer reads custom providers from that file in normal operation. Custom providers now live in ~/.zcode/v2/provider_config.json, and the legacy v2/config.json provider table is only consumed by a one-shot importLegacy migration that runs only when provider_config.json does not exist. On a machine where ZCode has already created that file (it is created on first run, before OpenCodex is ever installed), the migration never runs again — so anything written to v2/config.json is never read.

The exporter's docstring states the schema was "validated live against ZCode 3.7.7 / 3.8.1 and re-extracted from 3.11.2's bundle." The 3.14 client moved its provider store, so the write target is now stale. Reproduced on macOS with 2.60.0 (current latest on npm), so an upgrade does not help.

Reproduction

  1. Install ZCode desktop 3.14.0 and launch it once (this creates ~/.zcode/v2/provider_config.json; on our machine it already existed from a prior run, dated before OpenCodex was installed).
  2. ocx zcode enable (or toggle the ZCode injection in the dashboard) with any provider catalog configured — we used a command-code subscription (76 models).
  3. Confirm the write landed: ~/.zcode/v2/config.json contains provider.opencodex with enabled: true, kind: "openai", options.baseURL: "http://127.0.0.1:10100/v1", apiKeyRequired: true, and all 76 models entries. OpenCodex's own journal/records.json reports the apply as successful with matching fingerprints.
  4. Restart ZCode (we restarted after the injection; also retried a later apply/restart cycle). No OpenCodex provider and none of the 76 models are listed in the model picker.
  5. ~/.zcode/v2/provider_config.json is untouched by the injection (mtime unchanged, no provider inside). ZCode's logs contain no provider config error — consistent with the file simply never being read.

Workaround that confirms the diagnosis (all 76 models then appear immediately, no restart needed):

cp ~/.zcode/v2/provider_config.json ~/.zcode/v2/provider_config.json.bak
rm ~/.zcode/v2/provider_config.json

Deleting the file makes ZCode's repository take its ENOENT branch, run importLegacy against v2/config.json, migrate the OpenCodex block into the new store, and persist it. After that, ZCode sees the provider and every injected model.

Version

@bitkyc08/opencodex 2.60.0 (npm latest at time of writing).

Operating system

macOS 26.5.1 (build 25F80), arm64. ZCode desktop 3.14.0.

Provider and model

command-code/* — all 76 exported rows (e.g. command-code/claude-opus-5, command-code/MiniMaxAI-Minimax-M3, command-code/Qwen-Qwen3.8-Max). The problem is not provider-specific: nothing under provider.opencodex is visible at all.

Logs or error output

OpenCodex side — apply reported successful, block verified on disk:

{ "opId": "afd991bd-...", "clientId": "zcode", "kind": "apply",
  "at": "2026-09-20T11:28:34.036Z",
  "configPath": "/Users/<user>/.zcode/v2/config.json",
  "snapshot": { "kind": "none" }, "resultAbsent": false }

ZCode side — no provider/config error of any kind; the legacy reader's own failure messages (e.g. 旧 Provider 配置读取失败 / 解析失败 / 校验失败) never appear, because the reader is never invoked.

Redacted configuration

What the exporter wrote to ~/.zcode/v2/config.json (this part is correct, and the 3.14 parser still understands it — its openCodeProviderToModelProviderConfig / openCodeModelToModelProviderModel / openCodeReasoningToModelReasoning helpers map options.baseURL, the models object map, reasoning.{enabled,variants,defaultVariant} and limit.context exactly as written):

{
  "provider": {
    "opencodex": {
      "name": "OpenCodex",
      "kind": "openai",
      "enabled": true,
      "source": "custom",
      "options": {
        "apiKey": "opencodex-loopback",
        "baseURL": "http://127.0.0.1:10100/v1",
        "apiKeyRequired": true
      },
      "models": {
        "command-code/claude-opus-5": {
          "name": "... (command-code)",
          "modalities": { "input": ["text"], "output": ["text"] },
          "limit": { "context": 200000 }
        }
      }
    }
  }
}

What ZCode 3.14 actually reads (~/.zcode/v2/provider_config.json) — this is the shape its own migration produced, i.e. what an exporter targeting the current store would need to emit:

{
  "schemaVersion": 1,
  "config": {
    "providerConfigRules": {
      "providerRules": [
        {
          "providerId": "opencodex",
          "enabled": true,
          "providerName": "OpenCodex",
          "config": {
            "group": "standard-personal",
            "access": { "apiKey": "opencodex-loopback" },
            "api": { "type": "openai-responses", "baseUrl": "http://127.0.0.1:10100/v1" },
            "personalModelIds": ["command-code/claude-opus-5", "..."],
            "modelOrder": ["command-code/claude-opus-5", "..."]
          }
        }
      ]
    },
    "modelConfigRules": {
      "providerModelRules": [
        { "providerId": "opencodex", "modelId": "command-code/claude-opus-5",
          "config": { "properties": { "contextWindow": 200000 } } }
      ]
    }
  }
}

Code evidence from the ZCode 3.14.0 bundle

Extracted from /Applications/ZCode.app/Contents/Resources/app.asar (minified, so names are the bundles' own symbol annotations):

  • readRawZCodeConfigFile reads v2/config.json and its error strings call it the legacy provider config (旧 Provider 配置读取失败 / 解析失败 / 校验失败).

  • readZCodeConfigProviders (which parses the provider table of v2/config.json) is reached only through readLegacyZCodeConfigProviders, and is wired in solely as the importLegacy hook: ...t.readLegacyProviders ? { importLegacy: async () => importLegacyPersonalProviderConfig({ legacyProviders: await t.readLegacyProviders() }) } : {}.

  • NodePersonalProviderConfigRepository (class pg) — the live store. Its loader reads provider_config.json and only when the read returns null does it call importLegacy:

    async #f() {
      let t = await $k(this.#e);                 // read provider_config.json
      if (t === null) {                          // file absent
        let o = await this.#t?.();               // ← importLegacy only here
        let s = o ?? emptyConfig();
        return Mi(o ? await this.#p(s) : s);
      }
      let n = Ti(t.value), r = no(n);            // file present → legacy file never consulted
      ...
    }

    Its read() path and its ~1s polling loop likewise watch only provider_config.json.

  • Constants: Ec = "provider_config.json"; the path is overridable via the ZCODE_PERSONAL_PROVIDER_CONFIG_FILE env var (with ZCODE_BUILTIN_PROVIDER_CONFIG_FILE for the builtin side).

We could not find any provider_config.json reference anywhere in the OpenCodex source (grep over src/), and no ZCode version detection either — so the integration cannot currently notice this transition or warn about it.

Suggested fixes

Any of these would resolve it; 1 and 3 together would be ideal:

  1. Target the current store. Write the provider into ~/.zcode/v2/provider_config.json in its native shape (illustrated above), preserving unrelated existing rules. This is what ZCode itself produces via migration, so the shape is authoritative rather than guessed.
  2. Version-aware target selection. Keep the v2/config.json write for older clients, and switch to provider_config.json for 3.14+ (the version is available — ZCode reports app_version in its own API calls, and CFBundleShortVersionString on macOS).
  3. Detect and report the no-op instead of claiming success. If the target is the legacy file and provider_config.json already exists, the injection cannot take effect — the apply should not report an unqualified success. At minimum emit a clear warning and document the two-step remedy (quit ZCode, delete provider_config.json, relaunch) so users are not left with a silent no-op.

Residual limitations we hit (for documentation, not part of the bug)

  • Because the migration is one-shot, OpenCodex's write to the legacy file is not re-imported after our workaround. Catalog changes (models added/removed by a subscription) do not propagate; the delete-and-relaunch dance has to be repeated. A native write to the current store (fix 1) would remove this.
  • Disabling the integration does not remove the migrated provider from provider_config.json — OpenCodex only tracks the legacy file, so the entry has to be deleted by hand in ZCode's UI.

Checks

  • I searched existing issues and documentation.
  • I removed secrets, tokens, account details, request credentials, and personal data.

Activity

  1. lidge-jun commented on Sep 21, 2026

    @lidge-jun
    Owner

    Fixed on dev in 5d3f5db.

    The integration now resolves its target through the store the client reads rather than assuming the legacy config file. When the store exists and its shape is one this project has observed, apply and catalog refresh write the provider there and disable removes it from the same file, so enable, refresh and disable stay symmetric. When our own block is still sitting in the old config file, the operation stays on that file so a block is never left in one place while another is written, and when the store's schema cannot be established the write is reported as ineffective instead of successful. Relocating the store through the client's own environment variable is followed; a relative override is refused rather than resolved against a guess, because the working directory that would resolve it is the client's, not ours.

    Thanks for the detailed reproduction and for confirming the working path — the store's own schema is what made it safe to write there rather than only report the problem.

  2. zhangpeilin commented on Sep 22, 2026

    @zhangpeilin
    Author

    Follow-up: the missing access.type in 2.61.0's store builder breaks the entire personal provider config load on ZCode 3.14.1

    Opening a follow-up because this is user-confirmed with logs: the store builder shipped in 2.61.0 writes access without a type field, and on ZCode 3.14.1 that fails the load of the whole personal provider store — the app degrades to an empty in-memory config and no models load at all (not just OpenCodex's).

    Where it comes from. zcode-store.ts buildZcodeStoreProviderRule (2.61.0) emits:

    access: { apiKey: LOOPBACK_API_KEY_PLACEHOLDER },

    and its doc comment says the shape is "quoted from the report that opened #5348". That quote is mine, so the defect in it is mine to own: the abridged JSON in my opening report omitted access.type. The client's own migration output actually includes it:

    "access": { "type": "api-key", "apiKey": "opencodex-loopback" }

    Empirical confirmation (ZCode 3.14.1, macOS 26.5.1 arm64, OpenCodex 2.61.0). After the integration wrote the store without the field, ZCode logs this on every config poll and the model list never loads:

    [provider-config] Personal Provider Config 加载失败,已保留磁盘状态并以内存空配置降级
    {"error":{"name":"ZodError","message":"[\n  {\n    \"code\": \"invalid_union\",\n    \"errors\": [],\n    \"note\": \"No matching discriminator\",\n    \"discriminator\": \"type\",\n    \"options\": [\n      \"api-key\",\n      \"zhipu-coding-plan-api-key\",\n      \"zhipu-account\"\n    ],\n    \"path\": [\n      \"providerRules\",\n      0,\n      \"config\",\n      \"access\",\n      \"type\"\n    ],\n    \"message\": \"Invalid discriminator value. Expected 'api-key' | 'zhipu-coding-plan-api-key' | 'zhipu-account'\"\n  }\n]"}}
    

    So the store's access is gated by a discriminated union with no default before the in-memory classes are ever constructed. I had previously read the bundle's ApiKeyAccessConfig constructor (this.type = t.type ?? "api-key") and concluded the omission was harmless — that was wrong: the union validation rejects the document before that default can apply. On disk the field is effectively required. (Confirmed on 3.14.1; I have not separately re-tested 3.14.0, but the failure mode lives in the shared load path.)

    A/B evidence from one machine (all times local, 2026-09-23):

    1. 01:28:49 — 2.61.0 overwrite writes the store (81 models, access without type).
    2. 01:29:37 — ZodError storm begins; model list gone.
    3. 01:30:38 — "type": "api-key" added by hand to providerRules[0].config.access → config loads again, all 81 models present.
    4. 01:37:40 — a subsequent 2.61.0 overwrite rewrites the store and strips the field again → ZodErrors resume (still repeating at 01:42:20).

    Suggested fix. Emit the field the client itself always writes:

    access: { type: "api-key", apiKey: LOOPBACK_API_KEY_PLACEHOLDER },

    in buildZcodeStoreProviderRule, with ZcodeStoreProviderRule.config.access widened to access: { type: "api-key"; apiKey: string }. Worth pinning the field in the schema predicate too, so a future client that stops accepting api-key is reported rather than merged into. Note the client's own serializer always materializes type when it rewrites the file — which is exactly why a store the client has touched never exposes this, and a store only this project wrote does.

    For users hitting this now: add "type": "api-key" to the rule's access object in ~/.zcode/v2/provider_config.json (or re-run the client's own legacy migration) — the config loads immediately.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    bugSomething isn't working

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions