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

  1. added
    providerProvider adapters, OpenAI-compat presets, upstream API quirks
    toolstool_calls, MCP, web-search / sidecar tools
    on Sep 7, 2026
  2. lidge-jun commented on Sep 7, 2026

    @lidge-jun
    Owner

    리뷰 · 우선순위 76 / 80

    설명

    이 이슈는 Claude Code가 POST /v1/messages로 OpenCode Go 모델(예: opencode-go/glm-5.3-flash)을 고를 때, 업스트림이 요구하는 대화 고정 헤더 x-opencode-session이 빠져 HTTP 400(MissingSessionID / missing x-opencode-session)이 난다는 제보다. 제보자는 metadata.user_id 안에 대화 UUID를 넣었고, Chat 쪽 Pi/Go 친화는 이미 #3880으로 고쳤는데, Messages → Responses 리플레이 경로에서는 같은 친화가 안 붙는다고 본다. 재현 스크립트도 loopback 전용·자격증명 미전송으로 깔끔하다. 버전은 설치본 2.46.0 기준이고, 지금 dev HEAD는 6188458ae(package 2.48.0)다.

    현재 dev 코드로 경로를 따라가면 제보가 맞다. /v1/messages는 src/server/claude-messages.ts의 handleClaudeMessages가 Anthropic 요청을 Responses 몸으로 옮긴 뒤 handleResponses로 리플레이한다. Go 헤더는 src/providers/opencode-go-transport.ts의 resolveOpenCodeGoTransport가 붙인다. 조건은 (1) destination registry id가 opencode-go, (2) sessionLane 문자열이 있음, (3) provider.headers에 아직 x-opencode-session이 없을 때다. src/server/responses/core.ts는 sessionLane을 sessionLaneIdFromRequest(req.headers) 또는 요청 헤더 x-opencode-session에서만 읽는다. Chat Completions(src/server/chat-completions.ts)는 클라이언트 x-opencode-session을 그대로 복사하고 #3880이 Pi Chat 레인까지 이어 줬다. Messages 리플레이는 그 복사·합성이 빠져 있다.

    더 구체적으로, claude-messages.ts는 리플레이용 internalReq 헤더를 FORWARD_HEADERS(src/adapters/openai-responses.ts)에서만 고른다. 그 목록에는 session_id/thread-id/x-codex-parent-thread-id 등은 있지만 x-opencode-session은 없다. 게다가 session_id 합성(cacheKeySource === "metadata"일 때 prompt_cache_key → uuid)은 nativeRoute(adapter가 openai-responses)일 때만 돈다. OpenCode Go로 라우팅되면 nativeRoute가 false라서, Claude Code가 준 metadata.user_id가 있어도 리플레이 요청에 session lane이 안 생긴다. 그러면 resolveOpenCodeGoTransport는 sessionLane 없이 provider를 그대로 돌려보내고, Go는 세션 헤더 없는 요청을 거절한다. 제보의 “replay synthesizes session_id only for native Responses routes / drops explicit x-opencode-session” 설명이 HEAD와 일치한다.

    왜 지금 점수가 높은가. A/B/C 트랙 문서·config 마감이 tip에 있지만, 실제 Claude Code × OpenCode Go 사용성은 provider-compatibility 구멍이다. #3857/#3880이 Chat 쪽 같은 증상을 이미 닫았고, 이 이슈는 Messages 입구의 형제 구멍이다. 라벨도 provider-compatibility / provider / tools로 맞다. Desktop처럼 metadata.user_id가 없는 호출은 헤더리스로 남을 수 있다는 한계도 제보가 스스로 적었고, system-hash fallback을 session id로 쓰면 안 된다는 기존 가드(claude/inbound.ts, claude-messages.ts 주석)와도 같은 방향이다.

    고칠 때는 제보 제안이 대체로 맞다. (1) 명시 x-opencode-session은 Chat처럼 리플레이 헤더로 전달·우선, (2) 없을 때만 metadata.user_id 기반(이미 있는 prompt_cache_key / conversationIdFromClaudeMetadata 계열)으로 lane을 만들어 resolveOpenCodeGoTransport에 넘기고, (3) system-hash cohort는 절대 session으로 쓰지 않으며, (4) native Anthropic passthrough는 그대로 둔다. registryEntryForProviderDestination(...)?.id === "opencode-go" 검사는 transport에 이미 있다. wire override 앞뒤로 destination id가 깨지지 않는지만 회귀 테스트로 잠그면 된다. 테스트는 제보대로 Claude handler + fake outbound로 실제 upstream Go 헤더를 보면 충분하다.

    src/server/claude-messages.ts (nativeRoute 가드 안 session_id 합성) - Go 등 non-native 라우트에서는 metadata 기반 session lane이 안 생겨 resolveOpenCodeGoTransport가 빈손이다.
    src/adapters/openai-responses.ts FORWARD_HEADERS - x-opencode-session이 없어 클라이언트가 명시한 Go 세션 헤더가 리플레이 Request로 전달되지 않는다.
    src/server/responses/core.ts resolveOpenCodeGoTransport 호출부 - lane 입력이 헤더뿐이라 Messages 리플레이가 메타데이터를 안 실어 주면 Go 친화가 무조건 스킵된다.
    src/providers/opencode-go-transport.ts - 로직 자체는 건전하다. 문제는 호출 측이 sessionLane을 안 주는 것. 커스텀 URL lookalike는 registry id 검사로 이미 제외된다.
    재현·범위 - 2.46.0 실측과 HEAD 정적 경로가 같고, #3880 Chat 수정 후에도 Messages 구멍은 남아 있다. Desktop(메타데이터 없음)은 별 이슈로 남겨도 된다.

    메인테이너의 판단이 필요한 지점

    • Messages 리플레이에서 lane 소스를 session_id 합성으로 통일할지, Go 전용으로 x-opencode-session만 붙일지
    • FORWARD_HEADERS에 x-opencode-session을 넣어 전역 전달할지, Claude 리플레이 블록에서만 복사할지
    • Desktop/헤더리스 Go 호출을 같은 PR에서 완화할지, 이번엔 Claude Code metadata 경로만 닫을지
    • 기여자 PR을 기다릴지, maintainer가 fix: preserve Pi session affinity through native Chat #3880 후속으로 바로 집을지

    너의 추천

    열어 두고 help wanted(또는 maintainer 단기 픽스)로 받는 게 맞다. 최소 수정은 Claude 리플레이에서 (a) 수신 x-opencode-session 전달, (b) 없고 cacheKeySource === "metadata"일 때 lane을 만들어 resolveOpenCodeGoTransport가 돌게 하는 것. system-hash는 절대 lane으로 쓰지 말 것. #3880과 같은 계열 회귀 테스트를 Messages 경로에 추가하고, 통과하면 dev에 넣자. types/config 대분할과 무관하다.

    이 댓글은 grok-bot이 작성했습니다

  3. lidge-jun commented on Sep 7, 2026

    @lidge-jun
    Owner

    Fixed on dev by #3961 (60bcb90). Claude requests retain canonical Go session affinity, including explicit headers and metadata fallback; invalid/empty identities do not suppress usable fallback or create shared affinity. Both wires are covered by current-head CI34168444860. Callers without any usable per-conversation identity still need to supply one; no global identity is synthesized.

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