@bitkyc08/opencodex 2.7.4-preview.20260710 → 2.7.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.ko.md CHANGED
@@ -179,6 +179,7 @@ opencodex는 두 가지 동작을 분리해서 유지합니다:
179
179
  - **알맞은 모델에 위임.** 대시보드나 config에서 최대 5개의 라우팅/네이티브 모델을 Codex 서브에이전트 선택기에 노출해, 복잡한 작업은 reasoning 모델로, 빠른 작업은 저렴한 모델로 보낼 수 있습니다. v2 멀티에이전트 표면(GPT-5.6 Sol/Terra)에서는 프록시가 간결한 위임 가이드를 주입합니다. 선호 서브에이전트 모델·effort(`injectionModel` / `injectionEffort`), 노출된 모델 로스터와 각 모델이 지원하는 effort 사다리, 그리고 크로스모델 `spawn_agent` 호출이 실제로 먹히게 하는 `fork_turns` 규칙까지. 문구를 직접 쓰고 싶다면 `injectionPrompt`에 `{{model}}` / `{{effort}}` / `{{roster}}` 플레이스홀더를 넣으면 됩니다.
180
180
  - **프리뷰 게이트된 OpenAI rollout에 대비.** GPT-5.6 Sol/Terra/Luna 항목은 upstream 스펙 그대로(Sol/Terra는 `ultra`까지, Luna는 `max`까지; 372k usable context) ChatGPT passthrough, OpenAI API key, OpenRouter route에 준비되어 있습니다.
181
181
  - **어떤 모델에도 초능력을.** OpenAI가 아닌 모델도 ChatGPT 로그인 위에서 도는 `gpt-5.4-mini` sidecar로 실제 웹 검색과 이미지 이해를 사용합니다.
182
+ - **이미지를 네이티브로 생성.** Codex의 독립형 `image_gen` 도구는 생성할 때 `POST /v1/images/generations`, 편집할 때 `POST /v1/images/edits`를 사용합니다. Responses의 hosted `image_generation` 도구와는 별개입니다.
182
183
  - **무슨 일이 일어나는지 보이게.** 웹 대시보드가 프로바이더, OAuth 상태, 모델 선택, upstream이 보고한 cached/cache-write 토큰 수를 포함한 실시간 요청 로그를 보여줍니다 — 왜 요청이 실패했는지 더는 추측하지 않아도 됩니다.
183
184
  - **백그라운드 실행.** 시스템 서비스(launchd / systemd / Task Scheduler)로 설치하면 부팅 시 자동 시작되어 신경 쓸 필요가 없습니다.
184
185
  - **깔끔한 종료, 잔여물 제로.** `ocx stop`(또는 대시보드의 Stop 버튼)은 프록시를 종료하고, 설치된 백그라운드 서비스를 멈추며, Codex를 원래 설정으로 복원합니다. 이후 `codex`는 잔여 설정이나 좀비 프로세스 없이 이전과 똑같이 동작합니다.
@@ -321,7 +322,8 @@ WebSocket 전송은 기본적으로 꺼져 있습니다. Codex가 HTTP/SSE 대
321
322
  ### 원격 접근
322
323
 
323
324
  기본적으로 opencodex는 `127.0.0.1`(루프백)에 바인딩되며 별도 인증이 필요 없습니다.
324
- `"hostname": "0.0.0.0"`으로 LAN에 노출할 경우, opencodex는 관리 API(`/api/*`)와 데이터 플레인(`/v1/responses`) 모두에 bearer 토큰을 요구합니다:
325
+ `"hostname": "0.0.0.0"`으로 LAN에 노출할 경우, opencodex는 관리 API(`/api/*`)와 데이터 플레인
326
+ (`/v1/responses`, `/v1/images/generations`, `/v1/images/edits`) 모두에 bearer 토큰을 요구합니다:
325
327
 
326
328
  ```bash
327
329
  export OPENCODEX_API_AUTH_TOKEN="your-secret-token"
@@ -372,8 +374,9 @@ bun x tsc --noEmit # 타입 체크
372
374
  ```
373
375
 
374
376
  `bun run dev`는 호환성을 위해 `bun run dev:proxy`의 별칭으로 남아 있습니다. 소스 체크아웃에서 프록시
375
- API는 `/healthz`, `/v1/responses`, `/api/*`를 노출하며, `GET /`는 `bun run build:gui`가 `gui/dist`를
376
- 생성한 뒤에만 패키징된 대시보드를 서빙합니다. 대시보드를 수정할 때는 프론트엔드를 별도로 실행하세요:
377
+ API는 `/healthz`, `/v1/responses`, `POST /v1/images/generations`, `POST /v1/images/edits`, `/api/*`를
378
+ 노출하며, `GET /`는 `bun run build:gui`가 `gui/dist`를 생성한 뒤에만 패키징된 대시보드를 서빙합니다.
379
+ 대시보드를 수정할 때는 프론트엔드를 별도로 실행하세요:
377
380
 
378
381
  ```bash
379
382
  bun run dev:gui
package/README.md CHANGED
@@ -184,6 +184,7 @@ next Codex session. opencodex keeps two separate behaviors:
184
184
  - **Delegate to the right model.** Feature up to five routed or native models in Codex's subagent picker from the dashboard or config — route complex tasks to a reasoning model, fast tasks to a cheap one. On the v2 multi-agent surface (GPT-5.6 Sol/Terra) the proxy injects compact delegation guidance: a preferred sub-agent model and effort (`injectionModel` / `injectionEffort`), the featured-model roster with the effort ladder each supports, and the `fork_turns` rules that make cross-model `spawn_agent` calls actually stick. Want your own wording? Set `injectionPrompt` with `{{model}}` / `{{effort}}` / `{{roster}}` placeholders.
185
185
  - **Prepare for preview-gated OpenAI rollouts.** GPT-5.6 Sol/Terra/Luna entries ship with the exact upstream spec (Sol/Terra reach `ultra`, Luna caps at `max`; 372k usable context) for ChatGPT passthrough, OpenAI API key, and OpenRouter routes when upstream access is available.
186
186
  - **Give any model superpowers.** Non-OpenAI models get real web search and image understanding via a `gpt-5.4-mini` sidecar over your ChatGPT login.
187
+ - **Generate images natively.** Codex's standalone `image_gen` tool uses `POST /v1/images/generations` for generation and `POST /v1/images/edits` for edits; it is separate from the hosted Responses `image_generation` tool.
187
188
  - **See what's happening.** The web dashboard shows providers, OAuth status, model selection, and a live request log, including cached/cache-write token counts when upstream reports them — no more guessing why a request failed.
188
189
  - **Runs in the background.** Install as a system service (launchd / systemd / Task Scheduler) and forget about it. The proxy starts on boot and stays out of your way.
189
190
  - **Clean exit, zero residue.** `ocx stop` (or the dashboard's Stop button) shuts down the proxy, stops the background service if one is installed, and restores Codex to its original configuration. Plain `codex` works exactly as it did before — no leftover config, no orphaned processes.
@@ -341,7 +342,8 @@ WebSocket transport is off by default. Set `"websockets": true` only if you want
341
342
 
342
343
  By default opencodex binds to `127.0.0.1` (loopback) and requires no extra authentication.
343
344
  If you set `"hostname": "0.0.0.0"` to expose the proxy on the LAN, opencodex requires a bearer token
344
- to protect both the management API (`/api/*`) and the data-plane (`/v1/responses`):
345
+ to protect both the management API (`/api/*`) and the data-plane (`/v1/responses`,
346
+ `/v1/images/generations`, and `/v1/images/edits`):
345
347
 
346
348
  ```bash
347
349
  export OPENCODEX_API_AUTH_TOKEN="your-secret-token"
@@ -392,9 +394,9 @@ bun x tsc --noEmit # typecheck
392
394
  ```
393
395
 
394
396
  `bun run dev` remains an alias for `bun run dev:proxy` for compatibility. In a source checkout,
395
- the proxy API exposes `/healthz`, `/v1/responses`, and `/api/*`; `GET /` serves the packaged
396
- dashboard only after `bun run build:gui` has produced `gui/dist`. While hacking on the dashboard,
397
- run the frontend separately:
397
+ the proxy API exposes `/healthz`, `/v1/responses`, `POST /v1/images/generations`,
398
+ `POST /v1/images/edits`, and `/api/*`; `GET /` serves the packaged dashboard only after
399
+ `bun run build:gui` has produced `gui/dist`. While hacking on the dashboard, run the frontend separately:
398
400
 
399
401
  ```bash
400
402
  bun run dev:gui
package/README.zh-CN.md CHANGED
@@ -102,6 +102,7 @@ npm install -g @bitkyc08/opencodex # 不要加 --ignore-scripts、--omit=optio
102
102
  - **委派给合适的模型。** 在仪表盘或 config 中把最多 5 个路由/原生模型放进 Codex 的 subagent 选择器 —— 复杂任务交给 reasoning 模型,快速任务交给便宜模型。在 v2 多智能体表面(GPT-5.6 Sol/Terra)上,代理会注入精简的委派指引:首选子智能体模型与 effort(`injectionModel` / `injectionEffort`)、featured 模型清单及各自支持的 effort 阶梯,以及让跨模型 `spawn_agent` 调用真正生效的 `fork_turns` 规则。想自定义文案,可在 `injectionPrompt` 中使用 `{{model}}` / `{{effort}}` / `{{roster}}` 占位符。
103
103
  - **为 preview-gated OpenAI rollout 做好准备。** GPT-5.6 Sol/Terra/Luna 条目采用与 upstream 完全一致的规格(Sol/Terra 到 `ultra`,Luna 到 `max`;372k 可用上下文),覆盖 ChatGPT passthrough、OpenAI API key 和 OpenRouter 路由。
104
104
  - **给任意模型超能力。** 非 OpenAI 模型也能通过你的 ChatGPT 登录上运行的 `gpt-5.4-mini` sidecar 获得真正的网页搜索和图片理解。
105
+ - **原生生成图片。** Codex 的独立 `image_gen` 工具通过 `POST /v1/images/generations` 生成图片、通过 `POST /v1/images/edits` 编辑图片;它独立于 hosted Responses 的 `image_generation` 工具。
105
106
  - **看清正在发生什么。** Web 仪表盘展示 provider、OAuth 状态、模型选择和实时请求日志;当上游返回时,也会包含 cached/cache-write token 计数 —— 不必再猜测请求为何失败。
106
107
  - **后台运行。** 安装为系统服务(launchd / systemd / Task Scheduler)后开机自启,无需操心。
107
108
  - **干净退出,零残留。** `ocx stop`(或仪表盘的 Stop 按钮)会关闭代理、停止已安装的后台服务,并将 Codex 恢复为原始配置。之后 `codex` 就像从未安装过 opencodex 一样工作 —— 无残留配置,无僵尸进程。
@@ -303,7 +304,7 @@ WebSocket 传输默认关闭。只有当你希望 Codex 使用 Responses WebSock
303
304
 
304
305
  默认情况下 opencodex 绑定到 `127.0.0.1`(回环)且无需额外认证。
305
306
  如果你设置 `"hostname": "0.0.0.0"` 把代理暴露到局域网,opencodex 会要求一个 bearer token 来同时保护管理
306
- API(`/api/*`)和数据平面(`/v1/responses`):
307
+ API(`/api/*`)和数据平面(`/v1/responses`、`/v1/images/generations`、`/v1/images/edits`):
307
308
 
308
309
  ```bash
309
310
  export OPENCODEX_API_AUTH_TOKEN="your-secret-token"
@@ -350,8 +351,8 @@ bun x tsc --noEmit # 类型检查
350
351
  ```
351
352
 
352
353
  `bun run dev` 作为 `bun run dev:proxy` 的别名保留以兼容旧用法。在源码检出中,代理 API 暴露 `/healthz`、
353
- `/v1/responses`、`/api/*`;只有在 `bun run build:gui` 生成 `gui/dist` 之后,`GET /` 才会提供打包后的仪表盘。
354
- 开发前端时请单独运行:
354
+ `/v1/responses`、`POST /v1/images/generations`、`POST /v1/images/edits`、`/api/*`;只有在
355
+ `bun run build:gui` 生成 `gui/dist` 之后,`GET /` 才会提供打包后的仪表盘。开发前端时请单独运行:
355
356
 
356
357
  ```bash
357
358
  bun run dev:gui
@@ -37,4 +37,4 @@ v2: 多线程代理(spawn_agent)。所有模型使用 v2 协作界面。
37
37
  -d '{
38
38
  "model": "gpt-5.4",
39
39
  "input": "Hello, world!"
40
- }'`})]})]})}var Ft=`opencodex-api-token`,It=!1,Lt=null;function Rt(e){try{let t=e instanceof Request?e.url:String(e);return new URL(t,window.location.href).pathname}catch{return null}}function zt(e){let t=Rt(e);return!!t&&(t.startsWith(`/api/`)||t.startsWith(`/v1/`))}function Bt(){try{return sessionStorage.getItem(Ft)?.trim()||null}catch{return null}}function Vt(e){try{sessionStorage.setItem(Ft,e)}catch{}}function Ht(){try{sessionStorage.removeItem(Ft)}catch{}}function Ut(e,t,n){let r=new Headers(t?.headers??(e instanceof Request?e.headers:void 0));return r.set(`X-OpenCodex-API-Key`,n),e instanceof Request?[new Request(e,{headers:r}),t?{...t,headers:r}:void 0]:[e,{...t,headers:r}]}async function Wt(){return Lt||(Lt=Promise.resolve().then(()=>window.prompt(`OpenCodex API token`)?.trim()||null).finally(()=>{Lt=null}),Lt)}function Gt(){if(It)return;It=!0;let e=window.fetch.bind(window);window.fetch=async(t,n)=>{if(!zt(t))return e(t,n);let r=Bt(),[i,a]=r?Ut(t,n,r):[t,n],o=await e(i,a);if(o.status!==401)return o;r&&Ht();let s=await Wt();if(!s)return o;Vt(s);let[c,l]=Ut(t,n,s),u=await e(c,l);return u.status===401&&Ht(),u}}Gt();var Kt=new Set([`dashboard`,`providers`,`models`,`subagents`,`logs`,`debug`,`usage`,`codex-auth`,`api`]);function qt(){let e=location.hash.replace(/^#\/?/,``);return Kt.has(e)?e:`dashboard`}var Jt=``,Yt=`ocx-theme`,Xt=[{id:`dashboard`,tkey:`nav.dashboard`,Icon:w},{id:`providers`,tkey:`nav.providers`,Icon:T},{id:`models`,tkey:`nav.models`,Icon:E},{id:`subagents`,tkey:`nav.subagents`,Icon:ee},{id:`logs`,tkey:`nav.logs`,Icon:D},{id:`debug`,tkey:`nav.debug`,Icon:te},{id:`usage`,tkey:`nav.usage`,Icon:ne},{id:`codex-auth`,tkey:`nav.codexAuth`,Icon:L},{id:`api`,tkey:`nav.api`,Icon:me}],Zt={light:de,dark:fe,system:pe},Qt={light:`theme.light`,dark:`theme.dark`,system:`theme.system`};function $t(e){if(!e||typeof e!=`object`||!(`version`in e))return null;let t=e.version;return typeof t==`string`&&t.length>0?t:null}function en(){let e=localStorage.getItem(Yt);return e===`light`||e===`dark`?e:`system`}function tn(){let[e,t]=(0,_.useState)(qt),n=e=>{location.hash=e,t(e)},[r,i]=(0,_.useState)(en),[a,o]=(0,_.useState)(null),{locale:s,setLocale:c}=Ce(),l=we();(0,_.useEffect)(()=>{let e=()=>t(qt());return window.addEventListener(`hashchange`,e),()=>window.removeEventListener(`hashchange`,e)},[]),(0,_.useEffect)(()=>{let e=document.documentElement;r===`system`?(e.removeAttribute(`data-theme`),localStorage.removeItem(Yt)):(e.setAttribute(`data-theme`,r),localStorage.setItem(Yt,r))},[r]),(0,_.useEffect)(()=>{let e=!1,t=async()=>{try{let t=await fetch(`${Jt}/healthz`);if(!t.ok)return;let n=$t(await t.json());!e&&n&&o(n)}catch{}};t();let n=setInterval(t,3e4);return()=>{e=!0,clearInterval(n)}},[]);let u=()=>i(e=>e===`light`?`dark`:e===`dark`?`system`:`light`),d=Zt[r],f=a??`2.7.4-preview.20260710`,[p,m]=(0,_.useState)(!1);return(0,S.jsxs)(`div`,{className:`app`,children:[(0,S.jsxs)(`aside`,{className:`sidebar`,children:[(0,S.jsxs)(`div`,{className:`brand`,children:[(0,S.jsx)(`span`,{className:`brand-logo`,role:`img`,"aria-label":`opencodex logo`}),(0,S.jsx)(`span`,{className:`name`,children:`opencodex`}),(0,S.jsxs)(`span`,{className:`ver`,children:[`v`,f]})]}),(0,S.jsx)(`nav`,{children:Xt.map(({id:t,tkey:r,Icon:i})=>(0,S.jsxs)(`button`,{className:`nav-item${e===t?` active`:``}`,"data-page":t,onClick:()=>n(t),"aria-current":e===t?`page`:void 0,children:[(0,S.jsx)(i,{}),` `,l(r)]},t))}),(0,S.jsxs)(`div`,{className:`sidebar-foot`,children:[(0,S.jsxs)(`div`,{className:`lang-toggle`,children:[(0,S.jsx)(me,{"aria-hidden":!0}),(0,S.jsx)(Ae,{value:s,options:_e.map(e=>({value:e.code,label:e.name})),onChange:e=>c(e),label:l(`lang.label`),placement:`right`,style:{flex:1,minWidth:0,width:`100%`}})]}),(0,S.jsxs)(`button`,{type:`button`,className:`theme-toggle`,onClick:u,"aria-label":`${l(`theme.label`)}: ${l(Qt[r])}`,title:`${l(`theme.label`)}: ${l(Qt[r])}`,children:[(0,S.jsx)(d,{}),` `,(0,S.jsx)(`span`,{className:`mode`,children:l(Qt[r])})]}),(0,S.jsxs)(`button`,{type:`button`,className:`theme-toggle stop-toggle`,onClick:async()=>{if(confirm(l(`dash.stopConfirm`))){m(!0);try{await fetch(`${Jt}/api/stop`,{method:`POST`})}catch{}}},disabled:p,"aria-label":l(`dash.stop`),title:l(`dash.stop`),children:[(0,S.jsx)(I,{}),` `,(0,S.jsx)(`span`,{className:`mode`,children:l(p?`dash.stopping`:`dash.stop`)})]}),(0,S.jsxs)(`a`,{className:`sidebar-link`,href:`https://github.com/lidge-jun/opencodex`,target:`_blank`,rel:`noreferrer`,children:[(0,S.jsx)(F,{}),` `,l(`common.github`)]})]})]}),(0,S.jsx)(`main`,{className:`main`,children:(0,S.jsxs)(`div`,{className:`main-inner`,children:[e===`dashboard`&&(0,S.jsx)(He,{apiBase:Jt}),e===`providers`&&(0,S.jsx)(nt,{apiBase:Jt}),e===`models`&&(0,S.jsx)(ct,{apiBase:Jt}),e===`subagents`&&(0,S.jsx)(lt,{apiBase:Jt}),e===`logs`&&(0,S.jsx)(yt,{apiBase:Jt}),e===`debug`&&(0,S.jsx)(bt,{apiBase:Jt}),e===`usage`&&(0,S.jsx)(Dt,{apiBase:Jt}),e===`codex-auth`&&(0,S.jsx)(kt,{apiBase:Jt}),e===`api`&&(0,S.jsx)(Pt,{apiBase:Jt})]})})]})}v.createRoot(document.getElementById(`root`)).render((0,S.jsx)(_.StrictMode,{children:(0,S.jsx)(Se,{children:(0,S.jsx)(tn,{})})}));
40
+ }'`})]})]})}var Ft=`opencodex-api-token`,It=!1,Lt=null;function Rt(e){try{let t=e instanceof Request?e.url:String(e);return new URL(t,window.location.href).pathname}catch{return null}}function zt(e){let t=Rt(e);return!!t&&(t.startsWith(`/api/`)||t.startsWith(`/v1/`))}function Bt(){try{return sessionStorage.getItem(Ft)?.trim()||null}catch{return null}}function Vt(e){try{sessionStorage.setItem(Ft,e)}catch{}}function Ht(){try{sessionStorage.removeItem(Ft)}catch{}}function Ut(e,t,n){let r=new Headers(t?.headers??(e instanceof Request?e.headers:void 0));return r.set(`X-OpenCodex-API-Key`,n),e instanceof Request?[new Request(e,{headers:r}),t?{...t,headers:r}:void 0]:[e,{...t,headers:r}]}async function Wt(){return Lt||(Lt=Promise.resolve().then(()=>window.prompt(`OpenCodex API token`)?.trim()||null).finally(()=>{Lt=null}),Lt)}function Gt(){if(It)return;It=!0;let e=window.fetch.bind(window);window.fetch=async(t,n)=>{if(!zt(t))return e(t,n);let r=Bt(),[i,a]=r?Ut(t,n,r):[t,n],o=await e(i,a);if(o.status!==401)return o;r&&Ht();let s=await Wt();if(!s)return o;Vt(s);let[c,l]=Ut(t,n,s),u=await e(c,l);return u.status===401&&Ht(),u}}Gt();var Kt=new Set([`dashboard`,`providers`,`models`,`subagents`,`logs`,`debug`,`usage`,`codex-auth`,`api`]);function qt(){let e=location.hash.replace(/^#\/?/,``);return Kt.has(e)?e:`dashboard`}var Jt=``,Yt=`ocx-theme`,Xt=[{id:`dashboard`,tkey:`nav.dashboard`,Icon:w},{id:`providers`,tkey:`nav.providers`,Icon:T},{id:`models`,tkey:`nav.models`,Icon:E},{id:`subagents`,tkey:`nav.subagents`,Icon:ee},{id:`logs`,tkey:`nav.logs`,Icon:D},{id:`debug`,tkey:`nav.debug`,Icon:te},{id:`usage`,tkey:`nav.usage`,Icon:ne},{id:`codex-auth`,tkey:`nav.codexAuth`,Icon:L},{id:`api`,tkey:`nav.api`,Icon:me}],Zt={light:de,dark:fe,system:pe},Qt={light:`theme.light`,dark:`theme.dark`,system:`theme.system`};function $t(e){if(!e||typeof e!=`object`||!(`version`in e))return null;let t=e.version;return typeof t==`string`&&t.length>0?t:null}function en(){let e=localStorage.getItem(Yt);return e===`light`||e===`dark`?e:`system`}function tn(){let[e,t]=(0,_.useState)(qt),n=e=>{location.hash=e,t(e)},[r,i]=(0,_.useState)(en),[a,o]=(0,_.useState)(null),{locale:s,setLocale:c}=Ce(),l=we();(0,_.useEffect)(()=>{let e=()=>t(qt());return window.addEventListener(`hashchange`,e),()=>window.removeEventListener(`hashchange`,e)},[]),(0,_.useEffect)(()=>{let e=document.documentElement;r===`system`?(e.removeAttribute(`data-theme`),localStorage.removeItem(Yt)):(e.setAttribute(`data-theme`,r),localStorage.setItem(Yt,r))},[r]),(0,_.useEffect)(()=>{let e=!1,t=async()=>{try{let t=await fetch(`${Jt}/healthz`);if(!t.ok)return;let n=$t(await t.json());!e&&n&&o(n)}catch{}};t();let n=setInterval(t,3e4);return()=>{e=!0,clearInterval(n)}},[]);let u=()=>i(e=>e===`light`?`dark`:e===`dark`?`system`:`light`),d=Zt[r],f=a??`2.7.6`,[p,m]=(0,_.useState)(!1);return(0,S.jsxs)(`div`,{className:`app`,children:[(0,S.jsxs)(`aside`,{className:`sidebar`,children:[(0,S.jsxs)(`div`,{className:`brand`,children:[(0,S.jsx)(`span`,{className:`brand-logo`,role:`img`,"aria-label":`opencodex logo`}),(0,S.jsx)(`span`,{className:`name`,children:`opencodex`}),(0,S.jsxs)(`span`,{className:`ver`,children:[`v`,f]})]}),(0,S.jsx)(`nav`,{children:Xt.map(({id:t,tkey:r,Icon:i})=>(0,S.jsxs)(`button`,{className:`nav-item${e===t?` active`:``}`,"data-page":t,onClick:()=>n(t),"aria-current":e===t?`page`:void 0,children:[(0,S.jsx)(i,{}),` `,l(r)]},t))}),(0,S.jsxs)(`div`,{className:`sidebar-foot`,children:[(0,S.jsxs)(`div`,{className:`lang-toggle`,children:[(0,S.jsx)(me,{"aria-hidden":!0}),(0,S.jsx)(Ae,{value:s,options:_e.map(e=>({value:e.code,label:e.name})),onChange:e=>c(e),label:l(`lang.label`),placement:`right`,style:{flex:1,minWidth:0,width:`100%`}})]}),(0,S.jsxs)(`button`,{type:`button`,className:`theme-toggle`,onClick:u,"aria-label":`${l(`theme.label`)}: ${l(Qt[r])}`,title:`${l(`theme.label`)}: ${l(Qt[r])}`,children:[(0,S.jsx)(d,{}),` `,(0,S.jsx)(`span`,{className:`mode`,children:l(Qt[r])})]}),(0,S.jsxs)(`button`,{type:`button`,className:`theme-toggle stop-toggle`,onClick:async()=>{if(confirm(l(`dash.stopConfirm`))){m(!0);try{await fetch(`${Jt}/api/stop`,{method:`POST`})}catch{}}},disabled:p,"aria-label":l(`dash.stop`),title:l(`dash.stop`),children:[(0,S.jsx)(I,{}),` `,(0,S.jsx)(`span`,{className:`mode`,children:l(p?`dash.stopping`:`dash.stop`)})]}),(0,S.jsxs)(`a`,{className:`sidebar-link`,href:`https://github.com/lidge-jun/opencodex`,target:`_blank`,rel:`noreferrer`,children:[(0,S.jsx)(F,{}),` `,l(`common.github`)]})]})]}),(0,S.jsx)(`main`,{className:`main`,children:(0,S.jsxs)(`div`,{className:`main-inner`,children:[e===`dashboard`&&(0,S.jsx)(He,{apiBase:Jt}),e===`providers`&&(0,S.jsx)(nt,{apiBase:Jt}),e===`models`&&(0,S.jsx)(ct,{apiBase:Jt}),e===`subagents`&&(0,S.jsx)(lt,{apiBase:Jt}),e===`logs`&&(0,S.jsx)(yt,{apiBase:Jt}),e===`debug`&&(0,S.jsx)(bt,{apiBase:Jt}),e===`usage`&&(0,S.jsx)(Dt,{apiBase:Jt}),e===`codex-auth`&&(0,S.jsx)(kt,{apiBase:Jt}),e===`api`&&(0,S.jsx)(Pt,{apiBase:Jt})]})})]})}v.createRoot(document.getElementById(`root`)).render((0,S.jsx)(_.StrictMode,{children:(0,S.jsx)(Se,{children:(0,S.jsx)(tn,{})})}));
@@ -16,7 +16,7 @@
16
16
  } catch (e) {}
17
17
  })();
18
18
  </script>
19
- <script type="module" crossorigin src="/assets/index-DulXo5ZJ.js"></script>
19
+ <script type="module" crossorigin src="/assets/index-mEjIne-M.js"></script>
20
20
  <link rel="stylesheet" crossorigin href="/assets/index-D7o1qwy-.css">
21
21
  </head>
22
22
  <body>
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bitkyc08/opencodex",
3
- "version": "2.7.4-preview.20260710",
3
+ "version": "2.7.6",
4
4
  "description": "Universal provider proxy for OpenAI Codex — use any LLM with Codex CLI/App/SDK",
5
5
  "type": "module",
6
6
  "main": "./bin/package-main.mjs",
@@ -487,9 +487,37 @@ function toolsToAnthropicFormat(parsed: OcxParsedRequest, toolNames: { toWire: (
487
487
  return converted;
488
488
  }
489
489
 
490
+ // Codex multi-agent v2 stamps a Responses-only `encrypted: true` marker on
491
+ // collaboration tool schemas (openai/codex 5f4d06ef; issue #85). It is an
492
+ // annotation for the ChatGPT backend only. Anthropic input_schema is strict
493
+ // JSON Schema; strip the marker defensively everywhere it can appear as a
494
+ // schema keyword, while preserving properties literally named "encrypted".
495
+ const ENCRYPTED_MARKER_NAME_BAG_KEYS = new Set(["properties", "patternProperties", "$defs", "definitions"]);
496
+ const ENCRYPTED_MARKER_LITERAL_VALUE_KEYS = new Set(["const", "default", "enum", "examples"]);
497
+
498
+ function stripEncryptedMarker(node: unknown, inNameBag = false): unknown {
499
+ if (Array.isArray(node)) return node.map(item => stripEncryptedMarker(item));
500
+ if (!node || typeof node !== "object") return node;
501
+
502
+ const out: Record<string, unknown> = {};
503
+
504
+ for (const [key, value] of Object.entries(node as Record<string, unknown>)) {
505
+ if (inNameBag) {
506
+ out[key] = stripEncryptedMarker(value);
507
+ } else if (key !== "encrypted") {
508
+ out[key] = ENCRYPTED_MARKER_LITERAL_VALUE_KEYS.has(key)
509
+ ? value
510
+ : stripEncryptedMarker(value, ENCRYPTED_MARKER_NAME_BAG_KEYS.has(key));
511
+ }
512
+ }
513
+
514
+ return out;
515
+ }
516
+
490
517
  function normalizeAnthropicInputSchema(schema: unknown): Record<string, unknown> {
491
- const obj = schema && typeof schema === "object" && !Array.isArray(schema)
492
- ? schema as Record<string, unknown>
518
+ const stripped = stripEncryptedMarker(schema);
519
+ const obj = stripped && typeof stripped === "object" && !Array.isArray(stripped)
520
+ ? stripped as Record<string, unknown>
493
521
  : {};
494
522
  // Anthropic rejects root-level missing type and oneOf/anyOf/allOf in input_schema.
495
523
  // Normalize the root only: ensure type:"object" + properties, flatten root composition
@@ -9,6 +9,12 @@ export interface IncomingMeta {
9
9
  export interface ProviderAdapter {
10
10
  name: string;
11
11
 
12
+ /**
13
+ * Convert an already-read provider HTTP error into client-safe text. This hook must be pure and
14
+ * return fully redacted output: callers may pass untrusted provider headers and payload text.
15
+ */
16
+ formatErrorBody?(status: number, headers: Headers, payloadText: string): string;
17
+
12
18
  /**
13
19
  * Build the upstream request. May be async: adapters that resolve a short-lived credential
14
20
  * (e.g. Vertex AI ADC token) return a Promise. Sync adapters return the object directly; callers
@@ -39,6 +45,10 @@ export interface AdapterRequest {
39
45
  }
40
46
 
41
47
  export interface AdapterFetchContext {
48
+ /** Remains attached to the returned response body after the response headers arrive. */
42
49
  abortSignal?: AbortSignal;
50
+ /** Deadline for receiving response headers on each attempt, not for consuming the response body. */
43
51
  timeoutMs?: number;
52
+ /** Return final non-2xx responses untouched so the caller can own the error-body read. */
53
+ returnRawErrors?: boolean;
44
54
  }
@@ -1,5 +1,7 @@
1
1
  import type { AdapterFetchContext, AdapterRequest } from "./base";
2
2
  import { isQuotaExhaustedBody, retryableGoogleStatus, safeGoogleHttpErrorMessage } from "./google-errors";
3
+ import { clearableDeadline } from "../lib/abort";
4
+ import { readBoundedResponseBody } from "../lib/bounded-body";
3
5
  import { abortError, sleepWithAbort } from "../lib/upstream-retry";
4
6
 
5
7
  const GOOGLE_RETRY_ATTEMPTS = 3;
@@ -23,14 +25,28 @@ function retryDelayMs(attempt: number, headers?: Headers): number {
23
25
  return Math.floor(exp * (0.8 + Math.random() * 0.4));
24
26
  }
25
27
 
26
- function signalWithAttemptTimeout(parent: AbortSignal | undefined, timeoutMs: number): AbortSignal {
27
- const timeout = AbortSignal.timeout(timeoutMs);
28
- return parent ? AbortSignal.any([parent, timeout]) : timeout;
28
+ function cancelResponseBodyBestEffort(res: Response): void {
29
+ try {
30
+ const cancellation = res.body?.cancel();
31
+ if (cancellation) void cancellation.catch(() => {});
32
+ } catch {
33
+ // Cancellation is cleanup only; retries must not wait for or fail because of it.
34
+ }
29
35
  }
30
36
 
31
- async function normalizeFinalGoogleError(label: string, res: Response): Promise<Response> {
37
+ async function boundedBodyText(res: Response, signal?: AbortSignal): Promise<string> {
38
+ try {
39
+ const body = await readBoundedResponseBody(res, { signal });
40
+ return body.displaySafe ? body.text : "";
41
+ } catch (error) {
42
+ if (signal?.aborted) throw error;
43
+ return "";
44
+ }
45
+ }
46
+
47
+ async function normalizeFinalGoogleError(label: string, res: Response, signal?: AbortSignal): Promise<Response> {
32
48
  if (res.ok) return res;
33
- const payloadText = await res.clone().text().catch(() => "");
49
+ const payloadText = await boundedBodyText(res, signal);
34
50
  const headers = new Headers(res.headers);
35
51
  headers.delete("content-encoding");
36
52
  headers.delete("content-length");
@@ -51,17 +67,25 @@ export async function fetchGoogleWithRetry(label: string, request: AdapterReques
51
67
  for (let attempt = 0; attempt < GOOGLE_RETRY_ATTEMPTS; attempt++) {
52
68
  if (ctx.abortSignal?.aborted) throw abortError(ctx.abortSignal);
53
69
  try {
54
- const res = await fetch(request.url, {
55
- method: request.method, headers: request.headers, body: request.body,
56
- signal: signalWithAttemptTimeout(ctx.abortSignal, timeoutMs),
57
- });
70
+ const attemptTimeout = clearableDeadline(timeoutMs, ctx.abortSignal);
71
+ let res: Response;
72
+ try {
73
+ res = await fetch(request.url, {
74
+ method: request.method, headers: request.headers, body: request.body,
75
+ signal: attemptTimeout.signal,
76
+ });
77
+ } finally {
78
+ // Only the header timer is cleared. The composed signal still contains the parent, so a
79
+ // caller abort after headers continue to cancel consumption of the returned response body.
80
+ attemptTimeout.clear();
81
+ }
58
82
  if (!retryableGoogleStatus(res.status) || attempt === GOOGLE_RETRY_ATTEMPTS - 1) {
59
- return normalizeFinalGoogleError(label, res);
83
+ return ctx.returnRawErrors ? res : normalizeFinalGoogleError(label, res, ctx.abortSignal);
60
84
  }
61
85
  // A 429 may be a transient rate limit (retry) or hard quota exhaustion (do NOT retry —
62
86
  // it won't recover for hours and burns retries). Peek the body to tell them apart.
63
- if (res.status === 429) {
64
- const peek = await res.clone().text().catch(() => "");
87
+ if (res.status === 429 && !ctx.returnRawErrors) {
88
+ const peek = await boundedBodyText(res, ctx.abortSignal);
65
89
  if (isQuotaExhaustedBody(peek)) {
66
90
  const headers = new Headers(res.headers);
67
91
  headers.delete("content-encoding");
@@ -71,7 +95,7 @@ export async function fetchGoogleWithRetry(label: string, request: AdapterReques
71
95
  });
72
96
  }
73
97
  }
74
- await res.body?.cancel().catch(() => {});
98
+ cancelResponseBodyBestEffort(res);
75
99
  await sleepWithAbort(retryDelayMs(attempt, res.headers), ctx.abortSignal);
76
100
  } catch (err) {
77
101
  if (ctx.abortSignal?.aborted) throw err;
@@ -4,11 +4,16 @@ type Schema = Record<string, unknown>;
4
4
  // emits full JSON-Schema (draft 2020-12) tool definitions, so passing them through verbatim makes
5
5
  // CCA reject the whole request with "Request contains an invalid argument" / "Unknown name ...".
6
6
  // Every keyword below was confirmed live against the Antigravity backend to trigger a 400.
7
+ // `encrypted` is Codex's Responses-only marker (openai/codex 5f4d06ef, PR #26210) stamped on v2
8
+ // collaboration tool schemas (spawn_agent/send_message/followup_task `message`); CCA rejects it
9
+ // with 400 "Unknown name \"encrypted\"" (issue #85). It is an annotation for the ChatGPT backend
10
+ // only, so dropping it never changes tool behavior.
7
11
  const DROPPED_SCHEMA_KEYS = new Set([
8
12
  "$schema", "$id", "$comment", "$ref", "$defs", "definitions",
9
13
  "examples", "patternProperties", "if", "then", "else",
10
14
  "uniqueItems", "additionalItems", "unevaluatedProperties", "unevaluatedItems",
11
15
  "dependentRequired", "dependentSchemas", "propertyNames", "contains",
16
+ "encrypted",
12
17
  ]);
13
18
 
14
19
  const MAX_DEREF_DEPTH = 64;
@@ -15,6 +15,7 @@ import { isAllowedToolChoice, namespacedToolName, toolAllowedByChoice } from "..
15
15
  import { contentPartsToText, parseDataUrl } from "./image";
16
16
  import { getVertexAccessToken } from "../lib/gcp-adc";
17
17
  import { fetchAntigravityWithRetry, fetchVertexWithRetry } from "./google-http";
18
+ import { safeAntigravityHttpErrorMessage, safeVertexHttpErrorMessage } from "./google-errors";
18
19
  import { isVertexTruncationReason, vertexTruncationErrorMessage } from "./google-truncation";
19
20
  import { ANTIGRAVITY_REQUEST_UA, antigravitySessionId, isLikelyRealThoughtSignature, sanitizeAntigravityClaudeSignatures } from "./google-antigravity-wire";
20
21
  import { sanitizeGeminiToolParameters } from "./google-tool-schema";
@@ -206,6 +207,8 @@ export function createGoogleAdapter(provider: OcxProviderConfig): ProviderAdapte
206
207
  ? {
207
208
  fetchResponse: (request: AdapterRequest, ctx?: AdapterFetchContext): Promise<Response> =>
208
209
  (provider.googleMode === "cloud-code-assist" ? fetchAntigravityWithRetry : fetchVertexWithRetry)(request, ctx),
210
+ formatErrorBody: (status: number, _headers: Headers, payloadText: string): string =>
211
+ (provider.googleMode === "cloud-code-assist" ? safeAntigravityHttpErrorMessage : safeVertexHttpErrorMessage)(status, payloadText),
209
212
  }
210
213
  : {}),
211
214
 
@@ -1,5 +1,7 @@
1
1
  import type { AdapterFetchContext, AdapterRequest } from "./base";
2
2
  import { safeKiroHttpErrorMessage } from "./kiro-errors";
3
+ import { clearableDeadline } from "../lib/abort";
4
+ import { readBoundedResponseBody } from "../lib/bounded-body";
3
5
  import { abortError, isConnectionResetError, sleepWithAbort } from "../lib/upstream-retry";
4
6
 
5
7
  const KIRO_RETRY_ATTEMPTS = 3;
@@ -27,18 +29,28 @@ function retryDelayMs(attempt: number, headers?: Headers): number {
27
29
  return Math.floor(exp * (0.8 + Math.random() * 0.4));
28
30
  }
29
31
 
30
- function signalWithAttemptTimeout(parent: AbortSignal | undefined, timeoutMs: number): AbortSignal {
31
- const timeout = AbortSignal.timeout(timeoutMs);
32
- return parent ? AbortSignal.any([parent, timeout]) : timeout;
32
+ function cancelResponseBodyBestEffort(res: Response): void {
33
+ try {
34
+ const cancellation = res.body?.cancel();
35
+ if (cancellation) void cancellation.catch(() => {});
36
+ } catch {
37
+ // Cancellation is cleanup only; retries must not wait for or fail because of it.
38
+ }
33
39
  }
34
40
 
35
41
  function retryableKiroFetchError(err: unknown): boolean {
36
42
  return isConnectionResetError(err) || (err instanceof Error && err.name === "TimeoutError");
37
43
  }
38
44
 
39
- async function normalizeFinalKiroHttpError(res: Response): Promise<Response> {
45
+ async function normalizeFinalKiroHttpError(res: Response, signal?: AbortSignal): Promise<Response> {
40
46
  if (res.ok) return res;
41
- const payloadText = await res.clone().text().catch(() => "");
47
+ let payloadText = "";
48
+ try {
49
+ const body = await readBoundedResponseBody(res, { signal });
50
+ if (body.displaySafe) payloadText = body.text;
51
+ } catch (error) {
52
+ if (signal?.aborted) throw error;
53
+ }
42
54
  const headers = new Headers(res.headers);
43
55
  headers.delete("content-encoding");
44
56
  headers.delete("content-length");
@@ -55,14 +67,22 @@ export async function fetchKiroWithRetry(request: AdapterRequest, ctx: AdapterFe
55
67
  for (let attempt = 0; attempt < KIRO_RETRY_ATTEMPTS; attempt++) {
56
68
  if (ctx.abortSignal?.aborted) throw abortError(ctx.abortSignal);
57
69
  try {
58
- const res = await fetch(request.url, {
59
- method: request.method,
60
- headers: request.headers,
61
- body: request.body,
62
- signal: signalWithAttemptTimeout(ctx.abortSignal, timeoutMs),
63
- });
64
- if (!retryableKiroStatus(res.status) || attempt === KIRO_RETRY_ATTEMPTS - 1) return normalizeFinalKiroHttpError(res);
65
- await res.body?.cancel().catch(() => {});
70
+ const attemptTimeout = clearableDeadline(timeoutMs, ctx.abortSignal);
71
+ let res: Response;
72
+ try {
73
+ res = await fetch(request.url, {
74
+ method: request.method,
75
+ headers: request.headers,
76
+ body: request.body,
77
+ signal: attemptTimeout.signal,
78
+ });
79
+ } finally {
80
+ attemptTimeout.clear();
81
+ }
82
+ if (!retryableKiroStatus(res.status) || attempt === KIRO_RETRY_ATTEMPTS - 1) {
83
+ return ctx.returnRawErrors ? res : normalizeFinalKiroHttpError(res, ctx.abortSignal);
84
+ }
85
+ cancelResponseBodyBestEffort(res);
66
86
  await sleepWithAbort(retryDelayMs(attempt, res.headers), ctx.abortSignal);
67
87
  } catch (err) {
68
88
  if (ctx.abortSignal?.aborted) throw err;
@@ -41,6 +41,10 @@ const KIRO_REJECTED_SCHEMA_KEYS = new Set([
41
41
  "contains",
42
42
  "unevaluatedProperties",
43
43
  "unevaluatedItems",
44
+ // Codex's Responses-only `encrypted: true` marker (openai/codex 5f4d06ef) stamped on v2
45
+ // collaboration tool schemas. Kiro/Bedrock validators reject a narrower, undocumented schema
46
+ // subset (issue #85 class); the marker is a ChatGPT-backend annotation with no meaning here.
47
+ "encrypted",
44
48
  ]);
45
49
 
46
50
  // Keys whose values are maps of *property/definition name -> schema* (not schema keywords). Their
@@ -5,7 +5,7 @@ import { resolveKiroApiRegion, resolveKiroProfileArn } from "../oauth/kiro";
5
5
  import { KIRO_MODEL_CONTEXT_WINDOWS, normalizeKiroModelId } from "../providers/kiro-models";
6
6
  import { modelRecordValue } from "../reasoning-effort";
7
7
  import { parseKiroEvent } from "./kiro-events";
8
- import { safeKiroErrorMessage } from "./kiro-errors";
8
+ import { safeKiroErrorMessage, safeKiroHttpErrorMessage } from "./kiro-errors";
9
9
  import { appendFallbackText, toolCallFallbackText, toolResultFallbackText } from "./kiro-tool-fallback";
10
10
  import { KiroThinkingParser } from "./kiro-thinking";
11
11
  import { isCompleteKiroToolInput, kiroTruncationErrorMessage } from "./kiro-truncation";
@@ -552,6 +552,10 @@ export function createKiroAdapter(provider: OcxProviderConfig): ProviderAdapter
552
552
  return fetchKiroWithRetry(request, ctx);
553
553
  },
554
554
 
555
+ formatErrorBody(status: number, headers: Headers, payloadText: string): string {
556
+ return safeKiroHttpErrorMessage(status, headers, payloadText);
557
+ },
558
+
555
559
  // Non-streaming path used by the web-search sidecar loop (loop.ts runs each iteration
556
560
  // non-streamed so it can inspect tool calls). CW only ever event-streams, so we drain the
557
561
  // same decoder into an array. Without this, any Codex request that includes the web_search
@@ -303,6 +303,20 @@ type RawEntry = Record<string, unknown>;
303
303
  type RawCatalog = { models?: RawEntry[]; [k: string]: unknown };
304
304
  const JAWCODE_CATALOG_AUGMENT_PROVIDERS = new Set(["opencode-go"]);
305
305
 
306
+ /**
307
+ * Exact provider/model pairs whose discovery endpoint advertises them but whose inference backend
308
+ * rejects them. Apply this after live/static/metadata sources converge so no source can resurrect
309
+ * an uncallable picker row. Remove an entry once authenticated inference proves it usable again.
310
+ */
311
+ const ROUTED_MODEL_COMPATIBILITY_EXCLUSIONS = new Set([
312
+ // Issue #82: Zen Go /models advertises HY3, but Console Go rejects it as outside the lite list.
313
+ "opencode-go/hy3-preview",
314
+ ]);
315
+
316
+ function isRoutedModelCompatibilityExcluded(slug: string): boolean {
317
+ return ROUTED_MODEL_COMPATIBILITY_EXCLUSIONS.has(slug);
318
+ }
319
+
306
320
  /**
307
321
  * Image/video GENERATION model families. opencodex routes chat/coding models into Codex; media-
308
322
  * generation models (Grok image/video, DALL·E, Imagen, Sora, Veo, …) are useless to a coding agent
@@ -328,6 +342,7 @@ export function isMediaGenerationModelId(id: string): boolean {
328
342
  }
329
343
 
330
344
  function shouldExposeRoutedModel(model: CatalogModel): boolean {
345
+ if (isRoutedModelCompatibilityExcluded(`${model.provider}/${model.id}`)) return false;
331
346
  if (model.provider === "cursor" && model.id === "gemini-3-pro-image-preview") return true;
332
347
  return !isMediaGenerationModelId(model.id);
333
348
  }
@@ -1027,6 +1042,27 @@ function applyConfigHintsToCachedModels(name: string, prov: OcxProviderConfig, m
1027
1042
  return models.map(model => applyProviderConfigHints(name, prov, model, contextCap));
1028
1043
  }
1029
1044
 
1045
+ /**
1046
+ * TRUE when `liveId` is a dated release of the configured alias `configuredId`:
1047
+ * `<configuredId>-YYYYMMDD` (Anthropic's convention for superseded-but-callable models).
1048
+ */
1049
+ export function isDatedVariantId(liveId: string, configuredId: string): boolean {
1050
+ if (!liveId.startsWith(`${configuredId}-`)) return false;
1051
+ return /^\d{8}$/.test(liveId.slice(configuredId.length + 1));
1052
+ }
1053
+
1054
+ // Same-signature dedupe: Codex polls /v1/models frequently, and an unchanged drop list
1055
+ // repeated on every poll is pure noise. Warn once per provider until the id set changes.
1056
+ const lastDropWarnSignature = new Map<string, string>();
1057
+ function warnDroppedConfiguredIdsOnce(name: string, droppedConfiguredIds: string[]): void {
1058
+ const signature = [...droppedConfiguredIds].sort().join(",");
1059
+ if (lastDropWarnSignature.get(name) === signature) return;
1060
+ lastDropWarnSignature.set(name, signature);
1061
+ console.warn(
1062
+ `[opencodex] Provider model discovery for "${name}" omitted configured model ids; dropping them from the authoritative live catalog: ${droppedConfiguredIds.join(", ")}.`,
1063
+ );
1064
+ }
1065
+
1030
1066
  function isGlm52ModelId(id: string): boolean {
1031
1067
  const normalized = id.toLowerCase();
1032
1068
  return normalized === "glm-5.2" || normalized === "glm-5.2[1m]";
@@ -1133,15 +1169,28 @@ async function fetchProviderModels(name: string, prov: OcxProviderConfig, ttlMs:
1133
1169
  ...catalogHintsFromModelsApiItem(name, m),
1134
1170
  }, contextCap));
1135
1171
  const liveIds = new Set(live.map(m => m.id));
1136
- const droppedConfiguredIds = configured.filter(m => !liveIds.has(m.id)).map(m => m.id);
1172
+ // Dated-release aliases (Anthropic pattern): older models may appear in the live catalog
1173
+ // ONLY under their dated id (claude-haiku-4-5-20251001) while the config names the
1174
+ // API-valid alias (claude-haiku-4-5). Such aliases are real, callable models — keep them
1175
+ // in the authoritative catalog (alias id, hints from the dated live entry) instead of
1176
+ // dropping them and warning on every poll.
1177
+ const droppedConfiguredIds: string[] = [];
1178
+ for (const m of configured) {
1179
+ if (liveIds.has(m.id)) continue;
1180
+ const dated = live.find(l => isDatedVariantId(l.id, m.id));
1181
+ if (dated) {
1182
+ // Reapply config hints so alias-keyed overrides (modelContextWindows etc.) win.
1183
+ live.push(applyProviderConfigHints(name, prov, { ...dated, id: m.id }, contextCap));
1184
+ } else {
1185
+ droppedConfiguredIds.push(m.id);
1186
+ }
1187
+ }
1137
1188
  if (live.length === 0) {
1138
1189
  console.warn(
1139
1190
  `[opencodex] Provider model discovery for "${name}" returned an authoritative empty catalog; ${droppedConfiguredIds.length > 0 ? `dropping configured model ids: ${droppedConfiguredIds.join(", ")}` : "no models will be exposed"}.`,
1140
1191
  );
1141
1192
  } else if (droppedConfiguredIds.length > 0) {
1142
- console.warn(
1143
- `[opencodex] Provider model discovery for "${name}" omitted configured model ids; dropping them from the authoritative live catalog: ${droppedConfiguredIds.join(", ")}.`,
1144
- );
1193
+ warnDroppedConfiguredIdsOnce(name, droppedConfiguredIds);
1145
1194
  }
1146
1195
  setCached(name, live);
1147
1196
  return live;
@@ -1329,9 +1378,10 @@ export function mergeCatalogEntriesForSync(
1329
1378
  }
1330
1379
 
1331
1380
  let finalRoutedEntries = routedEntries;
1332
- if (routedEntries.length === 0 && catalogModels.some(m => typeof m.slug === "string" && (m.slug as string).includes("/"))) {
1381
+ const preservingExistingRouted = routedEntries.length === 0
1382
+ && catalogModels.some(m => typeof m.slug === "string" && (m.slug as string).includes("/"));
1383
+ if (preservingExistingRouted) {
1333
1384
  finalRoutedEntries = catalogModels.filter(m => typeof m.slug === "string" && (m.slug as string).includes("/"));
1334
- console.warn(`[opencodex] catalog sync: routed model fetch returned empty; preserving ${finalRoutedEntries.length} existing routed entr${finalRoutedEntries.length === 1 ? "y" : "ies"} on disk.`);
1335
1385
  } else {
1336
1386
  const freshSlugs = new Set(routedEntries.flatMap(entry => typeof entry.slug === "string" ? [entry.slug] : []));
1337
1387
  const preservedForeignRouted = catalogModels.filter(m => {
@@ -1341,6 +1391,14 @@ export function mergeCatalogEntriesForSync(
1341
1391
  });
1342
1392
  finalRoutedEntries = [...routedEntries, ...preservedForeignRouted];
1343
1393
  }
1394
+ // Reapply final catalog policy to rows preserved from disk. Those rows bypass
1395
+ // gatherRoutedModels, so filtering only the freshly gathered list can resurrect an excluded id.
1396
+ finalRoutedEntries = finalRoutedEntries.filter(entry =>
1397
+ typeof entry.slug !== "string" || !isRoutedModelCompatibilityExcluded(entry.slug)
1398
+ );
1399
+ if (preservingExistingRouted) {
1400
+ console.warn(`[opencodex] catalog sync: routed model fetch returned empty; preserving ${finalRoutedEntries.length} existing routed entr${finalRoutedEntries.length === 1 ? "y" : "ies"} on disk.`);
1401
+ }
1344
1402
 
1345
1403
  const mergedEntries = [...native, ...finalRoutedEntries].map(m => {
1346
1404
  const normalized = normalizeServiceTiers(m);
package/src/lib/abort.ts CHANGED
@@ -3,6 +3,46 @@ export interface LinkedAbortSignal {
3
3
  cleanup: () => void;
4
4
  }
5
5
 
6
+ export interface ClearableDeadline {
7
+ /** Parent-linked signal passed to fetch; remains parent-linked after clear(). */
8
+ signal: AbortSignal;
9
+ /** Stable reason object used when this deadline wins the abort race. */
10
+ timeoutReason: DOMException;
11
+ /** True only when this deadline, rather than the parent, fired first. */
12
+ didExpire: () => boolean;
13
+ /** Clear only the timer. Never aborts the deadline controller or detaches the parent. */
14
+ clear: () => void;
15
+ }
16
+
17
+ /**
18
+ * Response-header deadline whose timer can be cleared without severing body-lifetime cancellation.
19
+ *
20
+ * `signalWithTimeout().cleanup()` intentionally removes its parent listener and is therefore suited
21
+ * to operations that are completely finished at cleanup. A fetch response body is different: once
22
+ * headers arrive the deadline ends, but the original parent/client signal must remain attached to
23
+ * the body. `AbortSignal.any()` supplies that direct lifetime link while `clear()` owns only the
24
+ * timer.
25
+ */
26
+ export function clearableDeadline(timeoutMs: number, parent?: AbortSignal): ClearableDeadline {
27
+ const deadline = new AbortController();
28
+ const timeoutReason = new DOMException("Timeout elapsed", "TimeoutError");
29
+ let timer: ReturnType<typeof setTimeout> | undefined = setTimeout(() => {
30
+ timer = undefined;
31
+ if (!deadline.signal.aborted) deadline.abort(timeoutReason);
32
+ }, timeoutMs);
33
+ const signal = parent ? AbortSignal.any([parent, deadline.signal]) : deadline.signal;
34
+
35
+ return {
36
+ signal,
37
+ timeoutReason,
38
+ didExpire: () => signal.aborted && signal.reason === timeoutReason,
39
+ clear: () => {
40
+ if (timer !== undefined) clearTimeout(timer);
41
+ timer = undefined;
42
+ },
43
+ };
44
+ }
45
+
6
46
  export function signalWithTimeout(timeoutMs: number, parent?: AbortSignal): LinkedAbortSignal {
7
47
  const controller = new AbortController();
8
48
  const timeout = setTimeout(() => {