Notes · Engineering

The Proxy Was Fine. Except for the Part Where It Wasn’t.

Getting OpenCode Desktop, ChatGPT Plus OAuth, and Caveman to agree on where a request should actually go.

I wanted a pretty specific setup.

OpenCode Desktop for the interface. ChatGPT Plus for the model access I was already paying for. Caveman in the middle to compress context.

That sounds like three things that should be able to sit in a line:

OpenCode Desktop
      ↓
Caveman
      ↓
ChatGPT subscription

For a while, they absolutely could not.

The annoying part was that every individual piece looked healthy depending on where I checked. OpenCode could use my ChatGPT login. Caveman could proxy OpenAI traffic. Caveman already had a ChatGPT subscription route. OpenCode was launching Caveman's MCP server.

Put all of them together and I got this:

You have insufficient permissions for this operation.
Missing scopes: api.responses.write.

Cool.


In Plain English

OpenCode can use a ChatGPT Plus login instead of an OpenAI API key.

Caveman can sit between coding agents and model providers to reduce the amount of context they send.

The problem was that Caveman treated OpenCode's ChatGPT login like a normal OpenAI API credential. That sent the request to the wrong place.

There was also a second bug where Caveman had successfully installed its recovery tool into OpenCode, OpenCode was actively running it, and Caveman still reported that it was missing.

The fix was small, finding the fix... not small.


First, prove what is actually broken

I started with the provider URL Caveman installs for OpenCode:

http://127.0.0.1:8787/w/opencode/openai/v1

With ChatGPT OAuth, that produced the missing api.responses.write scope error.

The useful question was whether OAuth itself was broken or whether the request was simply being routed incorrectly.

So I stopped changing five things at once and tried with just three cases.

No Caveman OpenAI override

OpenCode → ChatGPT OAuth

This worked.

Caveman was bypassed, of course, but the credential was valid.

Normal Caveman OpenAI route

OpenCode
  ↓
/w/opencode/openai/v1
  ↓
Caveman

Failed:

Missing scopes: api.responses.write

Caveman's existing ChatGPT route

OpenCode
  ↓
/chatgpt
  ↓
Caveman
  ↓
chatgpt.com/backend-api/codex

Worked.

HTTP 200. Same ChatGPT login. Traffic went through Caveman.

This narrows things down.

Caveman already had the route I needed. OpenCode traffic was just entering through the generic OpenAI lane.

Why that distinction matters

A ChatGPT OAuth token is not an OpenAI API key with a different login screen.

OpenCode knows this. When it owns the transport decision, it can send ChatGPT-authenticated traffic to the Codex subscription backend.

Caveman also knows this in other integrations. Its existing /chatgpt path is specifically for that subscription transport.

The problem is the seam between them.

Caveman's OpenCode integration gives the OpenAI provider a static base URL:

/w/opencode/openai/v1

That is perfectly reasonable for normal API traffic.

For a ChatGPT subscription request, it wont work.

My first fix was bad

The obvious solution was to make the installer detect how OpenCode was authenticated.

Something like:

if OpenCode uses ChatGPT OAuth:
    configure /chatgpt
else:
    configure /w/opencode/openai/v1

I wrote it!

I wrote a test for it!

The test passed!

Then I tried it on my actual machine and it did nothing.

The detector depended on an assumed OpenCode credential file under its local data directory. My synthetic test put a credential exactly where my detector expected one, so naturally the detector found it.

The real OpenCode installation did not have that file there.

That test proved my code could read my fixture. Very impressive work by me.

More importantly, even a correct credential path would leave a design problem. OpenCode can change credentials after Caveman is installed. Picking the provider route once during caveman enable opencode means the route can become stale later.

Authentication is runtime state. The routing decision needed to happen at runtime too.


Meanwhile, compression was doing absolutely nothing

Manually pointing OpenCode at /chatgpt got the requests through, so I moved on to checking whether Caveman was actually transforming them.

Spoiler: It wasn't.

The request receipt showed identical hashes:

Original SHA-256 == Delivered SHA-256

Caveman saw the request and forwarded it successfully, but the body was completely untouched.

Then caveman status gave me this:

MCP recovery missing — streaming turns and Claude Pro/Max sessions pass through uncompressed

That seemed straightforward enough.

Except OpenCode was already configured with Caveman MCP.

And OpenCode was launching it.

And the MCP binary reported that it supported recovery.

And Caveman's native integration doctor said MCP was installed.

And caveman status said it was missing.

This became the second bug.

Seven MCP processes and apparently zero MCP

I checked the process table because at this point I did not trust anybody.

There were seven stable caveman-mcp processes under OpenCode.

Note that these are not seven crash-looping processes, seven processes that had been sitting there alive.

The binary itself reported:

mcp_recovery

as a supported capability.

So I had:

OpenCode config:      MCP installed
OpenCode processes:   MCP running
MCP binary:           recovery supported
Caveman doctor:       MCP installed
Caveman status:       MCP missing

This output was so baffling it made me actually pull out my dusty ol' rubber duck.

The missing file that was never supposed to exist

The status path eventually came down to mcpInstalled().

For OpenCode, it was checking for a legacy marker:

~/.caveman/mcp/opencode.json

That file did not exist.

Native OpenCode setup does not create it.

Instead, the native integration writes the real MCP registration directly into OpenCode's config:

~/.config/opencode/opencode.json

OpenCode reads that registration and launches caveman-mcp.

Caveman's native doctor already understood this, but the runtime recovery check did not.

So the system had two definitions of "MCP is installed."

Use the registration Caveman actually owns

Caveman's native integration journal already records the exact MCP object it installs.

That gave me a much better check:

registration recorded by Caveman
              ==
current mcp.caveman registration in OpenCode

If they match, the native registration is still there.

If the user removes it or changes it, they no longer match.

The helper ended up roughly like this:

function nativeOpencodeMcpInstalled(): boolean {
  const journal = readNativeJournal("opencode");
  if (!journal) return false;

  const operation = journal.operations.find(
    (item) => item.kind === "opencode-config",
  );

  if (!operation?.owned?.installed_mcp) return false;

  const current = fileBytes(operation.file);
  if (!current) return false;

  try {
    const root = parseJsonFileObject(operation.file, current);
    const mcp =
      root.mcp && typeof root.mcp === "object" && !Array.isArray(root.mcp)
        ? (root.mcp as Record<string, unknown>)
        : {};

    return (
      JSON.stringify(mcp.caveman) ===
      JSON.stringify(operation.owned.installed_mcp)
    );
  } catch {
    return false;
  }
}

Then OpenCode gets that native check before falling back to the old marker behavior.

After rebuilding:

caveman  ·  compress on
no off-states — everything the layer can do is on

The slightly less dusty rubber duck is safely back on its shelf. for now.

The funniest successful compression result possible

Once recovery was finally recognized, I sent a larger request and for the first time, the original and delivered hashes differed.

Caveman had actually transformed the request!

The token result:

35,065 original
35,218 delivered
-153 token reduction

Reader, in case you need a refresher, the definition of compress is:

to reduce in size, quantity, or volume as if by squeezing

I was genuinely happy to see it.

The number was bad, but it proved the path was alive. Before that request, Caveman was doing byte-safe pass-through. After the MCP fix, the request entered the transformation path and produced a measurable result.

Compression quality could be investigated later. At least there was now compression to investigate.


Back to the routing problem

The temporary /chatgpt configuration worked, but I did not want that to be the final integration.

It made Caveman's native ownership state drift because the installer still expected:

/w/opencode/openai/v1

I also did not want OpenCode's config to depend on whatever credential happened to be active when Caveman was installed.

The normal provider URL should stay normal.

So I looked at what OpenCode actually sends when using ChatGPT OAuth.

On the canonical Caveman route, OpenCode 2.0.14 included:

ChatGPT-Account-ID

on the Responses request.

That was the piece I needed.

Caveman already knows the request came through the OpenCode namespace. After path normalization it has:

x-cave-agent: opencode

So the proxy can identify the subscription request from the request itself.

No credential-file archaeology, installer guessing, or permanent /chatgpt override.

The final routing check

The predicate is intentionally boring:

func isOpenCodeChatGPTSubscription(r *http.Request) bool {
	return r.Method == http.MethodPost &&
		r.URL.Path == "/openai/v1/responses" &&
		r.Header.Get("x-cave-agent") == "opencode" &&
		r.Header.Get("ChatGPT-Account-ID") != ""
}

Then, immediately after Caveman normalizes the agent path:

if isOpenCodeChatGPTSubscription(r) {
	r.URL.Path = "/chatgpt/responses"
	r.URL.RawPath = ""
	s.chatgpt(w, r)
	return
}

That is the meat and potatoes of the routing fix.

The OpenCode config stays:

/w/opencode/openai/v1

Normal OpenAI requests keep using the normal OpenAI path.

A ChatGPT-authenticated OpenCode Responses request gets handed to Caveman's existing subscription handler.

The subscription handler already knows how to forward to the Codex backend, preserve the relevant OAuth/account metadata, apply compression when eligible, and fall back safely when it cannot transform something.

At this point: I did not need a second ChatGPT implementation.

I just needed to get the request into the one that already existed.


Tests, because I had trust issues by then

The routing regression test checks that a matching OpenCode request:

POST /w/opencode/openai/v1/responses
ChatGPT-Account-ID: present

reaches the subscription upstream as:

/responses

It also checks that authorization and ChatGPT-Account-ID survive the handoff and that Caveman's internal x-cave-agent header does not leak upstream.

The detection tests cover the boring negative cases too:

missing account ID    → no
different agent       → no
different route       → no
different method      → no

For MCP recovery, the native integration tests now cover:

native registration present                  → recovery detected
provider routing drift                       → recovery still detected
native MCP registration removed              → recovery not detected

Then:

go test ./... -count=1

Green.

And then I stopped testing with "reply OK"

A tiny successful request proves routing.

It does not prove the thing I actually wanted to use.

So I gave OpenCode a real repository and a task large enough to make it inspect code, research, reason across files, and produce a report.

That finally made the proxy logs interesting.

Early requests:

/responses 200 compressed=false

Once the context grew:

/responses 200 compressed=true
/responses 200 compressed=true
/responses 200 compressed=true
/responses 200 compressed=true
...

Multiple successful compressed Responses requests through the ChatGPT subscription path.

That was the end-to-end result I wanted from the beginning:

No API key.

No OpenAI API billing path.

No manual /chatgpt config hack.

No fake MCP marker.

OpenCode Desktop
      ↓
canonical Caveman OpenAI provider URL
      ↓
request-time ChatGPT subscription detection
      ↓
Caveman /chatgpt handler
      ↓
compression
      ↓
ChatGPT subscription backend

The part I am not claiming

I am not attaching a heroic token-savings percentage to this.

Caveman's proxy logs clearly show the real OpenCode subscription requests entering the compression path. The native receipt for that session could not cleanly attribute exact provider-side before/after numbers because its session correlation was approximate.

So the result I can support is simple:

routing works
OAuth works
native MCP recovery is detected
compression activates on eligible requests
responses complete successfully

That is enough for this patch.

The final diff versus the debugging session

This whole thing took long enough that the final code is almost insulting.

One helper decides whether an OpenCode Responses request belongs to the ChatGPT subscription path.

One branch hands it to the handler Caveman already had.

One native MCP check uses the registration Caveman already wrote instead of looking for a marker that native OpenCode never created.

That's it.

The work was mostly figuring out which layer was lying to me.

Technically, none of them were lying. They were all answering slightly different questions.

Which is worse.

Upstream

I opened the fix against Caveman as PR #1119.

The two commits are kept separate:

fix(opencode): recognize native MCP recovery registration
fix(opencode): route ChatGPT OAuth through subscription proxy

I tested the final path on an M5 MacBook running macOS Tahoe 26.6.2, with OpenCode Desktop 2.0.14 and ChatGPT OAuth using a ChatGPT Plus subscription.

At this point I am leaving it alone. it works.