Skip to content

[Provider compatibility] Key-auth Responses gateway (Kimi K3) echoes hosted web_search as client function_call; webSearchBridge executor is Ollama-only #4429

Closed

Description

@mdwsk88

Client or integration

Codex App

Provider or upstream service

AI2API - an internal corporate gateway speaking the OpenAI Responses API (opencodex provider config: adapter: "openai-responses", authMode: "key"), serving Moonshot kimi-k3. The endpoint is internal-only; there is no public hostname or public specification for the gateway itself.

OpenCodex version

2.51.0 (npm @bitkyc08/opencodex). Also source-checked against dev: webSearchBridge (#4142) is present, but src/web-search/passthrough-bridge.ts still states that only the ollama backend has a shipped executor today.

Endpoint or capability

/v1/responses - hosted web_search tool on a key-auth Responses passthrough route

Current behaviour

Codex App declares the hosted {type: "web_search"} tool on the request. On this route the model answers with a function_call item named web_search. A hosted declaration never authorizes a client function of that name, so the undeclared-tool guard fails the stream, Codex App retries up to 5/5, and the turn dies.

This is the same failure class as #3761 (Ollama Cloud). #4142 shipped providers.<name>.webSearchBridge for exactly this shape, but its only shipped executor posts to Ollama's /api/web_search. PROVIDER_WEB_SEARCH_BRIDGE_BACKENDS admits openai/anthropic/xai/gemini/exa as explicit-but-inert, so a non-Ollama key-auth gateway cannot arm the bridge at all.

Expected behaviour

A key-auth Responses passthrough provider that is not Ollama Cloud can opt into the #4142 bridge with a working search executor - for example by reusing the existing sidecar executors (openai/anthropic/xai/gemini/exa) in the bridge path - so the intercepted web_search call is executed proxy-side, the call and its result continue the upstream conversation, and Codex receives the hosted web_search_call cell, exactly as the Ollama backend already does.

Minimal redacted request or reproduction

// ~/.opencodex/config.json (redacted)
{
  "providers": {
    "AI2API": {
      "adapter": "openai-responses",
      "baseUrl": "https://<internal-host>/v1",
      "authMode": "key",
      "apiKey": "<redacted>",
      "defaultModel": "kimi-k3"
    }
  }
}

Then run any Codex App turn on that route with web search enabled where the model decides to search (for example, asking for the latest status of an external project). Non-search turns on the same route complete normally.

Actual response or error

stream disconnected before completion: routed provider emitted undeclared client tool "web_search"; only request-declared tools may be called

Codex App shows "Reconnecting 1/5 ... 5/5" and the turn fails. Screenshot available on request.

Upstream documentation

https://developers.openai.com/api/docs/guides/tools-web-search - hosted web_search returns web_search_call output items; a provider-side search is not a client function_call. The gateway itself is internal and publishes no specification.

Suggested mapping or implementation notes

Additional context and attachments

Checks

  • I searched existing provider and compatibility issues.
  • The request and response were redacted.
  • The expected behaviour is based on an upstream specification or a concrete client requirement.

Activity

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

    @lidge-jun
    Owner

    리뷰 · 우선순위 70 / 80

    이 이슈는 Codex App이 호스티드 web_search 도구를 켠 채로, key-auth openai-responses 패스스루(여기서는 사내 AI2API → Moonshot kimi-k3)로 보낼 때 생기는 실패입니다. 게이트웨이가 호스티드 검색을 대신 실행하지 않고 function_call 이름 web_search 를 그대로 돌려주면, 호스티드 선언은 클라이언트 함수 이름을 허가하지 않으므로 undeclared-tool 가드가 스트림을 끊고 Codex App이 5/5 재연결 후 턴이 죽습니다. 에러 문구는 이슈에 적힌 routed provider emitted undeclared client tool "web_search" 그대로입니다.

    지금 dev HEAD는 01e7d746d (#4422 Muse 64자 tool-name alias 직후)입니다. 같은 실패 유형은 이미 #3761(Ollama Cloud)로 보고됐고, #4142가 providers.<name>.webSearchBridge 옵트인을 넣었습니다. 다만 그 첫 슬라이스는 Ollama 전용입니다. src/types/provider.ts 의 PROVIDER_WEB_SEARCH_BRIDGE_BACKENDS 는 ollama|openai|anthropic|xai|gemini|exa 를 받지만, 주석과 planPassthroughWebSearchBridge 가 명시한 대로 실제 executor가 있는 건 ollama 뿐이고 나머지는 명시해도 브리지를 무장하지 않습니다(유료 Luna/Exa로 조용히 넘어가지 않으려는 의도).

    코드 경로를 짧게 따라가면: src/server/responses/core.ts 패스스루 분기에서 planPassthroughWebSearchBridge 를 부르고, 계획이 있을 때만 createPassthroughWebSearchBridgeStream + createOllamaBridgeExecutor 로 감쌉니다. planner는 authMode === "key", webSearchBridge.enabled === true, backend === "ollama", 그리고 Ollama Cloud origin(또는 명시 endpoint)이 있을 때만 계획을 돌려줍니다. AI2API처럼 baseUrl이 https://ollama.com 이 아닌 사내 게이트웨이에서는, 설정을 켜도 지금 HEAD에서는 브리지가 절대 안 켜집니다. 그래서 이 이슈는 #3761의 “나머지 일반 게이트웨이” 조각이 맞고, 가드를 풀자는 제안이 아니라 브리지 executor를 넓히자는 제안입니다.

    이미 사이드카 쪽에는 src/web-search/executor.ts(openai), anthropic-executor.ts, xai-executor.ts, gemini-executor.ts, exa-executor.ts 가 있습니다. 제보자가 말한 대로 그 실행기를 브리지 경로에 연결하면 “새 검색 벤더 통합” 없이 key-auth Responses 게이트웨이 일반 케이스를 커버할 수 있습니다. 다만 사이드카는 보통 별도 OAuth/키(ChatGPT forward, Anthropic stored OAuth 등)를 쓰고, 브리지의 ollama 경로는 “이 provider 자신의 apiKey를 검색 endpoint로 보낸다”는 계약이라 자격증명·과금 경계가 다릅니다. #3761에서 말한 credential/endpoint 스코핑을 그대로 가져가야 합니다.

    Kimi 쪽 추가 불확실성도 이슈에 잘 적혀 있습니다. 게이트웨이가 서버에서 $web_search 를 이미 실행한 뒤 call만 에코하는지, 아니면 진짜로 tool result continuation을 기다리는지 live probe가 필요합니다. 전자면 브리지 continuation이 중복 검색/턴 꼬임을 만들 수 있고, 후자면 #4142와 같은 “가로채기 → 실행 → 같은 upstream에 결과 붙여 재POST → Codex엔 web_search_call 셀” 패턴이 맞습니다. 제보자가 probe 가능하다고 했으니 그걸 먼저 받는 게 설계 리스크를 줄입니다.

    types.ts/config.ts 분할 캠페인과 직접 충돌하는 PR은 아니고, 중복 close 대상도 아닙니다. #3761은 #4142로 이미 닫혔고 이 이슈는 그 위의 일반화 요청입니다. 지금 dev 최적화 축(Muse device OAuth, Muse tool alias, Devin reasoning replay, quota-avoid)과도 직교합니다. 우선순위 70은 “실사용 Codex App 턴이 죽고, 수정 경로가 이미 #4142로 검증된 패턴”이기 때문이고, 100에 가깝지 않은 이유는 Muse/릴리즈 열차에 끼어들 필수는 아니고 설계(백엔드 선택·자격증명·Kimi 자동실행) 판단이 한 번 더 필요하기 때문입니다.

    src/types/provider.ts PROVIDER_WEB_SEARCH_BRIDGE_BACKENDS / 주석 - openai|anthropic|xai|gemini|exa 는 설정만 받고 executor는 없음. 사내 key-auth 게이트웨이는 켜도 무장 불가.
    src/web-search/passthrough-bridge.ts planPassthroughWebSearchBridge (~141행) - bridge.backend !== "ollama" 이면 즉시 undefined. 이슈 재현 설정으로는 절대 plan이 안 나옴.
    src/web-search/passthrough-bridge.ts resolveOllamaWebSearchEndpoint - endpoint 없을 때 origin이 ollama.com 일 때만 자동 유도. AI2API baseUrl은 여기서 탈락.
    src/server/responses/core.ts (~6081행) - 브리지 execute가 createOllamaBridgeExecutor 하드코딩. sidecar executor 재사용 경로가 아직 없음.
    undeclared-tool 가드 - 이 이슈에서 가드를 느슨히 하면 “실행할 사람 없는 web_search”를 클라이언트로 통과시켜 더 나빠짐. 가드는 유지가 맞음(제보자·#3761과 동일).
    Kimi/AI2API 동작 - 서버측 자동 검색 후 call 에코인지 continuation 대기인지 미확인. 잘못된 가정으로 브리지를 붙이면 중복 검색·턴 손상 가능.

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

    • 브리지에 sidecar executor(openai/anthropic/xai/gemini/exa)를 붙일지, 아니면 “게이트웨이 전용 검색 endpoint” 모델만 추가할지.
    • 자격증명: provider 자신의 apiKey를 쓸지, 별도 sidecar 계정(ChatGPT/Anthropic/…)을 쓸지, 둘 다 허용할지.
    • Kimi builtin 검색이 이미 서버에서 돌고 call만 에코하는 경우, 브리지 대신 “에코 call을 hosted web_search_call로만 재작성”하는 얇은 경로가 필요한지.
    • 이 작업을 지금 Muse/quota 열차와 병렬로 받을지, help wanted로 두고 제보자 probe 후에 설계할지.

    너의 추천
    이슈는 열어 두고 needs-design / help wanted 방향이 맞습니다. 가드는 풀지 마세요. 다음 단계는 (1) 제보자에게 AI2API가 tool result continuation을 기대하는지 live probe 부탁, (2) probe 결과에 따라 #4142 브리지에 non-ollama executor 배선 PR을 별도 유닛으로 잡기. types/config 분할에 무효화될 성격이 아니고, #3761 중복 close 대상도 아닙니다. PR이 오면 ollama 전용 계약을 깨지 않는지·credential 스코핑·혼합 tool fail-closed를 중점 리뷰하면 됩니다.

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

  3. mdwsk88 commented on Sep 12, 2026

    @mdwsk88
    ContributorAuthor

    Live probe results against the gateway (2026-09-13)

    I probed the AI2API /v1/responses endpoint directly (bypassing opencodex) with the route's own credential. Two controlled requests, streamed, store: false:

    Probe A - only hosted web_search declared (tools: [{type:"web_search"}], plus the same search-requiring prompt):

    • Relay behaved correctly: HTTP 200, 5 server-side searches emitted as hosted web_search_call items (response.web_search_call.in_progress / .searching / .completed), interleaved with reasoning + message items, response.status: completed in ~86s.
    • No function_call at all. So the gateway CAN express hosted search properly.

    Probe B - Codex-like mixed catalog (tools: [{type:"web_search"}, function exec, function update_plan], plus tool_choice: auto, parallel_tool_calls: true, reasoning {effort, summary}):

    • The relay first ran 2 searches server-side as proper web_search_call items (this matches the Codex App rendering "searched the web" cells before the failure).
    • Then it switched modes and ended the response with TWO pending client function_calls: exec (args: run python to fetch URLs) and web_search with args {"queries": [...]} - the argument shape of Kimi's builtin $web_search.
    • Final output array: [reasoning, web_search_call, reasoning, web_search_call, reasoning, function_call(exec), function_call(web_search)], response.status: completed, no message item.

    Conclusions:

    1. Trigger condition identified: the mixed tool catalog. Hosted-only requests get fully hosted execution; once client function tools are declared (what Codex always sends), the relay delegates further actions - including web_search - as client function_calls.
    2. The relay expects client execution plus a continuation request (it completed the response with the calls pending). This means the fix(web-search): opt-in hosted web-search bridge for the key-auth Responses passthrough #4142 passthrough-bridge continuation-leg contract (intercept the call, execute proxy-side, feed call + result back to the same upstream in a fresh POST) fits this gateway exactly - only a non-Ollama executor is missing.
    3. For the gateway team: mixing hosted web_search_call and a client function_call of the same tool in one response is inconsistent on their side too - a server-executed search should never surface as a client call, and an already server-executing tool should not be re-delegated mid-turn.

    Probe scripts available on request (plain python3 + urllib, no dependencies).

  4. jaychou0642-create commented on Sep 12, 2026

    @jaychou0642-create
    Contributor

    Additional live reproduction: official DeepSeek deepseek-flash on OpenCodex 2.52.0

    I reproduced the same missing non-Ollama bridge path with the built-in DeepSeek provider, so this is not limited to Kimi or a private gateway.

    Environment

    • Client: Codex App-compatible streaming /v1/responses request
    • OpenCodex: 2.52.0
    • Provider/model: deepseek / deepseek-flash
    • Provider auth: API key (redacted)
    • Effective wire adapter: openai-responses from the 2.52.0 registry default
    • Web-search sidecar: { "backend": "gemini" }

    Reproduction

    1. Configure the official DeepSeek provider and select deepseek/deepseek-flash.
    2. Configure webSearchSidecar.backend = "gemini".
    3. Send a streaming Responses request with tools: [{ "type": "web_search" }] and force/select that tool for a current-pricing query.
    4. Use sensenova/deepseek-v4-flash (chat adapter) with the same request as a positive control.

    Actual result

    The DeepSeek Responses route returned HTTP 200 and a normal terminal event sequence, but it performed no search:

    functionCalls: []
    web_search_call events: 0
    answer begins:
    I'll search for that information now.
    
    <web_search>
    <query>DeepSeek V4.1-Flash API price per 1M tokens input output</query>
    </web_search>
    
    I need to be straightforward with you: I don't have a web_search tool available...
    

    The positive-control chat route completed two real searches and returned a sourced answer. The service log recorded:

    [web-search-loop] done — 2 searches, 3 iterations
    [web-search-loop] cancelled — 2 real searches, 0 placeholders
    

    Source-path confirmation

    • planWebSearch(...) returns undefined when isPassthrough is true.
    • The registry maps deepseek-flash to openai-responses, whose adapter is passthrough.
    • planPassthroughWebSearchBridge(...) returns undefined for every backend except ollama.
    • The passthrough branch hard-codes createOllamaBridgeExecutor(...).
    • The existing Gemini sidecar itself works and uses native tools: [{ google_search: {} }] plus groundingMetadata; it is simply never planned for this passthrough route.

    Expected result

    A key-auth Responses passthrough route whose upstream does not execute hosted web_search should be able to opt into one of the existing non-Ollama search executors, including Gemini, without switching the main model back to the chat adapter.

    All credentials, account details, request headers, and local paths have been removed.

  5. Ingwannu commented on Sep 13, 2026

    @Ingwannu
    Owner

    The new probes narrow the problem, but they show two different contracts rather than only one missing executor.

    For the AI2API mixed-tool result, adding a non-Ollama executor alone would still not make the current bridge accept it. On dev 4a49d7f, src/web-search/passthrough-bridge.ts deliberately returns web_search_bridge_mixed_tools when intercepted search calls coexist with another client-executed call. Your pending exec + web_search result hits that boundary. A follow-up needs a mixed-tool continuation design that preserves the client's exec call/call_id and ordering without executing it proxy-side or losing already completed hosted-search items.

    The DeepSeek example is different: it reports zero function calls and only XML-like assistant text. The bridge intercepts structured function_call/custom_tool_call items, not arbitrary text. A Gemini executor cannot execute a call that was never emitted. Please keep that fixture separate; do not turn matching assistant text into executable search instructions.

    The hosted-only positive control remains useful. For the implementation proposal, keep three synthetic cases: hosted-only execution unchanged, mixed structured exec/search continuation, and plain-text pseudo-search with no dispatch. Backend credential ownership and explicit billing opt-in remain required. No live gateway requests were made in this review, and the undeclared-tool/mixed-tool guards should remain closed.

  6. added a commit that references this issue on Sep 13, 2026
  7. lidge-jun commented on Sep 13, 2026

    @lidge-jun
    Owner

    Partly addressed on dev, and left open on purpose for the part that matters most in your report.

    #4515 landed (merge commit cb2e15ba6f8ac17af0620d6ff04fcfa7d88e3dcd). It arms the existing sidecar executors — openai, anthropic, xai, gemini, exa — behind an explicit webSearchBridge.backend, where before only ollama armed the bridge even though the type already accepted all six. Credentials stay on the named backend and a missing credential leaves the bridge disarmed rather than falling through to a paid search.

    That does not fix your probe B, and saying otherwise would be wrong. Your live probe ends with two pending client calls, exec and web_search, and the bridge still refuses that with web_search_bridge_mixed_tools. @Ingwannu's point stands: adding an executor was never going to be enough, because the boundary is the mixed-tool leg rather than the missing backend. A continuation there has to preserve your exec call and its call_id and their ordering, must not execute it proxy-side, and must not drop hosted search items the relay already completed. That is a design, not a patch, and it is why this issue stays open.

    The DeepSeek XML case in this thread also stays separate, deliberately. It emits no function calls at all, only assistant text, and the bridge intercepts structured function_call and custom_tool_call items. Making matching prose executable would turn model output into tool execution, which is a boundary worth keeping even when it is inconvenient.

    Your probe A is what made the scoping possible — it proved the gateway can express hosted search correctly, so the failure is in the mixed catalog rather than in the relay's capability. Thanks for running both controls.

  8. lidge-jun commented on Sep 13, 2026

    @lidge-jun
    Owner

    Staying open, with the residual named so the next triage does not re-derive it.

    Tonight's web-search work landed next door to this issue without touching it. #4548 (1a942346997397a994ba4992d5a3d97c2184d147) bound the bridge's sidecar model to the bridge backend, so a global OpenAI sidecar model can no longer be handed to an Anthropic bridge executor. That was a real defect found in the #4515 review, but it is model selection, not continuation.

    The thing this issue is actually about is still fail-closed: a turn with both web_search and a pending exec still returns web_search_bridge_mixed_tools. The executor side is armed; the missing piece is a continuation design that preserves the call ids and ordering of the exec the client still has to run. That is a design question, not a wiring gap, which is why nothing tonight moved it.

    Also relevant and separately tracked: #4519 covers the destination policy for webSearchBridge.endpoint, whose fix is open in #4555 pending security review.

  9. added a commit that references this issue on Sep 17, 2026
  10. lidge-jun commented on Sep 17, 2026

    @lidge-jun
    Owner

    Closed. Both things this issue asked for are on dev, and the last remaining piece landed just now.

    Arming the bridge on non-Ollama key-auth gateways: 5b707d3a5c. planPassthroughWebSearchBridge no longer returns early for a non-ollama backend; openai, anthropic, xai, gemini and exa are planned on their own credentials, and no Ollama Cloud origin is required, so a self-hosted gateway on an arbitrary base URL arms as you asked. The credential boundary #3761 established still holds: only the ollama backend uses the provider's own API key, and a backend without credentials stays unarmed rather than silently falling through to a different paid search.

    Intercept, proxy-side execution, and continuing the upstream conversation: 024e43ddf1 and 3557ada7de. Not dying on a leg mixed with client tools: 2e6a0316b9 (#4586).

    Replaying the executed result to the destination on the next turn was the piece still missing, and it is now in as #4919. The memo is keyed by destination scope and hosted cell id, and every miss is a no-op — unknown id, expiry, a different destination, or a call_id already present in the body all leave the item alone. It does not silently re-run the search, which would both bill twice and answer with a different search than the history claims, and it does not synthesize result text.

    On the hosted versus client distinction: a namespaced ns__web_search is a separate tool identity the client declares and executes, so it is not intercepted, and web_search was not added to the undeclared-tool guard's allowed names — that would approve a call nobody can execute rather than remove it.

    One separate symptom is worth keeping in view, and it is not a code defect here: whether your gateway executes Kimi's builtin $web_search server-side and echoes only the call. That is the live probe you offered to run; if it turns out that way, reply on this thread rather than opening a new issue, since the disposition would be different.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    providerProvider adapters, OpenAI-compat presets, upstream API quirksprovider-compatibilityProvider compatibility reportstoolstool_calls, MCP, web-search / sidecar tools

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions