Skip to content

Claude Code: OpenCode Go requests lose session affinity through /v1/messages #3945

Description

@david-wang-0

Note

This was entirely done by GPT/Claude. - David

Client or integration

Claude Code

Provider or upstream service

OpenCode Go (opencode-go)

OpenCodex version

2.46.0; Claude Code 2.1.263. The missing Messages forwarding/fallback remains in the reviewed dev source at 76826fe.

Endpoint or capability

POST /v1/messages: Claude conversation affinity during replay into the Responses routing stack and Go transport.

Current behaviour

A real Claude Code request selecting opencode-go/glm-5.3-flash failed with Go's missing-session error. The request includes per-conversation identity in metadata.user_id, but replay synthesizes session_id only for native Responses routes. It also drops an explicitly supplied x-opencode-session header.

Expected behaviour

Go should receive stable opaque per-conversation affinity. Explicit session lanes and configured provider headers must retain precedence; conversations must never share an identifier derived from common system text.

Minimal redacted request or reproduction

Configure Go with its key in the local proxy. Save this dependency-free script as reproduce.py:

#!/usr/bin/env python3
"""Minimal Claude-shaped request to an already configured local OpenCodex proxy."""

import argparse
import ipaddress
import json
import sys
import urllib.error
import urllib.parse
import urllib.request
import uuid


def main():
    parser = argparse.ArgumentParser(description=__doc__)
    parser.add_argument("--base-url", required=True, help="Loopback OpenCodex origin, e.g. http://127.0.0.1:10101")
    parser.add_argument("--model", default="opencode-go/glm-5.3-flash")
    parser.add_argument("--conversation", type=uuid.UUID, default=None,
                        help="Reuse a UUID for the same conversation; otherwise generate a new one")
    parser.add_argument("--tool", action="store_true", help="Request a harmless report_ok tool call; never execute it")
    args = parser.parse_args()
    parsed = urllib.parse.urlsplit(args.base_url)
    try:
        loopback = ipaddress.ip_address(parsed.hostname or "").is_loopback
    except ValueError:
        loopback = parsed.hostname == "localhost"
    if parsed.scheme != "http" or not loopback or parsed.username or parsed.password or parsed.query or parsed.fragment:
        parser.error("base URL must be a plain HTTP loopback origin without credentials")
    if parsed.path not in ("", "/"):
        parser.error("base URL must be an origin without a path")
    conversation = args.conversation or uuid.uuid4()
    body = {
        "model": args.model,
        "max_tokens": 64,
        "stream": False,
        "metadata": {"user_id": f"user_mwe_account_mwe_session_{conversation}"},
        "messages": [{"role": "user", "content": "Reply with exactly GO_OK."}],
    }
    if args.tool:
        body["tools"] = [{"name": "report_ok", "description": "Report a successful test",
                          "input_schema": {"type": "object", "properties": {}, "additionalProperties": False}}]
        body["tool_choice"] = {"type": "tool", "name": "report_ok"}
    request = urllib.request.Request(
        args.base_url.rstrip("/") + "/v1/messages",
        data=json.dumps(body).encode(),
        headers={"Content-Type": "application/json", "anthropic-version": "2023-06-01"},
        method="POST",
    )
    # The proxy owns its Go key. This client never loads or sends any credential.
    opener = urllib.request.build_opener(urllib.request.ProxyHandler({}))
    try:
        with opener.open(request, timeout=90) as response:
            status, payload = response.status, response.read(1048576)
    except urllib.error.HTTPError as error:
        status, payload = error.code, error.read(1048576)
    except (OSError, urllib.error.URLError):
        print("Connection failed or timed out; confirm the local proxy is running.")
        return 2
    print(f"HTTP {status}")
    if b"missingsessionid" in payload.lower() or b"missing x-opencode-session" in payload.lower():
        print("Error: MissingSessionID / missing x-opencode-session")
        return 1
    try:
        message = json.loads(payload)
    except ValueError:
        print("Response was not message JSON; body omitted.")
        return 1
    content = message.get("content", []) if isinstance(message, dict) else []
    if status == 200 and any(c.get("type") == "text" and c.get("text", "").strip() == "GO_OK" for c in content):
        print("GO_OK")
        return 0
    if status == 200 and args.tool and any(c.get("type") == "tool_use" and c.get("name") == "report_ok" for c in content):
        print("Expected tool call received; no tool executed.")
        return 0
    print("Unexpected response; body omitted to avoid printing upstream diagnostics or credentials.")
    return 1


if __name__ == "__main__":
    sys.exit(main())

Run:

python3 reproduce.py --base-url http://127.0.0.1:10101

It sends an Anthropic-shaped Messages request with a Go model, a short user message, and metadata.user_id containing a new conversation UUID. It never reads or sends credentials. --conversation UUID reuses a conversation; --tool requests a harmless tool call without executing it.

Alternatively, reproduce through the configured ocx-claude launcher itself. Save this second version as reproduce-claude.py:

#!/usr/bin/env python3
"""Run the Go session reproduction through the existing ocx-claude launcher."""

import json
import shutil
import subprocess
import sys


def main():
    executable = shutil.which("ocx-claude")
    if not executable:
        print("ocx-claude is unavailable; put the configured launcher on PATH.")
        return 2
    command = [
        executable, "--model", "opencode-go/glm-5.3-flash",
        "-p", "Reply with exactly GO_OK.", "--tools", "", "--max-turns", "1",
        "--output-format", "json", "--no-session-persistence",
        "--strict-mcp-config", "--mcp-config", '{"mcpServers":{}}',
    ]
    try:
        completed = subprocess.run(command, capture_output=True, text=True,
                                   timeout=90, check=False)
    except subprocess.TimeoutExpired:
        print("Client timed out after 90 seconds; captured diagnostics omitted.")
        return 2
    except OSError:
        print("Client could not be started; diagnostics omitted.")
        return 2
    print(f"Client exit status: {completed.returncode}")
    diagnostic = (completed.stdout + completed.stderr).lower()
    if "missingsessionid" in diagnostic or "missing x-opencode-session" in diagnostic:
        print("Error: MissingSessionID / missing x-opencode-session")
        return 1
    try:
        result = json.loads(completed.stdout)
    except ValueError:
        result = None
    if (completed.returncode == 0 and isinstance(result, dict)
            and not result.get("is_error") and isinstance(result.get("result"), str)
            and result["result"].strip() == "GO_OK"):
        print("GO_OK")
        return 0
    print("Unexpected client result; captured output omitted to avoid exposing diagnostics or credentials.")
    return 1


if __name__ == "__main__":
    sys.exit(main())

Run:

python3 reproduce-claude.py

This variant invokes the real Claude Code client through ocx-claude, fixes the Go model, disables built-in tools and MCP servers, and captures diagnostics without printing them. A fresh run against the working patched local OpenCodex 2.46.0 workaround produced:

Client exit status: 0
GO_OK

Script exit status: 0. This confirms the patched local client path only; it does not establish pristine or refined-branch runtime behavior.

Actual response or error

The prepatch Claude Code call failed with MissingSessionID / "Request is missing x-opencode-session and cannot be routed efficiently" (400). After the metadata-affinity workaround, Claude Code returned GO_OK.

A fresh standalone run of the command above against the patched local OpenCodex 2.46.0 workaround produced:

HTTP 200
GO_OK

Exit status: 0. This result applies only to the patched local 2.46.0 workaround.

For comparison, the same standalone script was run once against an isolated server from the unmodified npm @bitkyc08/opencodex@2.46.0 tarball, after verifying its published integrity. The temporary server used fresh homes, no native subscription credentials, and no native passthrough:

python3 reproduce.py --base-url http://127.0.0.1:10102

Observed output:

HTTP 400
Error: MissingSessionID / missing x-opencode-session

Exit status: 1. The error line is normalized by the reproducer, not a verbatim raw response body. The temporary server was stopped after the probe; existing proxies were untouched. Only the standalone HTTP reproducer was run against pristine 2.46.0. The direct ocx-claude variant above was tested against the patched workaround only.

Upstream documentation

OpenCode Go: https://opencode.ai/docs/go/

Prior requirement and ingress fixes:
#3344
#3378
#3405
#3857
#3880

Suggested mapping or implementation notes

Match registryEntryForProviderDestination(route.provider)?.id before wire overrides, covering renamed canonical destinations without including custom/lookalike URLs. Forward x-opencode-session itself, matching Chat ingress. Synthesize the existing metadata-derived UUID only when no explicit session lane or Go header exists. Keep the metadata-only guard; shared system hashes never become session IDs. Native Anthropic passthrough remains unchanged.

Additional context and attachments

The proposed branch targets dev. Regression tests call the complete Claude handler with fake outbound fetches and inspect actual upstream Go headers. The separately installed 2.46.0 workaround passed real Claude/Go calls and Go tool-call probes through Messages and Responses; this runtime evidence is distinct from validation of the refined branch.

Desktop requests lacking metadata and explicit conversation identity remain headerless and may still fail on Go. Calls sharing one Claude session's metadata share its affinity.

Checks

  • I searched existing provider and compatibility issues.
  • The request and response were redacted.
  • The expected behaviour is based on an upstream specification or a concrete client requirement.

Co-authored-by: GPT-6 Astra noreply@openai.com
Co-authored-by: Claude Fable 5.1 noreply@anthropic.com

Activity

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

Metadata

Metadata

Assignees

No one assigned

    Labels

    providerProvider adapters, OpenAI-compat presets, upstream API quirksprovider-compatibilityProvider compatibility reportstoolstool_calls, MCP, web-search / sidecar tools

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions