Skip to content

[agent] fix: normalize inbound Chat images before route selection and preserve an explicit reasoning disable - #4534

Merged
lidge-jun merged 4 commits into
devfrom
agent/provider-parity-01-ingress
Sep 14, 2026
Merged

[agent] fix: normalize inbound Chat images before route selection and preserve an explicit reasoning disable#4534
lidge-jun merged 4 commits into
devfrom
agent/provider-parity-01-ingress

Conversation

@lidge-jun

@lidge-jun lidge-jun commented Sep 13, 2026

Copy link
Copy Markdown
Owner

Summary

The native Chat fast path recognized only image_url content parts, while the translated path also understood Pi/MCP {type:"image", data, mimeType} parts (Aside read_file tool results) and Anthropic-shaped {type:"image", source} parts. Two user-visible failures followed from that one gap:

  • A routed model an operator declared text-only kept an image-bearing body, because isNativeChatRouteEligible could not see the image and so never diverted the request to the Responses pipeline that describes or strips it.
  • The native path is a whitelist passthrough, so the foreign part was forwarded verbatim to an OpenAI-compatible upstream that does not accept that shape.

Recognition now lives once in src/chat/image-parts.ts, and normalizeChatImageParts runs in handleChatCompletionsWithBudget immediately after routing-body validation and before routeModel, so the diversion decision and the forwarded wire observe the same parts.

Identity is preserved deliberately: a body with no foreign image part is returned by reference and stays byte-identical, as is one whose images are already image_url. Only the messages array, the messages holding a rewritten part, and their content arrays are rebuilt — the native path is a whitelist passthrough, so an incidental deep clone would itself be a behavior change. A remote source.type:"url" reference is recognized and rewritten, never fetched; this PR adds no outbound request.

Separately, the Chat inbound effort allowlist dropped none. That is the runtime's disable sentinel, not an unknown value: src/reasoning-effort.ts accepts it and maps it to omitting the wire parameter, and src/clients/config-export.ts maps Pi's off thinking level onto it. Dropping it let a provider default re-enable thinking the caller had explicitly turned off — not neutral for the Anthropic families that think by default and require an explicit thinking:{type:"disabled"} to stop (src/adapters/anthropic.ts:960-966).

Addresses audit findings F1 and F7 from the 2026-09-14 provider/PI compatibility audit.

Stack (merge bottom-up)

# PR Layer Review focus
4 pending modality fidelity and explicit refusal F8, F5, F9, Kiro
3 pending Google structured output, Anthropic parallel-tool disable F3, F4
2 pending Chat→Responses control fidelity F2, F6
1 this PR ← you are here inbound normalization + reasoning disable F1, F7

Base is dev. Layer 2 genuinely depends on this one — both change src/server/chat-completions.ts and src/chat/inbound.ts. Review this PR's diff only.

The plan unit is devlog/_plan/260914_provider_parity_stack/; 003_blocker_corrections.md is authoritative over the per-layer docs and records the corrections independent review required.

Verification

Local verification NOT RUN BY USER INSTRUCTION. The repository owner directed that no local product check execute on this machine for this work. No bun test, bun run test, typecheck, build, lint, structure:check, privacy:scan or prepush script was run by the authoring session, and none is claimed as passing, provisional, or assumed. This PR is therefore opened as a draft.

Because red-first execution is impossible under that restriction, the regressions below were written to assert the desired behavior and reviewed statically rather than driven red first.

What real evidence exists:

  • Hosted GitHub Actions at this exact head (c7e863b9e5fff52c224574981d7b6860a34ce0f2) is the gate for this layer. Results are not pre-judged here.
  • Coordinator baseline at df7dc1be53, before this unit's changesbun run typecheck exit 0, bun run structure:check exit 0, bun run privacy:scan exit 0. That is a baseline of unmodified source and is not coverage of anything this PR adds.

Regression coverage added (not executed locally):

  • tests/server/chat-native-image-normalization.test.ts — recognizer accepts both OpenAI spellings, Pi data/mimeType, and both Anthropic source forms; returns null for a part with no usable reference. Normalization rewrites a Pi part, preserves a detail hint, handles image-only and tool-message content, returns the identical object reference when there is no image and when images are already image_url, and leaves other body fields untouched. Text-only diversion is asserted for Pi, Anthropic base64, Anthropic remote-url and tool-carried images; a text-only body still takes the native path and a vision-capable route still keeps an image body on it.
  • tests/server/chat-inbound-reasoning-none.test.ts — flat and nested none spellings survive into reasoning.effort, the produced body still validates against responsesRequestSchema, every other ladder value is unchanged, and an unknown effort is still ignored rather than forwarded.

Both files are registered in scripts/test-layout/layout.json and tests/fixtures/test-layout-expected.json (2-line insertions each, no reordering).

Checklist

  • Scope stays focused and avoids unrelated cleanup.
  • Docs or release notes were updated when needed. structure/data-planes/inbound-compat.md gains the two sections that own this behavior, per structure/INDEX.md ownership for src/chat/ and src/server/.
  • Security-sensitive changes were reviewed for secrets, auth, and unsafe defaults. No credential, auth or network path changes. A remote image reference is never fetched and never logged; no image bytes reach any log or error message.

Current stack synchronization

The manual stack was synchronized bottom-up with dev@246b5cab432b03cbec1766c2faffac13d6e39321.
The repository's existing 2.55.0 version change came from that parent; no artificial feature-branch version bump or release-test suppression was used.

Order: #4534#4535#4536#4539#4562.
Current head: c7e863b9e5fff52c224574981d7b6860a34ce0f2. Current base: dev.
Each parent is an ancestor of its child. All five branches were pushed using git push --no-verify.

Fresh hosted CI is required at these new heads. Earlier green jobs or the historical 2.54.0 release-line failure are not represented as new-head results. The PR remains draft; no merge or release was performed. No product validation ran on the connected Mac.

Summary by CodeRabbit

  • New Features

    • Added support for recognizing and normalizing image inputs from multiple compatible formats.
    • Preserved image ordering, detail hints, and tool-message images without fetching remote content.
    • Added support for explicitly disabling reasoning with reasoning_effort: "none".
  • Documentation

    • Added provider compatibility plans, audit findings, implementation guidance, and documented residual limitations.
  • Tests

    • Added coverage for image normalization, route handling, and reasoning-effort conversion.

… preserve an explicit reasoning disable

The native Chat fast path recognized only `image_url` parts, while the translated
path also understood Pi/MCP `{type:"image", data, mimeType}` and Anthropic-shaped
`{type:"image", source}` parts. Two failures followed from that single gap: a
text-only routed model kept an image-bearing body because `isNativeChatRouteEligible`
could not see the image, and the native whitelist passthrough forwarded the foreign
part verbatim to an OpenAI-compatible upstream that does not accept it.

Recognition now lives once in `src/chat/image-parts.ts`, and
`normalizeChatImageParts` runs before `routeModel` so the diversion decision and the
forwarded wire observe the same parts. A body with no foreign image part is returned
by reference and stays byte-identical. A remote reference is recognized and
rewritten, never fetched.

Separately, the Chat inbound effort allowlist dropped `none`. That is the runtime's
disable sentinel, not an unknown value: `src/reasoning-effort.ts` maps it to omitting
the wire parameter and the Pi export maps Pi's `off` level onto it. Dropping it let a
provider default re-enable thinking the caller had turned off, which is not neutral
for Anthropic families that think by default.

Audit findings F1 and F7 (2026-09-14).

Local verification NOT RUN BY USER INSTRUCTION.
@coderabbitai

coderabbitai Bot commented Sep 13, 2026

Copy link
Copy Markdown
Contributor

Review Change StackReview Change Stack

Caution

Review failed

The pull request is closed.

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Advanced

Run ID: 6449c3d4-4a78-43e3-b11a-5f5a2ade041f

📥 Commits

Reviewing files that changed from the base of the PR and between 246b5ca and c7e863b.

📒 Files selected for processing (19)
  • devlog/_plan/260914_provider_parity_stack/000_plan.md
  • devlog/_plan/260914_provider_parity_stack/001_audit_evidence.md
  • devlog/_plan/260914_provider_parity_stack/002_architect_dispositions.md
  • devlog/_plan/260914_provider_parity_stack/003_blocker_corrections.md
  • devlog/_plan/260914_provider_parity_stack/010_phase1_ingress_normalization.md
  • devlog/_plan/260914_provider_parity_stack/020_phase2_chat_responses_controls.md
  • devlog/_plan/260914_provider_parity_stack/030_phase3_provider_wire_contracts.md
  • devlog/_plan/260914_provider_parity_stack/040_phase4_modality_fidelity.md
  • devlog/_plan/260914_provider_parity_stack/050_residuals.md
  • scripts/test-layout/layout.json
  • src/chat/image-parts.ts
  • src/chat/inbound.ts
  • src/clients/config-export.ts
  • src/server/chat-completions.ts
  • src/server/chat-native.ts
  • structure/data-planes/inbound-compat.md
  • tests/fixtures/test-layout-expected.json
  • tests/responses/chat-inbound-reasoning-none.test.ts
  • tests/responses/chat-native-image-normalization.test.ts

📝 Walkthrough

Walkthrough

The pull request adds provider-parity planning documents and implements shared Chat image recognition and normalization. It also accepts "none" as an explicit reasoning-disable value, updates related documentation, and adds focused tests and layout mappings.

Changes

Provider parity planning

Layer / File(s) Summary
Audit, architecture, and execution scope
devlog/_plan/260914_provider_parity_stack/000_plan.md, 001_audit_evidence.md, 002_architect_dispositions.md, 003_blocker_corrections.md
The documents record audit findings, provider contracts, architectural decisions, blocker corrections, execution constraints, and excluded behaviors.
Phase plans and residuals
devlog/_plan/260914_provider_parity_stack/020_phase2_chat_responses_controls.md, 030_phase3_provider_wire_contracts.md, 040_phase4_modality_fidelity.md, 050_residuals.md
The documents define later work for Responses controls, provider wire contracts, modality fidelity, validation, and residual gaps.

Chat ingress normalization

Layer / File(s) Summary
Shared image recognition
src/chat/image-parts.ts, src/chat/inbound.ts, src/server/chat-native.ts
The shared utilities recognize OpenAI, Pi/MCP, and Anthropic image shapes. They extract references and details, normalize foreign parts to image_url, and provide the native-route image predicate.
Normalization before routing
src/server/chat-completions.ts, structure/data-planes/inbound-compat.md
Chat bodies are normalized before route selection and native forwarding. Bodies without rewrites retain their original reference.
Validation and layout support
tests/responses/chat-native-image-normalization.test.ts, scripts/test-layout/layout.json, tests/fixtures/test-layout-expected.json
Tests cover image recognition, normalization, ordering, identity preservation, route diversion, tool messages, and remote references without fetching.

Reasoning disablement

Layer / File(s) Summary
Accept "none" at ingress
src/chat/inbound.ts, tests/responses/chat-inbound-reasoning-none.test.ts
The inbound allowlist accepts "none". Tests verify flat and nested reasoning spellings and preserve the other supported values.
Documentation and export clarification
structure/data-planes/inbound-compat.md, src/clients/config-export.ts
Documentation describes "none" as the disable sentinel. The exporter comment records why "none" remains filtered from exported variants.

Estimated code review effort: 3 (Moderate) | ~25 minutes

Change: Bug fix

✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch agent/provider-parity-01-ingress

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@github-actions

Copy link
Copy Markdown
Contributor

Deterministic PR hygiene checks passed.

…omain

Hosted CI caught this: tests/test-layout-tooling.test.ts reported
"chat-inbound-reasoning-none.test.ts: seed responses != server" for both new files.
scripts/test-layout/layout.json seeds a `chat-` prefix to the `responses` domain,
which is where the sibling Chat-translation tests already live, so registering them
under `server` contradicted the seed.

Moves both files to tests/responses/ and registers them there. Import depth is
unchanged, so no import edits were needed.

Local verification NOT RUN BY USER INSTRUCTION.
@lidge-jun

Copy link
Copy Markdown
Owner Author

리뷰 · 우선순위 74 / 80

이 PR은 2026-09-14 프로바이더/PI 호환 감사의 1층(F1·F7) 입니다. 지금 dev HEAD는 df7dc1be5(#4520 desktop-restart 검증 docs, package.json 2.54.0)이고, Chat 네이티브 빠른 경로는 아직 image_url만 이미지로 봅니다. 번역(Responses) 경로는 이미 Pi/MCP {type:"image", data, mimeType}와 Anthropic {type:"image", source}까지 읽습니다. 그 차이 때문에 두 가지가 동시에 깨집니다.

첫째, 운영자가 텍스트만 받는 모델로 라우팅해도, 본문에 Pi/Anthropic 모양 이미지가 있으면 isNativeChatRouteEligible가 이미지를 못 보고 네이티브 경로로 보냅니다. 둘째, 네이티브 경로는 화이트리스트 패스스루라서 그 외국 파트를 OpenAI 호환 업스트림에 그대로 실어 보냅니다. 업스트림은 그 모양을 모릅니다. 이 PR은 인식을 src/chat/image-parts.ts 한곳으로 모으고, normalizeChatImagePartsrouteModel 에서 돌립니다. 그래서 “어느 파이프라인으로 갈지”와 “와이어에 실릴 파트”가 같은 본문을 봅니다. 외국 이미지가 없으면 같은 객체 참조를 돌려 바이트 동일성을 지키고, 원격 source.type:"url"은 인식·재작성만 하고 절대 fetch하지 않습니다.

같은 PR의 F7은 Chat inbound effort 허용 목록에 none이 빠진 구멍입니다. src/reasoning-effort.tsnone을 “와이어에서 reasoning을 빼라”는 센티널로 이미 알고 있고, Pi export는 offnone으로 넣습니다. 그런데 OUTPUT_CONFIG_EFFORTSnone을 버리면, Anthropic처럼 기본으로 생각하는 계열은 thinking:{type:"disabled"}를 못 받고 다시 생각하기 시작합니다. “끈다”가 “안 보냄”이 되어 버리는 셈입니다.

스택으로 보면 이 PR이 바닥입니다. base는 dev, 브랜치 agent/provider-parity-01-ingress, 위층 #4535가 같은 chat-completions.ts/inbound.ts를 건드립니다. devlog/_plan/260914_provider_parity_stack/ 계획서가 함께 들어와 줄 수가 많아 보이지만, 감사 근거·차단 수정·4층 순서를 고정하는 문서라 범위 밖 잡청소는 아닙니다. types.ts/config.ts 분리 캠페인과 충돌하지 않고, #4501/#4511(네이티브 describer의 modelCapabilities)이나 #4513(Devin 이미지 와이어)과도 겹치지 않습니다. 로컬 suite는 의도적으로 안 돌렸고 draft입니다. tip CI가 게이트입니다. 리뷰 시점에 test 2/4가 한 번 빨간 기록이 있어, 초록 확인 전까지 merge 점수는 76이 아니라 74입니다.

라인 - src/chat/image-parts.ts normalizeChatImageParts - 이미 image_url인 파트는 손대지 않고, 외국 모양만 새 image_url로 바꿉니다. 메시지가 바뀐 배열만 새로 만들고 나머지 필드는 그대로입니다. 네이티브 패스스루 계약과 맞습니다.
라인 - src/server/chat-completions.ts handleChatCompletionsWithBudget - assertChatCompletionsRoutingBody 직후·routeModel 전에 정규화를 넣었습니다. 순서가 핵심이라, 이 줄을 뒤로 밀면 F1이 다시 생깁니다.
라인 - src/server/chat-native.ts - 로컬 chatBodyCarriesImage를 지우고 image-parts를 씁니다. 인식 폭이 번역 경로와 같아집니다.
라인 - src/chat/inbound.ts OUTPUT_CONFIG_EFFORTS - none을 넣습니다. Anthropic 기본-on 모델에서 “끔”이 다시 살아나지 않게 하는 한 줄입니다.
경로/심볼 - tests/server/chat-native-image-normalization.test.ts / chat-inbound-reasoning-none.test.ts - Pi·Anthropic base64·원격 URL·tool 메시지·참조 동일성·텍스트온리 diversion을 잠급니다. 로컬 미실행이므로 tip CI가 진짜 증거입니다.
경로/심볼 - devlog/_plan/260914_provider_parity_stack/003_blocker_corrections.md - F2를 ingress strip이 아니라 final-target sanitization으로 고친 기록이 있습니다. 위층 #4535를 읽을 때 이 문서가 우선입니다.
경로/심볼 - tip CI test 2/4 - 리뷰 시점에 fail 기록이 있습니다. flake인지 제품 회귀인지 머지 전에 확인하세요.

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

너의 추천
내용 방향은 맞고 F1/F7 둘 다 현재 dev에 남아 있습니다. tip CI가 초록이면 draft를 풀고 dev에 merge하세요. 그 전에는 #4535를 단독으로 dev에 붙이지 마세요(base가 이 브랜치입니다). test 2/4 실패가 재현되면 그 shard 로그를 먼저 보고, 계획서 분량은 분리하지 않아도 됩니다.

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

… variant

Independent review found this comment now states a false fact about this stack's own
change. It said the chat ingress allowlist OUTPUT_CONFIG_EFFORTS "has no none", which
stopped being true in 63fbe66 when F7 added the disable sentinel to that allowlist.

The filter itself is kept, narrowly and on a stated basis: emitting the variant would
change what this exporter writes into a user's opencode config, and whether opencode's
picker round-trips reasoningEffort "none" back to a wire this proxy reads has not been
verified. Re-enabling it is a scoped follow-up needing that check, not a side effect of
an ingress fix. MCode and ZCode filter none for their own separate reasons, which
remain accurate at their call sites.

Comment-only; no behavior change.

Local verification NOT RUN BY USER INSTRUCTION.
@lidge-jun

Copy link
Copy Markdown
Owner Author

Maintainer integration per MAINTAINERS.md: landing this maintainer-authored PR on dev without a second maintainer approval.\n\nExact-head verification on c7e863b: 28 checks pass, 2 skipped (macos control, windows matrix placeholder), 0 failed/cancelled. Run set: 34790779603 plus metadata workflows (enforce-target, label, resolve-pr, hygiene) — all green on this head.\n\nLocal suite/typecheck/build: NOT RUN (hosted exact-head CI is the evidence).

@lidge-jun
lidge-jun marked this pull request as ready for review September 14, 2026 00:11
@lidge-jun
lidge-jun requested a review from Ingwannu as a code owner September 14, 2026 00:11
@chatgpt-codex-connector

chatgpt-codex-connector Bot commented Sep 14, 2026

Copy link
Copy Markdown

Codex Review Summary

This comment shows the latest Codex review activity on this pull request.

Review Status Commit Review trigger
📝 Code Review Completed 2026-09-14T00:17:14.949504Z c7e863b Draft marked ready
ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review" or "@codex security review".

Codex reacts with 👀 while any review is running, comments if it has suggestions, and reacts with 👍 once all reviews finish with no findings.

@lidge-jun
lidge-jun merged commit 94822f3 into dev Sep 14, 2026
36 of 37 checks passed
@lidge-jun
lidge-jun deleted the agent/provider-parity-01-ingress branch September 14, 2026 00:11
lidge-jun added a commit that referenced this pull request Sep 14, 2026
… and carry Chat reasoning and penalties

Restacked onto the squashed #4534 landing; tree identical to pre-restack head e35995c.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: c7e863b9e5

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread src/chat/image-parts.ts
for (const message of messages) {
if (!isRec(message) || !Array.isArray(message.content)) continue;
for (const part of message.content) {
if (isRec(part) && chatImageUrlFromPart(part) !== null) return true;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Keep malformed image parts off the native text-only path

When a text-only route receives a usable text part alongside {type:"image_url", image_url:""}, this predicate now returns false because the URL is empty. The previous predicate matched the image_url type itself and diverted the request to translation, where the unusable image was discarded and the text could proceed; normalization now leaves the malformed part unchanged and native passthrough forwards it, which can turn that previously accepted text turn into an upstream 400 on providers that validate content parts. Preserve type-based diversion for malformed image parts, or reject/strip them before native eligibility is evaluated.

Useful? React with 👍 / 👎.

@@ -247,3 +247,33 @@ Translated Chat request construction uses the [inline-image budget](../transport
The [explicit model-capability contract](../config.md#explicit-per-model-capability-declarations) preserves operator declarations through provider storage and catalog capture; it does not infer upstream capability or change this surface's routing behavior.

Provider-scoped approval reviewer settings are projected by the [catalog owner](../catalog.md#provider-scoped-approval-reviewer); this surface retains its existing routing, transport and account-selection behavior.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Update every structure owner for the changed source areas

This is the only structure document updated even though the commit changes contracts in both src/chat/ and src/server/; structure/INDEX.md maps src/chat/ to runtime.md and transports/inventory.md as well, and maps src/server/ to several additional documents. Update every listed owner with the new contract, or narrow the manifest ownership if those documents are not actually owners.

AGENTS.md reference: structure/AGENTS.md:L49-L50

Useful? React with 👍 / 👎.

Comment thread src/chat/inbound.ts
// Pi's "off" thinking level onto it (src/clients/config-export.ts). Dropping it here let a
// provider default re-enable thinking the caller had explicitly turned off — and for the
// Anthropic families that think by default, omission is not the same as disabled.
const OUTPUT_CONFIG_EFFORTS = new Set(["none", "minimal", "low", "medium", "high", "xhigh", "max", "ultra"]);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Document the newly supported reasoning disable

Accepting reasoning_effort: "none" changes the public Chat Completions contract, including causing supported default-on Anthropic models to receive an explicit thinking disable, but the public Chat section in docs-site/src/content/docs/reference/proxy-formats.md still only says that reasoning effort is translated and does not identify none or its disable semantics. Add this behavior to the English reference and keep the translated locales consistent.

AGENTS.md reference: AGENTS.md:L380-L381

Useful? React with 👍 / 👎.

lidge-jun added a commit that referenced this pull request Sep 14, 2026
… and carry Chat reasoning and penalties (#4535)

Restacked onto the squashed #4534 landing; tree identical to pre-restack head e35995c.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant