Skip to content

config: legacy provider shape silently drops compatibility for custom provider models #55652

Closed as not planned

Description

@yukunmeng

Summary

Custom providers declared with the legacy V1 config shape (provider + npm + options) lose their compatibility block. Every one of their models reaches GET /api/model with compatibility absent, so a documented compatibility.reasoningField never applies. Declaring the same provider with the V2 shape (providers + package + settings) preserves the field.

Environment

  • opencode version: 2.0.29 (@opencode/cli 2.0.29, npm global install)
  • OS: Windows 11 (NT 10.0.26200)
  • Terminal: Web UI in Chrome, TERM=xterm-256color
  • Shell: cmd.exe
  • Install/channel: latest
  • Active plugins: none found in config

Reproduction

  1. Declare a custom OpenAI-compatible provider using the legacy shape in ~/.config/opencode/opencode.json:
{
  "provider": {
    "acme": {
      "npm": "@ai-sdk/openai-compatible",
      "name": "Acme",
      "options": { "baseURL": "http://127.0.0.1:8788/v1", "apiKey": "<redacted>" },
      "compatibility": { "reasoningField": "reasoning_content" },
      "models": {
        "glm-5.2": {
          "name": "GLM-5.2",
          "limit": { "context": 1000000, "output": 64000 },
          "capabilities": { "tools": true, "input": ["text", "image"], "output": ["text"] }
        }
      }
    }
  }
}
  1. Read the model catalog: opencode api get /api/model.

  2. Every acme model comes back without compatibility:

{
  "id": "glm-5.2",
  "providerID": "acme",
  "package": "@opencode/ai/providers/openai-compatible",
  "capabilities": { "tools": true, "input": ["text", "image"], "output": ["text"] },
  "limit": { "context": 1000000, "output": 64000 }
}
  1. Rewrite the same provider in the V2 shape and change nothing else:
{
  "providers": {
    "acme": {
      "name": "Acme",
      "package": "@opencode/ai/providers/openai-compatible",
      "settings": { "baseURL": "http://127.0.0.1:8788/v1", "apiKey": "<redacted>" },
      "compatibility": { "reasoningField": "reasoning_content" },
      "models": { "glm-5.2": { "name": "GLM-5.2", "limit": { "context": 1000000, "output": 64000 } } }
    }
  }
}
  1. Now every model of that provider returns the field (37/37 in my setup), and it is picked up without restarting the server:
{ "id": "glm-5.2", "providerID": "acme", "compatibility": { "reasoningField": "reasoning_content" } }

Expected Behavior

Provider-level compatibility applies to every model of the provider and should survive normalization under both config shapes, exactly like name, options/settings, and models do. V1 config files are documented as remaining compatible in V2.

Actual Behavior

Under the legacy provider shape, compatibility is silently dropped for the provider and for all of its models. No warning, no error, and /api/model simply omits the key. The setting is therefore a no-op for providers declared that way.

Additional Context

Activity

  1. opencode-agent commented on Oct 15, 2026

    @opencode-agent
    Contributor

    Thanks for the detailed report. I can confirm this on 2.0.29, and it still happens on the latest dev (4397ee44).

    With the acme provider in the old provider / npm / options shape and a provider-level compatibility: { reasoningField: "reasoning_content" }, GET /api/model returns acme/glm-5.2 without compatibility. The same provider in the providers / package / settings shape returns compatibility: { reasoningField: "reasoning_content" }. The script below runs both configs against opencode serve and prints the result for each.

    It looks like the config loader reads old-shape providers with the v1 provider schema. That schema has no compatibility field, so the key is dropped with no warning. For now, writing the provider in the providers shape (as you did) works around it.

    Reproduction script (repro.sh)
    #!/usr/bin/env bash
    # Reproduces #55652: provider-level `compatibility` dropped for legacy V1 provider shape.
    # Usage: ./repro.sh [opencode command...]   (default: opencode)
    set -u
    OC=("${@:-opencode}")
    [ $# -eq 0 ] && OC=(opencode)
    run() { # $1=label $2=config json
      local dir; dir=$(mktemp -d)
      mkdir -p "$dir/home/.config/opencode" "$dir/proj"
      printf '%s' "$2" > "$dir/home/.config/opencode/opencode.json"
      echo "== $1"
      local port=$((20000 + RANDOM % 20000))
      export HOME="$dir/home" XDG_CONFIG_HOME="$dir/home/.config" XDG_DATA_HOME="$dir/home/.local/share" \
         XDG_STATE_HOME="$dir/home/.local/state" XDG_CACHE_HOME="$dir/home/.cache" OPENCODE_PASSWORD=repro-pass
      (cd "$dir/proj" && exec "${OC[@]}" serve --hostname 127.0.0.1 --port "$port" >"$dir/serve.log" 2>&1) &
      local pid=$!
      for _ in $(seq 1 120); do curl -s -o /dev/null "http://127.0.0.1:$port/" && break; sleep 1; done
      (cd "$dir/proj" && "${OC[@]}" api --server "http://127.0.0.1:$port" get /api/model 2>&1) | python3 -c '
    import json,sys
    raw=sys.stdin.read()
    try: d=json.loads(raw)
    except Exception: print(raw[:2000]); sys.exit()
    items=d if isinstance(d,list) else d.get("data",d.get("items",d))
    if isinstance(items,dict): items=list(items.values())
    for m in items:
      if isinstance(m,dict) and m.get("providerID")=="acme":
        print(json.dumps({k:m.get(k) for k in ("id","providerID","package","compatibility")}))
    '
      kill $pid 2>/dev/null; wait $pid 2>/dev/null
    }
    run "legacy V1 shape (provider/npm/options)" '{"provider":{"acme":{"npm":"@ai-sdk/openai-compatible","name":"Acme","options":{"baseURL":"http://127.0.0.1:8788/v1","apiKey":"x"},"compatibility":{"reasoningField":"reasoning_content"},"models":{"glm-5.2":{"name":"GLM-5.2","limit":{"context":1000000,"output":64000}}}}}}'
    run "V2 shape (providers/package/settings)" '{"providers":{"acme":{"name":"Acme","package":"@opencode/ai/providers/openai-compatible","settings":{"baseURL":"http://127.0.0.1:8788/v1","apiKey":"x"},"compatibility":{"reasoningField":"reasoning_content"},"models":{"glm-5.2":{"name":"GLM-5.2","limit":{"context":1000000,"output":64000}}}}}}'

    Run it: bash repro.sh

  2. Finn763 commented on Oct 15, 2026

    @Finn763
    Contributor

    Taking this one. The legacy (provider/npm/options) shape is validated by the v1 provider schema, which has no compatibility field, so the key is silently dropped. I'll extend the v1 schema to preserve it (same field definition as the v2 shape) and pin it with a config-load test.

  3. Finn763 commented on Oct 15, 2026

    @Finn763
    Contributor

    PR is up: #55703 — the v1 provider schema now accepts the optional compatibility block (same ProviderCompatibility schema the v2 shape uses) and migrateStandardProvider carries it through, with the legacy setCacheKey derivation merged underneath an explicit block. Two normalization cases added; both are red on the base (the block was dropped) and green after.

  4. opencode-agent commented on Oct 16, 2026

    @opencode-agent
    Contributor

    Closing this as not planned. The config mixes the two shapes: compatibility is a V2 providers field and has never been part of the V1 provider schema, so it isn't recognized inside a V1 provider entry.

    Use one shape or the other:

    • V2 (recommended): providers + package + settings + compatibility, as in your second example.
    • V1: set the reasoning field per model with interleaved, which is migrated to compatibility.reasoningField:
    "models": {
      "glm-5.2": { "interleaved": { "field": "reasoning_content" } }
    }
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions