Skip to content

claude-agent: saved model catalog loses context windows, models fall back to 200K and compaction triggers early #61020

Open

Description

@JtMotoX

The issue

The Claude Agent provider reports limit.context: 200000 for every model, while Claude Code reports 1M for the same models. OpenCode runs compaction for Claude Agent because Claude Code's own auto-compact is disabled. With the default 15% buffer, compaction starts near 170K instead of near 850K.

Context discovery itself works. The saved model catalog is what's wrong: it had no contextWindow on any model, and nothing ever re-runs the check to fill it in.

  • register in provider.ts only runs the full check at startup when the saved catalog is empty. A catalog full of models but without windows never triggers it.
  • When a chat starts, Claude Code's model list has no context windows. fromClaude only keeps windows from the plugin's in-memory copy, so a copy without windows saves a catalog without them.
  • Each server process loads the catalog once per directory and never re-reads it, so a stale copy can keep serving 200K, or write over a good catalog.

Reproduce

  1. Connect Claude Agent with a Claude Pro login (Claude Code 2.1.309).

  2. OpenCode's reported limits:

opencode api get /api/model | jq -c '.data[] | select(.providerID=="claude-agent") | [.id, .limit.context]'
["opus",200000]
["sonnet",200000]
["fable",200000]
["haiku",200000]
  1. Claude Code's reported limit:
claude -p /context --model opus | grep '^\*\*Tokens'
**Tokens:** 10.7k / 1m (1%)
  1. Saved models that have a context window:
sqlite3 ~/.local/share/opencode/opencode.db "select value from kv where key like 'plugin:%' and value like '%resolvedModel%'" | jq '[.models[] | select(.contextWindow)] | length'
0

Expected: the saved windows survive chat starts and restarts, and a catalog missing windows is re-checked.
Actual: once the windows are lost, they stay lost.

Local fix (recovery only)

This restored 1M for me. My two Claude Agent credentials are duplicates of the same Claude Code login, created by connecting twice. Neither stores any account data, so switching between them didn't change accounts. Activating a credential is what re-runs the full check.

  1. List the credential IDs:
opencode api get /api/integration/claude-agent | jq -c '.data.connections[] | {id, label}'
  1. Activate the other credential, then the one you want to keep. Each activation re-runs the full check and sends no prompt:
opencode api post /api/credential/<other-credential-id>/activate
opencode api post /api/credential/<credential-you-want-active>/activate
  1. Restart any other opencode serve process that was already running. They keep serving 200K until restarted.

After this, the step 4 count went from 0 to 11, and /api/model reported 1000000 on every server. claude-fable-5 stayed at 200K because discovery returns 429 Usage credits are required for this model on Pro. Haiku 4.5, Opus 4.6 and Sonnet 4.6 also report 200K, which matches Claude Code's docs.

Not tested: re-activating with only one credential. Also, connecting again after upgrading Claude Code did not restore the windows in my case (see below), so a reconnect may not be enough.

Request

Please fix this so users don't hit it again and don't need to know the recovery steps. I propose these changes in provider.ts. I read them off the code and haven't tested them:

  1. At startup, run the full check when any saved model lacks contextWindow, not only when the catalog is empty.
  2. Before writing the catalog, re-read the stored one and keep any windows it already has, so a stale instance can't erase them.

Environment

  • OpenCode version: 2.2.5
  • Claude Code: 2.1.309
  • Plugins: built-in opencode.provider.claude-agent
  • Operating System: Linux x86_64, kernel 6.8.0-142-generic (container)
  • Terminal: OpenCode TUI, zsh

Timeline and what I haven't confirmed:

  • My first connect used Claude Code 2.1.76 and failed with unknown option '--thinking-display=summarized'. It wrote no catalog.
  • I ran claude update (to 2.1.309) and connected again at 04:17:44. The catalog row was first created at 04:17:46, after the upgrade, and had no windows. So the old CLI did not write the broken catalog, and reconnecting after the upgrade did not repair it. The windows only came back when I activated a credential at 05:42, on the same server.
  • Why that first check on the updated Claude Code produced no windows is unknown. It may be a timing problem while the upgrade was finishing, but I haven't confirmed that.
  • I had three opencode serve processes sharing one database, and the log shows several of them writing the catalog. I haven't reproduced the overwrite from a clean install.
  • Actual compaction at about 170K. I haven't run a session that far. The figure comes from the source (RESERVE_SHARE = 0.15) and the reported window.

Activity

  1. opencodejr commented on Nov 1, 2026

    @opencodejr
    Contributor

    Thanks for the detailed report. I reproduced both problems on the latest dev (69269e4). The Claude Agent provider code there is the same as in 2.2.5. I didn't have a Claude login, so I used the repo's fake Claude Code test setup:

    1. No re-check at startup: with a saved catalog that has models but no contextWindow, and Claude Agent connected, startup runs no check and sonnet stays at 200000. In register, the full check only runs when the saved list is empty.
    2. Stale instance overwrites the windows: an instance starts with a catalog that has no windows. Another writer then saves haiku with a 1M window, and a chat starts on the first instance. The stored haiku loses its contextWindow, because fromClaude only fills in missing windows from its own in-memory copy and never re-reads storage.

    The script below adds these two regression tests to a checkout and runs them. Both fail on dev. I couldn't find an existing issue for this.

    Reproduction script (repro.sh)
    #!/usr/bin/env bash
    # Run from an opencode checkout (dev or v2.2.5) after `bun install`. Both tests fail = bug reproduced.
    set -e
    cd "${REPO:-.}/packages/core"
    cat > test/plugin/provider/claude-agent/issue-61020.test.ts <<'EOF'
    import { Message, SystemPart } from "@opencode/ai/effect"
    import type { LanguageModelV3 } from "@ai-sdk/provider"
    import type { AccountInfo } from "@anthropic-ai/claude-agent-sdk"
    import { AISDK } from "@opencode/core/aisdk"
    import { Agent } from "@opencode/core/agent"
    import { Bus } from "@opencode/core/bus"
    import type { ClaudeIO } from "@opencode/core/plugin/provider/claude-agent/claude/io"
    import { ClaudeSessions } from "@opencode/core/plugin/provider/claude-agent/claude/sessions"
    import { RequestKind } from "@opencode/core/plugin/provider/claude-agent/language-model/request-kind"
    import { ClaudeSystemPrompt } from "@opencode/core/plugin/provider/claude-agent/language-model/system-prompt"
    import { ClaudeIntegration } from "@opencode/core/plugin/provider/claude-agent/integration"
    import { ClaudeProvider } from "@opencode/core/plugin/provider/claude-agent/provider"
    import { Credential } from "@opencode/core/credential"
    import { Database } from "@opencode/core/database/database"
    import { Environment, makeFiles } from "@opencode/core/environment/index"
    import { EnvironmentUnavailable } from "@opencode/core/environment/unavailable"
    import { Integration } from "@opencode/core/integration"
    import { KV } from "@opencode/core/kv"
    import { Location } from "@opencode/core/location"
    import { Model } from "@opencode/core/model"
    import { Money } from "@opencode/schema/money"
    import { Plugin } from "@opencode/core/plugin"
    import { PluginHooks } from "@opencode/core/plugin/hooks"
    import { PluginHost } from "@opencode/core/plugin/host"
    import { ID, make } from "@opencode/core/plugin/provider/claude-agent/index"
    import { Provider } from "@opencode/core/provider"
    import { Session } from "@opencode/core/session"
    import { SessionInbox } from "@opencode/core/session/inbox"
    import { describe, expect } from "bun:test"
    import { Effect } from "effect"
    import { FakeClaude } from "./lib/fake-claude"
    import { FakeIO } from "./lib/fake-io"
    import { TestSessions } from "./lib/sessions"
    import { testEffect } from "../../../lib/effect"
    import { PluginTestLayer } from "../../fixture"
    
    const it = testEffect(PluginTestLayer)
    const integrationID = Integration.ID.make(ClaudeProvider.ID)
    const haiku = Model.Ref.make({ providerID: ClaudeProvider.ID, id: Model.ID.make("haiku") })
    
    // The registry outlives the plugin's scope in opencode, so each test closes its own, as an exiting opencode would.
    const addPlugin = Effect.fn(function* (io: ClaudeIO.Interface) {
      const registry = ClaudeSessions.registry()
      yield* Effect.addFinalizer(() => registry.close())
      const host = yield* PluginHost.make(yield* Plugin.Service, ID)
      yield* make(io, registry).effect(host)
      return registry
    })
    
    const storage = Effect.gen(function* () {
      return PluginHost.storage(yield* KV.Service, ID)
    })
    
    const connect = Effect.gen(function* () {
      const credentials = yield* Credential.Service
      return yield* credentials.create({
        integrationID,
        value: Credential.External.make({
          type: "external",
          methodID: Integration.MethodID.make(ClaudeIntegration.METHOD_ID),
        }),
      })
    })
    
    const claudeModels = Effect.gen(function* () {
      const result: Model.Info[] = []
      const models = yield* Model.Service
      for (const model of yield* models.all()) {
        if (model.providerID === ClaudeProvider.ID) result.push(model)
      }
      return result
    })
    
    const connectionStatus = Effect.gen(function* () {
      const integrations = yield* Integration.Service
      const integration = yield* integrations.get(integrationID)
      return integration?.connections[0]?.status
    })
    
    // Bounded by elapsed time rather than attempts, so a busy machine still fits.
    function eventually<A, R>(
      effect: Effect.Effect<A, never, R>,
      predicate: (value: A) => boolean,
      deadline = Date.now() + 10_000,
    ): Effect.Effect<A, Error, R> {
      return Effect.gen(function* () {
        const value = yield* effect
        if (predicate(value)) return value
        if (Date.now() > deadline) return yield* Effect.fail(new Error("Timed out waiting for value"))
        yield* Effect.promise(() => Bun.sleep(1))
        return yield* eventually(effect, predicate, deadline)
      })
    }
    
    function find(models: readonly Model.Info[], id: string) {
      const model = models.find((model) => model.id === id)
      if (!model) throw new Error(`Expected model ${id}`)
      return model
    }
    
    const price = [
      {
        input: Money.USDPerMillionTokens.make(3),
        output: Money.USDPerMillionTokens.make(15),
        cache: { read: Money.USDPerMillionTokens.make(0.3), write: Money.USDPerMillionTokens.make(3.75) },
      },
    ]
    
    const apiKey: AccountInfo = { apiKeySource: "ANTHROPIC_API_KEY", apiProvider: "firstParty" }
    
    // Stands in for models.dev, which lists prices before the plugin runs
    const listPrice = Effect.fn(function* (providerID: Provider.ID, modelID: string) {
      const providers = yield* Provider.Service
      yield* providers.transform((editor) => {
        editor.add({
          info: Provider.Info.empty(providerID),
          models: [{ ...Model.Info.default(providerID, Model.ID.make(modelID)), cost: price }],
        })
      })
    })
    
    const sonnetCost = Effect.fn(function* (account: AccountInfo, env?: Record<string, string>) {
      const onConnect = FakeClaude.expectStartup({ account })
      const claude = FakeClaude.make([onConnect])
      yield* addPlugin(FakeIO.make({ claude, env }))
      yield* connect
      yield* Effect.promise(() => claude.reached(onConnect))
      const models = yield* eventually(claudeModels, (models) => models.length > 0)
      claude.check()
      return find(models, "sonnet").cost
    })
    
    
    // Reproduction for https://github.com/anomalyco/opencode/issues/61020
    describe("issue 61020: lost context windows are never recovered", () => {
      it.live("a saved list without context windows is re-checked at startup", () =>
        Effect.gen(function* () {
          const store = yield* storage
          // A saved catalog that has models but lost its context windows
          yield* store.set("models", { models: [{ id: "sonnet", name: "Sonnet", efforts: ["high"] }] })
          yield* connect
          const claude = FakeClaude.make([FakeClaude.expectStartup()])
          yield* addPlugin(FakeIO.make({ claude }))
          yield* Effect.promise(() => Bun.sleep(2000))
          const models = yield* claudeModels
          console.log("claude runs at startup:", claude.runs.length)
          console.log("sonnet context:", find(models, "sonnet").limit.context)
          // Expected: the full check runs and fills in 1M; actual: no run, 200K forever
          expect(claude.runs.length).toBeGreaterThan(0)
          expect(find(models, "sonnet").limit.context).toBe(1_000_000)
        }),
      )
    
      it.live("a stale instance's chat start erases windows another instance saved", () =>
        Effect.gen(function* () {
          const store = yield* storage
          yield* store.set("models", { models: [{ id: "haiku", name: "Haiku", efforts: [] }] })
          yield* connect
          const claude = FakeClaude.make([
            FakeClaude.expectStartup(),
            FakeClaude.expectTurn(),
            FakeClaude.expectInterrupt(),
          ])
          const registry = yield* addPlugin(FakeIO.make({ claude }))
          // Another server sharing the database runs the full check and saves windows
          yield* store.set("models", { models: [{ id: "haiku", name: "Haiku", efforts: [], contextWindow: 1_000_000 }] })
    
          const aisdk = yield* AISDK.Service
          const sdk = yield* aisdk.runSDK({
            model: Model.Info.make({
              ...Model.Info.default(ClaudeProvider.ID, haiku.id),
              modelID: haiku.id,
              package: Provider.aisdk(ClaudeProvider.PACKAGE),
            }),
            package: ClaudeProvider.PACKAGE,
            options: {},
          })
          const model: LanguageModelV3 = sdk.sdk.languageModel("haiku")
          const result = yield* Effect.promise(() =>
            model.doStream({
              prompt: TestSessions.prompt("Remember the marker COPPER-OTTER. Reply only with that marker."),
              headers: { [RequestKind.SESSION_HEADER]: "session-a", [RequestKind.HEADER]: "primary" },
            }),
          )
          yield* Effect.promise(() => TestSessions.collect(result.stream))
          yield* eventually(claudeModels, (models) => models.length > 1)
          const saved: any = yield* store.get("models")
          const savedHaiku = saved.models.find((m: any) => m.id === "haiku")
          console.log("stored haiku after this instance's chat start:", JSON.stringify(savedHaiku))
          yield* registry.close()
          // Expected: the window another instance saved survives; actual: it is overwritten without one
          expect(savedHaiku.contextWindow).toBe(1_000_000)
        }),
      )
    })
    EOF
    bun test test/plugin/provider/claude-agent/issue-61020.test.ts

    Run it: bash repro.sh

  2. jimmyallinai commented on Nov 1, 2026

    @jimmyallinai
    Contributor

    I'll take this one. Plan: reproduce the two cases the bot confirmed (startup skips re-check when a saved catalog has models but no contextWindow, and fromClaude drops a saved window when the in-memory copy lacks it), then fix register to re-check catalogs missing windows and make fromClaude preserve saved windows instead of overwriting them with none. I'll add regression tests for both cases and run the claude-agent provider tests before opening a PR.

  3. JtMotoX commented on Nov 11, 2026

    @JtMotoX
    Author

    Update: also affects new models added after the first check (Claude Fable 5.2). Workaround below, not a fix

    I hit this again on a different machine with a different trigger. Opus, Sonnet, Haiku and Fable 5.1 kept their 1M windows. A model added after the last full check never gets a window. It falls back to 200K and stays there.

    What happened

    • Environment: Windows 11. OpenCode 2.2.6 (the copy bundled with OpenChamber). Claude Code 2.1.316. One Claude Agent credential, connected on 2026-11-02.

    • The full check ran once, when I connected on 2026-11-02. claude-fable-5-2 showed up in the saved model list later. No context check was ever logged for it.

    • Claude Code reports 1M for it:

      $ claude -p /context --model claude-fable-5-2
      **Model:** claude-fable-5-2
      **Tokens:** 28.4k / 1m (3%)
      
    • The saved list had a window for every model except two:

      claude-fable-5-2  | NONE      <- added after the 2026-11-02 check
      claude-fable-5    | NONE      <- check failed on 2026-11-02 with "429 Usage credits are required"
      everything else   | 1000000 (or 200000 for Haiku 4.5 / Opus 4.6 / Sonnet 4.6)
      

    So any new model Anthropic releases will show 200K, and compaction will start around 170K, until something re-runs the full check by hand. The current code doesn't do that at startup, when a chat starts, or when a new model first appears in the list.

    New finding: re-activating a single credential does nothing

    My original report said this was untested. I tested it: POST /api/credential/<id>/activate on the credential that's already active runs no check and changes nothing. You need a second credential to switch away from and back.

    Local workaround

    ⚠️ This is NOT a fix. It is a manual workaround. It refills the saved windows once. The bug is still there: the next model Anthropic releases will come in without a window, and anyone who doesn't know these steps is stuck at 200K. It also depends on undocumented behaviour (re-activating a credential re-runs the full check) that could change at any time. Please don't close this issue because a workaround exists.

    1. Add a temporary second Claude Agent credential. It points to the same Claude Code login and stores no account data:

      opencode api post /api/integration/claude-agent/connect/external \
        -d '{"methodID":"claude-login","label":"Claude Agent temp"}'
    2. Re-activate your original credential. This is the step that re-runs the full check:

      opencode api get /api/integration/claude-agent | jq -c '.data.connections[] | {id, label}'
      opencode api post /api/credential/<original-credential-id>/activate
    3. Delete the temporary credential:

      opencode api delete /api/credential/<temp-credential-id>
    4. Restart every other OpenCode server that uses the same database. For me that included the server bundled with OpenChamber. Each one keeps its old copy of the model list in memory. Until restarted, it keeps reporting 200K and could write the old list back over the fixed one (the second case in the original report).

    On Windows PowerShell, opencode api -d '<json>' mangled the JSON, so I ran step 1 by calling the HTTP API directly. It's the same endpoints.

    Result: 13/13 saved models had a window. claude-fable-5-2 and claude-fable-5 went to 1,000,000, and OpenChamber showed 1M after a restart. The 2026-11-02 check had failed for claude-fable-5 with a 429. This time it succeeded with no error.

    What a real fix needs

    1. Startup: run the full check when any saved model has no contextWindow, not only when the list is empty. This was proposal 1 in my original report, and fix(core): preserve Claude context windows #61136 seems to cover it.
    2. New models while running: when a model first appears in the list at a chat start with no window, check that model then. Otherwise it stays at 200K until the next restart, even with (1) in place.
    3. Stale servers: re-read the stored list before writing it and keep any windows it already has (proposal 2 in my original report).
    4. A failed check (for example a 429) should be retried later instead of leaving the model at 200K permanently.

    Also worth noting: I had an older OpenCode v2.0.18 background service using the same database alongside 2.2.6. I haven't confirmed whether it played any part in this.

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