Skip to content

Every HTTP 403 is classified as AuthenticationError, so FreeTierError/RegionError reach ACP clients as "Authentication required" #55995

Open

Description

@JumpLink

Description

classifyProviderFailure (packages/ai/src/provider-error.ts, v2.0.30) returns AuthenticationError for any status === 403, before looking at the error type. 403s that a login cannot fix therefore surface as Authentication required: provider authentication required (ACP -32000, via packages/core/src/session/to-session-error.ts → packages/cli/src/acp/event.ts → packages/cli/src/acp/error.ts):

ACP clients then offer a login, which cannot help.

Suggested:

  1. Classify FreeTierError / RegionError before the bare 403 check, as their own reason (or at least not auth).
  2. Pass the server's message through to the client.
  3. Use RegionError for the free-tier region refusal too: today it shares FreeTierError with the "within OpenCode" refusal, and only the message tells them apart.

OpenCode version

2.0.30 (code), 2.0.19 (measured refusal)

Steps to reproduce

  1. Run opencode acp with no login.
  2. Select a free model that answers 403 (region or free-tier gate).
  3. Send session/prompt.
  4. The client receives {"code":-32000,"message":"Authentication required: provider authentication required"} instead of the server's message.

Operating System

Fedora 44, Linux 7.2

Activity

  1. opencode-agent commented on Oct 16, 2026

    @opencode-agent
    Contributor

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

    I used a local mock OpenAI-compatible gateway in place of a real provider. It answers every request with HTTP 403 and an error body of type FreeTierError (and, in a second run, RegionError) with the message "This model is not available in your country". I then ran opencode acp (2.0.30) with a custom provider pointing at it and sent initialize, session/new and session/prompt. The gateway refused both requests with 403, and session/prompt returned:

    {"code":-32000,"message":"Authentication required: provider authentication required","data":{}}

    The provider's message never reaches the client. The cause looks like what you describe: classifyProviderFailure treats any 403 as an authentication failure before it looks at the error type, and ACP turns that into auth-required. The script below reproduces it (bun repro.ts FreeTierError or bun repro.ts RegionError).

    Related: #48297 / #48463 (where the 401/403 → auth-required mapping for ACP was added), #47949 / #49201 (RegionError from the gateway), #50366 / #50627 (the "free tier can only be used from within OpenCode" refusal).

    Reproduction script (repro.ts)
    // Reproduces #55995: a provider 403 FreeTierError/RegionError reaches ACP clients as
    // "Authentication required" (-32000) instead of the provider's message.
    // Usage: bun repro.ts            (uses `opencode` on PATH)
    //        OC_DEV=/workspace/repo bun repro.ts   (runs the CLI from a source checkout)
    import { mkdtempSync, writeFileSync } from "node:fs"
    import { tmpdir } from "node:os"
    import { join } from "node:path"
    
    const kind = process.argv[2] ?? "FreeTierError"
    const messages: Record<string, string> = {
      FreeTierError: "This model is not available in your country",
      RegionError: "This model is not available in your country",
    }
    // Mock OpenAI-compatible gateway that refuses like the OpenCode Zen gateway does.
    const server = Bun.serve({
      port: 0,
      fetch(req) {
        console.error(`[mock] ${req.method} ${new URL(req.url).pathname} -> 403 ${kind}`)
        return Response.json({ type: "error", error: { type: kind, message: messages[kind] } }, { status: 403 })
      },
    })
    
    const dir = mkdtempSync(join(tmpdir(), "acp403-"))
    writeFileSync(
      join(dir, "opencode.json"),
      JSON.stringify({
        $schema: "https://opencode.ai/config.json",
        model: "mock/free-model",
        provider: {
          mock: {
            npm: "@ai-sdk/openai-compatible",
            name: "Mock",
            options: { baseURL: `http://127.0.0.1:${server.port}/v1`, apiKey: "public" },
            models: { "free-model": { name: "Free model" } },
          },
        },
      }),
    )
    
    const cmd = process.env.OC_DEV
      ? ["bun", "--conditions=browser", `--preload=${process.env.OC_DEV}/packages/tui/node_modules/@opentui/solid/scripts/preload.js`, `${process.env.OC_DEV}/packages/cli/src/index.ts`, "acp"]
      : ["opencode", "acp"]
    const proc = Bun.spawn(cmd, { cwd: dir, stdin: "pipe", stdout: "pipe", stderr: "inherit", env: { ...process.env, ...(process.env.OC_DEV ? { BUN_OPTIONS: `--conditions=browser --preload=${process.env.OC_DEV}/packages/tui/node_modules/@opentui/solid/scripts/preload.js` } : {}), OPENCODE_CONFIG_DIR: join(dir, ".cfg"), XDG_DATA_HOME: join(dir, ".data") } })
    
    let id = 0
    const pending = new Map<number, (m: any) => void>()
    const send = (method: string, params: any) =>
      new Promise<any>((resolve) => {
        const i = ++id
        pending.set(i, resolve)
        proc.stdin.write(JSON.stringify({ jsonrpc: "2.0", id: i, method, params }) + "\n")
        proc.stdin.flush()
      })
    ;(async () => {
      let buf = ""
      for await (const chunk of proc.stdout) {
        buf += new TextDecoder().decode(chunk)
        let nl
        while ((nl = buf.indexOf("\n")) >= 0) {
          const line = buf.slice(0, nl)
          buf = buf.slice(nl + 1)
          if (!line.trim()) continue
          const msg = JSON.parse(line)
          if (msg.id !== undefined && pending.has(msg.id) && !msg.method) {
            pending.get(msg.id)!(msg)
            pending.delete(msg.id)
          } else if (msg.method && msg.id !== undefined) {
            proc.stdin.write(JSON.stringify({ jsonrpc: "2.0", id: msg.id, error: { code: -32601, message: "n/a" } }) + "\n")
          }
        }
      }
    })()
    
    const init = await send("initialize", { protocolVersion: 1, clientCapabilities: {} })
    console.log("initialize:", JSON.stringify(init.result ?? init.error).slice(0, 200))
    const sess = await send("session/new", { cwd: dir, mcpServers: [] })
    console.log("session/new:", JSON.stringify(sess.error ?? { sessionId: sess.result?.sessionId }))
    const res = await send("session/prompt", { sessionId: sess.result.sessionId, prompt: [{ type: "text", text: "hi" }] })
    console.log("session/prompt response:", JSON.stringify(res.error ?? res.result))
    proc.kill()
    server.stop(true)
    process.exit(0)

    Run it: bun repro.ts

  2. Ehsan-hash commented on Oct 16, 2026

    @Ehsan-hash

    Working on a small fix that keeps typed RegionError and FreeTierError 403 refusals out of the authentication path. Local gateway-to-ACP checks now preserve both refusal messages while keeping genuine 401/403 authentication errors unchanged. Completing independent review before opening the PR. Preparation and testing are AI-assisted.

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