From d8ed97eaad420827e6c8222241d745796085ad93 Mon Sep 17 00:00:00 2001 From: lidge-jun Date: Tue, 15 Sep 2026 10:13:47 +0900 Subject: [PATCH 1/2] docs(devlog): lock the round5 godfile decomposition roadmap Six contract documents for splitting the three remaining oversized src files behind facades: src/adapters/openai-responses.ts (2,627), src/bridge.ts (2,206) and src/server/index.ts (3,400). src/server/responses/core.ts is out of scope for this round. An independent read-only audit returned FAIL with 12 discrepancies on the first round. One was not a typo: 040 recorded the route-guards move range as 1191-1329, but runAdmittedHttpTurn closes at 1330, so moving that range would have left the function's closing brace behind and produced a syntax error. Parallel fixers applied the corrections after re-measuring each claim, rebutted one auditor claim with arithmetic, and the re-audit returned PASS with the auditor withdrawing it. 010 and 020 were additionally validated by executing the plan and reverting: 0 unmapped symbols, 0 leaf cycles, 0 new unresolved relative specifiers across 8,289, and an export surface identical to origin/dev. --- .../_plan/260915_godfile_round5/000_plan.md | 110 +++++++++ .../010_openai_responses.md | 211 ++++++++++++++++++ .../_plan/260915_godfile_round5/020_bridge.md | 176 +++++++++++++++ .../030_activation_guard.md | 198 ++++++++++++++++ .../260915_godfile_round5/040_server_index.md | 188 ++++++++++++++++ .../050_stack_and_gates.md | 187 ++++++++++++++++ .../260915_godfile_round5/060_audit_record.md | 74 ++++++ 7 files changed, 1144 insertions(+) create mode 100644 devlog/_plan/260915_godfile_round5/000_plan.md create mode 100644 devlog/_plan/260915_godfile_round5/010_openai_responses.md create mode 100644 devlog/_plan/260915_godfile_round5/020_bridge.md create mode 100644 devlog/_plan/260915_godfile_round5/030_activation_guard.md create mode 100644 devlog/_plan/260915_godfile_round5/040_server_index.md create mode 100644 devlog/_plan/260915_godfile_round5/050_stack_and_gates.md create mode 100644 devlog/_plan/260915_godfile_round5/060_audit_record.md diff --git a/devlog/_plan/260915_godfile_round5/000_plan.md b/devlog/_plan/260915_godfile_round5/000_plan.md new file mode 100644 index 0000000000..307f87272a --- /dev/null +++ b/devlog/_plan/260915_godfile_round5/000_plan.md @@ -0,0 +1,110 @@ +# Godfile Round 5 계획 — openai-responses.ts · bridge.ts · server/index.ts + +2026-09-15, 기준 커밋 aa91958e3b(= origin/dev, `git rev-parse` 실측). 이 문서는 라운드5 전체 요약이고, wp4 가드 상세는 030_activation_guard.md, 스택 체인과 게이트 체크리스트는 050_stack_and_gates.md 가 담당한다. 이 워크트리 히스토리는 6커밋으로 절단돼 있으므로 커밋 빈도 논거는 쓰지 않고, 아래 수치는 이 기준 커밋에서 실행한 명령 출력이다. 괄호 표기가 없는 줄 수 계산(예: 2,683)은 실측값의 산술 합이다. + +## 배경 + +라운드2가 여섯 파일, 라운드3이 다섯 파일을 파사드 뒤로 옮긴 뒤(260915_godfile_round3/000_plan.md:3) `src/`의 2,000줄 이상 파일은 생성물을 제외하면 넷이다(`rg --files src -g '*.ts' | xargs wc -l | awk '$1>=2000'`: bridge.ts 2,206 · gen/agent_pb.ts 15,274(생성물) · openai-responses.ts 2,627 · server/index.ts 3,400 · responses/core.ts 9,386). core.ts 는 함수 하나가 5,000줄을 넘는 별도 프로그램이라 다른 워크트리가 담당하고, 이 라운드는 나머지 셋을 옮긴다. 방법은 앞 라운드와 같다: 함수 본문을 한 줄도 고치지 않고 라인 범위를 리프로 옮기고, 파사드가 같은 이름을 re-export 해 importer 파일들의 import 경로를 그대로 유지한다. + +## 대상 실측 + +| 파일 | 줄 수 | export 문 | importer 파일 수 | +| --- | --- | --- | --- | +| src/adapters/openai-responses.ts | 2,627 | 5 | 46 | +| src/bridge.ts | 2,206 | 6 | 73 | +| src/server/index.ts | 3,400 | 22 | 118 | + +export 문은 `rg -n '^export' <파일>`. importer 는 슬래시 경계를 강제해 ws-bridge·remote-workspace-server 같은 다른 모듈 오탐을 뺀다: `rg -l 'from "[^"]*/bridge"' -g '*.ts' -g '*.tsx' | wc -l` → 73, `rg -l 'from "[^"]*/openai-responses"' ...` → 46, server/index 는 `sort -u <(rg -l 'from "[^"]*/server"' ...) <(rg -l 'from "[^"]*/server/index"' ...) | wc -l` → 118. + +## 가치 판정 + +**openai-responses.ts — 라운드3 판정은 틀렸다.** 라운드3 계획(260915_godfile_round3/000_plan.md:3)은 이 파일을 "export 밀도가 낮아 경계가 아니라 단일 흐름…쪼개면 내부 상태가 인자 목록으로 샌다"고 봤지만, 실측은 최상위 function 66개 중 export 5개, 모듈 수준 let·var 0개다. 상태가 없다는 것과 단일 흐름이라는 것은 다른 문제고, 함수 38개가 `(body: unknown)` 형태 입력 변환으로 주제별로 뭉쳐 있다: reasoning 입력 정화(sanitizeReasoningInputContent 76, stripInvalidItemIds 157), 도구 스키마(normalizeFunctionToolSchema 490, promoteClientLoadedTools 676), 도구 출력 복구(repairOversizedReplayCallIds 719, annotateEmptyResponsesToolOutputs 844), image_gen 네임스페이스(normalizeImageGenClientTools 1776), 웹검색 필드(stripOpenAiOnlyWebSearchFields 1957), usage 추출(usageFromResponsesPayload 2124). 유일한 흐름은 createResponsesPassthroughAdapter(2183-2627)가 이 도구 상자를 순서대로 적용하는 것이고 상태는 provider 인자 하나뿐이라, 주제별 리프 분해가 자연스럽다. + +**bridge.ts — 판정이 절반은 맞다.** 본체는 bridgeToResponsesSSE(215-1602, 1,388줄)와 buildResponseJSONWithBudget(1619-2180, 562줄) 두 함수라 통째 이동으로 끝난다. 다만 상태는 있다: 모듈 let ownedBudgetAbandonedMs(52)를 setter(54)와 sse 본문 340행이 함께 쓴다. 상태와 setter 를 한 리프에 두고 sse.ts 가 live binding 으로 읽게 하면 본문 수정 없이 해결된다. + +**server/index.ts — 판정이 맞다.** startServer(1006-3400)가 2,395줄이라 함수 밖(266-920, 944-1005)을 전부 옮겨도 파사드는 계산상 2,683줄로 2,000을 넘는다. 본문은 setup(1006-1103), 파이프라인 클로저(1104-1460), serveOptions 리터럴(1481-3220, 1,740줄 — fetch·websocket 라우팅 표면, websocket 키 2976), 바인딩과 activation(3221-3400)으로 떨어진다. 자르면 안 되는 자리는 3222(Bun.serve)부터 3399(return server)까지다. 이 구간은 한 동기 턴에 끝나야 하고 tests/lab/core-lab-boundary.test.ts:139-140 이 같은 문자열을 앵커로 검사하므로, 윈도우·startServer 선언·setup 시작부(1074 startupCodexHome 포함)는 파사드에 남기고 나머지를 리프로 옮긴다. 클로저 상태가 인자로 샤는 비용은 이 파일에서 실제로 발생한다(위험 절). + +## 작업 단위와 브랜치 + +| 단위 | 내용 | 브랜치 | base | +| --- | --- | --- | --- | +| wp1 | 이 계획 문서 | codex/godfile-r5-a-openai-responses | origin/dev | +| wp2 | openai-responses.ts 분해 | codex/godfile-r5-a-openai-responses | origin/dev | +| wp3 | bridge.ts 분해 | codex/godfile-r5-b-bridge | a | +| wp4 | 가드·소스 오라클 재지정 | codex/godfile-r5-c-activation-guard | b | +| wp5 | server/index.ts 분해 | codex/godfile-r5-d-server-index | c | +| wp6 | 머지 d→c→b→a→dev(050 순서, PR 본문은 템플릿 세 절) | — | — | + +### wp2 — openai-responses.ts → src/adapters/openai-responses/ + +| 리프 | 원본 라인 | 내용(대표 함수) | +| --- | --- | --- | +| forward-headers.ts | 44-73 | FORWARD_HEADERS(46) | +| reasoning-input.ts | 74-350 | sanitizeReasoningInputContent(76), scrubOcxCompactionItems(310) | +| forward-params.ts | 351-489 | 프롬프트캐시·요약·verbosity·effort 파라미터 제거 | +| tools.ts | 490-718 | normalizeToolSchemas(553), promoteClientLoadedTools(676) | +| tool-output-repair.ts | 719-927 | repairOversizedReplayCallIds(719), annotateEmptyResponsesToolOutputs(844) | +| input-repair.ts | 928-1221 | repairOrphanedInputItems(968), normalizeResponsesToolResultAdjacency(1121) | +| stateful-params.ts | 1222-1302 | stripPreviousResponseId(1222), stripStatefulResponsesParams(1260) | +| canonical-forward.ts | 1303-1455 | stripCanonicalForwardSamplingParams(1303), 전달 envelope 정규화 | +| image-gen-tools.ts | 1456-1928 | image_gen 네임스페이스, normalizeImageGenClientTools(1776) | +| web-search-fields.ts | 1929-2082 | stripOpenAiOnlyWebSearchFields(1957), muse 변형 | +| response-extraction.ts | 2083-2181 | usageFromResponsesPayload(2124), 에러·텍스트 추출 | +| passthrough-adapter.ts | 2183-2627 | createResponsesPassthroughAdapter(2183) | + +파사드는 경로가 그대로라 import 수정이 없고, 이동한 export 다섯(46, 76, 1303, 1957, 2183)을 같은 이름의 re-export 로 바꾼다. 리프 import 보정은 균일 규칙이다: 원본이 src/adapters/ 에 있으므로 `./x`는 `../x`로, `../y`는 `../../y`로 고치고(node:crypto·node:buffer 유지) 동적 import 는 없다(실측). passthrough-adapter.ts 는 내부 함수 38개를 호출하고 호출이 12개 리프 모두에 걸치므로(awk 실측), 리프 간 호출은 같은 디렉터리 상대 import 로 흡수한다. + +### wp3 — bridge.ts → src/bridge/ + +| 리프 | 원본 라인 | 내용 | +| --- | --- | --- | +| helpers.ts | 47-51 + 57-198 | uuid(47), sseEvent(58), responseError(130), webSearchAction(194) | +| budget-state.ts | 52-56 | let ownedBudgetAbandonedMs(52) + setter(54) | +| types.ts | 199-214 | OutputItem(199), ResponsesTerminalStatus(205), StringChunks(208) | +| sse.ts | 215-1602 | bridgeToResponsesSSE(1,388줄) | +| response-json.ts | 1603-2180 | buildResponseJSON(1603), buildResponseJSONWithBudget(1619) | +| format-error.ts | 2182-2206 | formatErrorResponse | + +파사드 171행 `export { adapterFailureFromMessage } from "./lib/errors";` 는 그대로 두고 나머지 export 다섯(54, 205, 215, 1603, 2182)을 re-export 로 교체한다. 리프 import 는 원본이 src/ 루트라 `./x` → `../x` 하나뿐이고 동적 import 는 없다(실측). 리프 간 import 필요량은 사용 스캔 실측이다 — sse.ts ← helpers{uuid, sseEvent, responsesUsage, responseError, toolCallArgumentsUsable, adapterFailureFromEvent, webSearchAction}, budget-state{ownedBudgetAbandonedMs}, types{OutputItem, ResponsesTerminalStatus, StringChunks, emptyChunks, joinChunks}; response-json.ts ← helpers{uuid, adapterFailureFromEvent, responsesUsage, toolCallArgumentsUsable, webSearchAction}, types{OutputItem, StringChunks, joinChunks}; format-error.ts 는 이들 모두 불필요(스캔 0건). + +### wp4 — 가드·소스 오라클 재지정 (wp5 착수 전에 land) + +| 테스트 | 검사 대상(실측) | wp5 후 조치 | +| --- | --- | --- | +| tests/lab/core-lab-boundary.test.ts:354 | 세 앵커 문자열(139-140 정의), startServer 선언, 윈도우 await 0 | 무수정 — wp5 가 앵커·선언의 파사드 잔여를 작업 명세로 고정 | +| tests/windows/windows-deploy-close-regressions.test.ts:81 | configuredHost(1115), serve 앵커 | 1115 단언을 request-pipeline.ts 로 재지정 | +| tests/lib/workflow-budget.test.ts:474 | runAdmittedHttpTurn 호출 10곳(2415-2715) 카운트 | 대상을 serve-options.ts 로 재지정 | +| tests/server/loopback-listener-admission.test.ts:64,92 | 라우트 순서·handleClaudeMessages 호출형 | serve-options.ts 로 재지정 | +| tests/responses/ws-endpoint.test.ts:40 | WEBSOCKET_IDLE_TIMEOUT_SECONDS(268), websocket:{(2976) | 268 은 constants.ts, 나머지는 serve-options.ts 로 분할 | +| tests/codex-integration/model-visibility-management-api.test.ts:72 | catalog_busy·Retry-After 문자열 | serve-options.ts 로 재지정 | +| tests/codex-integration/codex-retained-root-serialization.test.ts:295 | startupCodexHome(1074) 슬라이스 | 무수정(파사드 잔여) | +| tests/codex-integration/compatibility-manifest.test.ts:184, tests/usage/quota-reset-core-boundary.test.ts:80 | protectedFiles 그래프 워크 | 무수정 — import-graph 가 re-export 엣지를 추종(45행 실측) | + +bridge 와 openai-responses 텍스트를 읽는 테스트는 없다(tests 전수 검색, 경로 언급은 전부 주석). + +### wp5 — server/index.ts → src/server/index/ + +| 리프 | 원본 라인 | 내용 | +| --- | --- | --- | +| constants.ts | 266-275 | MAX_WS_FRAME_BYTES(267) 등 상수 5개 | +| bounded-request-text.ts | 276-327 | readBoundedRequestText(287) | +| remote-catalog-key.ts | 328-337 | withRemoteCatalogKeyId(328), pattern 은 ./constants 에서 import | +| live-sideband.ts | 338-889 | sideband export 8개(345, 356, 365, 369, 379, 381, 564, 712) | +| request-log-id.ts | 890-920 | withRequestLogId | +| startup-helpers.ts | 944-1005 | inspectStartupOwnership(944), let 981 동행, consumeStartupCacheInvalidationWrite(984), warn* 2개 | +| request-pipeline.ts | 1104-1460 | applyPolicy(1104), runAdmittedHttpTurn(1290), reprobeNativeOwnership(1367), ingressForServer(1447) | +| serve-options.ts | 1481-3220 | fetch·websocket 라우팅 표면 1,740줄(websocket 2976) | + +파사드 잔여는 1-265, 921-943(StartServerDeps), 1006-1103, 1461-1480, 3221-3400 으로 계산상 약 600줄이다. serve-options.ts 로 가는 동적 import 15곳(1590, 1666, 1851, 1919, 1968, 1969, 1970, 2038, 2039, 2153, 2154, 2155, 2285, 2350, 2465 — 실측 19곳 중 나머지는 파사드 잔여)은 `../x` → `../../x`, `./x` → `../x`로 고친다. request-pipeline.ts 와 serve-options.ts 는 config·deps 등 클로저 변수를 공유하므로 본문은 그대로 두고 시그니처에 캡처 변수를 받는 ctx 를 추가한다. 캡처 목록은 이동 직전 각 범위를 `rg -n` 으로 훑어 확정해 리프 상단에 기록한다. 파사드 let(1029, 1049-1050, 1426-1428, 1452-1455, 1461)에 쓰는 대입문은 1472-1480 이 마지막이라 전부 파사드에 남는다(`rg -n '^ let '` 실측). + +## 게이트 + +scripts/file-size-ratchet.ts: THRESHOLD = 2000, 기준선 tests/fixtures/file-size-baseline.json 의 caps 45개(`rg -c` 실측). cap 없는 파일이 2,000줄 이상이면 NEW_OVERSIZED, cap 초과면 GREW — 둘 다 exit 1. 갱신은 `bun scripts/file-size-ratchet.ts --update`(package.json:56 `ratchet:update`) 한 방법이고, updateBaseline 이 `files[path] = Math.min(cap, lines)` 으로 캡을 낮추기만 하므로 줄어든 파사드 캡(현재 2,627·2,206·3,400)은 자동으로 낮아지고 2,000 미만 리프는 항목이 생기지 않는다. 캡을 올리는 경로는 없다. CI 결속은 tests/ci-workflows/file-size-ratchet.test.ts 다. + +tests/ci-workflows/repo-import-resolution.test.ts 는 src/ 와 gui/src 의 모든 상대 import 지정자를 runtime 엣지와 type-only 엣지로 나눠 실제 파일로 해석되는지 검사하고, 스캔 파일이 500개를 넘는지 단언해 빈 통과를 막는다. 헤더가 기록하듯 파사드 추출 두 라운드에서 지정자는 잘났는데 경로가 없는 결함이 실제로 샜고(라운드1의 ../config, 라운드2의 import("./types")), 텍스트 파서·export 비교·diff 리뷰는 전부 이 결함을 못 봤다. 리프 깊이 보정 실수의 1차 기계적 검증은 이 가드가 담당한다. + +## 위험 + +라운드3 에서 서브에이전트 다섯이 전원 ALL CHECKS PASS 를 보고했는데 한 명이 자기 검증 스크립트(/tmp/m3_verify.ts)에서 TS2307 을 노이즈로 제외해 미해결 import 를 숨겼다. 검증 게이트 문언은 260915_godfile_round3/000_plan.md:25,53 이고 사건 기록은 050_stack_and_gates.md:153-156 이다. 이번 라운드 대응은 각 서브에이전트의 자체 검증을 신뢰하지 않고, 메인 세션이 스택을 접은 각 head 에서 export 표면 diff(origin/dev 대비), 상대 import 해석, ratchet 감사를 재실행하는 것이다. TS2307 필터를 서브에이전트가 정하게 두지 않는다(050 재확인). + +소스 오라클 vacuous 가 이 라운드에서 가장 조용한 결함 경로다: 문자열 단언 테스트는 대상이 이동해도 통과하지만 아무것도 검사하지 않게 된다. wp4 가 wp5 앞에 오는 이유다. startServer 내부 절단의 상태 샘은 tsc 와 포커스 테스트로 잡히지만 캡처 변수 목록화를 생략하면 인자 누락이 반복되므로 리프 상단 기록을 강제한다. diff --git a/devlog/_plan/260915_godfile_round5/010_openai_responses.md b/devlog/_plan/260915_godfile_round5/010_openai_responses.md new file mode 100644 index 0000000000..76d1acb642 --- /dev/null +++ b/devlog/_plan/260915_godfile_round5/010_openai_responses.md @@ -0,0 +1,211 @@ +# 010 — WP2: src/adapters/openai-responses.ts 분해 계약서 (godfile round5) + +측정 기준: 이 워크트리 HEAD aa91958e3b (git log --oneline -1 실측, base origin/dev 와 동일). 아래 숫자는 전부 이 워크트리에서 실행한 명령 출력값이고, 실행 전에는 확정할 수 없는 값은 미측정으로 표기한다. + +## 실측 요약 + +- wc -l: 2,627줄. +- rg 최상위 선언 83개(46~2183행). export 5개: FORWARD_HEADERS(46), sanitizeReasoningInputContent(76), stripCanonicalForwardSamplingParams(1303), stripOpenAiOnlyWebSearchFields(1957), createResponsesPassthroughAdapter(2183). 내부 전용 78개. +- awk 괄호 깊이 추적: 심볼 범위 합 2,163줄, 심볼 밖 464줄 = 헤더 1-45(45줄) + 심볼 사이 빈 줄·주석 419줄. OVERLAP 0건. 1-45(import 문)를 제외한 모든 갭이 빈 줄/주석뿐이라 각 심볼의 끝행 계산이 성립한다. +- 선행 주석(JSDoc)은 바로 아래 심볼에 붙아 함께 이동한다. 갭 검증이 이를 보장한다. +- 동적 import(: 파일 내 0건(rg -n 'import\(' 빈 출력). 테스트 쪽 동적 import 1건 — tests/adapters/anthropic/anthropic-thinking-signature.test.ts:317, facade 경로라 영향 없음. +- isPlainObject 등장 135회(rg -c). 전 리프가 쓰는 유일한 공용 유틸이다. + +## 이동 규칙 + +- 순수 이동만 한다. 함수 본문 수정 없이 시작행-끝행 범위를 통째로 옮기고, 선행 주석 블록은 해당 심볼과 함께 옮긴다. +- 리프 디렉터리는 src/adapters/openai-responses/ 이다. 상대 지정자 규칙: ./x → ../x, ../y → ../../y, node:* 는 그대로. 리프 파일 첫 import 블록에 일괄 적용한다. 라운드2의 ../config 가 존재하지 않는 src/codex/config 를 가린 사례가 바로 이 클래스고, 정적 가드 tests/ci-workflows/repo-import-resolution.test.ts(라운드3 커밋 0eab3851a5)가 해상 실패를 잡는다. +- 파사드 export 표면은 위 5개로 고정한다. 리프는 리프 간 참조와 어댑터 호출에 필요한 심볼에만 선언부에 export 키워드를 더한다(본문 무변경). +- 리프 간 의존성(호출 그래프 rg 실측): 전 리프 → internal(isPlainObject), canonical-forward → prompt-cache(stripPromptCacheBreakpoints, 원본 1449행 호출 지점), passthrough(어댑터) → 전 리프 진입 함수. 이 외 교차는 없다. + +## 리프 배치 (10개) + +심볼 줄수는 awk 계산값이다. 리프별 최종 줄수(주석·import 포함)는 이동 후 wc -l 로 확정하므로 현재 미측정이다. + +| 리프 파일 | 담는 심볼(원본 행) | 심볼 줄수 | 리프 진입 export | +|---|---|---|---| +| internal.ts | isPlainObject(458-460) | 3 | isPlainObject | +| reasoning.ts | sanitizeReasoningInputContent(76-142), stripUnsupportedReasoningSummaryDelivery(144-155), stripDisabledReasoningSummaries(377-413), stripDisabledVerbosity(420-434), normalizeConfiguredReasoningSummaryDelivery(440-456), mapRoutedResponsesReasoningEffort(467-488) | 170 | 위 6개 전부 | +| request-strips.ts | stripInvalidItemIds(157-183), CANONICAL_ONLY_TOOL_FIELDS(196-207), stripCanonicalOnlyToolFields(209-248), stripInternalChatMessageMetadataPassthrough(257-272), stripItemIdsWhenUnstored(280-294), scrubOcxCompactionItems(310-336) | 137 | stripInvalidItemIds, stripCanonicalOnlyToolFields, stripInternalChatMessageMetadataPassthrough, stripItemIdsWhenUnstored, scrubOcxCompactionItems | +| prompt-cache.ts | stripDeprecatedPromptCacheRetention(351-358), stripCanonicalForwardPromptCacheOptions(366-370), POSIT_CACHE_MARKER_MAX_DEPTH(1383), POSIT_CACHE_MARKER_MAX_NODES(1384), PromptCacheMarkerRewrite(1386-1390), stripPromptCacheBreakpoints(1397-1429) | 53 | stripDeprecatedPromptCacheRetention, stripCanonicalForwardPromptCacheOptions, stripPromptCacheBreakpoints | +| tool-schema.ts | normalizeFunctionToolSchema(490-505), reconcileToolChoiceForOmittedTools(516-551), normalizeToolSchemas(553-597), activateDeferredTool(599-606), mergeLoadedTools(608-669), promoteClientLoadedTools(676-703), stripUnsupportedHostedTools(1865-1926) | 257 | normalizeToolSchemas, promoteClientLoadedTools, stripUnsupportedHostedTools | +| tool-output-recovery.ts | MAX_RESPONSES_CALL_ID_LENGTH(705), REPAIRED_CALL_ID_PREFIX(707), REPAIRED_CALL_ID_DIGEST_LENGTH(708), repairOversizedReplayCallIds(719-753), toolOutputText(756-765), isRepairableToolOutput(768-794), orphanedToolOutputContent(797-820), isToolOutputEmpty(823-837), annotateEmptyResponsesToolOutputs(844-854), repairUnidentifiedToolOutputItems(862-880), backfillWebSearchQueries(928-966), repairOrphanedInputItems(968-1108), normalizeResponsesToolResultAdjacency(1121-1209) | 413 | repairOversizedReplayCallIds, annotateEmptyResponsesToolOutputs, repairUnidentifiedToolOutputItems, backfillWebSearchQueries, repairOrphanedInputItems, normalizeResponsesToolResultAdjacency | +| canonical-forward.ts | stripPreviousResponseId(1222-1226), applyTierDecisionToResponsesBody(1229-1235), stripStatefulResponsesParams(1260-1269), stripUnsupportedForwardParams(1279-1286), CANONICAL_FORWARD_UNSUPPORTED_SAMPLING(1289), stripCanonicalForwardSamplingParams(1303-1311), canonicalForwardSystemText(1314-1327), isCanonicalForwardSystemMessage(1330-1334), normalizeCanonicalForwardPromptEnvelope(1345-1381), normalizeCanonicalForwardContinuationEnvelope(1437-1455) | 115 | 위 10개 중 상수 1개 제외한 9개 | +| image-gen.ts | IMAGE_GEN_NAMESPACE(1457), HOSTED_IMAGE_GENERATION_TOOL(1458), IMAGE_GEN_DOTTED_PREFIX(1459), IMAGE_GEN_WIRE_PREFIX(1460), imageGenLocalName(1463-1467), imageGenWireName(1470-1472), isImageGenClientName(1475-1479), declaresImageGenClientTool(1482-1486), preferHostedImageGenToolChoice(1489-1515), preferConfiguredHostedTools(1522-1608), flattenImageGenNamespace(1619-1644), normalizeFlatImageGenFunction(1647-1655), imageGenFunctionName(1658-1663), declaresUsableImageGenAlias(1666-1676), imageGenToolChoiceAliases(1679-1711), normalizeImageGenToolChoice(1714-1737), declaresImageGenFunctionCall(1740-1745), normalizeImageGenFunctionCall(1748-1760), normalizeImageGenClientTools(1776-1858) | 347 | preferConfiguredHostedTools, normalizeImageGenClientTools | +| web-search.ts | OPENAI_ONLY_WEB_SEARCH_FIELDS(1938), stripOpenAiOnlyWebSearchFieldsFromTools(1940-1955), stripOpenAiOnlyWebSearchFields(1957-1988), MUSE_SPARK_WEB_SEARCH_STRICT_MODELS(1997-2002), MUSE_SPARK_WEB_SEARCH_STRICT_RESPONSE_URLS(2004-2008), MUSE_SPARK_UNSUPPORTED_WEB_SEARCH_FIELDS(2010-2013), stripMuseSparkUnsupportedWebSearchFields(2024-2081) | 122 | stripOpenAiOnlyWebSearchFields, stripMuseSparkUnsupportedWebSearchFields | +| passthrough.ts | FORWARD_HEADERS(46-65), stripInputImagesDeep(2084-2093), buildRoutedCompactionBody(2105-2121), usageFromResponsesPayload(2124-2150), responsesPayloadText(2152-2162), responsesErrorMessage(2164-2172), appendedUtf8Bytes(2175-2181), createResponsesPassthroughAdapter(2183-2627) | 546 | FORWARD_HEADERS, createResponsesPassthroughAdapter | + +리프 합계 검산: 3+170+137+53+257+413+115+347+122+546 = 2,163 = awk covered 총계와 일치. 가장 큰 리프는 passthrough 546줄이고 전 리프가 700줄 미만이다. + +## 전체 심볼 인벤토리 (83개) + +O 는 현재 export, 리프 열은 이동 대상 파일이다. + +| 심볼 | 원본 행 | 줄수 | export | 리프 | +|---|---|---|---|---| +| FORWARD_HEADERS | 46-65 | 20 | O | passthrough | +| sanitizeReasoningInputContent | 76-142 | 67 | O | reasoning | +| stripUnsupportedReasoningSummaryDelivery | 144-155 | 12 | | reasoning | +| stripInvalidItemIds | 157-183 | 27 | | request-strips | +| CANONICAL_ONLY_TOOL_FIELDS | 196-207 | 12 | | request-strips | +| stripCanonicalOnlyToolFields | 209-248 | 40 | | request-strips | +| stripInternalChatMessageMetadataPassthrough | 257-272 | 16 | | request-strips | +| stripItemIdsWhenUnstored | 280-294 | 15 | | request-strips | +| scrubOcxCompactionItems | 310-336 | 27 | | request-strips | +| stripDeprecatedPromptCacheRetention | 351-358 | 8 | | prompt-cache | +| stripCanonicalForwardPromptCacheOptions | 366-370 | 5 | | prompt-cache | +| stripDisabledReasoningSummaries | 377-413 | 37 | | reasoning | +| stripDisabledVerbosity | 420-434 | 15 | | reasoning | +| normalizeConfiguredReasoningSummaryDelivery | 440-456 | 17 | | reasoning | +| isPlainObject | 458-460 | 3 | | internal | +| mapRoutedResponsesReasoningEffort | 467-488 | 22 | | reasoning | +| normalizeFunctionToolSchema | 490-505 | 16 | | tool-schema | +| reconcileToolChoiceForOmittedTools | 516-551 | 36 | | tool-schema | +| normalizeToolSchemas | 553-597 | 45 | | tool-schema | +| activateDeferredTool | 599-606 | 8 | | tool-schema | +| mergeLoadedTools | 608-669 | 62 | | tool-schema | +| promoteClientLoadedTools | 676-703 | 28 | | tool-schema | +| MAX_RESPONSES_CALL_ID_LENGTH | 705 | 1 | | tool-output-recovery | +| REPAIRED_CALL_ID_PREFIX | 707 | 1 | | tool-output-recovery | +| REPAIRED_CALL_ID_DIGEST_LENGTH | 708 | 1 | | tool-output-recovery | +| repairOversizedReplayCallIds | 719-753 | 35 | | tool-output-recovery | +| toolOutputText | 756-765 | 10 | | tool-output-recovery | +| isRepairableToolOutput | 768-794 | 27 | | tool-output-recovery | +| orphanedToolOutputContent | 797-820 | 24 | | tool-output-recovery | +| isToolOutputEmpty | 823-837 | 15 | | tool-output-recovery | +| annotateEmptyResponsesToolOutputs | 844-854 | 11 | | tool-output-recovery | +| repairUnidentifiedToolOutputItems | 862-880 | 19 | | tool-output-recovery | +| backfillWebSearchQueries | 928-966 | 39 | | tool-output-recovery | +| repairOrphanedInputItems | 968-1108 | 141 | | tool-output-recovery | +| normalizeResponsesToolResultAdjacency | 1121-1209 | 89 | | tool-output-recovery | +| stripPreviousResponseId | 1222-1226 | 5 | | canonical-forward | +| applyTierDecisionToResponsesBody | 1229-1235 | 7 | | canonical-forward | +| stripStatefulResponsesParams | 1260-1269 | 10 | | canonical-forward | +| stripUnsupportedForwardParams | 1279-1286 | 8 | | canonical-forward | +| CANONICAL_FORWARD_UNSUPPORTED_SAMPLING | 1289 | 1 | | canonical-forward | +| stripCanonicalForwardSamplingParams | 1303-1311 | 9 | O | canonical-forward | +| canonicalForwardSystemText | 1314-1327 | 14 | | canonical-forward | +| isCanonicalForwardSystemMessage | 1330-1334 | 5 | | canonical-forward | +| normalizeCanonicalForwardPromptEnvelope | 1345-1381 | 37 | | canonical-forward | +| POSIT_CACHE_MARKER_MAX_DEPTH | 1383 | 1 | | prompt-cache | +| POSIT_CACHE_MARKER_MAX_NODES | 1384 | 1 | | prompt-cache | +| PromptCacheMarkerRewrite | 1386-1390 | 5 | | prompt-cache | +| stripPromptCacheBreakpoints | 1397-1429 | 33 | | prompt-cache | +| normalizeCanonicalForwardContinuationEnvelope | 1437-1455 | 19 | | canonical-forward | +| IMAGE_GEN_NAMESPACE | 1457 | 1 | | image-gen | +| HOSTED_IMAGE_GENERATION_TOOL | 1458 | 1 | | image-gen | +| IMAGE_GEN_DOTTED_PREFIX | 1459 | 1 | | image-gen | +| IMAGE_GEN_WIRE_PREFIX | 1460 | 1 | | image-gen | +| imageGenLocalName | 1463-1467 | 5 | | image-gen | +| imageGenWireName | 1470-1472 | 3 | | image-gen | +| isImageGenClientName | 1475-1479 | 5 | | image-gen | +| declaresImageGenClientTool | 1482-1486 | 5 | | image-gen | +| preferHostedImageGenToolChoice | 1489-1515 | 27 | | image-gen | +| preferConfiguredHostedTools | 1522-1608 | 87 | | image-gen | +| flattenImageGenNamespace | 1619-1644 | 26 | | image-gen | +| normalizeFlatImageGenFunction | 1647-1655 | 9 | | image-gen | +| imageGenFunctionName | 1658-1663 | 6 | | image-gen | +| declaresUsableImageGenAlias | 1666-1676 | 11 | | image-gen | +| imageGenToolChoiceAliases | 1679-1711 | 33 | | image-gen | +| normalizeImageGenToolChoice | 1714-1737 | 24 | | image-gen | +| declaresImageGenFunctionCall | 1740-1745 | 6 | | image-gen | +| normalizeImageGenFunctionCall | 1748-1760 | 13 | | image-gen | +| normalizeImageGenClientTools | 1776-1858 | 83 | | image-gen | +| stripUnsupportedHostedTools | 1865-1926 | 62 | | tool-schema | +| OPENAI_ONLY_WEB_SEARCH_FIELDS | 1938 | 1 | | web-search | +| stripOpenAiOnlyWebSearchFieldsFromTools | 1940-1955 | 16 | | web-search | +| stripOpenAiOnlyWebSearchFields | 1957-1988 | 32 | O | web-search | +| MUSE_SPARK_WEB_SEARCH_STRICT_MODELS | 1997-2002 | 6 | | web-search | +| MUSE_SPARK_WEB_SEARCH_STRICT_RESPONSE_URLS | 2004-2008 | 5 | | web-search | +| MUSE_SPARK_UNSUPPORTED_WEB_SEARCH_FIELDS | 2010-2013 | 4 | | web-search | +| stripMuseSparkUnsupportedWebSearchFields | 2024-2081 | 58 | | web-search | +| stripInputImagesDeep | 2084-2093 | 10 | | passthrough | +| buildRoutedCompactionBody | 2105-2121 | 17 | | passthrough | +| usageFromResponsesPayload | 2124-2150 | 27 | | passthrough | +| responsesPayloadText | 2152-2162 | 11 | | passthrough | +| responsesErrorMessage | 2164-2172 | 9 | | passthrough | +| appendedUtf8Bytes | 2175-2181 | 7 | | passthrough | +| createResponsesPassthroughAdapter | 2183-2627 | 445 | O | passthrough | + +## 파사드 + +src/adapters/openai-responses.ts 는 헤더 주석과 아래 5개 재노출만 남는다. import 문이 필요 없는 export-from 형태라 실무 약 15줄이고 200줄 상한 여유가 크다. 경로가 그대라서 src/index.ts:9 와 src 내부 12곳, 테스트 31곳의 import 는 무수정이다. + +| export | 새 위치 | +|---|---| +| FORWARD_HEADERS | ./openai-responses/passthrough | +| sanitizeReasoningInputContent | ./openai-responses/reasoning | +| stripCanonicalForwardSamplingParams | ./openai-responses/canonical-forward | +| stripOpenAiOnlyWebSearchFields | ./openai-responses/web-search | +| createResponsesPassthroughAdapter | ./openai-responses/passthrough | + +## import 재작성 표 (원본 1-42 → 리프) + +소비 리프는 식별자 사용 행을 rg 로 대조한 결과다. 멀티라인 문은 행 범위로 적었다. + +| 원본 행 | 지정자 | 변환 후 | 소비 리프 | +|---|---|---|---| +| 1 | ./routed-agent-messages | ../routed-agent-messages | passthrough | +| 2 | ./openai-chat | ../openai-chat | passthrough | +| 3 | ./opencode-go-additional-tools | ../opencode-go-additional-tools | passthrough | +| 4 | ../providers/xai-transport | ../../providers/xai-transport | passthrough | +| 5 | node:crypto | 그대로 | tool-output-recovery(740) | +| 6 | node:buffer | 그대로 | passthrough(2180, 2445 이후) | +| 7 | ./base (import type) | ../base | passthrough | +| 8 | ../types | ../../types | passthrough, reasoning, tool-schema, canonical-forward, image-gen | +| 9 | ../codex/catalog | ../../codex/catalog | reasoning(145) | +| 10 | ../codex/forward-transport-headers | ../../codex/forward-transport-headers | passthrough(64, 2418-2425) | +| 11 | ../responses/compaction | ../../responses/compaction | request-strips(319-331), passthrough(2118) | +| 12 | ../responses/tool-groups | ../../responses/tool-groups | image-gen(1779) | +| 13 | ../responses/hosted-tool-policy | ../../responses/hosted-tool-policy | tool-schema(1871, 1910) | +| 14 | ../lib/sse-decoder | ../../lib/sse-decoder | passthrough(2485) | +| 15 | ../lib/debug | ../../lib/debug | tool-schema(592) | +| 16-20 | ../providers/openai-tiers | ../../providers/openai-tiers | passthrough(2194-2388) | +| 21 | ../responses/reasoning-envelope | ../../responses/reasoning-envelope | reasoning(96) | +| 22 | ../reasoning-effort | ../../reasoning-effort | reasoning(382-485) | +| 23 | ../lib/translator-budget (import type) | ../../lib/translator-budget | passthrough(2467, 2593) | +| 24 | ../responses/custom-tool-compat | ../../responses/custom-tool-compat | passthrough(2311) | +| 25 | ../responses/tool-search-compat | ../../responses/tool-search-compat | passthrough(2322) | +| 26 | ../responses/namespace-tool-compat | ../../responses/namespace-tool-compat | passthrough(2330) | +| 27 | ../responses/plaintext-v2-agent-messages | ../../responses/plaintext-v2-agent-messages | passthrough(2368) | +| 28 | ../responses/muse-tool-name-alias | ../../responses/muse-tool-name-alias | passthrough(2346-2347) | +| 29 | ./openai-responses-url | ../openai-responses-url | passthrough(2232) | +| 30 | ./responses-code-mode | ../responses-code-mode | passthrough(2360) | +| 31 | ./responses-tool-schema | ../responses-tool-schema | tool-schema(494) | +| 32 | ./xai-web-search | ../xai-web-search | passthrough(2335-2336) | +| 33 | ./empty-tool-output-annotation | ../empty-tool-output-annotation | tool-output-recovery(830, 851) | +| 34-38 | ./xai-tool-schema | ../xai-tool-schema | tool-schema(497, 525), passthrough(2396) | +| 39-41 | ../providers/fastwire | ../../providers/fastwire | passthrough(2430) | + +## 동반 수정 (테스트·문서) + +경로형 검색 두 종(파일명 포함 rg -n 'openai-responses\.ts' tests/, 경로형 rg -n 'adapters/openai-responses')의 결과가 아래 판정의 근거다. readFileSync 로 어댑터 원문을 읽어 문자열을 단언하는 소스 오라클 테스트는 0건이다. gui/ 와 docs-site/ 에는 경로 참조가 없다. + +- tests/fixtures/file-size-baseline.json:21 — "src/adapters/openai-responses.ts": 2627. 파사드 축소는 SHRANK 로 통과(file-size-ratchet.test.ts:87). --update 로 캡을 내리는 건 선택. 신규 리프는 전부 2,000줄 미만(THRESHOLD, file-size-ratchet.test.ts:47)이라 NEW_OVERSIZED 없음. devlog/ 는 스캔 제외(같은 파일 203행). +- tests/ci-workflows/repo-import-resolution.test.ts — 리프 상대 경로 오타를 잡는 정적 가드. 분해 PR 은 이 테스트와 file-size-ratchet 이 1차 방어선이다. +- tests/routing/routing-compatibility-model-matching.test.ts:123, 146 — 주석이 파일명과 src/adapters/openai-responses.ts:1001 라인 앵커를 건다. 앵커는 이미 현행 1532-1534(preferConfiguredHostedTools)와 어긋진 상태고, 분해 후 image-gen.ts 로 재지정이 필요하다. 테스트 동작은 무관. +- src/routing/compatibility/behavior.ts:84 — 같은 :1001 앵커 주석. image-gen.ts 로 재지정. +- src/server/chat-completions.ts:244 — 파일 경로 주석. 파사드가 살아있어 깨지지 않고, 리프 언급으로 갱신하면 좋다. +- facade 경로 import 테스트 31개 파일(빠짐없이 열거): fastwire-policy:3, fastwire-observability:4, responses/compaction-progress:2, gui/volcengine-providers:3, responses/responses-routed-web-search-fields:2, responses/passthrough-override:2, responses/responses-forward-posit-continuation:2, responses/chat-responses-control-integration:24, responses/responses-forward-prompt-envelope:3, codex-integration/codex-metadata-integrity:2, responses/openai-responses-passthrough:4, responses/responses-muse-tool-name-alias:3, responses/plaintext-v2-agent-messages:2, responses/responses-compaction:3, responses/chat-responses-control-scope:17, responses/ws-upstream-reuse:6, responses/responses-forward-dangling-call:10, responses/responses-usage-passthrough:2, claude-integration/claude-inbound:8, providers/muse-spark-web-search-compat:2, providers/opencode-go-luna-wire:13, providers/muse-tool-name-alias:3, providers/meta-model-api-provider:16, providers/opencode-go-grok46-responses:2, providers/deepseek-reasoning-replay:10, providers/deepseek-inbound-wire:21, adapters/openai/openai-chat-model-suffix:3, adapters/routed-agent-messages:2, adapters/exec-tool-result-normalize:2, adapters/anthropic/anthropic-thinking-signature:12 과 317(동적 import), providers/xai/xai-web-search-compat:2. 전부 facade 재노출로 해결되고, named import 를 쓰는 파일(deepseek-reasoning-replay 의 sanitizeReasoningInputContent, chat-responses-control-scope 의 stripCanonicalForwardSamplingParams, responses-routed-web-search-fields 의 stripOpenAiOnlyWebSearchFields, codex-metadata-integrity 와 anthropic-thinking-signature 등의 FORWARD_HEADERS)도 표면 변화가 없어 깨지지 않는다. +- src 내부 소비자(무수정): index.ts:9, web-search/executor.ts:2, codex/auth-context.ts:60, server/chat-completions.ts:8, server/ws-bridge.ts:3, server/claude-messages.ts:9, vision/describe.ts:3, server/responses/compact.ts:11, server/responses/core.ts:44, server/responses/collaboration.ts:13, server/responses/encrypted-payload.ts:10, lab/conformance/executor.ts:2. providers/openai-tiers*.ts 의 openai-responses-url import 는 다른 모듈이라 오탐 제외. +- structure/ (파일:행 — 판정): runtime.md:178 테이블 행, 파사드 경로 유지로 유효, 리프 요구 보강 권장. runtime.md:419 동작 서술, 유효. data-planes/inbound-compat.md:290 파사드 경로 서술, 유효. subagents.md:12 유효. transports/byte-accounting.md:25 appendedUtf8Bytes 서술, 유효(passthrough 리프). transports/inventory.md:19 유효. adapters/registry.md:21, data-planes/images.md:21·43·76, transports/responses.md:107·152·220·310, adapters/compatibility-contracts.md:10·18(다른 파일 src/compatibility/openai-responses.ts), decisions/ADR-0061:9 는 어댑터 id 또는 타 파일 언급이라 무관. structure/manifest.json 과 INDEX.md 에는 이 파일명이 없다(rg 0건). +- tests/ 안의 openai-responses 문자열은 총 1,156회(rg -o | wc -l)이지만 대부분 어댑터 id 문자열이다. 파일 참조는 위에 열거한 것으로 전부다. + +## 검산 + +- 심볼 2,163줄(리프 합) + 이동 주석·빈 줄 419줄 + 헤더 1-45 = 2,627 = wc -l. 파사드는 이동분을 갖지 않는다. +- 리프 10개 + 파사드 1개. 새 트리 총 줄수는 import 분할로 2,627보다 소폭 늘어나며 정확한 값은 실행 후 wc -l 로 확정(현재 미측정). + +## 측정 명령 목록 + +- wc -l src/adapters/openai-responses.ts → 2627 +- rg -n '^(export |declare )?(async |abstract )?(function|class|const|let|var|type|interface|enum)[[:space:]]' 대상 파일 → 83선언 +- awk 괄호·중괄호·대괄호 깊이 추적(위 시작행 입력, SYM/GAP/TOTAL 출력) → 끝행·줄수·covered 2163·uncovered 464·OVERLAP 0 +- awk NR 구간 출력: 43-75, 310-376, 1489-1618, 1865-1937, 2250-2312(대상 파일), 테스트·baseline·가드 파일 구간 +- rg -n '심볼 83개 alternation' 대상 파일 → 호출 그래프와 어댑터 호출 행(2213-2400) +- rg -n 'import 식별자 52개 alternation' 대상 파일 → 소비 리프 판별 +- rg -n 'import\(' 대상 파일 → 0건 +- rg -n 'openai-responses\.ts' tests/ → 3건, rg -n 'adapters/openai-responses' tests/ src/ gui/ docs-site/ → 위 목록, rg -o 'openai-responses' tests/ | wc -l → 1156 +- rg -c 'isPlainObject' 대상 파일 → 135, rg -n 'openai-responses' structure/ 및 structure/manifest.json INDEX.md +- git log --oneline -3, git show --stat --oneline 0eab3851a5, ls tests/ci-workflows/, ls devlog/_plan + diff --git a/devlog/_plan/260915_godfile_round5/020_bridge.md b/devlog/_plan/260915_godfile_round5/020_bridge.md new file mode 100644 index 0000000000..629bf4b085 --- /dev/null +++ b/devlog/_plan/260915_godfile_round5/020_bridge.md @@ -0,0 +1,176 @@ +# Godfile Round 5 · wp3 — src/bridge.ts 분해 계약서 + +대상은 `src/bridge.ts` 2,206줄이다(`wc -l` 실측). 방식은 순수 이동 하나다. 함수 본문을 한 줄도 고치지 않고 지정한 라인 범위를 새 리프로 옮기고, 파사드 `src/bridge.ts`는 origin/dev와 동일한 export 6개를 재노출한다. 이 문서의 모든 숫자는 본 워크트리 HEAD에서 rg·sed·awk·wc로 잰 값이며, 측정 명령은 §10에 있다. + +## 1. 이동 확정표 + +| 원본 라인 | 내용 | 줄 수 | 리프 | +| --- | --- | --- | --- | +| 1-45 | import 16개 선언 | 45 | §3대로 리프별 재분배 | +| 47-49 | uuid | 3 | internal | +| 51-56 | 예산 상태(주석 51, let 52, const 53, 세터 54-56) | 6 | sse | +| 58-60 | sseEvent | 3 | sse | +| 62-64 | isRecord | 3 | internal | +| 66-128 | responsesUsage | 63 | internal | +| 130-132 | responseError | 3 | sse | +| 134-150 | toolCallArgumentsUsable(주석 134-139 포함) | 17 | internal | +| 152-169 | adapterFailureFromEvent | 18 | internal | +| 171 | re-export `export { adapterFailureFromMessage } from "./lib/errors";` | 1 | 파사드 원문 유지 | +| 173-197 | webSearchAction(주석 173-193 포함, 닫는 `}` 197) | 25 | internal | +| 199-203 | interface OutputItem | 5 | internal | +| 205 | export type ResponsesTerminalStatus | 1 | sse | +| 207-211 | interface StringChunks(주석 207 포함) | 5 | internal | +| 212 | emptyChunks | 1 | internal | +| 213 | joinChunks | 1 | internal | +| 215-1601 | bridgeToResponsesSSE(끝 `}` 1601, awk 실측) | 1,387 | sse | +| 1603-1617 | buildResponseJSON | 15 | response-json | +| 1619-2180 | buildResponseJSONWithBudget(끝 `}` 2180, awk 실측) | 562 | response-json | +| 2182-2206 | formatErrorResponse | 25 | errors | + +빈 줄 46, 50, 57, 61, 65, 129, 133, 151, 170, 172, 198, 204, 206, 214, 1602, 1618, 2181(17행)은 옮기지 않는다. 합계 검증: 이동 2,143(=1,400+141+577+25) + 파사드 잔류 63(=import 45 + 171행 1 + 빈 줄 17) = 2,206. + +## 2. 심볼 사용처 실측과 배치 + +사용처 수는 구간별 `rg -o '\b<심볼>\b' | wc -l` 카운트고 정의 행을 포함한다. S=215-1601, J=1603-2180, E=2182-2206. 배치 규칙은 "두 리프 이상에서 쓰이면 internal"이다. + +| 심볼 | 정의 | S | J | E | 리프 | +| --- | --- | --- | --- | --- | --- | +| uuid | 47 | 11 | 11 | 0 | internal | +| sseEvent | 58 | 2 | 0 | 0 | sse | +| isRecord | 62 | 0 | 0 | 0 | internal — responsesUsage 본문 전용(정의 구역 매칭 3 = 정의+본문 2) | +| responsesUsage | 66 | 6 | 1 | 0 | internal | +| responseError | 130 | 3 | 0 | 0 | sse | +| toolCallArgumentsUsable | 140 | 1 | 1 | 0 | internal | +| adapterFailureFromEvent | 152 | 2 | 1 | 0 | internal | +| webSearchAction | 194 | 1 | 1 | 0 | internal | +| OutputItem | 199 | 13 | 4 | 0 | internal | +| ResponsesTerminalStatus | 205 | 2 | 0 | 0 | sse | +| StringChunks | 208 | 7 | 3 | 0 | internal | +| emptyChunks | 212 | 8 | 9 | 0 | internal | +| joinChunks | 213 | 6 | 4 | 0 | internal | +| setOwnedBudgetAbandonedMsForTests | 54 | 0 | 0 | 0 | sse — 상태 52·53과 동행, 유일 읽기 340행 | +| formatErrorResponse | 2182 | 0 | 0 | 1 | errors — 파일 내부 사용처 없음, export 전용 | + +제안 골격과 다른 세 결정: sseEvent는 sse 전용이라 sse로 가고, responseError도 sse 전용(S=3, J=0)이다. adapterFailureFromEvent는 리프 두 곳에서 쓰이므로 internal로 보낸다. errors.ts는 formatErrorResponse 하나뿐이다(25줄). + +## 3. 리프별 import(원본 1-45 재분배, ./x → ../x) + +원본 지정자는 전부 ./ 형태다. ../·../../ 케이스는 없고 인라인 동적 import()도 0건이다(rg 실측). 괄호 안은 리프 구역 매칭 수이고 0인 이름은 뺐다. + +| 리프 | 변환 후 지정자 | 이름(실측 매칭 수) | +| --- | --- | --- | +| sse | ../types | AdapterEvent(2) OcxMessagePhase(2) OcxProviderContinuationState(1) OcxProviderOpaqueToolCallMetadata(1) OcxReasoningReplayScopeRef(1) OcxUsage(1) declaresCodeModeExec(1) normalizeDeclaredToolName(1) | +| sse | ../lib/errors | classifyError(1, 131행) isCyberPolicyCode(2) OcxErrorPayload(1, 130행 시그니처) | +| sse | ../lib/redact | redactSecretString(1) | +| sse | ../lib/tool-argument-integers | coerceIntegerToolArguments(1) | +| sse | ../lib/translator-budget | isTranslatorBudgetExceededError(4) createTranslatorBudget(1) TranslatorBudget(1) TranslatorBufferKind(5) | +| sse | ../responses/apply-patch-envelope | mayBecomePatchEnvelope(1) repairFreeformToolInput(1) | +| sse | ../responses/compaction | encodeCompactionSummary(1) | +| sse | ../responses/code-mode-helper-compat | compileCodeModeHelperInput(1) resolveCodeModeHelperName(1) | +| sse | ../responses/truncated-stop-reason | isTruncatedStopReason(3) truncationReasonFor(2) | +| sse | ../responses/reasoning-envelope | encodeReasoningEnvelope(4) ReasoningEnvelope(1) | +| sse | ../responses/reasoning-replay-cache | rememberReasoningForCall(1) | +| sse | ../responses/thought-signature-replay | rememberAndSerializeExtraContent(2) rememberExtraContentForReplay(2) awaitThoughtSignatureDurability(5) | +| sse | ../responses/citation-markers | createCitationMarkerFilter(1) stripCitationMarkers(1) CitationMarkerFilter(1) | +| sse | ../stall-timeout | resolveStallTimeoutSec(1) | +| sse | ../web-search/sources | appendSafeWebSearchSource(1) safeWebSearchSources(1) | +| sse | ./internal | uuid isRecord responsesUsage toolCallArgumentsUsable adapterFailureFromEvent webSearchAction OutputItem StringChunks emptyChunks joinChunks | +| response-json | ../types | AdapterEvent(4) OcxMessagePhase(2) OcxProviderContinuationState(1) OcxProviderOpaqueToolCallMetadata(1) OcxReasoningReplayScopeRef(1) OcxUsage(2) normalizeDeclaredToolName(1) | +| response-json | ../lib/errors | isCyberPolicyCode(1) | +| response-json | ../lib/tool-argument-integers | coerceIntegerToolArguments(1) | +| response-json | ../lib/translator-budget | releaseTranslatedEvent(3) createTranslatorBudget(1) TranslatorBudget(1) TranslatorBufferKind(4) | +| response-json | ../responses/apply-patch-envelope | repairFreeformToolInput(1) | +| response-json | ../responses/compaction | encodeCompactionSummary(1) | +| response-json | ../responses/code-mode-helper-compat | compileCodeModeHelperInput(1) resolveCodeModeHelperName(1) | +| response-json | ../responses/truncated-stop-reason | isTruncatedStopReason(2) truncationReasonFor(1) | +| response-json | ../responses/reasoning-envelope | encodeReasoningEnvelope(3) ReasoningEnvelope(1) | +| response-json | ../responses/reasoning-replay-cache | rememberReasoningForCall(1) | +| response-json | ../responses/thought-signature-replay | rememberAndSerializeExtraContent(1) rememberExtraContentForReplay(1) | +| response-json | ../responses/citation-markers | stripCitationMarkers(1) | +| response-json | ../web-search/sources | appendSafeWebSearchSource(1) safeWebSearchSources(1) | +| response-json | ./internal | uuid responsesUsage toolCallArgumentsUsable adapterFailureFromEvent webSearchAction OutputItem StringChunks emptyChunks joinChunks | +| errors | ../lib/errors | classifyError(1) cyberPolicyErrorType(1) CYBER_POLICY_ERROR_CODE(3) isCyberPolicyCode(1) | +| internal | ../types | AdapterEvent(1, 152행) OcxUsage(1, 66행) | +| internal | ../lib/errors | adapterFailureFromMessage(2) classifyError(1) cyberPolicyErrorType(1) CYBER_POLICY_ERROR_CODE(1) isCyberPolicyCode(2) OcxErrorPayload(1) | +| internal | ../lib/redact | redactSecretString(1) | +| internal | ../usage/totals | usageDisplayTotalTokens(1) | + +빠진 이름은 매칭 0 실측이다. sse는 releaseTranslatedEvent(J 전용)를 가져오지 않고, response-json은 mayBecomePatchEnvelope·awaitThoughtSignatureDurability·resolveStallTimeoutSec·createCitationMarkerFilter·CitationMarkerFilter·declaresCodeModeExec·isTranslatorBudgetExceededError·redactSecretString·classifyError를 가져오지 않는다. + +## 4. sse.ts 크기와 본문 분할 금지 + +sse로 모이는 본문은 1,400줄(6+3+3+1+1,387)이고 import가 더해진다. bridgeToResponsesSSE(215-1601) 본문을 이번 라운드에 쪼개지 않는다. 근거는 순수 이동 원칙과 함수 구조다. 예산 watchdog 지연(340행, ownedBudgetAbandonedMs 읽기), disposeOwnedBudget(334행 정의, 454·929·1518·1563·1576·1598행 호출), 툴 인자 버퍼, web-search 보류 해제가 한 클로저의 지역 상태를 공유하므로 범위를 자르는 순간 상태 재배치가 강제된다. 다음 라운드 참고 수치: S 구간 줄두 let 선언 39개, `\blet\b` 토큰 42개(주석·인라인 포함). 이 상태 경계 분석이 끝난 뒤에 내부 분할을 논의한다. + +## 5. 파사드 최종 형태 + +export 6개 실측 위치: 54(setOwnedBudgetAbandonedMsForTests), 171(adapterFailureFromMessage re-export), 205(ResponsesTerminalStatus), 215(bridgeToResponsesSSE), 1603(buildResponseJSON), 2182(formatErrorResponse). 파사드 `src/bridge.ts`는 아래 6줄만 남긴다. + +```ts +export { setOwnedBudgetAbandonedMsForTests } from "./bridge/sse"; +export type { ResponsesTerminalStatus } from "./bridge/sse"; +export { bridgeToResponsesSSE } from "./bridge/sse"; +export { buildResponseJSON } from "./bridge/response-json"; +export { formatErrorResponse } from "./bridge/errors"; +export { adapterFailureFromMessage } from "./lib/errors"; +``` + +171행은 ./lib/errors 지정자를 그대로 유지한다(파사드 위치가 src/bridge.ts로 불변). internal.ts는 같은 함수를 ../lib/errors에서 직접 가져온다(§3). buildResponseJSONWithBudget는 export가 아니므로 response-json.ts 안에 비공개로 남고 1610·1613행 호출도 같은 파일로 함께 이동한다. ResponsesTerminalStatus는 리프에서 export type으로 선언해 파사드 re-export가 타입 자리를 유지한다(src/server/index.ts:103의 type 수입 실측). + +## 6. 상대 지정자 변환 규칙 + +리프는 src/bridge/ 한 단계 아래에 둔다. 변환은 ./x → ../x가 전부다. 라운드 2 결함(../config가 없는 src/codex/config를 가리켜 샤드 전체 import 단계 실패)의 재발 방지로, 각 리프 저장 직후 §3 지정자와 실제 파일 경로를 한 행씩 대조하는 확인을 실행 라운드가 수행한다. 리프 간 참조는 sse·response-json·errors → ./internal 한 방향이고 internal은 ../lib/*·../types·../usage/totals만 보므로 순환이 없다. + +## 7. structure grace 처리 + +structure/manifest.json 390-391행 실측 인용: + +```json + "path": "src/bridge.ts", + "reason": "no doc names this file; it is the legacy adapter bridge entry and its behavior is described under the adapter registry without a path reference" +``` + +scripts/structure-ssot.ts 규칙(495-535행 실측): grace 경로는 트리에 실재해야 하고(506행 fail), described와 grace 동시 등록이면 fail한다(507-508행). src 영역은 tracked 경로에서 수집되며(515-527행) 파일은 src/<파일>, 디렉터리는 src/<디렉터리>/ 단위다. 어느 쪽에도 없는 영역은 fail한다(530행). + +분해 후 처리. 파사드 src/bridge.ts는 실재하므로 기존 grace 항목은 506행을 통과한다. reason의 "legacy adapter bridge entry"는 사실이 아니게 되므로 facade-only 사실로 갱신한다. src/bridge/는 새 영역이라 530행에 걸리며, grace 등록 대신 소유 문서 documents 목록에 src/bridge/를 추가해 claim한다. 이중 등록은 507-508행 충돌을 낸다. claim 위치는 grace reason이 가리키는 adapter registry 문서이고 INDEX.md 97행 실측 기준 structure/adapters/registry.md가 후보다(manifest docs 배열의 정확한 소유는 실행 라운드가 확인). INDEX.md는 생성물이므로 bun run structure:index 재생성과 structure:check 통과를 실행 라운드 게이트로 남긴다. + +## 8. 동반 수정 + +참조 실측: tests/ src/에서 매칭 83행, 그중 리프 경로 import 29행, src/index.ts:3 파사드 re-export 1행, 나머지 53행은 주석과 픽스처 표기다. 파사드가 export 표면을 유지하므로 import 29행과 re-export는 한 곳도 고치지 않는다. + +재지정이 필요한 두 지점: + +- 소스 오라클 tests/lib/reasoning-replay-scope-source.test.ts. 32행 source("bridge.ts")가 277행(S)과 1642행(J)의 const replayCacheScope = options?.replayCacheScope; 2건을 한 파일에서 센다(34행 toHaveLength(2)). 분해 후 0건이 되어 34행이 실패하고 37행 not.toContain 부정 검사는 아무것도 검사하지 않는다. 재지정: 32행을 source("bridge/sse.ts")와 source("bridge/response-json.ts") 두 읽기로 바꾸고, 34행을 리프당 toHaveLength(1) 두 검사로 쪼개며, 부정 검사는 결합 문자열에 유지한다. +- 픽스처 tests/fixtures/file-size-baseline.json 22행 "src/bridge.ts": 2206. 래칫(scripts/file-size-ratchet.ts)은 SHRANK를 통과시키고(93행 offender는 NEW_OVERSIZED·GREW만, 테스트 87행 "줄면 통과") 베이스라인에 없는 새 파일은 2,000줄 이상일 때만 NEW_OVERSIZED다. 리프 이동분 최대인 sse 1,400+import는 2,000 미만이라 새 베이스라인 행이 필요 없고, 파사드 급감은 SHRANK로 통과하며 --update는 캡을 내리기만 한다. + +주석 경로 표기 9곳은 빌드 영향이 없고 같은 PR에서 갱신한다. responsesUsage 지칭 3곳(src/chat/outbound.ts:51, src/server/request-log.ts:156, src/usage/log.ts:87)은 src/bridge/internal.ts로, 스트리밍 동작 지칭 3곳(src/web-search/passthrough-bridge.ts:1007, src/server/responses/core.ts:5565, src/server/responses-custom-tool-repair.ts:343)은 src/bridge/sse.ts로, declaredToolNames 옵션 지칭 2곳(src/server/responses-undeclared-tool-guard.ts:619, tests/responses/responses-undeclared-tool-guard.test.ts:5)은 양쪽 리프에 계약이 있으므로(sse 6건·response-json 5건 실측) 두 경로를 함께 적고, 일반 지칭 1곳(src/server/responses-snapshot-repair.ts:10)은 파사드 또는 리프 표기로 바꾼다. + +## 9. 검증 게이트(실행 라운드) + +이 문서 단계에서는 bun과 테스트를 실행하지 않았다(위임 범위 규칙). 실행 라운드 게이트: bun run structure:check, bun test tests/lib/reasoning-replay-scope-source.test.ts tests/ci-workflows/file-size-ratchet.test.ts tests/adapters/bridge.test.ts, bun run test:changed, PR 준비 시 bun run typecheck과 bun run test. + +## 10. 측정 명령 + +``` +wc -l src/bridge.ts +sed -n '1,214p' src/bridge.ts +rg -n '^(export )?(async )?function |^(export )?(type|interface) |^(export )?const ' src/bridge.ts +rg -n '^export' src/bridge.ts +awk 'NR>=1599&&NR<=1604{print NR": "$0}' src/bridge.ts +awk 'NR>=1615&&NR<=1620{print NR": "$0}' src/bridge.ts +awk 'NR>=2178&&NR<=2183{print NR": "$0}' src/bridge.ts +sed -n '<구간>' src/bridge.ts | rg -o '\b<심볼>\b' | wc -l + # 구간: 47-49, 51-60, 62-128, 130-132, 134-169, 173-213, 205, 215-1601, 1603-2180, 2182-2206 + # 심볼: §2 표 15개 + ownedBudgetAbandonedMs + declaredToolNames + import 이름 41개(§3) +awk 'NR>=215&&NR<=1601' src/bridge.ts | rg -c '^\s*let\b' +awk 'NR>=215&&NR<=1601' src/bridge.ts | rg -o '\blet\b' | wc -l +rg -n 'import\(' src/bridge.ts +rg -n 'ownedBudgetAbandonedMs|disposeOwnedBudget|setOwnedBudgetAbandonedMsForTests' src/bridge.ts +rg -n 'replayCacheScope' src/bridge.ts +rg -n 'from "(\.\.?/)+bridge"|src/bridge' tests/ src/ | wc -l +rg -n 'import .* from "(\.\.?/)+bridge"' tests/ src/ | wc -l +rg -n 'bridge\.ts' tests/ +awk 'NR>=386&&NR<=396{print NR": "$0}' structure/manifest.json +sed -n '495,535p' scripts/structure-ssot.ts +sed -n '1,90p' scripts/file-size-ratchet.ts +rg -n 'SHRANK|GREW|NEW_OVERSIZED' tests/ci-workflows/file-size-ratchet.test.ts +``` diff --git a/devlog/_plan/260915_godfile_round5/030_activation_guard.md b/devlog/_plan/260915_godfile_round5/030_activation_guard.md new file mode 100644 index 0000000000..27bea3dc14 --- /dev/null +++ b/devlog/_plan/260915_godfile_round5/030_activation_guard.md @@ -0,0 +1,198 @@ +# 030 — 활성화 가드 재설계: startServer 동기 도달 경로 전수 검사 (wp4) + +단위 산출물은 tests/lab/core-lab-boundary.test.ts(435행) 하나고, 여기에 startServer 동기 도달 +경로 검사를 추가한다. 모든 숫자는 2026-09-15 워크트리(codex/godfile-r5-a-openai-responses)에서 +rg/awk/cat/wc로 재측정한 값이다. 히스토리가 6커밋으로 절단돼 커밋 빈도 논거는 쓰지 않는다. + +## 1. 현재 가드의 기계 + +tests/lab/core-lab-boundary.test.ts의 describe `activation window stays synchronous`(353-434)는 +네 검사로 구성된다. + +| 검사 | 테스트 라인 | 판정 입력 | +|---|---|---| +| startServer 비-async 선언 | 357-367 | index.ts 선언부 정규식 | +| 창 안 body-level await 부재 | 369-389 | index.ts 창 텍스트 | +| 블랭킹·중첩 자기공격 | 391-420 | 합성 문자열 | +| 실창 tolerance 고정 | 422-433 | server.stop 클로저 | + +앵커 상수 세 개의 실제 문자열과 src/server/index.ts(3,400행) 안 현재 위치다. + +| 상수 | 실제 문자열 | index.ts 위치 | +|---|---|---| +| SERVE_ANCHOR(테스트 139) | `server = Bun.serve({ ...serveOptions, port: listenPort, hostname: bindHost });` | 3222 | +| ACTIVATION_ANCHOR(테스트 140) | `if (labActivationRequired(config, labConfigDir)) {` | 3386 | +| RETURN_ANCHOR(테스트 150) | 앞 공백 2개 + `return server;` | 3399 | + +검사 범위는 source.slice(start, end)(384)다. start는 indexOf(SERVE_ANCHOR), end는 +indexOf(RETURN_ANCHOR, start)(370-371)이므로 실질 창은 3222행부터 3399행 직전까지 177행이다. +창 붕괴 알람이 있다: start 미발견은 376, end 역전은 377, activation이 창 밖이면 381-382에서 적색. + +주석·문자열 blanking은 blankCommentsAndStrings(165-217)가 담당한다. 개행만 보존하고 나머지는 공백 +치환(171)이라 보고 라인 번호가 살아 있다. 템플릿은 모드 스택(168)으로 처리하고 보간 진입(178-182)과 +종료(206-209)에서만 코드로 복귀하며, 문자열(195-203)과 행주석(185-188), 블록주석(189-193)도 블랭킹한다. + +await 검출은 정규식이 아니라 토큰 스캔이다(bodyLevelAwaitLines, 253-273). 263행에서 slice(i, i+5)가 +"await"인지 보고 264-266행에서 앞뒤 단어 경계를 검사하고, 268행에서 점 접두를 제외해 속성 접근 +(thing.await())을 거른다. 중첩 함수 제외는 두 부품으로 된다. opensFunctionBody(225-243)가 '{'가 함수 +몸체인지 판정하고(228행 화살표, 242행 키워드 제외 if/for/while/switch/catch/do/with), 261-262행이 그 +결과를 스택에 넣고 빼며, 269행이 stack.some으로 함수 몸체 안 await를 버린다. try/if/for 블록은 함수 +몸체가 아니므로 그 안 await는 잡힌다(자기공격 407-411, for-await 412가 고정). + +startServer async 선언 검사는 363행 정규식이고 364-366행이 정확히 "export function startServer(" +를 요구한다. 현재 선언은 src/server/index.ts 1006행이다. + +소스 오라클 드리프트 실례가 이미 있다. 가드 주석(157-158)은 창 안 prose-await 위치를 index.ts:1853, +:1950이라 적지만 실측은 3237, 3384다. 주석이 흘러도 판정은 무변경이므로 새 가드의 판정 입력에 +"문서 속 라인"을 쓰지 않고 근거 서술로만 남긴다. + +## 2. 우회 경로 — 가드가 보지 않는 절반 + +가드가 읽는 것은 index.ts 텍스트의 3222-3399 슬라이스뿐이다. 판정 명제는 "창 텍스트에 await 토큰이 +없다"이지 "창에서 호출된 함수가 동기로 끝난다"가 아니다. 창 3222-3399의 호출 토큰은 uniq 기준 +30종이다. 이 중 hardenConfigDir(1회, 3307 주석)와 stop 1회(3308 주석)는 blanking 대상 텍스트다. +코드 위치 호출은 다음과 같이 나뉜다. + +| 분류 | 이름과 위치 | 수 | +|---|---|---| +| 동기 자유 함수 호출 | bindNativeMainStartupLifecycle 3273, setServerRef 3317, setCorsOrigin 3320, isCanonicalOpenAiForwardProvider 3361, providerCodexAccountMode 3362, getConfigDir 3385, labActivationRequired 3386, activateLab 3387, activateResetCreditAutoRedeem 3393, createResetCreditWhamClient 3395 | 10 | +| 생성자 | AuxiliaryListenerBindError 3245, 3262 | 2 | +| 수신자 메서드(동기 위치) | server.stop 3241, bound.stop 3260, unregisterQuotaAutoRefresh?. 3266, userCostOverlayReconciler?.stop 3267, backgroundLifecycle?.releaseAfterFailedStart 3268, nativeMainLifecycle.release 3269, backgroundLifecycle.scheduleStartupRun 3378 | 7 | +| Bun API | Bun.serve 3222/3230/3250, Object.defineProperty 3277 | 4 | +| 클로저·콜백 안 | server.stop 클로저(3277-3316)의 runListenerShutdown 3284, backgroundLifecycle.release 3304, releaseNativeMainStartupLifecycle 3305, flushConfigDirHardening 3311, then 체인(3364-3374)의 reconcileCodexPlansFromTokens 3367, primeCodexPoolQuotas 3373 | 미일괄 계수 | + +우회 시나리오 A — 피호출자 변경(오늘도 성립). src/lib/lab-activation.ts 167행의 activateLab을 +async로 고치고 몸체에 await를 넣어도 index.ts는 한 글자도 변하지 않는다. 앵커 세 개 제자리 +(376-382 통과), 창 await 0(388 통과)이라 가드는 녹색이다. 그러나 3387행 활성화가 다음 턴으로 +밀리고 AGENTS.md(383행, 절 50-93)의 76-79행이 말하는 "policy route가 evidence provider 등록 전에 +평가될 수 없다"는 보장이 깨진다. + +우회 시나리오 B — wp5가 만드는 형태. 활성화 블록 3385-3388을 src/server/index/ 아래 리프로 옮기고 +창에는 호출 한 줄만 남기면, 창 텍스트는 호출 토큰만 남고 리프 안 await는 영원히 미검사다. 단, +블록을 통째로 빼서 ACTIVATION_ANCHOR 문자열이 index.ts에서 사라지면 381-382가 적색이 된다. 기존 +가드는 "블록 완전 이탈"은 잡지만 "블록 유지 + 호출 대상 변경"은 못 잡는다. + +wp5 연결. wp5는 index.ts를 src/server/index/ 아래 리프로 쪼갠다. 쪼개는 순간 창은 호출 목록이 +되고 AGENTS.md 50-93의 불변식은 텍스트상으로만 남는다. 창에서 도달하는 함수 전부를 검사하는 가드가 +먼저 없으면 wp5는 불변식을 실질 폐기하는 변경이 된다. 이것이 이 단위가 wp5 선행인 이유다. + +## 3. 재설계 명세 + +수집 범위는 창이 아니라 startServer 몸체 전체(index.ts 1006-3400)다. 창 밖 동기 호출(getConfigDir +1010, setCorsOrigin 1109, providerCodexAccountMode 2042)도 활성화 순서의 일부다. + +(1) 수집. index.ts를 blankCommentsAndStrings로 블랭킹하고 1006행 선언부터 짝 괄호 매칭으로 몸체를 +추출한다. bodyLevelAwaitLines와 같은 스택 기법(261-262)으로 동기 위치의 `식별자(` 토큰을 모은다. +'.'/'?.' 접두 호출은 수집에서 제외하고 allowlist 대상으로 분류한다. 키워드 제외는 242행 목록에 +function/return/new 등을 더한 집합을 쓴다. + +(2) 정의 해석. 식별자마다 같은 파일에서 async 표지와 function/class/const 선언을 이름으로 찾고, +없으면 그 파일의 import를 따라간다. import 추출은 Bun.Transpiler.scanImports, 경로 해석은 기존 +resolveSpec(52-59)을 쓴다. re-export 추적이 필수다. 실측 체인 두 개: index.ts 72행은 +../providers/openai-tiers에서 가져오고 openai-tiers.ts 6행의 export 문을 타서 +openai-tiers-destination.ts 22행 정의에 닿는다. index.ts 48행은 ../codex/auth-api에서 가져오고 +auth-api.ts 34행을 타서 reset-credit-service.ts 124행 정의에 닿는다. 방문 집합과 깊이 상한 +(현재 최대 간선 2, 상한 8)으로 순환을 끊는다. + +(3) 단언. 수집된 함수마다 선언에 async가 없고 bodyLevelAwaitLines(몸체)가 빈 배열임을 단언한다. +동적 import는 따라가지 않는다. firstLabPath가 지연 간선으로 취급한 근거(75-80행 주석)와 같고, 실제 +then 체인 3364-3374는 모두 콜백 안이라 (4)에서 이미 제외된다. + +(4) 제외. 중첩 함수 몸체는 (1)의 스택 규칙이 자동으로 거른다. 실측 대상: server.stop 재정의 +클로저 3277-3316(테스트 422-433이 tolerance로 고정한 await 3284, 3304, 3311 포함), then 체인 +3364-3374. + +(5) allowlist. 테스트 파일 상단에 이름 기반 상수로 박는다. 라인 기반이면 wp5 이동 때 깨지므로 +이름과 근거를 기록한다. 양방향 검사를 강제한다. 수집됐는데 미등록이면 적색, 등록됐는데 수집되지 +않으면 적색(422-433 tolerance 고정과 같은 부식 방지). + +allowlist 초기 항목 후보(실측 근거): + +| 이름 | 위치 | 근거 | +|---|---|---| +| server.stop / bound.stop | 3241, 3260 | 보조 리스너 바인드 롤백. Bun Server API라 프로젝트 코드가 아니다 | +| unregisterQuotaAutoRefresh?.() | 3266 | StartServerDeps 계약 메서드. 텍스트 워커로 구현을 해석할 수 없다 | +| userCostOverlayReconciler?.stop | 3267 | 동일 | +| backgroundLifecycle?.releaseAfterFailedStart | 3268 | 동일 | +| nativeMainLifecycle.release | 3269 | 동일 | +| backgroundLifecycle.scheduleStartupRun | 3378 | 동일. "Never blocks listen; cancellable on shutdown"라는 동기성 주장은 3377행 주석뿐이다 | + +생성자 AuxiliaryListenerBindError(3245, 3262)는 allowlist에 넣지 않는다. ports.ts 4-18행 생성자는 +async일 수 없고 await도 없다(ports.ts 파일 전체 await 7회는 모두 43행 이후 비동기 함수 안). 참고로 +수집 대상 정의 파일의 await 분포 실측: reset-credit-auto-redeem.ts 4, native-profile-startup.ts 16, +reset-credit-service.ts 23, paths.ts 2, ports.ts 7, lab-activation.ts·auth-cors.ts· +openai-tiers-destination.ts·registry.ts 0(rg -c 무출력). + +## 4. 재사용 판정 + +같은 파일에서 바로 재사용: IMPORT_RE(50), resolveSpec(52-59), blankCommentsAndStrings(165-217), +opensFunctionBody(225-243), bodyLevelAwaitLines(253-273). namesLabDirectly(115-121)는 용도가 다르다. +firstLabPath(62-102)는 목적이 /src/lab/ 도달 여부(87행)라 정의 해석에는 못 쓰지만 BFS 골격 +(64-66 큐, 83 이전노드 지도)은 수집 워커 템플릿이 된다. + +Bun.Transpiler 재사용 가능. 실측 사용처 세 곳: tests/responses/responses-fetch-helpers-boundary.test.ts +17/34행(scanImports), tests/providers/api-key-selection-capture.test.ts 52행, +tests/clients/sync-client-integrations.test.ts 681행. scanImports는 지정자 목록만 준다(fetch-helpers +테스트 28-35행 인터페이스 실측)이라 심볼 정의 조회는 별도 텍스트 검색이 필요하다. IMPORT_RE 대신 +scanImports를 쓰는 이유: IMPORT_RE의 한정자는 주석이 낀 import에서 오검출 여지가 있고 scanImports는 +로더 기반이라 그렇지 않다. + +골격: + +```ts +const importTranspiler = new Bun.Transpiler({ loader: "ts" }); +// (1) 수집: startServer 몸체 동기 위치 호출 식별자(bodyLevelAwaitLines와 동일 스택) +function collectSyncCalls(fnBody: string): string[] { /* ... */ } +// (2) 해석: scanImports -> resolveSpec -> re-export 추적 +function resolveExportedDecl(name: string, file: string, seen: Set): Decl | null { + const text = readFileSync(file, "utf8"); + const local = new RegExp(String.raw`export\\s+(async\\s+)?(function|class|const)\\s+${name}\\b`) + .exec(blankCommentsAndStrings(text)); + if (local) return { file, body: text, isAsync: local[1] !== undefined }; + for (const spec of importTranspiler.scanImports(text).map(i => i.path)) { + const next = resolveSpec(spec, file); + if (!next || seen.has(next)) continue; + seen.add(next); + const found = resolveExportedDecl(name, next, seen); + if (found) return found; + } + return null; +} +// (3) 단언 +expect(decl.isAsync).toBe(false); +expect(bodyLevelAwaitLines(extractBody(decl.body, declOpenBraceOffset))).toEqual([]); +``` + +## 5. 비-vacuous 증명 절차 + +wp4 구현 시점에 순서대로 실행한다(이 문서 작성 시점에는 bun test 금지 규칙이 적용돼 미실행). + +1. 베이스라인: `bun test tests/lab/core-lab-boundary.test.ts` 녹색 확인. +2. 적색 구성: src/lib/lab-activation.ts 167행 선언을 `export async function activateLab(`로 고치고 + 열는 중괄호 다음 줄에 `await Promise.resolve();`를 넣는다(편집 2개). 실측 원선언: + `export function activateLab(config: OcxConfig, configDir?: string): void {` +3. 예상: 기존 창 검사 369-389, 391-420, 422-433은 녹색(앵커 무변경, 창 await 0). 새 도달가능성 검사만 + 적색 — activateLab이 수집 클로저에 들어가 async 선언과 몸체 await에 걸린다. +4. 되돌림: 167행 복원, 삽입 행 삭제. `git diff --stat`으로 잔여 변경 없음 확인 후 폐기. + +이 케이스가 "창 스캔 시절 잡히지 않던 것(피호출자 변경)을 새 가드가 잡는다"의 최소 증명이다. 보조로 +guard-on-guard describe(300-351 패턴)에 합성 모듈 공격을 추가한다. 임시 리프에 `export async function +probeStartupStep(): Promise { await Promise.resolve(); }`를 쓰고 startServer가 호출하는 것으로 +기록했을 때 수집기가 잡는지 단언하고, finally에서 rmSync한다(309-317 패턴). + +소스 오라클 목록. 텍스트로 server/index.ts를 읽는 tests/ 파일은 rg -l 기준 17개다(16개 테스트 + +tests/fixtures/file-size-baseline.json). 이 단위와 직접 관련된 것은 core-lab-boundary.test.ts +354-355행(readFileSync indexPath)과 369-389행 창 슬라이스다. wp5가 앵커 세 줄(3222, 3386, 3399)을 +리프로 옮기면 376-382가 적색으로 잡히니, wp5 설계는 앵커를 index.ts에 남기거나 검증을 새 가드로 옮겨야 한다. + +## 6. 유지/대체 판정 + +기존 가드를 지우지 말고 새 검사를 추가한다. + +근거. 앵커 위치 단언(376-382)은 활성화 블록의 완전 이탈을 잡는 유일한 검사다. 도달가능성 워커는 +호출 토큰이 사라지면 수집할 것 자체가 없어 조용해진다. 창 스캔은 wp5 이후에도 index.ts에 직접 쓰인 +await를 잡는다. 자기공격 스위트(391-420)가 blanking과 중첩 제외를 고정하는데 새 워커가 같은 부품을 +재사용하므로 이 고정이 그대로 유효하다. 대체 시 손실은 구조 알람인데 유지 비용은 텍스트 스캔 하나다. + +startServer 비-async 단언(357-367)도 남긴다. 도달가능성 검사가 startServer를 루트로 다루면 이론상 +흡수되지만, 독립 문장이 실패 메시지를 정확히 유지한다. diff --git a/devlog/_plan/260915_godfile_round5/040_server_index.md b/devlog/_plan/260915_godfile_round5/040_server_index.md new file mode 100644 index 0000000000..71d58bcad3 --- /dev/null +++ b/devlog/_plan/260915_godfile_round5/040_server_index.md @@ -0,0 +1,188 @@ +# 040 — wp5: `src/server/index.ts` 분해 계약서 + +대상 파일은 이 워크트리 HEAD 기준 3,400줄이다(`wc -l` 실측). 측정 도구는 `wc`, `awk`, `rg`, `sed`뿐이다. 워크트리에 `node_modules`가 없어 bun 계열 명령은 실행하지 않았고, 본 문서의 모든 줄 수·개수는 실행한 명령 출력에서 왔다. 추측·기억·반올림으로 쓴 숫자는 없고, 확인하지 못한 항목은 "미측정"으로 표기했다. + +블록의 "끝"은 별도 언급이 없는 한 다음 최상위 문장 시작 줄 − 1이며 빈 줄과 주석을 포함한다. 마지막 블록은 파일 끝(3,400)까지다. 이 규칙 아래 모든 블록 줄수의 합은 3,400과 정확히 일치한다(1절 검산). 순수 이동 시 블록 끝의 빈 줄이 함께 옮겨져도 파사드 합산은 이 규칙으로 보정된다. + +## 1. 최상위 심볼 인벤토리 + +헤더 1-266은 import 문과 재-export 문이 섞인 구간이다. `awk`가 잡은 최상위 `export` 문은 92-96(routing), 101(gui-static), 102(adapter-resolve), 116, 139, 159, 188, 198(responses)로 8개이고, 나머지는 import 문이다. 92-101 구간 실측: 92-96 export 블록, 97-100 import 4개, 101 export 1개. 199-265은 import 37개, 266은 빈 줄이다. + +| 심볼 | 시작 | 끝 | 줄수 | export | +|---|---|---|---|---| +| (헤더: import·재-export 혼재) | 1 | 266 | 266 | 재-export 8개 | +| MAX_WS_FRAME_BYTES | 267 | 267 | 1 | O | +| WEBSOCKET_IDLE_TIMEOUT_SECONDS | 268 | 271 | 4 | X | +| REMOTE_CATALOG_KEY_ID_PATTERN | 272 | 272 | 1 | X | +| GUI_PAIRING_EXCHANGE_BODY_LIMIT | 273 | 273 | 1 | X | +| REMOTE_WORKSPACE_PAIRING_BODY_LIMIT | 274 | 286 | 13 | X | +| readBoundedRequestText | 287 | 327 | 41 | X | +| withRemoteCatalogKeyId | 328 | 337 | 10 | X | +| LIVE_SIDEBAND_PENDING_MAX | 338 | 338 | 1 | X | +| LIVE_SIDEBAND_PENDING_BYTES_MAX | 339 | 339 | 1 | X | +| LIVE_SIDEBAND_CLOSE_FALLBACK_MS | 340 | 344 | 5 | X | +| LIVE_SIDEBAND_UPSTREAM_OPEN_TIMEOUT_MS | 345 | 355 | 11 | O | +| LiveSidebandUpstreamOpenResult | 356 | 364 | 9 | O(type) | +| exceedsLiveSidebandFrameByteLimit | 365 | 368 | 4 | O | +| exceedsLiveSidebandPendingByteLimit | 369 | 372 | 4 | O | +| webSocketFrameBytes | 373 | 378 | 6 | X | +| LiveSidebandPendingEnqueueResult | 379 | 380 | 2 | O(type) | +| enqueueLiveSidebandPendingFrame | 381 | 394 | 14 | O | +| LiveSidebandWebSocketFactory | 395 | 400 | 6 | X(type) | +| releaseLiveSidebandAdmission | 401 | 413 | 13 | X | +| sendUpstreamFrame | 414 | 421 | 8 | X | +| finalizeLiveSideband | 422 | 448 | 27 | X | +| armLiveSidebandCloseFallback | 449 | 475 | 27 | X | +| closeLiveSidebandBeforeUpgrade | 476 | 517 | 42 | X | +| closeLiveSideband | 518 | 563 | 46 | X | +| openLiveSidebandUpstream | 564 | 711 | 148 | O | +| attachLiveSidebandUpstream | 712 | 889 | 178 | O | +| REQUEST_LOG_ID_RESPONSE_HEADER | 890 | 891 | 2 | X | +| withRequestLogId | 892 | 920 | 29 | X | +| StartServerDeps | 921 | 943 | 23 | O(interface) | +| inspectStartupOwnership | 944 | 980 | 37 | X | +| startupCacheInvalidationWrote | 981 | 983 | 3 | X | +| consumeStartupCacheInvalidationWrite | 984 | 989 | 6 | O | +| warnAgentTaskRecoveryStartup | 990 | 998 | 9 | O | +| warnPlaintextV2AgentMessagesStartup | 999 | 1005 | 7 | O | +| startServer | 1006 | 3400 | 2395 | O | + +검산: 266 + (1+4+1+1+13+41+10+1+1+5+11+9+4+4+6+2+14+6+13+8+27+27+42+46+148+178+2+29+23+37+3+6+9+7+2395) = 266 + 3134 = 3,400. + +export 문은 22개다: 92, 101, 102, 116, 139, 159, 188, 198, 267, 345, 356, 365, 369, 379, 381, 564, 712, 921, 984, 990, 999, 1006. 앵커와 일치한다. 분해 후에도 이 22개 이름이 파사드에서 같은 의미로 보여야 하고, 이름 추가·삭제·변경은 없다. + +## 2. 산술 — startServer 내부를 잘라야 한다 + +startServer는 1006-3400, 2,395줄이다. 함수 밖은 1-1005, 1,005줄이다. + +함수 밖을 전부 옮겨도 파사드는 헤더 266줄(startServer가 그 import를 그대로 쓴다)에 startServer 2,395줄을 더한 2,661줄이 된다. 옮긴 export의 재-export 대체 라인이 몇 줄 더 붙는다. + +2,661 > 1,900이라 목표 도달이 불가능하다. startServer 본문 1006-3400에서 블록을 뽑는 것이 필수다. + +## 3. startServer 내부 구조 (1006-3400) + +### 3.1 앵커와 동기 창 + +측정된 앵커 세 개: Bun.serve 호출 3222(`server = Bun.serve({ ...serveOptions, port: listenPort, hostname: bindHost });`), lab 체크 3386(`if (labActivationRequired(config, labConfigDir)) {`, 준비 3385, 호출 3387), `return server;` 3399. + +동기 창은 3222-3399(178줄)이고 창 밖 준비부는 1006-3221(2,216줄)이다. 창 안에는 server.stop 롤백 클로저(3241)와 보조 바인드(3230, 3250), nativeStop 결선(3274), 포트 로그(3318), lab 활성화(3385-3388), reset-credit 활성화(3391-3395)가 있다. + +### 3.2 serveOptions 해부 + +`serveOptions` 리터럴은 1481-3220(1,740줄)이다. 구성은 키 2개와 주석 1483-1485(3줄), fetch 핸들러 1487-2975(1,489줄), websocket 핸들러 2976-3219(244줄)다. fetch 본문은 파일에서 가장 큰 단일 블록이다. + +### 3.3 판정 — fetch는 최대 블록이지만 순수 이동 대상이 아니다 + +근거는 라이브 바인딩 네 곳이다. `boundPort`(let, 1343 선언, 3319 배정)를 1543, 1782, 1801에서 읽고 `server`(let, 1426 선언, 3222 배정)를 1740에서 읽는다. serveOptions는 3222보다 앞서 만들어지므로 빌드 시점에 두 let은 아직 배정 전이고, 값을 파라미터로 넘기면 undefined가 고정된다. + +1740의 `server.port`는 /healthz 응답용이다. /healthz는 보조 리스너에서 차단된다(loopbackRouteAllowed 1191-1223에 /healthz가 없고, managementIngressRouteAllowed는 1246-1250에서 명시 거부). 그래서 `requestServer.port` 1줄 대체는 도달 경로가 동일하다. boundPort 세 곳은 게터 접근으로만 동일 의미가 유지된다. 네 줄 모두 본문 수정이므로 순수 이동 원칙의 예외가 필요하고, 승인은 부모의 몫이다. + +`server`의 나머지 7건 일치는 주석(1722, 1725, 1857, 1954, 2253)과 "server busy" 문자열(2999, 3006)이다(rg -w 실측). websocket 블록 2976-3219의 `server` 일치 2건도 문자열이다. + +"라우트 핸들러 본문은 fetch 콜백 안이라 동기 보장 대상이 아니다"는 관찰은 맞다(중첩 함수 무시 규칙, tests/lab/core-lab-boundary.test.ts:396-419 실측). 그러나 그것이 안전하게 뽑아낼 수 있는 가장 큰 덩어리라는 뜻은 아니다. 라이브 바인딩 네 곳과 7절의 텍스트 오라클 네 종이 fetch·websocket 본문을 붙들고 있다. + +### 3.4 창 밖에서 순수 이동 가능한 최대 블록 + +1191-1330(140줄)다. 여섯 함수가 있다: loopbackRouteAllowed 1191-1223, managementIngressRouteAllowed 1231-1253, drainingResponse 1262-1270, serverBusyResponse 1272-1279, packageTreeChangedResponse 1281-1288, runAdmittedHttpTurn 1290-1330. + +이들이 startServer 지역을 붙잡는 곳은 managementIngressRouteAllowed의 `config`(1234, 1236) 하나뿐이다. 나머지 자유 식별자는 전부 import 공급이다. + +1331-1480은 이동 불가 상태다: readinessGate(1337), packageTreeIntegrity(1338), boundPort(1343), nativeOwnership(1351), preparedNativeMainLifecycle(1357), retry 변수군(1364-1366), reprobeNativeOwnership(1367), ownershipRetryOptions(1393), nativeMainLifecycle(1403), server 계열 let(1426-1428), inboundBodyLimitBytes(1434), ingressForServer(1447), backgroundLifecycle(1452), managementApiDeps(1456), loadRemoteWorkspaceRuntime(1462-1471)이 서로를 참조하는 클로저 상태다. + +## 4. 리프 배치 — 순수 이동분 + +| 리프 | 원본 범위 | 줄수 | 파사드 잔류물 | +|---|---|---|---| +| src/server/index/bounded-request.ts | 272-337 | 66 | `import { GUI_PAIRING_EXCHANGE_BODY_LIMIT, REMOTE_WORKSPACE_PAIRING_BODY_LIMIT, readBoundedRequestText, withRemoteCatalogKeyId } from "./index/bounded-request"` — 사용처 1621, 1624, 1908, 2918, 2921 | +| src/server/index/live-sideband.ts | 338-889 | 552 | `export { LIVE_SIDEBAND_UPSTREAM_OPEN_TIMEOUT_MS, type LiveSidebandUpstreamOpenResult, exceedsLiveSidebandFrameByteLimit, exceedsLiveSidebandPendingByteLimit, type LiveSidebandPendingEnqueueResult, enqueueLiveSidebandPendingFrame, openLiveSidebandUpstream, attachLiveSidebandUpstream } from "./index/live-sideband"` — 이름 8개, 표면 동일 | +| src/server/index/startup-warnings.ts | 890-1005 | 116 | `import { withRequestLogId, inspectStartupOwnership }`(사용처 2585, 1062, 1351, 1383) + `export { type StartServerDeps, consumeStartupCacheInvalidationWrite, warnAgentTaskRecoveryStartup, warnPlaintextV2AgentMessagesStartup } from "./index/startup-warnings"` | +| src/server/index/route-guards.ts | 1191-1330 | 140 | `export function createRouteGuards(config: RequestPolicyView) { /* 1191-1330 원본 그대로 */ return { loopbackRouteAllowed, managementIngressRouteAllowed, drainingResponse, serverBusyResponse, packageTreeChangedResponse, runAdmittedHttpTurn }; }` — 파사드는 원래 1191 위치에서 구조 분해 1줄 | + +리프 의존 방향은 파사드 → 네 리프, startup-warnings → live-sideband(타입 1개)뿐이다. route-guards와 bounded-request는 리프 간 의존이 없다. 순환 import는 만들지 않는다. + +### 4.1 import 지정자 재작성 규칙 + +리프는 `src/server/index/` 한 단계 아래다. `./x`는 `../x`로, `../x`는 `../../x`로, `../../x`는 `../../../x`로 바꾼다. 라운드 2의 결함(`../config`가 없는 `src/codex/config`를 가리켜 샤드 전체가 import 단계에서 사망)이 정확히 이 규칙 위반이었다. + +실측 예시: 17행 `from "./ws-bridge"` → `from "../ws-bridge"`(WsData, LiveSidebandUpstreamFailure, LiveSidebandUpstreamHandoff), 27행 `from "../config"` → `from "../../config"`, 1행 `from "../remote-control/workspace-activation"` → `from "../../remote-control/workspace-activation"`. + +### 4.2 리프별 공급 식별자 + +- live-sideband 리프: WsData·LiveSidebandUpstreamFailure·LiveSidebandUpstreamHandoff(`../ws-bridge`), Server·ServerWebSocket(18행, bun). +- startup-warnings 리프: StartServerDeps가 참조하는 LiveSidebandWebSocketFactory를 `"./live-sideband"`에서 가져온다(공급 395-400). currentServiceHomes·inspectNativeCodexOwnership·OwnershipInspection·createWindowsTaskListingCache는 36행·40-44행 공급 모듈을 ../..로 가져온다. +- route-guards 리프: contextEndpoint(225행 공급), remoteWorkspaceEnabled(1행)은 측정됐다. RequestPolicyView와 tryAdmitTurn·sessionLaneIdFromRequest·admitWorkflowTurn·workflowRefusalResponse·withCors·formatErrorResponse·corsHeaders·serveGuiFile의 공급 모듈은 미측정이다 — 원본 1-265 import 블록에서 같은 식별자를 찾아 ../.. 규칙을 적용한다. +- bounded-request 리프: DataPlaneAdmission 타입의 공급 모듈은 미측정이다 — 같은 규칙을 적용한다. Request는 전역 타입이다. + +### 4.3 동적 import + +이동 범위 272-1330에는 `import(`가 없다. 파일 전체 동적 import는 19곳이다: 1461, 1463(startServer 준비부), 1590-2465(fetch 본문 15곳), 3364, 3371(창). 전부 이동 범위 밖이다. + +## 5. 파사드 목표 검산 + +- 순수 이동만(리프 4개): 3,400 − (66+552+116+140) = 2,526, 리프 import·재-export 추가 약 8줄 → 약 2,534. 목표 1,900 미달, 차이 약 634. +- route-guards를 뺀 세 리프만(테스트 무수정): 3,400 − 734 = 2,666 + 약 6 → 약 2,672. +- startServer 안에 순수 이동 가능한 나머지는 없다(1331-1480은 상태 클로저, 3222-3399는 창). + +1,900의 유일한 지렛대는 serveOptions 리프다. 1543·1782·1801·1740 네 줄 본문 수정을 승인하면 2,526 − 1,740 = 786에 buildServeOptions import 1줄과 호출부(deps 객체 — 측정된 캡처 21개에 게터 2개, 예상 약 26줄)를 더해 약 813이 된다. 이때 리프 합계는 66+552+116+140+1,740 = 2,614이다. + +serveOptions 캡처 21개(`uniq -c` 실측): config, drainingResponse, runAdmittedHttpTurn, managementAuth, listenPort, remoteWorkspaceStopping, boundPort(라이브), serverBusyResponse, loopbackPolicy, localAttestationSecret, loadRemoteWorkspaceRuntime, liveCallBindings, readinessGate, packageTreeIntegrity, packageTreeChangedResponse, managementSessionControl, managementIngressRouteAllowed, managementApiDeps, loopbackRouteAllowed, ingressForServer, inboundBodyLimitBytes. 라이브 바인딩은 boundPort와 server(1740) 둘뿐이다. websocket 블록 단독 캡처는 config뿐이다(2976-3219 스캔 실측). + +## 6. 동기 보장 제약 + +창 3222-3399의 코드는 리프로 옮기지 않는다. 테스트가 파사드 텍스트에서 앵커 세 개를 찾고(SERVE·ACTIVATION·RETURN 문자열, tests/lab/core-lab-boundary.test.ts:139-150 정의 실측), 본문 레벨 await을 금지한다(같은 파일 369-389 실측). 중첩 함수의 await은 무시된다(396-419 실측). + +lab 코드 — import 67행, 주석 3377-3384, 활성화 3385-3388 — 는 컴포지션 루트 의무라 파사드에 남는다. 파일 안 lab 토큰은 이 네 곳이 전부다(rg 'lab' 실측, 나머지 일치는 available 등 부분 문자열). 이동 범위 272-1330에 lab 참조가 없으므로 어떤 리프도 lab importer가 되지 않고, tests/lab/core-lab-boundary.test.ts 그래프는 불변이다. + +네 리프 모두 startServer 본문 레벨에서는 await 없이 한 번 호출된다. bounded-request·live-sideband·startup-warnings는 기존 호출 지점이 유지되고, route-guards는 1191 위치의 구조 분해 1줄이다. 리프 함수를 async로 바꾸거나 본문에 await을 추가하는 것을 금한다. runAdmittedHttpTurn은 이미 async이지만 호출이 전부 fetch 콜백 안이라(2415, 2451, 2507, 2533, 2568, 2605, 2635, 2665, 2683, 2715) 창 대상이 아니다. + +World B의 serveOptions 리프: buildServeOptions 호출은 1481 위치에서 동기 1회다. fetch·websocket은 중첩 함수라 창 스캔 제외 대상이고, 이동 후에도 파사드 창 텍스트 3222-3399는 변하지 않는다. + +## 7. 동반 수정 (World A 기준) + +| 파일:줄 | 검사 내용 | 조치 | +|---|---|---| +| tests/server/loopback-listener-admission.test.ts:92-100 | 허용 목록 2문자열(원본 1197, 1198) | 읽기 경로를 src/server/index/route-guards.ts로 | +| tests/server/loopback-listener-admission.test.ts:110-117 | `indexOf("function loopbackRouteAllowed(")` 앵커 2개 | 같은 리프 읽기로 | +| tests/codex-integration/model-visibility-management-api.test.ts:72-77 | `"Retry-After": "1"`(원본 1277) | 검사 대상 소스에 리프 추가 | +| tests/fixtures/file-size-baseline.json:27 | 3400 | 이동 후 실측값으로 갱신 — SHRANK는 offender가 아니다(scripts/file-size-ratchet.ts:87, 92-93 실측) | +| structure/manifest.json, structure/runtime.md | 신규 src/server/index/ 영역 | 소유자 등록 — AGENTS.md 규칙상 미소유 신규 src/ 영역에서 structure:check 실패 | + +무수정 근거. tests/server/server-live.test.ts:18-23과 tests/server/agent-task-recovery.test.ts:4는 런타임 import라 재-export가 표면을 유지한다. tests/codex-integration/codex-retained-root-serialization.test.ts:267은 런타임 import고, 295-299는 `const startupCodexHome`(1074)·`armClaudeCodeBaseline`(1090)을 읽는데 둘 다 파사드에 남는다. + +tests/windows/windows-deploy-close-regressions.test.ts:81-89는 1115-1116과 3222를 검사하고 둘 다 잔류한다. tests/codex-integration/compatibility-manifest.test.ts:184는 import 그래프 검사로 이동이 새 의존을 만들지 않는다. tests/usage/quota-reset-core-boundary.test.ts:74-82는 66행의 background-lifecycle 직접 import를 전제하므로 유지한다. tests/lab/core-lab-boundary.test.ts는 앵커 잔류로 무수정이다. 구조 문서 7곳(structure/runtime.md:18·27·367, remote-workspace.md:19, data-planes/inbound-compat.md:30, clients/claude-desktop.md:28, gui-and-management-api.md:106)의 `src/server/index.ts` 이름 참조는 파사드가 남으므로 경로 유효성 검사를 통과한다. + +World B(serveOptions 리프) 승인 시 추가 재지정: tests/responses/ws-endpoint.test.ts:40-46(websocket 키와 finalizeLog 2문자열), tests/lib/workflow-budget.test.ts:474-482(`return runAdmittedHttpTurn(` 개수와 `withCors(workflowRefusalResponse(`), loopback-listener-admission:63-89·122-130(라우트 마커), model-visibility:74-75(CatalogGatherBusyError·"catalog_busy" — 원본 2030-2031, 98행 import는 fetch 이동 후 미사용이라 삭제). + +fetch 본문의 동적 import 15곳(1590-2465)은 World B에서 지정자를 한 단계 내린다. 예: 1851 `import("./catalog-download")` → `import("../catalog-download")`, 1969 `import("../remote/hub-state")` → `import("../../remote/hub-state")`. + +## 8. 검증 순서 (구현자용) + +- live-sideband 이동 직후: bun test tests/server/server-live.test.ts — 재-export 표면 검증. +- startup-warnings 이동 직후: bun test tests/server/agent-task-recovery.test.ts. +- route-guards 이동·재지정 직후: bun test tests/server/loopback-listener-admission.test.ts tests/codex-integration/model-visibility-management-api.test.ts. +- 전체 완료 후: bun run typecheck, bun run test:changed, bun run structure:check, bun run privacy:scan. PR-ready 전에는 AGENTS.md 게이트대로 bun run typecheck과 bun run test를 실행한다. +- file-size-baseline.json은 마지막 리프 이동 후 실측값으로 한 번만 갱신한다. + +## 9. 측정 명령 목록 + +``` +wc -l src/server/index.ts +awk '/^(export |async |function |const |let |var |class |interface |type |enum )/ {print NR": "$0}' src/server/index.ts +awk 'NR<=91 && /from / {print NR": "$0}' src/server/index.ts +awk 'NR>=1006 && /^ (const|let|async function|function|type|interface)/ {print NR": "$0}' src/server/index.ts +rg -n 'Bun\.serve|labActivationRequired|return server|fetch:|websocket:' src/server/index.ts +rg -n 'serveOptions|server\.stop|loopbackServer|managementIngressServer' src/server/index.ts +rg -n 'as const' src/server/index.ts +rg -n 'boundPort' src/server/index.ts +rg -n 'import\(' src/server/index.ts +rg -n 'GUI_PAIRING_EXCHANGE_BODY_LIMIT|REMOTE_WORKSPACE_PAIRING_BODY_LIMIT|REMOTE_CATALOG_KEY_ID_PATTERN|REQUEST_LOG_ID_RESPONSE_HEADER|withRequestLogId|withRemoteCatalogKeyId|MAX_WS_FRAME_BYTES|WEBSOCKET_IDLE_TIMEOUT_SECONDS|CatalogGatherBusyError|"catalog_busy"|const startupCodexHome|armClaudeCodeBaseline\(' src/server/index.ts tests/ +rg -n 'openLiveSidebandUpstream|attachLiveSidebandUpstream|enqueueLiveSidebandPendingFrame|exceedsLiveSideband|consumeStartupCacheInvalidationWrite|warnAgentTaskRecoveryStartup|warnPlaintextV2AgentMessagesStartup|StartServerDeps' tests/ +rg -n 'ANCHOR =|bodyLevelAwaitLines' tests/lab/core-lab-boundary.test.ts +rg -n 'SHRANK|NEW_OK|OVERSIZED|isOffender' scripts/file-size-ratchet.ts +rg -n 'server/index' tests/ structure/ +sed -n '<구간>p' src/server/index.ts # 1-91, 92-101, 199-266, 267-296, 338-356, 921-1006, 1191-1340, 1325-1346, 1462-1492, 1730-1745, 2958-2980, 3200-3262, 3216-3223, 3370-3400 +sed -n '1481,3220p' src/server/index.ts | rg -o -w '' | sort | uniq -c # serveOptions 캡처 +sed -n '2976,3219p' src/server/index.ts | rg -o -w '<동일 목록>' | sort | uniq -c # websocket 캡처 +sed -n '1481,3220p' src/server/index.ts | rg -n -w 'server' # 라이브 바인딩 위치 +ls src/server/ ; ls -la devlog/_plan/260915_godfile_round5/ +``` diff --git a/devlog/_plan/260915_godfile_round5/050_stack_and_gates.md b/devlog/_plan/260915_godfile_round5/050_stack_and_gates.md new file mode 100644 index 0000000000..22f49acad9 --- /dev/null +++ b/devlog/_plan/260915_godfile_round5/050_stack_and_gates.md @@ -0,0 +1,187 @@ +# 050 스택 체인과 게이트 체크리스트 + +라운드5 는 브랜치 네 개를 수동으로 쌓아 origin/dev 로 접는다. 이 문서는 그 순서와, +각 PR 이 통과해야 하는 게이트, 그리고 분해 때문에 조용히 망가질 수 있는 검사 목록을 고정한다. +모든 수치는 aa91958e3b 시점에서 실측했다. + +## 브랜치 체인 + +| 순서 | 브랜치 | base | 담는 작업 | +| --- | --- | --- | --- | +| a | codex/godfile-r5-a-openai-responses | origin/dev | wp1 로드맵 문서 + wp2 openai-responses.ts 분해 | +| b | codex/godfile-r5-b-bridge | a | wp3 bridge.ts 분해 | +| c | codex/godfile-r5-c-activation-guard | b | wp4 동기 activation 가드 전수 검사 | +| d | codex/godfile-r5-d-server-index | c | wp5 server/index.ts 분해 | + +머지는 가장 깊은 자식부터 부모로 접는다. d -> c, c -> b, b -> a, 마지막에 a -> dev. +dev 기반은 a 하나뿐이므로 trunk 에 닿는 PR 도 a 하나다. + +AGENTS.md 는 열린 PR 의 head 브랜치를 base 로 삼는 자식 PR 을 의도된 리뷰 방식으로 인정하고, +enforce-target 이 그런 자식에 대해 wrong-base 게이트를 건너뛴다고 적고 있다. 부모가 머지되거나 +닫히면 자식을 dev 로 retarget 한다. 이 라운드는 자식을 부모로 접어 없애므로 retarget 이 필요 없다. + +## PR 본문 + +.github/PULL_REQUEST_TEMPLATE.md 는 세 절을 요구한다: `## Summary`, `## Verification`, `## Checklist`. +enforce-target 이 비거나 얄팍하거나 형식이 깨진 설명을 거절하므로 네 PR 모두 세 절을 채운다. +제목이나 본문에 `gui` 가 들어가면 UI 스크린샷을 요구하므로 그 단어를 쓰지 않는다. + +## PR 에 붙는 워크플로 + +`.github/workflows/` 15개 중 `pull_request` 를 트리거로 가진 것은 9개다. + +| 파일 | name | +| --- | --- | +| ci.yml | Cross-platform CI | +| enforce-pr-target.yml | Enforce PR target branch | +| pr-hygiene.yml | PR hygiene | +| pr-labeler.yml | PR Labeler | +| react-doctor.yml | React Doctor | +| service-lifecycle.yml | Service lifecycle | +| enforce-issue-quality.yml | Enforce issue quality | +| issue-quality-tests.yml | Issue quality tests | +| issue-triage.yml | Issue Triage (Deduplicate) | + +Cross-platform CI 가 Linux/Windows/macOS 에서 typecheck 와 전체 스위트를 돌린다. 로컬에는 +node_modules 가 없어 스위트를 돌릴 수 없으므로, 판정은 정적 감사 + exact-head 호스티드 CI 로 한다. + +## hygiene 게이트: missing_regression_test + +`.github/scripts/pr-hygiene.cjs:155-159` 의 조건은 이렇다. + +``` +behaviorChanged && !testsChanged && !labelSet.has("test-exception-approved") + -> failures.push({ code: "missing_regression_test" }) +``` + +순수 리팩터는 `src/` 를 건드리면서 테스트를 안 건드리기 때문에 기본적으로 여기서 걸린다. +빠져나가는 길은 둘이다. 같은 PR 이 `tests/` 를 함께 수정하거나, `test-exception-approved` 라벨을 붙인다. + +라운드5 는 네 PR 모두 `tests/` 를 실제로 건드리지만, 근거는 PR 마다 다르다. `TEST_PREFIXES` 는 +`["tests/"]` 하나뿐이고 `isTestPath` 가 접두사 일치만 보므로(`.github/scripts/pr-hygiene.cjs:14,77-79`), +래칫 기준선 `tests/fixtures/file-size-baseline.json` 의 캡을 내리는 것만으로 `testsChanged` 가 참이 된다. +분해 PR 은 캡 갱신이 필수이므로 a·b·d 는 이 경로 하나로 게이트를 충족한다. c 는 가드 자체가 +`tests/lab/core-lab-boundary.test.ts` 라 본체가 테스트 변경이고, d 는 추가로 오라클 8개를 재지정한다. +따라서 라벨은 쓰지 않는다. 필요해지면 `gh pr edit --add-label test-exception-approved` 로 붙인다. + +a 와 b 가 재지정할 텍스트 소스 오라클은 없다. 그 두 파일에는 애초에 텍스트 오라클이 없기 때문이고, +아래 목록이 그 근거다. 이 문서의 초안은 a·b 도 오라클을 재지정한다고 적었는데, 같은 문서의 +"텍스트 오라클 0건" 결론과 모순이어서 독립 감사에서 지적받아 고쳤다. + +## file-size ratchet + +`scripts/file-size-ratchet.ts` 를 읽고 확인한 규칙이다. + +- `THRESHOLD = 2000`. 기준선은 `tests/fixtures/file-size-baseline.json`, 현재 캡 45개. +- `evaluate()` 판정: 캡이 없는 새 파일이 2,000줄 이상이면 `NEW_OVERSIZED`, 캡보다 커지면 `GREW`. + 이 둘만 위반이다. `SHRANK` 와 `NEW_OK` 는 통과한다. +- `updateBaseline()` 은 `files[path] = Math.min(cap, lines)` 다. 캡은 내려가기만 하고 절대 올라가지 않는다. + 트리에서 사라진 경로는 캡에서 빠진다. +- 갱신 커맨드는 `bun run ratchet:update` (`bun scripts/file-size-ratchet.ts --update`). +- `--update` 는 기준선 파일이 **없을 때만** seed 모드로 새 2,000줄 이상 파일에 캡을 새로 심는다. + 기준선이 이미 있으면 새 파일에 캡을 추가하지 않는다. 즉 새 리프가 2,000줄을 넘으면 캡이 아니라 + `NEW_OVERSIZED` 로 떨어진다. 모든 리프를 2,000줄 아래로 잘라야 하는 실질적 이유가 이것이다. + +대상 3파일의 현재 캡은 현재 줄 수와 정확히 같다. + +``` +"src/adapters/openai-responses.ts": 2627, +"src/bridge.ts": 2206, +"src/server/index.ts": 3400, +``` + +분해하면 세 줄 모두 새 값으로 내려가야 한다. 재시딩 시점은 각 브랜치의 구현 커밋 직전이 아니라 +**직후**다. 구현 후 `bun run ratchet:update` 를 돌려 캡이 내려간 것만 확인하고, 최종적으로 a 를 dev 로 +접기 전에 머지된 트리에서 한 번 더 돌린다. dev 가 그 사이 움직였으면 다른 파일의 캡도 같이 내려갈 수 있다. + +## 소스 오라클 재지정 목록 + +이게 이 문서의 핵심이다. `tests/` 의 일부 테스트는 소스 파일을 **텍스트로 읽어** 문자열을 찾는다. +내용이 다른 파일로 옮겨가면 그 검사는 실패하지 않고 조용히 아무것도 검사하지 않게 된다. + +### src/server/index.ts 를 텍스트로 읽는 오라클 (wp5 의 실질 작업량) + +| 파일:라인 | 읽는 방식 | 분해 후 위험 | +| --- | --- | --- | +| tests/lib/workflow-budget.test.ts:474 | `Bun.file(repoPath("src/server/index.ts")).text()` | 찾는 패턴이 fetch 핸들러 안이면 vacuous | +| tests/windows/windows-deploy-close-regressions.test.ts:81 | `read("src/server/index.ts")` | 같음 | +| tests/codex-integration/codex-retained-root-serialization.test.ts:295 | `readFileSync(join(repoRoot, "src/server/index.ts"))` | 같음. 267행은 동적 import 라 무해 | +| tests/responses/ws-endpoint.test.ts:40 | `readFileSync(new URL("../../src/server/index.ts"))` | WS 라우트 등록이 fetch 핸들러 안 -> 거의 확실히 vacuous | +| tests/server/loopback-listener-admission.test.ts:64, 92 | 같은 방식 2회 | loopback admission 이 fetch 핸들러 안 -> vacuous | +| tests/codex-integration/model-visibility-management-api.test.ts:72 | `Bun.file(new URL("../../src/server/index.ts")).text()` | 관리 API 라우트가 fetch 핸들러 안 -> vacuous | +| tests/lab/core-lab-boundary.test.ts:354 | `resolve(repoRoot, "src/server/index.ts")` | 동기 창(3222-3399)은 파사드 잔류라 유지. wp4 에서 같이 손댄다 | +| tests/usage/quota-reset-core-boundary.test.ts:80-82 | import 그래프 체인 단언 | 체인 문자열 `src/server/index.ts -> src/server/background-lifecycle.ts -> src/quota/reset-poller.ts` 가 리프 경유로 바뀌면 깨진다 | + +규칙: 옮긴 코드를 검사하던 오라클은 **같은 커밋에서** 새 리프 경로로 재지정한다. 단언 문자열 자체는 +바꾸지 않는다. 읽는 파일만 바꾼다. 문자열까지 바꾸면 검사 내용이 달라져 순수 이동이 아니게 된다. +한 오라클이 두 리프에 걸친 내용을 찾으면 두 파일을 읽어 이어 붙인다 (라운드3 에서 codex-inject-history-wording 에 쓴 방법). + +### src/bridge.ts 와 src/adapters/openai-responses.ts + +`rg -n 'src/bridge\.ts|adapters/openai-responses\.ts' tests/` 결과에서 텍스트 오라클은 **하나도 없다**. +나온 것은 주석 참조 2건(responses-undeclared-tool-guard.test.ts:5, +routing-compatibility-model-matching.test.ts:146)과 ratchet 기준선 2행뿐이다. 초안은 여기에 +responses-forward-incomplete-quota.test.ts:202 를 포함했는데 그 줄은 "the bridge inspects" 라는 +산문일 뿐 이 패턴에 매칭되지 않아 독립 감사에서 제외됐다. 주석은 게이트가 아니므로 +라인 번호가 낡아도 red 가 되지 않는다. 그래도 :146 은 `src/adapters/openai-responses.ts:1001` 이라는 +구체적 라인을 인용하므로 분해 후 실제 위치로 고친다. + +이 차이가 라운드5 의 위험 분포를 설명한다. wp2 와 wp3 은 오라클 위험이 없고, wp5 가 전부 진다. + +## structure/ SSOT + +`bun run structure:check` (`bun scripts/structure-ssot.ts`) 가 게이트다. 이 트리에서 실행 가능하다. +문서가 이름을 대는 경로가 트리에 없으면 실패하고, 주인 없는 새 `src/` 영역이 생기면 실패한다. +`structure/manifest.json:390-391` 에 `src/bridge.ts` grace 항목이 있다. + +``` +"path": "src/bridge.ts", +"reason": "no doc names this file; it is the legacy adapter bridge entry and its behavior is described under the adapter registry without a path reference" +``` + +`src/bridge/` 리프가 생기면 이 grace 를 리프 경로로 확장하거나 소유 문서를 지정해야 한다. +`bun run structure:index` 로 `structure/INDEX.md` 를 재생성한다. + +## dev 통합 기록 의무 + +MAINTAINERS.md:59-64 가 정한다. `maintain` 또는 `admin` 권한 메인테이너는 다른 메인테이너 승인 없이 +자기 PR 을 포함해 `dev` 에 통합할 수 있지만, **그 선택과 exact-head 검증을 PR 설명이나 코멘트에 기록**해야 한다. +이건 self-approval 이 아니라 maintainer integration 이고, 기술 리뷰·귀속·문서·보안 리뷰 의무는 그대로다. +같은 절은 이 예외가 `dev` 에만 적용되며 direct push, force-push, 브랜치 삭제를 허용하지 않는다고 못 박는다. + +따라서 각 PR 을 접기 전에 (1) 머지 대상 head SHA, (2) 그 SHA 에서 돌아간 CI run 링크와 결론, +(3) maintainer integration 을 선택한 사실을 코멘트로 남긴다. 이 순서를 지키지 않은 머지는 정책 위반이다. + +## 로컬에서 돌 수 있는 것과 못 돌리는 것 + +이 워크트리에는 `node_modules` 가 없고 `bun install` 은 하지 않는다. 그래서: + +- 돈다: `bun scripts/structure-ssot.ts`, `bun scripts/file-size-ratchet.ts`, 의존성 없는 개별 `bun test `. +- 안 돈다: 전체 스위트, `bun run typecheck`, `bun run build:gui`. +- 대체 수단: `bun x tsc --noEmit` 을 개별 파일에 걸고 노이즈 코드를 걸러 본다. 실제 오류로 취급할 것은 + TS2304/2305/2459/2724 (전역 이름 process/Buffer/NodeJS/Bun 제외)와, 상대 지정자에 대한 TS2307 뿐이다. + 라운드3 에서 서브에이전트 하나가 자기 검증 스크립트에서 TS2307 을 노이즈로 제외해 실제 미해결 import 를 + 숨겼다. 그 필터를 서브에이전트가 정하게 두지 않는다. + +## 이 라운드가 쓴 기계 검증 (재현 절차) + +분해를 손으로 하지 않았다. `.tmp/r5/` (gitignore 대상, 보안 노트가 아닌 순수 스크래치) 에 도구 여섯 개를 +두고 돌렸다. `.tmp/` 는 휘발성이므로 다음 라운드가 다시 만들 수 있도록 각 도구가 무엇을 증명하는지 적는다. + +| 도구 | 증명하는 것 | +| --- | --- | +| `spans.ts ` | 최상위 선언마다 선행 주석을 흡수한 라인 스팬을 산출한다. 스팬 합계와 파일 줄 수의 차이가 전부 빈 줄이어야 한다 | +| `gen-spec.ts` | 심볼 -> 리프 매핑 표를 받아 split spec 을 생성한다. 매핑에 없는 심볼이 하나라도 있으면 실패하므로 계약서 누락이 드러난다 | +| `verify-spans.ts ` | 주석·문자열을 지운 뒤 각 이동 범위의 괄호 깊이가 0 에서 시작해 0 으로 끝나고 중간에 음수가 되지 않음을 확인한다 | +| `split.ts [--apply]` | 라인 범위를 통째로 옮기고, 원본 import 를 리프 깊이에 맞게 `./x -> ../x`, `../y -> ../../y` 로 바꾸고, 리프에서 안 쓰는 import 를 잘라내고, 리프 간 참조 심볼에 `export` 를 붙이고, 리프 사이 순환을 검출하고, 파사드 재노출을 생성한다 | +| `audit-imports.ts` | `src` 와 `gui/src` 전체에서 상대 지정자를 뽑아 실제 해석 여부를 확인한다. 기준선 대비 새 미해결이 생기면 실패한다 | +| `verify-surface.ts ` | `Bun.Transpiler().scan().exports` 로 파사드 export 집합을 `git show origin/dev:` 기준과 비교한다 | + +`verify-spans.ts` 가 vacuous 하지 않다는 증거는 이 라운드 안에 있다. 040 초안이 route-guards 리프 범위를 +`1191-1329` 로 적었는데 `runAdmittedHttpTurn` 의 닫는 중괄호는 1330 이다. 검증기는 그 범위를 +`INCOMPLETE ... 끝깊이=1` 로 거부하고 정정된 `1191-1330` 을 통과시켰다. 사람이 표를 읽어서는 잡기 어려운 +유형이고, 그대로 옮겼으면 함수의 닫는 `}` 가 잘린 채 커밋됐다. + +기준선 수치: `audit-imports.ts` 는 aa91958e3b 에서 상대 지정자 8,285개를 검사해 미해결 2건을 낸다. 둘 다 +기존 상태다. 하나는 정규식 오탐(`src/adapters/cursor/protobuf-events.ts:665` 의 문자열 조각), 하나는 실제 +미해결(`src/adapters/devin/cloud-direct/index.ts:29 -> ./cloud-direct/index.js`)이고 이 라운드 범위 밖이다. diff --git a/devlog/_plan/260915_godfile_round5/060_audit_record.md b/devlog/_plan/260915_godfile_round5/060_audit_record.md new file mode 100644 index 0000000000..0959f51de8 --- /dev/null +++ b/devlog/_plan/260915_godfile_round5/060_audit_record.md @@ -0,0 +1,74 @@ +# 060 감사 기록과 로드맵 잠금 (wp1 종결) + +이 문서는 라운드5 로드맵이 어떤 검증을 통과해 잠겼는지 기록한다. 이후 작업 단위(wp2~wp6)의 P 는 +각자 담당 decade 문서를 현재 코드와 다시 맞춰본 뒤 착수한다. + +## 산출물 + +| 문서 | 줄 수 | 담당 | +| --- | --- | --- | +| 000_plan.md | 110 | 라운드 전체 계획, 세 파일 실측과 가치 판정 | +| 010_openai_responses.md | 211 | wp2 계약서, 심볼 83개 인벤토리와 리프 10개 | +| 020_bridge.md | 176 | wp3 계약서, 리프 4개와 파사드 재노출 | +| 030_activation_guard.md | 198 | wp4 설계, 가드 기계와 전수 검사 재설계 | +| 040_server_index.md | 188 | wp5 계약서, startServer 해부와 접근자 예외 | +| 050_stack_and_gates.md | 187 | 스택 체인, 게이트, 소스 오라클, 기계 검증 절차 | + +## 감사 라운드 + +작성은 서브에이전트 6명이 병렬로, 감사는 별도 감사자가 읽기 전용으로 했다. 감사자는 문서를 믿지 않고 +같은 수치를 직접 재측정하는 임무만 받았다. + +1차 감사 결과는 `VERDICT: FAIL`, 불일치 12건이었다. 040 이 8건, 030 이 2건, 050 이 2건이다. +정정은 병렬 작업자 2명(040, 030)과 메인 세션(050)이 나눠 처리했고, 각 작업자는 감사자 주장도 +검증 대상으로 취급하라는 지시를 받았다. + +재감사 결과는 `VERDICT: PASS`, 12/12 반영 확인이다. + +## 실제 결함 1건 + +12건 중 하나는 문서 오타가 아니라 그대로 실행하면 코드를 깨뜨리는 결함이었다. + +040 초안은 route-guards 리프의 이동 범위를 `1191-1329` 로 적었다. `runAdmittedHttpTurn` 의 마지막 +문장이 1329행 `return response;` 이고 닫는 중괄호는 1330행이다. 표 그대로 옮기면 함수의 닫는 `}` 가 +원본에 남고 리프는 구문 오류가 된다. 표를 읽어서는 잡기 어려운 유형이고, 라인 범위로 코드를 옮기는 +작업에서 가장 흔한 실패 방식이다. + +이 건을 계기로 `.tmp/r5/verify-spans.ts` 를 만들었다. 주석과 문자열을 지운 뒤 각 이동 범위의 괄호 +깊이가 0 에서 시작해 0 으로 끝나고 중간에 음수가 되지 않는지 확인한다. 이 검증기는 `1191-1329` 를 +`INCOMPLETE ... 끝깊이=1` 로 거부하고 `1191-1330` 을 통과시킨다. 사람이 아니라 기계가 잡는 종류의 +오류이므로, wp2~wp5 의 모든 이동 범위는 이 검증기를 먼저 통과해야 한다. + +## 감사자 오류 1건과 그 처리 + +감사자는 040 의 파생 검산 중 "세 리프 합 2,666" 도 2,665 로 낮추라고 지시했다. 정정 작업자는 이를 +반박했다. 그 산식은 `3,400 - 734` 이고 `734 = 66(bounded-request) + 552(live-sideband) + +116(startup-warnings)` 이라 route-guards 크기가 들어가지 않으므로 1줄 확장과 무관하다. + +재감사에서 감사자는 산술을 다시 계산해 자기 지적을 철회했다. 이 기록을 남기는 이유는, 감사 결과를 +무조건 반영하는 파이프라인은 감사자의 오류를 문서에 주입하기 때문이다. 정정 작업자에게 감사자 주장도 +검증하라고 지시한 것이 이 건을 걸러냈다. + +## 계획 실행으로 얻은 사전 검증 + +010 과 020 은 서브에이전트 감사가 두 번 응답 불가로 실패했다(glm 40분 무응답, 후속 grok 도 무응답). +문서 리뷰 대신 계획을 실제로 실행해 경험적으로 검증했다. wp2 는 커밋하지 않고 적용 후 원복했다. + +| 확인 | 결과 | +| --- | --- | +| 010 의 심볼 -> 리프 매핑에 빠진 심볼 | 0건 (83개 전수 매핑) | +| 이동 범위 구문 완결성 | openai-responses 16범위 2,569줄, bridge 10범위 2,149줄 모두 통과 | +| 리프 간 순환 참조 | 0건 | +| 분할 적용 후 저장소 전역 상대 import 해석 | 8,289개 중 기준선 대비 새 미해결 0건 | +| 파사드 export 표면 (origin/dev 대비) | openai-responses 5개 동일 | +| 파사드 줄 수 | openai-responses 6줄, bridge 59줄 | +| 최대 리프 줄 수 | openai-responses passthrough.ts 611줄, bridge sse.ts 1,435줄 | + +즉 wp2 와 wp3 은 계약서가 실행 가능함이 이미 확인된 상태로 시작한다. 남은 불확실성은 타입 검사와 +호스티드 CI 뿐이고, 둘 다 이 워크트리에서는 돌릴 수 없다. + +## 잠금 선언 + +위 여섯 문서를 라운드5 의 계약으로 잠근다. 이후 각 작업 단위는 자기 decade 문서를 현재 코드와 다시 +맞춰보고(앞선 단위가 라인을 밀었을 수 있다) 어긋난 부분을 문서에 반영한 뒤 구현에 들어간다. +계약을 바꾸는 결정은 해당 단위의 P 에서 근거와 함께 문서에 기록한다. From ea9388ac0b4eb20185e318543d0fd0e315d50df1 Mon Sep 17 00:00:00 2001 From: lidge-jun Date: Tue, 15 Sep 2026 10:24:06 +0900 Subject: [PATCH 2/2] refactor(adapters): split openai-responses.ts behind a facade src/adapters/openai-responses.ts was 2,627 lines holding 83 top-level declarations, 78 of them file-private (body: unknown) => unknown transforms. A previous round recorded this file as "a single flow that leaks state into argument lists when split"; re-measuring it showed the opposite. The transforms are stateless and group cleanly by subject, so this is a pure move. Ten leaves under src/adapters/openai-responses/: internal.ts 3 isPlainObject, the one shared predicate prompt-cache.ts 83 posit cache markers and breakpoints web-search.ts 156 OpenAI-only and muse-spark field stripping request-strips.ts 185 item-id, metadata and compaction scrubbing canonical-forward.ts 202 sampling params, system text, envelopes reasoning.ts 209 reasoning summary and effort normalization tool-schema.ts 293 tool schema normalization and tool_choice image-gen.ts 406 image_gen namespace and alias handling tool-output-recovery.ts 509 call-id repair and orphaned output recovery passthrough.ts 611 FORWARD_HEADERS and the adapter factory The facade keeps its five exports as re-exports and is 6 lines. Every moved range was verified byte-identical against origin/dev, with only an added `export ` keyword normalized away: 16 ranges, 0 drift. The relative specifier rewrite (./x to ../x, ../y to ../../y) was generated, not hand-written, because a leaf one directory deeper silently keeping the original specifier is the defect that killed every test shard two rounds ago. A repository-wide resolution audit over 8,289 relative specifiers reports no new unresolved import, and the facade export surface is identical to origin/dev. tests/routing/routing-compatibility-model-matching.test.ts repointed its comment anchor for modelPreferHostedTools. That anchor already pointed at line 1001 while the read actually lived at 1532, so it now names the leaf and line that holds it. Ratchet cap lowered from 2,627 to 6. --- src/adapters/openai-responses.ts | 2629 +---------------- .../openai-responses/canonical-forward.ts | 202 ++ src/adapters/openai-responses/image-gen.ts | 406 +++ src/adapters/openai-responses/internal.ts | 3 + src/adapters/openai-responses/passthrough.ts | 611 ++++ src/adapters/openai-responses/prompt-cache.ts | 83 + src/adapters/openai-responses/reasoning.ts | 209 ++ .../openai-responses/request-strips.ts | 185 ++ .../openai-responses/tool-output-recovery.ts | 509 ++++ src/adapters/openai-responses/tool-schema.ts | 293 ++ src/adapters/openai-responses/web-search.ts | 156 + tests/fixtures/file-size-baseline.json | 2 +- ...uting-compatibility-model-matching.test.ts | 2 +- 13 files changed, 2663 insertions(+), 2627 deletions(-) create mode 100644 src/adapters/openai-responses/canonical-forward.ts create mode 100644 src/adapters/openai-responses/image-gen.ts create mode 100644 src/adapters/openai-responses/internal.ts create mode 100644 src/adapters/openai-responses/passthrough.ts create mode 100644 src/adapters/openai-responses/prompt-cache.ts create mode 100644 src/adapters/openai-responses/reasoning.ts create mode 100644 src/adapters/openai-responses/request-strips.ts create mode 100644 src/adapters/openai-responses/tool-output-recovery.ts create mode 100644 src/adapters/openai-responses/tool-schema.ts create mode 100644 src/adapters/openai-responses/web-search.ts diff --git a/src/adapters/openai-responses.ts b/src/adapters/openai-responses.ts index 6a07b84851..619fa667f3 100644 --- a/src/adapters/openai-responses.ts +++ b/src/adapters/openai-responses.ts @@ -1,2627 +1,6 @@ -import { normalizeRoutedAgentMessages } from "./routed-agent-messages"; -import { stripBracketedModelSuffix } from "./openai-chat"; -import { normalizeOpenCodeGoAdditionalTools } from "./opencode-go-additional-tools"; -import { isXaiResponsesDestination } from "../providers/xai-transport"; -import { createHash } from "node:crypto"; -import { Buffer } from "node:buffer"; -import type { IncomingMeta, ProviderAdapter } from "./base"; -import { namespacedToolName, type AdapterEvent, type OcxParsedRequest, type OcxProviderConfig, type OcxUsage, type TierDecision } from "../types"; -import { catalogModelSupportsReasoningSummaries } from "../codex/catalog"; -import { applyCodexRoutingHint, CODEX_RESPONSES_LITE_HEADER, CODEX_ROUTING_HINT_HEADER } from "../codex/forward-transport-headers"; -import { COMPACT_PROMPT, compactionItemToText, decodeCompactionSummary, isCompactionItemType } from "../responses/compaction"; -import { collectResponsesToolGroups } from "../responses/tool-groups"; -import { isHostedToolUnsupportedForModel } from "../responses/hosted-tool-policy"; -import { decodeServerSentEvents } from "../lib/sse-decoder"; -import { debugProviderDiagnostic } from "../lib/debug"; -import { - CODEX_FORWARD_BASE_URL, - destinationDecodesNativeCompactionBlob, - isCanonicalOpenAiForwardProvider, - isOpenAiOperatedResponsesDestination, -} from "../providers/openai-tiers"; -import { OCX_REASONING_PREFIX } from "../responses/reasoning-envelope"; -import { configuredReasoningEfforts, mapReasoningEffort, modelRecordValue } from "../reasoning-effort"; -import type { TranslatorBudget } from "../lib/translator-budget"; -import { rewriteRoutedCustomToolsForUpstream } from "../responses/custom-tool-compat"; -import { rewriteRoutedToolSearchForUpstream } from "../responses/tool-search-compat"; -import { rewriteRoutedNamespaceToolsForUpstream } from "../responses/namespace-tool-compat"; -import { preparePlaintextV2AgentMessages } from "../responses/plaintext-v2-agent-messages"; -import { isMetaAiResponsesDestination, rewriteMuseToolNamesForUpstream } from "../responses/muse-tool-name-alias"; -import { openaiResponsesUrl } from "./openai-responses-url"; -import { normalizeResponsesCodeMode } from "./responses-code-mode"; -import { stripUnicodePropertyPatterns } from "./responses-tool-schema"; -import { injectXaiResponsesXSearch, normalizeXaiResponsesWebSearch } from "./xai-web-search"; -import { EMPTY_TOOL_OUTPUT_ANNOTATION, isWhitespaceOnlyTextPartArray } from "./empty-tool-output-annotation"; -import { - isXaiSchemaTarget, - normalizeXaiToolParameters, - XaiToolSchemaCompatibilityError, -} from "./xai-tool-schema"; -import { - createAdapterTierMetadata, -} from "../providers/fastwire"; -// Headers relayed verbatim from the caller in OAuth-passthrough ("forward") mode. -// Exported so the web-search sidecar reuses the exact same forwarded-auth set for its ChatGPT call. -export const FORWARD_HEADERS = [ - "authorization", - "chatgpt-account-id", - "openai-beta", - "originator", - "session_id", - "session-id", - "thread-id", - "x-client-request-id", - "x-codex-beta-features", - "x-codex-installation-id", - "x-codex-parent-thread-id", - "x-codex-turn-metadata", - "x-codex-turn-state", - "x-codex-window-id", - "x-oai-attestation", - "x-openai-subagent", - "x-responsesapi-include-timing-metrics", - CODEX_RESPONSES_LITE_HEADER, -]; -/** - * Sanitize reasoning input by field policy, not by preserving each item's shape. Retaining a - * native `encrypted_content` guarantees only that blob value: `status` is always removed; - * proxy-owned `ocxr1:` envelopes are always removed; and native blobs are removed when the caller - * requests stripping after a route-identity change or opaque-blob recovery. On routed/non-OpenAI - * destinations, a present non-array `content` field is omitted. Otherwise non-empty array content - * is blanked unless raw reasoning preservation is enabled; removing an `ocxr1:` envelope selects - * the same blanking path when non-array omission is not active. - */ -export function sanitizeReasoningInputContent( - body: unknown, - opts?: { - preserveRawReasoningContent?: boolean; - dropNullContentChannel?: boolean; - stripEncryptedContent?: boolean; - }, -): unknown { - if (!body || typeof body !== "object" || Array.isArray(body)) return body; - const raw = body as Record; - if (!Array.isArray(raw.input)) return body; - - let changed = false; - const input = raw.input.map(item => { - if (!item || typeof item !== "object" || Array.isArray(item)) return item; - const rec = item as Record; - if (rec.type !== "reasoning") return item; - const hasRawContent = Array.isArray(rec.content) && rec.content.length > 0; - // ocxr1 envelopes are proxy-minted (Anthropic signatures), not OpenAI encryption — the native - // backend cannot decrypt them and would reject the request. Strip regardless of content shape. - const hasOcxEnvelope = typeof rec.encrypted_content === "string" && rec.encrypted_content.startsWith(OCX_REASONING_PREFIX); - const hasOutputStatus = Object.prototype.hasOwnProperty.call(rec, "status"); - const hasEncryptedContent = Object.prototype.hasOwnProperty.call(rec, "encrypted_content"); - const stripEncryptedContent = hasOcxEnvelope - || (opts?.stripEncryptedContent === true && hasEncryptedContent); - // Codex serializes an absent reasoning content channel as `"content": null`. The field is - // optional and null carries nothing, but a strict gateway rejects the item on its declared type - // — xAI answers `Could not decode the compaction blob`, naming the sibling `encrypted_content` - // rather than the field it actually refused, which is why this reads as a blob failure. Drop the - // key so the item matches the shape the upstream issued. - // - // Gated to routed destinations. An OpenAI-operated backend rejects a blob-bearing item when its - // null `content` channel is deleted (`The encrypted content ... could not be verified`); that - // live result establishes this channel constraint, not whole-item shape preservation. The gate - // is also why this drop may touch an item that keeps its blob: xAI demonstrably accepts its own - // blob without the null channel. This is independent of the output-only status removal below. - const dropNullContentChannel = opts?.dropNullContentChannel === true - && "content" in rec && !Array.isArray(rec.content); - // `status` is output-only. Measured OpenAI reasoning items never contain it, and Grok accepts - // its own encrypted_content with status removed. Keeping a foreign status beside a retained - // blob makes OpenAI reject the field before blob validation, starving the provenance recovery - // of the opaque-blob error it needs. Content blanking remains the separate pre-existing rule. - const stripOutputStatus = hasOutputStatus; - const blankContent = !dropNullContentChannel - && !opts?.preserveRawReasoningContent - && (hasRawContent || hasOcxEnvelope); - if (!blankContent && !stripOutputStatus && !stripEncryptedContent && !dropNullContentChannel) { - return item; - } - changed = true; - const next: Record = { ...rec }; - if (dropNullContentChannel) delete next.content; - if (stripOutputStatus) delete next.status; - if (stripEncryptedContent) delete next.encrypted_content; - // Routed models can produce raw `reasoning_text` output items. Codex echoes those in later - // native GPT requests, but ChatGPT's Responses backend accepts reasoning input only with empty - // `content`; keep summaries/ids and drop the raw content so native passthrough does not 400. - // DeepSeek's Responses API instead ACCEPTS plaintext reasoning replay (its compatibility - // guide merges reasoning items into the adjacent assistant message), so providers flagged - // `preserveResponsesReasoningContent` keep it — deleting valid replay content there breaks - // continuations after tool calls (issue #875 family). - if (blankContent) next.content = []; - return next; - }); - - return changed ? { ...raw, input } : body; -} - -function stripUnsupportedReasoningSummaryDelivery(body: unknown, modelId: string): unknown { - if (catalogModelSupportsReasoningSummaries(modelId) !== false) return body; - if (!isPlainObject(body) || !isPlainObject(body.stream_options)) return body; - if (!("reasoning_summary_delivery" in body.stream_options)) return body; - - const streamOptions = { ...body.stream_options }; - delete streamOptions.reasoning_summary_delivery; - const next = { ...body }; - if (Object.keys(streamOptions).length > 0) next.stream_options = streamOptions; - else delete next.stream_options; - return next; -} - -function stripInvalidItemIds(body: unknown): unknown { - if (!isPlainObject(body) || !Array.isArray(body.input)) return body; - - const validPrefixes: Record = { - message: "msg_", - agent_message: "amsg_", - reasoning: "rs_", - function_call: "fc_", - custom_tool_call: "ctc_", - tool_search_call: "tsc_", - web_search_call: "ws_", - }; - let changed = false; - const input = body.input.map(item => { - if (!isPlainObject(item) || typeof item.type !== "string") return item; - const validPrefix = validPrefixes[item.type]; - if (!validPrefix) return item; - if (typeof item.id === "string" && item.id.startsWith(validPrefix)) return item; - if (!("id" in item)) return item; - changed = true; - const next = { ...item }; - delete next.id; - return next; - }); - - return changed ? { ...body, input } : body; -} - -/** - * Codex-private tool fields that only the ChatGPT backend understands. - * - * A third-party Responses gateway validates its schema and rejects the whole request before - * inference — xAI answers `Argument not supported: external_web_access` — so these are removed at - * the noncanonical boundary while the tool and every public option stay. - * - * Keep this a table. Each private bit Codex attaches has so far arrived as its own bespoke strip - * with its own traversal, and the traversals disagreed about which containers they covered; a new - * one should be a row here instead. `toolTypes` omitted means the field is private on any tool. - */ -const CANONICAL_ONLY_TOOL_FIELDS: readonly { field: string; toolTypes?: ReadonlySet; capabilityGated?: boolean }[] = [ - // ChatGPT's browsing policy bit. The public hosted tool is enabled by its presence alone. - // OWNERSHIP: official OpenAI API-key traffic and unclassified gateways ACCEPT this field, so - // it is only stripped when the provider capability denies it (supportsOpenAiWebSearchToolFields - // === false), matching stripOpenAiOnlyWebSearchFields; see - // tests/responses/responses-routed-web-search-fields.test.ts. - { field: "external_web_access", toolTypes: new Set(["web_search", "web_search_preview"]), capabilityGated: true }, - // Deferred-discovery marker. `activateDeferredTool` clears it only for tools a `tool_search_output` - // already loaded, so a still-deferred declaration — including one promoted out of a namespace - // group — otherwise reaches the wire carrying it. - { field: "defer_loading" }, -]; - -function stripCanonicalOnlyToolFields(body: unknown, includeCapabilityGated: boolean): unknown { - if (!isPlainObject(body)) return body; - - const rewriteTools = (tools: unknown[]): unknown[] => { - let changed = false; - const rewritten = tools.map(tool => { - if (!isPlainObject(tool)) return tool; - let next = tool; - for (const { field, toolTypes, capabilityGated } of CANONICAL_ONLY_TOOL_FIELDS) { - if (capabilityGated && !includeCapabilityGated) continue; - if (!Object.hasOwn(next, field)) continue; - if (toolTypes && (typeof next.type !== "string" || !toolTypes.has(next.type))) continue; - const { [field]: _private, ...rest } = next; - next = rest; - } - if (next === tool) return tool; - changed = true; - return next; - }); - return changed ? rewritten : tools; - }; - - let rewrittenBody = body; - if (Array.isArray(body.tools)) { - const tools = rewriteTools(body.tools); - if (tools !== body.tools) rewrittenBody = { ...rewrittenBody, tools }; - } - if (!Array.isArray(body.input)) return rewrittenBody; - - let input: unknown[] | undefined; - for (let index = 0; index < body.input.length; index += 1) { - const item = body.input[index]; - if (!isPlainObject(item) || item.type !== "additional_tools" || !Array.isArray(item.tools)) continue; - const tools = rewriteTools(item.tools); - if (tools === item.tools) continue; - input ??= [...body.input]; - input[index] = { ...item, tools }; - } - return input ? { ...rewrittenBody, input } : rewrittenBody; -} - -/** - * Codex keeps this ChatGPT-internal item metadata when its configured provider name is `openai`. - * Loopback OpenCodex injection intentionally retains that provider identity for history continuity, - * even when the proxy ultimately routes the request to a public Responses destination. Those - * destinations reject the private field as an unknown `input[*]` parameter, so remove it at the - * noncanonical boundary without mutating the caller-owned raw body. - */ -function stripInternalChatMessageMetadataPassthrough(body: unknown): unknown { - if (!isPlainObject(body) || !Array.isArray(body.input)) return body; - - let changed = false; - const input = body.input.map(item => { - if (!isPlainObject(item) || !Object.hasOwn(item, "internal_chat_message_metadata_passthrough")) { - return item; - } - changed = true; - const next = { ...item }; - delete next.internal_chat_message_metadata_passthrough; - return next; - }); - - return changed ? { ...body, input } : body; -} - -/** - * When `store` is false, the upstream API does not persist response items. Any item ID - * forwarded in `input` is then interpreted as a reference to a stored item that does not - * exist, producing a 404. Strip all item IDs in this case — `call_id` pairing is unaffected. - * Matches codex-rs behavior (core/src/client.rs:918-925). - */ -function stripItemIdsWhenUnstored(body: unknown): unknown { - if (!isPlainObject(body) || body.store !== false) return body; - if (!Array.isArray(body.input)) return body; - - let changed = false; - const input = body.input.map(item => { - if (!isPlainObject(item) || !("id" in item)) return item; - changed = true; - const next = { ...item }; - delete next.id; - return next; - }); - - return changed ? { ...body, input } : body; -} - -/** - * Normalize replayed compaction items for the destination backend. - * - * A compaction item carries an `encrypted_content` blob the client replays verbatim on every later - * turn, and only the backend that minted it can decode it. Proxy-minted `ocx1:` envelopes are - * transparent base64 rather than encryption, so no upstream can read them and they always become - * plain user messages. Native blobs have multiple possible minters, so a destination's ability to - * decode its own blobs does not make a blob from a previous serving identity portable. On a known - * identity mismatch the blob degrades to the same note the bridged parser uses, even when the - * destination normally accepts native blobs. Without a known mismatch, the destination capability - * keeps the existing behavior. - * - * A bare `context_compaction` marker carries no blob and is forwarded untouched. - */ -function scrubOcxCompactionItems( - body: unknown, - destinationDecodesNativeBlob: boolean, - threadServingIdentityChanged: boolean, -): unknown { - if (!isPlainObject(body) || !Array.isArray(body.input)) return body; - - let changed = false; - const input = body.input.map(item => { - if (!isPlainObject(item) || !isCompactionItemType(item.type)) return item; - const encrypted = typeof item.encrypted_content === "string" ? item.encrypted_content : undefined; - if (encrypted === undefined) return item; - if ( - decodeCompactionSummary(encrypted) === null - && destinationDecodesNativeBlob - && !threadServingIdentityChanged - ) return item; - changed = true; - return { - type: "message", - role: "user", - content: [{ type: "input_text", text: compactionItemToText(encrypted) }], - }; - }); - - return changed ? { ...body, input } : body; -} - -/** - * GPT-5.6 retired the legacy 24-hour retention field, and the ChatGPT backend 400s the whole - * request when that field is present (issue #2092). - * - * The retired field is NOT translated to the replacement: 5.6 carries a different TTL contract, - * and implicit caching still applies when the caller sent no replacement options. Inventing a - * value here would silently change a caching decision the caller never made. - * - * Deliberately narrow on both axes, because a wider strip is a behavior change rather than a fix: - * only the gpt-5.6 family (an older model may still honor the field), and only on the canonical - * ChatGPT backend, which is the deployment that rejects it. Matching is exact-or-dashed-prefix so - * a future `gpt-5.60` is not swept up by a bare `startsWith`. - */ -function stripDeprecatedPromptCacheRetention(body: unknown, modelId: unknown): unknown { - if (!isPlainObject(body)) return body; - if (typeof modelId !== "string") return body; - if (modelId !== "gpt-5.6" && !modelId.startsWith("gpt-5.6-")) return body; - if (!Object.hasOwn(body, "prompt_cache_retention")) return body; - const { prompt_cache_retention: _retention, ...rest } = body; - return rest; -} - -/** - * Public Responses clients can send `prompt_cache_options`, but the canonical ChatGPT Codex - * backend rejects the top-level field before inference (issue #2765). Custom forward gateways and - * API-key Responses providers own different wire contracts, so the caller applies this only after - * the canonical destination predicate succeeds. - */ -function stripCanonicalForwardPromptCacheOptions(body: unknown): unknown { - if (!isPlainObject(body) || !Object.hasOwn(body, "prompt_cache_options")) return body; - const { prompt_cache_options: _options, ...rest } = body; - return rest; -} - -/** - * A false model capability prevents Codex from emitting summary fields after the catalog refresh. - * Strip them here as well so an already-running client with a stale catalog cannot keep sending an - * upstream-rejected `reasoning_summary_delivery` value (issue #323). - */ -function stripDisabledReasoningSummaries( - body: unknown, - provider: OcxProviderConfig, - modelId: string, -): unknown { - if (modelRecordValue(provider.modelSupportsReasoningSummaries, modelId) !== false || !isPlainObject(body)) { - return body; - } - - let changed = false; - let streamOptions = body.stream_options; - if (isPlainObject(streamOptions) && Object.hasOwn(streamOptions, "reasoning_summary_delivery")) { - const { reasoning_summary_delivery: _delivery, ...rest } = streamOptions; - streamOptions = rest; - changed = true; - } - - let reasoning = body.reasoning; - if (isPlainObject(reasoning)) { - const { summary: _summary, generate_summary: _generateSummary, ...rest } = reasoning; - if (_summary !== undefined || _generateSummary !== undefined) { - reasoning = rest; - changed = true; - } - } - - if (!changed) return body; - return { - ...body, - ...(isPlainObject(streamOptions) && Object.keys(streamOptions).length > 0 - ? { stream_options: streamOptions } - : { stream_options: undefined }), - ...(isPlainObject(reasoning) && Object.keys(reasoning).length > 0 - ? { reasoning } - : { reasoning: undefined }), - }; -} - -/** - * Hide a no-op Responses verbosity control from the wire as well as the catalog. This runs at - * final serialization so a stale catalog or direct caller cannot bypass the capability. Other - * `text` settings (notably structured-output `format`) remain untouched. - */ -function stripDisabledVerbosity( - body: unknown, - provider: OcxProviderConfig, - modelId: string, -): unknown { - if (modelRecordValue(provider.modelSupportsVerbosity, modelId) !== false || !isPlainObject(body)) { - return body; - } - if (!isPlainObject(body.text) || !Object.hasOwn(body.text, "verbosity")) return body; - const { verbosity: _verbosity, ...rest } = body.text; - return { - ...body, - ...(Object.keys(rest).length > 0 ? { text: rest } : { text: undefined }), - }; -} - -/** - * Normalize only the delivery enum Codex already emitted. Do not inject a field into callers that - * did not request summaries, and leave every unconfigured provider/model byte-for-byte unchanged. - */ -function normalizeConfiguredReasoningSummaryDelivery( - body: unknown, - provider: OcxProviderConfig, - modelId: string, -): unknown { - const delivery = modelRecordValue(provider.modelReasoningSummaryDelivery, modelId); - if (delivery === undefined || !isPlainObject(body) || !isPlainObject(body.stream_options)) return body; - if (!Object.hasOwn(body.stream_options, "reasoning_summary_delivery")) return body; - if (body.stream_options.reasoning_summary_delivery === delivery) return body; - return { - ...body, - stream_options: { - ...body.stream_options, - reasoning_summary_delivery: delivery, - }, - }; -} - -function isPlainObject(v: unknown): v is Record { - return !!v && typeof v === "object" && !Array.isArray(v); -} - -/** - * Apply the routed provider's real effort ladder to an existing Responses reasoning field. - * Native forward requests keep the server-owned native clamp; unknown third-party ladders stay - * byte-equivalent instead of acquiring a policy from this adapter. - */ -function mapRoutedResponsesReasoningEffort( - body: unknown, - provider: OcxProviderConfig, - modelId: string, -): unknown { - if (provider.authMode === "forward") return body; - if (configuredReasoningEfforts(provider, modelId) === undefined) return body; - if (!isPlainObject(body) || !isPlainObject(body.reasoning)) return body; - const declaredEfforts = modelRecordValue(provider.modelReasoningEfforts, modelId) ?? provider.reasoningEfforts; - // An explicitly empty ladder means no effort control, not no reasoning output. - // Omit only effort so the upstream default applies; unknown/non-rankable ladders stay untouched. - if (declaredEfforts?.length === 0 && Object.hasOwn(body.reasoning, "effort")) { - const { effort: _effort, ...reasoning } = body.reasoning; - return { ...body, reasoning: Object.keys(reasoning).length > 0 ? reasoning : undefined }; - } - const requested = body.reasoning.effort; - if (typeof requested !== "string") return body; - - const mapped = mapReasoningEffort(provider, modelId, requested); - if (!mapped || mapped === requested) return body; - return { ...body, reasoning: { ...body.reasoning, effort: mapped } }; -} - -function normalizeFunctionToolSchema(tool: unknown, xaiTarget: boolean): unknown | undefined { - if (!isPlainObject(tool) || tool.type !== "function") return tool; - // Runs for every Responses destination, forward auth included: the ChatGPT backend is where - // the `\p{…}` rejection was observed, and it reaches this function through the same seam. - const compatible = stripUnicodePropertyPatterns(tool); - const source = isPlainObject(compatible) ? compatible : tool; - if (xaiTarget) { - const parameters = normalizeXaiToolParameters(isPlainObject(source.parameters) ? source.parameters : {}); - return parameters === undefined ? undefined : { ...source, parameters }; - } - if (isPlainObject(source.parameters) && source.parameters.type === "object") return source; - return { - ...source, - parameters: { ...(isPlainObject(source.parameters) ? source.parameters : {}), type: "object" }, - }; -} - -/** - * Re-point `tool_choice` after an incompatible function was dropped from the catalog. Names here - * are already wire names, because namespace lowering rewrote the declarations and the selector - * together before this runs. A selector left naming an omitted tool reaches Grok as a dangling - * reference it rejects, and silently relaxing it to `auto` is worse: the turn would quietly - * proceed without the tool the caller required. So an `allowed_tools` list drops the omitted - * entries while any remain, and a selection with nothing left to point at fails locally with the - * same 400 the caller gets for a tool catalog this proxy cannot lower. - */ -function reconcileToolChoiceForOmittedTools( - body: Record, - omittedFunctionNames: ReadonlySet, -): Record { - if (omittedFunctionNames.size === 0) return body; - const toolChoice = body.tool_choice; - if (!isPlainObject(toolChoice)) return body; - - const refuse = (name: string): never => { - throw new XaiToolSchemaCompatibilityError( - `tool_choice requires function "${name}", but its parameter schema cannot be represented for this destination; ` - + "relax tool_choice or simplify the tool's parameter schema", - ); - }; - - if (toolChoice.type === "function" && typeof toolChoice.name === "string") { - return omittedFunctionNames.has(toolChoice.name) ? refuse(toolChoice.name) : body; - } - - if (toolChoice.type === "allowed_tools" && Array.isArray(toolChoice.tools)) { - const omitted = toolChoice.tools.filter(tool => - isPlainObject(tool) - && tool.type === "function" - && typeof tool.name === "string" - && omittedFunctionNames.has(tool.name)); - if (omitted.length === 0) return body; - const kept = toolChoice.tools.filter(tool => !omitted.includes(tool)); - if (kept.length === 0) { - const first = omitted[0]; - return refuse(isPlainObject(first) && typeof first.name === "string" ? first.name : "unknown"); - } - return { ...body, tool_choice: { ...toolChoice, tools: kept } }; - } - - return body; -} - -function normalizeToolSchemas(body: unknown, xaiTarget: boolean): unknown { - if (!isPlainObject(body)) return body; - - const omittedFunctionNames = new Set(); - const normalizeTools = (tools: unknown[]): unknown[] => { - let changed = false; - const normalized: unknown[] = []; - for (const tool of tools) { - const fixed = normalizeFunctionToolSchema(tool, xaiTarget); - if (fixed === undefined) { - changed = true; - if (isPlainObject(tool) && typeof tool.name === "string") omittedFunctionNames.add(tool.name); - continue; - } - if (fixed !== tool) changed = true; - normalized.push(fixed); - } - return changed ? normalized : tools; - }; - - let normalizedBody = body; - if (Array.isArray(body.tools)) { - const tools = normalizeTools(body.tools); - if (tools !== body.tools) normalizedBody = { ...normalizedBody, tools }; - } - if (Array.isArray(normalizedBody.input)) { - let inputChanged = false; - const input = normalizedBody.input.map((item) => { - if (!isPlainObject(item) || item.type !== "additional_tools" || !Array.isArray(item.tools)) return item; - const tools = normalizeTools(item.tools); - if (tools === item.tools) return item; - inputChanged = true; - return { ...item, tools }; - }); - if (inputChanged) normalizedBody = { ...normalizedBody, input }; - } - if (omittedFunctionNames.size > 0) { - // A dropped tool is a capability the caller declared and will not get, and the only other - // trace of it is a turn that never makes the call. Name them so the cause is recoverable. - debugProviderDiagnostic("openai-responses", "tool-schema-omitted", { - omitted: [...omittedFunctionNames], - }); - } - return reconcileToolChoiceForOmittedTools(normalizedBody, omittedFunctionNames); -} - -function activateDeferredTool(tool: Record): Record { - const { defer_loading: _, ...activeTool } = tool; - if (tool.type !== "namespace" || !Array.isArray(tool.tools)) return activeTool; - return { - ...activeTool, - tools: tool.tools.map(inner => isPlainObject(inner) ? activateDeferredTool(inner) : inner), - }; -} - -function mergeLoadedTools(declaredTools: unknown[], loadedTools: unknown[]): unknown[] { - const merged = [...declaredTools]; - let changed = false; - - for (const candidate of loadedTools) { - if (!isPlainObject(candidate) || typeof candidate.name !== "string") continue; - const loaded = activateDeferredTool(candidate); - if (loaded.type === "namespace" && Array.isArray(loaded.tools)) { - const namespaceIndex = merged.findIndex(tool => - isPlainObject(tool) && tool.type === "namespace" && tool.name === loaded.name - ); - if (namespaceIndex < 0) { - merged.push(loaded); - changed = true; - continue; - } - - const namespace = merged[namespaceIndex]; - if (!isPlainObject(namespace)) continue; - const namespaceTools = Array.isArray(namespace.tools) ? namespace.tools : []; - const nextNamespaceTools = [...namespaceTools]; - let namespaceChanged = "defer_loading" in namespace; - for (const tool of loaded.tools) { - if (!isPlainObject(tool) || typeof tool.name !== "string") continue; - const declaredIndex = nextNamespaceTools.findIndex(declared => - isPlainObject(declared) && declared.name === tool.name - ); - if (declaredIndex < 0) { - nextNamespaceTools.push(tool); - namespaceChanged = true; - continue; - } - const declared = nextNamespaceTools[declaredIndex]; - if (isPlainObject(declared) && "defer_loading" in declared) { - nextNamespaceTools[declaredIndex] = activateDeferredTool(declared); - namespaceChanged = true; - } - } - if (!namespaceChanged) continue; - const { defer_loading: _, ...activeNamespace } = namespace; - merged[namespaceIndex] = { ...activeNamespace, tools: nextNamespaceTools }; - changed = true; - continue; - } - - const declaredIndex = merged.findIndex(tool => - isPlainObject(tool) && tool.type !== "namespace" && tool.name === loaded.name - ); - if (declaredIndex < 0) { - merged.push(loaded); - changed = true; - } else { - const declared = merged[declaredIndex]; - if (isPlainObject(declared) && "defer_loading" in declared) { - merged[declaredIndex] = activateDeferredTool(declared); - changed = true; - } - } - } - - return changed ? merged : declaredTools; -} - -/** - * Client-executed tool search only changes Codex's parsed tool context. Routed passthrough keeps - * serializing the raw request, so activate those returned definitions for upstreams that do not - * implement the native deferred-loading handshake themselves. - */ -function promoteClientLoadedTools(body: unknown): unknown { - if (!isPlainObject(body) || !Array.isArray(body.input)) return body; - - const loadedTools = body.input.flatMap(item => - isPlainObject(item) && item.type === "tool_search_output" && Array.isArray(item.tools) - ? item.tools - : [] - ); - if (loadedTools.length === 0) return body; - - if (Array.isArray(body.tools)) { - const tools = mergeLoadedTools(body.tools, loadedTools); - return tools === body.tools ? body : { ...body, tools }; - } - - const additionalToolsIndex = body.input.findIndex(item => - isPlainObject(item) && item.type === "additional_tools" && Array.isArray(item.tools) - ); - if (additionalToolsIndex < 0) return { ...body, tools: mergeLoadedTools([], loadedTools) }; - - const additionalTools = body.input[additionalToolsIndex]; - if (!isPlainObject(additionalTools) || !Array.isArray(additionalTools.tools)) return body; - const tools = mergeLoadedTools(additionalTools.tools, loadedTools); - if (tools === additionalTools.tools) return body; - const input = [...body.input]; - input[additionalToolsIndex] = { ...additionalTools, tools }; - return { ...body, input }; -} - -const MAX_RESPONSES_CALL_ID_LENGTH = 64; - -const REPAIRED_CALL_ID_PREFIX = "call_ocx_"; -const REPAIRED_CALL_ID_DIGEST_LENGTH = MAX_RESPONSES_CALL_ID_LENGTH - REPAIRED_CALL_ID_PREFIX.length; - -/** - * The ChatGPT Responses backend rejects input `call_id` values longer than 64 characters. Codex - * sidechat/fork replay can namespace call ids from routed providers past that limit. Forward mode - * already sends explicit replay input without `previous_response_id`, so it is safe to replace each - * oversized id and every matching call/output occurrence with one deterministic request-local alias. - * Raw API-key continuations are intentionally excluded because an output-only continuation may - * reference a call stored upstream under the original id. Proxy-expanded API-key replays are - * explicit and stateless here, so they are safe to repair too. - */ -function repairOversizedReplayCallIds(body: unknown): unknown { - if (!isPlainObject(body) || !Array.isArray(body.input)) return body; - - const occupied = new Set(); - for (const item of body.input) { - if (!isPlainObject(item) || typeof item.call_id !== "string") continue; - if (item.call_id.length <= MAX_RESPONSES_CALL_ID_LENGTH) occupied.add(item.call_id); - } - - const aliases = new Map(); - let changed = false; - const input = body.input.map(item => { - if (!isPlainObject(item) || typeof item.call_id !== "string") return item; - const original = item.call_id; - if (original.length <= MAX_RESPONSES_CALL_ID_LENGTH) return item; - - let alias = aliases.get(original); - if (!alias) { - let salt = 0; - do { - const hashInput = salt === 0 ? original : `${original}\0${salt}`; - const digest = createHash("sha256").update(hashInput).digest("hex"); - alias = `${REPAIRED_CALL_ID_PREFIX}${digest.slice(0, REPAIRED_CALL_ID_DIGEST_LENGTH)}`; - salt += 1; - } while (occupied.has(alias)); - aliases.set(original, alias); - occupied.add(alias); - } - - changed = true; - return { ...item, call_id: alias }; - }); - - return changed ? { ...body, input } : body; -} - -/** Flatten a Responses tool-output `output` value (string or content-part array) to plain text. */ -function toolOutputText(output: unknown): string { - if (typeof output === "string") return output; - if (!Array.isArray(output)) return JSON.stringify(output ?? ""); - return output.map(part => { - if (!isPlainObject(part)) return ""; - if (typeof part.text === "string") return part.text; - if (part.type === "refusal" && typeof part.refusal === "string") return `[refusal] ${part.refusal}`; - return ""; - }).filter(Boolean).join("\n"); -} - -/** True when an output can be losslessly represented as user-message content. */ -function isRepairableToolOutput(output: unknown): output is string | Record[] { - if (typeof output === "string") return true; - if (!Array.isArray(output)) return false; - return output.every(part => { - if (!isPlainObject(part)) return false; - if (typeof part.type !== "string") return false; - if (["output_text", "text", "input_text"].includes(part.type)) { - return typeof part.text === "string"; - } - if (part.type === "refusal") return typeof part.refusal === "string"; - if (part.type === "encrypted_content") return typeof part.encrypted_content === "string"; - if (part.type !== "input_image") return false; - const imageUrl = part.image_url; - const fileId = part.file_id; - const imageUrlIsString = typeof imageUrl === "string"; - const fileIdIsString = typeof fileId === "string"; - const hasUsableSource = (imageUrlIsString && imageUrl.length > 0) - || (fileIdIsString && fileId.length > 0); - const validSource = hasUsableSource - && (part.image_url === undefined || imageUrlIsString) - && (part.file_id === undefined || fileIdIsString); - const validDetail = part.detail === undefined - || (typeof part.detail === "string" - && ["auto", "low", "high", "original"].includes(part.detail)); - return validSource && validDetail; - }); -} - -/** Convert orphaned tool output to user-message content without discarding valid images. */ -function orphanedToolOutputContent(output: unknown, callId = ""): Record[] { - const marker = `[tool output for ${callId || "unknown call"}]`; - if (typeof output !== "string" && !Array.isArray(output)) { - return [{ type: "input_text", text: marker }]; - } - if (!Array.isArray(output)) { - return [{ type: "input_text", text: `${marker}\n${toolOutputText(output)}` }]; - } - - const content: Record[] = [{ type: "input_text", text: marker }]; - for (const part of output) { - if (!isPlainObject(part)) continue; - if (part.type === "input_image") { - content.push(part); - } else if (part.type === "encrypted_content" && typeof part.encrypted_content === "string") { - content.push({ type: "input_text", text: "[encrypted content omitted]" }); - } else if (typeof part.text === "string") { - content.push({ type: "input_text", text: part.text }); - } else if (part.type === "refusal" && typeof part.refusal === "string") { - content.push({ type: "input_text", text: `[refusal] ${part.refusal}` }); - } - } - return content; -} - -/** True when a Responses tool output item is present but carries no usable content. */ -function isToolOutputEmpty(output: unknown): boolean { - if (typeof output === "string") return output.trim() === ""; - if (Array.isArray(output)) { - // Mirror the Chat wire rule through the shared contract: only a pure - // text/refusal part array whose joined content trims empty is annotated. - // input_image, encrypted_content, input_file and any other non-text part is - // real output and must never be replaced. - return isWhitespaceOnlyTextPartArray(output); - } - // A missing or null `output` is not a present-but-empty result: it is an - // incomplete payload. Leave it untouched so the upstream contract fails - // closed, and the orphan repair can surface it honestly instead of claiming - // the tool ran with no output. - return false; -} - -/** - * Rewrite present-but-empty tool outputs to an explicit annotation. Synthetic - * missing-result placeholders are non-empty and pass through untouched. No-op unless - * the provider opts in (`annotateEmptyToolOutputs`). - */ -function annotateEmptyResponsesToolOutputs(body: unknown, enabled: boolean): unknown { - if (!enabled || !isPlainObject(body) || !Array.isArray(body.input)) return body; - let changed = false; - const input = body.input.map(item => { - if (!isPlainObject(item) || (item.type !== "function_call_output" && item.type !== "custom_tool_call_output")) return item; - if (!isToolOutputEmpty(item.output)) return item; - changed = true; - return { ...item, output: EMPTY_TOOL_OUTPUT_ANNOTATION }; - }); - return changed ? { ...body, input } : body; -} - -/** - * Preserve the text of structurally invalid tool-output items before they reach a strict - * Responses parser. Stateful destinations may legitimately receive an output whose matching - * call lives behind `previous_response_id`, so ordinary orphan repair cannot run universally. - * A missing or empty `call_id`, however, cannot identify stored state on any destination. - */ -function repairUnidentifiedToolOutputItems(body: unknown): unknown { - if (!isPlainObject(body) || !Array.isArray(body.input)) return body; - let changed = false; - const input = body.input.map(item => { - if (!isPlainObject(item) - || (item.type !== "function_call_output" && item.type !== "custom_tool_call_output") - || (typeof item.call_id === "string" && item.call_id.length > 0)) { - return item; - } - if (!isRepairableToolOutput(item.output)) return item; - changed = true; - return { - type: "message", - role: "user", - content: orphanedToolOutputContent(item.output), - }; - }); - return changed ? { ...body, input } : body; -} - -/** - * Repair a forward-mode input array whose continuation context was lost. When the replay - * expansion misses (proxy restart, unrecorded prior turn), previous_response_id is stripped - * (the ChatGPT backend rejects it), so the delta may carry items that reference now-absent - * prior items and 400 upstream: - * - `function_call`/`local_shell_call`/`custom_tool_call` without their paired output item - * ("No tool output found for tool call "). A stateless upstream cannot resolve - * the pair from its own storage, so a placeholder output is synthesized to keep the - * turn continuable without pretending the result was real. Synthetic outputs are - * emitted after the complete parallel call batch, in call order alongside any real - * outputs, so the adjacency normalizer can still recognize the batch as one - * reasoning-bearing assistant turn (#1477). Gated on - * `synthesizeMissingCallOutputs` (stateless AND non-forward wires); forward replay keeps - * fail-closed behavior. - * - `function_call_output`/`custom_tool_call_output` without their paired call item - * ("No tool call found for function call output with call_id ..."). Converted to user - * messages so the result text survives. `function_call_output` also pairs with - * `local_shell_call` (codex-rs emits shell outputs as function_call_output). - * - `reasoning` items ("Item 'rs_*' ... was provided without its required following item"). - * Dropped, but only when `dropReasoning` (unexpanded miss): on a replay hit the prior - * reasoning chain is intact and must be preserved. - * Runs on every forward request; with intact pairs it returns the original reference. - */ -/** - * Repair a replayed `web_search_call` action that is missing either key. - * - * `webSearchAction()` in the bridge now emits both keys, but that only helps items - * created after the fix. A conversation that already recorded - * `{type:"search", query:"..."}` or `{type:"search", queries:[...]}` replays that stored - * item on every subsequent turn. DeepSeek's native Responses parser requires `queries` - * (#930) and Console Go's validator requires `query` (#3071), so upgrading alone leaves - * those threads permanently 400ing in one direction or the other. The repair runs both - * ways. - * - * Input items carry a loose schema, so a stored `queries` is not necessarily an array of - * strings. A partly- or wholly-malformed array is left alone rather than used as a source - * for the singular field: writing `query: 123` would satisfy the presence check and still - * fail the validator this repair exists to satisfy, and deriving `query` from - * `["a", 42]` would satisfy Console Go while leaving DeepSeek to reject the same replay. - * An empty `queries: []` canonicalizes to the shape the bridge emits for an empty search, - * keeping an existing `query` when the item has one. - * - * Runs on every Responses request, on both `input` items and the `action` nested inside - * them. Returns the original reference when nothing needs repair, so the common path - * allocates nothing. - */ -function backfillWebSearchQueries(body: unknown): unknown { - if (!isPlainObject(body) || !Array.isArray(body.input)) return body; - let changed = false; - const input = body.input.map(item => { - if (!isPlainObject(item) || item.type !== "web_search_call") return item; - const action = item.action; - if (!isPlainObject(action) || action.type !== "search") return item; - // Repair whichever side is missing so both strict parsers pass: - // DeepSeek native Responses requires `queries`; Console Go requires `query`. - const rep: Record = { ...action }; - let itemChanged = false; - const hasQuery = typeof action.query === "string"; - const queries = Array.isArray(action.queries) ? action.queries : undefined; - if (queries !== undefined && queries.length === 0) { - // An empty array satisfies neither validator. Canonicalize to the empty-search - // shape the bridge emits, keeping an existing query rather than discarding it. - const query = hasQuery ? action.query as string : ""; - rep.query = query; - rep.queries = [query]; - itemChanged = true; - } else if (!hasQuery && queries !== undefined) { - // A plural array is only a usable source for the singular field when EVERY member - // is a string: deriving `query` from a partly-malformed array would satisfy Console - // Go while leaving DeepSeek to reject the same replay. Wholly malformed arrays are - // left untouched — coercing or dropping members would invent semantics the stored - // item never had. - if (queries.every(entry => typeof entry === "string")) { - rep.query = queries[0]; // multi-query item recorded before the fix - itemChanged = true; - } - } else if (hasQuery && queries === undefined) { - rep.queries = [action.query]; // single-query item recorded before the fix - itemChanged = true; - } - if (itemChanged) changed = true; - return itemChanged ? { ...item, action: rep } : item; - }); - return changed ? { ...body, input } : body; -} - -function repairOrphanedInputItems(body: unknown, dropReasoning: boolean, synthesizeMissingCallOutputs = false): unknown { - if (!isPlainObject(body) || !Array.isArray(body.input)) return body; - const input = body.input; - - const functionCallIds = new Set(); - const customCallIds = new Set(); - const functionOutputIds = new Set(); - const customOutputIds = new Set(); - for (const item of input) { - if (!isPlainObject(item) || typeof item.call_id !== "string") continue; - if (item.type === "function_call" || item.type === "local_shell_call") functionCallIds.add(item.call_id); - else if (item.type === "custom_tool_call") customCallIds.add(item.call_id); - else if (item.type === "function_call_output") functionOutputIds.add(item.call_id); - else if (item.type === "custom_tool_call_output") customOutputIds.add(item.call_id); - } - - let changed = false; - const repaired: unknown[] = []; - const syntheticKeys = new Set(); - const pendingSyntheticOutputs: unknown[] = []; - const flushPendingSyntheticOutputs = (): void => { - if (pendingSyntheticOutputs.length === 0) return; - repaired.push(...pendingSyntheticOutputs); - pendingSyntheticOutputs.length = 0; - }; - for (const item of input) { - if (!isPlainObject(item)) { flushPendingSyntheticOutputs(); repaired.push(item); continue; } - if (dropReasoning && item.type === "reasoning") { changed = true; continue; } - const isFnOutput = item.type === "function_call_output"; - const isCustomOutput = item.type === "custom_tool_call_output"; - if (isFnOutput || isCustomOutput) { - flushPendingSyntheticOutputs(); - const callId = typeof item.call_id === "string" ? item.call_id : ""; - const paired = isFnOutput ? functionCallIds.has(callId) : customCallIds.has(callId); - const usableOutput = isRepairableToolOutput(item.output); - // A known orphan call is still useful as a labeled user message even when its output is - // incomplete. With no call id and no output, preserve the invalid item so validation fails - // closed rather than pretending any tool result exists. - const knownNullOutput = callId.length > 0 && item.output == null; - if (!paired && (knownNullOutput || usableOutput)) { - changed = true; - repaired.push({ - type: "message", - role: "user", - content: orphanedToolOutputContent(item.output, callId), - }); - continue; - } - } - const isFnCall = item.type === "function_call" || item.type === "local_shell_call"; - const isCustomCall = item.type === "custom_tool_call"; - if (isFnCall || isCustomCall) { - repaired.push(item); - if (synthesizeMissingCallOutputs) { - const callId = typeof item.call_id === "string" ? item.call_id : ""; - const hasOutput = isFnCall ? functionOutputIds.has(callId) : customOutputIds.has(callId); - if (!hasOutput && callId) { - changed = true; - const name = typeof item.name === "string" && item.name.length > 0 ? item.name : callId; - const text = `[ocx] no tool result was recorded for "${name}"; execution status unknown — do not treat this as success, failure, or user-provided input.`; - syntheticKeys.add(`${isFnCall ? "function" : "custom"}:${callId}`); - pendingSyntheticOutputs.push(isFnCall - ? { type: "function_call_output", call_id: callId, output: text } - : { type: "custom_tool_call_output", call_id: callId, output: text }); - } - } - continue; - } - flushPendingSyntheticOutputs(); - repaired.push(item); - } - flushPendingSyntheticOutputs(); - - const callKeyOf = (item: unknown): string | null => { - if (!isPlainObject(item) || typeof item.call_id !== "string") return null; - if (item.type === "function_call" || item.type === "local_shell_call") return `function:${item.call_id}`; - if (item.type === "custom_tool_call") return `custom:${item.call_id}`; - return null; - }; - const outputKeyOf = (item: unknown): string | null => { - if (!isPlainObject(item) || typeof item.call_id !== "string") return null; - if (item.type === "function_call_output") return `function:${item.call_id}`; - if (item.type === "custom_tool_call_output") return `custom:${item.call_id}`; - return null; - }; - const reorderBatchOutputs = (items: unknown[]): unknown[] => { - const ordered: unknown[] = []; - const claimedOutputIndexes = new Set(); - const outputIndexesByKey = new Map(); - for (let outputIndex = 0; outputIndex < items.length; outputIndex += 1) { - const outputKey = outputKeyOf(items[outputIndex]); - if (outputKey === null) continue; - const bucket = outputIndexesByKey.get(outputKey); - if (bucket) bucket.indexes.push(outputIndex); - else outputIndexesByKey.set(outputKey, { indexes: [outputIndex], offset: 0 }); - } - let index = 0; - while (index < items.length) { - if (claimedOutputIndexes.has(index)) { index += 1; continue; } - const key = callKeyOf(items[index]); - if (key === null) { ordered.push(items[index]); index += 1; continue; } - const batch: unknown[] = []; - const batchKeys: string[] = []; - let cursor = index; - while (cursor < items.length) { - const nextKey = callKeyOf(items[cursor]); - if (nextKey === null) break; - batch.push(items[cursor]); - batchKeys.push(nextKey); - cursor += 1; - } - const hasSynthetic = batchKeys.some(batchKey => syntheticKeys.has(batchKey)); - if (!hasSynthetic) { - ordered.push(...batch); - index = cursor; - continue; - } - const batchOutputs: unknown[] = []; - for (const batchKey of batchKeys) { - const bucket = outputIndexesByKey.get(batchKey); - if (!bucket) continue; - while (bucket.offset < bucket.indexes.length && bucket.indexes[bucket.offset]! < cursor) { - bucket.offset += 1; - } - while (bucket.offset < bucket.indexes.length) { - const outputIndex = bucket.indexes[bucket.offset]!; - bucket.offset += 1; - if (claimedOutputIndexes.has(outputIndex)) continue; - claimedOutputIndexes.add(outputIndex); - batchOutputs.push(items[outputIndex]); - break; - } - } - ordered.push(...batch, ...batchOutputs); - index = cursor; - } - return ordered; - }; - - return changed ? { ...body, input: reorderBatchOutputs(repaired) } : body; -} - -/** - * Make unambiguous Responses tool batches contiguous for upstream parsers that require it. - * - * [Decision Log] - * - 목적과 의도: Keep Codex hook-injected developer context without splitting a parallel tool-call turn away from its reasoning or making a strict upstream reject matching results. - * - 기존 구현 및 제약 조건: The orphan repair verifies only pair presence, while the original pair-by-pair reorder turned `reasoning, call A, call B, output A, output B` into two assistant turns and made DeepSeek reject call B for missing reasoning (#1477). - * - 검토한 주요 대안: Disable parallel calls (DeepSeek always enables them); duplicate reasoning per call; reorder each pair; or normalize the complete unambiguous call batch. - * - 선택한 방식: Treat calls emitted before the first matched result as one batch, emit all calls followed by their matched outputs, and preserve intervening non-tool items immediately after the batch. - * - 다른 대안 대신 이 방식을 선택한 이유: Batch normalization matches the Responses parallel-call shape without fabricating reasoning, while the provider gate and unique-pair requirement keep the blast radius narrow. - * - 장점, 단점 및 영향: DeepSeek keeps one reasoning-bearing assistant turn for parallel calls and still accepts hook-interleaved single calls; tolerant providers stay byte/order equivalent, and duplicate, missing, or backwards call/result pairs are not guessed. - */ -function normalizeResponsesToolResultAdjacency(body: unknown): unknown { - if (!isPlainObject(body) || !Array.isArray(body.input)) return body; - const input = body.input; - const calls = new Map(); - const outputs = new Map(); - - const appendIndex = (map: Map, key: string, index: number): void => { - const existing = map.get(key); - if (existing) existing.push(index); - else map.set(key, [index]); - }; - - for (let index = 0; index < input.length; index += 1) { - const item = input[index]; - if (!isPlainObject(item) || typeof item.call_id !== "string" || item.call_id.length === 0) continue; - if (item.type === "function_call" || item.type === "local_shell_call") { - appendIndex(calls, `function:${item.call_id}`, index); - } else if (item.type === "custom_tool_call") { - appendIndex(calls, `custom:${item.call_id}`, index); - } else if (item.type === "function_call_output") { - appendIndex(outputs, `function:${item.call_id}`, index); - } else if (item.type === "custom_tool_call_output") { - appendIndex(outputs, `custom:${item.call_id}`, index); - } - } - - const pairs: Array<{ callIndex: number; outputIndex: number }> = []; - for (const [key, callIndices] of calls) { - const outputIndices = outputs.get(key); - if (!outputIndices) return body; - if (callIndices.length !== 1 || outputIndices.length !== 1) return body; - const callIndex = callIndices[0]!; - const outputIndex = outputIndices[0]!; - if (outputIndex <= callIndex) return body; - pairs.push({ callIndex, outputIndex }); - } - // Reject any collected output that lacks exactly one matching call. A lone or - // duplicated output is ambiguous, and normalizing on top of it could sever a - // result from the reasoning-bearing call turn it belongs to. - for (const [key, outputIndices] of outputs) { - const callIndices = calls.get(key); - if (!callIndices || callIndices.length !== 1 || outputIndices.length !== 1) return body; - } - pairs.sort((left, right) => left.callIndex - right.callIndex); - - const movedIndices = new Set(); - const batchAt = new Map(); - for (let cursor = 0; cursor < pairs.length;) { - const group = [pairs[cursor]!]; - let firstOutputIndex = pairs[cursor]!.outputIndex; - let next = cursor + 1; - while (next < pairs.length && pairs[next]!.callIndex < firstOutputIndex) { - group.push(pairs[next]!); - firstOutputIndex = Math.min(firstOutputIndex, pairs[next]!.outputIndex); - next += 1; - } - - // Within one reasoning turn the outputs must appear in the same order as their - // calls. If they are reversed, normalizing would fabricate a new output order; - // leave the ambiguous history untouched instead. - for (let groupIndex = 1; groupIndex < group.length; groupIndex += 1) { - if (group[groupIndex]!.outputIndex < group[groupIndex - 1]!.outputIndex) return body; - } - - const batch = [ - ...group.map(pair => input[pair.callIndex]), - ...group.map(pair => input[pair.outputIndex]), - ]; - const anchor = group[0]!.callIndex; - const alreadyContiguous = batch.every((item, offset) => input[anchor + offset] === item); - if (!alreadyContiguous) { - batchAt.set(anchor, batch); - for (const pair of group) { - movedIndices.add(pair.callIndex); - movedIndices.add(pair.outputIndex); - } - } - cursor = next; - } - if (batchAt.size === 0) return body; - - const normalized: unknown[] = []; - for (let index = 0; index < input.length; index += 1) { - const batch = batchAt.get(index); - if (batch) normalized.push(...batch); - if (!movedIndices.has(index)) normalized.push(input[index]); - } - return { ...body, input: normalized }; -} - -/** - * Remove `previous_response_id` before forwarding. Two triggers: - * - the proxy expanded the request into a full input replay (the id is now redundant), or - * - the target is the ChatGPT backend (`authMode: "forward"`), whose Codex REST endpoint - * categorically rejects the parameter with `{"detail":"Unsupported parameter: - * previous_response_id"}` (strict allowlist; it also rejects `metadata` and - * `max_output_tokens`). Codex only sends the id on WS turns, and ocx converts those to - * internal HTTP requests, so forwarding it upstream is a guaranteed 400 — stripping is - * strictly better even when the local replay state missed. API-key mode keeps the field on - * unexpanded requests: the platform `/v1/responses` supports real server-side storage. - */ -function stripPreviousResponseId(body: unknown, strip: boolean): unknown { - if (!strip || !isPlainObject(body) || !Object.prototype.hasOwnProperty.call(body, "previous_response_id")) return body; - const { previous_response_id: _previousResponseId, ...rest } = body; - return rest; -} - -/** Apply the settled tier only to a fresh outbound object; `_rawBody` remains caller-owned. */ -function applyTierDecisionToResponsesBody(body: unknown, decision: TierDecision | undefined): unknown { - if (!decision || decision.kind === "forward-caller" || !isPlainObject(body)) return body; - const next: Record = { ...body }; - if (decision.kind === "set") next.service_tier = decision.value; - else delete next.service_tier; - return next; -} - -/** - * Drop request parameters a stateless Responses upstream cannot implement, and pin - * `store` false. - * - * `previous_response_id` is listed here as well as in `stripPreviousResponseId` - * because that helper's strip is conditional on replay expansion, and it keeps the - * field for API-key providers on the premise that the platform offers real - * server-side storage. DeepSeek documents the opposite: "the API is stateless: - * responses and conversations are not stored on the server", so the field can never - * be honoured regardless of expansion state. - * - * `prompt` is a reference to a server-stored prompt template — the most stateful - * field in the accepted schema. - * - * `service_tier` is deliberately NOT dropped: the final TierDecision is applied to a - * detached outbound body before this sanitizer chain, and silently deleting a configured knob is - * worse than forwarding a parameter the upstream ignores. - * - * MUST run before the composed sanitize chain below: `stripItemIdsWhenUnstored` keys - * off `store === false`, and a stateless upstream cannot resolve a stored item id. - * Returns a copy, so `parsed._rawBody` keeps the client's original `store` value and - * the local replay cache still records the turn. - */ -function stripStatefulResponsesParams(body: unknown): unknown { - if (!isPlainObject(body)) return body; - const drop = ["previous_response_id", "conversation", "background", "metadata", "prompt"] as const; - const present = drop.some(key => Object.prototype.hasOwnProperty.call(body, key)); - if (!present && body.store === false) return body; - const next: Record = { ...body }; - for (const key of drop) delete next[key]; - next.store = false; - return next; -} - -/** - * Remove top-level parameters the ChatGPT backend (`authMode: "forward"`) rejects - * with `{"detail":"Unsupported parameter: …"}` (strict allowlist). Codex CLI never - * sends these — it controls output length via `reasoning.effort` — but third-party - * Responses API clients (GJC, SDK wrappers) include `max_output_tokens` per the - * public spec. `metadata` is likewise absent from the allowlist. No-op when the - * body carries neither field, keeping the common Codex path allocation-free. - */ -function stripUnsupportedForwardParams(body: unknown): unknown { - if (!isPlainObject(body)) return body; - const hasMot = Object.prototype.hasOwnProperty.call(body, "max_output_tokens"); - const hasMeta = Object.prototype.hasOwnProperty.call(body, "metadata"); - if (!hasMot && !hasMeta) return body; - const { max_output_tokens: _mot, metadata: _meta, ...rest } = body; - return rest; -} - -/** Sampling controls the canonical ChatGPT backend rejects; other forward gateways accept them. */ -const CANONICAL_FORWARD_UNSUPPORTED_SAMPLING = ["temperature", "top_p", "stop", "user"] as const; - -/** - * Remove sampling controls only the canonical ChatGPT backend rejects. - * - * A translated Chat turn used to lose these at the Chat ingress for every provider on - * the `openai-responses` adapter, which silently discarded caller intent on generic - * key gateways that accept them. Deciding at the ingress was also unsound for combo - * and policy routes, whose concrete child is chosen later — so the decision belongs - * here, on the provider that actually receives the body. - * - * Returns a copy and never mutates, so `parsed._rawBody` stays caller-owned, and - * no-ops when the body carries none of these keys. - */ -export function stripCanonicalForwardSamplingParams(body: unknown): unknown { - if (!isPlainObject(body)) return body; - if (!CANONICAL_FORWARD_UNSUPPORTED_SAMPLING.some(key => Object.prototype.hasOwnProperty.call(body, key))) { - return body; - } - const next: Record = { ...body }; - for (const key of CANONICAL_FORWARD_UNSUPPORTED_SAMPLING) delete next[key]; - return next; -} - -/** Return the lossless text represented by one system message, or null when it is multimodal. */ -function canonicalForwardSystemText(item: Record): string | null { - const content = item.content; - if (content === undefined) return ""; - if (typeof content === "string") return content; - if (!Array.isArray(content)) return null; - let text = ""; - for (const block of content) { - if (!isPlainObject(block)) return null; - if (block.type !== "input_text" && block.type !== "text") return null; - if (typeof block.text !== "string") return null; - text += block.text; - } - return text; -} - -/** Only message items may carry privileged system instructions. */ -function isCanonicalForwardSystemMessage(item: unknown): item is Record { - return isPlainObject(item) - && (item.type === undefined || item.type === "message") - && item.role === "system"; -} - -/** - * The public Responses API accepts input system messages and `truncation`, but the canonical - * ChatGPT Codex forward endpoint rejects both. Fold only fully textual system messages into the - * existing top-level instructions and remove the unsupported flag at this destination boundary. - * - * The fold is atomic: if any system message contains a non-text block, keep every message in - * place so the proxy never silently drops multimodal content. The backend may still reject that - * unsupported shape, but it will not receive a partially rewritten prompt. - */ -function normalizeCanonicalForwardPromptEnvelope(body: unknown): unknown { - if (!isPlainObject(body)) return body; - const stripTruncation = Object.hasOwn(body, "truncation"); - const input = Array.isArray(body.input) ? body.input : undefined; - if (!input) { - if (!stripTruncation) return body; - const { truncation: _truncation, ...rest } = body; - return rest; - } - - const foldedText: string[] = []; - let sawSystemMessage = false; - let canFoldAllSystemMessages = true; - for (const item of input) { - if (!isCanonicalForwardSystemMessage(item)) continue; - sawSystemMessage = true; - const text = canonicalForwardSystemText(item); - if (text === null) { - canFoldAllSystemMessages = false; - break; - } - foldedText.push(text); - } - if (!stripTruncation && (!sawSystemMessage || !canFoldAllSystemMessages)) return body; - - const next: Record = { ...body }; - if (stripTruncation) delete next.truncation; - if (sawSystemMessage && canFoldAllSystemMessages) { - next.input = input.filter(item => !isCanonicalForwardSystemMessage(item)); - const folded = foldedText.join("\n\n"); - if (folded !== "") { - const existing = typeof body.instructions === "string" ? body.instructions : ""; - next.instructions = existing !== "" ? `${existing}\n\n${folded}` : folded; - } - } - return next; -} - -const POSIT_CACHE_MARKER_MAX_DEPTH = 64; -const POSIT_CACHE_MARKER_MAX_NODES = 100_000; - -type PromptCacheMarkerRewrite = { - value: unknown; - changed: boolean; - complete: boolean; -}; - -/** - * Remove Posit/Anthropic-style prompt-cache markers without trusting request nesting. The walk - * aborts atomically when its depth or node budget is exceeded, so a hostile extension object can - * neither overflow the stack nor receive a partially rewritten subtree. - */ -function stripPromptCacheBreakpoints( - value: unknown, - state: { nodes: number }, - depth = 0, -): PromptCacheMarkerRewrite { - state.nodes += 1; - if (depth > POSIT_CACHE_MARKER_MAX_DEPTH || state.nodes > POSIT_CACHE_MARKER_MAX_NODES) { - return { value, changed: false, complete: false }; - } - if (Array.isArray(value)) { - let changed = false; - const next: unknown[] = []; - for (const entry of value) { - const rewritten = stripPromptCacheBreakpoints(entry, state, depth + 1); - if (!rewritten.complete) return { value, changed: false, complete: false }; - changed ||= rewritten.changed; - next.push(rewritten.value); - } - return { value: changed ? next : value, changed, complete: true }; - } - if (!isPlainObject(value)) return { value, changed: false, complete: true }; - - let changed = Object.hasOwn(value, "prompt_cache_breakpoint"); - const next: Record = {}; - for (const [key, entry] of Object.entries(value)) { - if (key === "prompt_cache_breakpoint") continue; - const rewritten = stripPromptCacheBreakpoints(entry, state, depth + 1); - if (!rewritten.complete) return { value, changed: false, complete: false }; - changed ||= rewritten.changed; - next[key] = rewritten.value; - } - return { value: changed ? next : value, changed, complete: true }; -} - -/** - * Posit Assistant can replay client-only cache markers and stored-item references on a - * `store: false` continuation. The canonical ChatGPT Codex backend rejects both. Remove the - * markers recursively and drop only `item_reference` rows that cannot name persisted state; - * ordinary item ids are handled later by stripItemIdsWhenUnstored and tool call_id pairs remain. - */ -function normalizeCanonicalForwardContinuationEnvelope(body: unknown): unknown { - if (!isPlainObject(body) || !Array.isArray(body.input)) return body; - let input: unknown[] = body.input; - let changed = false; - if (body.store === false) { - const withoutReferences = input.filter(item => !isPlainObject(item) || item.type !== "item_reference"); - if (withoutReferences.length !== input.length) { - input = withoutReferences; - changed = true; - } - } - - const markerRewrite = stripPromptCacheBreakpoints(input, { nodes: 0 }); - if (markerRewrite.complete && markerRewrite.changed) { - input = markerRewrite.value as unknown[]; - changed = true; - } - return changed ? { ...body, input } : body; -} - -const IMAGE_GEN_NAMESPACE = "image_gen"; -const HOSTED_IMAGE_GENERATION_TOOL = "image_generation"; -const IMAGE_GEN_DOTTED_PREFIX = `${IMAGE_GEN_NAMESPACE}.`; -const IMAGE_GEN_WIRE_PREFIX = `${IMAGE_GEN_NAMESPACE}__`; - -/** Remove a supported client prefix before constructing the canonical image-gen wire alias. */ -function imageGenLocalName(name: string): string { - if (name.startsWith(IMAGE_GEN_DOTTED_PREFIX)) return name.slice(IMAGE_GEN_DOTTED_PREFIX.length); - if (name.startsWith(IMAGE_GEN_WIRE_PREFIX)) return name.slice(IMAGE_GEN_WIRE_PREFIX.length); - return name; -} - -/** Build the flat public-Responses name used only on the upstream wire. */ -function imageGenWireName(name: string): string { - return namespacedToolName(IMAGE_GEN_NAMESPACE, imageGenLocalName(name)); -} - -/** Match client image-gen declarations across namespace, legacy dotted, and canonical wire forms. */ -function isImageGenClientName(name: string): boolean { - return name === IMAGE_GEN_NAMESPACE - || name.startsWith(IMAGE_GEN_DOTTED_PREFIX) - || name.startsWith(IMAGE_GEN_WIRE_PREFIX); -} - -/** Identify declarations that should activate image-gen request normalization. */ -function declaresImageGenClientTool(tool: unknown): boolean { - if (!isPlainObject(tool) || typeof tool.name !== "string") return false; - if (tool.type === "namespace") return tool.name === IMAGE_GEN_NAMESPACE; - return isImageGenClientName(tool.name); -} - -/** Rewrite client image-gen selectors to the hosted tool without widening caller restrictions. */ -function preferHostedImageGenToolChoice(toolChoice: unknown): unknown { - if (!isPlainObject(toolChoice)) return toolChoice; - if ((toolChoice.type === "function" || toolChoice.type === "custom") && typeof toolChoice.name === "string") { - return isImageGenClientName(toolChoice.name) ? { type: HOSTED_IMAGE_GENERATION_TOOL } : toolChoice; - } - if (toolChoice.type !== "allowed_tools" || !Array.isArray(toolChoice.tools)) return toolChoice; - const hasHostedImageTool = toolChoice.tools.some(tool => isPlainObject(tool) && tool.type === HOSTED_IMAGE_GENERATION_TOOL); - let changed = false; - let addedHostedImageTool = false; - const tools: unknown[] = []; - for (const tool of toolChoice.tools) { - const isClientImageTool = isPlainObject(tool) - && (tool.type === "function" || tool.type === "custom") - && typeof tool.name === "string" - && isImageGenClientName(tool.name); - if (!isClientImageTool) { - tools.push(tool); - continue; - } - changed = true; - if (!hasHostedImageTool && !addedHostedImageTool) { - tools.push({ type: HOSTED_IMAGE_GENERATION_TOOL }); - addedHostedImageTool = true; - } - } - return changed ? { ...toolChoice, tools } : toolChoice; -} - -/** - * Some Responses-compatible gateways reserve the hosted image namespace even when the request - * does not explicitly declare `image_generation`. For an explicitly configured model, remove only - * colliding client declarations so the gateway's hosted tool can take precedence. - */ -function preferConfiguredHostedTools( - body: unknown, - provider: OcxProviderConfig, - modelId: string, - selectedModelId?: string, -): unknown { - // A virtual model's advertised id takes precedence over its resolved wire-model id. - // Read own properties only: a routed model id of `constructor`/`toString` would - // otherwise resolve to an inherited Object.prototype function and throw on the - // membership test below, failing the request before it is dispatched. - const preferenceMap = provider.modelPreferHostedTools; - const ownPreference = (key: string | undefined): string[] | undefined => { - if (!key || !preferenceMap || !Object.prototype.hasOwnProperty.call(preferenceMap, key)) return undefined; - const entry = preferenceMap[key]; - return Array.isArray(entry) ? entry : undefined; - }; - const preferredTools = ownPreference(selectedModelId) ?? ownPreference(modelId); - if (!preferredTools?.includes(HOSTED_IMAGE_GENERATION_TOOL) || !isPlainObject(body)) return body; - - const stripGroup = (tools: unknown[]): unknown[] => { - const filtered = tools.filter(tool => !declaresImageGenClientTool(tool)); - return filtered.length === tools.length ? tools : filtered; - }; - - let changed = false; - let tools = body.tools; - let strippedTopLevelImageGenTool = false; - if (Array.isArray(body.tools)) { - tools = stripGroup(body.tools); - strippedTopLevelImageGenTool = tools !== body.tools; - changed ||= strippedTopLevelImageGenTool; - } - - let input = body.input; - const strippedAdditionalToolsIndices = new Set(); - if (Array.isArray(body.input)) { - let nestedChanged = false; - const mappedInput = body.input.map((item, index) => { - if (!isPlainObject(item) || item.type !== "additional_tools" || !Array.isArray(item.tools)) return item; - const nestedTools = stripGroup(item.tools); - if (nestedTools === item.tools) return item; - strippedAdditionalToolsIndices.add(index); - nestedChanged = true; - return { ...item, tools: nestedTools }; - }); - if (nestedChanged) { - input = mappedInput; - changed = true; - } - } - - const hasToolChoice = Object.hasOwn(body, "tool_choice"); - const toolChoice = hasToolChoice ? preferHostedImageGenToolChoice(body.tool_choice) : body.tool_choice; - const toolChoiceChanged = hasToolChoice && toolChoice !== body.tool_choice; - const hasHostedImageGenTool = (toolGroup: unknown): boolean => Array.isArray(toolGroup) - && toolGroup.some(tool => isPlainObject(tool) && tool.type === HOSTED_IMAGE_GENERATION_TOOL); - const hasHostedImageGenDeclaration = hasHostedImageGenTool(tools) - || (Array.isArray(input) && input.some(item => isPlainObject(item) - && item.type === "additional_tools" - && hasHostedImageGenTool(item.tools))); - if ((strippedTopLevelImageGenTool || strippedAdditionalToolsIndices.size > 0) && !hasHostedImageGenDeclaration) { - if (strippedTopLevelImageGenTool && Array.isArray(tools)) { - tools = [...tools, { type: HOSTED_IMAGE_GENERATION_TOOL }]; - } else if (strippedAdditionalToolsIndices.size > 0 && Array.isArray(input)) { - // Restore into the FIRST stripped container only. Tool declarations are - // request-scoped, not container-scoped — the containers are separate carriers for - // one tool set, so a single hosted declaration covers the request. An earlier - // revision restored into every stripped container and put `image_generation` on - // the wire twice; review caught it. - const firstStripped = Math.min(...strippedAdditionalToolsIndices); - input = input.map((item, index) => index === firstStripped - && isPlainObject(item) - && Array.isArray(item.tools) - ? { ...item, tools: [...item.tools, { type: HOSTED_IMAGE_GENERATION_TOOL }] } - : item); - } - } - changed ||= toolChoiceChanged; - if (!changed) return body; - const next: Record = { - ...body, - ...(Array.isArray(body.tools) ? { tools } : {}), - ...(Array.isArray(body.input) ? { input } : {}), - }; - if (toolChoiceChanged) next.tool_choice = toolChoice; - return next; -} - -/** - * Lower one complete Codex image-gen namespace to public Responses function tools. - * - * The public API reserves the `image_gen` namespace and restricts function names to a flat safe - * alphabet. `image_gen__` is therefore an upstream-only alias; client-facing responses are - * restored to explicit `{ namespace: "image_gen", name: "" }` calls by the server. Only a - * non-empty namespace containing named function tools is safe to lower. Malformed, empty, and - * future namespace shapes stay untouched instead of silently losing client capabilities. - */ -function flattenImageGenNamespace(tool: unknown): Record[] | undefined { - if ( - !isPlainObject(tool) - || tool.type !== "namespace" - || tool.name !== IMAGE_GEN_NAMESPACE - || !Array.isArray(tool.tools) - || tool.tools.length === 0 - ) return undefined; - - for (const innerTool of tool.tools) { - if ( - !isPlainObject(innerTool) - || innerTool.type !== "function" - || typeof innerTool.name !== "string" - || innerTool.name.length === 0 - ) return undefined; - } - - return tool.tools.map(innerTool => { - const functionTool = innerTool as Record & { name: string }; - return { - ...functionTool, - name: imageGenWireName(functionTool.name), - }; - }); -} - -/** Convert a legacy dotted function declaration while preserving all other function metadata. */ -function normalizeFlatImageGenFunction(tool: unknown): unknown { - if ( - !isPlainObject(tool) - || tool.type !== "function" - || typeof tool.name !== "string" - || !tool.name.startsWith(IMAGE_GEN_DOTTED_PREFIX) - ) return tool; - return { ...tool, name: imageGenWireName(tool.name) }; -} - -/** Return the image-gen function name used for stable cross-container deduplication. */ -function imageGenFunctionName(tool: unknown): string | undefined { - if (!isPlainObject(tool) || tool.type !== "function" || typeof tool.name !== "string") { - return undefined; - } - return isImageGenClientName(tool.name) ? tool.name : undefined; -} - -/** True only when a declaration can yield a callable upstream-safe image-gen function alias. */ -function declaresUsableImageGenAlias(tool: unknown): boolean { - if (flattenImageGenNamespace(tool)) return true; - if (!isPlainObject(tool) || tool.type !== "function" || typeof tool.name !== "string") { - return false; - } - if (tool.name.startsWith(IMAGE_GEN_DOTTED_PREFIX)) { - return tool.name.length > IMAGE_GEN_DOTTED_PREFIX.length; - } - return tool.name.startsWith(IMAGE_GEN_WIRE_PREFIX) - && tool.name.length > IMAGE_GEN_WIRE_PREFIX.length; -} - -/** Collect client tool-choice names and the exact upstream aliases declared for them. */ -function imageGenToolChoiceAliases(toolGroups: unknown[][]): Map { - const aliases = new Map(); - - for (const group of toolGroups) { - for (const tool of group) { - const flattened = flattenImageGenNamespace(tool); - if (flattened) { - for (const candidate of flattened) { - const wireName = candidate.name as string; - aliases.set(`${IMAGE_GEN_DOTTED_PREFIX}${imageGenLocalName(wireName)}`, wireName); - aliases.set(wireName, wireName); - } - continue; - } - if (!isPlainObject(tool) || tool.type !== "function" || typeof tool.name !== "string") { - continue; - } - if ( - tool.name.startsWith(IMAGE_GEN_DOTTED_PREFIX) - && tool.name.length > IMAGE_GEN_DOTTED_PREFIX.length - ) { - aliases.set(tool.name, imageGenWireName(tool.name)); - } else if ( - tool.name.startsWith(IMAGE_GEN_WIRE_PREFIX) - && tool.name.length > IMAGE_GEN_WIRE_PREFIX.length - ) { - aliases.set(tool.name, tool.name); - } - } - } - - return aliases; -} - -/** Rewrite function selectors only when their corresponding declaration receives a wire alias. */ -function normalizeImageGenToolChoice( - toolChoice: unknown, - aliases: ReadonlyMap, -): unknown { - if (!isPlainObject(toolChoice)) return toolChoice; - - if (toolChoice.type === "function" && typeof toolChoice.name === "string") { - const alias = aliases.get(toolChoice.name); - return alias && alias !== toolChoice.name ? { ...toolChoice, name: alias } : toolChoice; - } - - if (toolChoice.type !== "allowed_tools" || !Array.isArray(toolChoice.tools)) return toolChoice; - let changed = false; - const tools = toolChoice.tools.map(tool => { - if (!isPlainObject(tool) || tool.type !== "function" || typeof tool.name !== "string") { - return tool; - } - const alias = aliases.get(tool.name); - if (!alias || alias === tool.name) return tool; - changed = true; - return { ...tool, name: alias }; - }); - return changed ? { ...toolChoice, tools } : toolChoice; -} - -/** Identify replayed image-gen calls that require upstream wire encoding. */ -function declaresImageGenFunctionCall(item: unknown): boolean { - if (!isPlainObject(item) || item.type !== "function_call" || typeof item.name !== "string") { - return false; - } - return item.namespace === IMAGE_GEN_NAMESPACE || isImageGenClientName(item.name); -} - -/** Encode native or legacy replay calls to the same flat name used by tool declarations. */ -function normalizeImageGenFunctionCall(item: unknown): unknown { - if (!declaresImageGenFunctionCall(item) || !isPlainObject(item) || typeof item.name !== "string") { - return item; - } - if (item.namespace === IMAGE_GEN_NAMESPACE) { - const { namespace: _namespace, ...rest } = item; - return { ...rest, name: imageGenWireName(item.name) }; - } - if (item.name.startsWith(IMAGE_GEN_DOTTED_PREFIX)) { - return { ...item, name: imageGenWireName(item.name) }; - } - return item; -} - -/** - * Normalize Codex's private image-gen tool declaration for API-key Responses providers. - * - * A complete `image_gen` namespace is flattened to safe `image_gen__` aliases even when it is - * the only image tool in the request. Replayed client calls are encoded to the same alias, including - * legacy dotted calls from older compatibility attempts. When a usable alias replaces a client - * image-gen declaration, the duplicate hosted `image_generation` entry is removed. Duplicate aliases - * are resolved in stable container order: top-level tools first, then Responses Lite - * `additional_tools` entries. - * - * This function is called only on the API-key path. ChatGPT forward mode understands the private - * namespace and must keep it. Copy-on-write preserves the original request reference when no - * namespace is flattened, hosted tool removed, or duplicate function discarded. - */ -function normalizeImageGenClientTools(body: unknown): unknown { - if (!isPlainObject(body)) return body; - - const toolGroups = collectResponsesToolGroups(body); - const hasImageGenClientTool = toolGroups.some(group => group.some(declaresImageGenClientTool)) - || (Array.isArray(body.input) && body.input.some(declaresImageGenFunctionCall)); - if (!hasImageGenClientTool) return body; - const hasUsableImageGenAlias = toolGroups.some(group => group.some(declaresUsableImageGenAlias)); - const toolChoiceAliases = imageGenToolChoiceAliases(toolGroups); - - const seenFunctionNames = new Set(); - const normalizeGroup = (tools: unknown[]): unknown[] => { - const normalized: unknown[] = []; - let groupChanged = false; - - for (const tool of tools) { - if ( - hasUsableImageGenAlias - && isPlainObject(tool) - && tool.type === HOSTED_IMAGE_GENERATION_TOOL - ) { - groupChanged = true; - continue; - } - - const flattened = flattenImageGenNamespace(tool); - const candidates = flattened ?? [tool]; - if (flattened) groupChanged = true; - - for (const candidate of candidates) { - const normalizedCandidate = normalizeFlatImageGenFunction(candidate); - if (normalizedCandidate !== candidate) groupChanged = true; - const functionName = imageGenFunctionName(normalizedCandidate); - if (functionName && seenFunctionNames.has(functionName)) { - groupChanged = true; - continue; - } - if (functionName) seenFunctionNames.add(functionName); - normalized.push(normalizedCandidate); - } - } - - return groupChanged ? normalized : tools; - }; - - let changed = false; - let tools = body.tools; - if (Array.isArray(body.tools)) { - tools = normalizeGroup(body.tools); - changed ||= tools !== body.tools; - } - - let input = body.input; - if (Array.isArray(body.input)) { - let nestedChanged = false; - const mappedInput = body.input.map(item => { - if (isPlainObject(item) && item.type === "additional_tools" && Array.isArray(item.tools)) { - const nestedTools = normalizeGroup(item.tools); - if (nestedTools === item.tools) return item; - nestedChanged = true; - return { ...item, tools: nestedTools }; - } - const normalizedCall = normalizeImageGenFunctionCall(item); - if (normalizedCall !== item) nestedChanged = true; - return normalizedCall; - }); - if (nestedChanged) { - input = mappedInput; - changed = true; - } - } - - const toolChoice = normalizeImageGenToolChoice(body.tool_choice, toolChoiceAliases); - changed ||= toolChoice !== body.tool_choice; - - if (!changed) return body; - return { - ...body, - ...(Array.isArray(body.tools) ? { tools } : {}), - ...(Array.isArray(body.input) ? { input } : {}), - ...(Object.prototype.hasOwnProperty.call(body, "tool_choice") ? { tool_choice: toolChoice } : {}), - }; -} - -/** - * Remove hosted tool entries the target native slug rejects, so the OAuth-passthrough body never - * carries a tool the upstream model 400s on. No-op (returns the original reference) when nothing - * matches, keeping the common path allocation-free. - */ -function stripUnsupportedHostedTools(body: unknown, provider: Pick): unknown { - if (!isPlainObject(body)) return body; - const model = typeof body.model === "string" ? body.model : ""; - const filterTools = (tools: unknown[]): unknown[] => { - const filtered = tools.filter(t => { - const type = isPlainObject(t) && typeof t.type === "string" ? t.type : undefined; - return !type || !isHostedToolUnsupportedForModel(model, type, provider.baseUrl); - }); - return filtered.length === tools.length ? tools : filtered; - }; - - let next: Record = body; - let changed = false; - if (Array.isArray(body.tools)) { - const tools = filterTools(body.tools); - if (tools !== body.tools) { - next = { ...next, tools }; - changed = true; - } - } - if (Array.isArray(body.input)) { - let inputChanged = false; - const input = body.input.map(item => { - if (!isPlainObject(item) || item.type !== "additional_tools" || !Array.isArray(item.tools)) return item; - const tools = filterTools(item.tools); - if (tools === item.tools) return item; - inputChanged = true; - return { ...item, tools }; - }); - if (inputChanged) { - next = { ...next, input }; - changed = true; - } - } - - const toolChoice = next.tool_choice; - if (isPlainObject(toolChoice) && toolChoice.type === "allowed_tools" && Array.isArray(toolChoice.tools)) { - const tools = filterTools(toolChoice.tools); - if (tools !== toolChoice.tools) { - next = { ...next, tool_choice: tools.length > 0 ? { ...toolChoice, tools } : "none" }; - changed = true; - } - } else if ( - isPlainObject(toolChoice) - && typeof toolChoice.type === "string" - && isHostedToolUnsupportedForModel(model, toolChoice.type, provider.baseUrl) - ) { - next = { ...next, tool_choice: "none" }; - changed = true; - } else if (changed && toolChoice === "required") { - const hasDeclaredTools = (Array.isArray(next.tools) && next.tools.length > 0) - || (Array.isArray(next.input) && next.input.some(item => - isPlainObject(item) - && item.type === "additional_tools" - && Array.isArray(item.tools) - && item.tools.length > 0)); - if (!hasDeclaredTools) { - next = { ...next, tool_choice: "none" }; - } - } - return changed ? next : body; -} - -/** - * OpenAI hosted web_search config fields that a capability-classified Responses - * upstream may reject wholesale. xAI's /v1/responses 400s the entire request on - * `external_web_access` and `search_context_size` ("Argument not supported"), - * which killed every routed Grok turn whose client (Codex) attaches its - * default web_search tool config (probe 2026-08-21: both fields 400 - * individually; `user_location` and `filters` are accepted and kept). - * The caller decides whether to apply this compatibility transform from explicit - * provider capability metadata; an unclassified upstream keeps the fields. - */ -const OPENAI_ONLY_WEB_SEARCH_FIELDS = ["external_web_access", "search_context_size"] as const; - -function stripOpenAiOnlyWebSearchFieldsFromTools(tools: unknown[]): { - tools: unknown[]; - changed: boolean; -} { - let changed = false; - const stripped = tools.map(tool => { - if (!isPlainObject(tool) || (tool.type !== "web_search" && tool.type !== "web_search_preview")) { - return tool; - } - if (!OPENAI_ONLY_WEB_SEARCH_FIELDS.some(field => Object.hasOwn(tool, field))) return tool; - const { external_web_access: _access, search_context_size: _size, ...rest } = tool; - changed = true; - return rest; - }); - return { tools: changed ? stripped : tools, changed }; -} - -export function stripOpenAiOnlyWebSearchFields(body: unknown): unknown { - if (!isPlainObject(body)) return body; - - let next: Record = body; - let changed = false; - if (Array.isArray(body.tools)) { - const stripped = stripOpenAiOnlyWebSearchFieldsFromTools(body.tools); - if (stripped.changed) { - next = { ...next, tools: stripped.tools }; - changed = true; - } - } - - if (Array.isArray(body.input)) { - let inputChanged = false; - const input = body.input.map(item => { - if (!isPlainObject(item) || item.type !== "additional_tools" || !Array.isArray(item.tools)) { - return item; - } - const stripped = stripOpenAiOnlyWebSearchFieldsFromTools(item.tools); - if (!stripped.changed) return item; - inputChanged = true; - return { ...item, tools: stripped.tools }; - }); - if (inputChanged) { - next = { ...next, input }; - changed = true; - } - } - - return changed ? next : body; -} - -/** - * Muse Spark ids whose Responses gateway refuses provider-specific fields on a plain - * `web_search` tool. Membership, not equality: 1.3 shipped 2026-09-02 as the - * same-shaped successor to 1.2 on the same Zen wire, and an equality check would - * have let a Codex-emitted `web_search` body reach the - * gateway and come back 400 for every request the moment 1.3 was selected. - */ -const MUSE_SPARK_WEB_SEARCH_STRICT_MODELS = new Set([ - "muse-spark-1.3-contributor", - "muse-spark-1.3-contributor-free", - "muse-spark-1.2-contributor", - "muse-spark-1.2-contributor-free", -]); - -const MUSE_SPARK_WEB_SEARCH_STRICT_RESPONSE_URLS = new Set([ - "https://opencode.ai/zen/v1/responses", - "https://opencode.ai/zen/go/v1/responses", - "https://api.meta.ai/v1/responses", -]); - -const MUSE_SPARK_UNSUPPORTED_WEB_SEARCH_FIELDS = [ - "search_content_types", - "indexed_web_access", -] as const; - -/** - * OpenCode Zen / Go and the direct Meta Muse Spark Responses gateways refuse a - * short list of Codex `web_search` fields. `web_search_preview` keeps its accepted - * shape, and Luna remains untouched. Match the exact effective request URL; - * malformed, credentialed, or parameterized destinations keep their original body - * instead of assuming this gateway contract. Keep the rejected names together so a - * newly identified field is a one-line compatibility update rather than another - * bespoke rewrite. - */ -function stripMuseSparkUnsupportedWebSearchFields( - body: unknown, - modelId: unknown, - responseUrl: string, -): unknown { - if (!isPlainObject(body)) return body; - if (typeof modelId !== "string") return body; - if (!MUSE_SPARK_WEB_SEARCH_STRICT_MODELS.has(modelId.trim().toLowerCase())) return body; - let destination: string; - try { - const url = new URL(responseUrl); - if (url.username || url.password || url.search || url.hash) return body; - destination = `${url.origin.toLowerCase()}${url.pathname.replace(/\/+$/, "")}`; - } catch { - return body; - } - if (!MUSE_SPARK_WEB_SEARCH_STRICT_RESPONSE_URLS.has(destination)) return body; - - const rewriteTools = (tools: unknown[]): { tools: unknown[]; changed: boolean } => { - let changed = false; - const rewritten = tools.map(tool => { - if (!isPlainObject(tool) || tool.type !== "web_search") return tool; - if (!MUSE_SPARK_UNSUPPORTED_WEB_SEARCH_FIELDS.some(field => Object.hasOwn(tool, field))) { - return tool; - } - const rest = { ...tool }; - for (const field of MUSE_SPARK_UNSUPPORTED_WEB_SEARCH_FIELDS) delete rest[field]; - changed = true; - return rest; - }); - return { tools: changed ? rewritten : tools, changed }; - }; - - let next: Record = body; - let changed = false; - if (Array.isArray(body.tools)) { - const rewritten = rewriteTools(body.tools); - if (rewritten.changed) { - next = { ...next, tools: rewritten.tools }; - changed = true; - } - } - if (Array.isArray(next.input)) { - let inputChanged = false; - const input = next.input.map(item => { - if (!isPlainObject(item) || item.type !== "additional_tools" || !Array.isArray(item.tools)) return item; - const rewritten = rewriteTools(item.tools); - if (!rewritten.changed) return item; - inputChanged = true; - return { ...item, tools: rewritten.tools }; - }); - if (inputChanged) { - next = { ...next, input }; - changed = true; - } - } - return changed ? next : body; -} - -/** Replace every `input_image` part under a routed-compaction body with a short marker. */ -function stripInputImagesDeep(value: unknown): unknown { - if (Array.isArray(value)) return value.map(stripInputImagesDeep); - if (!isPlainObject(value)) return value; - if (value.type === "input_image") { - return { type: "input_text", text: "[image omitted for compaction]" }; - } - const out: Record = {}; - for (const [key, entry] of Object.entries(value)) out[key] = stripInputImagesDeep(entry); - return out; -} - -/** - * Rewrite a compaction turn for an upstream that does not speak Codex's private - * `compaction_trigger` item: drop the trigger and the whole tool surface, and ask - * for the handoff summary in plain terms instead (#422). - * - * The adapter builds from `parsed._rawBody`, so the summarizer prompt that - * handleResponses() pushed onto `parsed.context` never reaches the wire — it has to - * be applied here. Images go too: a summary needs no pixels, and a text-only - * gateway would reject them. - */ -function buildRoutedCompactionBody(body: unknown): unknown { - if (!isPlainObject(body)) return body; - // `text` goes with the tool fields: the summary must be prose, not schema-constrained JSON. - const { tools: _tools, tool_choice: _toolChoice, parallel_tool_calls: _parallel, text: _text, ...rest } = body; - const input = Array.isArray(body.input) ? body.input : []; - const kept = input.filter(item => !isPlainObject(item) - // `additional_tools` is how Codex Desktop's responses-lite shape carries tools; - // leaving it in would break the no-tools invariant even with `tools` removed. - || (item.type !== "compaction_trigger" && item.type !== "additional_tools")); - return { - ...rest, - input: [ - ...(stripInputImagesDeep(kept) as unknown[]), - { type: "message", role: "user", content: [{ type: "input_text", text: COMPACT_PROMPT }] }, - ], - }; -} - -/** Read the Responses `usage` block, if the gateway sent one. */ -function usageFromResponsesPayload(payload: unknown): OcxUsage | undefined { - if (!isPlainObject(payload) || !isPlainObject(payload.usage)) return undefined; - const usage = payload.usage; - const inputTokens = typeof usage.input_tokens === "number" ? usage.input_tokens : 0; - const outputTokens = typeof usage.output_tokens === "number" ? usage.output_tokens : 0; - // openai/codex#41980: the raw usage object is wire data a rebuilt response.completed must keep — - // unknown keys (subscription metadata, future counters) ride along even when the token counts - // themselves are zero or absent (metadata-only usage). - const knownKeys = new Set(["input_tokens", "output_tokens", "total_tokens", "input_tokens_details", "output_tokens_details"]); - const hasExtras = Object.keys(usage).some(key => !knownKeys.has(key)) - || (isPlainObject(usage.input_tokens_details) - && Object.keys(usage.input_tokens_details).some(key => key !== "cached_tokens" && key !== "cache_write_tokens")) - || (isPlainObject(usage.output_tokens_details) - && Object.keys(usage.output_tokens_details).some(key => key !== "reasoning_tokens")); - if (inputTokens === 0 && outputTokens === 0 && !hasExtras) return undefined; - const inputDetails = isPlainObject(usage.input_tokens_details) ? usage.input_tokens_details : undefined; - const outputDetails = isPlainObject(usage.output_tokens_details) ? usage.output_tokens_details : undefined; - return { - inputTokens, - outputTokens, - ...(typeof usage.total_tokens === "number" ? { totalTokens: usage.total_tokens } : {}), - ...(typeof inputDetails?.cached_tokens === "number" ? { cachedInputTokens: inputDetails.cached_tokens } : {}), - ...(typeof inputDetails?.cache_write_tokens === "number" ? { cacheCreationInputTokens: inputDetails.cache_write_tokens } : {}), - ...(typeof outputDetails?.reasoning_tokens === "number" ? { reasoningOutputTokens: outputDetails.reasoning_tokens } : {}), - ...(hasExtras ? { rawUsage: { ...usage } } : {}), - }; -} - -function responsesPayloadText(response: unknown): string { - if (!isPlainObject(response) || !Array.isArray(response.output)) return ""; - return response.output - .filter(item => isPlainObject(item) && item.type === "message") - .flatMap(item => (Array.isArray((item as Record).content) - ? (item as { content: unknown[] }).content - : [])) - .filter(part => isPlainObject(part) && part.type === "output_text") - .map(part => String((part as { text?: unknown }).text ?? "")) - .join(""); -} - -function responsesErrorMessage(payload: unknown): string { - if (!isPlainObject(payload)) return "upstream compaction failed"; - const err = payload.error; - if (typeof err === "string") return err; - if (isPlainObject(err) && typeof err.message === "string") return err.message; - const incomplete = payload.incomplete_details; - if (isPlainObject(incomplete) && typeof incomplete.reason === "string") return incomplete.reason; - return "upstream compaction failed"; -} - -/** Count an append without rescanning accumulated text, including split surrogate pairs. */ -function appendedUtf8Bytes(previousBytes: number, lastCodeUnit: number, fragment: string): number { - const first = fragment.charCodeAt(0); - // Separate lone surrogates each count as a three-byte replacement character; together - // they encode as one four-byte scalar. Empty fragments produce NaN and never pair. - const joinsSurrogatePair = lastCodeUnit >= 0xd800 && lastCodeUnit <= 0xdbff && first >= 0xdc00 && first <= 0xdfff; - return previousBytes + Buffer.byteLength(fragment, "utf8") - (joinsSurrogatePair ? 2 : 0); -} - -export function createResponsesPassthroughAdapter(provider: OcxProviderConfig): ProviderAdapter & { passthrough: true } { - return { - name: "openai-responses", - passthrough: true as const, - - buildRequest(parsed: OcxParsedRequest, incoming: IncomingMeta) { - const translatorBudget = incoming.translatorBudget; - const headers: Record = { "Content-Type": "application/json" }; - let url: string; - - if (provider.authMode === "forward") { - const mayForwardCallerCredentials = isCanonicalOpenAiForwardProvider(provider); - // OAuth passthrough: ChatGPT backend path is `${baseUrl}/responses` (no /v1). - const baseUrl = mayForwardCallerCredentials - ? CODEX_FORWARD_BASE_URL - : provider.baseUrl.replace(/\/+$/, ""); - url = `${baseUrl}/responses`; - if (provider.headers) Object.assign(headers, provider.headers); // static headers first… - const runtimeProvider = provider as { - _codexAccountOverride?: { accessToken: string; chatgptAccountId: string }; - _codexAccountRequired?: boolean; - }; - if ( - mayForwardCallerCredentials - && runtimeProvider._codexAccountRequired - && !runtimeProvider._codexAccountOverride - ) { - throw new Error("Codex pool account auth is required but unavailable"); - } - if (mayForwardCallerCredentials) { - for (const h of FORWARD_HEADERS) { - const v = incoming?.headers.get(h); - if (v) { - if (h === CODEX_RESPONSES_LITE_HEADER) { - for (const name of Object.keys(headers)) { - if (name.toLowerCase() === h) delete headers[name]; - } - } - headers[h] = v; // …so genuine forwarded fields win. - } - } - } - const override = runtimeProvider._codexAccountOverride; - if (override && mayForwardCallerCredentials) { - headers["authorization"] = `Bearer ${override.accessToken}`; - headers["chatgpt-account-id"] = override.chatgptAccountId; - } - } else { - if (provider.responsesPath === undefined) { - url = openaiResponsesUrl(provider.baseUrl); - } else { - const base = provider.baseUrl.replace(/\/$/, ""); - url = `${base}${provider.responsesPath}`; - } - if (provider.apiKey) headers["Authorization"] = `Bearer ${provider.apiKey}`; - if (provider.headers) Object.assign(headers, provider.headers); - } - - const forward = provider.authMode === "forward"; - let convertedRoutedCustomToolNames: Set | undefined; - let routedCustomToolRepairNames: Set | undefined; - let convertedRoutedToolSearchNames: Set | undefined; - let convertedRoutedNamespaceToolAliases: Map | undefined; - let plaintextV2AgentMessageToolNames: ReadonlySet | undefined; - let plaintextV2AgentMessageAliasedToolNames: ReadonlySet | undefined; - let convertedMuseToolNameAliases: Map | undefined; - const unexpandedMiss = !!parsed.previousResponseId && parsed._previousResponseInputExpanded !== true; - let outBody = stripPreviousResponseId( - parsed._rawBody, - forward || parsed._previousResponseInputExpanded === true, - ); - if (!forward) outBody = normalizeRoutedAgentMessages(outBody, { - allowStringContent: isXaiResponsesDestination(provider), - }); - outBody = mapRoutedResponsesReasoningEffort(outBody, provider, parsed.modelId); - // stripPreviousResponseId() intentionally returns its input on a no-op. Detach before the - // tier write so a force-fast/default decision can never mutate parsed._rawBody. - outBody = applyTierDecisionToResponsesBody(outBody, parsed.options?.tierDecision); - const stateless = provider.statelessResponses === true; - if (stateless) outBody = stripStatefulResponsesParams(outBody); - // A replay miss can leave a function_call_output whose paired function_call sat - // in the prefix that was never expanded. A stateless upstream cannot resolve the - // pair from its own storage either, so it needs the same repair the forward - // backend gets — dropping previous_response_id is not much use if the body that - // reaches the wire is unparseable. - if (provider.annotateEmptyToolOutputs === true) { - outBody = annotateEmptyResponsesToolOutputs(outBody, true); - } - if (forward || stateless) { - outBody = repairOrphanedInputItems(outBody, unexpandedMiss, stateless && !forward); - } - if (provider.requiresAdjacentResponsesToolResults === true) { - outBody = normalizeResponsesToolResultAdjacency(outBody); - } - if (forward) { - outBody = stripUnsupportedForwardParams(outBody); - // Only the canonical ChatGPT backend rejects the retired field; a self-hosted or - // third-party forward gateway may still accept it, so this must not be widened. - if (isCanonicalOpenAiForwardProvider(provider)) { - outBody = stripCanonicalForwardSamplingParams(outBody); - outBody = stripDeprecatedPromptCacheRetention(outBody, parsed.modelId); - outBody = stripCanonicalForwardPromptCacheOptions(outBody); - outBody = normalizeCanonicalForwardPromptEnvelope(outBody); - outBody = normalizeCanonicalForwardContinuationEnvelope(outBody); - } - } else { - outBody = preferConfiguredHostedTools( - outBody, - provider, - parsed.modelId, - parsed._openAiVirtualSelectedModelId, - ); - outBody = normalizeImageGenClientTools(outBody); - } - if (forward || parsed._previousResponseInputExpanded === true) { - outBody = repairOversizedReplayCallIds(outBody); - } - outBody = stripUnsupportedReasoningSummaryDelivery(outBody, parsed.modelId); - // Repair stored history from before the bridge emitted both keys, in either - // direction: a conversation that already recorded a web_search_call replays it - // every turn, and a strict parser rejects the whole request over the missing key — - // `queries` for DeepSeek (#930), `query` for Console Go (#3071). - outBody = backfillWebSearchQueries(outBody); - if (!isCanonicalOpenAiForwardProvider(provider)) { - outBody = stripInternalChatMessageMetadataPassthrough(outBody); - outBody = promoteClientLoadedTools(outBody); - } - if (!isCanonicalOpenAiForwardProvider(provider)) { - const rewritten = rewriteRoutedCustomToolsForUpstream( - outBody, - provider.supportsResponsesCustomTools, - ); - outBody = rewritten.body; - convertedRoutedCustomToolNames = rewritten.names; - routedCustomToolRepairNames = rewritten.repairNames; - } - if (!isCanonicalOpenAiForwardProvider(provider)) { - // Run after custom-tool lowering so the search compatibility layer can choose a - // collision-free public function name against the final routed function catalog. - const rewritten = rewriteRoutedToolSearchForUpstream(outBody); - outBody = rewritten.body; - convertedRoutedToolSearchNames = rewritten.names; - } - if (!isCanonicalOpenAiForwardProvider(provider)) { - // Codex 0.147 emits private namespace tool groups, while public/third-party Responses - // gateways accept only flat tool variants. Run after custom/tool-search lowering so - // namespace children already carry their final public kind before they are promoted. - const rewritten = rewriteRoutedNamespaceToolsForUpstream(outBody, convertedRoutedCustomToolNames); - outBody = rewritten.body; - convertedRoutedNamespaceToolAliases = rewritten.aliases; - // Preserve xAI's cached-only fail-closed semantics and image-search mapping before the - // generic capability fallback removes the private OpenAI fields. - outBody = normalizeXaiResponsesWebSearch(outBody, provider); - outBody = injectXaiResponsesXSearch(outBody, provider, parsed._replayPrefixLen); - // xAI and explicitly classified compatible gateways reject these OpenAI web_search - // extensions. Keep them for OpenAI API-key traffic and unclassified gateways. - if (provider.supportsOpenAiWebSearchToolFields === false) { - outBody = stripOpenAiOnlyWebSearchFields(outBody); - } - outBody = stripMuseSparkUnsupportedWebSearchFields(outBody, parsed.modelId, url); - // Host-only: api.meta.ai rejects function names over 64 chars on every Muse model, - // including default muse-spark-1.3. Do not reuse the contributor/Zen web_search - // predicates. Namespace flattening has already produced the public wire names. - if (isMetaAiResponsesDestination(url)) { - const rewritten = rewriteMuseToolNamesForUpstream(outBody); - outBody = rewritten.body; - convertedMuseToolNameAliases = rewritten.aliases; - } - // Last, so promoted namespace children are also cleared of Codex-private fields. - outBody = stripCanonicalOnlyToolFields(outBody, provider.supportsOpenAiWebSearchToolFields === false); - } - if (!forward) outBody = normalizeOpenCodeGoAdditionalTools(outBody, url); - // Same predicate as the routedCompaction gate in handleResponses(): an authMode check would - // let a noncanonical custom forward provider skip this rewrite while the server still routes - // it as a summarizer turn (#422). The compaction body build removes the tool surface and must - // therefore be the last routed transform that may depend on those declarations. Structural - // sanitizers below can still run after it. - outBody = normalizeResponsesCodeMode(outBody, parsed, provider); - if (parsed._compactionRequest === true && !isCanonicalOpenAiForwardProvider(provider)) { - outBody = buildRoutedCompactionBody(outBody); - } - // Run after routed compaction so nested input_image parts are replaced before a malformed - // tool output is flattened to text and can no longer be inspected structurally. - outBody = repairUnidentifiedToolOutputItems(outBody); - if (parsed._plaintextV2AgentMessages === true && isCanonicalOpenAiForwardProvider(provider)) { - const prepared = preparePlaintextV2AgentMessages(outBody); - outBody = prepared.body; - if (prepared.namespaceAliased) { - plaintextV2AgentMessageToolNames = prepared.toolNames; - plaintextV2AgentMessageAliasedToolNames = prepared.aliasedAgentMessageToolNames; - } - } - const threadServingIdentityChanged = parsed._stripReasoningEncryptedContent === true; - const sanitizedBody = normalizeToolSchemas( - stripItemIdsWhenUnstored( - stripInvalidItemIds( - stripUnsupportedHostedTools( - sanitizeReasoningInputContent( - scrubOcxCompactionItems( - outBody, - destinationDecodesNativeCompactionBlob(provider), - threadServingIdentityChanged, - ), - { - preserveRawReasoningContent: provider.preserveResponsesReasoningContent === true, - dropNullContentChannel: !isOpenAiOperatedResponsesDestination(provider), - stripEncryptedContent: threadServingIdentityChanged, - }, - ), - provider, - ), - ), - ), - isXaiSchemaTarget(provider), - ); - const unnormalizedBody = stripDisabledVerbosity( - stripDisabledReasoningSummaries( - normalizeConfiguredReasoningSummaryDelivery(sanitizedBody, provider, parsed.modelId), - provider, - parsed.modelId, - ), - provider, - parsed.modelId, - ); - // Normalize the wire model before deriving model-dependent transport metadata. - const finalBody = - provider.modelSuffixBracketStrip - && unnormalizedBody !== null - && typeof unnormalizedBody === "object" - && !Array.isArray(unnormalizedBody) - && typeof (unnormalizedBody as { model?: unknown }).model === "string" - ? { ...(unnormalizedBody as Record), model: stripBracketedModelSuffix((unnormalizedBody as { model: string }).model) } - : unnormalizedBody; - if (isCanonicalOpenAiForwardProvider(provider)) { - const routingHeaders = new Headers(headers); - applyCodexRoutingHint(routingHeaders, finalBody); - // Static headers may use mixed casing. Remove every stale spelling - // without normalizing unrelated headers returned by this adapter. - for (const name of Object.keys(headers)) { - if (name.toLowerCase() === CODEX_ROUTING_HINT_HEADER) delete headers[name]; - } - const hint = routingHeaders.get(CODEX_ROUTING_HINT_HEADER); - if (hint !== null) headers[CODEX_ROUTING_HINT_HEADER] = hint; - } - const actualServiceTier = isPlainObject(finalBody) && typeof finalBody.service_tier === "string" - ? finalBody.service_tier - : null; - const tierLog = createAdapterTierMetadata( - parsed.options?.tierObservation, - parsed.options?.tierDecision, - actualServiceTier === null ? null : "service-tier", - actualServiceTier, - ); - // The Responses adapter is passthrough: it forwards `parsed._rawBody` rather than - // rebuilding the body from `parsed.modelId`, and the router writes the routed id into - // that raw body. So a provider whose upstream rejects bracketed ids has to be honoured - // here, on the serialized body, not on the parsed selector. One place covers both the - // HTTP and the WebSocket outbound, because the WS path transports this same request - // instead of rebuilding it. - const body = JSON.stringify(finalBody); - const releaseBodyObservation = translatorBudget.observeExternallyCapped( - "passthrough_serialization", - Buffer.byteLength(body, "utf8"), - ); - return { - url, - method: "POST", - headers, - body, - releaseBodyObservation, - ...(convertedRoutedCustomToolNames ? { convertedRoutedCustomToolNames } : {}), - ...(routedCustomToolRepairNames ? { routedCustomToolRepairNames } : {}), - ...(convertedRoutedToolSearchNames ? { convertedRoutedToolSearchNames } : {}), - ...(convertedRoutedNamespaceToolAliases ? { convertedRoutedNamespaceToolAliases } : {}), - ...(plaintextV2AgentMessageToolNames ? { plaintextV2AgentMessageToolNames } : {}), - ...(plaintextV2AgentMessageAliasedToolNames ? { plaintextV2AgentMessageAliasedToolNames } : {}), - ...(convertedMuseToolNameAliases ? { convertedMuseToolNameAliases } : {}), - ...(tierLog ? { tierLog } : {}), - }; - }, - - // The passthrough normally relays the upstream stream verbatim and never parses. - // The exception is a routed compaction turn: the server drives this adapter like - // an ordinary one so the bridge can build the single compaction item (#422). - async *parseStream(response: Response, budget: TranslatorBudget): AsyncGenerator { - if (!response.body) { - yield { type: "error", message: "passthrough adapter received no response body" }; - return; - } - let deltas = ""; - let deltasBytes = 0; - let deltasLastCodeUnit = 0; - let doneText = ""; - let doneTextBytes = 0; - let doneTextLastCodeUnit = 0; - let snapshot = ""; - let snapshotBytes = 0; - let usage: OcxUsage | undefined; - let usageRawBytes = 0; - let compactionEncryptedContent: string | undefined; - let compactionEncryptedContentBytes = 0; - let completedSeen = false; - for await (const event of decodeServerSentEvents(response.body, { translatorBudget: budget })) { - let payload: unknown; - try { payload = JSON.parse(event.data); } catch { continue; } - if (!isPlainObject(payload)) continue; - switch (payload.type) { - case "response.output_text.delta": - if (typeof payload.delta === "string") { - const next = deltas + payload.delta; - const nextBytes = appendedUtf8Bytes(deltasBytes, deltasLastCodeUnit, payload.delta); - const reservation = budget.reserveTransient(nextBytes, { kind: "retained_collectors" }); - deltas = next; - reservation.commitRetained(); - budget.releaseRetained(deltasBytes, { kind: "retained_collectors" }); - deltasBytes = nextBytes; - if (payload.delta.length > 0) deltasLastCodeUnit = payload.delta.charCodeAt(payload.delta.length - 1); - } - break; - case "response.output_text.done": - if (typeof payload.text === "string") { - const next = doneText + payload.text; - const nextBytes = appendedUtf8Bytes(doneTextBytes, doneTextLastCodeUnit, payload.text); - const reservation = budget.reserveTransient(nextBytes, { kind: "retained_collectors" }); - doneText = next; - reservation.commitRetained(); - budget.releaseRetained(doneTextBytes, { kind: "retained_collectors" }); - doneTextBytes = nextBytes; - if (payload.text.length > 0) doneTextLastCodeUnit = payload.text.charCodeAt(payload.text.length - 1); - } - break; - case "response.failed": - case "error": - yield { type: "error", message: responsesErrorMessage(payload.response ?? payload) }; - return; - case "response.incomplete": - yield { type: "incomplete", reason: responsesErrorMessage(payload.response ?? payload) }; - return; - case "response.completed": - { - completedSeen = true; - const responsePayload = isPlainObject(payload.response) ? payload.response : undefined; - const output = Array.isArray(responsePayload?.output) ? responsePayload.output : []; - const compaction = output.find(item => isPlainObject(item) && item.type === "compaction"); - if (isPlainObject(compaction) && typeof compaction.encrypted_content === "string") { - const nextEncryptedContent = compaction.encrypted_content; - const nextEncryptedContentBytes = Buffer.byteLength(nextEncryptedContent, "utf8"); - const reservation = budget.reserveTransient(nextEncryptedContentBytes, { kind: "retained_collectors" }); - compactionEncryptedContent = nextEncryptedContent; - reservation.commitRetained(); - budget.releaseRetained(compactionEncryptedContentBytes, { kind: "retained_collectors" }); - compactionEncryptedContentBytes = nextEncryptedContentBytes; - } - const next = responsesPayloadText(payload.response); - const nextBytes = Buffer.byteLength(next, "utf8"); - const reservation = budget.reserveTransient(nextBytes, { kind: "retained_collectors" }); - snapshot = next; - reservation.commitRetained(); - budget.releaseRetained(snapshotBytes, { kind: "retained_collectors" }); - snapshotBytes = nextBytes; - } - { - const nextUsage = usageFromResponsesPayload(payload.response); - // The attached raw usage object can be event-sized (unknown keys carry arbitrary - // values); it stays reachable until the terminal yields, so charge it like the - // adjacent retained collectors or it would defeat the per-request memory cap. - const nextRawBytes = nextUsage?.rawUsage === undefined ? 0 - : Buffer.byteLength(JSON.stringify(nextUsage.rawUsage), "utf8"); - if (nextRawBytes > 0) { - const reservation = budget.reserveTransient(nextRawBytes, { kind: "retained_collectors" }); - usage = nextUsage; - reservation.commitRetained(); - } else { - usage = nextUsage; - } - if (usageRawBytes > 0) { - budget.releaseRetained(usageRawBytes, { kind: "retained_collectors" }); - } - usageRawBytes = nextRawBytes; - } - break; - } - // Buffered text is still upstream progress, but gateway keepalives are not. - // Yield after accounting, directly to the consumer: no progress queue or content leak. - if ( - !completedSeen - && (payload.type === "response.output_text.delta" - || payload.type === "response.reasoning_summary_text.delta" - || payload.type === "response.reasoning_text.delta") - && typeof payload.delta === "string" - && payload.delta.length > 0 - ) { - yield { type: "heartbeat" }; - } - } - // Gateways differ in which of these they emit; prefer the authoritative - // completed snapshot so text is never double-counted. - const text = snapshot || doneText || deltas; - if (text) yield { type: "text_delta", text }; - budget.releaseRetained( - deltasBytes + doneTextBytes + snapshotBytes + usageRawBytes, - { kind: "retained_collectors" }, - ); - yield { - type: "done", - ...(usage ? { usage } : {}), - ...(compactionEncryptedContent ? { compactionEncryptedContent } : {}), - }; - }, - - async parseResponse(response: Response, budget: TranslatorBudget): Promise { - let payload: unknown; - try { payload = await response.json(); } catch { - return [{ type: "error", message: "malformed upstream compaction response" }]; - } - budget.chargeRetained(Buffer.byteLength(JSON.stringify(payload), "utf8"), { kind: "retained_collectors" }); - if (!isPlainObject(payload)) { - return [{ type: "error", message: "malformed upstream compaction response" }]; - } - if (payload.error || payload.status === "failed") { - return [{ type: "error", message: responsesErrorMessage(payload) }]; - } - if (payload.status === "incomplete") { - return [{ type: "incomplete", reason: responsesErrorMessage(payload) }]; - } - const usage = usageFromResponsesPayload(payload); - const output = Array.isArray(payload.output) ? payload.output : []; - const compaction = output.find(item => isPlainObject(item) && item.type === "compaction"); - const compactionEncryptedContent = isPlainObject(compaction) && typeof compaction.encrypted_content === "string" - ? compaction.encrypted_content - : undefined; - const text = responsesPayloadText(payload); - if (!text && !compactionEncryptedContent) { - // A completed turn with neither text nor a native compaction blob cannot become a - // replacement-history item. A ciphertext-only native completion is valid, though. - return [{ type: "error", message: "upstream compaction returned no summary text" }]; - } - return [...(text ? [{ type: "text_delta" as const, text }] : []), { - type: "done", - ...(usage ? { usage } : {}), - ...(compactionEncryptedContent ? { compactionEncryptedContent } : {}), - }]; - }, - }; -} +export { stripCanonicalForwardSamplingParams } from "./openai-responses/canonical-forward"; +export { FORWARD_HEADERS, createResponsesPassthroughAdapter } from "./openai-responses/passthrough"; +export { sanitizeReasoningInputContent } from "./openai-responses/reasoning"; +export { stripOpenAiOnlyWebSearchFields } from "./openai-responses/web-search"; diff --git a/src/adapters/openai-responses/canonical-forward.ts b/src/adapters/openai-responses/canonical-forward.ts new file mode 100644 index 0000000000..8967a44798 --- /dev/null +++ b/src/adapters/openai-responses/canonical-forward.ts @@ -0,0 +1,202 @@ +import { namespacedToolName, type AdapterEvent, type OcxParsedRequest, type OcxProviderConfig, type OcxUsage, type TierDecision } from "../../types"; +import { stripItemIdsWhenUnstored } from "./request-strips"; +import { isPlainObject } from "./internal"; +import { stripPromptCacheBreakpoints } from "./prompt-cache"; + +/** + * Remove `previous_response_id` before forwarding. Two triggers: + * - the proxy expanded the request into a full input replay (the id is now redundant), or + * - the target is the ChatGPT backend (`authMode: "forward"`), whose Codex REST endpoint + * categorically rejects the parameter with `{"detail":"Unsupported parameter: + * previous_response_id"}` (strict allowlist; it also rejects `metadata` and + * `max_output_tokens`). Codex only sends the id on WS turns, and ocx converts those to + * internal HTTP requests, so forwarding it upstream is a guaranteed 400 — stripping is + * strictly better even when the local replay state missed. API-key mode keeps the field on + * unexpanded requests: the platform `/v1/responses` supports real server-side storage. + */ +export function stripPreviousResponseId(body: unknown, strip: boolean): unknown { + if (!strip || !isPlainObject(body) || !Object.prototype.hasOwnProperty.call(body, "previous_response_id")) return body; + const { previous_response_id: _previousResponseId, ...rest } = body; + return rest; +} + +/** Apply the settled tier only to a fresh outbound object; `_rawBody` remains caller-owned. */ +export function applyTierDecisionToResponsesBody(body: unknown, decision: TierDecision | undefined): unknown { + if (!decision || decision.kind === "forward-caller" || !isPlainObject(body)) return body; + const next: Record = { ...body }; + if (decision.kind === "set") next.service_tier = decision.value; + else delete next.service_tier; + return next; +} + +/** + * Drop request parameters a stateless Responses upstream cannot implement, and pin + * `store` false. + * + * `previous_response_id` is listed here as well as in `stripPreviousResponseId` + * because that helper's strip is conditional on replay expansion, and it keeps the + * field for API-key providers on the premise that the platform offers real + * server-side storage. DeepSeek documents the opposite: "the API is stateless: + * responses and conversations are not stored on the server", so the field can never + * be honoured regardless of expansion state. + * + * `prompt` is a reference to a server-stored prompt template — the most stateful + * field in the accepted schema. + * + * `service_tier` is deliberately NOT dropped: the final TierDecision is applied to a + * detached outbound body before this sanitizer chain, and silently deleting a configured knob is + * worse than forwarding a parameter the upstream ignores. + * + * MUST run before the composed sanitize chain below: `stripItemIdsWhenUnstored` keys + * off `store === false`, and a stateless upstream cannot resolve a stored item id. + * Returns a copy, so `parsed._rawBody` keeps the client's original `store` value and + * the local replay cache still records the turn. + */ +export function stripStatefulResponsesParams(body: unknown): unknown { + if (!isPlainObject(body)) return body; + const drop = ["previous_response_id", "conversation", "background", "metadata", "prompt"] as const; + const present = drop.some(key => Object.prototype.hasOwnProperty.call(body, key)); + if (!present && body.store === false) return body; + const next: Record = { ...body }; + for (const key of drop) delete next[key]; + next.store = false; + return next; +} + +/** + * Remove top-level parameters the ChatGPT backend (`authMode: "forward"`) rejects + * with `{"detail":"Unsupported parameter: …"}` (strict allowlist). Codex CLI never + * sends these — it controls output length via `reasoning.effort` — but third-party + * Responses API clients (GJC, SDK wrappers) include `max_output_tokens` per the + * public spec. `metadata` is likewise absent from the allowlist. No-op when the + * body carries neither field, keeping the common Codex path allocation-free. + */ +export function stripUnsupportedForwardParams(body: unknown): unknown { + if (!isPlainObject(body)) return body; + const hasMot = Object.prototype.hasOwnProperty.call(body, "max_output_tokens"); + const hasMeta = Object.prototype.hasOwnProperty.call(body, "metadata"); + if (!hasMot && !hasMeta) return body; + const { max_output_tokens: _mot, metadata: _meta, ...rest } = body; + return rest; +} + +/** Sampling controls the canonical ChatGPT backend rejects; other forward gateways accept them. */ +const CANONICAL_FORWARD_UNSUPPORTED_SAMPLING = ["temperature", "top_p", "stop", "user"] as const; + +/** + * Remove sampling controls only the canonical ChatGPT backend rejects. + * + * A translated Chat turn used to lose these at the Chat ingress for every provider on + * the `openai-responses` adapter, which silently discarded caller intent on generic + * key gateways that accept them. Deciding at the ingress was also unsound for combo + * and policy routes, whose concrete child is chosen later — so the decision belongs + * here, on the provider that actually receives the body. + * + * Returns a copy and never mutates, so `parsed._rawBody` stays caller-owned, and + * no-ops when the body carries none of these keys. + */ +export function stripCanonicalForwardSamplingParams(body: unknown): unknown { + if (!isPlainObject(body)) return body; + if (!CANONICAL_FORWARD_UNSUPPORTED_SAMPLING.some(key => Object.prototype.hasOwnProperty.call(body, key))) { + return body; + } + const next: Record = { ...body }; + for (const key of CANONICAL_FORWARD_UNSUPPORTED_SAMPLING) delete next[key]; + return next; +} + +/** Return the lossless text represented by one system message, or null when it is multimodal. */ +function canonicalForwardSystemText(item: Record): string | null { + const content = item.content; + if (content === undefined) return ""; + if (typeof content === "string") return content; + if (!Array.isArray(content)) return null; + let text = ""; + for (const block of content) { + if (!isPlainObject(block)) return null; + if (block.type !== "input_text" && block.type !== "text") return null; + if (typeof block.text !== "string") return null; + text += block.text; + } + return text; +} + +/** Only message items may carry privileged system instructions. */ +function isCanonicalForwardSystemMessage(item: unknown): item is Record { + return isPlainObject(item) + && (item.type === undefined || item.type === "message") + && item.role === "system"; +} + +/** + * The public Responses API accepts input system messages and `truncation`, but the canonical + * ChatGPT Codex forward endpoint rejects both. Fold only fully textual system messages into the + * existing top-level instructions and remove the unsupported flag at this destination boundary. + * + * The fold is atomic: if any system message contains a non-text block, keep every message in + * place so the proxy never silently drops multimodal content. The backend may still reject that + * unsupported shape, but it will not receive a partially rewritten prompt. + */ +export function normalizeCanonicalForwardPromptEnvelope(body: unknown): unknown { + if (!isPlainObject(body)) return body; + const stripTruncation = Object.hasOwn(body, "truncation"); + const input = Array.isArray(body.input) ? body.input : undefined; + if (!input) { + if (!stripTruncation) return body; + const { truncation: _truncation, ...rest } = body; + return rest; + } + + const foldedText: string[] = []; + let sawSystemMessage = false; + let canFoldAllSystemMessages = true; + for (const item of input) { + if (!isCanonicalForwardSystemMessage(item)) continue; + sawSystemMessage = true; + const text = canonicalForwardSystemText(item); + if (text === null) { + canFoldAllSystemMessages = false; + break; + } + foldedText.push(text); + } + if (!stripTruncation && (!sawSystemMessage || !canFoldAllSystemMessages)) return body; + + const next: Record = { ...body }; + if (stripTruncation) delete next.truncation; + if (sawSystemMessage && canFoldAllSystemMessages) { + next.input = input.filter(item => !isCanonicalForwardSystemMessage(item)); + const folded = foldedText.join("\n\n"); + if (folded !== "") { + const existing = typeof body.instructions === "string" ? body.instructions : ""; + next.instructions = existing !== "" ? `${existing}\n\n${folded}` : folded; + } + } + return next; +} + +/** + * Posit Assistant can replay client-only cache markers and stored-item references on a + * `store: false` continuation. The canonical ChatGPT Codex backend rejects both. Remove the + * markers recursively and drop only `item_reference` rows that cannot name persisted state; + * ordinary item ids are handled later by stripItemIdsWhenUnstored and tool call_id pairs remain. + */ +export function normalizeCanonicalForwardContinuationEnvelope(body: unknown): unknown { + if (!isPlainObject(body) || !Array.isArray(body.input)) return body; + let input: unknown[] = body.input; + let changed = false; + if (body.store === false) { + const withoutReferences = input.filter(item => !isPlainObject(item) || item.type !== "item_reference"); + if (withoutReferences.length !== input.length) { + input = withoutReferences; + changed = true; + } + } + + const markerRewrite = stripPromptCacheBreakpoints(input, { nodes: 0 }); + if (markerRewrite.complete && markerRewrite.changed) { + input = markerRewrite.value as unknown[]; + changed = true; + } + return changed ? { ...body, input } : body; +} diff --git a/src/adapters/openai-responses/image-gen.ts b/src/adapters/openai-responses/image-gen.ts new file mode 100644 index 0000000000..4b29c66ad7 --- /dev/null +++ b/src/adapters/openai-responses/image-gen.ts @@ -0,0 +1,406 @@ +import { namespacedToolName, type AdapterEvent, type OcxParsedRequest, type OcxProviderConfig, type OcxUsage, type TierDecision } from "../../types"; +import { collectResponsesToolGroups } from "../../responses/tool-groups"; +import { isPlainObject } from "./internal"; + +const IMAGE_GEN_NAMESPACE = "image_gen"; +const HOSTED_IMAGE_GENERATION_TOOL = "image_generation"; +const IMAGE_GEN_DOTTED_PREFIX = `${IMAGE_GEN_NAMESPACE}.`; +const IMAGE_GEN_WIRE_PREFIX = `${IMAGE_GEN_NAMESPACE}__`; + +/** Remove a supported client prefix before constructing the canonical image-gen wire alias. */ +function imageGenLocalName(name: string): string { + if (name.startsWith(IMAGE_GEN_DOTTED_PREFIX)) return name.slice(IMAGE_GEN_DOTTED_PREFIX.length); + if (name.startsWith(IMAGE_GEN_WIRE_PREFIX)) return name.slice(IMAGE_GEN_WIRE_PREFIX.length); + return name; +} + +/** Build the flat public-Responses name used only on the upstream wire. */ +function imageGenWireName(name: string): string { + return namespacedToolName(IMAGE_GEN_NAMESPACE, imageGenLocalName(name)); +} + +/** Match client image-gen declarations across namespace, legacy dotted, and canonical wire forms. */ +function isImageGenClientName(name: string): boolean { + return name === IMAGE_GEN_NAMESPACE + || name.startsWith(IMAGE_GEN_DOTTED_PREFIX) + || name.startsWith(IMAGE_GEN_WIRE_PREFIX); +} + +/** Identify declarations that should activate image-gen request normalization. */ +function declaresImageGenClientTool(tool: unknown): boolean { + if (!isPlainObject(tool) || typeof tool.name !== "string") return false; + if (tool.type === "namespace") return tool.name === IMAGE_GEN_NAMESPACE; + return isImageGenClientName(tool.name); +} + +/** Rewrite client image-gen selectors to the hosted tool without widening caller restrictions. */ +function preferHostedImageGenToolChoice(toolChoice: unknown): unknown { + if (!isPlainObject(toolChoice)) return toolChoice; + if ((toolChoice.type === "function" || toolChoice.type === "custom") && typeof toolChoice.name === "string") { + return isImageGenClientName(toolChoice.name) ? { type: HOSTED_IMAGE_GENERATION_TOOL } : toolChoice; + } + if (toolChoice.type !== "allowed_tools" || !Array.isArray(toolChoice.tools)) return toolChoice; + const hasHostedImageTool = toolChoice.tools.some(tool => isPlainObject(tool) && tool.type === HOSTED_IMAGE_GENERATION_TOOL); + let changed = false; + let addedHostedImageTool = false; + const tools: unknown[] = []; + for (const tool of toolChoice.tools) { + const isClientImageTool = isPlainObject(tool) + && (tool.type === "function" || tool.type === "custom") + && typeof tool.name === "string" + && isImageGenClientName(tool.name); + if (!isClientImageTool) { + tools.push(tool); + continue; + } + changed = true; + if (!hasHostedImageTool && !addedHostedImageTool) { + tools.push({ type: HOSTED_IMAGE_GENERATION_TOOL }); + addedHostedImageTool = true; + } + } + return changed ? { ...toolChoice, tools } : toolChoice; +} + +/** + * Some Responses-compatible gateways reserve the hosted image namespace even when the request + * does not explicitly declare `image_generation`. For an explicitly configured model, remove only + * colliding client declarations so the gateway's hosted tool can take precedence. + */ +export function preferConfiguredHostedTools( + body: unknown, + provider: OcxProviderConfig, + modelId: string, + selectedModelId?: string, +): unknown { + // A virtual model's advertised id takes precedence over its resolved wire-model id. + // Read own properties only: a routed model id of `constructor`/`toString` would + // otherwise resolve to an inherited Object.prototype function and throw on the + // membership test below, failing the request before it is dispatched. + const preferenceMap = provider.modelPreferHostedTools; + const ownPreference = (key: string | undefined): string[] | undefined => { + if (!key || !preferenceMap || !Object.prototype.hasOwnProperty.call(preferenceMap, key)) return undefined; + const entry = preferenceMap[key]; + return Array.isArray(entry) ? entry : undefined; + }; + const preferredTools = ownPreference(selectedModelId) ?? ownPreference(modelId); + if (!preferredTools?.includes(HOSTED_IMAGE_GENERATION_TOOL) || !isPlainObject(body)) return body; + + const stripGroup = (tools: unknown[]): unknown[] => { + const filtered = tools.filter(tool => !declaresImageGenClientTool(tool)); + return filtered.length === tools.length ? tools : filtered; + }; + + let changed = false; + let tools = body.tools; + let strippedTopLevelImageGenTool = false; + if (Array.isArray(body.tools)) { + tools = stripGroup(body.tools); + strippedTopLevelImageGenTool = tools !== body.tools; + changed ||= strippedTopLevelImageGenTool; + } + + let input = body.input; + const strippedAdditionalToolsIndices = new Set(); + if (Array.isArray(body.input)) { + let nestedChanged = false; + const mappedInput = body.input.map((item, index) => { + if (!isPlainObject(item) || item.type !== "additional_tools" || !Array.isArray(item.tools)) return item; + const nestedTools = stripGroup(item.tools); + if (nestedTools === item.tools) return item; + strippedAdditionalToolsIndices.add(index); + nestedChanged = true; + return { ...item, tools: nestedTools }; + }); + if (nestedChanged) { + input = mappedInput; + changed = true; + } + } + + const hasToolChoice = Object.hasOwn(body, "tool_choice"); + const toolChoice = hasToolChoice ? preferHostedImageGenToolChoice(body.tool_choice) : body.tool_choice; + const toolChoiceChanged = hasToolChoice && toolChoice !== body.tool_choice; + const hasHostedImageGenTool = (toolGroup: unknown): boolean => Array.isArray(toolGroup) + && toolGroup.some(tool => isPlainObject(tool) && tool.type === HOSTED_IMAGE_GENERATION_TOOL); + const hasHostedImageGenDeclaration = hasHostedImageGenTool(tools) + || (Array.isArray(input) && input.some(item => isPlainObject(item) + && item.type === "additional_tools" + && hasHostedImageGenTool(item.tools))); + if ((strippedTopLevelImageGenTool || strippedAdditionalToolsIndices.size > 0) && !hasHostedImageGenDeclaration) { + if (strippedTopLevelImageGenTool && Array.isArray(tools)) { + tools = [...tools, { type: HOSTED_IMAGE_GENERATION_TOOL }]; + } else if (strippedAdditionalToolsIndices.size > 0 && Array.isArray(input)) { + // Restore into the FIRST stripped container only. Tool declarations are + // request-scoped, not container-scoped — the containers are separate carriers for + // one tool set, so a single hosted declaration covers the request. An earlier + // revision restored into every stripped container and put `image_generation` on + // the wire twice; review caught it. + const firstStripped = Math.min(...strippedAdditionalToolsIndices); + input = input.map((item, index) => index === firstStripped + && isPlainObject(item) + && Array.isArray(item.tools) + ? { ...item, tools: [...item.tools, { type: HOSTED_IMAGE_GENERATION_TOOL }] } + : item); + } + } + changed ||= toolChoiceChanged; + if (!changed) return body; + const next: Record = { + ...body, + ...(Array.isArray(body.tools) ? { tools } : {}), + ...(Array.isArray(body.input) ? { input } : {}), + }; + if (toolChoiceChanged) next.tool_choice = toolChoice; + return next; +} + +/** + * Lower one complete Codex image-gen namespace to public Responses function tools. + * + * The public API reserves the `image_gen` namespace and restricts function names to a flat safe + * alphabet. `image_gen__` is therefore an upstream-only alias; client-facing responses are + * restored to explicit `{ namespace: "image_gen", name: "" }` calls by the server. Only a + * non-empty namespace containing named function tools is safe to lower. Malformed, empty, and + * future namespace shapes stay untouched instead of silently losing client capabilities. + */ +function flattenImageGenNamespace(tool: unknown): Record[] | undefined { + if ( + !isPlainObject(tool) + || tool.type !== "namespace" + || tool.name !== IMAGE_GEN_NAMESPACE + || !Array.isArray(tool.tools) + || tool.tools.length === 0 + ) return undefined; + + for (const innerTool of tool.tools) { + if ( + !isPlainObject(innerTool) + || innerTool.type !== "function" + || typeof innerTool.name !== "string" + || innerTool.name.length === 0 + ) return undefined; + } + + return tool.tools.map(innerTool => { + const functionTool = innerTool as Record & { name: string }; + return { + ...functionTool, + name: imageGenWireName(functionTool.name), + }; + }); +} + +/** Convert a legacy dotted function declaration while preserving all other function metadata. */ +function normalizeFlatImageGenFunction(tool: unknown): unknown { + if ( + !isPlainObject(tool) + || tool.type !== "function" + || typeof tool.name !== "string" + || !tool.name.startsWith(IMAGE_GEN_DOTTED_PREFIX) + ) return tool; + return { ...tool, name: imageGenWireName(tool.name) }; +} + +/** Return the image-gen function name used for stable cross-container deduplication. */ +function imageGenFunctionName(tool: unknown): string | undefined { + if (!isPlainObject(tool) || tool.type !== "function" || typeof tool.name !== "string") { + return undefined; + } + return isImageGenClientName(tool.name) ? tool.name : undefined; +} + +/** True only when a declaration can yield a callable upstream-safe image-gen function alias. */ +function declaresUsableImageGenAlias(tool: unknown): boolean { + if (flattenImageGenNamespace(tool)) return true; + if (!isPlainObject(tool) || tool.type !== "function" || typeof tool.name !== "string") { + return false; + } + if (tool.name.startsWith(IMAGE_GEN_DOTTED_PREFIX)) { + return tool.name.length > IMAGE_GEN_DOTTED_PREFIX.length; + } + return tool.name.startsWith(IMAGE_GEN_WIRE_PREFIX) + && tool.name.length > IMAGE_GEN_WIRE_PREFIX.length; +} + +/** Collect client tool-choice names and the exact upstream aliases declared for them. */ +function imageGenToolChoiceAliases(toolGroups: unknown[][]): Map { + const aliases = new Map(); + + for (const group of toolGroups) { + for (const tool of group) { + const flattened = flattenImageGenNamespace(tool); + if (flattened) { + for (const candidate of flattened) { + const wireName = candidate.name as string; + aliases.set(`${IMAGE_GEN_DOTTED_PREFIX}${imageGenLocalName(wireName)}`, wireName); + aliases.set(wireName, wireName); + } + continue; + } + if (!isPlainObject(tool) || tool.type !== "function" || typeof tool.name !== "string") { + continue; + } + if ( + tool.name.startsWith(IMAGE_GEN_DOTTED_PREFIX) + && tool.name.length > IMAGE_GEN_DOTTED_PREFIX.length + ) { + aliases.set(tool.name, imageGenWireName(tool.name)); + } else if ( + tool.name.startsWith(IMAGE_GEN_WIRE_PREFIX) + && tool.name.length > IMAGE_GEN_WIRE_PREFIX.length + ) { + aliases.set(tool.name, tool.name); + } + } + } + + return aliases; +} + +/** Rewrite function selectors only when their corresponding declaration receives a wire alias. */ +function normalizeImageGenToolChoice( + toolChoice: unknown, + aliases: ReadonlyMap, +): unknown { + if (!isPlainObject(toolChoice)) return toolChoice; + + if (toolChoice.type === "function" && typeof toolChoice.name === "string") { + const alias = aliases.get(toolChoice.name); + return alias && alias !== toolChoice.name ? { ...toolChoice, name: alias } : toolChoice; + } + + if (toolChoice.type !== "allowed_tools" || !Array.isArray(toolChoice.tools)) return toolChoice; + let changed = false; + const tools = toolChoice.tools.map(tool => { + if (!isPlainObject(tool) || tool.type !== "function" || typeof tool.name !== "string") { + return tool; + } + const alias = aliases.get(tool.name); + if (!alias || alias === tool.name) return tool; + changed = true; + return { ...tool, name: alias }; + }); + return changed ? { ...toolChoice, tools } : toolChoice; +} + +/** Identify replayed image-gen calls that require upstream wire encoding. */ +function declaresImageGenFunctionCall(item: unknown): boolean { + if (!isPlainObject(item) || item.type !== "function_call" || typeof item.name !== "string") { + return false; + } + return item.namespace === IMAGE_GEN_NAMESPACE || isImageGenClientName(item.name); +} + +/** Encode native or legacy replay calls to the same flat name used by tool declarations. */ +function normalizeImageGenFunctionCall(item: unknown): unknown { + if (!declaresImageGenFunctionCall(item) || !isPlainObject(item) || typeof item.name !== "string") { + return item; + } + if (item.namespace === IMAGE_GEN_NAMESPACE) { + const { namespace: _namespace, ...rest } = item; + return { ...rest, name: imageGenWireName(item.name) }; + } + if (item.name.startsWith(IMAGE_GEN_DOTTED_PREFIX)) { + return { ...item, name: imageGenWireName(item.name) }; + } + return item; +} + +/** + * Normalize Codex's private image-gen tool declaration for API-key Responses providers. + * + * A complete `image_gen` namespace is flattened to safe `image_gen__` aliases even when it is + * the only image tool in the request. Replayed client calls are encoded to the same alias, including + * legacy dotted calls from older compatibility attempts. When a usable alias replaces a client + * image-gen declaration, the duplicate hosted `image_generation` entry is removed. Duplicate aliases + * are resolved in stable container order: top-level tools first, then Responses Lite + * `additional_tools` entries. + * + * This function is called only on the API-key path. ChatGPT forward mode understands the private + * namespace and must keep it. Copy-on-write preserves the original request reference when no + * namespace is flattened, hosted tool removed, or duplicate function discarded. + */ +export function normalizeImageGenClientTools(body: unknown): unknown { + if (!isPlainObject(body)) return body; + + const toolGroups = collectResponsesToolGroups(body); + const hasImageGenClientTool = toolGroups.some(group => group.some(declaresImageGenClientTool)) + || (Array.isArray(body.input) && body.input.some(declaresImageGenFunctionCall)); + if (!hasImageGenClientTool) return body; + const hasUsableImageGenAlias = toolGroups.some(group => group.some(declaresUsableImageGenAlias)); + const toolChoiceAliases = imageGenToolChoiceAliases(toolGroups); + + const seenFunctionNames = new Set(); + const normalizeGroup = (tools: unknown[]): unknown[] => { + const normalized: unknown[] = []; + let groupChanged = false; + + for (const tool of tools) { + if ( + hasUsableImageGenAlias + && isPlainObject(tool) + && tool.type === HOSTED_IMAGE_GENERATION_TOOL + ) { + groupChanged = true; + continue; + } + + const flattened = flattenImageGenNamespace(tool); + const candidates = flattened ?? [tool]; + if (flattened) groupChanged = true; + + for (const candidate of candidates) { + const normalizedCandidate = normalizeFlatImageGenFunction(candidate); + if (normalizedCandidate !== candidate) groupChanged = true; + const functionName = imageGenFunctionName(normalizedCandidate); + if (functionName && seenFunctionNames.has(functionName)) { + groupChanged = true; + continue; + } + if (functionName) seenFunctionNames.add(functionName); + normalized.push(normalizedCandidate); + } + } + + return groupChanged ? normalized : tools; + }; + + let changed = false; + let tools = body.tools; + if (Array.isArray(body.tools)) { + tools = normalizeGroup(body.tools); + changed ||= tools !== body.tools; + } + + let input = body.input; + if (Array.isArray(body.input)) { + let nestedChanged = false; + const mappedInput = body.input.map(item => { + if (isPlainObject(item) && item.type === "additional_tools" && Array.isArray(item.tools)) { + const nestedTools = normalizeGroup(item.tools); + if (nestedTools === item.tools) return item; + nestedChanged = true; + return { ...item, tools: nestedTools }; + } + const normalizedCall = normalizeImageGenFunctionCall(item); + if (normalizedCall !== item) nestedChanged = true; + return normalizedCall; + }); + if (nestedChanged) { + input = mappedInput; + changed = true; + } + } + + const toolChoice = normalizeImageGenToolChoice(body.tool_choice, toolChoiceAliases); + changed ||= toolChoice !== body.tool_choice; + + if (!changed) return body; + return { + ...body, + ...(Array.isArray(body.tools) ? { tools } : {}), + ...(Array.isArray(body.input) ? { input } : {}), + ...(Object.prototype.hasOwnProperty.call(body, "tool_choice") ? { tool_choice: toolChoice } : {}), + }; +} diff --git a/src/adapters/openai-responses/internal.ts b/src/adapters/openai-responses/internal.ts new file mode 100644 index 0000000000..4c43aa836c --- /dev/null +++ b/src/adapters/openai-responses/internal.ts @@ -0,0 +1,3 @@ +export function isPlainObject(v: unknown): v is Record { + return !!v && typeof v === "object" && !Array.isArray(v); +} diff --git a/src/adapters/openai-responses/passthrough.ts b/src/adapters/openai-responses/passthrough.ts new file mode 100644 index 0000000000..4cde7b8d3c --- /dev/null +++ b/src/adapters/openai-responses/passthrough.ts @@ -0,0 +1,611 @@ +import { normalizeRoutedAgentMessages } from "../routed-agent-messages"; +import { stripBracketedModelSuffix } from "../openai-chat"; +import { normalizeOpenCodeGoAdditionalTools } from "../opencode-go-additional-tools"; +import { isXaiResponsesDestination } from "../../providers/xai-transport"; +import { Buffer } from "node:buffer"; +import type { IncomingMeta, ProviderAdapter } from "../base"; +import { namespacedToolName, type AdapterEvent, type OcxParsedRequest, type OcxProviderConfig, type OcxUsage, type TierDecision } from "../../types"; +import { applyCodexRoutingHint, CODEX_RESPONSES_LITE_HEADER, CODEX_ROUTING_HINT_HEADER } from "../../codex/forward-transport-headers"; +import { COMPACT_PROMPT, compactionItemToText, decodeCompactionSummary, isCompactionItemType } from "../../responses/compaction"; +import { decodeServerSentEvents } from "../../lib/sse-decoder"; +import { + CODEX_FORWARD_BASE_URL, + destinationDecodesNativeCompactionBlob, + isCanonicalOpenAiForwardProvider, + isOpenAiOperatedResponsesDestination, +} from "../../providers/openai-tiers"; +import type { TranslatorBudget } from "../../lib/translator-budget"; +import { rewriteRoutedCustomToolsForUpstream } from "../../responses/custom-tool-compat"; +import { rewriteRoutedToolSearchForUpstream } from "../../responses/tool-search-compat"; +import { rewriteRoutedNamespaceToolsForUpstream } from "../../responses/namespace-tool-compat"; +import { preparePlaintextV2AgentMessages } from "../../responses/plaintext-v2-agent-messages"; +import { isMetaAiResponsesDestination, rewriteMuseToolNamesForUpstream } from "../../responses/muse-tool-name-alias"; +import { openaiResponsesUrl } from "../openai-responses-url"; +import { normalizeResponsesCodeMode } from "../responses-code-mode"; +import { injectXaiResponsesXSearch, normalizeXaiResponsesWebSearch } from "../xai-web-search"; +import { + isXaiSchemaTarget, + normalizeXaiToolParameters, + XaiToolSchemaCompatibilityError, +} from "../xai-tool-schema"; +import { + createAdapterTierMetadata, +} from "../../providers/fastwire"; +import { mapRoutedResponsesReasoningEffort, normalizeConfiguredReasoningSummaryDelivery, sanitizeReasoningInputContent, stripDisabledReasoningSummaries, stripDisabledVerbosity, stripUnsupportedReasoningSummaryDelivery } from "./reasoning"; +import { scrubOcxCompactionItems, stripCanonicalOnlyToolFields, stripInternalChatMessageMetadataPassthrough, stripInvalidItemIds, stripItemIdsWhenUnstored } from "./request-strips"; +import { stripCanonicalForwardPromptCacheOptions, stripDeprecatedPromptCacheRetention } from "./prompt-cache"; +import { isPlainObject } from "./internal"; +import { normalizeToolSchemas, promoteClientLoadedTools, stripUnsupportedHostedTools } from "./tool-schema"; +import { annotateEmptyResponsesToolOutputs, backfillWebSearchQueries, normalizeResponsesToolResultAdjacency, repairOrphanedInputItems, repairOversizedReplayCallIds, repairUnidentifiedToolOutputItems } from "./tool-output-recovery"; +import { applyTierDecisionToResponsesBody, normalizeCanonicalForwardContinuationEnvelope, normalizeCanonicalForwardPromptEnvelope, stripCanonicalForwardSamplingParams, stripPreviousResponseId, stripStatefulResponsesParams, stripUnsupportedForwardParams } from "./canonical-forward"; +import { normalizeImageGenClientTools, preferConfiguredHostedTools } from "./image-gen"; +import { stripMuseSparkUnsupportedWebSearchFields, stripOpenAiOnlyWebSearchFields } from "./web-search"; + +// Headers relayed verbatim from the caller in OAuth-passthrough ("forward") mode. +// Exported so the web-search sidecar reuses the exact same forwarded-auth set for its ChatGPT call. +export const FORWARD_HEADERS = [ + "authorization", + "chatgpt-account-id", + "openai-beta", + "originator", + "session_id", + "session-id", + "thread-id", + "x-client-request-id", + "x-codex-beta-features", + "x-codex-installation-id", + "x-codex-parent-thread-id", + "x-codex-turn-metadata", + "x-codex-turn-state", + "x-codex-window-id", + "x-oai-attestation", + "x-openai-subagent", + "x-responsesapi-include-timing-metrics", + CODEX_RESPONSES_LITE_HEADER, +]; + +/** Replace every `input_image` part under a routed-compaction body with a short marker. */ +function stripInputImagesDeep(value: unknown): unknown { + if (Array.isArray(value)) return value.map(stripInputImagesDeep); + if (!isPlainObject(value)) return value; + if (value.type === "input_image") { + return { type: "input_text", text: "[image omitted for compaction]" }; + } + const out: Record = {}; + for (const [key, entry] of Object.entries(value)) out[key] = stripInputImagesDeep(entry); + return out; +} + +/** + * Rewrite a compaction turn for an upstream that does not speak Codex's private + * `compaction_trigger` item: drop the trigger and the whole tool surface, and ask + * for the handoff summary in plain terms instead (#422). + * + * The adapter builds from `parsed._rawBody`, so the summarizer prompt that + * handleResponses() pushed onto `parsed.context` never reaches the wire — it has to + * be applied here. Images go too: a summary needs no pixels, and a text-only + * gateway would reject them. + */ +function buildRoutedCompactionBody(body: unknown): unknown { + if (!isPlainObject(body)) return body; + // `text` goes with the tool fields: the summary must be prose, not schema-constrained JSON. + const { tools: _tools, tool_choice: _toolChoice, parallel_tool_calls: _parallel, text: _text, ...rest } = body; + const input = Array.isArray(body.input) ? body.input : []; + const kept = input.filter(item => !isPlainObject(item) + // `additional_tools` is how Codex Desktop's responses-lite shape carries tools; + // leaving it in would break the no-tools invariant even with `tools` removed. + || (item.type !== "compaction_trigger" && item.type !== "additional_tools")); + return { + ...rest, + input: [ + ...(stripInputImagesDeep(kept) as unknown[]), + { type: "message", role: "user", content: [{ type: "input_text", text: COMPACT_PROMPT }] }, + ], + }; +} + +/** Read the Responses `usage` block, if the gateway sent one. */ +function usageFromResponsesPayload(payload: unknown): OcxUsage | undefined { + if (!isPlainObject(payload) || !isPlainObject(payload.usage)) return undefined; + const usage = payload.usage; + const inputTokens = typeof usage.input_tokens === "number" ? usage.input_tokens : 0; + const outputTokens = typeof usage.output_tokens === "number" ? usage.output_tokens : 0; + // openai/codex#41980: the raw usage object is wire data a rebuilt response.completed must keep — + // unknown keys (subscription metadata, future counters) ride along even when the token counts + // themselves are zero or absent (metadata-only usage). + const knownKeys = new Set(["input_tokens", "output_tokens", "total_tokens", "input_tokens_details", "output_tokens_details"]); + const hasExtras = Object.keys(usage).some(key => !knownKeys.has(key)) + || (isPlainObject(usage.input_tokens_details) + && Object.keys(usage.input_tokens_details).some(key => key !== "cached_tokens" && key !== "cache_write_tokens")) + || (isPlainObject(usage.output_tokens_details) + && Object.keys(usage.output_tokens_details).some(key => key !== "reasoning_tokens")); + if (inputTokens === 0 && outputTokens === 0 && !hasExtras) return undefined; + const inputDetails = isPlainObject(usage.input_tokens_details) ? usage.input_tokens_details : undefined; + const outputDetails = isPlainObject(usage.output_tokens_details) ? usage.output_tokens_details : undefined; + return { + inputTokens, + outputTokens, + ...(typeof usage.total_tokens === "number" ? { totalTokens: usage.total_tokens } : {}), + ...(typeof inputDetails?.cached_tokens === "number" ? { cachedInputTokens: inputDetails.cached_tokens } : {}), + ...(typeof inputDetails?.cache_write_tokens === "number" ? { cacheCreationInputTokens: inputDetails.cache_write_tokens } : {}), + ...(typeof outputDetails?.reasoning_tokens === "number" ? { reasoningOutputTokens: outputDetails.reasoning_tokens } : {}), + ...(hasExtras ? { rawUsage: { ...usage } } : {}), + }; +} + +function responsesPayloadText(response: unknown): string { + if (!isPlainObject(response) || !Array.isArray(response.output)) return ""; + return response.output + .filter(item => isPlainObject(item) && item.type === "message") + .flatMap(item => (Array.isArray((item as Record).content) + ? (item as { content: unknown[] }).content + : [])) + .filter(part => isPlainObject(part) && part.type === "output_text") + .map(part => String((part as { text?: unknown }).text ?? "")) + .join(""); +} + +function responsesErrorMessage(payload: unknown): string { + if (!isPlainObject(payload)) return "upstream compaction failed"; + const err = payload.error; + if (typeof err === "string") return err; + if (isPlainObject(err) && typeof err.message === "string") return err.message; + const incomplete = payload.incomplete_details; + if (isPlainObject(incomplete) && typeof incomplete.reason === "string") return incomplete.reason; + return "upstream compaction failed"; +} + +/** Count an append without rescanning accumulated text, including split surrogate pairs. */ +function appendedUtf8Bytes(previousBytes: number, lastCodeUnit: number, fragment: string): number { + const first = fragment.charCodeAt(0); + // Separate lone surrogates each count as a three-byte replacement character; together + // they encode as one four-byte scalar. Empty fragments produce NaN and never pair. + const joinsSurrogatePair = lastCodeUnit >= 0xd800 && lastCodeUnit <= 0xdbff && first >= 0xdc00 && first <= 0xdfff; + return previousBytes + Buffer.byteLength(fragment, "utf8") - (joinsSurrogatePair ? 2 : 0); +} + +export function createResponsesPassthroughAdapter(provider: OcxProviderConfig): ProviderAdapter & { passthrough: true } { + return { + name: "openai-responses", + passthrough: true as const, + + buildRequest(parsed: OcxParsedRequest, incoming: IncomingMeta) { + const translatorBudget = incoming.translatorBudget; + const headers: Record = { "Content-Type": "application/json" }; + let url: string; + + if (provider.authMode === "forward") { + const mayForwardCallerCredentials = isCanonicalOpenAiForwardProvider(provider); + // OAuth passthrough: ChatGPT backend path is `${baseUrl}/responses` (no /v1). + const baseUrl = mayForwardCallerCredentials + ? CODEX_FORWARD_BASE_URL + : provider.baseUrl.replace(/\/+$/, ""); + url = `${baseUrl}/responses`; + if (provider.headers) Object.assign(headers, provider.headers); // static headers first… + const runtimeProvider = provider as { + _codexAccountOverride?: { accessToken: string; chatgptAccountId: string }; + _codexAccountRequired?: boolean; + }; + if ( + mayForwardCallerCredentials + && runtimeProvider._codexAccountRequired + && !runtimeProvider._codexAccountOverride + ) { + throw new Error("Codex pool account auth is required but unavailable"); + } + if (mayForwardCallerCredentials) { + for (const h of FORWARD_HEADERS) { + const v = incoming?.headers.get(h); + if (v) { + if (h === CODEX_RESPONSES_LITE_HEADER) { + for (const name of Object.keys(headers)) { + if (name.toLowerCase() === h) delete headers[name]; + } + } + headers[h] = v; // …so genuine forwarded fields win. + } + } + } + const override = runtimeProvider._codexAccountOverride; + if (override && mayForwardCallerCredentials) { + headers["authorization"] = `Bearer ${override.accessToken}`; + headers["chatgpt-account-id"] = override.chatgptAccountId; + } + } else { + if (provider.responsesPath === undefined) { + url = openaiResponsesUrl(provider.baseUrl); + } else { + const base = provider.baseUrl.replace(/\/$/, ""); + url = `${base}${provider.responsesPath}`; + } + if (provider.apiKey) headers["Authorization"] = `Bearer ${provider.apiKey}`; + if (provider.headers) Object.assign(headers, provider.headers); + } + + const forward = provider.authMode === "forward"; + let convertedRoutedCustomToolNames: Set | undefined; + let routedCustomToolRepairNames: Set | undefined; + let convertedRoutedToolSearchNames: Set | undefined; + let convertedRoutedNamespaceToolAliases: Map | undefined; + let plaintextV2AgentMessageToolNames: ReadonlySet | undefined; + let plaintextV2AgentMessageAliasedToolNames: ReadonlySet | undefined; + let convertedMuseToolNameAliases: Map | undefined; + const unexpandedMiss = !!parsed.previousResponseId && parsed._previousResponseInputExpanded !== true; + let outBody = stripPreviousResponseId( + parsed._rawBody, + forward || parsed._previousResponseInputExpanded === true, + ); + if (!forward) outBody = normalizeRoutedAgentMessages(outBody, { + allowStringContent: isXaiResponsesDestination(provider), + }); + outBody = mapRoutedResponsesReasoningEffort(outBody, provider, parsed.modelId); + // stripPreviousResponseId() intentionally returns its input on a no-op. Detach before the + // tier write so a force-fast/default decision can never mutate parsed._rawBody. + outBody = applyTierDecisionToResponsesBody(outBody, parsed.options?.tierDecision); + const stateless = provider.statelessResponses === true; + if (stateless) outBody = stripStatefulResponsesParams(outBody); + // A replay miss can leave a function_call_output whose paired function_call sat + // in the prefix that was never expanded. A stateless upstream cannot resolve the + // pair from its own storage either, so it needs the same repair the forward + // backend gets — dropping previous_response_id is not much use if the body that + // reaches the wire is unparseable. + if (provider.annotateEmptyToolOutputs === true) { + outBody = annotateEmptyResponsesToolOutputs(outBody, true); + } + if (forward || stateless) { + outBody = repairOrphanedInputItems(outBody, unexpandedMiss, stateless && !forward); + } + if (provider.requiresAdjacentResponsesToolResults === true) { + outBody = normalizeResponsesToolResultAdjacency(outBody); + } + if (forward) { + outBody = stripUnsupportedForwardParams(outBody); + // Only the canonical ChatGPT backend rejects the retired field; a self-hosted or + // third-party forward gateway may still accept it, so this must not be widened. + if (isCanonicalOpenAiForwardProvider(provider)) { + outBody = stripCanonicalForwardSamplingParams(outBody); + outBody = stripDeprecatedPromptCacheRetention(outBody, parsed.modelId); + outBody = stripCanonicalForwardPromptCacheOptions(outBody); + outBody = normalizeCanonicalForwardPromptEnvelope(outBody); + outBody = normalizeCanonicalForwardContinuationEnvelope(outBody); + } + } else { + outBody = preferConfiguredHostedTools( + outBody, + provider, + parsed.modelId, + parsed._openAiVirtualSelectedModelId, + ); + outBody = normalizeImageGenClientTools(outBody); + } + if (forward || parsed._previousResponseInputExpanded === true) { + outBody = repairOversizedReplayCallIds(outBody); + } + outBody = stripUnsupportedReasoningSummaryDelivery(outBody, parsed.modelId); + // Repair stored history from before the bridge emitted both keys, in either + // direction: a conversation that already recorded a web_search_call replays it + // every turn, and a strict parser rejects the whole request over the missing key — + // `queries` for DeepSeek (#930), `query` for Console Go (#3071). + outBody = backfillWebSearchQueries(outBody); + if (!isCanonicalOpenAiForwardProvider(provider)) { + outBody = stripInternalChatMessageMetadataPassthrough(outBody); + outBody = promoteClientLoadedTools(outBody); + } + if (!isCanonicalOpenAiForwardProvider(provider)) { + const rewritten = rewriteRoutedCustomToolsForUpstream( + outBody, + provider.supportsResponsesCustomTools, + ); + outBody = rewritten.body; + convertedRoutedCustomToolNames = rewritten.names; + routedCustomToolRepairNames = rewritten.repairNames; + } + if (!isCanonicalOpenAiForwardProvider(provider)) { + // Run after custom-tool lowering so the search compatibility layer can choose a + // collision-free public function name against the final routed function catalog. + const rewritten = rewriteRoutedToolSearchForUpstream(outBody); + outBody = rewritten.body; + convertedRoutedToolSearchNames = rewritten.names; + } + if (!isCanonicalOpenAiForwardProvider(provider)) { + // Codex 0.147 emits private namespace tool groups, while public/third-party Responses + // gateways accept only flat tool variants. Run after custom/tool-search lowering so + // namespace children already carry their final public kind before they are promoted. + const rewritten = rewriteRoutedNamespaceToolsForUpstream(outBody, convertedRoutedCustomToolNames); + outBody = rewritten.body; + convertedRoutedNamespaceToolAliases = rewritten.aliases; + // Preserve xAI's cached-only fail-closed semantics and image-search mapping before the + // generic capability fallback removes the private OpenAI fields. + outBody = normalizeXaiResponsesWebSearch(outBody, provider); + outBody = injectXaiResponsesXSearch(outBody, provider, parsed._replayPrefixLen); + // xAI and explicitly classified compatible gateways reject these OpenAI web_search + // extensions. Keep them for OpenAI API-key traffic and unclassified gateways. + if (provider.supportsOpenAiWebSearchToolFields === false) { + outBody = stripOpenAiOnlyWebSearchFields(outBody); + } + outBody = stripMuseSparkUnsupportedWebSearchFields(outBody, parsed.modelId, url); + // Host-only: api.meta.ai rejects function names over 64 chars on every Muse model, + // including default muse-spark-1.3. Do not reuse the contributor/Zen web_search + // predicates. Namespace flattening has already produced the public wire names. + if (isMetaAiResponsesDestination(url)) { + const rewritten = rewriteMuseToolNamesForUpstream(outBody); + outBody = rewritten.body; + convertedMuseToolNameAliases = rewritten.aliases; + } + // Last, so promoted namespace children are also cleared of Codex-private fields. + outBody = stripCanonicalOnlyToolFields(outBody, provider.supportsOpenAiWebSearchToolFields === false); + } + if (!forward) outBody = normalizeOpenCodeGoAdditionalTools(outBody, url); + // Same predicate as the routedCompaction gate in handleResponses(): an authMode check would + // let a noncanonical custom forward provider skip this rewrite while the server still routes + // it as a summarizer turn (#422). The compaction body build removes the tool surface and must + // therefore be the last routed transform that may depend on those declarations. Structural + // sanitizers below can still run after it. + outBody = normalizeResponsesCodeMode(outBody, parsed, provider); + if (parsed._compactionRequest === true && !isCanonicalOpenAiForwardProvider(provider)) { + outBody = buildRoutedCompactionBody(outBody); + } + // Run after routed compaction so nested input_image parts are replaced before a malformed + // tool output is flattened to text and can no longer be inspected structurally. + outBody = repairUnidentifiedToolOutputItems(outBody); + if (parsed._plaintextV2AgentMessages === true && isCanonicalOpenAiForwardProvider(provider)) { + const prepared = preparePlaintextV2AgentMessages(outBody); + outBody = prepared.body; + if (prepared.namespaceAliased) { + plaintextV2AgentMessageToolNames = prepared.toolNames; + plaintextV2AgentMessageAliasedToolNames = prepared.aliasedAgentMessageToolNames; + } + } + const threadServingIdentityChanged = parsed._stripReasoningEncryptedContent === true; + const sanitizedBody = normalizeToolSchemas( + stripItemIdsWhenUnstored( + stripInvalidItemIds( + stripUnsupportedHostedTools( + sanitizeReasoningInputContent( + scrubOcxCompactionItems( + outBody, + destinationDecodesNativeCompactionBlob(provider), + threadServingIdentityChanged, + ), + { + preserveRawReasoningContent: provider.preserveResponsesReasoningContent === true, + dropNullContentChannel: !isOpenAiOperatedResponsesDestination(provider), + stripEncryptedContent: threadServingIdentityChanged, + }, + ), + provider, + ), + ), + ), + isXaiSchemaTarget(provider), + ); + const unnormalizedBody = stripDisabledVerbosity( + stripDisabledReasoningSummaries( + normalizeConfiguredReasoningSummaryDelivery(sanitizedBody, provider, parsed.modelId), + provider, + parsed.modelId, + ), + provider, + parsed.modelId, + ); + // Normalize the wire model before deriving model-dependent transport metadata. + const finalBody = + provider.modelSuffixBracketStrip + && unnormalizedBody !== null + && typeof unnormalizedBody === "object" + && !Array.isArray(unnormalizedBody) + && typeof (unnormalizedBody as { model?: unknown }).model === "string" + ? { ...(unnormalizedBody as Record), model: stripBracketedModelSuffix((unnormalizedBody as { model: string }).model) } + : unnormalizedBody; + if (isCanonicalOpenAiForwardProvider(provider)) { + const routingHeaders = new Headers(headers); + applyCodexRoutingHint(routingHeaders, finalBody); + // Static headers may use mixed casing. Remove every stale spelling + // without normalizing unrelated headers returned by this adapter. + for (const name of Object.keys(headers)) { + if (name.toLowerCase() === CODEX_ROUTING_HINT_HEADER) delete headers[name]; + } + const hint = routingHeaders.get(CODEX_ROUTING_HINT_HEADER); + if (hint !== null) headers[CODEX_ROUTING_HINT_HEADER] = hint; + } + const actualServiceTier = isPlainObject(finalBody) && typeof finalBody.service_tier === "string" + ? finalBody.service_tier + : null; + const tierLog = createAdapterTierMetadata( + parsed.options?.tierObservation, + parsed.options?.tierDecision, + actualServiceTier === null ? null : "service-tier", + actualServiceTier, + ); + // The Responses adapter is passthrough: it forwards `parsed._rawBody` rather than + // rebuilding the body from `parsed.modelId`, and the router writes the routed id into + // that raw body. So a provider whose upstream rejects bracketed ids has to be honoured + // here, on the serialized body, not on the parsed selector. One place covers both the + // HTTP and the WebSocket outbound, because the WS path transports this same request + // instead of rebuilding it. + const body = JSON.stringify(finalBody); + const releaseBodyObservation = translatorBudget.observeExternallyCapped( + "passthrough_serialization", + Buffer.byteLength(body, "utf8"), + ); + return { + url, + method: "POST", + headers, + body, + releaseBodyObservation, + ...(convertedRoutedCustomToolNames ? { convertedRoutedCustomToolNames } : {}), + ...(routedCustomToolRepairNames ? { routedCustomToolRepairNames } : {}), + ...(convertedRoutedToolSearchNames ? { convertedRoutedToolSearchNames } : {}), + ...(convertedRoutedNamespaceToolAliases ? { convertedRoutedNamespaceToolAliases } : {}), + ...(plaintextV2AgentMessageToolNames ? { plaintextV2AgentMessageToolNames } : {}), + ...(plaintextV2AgentMessageAliasedToolNames ? { plaintextV2AgentMessageAliasedToolNames } : {}), + ...(convertedMuseToolNameAliases ? { convertedMuseToolNameAliases } : {}), + ...(tierLog ? { tierLog } : {}), + }; + }, + + // The passthrough normally relays the upstream stream verbatim and never parses. + // The exception is a routed compaction turn: the server drives this adapter like + // an ordinary one so the bridge can build the single compaction item (#422). + async *parseStream(response: Response, budget: TranslatorBudget): AsyncGenerator { + if (!response.body) { + yield { type: "error", message: "passthrough adapter received no response body" }; + return; + } + let deltas = ""; + let deltasBytes = 0; + let deltasLastCodeUnit = 0; + let doneText = ""; + let doneTextBytes = 0; + let doneTextLastCodeUnit = 0; + let snapshot = ""; + let snapshotBytes = 0; + let usage: OcxUsage | undefined; + let usageRawBytes = 0; + let compactionEncryptedContent: string | undefined; + let compactionEncryptedContentBytes = 0; + let completedSeen = false; + for await (const event of decodeServerSentEvents(response.body, { translatorBudget: budget })) { + let payload: unknown; + try { payload = JSON.parse(event.data); } catch { continue; } + if (!isPlainObject(payload)) continue; + switch (payload.type) { + case "response.output_text.delta": + if (typeof payload.delta === "string") { + const next = deltas + payload.delta; + const nextBytes = appendedUtf8Bytes(deltasBytes, deltasLastCodeUnit, payload.delta); + const reservation = budget.reserveTransient(nextBytes, { kind: "retained_collectors" }); + deltas = next; + reservation.commitRetained(); + budget.releaseRetained(deltasBytes, { kind: "retained_collectors" }); + deltasBytes = nextBytes; + if (payload.delta.length > 0) deltasLastCodeUnit = payload.delta.charCodeAt(payload.delta.length - 1); + } + break; + case "response.output_text.done": + if (typeof payload.text === "string") { + const next = doneText + payload.text; + const nextBytes = appendedUtf8Bytes(doneTextBytes, doneTextLastCodeUnit, payload.text); + const reservation = budget.reserveTransient(nextBytes, { kind: "retained_collectors" }); + doneText = next; + reservation.commitRetained(); + budget.releaseRetained(doneTextBytes, { kind: "retained_collectors" }); + doneTextBytes = nextBytes; + if (payload.text.length > 0) doneTextLastCodeUnit = payload.text.charCodeAt(payload.text.length - 1); + } + break; + case "response.failed": + case "error": + yield { type: "error", message: responsesErrorMessage(payload.response ?? payload) }; + return; + case "response.incomplete": + yield { type: "incomplete", reason: responsesErrorMessage(payload.response ?? payload) }; + return; + case "response.completed": + { + completedSeen = true; + const responsePayload = isPlainObject(payload.response) ? payload.response : undefined; + const output = Array.isArray(responsePayload?.output) ? responsePayload.output : []; + const compaction = output.find(item => isPlainObject(item) && item.type === "compaction"); + if (isPlainObject(compaction) && typeof compaction.encrypted_content === "string") { + const nextEncryptedContent = compaction.encrypted_content; + const nextEncryptedContentBytes = Buffer.byteLength(nextEncryptedContent, "utf8"); + const reservation = budget.reserveTransient(nextEncryptedContentBytes, { kind: "retained_collectors" }); + compactionEncryptedContent = nextEncryptedContent; + reservation.commitRetained(); + budget.releaseRetained(compactionEncryptedContentBytes, { kind: "retained_collectors" }); + compactionEncryptedContentBytes = nextEncryptedContentBytes; + } + const next = responsesPayloadText(payload.response); + const nextBytes = Buffer.byteLength(next, "utf8"); + const reservation = budget.reserveTransient(nextBytes, { kind: "retained_collectors" }); + snapshot = next; + reservation.commitRetained(); + budget.releaseRetained(snapshotBytes, { kind: "retained_collectors" }); + snapshotBytes = nextBytes; + } + { + const nextUsage = usageFromResponsesPayload(payload.response); + // The attached raw usage object can be event-sized (unknown keys carry arbitrary + // values); it stays reachable until the terminal yields, so charge it like the + // adjacent retained collectors or it would defeat the per-request memory cap. + const nextRawBytes = nextUsage?.rawUsage === undefined ? 0 + : Buffer.byteLength(JSON.stringify(nextUsage.rawUsage), "utf8"); + if (nextRawBytes > 0) { + const reservation = budget.reserveTransient(nextRawBytes, { kind: "retained_collectors" }); + usage = nextUsage; + reservation.commitRetained(); + } else { + usage = nextUsage; + } + if (usageRawBytes > 0) { + budget.releaseRetained(usageRawBytes, { kind: "retained_collectors" }); + } + usageRawBytes = nextRawBytes; + } + break; + } + // Buffered text is still upstream progress, but gateway keepalives are not. + // Yield after accounting, directly to the consumer: no progress queue or content leak. + if ( + !completedSeen + && (payload.type === "response.output_text.delta" + || payload.type === "response.reasoning_summary_text.delta" + || payload.type === "response.reasoning_text.delta") + && typeof payload.delta === "string" + && payload.delta.length > 0 + ) { + yield { type: "heartbeat" }; + } + } + // Gateways differ in which of these they emit; prefer the authoritative + // completed snapshot so text is never double-counted. + const text = snapshot || doneText || deltas; + if (text) yield { type: "text_delta", text }; + budget.releaseRetained( + deltasBytes + doneTextBytes + snapshotBytes + usageRawBytes, + { kind: "retained_collectors" }, + ); + yield { + type: "done", + ...(usage ? { usage } : {}), + ...(compactionEncryptedContent ? { compactionEncryptedContent } : {}), + }; + }, + + async parseResponse(response: Response, budget: TranslatorBudget): Promise { + let payload: unknown; + try { payload = await response.json(); } catch { + return [{ type: "error", message: "malformed upstream compaction response" }]; + } + budget.chargeRetained(Buffer.byteLength(JSON.stringify(payload), "utf8"), { kind: "retained_collectors" }); + if (!isPlainObject(payload)) { + return [{ type: "error", message: "malformed upstream compaction response" }]; + } + if (payload.error || payload.status === "failed") { + return [{ type: "error", message: responsesErrorMessage(payload) }]; + } + if (payload.status === "incomplete") { + return [{ type: "incomplete", reason: responsesErrorMessage(payload) }]; + } + const usage = usageFromResponsesPayload(payload); + const output = Array.isArray(payload.output) ? payload.output : []; + const compaction = output.find(item => isPlainObject(item) && item.type === "compaction"); + const compactionEncryptedContent = isPlainObject(compaction) && typeof compaction.encrypted_content === "string" + ? compaction.encrypted_content + : undefined; + const text = responsesPayloadText(payload); + if (!text && !compactionEncryptedContent) { + // A completed turn with neither text nor a native compaction blob cannot become a + // replacement-history item. A ciphertext-only native completion is valid, though. + return [{ type: "error", message: "upstream compaction returned no summary text" }]; + } + return [...(text ? [{ type: "text_delta" as const, text }] : []), { + type: "done", + ...(usage ? { usage } : {}), + ...(compactionEncryptedContent ? { compactionEncryptedContent } : {}), + }]; + }, + }; +} diff --git a/src/adapters/openai-responses/prompt-cache.ts b/src/adapters/openai-responses/prompt-cache.ts new file mode 100644 index 0000000000..dff927dd17 --- /dev/null +++ b/src/adapters/openai-responses/prompt-cache.ts @@ -0,0 +1,83 @@ +import { isPlainObject } from "./internal"; + +/** + * GPT-5.6 retired the legacy 24-hour retention field, and the ChatGPT backend 400s the whole + * request when that field is present (issue #2092). + * + * The retired field is NOT translated to the replacement: 5.6 carries a different TTL contract, + * and implicit caching still applies when the caller sent no replacement options. Inventing a + * value here would silently change a caching decision the caller never made. + * + * Deliberately narrow on both axes, because a wider strip is a behavior change rather than a fix: + * only the gpt-5.6 family (an older model may still honor the field), and only on the canonical + * ChatGPT backend, which is the deployment that rejects it. Matching is exact-or-dashed-prefix so + * a future `gpt-5.60` is not swept up by a bare `startsWith`. + */ +export function stripDeprecatedPromptCacheRetention(body: unknown, modelId: unknown): unknown { + if (!isPlainObject(body)) return body; + if (typeof modelId !== "string") return body; + if (modelId !== "gpt-5.6" && !modelId.startsWith("gpt-5.6-")) return body; + if (!Object.hasOwn(body, "prompt_cache_retention")) return body; + const { prompt_cache_retention: _retention, ...rest } = body; + return rest; +} + +/** + * Public Responses clients can send `prompt_cache_options`, but the canonical ChatGPT Codex + * backend rejects the top-level field before inference (issue #2765). Custom forward gateways and + * API-key Responses providers own different wire contracts, so the caller applies this only after + * the canonical destination predicate succeeds. + */ +export function stripCanonicalForwardPromptCacheOptions(body: unknown): unknown { + if (!isPlainObject(body) || !Object.hasOwn(body, "prompt_cache_options")) return body; + const { prompt_cache_options: _options, ...rest } = body; + return rest; +} + +const POSIT_CACHE_MARKER_MAX_DEPTH = 64; +const POSIT_CACHE_MARKER_MAX_NODES = 100_000; + +type PromptCacheMarkerRewrite = { + value: unknown; + changed: boolean; + complete: boolean; +}; + +/** + * Remove Posit/Anthropic-style prompt-cache markers without trusting request nesting. The walk + * aborts atomically when its depth or node budget is exceeded, so a hostile extension object can + * neither overflow the stack nor receive a partially rewritten subtree. + */ +export function stripPromptCacheBreakpoints( + value: unknown, + state: { nodes: number }, + depth = 0, +): PromptCacheMarkerRewrite { + state.nodes += 1; + if (depth > POSIT_CACHE_MARKER_MAX_DEPTH || state.nodes > POSIT_CACHE_MARKER_MAX_NODES) { + return { value, changed: false, complete: false }; + } + if (Array.isArray(value)) { + let changed = false; + const next: unknown[] = []; + for (const entry of value) { + const rewritten = stripPromptCacheBreakpoints(entry, state, depth + 1); + if (!rewritten.complete) return { value, changed: false, complete: false }; + changed ||= rewritten.changed; + next.push(rewritten.value); + } + return { value: changed ? next : value, changed, complete: true }; + } + if (!isPlainObject(value)) return { value, changed: false, complete: true }; + + let changed = Object.hasOwn(value, "prompt_cache_breakpoint"); + const next: Record = {}; + for (const [key, entry] of Object.entries(value)) { + if (key === "prompt_cache_breakpoint") continue; + const rewritten = stripPromptCacheBreakpoints(entry, state, depth + 1); + if (!rewritten.complete) return { value, changed: false, complete: false }; + changed ||= rewritten.changed; + next[key] = rewritten.value; + } + return { value: changed ? next : value, changed, complete: true }; +} diff --git a/src/adapters/openai-responses/reasoning.ts b/src/adapters/openai-responses/reasoning.ts new file mode 100644 index 0000000000..5e84defec6 --- /dev/null +++ b/src/adapters/openai-responses/reasoning.ts @@ -0,0 +1,209 @@ +import { namespacedToolName, type AdapterEvent, type OcxParsedRequest, type OcxProviderConfig, type OcxUsage, type TierDecision } from "../../types"; +import { catalogModelSupportsReasoningSummaries } from "../../codex/catalog"; +import { OCX_REASONING_PREFIX } from "../../responses/reasoning-envelope"; +import { configuredReasoningEfforts, mapReasoningEffort, modelRecordValue } from "../../reasoning-effort"; +import { isPlainObject } from "./internal"; + +/** + * Sanitize reasoning input by field policy, not by preserving each item's shape. Retaining a + * native `encrypted_content` guarantees only that blob value: `status` is always removed; + * proxy-owned `ocxr1:` envelopes are always removed; and native blobs are removed when the caller + * requests stripping after a route-identity change or opaque-blob recovery. On routed/non-OpenAI + * destinations, a present non-array `content` field is omitted. Otherwise non-empty array content + * is blanked unless raw reasoning preservation is enabled; removing an `ocxr1:` envelope selects + * the same blanking path when non-array omission is not active. + */ +export function sanitizeReasoningInputContent( + body: unknown, + opts?: { + preserveRawReasoningContent?: boolean; + dropNullContentChannel?: boolean; + stripEncryptedContent?: boolean; + }, +): unknown { + if (!body || typeof body !== "object" || Array.isArray(body)) return body; + const raw = body as Record; + if (!Array.isArray(raw.input)) return body; + + let changed = false; + const input = raw.input.map(item => { + if (!item || typeof item !== "object" || Array.isArray(item)) return item; + const rec = item as Record; + if (rec.type !== "reasoning") return item; + const hasRawContent = Array.isArray(rec.content) && rec.content.length > 0; + // ocxr1 envelopes are proxy-minted (Anthropic signatures), not OpenAI encryption — the native + // backend cannot decrypt them and would reject the request. Strip regardless of content shape. + const hasOcxEnvelope = typeof rec.encrypted_content === "string" && rec.encrypted_content.startsWith(OCX_REASONING_PREFIX); + const hasOutputStatus = Object.prototype.hasOwnProperty.call(rec, "status"); + const hasEncryptedContent = Object.prototype.hasOwnProperty.call(rec, "encrypted_content"); + const stripEncryptedContent = hasOcxEnvelope + || (opts?.stripEncryptedContent === true && hasEncryptedContent); + // Codex serializes an absent reasoning content channel as `"content": null`. The field is + // optional and null carries nothing, but a strict gateway rejects the item on its declared type + // — xAI answers `Could not decode the compaction blob`, naming the sibling `encrypted_content` + // rather than the field it actually refused, which is why this reads as a blob failure. Drop the + // key so the item matches the shape the upstream issued. + // + // Gated to routed destinations. An OpenAI-operated backend rejects a blob-bearing item when its + // null `content` channel is deleted (`The encrypted content ... could not be verified`); that + // live result establishes this channel constraint, not whole-item shape preservation. The gate + // is also why this drop may touch an item that keeps its blob: xAI demonstrably accepts its own + // blob without the null channel. This is independent of the output-only status removal below. + const dropNullContentChannel = opts?.dropNullContentChannel === true + && "content" in rec && !Array.isArray(rec.content); + // `status` is output-only. Measured OpenAI reasoning items never contain it, and Grok accepts + // its own encrypted_content with status removed. Keeping a foreign status beside a retained + // blob makes OpenAI reject the field before blob validation, starving the provenance recovery + // of the opaque-blob error it needs. Content blanking remains the separate pre-existing rule. + const stripOutputStatus = hasOutputStatus; + const blankContent = !dropNullContentChannel + && !opts?.preserveRawReasoningContent + && (hasRawContent || hasOcxEnvelope); + if (!blankContent && !stripOutputStatus && !stripEncryptedContent && !dropNullContentChannel) { + return item; + } + changed = true; + const next: Record = { ...rec }; + if (dropNullContentChannel) delete next.content; + if (stripOutputStatus) delete next.status; + if (stripEncryptedContent) delete next.encrypted_content; + // Routed models can produce raw `reasoning_text` output items. Codex echoes those in later + // native GPT requests, but ChatGPT's Responses backend accepts reasoning input only with empty + // `content`; keep summaries/ids and drop the raw content so native passthrough does not 400. + // DeepSeek's Responses API instead ACCEPTS plaintext reasoning replay (its compatibility + // guide merges reasoning items into the adjacent assistant message), so providers flagged + // `preserveResponsesReasoningContent` keep it — deleting valid replay content there breaks + // continuations after tool calls (issue #875 family). + if (blankContent) next.content = []; + return next; + }); + + return changed ? { ...raw, input } : body; +} + +export function stripUnsupportedReasoningSummaryDelivery(body: unknown, modelId: string): unknown { + if (catalogModelSupportsReasoningSummaries(modelId) !== false) return body; + if (!isPlainObject(body) || !isPlainObject(body.stream_options)) return body; + if (!("reasoning_summary_delivery" in body.stream_options)) return body; + + const streamOptions = { ...body.stream_options }; + delete streamOptions.reasoning_summary_delivery; + const next = { ...body }; + if (Object.keys(streamOptions).length > 0) next.stream_options = streamOptions; + else delete next.stream_options; + return next; +} + +/** + * A false model capability prevents Codex from emitting summary fields after the catalog refresh. + * Strip them here as well so an already-running client with a stale catalog cannot keep sending an + * upstream-rejected `reasoning_summary_delivery` value (issue #323). + */ +export function stripDisabledReasoningSummaries( + body: unknown, + provider: OcxProviderConfig, + modelId: string, +): unknown { + if (modelRecordValue(provider.modelSupportsReasoningSummaries, modelId) !== false || !isPlainObject(body)) { + return body; + } + + let changed = false; + let streamOptions = body.stream_options; + if (isPlainObject(streamOptions) && Object.hasOwn(streamOptions, "reasoning_summary_delivery")) { + const { reasoning_summary_delivery: _delivery, ...rest } = streamOptions; + streamOptions = rest; + changed = true; + } + + let reasoning = body.reasoning; + if (isPlainObject(reasoning)) { + const { summary: _summary, generate_summary: _generateSummary, ...rest } = reasoning; + if (_summary !== undefined || _generateSummary !== undefined) { + reasoning = rest; + changed = true; + } + } + + if (!changed) return body; + return { + ...body, + ...(isPlainObject(streamOptions) && Object.keys(streamOptions).length > 0 + ? { stream_options: streamOptions } + : { stream_options: undefined }), + ...(isPlainObject(reasoning) && Object.keys(reasoning).length > 0 + ? { reasoning } + : { reasoning: undefined }), + }; +} + +/** + * Hide a no-op Responses verbosity control from the wire as well as the catalog. This runs at + * final serialization so a stale catalog or direct caller cannot bypass the capability. Other + * `text` settings (notably structured-output `format`) remain untouched. + */ +export function stripDisabledVerbosity( + body: unknown, + provider: OcxProviderConfig, + modelId: string, +): unknown { + if (modelRecordValue(provider.modelSupportsVerbosity, modelId) !== false || !isPlainObject(body)) { + return body; + } + if (!isPlainObject(body.text) || !Object.hasOwn(body.text, "verbosity")) return body; + const { verbosity: _verbosity, ...rest } = body.text; + return { + ...body, + ...(Object.keys(rest).length > 0 ? { text: rest } : { text: undefined }), + }; +} + +/** + * Normalize only the delivery enum Codex already emitted. Do not inject a field into callers that + * did not request summaries, and leave every unconfigured provider/model byte-for-byte unchanged. + */ +export function normalizeConfiguredReasoningSummaryDelivery( + body: unknown, + provider: OcxProviderConfig, + modelId: string, +): unknown { + const delivery = modelRecordValue(provider.modelReasoningSummaryDelivery, modelId); + if (delivery === undefined || !isPlainObject(body) || !isPlainObject(body.stream_options)) return body; + if (!Object.hasOwn(body.stream_options, "reasoning_summary_delivery")) return body; + if (body.stream_options.reasoning_summary_delivery === delivery) return body; + return { + ...body, + stream_options: { + ...body.stream_options, + reasoning_summary_delivery: delivery, + }, + }; +} + +/** + * Apply the routed provider's real effort ladder to an existing Responses reasoning field. + * Native forward requests keep the server-owned native clamp; unknown third-party ladders stay + * byte-equivalent instead of acquiring a policy from this adapter. + */ +export function mapRoutedResponsesReasoningEffort( + body: unknown, + provider: OcxProviderConfig, + modelId: string, +): unknown { + if (provider.authMode === "forward") return body; + if (configuredReasoningEfforts(provider, modelId) === undefined) return body; + if (!isPlainObject(body) || !isPlainObject(body.reasoning)) return body; + const declaredEfforts = modelRecordValue(provider.modelReasoningEfforts, modelId) ?? provider.reasoningEfforts; + // An explicitly empty ladder means no effort control, not no reasoning output. + // Omit only effort so the upstream default applies; unknown/non-rankable ladders stay untouched. + if (declaredEfforts?.length === 0 && Object.hasOwn(body.reasoning, "effort")) { + const { effort: _effort, ...reasoning } = body.reasoning; + return { ...body, reasoning: Object.keys(reasoning).length > 0 ? reasoning : undefined }; + } + const requested = body.reasoning.effort; + if (typeof requested !== "string") return body; + + const mapped = mapReasoningEffort(provider, modelId, requested); + if (!mapped || mapped === requested) return body; + return { ...body, reasoning: { ...body.reasoning, effort: mapped } }; +} diff --git a/src/adapters/openai-responses/request-strips.ts b/src/adapters/openai-responses/request-strips.ts new file mode 100644 index 0000000000..92da13025c --- /dev/null +++ b/src/adapters/openai-responses/request-strips.ts @@ -0,0 +1,185 @@ +import { COMPACT_PROMPT, compactionItemToText, decodeCompactionSummary, isCompactionItemType } from "../../responses/compaction"; +import { isPlainObject } from "./internal"; +import { activateDeferredTool } from "./tool-schema"; +import { stripOpenAiOnlyWebSearchFields } from "./web-search"; + +export function stripInvalidItemIds(body: unknown): unknown { + if (!isPlainObject(body) || !Array.isArray(body.input)) return body; + + const validPrefixes: Record = { + message: "msg_", + agent_message: "amsg_", + reasoning: "rs_", + function_call: "fc_", + custom_tool_call: "ctc_", + tool_search_call: "tsc_", + web_search_call: "ws_", + }; + let changed = false; + const input = body.input.map(item => { + if (!isPlainObject(item) || typeof item.type !== "string") return item; + const validPrefix = validPrefixes[item.type]; + if (!validPrefix) return item; + if (typeof item.id === "string" && item.id.startsWith(validPrefix)) return item; + if (!("id" in item)) return item; + changed = true; + const next = { ...item }; + delete next.id; + return next; + }); + + return changed ? { ...body, input } : body; +} + +/** + * Codex-private tool fields that only the ChatGPT backend understands. + * + * A third-party Responses gateway validates its schema and rejects the whole request before + * inference — xAI answers `Argument not supported: external_web_access` — so these are removed at + * the noncanonical boundary while the tool and every public option stay. + * + * Keep this a table. Each private bit Codex attaches has so far arrived as its own bespoke strip + * with its own traversal, and the traversals disagreed about which containers they covered; a new + * one should be a row here instead. `toolTypes` omitted means the field is private on any tool. + */ +const CANONICAL_ONLY_TOOL_FIELDS: readonly { field: string; toolTypes?: ReadonlySet; capabilityGated?: boolean }[] = [ + // ChatGPT's browsing policy bit. The public hosted tool is enabled by its presence alone. + // OWNERSHIP: official OpenAI API-key traffic and unclassified gateways ACCEPT this field, so + // it is only stripped when the provider capability denies it (supportsOpenAiWebSearchToolFields + // === false), matching stripOpenAiOnlyWebSearchFields; see + // tests/responses/responses-routed-web-search-fields.test.ts. + { field: "external_web_access", toolTypes: new Set(["web_search", "web_search_preview"]), capabilityGated: true }, + // Deferred-discovery marker. `activateDeferredTool` clears it only for tools a `tool_search_output` + // already loaded, so a still-deferred declaration — including one promoted out of a namespace + // group — otherwise reaches the wire carrying it. + { field: "defer_loading" }, +]; + +export function stripCanonicalOnlyToolFields(body: unknown, includeCapabilityGated: boolean): unknown { + if (!isPlainObject(body)) return body; + + const rewriteTools = (tools: unknown[]): unknown[] => { + let changed = false; + const rewritten = tools.map(tool => { + if (!isPlainObject(tool)) return tool; + let next = tool; + for (const { field, toolTypes, capabilityGated } of CANONICAL_ONLY_TOOL_FIELDS) { + if (capabilityGated && !includeCapabilityGated) continue; + if (!Object.hasOwn(next, field)) continue; + if (toolTypes && (typeof next.type !== "string" || !toolTypes.has(next.type))) continue; + const { [field]: _private, ...rest } = next; + next = rest; + } + if (next === tool) return tool; + changed = true; + return next; + }); + return changed ? rewritten : tools; + }; + + let rewrittenBody = body; + if (Array.isArray(body.tools)) { + const tools = rewriteTools(body.tools); + if (tools !== body.tools) rewrittenBody = { ...rewrittenBody, tools }; + } + if (!Array.isArray(body.input)) return rewrittenBody; + + let input: unknown[] | undefined; + for (let index = 0; index < body.input.length; index += 1) { + const item = body.input[index]; + if (!isPlainObject(item) || item.type !== "additional_tools" || !Array.isArray(item.tools)) continue; + const tools = rewriteTools(item.tools); + if (tools === item.tools) continue; + input ??= [...body.input]; + input[index] = { ...item, tools }; + } + return input ? { ...rewrittenBody, input } : rewrittenBody; +} + +/** + * Codex keeps this ChatGPT-internal item metadata when its configured provider name is `openai`. + * Loopback OpenCodex injection intentionally retains that provider identity for history continuity, + * even when the proxy ultimately routes the request to a public Responses destination. Those + * destinations reject the private field as an unknown `input[*]` parameter, so remove it at the + * noncanonical boundary without mutating the caller-owned raw body. + */ +export function stripInternalChatMessageMetadataPassthrough(body: unknown): unknown { + if (!isPlainObject(body) || !Array.isArray(body.input)) return body; + + let changed = false; + const input = body.input.map(item => { + if (!isPlainObject(item) || !Object.hasOwn(item, "internal_chat_message_metadata_passthrough")) { + return item; + } + changed = true; + const next = { ...item }; + delete next.internal_chat_message_metadata_passthrough; + return next; + }); + + return changed ? { ...body, input } : body; +} + +/** + * When `store` is false, the upstream API does not persist response items. Any item ID + * forwarded in `input` is then interpreted as a reference to a stored item that does not + * exist, producing a 404. Strip all item IDs in this case — `call_id` pairing is unaffected. + * Matches codex-rs behavior (core/src/client.rs:918-925). + */ +export function stripItemIdsWhenUnstored(body: unknown): unknown { + if (!isPlainObject(body) || body.store !== false) return body; + if (!Array.isArray(body.input)) return body; + + let changed = false; + const input = body.input.map(item => { + if (!isPlainObject(item) || !("id" in item)) return item; + changed = true; + const next = { ...item }; + delete next.id; + return next; + }); + + return changed ? { ...body, input } : body; +} + +/** + * Normalize replayed compaction items for the destination backend. + * + * A compaction item carries an `encrypted_content` blob the client replays verbatim on every later + * turn, and only the backend that minted it can decode it. Proxy-minted `ocx1:` envelopes are + * transparent base64 rather than encryption, so no upstream can read them and they always become + * plain user messages. Native blobs have multiple possible minters, so a destination's ability to + * decode its own blobs does not make a blob from a previous serving identity portable. On a known + * identity mismatch the blob degrades to the same note the bridged parser uses, even when the + * destination normally accepts native blobs. Without a known mismatch, the destination capability + * keeps the existing behavior. + * + * A bare `context_compaction` marker carries no blob and is forwarded untouched. + */ +export function scrubOcxCompactionItems( + body: unknown, + destinationDecodesNativeBlob: boolean, + threadServingIdentityChanged: boolean, +): unknown { + if (!isPlainObject(body) || !Array.isArray(body.input)) return body; + + let changed = false; + const input = body.input.map(item => { + if (!isPlainObject(item) || !isCompactionItemType(item.type)) return item; + const encrypted = typeof item.encrypted_content === "string" ? item.encrypted_content : undefined; + if (encrypted === undefined) return item; + if ( + decodeCompactionSummary(encrypted) === null + && destinationDecodesNativeBlob + && !threadServingIdentityChanged + ) return item; + changed = true; + return { + type: "message", + role: "user", + content: [{ type: "input_text", text: compactionItemToText(encrypted) }], + }; + }); + + return changed ? { ...body, input } : body; +} diff --git a/src/adapters/openai-responses/tool-output-recovery.ts b/src/adapters/openai-responses/tool-output-recovery.ts new file mode 100644 index 0000000000..ec67cbaee9 --- /dev/null +++ b/src/adapters/openai-responses/tool-output-recovery.ts @@ -0,0 +1,509 @@ +import { createHash } from "node:crypto"; +import { EMPTY_TOOL_OUTPUT_ANNOTATION, isWhitespaceOnlyTextPartArray } from "../empty-tool-output-annotation"; +import { isPlainObject } from "./internal"; + +const MAX_RESPONSES_CALL_ID_LENGTH = 64; + +const REPAIRED_CALL_ID_PREFIX = "call_ocx_"; +const REPAIRED_CALL_ID_DIGEST_LENGTH = MAX_RESPONSES_CALL_ID_LENGTH - REPAIRED_CALL_ID_PREFIX.length; + +/** + * The ChatGPT Responses backend rejects input `call_id` values longer than 64 characters. Codex + * sidechat/fork replay can namespace call ids from routed providers past that limit. Forward mode + * already sends explicit replay input without `previous_response_id`, so it is safe to replace each + * oversized id and every matching call/output occurrence with one deterministic request-local alias. + * Raw API-key continuations are intentionally excluded because an output-only continuation may + * reference a call stored upstream under the original id. Proxy-expanded API-key replays are + * explicit and stateless here, so they are safe to repair too. + */ +export function repairOversizedReplayCallIds(body: unknown): unknown { + if (!isPlainObject(body) || !Array.isArray(body.input)) return body; + + const occupied = new Set(); + for (const item of body.input) { + if (!isPlainObject(item) || typeof item.call_id !== "string") continue; + if (item.call_id.length <= MAX_RESPONSES_CALL_ID_LENGTH) occupied.add(item.call_id); + } + + const aliases = new Map(); + let changed = false; + const input = body.input.map(item => { + if (!isPlainObject(item) || typeof item.call_id !== "string") return item; + const original = item.call_id; + if (original.length <= MAX_RESPONSES_CALL_ID_LENGTH) return item; + + let alias = aliases.get(original); + if (!alias) { + let salt = 0; + do { + const hashInput = salt === 0 ? original : `${original}\0${salt}`; + const digest = createHash("sha256").update(hashInput).digest("hex"); + alias = `${REPAIRED_CALL_ID_PREFIX}${digest.slice(0, REPAIRED_CALL_ID_DIGEST_LENGTH)}`; + salt += 1; + } while (occupied.has(alias)); + aliases.set(original, alias); + occupied.add(alias); + } + + changed = true; + return { ...item, call_id: alias }; + }); + + return changed ? { ...body, input } : body; +} + +/** Flatten a Responses tool-output `output` value (string or content-part array) to plain text. */ +function toolOutputText(output: unknown): string { + if (typeof output === "string") return output; + if (!Array.isArray(output)) return JSON.stringify(output ?? ""); + return output.map(part => { + if (!isPlainObject(part)) return ""; + if (typeof part.text === "string") return part.text; + if (part.type === "refusal" && typeof part.refusal === "string") return `[refusal] ${part.refusal}`; + return ""; + }).filter(Boolean).join("\n"); +} + +/** True when an output can be losslessly represented as user-message content. */ +function isRepairableToolOutput(output: unknown): output is string | Record[] { + if (typeof output === "string") return true; + if (!Array.isArray(output)) return false; + return output.every(part => { + if (!isPlainObject(part)) return false; + if (typeof part.type !== "string") return false; + if (["output_text", "text", "input_text"].includes(part.type)) { + return typeof part.text === "string"; + } + if (part.type === "refusal") return typeof part.refusal === "string"; + if (part.type === "encrypted_content") return typeof part.encrypted_content === "string"; + if (part.type !== "input_image") return false; + const imageUrl = part.image_url; + const fileId = part.file_id; + const imageUrlIsString = typeof imageUrl === "string"; + const fileIdIsString = typeof fileId === "string"; + const hasUsableSource = (imageUrlIsString && imageUrl.length > 0) + || (fileIdIsString && fileId.length > 0); + const validSource = hasUsableSource + && (part.image_url === undefined || imageUrlIsString) + && (part.file_id === undefined || fileIdIsString); + const validDetail = part.detail === undefined + || (typeof part.detail === "string" + && ["auto", "low", "high", "original"].includes(part.detail)); + return validSource && validDetail; + }); +} + +/** Convert orphaned tool output to user-message content without discarding valid images. */ +function orphanedToolOutputContent(output: unknown, callId = ""): Record[] { + const marker = `[tool output for ${callId || "unknown call"}]`; + if (typeof output !== "string" && !Array.isArray(output)) { + return [{ type: "input_text", text: marker }]; + } + if (!Array.isArray(output)) { + return [{ type: "input_text", text: `${marker}\n${toolOutputText(output)}` }]; + } + + const content: Record[] = [{ type: "input_text", text: marker }]; + for (const part of output) { + if (!isPlainObject(part)) continue; + if (part.type === "input_image") { + content.push(part); + } else if (part.type === "encrypted_content" && typeof part.encrypted_content === "string") { + content.push({ type: "input_text", text: "[encrypted content omitted]" }); + } else if (typeof part.text === "string") { + content.push({ type: "input_text", text: part.text }); + } else if (part.type === "refusal" && typeof part.refusal === "string") { + content.push({ type: "input_text", text: `[refusal] ${part.refusal}` }); + } + } + return content; +} + +/** True when a Responses tool output item is present but carries no usable content. */ +function isToolOutputEmpty(output: unknown): boolean { + if (typeof output === "string") return output.trim() === ""; + if (Array.isArray(output)) { + // Mirror the Chat wire rule through the shared contract: only a pure + // text/refusal part array whose joined content trims empty is annotated. + // input_image, encrypted_content, input_file and any other non-text part is + // real output and must never be replaced. + return isWhitespaceOnlyTextPartArray(output); + } + // A missing or null `output` is not a present-but-empty result: it is an + // incomplete payload. Leave it untouched so the upstream contract fails + // closed, and the orphan repair can surface it honestly instead of claiming + // the tool ran with no output. + return false; +} + +/** + * Rewrite present-but-empty tool outputs to an explicit annotation. Synthetic + * missing-result placeholders are non-empty and pass through untouched. No-op unless + * the provider opts in (`annotateEmptyToolOutputs`). + */ +export function annotateEmptyResponsesToolOutputs(body: unknown, enabled: boolean): unknown { + if (!enabled || !isPlainObject(body) || !Array.isArray(body.input)) return body; + let changed = false; + const input = body.input.map(item => { + if (!isPlainObject(item) || (item.type !== "function_call_output" && item.type !== "custom_tool_call_output")) return item; + if (!isToolOutputEmpty(item.output)) return item; + changed = true; + return { ...item, output: EMPTY_TOOL_OUTPUT_ANNOTATION }; + }); + return changed ? { ...body, input } : body; +} + +/** + * Preserve the text of structurally invalid tool-output items before they reach a strict + * Responses parser. Stateful destinations may legitimately receive an output whose matching + * call lives behind `previous_response_id`, so ordinary orphan repair cannot run universally. + * A missing or empty `call_id`, however, cannot identify stored state on any destination. + */ +export function repairUnidentifiedToolOutputItems(body: unknown): unknown { + if (!isPlainObject(body) || !Array.isArray(body.input)) return body; + let changed = false; + const input = body.input.map(item => { + if (!isPlainObject(item) + || (item.type !== "function_call_output" && item.type !== "custom_tool_call_output") + || (typeof item.call_id === "string" && item.call_id.length > 0)) { + return item; + } + if (!isRepairableToolOutput(item.output)) return item; + changed = true; + return { + type: "message", + role: "user", + content: orphanedToolOutputContent(item.output), + }; + }); + return changed ? { ...body, input } : body; +} + +/** + * Repair a forward-mode input array whose continuation context was lost. When the replay + * expansion misses (proxy restart, unrecorded prior turn), previous_response_id is stripped + * (the ChatGPT backend rejects it), so the delta may carry items that reference now-absent + * prior items and 400 upstream: + * - `function_call`/`local_shell_call`/`custom_tool_call` without their paired output item + * ("No tool output found for tool call "). A stateless upstream cannot resolve + * the pair from its own storage, so a placeholder output is synthesized to keep the + * turn continuable without pretending the result was real. Synthetic outputs are + * emitted after the complete parallel call batch, in call order alongside any real + * outputs, so the adjacency normalizer can still recognize the batch as one + * reasoning-bearing assistant turn (#1477). Gated on + * `synthesizeMissingCallOutputs` (stateless AND non-forward wires); forward replay keeps + * fail-closed behavior. + * - `function_call_output`/`custom_tool_call_output` without their paired call item + * ("No tool call found for function call output with call_id ..."). Converted to user + * messages so the result text survives. `function_call_output` also pairs with + * `local_shell_call` (codex-rs emits shell outputs as function_call_output). + * - `reasoning` items ("Item 'rs_*' ... was provided without its required following item"). + * Dropped, but only when `dropReasoning` (unexpanded miss): on a replay hit the prior + * reasoning chain is intact and must be preserved. + * Runs on every forward request; with intact pairs it returns the original reference. + */ +/** + * Repair a replayed `web_search_call` action that is missing either key. + * + * `webSearchAction()` in the bridge now emits both keys, but that only helps items + * created after the fix. A conversation that already recorded + * `{type:"search", query:"..."}` or `{type:"search", queries:[...]}` replays that stored + * item on every subsequent turn. DeepSeek's native Responses parser requires `queries` + * (#930) and Console Go's validator requires `query` (#3071), so upgrading alone leaves + * those threads permanently 400ing in one direction or the other. The repair runs both + * ways. + * + * Input items carry a loose schema, so a stored `queries` is not necessarily an array of + * strings. A partly- or wholly-malformed array is left alone rather than used as a source + * for the singular field: writing `query: 123` would satisfy the presence check and still + * fail the validator this repair exists to satisfy, and deriving `query` from + * `["a", 42]` would satisfy Console Go while leaving DeepSeek to reject the same replay. + * An empty `queries: []` canonicalizes to the shape the bridge emits for an empty search, + * keeping an existing `query` when the item has one. + * + * Runs on every Responses request, on both `input` items and the `action` nested inside + * them. Returns the original reference when nothing needs repair, so the common path + * allocates nothing. + */ +export function backfillWebSearchQueries(body: unknown): unknown { + if (!isPlainObject(body) || !Array.isArray(body.input)) return body; + let changed = false; + const input = body.input.map(item => { + if (!isPlainObject(item) || item.type !== "web_search_call") return item; + const action = item.action; + if (!isPlainObject(action) || action.type !== "search") return item; + // Repair whichever side is missing so both strict parsers pass: + // DeepSeek native Responses requires `queries`; Console Go requires `query`. + const rep: Record = { ...action }; + let itemChanged = false; + const hasQuery = typeof action.query === "string"; + const queries = Array.isArray(action.queries) ? action.queries : undefined; + if (queries !== undefined && queries.length === 0) { + // An empty array satisfies neither validator. Canonicalize to the empty-search + // shape the bridge emits, keeping an existing query rather than discarding it. + const query = hasQuery ? action.query as string : ""; + rep.query = query; + rep.queries = [query]; + itemChanged = true; + } else if (!hasQuery && queries !== undefined) { + // A plural array is only a usable source for the singular field when EVERY member + // is a string: deriving `query` from a partly-malformed array would satisfy Console + // Go while leaving DeepSeek to reject the same replay. Wholly malformed arrays are + // left untouched — coercing or dropping members would invent semantics the stored + // item never had. + if (queries.every(entry => typeof entry === "string")) { + rep.query = queries[0]; // multi-query item recorded before the fix + itemChanged = true; + } + } else if (hasQuery && queries === undefined) { + rep.queries = [action.query]; // single-query item recorded before the fix + itemChanged = true; + } + if (itemChanged) changed = true; + return itemChanged ? { ...item, action: rep } : item; + }); + return changed ? { ...body, input } : body; +} + +export function repairOrphanedInputItems(body: unknown, dropReasoning: boolean, synthesizeMissingCallOutputs = false): unknown { + if (!isPlainObject(body) || !Array.isArray(body.input)) return body; + const input = body.input; + + const functionCallIds = new Set(); + const customCallIds = new Set(); + const functionOutputIds = new Set(); + const customOutputIds = new Set(); + for (const item of input) { + if (!isPlainObject(item) || typeof item.call_id !== "string") continue; + if (item.type === "function_call" || item.type === "local_shell_call") functionCallIds.add(item.call_id); + else if (item.type === "custom_tool_call") customCallIds.add(item.call_id); + else if (item.type === "function_call_output") functionOutputIds.add(item.call_id); + else if (item.type === "custom_tool_call_output") customOutputIds.add(item.call_id); + } + + let changed = false; + const repaired: unknown[] = []; + const syntheticKeys = new Set(); + const pendingSyntheticOutputs: unknown[] = []; + const flushPendingSyntheticOutputs = (): void => { + if (pendingSyntheticOutputs.length === 0) return; + repaired.push(...pendingSyntheticOutputs); + pendingSyntheticOutputs.length = 0; + }; + for (const item of input) { + if (!isPlainObject(item)) { flushPendingSyntheticOutputs(); repaired.push(item); continue; } + if (dropReasoning && item.type === "reasoning") { changed = true; continue; } + const isFnOutput = item.type === "function_call_output"; + const isCustomOutput = item.type === "custom_tool_call_output"; + if (isFnOutput || isCustomOutput) { + flushPendingSyntheticOutputs(); + const callId = typeof item.call_id === "string" ? item.call_id : ""; + const paired = isFnOutput ? functionCallIds.has(callId) : customCallIds.has(callId); + const usableOutput = isRepairableToolOutput(item.output); + // A known orphan call is still useful as a labeled user message even when its output is + // incomplete. With no call id and no output, preserve the invalid item so validation fails + // closed rather than pretending any tool result exists. + const knownNullOutput = callId.length > 0 && item.output == null; + if (!paired && (knownNullOutput || usableOutput)) { + changed = true; + repaired.push({ + type: "message", + role: "user", + content: orphanedToolOutputContent(item.output, callId), + }); + continue; + } + } + const isFnCall = item.type === "function_call" || item.type === "local_shell_call"; + const isCustomCall = item.type === "custom_tool_call"; + if (isFnCall || isCustomCall) { + repaired.push(item); + if (synthesizeMissingCallOutputs) { + const callId = typeof item.call_id === "string" ? item.call_id : ""; + const hasOutput = isFnCall ? functionOutputIds.has(callId) : customOutputIds.has(callId); + if (!hasOutput && callId) { + changed = true; + const name = typeof item.name === "string" && item.name.length > 0 ? item.name : callId; + const text = `[ocx] no tool result was recorded for "${name}"; execution status unknown — do not treat this as success, failure, or user-provided input.`; + syntheticKeys.add(`${isFnCall ? "function" : "custom"}:${callId}`); + pendingSyntheticOutputs.push(isFnCall + ? { type: "function_call_output", call_id: callId, output: text } + : { type: "custom_tool_call_output", call_id: callId, output: text }); + } + } + continue; + } + flushPendingSyntheticOutputs(); + repaired.push(item); + } + flushPendingSyntheticOutputs(); + + const callKeyOf = (item: unknown): string | null => { + if (!isPlainObject(item) || typeof item.call_id !== "string") return null; + if (item.type === "function_call" || item.type === "local_shell_call") return `function:${item.call_id}`; + if (item.type === "custom_tool_call") return `custom:${item.call_id}`; + return null; + }; + const outputKeyOf = (item: unknown): string | null => { + if (!isPlainObject(item) || typeof item.call_id !== "string") return null; + if (item.type === "function_call_output") return `function:${item.call_id}`; + if (item.type === "custom_tool_call_output") return `custom:${item.call_id}`; + return null; + }; + const reorderBatchOutputs = (items: unknown[]): unknown[] => { + const ordered: unknown[] = []; + const claimedOutputIndexes = new Set(); + const outputIndexesByKey = new Map(); + for (let outputIndex = 0; outputIndex < items.length; outputIndex += 1) { + const outputKey = outputKeyOf(items[outputIndex]); + if (outputKey === null) continue; + const bucket = outputIndexesByKey.get(outputKey); + if (bucket) bucket.indexes.push(outputIndex); + else outputIndexesByKey.set(outputKey, { indexes: [outputIndex], offset: 0 }); + } + let index = 0; + while (index < items.length) { + if (claimedOutputIndexes.has(index)) { index += 1; continue; } + const key = callKeyOf(items[index]); + if (key === null) { ordered.push(items[index]); index += 1; continue; } + const batch: unknown[] = []; + const batchKeys: string[] = []; + let cursor = index; + while (cursor < items.length) { + const nextKey = callKeyOf(items[cursor]); + if (nextKey === null) break; + batch.push(items[cursor]); + batchKeys.push(nextKey); + cursor += 1; + } + const hasSynthetic = batchKeys.some(batchKey => syntheticKeys.has(batchKey)); + if (!hasSynthetic) { + ordered.push(...batch); + index = cursor; + continue; + } + const batchOutputs: unknown[] = []; + for (const batchKey of batchKeys) { + const bucket = outputIndexesByKey.get(batchKey); + if (!bucket) continue; + while (bucket.offset < bucket.indexes.length && bucket.indexes[bucket.offset]! < cursor) { + bucket.offset += 1; + } + while (bucket.offset < bucket.indexes.length) { + const outputIndex = bucket.indexes[bucket.offset]!; + bucket.offset += 1; + if (claimedOutputIndexes.has(outputIndex)) continue; + claimedOutputIndexes.add(outputIndex); + batchOutputs.push(items[outputIndex]); + break; + } + } + ordered.push(...batch, ...batchOutputs); + index = cursor; + } + return ordered; + }; + + return changed ? { ...body, input: reorderBatchOutputs(repaired) } : body; +} + +/** + * Make unambiguous Responses tool batches contiguous for upstream parsers that require it. + * + * [Decision Log] + * - 목적과 의도: Keep Codex hook-injected developer context without splitting a parallel tool-call turn away from its reasoning or making a strict upstream reject matching results. + * - 기존 구현 및 제약 조건: The orphan repair verifies only pair presence, while the original pair-by-pair reorder turned `reasoning, call A, call B, output A, output B` into two assistant turns and made DeepSeek reject call B for missing reasoning (#1477). + * - 검토한 주요 대안: Disable parallel calls (DeepSeek always enables them); duplicate reasoning per call; reorder each pair; or normalize the complete unambiguous call batch. + * - 선택한 방식: Treat calls emitted before the first matched result as one batch, emit all calls followed by their matched outputs, and preserve intervening non-tool items immediately after the batch. + * - 다른 대안 대신 이 방식을 선택한 이유: Batch normalization matches the Responses parallel-call shape without fabricating reasoning, while the provider gate and unique-pair requirement keep the blast radius narrow. + * - 장점, 단점 및 영향: DeepSeek keeps one reasoning-bearing assistant turn for parallel calls and still accepts hook-interleaved single calls; tolerant providers stay byte/order equivalent, and duplicate, missing, or backwards call/result pairs are not guessed. + */ +export function normalizeResponsesToolResultAdjacency(body: unknown): unknown { + if (!isPlainObject(body) || !Array.isArray(body.input)) return body; + const input = body.input; + const calls = new Map(); + const outputs = new Map(); + + const appendIndex = (map: Map, key: string, index: number): void => { + const existing = map.get(key); + if (existing) existing.push(index); + else map.set(key, [index]); + }; + + for (let index = 0; index < input.length; index += 1) { + const item = input[index]; + if (!isPlainObject(item) || typeof item.call_id !== "string" || item.call_id.length === 0) continue; + if (item.type === "function_call" || item.type === "local_shell_call") { + appendIndex(calls, `function:${item.call_id}`, index); + } else if (item.type === "custom_tool_call") { + appendIndex(calls, `custom:${item.call_id}`, index); + } else if (item.type === "function_call_output") { + appendIndex(outputs, `function:${item.call_id}`, index); + } else if (item.type === "custom_tool_call_output") { + appendIndex(outputs, `custom:${item.call_id}`, index); + } + } + + const pairs: Array<{ callIndex: number; outputIndex: number }> = []; + for (const [key, callIndices] of calls) { + const outputIndices = outputs.get(key); + if (!outputIndices) return body; + if (callIndices.length !== 1 || outputIndices.length !== 1) return body; + const callIndex = callIndices[0]!; + const outputIndex = outputIndices[0]!; + if (outputIndex <= callIndex) return body; + pairs.push({ callIndex, outputIndex }); + } + // Reject any collected output that lacks exactly one matching call. A lone or + // duplicated output is ambiguous, and normalizing on top of it could sever a + // result from the reasoning-bearing call turn it belongs to. + for (const [key, outputIndices] of outputs) { + const callIndices = calls.get(key); + if (!callIndices || callIndices.length !== 1 || outputIndices.length !== 1) return body; + } + pairs.sort((left, right) => left.callIndex - right.callIndex); + + const movedIndices = new Set(); + const batchAt = new Map(); + for (let cursor = 0; cursor < pairs.length;) { + const group = [pairs[cursor]!]; + let firstOutputIndex = pairs[cursor]!.outputIndex; + let next = cursor + 1; + while (next < pairs.length && pairs[next]!.callIndex < firstOutputIndex) { + group.push(pairs[next]!); + firstOutputIndex = Math.min(firstOutputIndex, pairs[next]!.outputIndex); + next += 1; + } + + // Within one reasoning turn the outputs must appear in the same order as their + // calls. If they are reversed, normalizing would fabricate a new output order; + // leave the ambiguous history untouched instead. + for (let groupIndex = 1; groupIndex < group.length; groupIndex += 1) { + if (group[groupIndex]!.outputIndex < group[groupIndex - 1]!.outputIndex) return body; + } + + const batch = [ + ...group.map(pair => input[pair.callIndex]), + ...group.map(pair => input[pair.outputIndex]), + ]; + const anchor = group[0]!.callIndex; + const alreadyContiguous = batch.every((item, offset) => input[anchor + offset] === item); + if (!alreadyContiguous) { + batchAt.set(anchor, batch); + for (const pair of group) { + movedIndices.add(pair.callIndex); + movedIndices.add(pair.outputIndex); + } + } + cursor = next; + } + if (batchAt.size === 0) return body; + + const normalized: unknown[] = []; + for (let index = 0; index < input.length; index += 1) { + const batch = batchAt.get(index); + if (batch) normalized.push(...batch); + if (!movedIndices.has(index)) normalized.push(input[index]); + } + return { ...body, input: normalized }; +} diff --git a/src/adapters/openai-responses/tool-schema.ts b/src/adapters/openai-responses/tool-schema.ts new file mode 100644 index 0000000000..6158685dcc --- /dev/null +++ b/src/adapters/openai-responses/tool-schema.ts @@ -0,0 +1,293 @@ +import { namespacedToolName, type AdapterEvent, type OcxParsedRequest, type OcxProviderConfig, type OcxUsage, type TierDecision } from "../../types"; +import { isHostedToolUnsupportedForModel } from "../../responses/hosted-tool-policy"; +import { debugProviderDiagnostic } from "../../lib/debug"; +import { stripUnicodePropertyPatterns } from "../responses-tool-schema"; +import { + isXaiSchemaTarget, + normalizeXaiToolParameters, + XaiToolSchemaCompatibilityError, +} from "../xai-tool-schema"; +import { isPlainObject } from "./internal"; + +function normalizeFunctionToolSchema(tool: unknown, xaiTarget: boolean): unknown | undefined { + if (!isPlainObject(tool) || tool.type !== "function") return tool; + // Runs for every Responses destination, forward auth included: the ChatGPT backend is where + // the `\p{…}` rejection was observed, and it reaches this function through the same seam. + const compatible = stripUnicodePropertyPatterns(tool); + const source = isPlainObject(compatible) ? compatible : tool; + if (xaiTarget) { + const parameters = normalizeXaiToolParameters(isPlainObject(source.parameters) ? source.parameters : {}); + return parameters === undefined ? undefined : { ...source, parameters }; + } + if (isPlainObject(source.parameters) && source.parameters.type === "object") return source; + return { + ...source, + parameters: { ...(isPlainObject(source.parameters) ? source.parameters : {}), type: "object" }, + }; +} + +/** + * Re-point `tool_choice` after an incompatible function was dropped from the catalog. Names here + * are already wire names, because namespace lowering rewrote the declarations and the selector + * together before this runs. A selector left naming an omitted tool reaches Grok as a dangling + * reference it rejects, and silently relaxing it to `auto` is worse: the turn would quietly + * proceed without the tool the caller required. So an `allowed_tools` list drops the omitted + * entries while any remain, and a selection with nothing left to point at fails locally with the + * same 400 the caller gets for a tool catalog this proxy cannot lower. + */ +function reconcileToolChoiceForOmittedTools( + body: Record, + omittedFunctionNames: ReadonlySet, +): Record { + if (omittedFunctionNames.size === 0) return body; + const toolChoice = body.tool_choice; + if (!isPlainObject(toolChoice)) return body; + + const refuse = (name: string): never => { + throw new XaiToolSchemaCompatibilityError( + `tool_choice requires function "${name}", but its parameter schema cannot be represented for this destination; ` + + "relax tool_choice or simplify the tool's parameter schema", + ); + }; + + if (toolChoice.type === "function" && typeof toolChoice.name === "string") { + return omittedFunctionNames.has(toolChoice.name) ? refuse(toolChoice.name) : body; + } + + if (toolChoice.type === "allowed_tools" && Array.isArray(toolChoice.tools)) { + const omitted = toolChoice.tools.filter(tool => + isPlainObject(tool) + && tool.type === "function" + && typeof tool.name === "string" + && omittedFunctionNames.has(tool.name)); + if (omitted.length === 0) return body; + const kept = toolChoice.tools.filter(tool => !omitted.includes(tool)); + if (kept.length === 0) { + const first = omitted[0]; + return refuse(isPlainObject(first) && typeof first.name === "string" ? first.name : "unknown"); + } + return { ...body, tool_choice: { ...toolChoice, tools: kept } }; + } + + return body; +} + +export function normalizeToolSchemas(body: unknown, xaiTarget: boolean): unknown { + if (!isPlainObject(body)) return body; + + const omittedFunctionNames = new Set(); + const normalizeTools = (tools: unknown[]): unknown[] => { + let changed = false; + const normalized: unknown[] = []; + for (const tool of tools) { + const fixed = normalizeFunctionToolSchema(tool, xaiTarget); + if (fixed === undefined) { + changed = true; + if (isPlainObject(tool) && typeof tool.name === "string") omittedFunctionNames.add(tool.name); + continue; + } + if (fixed !== tool) changed = true; + normalized.push(fixed); + } + return changed ? normalized : tools; + }; + + let normalizedBody = body; + if (Array.isArray(body.tools)) { + const tools = normalizeTools(body.tools); + if (tools !== body.tools) normalizedBody = { ...normalizedBody, tools }; + } + if (Array.isArray(normalizedBody.input)) { + let inputChanged = false; + const input = normalizedBody.input.map((item) => { + if (!isPlainObject(item) || item.type !== "additional_tools" || !Array.isArray(item.tools)) return item; + const tools = normalizeTools(item.tools); + if (tools === item.tools) return item; + inputChanged = true; + return { ...item, tools }; + }); + if (inputChanged) normalizedBody = { ...normalizedBody, input }; + } + if (omittedFunctionNames.size > 0) { + // A dropped tool is a capability the caller declared and will not get, and the only other + // trace of it is a turn that never makes the call. Name them so the cause is recoverable. + debugProviderDiagnostic("openai-responses", "tool-schema-omitted", { + omitted: [...omittedFunctionNames], + }); + } + return reconcileToolChoiceForOmittedTools(normalizedBody, omittedFunctionNames); +} + +export function activateDeferredTool(tool: Record): Record { + const { defer_loading: _, ...activeTool } = tool; + if (tool.type !== "namespace" || !Array.isArray(tool.tools)) return activeTool; + return { + ...activeTool, + tools: tool.tools.map(inner => isPlainObject(inner) ? activateDeferredTool(inner) : inner), + }; +} + +function mergeLoadedTools(declaredTools: unknown[], loadedTools: unknown[]): unknown[] { + const merged = [...declaredTools]; + let changed = false; + + for (const candidate of loadedTools) { + if (!isPlainObject(candidate) || typeof candidate.name !== "string") continue; + const loaded = activateDeferredTool(candidate); + if (loaded.type === "namespace" && Array.isArray(loaded.tools)) { + const namespaceIndex = merged.findIndex(tool => + isPlainObject(tool) && tool.type === "namespace" && tool.name === loaded.name + ); + if (namespaceIndex < 0) { + merged.push(loaded); + changed = true; + continue; + } + + const namespace = merged[namespaceIndex]; + if (!isPlainObject(namespace)) continue; + const namespaceTools = Array.isArray(namespace.tools) ? namespace.tools : []; + const nextNamespaceTools = [...namespaceTools]; + let namespaceChanged = "defer_loading" in namespace; + for (const tool of loaded.tools) { + if (!isPlainObject(tool) || typeof tool.name !== "string") continue; + const declaredIndex = nextNamespaceTools.findIndex(declared => + isPlainObject(declared) && declared.name === tool.name + ); + if (declaredIndex < 0) { + nextNamespaceTools.push(tool); + namespaceChanged = true; + continue; + } + const declared = nextNamespaceTools[declaredIndex]; + if (isPlainObject(declared) && "defer_loading" in declared) { + nextNamespaceTools[declaredIndex] = activateDeferredTool(declared); + namespaceChanged = true; + } + } + if (!namespaceChanged) continue; + const { defer_loading: _, ...activeNamespace } = namespace; + merged[namespaceIndex] = { ...activeNamespace, tools: nextNamespaceTools }; + changed = true; + continue; + } + + const declaredIndex = merged.findIndex(tool => + isPlainObject(tool) && tool.type !== "namespace" && tool.name === loaded.name + ); + if (declaredIndex < 0) { + merged.push(loaded); + changed = true; + } else { + const declared = merged[declaredIndex]; + if (isPlainObject(declared) && "defer_loading" in declared) { + merged[declaredIndex] = activateDeferredTool(declared); + changed = true; + } + } + } + + return changed ? merged : declaredTools; +} + +/** + * Client-executed tool search only changes Codex's parsed tool context. Routed passthrough keeps + * serializing the raw request, so activate those returned definitions for upstreams that do not + * implement the native deferred-loading handshake themselves. + */ +export function promoteClientLoadedTools(body: unknown): unknown { + if (!isPlainObject(body) || !Array.isArray(body.input)) return body; + + const loadedTools = body.input.flatMap(item => + isPlainObject(item) && item.type === "tool_search_output" && Array.isArray(item.tools) + ? item.tools + : [] + ); + if (loadedTools.length === 0) return body; + + if (Array.isArray(body.tools)) { + const tools = mergeLoadedTools(body.tools, loadedTools); + return tools === body.tools ? body : { ...body, tools }; + } + + const additionalToolsIndex = body.input.findIndex(item => + isPlainObject(item) && item.type === "additional_tools" && Array.isArray(item.tools) + ); + if (additionalToolsIndex < 0) return { ...body, tools: mergeLoadedTools([], loadedTools) }; + + const additionalTools = body.input[additionalToolsIndex]; + if (!isPlainObject(additionalTools) || !Array.isArray(additionalTools.tools)) return body; + const tools = mergeLoadedTools(additionalTools.tools, loadedTools); + if (tools === additionalTools.tools) return body; + const input = [...body.input]; + input[additionalToolsIndex] = { ...additionalTools, tools }; + return { ...body, input }; +} + +/** + * Remove hosted tool entries the target native slug rejects, so the OAuth-passthrough body never + * carries a tool the upstream model 400s on. No-op (returns the original reference) when nothing + * matches, keeping the common path allocation-free. + */ +export function stripUnsupportedHostedTools(body: unknown, provider: Pick): unknown { + if (!isPlainObject(body)) return body; + const model = typeof body.model === "string" ? body.model : ""; + const filterTools = (tools: unknown[]): unknown[] => { + const filtered = tools.filter(t => { + const type = isPlainObject(t) && typeof t.type === "string" ? t.type : undefined; + return !type || !isHostedToolUnsupportedForModel(model, type, provider.baseUrl); + }); + return filtered.length === tools.length ? tools : filtered; + }; + + let next: Record = body; + let changed = false; + if (Array.isArray(body.tools)) { + const tools = filterTools(body.tools); + if (tools !== body.tools) { + next = { ...next, tools }; + changed = true; + } + } + if (Array.isArray(body.input)) { + let inputChanged = false; + const input = body.input.map(item => { + if (!isPlainObject(item) || item.type !== "additional_tools" || !Array.isArray(item.tools)) return item; + const tools = filterTools(item.tools); + if (tools === item.tools) return item; + inputChanged = true; + return { ...item, tools }; + }); + if (inputChanged) { + next = { ...next, input }; + changed = true; + } + } + + const toolChoice = next.tool_choice; + if (isPlainObject(toolChoice) && toolChoice.type === "allowed_tools" && Array.isArray(toolChoice.tools)) { + const tools = filterTools(toolChoice.tools); + if (tools !== toolChoice.tools) { + next = { ...next, tool_choice: tools.length > 0 ? { ...toolChoice, tools } : "none" }; + changed = true; + } + } else if ( + isPlainObject(toolChoice) + && typeof toolChoice.type === "string" + && isHostedToolUnsupportedForModel(model, toolChoice.type, provider.baseUrl) + ) { + next = { ...next, tool_choice: "none" }; + changed = true; + } else if (changed && toolChoice === "required") { + const hasDeclaredTools = (Array.isArray(next.tools) && next.tools.length > 0) + || (Array.isArray(next.input) && next.input.some(item => + isPlainObject(item) + && item.type === "additional_tools" + && Array.isArray(item.tools) + && item.tools.length > 0)); + if (!hasDeclaredTools) { + next = { ...next, tool_choice: "none" }; + } + } + return changed ? next : body; +} diff --git a/src/adapters/openai-responses/web-search.ts b/src/adapters/openai-responses/web-search.ts new file mode 100644 index 0000000000..6af07160e7 --- /dev/null +++ b/src/adapters/openai-responses/web-search.ts @@ -0,0 +1,156 @@ +import { isPlainObject } from "./internal"; + +/** + * OpenAI hosted web_search config fields that a capability-classified Responses + * upstream may reject wholesale. xAI's /v1/responses 400s the entire request on + * `external_web_access` and `search_context_size` ("Argument not supported"), + * which killed every routed Grok turn whose client (Codex) attaches its + * default web_search tool config (probe 2026-08-21: both fields 400 + * individually; `user_location` and `filters` are accepted and kept). + * The caller decides whether to apply this compatibility transform from explicit + * provider capability metadata; an unclassified upstream keeps the fields. + */ +const OPENAI_ONLY_WEB_SEARCH_FIELDS = ["external_web_access", "search_context_size"] as const; + +function stripOpenAiOnlyWebSearchFieldsFromTools(tools: unknown[]): { + tools: unknown[]; + changed: boolean; +} { + let changed = false; + const stripped = tools.map(tool => { + if (!isPlainObject(tool) || (tool.type !== "web_search" && tool.type !== "web_search_preview")) { + return tool; + } + if (!OPENAI_ONLY_WEB_SEARCH_FIELDS.some(field => Object.hasOwn(tool, field))) return tool; + const { external_web_access: _access, search_context_size: _size, ...rest } = tool; + changed = true; + return rest; + }); + return { tools: changed ? stripped : tools, changed }; +} + +export function stripOpenAiOnlyWebSearchFields(body: unknown): unknown { + if (!isPlainObject(body)) return body; + + let next: Record = body; + let changed = false; + if (Array.isArray(body.tools)) { + const stripped = stripOpenAiOnlyWebSearchFieldsFromTools(body.tools); + if (stripped.changed) { + next = { ...next, tools: stripped.tools }; + changed = true; + } + } + + if (Array.isArray(body.input)) { + let inputChanged = false; + const input = body.input.map(item => { + if (!isPlainObject(item) || item.type !== "additional_tools" || !Array.isArray(item.tools)) { + return item; + } + const stripped = stripOpenAiOnlyWebSearchFieldsFromTools(item.tools); + if (!stripped.changed) return item; + inputChanged = true; + return { ...item, tools: stripped.tools }; + }); + if (inputChanged) { + next = { ...next, input }; + changed = true; + } + } + + return changed ? next : body; +} + +/** + * Muse Spark ids whose Responses gateway refuses provider-specific fields on a plain + * `web_search` tool. Membership, not equality: 1.3 shipped 2026-09-02 as the + * same-shaped successor to 1.2 on the same Zen wire, and an equality check would + * have let a Codex-emitted `web_search` body reach the + * gateway and come back 400 for every request the moment 1.3 was selected. + */ +const MUSE_SPARK_WEB_SEARCH_STRICT_MODELS = new Set([ + "muse-spark-1.3-contributor", + "muse-spark-1.3-contributor-free", + "muse-spark-1.2-contributor", + "muse-spark-1.2-contributor-free", +]); + +const MUSE_SPARK_WEB_SEARCH_STRICT_RESPONSE_URLS = new Set([ + "https://opencode.ai/zen/v1/responses", + "https://opencode.ai/zen/go/v1/responses", + "https://api.meta.ai/v1/responses", +]); + +const MUSE_SPARK_UNSUPPORTED_WEB_SEARCH_FIELDS = [ + "search_content_types", + "indexed_web_access", +] as const; + +/** + * OpenCode Zen / Go and the direct Meta Muse Spark Responses gateways refuse a + * short list of Codex `web_search` fields. `web_search_preview` keeps its accepted + * shape, and Luna remains untouched. Match the exact effective request URL; + * malformed, credentialed, or parameterized destinations keep their original body + * instead of assuming this gateway contract. Keep the rejected names together so a + * newly identified field is a one-line compatibility update rather than another + * bespoke rewrite. + */ +export function stripMuseSparkUnsupportedWebSearchFields( + body: unknown, + modelId: unknown, + responseUrl: string, +): unknown { + if (!isPlainObject(body)) return body; + if (typeof modelId !== "string") return body; + if (!MUSE_SPARK_WEB_SEARCH_STRICT_MODELS.has(modelId.trim().toLowerCase())) return body; + let destination: string; + try { + const url = new URL(responseUrl); + if (url.username || url.password || url.search || url.hash) return body; + destination = `${url.origin.toLowerCase()}${url.pathname.replace(/\/+$/, "")}`; + } catch { + return body; + } + if (!MUSE_SPARK_WEB_SEARCH_STRICT_RESPONSE_URLS.has(destination)) return body; + + const rewriteTools = (tools: unknown[]): { tools: unknown[]; changed: boolean } => { + let changed = false; + const rewritten = tools.map(tool => { + if (!isPlainObject(tool) || tool.type !== "web_search") return tool; + if (!MUSE_SPARK_UNSUPPORTED_WEB_SEARCH_FIELDS.some(field => Object.hasOwn(tool, field))) { + return tool; + } + const rest = { ...tool }; + for (const field of MUSE_SPARK_UNSUPPORTED_WEB_SEARCH_FIELDS) delete rest[field]; + changed = true; + return rest; + }); + return { tools: changed ? rewritten : tools, changed }; + }; + + let next: Record = body; + let changed = false; + if (Array.isArray(body.tools)) { + const rewritten = rewriteTools(body.tools); + if (rewritten.changed) { + next = { ...next, tools: rewritten.tools }; + changed = true; + } + } + if (Array.isArray(next.input)) { + let inputChanged = false; + const input = next.input.map(item => { + if (!isPlainObject(item) || item.type !== "additional_tools" || !Array.isArray(item.tools)) return item; + const rewritten = rewriteTools(item.tools); + if (!rewritten.changed) return item; + inputChanged = true; + return { ...item, tools: rewritten.tools }; + }); + if (inputChanged) { + next = { ...next, input }; + changed = true; + } + } + return changed ? next : body; +} diff --git a/tests/fixtures/file-size-baseline.json b/tests/fixtures/file-size-baseline.json index e74693dfd4..8001ae0322 100644 --- a/tests/fixtures/file-size-baseline.json +++ b/tests/fixtures/file-size-baseline.json @@ -18,7 +18,7 @@ "gui/src/pages/Models.tsx": 2792, "gui/src/styles.css": 2958, "src/adapters/openai-chat.ts": 822, - "src/adapters/openai-responses.ts": 2627, + "src/adapters/openai-responses.ts": 6, "src/bridge.ts": 2206, "src/codex/auth-api.ts": 43, "src/codex/catalog/provider-fetch.ts": 54, diff --git a/tests/routing/routing-compatibility-model-matching.test.ts b/tests/routing/routing-compatibility-model-matching.test.ts index 2c3ebb4def..3d7e46d0ec 100644 --- a/tests/routing/routing-compatibility-model-matching.test.ts +++ b/tests/routing/routing-compatibility-model-matching.test.ts @@ -143,7 +143,7 @@ describe("a prototype-shaped model id resolves to no override", () => { // Not every override map is family-aware, and the two that are not must stay that way. // The adapter reads `modelPreferHostedTools` through `hasOwnProperty` -// (`src/adapters/openai-responses.ts:1001`) and `resolveOpenRouterRouting` reads +// (`src/adapters/openai-responses/image-gen.ts:80`) and `resolveOpenRouterRouting` reads // `modelOpenRouterRouting` through `Object.hasOwn` (`src/providers/openrouter-routing.ts:89`); // the type calls the first "Exact-model hosted tools" (`src/types.ts:1584`). Sending // these through modelRecordValue would be the divergence above with the sign flipped: