@bitkyc08/opencodex 2.7.4 → 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 +6 -3
- package/README.md +6 -4
- package/README.zh-CN.md +4 -3
- package/gui/dist/assets/{index-66dPs6l_.js → index-mEjIne-M.js} +1 -1
- package/gui/dist/index.html +1 -1
- package/package.json +1 -1
- package/src/adapters/anthropic.ts +30 -2
- package/src/adapters/base.ts +10 -0
- package/src/adapters/google-http.ts +37 -13
- package/src/adapters/google-tool-schema.ts +5 -0
- package/src/adapters/google.ts +3 -0
- package/src/adapters/kiro-retry.ts +33 -13
- package/src/adapters/kiro-tools.ts +4 -0
- package/src/adapters/kiro.ts +5 -1
- package/src/codex/catalog.ts +64 -6
- package/src/lib/abort.ts +40 -0
- package/src/lib/bounded-body.ts +202 -0
- package/src/providers/registry.ts +4 -1
- package/src/server/auth-cors.ts +16 -13
- package/src/server/images.ts +218 -0
- package/src/server/index.ts +29 -2
- package/src/server/responses.ts +2 -1
- package/src/types.ts +12 -0
- package/src/web-search/index.ts +44 -10
- package/src/web-search/loop.ts +165 -112
- package/src/web-search/progress-stream.ts +329 -0
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/*`)와 데이터 플레인
|
|
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`,
|
|
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`,
|
|
396
|
-
|
|
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/*`;只有在
|
|
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.
|
|
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,{})})}));
|
package/gui/dist/index.html
CHANGED
|
@@ -16,7 +16,7 @@
|
|
|
16
16
|
} catch (e) {}
|
|
17
17
|
})();
|
|
18
18
|
</script>
|
|
19
|
-
<script type="module" crossorigin src="/assets/index-
|
|
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
|
@@ -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
|
|
492
|
-
|
|
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
|
package/src/adapters/base.ts
CHANGED
|
@@ -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
|
|
27
|
-
|
|
28
|
-
|
|
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
|
|
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
|
|
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
|
|
55
|
-
|
|
56
|
-
|
|
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.
|
|
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
|
-
|
|
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;
|
package/src/adapters/google.ts
CHANGED
|
@@ -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
|
|
31
|
-
|
|
32
|
-
|
|
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
|
-
|
|
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
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
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
|
package/src/adapters/kiro.ts
CHANGED
|
@@ -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
|
package/src/codex/catalog.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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(() => {
|