Skip to content

[WRONG BRANCH] fix(catalog): normalize the custom-provider model-discovery join (#4724) - #4773

Merged
lidge-jun merged 8 commits into
codex/pw3-kimi-tool-adjacencyfrom
codex/pw4-custom-baseurl-join
Sep 16, 2026
Merged

lidge-jun merged 8 commits into
codex/pw3-kimi-tool-adjacencyfrom
codex/pw4-custom-baseurl-join

Conversation

@lidge-jun

Copy link
Copy Markdown
Owner

Summary

A custom (non-registry) provider whose baseUrl ends in / produced a doubled model-discovery path — https://gateway.example.com/v1//models. Gateways that treat the doubled path as a distinct route reject it (the report observed HTTP 403), so discovery failed and the catalog silently fell back to the configured models, with the provider's discovery status reading {"status":"failed","reason":"http","httpStatus":403}.

The send paths were normalized already — openaiChatCompletionsUrl (#1582) and openaiResponsesUrl — but discovery was not:

  • buildModelsRequest() in src/oauth/index.ts appended the endpoint verbatim as \${baseUrl}/models.
  • resolveProviderModelDiscoveryUrl() in src/providers/model-discovery.ts returns that default unchanged for a provider with no registry spec — precisely the custom case.

Registry providers escaped the bug because new URL(spec.path, base) collapses the doubled slash.

providerModelsUrl() mirrors the existing openaiChatCompletionsUrl precedent rather than inventing a second normalization policy — same trim, same strip-if-already-pasted, same single append:

const trimmed = baseUrl.trim().replace(TRAILING_SLASHES, "");
const withoutEndpoint = trimmed.replace(TRAILING_MODELS, "");
return \`\${withoutEndpoint}/models\`;

It lives beside the other discovery logic rather than being shared across the module boundary, because the send-path helper belongs to the chat adapter while this belongs to provider discovery. Both production callers of the default discovery URL now use it: catalog discovery (buildModelsRequest) and API-key validation (validateApiKey).

Scope of behaviour change, enumerated rather than assumed:

  • Changes: a default OpenAI-compatible discovery URL whose baseUrl carries outer whitespace, one or more trailing slashes, or an already-pasted /models.
  • Byte-identical to before: a baseUrl already written as https://gw.example.com/v1; any registry spec.path; absolute endpoint overrides; relative endpoint overrides; and the Google CCA, AI Studio and Anthropic branches.
  • Preserved: existing path prefixes. https://gw.example.com/api/openai/v1 keeps /api/openai and is not collapsed to the origin.

Workaround for anyone on a released build: configure baseUrl without the trailing slash.

Closes #4724

Verification

Static source review only, plus hosted CI. No local suite, typecheck, or build was run — the repository owner prohibits local suite execution in this lane after a past local run deleted real ~/.opencodex data.

Static checks performed:

  • Read every branch of resolveProviderModelDiscoveryUrl and the discoveryUrl closure in buildModelsRequest before editing, then enumerated the changed vs. byte-identical input classes listed above.
  • Confirmed by search that these are the only two production producers of the default /models URL, so no third caller keeps the old join.
  • Compared the new helper against src/adapters/openai-chat-url.ts to confirm the normalization rule is the same one the send path already establishes, rather than a new policy invented for discovery.
  • Confirmed no existing test was deleted or weakened in this layer.
  • git diff --check clean.

Regression coverage added to tests/providers/provider-model-discovery-contract.test.ts (the existing owner of this contract, so no new file):

  • normalizes custom model discovery URLs without collapsing path prefixes (#4724) — no slash, one slash, multiple slashes, nested prefix, longer prefix, pasted /models.
  • keeps registry path discovery independent of default URL normalization (#4724)
  • keeps absolute and relative discovery endpoint overrides unchanged (#4724)
  • normalizes a custom API-key validation discovery URL (#4724) — covers the second production caller.

structure/runtime.md, which owns src/providers/, is updated so the documented custom-provider discovery rule states the normalization instead of implying a verbatim join.

Hosted CI: this is a non-tip layer of a stacked lane and carries [skip ci] under the maintainer-approved DEV-STACK-08 tip-only policy. The lane's CI gate runs on the tip branch, which contains this commit.

Checklist

  • Scope stays focused and avoids unrelated cleanup.
  • Docs or release notes were updated when needed. (structure/runtime.md; the user-facing fix is that a previously broken configuration now works, so no configuration documentation changes)
  • Security-sensitive changes were reviewed for secrets, auth, and unsafe defaults. (validateApiKey is touched only to reuse the same URL join; no change to auth headers, token handling, or destination trust — the helper cannot move a request to a different origin because it only trims and appends a path)

@lidge-jun
lidge-jun requested a review from Ingwannu as a code owner September 16, 2026 02:33
@coderabbitai

coderabbitai Bot commented Sep 16, 2026 •

Copy link
Copy Markdown
Contributor

Important

Review skipped

Auto reviews are disabled on base/target branches other than the default branch.

🗂️ Base branches to auto review (2)
  • ^dev$
  • ^preview$

Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Advanced

Run ID: fb1a44fe-6ab2-4387-9189-5a37098d29ab

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review

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.

@chatgpt-codex-connector

chatgpt-codex-connector Bot commented Sep 16, 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-16T02:36:46.059549Z 9984bca PR opened
ℹ️ 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

Copy link
Copy Markdown
Owner Author

리뷰 · 우선순위 77 / 80

이 PR(#4773)은 이슈 #4724를 닫는 패치다. 커스텀(비레지스트리) 프로바이더의 baseUrl 끝에 슬래시가 있으면, 모델 디스커버리만 https://gateway.example.com/v1//models처럼 경로가 두 번 이어져서, 그 경로를 별도 라우트로 보는 게이트웨이가 HTTP 403을 주고 카탈로그가 설정에 적힌 정적 모델로 조용히 내려앉는 버그다. 지금 비교 기준인 dev HEAD(3070d64d8822c6d8c62989665f82ab665e4d164c, package 2.57.0)에도 같은 모양이 그대로 있다. 보내기(send) 쪽은 이미 openaiChatCompletionsUrl(src/adapters/openai-chat-url.ts)와 openaiResponsesUrl이 끝 슬래시·이미 붙인 엔드포인트를 정규화한다. 그런데 디스커버리 기본 경로는 buildModelsRequest 일반 분기가 ${effectiveProvider.baseUrl}/models를 그대로 만들고, validateApiKey도 같은 문자열을 넘기며, resolveProviderModelDiscoveryUrl은 레지스트리 spec이 없으면 defaultUrl을 손대지 않고 돌려준다. 레지스트리 spec.path가 있는 프로바이더는 new URL(spec.path, base)가 이중 슬래시를 합쳐 주지만, 대시보드에 직접 넣은 커스텀 게이트웨이만 깨진다.

이 PR이 하는 일은 좁고 분명하다. src/providers/model-discovery.ts에 providerModelsUrl을 새로 두고, 바깥 공백·끝 슬래시를 지운 뒤 이미 붙여 넣은 /models를 한 번 벗기고 다시 정확히 하나 붙인다. 규칙은 openaiChatCompletionsUrl과 같은 정책이고, 모듈 경계를 이유로 어댑터 헬퍼를 import하지 않고 discovery 옆에 둔다. 생산 호출자 두 곳만 바꾼다. 카탈로그 디스커버리(src/oauth/index.ts buildModelsRequest)와 API 키 검증(src/oauth/key-providers.ts validateApiKey). 레지스트리 path·절대/상대 endpoint override·이미 슬래시 없는 baseUrl은 바이트 동일을 유지하고, /api/openai/v1 같은 기존 path prefix는 origin으로 접지 않는다. 회귀는 기존 계약 파일 tests/providers/provider-model-discovery-contract.test.ts에 네 케이스로 잠갔고, structure/runtime.md도 정규화 규칙을 적도록 고쳤다. types.ts/config.ts 분할 캠페인에 무효화될 경로가 아니다.

스택 위치도 중요하다. base는 dev가 아니라 codex/pw3-kimi-tool-adjacency(#4770)이고, 위쪽 레인은 #4752(pw1 비전 사이드카) → #4769(pw2 콤보 reasoning 재생 거부) → #4770(pw3 Kimi tool-result adjacency) → 이 PR(pw4 tip, codex/pw4-custom-baseurl-join @ 9984bca2a, 커밋에 [skip ci])다. mergeable은 MERGEABLE, mergeStateStatus는 UNSTABLE이며 체크는 resolve-pr/label/hygiene가 큐에 있다. 본문은 DEV-STACK-08 tip-only CI 정책 아래에서 non-tip이라고 적었지만, 지금 열린 pw 스택에서는 이 PR이 tip이다. tip인데 [skip ci]면 레인 게이트가 어디 브랜치에서 도는지 한 번 확인해야 한다. 이슈 #4724에는 이미 grok-bot 이슈 리뷰(74/80)가 있고, 이 PR이 그 추천(공유 정규화 + 계약 테스트)을 그대로 실행한 형태다. CodeRabbit은 base가 default가 아니라서 자동 리뷰를 건너뛰었다.

라인 providerModelsUrl (src/providers/model-discovery.ts 신규) - send 헬퍼와 같은 trim/strip/append 규칙을 discovery 모듈에 두어 커스텀 기본 URL만 고친다. 범위가 맞다
라인 buildModelsRequest 일반 분기 (src/oauth/index.ts) - 예전 ${baseUrl}/models 직접 이어붙이기를 providerModelsUrl(...)로 바꿔 #4724 재현 경로를 막는다
라인 validateApiKey (src/oauth/key-providers.ts) - 두 번째 생산 호출자도 같은 헬퍼를 쓰게 해서, 카탈로그는 되고 키 검증만 이중 슬래시로 실패하는 비대칭을 막는다
테스트 normalizes custom model discovery URLs... - 슬래시 0/1/다수, nested prefix, 이미 붙인 /models까지 잠가서 회귀 가치가 높다
테스트 registry/override/validateApiKey 세 케이스 - 레지스트리 path·절대/상대 override·키 검증 호출자가 바이트 동일/정규화됨을 각각 증명한다
ai-studio 분기 (buildModelsRequest의 ${baseUrl}/v1beta/models?..., 현재 dev) - 이번 PR 범위 밖으로 명시적으로 남겨 두었다. 끝 슬래시가 있으면 같은 이중 슬래시 위험이 남는지는 별도 이슈로 열지 말지 판단이 필요하다
커밋 [skip ci] + tip 위치 - 본문은 non-tip이라고 하나 현재 pw 스택 tip이 이 브랜치다. tip CI가 다른 브랜치에 실려 있는지, 아니면 tip에서 CI를 켜야 하는지 확인이 필요하다

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

너의 추천
KEEP. #4724를 정확히 고치는 좁은 패치고, 생산 호출자 두 곳·계약 테스트·런타임 문서가 揃어 머지 가치가 높다. 다만 base가 #4770이라 부모 스택이 먼저 안정적으로 랜딩한 뒤 tip CI(또는 정책상 tip에 실리는 CI)가 그린인지 확인한 다음 머지하고, 머지와 함께 #4724를 close하면 된다. types/config 분할로 무효화될 PR이 아니며 close-don't-rebase 해당 없음. 중복 수정 PR은 현재 안 보인다. 임시 우회는 여전히 baseUrl 끝 /를 빼는 것.

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

@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: 9984bca2a8

ℹ️ 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 on lines +56 to +59
export function providerModelsUrl(baseUrl: string): string {
const trimmed = baseUrl.trim().replace(TRAILING_SLASHES, "");
const withoutEndpoint = trimmed.replace(TRAILING_MODELS, "");
return `${withoutEndpoint}/models`;

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 mapped structure document

This changes both src/providers/ and src/oauth/, but the commit updates only structure/runtime.md. The source map also assigns these areas to structure/transports/inventory.md, structure/providers/xai-grok.md, and, for providers, structure/subagents.md; update each mapped document to reconcile or explicitly preserve its contract rather than leaving the required architecture review incomplete.

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

Useful? React with 👍 / 👎.

@github-actions github-actions Bot added the bug Something isn't working label Sep 16, 2026
@github-actions

Copy link
Copy Markdown
Contributor

✅ Deterministic PR hygiene checks passed.

@lidge-jun
lidge-jun force-pushed the codex/pw3-kimi-tool-adjacency branch from 68b5c73 to 70129ac Compare September 16, 2026 03:11
@lidge-jun
lidge-jun force-pushed the codex/pw4-custom-baseurl-join branch from 9984bca to a9eb794 Compare September 16, 2026 03:11
@lidge-jun
lidge-jun force-pushed the codex/pw4-custom-baseurl-join branch 2 times, most recently from d75657e to 3d74681 Compare September 16, 2026 04:46
lidge-jun and others added 3 commits September 16, 2026 14:01
…) [skip ci]

A custom provider whose baseUrl ends in a slash produced a doubled discovery
path: https://gateway.example.com/v1//models. Gateways that route the doubled
path as a distinct route reject it — the report observed HTTP 403 — so discovery
failed and the catalog silently fell back to the configured models.

The send paths were normalized already, by openaiChatCompletionsUrl and
openaiResponsesUrl, but discovery was not. buildModelsRequest appended the
endpoint verbatim, and resolveProviderModelDiscoveryUrl returns that default
unchanged for a provider with no registry spec, which is exactly the custom
case. Registry providers escaped it because new URL(spec.path, base) collapses
the doubled slash.

providerModelsUrl mirrors openaiChatCompletionsUrl rather than inventing a
second policy: trim outer whitespace and trailing slashes, drop an already
pasted /models, then append exactly one. Both production callers of the default
discovery URL use it — catalog discovery and API-key validation.

An existing path prefix is preserved, so /api/openai/v1 is not collapsed to the
origin. Registry spec.path, absolute endpoint overrides and relative endpoint
overrides all resolve exactly as before; a baseUrl already written without a
trailing slash is byte-identical to its previous output.
The CodeBuddy route launches the vendor CLI with --tools "" and
--strict-mcp-config, so the routed model has no native tool channel and writes
its call as prose. The shared coding-agent projection forwards text_delta
unrepaired, so that markup reached the client as an ordinary assistant answer.

Qoder's guard does not match it. The leaked tags are wrapped in FULLWIDTH
VERTICAL LINE (U+FF5C), which none of the shipped UNREPAIRABLE_MARKERS cover, so
this needed a signature of its own rather than a port.

Refusal requires the observed two-line grammar: a calls control line at column
zero, outside a Markdown fence, immediately followed by an invoke line naming a
functions.* tool. A lone tag, a quoted or inline-code literal, a fenced example,
a blockquote, indented source, or prose discussing the markup all carry extra
syntax before the tag and are forwarded untouched. Matching the marker alone
would refuse a legitimate answer that merely explains this protocol, which is
why the detector is narrower than the marker spelling.

A detected leak preserves the answer text already proven safe, emits one
non-retryable vendor_scaffold_detected error, and suppresses the vendor's later
success terminal so the client never sees a completed turn. Markers split across
streamed deltas are caught by holding only a bounded suffix that could still
complete a control sequence or a fence; unrelated pending text is released at
the next mismatch or terminal. The reasoning channel is guarded independently.

Leaked prose is never promoted into a real tool call. The text channel carries
no authenticated call envelope and no validated arguments, so converting it
would manufacture execution authority out of model output.

Kept CodeBuddy-owned rather than lifted into the shared coding-agent path, the
same containment #4234 chose for Qoder: the contract observed here is this
vendor's, and #4190's lane packet asked for a report rather than symmetry.

Co-authored-by: Ingwannu <ingwannu@users.noreply.github.com>
…#4679) [skip ci]

Command Code's gateway rejects a request outright with 400 name must be at most
64 characters, got 66. Codex Desktop built-in app tools flatten to
<namespace>__<name> past that bound, a user cannot exclude them, and
Responses-Lite catalogs bundle every declared tool, so the surface cannot be
shrunk from configuration.

The bound belongs to the adapter, not to the shared name helper. Three adapters
already solve this for themselves: Kiro normalizes to its own charset with a
deterministic 8-hex suffix, Google compiles and restores names in its wire
compiler, and Meta Muse aliases names on api.meta.ai. The translated
openai-chat path is the only one with no answer, and it is the path Command Code
uses.

A request-scoped registry now owns one collision domain per translated Chat
Completions request, following Kiro's shape. A namespaced name whose flattened
spelling exceeds 64 characters becomes a charset-safe alias derived purely from
the native identity, so it is stable across processes, catalog order and catalog
membership. Declarations, replayed assistant tool calls and tool_choice all pass
through the same registry, and both the streaming and buffered parsers restore
the echoed alias before tool_call_start, so the existing bridge map still hands
the client its native {namespace, name}.

The registry is seeded from the union of the current catalog and the structured
tool calls still present in replay history, because a historical call can keep
its namespace without being redeclared; seeding from the catalog alone would let
exactly the reported over-limit name reach the gateway again on a later turn.

Nothing else changes. Names at or under 64 characters and bare names are
byte-identical on the wire, and Kiro, Google and Muse still receive the raw
flattened name and run their own normalization.

64 is the Chat Completions function-name limit and a strict-gateway
compatibility concern, not OpenAI Responses parity: upstream Codex raised its own
MCP ceiling to 128 bytes in openai/codex#39594 because native Responses accepts
128. Applying it on this wire is correct for that wire alone.

Carried from #4715. That PR placed the bound in the shared namespacedToolName
helper and was provisionally accepted there. Hosted CI then showed twice that the
shared point intercepts adapters which already had an answer: it broke Google's
wire-compiler restore, and after that was narrowed it broke Kiro's normalizer.
The problem statement and issue analysis are the original author's; only the
placement changed.

Co-authored-by: Hulian Buligon <205309211+HulianBuligon@users.noreply.github.com>
@lidge-jun
lidge-jun force-pushed the codex/pw4-custom-baseurl-join branch from 3d74681 to 217fe01 Compare September 16, 2026 05:02
@lidge-jun

Copy link
Copy Markdown
Owner Author

Cascading downward. A custom provider base URL with a trailing slash no longer produces a doubled path segment. The first attempt guarded a baseUrl that arrived undefined; that guard was removed once it became clear the configuration schema makes the state unreachable and the real cause was a test fixture bypassing the schema.

Evidence at the verified tip 49f815d (tree cea66da56f10b8d1aee2290fa185d321442eab98), from run 35061163092:

  • test 1-4/4 and macos 1-2/2 all completed with conclusion success, confirmed through the check-runs API rather than the check rollup, so the heavy jobs actually executed and were not path-filtered. gates, changes, storage policy, api usage, docker smoke, keyring and npm-global on three platforms, the three service-lifecycle jobs, and the aggregate ci check all succeeded.
  • The ci failure at this commit belongs to run 35061161660, which this push superseded; run 35061163092 is the live one and it concluded success.
  • The lane absorbed dev at cf6e939 from the bottom layer upward, so each pull request keeps its own layer diff (4 / 9 / 6 / 7 / 7 / 5 files) and no dev commit appears in any layer's diff.
  • The single conflict was structure/transports/responses.md, where both sides appended a new section to the same empty base. It was resolved by keeping both: the section count goes from 14 on dev to 15 here, and every dev section name is still present. That loss is the kind CI cannot detect, so the names were compared directly rather than trusting the count.
  • The file-size ratchet reports no offenders after the absorption; openai-chat.ts sits exactly at its 822 cap.
  • git merge-tree --write-tree origin/dev <tip> reports a clean merge, and origin/dev is itself an ancestor of this tip.
  • Ancestry verified so each layer closes as MERGED: pw1 through pw5 are all ancestors of this tip.

Maintainer integration decision under MAINTAINERS.md / AGENTS.md: a maintainer with maintain or admin access may integrate into dev without a second maintainer approval, recording the decision and exact-head CI evidence.

@lidge-jun
lidge-jun merged commit 6a8f754 into codex/pw3-kimi-tool-adjacency Sep 16, 2026
7 of 8 checks passed
@lidge-jun
lidge-jun deleted the codex/pw4-custom-baseurl-join branch September 16, 2026 06:07
@github-actions github-actions Bot changed the title fix(catalog): normalize the custom-provider model-discovery join (#4724) [WRONG BRANCH] fix(catalog): normalize the custom-provider model-discovery join (#4724) Sep 16, 2026
@github-actions

github-actions Bot commented Sep 16, 2026 •

Copy link
Copy Markdown
Contributor

⏳ DRAFT

  • wrong target branch (codex/pw3-kimi-tool-adjacency); retarget to dev.

What to do

  • Retarget this PR to dev — all contributions go to dev.

Its title has been prefixed with [WRONG BRANCH].
Automatic draft conversion failed (token cannot change draft status). Please convert this pull request to a draft manually. The required enforce-target check will keep failing until every issue above is resolved.

agentHits pushed a commit to agentHits/opencodex that referenced this pull request Sep 17, 2026
…eurl-join

fix(catalog): normalize the custom-provider model-discovery join (lidge-jun#4724)
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

bug Something isn't working

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant