Skip to content

providers: request falls back to api.anthropic.com (401 invalid x-api-key) after plugin hot-reload #47114

Open

Description

@robin-bially

Summary

With a custom providers.anthropic.settings.baseURL pointing at a local proxy (and a dummy apiKey), two requests bypassed the configured baseURL and went to api.anthropic.com, failing with 401 invalid x-api-key. Both happened less than a second after a plugin file change triggered a plugin reload and catalog rebuild.

Beyond the failed turn, this is a credential-exposure concern: the API key configured for a private relay is sent to a third-party endpoint the user never configured. ANTHROPIC_BASE_URL is not honored, so there is no environment-level safety net.

I could not reproduce it on demand (~40 controlled turns across five hypotheses, all listed below), so this report focuses on hard evidence rather than a recipe.

Environment

  • opencode version: 0.0.0-beta-18999
  • OS: Darwin 27.0.0, arm64 (macOS)
  • Terminal: TERM=xterm-256color (TERM_PROGRAM unset)
  • Shell: /bin/zsh
  • Install/channel: beta, npm global (@opencode-ai/cli)
  • Active plugins: one local plugin directory (plugins: ["/Users/<user>/.config/opencode/plugins/<name>"]). It registers session.hook("model.request") and session.hook("http.request") for providerID: "anthropic" and only adds/removes request headers. It does not touch baseURL.

Relevant config (redacted):

{
  "providers": {
    "anthropic": {
      "package": "@opencode-ai/ai/providers/anthropic",
      "settings": { "baseURL": "http://127.0.0.1:3456", "apiKey": "x" },
      "models": { "claude-opus-5": { "limit": { "context": 1000000, "output": 32000 } } }
    }
  }
}

The proxy at 127.0.0.1:3456 accepts any API key, so a request that reaches it can never produce invalid x-api-key. Verified directly:

$ curl -s -o /dev/null -w '%{http_code}\n' http://127.0.0.1:3456/v1/messages \
    -H 'x-api-key: totally-bogus' -H 'anthropic-version: 2023-06-01' \
    -H 'content-type: application/json' -d '{"model":"claude-haiku-4-5","max_tokens":8,"messages":[{"role":"user","content":"hi"}]}'
200

$ curl -s https://api.anthropic.com/v1/messages -H 'x-api-key: x' ... 
{"type":"error","error":{"type":"authentication_error","message":"invalid x-api-key"},...}

The error string is verbatim the upstream Anthropic response, and the proxy logged no request at those moments. The request therefore left the machine.

Reproduction

A. The failure mode itself is deterministic

  1. Configure providers.anthropic with settings.apiKey but no baseURL.
  2. Run any prompt: opencode2 run --standalone -m anthropic/claude-opus-5 "say ok".
  3. Result: Error: invalid x-api-key.
  4. Repeat step 2 with ANTHROPIC_BASE_URL=http://127.0.0.1:3456 set: still invalid x-api-key.

Step 4 is the part worth fixing regardless of the race below: there is no way to redirect the built-in Anthropic provider outside of settings.baseURL, so any code path that loses that value fails open toward api.anthropic.com.

B. The observed occurrences (not reproducible on demand)

  1. Have a session running a turn with anthropic/claude-opus-5#medium through the configured proxy.
  2. Write to a file inside the configured local plugin directory (~/.config/opencode/plugins/<name>/index.js), which triggers a plugin reload in the running serve --service process.
  3. Occasionally the in-flight turn dies with provider.auth / invalid x-api-key / status 401.

Observed 2 times out of many reloads. Both times the log shows the same ordering within ~0.5 s:

15:54:07.386Z INFO  msg="loading plugin" id=/Users/<user>/.config/opencode/plugins/<name>
15:54:07.496Z INFO  event.type=catalog.updated event.location.directory=/…/worktree-1
15:54:07.510Z INFO  event.type=catalog.updated event.location.directory=/…/worktree-3
15:54:07.855Z ERROR message="Failed to drain Session" cause="AI.Error: invalid x-api-key
    at SessionStep.attempt … at SessionRunner.drain …"

The service had several sessions across multiple project directories (git worktrees) attached at the time.

Expected Behavior

When providers.<id>.settings.baseURL is configured, a request must never be sent to the provider's default endpoint. If the override cannot be resolved for a request — during a reload, a catalog rebuild, or anything else — the request should fail closed with a clear error instead of silently contacting api.anthropic.com with the configured credential.

Actual Behavior

The request is sent to api.anthropic.com with the locally configured key, and the turn dies with:

{ "type": "provider.auth", "message": "invalid x-api-key", "status": 401 }

Additional Context

Exactly two occurrences, confirmed structurally against the session store rather than by text search:

sqlite3 "file:$HOME/.local/share/opencode/opencode.db?mode=ro" \
  "SELECT time_created, json_extract(data,'\$.error')
     FROM session_message
    WHERE json_extract(data,'\$.error.type')='provider.auth' ORDER BY time_created;"

Both are in the same session, both agent=build, model=claude-opus-5 (variant medium), and each lands within one second of a plugin-directory write. None before, none in the ~2 hours since, across a service restart.

Hypotheses I tested and ruled out (isolated XDG_CONFIG_HOME/XDG_DATA_HOME/XDG_CACHE_HOME, ~40 turns total, zero reproductions):

Hypothesis Turns Result
Writing opencode.json / AGENTS.md / a plugin file mid-turn (3 s and 6 s in) 8 clean
Legacy provider + native providers conflict, and legacy-only config 12 clean
Model ref carrying a variant (claude-opus-5#medium) plus reload 4 clean
Shared background service, 3 concurrent turns from 3 project directories, plugin edit mid-turn 12 clean
Model not listed in config (resolved from built-in catalog), incl. full dated ID 3 clean

So the trigger is narrower than any of those alone — my guess is a request that resolves its model while the per-location catalog is being rebuilt, but I could not force that window.

Prior art for the same class of bug: openclaw/openclaw#23332 describes an Anthropic-relay setup where a dropped provider override silently fell back to api.anthropic.com, producing both a 401 and a credential leak. Their fix was to default the missing field and fail loudly instead of discarding overrides.

Activity

  1. opencodejr commented on Oct 28, 2026

    @opencodejr

    Thanks for the detailed report. I reproduced this on 0.0.0-beta-18999, and it still happens on the latest dev (35a4a68).

    Setup: your providers.anthropic config pointing at a local mock relay that accepts any key, one local plugin directory in plugins, and a shared serve process with an Anthropic credential in its environment (ANTHROPIC_API_KEY).

    • For about 2 seconds after the plugin file is saved, GET /api/model lists the anthropic models without your baseURL. On dev it shows https://api.anthropic.com/v1.
    • Prompts sent from three directories while saving the plugin file often failed with Error: invalid x-api-key, the real response from api.anthropic.com. On dev, 7 to 13 of 24 runs failed; the others reached the relay.

    This only happened when an Anthropic credential was available outside the config: an environment variable in my test, though stored auth credentials might do the same. Without one, the anthropic models disappeared from the catalog for that window and runs failed with Model unavailable instead, so nothing left the machine. That may be why your isolated tests stayed clean. Do you have ANTHROPIC_API_KEY set or a stored Anthropic credential where the service runs?

    The script below reproduces it. It looks closely related to #52333, where the catalog also briefly loses config-defined providers and policies during a plugin reload.

    Reproduction script (repro.sh)
    #!/usr/bin/env bash
    # Repro for anomalyco/opencode#47114: during a local plugin reload, the built-in `anthropic`
    # provider loses its configured `settings.baseURL`, so requests go to https://api.anthropic.com
    # and fail with 401 "invalid x-api-key" instead of hitting the configured relay.
    #
    # Usage: OC=opencode2 ./repro.sh          (installed CLI, e.g. @opencode-ai/cli@0.0.0-beta-18999)
    #        OC=/path/to/oc-dev.sh ./repro.sh (CLI from a dev checkout)
    # Needs: bun, curl, python3, git, outbound network (to observe the real Anthropic 401).
    # Precondition: an ambient Anthropic credential (here ANTHROPIC_API_KEY=x), so the built-in
    # anthropic provider stays in the catalog during the reload window.
    set -u
    OC=${OC:-opencode2}
    PORT=${PORT:-4315}
    S=$(mktemp -d /tmp/opencode/r47114.XXXX)
    export XDG_CONFIG_HOME=$S/config XDG_DATA_HOME=$S/data XDG_CACHE_HOME=$S/cache XDG_STATE_HOME=$S/state
    export OPENCODE_SERVER_PASSWORD=pw
    mkdir -p "$XDG_CONFIG_HOME/opencode/plugins/p" "$S/proj"
    (cd "$S/proj" && git init -q)
    
    # 1. Mock relay on 127.0.0.1:3456: accepts any key, answers with a tiny Anthropic SSE stream.
    cat > "$S/mock.ts" <<'EOF'
    Bun.serve({ port: 3456, hostname: "127.0.0.1", fetch() {
      const ev = (t: string, d: any) => `event: ${t}\ndata: ${JSON.stringify({ type: t, ...d })}\n\n`
      return new Response(
        ev("message_start", { message: { id: "m", type: "message", role: "assistant", model: "claude-opus-5", content: [], stop_reason: null, usage: { input_tokens: 1, output_tokens: 0 } } }) +
        ev("content_block_start", { index: 0, content_block: { type: "text", text: "" } }) +
        ev("content_block_delta", { index: 0, delta: { type: "text_delta", text: "ok" } }) +
        ev("content_block_stop", { index: 0 }) +
        ev("message_delta", { delta: { stop_reason: "end_turn" }, usage: { output_tokens: 1 } }) +
        ev("message_stop", {}),
        { headers: { "content-type": "text/event-stream" } })
    } })
    EOF
    bun "$S/mock.ts" & MOCK=$!
    
    # 2. Config from the report: anthropic pointed at the relay, plus one local plugin directory.
    cat > "$XDG_CONFIG_HOME/opencode/opencode.json" <<J
    {
      "plugins": ["$XDG_CONFIG_HOME/opencode/plugins/p"],
      "providers": { "anthropic": {
        "package": "@opencode-ai/ai/providers/anthropic",
        "settings": { "baseURL": "http://127.0.0.1:3456", "apiKey": "x" },
        "models": { "claude-opus-5": { "limit": { "context": 1000000, "output": 32000 } } } } }
    }
    J
    save_plugin() { printf '// v=%s\nexport default { id: "local.p", setup: async () => ({}) }\n' "$RANDOM$1" > "$XDG_CONFIG_HOME/opencode/plugins/p/index.js"; }
    save_plugin 0
    
    # 3. Shared server.
    (cd "$S/proj" && ANTHROPIC_API_KEY=x $OC serve --port $PORT --hostname 127.0.0.1 > "$S/serve.log" 2>&1) & SERVER=$!
    trap 'kill $SERVER $MOCK 2>/dev/null; pkill -P $SERVER 2>/dev/null' EXIT
    for _ in $(seq 60); do curl -s -u opencode:pw -o /dev/null "localhost:$PORT/api/health" && break; sleep 1; done
    sleep 5
    
    # 4. Catalog view: anthropic baseURL as seen by GET /api/model while the plugin file is saved.
    echo "== anthropic baseURL in GET /api/model while saving the plugin file (count, value)"
    save_plugin x
    for _ in $(seq 60); do
      curl -s -u opencode:pw "localhost:$PORT/api/model?directory=$S/proj" | python3 -c '
    import sys,json
    d=json.load(sys.stdin)["data"]
    print(sorted({m.get("settings",{}).get("baseURL") for m in d if m["providerID"]=="anthropic"}))'
      sleep 0.05
    done | uniq -c
    
    # 5. Prompts racing plugin saves.
    mkdir -p "$S/out"
    for i in $(seq 8); do
      save_plugin "$i"
      for d in a b c; do
        (sleep 0.$((RANDOM % 5)); cd "$S/proj"; timeout 60 $OC run --server "http://127.0.0.1:$PORT" -m anthropic/claude-opus-5#medium "say ok" > "$S/out/$i-$d.txt" 2>&1) &
      done
      sleep 0.7
    done
    wait $(jobs -p | grep -v -e "^$SERVER$" -e "^$MOCK$") 2>/dev/null
    sleep 2
    echo "== runs: $(ls "$S/out" | wc -l)  ok (hit the relay): $(grep -l '^ok' "$S/out"/* | wc -l)  invalid x-api-key (went to api.anthropic.com): $(grep -li 'invalid x-api-key' "$S/out"/* | wc -l)"

    Run it: bash repro.sh

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

Metadata

Metadata

Assignees

Type

No type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions