Skip to content

[Provider compatibility] Codex 0.155 access_programs is forwarded to strict third-party Responses upstreams and 400s every OpenCode Go muse-spark turn #4853

Closed

Description

@jhl880617

Client or integration

Codex App. Codex runtime codex-cli 0.155.0-alpha.2.6 (shipped with ChatGPT.app, installed 2026-09-17 10:03, previously 0.154.0-alpha.6.2). Also reproduced with minimal direct HTTP requests against the proxy and against the upstream.

Provider or upstream service

OpenCode Go (OpenCode-Go, https://opencode.ai/zen/go/v1) — the gateway that identifies itself as "Console Go" in the error message. Affects muse-spark-1.3-contributor and muse-spark-1.2-contributor; the same request shape succeeds on other models behind the same base URL.

OpenCodex version

2.57.0 (installed CLI, latest on npm at the time of writing). macOS, Apple Silicon. Observed 2026-09-17.

Endpoint or capability

Inbound POST /v1/responses on the proxy → outbound Responses passthrough to /zen/go/v1/responses (adapter: openai-responses, OpenCode-Go provider). Request body shape / unknown-parameter handling.

Current behaviour

Every routed turn to muse-spark-1.3-contributor fails with HTTP 400:

Error from provider (Console Go): Upstream request failed: [invalid_request_error] unknown parameter `access_programs`

Root cause: Codex 0.155 attaches a new OpenAI-private top-level field, access_programs, to its Responses request body. The client gates it on ChatGPT auth, not on the destination's base URL (codex-rs/core/src/cyber_access_program.rs):

pub(crate) fn for_auth(auth: Option<&CodexAuth>, program: Option<CyberAccessProgram>) -> Option<AccessPrograms> {
    program.filter(|_| auth.is_some_and(CodexAuth::is_chatgpt_auth)).map(AccessPrograms::from)
}

Because loopback injection keeps Codex's built-in openai provider pointed at the proxy (openai_base_url + realtime sideband override), the client still believes it is talking to the canonical ChatGPT backend and keeps the field. There is no client-side switch to suppress it, and it is not conditional on the model being requested.

The proxy forwards it verbatim, so whether a turn fails depends only on how strict the destination is:

Destination model (same provider, same wire) Unknown top-level field tolerated?
muse-spark-1.3-contributor No → 400
muse-spark-1.2-contributor No → 400
deepseek-v4.1-flash Yes → 200
grok-4.6 Yes → 200
glm-5.3 Yes → 200

Expected behaviour

Top-level fields that only OpenAI-operated destinations define should not be forwarded to third-party Responses upstreams. The proxy already does this for the per-item private field internal_chat_message_metadata_passthrough (stripInternalChatMessageMetadataPassthrough) at exactly the same boundary; the top-level private list needs the equivalent treatment.

Minimal redacted request or reproduction

# Fails: 400 unknown parameter `access_programs`
curl -sS http://127.0.0.1:10100/v1/responses \
  -H 'Content-Type: application/json' \
  -d '{"model":"OpenCode-Go/muse-spark-1.3-contributor","input":"hi","stream":false,"access_programs":{"cyber":"standard"}}'

# Succeeds: 200 (identical body, field omitted)
curl -sS http://127.0.0.1:10100/v1/responses \
  -H 'Content-Type: application/json' \
  -d '{"model":"OpenCode-Go/muse-spark-1.3-contributor","input":"hi","stream":false}'

The same body sent straight to the upstream, bypassing OpenCodex, isolates the field as the cause:

curl -sS https://opencode.ai/zen/go/v1/responses \
  -H "Authorization: Bearer $OPENCODE_GO_API_KEY" \
  -H 'x-opencode-session: <any-session-id>' \
  -H 'Content-Type: application/json' \
  -d '{"model":"muse-spark-1.3-contributor","input":"hi","access_programs":{"cyber":"standard"}}'

Actual response or error

{"model":"muse-spark-1.3-contributor","error":{"param":"access_programs","type":"invalid_request_error","message":"Error from provider (Console Go): Upstream request failed: [invalid_request_error] unknown parameter `access_programs`"}}

Probe results with the same minimal body against /zen/go/v1/responses for muse-spark-1.3-contributor:

Extra top-level field Result
access_programs: {"cyber":"standard"} 400, names access_programs
codex_output_schema: {"type":"object"} 400, names codex_output_schema
totally_made_up_param: 1 400, names totally_made_up_param
client_metadata, prompt_cache_key, service_tier, safety_identifier 200
(none) 200

So this upstream validates the top-level response schema strictly, and the only reason the failures started today is the new client field.

Upstream documentation

No public specification defines access_programs; it appears to be OpenAI-internal (cyber access program), so a third-party gateway treating it as unknown is correct.

Suggested mapping or implementation notes

Validated locally against 2.57.0, in the same place the per-item private field is already stripped:

  1. src/adapters/openai-responses/request-strips.ts — add stripOpenAiPrivateClientParams(body) that deletes access_programs and codex_output_schema, returns the input object unchanged when neither key is present (so the common path stays allocation-free), and passes non-objects through.
  2. src/adapters/openai-responses/passthrough.ts — inside the existing if (!isCanonicalOpenAiForwardProvider(provider)) block, next to stripInternalChatMessageMetadataPassthrough(outBody), call it when !isOpenAiOperatedResponsesDestination(provider) so official OpenAI / ChatGPT traffic keeps the field and only third-party destinations lose it.

Verification after patching: muse-spark turns return 200 with the field present in the incoming body; a genuinely invalid parameter still returns 400 (so the change does not mask real client errors); deepseek-v4.1-flash, grok-4.6 and glm-5.3 are unaffected; the incoming raw body object is not mutated.

Note: forcing muse-spark onto the openai-chat wire is not an alternative workaround — POST /zen/go/v1/chat/completions for muse-spark-1.3-contributor returns HTTP 500 {"type":"error","error":{"type":"error","message":"Internal server error"}} (observed the same day, both via the proxy and directly), so the Responses wire is the only working one for these models.

Additional context and attachments

  • Checked for duplicates: no existing issue or PR mentions access_programs, codex_output_schema, or Codex 0.155.
  • Requests and responses above are redacted: no credentials, tokens, account labels, or conversation content are included.
  • The field is version-dependent (access_programs is new in Codex 0.155; requests from Codex 0.154 and earlier did not contain it) and the same Codex build works fine on providers whose validator is lenient, which is why this only broke today and only for the strict Responses backends.

Activity

  1. added
    account-poolOAuth, credentials, Codex pool, quota, failover, plans
    providerProvider adapters, OpenAI-compat presets, upstream API quirks
    on Sep 17, 2026
  2. lidge-jun commented on Sep 17, 2026

    @lidge-jun
    Owner

    리뷰 · 우선순위 77 / 80

    이 이슈는 Codex CLI 0.155가 Responses 요청 최상위에 붙이기 시작한 OpenAI 비공개 필드 access_programs가, 엄격한 서드파티 Responses 업스트림으로 그대로 전달되면서 매 턴 400이 난다는 프로바이더 호환 보고입니다. 증상은 OpenCode Go(OpenCode-Go, https://opencode.ai/zen/go/v1)의 muse-spark-1.3-contributor / 1.2-contributor에서 unknown parameter access_programs로 나타납니다. 같은 베이스 URL의 deepseek-v4.1-flash·grok-4.6·glm-5.3은 모르는 필드를 관대해서 200이 나옵니다. 즉 프록시 “라우팅”이 깨진 게 아니라, 엄격 스키마 게이트웨이만 새 클라이언트 필드에 걸린 것입니다.

    원인은 클라이언트와 프록시 경계가 어긋난 전형적인 패턴입니다. Codex는 cyber_access_program.rs에서 ChatGPT 인증이면 access_programs를 넣고, 목적지 base URL은 보지 않습니다. 루프백 주입은 Codex의 openai 프로바이더 정체성을 유지한 채 프록시로 보내므로, 클라이언트는 계속 “정식 ChatGPT 백엔드”라고 믿고 필드를 붙입니다. 지금 dev의 src/adapters/openai-responses/passthrough.ts는 이미 비슷한 일을 합니다. !isCanonicalOpenAiForwardProvider(provider)일 때 stripInternalChatMessageMetadataPassthrough로 아이템 단위 비공개 필드 internal_chat_message_metadata_passthrough를 지웁니다(request-strips.ts). 다만 최상위 비공개 키(access_programs, 리포터가 같이 확인한 codex_output_schema)를 지우는 헬퍼는 아직 없습니다. rg로 dev 전체를 봐도 두 심볼은 코드에 없습니다.

    리포터의 최소 재현도 깔끔합니다. 같은 muse-spark 바디에 필드만 있으면 400, 빼면 200. 업스트림 직행도 동일. totally_made_up_param도 400인 걸 보면 Console Go는 최상위 스키마를 엄격히 검사합니다. 제안 패치 자리(stripOpenAiPrivateClientParams를 request-strips.ts에 두고, 기존 non-canonical 블록에서 isOpenAiOperatedResponsesDestination이 아닐 때 호출)는 현재 코드 구조와 잘 맞습니다. 공식 OpenAI/ChatGPT 트래픽은 필드를 유지하고, 서드파티만 벗겨야 합니다. Chat 와이어로 muse-spark를 돌리는 우회는 업스트림 500이라 대안이 아닙니다. types/config 대형 분리와도 무관하고, 스트립 한 함수 + passthrough 한 줄 + 회귀 테스트면 끝납니다. Codex 0.155가 퍼지면 같은 400이 다른 엄격 게이트웨이에도 퍼질 수 있어서, 지금 dev(2.58.0 트레인)에 넣는 가치가 큽니다.

    경로 src/adapters/openai-responses/request-strips.ts - 아이템 메타 스트립만 있고 최상위 access_programs / codex_output_schema 삭제가 없다.
    경로 src/adapters/openai-responses/passthrough.ts 라인 317-318 근처 - non-canonical 분기에서 아이템 스트립만 호출하고, 최상위 비공개 키는 그대로 나간다.
    심볼 access_programs / codex_output_schema - dev 트리에 언급이 없어 Codex 0.155 이후 필드가 아직 가드 목록에 없다.
    경로 테스트 - muse-spark(엄격) vs 관대 모델, canonical OpenAI 유지 여부를 같이 고정하지 않으면 공식 경로까지 필드를 지울 위험이 있다.
    경로 출시 - 증상은 npm latest 2.57.0 + Codex 0.155 조합에서 오늘 터졌다. 2.57.x 핫픽스 vs 2.58.0만 넣을지 정해야 한다.

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

    • 최상위 스트립 목록을 access_programs+codex_output_schema로 최소 시작할지, “OpenAI-private top-level” 확장 목록으로 갈지
    • isOpenAiOperatedResponsesDestination 기준을 쓸지, 기존처럼 !isCanonicalOpenAiForwardProvider만으로 충분할지(리포터 제안은 전자를 명시)
    • 2.57.x 패치 릴리스 필요 여부(Codex 앱 사용자가 이미 0.155로 올라옴)

    너의 추천
    재현·원인·패치 자리가 모두 명확하고, 기존 stripInternalChatMessageMetadataPassthrough와 같은 계층의 구멍이다. 제안대로 request-strips.ts에 할당 없는 no-op 경로를 둔 최상위 스트립을 추가하고, non-canonical(또는 non-OpenAI-operated) passthrough에서 호출한 뒤, 필드 있음→서드파티 200 / 공식 경로 유지 / 진짜 잘못된 파라미터는 여전히 400 회귀를 넣고 dev에 랜딩하는 쪽을 강하게 추천한다. 우선순위는 오늘 기준 프로바이더 호환 이슈 중 최상위권이다.

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

  3. added a commit that references this issue on Sep 17, 2026
    4b234da
  4. lidge-jun commented on Sep 17, 2026

    @lidge-jun
    Owner

    Fixed on dev in 4b234da (#4884). Closing manually, since pull requests here target dev rather than the default branch.

    Two notes on what shipped, because both differ from the suggested patch.

    codex_output_schema is not stripped. In codex-rs that string is the name of the JSON-schema text.format object (codex-rs/codex-api/src/common.rs), not a top-level request key, and no serde field carries it — your probe table used it the same way as totally_made_up_param, as an arbitrary unknown key. Listing it would delete a field this client never sends, and discard it for any client that does send it meaningfully. access_programs is the only row in CANONICAL_ONLY_TOP_LEVEL_FIELDS, and a regression pins that an unlisted top-level key still reaches the wire, which is what keeps the change from becoming a general unknown-parameter sanitizer.

    The boundary is !isOpenAiOperatedResponsesDestination, as you suggested, and the deciding reason turned out to be one you could not have seen from outside: src/server/responses/compact.ts spreads the caller's raw body into the native /responses/compact request without passing through the Responses adapter, and supportsNativeResponsesCompactEndpoint offers that endpoint only to OpenAI-operated destinations. Scoping on the canonical surface instead would have made openai-apikey behave differently on its two endpoints for the same field.

    Your report made this quick to land: the probe table isolating the field, the before/after pair against the upstream directly, and the pointer to stripInternalChatMessageMetadataPassthrough as the existing equivalent one level down were all correct and all load-bearing. The note that the Chat wire is not a workaround saved a dead end too.

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

    account-poolOAuth, credentials, Codex pool, quota, failover, plansproviderProvider adapters, OpenAI-compat presets, upstream API quirksprovider-compatibilityProvider compatibility reports

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions