Skip to content

provider: @ai-sdk/anthropic package returns 401 "Invalid or missing API key" on custom baseURL + authToken in v2.0.21 (works in v1.18.25) #52502

Open

Description

@valleedelisle

Summary

A custom provider configured with package: "aisdk:@ai-sdk/anthropic", a custom settings.baseURL, and settings.authToken fails every request with HTTP 401: Invalid or missing API key on opencode v2.0.21. The identical opencode.json provider definition works correctly against the same endpoint on opencode v1.18.25. A sibling provider on the same gateway using @ai-sdk/openai-compatible with the same credential works fine on both versions, isolating the regression to the Anthropic-native package's v2 request path.

Environment

  • opencode version: 2.0.21 (failing) / 1.18.25 (working) — both installed locally, same machine
  • OS: Darwin 25.6.0 (macOS, arm64)
  • Terminal: Unavailable (environment variables not set in this context)
  • Shell: /opt/homebrew/bin/fish
  • Install/channel: latest (v2.0.21 bundled by a desktop client; v1.18.25 via Homebrew)
  • Active plugins: -opencode.browser, a local agent-tool plugin, opencode-agent-skills@0.6.5

Reproduction

  1. Configure a custom Anthropic-compatible provider in opencode.json:
    "providers": {
      "my-anthropic-gateway": {
        "name": "My Anthropic Gateway",
        "package": "aisdk:@ai-sdk/anthropic",
        "settings": {
          "baseURL": "https://gateway.example.com/v1",
          "authToken": "{env:GATEWAY_API_KEY}"
        },
        "models": {
          "claude-model": {
            "modelID": "claude-model",
            "name": "Claude Model",
            "capabilities": { "tools": true, "input": ["text"], "output": ["text"] },
            "limit": { "context": 1000000, "output": 64000 }
          }
        }
      }
    }
  2. Verify the key works directly against the gateway's /v1/messages endpoint with curl -H "Authorization: Bearer $GATEWAY_API_KEY" → returns 200.
  3. Run a session on opencode v1.18.25 using my-anthropic-gateway/claude-model → succeeds consistently.
  4. Run a session on opencode v2.0.21 using the same config, same env var, same model → every request fails with:
    AI.Error.Authentication: Provider request failed with HTTP 401: {
      "type": "https://httpstatuses.com/401",
      "title": "Unauthorized",
      "detail": ["Invalid or missing API key"],
      "status": 401,
      "instance": "/v1/messages"
    }
    

Expected Behavior

v2.0.21 should send the same Authorization: Bearer <authToken> header to /v1/messages that v1.18.25 sends, and the gateway should accept it (as proven by direct curl and by v1.18.25 working).

Actual Behavior

Every request from v2.0.21 to the custom Anthropic provider's /v1/messages endpoint is rejected with 401 "Invalid or missing API key", even though:

  • The same API key/env var is confirmed valid via direct curl against the same endpoint.
  • The same key works through a sibling @ai-sdk/openai-compatible provider pointed at the same gateway (different endpoint path) under the same v2.0.21 process.
  • The credential stored by the client matches the live environment variable byte-for-byte (verified via hash comparison).

This strongly suggests the @ai-sdk/anthropic provider package in v2.0.21, when given a custom baseURL + authToken, fails to attach the Authorization: Bearer header correctly (or attaches it malformed/empty) on the outbound request, while v1.18.25's equivalent code path does it correctly.

Additional Context

  • Confirmed via server logs: 100% of @ai-sdk/anthropic-routed requests under the v2.0.21 process fail with this 401; 100% of requests to the same endpoint/key under v1.18.25 sessions succeed.
  • The sibling @ai-sdk/openai-compatible provider (same gateway, same API key, different package/endpoint) has zero failures under the same v2.0.21 process in the same time window — ruling out network, key, or gateway-side causes.
  • Direct curl reproduction against the gateway with Authorization: Bearer <key> returns 200 reliably; with both Authorization and x-api-key headers it's also 200; with only x-api-key it 401s — meaning the gateway strictly expects Authorization: Bearer for this key, matching the documented behavior of authToken in @ai-sdk/anthropic's AnthropicProviderSettings.
  • No config changes were made between the working v1.18.25 run and the failing v2.0.21 run — same opencode.json, same environment, same machine.
  • Workaround: none available for this specific model, since it is only exposed via the Anthropic Messages API on our gateway (not available through the OpenAI-compatible chat-completions route).

Activity

  1. github-actions commented on Oct 1, 2026

    @github-actions
    Contributor

    This issue might be a duplicate of existing issues. Please check:

  2. valleedelisle commented on Oct 1, 2026

    @valleedelisle
    Author

    Update: narrower root cause + confirmed workaround

    Follow-up after further isolation (same environment as the original report).

    Corrected root cause: this is not a blanket "authToken is broken in v2.0.21" regression. It is specific to how an implicit settings.authToken is resolved when a credential for the provider also exists in the client's credential store (created via a "connect"/UI flow rather than purely from the static config file).

    • A teammate's install, using a config that sets settings.apiKey and an explicit headers.Authorization: Bearer {env:...} override, works correctly on the same v2.0.21 build, same gateway, same key.
    • Our config, which relied solely on settings.authToken (no explicit headers override), reliably produced 401 Invalid or missing API key at /v1/messages — even though the same key is valid per direct curl and matches byte-for-byte (hash-verified) what's stored for the provider.
    • Per @ai-sdk/anthropic's request construction, explicit options.headers/provider headers are spread last, after whatever apiKey/authToken resolution produces — so an explicit Authorization header always wins regardless of internal resolution. That's why the teammate's config is unaffected while ours wasn't.
    • Confirmed workaround: adding an explicit provider-level headers: { "Authorization": "Bearer {env:API_KEY}" } alongside settings.apiKey (or authToken) fixes it immediately — verified via a live end-to-end session with no further 401s.

    This points suspicion specifically at how the v2 credential-store layer injects a stored "connect"-flow credential into @ai-sdk/anthropic's auth option (possibly defaulting it into apiKey/x-api-key instead of honoring a config-declared authToken, or otherwise failing to carry authToken through when a stored credential of generic shape {type: "key", key: ...} exists for the provider) — rather than a wholesale break of authToken support in the package itself, since static-config-only usage (no stored credential) continues to work fine on both versions.

    Happy to provide more targeted logs/repro around the credential-store injection path if useful for triage.

  3. opencodejr commented on Oct 28, 2026

    @opencodejr

    Thanks for the detailed report. I could reproduce this on 2.0.21, and it still happens on the latest dev (891a020bc).

    The 401 only appears when the same key is also stored as a credential for the provider, which matches what you described about the client's stored credential:

    1. Using your provider config (aisdk:@ai-sdk/anthropic, custom baseURL, authToken: "{env:GATEWAY_API_KEY}") against a local mock gateway that only accepts Authorization: Bearer, opencode run with no stored credential sends Authorization: Bearer <key> and works.
    2. After storing the same key with opencode auth login my-anthropic-gateway --method key, the request goes out with only x-api-key: <key>, no Authorization header, and fails with HTTP 401 ... Invalid or missing API key.

    It looks like a stored key credential replaces the configured authToken and is sent as an API key (x-api-key) instead of a Bearer token. Until this is looked at, removing the stored credential for that provider (opencode auth logout) and relying on settings.authToken alone should avoid the 401. That worked in my test.

    The script below reproduces it end to end.

    Reproduction script (repro.sh)
    #!/usr/bin/env bash
    # Repro for #52502: a stored key credential makes `aisdk:@ai-sdk/anthropic` send `x-api-key`
    # instead of the configured `settings.authToken` (Authorization: Bearer).
    # Usage: bash repro.sh            (uses `opencode` from PATH)
    #        From a dev checkout:
    #        BUN_OPTIONS="--conditions=browser --preload=$REPO/packages/tui/node_modules/@opentui/solid/scripts/preload.js" \
    #          OC="bun $REPO/packages/cli/src/index.ts" bash repro.sh
    set -u
    OC=${OC:-opencode}
    WORK=$(mktemp -d /tmp/opencode/repro52502.XXXX)
    export HOME=$WORK/home GATEWAY_API_KEY=secret-token
    mkdir -p "$HOME" "$WORK/proj"
    
    cat > "$WORK/mock.mjs" <<'EOF'
    import http from "node:http";
    http.createServer((req, res) => { req.resume(); req.on("end", () => {
      const a = req.headers["authorization"], x = req.headers["x-api-key"];
      console.log(JSON.stringify({ url: req.url, authorization: a ?? null, "x-api-key": x ?? null }));
      if (a !== "Bearer secret-token") { // gateway that only accepts Bearer, like the author's
        res.writeHead(401, { "content-type": "application/json" });
        return res.end(JSON.stringify({ title: "Unauthorized", detail: ["Invalid or missing API key"], status: 401, instance: "/v1/messages" }));
      }
      res.writeHead(200, { "content-type": "text/event-stream" });
      const ev = (t, d) => res.write(`event: ${t}\ndata: ${JSON.stringify(d)}\n\n`);
      ev("message_start", { type: "message_start", message: { id: "m", type: "message", role: "assistant", model: "claude-model", content: [], stop_reason: null, usage: { input_tokens: 1, output_tokens: 1 } } });
      ev("content_block_start", { type: "content_block_start", index: 0, content_block: { type: "text", text: "" } });
      ev("content_block_delta", { type: "content_block_delta", index: 0, delta: { type: "text_delta", text: "hello from mock" } });
      ev("content_block_stop", { type: "content_block_stop", index: 0 });
      ev("message_delta", { type: "message_delta", delta: { stop_reason: "end_turn" }, usage: { output_tokens: 3 } });
      ev("message_stop", { type: "message_stop" }); res.end();
    }); }).listen(4599);
    EOF
    node "$WORK/mock.mjs" > "$WORK/mock.log" 2>&1 &
    MOCK=$!; trap 'kill $MOCK' EXIT; sleep 1
    
    cat > "$WORK/proj/opencode.json" <<'EOF'
    {
      "providers": {
        "my-anthropic-gateway": {
          "name": "My Anthropic Gateway",
          "package": "aisdk:@ai-sdk/anthropic",
          "settings": { "baseURL": "http://127.0.0.1:4599/v1", "authToken": "{env:GATEWAY_API_KEY}" },
          "models": { "claude-model": { "modelID": "claude-model", "name": "Claude Model",
            "capabilities": { "tools": true, "input": ["text"], "output": ["text"] },
            "limit": { "context": 1000000, "output": 64000 } } }
        }
      }
    }
    EOF
    cd "$WORK/proj"
    
    echo "=== 1) no stored credential ==="
    timeout 90 $OC run --standalone -m my-anthropic-gateway/claude-model "hi" 2>&1 | tail -3
    cat "$WORK/mock.log"; : > "$WORK/mock.log"
    
    echo "=== storing the same key as a credential (opencode auth login, method key) ==="
    OC="$OC" python3 - <<'EOF'
    import os, pty, select, shlex, time
    pid, fd = pty.fork()
    if pid == 0:
        cmd = shlex.split(os.environ["OC"]) + ["auth", "login", "--standalone", "my-anthropic-gateway", "--method", "key"]
        os.execvp(cmd[0], cmd)
    out, sent, t = b"", False, time.time()
    while time.time() - t < 60:
        r, _, _ = select.select([fd], [], [], 0.5)
        if r:
            try: d = os.read(fd, 4096)
            except OSError: break
            if not d: break
            out += d
        if not sent and "\u2514".encode() in out:
            time.sleep(1)
            for c in b"secret-token": os.write(fd, bytes([c])); time.sleep(0.05)
            os.write(fd, b"\r"); sent = True
    print("connected" if b"Connected" in out else "login failed")
    EOF
    
    echo "=== 2) with stored credential ==="
    timeout 90 $OC run --standalone -m my-anthropic-gateway/claude-model "hi" 2>&1 | tail -3
    cat "$WORK/mock.log"

    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