Compare commits

...

378 Commits

Author SHA1 Message Date
diegosouzapw
e5f3866ee0 Merge remote-tracking branch 'origin/release/v3.8.51' into fix/data-dir-private-perms
# Conflicts:
#	stryker.conf.json
2026-09-23 22:45:28 -03:00
Diego Rodrigues de Sa e Souza
db70929330 fix(test): boot ecosystem through trusted-peer server (#14403)
Merge-batch 2026-09-23 (PRs do mantenedor, Trilha B, retrabalhada).

Um worker corrigiu o defeito apontado na revisão direto no branch da PR; o detalhe está na seção "Rework (merge-batch 2026-09-23)" do corpo, com a prova de red→green. Revalidado numa árvore combinada das PRs da 2ª leva da Trilha B (#14415 #14472 #14468 #14493 #14403) sobre o tip atual de `release/v3.8.51`:
- 104/104 testes node focados + 46/46 vitest (as 6 suítes de UI que saíram da quarentena); `check:provider-consistency` OK (359)
- `typecheck:core`: só o herdado `cliproxyAccountHealth.ts:157`; `check:open-sse-typecheck`: só o herdado `auggie.ts` (base-red #14547, corrigido pela #14511)
- eslint nos arquivos alterados limpo; file-size OK
2026-09-23 22:17:46 -03:00
Diego Rodrigues de Sa e Souza
2697760aa5 test(ui): repair and restore all quarantined component suites (#14493)
Merge-batch 2026-09-23 (PRs do mantenedor, Trilha B, retrabalhada).

Um worker corrigiu o defeito apontado na revisão direto no branch da PR; o detalhe está na seção "Rework (merge-batch 2026-09-23)" do corpo, com a prova de red→green. Revalidado numa árvore combinada das PRs da 2ª leva da Trilha B (#14415 #14472 #14468 #14493 #14403) sobre o tip atual de `release/v3.8.51`:
- 104/104 testes node focados + 46/46 vitest (as 6 suítes de UI que saíram da quarentena); `check:provider-consistency` OK (359)
- `typecheck:core`: só o herdado `cliproxyAccountHealth.ts:157`; `check:open-sse-typecheck`: só o herdado `auggie.ts` (base-red #14547, corrigido pela #14511)
- eslint nos arquivos alterados limpo; file-size OK
2026-09-23 22:17:38 -03:00
Diego Rodrigues de Sa e Souza
4af4937e1e fix(providers): retire the suno music provider (#14224) (#14468)
Merge-batch 2026-09-23 (PRs do mantenedor, Trilha B, retrabalhada).

Um worker corrigiu o defeito apontado na revisão direto no branch da PR; o detalhe está na seção "Rework (merge-batch 2026-09-23)" do corpo, com a prova de red→green. Revalidado numa árvore combinada das PRs da 2ª leva da Trilha B (#14415 #14472 #14468 #14493 #14403) sobre o tip atual de `release/v3.8.51`:
- 104/104 testes node focados + 46/46 vitest (as 6 suítes de UI que saíram da quarentena); `check:provider-consistency` OK (359)
- `typecheck:core`: só o herdado `cliproxyAccountHealth.ts:157`; `check:open-sse-typecheck`: só o herdado `auggie.ts` (base-red #14547, corrigido pela #14511)
- eslint nos arquivos alterados limpo; file-size OK
2026-09-23 22:17:31 -03:00
Diego Rodrigues de Sa e Souza
43b6eeb129 fix(auth): unblock fresh Docker installs from the onboarding loop with a one-shot bootstrap token (#14296) (#14472)
Merge-batch 2026-09-23 (PRs do mantenedor, Trilha B, retrabalhada).

Um worker corrigiu o defeito apontado na revisão direto no branch da PR; o detalhe está na seção "Rework (merge-batch 2026-09-23)" do corpo, com a prova de red→green. Revalidado numa árvore combinada das PRs da 2ª leva da Trilha B (#14415 #14472 #14468 #14493 #14403) sobre o tip atual de `release/v3.8.51`:
- 104/104 testes node focados + 46/46 vitest (as 6 suítes de UI que saíram da quarentena); `check:provider-consistency` OK (359)
- `typecheck:core`: só o herdado `cliproxyAccountHealth.ts:157`; `check:open-sse-typecheck`: só o herdado `auggie.ts` (base-red #14547, corrigido pela #14511)
- eslint nos arquivos alterados limpo; file-size OK
2026-09-23 22:17:22 -03:00
Diego Rodrigues de Sa e Souza
e60b01e818 fix(auth): restore keyless local-first posture on GET /v1/models (#13354) (#14415)
Merge-batch 2026-09-23 (PRs do mantenedor, Trilha B, retrabalhada).

Um worker corrigiu o defeito apontado na revisão direto no branch da PR; o detalhe está na seção "Rework (merge-batch 2026-09-23)" do corpo, com a prova de red→green. Revalidado numa árvore combinada das PRs da 2ª leva da Trilha B (#14415 #14472 #14468 #14493 #14403) sobre o tip atual de `release/v3.8.51`:
- 104/104 testes node focados + 46/46 vitest (as 6 suítes de UI que saíram da quarentena); `check:provider-consistency` OK (359)
- `typecheck:core`: só o herdado `cliproxyAccountHealth.ts:157`; `check:open-sse-typecheck`: só o herdado `auggie.ts` (base-red #14547, corrigido pela #14511)
- eslint nos arquivos alterados limpo; file-size OK
2026-09-23 22:17:12 -03:00
Diego Rodrigues de Sa e Souza
3a2a1869e0 docs(i18n): translate the pool member egress keys #14364 left as markers (#14539)
Merge-batch 2026-09-23 (PRs do mantenedor, Trilha B, retrabalhada).

Um worker corrigiu o defeito apontado na revisão direto no branch da PR; o detalhe está na seção "Rework (merge-batch 2026-09-23)" do corpo, com a prova de red→green. Revalidado numa árvore combinada das 8 PRs da Trilha B sobre o tip atual de `release/v3.8.51` (depois das 47 da Trilha A):
- 111/111 testes focados (inclui migration-numbering-collision e api-auth); `check-migration-numbering` OK (183 migrations, sem duplicata)
- `typecheck:core`: só o herdado `cliproxyAccountHealth.ts:157`; `check:open-sse-typecheck`: só o herdado `auggie.ts` (base-red #14547, corrigido pela #14511)
- eslint nos arquivos alterados limpo; file-size OK
2026-09-23 22:11:00 -03:00
Diego Rodrigues de Sa e Souza
5e6df0e049 test(deepseek): reconcile Chat default and explicit Responses fixtures (#14516)
Merge-batch 2026-09-23 (PRs do mantenedor, Trilha B, retrabalhada).

Um worker corrigiu o defeito apontado na revisão direto no branch da PR; o detalhe está na seção "Rework (merge-batch 2026-09-23)" do corpo, com a prova de red→green. Revalidado numa árvore combinada das 8 PRs da Trilha B sobre o tip atual de `release/v3.8.51` (depois das 47 da Trilha A):
- 111/111 testes focados (inclui migration-numbering-collision e api-auth); `check-migration-numbering` OK (183 migrations, sem duplicata)
- `typecheck:core`: só o herdado `cliproxyAccountHealth.ts:157`; `check:open-sse-typecheck`: só o herdado `auggie.ts` (base-red #14547, corrigido pela #14511)
- eslint nos arquivos alterados limpo; file-size OK
2026-09-23 22:10:52 -03:00
Diego Rodrigues de Sa e Souza
708168f56c fix(db): degrade getSettings() to defaults instead of crashing Home on corrupted SQLite (#14060) (#14421)
Merge-batch 2026-09-23 (PRs do mantenedor, Trilha B, retrabalhada).

Um worker corrigiu o defeito apontado na revisão direto no branch da PR; o detalhe está na seção "Rework (merge-batch 2026-09-23)" do corpo, com a prova de red→green. Revalidado numa árvore combinada das 8 PRs da Trilha B sobre o tip atual de `release/v3.8.51` (depois das 47 da Trilha A):
- 111/111 testes focados (inclui migration-numbering-collision e api-auth); `check-migration-numbering` OK (183 migrations, sem duplicata)
- `typecheck:core`: só o herdado `cliproxyAccountHealth.ts:157`; `check:open-sse-typecheck`: só o herdado `auggie.ts` (base-red #14547, corrigido pela #14511)
- eslint nos arquivos alterados limpo; file-size OK
2026-09-23 22:10:47 -03:00
Diego Rodrigues de Sa e Souza
fa3555f597 fix(db): stagger the cleanup/model-sync 6h schedulers and index conversation_turn_nodes.last_seen_at (#13973) (#14567)
Merge-batch 2026-09-23 (PRs do mantenedor, Trilha B, retrabalhada).

Um worker corrigiu o defeito apontado na revisão direto no branch da PR; o detalhe está na seção "Rework (merge-batch 2026-09-23)" do corpo, com a prova de red→green. Revalidado numa árvore combinada das 8 PRs da Trilha B sobre o tip atual de `release/v3.8.51` (depois das 47 da Trilha A):
- 111/111 testes focados (inclui migration-numbering-collision e api-auth); `check-migration-numbering` OK (183 migrations, sem duplicata)
- `typecheck:core`: só o herdado `cliproxyAccountHealth.ts:157`; `check:open-sse-typecheck`: só o herdado `auggie.ts` (base-red #14547, corrigido pela #14511)
- eslint nos arquivos alterados limpo; file-size OK
2026-09-23 22:10:43 -03:00
Diego Rodrigues de Sa e Souza
705a02ec4a ci(merge-train): run the ci.yml:lint family on the combined tree (#14489)
Merge-batch 2026-09-23 (PRs do mantenedor, Trilha B, retrabalhada).

Um worker corrigiu o defeito apontado na revisão direto no branch da PR; o detalhe está na seção "Rework (merge-batch 2026-09-23)" do corpo, com a prova de red→green. Revalidado numa árvore combinada das 8 PRs da Trilha B sobre o tip atual de `release/v3.8.51` (depois das 47 da Trilha A):
- 111/111 testes focados (inclui migration-numbering-collision e api-auth); `check-migration-numbering` OK (183 migrations, sem duplicata)
- `typecheck:core`: só o herdado `cliproxyAccountHealth.ts:157`; `check:open-sse-typecheck`: só o herdado `auggie.ts` (base-red #14547, corrigido pela #14511)
- eslint nos arquivos alterados limpo; file-size OK
2026-09-23 22:10:37 -03:00
Diego Rodrigues de Sa e Souza
25c52b7660 fix(providers): devin-cli-agentic no longer forwards bare <summary> envelopes (#13691) (#14396)
Merge-batch 2026-09-23 (PRs do mantenedor, Trilha B, retrabalhada).

Um worker corrigiu o defeito apontado na revisão direto no branch da PR; o detalhe está na seção "Rework (merge-batch 2026-09-23)" do corpo, com a prova de red→green. Revalidado numa árvore combinada das 8 PRs da Trilha B sobre o tip atual de `release/v3.8.51` (depois das 47 da Trilha A):
- 111/111 testes focados (inclui migration-numbering-collision e api-auth); `check-migration-numbering` OK (183 migrations, sem duplicata)
- `typecheck:core`: só o herdado `cliproxyAccountHealth.ts:157`; `check:open-sse-typecheck`: só o herdado `auggie.ts` (base-red #14547, corrigido pela #14511)
- eslint nos arquivos alterados limpo; file-size OK
2026-09-23 22:10:32 -03:00
Diego Rodrigues de Sa e Souza
4f6306f90f fix(security): stop trusting a world-writable opencode pool-strain marker (#14487) (#14568)
Merge-batch 2026-09-23 (PRs do mantenedor, Trilha B, retrabalhada).

Um worker corrigiu o defeito apontado na revisão direto no branch da PR; o detalhe está na seção "Rework (merge-batch 2026-09-23)" do corpo, com a prova de red→green. Revalidado numa árvore combinada das 8 PRs da Trilha B sobre o tip atual de `release/v3.8.51` (depois das 47 da Trilha A):
- 111/111 testes focados (inclui migration-numbering-collision e api-auth); `check-migration-numbering` OK (183 migrations, sem duplicata)
- `typecheck:core`: só o herdado `cliproxyAccountHealth.ts:157`; `check:open-sse-typecheck`: só o herdado `auggie.ts` (base-red #14547, corrigido pela #14511)
- eslint nos arquivos alterados limpo; file-size OK
2026-09-23 22:10:28 -03:00
Diego Rodrigues de Sa e Souza
df5423bb86 fix(guardrails): flag authority/educational-framing jailbreak attempts (#14483) (#14563)
Merge-batch 2026-09-23 (PRs do mantenedor, Trilha B, retrabalhada).

Um worker corrigiu o defeito apontado na revisão direto no branch da PR; o detalhe está na seção "Rework (merge-batch 2026-09-23)" do corpo, com a prova de red→green. Revalidado numa árvore combinada das 8 PRs da Trilha B sobre o tip atual de `release/v3.8.51` (depois das 47 da Trilha A):
- 111/111 testes focados (inclui migration-numbering-collision e api-auth); `check-migration-numbering` OK (183 migrations, sem duplicata)
- `typecheck:core`: só o herdado `cliproxyAccountHealth.ts:157`; `check:open-sse-typecheck`: só o herdado `auggie.ts` (base-red #14547, corrigido pela #14511)
- eslint nos arquivos alterados limpo; file-size OK
2026-09-23 22:10:23 -03:00
Diego Rodrigues de Sa e Souza
01d2af9174 feat(cli): add copy-only dashboard companion (#14494)
Merge-batch 2026-09-23 (PRs do mantenedor, 99 triadas por ; esta entrou pela Trilha A).

Validação única na árvore combinada das 49 PRs da trilha sobre `release/v3.8.51` @18bbb101:
- `typecheck:core`: só o erro herdado `cliproxyAccountHealth.ts:157` (TS2322, não desta leva)
- `check:open-sse-typecheck`: 0 erros (o auggie.ts da base é corrigido pela #14511 do mesmo lote)
- eslint nos arquivos alterados: limpo
- 89 arquivos de teste node focados (710 testes) + 11 suítes vitest de UI: todo vermelho restante reproduz também no tip puro (base-red #14547/#14496) e a árvore combinada fica com um subconjunto estrito dos vermelhos do tip, ou seja, nenhum introduzido pelo lote
- teto de file-size acertado na PR de acompanhamento da leva
2026-09-23 21:51:19 -03:00
Diego Rodrigues de Sa e Souza
078adcafa9 fix(opencode): preserve native conversation identity across translation (#14390)
Merge-batch 2026-09-23 (PRs do mantenedor, 99 triadas por ; esta entrou pela Trilha A).

Validação única na árvore combinada das 49 PRs da trilha sobre `release/v3.8.51` @18bbb101:
- `typecheck:core`: só o erro herdado `cliproxyAccountHealth.ts:157` (TS2322, não desta leva)
- `check:open-sse-typecheck`: 0 erros (o auggie.ts da base é corrigido pela #14511 do mesmo lote)
- eslint nos arquivos alterados: limpo
- 89 arquivos de teste node focados (710 testes) + 11 suítes vitest de UI: todo vermelho restante reproduz também no tip puro (base-red #14547/#14496) e a árvore combinada fica com um subconjunto estrito dos vermelhos do tip, ou seja, nenhum introduzido pelo lote
- teto de file-size acertado na PR de acompanhamento da leva
2026-09-23 21:51:04 -03:00
Diego Rodrigues de Sa e Souza
c95e6faec3 feat(cli): manage manual models and preserve complete catalogs (#14392)
Merge-batch 2026-09-23 (PRs do mantenedor, 99 triadas por ; esta entrou pela Trilha A).

Validação única na árvore combinada das 49 PRs da trilha sobre `release/v3.8.51` @18bbb101:
- `typecheck:core`: só o erro herdado `cliproxyAccountHealth.ts:157` (TS2322, não desta leva)
- `check:open-sse-typecheck`: 0 erros (o auggie.ts da base é corrigido pela #14511 do mesmo lote)
- eslint nos arquivos alterados: limpo
- 89 arquivos de teste node focados (710 testes) + 11 suítes vitest de UI: todo vermelho restante reproduz também no tip puro (base-red #14547/#14496) e a árvore combinada fica com um subconjunto estrito dos vermelhos do tip, ou seja, nenhum introduzido pelo lote
- teto de file-size acertado na PR de acompanhamento da leva
2026-09-23 21:50:51 -03:00
Diego Rodrigues de Sa e Souza
bf2b39c626 test(cli): isolate real-binary smoke contracts (#14382)
Merge-batch 2026-09-23 (PRs do mantenedor, 99 triadas por ; esta entrou pela Trilha A).

Validação única na árvore combinada das 49 PRs da trilha sobre `release/v3.8.51` @18bbb101:
- `typecheck:core`: só o erro herdado `cliproxyAccountHealth.ts:157` (TS2322, não desta leva)
- `check:open-sse-typecheck`: 0 erros (o auggie.ts da base é corrigido pela #14511 do mesmo lote)
- eslint nos arquivos alterados: limpo
- 89 arquivos de teste node focados (710 testes) + 11 suítes vitest de UI: todo vermelho restante reproduz também no tip puro (base-red #14547/#14496) e a árvore combinada fica com um subconjunto estrito dos vermelhos do tip, ou seja, nenhum introduzido pelo lote
- teto de file-size acertado na PR de acompanhamento da leva
2026-09-23 21:50:38 -03:00
Diego Rodrigues de Sa e Souza
4df9ceae03 fix(cli): secure configuration previews and atomic writes (#14381)
Merge-batch 2026-09-23 (PRs do mantenedor, 99 triadas por ; esta entrou pela Trilha A).

Validação única na árvore combinada das 49 PRs da trilha sobre `release/v3.8.51` @18bbb101:
- `typecheck:core`: só o erro herdado `cliproxyAccountHealth.ts:157` (TS2322, não desta leva)
- `check:open-sse-typecheck`: 0 erros (o auggie.ts da base é corrigido pela #14511 do mesmo lote)
- eslint nos arquivos alterados: limpo
- 89 arquivos de teste node focados (710 testes) + 11 suítes vitest de UI: todo vermelho restante reproduz também no tip puro (base-red #14547/#14496) e a árvore combinada fica com um subconjunto estrito dos vermelhos do tip, ou seja, nenhum introduzido pelo lote
- teto de file-size acertado na PR de acompanhamento da leva
2026-09-23 21:50:24 -03:00
Diego Rodrigues de Sa e Souza
d6f53e26e9 fix(cli): secure context exports and verify provider mutations (#14379)
Merge-batch 2026-09-23 (PRs do mantenedor, 99 triadas por ; esta entrou pela Trilha A).

Validação única na árvore combinada das 49 PRs da trilha sobre `release/v3.8.51` @18bbb101:
- `typecheck:core`: só o erro herdado `cliproxyAccountHealth.ts:157` (TS2322, não desta leva)
- `check:open-sse-typecheck`: 0 erros (o auggie.ts da base é corrigido pela #14511 do mesmo lote)
- eslint nos arquivos alterados: limpo
- 89 arquivos de teste node focados (710 testes) + 11 suítes vitest de UI: todo vermelho restante reproduz também no tip puro (base-red #14547/#14496) e a árvore combinada fica com um subconjunto estrito dos vermelhos do tip, ou seja, nenhum introduzido pelo lote
- teto de file-size acertado na PR de acompanhamento da leva
2026-09-23 21:50:06 -03:00
Diego Rodrigues de Sa e Souza
02fe7cfe43 security(video): close response-cache transcript retention gaps (#14373)
Merge-batch 2026-09-23 (PRs do mantenedor, 99 triadas por ; esta entrou pela Trilha A).

Validação única na árvore combinada das 49 PRs da trilha sobre `release/v3.8.51` @18bbb101:
- `typecheck:core`: só o erro herdado `cliproxyAccountHealth.ts:157` (TS2322, não desta leva)
- `check:open-sse-typecheck`: 0 erros (o auggie.ts da base é corrigido pela #14511 do mesmo lote)
- eslint nos arquivos alterados: limpo
- 89 arquivos de teste node focados (710 testes) + 11 suítes vitest de UI: todo vermelho restante reproduz também no tip puro (base-red #14547/#14496) e a árvore combinada fica com um subconjunto estrito dos vermelhos do tip, ou seja, nenhum introduzido pelo lote
- teto de file-size acertado na PR de acompanhamento da leva
2026-09-23 21:49:52 -03:00
Diego Rodrigues de Sa e Souza
e36f35e469 fix(video): omit transcript echoes from active request details (#14517)
Merge-batch 2026-09-23 (PRs do mantenedor, 99 triadas por ; esta entrou pela Trilha A).

Validação única na árvore combinada das 49 PRs da trilha sobre `release/v3.8.51` @18bbb101:
- `typecheck:core`: só o erro herdado `cliproxyAccountHealth.ts:157` (TS2322, não desta leva)
- `check:open-sse-typecheck`: 0 erros (o auggie.ts da base é corrigido pela #14511 do mesmo lote)
- eslint nos arquivos alterados: limpo
- 89 arquivos de teste node focados (710 testes) + 11 suítes vitest de UI: todo vermelho restante reproduz também no tip puro (base-red #14547/#14496) e a árvore combinada fica com um subconjunto estrito dos vermelhos do tip, ou seja, nenhum introduzido pelo lote
- teto de file-size acertado na PR de acompanhamento da leva
2026-09-23 21:49:37 -03:00
Diego Rodrigues de Sa e Souza
2afbd8723a security(video): omit echoed cues from retained provider failures (#14527)
Merge-batch 2026-09-23 (PRs do mantenedor, 99 triadas por ; esta entrou pela Trilha A).

Validação única na árvore combinada das 49 PRs da trilha sobre `release/v3.8.51` @18bbb101:
- `typecheck:core`: só o erro herdado `cliproxyAccountHealth.ts:157` (TS2322, não desta leva)
- `check:open-sse-typecheck`: 0 erros (o auggie.ts da base é corrigido pela #14511 do mesmo lote)
- eslint nos arquivos alterados: limpo
- 89 arquivos de teste node focados (710 testes) + 11 suítes vitest de UI: todo vermelho restante reproduz também no tip puro (base-red #14547/#14496) e a árvore combinada fica com um subconjunto estrito dos vermelhos do tip, ou seja, nenhum introduzido pelo lote
- teto de file-size acertado na PR de acompanhamento da leva
2026-09-23 21:49:20 -03:00
Diego Rodrigues de Sa e Souza
174eee14f4 security(video): keep capture-hook exceptions out of request logs (#14522)
Merge-batch 2026-09-23 (PRs do mantenedor, 99 triadas por ; esta entrou pela Trilha A).

Validação única na árvore combinada das 49 PRs da trilha sobre `release/v3.8.51` @18bbb101:
- `typecheck:core`: só o erro herdado `cliproxyAccountHealth.ts:157` (TS2322, não desta leva)
- `check:open-sse-typecheck`: 0 erros (o auggie.ts da base é corrigido pela #14511 do mesmo lote)
- eslint nos arquivos alterados: limpo
- 89 arquivos de teste node focados (710 testes) + 11 suítes vitest de UI: todo vermelho restante reproduz também no tip puro (base-red #14547/#14496) e a árvore combinada fica com um subconjunto estrito dos vermelhos do tip, ou seja, nenhum introduzido pelo lote
- teto de file-size acertado na PR de acompanhamento da leva
2026-09-23 21:49:08 -03:00
Diego Rodrigues de Sa e Souza
db97f0c5c5 fix(sse): give synthesized Responses API keepalive and failure frames a real sequence_number (#14330) (#14572)
Merge-batch 2026-09-23 (PRs do mantenedor, 99 triadas por ; esta entrou pela Trilha A).

Validação única na árvore combinada das 49 PRs da trilha sobre `release/v3.8.51` @18bbb101:
- `typecheck:core`: só o erro herdado `cliproxyAccountHealth.ts:157` (TS2322, não desta leva)
- `check:open-sse-typecheck`: 0 erros (o auggie.ts da base é corrigido pela #14511 do mesmo lote)
- eslint nos arquivos alterados: limpo
- 89 arquivos de teste node focados (710 testes) + 11 suítes vitest de UI: todo vermelho restante reproduz também no tip puro (base-red #14547/#14496) e a árvore combinada fica com um subconjunto estrito dos vermelhos do tip, ou seja, nenhum introduzido pelo lote
- teto de file-size acertado na PR de acompanhamento da leva
2026-09-23 21:48:55 -03:00
Diego Rodrigues de Sa e Souza
02e7ecae2f fix(providers): read KIE flux/kontext resultImageUrl(s) and reject empty-image fake success (#14335) (#14570)
Merge-batch 2026-09-23 (PRs do mantenedor, 99 triadas por ; esta entrou pela Trilha A).

Validação única na árvore combinada das 49 PRs da trilha sobre `release/v3.8.51` @18bbb101:
- `typecheck:core`: só o erro herdado `cliproxyAccountHealth.ts:157` (TS2322, não desta leva)
- `check:open-sse-typecheck`: 0 erros (o auggie.ts da base é corrigido pela #14511 do mesmo lote)
- eslint nos arquivos alterados: limpo
- 89 arquivos de teste node focados (710 testes) + 11 suítes vitest de UI: todo vermelho restante reproduz também no tip puro (base-red #14547/#14496) e a árvore combinada fica com um subconjunto estrito dos vermelhos do tip, ou seja, nenhum introduzido pelo lote
- teto de file-size acertado na PR de acompanhamento da leva
2026-09-23 21:48:42 -03:00
Diego Rodrigues de Sa e Souza
20ad5a6ccf fix(tests): make executor-web-cookie-sweep hermetic for tencent-aistudio-web (#14480) (#14569)
Merge-batch 2026-09-23 (PRs do mantenedor, 99 triadas por ; esta entrou pela Trilha A).

Validação única na árvore combinada das 49 PRs da trilha sobre `release/v3.8.51` @18bbb101:
- `typecheck:core`: só o erro herdado `cliproxyAccountHealth.ts:157` (TS2322, não desta leva)
- `check:open-sse-typecheck`: 0 erros (o auggie.ts da base é corrigido pela #14511 do mesmo lote)
- eslint nos arquivos alterados: limpo
- 89 arquivos de teste node focados (710 testes) + 11 suítes vitest de UI: todo vermelho restante reproduz também no tip puro (base-red #14547/#14496) e a árvore combinada fica com um subconjunto estrito dos vermelhos do tip, ou seja, nenhum introduzido pelo lote
- teto de file-size acertado na PR de acompanhamento da leva
2026-09-23 21:48:31 -03:00
Diego Rodrigues de Sa e Souza
353a5a20b0 fix(resilience): admission lease cleanup is incomplete while the handler is still pending (#14456) (#14566)
Merge-batch 2026-09-23 (PRs do mantenedor, 99 triadas por ; esta entrou pela Trilha A).

Validação única na árvore combinada das 49 PRs da trilha sobre `release/v3.8.51` @18bbb101:
- `typecheck:core`: só o erro herdado `cliproxyAccountHealth.ts:157` (TS2322, não desta leva)
- `check:open-sse-typecheck`: 0 erros (o auggie.ts da base é corrigido pela #14511 do mesmo lote)
- eslint nos arquivos alterados: limpo
- 89 arquivos de teste node focados (710 testes) + 11 suítes vitest de UI: todo vermelho restante reproduz também no tip puro (base-red #14547/#14496) e a árvore combinada fica com um subconjunto estrito dos vermelhos do tip, ou seja, nenhum introduzido pelo lote
- teto de file-size acertado na PR de acompanhamento da leva
2026-09-23 21:48:20 -03:00
Diego Rodrigues de Sa e Souza
65c584a5eb fix(cache): fold output contract into semantic-cache Layer-1 hash (#14484) (#14565)
Merge-batch 2026-09-23 (PRs do mantenedor, 99 triadas por ; esta entrou pela Trilha A).

Validação única na árvore combinada das 49 PRs da trilha sobre `release/v3.8.51` @18bbb101:
- `typecheck:core`: só o erro herdado `cliproxyAccountHealth.ts:157` (TS2322, não desta leva)
- `check:open-sse-typecheck`: 0 erros (o auggie.ts da base é corrigido pela #14511 do mesmo lote)
- eslint nos arquivos alterados: limpo
- 89 arquivos de teste node focados (710 testes) + 11 suítes vitest de UI: todo vermelho restante reproduz também no tip puro (base-red #14547/#14496) e a árvore combinada fica com um subconjunto estrito dos vermelhos do tip, ou seja, nenhum introduzido pelo lote
- teto de file-size acertado na PR de acompanhamento da leva
2026-09-23 21:48:10 -03:00
Diego Rodrigues de Sa e Souza
27f93a0767 fix(security): document accepted-risk extract-zip advisories + regression guard (#14482) (#14564)
Merge-batch 2026-09-23 (PRs do mantenedor, 99 triadas por ; esta entrou pela Trilha A).

Validação única na árvore combinada das 49 PRs da trilha sobre `release/v3.8.51` @18bbb101:
- `typecheck:core`: só o erro herdado `cliproxyAccountHealth.ts:157` (TS2322, não desta leva)
- `check:open-sse-typecheck`: 0 erros (o auggie.ts da base é corrigido pela #14511 do mesmo lote)
- eslint nos arquivos alterados: limpo
- 89 arquivos de teste node focados (710 testes) + 11 suítes vitest de UI: todo vermelho restante reproduz também no tip puro (base-red #14547/#14496) e a árvore combinada fica com um subconjunto estrito dos vermelhos do tip, ou seja, nenhum introduzido pelo lote
- teto de file-size acertado na PR de acompanhamento da leva
2026-09-23 21:47:59 -03:00
Diego Rodrigues de Sa e Souza
12cc75a44c fix(security): refuse the well-known default password on /api/cli/connect from off-loopback (#14486) (#14491)
Merge-batch 2026-09-23 (PRs do mantenedor, 99 triadas por ; esta entrou pela Trilha A).

Validação única na árvore combinada das 49 PRs da trilha sobre `release/v3.8.51` @18bbb101:
- `typecheck:core`: só o erro herdado `cliproxyAccountHealth.ts:157` (TS2322, não desta leva)
- `check:open-sse-typecheck`: 0 erros (o auggie.ts da base é corrigido pela #14511 do mesmo lote)
- eslint nos arquivos alterados: limpo
- 89 arquivos de teste node focados (710 testes) + 11 suítes vitest de UI: todo vermelho restante reproduz também no tip puro (base-red #14547/#14496) e a árvore combinada fica com um subconjunto estrito dos vermelhos do tip, ou seja, nenhum introduzido pelo lote
- teto de file-size acertado na PR de acompanhamento da leva
2026-09-23 21:47:49 -03:00
Diego Rodrigues de Sa e Souza
3f63efea05 fix(auth): back manage-scope authz-bypass policy with a globalThis store (#13995) (#14473)
Merge-batch 2026-09-23 (PRs do mantenedor, 99 triadas por ; esta entrou pela Trilha A).

Validação única na árvore combinada das 49 PRs da trilha sobre `release/v3.8.51` @18bbb101:
- `typecheck:core`: só o erro herdado `cliproxyAccountHealth.ts:157` (TS2322, não desta leva)
- `check:open-sse-typecheck`: 0 erros (o auggie.ts da base é corrigido pela #14511 do mesmo lote)
- eslint nos arquivos alterados: limpo
- 89 arquivos de teste node focados (710 testes) + 11 suítes vitest de UI: todo vermelho restante reproduz também no tip puro (base-red #14547/#14496) e a árvore combinada fica com um subconjunto estrito dos vermelhos do tip, ou seja, nenhum introduzido pelo lote
- teto de file-size acertado na PR de acompanhamento da leva
2026-09-23 21:47:39 -03:00
Diego Rodrigues de Sa e Souza
c9a5705440 fix: keep Qdrant embedding options limited to configured providers (#14427)
Merge-batch 2026-09-23 (PRs do mantenedor, 99 triadas por ; esta entrou pela Trilha A).

Validação única na árvore combinada das 49 PRs da trilha sobre `release/v3.8.51` @18bbb101:
- `typecheck:core`: só o erro herdado `cliproxyAccountHealth.ts:157` (TS2322, não desta leva)
- `check:open-sse-typecheck`: 0 erros (o auggie.ts da base é corrigido pela #14511 do mesmo lote)
- eslint nos arquivos alterados: limpo
- 89 arquivos de teste node focados (710 testes) + 11 suítes vitest de UI: todo vermelho restante reproduz também no tip puro (base-red #14547/#14496) e a árvore combinada fica com um subconjunto estrito dos vermelhos do tip, ou seja, nenhum introduzido pelo lote
- teto de file-size acertado na PR de acompanhamento da leva
2026-09-23 21:47:29 -03:00
Diego Rodrigues de Sa e Souza
bf66b68b7a fix: preserve explicit tier policy during async pricing resolution (#14411)
Merge-batch 2026-09-23 (PRs do mantenedor, 99 triadas por ; esta entrou pela Trilha A).

Validação única na árvore combinada das 49 PRs da trilha sobre `release/v3.8.51` @18bbb101:
- `typecheck:core`: só o erro herdado `cliproxyAccountHealth.ts:157` (TS2322, não desta leva)
- `check:open-sse-typecheck`: 0 erros (o auggie.ts da base é corrigido pela #14511 do mesmo lote)
- eslint nos arquivos alterados: limpo
- 89 arquivos de teste node focados (710 testes) + 11 suítes vitest de UI: todo vermelho restante reproduz também no tip puro (base-red #14547/#14496) e a árvore combinada fica com um subconjunto estrito dos vermelhos do tip, ou seja, nenhum introduzido pelo lote
- teto de file-size acertado na PR de acompanhamento da leva
2026-09-23 21:47:17 -03:00
Diego Rodrigues de Sa e Souza
735ba1323d fix(providers): custom OpenAI-compatible node models keep synced vision capability (#14081) (#14428)
Merge-batch 2026-09-23 (PRs do mantenedor, 99 triadas por ; esta entrou pela Trilha A).

Validação única na árvore combinada das 49 PRs da trilha sobre `release/v3.8.51` @18bbb101:
- `typecheck:core`: só o erro herdado `cliproxyAccountHealth.ts:157` (TS2322, não desta leva)
- `check:open-sse-typecheck`: 0 erros (o auggie.ts da base é corrigido pela #14511 do mesmo lote)
- eslint nos arquivos alterados: limpo
- 89 arquivos de teste node focados (710 testes) + 11 suítes vitest de UI: todo vermelho restante reproduz também no tip puro (base-red #14547/#14496) e a árvore combinada fica com um subconjunto estrito dos vermelhos do tip, ou seja, nenhum introduzido pelo lote
- teto de file-size acertado na PR de acompanhamento da leva
2026-09-23 21:47:04 -03:00
Diego Rodrigues de Sa e Souza
482d56bda9 fix(api): log cache-hit search requests so cacheHitRate reflects real hits (#13928) (#14394)
Merge-batch 2026-09-23 (PRs do mantenedor, 99 triadas por ; esta entrou pela Trilha A).

Validação única na árvore combinada das 49 PRs da trilha sobre `release/v3.8.51` @18bbb101:
- `typecheck:core`: só o erro herdado `cliproxyAccountHealth.ts:157` (TS2322, não desta leva)
- `check:open-sse-typecheck`: 0 erros (o auggie.ts da base é corrigido pela #14511 do mesmo lote)
- eslint nos arquivos alterados: limpo
- 89 arquivos de teste node focados (710 testes) + 11 suítes vitest de UI: todo vermelho restante reproduz também no tip puro (base-red #14547/#14496) e a árvore combinada fica com um subconjunto estrito dos vermelhos do tip, ou seja, nenhum introduzido pelo lote
- teto de file-size acertado na PR de acompanhamento da leva
2026-09-23 21:46:52 -03:00
Diego Rodrigues de Sa e Souza
341d56fb4b fix(db): stop syncManagedAvailableModelAliases from pruning hand-created custom aliases (#11836) (#14409)
Merge-batch 2026-09-23 (PRs do mantenedor, 99 triadas por ; esta entrou pela Trilha A).

Validação única na árvore combinada das 49 PRs da trilha sobre `release/v3.8.51` @18bbb101:
- `typecheck:core`: só o erro herdado `cliproxyAccountHealth.ts:157` (TS2322, não desta leva)
- `check:open-sse-typecheck`: 0 erros (o auggie.ts da base é corrigido pela #14511 do mesmo lote)
- eslint nos arquivos alterados: limpo
- 89 arquivos de teste node focados (710 testes) + 11 suítes vitest de UI: todo vermelho restante reproduz também no tip puro (base-red #14547/#14496) e a árvore combinada fica com um subconjunto estrito dos vermelhos do tip, ou seja, nenhum introduzido pelo lote
- teto de file-size acertado na PR de acompanhamento da leva
2026-09-23 21:46:39 -03:00
Diego Rodrigues de Sa e Souza
5d51eb7b16 fix(providers): stop sending Vertex Express API keys to generativelanguage.googleapis.com (#12328) (#14383)
Merge-batch 2026-09-23 (PRs do mantenedor, 99 triadas por ; esta entrou pela Trilha A).

Validação única na árvore combinada das 49 PRs da trilha sobre `release/v3.8.51` @18bbb101:
- `typecheck:core`: só o erro herdado `cliproxyAccountHealth.ts:157` (TS2322, não desta leva)
- `check:open-sse-typecheck`: 0 erros (o auggie.ts da base é corrigido pela #14511 do mesmo lote)
- eslint nos arquivos alterados: limpo
- 89 arquivos de teste node focados (710 testes) + 11 suítes vitest de UI: todo vermelho restante reproduz também no tip puro (base-red #14547/#14496) e a árvore combinada fica com um subconjunto estrito dos vermelhos do tip, ou seja, nenhum introduzido pelo lote
- teto de file-size acertado na PR de acompanhamento da leva
2026-09-23 21:46:29 -03:00
Diego Rodrigues de Sa e Souza
8909f82c9c fix(sse): widen compression worker gate to accept structured-clone-safe values (#13154) (#14391)
Merge-batch 2026-09-23 (PRs do mantenedor, 99 triadas por ; esta entrou pela Trilha A).

Validação única na árvore combinada das 49 PRs da trilha sobre `release/v3.8.51` @18bbb101:
- `typecheck:core`: só o erro herdado `cliproxyAccountHealth.ts:157` (TS2322, não desta leva)
- `check:open-sse-typecheck`: 0 erros (o auggie.ts da base é corrigido pela #14511 do mesmo lote)
- eslint nos arquivos alterados: limpo
- 89 arquivos de teste node focados (710 testes) + 11 suítes vitest de UI: todo vermelho restante reproduz também no tip puro (base-red #14547/#14496) e a árvore combinada fica com um subconjunto estrito dos vermelhos do tip, ou seja, nenhum introduzido pelo lote
- teto de file-size acertado na PR de acompanhamento da leva
2026-09-23 21:46:18 -03:00
Diego Rodrigues de Sa e Souza
2ac43f51a0 fix(auth): treat credential diagnostic sentinels as unusable in non-chat callers (#13945) (#14414)
Merge-batch 2026-09-23 (PRs do mantenedor, 99 triadas por ; esta entrou pela Trilha A).

Validação única na árvore combinada das 49 PRs da trilha sobre `release/v3.8.51` @18bbb101:
- `typecheck:core`: só o erro herdado `cliproxyAccountHealth.ts:157` (TS2322, não desta leva)
- `check:open-sse-typecheck`: 0 erros (o auggie.ts da base é corrigido pela #14511 do mesmo lote)
- eslint nos arquivos alterados: limpo
- 89 arquivos de teste node focados (710 testes) + 11 suítes vitest de UI: todo vermelho restante reproduz também no tip puro (base-red #14547/#14496) e a árvore combinada fica com um subconjunto estrito dos vermelhos do tip, ou seja, nenhum introduzido pelo lote
- teto de file-size acertado na PR de acompanhamento da leva
2026-09-23 21:46:08 -03:00
Diego Rodrigues de Sa e Souza
bfd54ef7a4 fix(mitm): resolve AgentBridge cert-trust check against the active cert model (#14070) (#14425)
Merge-batch 2026-09-23 (PRs do mantenedor, 99 triadas por ; esta entrou pela Trilha A).

Validação única na árvore combinada das 49 PRs da trilha sobre `release/v3.8.51` @18bbb101:
- `typecheck:core`: só o erro herdado `cliproxyAccountHealth.ts:157` (TS2322, não desta leva)
- `check:open-sse-typecheck`: 0 erros (o auggie.ts da base é corrigido pela #14511 do mesmo lote)
- eslint nos arquivos alterados: limpo
- 89 arquivos de teste node focados (710 testes) + 11 suítes vitest de UI: todo vermelho restante reproduz também no tip puro (base-red #14547/#14496) e a árvore combinada fica com um subconjunto estrito dos vermelhos do tip, ou seja, nenhum introduzido pelo lote
- teto de file-size acertado na PR de acompanhamento da leva
2026-09-23 21:45:57 -03:00
Diego Rodrigues de Sa e Souza
36c8204079 fix(providers): use shell:true on win32 for zcode .cmd/.bat shim spawn (#13963) (#14422)
Merge-batch 2026-09-23 (PRs do mantenedor, 99 triadas por ; esta entrou pela Trilha A).

Validação única na árvore combinada das 49 PRs da trilha sobre `release/v3.8.51` @18bbb101:
- `typecheck:core`: só o erro herdado `cliproxyAccountHealth.ts:157` (TS2322, não desta leva)
- `check:open-sse-typecheck`: 0 erros (o auggie.ts da base é corrigido pela #14511 do mesmo lote)
- eslint nos arquivos alterados: limpo
- 89 arquivos de teste node focados (710 testes) + 11 suítes vitest de UI: todo vermelho restante reproduz também no tip puro (base-red #14547/#14496) e a árvore combinada fica com um subconjunto estrito dos vermelhos do tip, ou seja, nenhum introduzido pelo lote
- teto de file-size acertado na PR de acompanhamento da leva
2026-09-23 21:45:46 -03:00
Diego Rodrigues de Sa e Souza
311dfdf13b fix(sse): stop reordering demoted mid-array system messages (#13948) (#14397)
Merge-batch 2026-09-23 (PRs do mantenedor, 99 triadas por ; esta entrou pela Trilha A).

Validação única na árvore combinada das 49 PRs da trilha sobre `release/v3.8.51` @18bbb101:
- `typecheck:core`: só o erro herdado `cliproxyAccountHealth.ts:157` (TS2322, não desta leva)
- `check:open-sse-typecheck`: 0 erros (o auggie.ts da base é corrigido pela #14511 do mesmo lote)
- eslint nos arquivos alterados: limpo
- 89 arquivos de teste node focados (710 testes) + 11 suítes vitest de UI: todo vermelho restante reproduz também no tip puro (base-red #14547/#14496) e a árvore combinada fica com um subconjunto estrito dos vermelhos do tip, ou seja, nenhum introduzido pelo lote
- teto de file-size acertado na PR de acompanhamento da leva
2026-09-23 21:45:34 -03:00
Diego Rodrigues de Sa e Souza
c94a427e32 fix(dashboard): un-gate Dev Tools sidebar section from Debug Mode (#14021) (#14416)
Merge-batch 2026-09-23 (PRs do mantenedor, 99 triadas por ; esta entrou pela Trilha A).

Validação única na árvore combinada das 49 PRs da trilha sobre `release/v3.8.51` @18bbb101:
- `typecheck:core`: só o erro herdado `cliproxyAccountHealth.ts:157` (TS2322, não desta leva)
- `check:open-sse-typecheck`: 0 erros (o auggie.ts da base é corrigido pela #14511 do mesmo lote)
- eslint nos arquivos alterados: limpo
- 89 arquivos de teste node focados (710 testes) + 11 suítes vitest de UI: todo vermelho restante reproduz também no tip puro (base-red #14547/#14496) e a árvore combinada fica com um subconjunto estrito dos vermelhos do tip, ou seja, nenhum introduzido pelo lote
- teto de file-size acertado na PR de acompanhamento da leva
2026-09-23 21:45:23 -03:00
Diego Rodrigues de Sa e Souza
7f9ecb6da7 fix(test-gate): reject missing golden snapshots without explicit generation (#14521)
Merge-batch 2026-09-23 (PRs do mantenedor, 99 triadas por ; esta entrou pela Trilha A).

Validação única na árvore combinada das 49 PRs da trilha sobre `release/v3.8.51` @18bbb101:
- `typecheck:core`: só o erro herdado `cliproxyAccountHealth.ts:157` (TS2322, não desta leva)
- `check:open-sse-typecheck`: 0 erros (o auggie.ts da base é corrigido pela #14511 do mesmo lote)
- eslint nos arquivos alterados: limpo
- 89 arquivos de teste node focados (710 testes) + 11 suítes vitest de UI: todo vermelho restante reproduz também no tip puro (base-red #14547/#14496) e a árvore combinada fica com um subconjunto estrito dos vermelhos do tip, ou seja, nenhum introduzido pelo lote
- teto de file-size acertado na PR de acompanhamento da leva
2026-09-23 21:45:13 -03:00
Diego Rodrigues de Sa e Souza
9b6f38df34 test(catalog): align Codex image IDs with distinct modality entries (#14508)
Merge-batch 2026-09-23 (PRs do mantenedor, 99 triadas por ; esta entrou pela Trilha A).

Validação única na árvore combinada das 49 PRs da trilha sobre `release/v3.8.51` @18bbb101:
- `typecheck:core`: só o erro herdado `cliproxyAccountHealth.ts:157` (TS2322, não desta leva)
- `check:open-sse-typecheck`: 0 erros (o auggie.ts da base é corrigido pela #14511 do mesmo lote)
- eslint nos arquivos alterados: limpo
- 89 arquivos de teste node focados (710 testes) + 11 suítes vitest de UI: todo vermelho restante reproduz também no tip puro (base-red #14547/#14496) e a árvore combinada fica com um subconjunto estrito dos vermelhos do tip, ou seja, nenhum introduzido pelo lote
- teto de file-size acertado na PR de acompanhamento da leva
2026-09-23 21:45:00 -03:00
Diego Rodrigues de Sa e Souza
ef08f98ca6 test: verify chat cost accounting through pipeline responses (#14505)
Merge-batch 2026-09-23 (PRs do mantenedor, 99 triadas por ; esta entrou pela Trilha A).

Validação única na árvore combinada das 49 PRs da trilha sobre `release/v3.8.51` @18bbb101:
- `typecheck:core`: só o erro herdado `cliproxyAccountHealth.ts:157` (TS2322, não desta leva)
- `check:open-sse-typecheck`: 0 erros (o auggie.ts da base é corrigido pela #14511 do mesmo lote)
- eslint nos arquivos alterados: limpo
- 89 arquivos de teste node focados (710 testes) + 11 suítes vitest de UI: todo vermelho restante reproduz também no tip puro (base-red #14547/#14496) e a árvore combinada fica com um subconjunto estrito dos vermelhos do tip, ou seja, nenhum introduzido pelo lote
- teto de file-size acertado na PR de acompanhamento da leva
2026-09-23 21:44:46 -03:00
Diego Rodrigues de Sa e Souza
8d98f5ea56 fix(types): preserve narrowed intelligent routing weight patch (#14503)
Merge-batch 2026-09-23 (PRs do mantenedor, 99 triadas por ; esta entrou pela Trilha A).

Validação única na árvore combinada das 49 PRs da trilha sobre `release/v3.8.51` @18bbb101:
- `typecheck:core`: só o erro herdado `cliproxyAccountHealth.ts:157` (TS2322, não desta leva)
- `check:open-sse-typecheck`: 0 erros (o auggie.ts da base é corrigido pela #14511 do mesmo lote)
- eslint nos arquivos alterados: limpo
- 89 arquivos de teste node focados (710 testes) + 11 suítes vitest de UI: todo vermelho restante reproduz também no tip puro (base-red #14547/#14496) e a árvore combinada fica com um subconjunto estrito dos vermelhos do tip, ou seja, nenhum introduzido pelo lote
- teto de file-size acertado na PR de acompanhamento da leva
2026-09-23 21:44:34 -03:00
Diego Rodrigues de Sa e Souza
7aff5ecb25 test: unmount React roots before UI environment teardown (#14502)
Merge-batch 2026-09-23 (PRs do mantenedor, 99 triadas por ; esta entrou pela Trilha A).

Validação única na árvore combinada das 49 PRs da trilha sobre `release/v3.8.51` @18bbb101:
- `typecheck:core`: só o erro herdado `cliproxyAccountHealth.ts:157` (TS2322, não desta leva)
- `check:open-sse-typecheck`: 0 erros (o auggie.ts da base é corrigido pela #14511 do mesmo lote)
- eslint nos arquivos alterados: limpo
- 89 arquivos de teste node focados (710 testes) + 11 suítes vitest de UI: todo vermelho restante reproduz também no tip puro (base-red #14547/#14496) e a árvore combinada fica com um subconjunto estrito dos vermelhos do tip, ou seja, nenhum introduzido pelo lote
- teto de file-size acertado na PR de acompanhamento da leva
2026-09-23 21:44:21 -03:00
Diego Rodrigues de Sa e Souza
e1c953ea55 test: include new covering cases in mutation runner (#14501)
Merge-batch 2026-09-23 (PRs do mantenedor, 99 triadas por ; esta entrou pela Trilha A).

Validação única na árvore combinada das 49 PRs da trilha sobre `release/v3.8.51` @18bbb101:
- `typecheck:core`: só o erro herdado `cliproxyAccountHealth.ts:157` (TS2322, não desta leva)
- `check:open-sse-typecheck`: 0 erros (o auggie.ts da base é corrigido pela #14511 do mesmo lote)
- eslint nos arquivos alterados: limpo
- 89 arquivos de teste node focados (710 testes) + 11 suítes vitest de UI: todo vermelho restante reproduz também no tip puro (base-red #14547/#14496) e a árvore combinada fica com um subconjunto estrito dos vermelhos do tip, ou seja, nenhum introduzido pelo lote
- teto de file-size acertado na PR de acompanhamento da leva
2026-09-23 21:44:09 -03:00
Diego Rodrigues de Sa e Souza
d870d64b9f test: replenish persistent quota in request exhaustion fixture (#14431)
Merge-batch 2026-09-23 (PRs do mantenedor, 99 triadas por ; esta entrou pela Trilha A).

Validação única na árvore combinada das 49 PRs da trilha sobre `release/v3.8.51` @18bbb101:
- `typecheck:core`: só o erro herdado `cliproxyAccountHealth.ts:157` (TS2322, não desta leva)
- `check:open-sse-typecheck`: 0 erros (o auggie.ts da base é corrigido pela #14511 do mesmo lote)
- eslint nos arquivos alterados: limpo
- 89 arquivos de teste node focados (710 testes) + 11 suítes vitest de UI: todo vermelho restante reproduz também no tip puro (base-red #14547/#14496) e a árvore combinada fica com um subconjunto estrito dos vermelhos do tip, ou seja, nenhum introduzido pelo lote
- teto de file-size acertado na PR de acompanhamento da leva
2026-09-23 21:43:57 -03:00
Diego Rodrigues de Sa e Souza
48edaf427b test: recognize handoff requests at the sanitized upstream boundary (#14429)
Merge-batch 2026-09-23 (PRs do mantenedor, 99 triadas por ; esta entrou pela Trilha A).

Validação única na árvore combinada das 49 PRs da trilha sobre `release/v3.8.51` @18bbb101:
- `typecheck:core`: só o erro herdado `cliproxyAccountHealth.ts:157` (TS2322, não desta leva)
- `check:open-sse-typecheck`: 0 erros (o auggie.ts da base é corrigido pela #14511 do mesmo lote)
- eslint nos arquivos alterados: limpo
- 89 arquivos de teste node focados (710 testes) + 11 suítes vitest de UI: todo vermelho restante reproduz também no tip puro (base-red #14547/#14496) e a árvore combinada fica com um subconjunto estrito dos vermelhos do tip, ou seja, nenhum introduzido pelo lote
- teto de file-size acertado na PR de acompanhamento da leva
2026-09-23 21:43:45 -03:00
Diego Rodrigues de Sa e Souza
9ab0d19ff8 test: align output style fixtures with non-leading system placement (#14423)
Merge-batch 2026-09-23 (PRs do mantenedor, 99 triadas por ; esta entrou pela Trilha A).

Validação única na árvore combinada das 49 PRs da trilha sobre `release/v3.8.51` @18bbb101:
- `typecheck:core`: só o erro herdado `cliproxyAccountHealth.ts:157` (TS2322, não desta leva)
- `check:open-sse-typecheck`: 0 erros (o auggie.ts da base é corrigido pela #14511 do mesmo lote)
- eslint nos arquivos alterados: limpo
- 89 arquivos de teste node focados (710 testes) + 11 suítes vitest de UI: todo vermelho restante reproduz também no tip puro (base-red #14547/#14496) e a árvore combinada fica com um subconjunto estrito dos vermelhos do tip, ou seja, nenhum introduzido pelo lote
- teto de file-size acertado na PR de acompanhamento da leva
2026-09-23 21:43:33 -03:00
Diego Rodrigues de Sa e Souza
1297dcefb1 test: isolate weighted selection from fixture RPM exhaustion (#14418)
Merge-batch 2026-09-23 (PRs do mantenedor, 99 triadas por ; esta entrou pela Trilha A).

Validação única na árvore combinada das 49 PRs da trilha sobre `release/v3.8.51` @18bbb101:
- `typecheck:core`: só o erro herdado `cliproxyAccountHealth.ts:157` (TS2322, não desta leva)
- `check:open-sse-typecheck`: 0 erros (o auggie.ts da base é corrigido pela #14511 do mesmo lote)
- eslint nos arquivos alterados: limpo
- 89 arquivos de teste node focados (710 testes) + 11 suítes vitest de UI: todo vermelho restante reproduz também no tip puro (base-red #14547/#14496) e a árvore combinada fica com um subconjunto estrito dos vermelhos do tip, ou seja, nenhum introduzido pelo lote
- teto de file-size acertado na PR de acompanhamento da leva
2026-09-23 21:43:21 -03:00
Diego Rodrigues de Sa e Souza
6987e15562 test: make provider model auto-sync timing deterministic (#14413)
Merge-batch 2026-09-23 (PRs do mantenedor, 99 triadas por ; esta entrou pela Trilha A).

Validação única na árvore combinada das 49 PRs da trilha sobre `release/v3.8.51` @18bbb101:
- `typecheck:core`: só o erro herdado `cliproxyAccountHealth.ts:157` (TS2322, não desta leva)
- `check:open-sse-typecheck`: 0 erros (o auggie.ts da base é corrigido pela #14511 do mesmo lote)
- eslint nos arquivos alterados: limpo
- 89 arquivos de teste node focados (710 testes) + 11 suítes vitest de UI: todo vermelho restante reproduz também no tip puro (base-red #14547/#14496) e a árvore combinada fica com um subconjunto estrito dos vermelhos do tip, ou seja, nenhum introduzido pelo lote
- teto de file-size acertado na PR de acompanhamento da leva
2026-09-23 21:43:09 -03:00
Diego Rodrigues de Sa e Souza
b2875a5b4b test: authenticate memory filter fixtures under CI bootstrap settings (#14410)
Merge-batch 2026-09-23 (PRs do mantenedor, 99 triadas por ; esta entrou pela Trilha A).

Validação única na árvore combinada das 49 PRs da trilha sobre `release/v3.8.51` @18bbb101:
- `typecheck:core`: só o erro herdado `cliproxyAccountHealth.ts:157` (TS2322, não desta leva)
- `check:open-sse-typecheck`: 0 erros (o auggie.ts da base é corrigido pela #14511 do mesmo lote)
- eslint nos arquivos alterados: limpo
- 89 arquivos de teste node focados (710 testes) + 11 suítes vitest de UI: todo vermelho restante reproduz também no tip puro (base-red #14547/#14496) e a árvore combinada fica com um subconjunto estrito dos vermelhos do tip, ou seja, nenhum introduzido pelo lote
- teto de file-size acertado na PR de acompanhamento da leva
2026-09-23 21:42:57 -03:00
Diego Rodrigues de Sa e Souza
7afee2909c test: assert visible provider topology legend with real messages (#14408)
Merge-batch 2026-09-23 (PRs do mantenedor, 99 triadas por ; esta entrou pela Trilha A).

Validação única na árvore combinada das 49 PRs da trilha sobre `release/v3.8.51` @18bbb101:
- `typecheck:core`: só o erro herdado `cliproxyAccountHealth.ts:157` (TS2322, não desta leva)
- `check:open-sse-typecheck`: 0 erros (o auggie.ts da base é corrigido pela #14511 do mesmo lote)
- eslint nos arquivos alterados: limpo
- 89 arquivos de teste node focados (710 testes) + 11 suítes vitest de UI: todo vermelho restante reproduz também no tip puro (base-red #14547/#14496) e a árvore combinada fica com um subconjunto estrito dos vermelhos do tip, ou seja, nenhum introduzido pelo lote
- teto de file-size acertado na PR de acompanhamento da leva
2026-09-23 21:42:46 -03:00
Diego Rodrigues de Sa e Souza
8fa8cdf42f test: align orchestration history fixtures with controlled clock (#14407)
Merge-batch 2026-09-23 (PRs do mantenedor, 99 triadas por ; esta entrou pela Trilha A).

Validação única na árvore combinada das 49 PRs da trilha sobre `release/v3.8.51` @18bbb101:
- `typecheck:core`: só o erro herdado `cliproxyAccountHealth.ts:157` (TS2322, não desta leva)
- `check:open-sse-typecheck`: 0 erros (o auggie.ts da base é corrigido pela #14511 do mesmo lote)
- eslint nos arquivos alterados: limpo
- 89 arquivos de teste node focados (710 testes) + 11 suítes vitest de UI: todo vermelho restante reproduz também no tip puro (base-red #14547/#14496) e a árvore combinada fica com um subconjunto estrito dos vermelhos do tip, ou seja, nenhum introduzido pelo lote
- teto de file-size acertado na PR de acompanhamento da leva
2026-09-23 21:42:33 -03:00
Diego Rodrigues de Sa e Souza
01deb5e2c3 fix(test): align Vitest collectors and lifecycle data loading (#14406)
Merge-batch 2026-09-23 (PRs do mantenedor, 99 triadas por ; esta entrou pela Trilha A).

Validação única na árvore combinada das 49 PRs da trilha sobre `release/v3.8.51` @18bbb101:
- `typecheck:core`: só o erro herdado `cliproxyAccountHealth.ts:157` (TS2322, não desta leva)
- `check:open-sse-typecheck`: 0 erros (o auggie.ts da base é corrigido pela #14511 do mesmo lote)
- eslint nos arquivos alterados: limpo
- 89 arquivos de teste node focados (710 testes) + 11 suítes vitest de UI: todo vermelho restante reproduz também no tip puro (base-red #14547/#14496) e a árvore combinada fica com um subconjunto estrito dos vermelhos do tip, ou seja, nenhum introduzido pelo lote
- teto de file-size acertado na PR de acompanhamento da leva
2026-09-23 21:42:21 -03:00
Diego Rodrigues de Sa e Souza
f9b042cea0 fix(hooks): fail closed without shared worktree stashes (#14389)
Merge-batch 2026-09-23 (PRs do mantenedor, 99 triadas por ; esta entrou pela Trilha A).

Validação única na árvore combinada das 49 PRs da trilha sobre `release/v3.8.51` @18bbb101:
- `typecheck:core`: só o erro herdado `cliproxyAccountHealth.ts:157` (TS2322, não desta leva)
- `check:open-sse-typecheck`: 0 erros (o auggie.ts da base é corrigido pela #14511 do mesmo lote)
- eslint nos arquivos alterados: limpo
- 89 arquivos de teste node focados (710 testes) + 11 suítes vitest de UI: todo vermelho restante reproduz também no tip puro (base-red #14547/#14496) e a árvore combinada fica com um subconjunto estrito dos vermelhos do tip, ou seja, nenhum introduzido pelo lote
- teto de file-size acertado na PR de acompanhamento da leva
2026-09-23 21:42:08 -03:00
Diego Rodrigues de Sa e Souza
0fe31c666f fix(ci): preserve failed npm install exit after retries (#14369)
Merge-batch 2026-09-23 (PRs do mantenedor, 99 triadas por ; esta entrou pela Trilha A).

Validação única na árvore combinada das 49 PRs da trilha sobre `release/v3.8.51` @18bbb101:
- `typecheck:core`: só o erro herdado `cliproxyAccountHealth.ts:157` (TS2322, não desta leva)
- `check:open-sse-typecheck`: 0 erros (o auggie.ts da base é corrigido pela #14511 do mesmo lote)
- eslint nos arquivos alterados: limpo
- 89 arquivos de teste node focados (710 testes) + 11 suítes vitest de UI: todo vermelho restante reproduz também no tip puro (base-red #14547/#14496) e a árvore combinada fica com um subconjunto estrito dos vermelhos do tip, ou seja, nenhum introduzido pelo lote
- teto de file-size acertado na PR de acompanhamento da leva
2026-09-23 21:42:03 -03:00
Diego Rodrigues de Sa e Souza
adefe4f583 docs(i18n): refresh 10 drifted docs mirrors across 66 locales (#14676)
* docs(i18n): refresh 10 drifted docs mirrors across 66 locales

The base branch edited README, AUTHZ_GUIDE, COMPRESSION_ENGINES,
COMPRESSION_GUIDE, MCP-SERVER, MANAGEMENT-AUTH, API_REFERENCE,
ENVIRONMENT, FEATURE_FLAGS and PROVIDER_REFERENCE without updating their
translations. This re-translates the changed sections for every locale
and re-records the target hashes in .i18n-state.json.

Every changed mirror was validated against its English source: equal
code-fence count, table rows within 3, and no leaked model reasoning
(<think> blocks or English meta-prose).

* docs(i18n): re-adopt target hashes after pre-commit formatting
2026-09-23 20:55:15 -03:00
diegosouzapw
2f16c436eb fix(security): create the data dir 0700 and .env 0600, and repair existing installs
The CLI created ~/.omniroute (or DATA_DIR) and wrote .env — which holds
STORAGE_ENCRYPTION_KEY, the key to every credential in storage.sqlite —
without an explicit mode, so the process umask decided. Under the common
umask 002 that is 0775 / 0664: any local user could read the key and the
database. Reproduced live on the tip: dir 775, .env 664.

- bin/cli/privateDataDir.mjs: ensurePrivateDataDir (0700), writePrivateFile
  (0600, also tightening a pre-existing file) and tightenDataDirSecrets
  (startup repair: strip world bits from the dir, .env/server.env -> 0600;
  group bits kept so deliberate group shares survive). All best-effort.
- bin/omniroute.mjs: both .env write sites use it (key generation and the
  Electron server.env migration), and the repair runs inside the existing
  shouldProvisionStorageKey block, so --version/--help never touch DATA_DIR.

Refs GHSA-2pg2-xm9r-8544.
2026-09-23 20:39:32 -03:00
Ravi Tharuma
18bbb10198 fix(resilience): scope the circuit breaker to one connection (#14530)
Merged. Thank you, @RaviTharuma — and thank you for crediting whose design this follows.

The principle is right and overdue: a circuit-breaker trip belongs to the **connection** that failed. One account returning errors should not open `getCircuitBreaker(provider)` for every other healthy account on that provider — that turns a single bad key into a provider-wide outage. Keeping the provider-wide breaker for the case that genuinely is provider-wide (a dead proxy) is the right line to draw. Pulling `MODEL_ACCESS_DENIED_PATTERNS` / `isModelScoped400` into `open-sse/services/modelAccessDenied.ts` so combo predicates and account fallback quote one module is a good consolidation too.

**One real defect had to be fixed before this could land — the PR did not compile.** In `evaluateExecuteTargetGates`, the connection breaker was looked up with `connectionId`:

```ts
const scopedConnectionId = target.connectionId ?? undefined;
const connectionBreaker = scopedConnectionId
  ? getCircuitBreaker(connectionCircuitBreakerName(provider, connectionId))   // ← here
  : null;
```

The function declares `connectionId` again ~245 lines further down, in the credential-gate block, so this reference sat in that `const`'s temporal dead zone (TS2448) and carried the unnarrowed `string | undefined` into a `string` parameter (TS2345). Now it passes `scopedConnectionId` — the same expression, already narrowed by the ternary guard, and the same id the OPEN log line six lines below already reports, so lookup and message now name the same connection. This is the value you meant, not just the one that compiles.

It survived because `tests/unit/connection-circuit-breaker.test.ts` exercises `recordProviderFailure` / `isProviderInCooldown` / `isModelScoped400` but never reaches `evaluateExecuteTargetGates`. Two tests were added to the existing `tests/unit/combo/execute-target-gates.test.ts` harness: an OPEN **connection** breaker skips only that connection (mutation-verified — swapping the lookup back to `getCircuitBreaker(provider)` makes it fail, so it pins the breaker *name*), and a sibling connection on the same provider still proceeds.

Validation before merge, on the real merge tree (current tip + this head): `typecheck:core` no longer reports either `executeTargetGates.ts` error; `connection-circuit-breaker` + `provider-breaker-halfopen-recovery` + `execute-target-gates` **25 pass / 0 fail**; `check:file-size` and `check:changelog-integrity` OK. Two files you left over Prettier's 100-char width were formatted in a separate style-only commit.

`check:open-sse-typecheck` still reports 8 errors in `open-sse/executors/auggie.ts`. Those are pre-existing on the tip (#14496) and this PR does not touch that file.

Co-authored-by: Bob.Hou <19586012+HouMinXi@users.noreply.github.com>
Co-authored-by: Koosha Paridehpour <42529354+KooshaPari@users.noreply.github.com>
2026-09-23 01:58:44 -03:00
Diego Rodrigues de Sa e Souza
99ba14b776 fix(sse): keep providerResponse in scope for failure usage (regression from #14544) (#14599)
Hoists `let providerResponse` out of handleChatCore's prettier-ignore try block so persistFailureUsage (declared before that try) can read it. #14544 had made every upstream failure throw ReferenceError: providerResponse is not defined out of handleChatCore. Net-zero lines. Adds an end-to-end regression test (red on the tip, green here) and restores 2 failing tests in chatcore-codex-account-pool and 17 in chatcore-translation-paths. check:open-sse-typecheck no longer reports the chatCore.ts TS2304.

Refs #14544
2026-09-23 01:58:02 -03:00
Tony Yu
5483abb4b3 docs: point Kimi Code plan links at the international site (kimi.ai/code) (#14246)
kimi.com/code serves mainland China (WeChat / +86 sign-in, Alipay and WeChat Pay), so OmniRoute's mostly international users cannot sign up or pay there.
kimi.ai/code is the international site (Google sign-in, card payment); the aff value is unchanged, so attribution keeps working as before.
2026-09-22 23:03:12 -03:00
Ravi Tharuma
d7be9fd528 feat(providers): correlate X-CPA-TRACE-ID auth_index with usage history (#14544)
Merged — closes #11725. Thank you, @RaviTharuma.

Correlating the credential CLIProxyAPI actually selected with the usage row is the kind of plumbing that only pays off later, when someone is trying to work out *which* account burned the quota. Storing the opaque `auth_index` on all three paths (success, streaming, error) with a backfill when the first write missed the header, and resolving the human-readable label at **read** time from the sanitized account-health projection, is the right split: the column never holds an email, a path, a token or a raw management payload, and a missing or unrecognized header stays unattributed instead of failing the request.

Validation before merge: boarded onto the release tip with the other seven PRs of your batch, then reconciled twice as the branch moved under it.

- **First move:** #14559 (another session) corrected the docs migration count 178 → 181, colliding with this PR on 69 files — `README.md`, `AGENTS.md`, `llm.txt` and 66 i18n `llm.txt` mirrors, 270 hunks. All 270 were proven mechanically to be pure numeral collisions (base 178 / ours 182 / theirs 181, byte-identical otherwise), and the resolution keeps the tip's exact line shape with 182. I checked separately that no mirror lost its translated prose: 270 removed lines and 270 added lines, each pair identical but for the numeral.
- **Second move:** #14069 landed and both sides had *added* a new top-level `_rebaseline_*` key to `config/quality/file-size-baseline.json` — an add/add, so both were kept and #14069's entry is intact.

The 182 is verified, not carried over: the tip has 181 migration files and this PR adds `185_usage_history_cpa_auth_index.sql`. `check:migration-numbering` reports `182 migrations, 0 duplicates` and `check:docs-counts-sync` passes its STRICT migration-count assertion across all four claim sites.

`cpa-trace-auth-index` + `cpa-auth-index-usage` + `db/migration-185-cpa-auth-index` + `chatcore-failure-usage` 12 pass / 0 fail. `check:file-size` OK — note your `chatCore.ts` ceiling is now 6402, measured after the commit; that file had been sitting at exactly 6400 with zero headroom since #14213.

`typecheck:core` reports exactly one error, and it is **not yours**: `src/lib/services/cliproxyAccountHealth.ts` TS2322 on `host: options.host ?? externalHost`. That expression is byte-identical on the pure tip at line 146 — this PR only shifts it to 157 by adding `labelForCliproxyAuthIndex` above — and it touches neither the `host: string` type nor `externalHost`. Inherited base defect, tracked separately.

Two things your PR did that I want to name, since both were on my checklist and neither needed a bounce: you bumped the migration count in `AGENTS.md`/`llm.txt` yourself (those are agent-instruction surfaces, so I confirmed the change was purely the numeral before merging), and in doing so you drained the 178 → 181 docs drift the previous batch had deferred.

A leftover for someone else: `docs/i18n/fr/README.md` and `docs/i18n/phi/README.md` still say 178 — #14559 updated the `llm.txt` mirrors but missed the README ones. Pre-existing on the tip, outside this diff.
2026-09-22 20:03:11 -03:00
Ravi Tharuma
819adea9c0 fix(combo): record live-catalog misses as model_not_in_catalog (#14069)
Merged — closes #14068. Thank you, @RaviTharuma.

Collapsing "this alias is not in the live catalog" into the generic `availability` bucket meant an unknown model got reported to the operator as "no credentials available" — a diagnosis pointing at the wrong subsystem entirely. Giving it its own `model_not_in_catalog` reason, allowlisted and grouped in `diagnostics.skippedTargets` on `ALL_TARGETS_SKIPPED`, is the difference between a five-minute fix and an afternoon of checking keys. Deleting the earlier `src/lib/model-not-found.ts` scaffolding and its unused helper test in the same PR, rather than leaving it orphaned, is the right call too.

Validation before merge: boarded onto the current release tip together with the other seven PRs of your batch — merged clean, no conflict against the tip or any sibling. `combo-skipped-targets-summary` + `combo-decision-trace` 16 pass / 0 fail, `typecheck:core` clean, `check:changelog-integrity` OK.

One thing I added on your branch: this PR was the only one in the batch to trip `check:file-size`, because both files it threads the new reason through were already frozen **exactly at their measured size with zero headroom** — `src/sse/handlers/chat.ts` had been tightened to 2559 by #14223 two days ago, and `roundRobinCombo.ts` sat at 1261. Your own growth is +1 and +2 respectively (the `errorType === "model_not_found"` early return, and the strict `available !== true` pre-check plus the sticky-target expression that Prettier reflows over three lines at printWidth 100). That is irreducible call-site plumbing, not a quality regression, so it earned a documented rebaseline rather than a bounce: annotation key `_rebaseline_2026_09_22_14069_model_not_in_catalog`, with both ceilings set to the values measured on the reconciled tree *after* the commit. Structural shrink of both god-files stays tracked in #3501.
2026-09-22 19:52:06 -03:00
Ravi Tharuma
d54d31365e feat(oauth): add Muse Code device login with CLIProxyAPI-parity remint (#14329)
Merged — closes #14328. Thank you, @RaviTharuma. This is the biggest PR of your batch and the one I checked hardest.

Native RFC 8628 device authorization against `auth.meta.com`, minting the subscription inference key at `POST https://api.meta.ai/muse-code/key`, persisting the durable `dca:` token, reminting on 401, honoring `error.resets_at` quota cooldowns, and importing CLIProxyAPI `type: meta` auth files — with dual-auth keeping a pasted `META_API_KEY` on the same card and the Responses API wire format unchanged. That last part matters: a new auth path that quietly changes the wire format is how a provider integration breaks for everyone already using it.

**Hard Rule #11 verified, not assumed.** The public client_id goes through `resolvePublicCred("muse_id", "MUSE_CODE_OAUTH_CLIENT_ID")` — never a string literal — and you shipped a test that asserts the resolved shape rather than just the wiring. Eight `resolvePublicCred` call sites in the diff, zero literals.

Validation before merge: boarded onto the current release tip together with the other seven PRs of your batch — merged clean, no conflict against the tip or any sibling. The combined board ran 81 focused tests with 0 failures, including `muse-code-oauth`, `muse-code-models`, `muse-code-provider`, `oauth-providers-config`, `oauth-polltoken-nonjson-body`, `cliproxy-auth-import-1934` and `publicCreds`. `check:file-size`, `check:changelog-integrity` and `check:migration-numbering` (182 migrations, zero duplicates) green.

One small follow-up I am not asking you to do: adding this provider takes the OAuth count to 23, and `docs/architecture/ARCHITECTURE.md` still does not state it, so `check:docs-counts-sync` reports it as a soft drift. Soft is advisory, not blocking — I am folding it into the docs-count reconciliation rather than bouncing the PR for one number.
2026-09-22 19:39:53 -03:00
Ravi Tharuma
afe1a0f9c5 feat(opencode-plugin): escalate stale disk-cache fallback past an opt-in age (#14540)
Merged. Thank you, @RaviTharuma.

The restraint is what makes this one easy to take: unset or `0` stays unbounded, which is the historical default, so nobody's behaviour changes unless they opt in. Past a positive `features.diskCacheMaxAgeMs` the snapshot is **still served** — only the log escalates from warn to error and names the bound. Degrading the signal rather than the service is the right trade for a cache fallback, and deliberately leaving the TUI/toast surface on the separate #13390 track keeps the diff honest at 78 lines.

Validation before merge: boarded onto the current release tip together with the other seven PRs of your batch — merged clean against the tip and against #14088, which touches the same two plugin files; after #14088 landed this one re-reported MERGEABLE with no reconciliation needed. `check:file-size`, `check:changelog-integrity` and `check:migration-numbering` green on the combined tree.
2026-09-22 19:39:27 -03:00
Ravi Tharuma
2e60034d58 fix(opencode-plugin): skip combos fetch when disabled and keep cc/*-low on /v1 (#14088)
Merged — closes #14087. Thank you, @RaviTharuma.

Two small gaps, both worth closing: the provider hook already honored `features.combos: false` but the config hook did not, so a user who disabled combos still paid a `GET /api/combos` on every config load. And keeping the first-class effort-tier ids (`cc/...-low` and siblings) on openai-compatible `/v1` stops OpenCode from 404ing them as `claude-<name>` on provider `claude` — a failure mode that looks like a missing model rather than a routing mistake, which is the worst kind to debug from the user's side.

Validation before merge: boarded onto the current release tip together with the other seven PRs of your batch — merged clean against the tip and against #14540, which touches the same two plugin files. `check:file-size`, `check:changelog-integrity` and `check:migration-numbering` green on the combined tree.
2026-09-22 19:38:26 -03:00
Ravi Tharuma
2def447b15 fix(compression): keep lossy engines off the default path (#14529)
Merged. Thank you, @RaviTharuma — and thank you for naming whose design this is instead of quietly absorbing it.

Two good calls landed here:

- **Lossy stays off the default path.** Session dedup and whitespace folding still run when compression is on; summaries, relevance filters and style rewrites now require an explicit `x-omniroute-compression: allow-lossy`, `engine:<id>`, or a named combo. Silently rewriting a user's prompt is exactly the class of default this project should not have.
- **Combo 400 handling became a table instead of another string guard.** `statusDecisionTable.ts` with `advance` | `stop` rows is reviewable in a way a growing chain of substring checks never is: model-scoped wrappers advance, `Invalid message format` still stops, and a 400 no longer opens the provider-wide breaker.

Migrating the seeded Standard Savings combo to `session-dedup` + `lite` only when it is still the untouched RTK + Caveman seed — and leaving a renamed or edited combo alone — is the right conservative shape for a seed migration.

Validation before merge: boarded onto the current release tip together with the other seven PRs of your batch — merged clean, no conflict against the tip or any sibling. The combined board ran 81 focused tests across the batch with 0 failures, including `combo-status-decision-table`, the four compression suites and `strategySelector`; `check:file-size`, `check:changelog-integrity` and `check:migration-numbering` green.

Co-authored-by: Bob.Hou <19586012+HouMinXi@users.noreply.github.com>
Co-authored-by: Koosha Paridehpour <42529354+KooshaPari@users.noreply.github.com>
2026-09-22 19:37:18 -03:00
Ravi Tharuma
1c957d9508 docs(authz): document independent scope namespaces (#14526)
Merged — closes #13391. Thank you, @RaviTharuma.

The valuable part here is the sentence that was missing everywhere: **a pass in one namespace is not a pass in another**. Someone reading `AUTHZ_GUIDE.md` alone could reasonably conclude a `manage` key satisfies `read:compression`, and the old `MCP_SCOPE_LIST` block made it worse by presenting a partial list as the full catalog. Documenting the three namespaces side by side — API-key management scopes, MCP tool scopes via `scopeMatches` (exact string or trailing `*`), and access-token ranks via `scopeSatisfies` — with the two concrete counter-examples is what makes it usable rather than merely accurate.

Validation before merge: boarded onto the current release tip together with the other seven PRs of your batch — merged clean, no conflict against the tip or any sibling. Markdown-only diff, no production code, so nothing to weigh on the ratchets; `check:changelog-integrity` green on the combined tree.
2026-09-22 19:36:43 -03:00
Diego Rodrigues de Sa e Souza
373d784e09 docs: migration count 178 -> 181 in README, AGENTS.md and llm.txt (+ mirrors) (#14559)
The Docs Gates job was red on the release tip: three migrations landed
(179, 180, 181) without the count claims being touched. check:docs-counts
is green again; the 66 llm.txt mirrors regenerated with sync-llm-mirrors.
2026-09-22 19:14:14 -03:00
Diego Rodrigues de Sa e Souza
ea3c12264b docs(i18n): refresh the mirrors of the 8 docs the base edited on 2026-09-22 (66 locales) (#14532)
* docs(i18n): refresh the mirrors of the 8 docs the base edited on 2026-09-22 (66 locales)

* docs(i18n): re-adopt mirrors reformatted by the pre-commit hook

* docs(i18n): refresh ENVIRONMENT and FEATURE_FLAGS mirrors again and add the 3 new keys to bs

* docs(i18n): re-adopt mirrors reformatted by the pre-commit hook
2026-09-22 14:39:54 -03:00
Diego Rodrigues de Sa e Souza
9f4d3b0ab8 fix(api): apply the per-key operator policy on every /v1/files and /v1/batches handler (omni-code-sec LEDGER-2) (#14495)
LEDGER-2 (#14481): every /v1/files and /v1/batches handler now runs enforceApiKeyPolicy() after the 401 fold, so the per-key operator policy (endpoint allowlist, schedule, usage cap, rate limit) applies to the file/batch surface — including the ungated x-api-key transport that bypassed the gate (omni-code-sec round 2).
LEDGER-27: uploads/batches/sweeps on the x-api-key transport are attributed to the key the policy resolved (resolveEffectiveApiKeyId) instead of persisting apiKeyId: null; LEDGER-28: direct coverage of the 401 fold. LEDGER-29 (fold vs #2257 degrade-to-anonymous) stays a product decision on #14332/#14481.
CI reds at merge time are all inherited from base-red #14496 (triage in the PR comments).

Refs #14481
2026-09-22 13:52:32 -03:00
Dizzle
66688599b2 fix(sse): rotation attribution diagnostics (skipped accounts, serving id, request correlation) (#14223)
Merged. Thank you, @maxmad64bis — and thank you for putting the whole thing behind an off-by-default flag, which is what made it possible to take a 25-file observability change late in a cycle.

"Rotation passes over cooling-down accounts, traffic concentrates, and nothing says why" is a real operator problem, and naming the skipped accounts, exposing per-account rotation state, and carrying the serving account plus request correlation onto proxy log rows is the right set of three. No selection behaviour changes.

This one needed the most reconciliation of the batch, because `opencode.ts` moved four times under it. What was found and fixed, so it is on the record:

**1 — Migration collision that would have stopped the app from booting.** You added `182_proxy_logs_rotation_account.sql`, but `182_request_cost_ledger_and_key_quota.sql` landed on the release while this was open. `getMigrationFiles()` in `src/lib/db/migrationRunner.ts` detects version collisions and **throws**, so a fresh install would have failed at startup — and the `case "182":` arm you added to `isSchemaAlreadyApplied()` would have answered for the *other* migration's schema and skipped it, exactly the hazard your own comment warns about. Renumbered to **183/184**, with `migrationRunner.ts`, the test file name and its contents, and the rebaseline note all moved with them. Proven by `tests/unit/migration-135-numbering-collision.test.ts` (which runs against the *real* migrations directory, unlike the PR's own test which copies into a temp dir), and mutation-proved by re-adding the duplicate and watching it fail.

**2 — A dangling reference git never flagged.** #14353 moved the rotation member list off the shared instance, deleting `this.accounts`. Your `noteRotationAccount` site read `this.accounts.length` and auto-merged with no conflict marker. Fixed to the per-request `accounts`, along with three sibling call sites and `snapshotEntries()`, which had to be re-signed to take the list.

**3 — Two rotation exits were missing the attribution flush.** The `runParkAndReplay` returns in the 429 arm returned without flushing `skippedCooldown`, so a parked-and-replayed request lost its skipped-account line. Added with the same `attributionOn && skippedCooldown.size > 0` guard the other exits use — there are now nine guarded exits. The single-account fast path needs none (`skippedCooldown` is provably empty there).

Validation on the final tree: rotation-attribution suites 4+2+2, `proxy-logger-attribution` 4, `migration-183-184` 3, `migration-135-numbering-collision` 2, `resilience-connections-rotation` 2, `feature-flags-settings` 63, plus the six opencode suites (5+4+34+9+38+8+9) — all green. `opencode-executor` is 58/59 with `"omits accept header when stream is false"` failing identically on a pure-tip control checkout, so inherited. `typecheck:core` clean, `check:file-size` OK, `check:changelog-integrity` OK. `check:open-sse-typecheck` fails with exactly the 8 inherited `auggie.ts` errors (#14496) and nothing naming a file this PR touches.

Flag count reconciled to 77; `ROTATION_ATTRIBUTION` stays `defaultValue: "false"`.

Two things to be aware of going forward:

- `open-sse/executors/opencode.ts` (1318) and `src/sse/handlers/chat.ts` (2559) now sit **exactly at their ceilings**, with zero headroom — the previous 1386 entry was ~68 lines of phantom headroom and was tightened to the measured value. The next PR touching either will need a rebaseline of its own.
- `check:docs-all` reports the migration count drift (README/AGENTS/llm.txt say 178; the tip alone is at 179 and these two take it to 181). Left untouched on purpose: those are agent-instruction surfaces and the count is reconciled on the merge train, not per-PR.
2026-09-22 13:43:20 -03:00
Dizzle
a61b34d7f2 fix(opencode): keep each request on its own member list (#14353)
Merged. Thank you, @maxmad64bis — this and #14149 are the same class of bug caught twice, and both were worth catching.

Two requests sharing one executor walking one shared member list means one request's re-sync can replace the other's picks — silently, and only under concurrency, which is exactly why it never shows up in a single-request test. Keying the list on request body identity while keeping the shared pick index (clamped to the local list) preserves the dispatch order for a single request, so the fix costs nothing in the common case. Per-member health in a named store with write-back is the right place for the state that genuinely should outlive the request.

Validation before merge: re-reconciled after #14149 landed, which is where the real work was. #14149 had extracted the MuseSpark block out of `opencode.ts` into its own module and wrapped `execute()` in `runInRequestContext(...)`, while #14464 had added the free-tier completion-retry — so `execute()` had to carry three things at once. Final shape:

```ts
async execute(input: ExecuteInput) {
  try {
    return await runInRequestContext(() =>
      withRequestShapeRetry(input, (i) => this.executeOnce(i))
    );
  } finally {
    releaseRequestList(input.body, this.accountHealth);
  }
}
```

The `finally` is outermost on purpose: it runs once, after every shape replay and after the request context exits, against the same `input.body` the list was keyed on.

I checked the things a clean-looking merge can quietly break here: MuseSpark is not duplicated back inline (it is only imported and re-exported), #14149's `_requestFormat`/`_clientSession` accessors and context wrap are intact, and #14464's four wiring points are byte-identical to the tip. The diff against the tip contains only your delta — no tip line reverted.

`opencode-accounts-per-request` 5/5, plus the sibling suites that would catch a botched resolution: `opencode-request-format-race` 4/4, `opencode-request-shape-retry` 34/34, `opencode-free-tier-refusal-rotation` 9/9, `opencode-free-tier-request-contract` 38/38 — **90 pass / 0 fail**. `typecheck:core` clean, `check:file-size` OK (`opencode.ts` at 1224 against the 1303 ceiling, no rebaseline needed), `check:changelog-integrity` OK.

Note: the release tip is currently base-red on `open-sse/executors/auggie.ts` (#14496). Inherited, unrelated to this diff.
2026-09-22 12:59:42 -03:00
Dizzle
8bf6b60a4b fix(sse): retry empty translated stream turns through the normal path (#14213)
Merged. Thank you, @maxmad64bis.

Four distinct shapes of "the turn came back empty" — reasoning-only 200, zero-chunk 502, pre-first-byte drop, header-then-quiet stall — collapsed into one retry through the normal credential path, with a bounded `EMPTY_TURN_RETRY_MAX` budget and the stall gated on `STREAM_READINESS_TIMEOUT_MS`. Retrying server-side *before* anything reaches the client is the right layer for this; the related issues you listed (#11902 client-side signalling, #12656 first-byte watchdog, #13603 combo-level hop) each sit one stage away, and you were explicit about which.

The thing that made this safe to take: `FLUSH_EMPTY_RETRY_ENABLED` defaults to `"false"` and the flag-off path is byte-identical. Verified on the final tree, not taken on trust.

Validation before merge: reconciled twice as the tip moved under it. The only conflict each time was the additive `_rebaseline_*` key at the top of `config/quality/file-size-baseline.json` — resolved keep-both, JSON re-parsed, 523 keys, every tip entry preserved. `flush-empty-retry` + `flush-empty-retry-hook` + `feature-flags-settings` + `server-owned-tool-loop-flag` **100 pass / 0 fail** across 12 suites on the final tree. `check:file-size` OK, `check:changelog-integrity` OK, `typecheck:core` clean.

Two numbers I re-measured rather than trusting:

- The feature-flag count. Both count assertions said 76; the tip has 75 and this PR adds exactly one, so 76 is right and needed no change. `docs/reference/FEATURE_FLAGS.md` totals and the `### Network (18)` heading agree.
- `open-sse/handlers/chatCore.ts` — your ceiling of 6392 was exact on your fork base but stale; the reconciled tree measures **6400**, which is your `+113` own growth on top of the tip's 6287. Set to 6400. Note that leaves the file sitting exactly at its ceiling with zero headroom, so the next PR to touch `chatCore.ts` will need a rebaseline of its own.

Note: the release tip is currently base-red on `open-sse/executors/auggie.ts` (#14496). Inherited, unrelated to this diff.
2026-09-22 12:42:22 -03:00
Dizzle
59de50e42a fix(opencode): keep the target format and client session per request, not on the shared executor (#14149)
Merged. Thank you, @maxmad64bis — this is the best-diagnosed bug of the batch.

Instance fields written at request start and read after later awaits is a textbook cross-request race, and the symptom you traced it to is a nasty one: a JSON caller of a Responses model getting `event: response.completed …` back because a Chat request happened to finish first. Moving target format and client session into a per-`execute()` context while keeping the public field names as accessors is exactly the minimal shape — callers outside `execute()` see no change at all.

Validation before merge: re-reconciled twice, because the tip moved under it. #14148 had already squash-merged, so the branch's copy of it collapsed onto the tip's identical content — `changelog.d/fixes/14148-opencode-refused-tool-shape.md`, `opencodeRequestShape.ts`, `opencodeFreeTierContract.ts` and `opencode-request-shape-retry.test.ts` all merged to one copy, nothing duplicated. The final delta against the tip is byte-exactly your commit `386c427e`: the MuseSpark extraction, the accessor pair, and the `runInRequestContext` wrap. Nothing of #14464's completion-retry wiring was reverted — its import, `freeTierRetryCtx()`/`attemptFor(input.body)`, the direct fast-path call and the rotation-loop refusal arm are all byte-identical to the tip.

`opencode-request-format-race` + `opencode-request-shape-retry` + `opencode-free-tier-refusal-rotation` + `opencode-free-tier-request-contract` 85 pass / 0 fail. `typecheck:core` clean, `check:file-size` and `check:changelog-integrity` OK — the extraction actually *shrinks* `opencode.ts` to 1233 against the 1303 ceiling, so no rebaseline was needed.

Note: the release tip is currently base-red on `open-sse/executors/auggie.ts` (#14496). Inherited, unrelated to this diff.
2026-09-22 12:19:39 -03:00
Dizzle
b5d6404a90 fix(rate-limit): persist per-connection overrides across restart and re-import (#14226)
Merged. Thank you, @maxmad64bis.

Two distinct bugs, one symptom, both correctly diagnosed: limiters were built *before* the overrides loaded, and the re-import `INSERT OR REPLACE` dropped the `rate_limit_overrides_json` column entirely. An operator setting a per-connection limit and losing it on the next restart — silently — is the kind of thing nobody reports as a bug because it just looks like the setting "didn't take". Loading before building and carrying the column on both re-import paths (preserving the stored value when the backup has none) covers it end to end.

Validation before merge: reconciled onto the current release tip — the merge was conflict-free, so no hunk was dropped or reverted anywhere; the diff against the tip is still exactly your 9 files. `tests/unit/rate-limit-overrides-{reimport,restart,startup}.test.ts` 9 pass / 0 fail. `typecheck:core` clean, `check:changelog-integrity` OK, `check:file-size` OK — I re-measured `src/lib/db/core.ts` on the reconciled tree with the gate's own count and it is 1800, exactly the ceiling you set, so your number needed no correction.

Note: the release tip is currently base-red on `open-sse/executors/auggie.ts` (#14496). Inherited, unrelated to this diff.
2026-09-22 12:18:15 -03:00
Dizzle
30a83ee8d0 fix(opencode): complete refused tool subsets with previously accepted names (#14464)
Merged — closes #14405. Thank you, @maxmad64bis.

The shape of the fix is right: a gated request that carries its own tools now gets the same completion the tool-less path already had (previously accepted names appended once, client entries unchanged), and when the completed shape is *also* refused the original 403 propagates with the observation store untouched — so a failed recovery leaves no residue to poison the next request. Reusing each arm's egress and never touching account health on failure is the detail that makes this safe to enable by default.

Validation before merge: reconciled onto the current release tip. Two conflicts and one silent break:

- `open-sse/executors/opencode.ts` import block and the `_rebaseline_*` key in `config/quality/file-size-baseline.json` — both additive, kept both sides.
- **The silent one, worth your attention:** `freeTierRetryCtx()` read `this._contractAttempt`, a field #14148 deleted when it moved the contract attempt onto the request body. Git auto-merged your new method next to the tip's field deletion without a marker, leaving two dangling references that would have failed the build. Rewritten to the tip's shape — `attemptFor(input.body)`, with `input` threaded through both call sites (fast path and the rotation-loop refusal arm). `FreeTierContractAttempt` still carries `borrowed` and `clientToolNames`, so the semantics are unchanged.

`opencode-free-tier-request-contract.test.ts` + `opencode-free-tier-refusal-rotation.test.ts` 47 pass / 0 fail, `check:file-size` and `check:changelog-integrity` green.

Your `open-sse/executors/opencode.ts` ceiling needed re-measuring twice: your original 1294 was against a tip since moved to 1251, and the reconciled tree measures **1303** (the Prettier pass on commit added two more lines than the first measurement caught). Set to 1303 with the annotation corrected.

Note: the release tip is currently base-red on `open-sse/executors/auggie.ts` (#14496). That is inherited and unrelated to this diff — verified identical on a pure-tip control checkout.
2026-09-22 11:37:57 -03:00
Dizzle
9e44a5d1cc style(proxy-registry): bring frozen files back to prettier format with rebaseline (#14218)
Merged. Thank you, @maxmad64bis.

Format drift on a frozen file is a real tax: the next person to touch `ProxyRegistryManager.tsx` or `parseBulkProxyImport.ts` either absorbs unrelated reflow into their diff or trips the frozen ceiling for something they did not write. Clearing it in a dedicated no-behavior PR is the right way to pay it.

I checked the one thing worth checking on a "format-only" claim that deletes 17 net lines from a test file: `tests/unit/proxy-registry-manager.test.ts` loses no assertion. Every deletion is Prettier joining a multi-line `[...].join("\n")` array or a wrapped call onto one line; all 35 `assert.equal` calls are untouched.

Validation before merge: reconciled onto the tip (the only conflict was the additive `_rebaseline_*` key at the top of `config/quality/file-size-baseline.json`, resolved keep-both). `tests/unit/proxy-registry-manager.test.ts` 35 pass / 0 fail on the final tree, `check:file-size` and `check:changelog-integrity` green.

One correction I made to your baseline entry: you froze `open-sse/utils/proxyFetch.ts` at 1275, which was exact on your fork base but stale on the tip — the file is 1268 there now (`split("\n").length`, the same count `scripts/check/check-file-size.mjs:81` uses). Set to 1268 and the annotation re-worded to say it was re-measured on the reconciled tip. The file is not one this PR touches; it was inherited drift you were absorbing.
2026-09-22 11:36:01 -03:00
Dizzle
652c4f3636 feat(plugins): register host sdk telemetry hook behind opt-in flag (#14235)
Merged. Thank you, @maxmad64bis.

The design is what made this easy to carry: gated on a new `telemetry` option that is `z.boolean().default(false)`, inert on a host that has no `aisdk` domain, marking matching inference calls in their options only (filtered by provider and SDK package), no fetch wrapping, no client assignment, and any registration failure degraded to a warning. Five tests, one per property.

One thing happened on our side and you should know exactly what: #14370 landed first and rewrote `src/index.ts` against the stable `@opencode/plugin` 2.0.12 contract, which collapses the per-event `aisdk` domains into a single named entry point `ctx.aisdk.hook(name, cb)`. Your registration block was written against the beta callable `ctx.aisdk.sdk(cb)`, so it was re-applied onto the new shape rather than merged as-is — mirroring how #14370 wired the sibling Gemini `"language"` hook. The `"sdk"` event on the stable contract carries exactly the three fields your callback already read (`model` / `package` / `options`), so nothing was lost: **the callback body is unchanged**.

In the test file, every `it(...)` body and every assertion is byte-identical to yours. Only the host fake moved, and only because it had to: `ctx.catalog` → `ctx.provider` + `ctx.model` (the tip's `assertContext` now requires those transforms and no longer knows `catalog`), and the two callable domains → one `hook(name, cb)` router — the same fake the tip's own `gemini-language.test.ts` uses. The header comment was updated where it cited the old contract version.

Validation before merge: `telemetry-sdk-hook.test.ts` 5 pass / 0 fail; the full `@omniroute/opencode-plugin-v2` suite 244 pass / 0 fail across 65 suites (= #14370's 239 plus your 5); `npm run build` and `tsc --noEmit` over src + tests under `strict: true` both exit 0; `typecheck:core`, `check:file-size` and `check:changelog-integrity` green. The net change to `index.ts` is purely additive — the few removed lines are prettier reflow of #14370's own lines.
2026-09-22 11:25:09 -03:00
Dizzle
007ea22f5f feat(proxies): show each pool member's last seen egress IP (#14364)
Merged. Thank you, @maxmad64bis — and thank you for taking the three conditions from #14250 seriously instead of reopening the same shape.

All three are answered in this PR: the helper ships with its consumer (`readPoolMemberEgressObservation` → dedicated route → dashboard lines), `proxy_host` is normalized at *write* time in `logProxyEvent` with a divergent stored-vs-query test proving a stored `Proxy.Example.COM` / `[::1]` now resolves, and the operator-visible measure is stated up front (15 distinct `(host, port)` couples over 24 h, 8 of them sharing an exit — the pool counts N members but serves fewer exits).

Validation before merge: reconciled onto the current release tip — the only conflict was the additive `_rebaseline_*` key at the top of `config/quality/file-size-baseline.json`, resolved keep-both; all 66 locale files auto-merged. `tests/unit/proxy-logs-egress-ip-by-proxy.test.ts` + `tests/unit/proxy-pool-member-egress-route.test.ts` 14 pass / 0 fail under the node runner, and `tests/unit/ui/PoolMemberEgressLines.test.tsx` 4 pass / 0 fail under Vitest (it is a `@vitest-environment jsdom` file, so it belongs to the `test-vitest:ui` job, not the node runner). `typecheck:core`, `check:file-size` and `check:changelog-integrity` green. Your `ProxyRegistryManager.tsx` ceiling of 1479 was re-measured on the reconciled tree and is exact (1477 + the import + the one-line mount).

Two things I checked and deliberately left as you wrote them:

- The edit to `src/lib/db/migrations/134_proxy_logs_egress_ip.sql` touches only the `--` comment; the `ALTER TABLE` line is byte-identical and the runner does not checksum migration contents, so an already-migrated install is unaffected.
- The three new keys land as `__MISSING__:` in the 65 non-English locales. That is the expected shape for a new key, and I am filling them in a follow-up rather than asking you to — nothing for you to do.

The route is correctly behind `requireManagementAuth` with Zod validation and `errorResponse()`, so no stack reaches a response body.
2026-09-22 11:23:28 -03:00
Dizzle
30f7088c74 fix(opencode-plugin-v2): port catalog publishing to stable provider contract (#14370)
Merged. Thank you, @maxmad64bis.

The reasoning holds: the stable 2.0 host no longer provides the beta `@opencode-ai/plugin` contract the plugin was written against, so the plugin simply failed to load there — moving catalog publishing to `Plugin.define` with `ctx.provider.transform` against the stable `@opencode/plugin` peer range is the fix rather than a preference. The diff is large, but it is contained entirely inside the `@omniroute/opencode-plugin-v2` workspace package and carries its own suite.

Validation before merge: boarded onto the current release tip together with four sibling PRs of yours — merged clean, no conflict against the tip or the siblings. `check:file-size`, `check:changelog-integrity` and `typecheck:core` green on the combined tree, and the boarded unit suites 36 pass / 0 fail.

Note for the follow-up: #14235 (the sdk telemetry hook) conflicts with this one on `src/index.ts` and will be re-applied on top of the new shape rather than merged as-is — nothing for you to do, I'll handle the reconciliation.
2026-09-22 10:40:29 -03:00
Dizzle
7d5292fcac fix(health): opt-in deep check sampling completions surface (#14236)
Merged. Thank you, @maxmad64bis.

A deep check that actually samples the completions surface is worth having, and this one is gated the way it should be: `DEEP_HEALTH_CHECK_ENABLED` plus management authentication, off by default, inert without configuration, anonymous callers never trigger a probe, one-token non-streaming request, 30 s cache, and only 502/503 raising the failover signal so a 4xx or a timeout cannot flap a healthy instance.

Validation before merge: boarded onto the current release tip alongside four sibling PRs of yours — merged clean, no conflict. `tests/unit/monitoring-deep-health.test.ts` green on the combined tree — all ten cases including flag-off inertness, the anonymous path and the cached-verdict identity (36 pass / 0 fail across the boarded suites), plus `check:file-size`, `check:changelog-integrity` and `typecheck:core`.
2026-09-22 10:40:15 -03:00
Dizzle
f577d2124e fix(usage): keep over-age pending requests visible with a marked state (#14319)
Merged. Thank you, @maxmad64bis.

Deleting an over-age pending entry hid exactly the signal an operator needs — a stuck request. Keeping it with a distinct marked state, leaving the 60 min limit, the 5 min cleanup cadence and the shared registry alone, and only letting the size cap still evict (marked-first, oldest-first) is a conservative way to get that signal back.

Validation before merge: boarded onto the current release tip alongside four sibling PRs of yours — merged clean, no conflict. `tests/unit/usage-pending-sweep.test.ts` green on the combined tree, including the idempotent re-pass, the single-finalize path and the cap-eviction ordering (36 pass / 0 fail across the boarded suites), plus `check:file-size`, `check:changelog-integrity` and `typecheck:core`.
2026-09-22 10:39:44 -03:00
Dizzle
30d0158272 fix(logging): omit request body before pipeline in call-log size fallback (#14253)
Merged. Thank you, @maxmad64bis.

The ordering argument is the right one: when an artifact overflows, the provider response is the part that says *why* the call failed, so dropping the whole pipeline before omitting the bodies threw away the only useful half. Omitting bodies first keeps that, and artifacts that overflow through the pipeline alone keep their previous behaviour — no cap raised, `truncateArtifactForStorage` untouched.

Validation before merge: boarded onto the current release tip alongside four sibling PRs of yours — merged clean, no conflict. `tests/unit/call-log-artifact-bodies-first.test.ts` green on the combined tree (36 pass / 0 fail across the boarded suites), plus `check:file-size`, `check:changelog-integrity` and `typecheck:core`.
2026-09-22 10:39:34 -03:00
Dizzle
84988bd355 test(providers): pin opencode 400 model-unavailable catalog rule at registry level (#14251)
Merged. Thank you, @maxmad64bis.

A registry-level pin for a rule that shipped in #13146 is exactly the kind of guard that stops a future edit from silently widening or dropping it, and the three cases (marked 400 locks the model, unmarked 400 stays terminal, cooldown comes from the rule) pin the behaviour at the right boundary.

Validation before merge: boarded onto the current release tip alongside four sibling PRs of yours — merged clean, no conflict. `tests/unit/opencode-400-model-unavailable-registry.test.ts` green on the combined tree, together with the other boarded suites (36 pass / 0 fail). Test-only change, no production file touched, so nothing to weigh on the size or complexity ratchets.
2026-09-22 10:39:26 -03:00
Jasmin Sehic
373c31f3dc feat(i18n): add Bosnian (bs) localization (#14187)
* feat(i18n): add Bosnian (bs) localization

* docs(i18n): re-adopt mirrors reformatted by the pre-commit hook

* test(i18n): count 67 locale files and accept the Bosnian 'Model' cognate; restore the bs ICU placeholder quotes

The Bosnian locale adds a 67th messages file (agnes-cn-provider pins the
count), 'Model' is the Bosnian word for the Recent Requests legend (same
cognate as hr/sr) and the retranslation had dropped the ICU literal quotes
around <your OmniRoute API key> in ccOnboardingKeyPlaceholder.

* docs(i18n): re-adopt mirrors reformatted by the pre-commit hook

* docs(i18n): regenerate the language bars and adopt the bar-only source edits after the base merge

* docs(i18n): re-adopt mirrors reformatted by the pre-commit hook

---------

Co-authored-by: diegosouzapw <8016841+diegosouzapw@users.noreply.github.com>
2026-09-22 09:28:43 -03:00
Diego Rodrigues de Sa e Souza
1c213eb156 fix(review): resolve findings from omni-code-review battery (release/v3.8.51 vs main, api/v1 + image handlers) (#14365)
Fixes from the 2026-09-21 E2E run of /omni-code-review (15 lenses, 3 rounds): delete-completed keyState audit without the redundant re-validation, honest image_size logging with a warn on clamp, one shared fetchUntrustedRemoteImage and one shared stringifyImageErrorForLog for both image sinks, tests. Follow-ups: #14332-#14335.
2026-09-22 08:54:21 -03:00
Diego Rodrigues de Sa e Souza
2c9938d14e fix(ci): raise Docker publish build heap budget to 12288 MB (#14325) (#14469) 2026-09-22 08:15:11 -03:00
Diego Rodrigues de Sa e Souza
07103e2f12 fix(providers): surface real transport cause instead of bare "fetch failed" (#14309) (#14463)
open-sse/utils/proxyFetch.ts already computes a detailed transport
diagnosis (DNS/socket error code, syscall, address) whenever a direct
fetch fails on both the pooled undici dispatcher and the native-fetch
fallback, attaching it to the thrown error as `.proxyFetchDetail`. But
src/lib/providers/validation/transport.ts::toValidationErrorResult()
only ever read `error.message` — always the generic "fetch failed"
string by undici/native-fetch design — and never `error.cause` (where
SafeOutboundFetchError's normalizeFetchFailure() stores the original
error carrying `.proxyFetchDetail`). The computed diagnosis was
silently discarded before it ever reached the dashboard.

toValidationErrorResult() now walks one level of `error.cause` when
the message is the generic "fetch failed" string and, if a
`.proxyFetchDetail` is found there, surfaces it (still routed through
sanitizeErrorMessage()) instead of the bare message.

Also extracted the direct-path detail-string builder (and the existing
redactProxyDetailsInMessage() redaction helper) out of proxyFetch.ts
into a new open-sse/utils/proxyFetchRedaction.ts module, applying the
same proxy-URL/credential redaction to the direct-path detail that the
proxy-path message already had — closing a latent redaction gap
between the two branches — without growing proxyFetch.ts past its
frozen file-size baseline.

This does not explain the reporter's own underlying transport failure
(likely local network/DNS/firewall config on their machine); it makes
that failure diagnosable instead of opaque.
2026-09-22 08:14:49 -03:00
Diego Rodrigues de Sa e Souza
38cbb7ef80 fix(sse): stamp encrypted_function_args:[] on collaboration function_calls (#14154) (#14447)
Codex MultiAgent V2 treats a collaboration function_call's arguments as
backend ciphertext unless the item carries an explicit
encrypted_function_args: [] plaintext-delivery marker (codex-rs
ToolCall::direct_source). The vendored ChatGPT-web bridge already emitted
this marker for spawn_agent/send_message/followup_task via its own local
plaintextCollaborationFields() helper, but the three general Responses-API
output emitters used for every non-OpenAI chat-format upstream never did:
the streaming translator (translator/response/openai-responses.ts), the
non-streaming client translator (handlers/chatCore/nonStreamingClientTranslate.ts),
and the Responses transformer (transformer/responsesTransformer.ts).

Extracts the marker into a shared
translator/response/openai-responses/collaborationPlaintextMarker.ts helper
(bridge.ts now imports it instead of keeping its own copy) and a
functionCallIdentity.ts helper that composes the existing #7936 identity
restoration with the new marker for the streaming translator. Wires the
Responses transformer with the same requestToolIdentityMap plumbing the
other two emitters already had (it previously restored neither namespace
nor the marker at all), threading the ledger through from
handlers/responsesHandler.ts before handleChatCore deletes the side
channel.
2026-09-22 08:14:12 -03:00
Diego Rodrigues de Sa e Souza
582a2027e8 fix(sse): parse deepseek-web double-pipe DSML invoke/parameter markup (#14208) (#14446)
deepseek-web's tool-call parser only recognized tags literally named
`tool`/`tool_call`. Some harness builds instead emit a well-formed grammar
wrapped in doubled full-width-pipe "DSML" namespace markers
(`<||DSML|| calls>`, `<||DSML|| invoke name="...">`,
`<||DSML|| parameter name="...">`), which neither this parser, the generic
single-pipe DSML normalizer, nor the canonical <tool> fallback recognized —
the block was silently dropped and the raw DSML text leaked to the user.

Add normalizeDsmlInvokeMarkup() to rewrite the DSML calls/invoke/parameter
grammar (single- and double-pipe) into the canonical <tool>/<parameter> tags
before tokenization, so parseDeepSeekToolCalls resolves it into a proper
tool_calls entry.
2026-09-22 08:13:45 -03:00
Diego Rodrigues de Sa e Souza
61b3546b17 fix(guardrails): fail closed on unknown-root paths in ambiguous prose (#14110) (#14445)
* fix(guardrails): fail closed on unknown-root paths in ambiguous prose (#14110)

PR #13295 narrowed findUnquotedPathEnd's ambiguity fallback to
failClosedAmbiguity only, but the redactUnquotedAbsolutePathSpans call site
only set that flag for Windows/file-URI/known-POSIX-root candidates. An
unquoted POSIX path whose root is not in POSIX_FILESYSTEM_ROOTS (e.g.
/custom/internal in a container), followed by ambiguous prose, fell through
unredacted -- regressing HR#12 (never leak err.message/paths).

Per owner decision on #14110 (fail-closed), extend mustFailClosed in
redactUnquotedAbsolutePathSpans to also cover an unknown-root POSIX candidate
(isPosixPath) once findUnquotedPathEnd returns -1. This intentionally also
fail-closes a bare, unanchored API route in prose (route- and filesystem-
shaped candidates are lexically indistinguishable with no second anchor) --
the #13144 guarantee that a route immediately followed by another absolute-
path span keeps its trailing prose is unaffected and re-asserted as a
co-located regression test.

Reactivates the #14110-SUSPENDED assertion in
error-public-boundaries-hardening.fixture.ts and folds in the proven repro.

* chore(quality): register the #14110 redaction test in stryker tap.testFiles
2026-09-22 08:13:21 -03:00
Diego Rodrigues de Sa e Souza
6c8a3958fd perf(release): resolve fragment origins with one git history walk in release:reconcile (#14440)
readFragments ran one `git log --diff-filter=A -- <file>` per fragment (≈4.5 s each on a loaded devbox → 364 fragments ≈ 30 min in the v3.8.51 round-3 pass) plus one `git show` per fragment. It now builds a path→oldest-adding-commit map from a single `git log --diff-filter=A --name-only` walk over changelog.d/ and reads every blob with one `git cat-file --batch`; the same fields (originHash/originPr/prefixPr/text) come out, phantom fragments keep their original origin. Measured: 5.3 s for the whole pass on the same box. Tests: tests/unit/reconcile-changelog-fragments.test.ts (temp git repos).
2026-09-22 08:12:55 -03:00
Diego Rodrigues de Sa e Souza
e21f17ed89 fix(api): settle logs export stream on mid-stream DB error (#13999) (#14439)
* fix(api): settle logs export stream on mid-stream DB error (#13999)

* refactor(api): extract the log-export stream builder to clear the new-code complexity ratchet (#13999)
2026-09-22 08:12:08 -03:00
Nguyen Thanh Dat
7a179de486 fix(translator): keep a Claude image whose source is an HTTPS URL (#14460)
* fix(translator): keep a Claude image whose source is an HTTPS URL

Claude accepts an image block as { source: { type: "url", url } } next to
base64. claude-to-openai.ts took that in its image case (592ca9b5c, 05/04)
but not in the tool_result branch below it, where image lifting was added
for base64 only (7b139fdb5, #5100, 27/06): a tool returning a URL image had
the raw block JSON.stringify'd into the tool message. claude-to-gemini.ts
had no URL branch at all — #13335 lifted base64 tool_result images there and
said so in its notes, leaving the URL half for a follow-up. This is it.

Lift a URL tool_result image into the following user turn, as the base64
half already does, and send a URL image to Gemini as fileData { fileUri },
the mapping helpers/geminiHelper.ts uses for an OpenAI image_url that is a
URL. HTTPS only, as validation/schemas/apiV1.ts already requires of every
media URL and as Gemini documents for an external fileUri: an empty, http:,
data: or file: url stays dropped instead of reaching Gemini as a fileUri it
would reject.

* docs(changelog): add fragment for #14460

---------

Co-authored-by: datrixlab <325650023+datrixlab@users.noreply.github.com>
2026-09-22 08:11:34 -03:00
小妍儿 ✨
49de88f621 fix(resilience): release admission lease on client abort after SSE response (#14456) (#14457)
* fix(resilience): release admission lease on client disconnect (#14456)

releaseChatAdmissionWhenDone wired lease release into three consumer-driven
paths only: pull-to-done, pull-throws, and cancel. A client that disconnects
mid-stream may stop pulling and never cancel, so none of them run and the
heavyweight slot is charged for the lifetime of the process. Once activeHeavy
reaches OMNIROUTE_CHAT_MAX_HEAVY_IN_FLIGHT every heavy request is shed with
503 chat_admission_busy, with waiting=0 and no real load.

Observe the request signal as a fallback release path and route all five call
sites through it. Release is idempotent so a late abort after a clean finish
cannot double-decrement.

* refactor(admission): extract lease release into chatAdmissionRelease

check:file-size freezes chatBodyAdmission.ts at 1206 lines and the fix pushed
it to 1242. Move the release binding into its own module and re-export it from
the original path, so every existing import site is unchanged. File is now
1159 lines; check:file-size passes.

---------

Co-authored-by: xiaoyaner0201 <xiaoyaner0201@users.noreply.github.com>
2026-09-22 08:10:27 -03:00
Goni Sulaiman
e2dc851a7f fix: repair rotted import paths and a drifted test fixture (#14412)
Detected by a full-repo Grounded (reference-integrity) audit and confirmed
with targeted tsc probes against the root tsconfig.json paths:

- src/lib/db/discovery.ts: logger import "../../open-sse/utils/logger"
  cannot resolve from src/lib/db/ (TS2307); use the @omniroute/open-sse
  alias like the sibling DB modules. Masked in CI because typecheck:core
  compiles an empty include set.
- scripts/ad-hoc/regen-opencode-config.ts: import and Usage header still
  pointed at the pre-move root scripts/ location (TS2307); fixed both so
  the script runs exactly as documented.
- tests/unit/combo/recovery-hint.test.ts: import renamed the ghost type
  RecoveryAction -> ComboRecoveryAction (TS2305) and rebuilt the
  emptyDiag() fixture on the current ComboDiagnostics shape (post-#12659);
  all 10 behavioral assertions unchanged.

Validation: node --import tsx/esm --test tests/unit/combo/recovery-hint.test.ts
(11/11 pass), scoped tsc probe (0 errors), eslint clean on changed files.

Audit reference: https://github.com/gonisulaimann/Grounded

Co-authored-by: Goni Sulaiman <gonisulaimann@users.noreply.github.com>
2026-09-22 08:07:54 -03:00
Abhishek Sharma
c68307a418 feat(dashboard): let synced models carry a context-window override (#14356)
* feat(dashboard): let synced models carry a context-window override

Models imported through a provider's /models import render as "imported" rows
with no edit affordance, so the manual context-window override #4125 added for
custom models was unreachable for exactly the models that need it most: third
-party OpenAI-compatible aggregators whose discovery metadata understates
context length. The GUI offered no correction path at all.

The backend already accepted the write. `PUT /api/provider-models` lists
`contextWindowOverride` in its compatOnly branch, which is the path taken when
no `customModels` row exists, and persists it through `setModelContextOverride`
with `source="manual"` — the source the 24h reconciler never overwrites.

The read path was the missing half. `GET /api/provider-models` attached
`contextWindowOverride` only to custom-model rows, so a value stored against a
synced model could be written and never read back: the UI had nothing to show
and nothing to seed an editor with. The GET now returns the provider's
overrides directly, and the row renders the same badge and inline editor as a
custom row.

#4125 semantics are unchanged and now shared rather than duplicated: the parse
helper moves to providerPageHelpers so "blank clears" and "zero is invalid"
have one definition instead of two that can drift.

Both new props are optional and the affordance renders only when the section
supplies a handler, so a row that cannot take an override is byte-identical to
before. Invalid input keeps the editor open and reports, rather than silently
writing a wrong value or discarding the one typed.

No new i18n keys: the contextWindowOverride* strings already ship in all 66
locales for the custom-model form.

Mutation-tested 4/4 — rendering the pencil unconditionally, treating blank as
invalid, accepting zero, and closing the editor on invalid input each fail the
new tests. tests/unit/ui is 1458 pass / 94 fail against 1452 / 94 on a stashed
tree: the same failures, plus my six.

Does not implement the max-output-tokens half of the issue: unlike
contextWindowOverride, no capability key is in the PUT compatOnly allowlist, so
that needs its own backend change and belongs in its own PR.

Closes #14337

* fix(dashboard): drop the invalid Badge variant on the override badge

`check:dashboard-typecheck` caught a real one: `Badge` has no `secondary`
variant, so the override badge raised TS2322 under
tsconfig.typecheck-dashboard.json.

My local `tsc --noEmit -p tsconfig.json` never saw it — that config does not
cover these files the same way, which is the whole reason the dashboard gate
exists as a separate scoped check with a frozen baseline.

Rendered as the same styled span CustomModelsSection already uses for this exact
badge, rather than picking another variant: same visual treatment as the custom
-model 🪟 badge, and one fewer component contract to get wrong.

With this, the dashboard gate reports the same 5 pre-existing errors on this
branch as on release/v3.8.51 — verified by running the check in a worktree on
the base — so the branch adds none.
2026-09-22 08:07:03 -03:00
Diego Rodrigues de Sa e Souza
6f1d4beabc chore(ci): add check:ai-attribution — reject AI/bot trailers and AI-generation footers (#14441)
Hard Rule #16 had no automated enforcement: eight contributor commits reached release/v3.8.51 carrying `Co-Authored-By: Claude …`, `Codex <codex@openai.com>` and `Claude-Session:` trailers through squash-merge bodies (#14436).

The gate runs in three places: the husky commit-msg hook (local), the quality.yml fast-gates loop (PR→release/**) and a PR-only lint step in ci.yml (PR→main). It reads the event payload on PRs and no-ops elsewhere. Human co-authors are explicitly allowed — only AI/bot names, AI-owned e-mail domains and AI-generation footers are rejected.

Refs #14436.
2026-09-22 07:41:57 -03:00
diegosouzapw
198b3bfd8a chore(quality): rebaseline opencode.ts 1247->1251 (train-10c tip drift)
The release tip went red on check:file-size after the 2026-09-22 merge wave:
#14179 grew open-sse/executors/opencode.ts past its frozen ceiling and the
PR->release fast-gates do not run check:file-size, so every merge-train
boarding afterwards inherits the red. Absorbed once at the tip under the
owner-approved train-rebaseline policy. Measured clean: check:file-size passes
on the tip with this entry.
2026-09-22 07:31:45 -03:00
luyuehm
27b4cabd8b feat(db): per-request cost ledger + per-key tpm/rpm/monthly quota (RIC-741) (#13610)
* feat(db): per-request cost ledger + per-key tpm/rpm/monthly quota (RIC-741)

Add M3 cost transparency + team autonomy:
- request_cost_ledger: one row per completed call with provider/model/token/
  unit-price/amount breakdown, written from the existing recordCost paths.
- api_key_quota_limits + api_key_quota_counters: KISS counter+threshold quota
  for tpm (tokens/minute) and rpm (requests/minute) via 2-bucket sliding
  window; monthly USD cap reads from the ledger month SUM.
- checkKeyQuota/recordKeyQuotaUsage domain gate (fail-open B16/B29), wired
  into enforceApiKeyPolicy pre-request and the usage-record post hooks.
- /api/usage/key-quota route + setKeyQuotaSchema for per-key config.
- Migration 177; tests cover ledger traceability and tpm/rpm/monthly break.

* fix(db): renumber cost-ledger/key-quota migration to 180

177 and 178 already landed on release/v3.8.51 by the time this branch was
analyzed (177_provider_connection_synced_models_at.sql,
178_memory_fts_au_conditional.sql); 179 also landed since. Renumber to the
next free slot and fix the file's own internal comment to match.

Co-authored-by: diegosouzapw <8016841+diegosouzapw@users.noreply.github.com>

* fix(db): use the canonical toNumber helper instead of local redefinitions

ESLint no-restricted-syntax bars new local toNumber definitions in favor of
@/shared/utils/numeric (#7879, DRY extraction) — replace the 3 near-identical
local copies in costLedger.ts, keyQuota.ts and costLedgerRecorder.ts. Also
wires keyQuota.ts's getKeyQuotaStatus to reuse the already-defined
toIsoWindowStart helper instead of duplicating the window-start math inline,
which fixes the unused-var lint error on that function.

Co-authored-by: diegosouzapw <8016841+diegosouzapw@users.noreply.github.com>

* refactor(sse): keep chatCore.ts under its frozen file-size ratchet

The recordCost() wiring for the per-request cost ledger added ~24 lines to
chatCore.ts, an already-frozen file (file-size-baseline.json caps it at
6146 lines). Extract the shared provider/model/tokens/serviceTier/requestId
breakdown into buildCostCtx() and the apiKeyInfo?.id && estimatedCost > 0
guard into recordChatCallCost() (both in src/domain/costRules.ts), and the
streaming ledger-details object into buildStreamLedgerDetails() in
streamingCost.ts. No behavior change: same 18 cost-ledger/domain-cost-rules
tests pass unmodified; net effect is chatCore.ts now at 6143 lines (3 under
the frozen ceiling) instead of 6165.

Co-authored-by: diegosouzapw <8016841+diegosouzapw@users.noreply.github.com>

* docs: bump the migration count to 178 after the new cost-ledger/key-quota migration

check:docs-all (stale-migrations, STRICT) failed because README.md, AGENTS.md
and llm.txt (plus its 65 docs/i18n/*/llm.txt mirrors, which must be exact
body copies of the root file) still said "177 migrations" after this PR
added migration 181_request_cost_ledger_and_key_quota.sql, bringing the
real count to 178.

Co-authored-by: diegosouzapw <8016841+diegosouzapw@users.noreply.github.com>

---------

Co-authored-by: Ant Rich <ant@richants.com>
Co-authored-by: diegosouzapw <8016841+diegosouzapw@users.noreply.github.com>
2026-09-22 07:23:04 -03:00
Pixma
2a33528f74 feat(providers): add Lyceum pay-per-use provider + credit quota tracking (#12474)
* feat(providers): add Lyceum pay-per-use provider + credit quota

Lyceum (lyceum.technology) is an OpenAI-compatible, usage-based inference
provider. Registered as a first-class apikey provider mirroring the
llmgateway/openrouter pattern:

- registry/lyceum: buildOpenAiCompatibleRegistryEntry (base
  https://api.lyceum.technology/openai/v1, live /models discovery,
  passthrough). Generic DefaultExecutor handles chat/embeddings — no
  custom executor or translator needed.
- apikey metadata card + AGGREGATOR_PROVIDER_IDS + PROVIDER_ENDPOINTS.
- lyceumQuotaFetcher: reads the credit balance from
  GET /api/v2/external/billing/credits (pay-per-use), surfaced as a
  "credits" window in Dashboard > Limits and quota-aware preflight.
  Registered via quotaTrackersBatch (keeps chat.ts frozen).
- usage leaf + dispatch + fetcher/supported/apikey-limits lists + label.
- tests: registry-shape (wave1-c) + 16-case quota fetcher/usage suite.
- docs: changelog fragment, regenerated PROVIDER_REFERENCE, provider
  count 354->355 across README/AGENTS/llm.txt (+42 i18n mirrors)/SVGs,
  file-size baseline bump for the gateways.ts catalog entry.

* docs(providers): regenerate provider reference for Lyceum (359→360)

Rebased onto release/v3.8.51 after #12462 (LLM Gateway DevPass quota) merged
and the base advanced. The live registry now totals 360 providers, so the
auto-generated reference and the diagram/marketing counts are regenerated
against the new base:

- npm run gen:provider-reference → docs/reference/PROVIDER_REFERENCE.md
- 359→360 in README.md, AGENTS.md, llm.txt (provider count), package.json
  description and the diagram SVGs (readme-hero, promise-pillars,
  comparison-table, cli-terminal, tier-flow-dark/light)
- node scripts/i18n/sync-llm-mirrors.mjs → 65 locale mirrors

Addresses the maintainer review asking to rerun gen-provider-reference.ts and
push the doc files missing from the original diff, which left Docs Gates red.

* test(providers): refresh count assertions + golden snapshot for the new Lyceum provider
2026-09-22 07:22:55 -03:00
Alex Chan
5f67e11c82 fix(deepseek): default to OpenAI Chat Completions instead of Responses API (#14316)
DeepSeek's primary public API is /chat/completions, not /responses. The
previous default caused multi-turn tool-call requests to fail with:

  400 The reasoning_text in the thinking mode must be passed back to the API

because the Responses API requires the caller to echo reasoning_text on every
turn, while the Chat Completions protocol does not.

Changes:
- format: "openai-responses" → "openai"
- baseUrl: .../responses → .../chat/completions
- Move openai-responses to alternateFormats ("Responses-compatible") so
  operators who explicitly need that path can still select it per-connection
- Update unit test assertions to match the new default and alternate count
2026-09-22 07:22:46 -03:00
Damian Pozimski
9a352bf3e5 feat(sse): forward the auto mode classifier beta so gateway sessions stay eligible (#14312)
* feat(sse): forward the auto mode classifier beta to Anthropic-format upstreams

* docs(changelog): fragment for the auto mode classifier beta pass-through

* refactor(sse): move the client beta application into anthropicHeaders
2026-09-22 07:22:38 -03:00
Gery_MK
956423712f fix(cli): probe every bind address before respawning so a wildcard listener is not read as free (#14307)
The #4425 port guard waits for the listen port to free up before respawning a
crashed child. It probed only 127.0.0.1, which does not detect the address
`omniroute serve` actually binds by default: 0.0.0.0.

Node sets SO_REUSEADDR on every listener it creates. On macOS/BSD that lets a
specific-address bind coexist with an existing wildcard bind (Linux still
rejects the overlap in LISTEN state), so binding 127.0.0.1:PORT succeeds while
another process holds 0.0.0.0:PORT. The probe therefore reported "free", the
wait resolved immediately, the respawned child failed with EADDRINUSE, and the
crash/restart cascade #4425 set out to fix continued — indefinitely under a
`KeepAlive` supervisor such as launchd or systemd.

Probe the wildcard and loopback (plus any caller-supplied host) and treat the
port as busy if any of them is occupied.

Verified on macOS 27.0 / Node 22.22.3: with a server holding 0.0.0.0:PORT,
`isPortFree(PORT)` returned true before and now returns false, and a child
rebind of 0.0.0.0:PORT fails with EADDRINUSE as expected.

Tests: tests/unit/supervisor-policy-4425.test.ts

Co-authored-by: Gery.MK Song <gery@macunzip.dev>
2026-09-22 07:22:29 -03:00
Xmon Dai
844e35cdcf fix(cli): route the copilot device flow through the github backend key and the real poll route (#14300) 2026-09-22 07:22:19 -03:00
Xmon Dai
1ebdbed56b fix(skills): expand nested string-shorthand property types in injected skill tool schemas (#14288) (#14291) 2026-09-22 07:22:10 -03:00
Peter Busscher
257f0a0d96 fix(providers): fail closed for retired gemini-cli routing (#14289)
* fix(providers): fail closed for retired gemini-cli

* docs(changelog): name fragment for PR 14289
2026-09-22 07:22:01 -03:00
Goni Sulaiman
4973b8b74d fix(cleanup): prune compression_engine_breakdown on retention schedule and usage resets (#14268) (#14285)
When stacked prompt compression telemetry was added, per-engine breakdown
logs were written to `compression_engine_breakdown`. While the parent
`compression_analytics` table was regularly trimmed on an operator-configurable
retention policy (default 30 days) and purged on usage resets,
`compression_engine_breakdown` was omitted from both `cleanup.ts` and
`RESET_TARGETS`. In long-running deployments (5+ months), this table
accumulated 731,000+ unpruned rows without any deletion path.

Wired `compression_engine_breakdown` into `cleanupCompressionEngineBreakdown()`
with a 30-day cutoff, hooked it into nightly auto-cleanup, and added the
table to `RESET_TARGETS` so manual purges clean it completely.

Fixes #14268

Co-authored-by: Goni Sulaiman <gonisulaimann@users.noreply.github.com>
Co-authored-by: Koosha Paridehpour <KooshaPari@users.noreply.github.com>
2026-09-22 07:21:52 -03:00
Isaias Amaral
130bcadb1b feat(kiro): expose the provider-native Opus 5 Max effort tier (#14284)
* feat(kiro): expose the provider-native Opus 5 Max effort tier

Advertise "<base>-max" for Kiro's claude-opus-5 in the Claude effort catalog,
allow "max" in the Kiro effort values, and enable the adaptive-thinking
envelope for claude-opus-5.

The original change (the effortStandardization / adaptiveThinking edits and
the kiro-opus-5-max-effort test) is by tarciorick. The scoped
KIRO_OPUS_5_MAX_VARIANT_RE guard is an addition: the shared
CLAUDE_EFFORT_SUFFIX_RE must stay byte-identical to its sibling copies (drift
guard in claude-effort-variants.test.ts), so the synthesized -max id is
excluded by a separate regex scoped to that one id.

* chore(changelog): add fragment for #14284
2026-09-22 07:21:44 -03:00
Isaias Amaral
e5dc5ceea5 fix(antigravity): never persist or send the manual-project sentinel as a projectId (#14282)
* fix(antigravity): never persist or send the manual-project sentinel as a projectId

ensureAntigravityProjectAssigned() returns __REQUIRES_GCP_PROJECT__ when Google
does not auto-provision a project (BYOP). Only the in-request path guarded it;
token refresh, models discovery and the executor refresh persisted it as the
connection's projectId. The connection then looked configured, was PREFERRED by
preferAntigravityConnectionsWithStoredProject, and every request went upstream
as projects/__REQUIRES_GCP_PROJECT__ (HTTP 400, no lockout, no account
failover), so one poisoned account served nearly all traffic.

Add isUsableAntigravityProjectId() (rejects blank + the sentinel) and use it in
the persist helper, the stored-project selector, token refresh, models
discovery and the executor. A row already poisoned now behaves as "no project":
it takes the typed 422 GCP_PROJECT_REQUIRED path and is dropped by the selector
when a healthy sibling exists.

* chore(changelog): add fragment for #14282
2026-09-22 07:21:35 -03:00
Goni Sulaiman
95a6e4e8db fix(providers): wire app-server websocket transport and normalize reasoning model aliases (#14277) (#14281)
When selecting the first-class `codex-app-server` provider directly through
the executor registry (`open-sse/executors/index.ts`), the lazy loader
instantiated `CodexAppServerExecutor({}, "codex-app-server")` without
supplying a WebSocket transport function, preventing any connection from
opening.

Additionally, `CodexAppServerExecutor.execute` passed the raw `input.model`
directly to `turn/start` without stripping reasoning suffix aliases (e.g.,
`gpt-5.5-medium`), which caused upstream app-server turns to fail because the
aliased name is not recognized as a valid model ID.

Fixes:
1. In `open-sse/executors/index.ts`, wire the shared WebSocket transport
   (`codex.getCodexAppServerWebsocketTransport()`) into the `codex-app-server`
   executor factory.
2. In `open-sse/executors/codex-app-server.ts`, use `splitCodexReasoningSuffix`
   to send `baseModel` to `turn/start` while passing the selected effort
   separately, prioritizing explicit model suffix selection over request body
   defaults (#2331). Also support `body.reasoning_effort` in `extractEffort`.

Added regression tests in `tests/unit/codex-app-server.test.ts` verifying:
- baseModel extraction and effort derivation from model suffixes on `turn/start`
- precedence of suffix effort over body reasoning effort
- forwarding of body reasoning effort when the model is unsuffixed
- registry executor initialization with injected transport

Co-authored-by: Goni Sulaiman <gonisulaimann@users.noreply.github.com>
Co-authored-by: Aref Alapour <aref-alapour@users.noreply.github.com>
2026-09-22 07:21:22 -03:00
Markus Hartung
b9d4ea77b2 fix(gemini): strip tilde-prefixed Standard Schema keys from tool parameters (#14279)
Gemini/antigravity returned a hard 400 ("Unknown name \"~optional\" ...
Cannot find field") the moment a tool's parameter schema contained a
`~optional` key, taking down every Gemini-family model in a combo's
fallback chain at once -- confirmed live: this exact error killed 100% of
a production combo's usable fallbacks for ~18 hours (the two remaining
candidates ahead of it were OpenCode free-tier models, permanently
inaccessible via API regardless).

Root cause: `~`-prefixed keys are the Standard Schema convention (Zod 4+,
Valibot, ArkType) for internal/vendor metadata, namespaced with a leading
`~` specifically so it can never collide with a real schema property name.
A tool built from one of those libraries leaked a literal `~optional` key
into a property's subschema. GEMINI_UNSUPPORTED_SCHEMA_KEYS already listed
the plain `"optional"` string, but `removeUnsupportedKeywords`'s exact-match
check doesn't catch the tilde-prefixed form, so it survived sanitization
and Gemini's OpenAPI 3.0 schema subset rejected the whole request.

Fix: strip any `~`-prefixed key at every schema level, the same way `x-`
vendor extensions are already stripped -- this covers `~optional` and any
other Standard Schema metadata key the same libraries may emit, rather than
only patching this one literal key.

Testing: new regression test confirmed failing on unpatched code and
passing after the fix; full existing Gemini/schema-stripping test suite
(495 tests) still green.
2026-09-22 07:21:14 -03:00
Jan Leon
282e2c4f9b fix(quota): prevent Vertex spend telemetry from exhausting accounts (#14276) 2026-09-22 07:20:56 -03:00
Goni Sulaiman
75305b7d9f fix(api): expand combo-ref targets when computing combo capabilities (#14271)
/v1/combos hardcoded multimodal: false for any combo containing a
combo-ref, while /v1/models resolved the same combo through
resolveNestedComboTargets and intersected the real leaves' vision flags.
The two catalogs disagreed about the same combo (#14232).

projectCombo now accepts the combo collection and expands resolvable
combo-refs with the same resolver the routing runtime dispatches
against. Dangling refs and self-cycles contribute no targets and keep
the conservative false, matching the doc contract. The vscode and raw
vscode import surfaces get the same wiring.

Co-authored-by: Goni Sulaiman <gonisulaimann@users.noreply.github.com>
2026-09-22 07:20:46 -03:00
lorenzozane
992d274938 docs: explain Gemini Web Chromium setup (#14264) 2026-09-22 07:20:30 -03:00
Bob.Hou
a27d2e3c4e providers/discovery: do not treat max_tokens as the context window (#14260)
Anthropic's Models API reports the window as max_input_tokens and
the output cap as max_tokens. Putting max_tokens in the window
candidate list made Claude Opus 5 advertise 128K instead of 1M.

Read max_input_tokens for the window. Keep max_tokens on the
output-limit list next to max_output_tokens.

Signed-off-by: Minxi Hou <houminxi@gmail.com>
2026-09-22 07:20:14 -03:00
alfred-rootson
1fb7c9d7fc fix(egress): strip _omniroute* markers at shared pre-executor boundary (#14252)
Ensure internal control markers (e.g. _omnirouteSkipContextRelay,
_omnirouteInternalRequest) injected during universal/context handoff
are stripped at normalizeAttemptBody before reaching any executor.
Prevents leaks on custom executors that serialize request bodies
independently.

Includes cross-layer regression test asserting that universal handoff
dispatches contain no _omniroute* keys.
2026-09-22 07:20:01 -03:00
Ishan Parihar
cd396b06e2 fix(sse): price the default keepalive threshold below the client watchdog and stop leaking Anthropic finish reasons (#14247)
* fix(sse): stop racing the client first-byte watchdog and leaking Anthropic finish reasons

Two independent faults made combo streaming unusable from a strict OpenAI client
(oh-my-pi/omp) that had previously worked.

1. The early-stream keepalive threshold equalled the tightest client first-byte
   watchdog. Every combo request reached the resolver as a bare name with no
   provider prefix, so it took the 2 000 ms default — and a combo handler
   essentially never resolves inside 2 s, because it probes candidate legs first.
   The default threshold therefore *was* the combo time-to-first-byte: the
   synthetic keepalive byte landed at 2 003 ms while omp aborted at 2 011–2 016 ms
   having received nothing, so the request died as a 499 with no diagnosable error.
   Price the default tier at half the observed watchdog (1 000 ms) and tighten the
   keepalive cadence from 2 500 ms to 1 500 ms so the inter-byte gap stays inside
   the same budget. The slow tier keeps its deliberately long value: for
   browser-session and anonymous-fallback providers, committing early only adds SSE
   framing to a request the caller already expects to wait on.

2. normalizeOpenAICompatibleFinishReason passed cross-vendor synonyms through raw,
   so an Anthropic-style `end_turn` returned by an OpenAI-format leg landed on the
   OpenAI wire. omp reads an unrecognized finish_reason as a provider fault and
   fails the whole turn with `Provider finish_reason: end_turn` — after the text had
   already streamed, discarding a completed assistant message. Map the Claude
   stop_reason vocabulary onto its exact OpenAI equivalent, keeping the deliberate
   raw passthrough for genuinely unknown values and for the abort reasons whose
   whole purpose is to not present as a clean `stop`.

Also make the combo 403 actionable: the bare "Combo X is not allowed for this API
key" reads like a routing bug, so callers retry the same doomed model or fall
through a whole compaction cascade instead of adding the combo to the key.

* changelog: add the #14247 fragment
2026-09-22 07:19:52 -03:00
Bl0ck
4aed815294 fix(db): avoid live WAL truncate after incremental reclaim (#14244) 2026-09-22 07:19:45 -03:00
Xmon Dai
c45884c9e8 fix(sse): treat antigravity empty completions with a normal stop as valid 200s (#14160) (#14243)
An empty completion from antigravity's Gemini can be a real answer:
some prompts legitimately produce no text, and the upstream reports a
normal terminal finish reason (STOP -> "stop"). The fake-success guard
in isEmptyContentResponse flagged these regardless, so the
non-streaming leg rewrote them into synthetic 502s that fed model
lockout — a few hundred such "failures" a day kept most of the
reporter's 22-connection pool excluded and starved unrelated clients.

The guard exists for free-tier/scraping providers whose failure mode is
an empty 200 shell (#13461), so scope the exemption the same way the
repo scopes classifyFakeSuccessBody: a trusted-provider allowlist. On
antigravity, an empty completion with a normal stop reason (openai
"stop", claude "end_turn") now passes through as a valid 200; every
other provider keeps the existing guard, and antigravity responses with
no terminal stop reason are still flagged.
2026-09-22 07:19:33 -03:00
Xmon Dai
83a6e9a526 fix(providers): stop rewriting GA qwen3.8-max to preview on opencode-go (#14181) (#14242)
OpenCode Go now serves a GA qwen3.8-max, but the built-in deprecation
table (written when the model shipped only under the -preview id)
rewrote it to qwen3.8-max-preview before dispatch, and the upstream
rejects the preview id with a 401. The provider-aware exemption in
resolveModelAlias never fired because the opencode-go static registry
lacked the GA id.

- declare qwen3.8-max in the opencode-go registry (qwen family there is
  text-only and routes through the Claude translator per #2292)
- give the GA model its own MODEL_SPECS row instead of aliasing it to
  the preview spec

The rewrite stays for preview-only providers (qoder, bailian-coding-plan,
qwen-cloud-token-plan) and for callers with no provider in hand.
2026-09-22 07:19:26 -03:00
Dizzle
156f7018c2 fix(sse): neutral 403 code for non-quota refusals (#14234)
Co-authored-by: Max <maxmad64@gmail.com>
2026-09-22 07:19:12 -03:00
Dizzle
bebbad661a fix(proxies): classify refusal cause and hang, add bounded opt-in recovery pass (#14233)
* fix(proxies): classify refusal cause and hang, add bounded opt-in recovery pass

* docs(env): document PROXY_HEALTH_RECOVERY_INTERVAL_MS

The recovery-pass interval this PR adds is read from process.env, so the
env/docs contract gate (check-env-doc-sync) requires it in .env.example and
docs/reference/ENVIRONMENT.md.

Co-authored-by: diegosouzapw <8016841+diegosouzapw@users.noreply.github.com>

---------

Co-authored-by: Max <maxmad64@gmail.com>
Co-authored-by: maxmad64bis <maxmad64bis@users.noreply.github.com>
Co-authored-by: diegosouzapw <8016841+diegosouzapw@users.noreply.github.com>
2026-09-22 07:19:01 -03:00
Dizzle
84fd18af8d fix(proxies): share scopeId guard across validation and stored assignments (#14231)
Co-authored-by: Max <maxmad64@gmail.com>
2026-09-22 07:18:53 -03:00
Mike Henke
95872d5a24 fix(providers): route opencode-zen GPT-5.6 family to the Responses API (#14230)
* fix(providers): route opencode-zen GPT-5.6 family to the Responses API

Upstream serves the GPT-5.6 trio only on /responses; /chat/completions
answers 503 "Endpoint is unavailable" (live-verified 2026-09-19 against
opencode.ai/zen/v1 with the same key on both endpoints, gpt-5.6-luna;
sol/terra declared from the same upstream endpoint docs). The zen registry
tagged muse-spark-1.2 with targetFormat:"openai-responses" but never the
GPT-5.6 entries, so OpencodeExecutor.buildUrl() posted them to the chat
endpoint. #12196 made the same declaration for gpt-5.6-luna on opencode-go.

Test: tests/unit/opencode-zen-gpt56-responses-format.test.ts, red without
the registry change, green with it, muse-spark control included.

* chore(changelog): add fragment for #14230
2026-09-22 07:18:41 -03:00
Yuri Gui
535ed50c2f fix(dashboard): zero-fill model usage chart data (#14228)
* fix(dashboard): zero-fill model usage chart data

* docs(dashboard): add model usage comparison screenshots
2026-09-22 07:18:34 -03:00
Dizzle
e4e41ac696 fix(subscription): serve last known good nodes instead of emptying the pool on fetch or parse failure (#14222)
Co-authored-by: Max <maxmad64@gmail.com>
2026-09-22 07:18:25 -03:00
Dizzle
a204799093 fix(proxies): order pool candidates by crossed short-memory health signals (#14221)
Co-authored-by: Max <maxmad64@gmail.com>
2026-09-22 07:18:14 -03:00
Dizzle
df717be710 fix(proxy-logs): keep the HTTP status the provider actually returned on search rows (#14220)
Search proxy-log rows always read null (no response received), even when the
provider answered with a 429, 403, or 500, so operators could not tell a real
refusal from a transport failure. Forward response.status at the four
emitEvent call sites; locally synthesized codes (envelope, transport) stay
null, matching the chat writer. Verified: new test 6/6 RED-then-GREEN,
neighbor search-432 7/7.

Co-authored-by: Max <maxmad64@gmail.com>
2026-09-22 07:18:04 -03:00
Domenico Massafra
4970f5f8ba fix(providers): separate Codex GPT-5.6 image catalog ids (#14216)
Co-authored-by: ginettododo <117327638+ginettododo@users.noreply.github.com>
2026-09-22 07:17:56 -03:00
andreyzagid-tech
10c8aaae82 fix(auggie): resolve spawn EINVAL when running the CLI shim on Windows (#14215) 2026-09-22 07:17:47 -03:00
lorenzozane
f204c4ec2c fix(quota): expire stale Antigravity 429 exhaustion (#14211) 2026-09-22 07:17:34 -03:00
lorenzozane
3b0007742b fix(sse): clarify compressed context token accounting (#14210) 2026-09-22 07:17:25 -03:00
Ercan Er
0fcac56cba fix(db): return undefined for empty bun:sqlite .get() results (#14203)
* fix(db): return undefined for empty bun:sqlite .get() results

bun:sqlite's Statement.get() returns null when no row matches, while
better-sqlite3, node:sqlite and sql.js return undefined. The call sites
are typed and written against undefined (`get(...) as Row | undefined`,
and isExclusiveConnectionActivelyLeased compares with `!== undefined`),
so when the server runs under Bun every connection looked exclusively
leased: dashboard connection tests reported LEASE_ACTIVE, usage refresh
was deferred with 409, and the model-sync scheduler found no connections
to sync.

Normalize the no-row result in the Bun adapter so all drivers share one
contract. Covered by a driver-independent unit test that runs in the
Node shards and a Bun-only test against the real bun:sqlite driver.

* docs: add changelog fragment for #14203
2026-09-22 07:17:12 -03:00
Dizzle
985136b56f fix(sse): sticky head + store drain for opencode rotation (opt-in) (#14202)
Co-authored-by: Max <maxmad64@gmail.com>
2026-09-22 07:17:04 -03:00
Bob.Hou
dcaec5e111 quota/kimi: route coding hosts to /usages and map used_ratio (#14201)
Moonshot connections whose baseUrl is api.kimi.com/coding were classified
as Open Platform and queried users/me/balance, which those Allegro keys
reject. Membership windows live on GET /coding/v1/usages as
usages.limit_7d.used_ratio (and limit_5h). Host classification now treats
an explicit coding URL as Coding Plan, the dispatcher and combo preflight
follow it, and used_ratio is mapped onto Code 7d/5h.

Signed-off-by: Minxi Hou <houminxi@gmail.com>
2026-09-22 07:16:53 -03:00
Goni Sulaiman
a77b8a1048 fix(api): detect /v1beta ingress bodies as openai, not claude (#14185)
The /v1beta Gemini ingress converts gemini to openai chat format before
re-entering handleChat, but the request URL keeps its /v1beta path. With
no path branch, detectFormat's max_tokens heuristic misread the
converted body as claude: non-streaming replies came back anthropic-
shaped and were dropped by the route's OpenAI-to-Gemini converter, and
streaming replies were 200 SSE responses with zero bytes, which made
the antigravity CLI and the Google GenAI SDK loop forever.

Treat a /v1beta path as openai chat unless the body still carries the
raw gemini contents envelope, so client-raw-request contexts that pass
unconverted gemini bodies keep the existing gemini detection.

Fixes #14165

Co-authored-by: Goni Sulaiman <gonisulaimann@users.noreply.github.com>
2026-09-22 07:16:44 -03:00
Goni Sulaiman
898f26b366 fix(proxy): probe http proxies on their default port 80 (#14184)
The WHATWG URL parser drops the port when it equals the scheme default,
so an http proxy URL like http://user:pass@host:80 reached the probe
with an empty port and defaultPortForScheme handed back 8080. The probe
then failed, the request was rejected with Proxy Fast-Fail, and the
healthy connection went into cooldown.

The https and socks5 defaults were already correct; only the http case
was wrong. Proxies with explicit non-default ports are unaffected.

Fixes #14157

Co-authored-by: Goni Sulaiman <gonisulaimann@users.noreply.github.com>
2026-09-22 07:16:33 -03:00
Dizzle
9747cf297e fix(build): combos page drops server-only import (#14179)
Combos page resolves provider prefixes through the client-safe alias map instead of the server-side model service.

Co-authored-by: Max <maxmad64@gmail.com>
2026-09-22 07:16:25 -03:00
navanshjagetiya7-eng
f987ab6566 feat(cli-tools): add Oh My Pi (omp) CLI detection and configuration support (#14178)
- Register omp in cliRuntime.ts getKnownToolPaths for Windows (omp.cmd, omp.exe, %LOCALAPPDATA%\omp\omp.exe, ~/.omp/bin/omp.exe) and POSIX (~/.omp/bin/omp)
- Fix double .omp typo in cliTools.ts config path documentation
- Switch omp-settings route to openai-models-list discovery mode with injectV1: false
- Add case "omp" to extractEndpointFromConfig in all-statuses route
- Handle omp YAML configuration in checkToolConfigStatus before JSON parsing
- Add unit tests in cli-runtime-detection.test.ts and check-tool-config-status.test.ts
- Enhance cli-settings-omp.test.ts with cross-platform environment isolation
2026-09-22 07:16:17 -03:00
Jan Leon
5229a40db3 fix(routing): honor manual auto weights and weight weekly reset urgency higher (#14176)
* fix(combos): manuelle Routing-Gewichte wirksam übernehmen

* fix(routing): Weekly-Reset im Auto-Faktor stärker gewichten

* docs(changelog): Routing-Fix dem PR zuordnen
2026-09-22 07:16:03 -03:00
Jan Leon
21b770b808 fix(codex): recover reset cooldowns and expose account release (#14175)
* fix(codex): Quota-Sperren freigeben und Account-Limits anzeigen

* fix(codex): Cache-Schlüssel im Release-Port typisieren

* docs: Codex-Sperrenkorrektur im Changelog erfassen
2026-09-22 07:15:53 -03:00
Nguyen Thanh Dat
3934dc00d3 fix(v1beta): keep inlineData and parts sent next to a functionResponse (#14173)
* fix(v1beta): keep inlineData and parts sent next to a functionResponse

convertGeminiToInternal() read only text, functionCall and
functionResponse parts. An image, PDF or audio sent as inlineData to
/v1beta/models/{m}:generateContent never reached the provider, and an
image-only turn became an empty user message. A content holding a
functionResponse returned its tool messages alone, dropping every other
part; gemini-cli sends a binary file a tool read exactly that way.

Map inlineData in non-model turns to image_url data URLs, and split a
content with functionResponse parts into the responses and the rest
before conversion, as the Gemini request translator already does.

* docs(changelog): add fragment for #14173

---------

Co-authored-by: datrixlab <325650023+datrixlab@users.noreply.github.com>
2026-09-22 07:15:45 -03:00
Aaron Scherer
f82ae804aa fix(guardrails): cache the vision-bridge no-candidate outcome (#14161)
* fix(guardrails): cache the vision-bridge no-candidate outcome

* docs(changelog): number the fragment for #14161

* docs(env): document OMNIROUTE_VISION_BRIDGE_NEGATIVE_CACHE_MS

The negative-cache TTL this PR introduces is read from process.env, so the
env/docs contract gate (check-env-doc-sync) requires it in .env.example and
docs/reference/ENVIRONMENT.md.

Co-authored-by: diegosouzapw <8016841+diegosouzapw@users.noreply.github.com>

---------

Co-authored-by: cryptiklemur <cryptiklemur@users.noreply.github.com>
Co-authored-by: diegosouzapw <8016841+diegosouzapw@users.noreply.github.com>
2026-09-22 07:15:37 -03:00
Goni Sulaiman
b6eaf3734f fix(ci): run the env/doc sync gate on checkouts whose paths need URL encoding (#14155)
Co-authored-by: Goni Sulaiman <gonisulaimann@users.noreply.github.com>
2026-09-22 07:15:24 -03:00
Dizzle
9962f9f65c fix(auth): serve a locked no-auth model as a retryable cooldown (#14151)
No-auth model lock now surfaces as a retryable cooldown (wait or 429 with Retry-After) instead of 401, and request-scoped refusals skip the token refresh.

Co-authored-by: Max <maxmad64@gmail.com>
2026-09-22 07:15:13 -03:00
Dizzle
771c376387 fix(opencode): replay a request in the other tool shape when its tools are refused (#14148)
Tool-shape refusal replays once in the other shape and remembers the working shape per prompt digest.

Co-authored-by: Max <maxmad64@gmail.com>
2026-09-22 07:14:56 -03:00
Rafa Martins
f4d7d8a53a fix(combos): use provider node prefix alias in combo builder qualifiedModel (#14143)
Fixes #14135

Ensure custom provider nodes configured with a prefix alias generate
model options with `qualifiedModel: <alias>/<model>` rather than
falling back to the raw internal database node ID. This ensures
proper routing through the configured prefix and accurate catalog
and capability lookups in /v1/models.
2026-09-22 07:14:45 -03:00
Rafa Martins
f3720b9a7c fix(combo): exempt per-model-quota 403 from connection exhaustion (#14136) (#14140) 2026-09-22 07:14:36 -03:00
dependabot[bot]
ed384009a8 chore(deps): bump github/codeql-action from 4.37.9 to 4.38.0 (#14130)
Bumps [github/codeql-action](https://github.com/github/codeql-action) from 4.37.9 to 4.38.0.
- [Release notes](https://github.com/github/codeql-action/releases)
- [Changelog](https://github.com/github/codeql-action/blob/main/CHANGELOG.md)
- [Commits](https://github.com/github/codeql-action/compare/v4.37.9...v4.38.0)

---
updated-dependencies:
- dependency-name: github/codeql-action
  dependency-version: 4.38.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-09-22 07:14:28 -03:00
dependabot[bot]
84eae29d31 chore(deps): bump github/codeql-action/init from 4.37.9 to 4.38.0 (#14129)
Bumps [github/codeql-action/init](https://github.com/github/codeql-action) from 4.37.9 to 4.38.0.
- [Release notes](https://github.com/github/codeql-action/releases)
- [Changelog](https://github.com/github/codeql-action/blob/main/CHANGELOG.md)
- [Commits](cdf488f595...b96794f015)

---
updated-dependencies:
- dependency-name: github/codeql-action/init
  dependency-version: 4.38.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-09-22 07:14:20 -03:00
dependabot[bot]
e8413da450 chore(deps): bump codecov/codecov-action from 7.0.0 to 7.1.0 (#14128)
Bumps [codecov/codecov-action](https://github.com/codecov/codecov-action) from 7.0.0 to 7.1.0.
- [Release notes](https://github.com/codecov/codecov-action/releases)
- [Changelog](https://github.com/codecov/codecov-action/blob/main/CHANGELOG.md)
- [Commits](fb8b3582c8...0b35c9ecc4)

---
updated-dependencies:
- dependency-name: codecov/codecov-action
  dependency-version: 7.1.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-09-22 07:14:06 -03:00
dependabot[bot]
ddd2aec818 chore(deps): bump github/codeql-action/analyze from 4.37.9 to 4.38.0 (#14127)
Bumps [github/codeql-action/analyze](https://github.com/github/codeql-action) from 4.37.9 to 4.38.0.
- [Release notes](https://github.com/github/codeql-action/releases)
- [Changelog](https://github.com/github/codeql-action/blob/main/CHANGELOG.md)
- [Commits](cdf488f595...b96794f015)

---
updated-dependencies:
- dependency-name: github/codeql-action/analyze
  dependency-version: 4.38.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-09-22 07:13:58 -03:00
dependabot[bot]
c198720b2c chore(deps): bump actions/setup-node from 5 to 7 (#14126)
Bumps [actions/setup-node](https://github.com/actions/setup-node) from 5 to 7.
- [Release notes](https://github.com/actions/setup-node/releases)
- [Commits](https://github.com/actions/setup-node/compare/v5...v7)

---
updated-dependencies:
- dependency-name: actions/setup-node
  dependency-version: '7'
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-09-22 07:13:51 -03:00
Joao Pedro Mota
9e488cde46 docs(DOCKER_GUIDE): explain the -web image variant where image users will find it (#14125)
Picking a :<channel> tag without the -web suffix only fails at request
time for web-session providers (gemini-web, claude-web, claude-turnstile)
with a node_modules-looking playwright error and no remedy in the guide.

- Release Channels: what -web adds (runner-web: Playwright + Chromium),
  the deferred failure symptom, and the remedy per install method.
- Dockerfile Stages: add the runner-web row + build example.
- Available Profiles: add the web profile row (SELF_HOST_GUIDE links
  here for its web-cookie troubleshooting item); drop a stale count.

Closes #14105

Co-authored-by: Mistertelecom <noc@ysoftware.com>
2026-09-22 07:13:44 -03:00
Joao Pedro Mota
19b61c88d4 fix(translator): lift Responses tool-output images into a multimodal user message (#14123)
#14111 (follow-up to #8459): function_call_output / custom_tool_call_output
that carry input_image parts kept only a placeholder — the image never
reached Chat-backed vision models (Codex view_image).

The tool message stays text-only (text + placeholder, no raw base64), and
each image is now lifted into a following multimodal user message as an
Chat Completions image_url part, in output order, detail preserved.

Closes #14111

Co-authored-by: Mistertelecom <noc@ysoftware.com>
2026-09-22 07:13:29 -03:00
Zackaria.A
b51e9bb344 fix(providers): keep array subscripts in perplexity-web code output (#14122)
CITATION_RE strips any [n] token and cleanResponse() runs over the whole
answer before tool mode parses <tool> text into tool_calls, so subscript
indexing was removed from code: print(arr[0], arr[12]) came back as
print(arr, arr), in rendered code blocks and in write_file arguments
alike.

Citation cleanup now skips protected regions — fenced code blocks, inline
code spans and <tool> payloads — via CODE_SPAN_RE and stripCitations().
Prose citations are stripped exactly as before, including the leading
space folded into CITATION_RE by #14009.

Closes #14121

Co-authored-by: Zicocoder <Zicocoder@users.noreply.github.com>
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
2026-09-22 07:13:21 -03:00
Xmon Dai
6cb34028a3 fix(api): sanitize /v1/batches create error body (Hard Rule #12) (#14115)
POST /api/v1/batches returned the raw error.message from its catch block,
so a DB-layer failure could ship file paths, SQL fragments or internal
identifiers to the client. Route it through sanitizeErrorMessage() from
open-sse/utils/error.ts, the same shared sanitizer every other /v1 route
already uses, keeping the existing 400 + invalid_request_error shape and a
non-empty message fallback.

Adds route-level coverage driving the real POST handler with a real API key
and seeded input file: the batch INSERT is made to throw a path-and-stack
laden SQLITE_CANTOPEN through the repo's own setDbInstance seam (no module
mocking), asserting the path/filename/stack never reach the body, plus a
control that a clean message survives sanitization intact.

Closes #14089
2026-09-22 07:13:11 -03:00
Kaung Min Khant
8183e95ae8 fix(video): honor video job polling overrides (#14114)
* fix(video): honor video job polling overrides

* fix(video): read Agnes 2.5 result URL from metadata
2026-09-22 07:12:53 -03:00
Joao Pedro Mota
60a2097255 docs: fix three stale/missing support entries (#14108)
Closes #13998

1. .env.example: CREDENTIAL_HEALTH_CHECK_INTERVAL default is 60m (3600000), not 5m (300000)
2. README.md: clarify OMNIROUTE_SKIP_POSTINSTALL only skips the native SQLite warm-up
3. TROUBLESHOOTING.md: add Windows PATH guidance for "omniroute is not recognized"

(The OpenCode header-defaults half of #13998 was already fixed upstream by #14013.)

Co-authored-by: Mistertelecom <noc@ysoftware.com>
2026-09-22 07:12:45 -03:00
Paco Cartones
9533749fba test(translator): cover computeFinishReason stop/tool_calls contract (#14100)
Pins that finish_reason is 'tool_calls' when a tool call was emitted (toolCallIndex > 0)
or one is still open (currentToolCallId), and 'stop' otherwise — including the sticky
currentToolCallId OR-branch that keeps a mid-stream tool call from reporting as stop.

Co-authored-by: pacocartones <pacocartones@users.noreply.github.com>
2026-09-22 07:12:37 -03:00
Paco Cartones
e5a2f9aa6f docs(opencode-provider): correct default model count (4 -> 8) (#14099)
OMNIROUTE_DEFAULT_OPENCODE_MODELS in src/index.ts lists 8 models, but the README said
'Default: 4 curated models' and 'the default 4 may be hidden'. The list grew to 8
(cross-checked against the sibling opencode-plugin README and the package tests). Correct
both mentions.

Co-authored-by: pacocartones <pacocartones@users.noreply.github.com>
2026-09-22 07:12:28 -03:00
Paco Cartones
d127fc6480 test(translator): cover resolveLocalToolCallIndex remap contract (#14098)
Pins the local-index remap that fixed the 2026-09-02 output_index-gap incident:
raw upstream tool_call indices map onto a contiguous 0-based first-seen sequence,
idempotently, honouring a seeded next and coercing numeric/string keys.

Co-authored-by: pacocartones <pacocartones@users.noreply.github.com>
2026-09-22 07:12:13 -03:00
Paco Cartones
31cc3d63bf test(translator): cover enforceToolResultAdjacency ordering repair and unpaired fallback (#14097)
Pins two currently-untested behaviors of the OpenAI->Claude tool-result adjacency
repair: a tool_result arriving after intervening user text is moved adjacent to its
tool_use turn, and an unmatched tool_result is preserved as unpaired text rather than
dropped.

Co-authored-by: pacocartones <pacocartones@users.noreply.github.com>
2026-09-22 07:12:04 -03:00
Paco Cartones
cb583db280 fix(sse): guard STREAM_HISTORY_MAX parse so completed-stream history stays bounded (#14095)
parseInt(process.env.STREAM_HISTORY_MAX) returned NaN for a non-numeric value, so the
'completedStreams.length > MAX' trim never ran and history grew without bound (a slow
leak in a long-lived SSE process). Extract resolveMaxCompletedHistory(), which falls back
to 50 on non-numeric/negative input and honours valid values including 0. Adds a unit test.

Co-authored-by: pacocartones <pacocartones@users.noreply.github.com>
2026-09-22 07:11:55 -03:00
Paco Cartones
c20ff10ed2 fix(sse): allow a stream to fail from INITIALIZED (setup-time failure) (#14094)
VALID_TRANSITIONS let INITIALIZED go only to CONNECTING or CANCELLED, but a stream can
fail before it connects (credential selection/setup throws). fail() then hit an invalid
transition, left the tracker in INITIALIZED with error set and completedAt null, and
archiveStream persisted that inconsistent summary. Add FAILED to the INITIALIZED
transitions. Adds a regression test.

Co-authored-by: pacocartones <pacocartones@users.noreply.github.com>
2026-09-22 07:11:46 -03:00
Paco Cartones
86c08423df fix(openai-responses): trim tool_call_id and function name so padded call ids stay paired (#14093)
openaiToOpenAIResponsesRequest trimmed the id on the function_call side (assistant
tool_calls -> function_call) but not on the function_call_output side (tool/function
role). clampCallId only length-clamps, so a whitespace-padded id that a client echoes
on both sides was keyed trimmed on one side and untrimmed on the other; the
orphaned-output filter then dropped the tool result and the model answered as if the
tool never ran. Trim both sides to preserve the pairing invariant clampCallId exists
to protect. Adds a regression test with padded matching ids.

Co-authored-by: pacocartones <pacocartones@users.noreply.github.com>
2026-09-22 07:11:38 -03:00
Joao Pedro Mota
928663790a docs(cli): fix non-existent config show and models list commands (#14056)
Closes #13997

`config show` does not exist, and the config subcommand actually manages external CLI integrations, not OmniRoute's own envs. Changed to `config list`.
`models list` parses `list` as a provider filter, causing unexpected behaviors. The correct command is just `models`.

Co-authored-by: Mistertelecom <noc@ysoftware.com>
2026-09-22 07:11:29 -03:00
Bob.Hou
d46d4d8042 docs: name combo hang-stop from source, not as an env var (#14054)
#14022 already documented OMNIROUTE_STRIP_SYSTEM_PREAMBLE and
allowlisted COMBO_LOOP_SAFETY_TIMEOUT_MS. The remaining mismatch is
the ENVIRONMENT.md sentence that still backticks the constant as if
it were operator-facing.

Signed-off-by: Minxi Hou <houminxi@gmail.com>
2026-09-22 07:11:20 -03:00
backryun
252d604dbe chore(codex): bump Codex CLI to 0.155.0 (#14052)
Keep the emulated client fingerprint in lockstep with the CLI pinned in the Dockerfile: shared client constant, .env.example overrides, provider translate-path golden, executor header assertions, the caller-version fallback assertions from #13708, and the live env/stealth docs.

Co-authored-by: backryun <backryun@daonlab.local>
2026-09-22 07:11:11 -03:00
AStupidBear
ecedc1e4d3 fix(guardrails): resolve prefixed combo names in Vision Bridge (#13332)
Co-authored-by: AStupidBear <16422976+AStupidBear@users.noreply.github.com>
2026-09-22 07:11:02 -03:00
Diego Rodrigues de Sa e Souza
b10d05fad2 docs(i18n): refresh the README and ENVIRONMENT mirrors the base left behind (#14461)
* docs(i18n): refresh README and ENVIRONMENT mirrors for the base edits of 2026-09-21

* docs(i18n): re-adopt mirrors reformatted by the pre-commit hook

* docs(i18n): adopt the mirrors whose state save lost the race
2026-09-22 06:50:16 -03:00
Diego Rodrigues de Sa e Souza
2b8f89a652 fix(release): run the i18n contract gates in the merge-train (#14437)
The train validated typecheck, file-size/complexity and changelog
integrity only, so combined trees that add en.json keys without catalogs
or edit docs without mirrors reached release/v3.8.51 three times in 48 h
(#13670, 1b2349de, 7f1b4a5e) while each PR's own CI was red on those
gates. i18n:check-keys, i18n:check-keys:cli, i18n:check-ratio and the docs
drift gate now run with the static gates.
2026-09-22 02:12:20 -03:00
Diego Rodrigues de Sa e Souza
d0b6f9a2c6 fix(ci): drain the release/v3.8.51 base-reds — env-doc, generated skill, TS2677, ESLint (Refs #13866) (#14331)
* fix(build): ship httpClientAbortGuard.mjs in the pack artifact; validate input_tokens with Zod

Wave five of the release/v3.8.51 base-reds, part 1 — the two that matter.

#14064 restored server-ws.mjs's import of ./httpClientAbortGuard.mjs and the
assembleStandalone copy, but not the two pack-artifact policy entries that
were lost with it. Without APP_STAGING_ALLOWED_EXACT_PATHS the prepublish
prune deletes the file; without PACK_ARTIFACT_REQUIRED_PATHS nothing notices.
Every boot of the published package would die with ERR_MODULE_NOT_FOUND — the
3.8.47 head-response-guard class. Both closure suites (9/9) now enforce it.

#13910's /v1/responses/input_tokens read request.json() behind a hand-rolled
typeof check. Hard Rule #7 wants the boundary on Zod; the t06 guard caught it.
Same passthrough envelope the catch-all Responses route uses, since the
counters below already walk the fields defensively. 9/9 on the route's suite.

Five no-unused-vars left behind by the wave (cliRuntime execFileSync, arena
test symbols and a type, compression rmSync, waitForServer req) are removed.
The 'openwa routes removed without deprecation' entry from the #14101 run was
an artifact of that PR trailing its base — the gate is clean on the tip.

Refs #13866

* fix(compression): let anchored file-pack rules see the transformed text; align wave-5 guards

Wave five of the release/v3.8.51 base-reds, part 2.

One production defect. #12825 (Hungarian Caveman pack) stopped gating file-pack
rules with the English keyword list and tested the rule's own regex instead —
against `lowerResult`, a lower-cased copy of the ORIGINAL text that the loop
never refreshed. An anchored pattern like leader_phrases' `^(?:i will|…)`
therefore ran its prefilter on "sure, i will…", failed the anchor, and was
skipped; the rule that strips "I will " from every English response was dead
since the merge. The prefilter now sees the text as the rules so far have left
it. New test fails on the tip and passes here; all Caveman suites, Hungarian
included, are 95/95. A frozen no-unused-vars suppression on caveman.ts no
longer had a target and is pruned.

Two more TS2677 predicates of the kind #14101 fixed: #13910
(rerankProviderNodes.ts, `n is RerankProviderNodeRow` on a Record row) and
#13957's mitm catalog (antigravity.ts, `c is DynamicCatalogModel` on a
literal-or-null). Both narrow by NonNullable of the element's own type; the
api-route typecheck was 285 against a baseline of 283 on the pristine tip.

The rest are guards trailing legitimate changes:

- #12663 made gemini-3.8-flash the catalog head; T28 pinned 3.7.
- #13863 put mimo-v2.5 into the shared vision heuristic on purpose (the base
  model is multimodal, only the Pro variants are text-only). The safety test
  now asserts the real invariant: base and :free aliases yes, -pro no.
- #12565 moved npm-prefix detection into cliRuntimeNpmPrefix.ts with a
  process-lifetime cache that importFresh() does not reset; the case resets it.
  #12565 also builds Windows candidates with path.win32 on purpose; the qodercli
  test compared against POSIX path.join.
- #13990 (the 2 GB Docker image) copies better-sqlite3 with --chown; the guard
  matched the flag order literally. Now flag-order tolerant, still fails when
  --from=builder is removed.
- #13378 reintroduced public/openference.svg under a name #11750 retired for
  missing provenance and swapped the Cerebras showcase cell for it. The cell is
  back and the asset is gone; whether the new drawing counts as provenance is
  the owner's call.

Refs #13866

* fix(i18n): translate the 7 sidebar-pin and Claude low-priority keys into all 65 locales

#7f1b4a5e (sidebar pinned items) and #1b2349de (Claude OAuth lower-priority /
auto-reset) landed with their 7 new keys in en.json only, which the vi and
pt-BR parity suites flag. Translated with the repo's own sync-ui-keys
--translate-markers against the .113 i18n instance (codex/gpt-5.6-sol-low):
+446 lines across 65 catalogs, zero __MISSING__ markers, placeholders intact.
vi.json also has two keys reordered to mirror en.json; values unchanged.

Refs #13866

* chore(quality): list the 8 covering tests the sixth wave added in stryker tap.testFiles

30 commits landed on release/v3.8.51 while wave five drained; eight new unit
tests cover mutated modules and were not in tap.testFiles, so their mutant
kills did not count and check:mutation-test-coverage --strict failed on the
merged tree. Appended at the end of the list, nothing reordered.

Refs #13866

* docs: document the five env vars of the 09-18 wave; regenerate the version-manager skill for the open-wa routes

check:docs-all: BRIDGE_PORT, ROUTER_URL and CERT_DIR (bin/antigravity-bridge.mjs,
#c74cea3d), OPENWA_SERVICE_PORT (src/lib/services/bootstrap.ts, #1e8c913c) and
NEXT_PUBLIC_PORT (src/shared/hooks/useDisplayBaseUrl.ts, #d715190b) were read
in code but absent from .env.example and docs/reference/ENVIRONMENT.md. Added
next to their neighbours, with the defaults the code actually uses (open-wa is
8323, not the 201xx range the other services sit in).

check:agent-skills-sync: the open-wa feature added eight /api/services/openwa/*
routes to docs/openapi.yaml without regenerating skills/omni-version-manager/
SKILL.md. Regenerated with the repo generator; the diff is exactly those eight
route sections.

Refs #13866

* test: register the crash guard in the pack snapshot; inventory #13874's refresh-lane row read

pack-artifact-policy pins the list of root runtime files check:pack-artifact
must find in the tarball; dist/httpClientAbortGuard.mjs joined
PACK_ARTIFACT_REQUIRED_PATHS in this PR and the snapshot follows.

#13874 re-reads the connection row inside the Claude refresh lane so a queued
health check does not POST a refresh token a Layer 2 refresh already rotated —
a state read, inventoried like the family-cooldown lookup (tokenHealthCheck.ts
2 -> 3).

Refs #13866

* chore(quality): list native-codex-auto-resume test in stryker tap.testFiles (#13180 landed without it)

* fix(release): drain the seventh base-red wave of release/v3.8.51 (9 tests + pack-policy + dashboard-typecheck)

Three production defects the tests caught:
- rateLimitManager: maxWaitMs=0 (the #12902 disable sentinel) hit #12715's
  queue-budget gate as "0 ms left" and 503'd every protected request.
- emergencyFallback: #14006 silently switched the budget-exhaustion target
  provider nvidia -> groq against ENVIRONMENT.md and the NIM snapshot; restored.
- claudeConnectionFields.ts vs ClaudeConnectionFields.tsx (#13074) differed only
  by casing; helpers renamed to claudeConnectionFieldValues.ts.

Guards realigned to legitimate changes: #13874 rotation map (distinct token in
the error test), #13350 origin-IP denylist, #13318 shared-catalog growth
(counts by invariant), comboTargetKeyPolicy import in the telegram stub, the
22 README mirrors that #13940/#14106 stamped with the retired openference.svg
(translated Cerebras cells recovered from history, hashes re-stamped),
bin/antigravity-bridge.mjs allowed in the pack policy, and the two dashboard
typecheck regressions (typed pinned section, ComponentProps cast).

Refs #13866.

* test: type the #13848 Gemini pairing tests (no-explicit-any) and inventory the semantic-cache embedding picker's connection read

Both arrived with the tip merge: #13848 added 13 explicit any casts to
translator-openai-to-gemini.test.ts (no-explicit-any is an error under
tests/), and 7a921299's embeddingOptions.ts reads provider connections
once without a hard-session-lease inventory entry. Stale suppression
count pruned for the test file only.

Refs #13866.

* test: split the #13848 turn-pairing cases out of translator-openai-to-gemini.test.ts

The file sits exactly at its frozen size cap; typing the pairing tests
(no-explicit-any) pushed it 14 lines over. The two cases are a coherent
regression suite of their own, so they move to
translator-openai-to-gemini-turn-pairing-13848.test.ts (registered in
stryker tap.testFiles) instead of widening the baseline.

* docs(env): document BRIDGE_PORT, ROUTER_URL, CERT_DIR, OPENWA_SERVICE_PORT and NEXT_PUBLIC_PORT (Refs #13866)

check:env-doc-sync has been red on the release tip since these five vars
reached code without their .env.example / ENVIRONMENT.md entries:
bin/antigravity-bridge.mjs (BRIDGE_PORT, ROUTER_URL, CERT_DIR — #14006),
src/lib/services/bootstrap.ts + api/services/openwa/_lib.ts (OPENWA_SERVICE_PORT)
and src/shared/hooks/useDisplayBaseUrl.ts (NEXT_PUBLIC_PORT — #13533).
Defaults and source files copied from the reads themselves.

* chore(skills): regenerate omni-version-manager for the open-wa service routes (Refs #13866)

check:agent-skills-sync (Merge integrity job) has been red on the tip since
the open-wa embedded-service routes reached docs/openapi.yaml without the
generated SKILL.md being refreshed. Output of
scripts/skills/generate-agent-skills.mjs --apply, no hand edits: the eight
/api/services/openwa/* operations.

* fix(types): make the two TS2677 type predicates sound (Refs #13866)

check:api-typecheck has been red on the tip with two "type predicate's
type must be assignable to its parameter's type" errors:

- src/app/api/v1/_shared/rerankProviderNodes.ts (#13733): the read cache
  hands back `Record<string, unknown> | null`, and an interface whose members
  are all optional is not assignable to an index-signature type. Narrow to the
  non-null record and assert the row shape afterwards.
- src/mitm/handlers/antigravity.ts (#14006): the map callback returned
  `{ displayName: string }` while DynamicCatalogModel declares it optional, so
  the predicate could not be proven. Type the callback's return explicitly and
  filter on `!== null`.

No runtime change; rerank-remote-provider-nodes / rerank-local-node-shapes /
mitm-handler-antigravity stay green.

* fix(lint): clear the 92 ESLint errors the lint gate reports on the tip (Refs #13866)

- tests/unit/translator-openai-to-gemini.test.ts: #13848 / #13318 added 13
  `any` casts/params on top of the 74 frozen for the file, so ESLint reported
  all 87. Typed them (GeminiRequestWithContents / GeminiToolPart, and the
  existing GeminiRequestWithConfig) and pruned the file's suppression to the
  new count of 71 — nothing else in eslint-suppressions.json changes.
- no-unused-vars: execFileSync import (src/shared/services/cliRuntime.ts,
  #12565), getArenaEloSyncStatus + makeLeaderboardMap + ArenaLeaderboardMap
  (tests/unit/arena-elo-sync-redesign.test.ts, #13446), rmSync
  (compressionAnalyticsWriterFlatRate.test.ts, #13446), `req` → `_req`
  (waitForServer-slow-first-response.test.mjs).

translator-openai-to-gemini 48/48; arena-elo-sync-redesign,
compressionAnalyticsWriterFlatRate, waitForServer-slow-first-response green.

* fix(compression): stop skipping anchored Caveman rules that only match after earlier rules

#12825 (Hungarian pack) replaced the English keyword prefilter with a
`rule.pattern.test(lowerText)` pre-check for every file-based rule, including
the default `en` pack. `lowerText` is the ORIGINAL message, so anchored rules
such as `leader_phrases` (`^i will …`) — which only match after `pleasantries`
strips "Sure, " — were dropped before they could run. `caveman-v379` caught the
regression ("I will ensure …" survived at full intensity).

Tag file-based rules with their pack language in ruleLoader and let the
keyword prefilter apply to `en`/built-in rules only; non-English packs (which
reuse English rule names) simply run their localized regex, which is what the
pre-test cost anyway. Drops the now-unused CAVEMAN_RULES import and prunes the
already-stale `caveman.ts` no-unused-vars suppression (0 violations on the tip)
that blocked the pre-commit hook for any change to this file.

Refs #13866

* test(models): align catalog and vision-heuristic guards with the tip's intended contracts

Three base-reds where the production change was deliberate and the pinned
guard was simply not bumped by the PR that changed the contract:

- agy-antigravity-shared-catalog-12724: #13318 added the three Gemini 3.8
  Flash tiers (high/medium/low, no "-tiered" endpoint for 3.8) to the shared
  Antigravity/AGY base, 10 -> 13. Pin the new size in one constant and make the
  buildSurfaceCatalog delta assertions relative to it.
- t28-model-catalog-updates: #12663 (issue #12638) registered gemini-3.8-flash
  at the head of the AI Studio fallback catalog as the current Flash default;
  assert 3.8 first and keep 3.7 present.
- command-code-mimo-v2-5-safety: #13863 (issue #13847) added an explicit
  "mimo-v2.5" fragment to the shared vision heuristic so provider-qualified and
  `-free` aliases keep their vision flag. The guard's real concern (the
  "mimo-vl" fragment must not cover "mimo-v2.5") is asserted on the fragment
  itself; the bare id is now vision by heuristic on purpose, and the Pro
  text-only sibling stays excluded.

Refs #13866

* test(cli): follow the #12565 cliRuntime module split in the npm-prefix and qodercli guards

#12565 (issue #12563) moved the npm global-prefix cache out of cliRuntime.ts
into cliRuntimeNpmPrefix.ts and built the Windows known-bin candidates with
`path.win32` (cliRuntimeWindowsNode.ts) so they stay Windows-shaped when
`process.platform` is mocked on a POSIX runner. Two pre-existing guards
depended on the old layout:

- cli-runtime-extended "resolves known binaries from npm global prefix":
  importFresh() only re-evaluates cliRuntime.ts; the prefix cache now lives in
  a module that stays shared across cases, so a real `npm config get prefix`
  from an earlier case was cached and the mocked execFileSync never ran. Reset
  the cache with the helper #12565 exported for exactly this in afterEach.
- qodercli-windows-resolve-6263: compare against `path.win32.join` — identical
  to `path.join` on a real Windows host, which is the behaviour under test.

Production behaviour is unchanged on both platforms.

Refs #13866

* test(auto-update): write the source-mode log inside the test's own temp dir

The launchAutoUpdate case pointed AUTO_UPDATE_LOG_PATH at a fixed, world-shared
`/tmp/auto-update-source.log`. On the .113 runner the suite executes both as
`root` and as `runner` (uid 1001): the file survives owned by whoever ran
first (`-rw-r--r-- root root`), and the next `openSync(logPath, "a")` fails
with EACCES for the other user. Reproduced locally by making the shared file
read-only; production code is untouched (autoUpdate.ts last changed in #9354).

Use a per-test mkdtemp path for the source-mode log and clean the whole temp
root in the existing finally block.

Refs #13866

* fix(dashboard): rename claudeConnectionFields.ts so it no longer case-collides with ClaudeConnectionFields.tsx

#13074 added two modules to the provider-detail modals directory whose names
differ only by casing: `ClaudeConnectionFields.tsx` (the component) and
`claudeConnectionFields.ts` (the value/patch helpers). On a case-insensitive
filesystem the pair breaks the webpack build (#6584 guard), and esbuild's
resolver already picks the `.tsx` for the extension-less `./claudeConnectionFields`
specifier, so the provider-detail client entry failed to bundle ("No matching
export ... for import claudeConnectionFieldPatch").

Rename the helper module to `claudeConnectionFieldValues.ts` (the same naming
the sibling `quotaScrapingFieldValues.ts` uses) and point the only importer,
EditConnectionModal.tsx, at the new name. Greens
tests/unit/case-collision-6584.test.ts and
tests/unit/media-page-client-browser-bundle.test.ts.

Refs #13866

* fix(build): allowlist dist/httpClientAbortGuard.mjs so the published tarball keeps the server-ws crash guard

#14064 (re-land of #13636) made scripts/dev/standalone-server-ws.mjs import
./httpClientAbortGuard.mjs and taught assembleStandalone to copy the shared
implementation next to dist/server-ws.mjs — but never registered the file in
scripts/build/pack-artifact-policy.ts. The prepublish prune deletes anything
outside APP_STAGING_ALLOWED_EXACT_PATHS, and check:pack-artifact only fails on
PACK_ARTIFACT_REQUIRED_PATHS entries, so the next `omniroute` tarball would
boot straight into ERR_MODULE_NOT_FOUND (the #7065 / tls-options class the
closure tests exist to catch).

Add the bare and dist/ entries to both lists and extend the required-paths
snapshot in tests/unit/pack-artifact-policy.test.ts. Greens
tests/unit/pack-artifact-entrypoint-closures.test.ts and
tests/unit/pack-artifact-server-ws-closure.test.ts.

Refs #13866

* test(docker): accept --chown=node:node on the better-sqlite3 runner COPY

#14010 deliberately changed the runner-stage COPYs to `COPY --chown=node:node
--from=builder ...` (ownership at copy time instead of a second ~2 GB
`chown -R` overlay layer). The Dockerfile contract test still matched the old
`COPY --from=builder /app/node_modules/better-sqlite3` prefix and went red on
the tip even though the native-addon guard it protects is intact. Tolerate the
optional --chown flag; every other assertion (node-gyp rebuild, both
`test -f .../better_sqlite3.node` checks) is unchanged.

Refs #13866

* fix(api): validate /v1/responses/input_tokens bodies with Zod (t06)

#13167 added the local Responses token-count route with hand-rolled
`typeof` checks on `request.json()`. Hard Rule #7 and the t06 gate
(scripts/check/check-route-validation.mjs, mirrored by
tests/unit/route-body-validation-t06.test.ts) require every route that reads
request.json() to go through validateBody()/safeParse(), so the tip was red.

Add `v1ResponsesInputTokensSchema` (pins the wire types the counter reads —
model/instructions strings, input string-or-array, tools array — and lets
unknown keys through since they are counted, never forwarded) and run the body
through validateBody(); a type mismatch is now a 400 naming the field instead
of a silently ignored key. Regression test added to
tests/unit/responses-input-tokens-local-route.test.ts.

Refs #13866

* fix(docs): drop the retired openference.svg asset reintroduced by #13378

`openference.svg` is one of the 78 provider assets retired for missing
provenance (tests/unit/provider-assets-generic-fallback.test.mjs freezes that
list and forbids any tracked surface from referencing a retired name). #13378
added a new hand-drawn `public/openference.svg` outside the manifest-audited
public/providers/ tree and pointed the README free-tier table (plus the 22
i18n mirrors that carry the row) at it, which put the retired name back on a
tracked surface and left an unaudited asset in the package.

Use the generic fallback icon (`public/providers/cli-generic.svg`) the other
provenance-less providers already use, delete the unaudited file, and adopt
the mechanical README edit into .i18n-state.json
(`i18n:run -- --adopt --files=README.md`, no API calls) so the i18n drift gate
does not flag README.md as source-changed.

Refs #13866

* test(lease): classify the two connection-query sites added by #14159 and #13874

The hard-lease bypass inventory froze every getProviderConnections /
getProviderConnectionById site with a class; two landed on the tip without a
golden update:

- src/app/api/settings/cache-config/embeddingOptions.ts (#14159, re-land of
  #12630): read-only listing that feeds the semantic-cache embedding dropdown,
  same shape as the qdrant embedding-models route — class C.
- src/lib/tokenHealthCheck.ts 2 -> 3 (#13874): re-reads the row by id after an
  unrecoverable refresh error to detect credentials rotated by a concurrent
  Layer 2 refresh before deactivating — a state read, not dispatch; stays C.

Refs #13866

* fix(sse): restore nvidia as the emergency budget-fallback provider

#14006 (Antigravity MITM catalog injection) flipped
EMERGENCY_FALLBACK_CONFIG.provider from "nvidia" to "groq" in one line,
without touching ENVIRONMENT.md, .env.example, the chat.ts comment or the
NVIDIA hosted-model snapshot, all of which still promise
nvidia/openai/gpt-oss-120b. Operators without a Groq connection got the
original 402 back instead of the free reroute, and
chat-route-coverage ("uses the emergency fallback model on budget
exhaustion" / "returns the primary budget error when emergency fallback
also fails") went red on the tip.

Put the documented default back; the #14006 bridge tests exercise
bin/antigravity-bridge.mjs and do not read this config.

Refs #13866

* fix(resilience): keep maxWaitMs=0 a "no queue deadline" sentinel

#12902 released requestQueue.maxWaitMs=0 as the sentinel that disables
the queue-wait deadline, but the #12715 queue-budget gate in
withRateLimit() (`if (queueRemainingMs <= 0) throw`) read 0 as "budget
spent" and rejected every request on a protected connection with an
immediate 503 queue-budget error — the exact opposite of what the
setting promises. rate-limit-maxwaitms-disable-execution ("400ms job
completes without 504") was red on the tip.

When no caller budget is passed and the configured queue budget is 0,
skip the gate, never arm the queue-wait timer and hand
awaitProviderDefaultSlot no budget (it falls back to the window).
Execution stays bounded by executionMaxWaitMs and the upstream
fetch-start timeout, as before.

Refs #13866

* test: align three fixtures with the #13874, #13861 and #13350 contracts

Three base-reds that are deliberate contract changes, not defects:

- executor-default-base "refreshCredentials swallows refresh errors":
  #13874 records rotations on the Layer 2 (no connectionId) refresh path
  too, so the "refresh-me" token the previous case already rotated was
  served from the rotation map without the network POST the test wanted
  to fail. Use a token nobody rotated.
- telegram-keycache-bounded-13165: #13861 made comboTargetKeyPolicy
  import isModelBlockedByPatterns from db/apiKeys; the loader-stubbed
  module lacked it and the suite died at module load. Export an honest
  "not blocked" stub (the test has no blocked models).
- upstream-headers-proxy-auth "ordinary headers are still allowed":
  #13350 forbids the whole origin-IP forwarding set upstream (covered
  by upstream-headers-sanitize). Swap x-forwarded-for for x-request-id.

Refs #13866
2026-09-22 01:52:30 -03:00
Diego Rodrigues de Sa e Souza
282db2a371 docs(changelog): reconcile the v3.8.51 living section — round 3 (2026-09-21) (#14371)
Third reconciliation pass of the living [3.8.51] section against release/v3.8.50..release/v3.8.51 (091589089c06f1df9d77): 364 fragments folded, 104 bullets generated for commits without a fragment, 254 fragment bullets linked and credited by origin commit, 1,393 bullets total, 226 external contributors (0 missing on cross-check). Hand-corrected credits: #12885 → @patrykkopycinski, #14159 → @BillyOutlast (feature bullet), #12972 → @IAMBOBJIM.
2026-09-22 00:27:51 -03:00
Diego Rodrigues de Sa e Souza
3ee283bb94 docs(i18n): refresh the 689 mirrors left stale by the Codex quota outage (#14357)
* docs(i18n): refresh the 689 mirrors left stale by the Codex quota outage

Section-level retranslation, for the 45 locales the interrupted run of
2026-09-18 left behind, of the docs the base edited that day (README,
SECURITY, API_REFERENCE, ENVIRONMENT, FEATURE_FLAGS, PROVIDER_REFERENCE,
REASONING_REPLAY, SOCKET_DEV_FINDINGS, EMBEDDED-SERVICES, admission-lanes,
...) on codex/gpt-5.6-sol-low once its quota returned: 65/65 locales, 0
failures, 2 h 40 with 6 workers. The drift gate's stale-target warning is
empty again.

* docs(i18n): adopt the tr mirrors whose state save lost the race

* docs(i18n): re-adopt the mirrors prettier reformatted in the pre-commit hook

* docs(i18n): refresh ENVIRONMENT and FEATURE_FLAGS mirrors for the base edits of 2026-09-21

* docs(i18n): re-adopt mirrors reformatted by the pre-commit hook

* docs(i18n): adopt the he mirrors whose state save lost the race

* docs(i18n): take the base README mirrors and state after the merge
2026-09-21 20:42:15 -03:00
Bob.Hou
1853a61807 fix(startup): bound model-catalog sync fan-out at boot (#14113)
The first autoSync cycle launched every connection at once. A host with
112 connections then held 112 catalog JSON parses on a cold heap and
died at the V8 cap. Cap in-flight fetches at 4 for the whole cycle, and
wait 90s so boot can serve traffic and the 30s cleanup has already run.

Signed-off-by: Minxi Hou <houminxi@gmail.com>
Co-authored-by: Diego Rodrigues de Sa e Souza <diegosouza.pw@gmail.com>
2026-09-21 19:07:41 -03:00
Diego Rodrigues de Sa e Souza
66872b3271 fix(security): guard the Obsidian baseUrl against cloud-metadata SSRF (GHSA-474q-g63r-w4rr) (#14322)
obsidianFetch passed the operator-controlled baseUrl straight to fetch(); POST /api/settings/obsidian then dialed it and persisted it for every later /api/obsidian/* call. The Local REST API legitimately lives on loopback / LAN / Tailscale, so the policy is the provider one (#5066): private hosts stay allowed, cloud-metadata and link-local are blocked unconditionally (route-level Zod refine + safeOutboundFetch block-metadata), redirects are never followed. Five TDD cases, red on the tip.
2026-09-21 18:35:50 -03:00
Diego Rodrigues de Sa e Souza
b03276e99a test: bump the feature-flag count guard to 75 (893fef9c added OPENCODE_PARK_AND_RESUME) (#14366)
893fef9c updated feature-flags-settings.test.ts but not the sibling count guard in server-owned-tool-loop-flag.test.ts, so unit shard 2/4 failed on every PR cut from the tip. Refs #13866.
2026-09-21 18:18:58 -03:00
diegosouzapw
06f1df9d77 chore(credit): record co-authorship dropped by three squash merges
Three squash merges on release/v3.8.51 lost the contributor attribution that
the pipeline had preserved on the branches:

- 7a921299 (#14159) re-landed #12630: the dual-layer semantic cache is
  @BillyOutlast's work; the squash author is the maintainer.
- cd4c6f69 (#14162) re-landed #13180: the native Codex auto-resume fix is
  Felipe Reis's (@mdigitalbh81); the squash author is the maintainer.
- 53a147c7 (#13295) shipped the same one-line resolvedExtensionEnd reorder
  that @ggiak landed first in #13036; the squash carried no trailer.

The changelog fragment and these trailers record that credit in the branch
history, the contributors graph and the release notes.

Co-authored-by: BillyOutlast <172061051+BillyOutlast@users.noreply.github.com>
Co-authored-by: Felipe Reis <mdigitalbh81@gmail.com>
Co-authored-by: Giorgos Giakoumettis <giorgos@yiakoumettis.gr>
Co-authored-by: ggiak <20743694+ggiak@users.noreply.github.com>
2026-09-21 16:33:10 -03:00
Diego Rodrigues de Sa e Souza
d610c24c62 fix(release): drain base-red waves 5–8 of release/v3.8.51 (#14164)
Drains base-red waves 5–8 of release/v3.8.51: 35+ tests and the pack-policy,
api-typecheck, dashboard-typecheck, docs-all, agent-skills-sync, ESLint and
mutation-coverage gates (#13866).

Production defects the tests caught:
- Caveman: #12825's file-pack prefilter tested anchored rules against the
  original text, so leader_phrases never ran.
- pack-artifact: httpClientAbortGuard.mjs was missing from the staging
  allowlist and the required set — every published boot died with
  ERR_MODULE_NOT_FOUND (#14191).
- rateLimitManager: maxWaitMs=0 (the #12902 disable sentinel) hit #12715's
  queue-budget gate as "0 ms left" and 503'd every protected request.
- emergencyFallback: #14006 silently switched nvidia -> groq; restored per
  ENVIRONMENT.md and the NIM snapshot.
- ClaudeConnectionFields.tsx vs claudeConnectionFields.ts (#13074) differed
  only by casing; helpers renamed to claudeConnectionFieldValues.ts.
- /v1/responses/input_tokens body validated with Zod (HR#7).

Guards realigned to legitimate changes (#12663, #13863, #12565, #13990,
#13874, #13350, #13318, #13848 typed and split out of a size-capped file —
#14254), i18n catalogs for the 7 keys of #13074/#7f1b4a5e in 65 locales,
22 README mirrors restored to the Cerebras cell, env docs for 5 vars,
regenerated omni-version-manager skill.

Refs #13866. Closes #14254. Refs #14191.

Co-authored-by: Prabhjot Singh <jotgill1522@gmail.com>
Co-authored-by: Xmon Dai <xiechimon@qq.com>
2026-09-21 16:31:57 -03:00
Dizzle
34113170f4 feat(sse): per-egress pacing + fleet-wide backoff for opencode rotation, opt-in (#14290)
Per-egress pacing and fleet-wide suspect backoff for OpenCode free-tier rotation,
layered on #13924's park-and-replay: paces requests per egress identity, marks an
egress fleet-suspect after repeated transient 429s, and hands a suspect slot
budget over to the park/heartbeat path instead of surfacing the last 429.
Opt-in via OPENCODE_EGRESS_THROTTLE_ENABLED (default off) — with the flag off every
hook is a no-op and behavior is identical to the tip.

Stacked on #13924 (merged as 893fef9c); reconciled onto the merged form.

Co-authored-by: diegosouzapw <8016841+diegosouzapw@users.noreply.github.com>
2026-09-21 13:23:55 -03:00
Dizzle
893fef9c67 fix(sse): park request with heartbeat after repeated transient 429s, replay one capped leg (#13924)
Park a request with a heartbeat after repeated transient 429s from the OpenCode
free tier and replay exactly one capped leg, instead of failing the turn or
fanning out across the whole pool. Opt-in via OPENCODE_PARK_ON_429 (default
off); the off-path is byte-identical to the previous behavior.

Co-authored-by: diegosouzapw <8016841+diegosouzapw@users.noreply.github.com>
2026-09-21 12:33:12 -03:00
Diego Rodrigues de Sa e Souza
7a921299c5 feat(cache): configurable dual-layer semantic caching with Redis/In-Memory vector stores (re-land of #12630) (#14159)
Re-land of #12630 by @BillyOutlast (their commits carried with authorship intact), merged via /merge-batch (2026-09-19) on top of the current `release/v3.8.51` tip.

**Reconciled before landing — `c2709ea7`.** The re-land had only run its own 78 tests; against the existing suites of the modules it touches it introduced **15 regressions** (all green on the pure tip, reproduced red with the PR). Root causes and fixes:

- **Vector layer was on by default** (`semanticCacheConfig.ts` `enabled: true`, bridged from the legacy `semanticCacheEnabled` toggle) and its default embedding client called `http://localhost:13305/v1/embeddings` on every cacheable lookup *and* store — `chat-combo-live-test` ×2 and `issue-agent-route-execution` saw 3 fetches instead of 1. The layer is now **opt-in** via a new `semanticCacheVectorEnabled` setting (default `false`; sub-toggle in the Cache settings tab; `OMNIROUTE_SEMANTIC_CACHE_ENABLED=true` still works). With it off, `chatCore` behaves exactly like the legacy SQLite exact-match cache.
- **`X-OmniRoute-Cache` changed from `HIT` to `HIT (exact)`/`HIT (semantic)`** — 5 chat-route/chatCore contract tests. Restored the legacy `HIT` value (similarity hits keep `X-OmniRoute-Cache-Similarity`); `cacheSource: "semantic_similarity"` also fell through `attemptLogging`'s narrowing as `"upstream"` and is now `"semantic"`.
- **`normalizeDiscoveredModels` stamped `modelType: "chat"` + `supportedInputTypes: ["text"]` on every model** — kimi/vertex/reasoning-levels/provider-models/model-sync snapshots churned. Chat models keep the tip's exact shape; only non-chat modalities (or explicit `supportedInputTypes`) are stamped.
- **`detectModelModality` classified `supportedEndpoints: ["chat","embeddings"]` as embedding** and dropped the model from the chat catalog. An explicit chat endpoint is now authoritative over the embedding/rerank/image heuristics.
- Extras found on the way: `chatCore` now passes `provider` to both stores (the manager filters by provider on lookup, so writes without it could never hit); `test-embedding/route.ts` returned `undefined` on invalid payloads (`validateBody()` has no `.response`) → 400.
- Tests: `chatcore-semantic-cache.test.ts` restored to the tip's contract; `semantic-cache-no-truncated-writes.test.ts` restored to the tip + the PR's two object-shaped streaming cases appended (with `isTruncatedStreamBody` now delegating to `isTruncatedCompletion` for object bodies — on the tip that guard was a no-op in production); new guard `semantic-cache-vector-layer-opt-in.test.ts`.

**Evidence on the merged tree:** the 9 previously-red files + the PR's 7 test files: 270 pass / 0 fail / 2 skipped (Lemonade live, self-skip); `typecheck:core` exit 0; `check:open-sse-typecheck` 0 errors; `check-api-typecheck` only the two inherited errors (`rerankProviderNodes.ts`, `antigravity.ts`); file-size, changelog-integrity, docs-counts, complexity, cognitive-complexity, vitest-exclusions OK; the 5 removed eslint suppressions verified clean.

**Owner decisions surfaced by the rework:** the PR wanted the hit type in the `X-OmniRoute-Cache` value — kept the legacy value; a separate header would be the non-breaking way. `modelDiscovery.ts` now considers `record.max_tokens` as an `inputTokenLimit` candidate (on several providers that is the *output* limit) — left as submitted, untested. The contributor's `/review/` `.gitignore` + eslint ignore entries were left as submitted.

**Inherited, not from this PR:** the fast-path unit shard reds shared with every PR of this wave (vi locale parity, pack-artifact allowlists, `.env.example` sync, casing, budget fallback), `hard-session-lease-bypass-inventory`, the 5 `no-unused-vars` lint errors, the `omni-version-manager` generated-skill drift.

Supersedes #12630.
2026-09-19 02:54:34 -03:00
Diego Rodrigues de Sa e Souza
b61ff773b0 docs(i18n): refresh the mirrors and keys the base left behind; warn on stale mirrors (#14166)
* fix(i18n): make the docs drift gate flag mirrors translated from an older source

check-translation-drift.mjs compared only the top-level source_hash of
.i18n-state.json. A partial refresh run (17 of 65 locales retranslated
before an upstream quota 429) bumps that hash while the interrupted locales
keep the old one in locales[code].source_hash — the source no longer reads
as changed, the stale mirrors' target hashes still match, and the gate said
PASS for 703 stale mirrors. findStaleTargets() now reports every locale
record behind its source.

* docs(i18n): refresh the mirrors the base left behind and warn on stale ones

Section-level retranslation of the docs edited on release/v3.8.51 after the
full-coverage cut (README, API_REFERENCE, ENVIRONMENT, FEATURE_FLAGS,
PROVIDER_REFERENCE, REASONING_REPLAY, SOCKET_DEV_FINDINGS, admission-lanes,
...): 20 locales complete on the Codex backend before its accounts hit their
quota threshold (429, reset in ~146 h); the alternative routes on the LAN
instance (auto/gemini -> dva 500, kilocode 401, zai/opencode/cloudflare
down, auto/claude-opus at ~30 s per chunk with socket drops) did not
converge, so the remaining 689 mirrors stay stale until the quota returns.

check-translation-drift.mjs now lists those stale mirrors (locale record
behind the top-level source_hash) as a non-blocking warning instead of
silently passing; documented in docs/guides/I18N.md.

* fix(i18n): translate the 7 dashboard keys the base added without catalogs

1b2349de (Claude OAuth lower-priority lane) and 7f1b4a5e (sidebar pinned
items) added 7 keys to en.json and to no other catalog, so 65 locales
missed them and i18n:check-keys was red on the tip. sync-ui-keys
--translate-markers over the 65 catalogs (444 leaves) on the auto/claude-opus
combo of the LAN instance; zh provider term normalised; gates green.

* docs(i18n): adopt the API_REFERENCE and MEMORY mirrors the base refreshed
2026-09-19 02:31:22 -03:00
Diego Rodrigues de Sa e Souza
cd4c6f6947 fix(combo): auto-resume pinned native Codex turns (re-land of #13180) (#14162)
Re-land of #13180 by @mdigitalbh81 (their commits carried with authorship intact), merged via /merge-batch (2026-09-19) on top of the current `release/v3.8.51` tip.

**Reconciled before landing (three maintainer commits on top of the re-land):**
- `81d770b3` — `check:api-typecheck` / `check:open-sse-typecheck` were red on the PR's own head with `TS2339 'reason' does not exist on type 'AutoResumeDecision'` ×3 in `open-sse/services/combo.ts` (green on the tip). Without `strictNullChecks` the truthiness test on the `eligible` discriminant does not narrow the else-branch; `=== true` does.
- `66e57cfe` — `tests/unit/native-codex-auto-resume.test.ts` was 1540 lines, above the 1200-line new-test cap. The three pure guard cases (`hasUnresolvedToolCalls`, `hasProviderSpecificUnsafeContinuationState`, `MAX_AUTORESUMES_PER_TURN`) moved verbatim to `native-codex-auto-resume-guards.test.ts`; the combo-flow file is now 1131 lines.
- `c2f1937f` — `executeTargetAttempt.ts` 1258→1273 rebaselined with a dated justification (own growth: generation advance + log at the pin site).

**Evidence on the merged tree:** `native-codex-auto-resume{,-guards}.test.ts` 15/15; `native-codex-turn-pin-model-scoped-fallback`, `native-codex-turn-pin-10379`, `chatgpt-web-codex-turn-pin` 33/33 total; broader combo sweep (`combo-responses-sse-failure-fallback`, `combo-context-window-filter`, `perf-waterfall-elimination`, `chatgpt-web-codex`, `combo-context-overflow-compression-probe`) 70/70; api/open-sse typecheck clean for the PR's files; file-size, changelog-integrity, complexity and cognitive-complexity gates OK.

**Inherited, not from this PR** (reproduced on the pure tip): the 23 unit reds in the fast-path shards (vi locale parity, pack-artifact allowlists, `.env.example` sync, casing, budget fallback, etc.), the 5 `no-unused-vars` lint errors (`cliRuntime.ts`, `arena-elo-sync-redesign`, `compressionAnalyticsWriterFlatRate`, `waitForServer-slow-first-response`), the `omni-version-manager` generated-skill drift, `tinycmsDomMocks.ts` / `rerankProviderNodes.ts` / `antigravity.ts` typecheck errors, and the 4 env vars missing from `.env.example`.

Supersedes #13180. Follow-up to #12240.
2026-09-19 01:24:39 -03:00
Diego Rodrigues de Sa e Souza
3fd1265d89 fix(dashboard): explicit notice for size-limit-omitted log payloads (#14045)
Merged via /merge-batch (2026-09-19) on top of the current `release/v3.8.51` tip.

**Reconciled before landing:** `requestLogger.detail.payloadSizeLimitOmitted` was stamped `__MISSING__:` in 65 locales and the new-key gate rejects markers since 2026-09-17. The LAN translation backends were unavailable (codex accounts at quota for ~136h, claude connection expired, .113 instance in resource_pressure), so the single string was translated by hand in `818a1151` — `CALL_LOG_PIPELINE_MAX_SIZE_KB` kept verbatim in every locale, one line changed per catalog.

**Evidence on the merged tree:** `check-new-key-coverage` PASS (every new key reached all 65 locales); `payload-section-size-limit-notice.test.tsx` 7/7; `typecheck:core` clean; file-size, changelog-integrity, complexity and cognitive-complexity gates OK; prettier clean on all 65 catalogs.

**Inherited, not from this PR** (identical on the pure tip): `check-env-doc-sync` misses `BRIDGE_PORT`/`CERT_DIR`/`OPENWA_SERVICE_PORT`/`ROUTER_URL` in `.env.example`, and `check-key-completeness` reports 7 keys (`providers.claude*`, `sidebar.pin*`) present in `en.json` but absent from every locale.

Closes #13894
2026-09-19 00:41:36 -03:00
Diego Rodrigues de Sa e Souza
ec4d1eff43 fix(sse): pin DNS on the three public-only image fetch sites (#13883) (#14032)
Merged via /merge-batch (2026-09-19) on top of the current `release/v3.8.51` tip.

**Reconciled before landing:** the train-3 ejection from 2026-09-18 (`tests/unit/image-generation-route.test.ts` → promptless topaz request came back 502 because `pinDns: true` bypassed the test's mocked `globalThis.fetch`) is fixed in `5fafc490` by registering the same `setPinnedFetchTestOverride()` seam the other four image tests already use, cleared in `resetStorage()`/`after`. Production keeps pinning for real.

**Evidence on the merged tree:** `image-generation-route.test.ts` 25/25; all 18 image-path test files that mock `globalThis.fetch` (handler, upscale, fal, nanobanana, agnes, alibaba, bailian, kie, magnific, minimax, pollinations, qwen, edits-multipart, fetch-timeout, route-auth, pindns-toctou-13883) 183/183; `typecheck:core` clean; `check:open-sse-typecheck` 0 errors; file-size, changelog-integrity, complexity and cognitive-complexity gates OK.

Closes #13883
2026-09-19 00:41:22 -03:00
stormsia
2e0edd2241 fix(grok-cli): treat rolling 24h free-usage 429 as quota exhaustion (#13984)
* fix(grok-cli): treat rolling 24h free-usage 429 as quota exhaustion

* docs(grok-cli): fix comment parens in rolling 24h quota fallback
2026-09-19 00:05:43 -03:00
phs1997
16f8837acf fix(chatCore): only preserve tool_result blocks for Claude-native targets (#13972)
* fix(chatCore): only preserve tool_result blocks for Claude-native targets

When forwarding requests through the CC-bridge or Claude passthrough paths
to OpenAI-compatible upstream targets (e.g. logfare, OpenRouter), Anthropic-shape
tool_result content parts in user messages were preserved unchanged, causing
upstream gateways to reject the request with HTTP 503.

Gate preserveToolResultBlocks on targetFormat === FORMATS.CLAUDE so that
OpenAI targets receive properly normalized content strings.

Fixes #13971

* fix(chatCore): add regression coverage for the CC-bridge tool_result guard

Adds a one-line clarifying comment on the isClaudePassthrough call site (the
guard is a no-op there by construction) and two chatcore-translation-paths
tests proving the CC-bridge branch strips raw tool_result blocks for
OpenAI-compatible targets while still preserving them for Claude-native ones.

---------

Co-authored-by: phs1997 <phs1997@users.noreply.github.com>
Co-authored-by: diegosouzapw <8016841+diegosouzapw@users.noreply.github.com>
2026-09-19 00:05:34 -03:00
Nguyễn Viết Tuấn
233c66ffca fix(sse): enforce strict OpenAI Responses API streaming schema compliance (#13956)
* fix(sse): include output in response.in_progress event

* fix(sse): include output in translator response.in_progress event

* fix(sse): include status in all responses output items

* fix(sse): include sequence_number in Responses API error frames

* docs(changelog): expand responses streaming schema compliance notes

* fix(sse): always include token details in Responses API usage

---------

Co-authored-by: TheDemonTuan <nguyenviettuanbp@gmail.com>
2026-09-19 00:05:23 -03:00
Fouad Salkini
b67915d9eb fix(combos): synchronize allowedProviders and allow invariant overrides on update (#13951)
* fix(combos): synchronize allowedProviders and allow invariant overrides

* docs: add PR reference to changelog fragment #13951

* fix(combos): only widen existing allowedProviders/allowedModelFamilies restrictions on edit

The dashboard's combo edit save unconditionally set overrideAllowedProviders
and unconditionally nulled allowedModelFamilies. When a combo had NO prior
allowedProviders restriction, the PUT route unioned the (empty) current
restriction with the new step providers, synthesizing a brand-new allowlist
out of nothing — the opposite of "no restriction" — so a later add-a-provider
update would start failing COMBO_008 where it previously succeeded.

- Gate the server-side union in PUT /api/combos/[id] so it only widens an
  ALREADY non-empty allowedProviders restriction; a combo with no restriction
  stays unrestricted.
- Only clear allowedModelFamilies when a new step's family actually falls
  outside the existing restriction, instead of always nulling it on any edit.
- Derive the provider id via resolveCanonicalProviderModel instead of a naive
  model.split('/')[0], so self-aliased no-auth providers (e.g. 'opencode' ->
  'opencode-zen') resolve to their real routing provider.
- Add a PUT-route-level regression test covering both the "no prior
  restriction stays unrestricted" and "prior restriction gets unioned" cases
  (the existing combo-update-invariants test only exercised
  combosDb.updateCombo() directly, bypassing this route branch).

Co-authored-by: diegosouzapw <8016841+diegosouzapw@users.noreply.github.com>

* refactor(combos): extract computeAllowedRestrictionSync to keep handleSave under the complexity ratchet

The #13951 gating fix pushed handleSave's cyclomatic complexity past the frozen
new-code ceiling (16 > 15). Moving the allowedProviders/allowedModelFamilies sync
into a module-level helper restores complexityNewCode=0 with no behavior change.

Co-authored-by: diegosouzapw <8016841+diegosouzapw@users.noreply.github.com>

---------

Co-authored-by: diegosouzapw <8016841+diegosouzapw@users.noreply.github.com>
2026-09-19 00:05:14 -03:00
John Costa
736ade4499 feat(sse): learn hard request caps from 429 bodies and pace under them (#13895)
* feat(sse): learn hard request caps from 429 bodies and pace under them

Providers such as TokenRouter reject bursts with prose like "Maximum 5
requests within 1 minutes" and send no rate-limit headers, so the limiter
never learned the ceiling and kept racing into it. Every 429 also evicts
the limiter, and the rebuilt one carried no pacing at all.

updateFromResponseBody now parses that phrasing (plus "N requests per
minute", "N requests per M seconds" and "N RPM") into a per-window cap,
applies it to the limiter as an empty reservoir that refills N every
window with calls spread window/N apart, and records it in
learnedRateLimits. getLimiter reapplies a learned cap whenever the limiter
is rebuilt, and loadPersistedLimits restores it after a restart, unless
the connection has an explicit RPM override.

Also pins the capacity-503 classification for TokenRouter's "system
cpu/disk overloaded" bodies as model_capacity with backoff, and prunes the
no-unused-vars allowlist entry for rateLimitManager.ts that this change
made obsolete.

Fixes #13594

* fix(sse): require a cap word before rpm figures and gate every cap path on the queue budget

`RPM_RE` accepted any "<n> rpm" so a usage readout like "current usage:
4 rpm" pinned the connection to one request every 15s. It now shares the
cap-word prefix and made/sent/used guard with the verbose pattern, so a
bare usage figure is not read as a ceiling.

The queue-budget refusal only guarded the learn path. A cap learned under
a generous maxWaitMs was still re-applied by getLimiter on every rebuild
and by loadPersistedLimits after a restart once the budget shrank, turning
every request into a local queue timeout. The check now lives in
capSettingsWithinBudget(), which all three paths call; a refused cap stays
recorded and is retried the next time the limiter is built.
2026-09-19 00:05:07 -03:00
Ai Jeremi
a100e2c906 fix(oauth): skip a second POST of a consumed Claude refresh token (#13874)
HealthCheck queued behind a Layer 2 refresh still presented the old
refresh_token after serializeRefresh released, which burned the family
and then nulled the row. Re-check rotation inside the lane, record
Layer 2 rotations, and keep the Claude refresh token on invalid_grant.

Co-authored-by: Cursor <cursoragent@cursor.com>
Co-authored-by: diegosouzapw <8016841+diegosouzapw@users.noreply.github.com>
2026-09-19 00:05:00 -03:00
fewensa
2f6b5b18e8 fix(auth): enforce blocked models in all-access mode (#13861) 2026-09-19 00:04:50 -03:00
Dylan Haskins
80e2ca7c39 fix: don't blanket-disable Claude tool-name prefix for non-Anthropic providers (#13856)
* fix: don't blanket-disable Claude tool-name prefix for non-Anthropic providers

The Issue #199/#618 fix that disables the proxy_ tool-name prefix for
requests landing in the general (non-CC-bridge, non-passthrough)
translation branch was scoped to "targetFormat === Claude", but that
condition is true for any provider translating into Claude's wire
format, not just genuine first-party Anthropic traffic. In practice
this let ordinary third-party tool names (e.g. GitHub Copilot's own
client-executed "web_fetch" function tool) pass through unprefixed
and collide with Claude's reserved tool namespace, getting rejected
upstream ("rejected tool(s): web_fetch") for any gh/claude-* model.

#618's actual traffic was real Claude Code (provider "claude") landing
in this fallback branch, so scoping the disable to provider === "claude"
(the same check already used a few lines above in the sibling
isClaudePassthrough branch) keeps that fix intact while restoring
correct prefixing for every other provider that merely targets
Claude's wire format.

Fixes #13835.

* docs: add changelog fragment for #13856
2026-09-19 00:04:42 -03:00
Nguyễn Viết Tuấn
c299e6c180 fix(translator): pair Gemini tool responses per turn to prevent cross-turn ID collision (#13848) 2026-09-19 00:04:31 -03:00
Pandu dwi Putra
f6bafe4b88 fix(resilience): resource_pressure admission gate actively re-samples instead of reading a stale cache (#13823)
`admitChatRequest`'s structural admission gate (chatBodyAdmission.ts) is the
first caller in the request path to consult pressure severity, ahead of every
other code path that would otherwise call `checkResourcePressureGuard()`
(handleChatCore, checkResourcePressureBeforeProviderWork,
AdaptiveAdmissionRuntimeImpl.acquire). `defaultPressureSeverity()` read the
resourcePressure singleton's cached `state` directly
(getResourcePressureObservation) instead of driving `check()` — so once
`state.severity` flipped to "critical" (from any pressure trigger: PSI, v8
heap ratio, a worker-leak spike, etc.), every subsequent request was shed at
this cheap cached-read gate before it could ever reach the one function
capable of drawing a fresh sample and observing recovery. The gate and the
only means of clearing it were mutually exclusive once tripped: the gate's own
rejection starved the sampler that would clear the gate. Only a full process
restart cleared it.

`defaultPressureSeverity()` now calls `checkResourcePressureGuard()` first.
That call is cheap on the hot path — a synchronous `process.memoryUsage()`
read plus a timestamp comparison; the actual signal sampling
(`/proc/pressure/memory`, cgroup reads) stays asynchronous via
`scheduleRefresh()` and throttled by `staleAfterMs`, so this adds no
per-request I/O. A non-null guard is this request's authoritative "shed now"
answer and maps to "critical". A null guard means this request is not shed,
but the raw cached label can still briefly read "critical" until the async
refresh settles (or if the last real sample simply went stale), so that case
is downgraded to "high" rather than re-introducing the same problem for the
queue-wait-sizing branch that also reads this value.

Fixes #13821

Test: new tests/unit/resource-pressure-gate-recovery.test.ts drives the
resourcePressure singleton to critical through the same sustained-sample path
production uses (not the synchronous immediate-heap escape hatch), using an
injected mock clock so no scheduled refresh from setup can resolve on its own.
Confirmed red on the base commit — the recovery assertion fails with
`actual: 'critical', expected: 'normal'`, i.e. the singleton never recovers on
its own — and green with the fix.
2026-09-19 00:04:24 -03:00
Zeeshan Haque
fa23670ea2 fix(codex): forward the caller client version upstream instead of a pinned default (#13708)
* fix(codex): forward the caller client version upstream instead of pinning 0.144.1

The Codex provider reported a hardcoded client version (DEFAULT_CODEX_CLIENT_VERSION)
to the ChatGPT backend. Newer gated models reject older clients, e.g.:

  The 'gpt-6-astra' model requires a newer version of Codex.

so the pinned value silently rots on every CLI upgrade, and a user on the latest
CLI is still refused.

CodexExecutor.buildHeaders also dropped the clientHeaders/model/health arguments
that the base class accepts (base.ts:509), so the caller User-Agent never reached
the version resolution.

- codexClient.ts: add getCodexClientVersionFromHeaders(), which reads the version
  the caller reports in its User-Agent (codex_cli_rs/<v>, codex_exec/<v>) or a
  version header, validated against SAFE_HEADER_TOKEN_PATTERN. getCodexUserAgent()
  now takes an optional version override.
- codex.ts: forward clientHeaders/model/health to super.buildHeaders(), and use
  getCodexClientVersionFromHeaders(clientHeaders) ?? getCodexClientVersion().

Falls back to the existing env override / default when the caller sends nothing.

* test(codex): cover getCodexClientVersionFromHeaders and clientHeaders-aware buildHeaders

Adds unit coverage for the caller-version forwarding introduced in this PR:
real Codex CLI User-Agent parsing, the generic version header, missing/empty
headers, non-Codex User-Agent, and CRLF/oversized injection attempts safely
returning null/falling back to the default version.

Co-authored-by: diegosouzapw <8016841+diegosouzapw@users.noreply.github.com>

---------

Co-authored-by: Zeeshan Haque <zeeshan@moonscape.local>
Co-authored-by: diegosouzapw <8016841+diegosouzapw@users.noreply.github.com>
2026-09-19 00:04:17 -03:00
Domenico Massafra
c8434436af feat(antigravity): expose physical send telemetry (#13659)
Co-authored-by: ginettododo <117327638+ginettododo@users.noreply.github.com>
2026-09-19 00:04:10 -03:00
ggdayup
d715190bb0 fix(runtime): eliminate hardcoded 20128 port remnants and make loopback URLs dynamic (#13533)
* fix(runtime): eliminate hardcoded 20128 port remnants and make loopback URLs dynamic

- Make model assessment probe base URL resolve dynamically from getRuntimePorts() / env
- Support dynamic loopback in traffic inspector replay route and MITM handlers
- Make WebSocket live server allowlist dynamically include runtime PORT/DASHBOARD_PORT loopback origins
- Update CLI tools config/apply/letta-settings and tool-detector to adapt to configured runtime port
- Update client UI components (EndpointPageClient, ApiExplorerClient, RelayProxyClient) to use current window origin or dynamic port
- Make resolveOmniRouteBaseUrl, useDisplayBaseUrl, and wellKnown.ts respect configured port
- Update package.json electron:dev wait-on to use ${PORT:-20128}
- Add test coverage for custom port in resolveOmniRouteBaseUrl and liveServerAllowList
- Add changelog fragment for PR #13533

* fix(runtime): complete the truncated fallback comment in wellKnown.ts

Co-authored-by: diegosouzapw <8016841+diegosouzapw@users.noreply.github.com>

---------

Co-authored-by: diegosouzapw <8016841+diegosouzapw@users.noreply.github.com>
2026-09-19 00:04:02 -03:00
Eliel Sousa
bf0f213cf0 fix(oauth): parse numeric epoch expiry so the health check still refreshes tokens (#13444)
* fix(oauth): parse numeric epoch expiry so the health check still refreshes tokens

provider_connections.expires_at / token_expires_at are TEXT columns, so an
epoch timestamp always reads back as a string. getEffectiveTokenExpiryMs()
went straight to new Date(), which yields an Invalid Date for a numeric
string, while the sibling getCopilotTokenExpiryMs() in the same file already
handled both numeric shapes.

Two ways this breaks checkConnection():

  - NaN -> getEffectiveTokenExpiryMs() returns 0 -> hasKnownExpiry is false.
    For a ROTATING_REFRESH_PROVIDERS entry (codex, claude, kiro, openai, ...)
    shouldRefreshByInterval is false as well, so the guard at
    'if (!isAboutToExpire && !shouldRefreshByInterval) return' skips the
    connection and it is never refreshed at all - the expiry-driven path the
    surrounding comment says it prefers is silently off.
  - An epoch-seconds *number* read as milliseconds lands in 1970, so
    isAboutToExpire is permanently true and every sweep refreshes the
    connection, burning single-use refresh-token rotations.

Extracts the numeric/string handling that getCopilotTokenExpiryMs() already
had into one exported parseTokenExpiryMs() and routes both call sites through
it, so every connection is parsed the same way.

Refs #12732 (base red at branch time).

* docs(changelog): add fragment for #13444
2026-09-19 00:03:56 -03:00
Themedexperiencesusa
c43fbb1b3d fix(security,resilience): block origin-IP header forwarding and treat 413 as retryable TPM (#13350)
* fix(security): never forward origin-IP headers upstream

Operator-set custom upstream headers could carry the client-origin IP
(x-forwarded-for, x-real-ip, cf-connecting-ip, forwarded, via, ...) to the
upstream provider, disclosing or spoofing it.

Extend the FORBIDDEN denylist in upstreamHeaders.ts to cover the whole
forwarding/IP set, mirroring the scrubbers already used by the Antigravity
(antigravityHeaderScrub.ts) and Cursor CLI (cursorCliProxy.ts) paths, so the
protection applies to every provider rather than those two.

Covered by tests/unit/upstream-headers-sanitize.test.ts (6 passing).

* fix(resilience): treat 413 payload-too-large as retryable TPM rate limit

Providers with a tokens-per-minute cap (Groq among them) answer an oversized
turn with 413 rather than 429. checkFallbackError() did not list 413 as
retryable, so the request failed hard instead of falling back to another
account or model.

- Add PAYLOAD_TOO_LARGE (413) to HTTP_STATUS and to the retryable set
- Return a MODEL_CAPACITY retryable fallback for 413
- Recognise "tokens per minute" / "tpm" as context-overflow patterns

---------

Co-authored-by: Themedexperiencesusa <221764849+themedexperiencesusa@users.noreply.github.com>
2026-09-19 00:03:49 -03:00
Tuan Dinh
7663aadea9 feat(models): add Gemini 3.8 Flash tiers to Antigravity and AGY catalogs (#13318)
* feat(models): add Gemini 3.8 Flash tiers to Antigravity and AGY catalogs

* fix(antigravity): handle Gemini 3.8 Flash thought signatures, native tool calls, and output token limits

* fix: remove duplicate Gemini 3.8 model specs

* feat(models): adopt CLI catalog, pricing, and version fallbacks from #12499 (#13318)

* test(models): assert Gemini 3.8 Flash catalog and pricing presence (#13318)

* fix(antigravity): Gemini 3.8 Flash tiers have no shared -tiered endpoint

Live testing against Google's Cloud Code upstream
(daily-cloudcode-pa.googleapis.com/v1internal:streamGenerateContent),
documented in #12499, shows Gemini 3.8 Flash is served directly at
gemini-3.8-flash-high/-medium/-low. Unlike 3.7, there is no
gemini-3.8-flash-tiered endpoint for 3.8.

This branch mapped the bare id and all three tiers to an invented
gemini-3.8-flash-tiered upstream target and declared that model in the
shared Antigravity/AGY catalog. Remove the invented catalog entry, alias
only the bare "gemini-3.8-flash" display id to its default tier
(gemini-3.8-flash-high), and let -high/-medium/-low pass through
verbatim to match what the live endpoint actually serves. Also drops
the now-dead managedModelImport.ts mitm-alias branch that forced the
same invented -tiered target, and updates the catalog/alias tests
accordingly.

Co-authored-by: diegosouzapw <8016841+diegosouzapw@users.noreply.github.com>

* revert: drop CLI catalog/pricing/version-fallback adoption also shipped by #12499

Commit be65c761 ("adopt CLI catalog, pricing, and version fallbacks from
#12499") pulled the Antigravity IDE/CLI fallback-version bump, the
CLI_TOOLS gemini-3.8-flash-* aliases/defaults, the oauth-subscriptions
pricing entries, and the matching antigravity-version.test.ts
assertions straight from PR #12499's own diff. #12499 ships all of that
content itself and lands first in the three-way reconciliation with
#12499/#13318/#14018, so keeping a second copy here would duplicate and
collide with it on merge. Revert exactly those four files to their
pre-be65c761 state; #12499 is now the sole source for this content.

Co-authored-by: diegosouzapw <8016841+diegosouzapw@users.noreply.github.com>

* test(models): align provider-models-route with the corrected 3.8 Flash catalog

Registering gemini-3.8-flash-high in ANTIGRAVITY_PUBLIC_MODELS (needed for
the corrected direct-tier routing) makes getClientVisibleAntigravityModelName()
prefer our static catalog name "Gemini 3.8 Flash (High)" over the upstream
discovery echo's own displayName, the same interaction #12499 hit and fixed
for its own copy of this catalog entry. Update the pre-existing discovery
retry test's expectation to match, mirroring #12499's fix to the same
assertion.

Co-authored-by: diegosouzapw <8016841+diegosouzapw@users.noreply.github.com>

---------

Co-authored-by: diegosouzapw <8016841+diegosouzapw@users.noreply.github.com>
2026-09-19 00:03:42 -03:00
VictorRP7
8288a4a012 fix(sse): deepseek-web resilience — premature session close + malformed tool-call recovery (#13226)
* fix(sse): deepseek-web collectSSEContent no longer returns a silent partial stub on premature session close

collectSSEContent() (used for the deepseek-web tool-calling / non-stream path)
drained the upstream SSE body and returned whatever content it had once the
reader reported done, with no check that DeepSeek had actually signalled
completion via response/status: "FINISHED".

When the upstream cookie session drops mid-generation (expired session,
anti-bot challenge, network interruption), the HTTP body simply closes
early. That was indistinguishable from a real completion: execute()
returned HTTP 200 with finish_reason "stop" and whatever partial stub text
had arrived so far. Observed in production call logs: a lone "I'll check
that..." / "Vou verificar..." with no continuation, reported as a
successful completion.

collectSSEContent now tracks whether the FINISHED status event was seen. If
the stream ends without it, it throws instead of returning the stub -
execute()'s existing try/catch turns that into a proper 502 that the
client, or a combo's retry/fallback logic, can react to.

Added tests/unit/deepseek-web-premature-close.test.ts covering both the
premature-close error path and the normal FINISHED completion path. Full
deepseek-web unit suite (97 tests) still passes.

* fix(sse): recover malformed deepseek-web tool-call replies and retry when unrecoverable

Two related failure modes on the deepseek-web tool-calling path, both
observed in production call logs from real agentic (VS Code Copilot-style)
usage of the deepseek combo:

1. DeepSeek's web session occasionally leaks malformed/internal formatting
   tokens right after an otherwise-complete <tool>{json} body, instead of
   a clean </tool> close (observed: a fully valid create_file JSON call
   immediately followed by corrupted pseudo-tags). parseLooseJsonObject's
   strict JSON.parse rejected the whole block over that trailing garbage,
   even though a perfectly valid object sat at the start - so the call was
   silently dropped and the raw tagged text was shown to the user instead
   of the file being created.

   deepseekWebTools.ts: added salvageLeadingJsonObject(), a quote/escape
   aware balanced-brace scanner that recovers just the leading JSON object
   when the strict parse fails, reusing the same salvage idea already used
   elsewhere in this file (findBareJsonCandidates) for bare-JSON detection.

2. When even that salvage cannot recover a call (genuinely truncated JSON,
   garbled beyond repair), execute() previously gave up on the first try.
   Since this is a scraped, non-deterministic web session rather than a
   real API, simply asking again is usually enough to get a clean reply.

   deepseek-web.ts: the hasTools branch now detects an unparsed <tool...>
   tag surviving in the cleaned content and retries with a brand-new
   session, bounded to MAX_TOOL_PARSE_ATTEMPTS (2) - never an unbounded
   retry loop, and a reply that parses cleanly on the first try costs no
   extra latency.

Builds on the collectSSEContent premature-close fix from the same PR -
that one covers the upstream session dropping mid-stream; this one covers
the session completing but returning malformed tool-call content.

Testing:
- tests/unit/deepseek-web-tools-salvage-leading-json.test.ts (4 tests):
  recovery from the exact production-observed corruption pattern, escaped
  quotes/nested braces before the garbage, correct non-promotion of
  genuinely truncated JSON, and no regression on well-formed blocks.
- tests/unit/deepseek-web-tool-call-retry.test.ts (3 tests): retry
  succeeds on a fresh session, retry is bounded (gives up after
  MAX_TOOL_PARSE_ATTEMPTS and surfaces the raw content rather than
  looping forever), and a clean first reply never triggers a retry.
- Full deepseek-web unit suite: 104/104 passing, no regressions.
- The salvage fix was additionally verified directly against the exact
  malformed content captured from a live production call log (not just
  the hand-written test fixture).

---------

Co-authored-by: VictorRP7 <187780317+VictorRP7@users.noreply.github.com>
2026-09-19 00:03:34 -03:00
Tobias Andersen
db022768df fix: FriendliAI 403 credit exhaustion misclassified as auth_error (#13040)
* Add new error signal for depleted credits

* chore: add changelog fragment for #13040 FriendliAI credit-exhaustion 403 fix

* test(accountFallback): add FriendliAI credit-exhaustion 403 body test (#13040)

---------

Co-authored-by: fesshompa <fesshompa@yahoo.com>
2026-09-19 00:03:25 -03:00
Rick7C2
614ff4b60a fix(sse): thread errorText into shouldMarkAccountExhaustedFrom429 (#13008)
* fix(sse): thread errorText into shouldMarkAccountExhaustedFrom429

shouldPreserveQuotaSignals() (open-sse/services/quotaResetParsing.ts) gained
an errorText parameter so an explicit quota-exhausted body could override the
apikey-category default, but only one of its two call sites was updated.
checkFallbackError() passes the upstream body; shouldMarkAccountExhaustedFrom429()
still called it with the provider alone.

With errorText undefined the helper's
`Boolean(errorText) && looksLikeQuotaExhausted(errorText)` branch can never be
true, so for every apikey-category provider without per-model quotas the
connection was never marked quota-exhausted -- even when the upstream body
explicitly said a long-window cap was hit.

Thread errorText through the helper and pass it at the src/sse/handlers/chat.ts
call site. The parameter is optional and additive: OAuth-category providers and
callers that pass no body keep their existing behavior, and plain rate limits
("Rate limit exceeded, retry in 20s", "Too Many Requests") still fall through to
the short generic cooldown.

Regression guard: tests/unit/quota-signal-errortext-threading.test.ts

* test(sse): pin the chat.ts call site that forwards errorText

The errorText parameter added in the previous commit is optional, so dropping
it at the only production call site (src/sse/handlers/chat.ts) is neither a
type error nor a test failure -- the four existing cases call the helper
directly and none of them assert the wiring. The half of the patch that makes
it do anything in production could be reverted, or lost in a refactor, with
the whole suite green. That is the same failure mode this branch fixes (a
two-argument helper with a call site silently passing one), one level up.

handleSingleModelChat is not exported, so the call cannot be driven or spied
without widening the production surface. Assert at the source level instead,
following tests/unit/api-key-provider-quota-bypass-scope.test.ts, and parse
the argument list rather than regex-matching formatted text so a Prettier
reflow cannot cause a false failure or a false pass. Also pins that there is
exactly one call site, and what errorStr holds.

Verified by mutation: dropping errorStr at chat.ts now fails 1 of 5.

* chore: number quota signal changelog fragment

Co-Authored-By: Paperclip <noreply@paperclip.ing>

---------

Co-authored-by: TogetherWeOwn <eng@togetherweown.com>
Co-authored-by: togetherweown[bot] <togetherweown[bot]@users.noreply.github.com>
Co-authored-by: Paperclip <noreply@paperclip.ing>
2026-09-19 00:03:18 -03:00
Tiangao
a123bd049e fix(images): fall back when combo leg returns empty 2xx response (#12982)
* fix(images): fall back when combo leg returns empty 2xx response

fetchImageEndpoint() normalized any successful HTTP response to
success:true with data.data || [], so an OpenAI-compatible provider
returning 200 with an empty/malformed image payload stopped image
combos on the first leg and produced an image-less 200.

Require at least one usable item (non-empty b64_json or url) before
declaring success; empty 2xx becomes a retryable 502 with a sanitized
error so executeImageCombo() advances to the next priority leg. Valid
responses and direct image-model requests are unchanged.

* chore(changelog): add fragment for image-combo empty-2xx fallback (#12982)

* test(images): drop misleading #10199 reference from empty-200 fallback test

The test file and its production-code comment referenced issue #10199,
which belongs to an unrelated, already-merged PR (auto/best-free free-tier
filter fix). Rename the test file and update comments to remove the
incorrect reference so future readers are not misled.

Co-authored-by: diegosouzapw <8016841+diegosouzapw@users.noreply.github.com>

* test(images): fix response-shape assertions after #12268 combo envelope change

release/v3.8.51 already ships #12268, which changed executeImageCombo() to
return the handler's payload unchanged ({created, data: [...]}) instead of
unwrapping it into a bare array. Update the two assertions that still
expected a bare array so the test reflects the combo response shape that
is actually live on the target branch; the fallback behavior under test is
unaffected.

Co-authored-by: diegosouzapw <8016841+diegosouzapw@users.noreply.github.com>

---------

Co-authored-by: Tiangao (hermes) <montigaud@aikumi.pro>
Co-authored-by: diegosouzapw <8016841+diegosouzapw@users.noreply.github.com>
2026-09-19 00:03:11 -03:00
Vishesh
b272aa2f23 fix(tests): drain call-log saves before chat-pipeline DB resets (#12780) (#12966)
Pending call-log saves can still be in flight when resetStorage closes
the DB instance. The orphaned save then writes into the next test's DB
after getDbInstance resolves.

- Drain pending saves in resetStorage before resetting DB instances.
- Bind DB instance at the start of saveCallLogOperation.
- Wait specifically for the Codex responses row in chat-pipeline tests.
2026-09-19 00:02:56 -03:00
Can H. Tartanoglu
88b928b8f1 feat(api): accept expiresAt when creating API keys (#12952)
* feat(api): accept expiresAt when creating API keys

POST /api/keys accepts expiresAt (ISO datetime, nullable) with the same
semantics as the key-update path, so automation can create an expiring key
in one operation instead of create-then-update. Omitted/null preserves the
current non-expiring behavior; enforcement reuses the existing expiry
policy without changes.

* docs: name changelog fragment after PR number with credit
2026-09-19 00:02:48 -03:00
Mr White
2b7a881c6f fix(claude): passthrough tool_use names must match client-declared casing (#12855)
* fix(claude): passthrough tool_use names must match client-declared casing

A mapless restoreClaudePassthroughToolUseName upgraded known Claude Code
tool names (bash -> Bash) on every Claude-format SSE passthrough. Clients
that declared lowercase tool names (pi, OpenCode, ... on claude-format
executors like devin-cli-agentic) received a tool_use name they never
declared: client-side tool dispatch fails and echoing the history back
hard-fails with devin-cli-agentic undeclared_historical_tool (live repro:
pi + dva/glm-5-2 on a self-hosted router, 400 on every agentic turn).

- restoreClaudePassthroughToolUseName: alias map first (renamed ->
  original), then normalize to the request's declared tools[] casing,
  never canonicalize undeclared names. Genuine Claude Code clients keep
  their #7926 protection (upstream downcase -> declared PascalCase).
- devin-agentic serializer: case-insensitive fallback for historical
  tool_use names + render the declared spelling, so case drift can no
  longer kill a whole turn.

Tests: tests/unit/claude-passthrough-tool-name-mapless-leak.test.ts,
tests/unit/devin-agentic-serializer-case-insensitive-history.test.ts

* fix(stream): direct ledger lookups in claude passthrough restore

restoreClaudeToolName's canonical-upgrade fallback fires even when an alias
ledger exists (canonical beats the identity match). The claude passthrough
lane always carries a non-empty proxy_ ledger
(buildClaudePassthroughToolNameMap), so every lowercase tool_use name was
upgraded to Claude Code PascalCase on the SSE path while the JSON path
(direct map.get) stayed correct — the live leak behind #12721.

* docs(changelog): add fragment for claude passthrough tool_use casing fix

Co-authored-by: diegosouzapw <8016841+diegosouzapw@users.noreply.github.com>

---------

Co-authored-by: diegosouzapw <8016841+diegosouzapw@users.noreply.github.com>
2026-09-19 00:02:37 -03:00
Rafa Martins
88e666e596 fix(sse): fix double-escaped tabs in Codex JSON tool call arguments (#12841)
Fixes #12831. When the Codex upstream model produces string values in tool arguments that contain double-escaped tabs (\t inside the JSON string instead of \t), the parser outputs literal backslash-t characters. This breaks editor patches that rely on proper indentation. This commit adds a fixDoubleEscapedTabs sanitization step before parsing to restore them to single tabs.

Co-authored-by: diegosouzapw <8016841+diegosouzapw@users.noreply.github.com>
2026-09-19 00:02:30 -03:00
tom
c193595db6 fix(open-sse): promote reasoning_details text to reasoning_content even when reasoning present (#12665) (#12688)
* fix(open-sse): promote reasoning_details text to reasoning_content even when reasoning present (#12665)

OpenRouter thinking models return both a "reasoning" string and a
"reasoning_details[].text" array for the same thinking trace. OmniRoute's
reasoning promotion gated on "is any readable value present" (which includes
the "reasoning" alias), so reasoning_content was never populated and clients
like opencode that only read reasoning_content lost all thinking traces.
Encrypted-only reasoning_details items are left intact (not flattened).

Fixes all three promotion gates:
- non-streaming: copyOpenAICompatibleReasoningFields now mirrors
  reasoning_details[].text into reasoning_content unless reasoning_content
  itself is present
- streaming mirror block: same gate fix on getReadableReasoningValue
- streaming passthrough: force re-serialization when sanitize added a
  reasoning_content the upstream delta did not carry (needsReserialization
  was false because hasUnsupportedReasoningSignal requires !readable)

Tests: non-streaming + streaming unit regressions and an integration E2E
that drives the full handleChat path against a mock OpenRouter provider.

Also closes the same latent gate in the JSON-to-SSE rehydrator
(jsonToSse.ts buildReasoningDelta): a populated reasoning string used to
short-circuit the unsupported-alias mirror, so reasoning_details[].text
was dropped when synthesizing an SSE stream from a non-streaming JSON
body. Adds a #12665 regression test for that path, fixes an over-indented
brace in stream.ts (lint), and restores the missing trailing newline in
the E2E.

* docs(changelog): add fragment for #12688 — fix(open-sse): promote reasoning_details text to reasoning_content even when reasoning present

* fix(tests): replace any with typed casts in #12665 regressions to satisfy no-explicit-any gate
2026-09-19 00:02:22 -03:00
Abhishek Sharma
0f5f83c5ed fix(resilience): clear the combo LKGP pin only when it names the failed target (#12235)
* fix(resilience): clear the combo LKGP pin only when it names the failed target

Re-applied onto current release/v3.8.51. The branch was 217 commits behind
and dispatchWithCooldownRetry / handleRoundRobinCombo have since moved out
of combo.ts, so this is a re-application, not a rebase: the definition is
still in combo.ts, and the 14 call sites now live in
combo/executeTargetAttempt.ts (4), combo/executeTargetGates.ts (5) and
combo/roundRobinCombo.ts (5). clearStaleLKGP is dependency-injected via
attemptLoopTypes.ts, so that signature takes the new parameter too.

Unchanged in substance. The target-scoped pin is still always cleared; the
combo-level pin is cleared only when it actually names the failed target's
provider, because it records whichever provider last *succeeded* and that
need not be the one failing now. Under `auto` the pin is a scoring input
rather than a hoist, so clearing unconditionally discarded a preference for
a healthy provider every time an unrelated target was skipped. Omitting
`failed` keeps the old behaviour for callers with no target in scope.

  4 regression tests pass, including "a pin naming a healthy provider
  survives another target being skipped"

  mutation: clear the combo pin unconditionally (the pre-fix behaviour)
    -> only that test fails, 3 pass

  248 tests pass across tests/unit/lkgp*, tests/unit/combo/*
  eslint clean on all five changed files (exit 0, no suppressions flag)

* docs(changelog): add fragment for the LKGP pin scope fix

Co-authored-by: diegosouzapw <8016841+diegosouzapw@users.noreply.github.com>

---------

Co-authored-by: diegosouzapw <8016841+diegosouzapw@users.noreply.github.com>
Co-authored-by: abhisheksharma2411 <abhisheksharma2411@users.noreply.github.com>
2026-09-19 00:02:10 -03:00
Syed Raheemuddin
975b29c275 feat(core): improve observability for dual-auth fallback execution (#11828)
* feat(core): improve observability for dual-auth fallback execution

* refactor(executors): keep the clinepass auth decision in buildClinepassHeaders

The clinepass case no longer re-decides OAuth vs BYOK off
credentials.authType; it always delegates to buildClinepassHeaders(),
which already keys the decision off credentials.accessToken, and only
the debug log line branches. The OAuth test now uses the real credential
shape ({ accessToken }) instead of an OAuth token stored in apiKey, and
a parity test pins the executor output to buildClinepassHeaders() for
both credential shapes so the two paths cannot drift apart.

Co-authored-by: diegosouzapw <8016841+diegosouzapw@users.noreply.github.com>

---------

Co-authored-by: diegosouzapw <8016841+diegosouzapw@users.noreply.github.com>
2026-09-19 00:02:02 -03:00
diegosouzapw
271ec25f12 chore(quality): adjust the train-8 accountFallback ceiling to the re-measured 2517 2026-09-18 23:37:03 -03:00
Diego Rodrigues de Sa e Souza
4d1282be31 fix(sse): restore maxQueueDepth=0 as unbounded, sanitize refusals at the write, drain the 09-18 base-reds (#14101)
* fix(quality): drain the 09-18 base-reds, part 1 — thinking gate parity, inventory, webpack externals

Reproduced on the clean tip 7cc454d9 before touching anything.

Five of the failures trace to one commit, #12905 (b7192b72): it gated
thinking-block emission on `requestedThinking === true` in the streaming
translator, while its own non-streaming path documents `undefined` as the
legacy caller shape that keeps "always a thinking block". The two paths
disagreed on the same input, and the streaming side also synthesized the
reasoning into a TEXT block for that legacy shape. chatCore always resolves a
boolean, so production never sends `undefined` — but every direct caller and
the older #5786 suites do. Aligned the streaming gate to the documented
tri-state: `false` suppresses, `true` and `undefined` relay, and the fix-B text
synthesis fires only on an explicit opt-out. The #12905 test that asserted
suppression used a bare createState() (`undefined`) to mean "client did not
request thinking"; it now passes `requestedThinking: false`, which is what that
sentence resolves to in production. The whole thinking family — dsml, adapter,
translator, non-stream parity, #13620, #5786, markdown boundary — is 77/77.

#12864 added requestRejectedFailure.ts with a getProviderConnectionById read
that seeds the refusal streak across restarts; inventoried as a connection
state read next to the family-cooldown site it resembles.

#13909 made machineToken.ts import ./dataPaths; the isolated webpack compile has
no repo tree, so it joins the sibling externals.

The free-tier budget card SVG was one wave behind again (482 -> 491 models).

Refs #13866

* fix(sse): restore maxQueueDepth=0 as unbounded, sanitize refusals at the write, drain the rest

Part 2 of the 09-18 base-red drain. Two of the remaining failures were not
stale tests but production defects the tests had caught.

#12911 taught accountSemaphore to read `maxQueueSize: 0` as "reject when the
slot is busy", which is what its Codex WS lease wants. But chatCore forwards
`resilienceSettings.requestQueue.maxQueueDepth` into that option, and that
setting's documented default since #6593 is `0 = disabled`. Under default
settings every request that found its account slot occupied was answered
429 "Semaphore queue full (0)" instead of waiting — the managed-lease routing
test saw exactly that. `0` (and any non-positive value) is unbounded again;
the lease gets an explicit `failFast` option and its four tests stay green, so
the #12911 behaviour is preserved where it was meant to apply. A contract test
pins the #6593 semantics on the semaphore itself.

#12864 moved two providerFailure persistence branches out of chatCore into
requestRejectedFailure.ts and the sanitization did not travel with them: three
`lastError` writes stored the message as received. The only caller already
hands in the projected persistentMessage, so nothing leaks today, but a
persistence branch must be safe at its own write (docs/security/
ERROR_SANITIZATION.md) rather than trust whoever calls it. The module now
sanitizes on entry, and the public-boundary guard — which caught this by
counting sanitized writes in chatCore and coming up two short — covers the
extracted module too, verified by mutating one write back to raw.

The rest are tests that had fallen behind legitimate changes:

- #12905 inserted `requestedThinking` as the 14th positional argument of
  createSSETransformStreamWithLogger; two tests passed customToolNames or the
  buffer budget at their old positions. Both production callers were already
  correct.
- #12754 added a per-connection reset-card fetch after the quota fetch; the
  spacing test now marks a chunk at the quota request only.
- #13910 renamed `error` to `errorMetadata` in the timeout classification; the
  probe matches the identifier with a backreference and still fails when
  BodyTimeoutError is removed from both sites.

Refs #13866

* fix(test): pin the opt-out thinking cases to requestedThinking=false; keep acquireMany under the complexity ceiling

The #12905 gate-restore suite encoded 'requestedThinking absent' as opt-out, the
same undefined-means-false shape its non-streaming twin documents the other way
and that the two-month-old #5786 suites contradict. The three opt-out cases now
set the flag explicitly, which is what chatCore resolves for an opted-out
client; the two opt-in cases already did. Both suites pass together (27/27).

The failFast branch pushed acquireMany over the complexity ceiling it already
sat on; the admission policy (fail-fast / bounded / unbounded queue) moves to
findQueueRejection() and the new-code ratchet is back at its base.

Refs #13866

* fix(test): suspend the #14110 redaction assertion inline; refresh the budget card

The 57 commits merged since the previous validation moved two things.

#13295 changed how an unknown-root path with an ambiguous tail is answered:
where `Provider failed at /custom/internal secret directory` used to become
`Provider failed at <path>` it now ships verbatim. The #12506 boundary guard
caught it. Two candidate fixes were tried and each breaks one of the two live
contracts — #12506's fail-closed swallow, or #13144's rule that a route in
prose must survive — so the choice is the owner's (#14110). The one contested
assertion is suspended inline with the exact line and the issue; the other
nine stay active. The isolated-child harness requires tests == pass, which is
why it is a comment and not a todo.

The free-tier budget card was one wave behind again (491 -> 489 models).

Refs #13866, #14110

* fix(providers): type the TinyCMS DOM stub global as a loose record

#13957 typed the mock global as `typeof globalThis & Record<string, unknown>`.
The api-route typecheck loads lib.dom, so that intersection carries the real
Window / HTMLCanvasElement / document signatures — every stub assignment fails
against a DOM constructor, and `delete g.window` narrows the object to
`never` (13 diagnostics, the API Route Typecheck base-red on the tip). The
function exists to overwrite those globals with stubs; it is now typed as the
plain record it manipulates. 29/29 tinycms tests unchanged.

Refs #13866

* fix(test): pin the last opt-out thinking sibling to requestedThinking=false

translator-reasoning-gate-502-repro is the third #12905 test that encoded a bare
state as opt-out; the previous sweep matched files by glob and missed it. The
family is now enumerated by grep on requestedThinking (7 files) plus the two
pre-#12905 suites: 83/83 together.

Refs #13866
2026-09-18 13:58:56 -03:00
diegosouzapw
2af688a94f chore(quality): file-size rebaseline for merge-train 8 (owner-approved, 2026-09-18)
28 ceilings raised to the sizes measured on the combined train tip 04cf8095
(32 contributor PRs boarding release/v3.8.51, all merge-ready; the release tip
itself was green on check:file-size before boarding). Each PR adds a few
irreducible call-site lines to a god-file the ratchet already freezes; the
per-file attribution and the policy reference live in the baseline entry
_rebaseline_2026_09_18_merge_train_8_frozen_growth. Precedent:
_rebaseline_2026_07_23_v3849_merge_train_15.
2026-09-18 13:44:44 -03:00
Diego Rodrigues de Sa e Souza
8feea123bb feat(docs): mirror every docs/ page in all 65 locales (#14106)
* feat(docs): mirror every docs/ page in all 65 locales

Extends the documentation mirrors from the 22-page core set (#13940) to
every Markdown page under docs/: 152 sources x 65 locales = 9,880 mirrors
(6,208 new), language bars rewritten for the full locale list, state
adopted so the blocking drift gate now covers all 152 pages.

run-translation.mjs: an oversized block made only of table rows or list
items (PROVIDER_REFERENCE.md 244-row table, FREE_TIERS.md 71-item list) is
cut at item boundaries and rejoined without a blank line — the single
16-40 KB request outlived the backend socket for verbose scripts. 48
older mirrors whose tables had lost rows were retranslated with --force.

* docs(i18n): refresh mirrors for the sources the base changed since the branch cut

Section-level retranslation of the 29 docs (and README.md) whose source
or mirrors moved on release/v3.8.51 during the run, then state adoption;
the drift gate is green again on the merged tree.
2026-09-18 13:16:46 -03:00
diegosouzapw
c059b77823 docs: bump the migration count 176 -> 178 (#13222 open-wa seed, #12814 proxy_logs.proxy_name)
Maintainer-side count bump after the two migration PRs merged; the docs-counts
gate reads README.md, AGENTS.md and llm.txt (+ its 65 i18n mirrors), all of
which are agent-instruction / protected surfaces the contributor PRs must not
touch. check:docs-counts and check:docs-sync green.
2026-09-18 13:16:02 -03:00
Tiangao
fda9ef78b1 feat(proxylogs): show registry proxy name in proxy log columns (#12814)
* feat(proxylogs): show registry proxy name in proxy log columns

Registry resolution already attaches name to the runtime proxy object; the
name was dropped at the persistence boundary (ProxyInfo had no name field)
and never rendered. Add proxy_name column (base schema + ALTER heal for
existing DBs), persist/hydrate it, render it in the ProxyLogger table and
ProxyLogDetail pane with host:port fallback, and search by name.

Local-only (PMO City): not submitted upstream. Re-apply after upgrades via
patch file (see pmo-city-builds omniroute/Operator/runbooks/upgrade.md).

* test(proxylogs): flush batched writes before asserting persisted row

The v3.8.50 rebase kept upstream's batched proxy-log persistence
(enqueueProxyLogs/flushProxyLogsSync); logProxyEvent no longer writes
synchronously, so the persist+hydrate test closed the DB before the row
was flushed. Flush explicitly first.

* test(proxylogs): drain batched queue in resetStorage to avoid cross-test row bleed

* docs(changelog): add changelog fragment for proxy registry name in proxy logs

Co-authored-by: diegosouzapw <8016841+diegosouzapw@users.noreply.github.com>

---------

Co-authored-by: Tiangao (hermes) <montigaud@aikumi.pro>
Co-authored-by: diegosouzapw <8016841+diegosouzapw@users.noreply.github.com>
2026-09-18 13:14:14 -03:00
Abhishek Sharma
95b2e53727 feat(usage): generic billing/quota for openai-compatible connections (#13673)
* feat(usage): generic billing/quota for openai-compatible connections (#13616)

Every other fetcher in services/usage hard-codes one upstream's URL, auth
and response shape, which works because those providers are known. An
openai-compatible connection can point at anything, and its id is minted
per connection -- so it can never be a member of USAGE_SUPPORTED_PROVIDERS
or a case in the dispatcher switch.

So the shape comes from the connection instead. `providerSpecificData.
quotaEndpoint` declares the url, auth mode, optional headers, and a mapping
of dot-paths onto UsageQuota:

    { "url": "...", "auth": "bearer",
      "quotas": { "credits": { "used": "$.data.used_usd",
                               "total": "$.data.limit_usd",
                               "currency": "USD" } } }

Dot/bracket paths (`$.a.b[0].c`) rather than full JSONPath, so the mapping
stays dependency-free and legible in a config field.

Three decisions worth stating:

- **An unresolvable mapping reports nothing, never 0/0.** A quota reading
  0 of 0 renders as fully exhausted, and an operator would act on that. A
  typo'd path must produce no card, not a fake outage.
- **The transport error is not echoed.** The url is operator-supplied and
  can carry a query-string secret; the message says "unreachable" and
  nothing more.
- **The capability is read off the connection, not the id.**
  `supportsProviderQuota` already takes the connection and already has a
  connection-shaped check (moonshot), so the gate goes there. A declared
  url with no `quotas` mapping does NOT count as supported: it can be
  fetched but can never yield a quota, and would leave a permanently empty
  card in Provider Limits.

Verified: 7 new tests; mutations each killed by the right one --
  let an unresolved mapping fall through to 0/0 -> that test alone fails
  echo the transport error                       -> that test alone fails
121 tests pass across this file, usage-families-split, provider-plugin-
manifest, provider-limits* and the quota-visibility suites (the drift
guard from #13134 included). eslint clean on all four files; the three
no-unused-vars errors in usage.ts are byte-identical on the base branch.

* fix(usage): bound the openai-compatible quota fetch with a timeout

An operator-configured quotaEndpoint that never responds would hang
getOpenAiCompatibleUsage()'s fetch() indefinitely, stalling that
connection's Provider Limits sync. Same 15s bound as the other
fetchers in this directory (grokResetCredits.ts's FETCH_TIMEOUT_MS).

Co-authored-by: diegosouzapw <8016841+diegosouzapw@users.noreply.github.com>

* test(usage): pin the openai-compatible quota fetch timeout

A quota endpoint that accepts the connection and never answers must be
aborted by the fetch signal instead of hanging the Provider Limits sync.
Fails without the AbortSignal.timeout() bound, passes with it.

Co-authored-by: diegosouzapw <8016841+diegosouzapw@users.noreply.github.com>

---------

Co-authored-by: diegosouzapw <8016841+diegosouzapw@users.noreply.github.com>
Co-authored-by: abhisheksharma2411 <abhisheksharma2411@users.noreply.github.com>
2026-09-18 13:14:06 -03:00
STAVAN SHAMUVEL WADEKAR
c74cea3d35 feat(mitm): dynamically inject configured models into Antigravity model catalog (#14006)
* feat(mitm): dynamically inject configured models into Antigravity model catalog

- Add /v1internal:fetchAvailableModels to ANTIGRAVITY_TARGET.endpointPatterns in src/mitm/targets/antigravity.ts
- Implement catalog interception and dynamic model merging in AntigravityHandler.intercept() (src/mitm/handlers/antigravity.ts)
- Merge operator's configured combos/models dynamically from the repository into Google Cloud Code's upstream catalog
- Prepend injected models to agentModelSorts recommended group while preserving native models and upstream structure
- Add unit tests covering target endpoint pattern declaration, catalog merging, dynamic combo retrieval, and error propagation in tests/unit/mitm-handler-antigravity.test.ts

Resolves #13959

* test(mitm): isolate DATA_DIR and clean up the combo row in the antigravity catalog test

The DB-backed test ("dynamic catalog pulls configured combos from database
repository") creates a real combo row via src/lib/db/combos.ts, whose module-
level DATA_DIR const resolves once at import time. The PR's own documented
Validation command (`node --import tsx/esm tests/unit/mitm-handler-antigravity.test.ts`)
runs without the `--test` flag, so the existing #10428 eval-probe/test-context
guard in resolveWritableDataDir() never triggers and DATA_DIR falls through to
the real ~/.omniroute home database — writing a permanent test-combo row into
it every run.

Set DATA_DIR to an isolated temp dir at the top of the file (before the
combos.ts import), reset the DB singleton and clean up the temp dir in
test.after(), and wrap the combo creation in try/finally so the created row is
deleted even on assertion failure.

Co-authored-by: diegosouzapw <8016841+diegosouzapw@users.noreply.github.com>

* fix(mitm): prevent model name collision and filter inactive combos in antigravity catalog

* feat(antigravity): integrate native auto groups, fallback to groq, and add bridge proxy

- Inject OmniRoute native auto groups (auto/best-fast, auto/best-coding, auto/best-reasoning, auto/best-free, etc.) into Antigravity IDE & CLI /model selector
- Add bin/antigravity-bridge.mjs with selective proxy routing to isolate native Gemini quota (zero Google token leakage)
- Implement transparent self-healing model remapping to prevent upstream 410 model_shutdown errors on deprecated models
- Update emergencyFallback provider from nvidia to groq/openai/gpt-oss-120b for resilient 0.02s failover

* test(antigravity): add unit test suite for antigravity bridge routing and model self-healing

- Add tests/unit/antigravity-bridge-routing.test.ts covering zero quota leakage for native Gemini models
- Validate OmniRoute auto group routing and display name interception
- Validate retired upstream model self-healing (preventing HTTP 410 crashes)
- Export helper methods from bin/antigravity-bridge.mjs with isMain guard

---------

Co-authored-by: Stavan <stavan794@gmail>
Co-authored-by: diegosouzapw <8016841+diegosouzapw@users.noreply.github.com>
Co-authored-by: steve25060 <steve25060@users.noreply.github.com>
2026-09-18 13:13:59 -03:00
dmlanday
d600eba35b fix(cli): preflight the port before serving so a second instance cannot de-register the first (#12485)
* fix(cli): preflight the port before serving so a second instance cannot de-register the first

Starting `omniroute serve` against a port another OmniRoute already owns
produced three identical raw Node stack traces and no explanation:

    Error: listen EADDRINUSE: address already in use 0.0.0.0:20128

The conflict was handed to the child process, so it surfaced only after the
child had been spawned and retried twice on the supervisor's restart budget,
and never named the process holding the port.

The damage was worse than the noise. Both spawns happen after
writePidFile("supervisor") and the failed child's cleanupPidFile("server"), so
a doomed second instance overwrites the pid files of the healthy instance that
owns the port: supervisor/.pid ends up pointing at the dead starter and
server/.pid is deleted, de-registering a server that is up and serving.
Observed live: healthy server 19348 under supervisor 11108, while
supervisor/.pid read 21440 (dead) and server/.pid was gone. `omniroute stop`
still worked, but only by falling through to its killByPort port fallback.

serve now resolves the port owner before spawning anything or touching a pid
file, and exits with a message naming the owning PID and the two ways out
(`omniroute stop`, or `serve --port <other>`). Discovery lives in
findListeningPids() in bin/cli/utils/pid.mjs (netstat on win32, lsof
elsewhere); it mirrors killByPort()'s discovery in stop.mjs, which is worth
consolidating next time that file is touched. A discovery failure reports the
port as free, since a false "busy" would block a legitimate start.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CJf2dxEpiwZqyZujWk57T2

* chore(changelog): link the port-preflight fix to PR 12485

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CJf2dxEpiwZqyZujWk57T2

---------

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
Co-authored-by: diegosouzapw <8016841+diegosouzapw@users.noreply.github.com>
Co-authored-by: dmlanday <dmlanday@users.noreply.github.com>
2026-09-18 13:13:49 -03:00
Davide Baraldo
1b2349de22 feat(sse): Claude OAuth lower-priority lane + weekly session-limit reset (#13074)
* feat(sse): Claude OAuth lower-priority lane + weekly session-limit reset

Mirror Claude Code's /low-priority and /limit-reset for OmniRoute-managed
Claude subscription accounts (wire contract captured from Claude Code 2.1.263).

Both are opt-in per connection (providerSpecificData.lowPriorityMode /
autoLimitReset, Edit connection -> Claude section, default off) and only act
on the 5-hour usage wall: a 429 carrying
anthropic-ratelimit-unified-status: rejected and, when eligible,
anthropic-ratelimit-unified-slow-offer: treatment. Nothing is sent before
that first wall 429.

- Lower-priority lane: on the wall the executor retries the SAME account
  with `anthropic-usage-limit: slow` and keeps the header on every request
  until anthropic-ratelimit-unified-reset (+60s). The intercepted 429 never
  reaches chatCore, so the connection is not cooled down or rotated away.
  slot_busy (429) / 529 wait slow-retry-after (20s default, 5-600s, +-30%
  jitter) bounded by slow-max-wait (20min default, 1min-6h), then end +
  10min cool-off. weekly_limit / budget_exhausted / off / ineligible, a
  5h-window rollover, or ineligible + overage-in-use end the lane and let
  the response flow to the normal cooldown path.
- Session-limit reset: GET /api/oauth/usage?at_wall=1&skip_spend=1 ->
  juniper_tide block; when arm=reset and available, POST
  /api/organizations/{org}/reset_rate_limits {program: "juniper_tide"} and
  retry at full speed. already_used / not offered memoise next_available_at.
- State is in-memory per connection; the executor owns the abort-aware
  sleep; the pure state machine and the HTTP client are separate modules
  with unit tests; an executor-level test proves the header/retry wiring
  end to end with a mocked upstream.

* fix(sse): make the Claude usage-wall handling race-safe for parallel requests

Two requests on the same Claude OAuth connection can hit the 5-hour wall in
the same instant.

- Lower-priority lane: the executor now tells the decider whether THIS
  request carried `anthropic-usage-limit: slow`. A sibling built while the
  lane was still idle (no header) whose 429 lands after the lane activated
  is re-sent on the lane instead of being misread as a "wall" verdict that
  would end it; its 2xx is not counted as lane telemetry either.
- Session-limit reset: concurrent wall hits share one in-flight status+claim
  round trip (no duplicate POST reset_rate_limits), and for 60s after a
  granted reset stale sibling walls are answered "reset" without touching
  the network, so they retry at full speed instead of re-claiming or
  falling into the slow lane.

Tests cover both races.

* fix(sse): address adversarial review of the Claude usage-wall handling

Three defects found by a 3-lens review of the two previous commits.

1. Lane wait could outlive the request (high). The slot_busy/529 sleep shares
   the request's AbortSignal with chatCore's upstream-start timeout (10 min by
   default), while the lane's own max-wait defaults to 20 min and can reach 6h
   from the server header. A long slot_busy streak was therefore killed
   mid-sleep with a TimeoutError instead of ending gracefully as max_wait with
   its cool-off. The decision now takes a waitCeilingMs — what is left of the
   executor's own timeout, minus a 5s margin — which caps the effective
   max-wait and clamps each individual sleep.

2. A wall 429 surfacing only after a 400-driven intra-attempt retry was missed
   (medium). The context-editing / thinking-budget / effort / auto-learn
   fallbacks all re-fetch and REASSIGN `response`, and the wall check ran
   before them, so such a 429 fell through to the generic path and cooled the
   connection down. The check now runs after those retries, on the final
   response of the attempt.

3. `ineligible` + `overage-in-use: true` ended the lane as plain `ineligible`
   on a 429 (medium) because the status mapping ran first; only the non-429
   tail produced `extra_usage`. Overage takeover now wins on every status.

Also bounds the module-level per-connection maps with the same FIFO policy as
the identity caches in claudeIdentity.ts: the state key falls back to the
access token when a connection id is absent, and OAuth tokens rotate on every
refresh, so the maps could grow for the process lifetime.

Tests cover all three fixes, including an executor-level regression for the
400-then-wall ordering.

* fix(i18n): add the Claude usage-wall toggle strings to pt-BR

`tests/unit/i18n-pt-br.test.ts` (#6695) requires pt-BR.json to carry every key
present in en.json; the four new `providers.claude{LowPriorityMode,AutoLimitReset}*`
keys were only added to en and it, so the gate failed on this branch.

* refactor(sse): keep the usage-wall change inside the frozen quality budgets

The three ratchets this PR tripped were all its own, not inherited:

- file-size (frozen, may only shrink): open-sse/executors/base.ts 1857 > 1751
  and EditConnectionModal.tsx 1653 > 1631.
- complexity / cognitive-complexity (new-code mode): three functions over the
  15 threshold — runClaudeLimitResetAttempt (27), handleClaudeUsageLimitResponse
  (19 / cognitive 23) and observeClaudeLowPriorityResponse (17 / 17).

Extractions, all behavior-preserving:

- New open-sse/executors/claudeUsageLimit.ts owns the executor-side glue (header
  injection, wait accounting, abort-aware sleep, timeout-derived wait ceiling and
  the decision logging) behind a ClaudeUsageLimitGuard, so base.ts keeps a
  three-line call site instead of ~100 lines of mechanics.
- Three long-standing Claude blocks leave base.ts for the modules they belong to:
  mergeCcHeaders + applyStainlessHeaders into config/anthropicHeaders.ts and
  stripClaudeSystemPrefixBlocks into executors/claudeIdentity.ts. base.ts is back
  at its frozen 1750 lines.
- The modal's Claude section becomes ClaudeConnectionFields.tsx (mirroring
  CcCompatibleRequestDefaultsFields) plus a claudeConnectionFields.ts helper that
  de-duplicates the field defaults across the modal's two init sites; the file
  drops to 1622, below its frozen 1631.
- The three over-threshold functions are split into focused helpers
  (observeErrorResponse / observeSuccessResponse, shouldClaimLimitReset,
  resolveLimitResetOffer / runLimitResetClaim / memoiseNotBefore).

Gates now: file-size OK, complexity 0 new violations, cognitive 0 new,
fetch-targets / error-helper / build-scope / deps OK, typecheck clean, ESLint 0,
Prettier clean, 155 unit tests green across the feature and its neighbours.

Still failing and NOT this branch's: pack-policy (unexpected
@omniroute/opencode-plugin-v2 files in the npm artifact) and
mutation-test-coverage (stryker tap.testFiles missing entries for
circuitBreaker.ts and comboStructure.ts) — both reproduce on the untouched base.

---------

Co-authored-by: davidebaraldo <davidebaraldo@users.noreply.github.com>
Co-authored-by: diegosouzapw <8016841+diegosouzapw@users.noreply.github.com>
2026-09-18 13:13:41 -03:00
Sean Ford
0fbd8854c5 fix(api): adapt TEI/Infinity request and response shapes on the /v1/rerank node path (#13733)
* feat(api): route /v1/rerank to remote provider nodes behind RERANK_REMOTE_PROVIDER_NODES

POST /v1/rerank only ever dispatched to provider nodes whose base URL hostname
was localhost, 127.0.0.1, or 172.16.0.0/12 — a filter hardcoded in the route.
A rerank node on any other host (a LAN box or Tailscale peer running TEI,
Infinity, vLLM, …) was silently dropped and the request fell through to
"Invalid rerank model", even though the same node served /v1/embeddings
without complaint and had already passed the provider outbound URL policy at
creation time. The memory engine's rerank step calls this route over loopback,
so `rerankProviderModel` could not reach such a node either.

Mirror the audio routes (#3963): loopback nodes stay always-eligible and
unchanged; remote nodes are opt-in via a new `RERANK_REMOTE_PROVIDER_NODES`
feature flag (default off — routing to a remote host changes egress identity)
AND must pass the provider outbound URL policy (`getProviderOutboundGuard()`,
`public-only` deployments never route to private hosts.

- src/shared/network/loopbackNodeHost.ts: one pure definition of the
  loopback host set, replacing three copies (rerank route, audioRegistry,
  localHealthCheck). The shared version also rejects `user@host` URLs, which
  the audio copy did not.
- src/shared/network/providerNodeHost.ts: policy-aware remote-node
  eligibility that mirrors guardProviderNodeBaseUrl() on the creation path.
- src/app/api/v1/_shared/rerankProviderNodes.ts: pure, testable selection
  step + loader, modelled on audioProviderNodes.ts.
- Feature flag definition, FEATURE_FLAGS.md / ENVIRONMENT.md / .env.example
  rows, API_REFERENCE.md and MEMORY.md notes, changelog fragment.
- tests/unit/rerank-remote-provider-nodes.test.ts covers the host
  classification, the three policy modes, the selection step, and the route
  end-to-end (flag off → 400 without contacting the node; flag on → forwarded
  to <base>/v1/rerank with the node credential; flag on + strict policy →
  still excluded). Feature-flag count test bumped to 56.

* chore(changelog): name the #13732 fragment

* fix(api): adapt TEI/Infinity request and response shapes on the /v1/rerank node path

The provider-node branch of POST /v1/rerank already fell back from
<base>/v1/rerank to <base>/rerank on 404 "for Infinity / TEI", but it kept
sending the Cohere body and returned the upstream JSON verbatim. Against
Hugging Face text-embeddings-inference that could never work: TEI requires
the candidate list as `texts` (HTTP 422 otherwise), takes `return_text`,
and answers a bare `[{index, score, text?}]` with no `results` envelope and
`score` instead of `relevance_score`. Thin gateways in front of TEI/Infinity
commonly emit `score` too. Either way the memory engine's applyRerank(),
which reads `results[].relevance_score`, ended up with undefined scores.

Add two pure adapters in src/app/api/v1/_shared/rerankLocalNodeShapes.ts:

- buildLocalRerankRequestBody(): one upstream body carrying both spellings
  (`documents` + `texts`, `return_documents` + `return_text`). TEI's request
  struct is not deny_unknown_fields and the OpenAI-shaped servers (vLLM,
  llama.cpp, Infinity, oMLX) ignore extras, so a single body serves all.
- normalizeLocalRerankResponse(): folds `{results:[…]}`, Voyage-style
  `{data:[…]}`, and TEI's bare array into the Cohere envelope, backfilling
  `relevance_score` from `score`, sorting by score, honouring `top_n`,
  attaching `document.text` when requested, dropping malformed entries, and
  preserving other top-level fields (`model`, `usage`, …).

The route now uses both on the primary and fallback fetch. Cloud registry
providers are untouched (they go through open-sse/handlers/rerank.ts).

tests/unit/rerank-local-node-shapes.test.ts covers the adapters and the
route end-to-end: 404 → /rerank with `texts`, bare TEI array normalized and
top_n-capped; `score`-only gateway → `relevance_score` for the client.

* chore(changelog): name the #13733 fragment

* refactor(api): split the local rerank response normalizer into per-entry helpers

The complexity ratchet (new-code mode) flagged normalizeLocalRerankResponse
at 18/15 on both metrics. Pull the per-entry validation and the document
resolution into toCohereResult() / resolveResultDocument(); behaviour and
tests are unchanged.

---------

Co-authored-by: seanford <seanford@users.noreply.github.com>
Co-authored-by: diegosouzapw <8016841+diegosouzapw@users.noreply.github.com>
2026-09-18 13:13:32 -03:00
Marco
55b6ca6573 fix(compression): fall back in-process when the compression worker fa… (#13637)
* fix(compression): fall back in-process when the compression worker fails (#13145)

The worker pool resolved every worker fault with the *uncompressed* body instead
of reporting it. `PendingJob` had no reject path at all, so a thread error, a
worker exit, a dispatch timeout, or an engine error posted back as
`type: "error"` all resolved as `{ compressed: false, stats: null }`.

`applyCompressionAsync` then treated that as a legitimate "nothing to compress"
result and returned it as-is, so the request reached the provider uncompressed
while the response header still announced the selected plan ("stacked") — the
header is emitted before the pipeline runs. Nothing was logged at any level, and
`compression_analytics` stayed empty because rows are only written when a
compressed result is reported. The net effect was compression silently disabled
for every worker-eligible request.

The worker is a throughput optimisation, not a behavioural variant, so a worker
fault must degrade to the in-process pipeline rather than to no compression:

- `PendingJob` gains `reject`; `fail()` delegates to a new `abort()` that clears
  the slot timeout and rejects with a diagnostic cause (thread error, exit code,
  or timeout budget).
- An `error` message from the worker is propagated instead of being swallowed.
- `applyCompressionAsync` catches the rejection and falls through to the
  in-process path, logging the cause. The logger is imported lazily and
  defensively: `compressionWorker.ts` imports this module, so a static import
  would pull the logger into the worker bundle, and a logging failure must never
  be able to break compression itself.

`close()` keeps resolving with the unchanged body — shutdown is not a fault.

The regression test drives a real worker fault via
`OMNI_COMPRESSION_WORKER_TIMEOUT_MS` rather than mocking the module, since this
project's tsx/ESM + node:test setup has no `mock.module()` support. Its options
are fully populated on purpose: `runCompressionAsync` forwards them into
`workerOptions`, and `isStrictlySerializable` rejects an object holding
`undefined` values — which would route the test through the in-process path and
assert nothing. Production requests always carry all of those fields, which is
why the worker path is taken there.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LGJQT3E6iJZq4zNGwfjkPs

* fix(compression): keep the timeout path uncompressed, retry only fast worker faults (#13145)

Review follow-up: the in-process fallthrough ran the full pipeline on the main
event loop for *every* worker fault, including a dispatch timeout. A timeout
means the worker already spent its whole budget on that body, so re-running the
same CPU-bound work inline would stall other in-flight requests — strictly worse
than not compressing on a shared gateway.

Faults are now typed by whether recovery is cheap:

- `CompressionWorkerError.retryInProcess` distinguishes fast faults (thread
  error, worker exit, engine throw — no work was done, so the in-process path
  costs what the worker would have) from a dispatch timeout.
- Timeouts keep the original degrade-to-uncompressed behaviour, but are now
  reported. The defect this PR fixes is the silent swallow, not the degrade.

Also strips `reject` from the structured-clone wire job. It is a function, so
leaving it on the object handed to `postMessage` threw `DataCloneError` before
the worker ever saw the job — turning every dispatch into an immediate fault.

Adds the missing `changelog.d/fixes/` fragment.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(compression): narrow the worker thread-error type for typecheck:core

@types/node 26 types the Worker "error" event payload as unknown, not
Error, so `error?.message` failed typecheck:core (TS2339). Narrow with
an instanceof check before reading .message.

Co-authored-by: diegosouzapw <8016841+diegosouzapw@users.noreply.github.com>

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
Co-authored-by: diegosouzapw <8016841+diegosouzapw@users.noreply.github.com>
Co-authored-by: marcs7 <marcs7@users.noreply.github.com>
2026-09-18 13:13:25 -03:00
birdleandro-bit
1e8c913ca2 feat(services): add open-wa as a 6th embedded service (#13222)
* feat(services): add open-wa as a 6th embedded service

Adds @open-wa/wa-automate (WhatsApp Web automation via headless
Chromium) following the existing embedded-service pattern, mirroring
Mux's lifecycle-managed-only shape (no Layer 4 executor — this is not
a routing target). Flags/env verified directly against the installed
4.76.0 source rather than trusted from web docs, which mix this
stable v4 line with an unreleased v5 alpha CLI surface.

healthIntervalMs is set to 60s (vs. the usual 5s) for this service:
open-wa's HTTP server does not start listening until the full
WhatsApp handshake resolves, which blocks on a human scanning the
pairing QR code on first pairing. At the default 5s interval the
supervisor's 3-consecutive-failure threshold would declare "error"
~15s into every legitimate start. This is a local, single-service
config change — a proper fix (a startup-grace knob distinct from the
steady-state poll interval) belongs in ServiceSupervisor/HealthChecker
as a follow-up affecting all embedded services.

* fix(db): renumber open-wa seed migration to avoid collision with 163

Migration 163 was already taken by 163_radar_feed_cache_generated_at.sql
on release/v3.8.51 (tip is at 179). Renumbered to 180, the next free slot.

Co-authored-by: diegosouzapw <8016841+diegosouzapw@users.noreply.github.com>

* fix(db): renumber open-wa seed migration to avoid collision with 180

Slot 180 was reused by 180_memory_fts_au_conditional_memory_id.sql
(merged 2026-09-16), so this PR's seed migration moves to the owner's
assigned slot 185.

Co-authored-by: diegosouzapw <8016841+diegosouzapw@users.noreply.github.com>

---------

Co-authored-by: diegosouzapw <8016841+diegosouzapw@users.noreply.github.com>
Co-authored-by: birdleandro-bit <birdleandro-bit@users.noreply.github.com>
2026-09-18 13:13:16 -03:00
Beexly
706dc75c13 fix(chatCore): stop executeWithUpstreamStartTimeout leaking its abortPromise listener (hedge-cancelled process exit) (#12406)
* fix(sse): stop mergeAbortSignals from leaking abort listeners

mergeAbortSignals() attached "abort" listeners to its primary/secondary
signals but never removed them once the merged signal settled. Every
executor fetch attempt calls this (fetchWithStartTimeout, once per
URL/retry), so a busy combo request accumulated one live listener per
call on the long-lived combo/client signal. A leaked listener still
fires when that signal is later aborted (e.g. a hedge cancellation
arriving after this merge's own caller already finished), for a merged
output nothing is watching anymore.

Mirrors the already-correct self-cleaning pattern in
open-sse/utils/directResponseStartTimeout.ts's local mergeAbortSignals.

Regression test measures listener growth across repeated merges of the
same long-lived signal: 25 merges leaked exactly 25 listeners pre-fix,
0 post-fix.

(cherry picked from commit 07969655147d3236969b38bfb41280ab4fb52b79)

* fix(server): stop the crash guard re-throwing combo abort reasons

Production crash 2026-08-31 (omniroute.log): on a client disconnect,
handleDisconnect aborted the combo controller and a late abort listener
threw the abort reason on an empty stack:

    Error [AbortError]: hedge-cancelled
        at ... AbortController.abort ... handleDisconnect
    file:///.../src/shared/utils/httpClientAbortGuard.mjs:130  throw err;

isClientAbortError() only knew Node's stream codes and "aborted", so
shouldSwallowUncaught() said false and the guard re-threw, taking the
whole server down.

- Port upstream's AbortError line (name "AbortError" + abort-flavoured
  message) so request_signal_aborted / DOMException aborts are absorbed.
- Add an exact-message match for the combo abort reasons from
  open-sse/services/combo/comboAbortReasons.ts ("hedge-cancelled",
  "combo-per-model-timeout"), name-agnostic because the raw reason is a
  plain Error that only gets name="AbortError" stamped on the way out.
  A losing hedge / stalled target is never a server fault. Inlined so
  this .mjs stays dependency-free for scripts/dev/run-next.mjs.

Tests: port upstream's guard tests, add the exact crash shape, a
child-process replay of the crash (dies pre-fix, survives post-fix), a
genuine-error case that must still crash, and a sync check against
comboAbortReasons.ts. The child-process helper passes a file:// URL, not
a bare path, so the tests run on Windows.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
(cherry picked from commit 90c9bce8c474b60c37cc63f4421d50feae4c0ad2)

* fix(chatCore): stop executeWithUpstreamStartTimeout leaking its abortPromise listener

Root cause of the 2026-08-31 production exit (Error [AbortError]:
hedge-cancelled), verified by mapping the crash frames in
.build/next/server/chunks/13721.js back to this file:

- The abortPromise abort listener registered on the long-lived client /
  stream signal was never removed in the finally block (only abortListener
  and timeoutAbortListener were), so every executor attempt (and every
  retry) leaked one listener onto that signal.
- Promise.race only subscribes to abortPromise/timeoutPromise once the
  array literal has been evaluated. When execute() threw synchronously the
  race never ran, abortPromise was orphaned, and the next hedge
  cancellation / client disconnect aborted the signal with the string
  reason streamHandler.ts forwards; createAbortError() rebuilt it as an
  AbortError-named Error and rejected a promise nothing awaited. That
  unhandledRejection reached the process crash guard, which re-threw it as
  an uncaughtException and exited with code 7.

Keep a handle to the listener and remove it with the others, and mark the
two race-loser promises as handled so a synchronous throw from execute()
can never orphan them. Race semantics are unchanged (the race still
observes their rejections).

Regression tests: (1) a resolving execute leaves the listener count on
the client signal unchanged; (2) a synchronously throwing execute leaks
no listener and a later abort with the string "hedge-cancelled" produces
no unhandledRejection. Both fail against the previous implementation.

Note: commit 079696551 (mergeAbortSignals cleanup) is correct listener
hygiene but is not on this crash path; this is the fix for the incident.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
(cherry picked from commit e68a50ad854a1945c23e747da4ef15a820cc148d)

* fix(server): absorb raw string abort reasons in the crash guard; document the verified crash path

Follow-ups from the adversarial review of 90c9bce8c:

- open-sse/utils/streamHandler.ts aborts the stream controller with a raw
  string reason (getClientAbortReason / handleDisconnect) and undici
  rejects with signal.reason verbatim, so a cancellation can reach
  process level as a bare string. isClientAbortError() returned false for
  every non-object, which would still have exited the process. Absorb the
  combo abort reasons and the stream-handler disconnect reasons when they
  arrive as strings.
- Correct the mechanism comment: the 2026-08-31 exit was a leaked
  upstreamTimeouts.ts abortPromise listener rejecting a promise nothing
  awaited (unhandledRejection), escalated by this guard, not a listener
  throwing synchronously. The leak is fixed at the source in the previous
  commit; this guard remains the last-resort net.
- Reword the inlining rationale (plain node launcher, no reliance on
  type-stripping for the .ts constants module).
- Tests: the child-process replay now also exercises the
  unhandledRejection route with the exact production error shape and with
  raw string reasons; add unit coverage for string reasons and non-object
  look-alikes.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
(cherry picked from commit 696fcc8fe1b9b9bc43e6e5f5f5e44b619a86e68a)

---------

Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
Co-authored-by: diegosouzapw <8016841+diegosouzapw@users.noreply.github.com>
Co-authored-by: Beexly <Beexly@users.noreply.github.com>
2026-09-18 13:13:06 -03:00
GiauPhan
eb4e3be5dc fix(quota): keep Kiro active while any _freetrial pool has quota (#13088) (#13324)
* fix(quota): keep Kiro active while any _freetrial pool has quota (#13088)

* fix(quota): rename changelog and remove blank line

---------

Co-authored-by: giauphan <giauphan@users.noreply.github.com>
Co-authored-by: diegosouzapw <8016841+diegosouzapw@users.noreply.github.com>
2026-09-18 13:12:58 -03:00
Dizzle
0349627c86 fix(opencode): match the upstream free-tier request contract (#14013)
Match the upstream OpenCode free-tier request contract (issue #13935): canonical
ses_/msg_ identity ids, versioned User-Agent, and the measured body requirements
(stream:true + non-empty tools) with a learn-and-reuse tool-name cache, so
no-auth oc/* requests stop being refused with 403 FreeTierError.

Supersedes #13937 (session regex and minimum-version rule kept, credited below).
Complements #14011 (refusal classification) and #13819 (stream_options strip),
both already merged.

Closes #13935

Co-authored-by: AStupidBear <16422976+AStupidBear@users.noreply.github.com>
Co-authored-by: diegosouzapw <8016841+diegosouzapw@users.noreply.github.com>
2026-09-18 12:57:10 -03:00
Prabhjot Singh
2ebafaedce fix(windows): hide supervised server console (#13992)
* fix(windows): hide supervised server console

* fix(windows): port icon.ico fix from #13991 and add regression tests

Adds a source-pattern test asserting the supervised server spawn() passes
windowsHide: true (Hard Rule #8 gap noted in review), and ports the
icon.ico-on-win32 fix from #13991 (credit @prabhtheone) with its own
regression test, so both real fixes ship without #13991's unrelated
comment purge.

Co-authored-by: diegosouzapw <8016841+diegosouzapw@users.noreply.github.com>

---------

Co-authored-by: diegosouzapw <8016841+diegosouzapw@users.noreply.github.com>
2026-09-18 12:26:26 -03:00
Климентий Горенков
dfcc4baed8 fix(codex): preserve native custom tools in Responses WebSocket requests (#13864)
* fix(codex): preserve native custom tools in Responses WebSocket requests

* test(codex): release per-turn WS leases in the custom-tools passthrough test

The per-account WS lease (release/v3.8.51, added after this branch's fork
point) is non-queued with a default maxConcurrent of 1. This test's bare
prepare() helper never released its lease, and the reused-WebSocket case
re-prepares (acquiring a fresh lease) per turn before releasing the
previous one — both starve the single test connection once merged with
the lease feature. Release after each bare prepare() and raise the test
fixture's maxConcurrent to 2 so a session's sequential turns fit.

Co-authored-by: diegosouzapw <8016841+diegosouzapw@users.noreply.github.com>

---------

Co-authored-by: diegosouzapw <8016841+diegosouzapw@users.noreply.github.com>
2026-09-18 12:26:17 -03:00
voidstack
e400cf9ac7 fix(db): resolve backup retention from persisted setting on health-check path (#13773)
* fix(db): resolve backup retention from persisted setting on health-check path

#13404 fixed the missing prune call after health-check-repair backups but
only resolved maxFiles/retentionDays from env vars, so the persisted
Storage-page setting (honored for manual/API/auto backups via
getDbBackupMaxFiles/getDbBackupRetentionDays) was silently ignored on this
path. Extract that env->persisted->default precedence into
resolveDbBackupRetention() in backupRetention.ts and share it between
backup.ts and core.ts's createManagedDbBackup().

* docs: add changelog fragment for #13308 persisted-setting follow-up

* fix(db): re-point backup retention fix at managedBackup.ts's prune call

The base drifted since this branch was opened: the health-check-repair backup
path (createManagedDbBackup) moved from core.ts into managedBackup.ts
(writeManagedDbBackup), taking its env-only maxFiles/retentionDays resolution
along with it. This branch's resolveDbBackupRetention() extraction and
backup.ts delegation were already correct and unaffected; only the wiring
that used to live in core.ts needed to move to managedBackup.ts's prune call
so the persisted Storage-page setting is honored on this path too (#13308).

Co-authored-by: diegosouzapw <8016841+diegosouzapw@users.noreply.github.com>

---------

Co-authored-by: diegosouzapw <8016841+diegosouzapw@users.noreply.github.com>
2026-09-18 12:26:09 -03:00
小妍儿 ✨
27e0d9b5b7 fix(dashboard): refresh per-connection proxy badges after a proxy save (#13711)
* fix(dashboard): refresh per-connection proxy badges after a proxy save

ProxyConfigModal persists an assignment through
`PUT /api/settings/proxies/assignments`, but the provider page bound its
`onSaved` callback to `fetchProxyConfig()`, which only refetches
`GET /api/settings/proxy` into `proxyConfig`.

The per-account proxy badges read `connProxyMap`, which is filled from a
different endpoint (`GET /api/settings/proxy?resolve=<connectionId>`) by an
effect keyed on `[loading, connections]`. A proxy save changes neither key,
so the effect never re-ran and the saved (or cleared) proxy stayed invisible
until a manual page reload. Account- and combo-level saves refreshed nothing
visible at all; a provider-level save refreshed only the toolbar chip while
the rows that inherit that proxy stayed stale.

Adds `refreshProxyState()`, which re-reads both sources together, and binds
the modal's `onSaved` to it. The callback reads the latest connections from a
ref so it stays referentially stable and does not re-render consumers on every
connections fetch.

Fixes #13710

Also removes the now-unused `no-unused-vars` suppression entry for
useProviderConnections.ts. That entry was already stale on the base branch
(the same eslint invocation reports it on an unmodified tree), and the
pre-commit ratchet refuses to pass while a touched file carries one. Only
that single exact entry was pruned; no baseline was widened.

* docs(changelog): add fragment for #13711

* test(providers): raise proxySaveRefresh test timeout to 30s

The it() case dynamically imports ProviderModalsPanel, the first test in
the repo to pull in that module's ~15 modal components, which alone
consumes most of Vitest's default 5000ms testTimeout and made the test
flake under CI/runner load.

Co-authored-by: diegosouzapw <8016841+diegosouzapw@users.noreply.github.com>

---------

Co-authored-by: 千乘妍 (Xiaoyaner) <xiaoyaner0201@users.noreply.github.com>
Co-authored-by: diegosouzapw <8016841+diegosouzapw@users.noreply.github.com>
2026-09-18 12:26:01 -03:00
keii-2596
80ea176022 fix: feat(providers): add kimi/qwen/deepseek/gpt auto-routing families (#13214) (#13709) 2026-09-18 12:25:53 -03:00
Paijo
2374bbf1ee fix(mitm): bound SSE transcript retention and cancel abandoned upstream reads (#13702)
Handler-side collected strings grew without bound before the inspector
clamp; abandoned streams kept the reader alive for the full upstream
lifetime. createBoundedCollector caps retention at 1 MiB while keeping
true responseSize; pipeSSE and server.cjs cancel on downstream close.
Fixes #13395.

Co-authored-by: oyi77 <oyi77@users.noreply.github.com>
2026-09-18 12:25:45 -03:00
Kha Tran
994245a476 fix(translator): prevent schema property name collision in Gemini sanitizer (#13057, #13477) (#13690)
* fix(translator): prevent schema property name collision in Gemini sanitizer (#13057, #13477)

* refactor(translator): reuse SCHEMA_MAP_KEYS in forEachSubschema and add changelog fragment

* test(translator): avoid explicit any in gemini schema collision regression test

Co-authored-by: diegosouzapw <8016841+diegosouzapw@users.noreply.github.com>

---------

Co-authored-by: zcrew0x <zcrew0x@users.noreply.github.com>
Co-authored-by: diegosouzapw <8016841+diegosouzapw@users.noreply.github.com>
2026-09-18 12:25:38 -03:00
dependabot[bot]
44e32a1995 deps: bump electron from 44.0.0 to 44.3.0 in /electron (#13664)
Bumps [electron](https://github.com/electron/electron) from 44.0.0 to 44.3.0.
- [Release notes](https://github.com/electron/electron/releases)
- [Commits](https://github.com/electron/electron/compare/v44.0.0...v44.3.0)

---
updated-dependencies:
- dependency-name: electron
  dependency-version: 44.3.0
  dependency-type: direct:development
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-09-18 12:25:30 -03:00
hummern
cc7d19daa9 fix(routing): skip redundant parseAutoPrefix for recognized built-in auto variants (#13647)
* Change hasFree from true to false for pioneer.ai

Pioneer.ai removed the free tier.

Before:

https://web.archive.org/web/20260516140358/https://pioneer.ai/pricing

After:

https://pioneer.ai/pricing

* fix(sse): skip parseAutoPrefix invalid-prefix warning for recognized built-in auto variants

resolveAutoRoutingState() already classifies auto/best-* variants correctly via
classifyAutoModel() before applyAutoPrefix() runs, and the old early-return
preserved that state — so the routing variant was never broken. The real,
observable defect was the spurious 'Invalid auto prefix format' warning logged
on every auto/best-* request, because parseAutoPrefix() only knows the short
aliases (VALID_VARIANTS) and returns valid:false for the best-* built-ins that
AUTO_TEMPLATE_VARIANTS recognizes.

Skip the warning (and the pointless early-return) for any model already present
in AUTO_TEMPLATE_VARIANTS. Add a regression test asserting the warning no
longer fires for auto/best-coding while an genuinely unknown auto/* variant
still warns (proving the log probe detects the message).

* fix(providers): drop out-of-scope Pioneer AI hasFree change from this PR

The Pioneer AI hasFree=false commit (c70e425) leaked into this branch
via a merge and is unrelated/stale vs. the release tip's current
hasFree=true value for this unrelated PR (#13647 is about auto-routing
prefix warnings). Reverting to keep the diff scoped to the actual fix.

Co-authored-by: diegosouzapw <8016841+diegosouzapw@users.noreply.github.com>

---------

Co-authored-by: Tobias Andersen <turbolego@gmail.com>
Co-authored-by: hummern <hummern@users.noreply.github.com>
Co-authored-by: diegosouzapw <8016841+diegosouzapw@users.noreply.github.com>
2026-09-18 12:25:23 -03:00
Moseyuh333
31ea46934e fix(sse): recognize reasoning_effort in the reactive 400 field-strip retry (#13642)
* fix(sse): recognize reasoning_effort in the reactive 400 field-strip retry

Strict OpenAI-compatible gateways that don't implement the reasoning-effort
knob reject requests with 400 "Unsupported parameter: reasoning_effort".
findOffendingField() did not list it in KNOWN_OFFENDING_FIELDS, so the
generic strip-and-retry in base.ts never fired and the 400 surfaced to the
client — the request died instead of being retried once without the field.

Add "reasoning_effort" to KNOWN_OFFENDING_FIELDS (sibling of the existing
reasoning_budget entry, same FCC/NIM-style recovery) and pin the new match
in provider-field-strips.test.ts.

* chore(changelog): add fix fragment for the reasoning_effort field-strip retry (#13642)
2026-09-18 12:25:15 -03:00
luyuehm
1f8bfe52c5 feat(docker): add self-host compose + 5-minute deploy doc (RIC-739) (#13639)
KISS self-host carrier for the 零月费 + 自托管 product form. One command
brings up the published image + Redis on loopback — no profile choice, no
build step, no multi-tenant anything.

- docker-compose.selfhost.yml: pulls diegosouzapw/omniroute:latest + redis,
  all app ports 127.0.0.1-only by default, Redis not published to host,
  depends_on healthy, healthcheck wired.
- .env.selfhost.example: minimal env (2 EDIT ME lines), no secrets baked in.
- docs/getting-started/SELF_HOST_GUIDE.md: 5-minute deploy, sizing, exposing,
  data/backups, common issues, security checklist, what-it-is-NOT.
- meta.json + DOCKER_GUIDE cross-link.

Graduates to the full docker-compose.yml profiles when the user needs CLI
tools / web-cookie Chromium / sidecars.

Co-authored-by: Ant Rich <ant@richants.com>
2026-09-18 12:25:08 -03:00
Lukas
639d7dac44 docs(resilience): describe the queue-wait and execution deadlines as they are (#13624)
* docs(resilience): describe the queue-wait and execution deadlines as they are

Two docs still describe `requestQueue.maxWaitMs` the way it behaved before
the split, in two different and both incorrect ways:

  RESILIENCE_GUIDE.md  "a legacy persisted name for execution expiration
                        ... bounds limiter-managed execution, not time
                        spent in the local queue ... Queue residence has
                        no time deadline"
  ENVIRONMENT.md       "Max time to wait on a 429 before failing the
                        request"

`rateLimitManager.ts` does the opposite of the first and has nothing to do
with the second. `maxWaitMs` is the queue-wait budget: it covers the slot
wait plus QUEUED residence, and its timer is cleared the moment the job
starts executing (`wrappedFn`). Bottleneck's `expiration` is fed by
`executionMaxWaitMs` (default 600000ms), "never by the queue-wait budget"
in the source's own words, and is raised to the executor's fetch-start
timeout when that is longer.

Issue #13592 is that misreading in practice: the reporter concluded the
deadline "measures execution time after dispatch rather than queue wait"
-- which is what the guide says.

Also corrected while here:

  * The guide's "1-30000ms UI ceiling" is a different setting's bound
    (`comboCooldownWaitSettings`). `requestQueueSettingsSchema.maxWaitMs`
    is `min(1)` with no max, normalised to 1ms-24h.
  * Precedence is documented for the first time: the env var supplies the
    DEFAULT only, a persisted `resilienceSettings.requestQueue` value wins
    over it, and a per-connection `rateLimitOverrides` value wins over
    that. #13592 reports exactly this surprise -- setting
    `RATE_LIMIT_MAX_WAIT_MS` on a deployment that already has a persisted
    value changes nothing.

Documentation only; no behaviour change.

Refs #13592

* docs(changelog): add fragment for the resilience-deadline doc correction
2026-09-18 12:25:00 -03:00
jbovard2016
d574826f37 fix(usage): detach completed request previews (#13623)
* fix(usage): bound completed request retention

Completed request previews used V8 sliced strings that kept multi-megabyte request backing stores alive. Detach and byte-bound cached details, and add a credential-free profiler with cleanup and physical-retention assertions.

* fix(usage): give the JON-562 memory-profile canary realistic timeouts

The 100k-token worker step alone takes ~230s (tsx/esm boot of the full
route/handler module graph plus the real request lifecycle), well past
the driver's hardcoded 180s spawnSync timeout — the resulting SIGKILL
surfaces as `worker.status === null`, indistinguishable from a real
crash. Bump the worker timeout to 300s and the test's own outer/inner
timeouts to match the measured ~230-330s real runtime.

Also make git-branch provenance detached-HEAD safe: `git branch
--show-current` is empty on a detached HEAD (the normal state for a CI
PR checkout, and for this fix worktree itself), which made the canary
throw "git branch is empty" deterministically outside a regular branch
checkout.

Co-authored-by: diegosouzapw <8016841+diegosouzapw@users.noreply.github.com>

---------

Co-authored-by: diegosouzapw <8016841+diegosouzapw@users.noreply.github.com>
2026-09-18 12:24:52 -03:00
Omkar Prabhu
9b84531e4c fix(cli): pass --legacy-peer-deps to npm install -g in omniroute update (#13579)
* fix(cli): pass --legacy-peer-deps to npm install -g in omniroute update

Problem
-------
Running \
pm install -g omniroute\ prints a wall of ERESOLVE / peer-dependency
warnings because marked-terminal@7.3.0 declares a peer range of marked>=1 <16,
while omniroute ships marked@18. npm's strict peer-resolution mode (the default
since npm 7) flags this mismatch loudly even though the packages work correctly
together at runtime.

Fix
---
Pass --legacy-peer-deps to the npm install -g call that \omniroute update\
issues so that every user who upgrades through the built-in updater gets a
clean, warning-free output. The dry-run log line is updated to match.

Why --legacy-peer-deps is safe here
------------------------------------
The repo already ships .npmrc with legacy-peer-deps=true (added in #11544) so
the published package documents this as its supported install mode. This commit
simply applies the same flag programmatically in the updater so the flag is
always honoured regardless of the caller's local npm config.

Changes
-------
- bin/cli/commands/update.mjs: append --legacy-peer-deps to execSync npm call
  and to the dry-run console.log so output matches the real command
- docs/guides/TROUBLESHOOTING.md: add a supported install snippet and clarify
  that residual deprecation notices come from third-party transitive packages
- tests/unit/cli-update-npm-win32-11335.test.ts: regression test asserting both
  the dry-run string and execSync call carry --legacy-peer-deps
- changelog.d/fixes/: add fragment (number updated after PR is opened)

* chore(changelog): rename fragment to PR #13579
2026-09-18 12:24:43 -03:00
mdigitalbh81
f24c3665df fix(proxy): skip bare TCP health probe for SOCKS5 data plane (#13571)
* fix(proxy): skip bare TCP health probe for SOCKS5 data plane

* docs(changelog): add fragment for SOCKS5 bare TCP probe skip fix

Co-authored-by: diegosouzapw <8016841+diegosouzapw@users.noreply.github.com>

---------

Co-authored-by: diegosouzapw <8016841+diegosouzapw@users.noreply.github.com>
2026-09-18 12:24:35 -03:00
Ryan
70eebe9adb fix(arena+analytics): atomic ELO sync (fetch-first) + flatRateAsZero in compression writer (#13446)
* fix(analytics+arena): flatRateAsZero in compression writer; atomic arena sync redesign

* docs(changelog): add fragments for arena ELO sync and compression flat-rate fixes

Co-authored-by: diegosouzapw <8016841+diegosouzapw@users.noreply.github.com>

---------

Co-authored-by: CrashCartCapital <crashcartcapital@gmail.com>
Co-authored-by: diegosouzapw <8016841+diegosouzapw@users.noreply.github.com>
2026-09-18 12:24:28 -03:00
이창섭
a9c62ba83b fix(playground): improve Compare response scrolling and copy actions (#13317)
* feat(playground): copy individual compare responses

* docs(changelog): add fragment for Compare column copy-to-clipboard

Co-authored-by: diegosouzapw <8016841+diegosouzapw@users.noreply.github.com>

---------

Co-authored-by: diegosouzapw <8016841+diegosouzapw@users.noreply.github.com>
2026-09-18 12:24:19 -03:00
easypathuni
4dc73fb36a chore: add Windows helpers to run OmniRoute and Claude Code from a source checkout (#13312)
* chore: add Windows helpers to run OmniRoute and Claude Code from a source checkout

Adds contrib/windows/ with two double-clickable scripts for Windows users who
cloned the repo instead of installing the npm package:

- start-omniroute.bat  -> npm run dev (resolves the repo root from its own path)
- launch-claude.bat    -> node bin/omniroute.mjs launch, forwarding extra args

The README documents a fresh-clone gotcha on Windows with npm >= 11: the
optional better-sqlite3 dependency is silently skipped, the server falls
back to node:sqlite and logs "Module not found: Can't resolve
'better-sqlite3'". Since better-sqlite3@13 ships win32-x64 prebuilds inside
the package, extracting the npm pack into node_modules fixes it without a
compiler. Verified on Windows 11, Node 24.14.0, npm 11.6.1.

* chore(contrib): start Claude Code in the project folder, not the OmniRoute checkout

launch-claude.bat used to cd into the repo root before exec'ing
`omniroute launch`, so Claude Code always started inside the OmniRoute
checkout and loaded this repo's CLAUDE.md/AGENTS.md (~60k chars) into the
user's own coding session. Take the project folder as the first argument
(or prompt for it on double-click) and resolve bin/omniroute.mjs from the
script's own path instead. Remaining args are still forwarded to
`omniroute launch`.
2026-09-18 12:24:11 -03:00
Domenico Massafra
fbe195d69e fix(opencode): preserve catalog display names (#13168)
* fix(opencode): preserve catalog display names

* test(cli): cover OpenCode catalog display-name precedence

Adds the automated unit test the PR body's manual smoke check
(Auto Chat / DeepSeek V4 Pro) was standing in for, covering all four
name-precedence branches: existing custom name, catalog display_name,
native catalog name with owned_by prefix stripped, and the auto/* readable
fallback. Also adds the changelog.d/fixes/ fragment.

Co-authored-by: diegosouzapw <8016841+diegosouzapw@users.noreply.github.com>

---------

Co-authored-by: ginettododo <117327638+ginettododo@users.noreply.github.com>
Co-authored-by: diegosouzapw <8016841+diegosouzapw@users.noreply.github.com>
2026-09-18 12:24:03 -03:00
Anh Tran
93be9d544c fix(responses): count input tokens locally for Codex OAuth (#13167)
The ChatGPT subscription backend does not serve
/backend-api/codex/responses/input_tokens for the affected account. Native
requests to that path are intercepted by an OpenAI Cloudflare managed challenge,
while the same path over the bundled Chrome transport returns 404 Not Found.
Forwarding the client preflight can therefore never return a useful count and,
before the companion classifier fix, permanently disabled the healthy Codex
connection on the first challenge.

Add a static /v1/responses/input_tokens route that shadows the generic
Responses passthrough, uses the existing offline o200k_base token counter, and
returns the standard response.input_tokens contract without issuing any upstream
request. Count instructions, structured input, tool definitions and config;
apply a conservative five-percent margin so the failure mode is earlier client
compaction rather than a context-window overflow.

Preserve the API-key and model-policy boundary from the catch-all Responses path.
Tests pin the public schema, prove fetch is never called, cover text,
instructions, structured input, tools, non-text parts, server-held context ids,
invalid JSON, OPTIONS, and the conservative lower bound.

Co-authored-by: anhth2 <anhth2@vng.com.vn>
2026-09-18 12:23:55 -03:00
marioschoenert-code
6614780c36 fix(deps): declare remark-gfm as a direct dependency (#13162)
MarkdownMessage.tsx and other client components import remark-gfm
directly, but it was only present transitively via fumadocs-mdx (a
devDependency). npm's hoisting happens to resolve it, masking the
issue, but pnpm's strict node_modules isolation fails with
"Module not found: Can't resolve 'remark-gfm'" since the package was
never declared as a direct dependency of the app.

Add it explicitly to package.json/package-lock.json and the
supply-chain dependency allowlist.

Co-authored-by: marioschoenert-code <291579285+marioschoenert-code@users.noreply.github.com>
2026-09-18 12:23:48 -03:00
Ozeas Souza
b71f466f1c fix(authz): preserve zed-hosted native-app callback through root middleware redirect (#13140)
* fix(release): let the Electron workflow start again — grant actions:read to the npm leg (#11973)

v3.8.50 shipped with zero desktop assets. The tag push did trigger electron-release.yml
(run 33005490476) but GitHub refused the run at startup:

  Error calling workflow 'npm-publish.yml@5458026'. The nested job 'publish' is
  requesting 'actions: read', but is only allowed 'actions: none'.

npm-publish.yml's `publish` job gained `actions: read` (it downloads the next-build
artefact) and the caller job here never widened its grant — a reusable workflow may not
request more than its caller allows, and the refusal is a startup failure of the WHOLE
run, so the `release` job that attaches the installers, the source archives and the
SBOM never ran either. Nothing about it is visible through the API (no jobs, no
check-runs); only the run page shows the annotation.

- publish-npm: `actions: read` added, with the rule written down (keep the block a
  superset of every job in npm-publish.yml).
- workflow_dispatch: new boolean input `publish_npm` (default true) and the npm leg
  is gated on it, so re-attaching assets to a release whose package already shipped
  does not try to publish the same version twice.
- web-build / build / release checkouts pin `ref: needs.validate.outputs.version`:
  a dispatch builds the tag it names, not the dispatching branch (a tag push resolves
  to the same commit, so nothing changes on the normal path).

actionlint clean; electron-release-desktop-channel-8949, electron-release-efficiency,
build-next-isolated-windows-home-2402, electron-release-latest-yml.repro and
check-workflows suites pass. Next step: dispatch on main with version=v3.8.50 and
publish_npm=false to attach the missing assets.

* fix(ci): stop a stalled Codecov upload from cancelling the Coverage job and the main run (main twin of #11972) (#11978)

Same change as #11972 on release/v3.8.51: the Coverage job had timeout-minutes: 20,
the c8 merge across 8 shards takes ~10 min and the informational Codecov upload hung
for the rest of the budget on two consecutive main runs (33207760653, 33215115341),
ending the job cancelled and turning the run's conclusion cancelled with every
blocking job green. Codecov step: 5-minute ceiling + continue-on-error; job: 30 min.

* fix(release): resync the electron lockfile and let a dispatch build from a repaired ref (#11982)

* fix(release): resync the electron lockfile and let a dispatch build from a repaired ref

The v3.8.50 desktop re-dispatch (run 33238093090) lost its Linux leg at
`npm ci` in electron/: "Missing: electron-builder-squirrel-windows@26.15.3 from
lock file" plus its 12 transitive entries — the optional Windows-installer subtree of
electron-builder had been dropped when the lock was last regenerated, and no CI ran
the desktop legs between then and the tag (v3.8.49 never ran them; v3.8.50 died at
startup, #11973). `npm install --package-lock-only` restores the 13 entries; a clean
`npm ci --ignore-scripts` on the result adds 284 packages with no complaint.

The tag itself carries the broken lock, and the workflow now checks out the tag on
dispatch (#11973), so a dispatch input `build_ref` (default: the version tag) lets the
operator name the repaired line — the v3.8.50 assets will be rebuilt from main, which
is 3.8.50 plus its post-release fixes. Push-triggered runs are unaffected.

actionlint clean; electron-release-desktop-channel-8949, electron-release-efficiency,
electron-release-latest-yml.repro and check-workflows suites pass.

* fix(release): do not regenerate release notes on a re-attach dispatch

`generate_release_notes: true` on an existing release APPENDS GitHub's auto-generated
"What's Changed" block to the curated body — the v3.8.50 re-dispatch (run 33238093090)
added 1,416 chars to the 121 KB notes. Only the tag push should generate notes.

* fix(release): attach the SBOM to the GitHub Release on dispatch publishes too (#12020)

The step was gated on github.event_name == 'release'. v3.8.50's package shipped
through a workflow_dispatch (the staged publish, 11 attempts) and the step was
skipped, so the GitHub Release carried no SBOM — it was attached by hand from the
run's sbom-npm artifact (5.0 MB, 1,886 components). Now it attaches on release or
workflow_dispatch whenever a release for the published tag exists, and says so
when it does not (the workflow artifact remains the durable copy either way).

actionlint and prettier clean; npm-publish-artifact-provenance and
check-workflows-provenance-runner suites pass.

* fix(release): drop the build_ref input — a dispatch builds the ref it is dispatched on (#12032)

Twin of #12022 on main: CodeQL flagged the same input-controlled checkout + npm cache pattern (cache-poisoning/poisonable-step) on main since it's the default branch. Checkouts go back to github.ref; dispatch still works via --ref (documented in the workflow's own on: contract).

Also fixes the packaged-app smoke: it now waits on /api/monitoring/health (which touches the DB) instead of /login (which doesn't), so the smoke can actually distinguish "native driver selected" from "database never opened." electron-smoke-script.test.ts 9/9 (2 new cases).

* fix(ci): accept CVE-2025-68121 in the prebuilt tls-client .so, auto-close base-red issues, guard Scorecard on the default branch (main twin) (#12086)

* fix(ci): accept CVE-2025-68121 in the prebuilt tls-client .so, auto-close base-red issues, guard Scorecard on the default branch

- .trivyignore: CVE-2025-68121 (Go stdlib crypto/tls inside bogdanfinn/tls-client
  v1.15.1, built with go 1.24.1) with justification, expiry and tracker #12084.
  No upstream rebuild exists; the blocking Trivy gate now also names the ignore
  file explicitly.
- nightly-release-green: close the "not green" issue when the validation passes
  again (the workflow only ever opened/commented it, so stale issues outlived
  the fix and stamped new PRs as base-red inherited).
- scorecard: the action only accepts the DEFAULT branch (the active release
  branch, not main) - guard the job on it so pushes to main stop failing.

Refs #12084

(cherry picked from commit 8adf34bada)

* fix(release): never let the tag-push Create Release append auto notes to the curated body

Twin of the release/v3.8.51 commit (see #12085).

Refs #12084

* fix(docker): bump Bun image to 1.4.0 with Turbopack and port the node image's build memory guards (#11719)

Validated in an isolated worktree against main: typecheck:core clean, 15/15 focused tests pass (docker-build-memory-budget, bun-support, resolve-next-build-bundler-flag). Root cause confirmed against the current workflow config (docker-publish.yml triggers on push to both main and release/v*, so this genuinely needed to target main). One out-of-scope change dropped before merging: config/alibaba-free-tier-allowlist.json's validUntil bump (2026-08-27 -> 2027-12-31) was unrelated to the Docker/Bun fix — reverted to the current value, keeping only the Docker/Bun/memory-guard changes this PR is actually about. Thanks for the thorough root-cause writeup and the worker-pool math.

* test(infra): retry recursive temp-dir removal on main (main twin of #11968) (#12246)

* test(infra): retry recursive temp-dir removal on main (main twin of #11968)

`main` has been red since b342c1a361 on the vitest and integration gates:

  ✖ tests/unit/autoCombo/provider-family-combos.test.ts > auto/<family>
  ✖ chat pipeline applies Codex OAuth fingerprint and priority tier inside combos

Both call resetStorage() from beforeEach, which does an fs.rmSync(TEST_DATA_DIR,
{recursive: true, force: true}) with no retry, and intermittently loses the race
with a not-yet-released SQLite handle (ENOTEMPTY).

release/v3.8.51 fixed this in #11968 with a mechanical codemod adding
maxRetries/retryDelay to every recursive rm/rmSync/rmdirSync under tests/, but
that PR landed only on the release branch. Because main only receives work at
the release squash, it stayed broken for the whole cycle — and repo-wide gates
then turn every open PR into main red on checks unrelated to their diff.

This is the --base main twin: re-runs the same codemod that already shipped on
the release branch (scripts/ad-hoc/codemod-rm-maxretries.mjs), so the two
branches converge on identical test-teardown semantics. Test-only; no product
logic is touched.

The remaining three failures reported on #12133 (unit full suite exceeding its
4800s ceiling, package-artifact exceeding 1200s, and the boot-smoke that is
skipped as a consequence) are runner-contention timeouts, not code defects —
validate-release-green.mjs runs those heavy gates concurrently on one shared
hosted runner. There is no fix to port for those.

* chore(scripts): carry the rm-maxretries codemod onto main alongside its output

The codemod that generated the previous commit lives in the repo on
release/v3.8.51 (added by #11968) but was never on main. Bringing it over keeps
the tool next to the change it produced, so the transformation stays
reproducible and auditable from either branch.

* fix(ci): port the release-green ESLint gate fix to main (base-red #12363) (#12618)

Porta para `main` o fix do gate de ESLint que só havia entrado na branch de release — o padrão de PR-companheiro que `_shared/merge-gates.md` §8 prescreve.

As 12 falhas de CI foram discriminadas como o **outro** base-red do main, não deste diff. Todas descendem de um único ponto: `Package Artifact` falha e os 9 shards de E2E mais os 2 Electron Package Smoke consomem esse artefato. A própria issue #12363 lista os dois separadamente:

- ` ESLint: could not parse eslint json` — que é justamente o que este PR conserta;
- ` Package artifact (npm pack policy): gate exceeded its 1200s ceiling` — a raiz da cascata.

O PR toca apenas `scripts/quality/validate-release-green.mjs` e seu teste, então não tem caminho para afetar o build do pacote. Teste portado primeiro e falhando no script atual do main (TDD).

* fix(authz): preserve zed-hosted native-app callback through root middleware redirect

The root middleware intercepts `pathname === "/"` and redirects to
`/dashboard` using `new URL(basePath+"/dashboard", url)`, which drops
the query string entirely.

Zed's native-app sign-in always redirects the browser to the loopback
root — `http://127.0.0.1:<port>/?user_id=...&access_token=...` — ignoring
any path. When the dashboard's own loopback port is reused as
`native_app_port` (see `src/lib/oauth/providers/zed-hosted.ts`'s
`resolveDashboardLoopbackPort`), that redirect lands on `/` of the running
OmniRoute instance. The root page (`src/app/page.tsx`) was already written
to forward `user_id`+`access_token` to `/callback`, but this middleware
runs first and silently discards the payload — making page.tsx's forward
dead code and breaking the entire zed-hosted sign-in flow.

Fix: detect `user_id` + `access_token` in `searchParams` and, when
present, redirect to `/callback${search}` (preserving the query string)
instead of `/dashboard`. Regular root visits (no native callback params)
continue to redirect to `/dashboard` unchanged.

This approach mirrors what `src/app/page.tsx` already does and is
provider-agnostic: any future provider whose native loopback callback lands
on `/` with `user_id`+`access_token` params benefits automatically.

* test(authz): cover zed-hosted native-app callback root redirect (#13140)

Adds automated coverage for the new pathname === "/" branch: with
user_id+access_token both present the redirect now forwards to
/callback preserving the query string; with only one of the two
present, behavior is unchanged (redirect to /dashboard).

Co-authored-by: diegosouzapw <8016841+diegosouzapw@users.noreply.github.com>

---------

Co-authored-by: Diego Rodrigues de Sa e Souza <diegosouza.pw@gmail.com>
Co-authored-by: Rouzbeh† <78313022+rqzbeh@users.noreply.github.com>
Co-authored-by: diegosouzapw <8016841+diegosouzapw@users.noreply.github.com>
2026-09-18 12:23:39 -03:00
Bl0ck
62d14a7fc7 feat(audio): expand Fish Audio S2.1 and voice cloning (#13090)
* feat(audio): expand Fish Audio S2.1 and voice cloning

* fix(audio): type Node streaming request init

* test(audio): align Fish Audio CI expectations

---------

Co-authored-by: diegosouzapw <8016841+diegosouzapw@users.noreply.github.com>
2026-09-18 12:23:31 -03:00
Tuan Dinh
45fa62a18e feat(build): add build:fast and start:fast to bypass standalone tracing (#13021)
* feat(build): add build:fast and OMNIROUTE_SKIP_STANDALONE to bypass standalone tracing

* feat(build): add start:fast script to run non-standalone builds locally

* docs(changelog): add fragment for #13021
2026-09-18 12:23:23 -03:00
Tuan Dinh
e188da6399 fix(dev): allow Ctrl+C to promptly kill dev server by closing active connections (#13020)
* fix(dev): allow Ctrl+C to promptly kill dev server by closing active connections and adding force-exit timeout

* test(dev): add regression assertions for prompt dev server exit on Ctrl+C

* docs(changelog): add fragment for #13020
2026-09-18 12:23:15 -03:00
Tuan Dinh
4587c7ec3a fix(mitm): add catch-all (*) model mapping fallback for Agent Bridge (#12140) (#13013)
* fix(mitm): add catch-all (*) model mapping fallback for Agent Bridge (#12140)

* docs(changelog): add fragment for #13013
2026-09-18 12:23:07 -03:00
Zach Frederich
80a2bb1230 fix(cli): skip POSIX path lookup on Windows autostart (#12993)
* fix(cli): skip POSIX path lookup on Windows autostart

* docs(changelog): record Windows autostart path fix
2026-09-18 12:22:59 -03:00
Nguyễn Viết Tuấn
d5452d03e7 feat(codex): safely discover compatible models (#12933)
* feat(codex): safely discover compatible models

* docs(changelog): add fragment for #12933

* fix(codex): drop the duplicate GPT-6 Astra registry entries from the merge

release/v3.8.51 had already landed the seven gpt-6-astra* models, and the
merge kept both copies, so the Codex registry listed every Astra id twice.
Keep the release's entries (same ids, capabilities and timeouts) and drop
this branch's copies.

Co-authored-by: diegosouzapw <8016841+diegosouzapw@users.noreply.github.com>

---------

Co-authored-by: TheDemonTuan <nguyenviettuanbp@gmail.com>
Co-authored-by: diegosouzapw <8016841+diegosouzapw@users.noreply.github.com>
2026-09-18 12:22:50 -03:00
ZaimMarzuki
7f1b4a5eb7 feat(dashboard): add sidebar pinned items shortcut section (#12891)
* feat(dashboard): add sidebar pinned items shortcut section

* docs(changelog): add fragment for sidebar pinned items feature

* docs(changelog): update pr number in changelog fragment

* fix(dashboard): scale down sidebar pinned item icons to match text proportions

* fix(dashboard): remove redundant pin icon from PINNED category header

---------

Co-authored-by: ZaimMarzuki <ZaimMarzuki@users.noreply.github.com>
2026-09-18 12:22:42 -03:00
botea
9eb7c2f17e feat(compression): add Hungarian Caveman language pack (#12825)
* feat(compression): add Hungarian Caveman language pack

* docs(changelog): add Hungarian Caveman entry

* chore(changelog): make the fragment a well-formed bullet

changelog.d/ fragments must start with "- " (scripts/release/aggregate-changelog.mjs).

Co-authored-by: diegosouzapw <8016841+diegosouzapw@users.noreply.github.com>

---------

Co-authored-by: botii16 <botii16@users.noreply.github.com>
Co-authored-by: diegosouzapw <8016841+diegosouzapw@users.noreply.github.com>
2026-09-18 12:22:34 -03:00
KeelTrace
95d3b164a5 fix(models): preserve free-model metadata from discovery (#12763)
* fix(models): preserve live free economics in synced discovery

* docs(changelog): add fragment for free-model metadata discovery fix

Co-authored-by: diegosouzapw <8016841+diegosouzapw@users.noreply.github.com>

---------

Co-authored-by: diegosouzapw <8016841+diegosouzapw@users.noreply.github.com>
2026-09-18 12:22:26 -03:00
Goni Sulaiman
5c305680ac fix(sse): strip internal markers from the dario and 9router request bodies (#12729) (#12735)
`dario` and `9router` both override transformRequest() without calling the base
implementation, so the internal-marker strip that runs inside
BaseExecutor.transformRequest() never applied to their bodies, and the second
strip before dispatch in BaseExecutor.execute() is not on their path either.
Whatever the routing layer left on the request — including the context-relay /
universal-handoff markers — went upstream verbatim, and strict
OpenAI-compatible gateways reject unknown top-level keys with HTTP 400.

glm and gitlab look like the same class of bypass but are not: glm's
transformRequest() calls super, so the shared strip already covers it, and
gitlab rebuilds its payload field by field, so no extra key can survive to the
wire.

Co-authored-by: Goni Sulaiman <gonisulaimann@users.noreply.github.com>
2026-09-18 12:22:17 -03:00
Juri
db5ae3c33d docs(dependencies): clarify socket.yml is registry-side scan, not CI gate (#12664)
* docs(dependencies): clarify socket.yml is registry-side scan, not CI gate

* docs(dependencies): add changelog fragment for socket.yml scope note

Co-authored-by: diegosouzapw <8016841+diegosouzapw@users.noreply.github.com>

---------

Co-authored-by: diegosouzapw <8016841+diegosouzapw@users.noreply.github.com>
2026-09-18 12:22:09 -03:00
wofiporia
6e74739607 fix(pricing): accept sync-written fields on PATCH and surface actionable save errors (#12629)
* fix(pricing): accept sync-written fields on PATCH and surface actionable save errors

* docs(changelog): add fragment for #12629

---------

Co-authored-by: wofiporia <172453170+wofiporia@users.noreply.github.com>
2026-09-18 12:22:00 -03:00
ZaimMarzuki
060f70ed18 feat(dashboard): show exact token counts on hover in usage analytics cards and tables (#12553)
* feat(dashboard): show exact token counts on hover in usage analytics cards and tables

* fix(dashboard): lock tooltip position to prevent top-left slide animation

---------

Co-authored-by: ZaimMarzuki <ZaimMarzuki@users.noreply.github.com>
2026-09-18 12:21:52 -03:00
Gaul Samuyenga
43758c7885 fix(cli): support dashboard API key ids (#12520)
* fix(cli): support dashboard API key ids

* docs(changelog): add fragment for CLI API key route drift fix

Co-authored-by: diegosouzapw <8016841+diegosouzapw@users.noreply.github.com>

---------

Co-authored-by: diegosouzapw <8016841+diegosouzapw@users.noreply.github.com>
2026-09-18 12:21:44 -03:00
Mr White
5ba3eee2b0 fix(devin): treat Devin CLI model ids as literal — never strip or synthesize effort suffixes (#12492)
The Devin CLI providers (devin-cli, devin-cli-agentic, devin-desktop; aliases
dv/dva) serve a catalog whose model ids EMBED the reasoning tier:
claude-opus-5-low, claude-opus-5-medium, … and gpt-5-6-sol-max/-low are
distinct upstream models (see registry/devin/catalog.ts).

applyClaudeEffortVariant stripped the trailing -{low,medium,high,xhigh,max}
from any id whose base is a known Claude model, regardless of provider. For
Devin lanes this dispatched a base id that does not exist upstream, e.g.

  dva/claude-opus-5-low  ->  claude-opus-5  ->  400
  'Model is not present in the current Devin catalog: claude-opus-5'

Only accidental double-suffixed ids (dva/claude-opus-5-max-low) survived,
because stripping the outer -low left the real claude-opus-5-max. Symmetrically,
the catalog synthesized -<level> variants on top of tier-embedded ids,
advertising phantom ids (dva/gpt-5-6-sol-max-low, dva/kimi-k3-*) that 400 when
called.

Three gates now treat Devin ids as literal:
- applyClaudeEffortVariant: early return for Devin providers (ids/aliases)
- appendClaudeEffortVariants: no -<level> variants for devin-prefixed ids
- appendSyncedEffortVariants: isSkippedEffortProvider now covers Devin
  providers (they own their suffix mechanism — the tier IS the id)

Validated live on a self-hosted v3.8.51 deployment: dva/claude-opus-5-low,
dva/claude-5-fable-low and the whole tier-embedded catalog now dispatch; the
phantom variant ids disappear from /v1/models. Claude-lane stripping
(claude/cc, e.g. cc/claude-opus-5-high -> claude-opus-5 + reasoning_effort) is
unchanged and covered by existing + new characterization tests.

Co-authored-by: Neuron Mr White <whiteneuron@gmail.com>
Co-authored-by: diegosouzapw <8016841+diegosouzapw@users.noreply.github.com>
2026-09-18 12:21:36 -03:00
dmlanday
b36c81d3f0 fix(cli): escalate the readiness probe timeout so a slow health response is not a phantom boot failure (#12484)
* fix(cli): escalate the readiness probe timeout so a slow health response is not a phantom boot failure

`omniroute serve` reported "Server did not respond within 60s" over servers that
were up and serving traffic. Every probe of /api/monitoring/health was aborted at
a fixed 2s, and a timed-out probe is classified "hanging", which never counts
toward readiness (#6800). So whenever the first health response takes longer than
2s the poll can never succeed: each abort discards the in-flight request before
the route finishes (its own 1s payload cache is never populated either), and
500ms later the next probe restarts the same work into the same ceiling, for the
whole 60s budget. Reproduced by the new test: against a health route that answers
200 in 3.2s, the old poller ran 12 probes over 30s and reported ready=false every
time.

The per-probe timeout now escalates after each hang (2s, 4s, 8s, 15s), clamped to
the time left in the budget so the caller's total timeout still holds. Only a hang
escalates, so #6800's guarantee is unchanged: a socket that accepts TCP and never
answers still resolves false. waitForServer also reports each probe outcome to an
optional onOutcome callback, and the readiness-timeout diagnostic uses it to say
whether the port was accepting connections, which separates "up and still warming"
from "never bound the port".

Same failure family as #10508, which fixed it by taking a DNS lookup out of the 2s
budget rather than by widening it. The heavy /api/monitoring/health route is what
makes that budget tight in the first place (its own docstring points high-frequency
pollers at /api/health/ping, which is what the Electron readiness poller uses);
switching the CLI probe route is a larger change, left as a follow-up.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CJf2dxEpiwZqyZujWk57T2

* chore(changelog): link the readiness-probe fix to PR 12484

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CJf2dxEpiwZqyZujWk57T2

---------

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
Co-authored-by: diegosouzapw <8016841+diegosouzapw@users.noreply.github.com>
2026-09-18 12:21:27 -03:00
killer30001000
d71d0f76e5 fix(usage): render OpenRouter PAYG credit pool with real denominator (#12468)
* fix(usage) handle OpenRouter PAYG credit percentage

OpenRouter PAYG accounts without a per-key limit previously rendered
the credits row as 'total: 0, remainingPercentage: 100, unlimited: true',
treating /credits balance as unlimited even when a real credit pool was
present. Route the credit pool through the credits renderer with the
real denominator: total = totalCredits when positive, used = total -
creditBalance, remaining = creditBalance, remainingPercentage =
round(balance / total * 100), isCredits: true, unlimited: false. Per-key
limit still wins. A non-positive pool surfaces the row but never invents
a 100% bar.

Tests cover: explicit key limit, PAYG account credits without key limit,
key limit taking priority over account credits, and a balance without a
positive denominator.

* fix(usage) render OpenRouter PAYG quota as a metered percentage bar

The frontend parser was routing every OpenRouter 'credits' quota through
buildCreditsQuota(), which sets isCredits: true. QuotaCardExpanded
short-circuits on that flag and shows only the USD balance as a bare
number, so a real PAYG payload (used: 7.33, total: 10, remaining: 2.67,
remainingPercentage: 27) was rendered as '$2.67' instead of the '27% left
/ 7.33 / 10' bar the backend already computed.

Drop isCredits: true for any payload whose total is a positive finite
number - the row then goes through the normal normalizeQuotaEntry() path
with currency preserved as an extra. The balance-only fallback (total 0
or non-finite denominator, used by legacy /credits responses) still uses
buildCreditsQuota() so the row stays renderable, and never invents a
100% percentage.

The frontend test now asserts:
- PAYG positive denominator -> total: 10, remainingPercentage: 27,
  currency: 'USD', isCredits !== true.
- Balance-only payload -> isCredits === true, creditCount === 2.67,
  total: 0, no fabricated 100%.
- NaN denominator -> balance-only fallback.
- Non-credits keys -> unchanged normalizeQuotaEntry() path.
- Mixed payload -> normal quota row + PAYG row, both kept.

* docs(changelog): add OpenRouter PAYG fix fragment

* docs(changelog): remove self credit
2026-09-18 12:21:17 -03:00
Kareem Jalal
ad633c8440 fix(memory): word/sentence-boundary aware fact truncation (#12383)
* fix(memory): word/sentence-boundary aware truncation in extraction

sanitizeMatch() and capExtractionText() previously did raw character-offset
slices (slice(0, MAX_FACT_LENGTH) / slice(-MAX_EXTRACTION_TEXT_LENGTH)) with
no boundary awareness, producing garbled mid-word/mid-clause fragments that
get injected into LLM context as memory facts.

- sanitizeMatch() now backs the cut off to the nearest sentence-ending
  punctuation (. ! ?) within a lookback window, falling back to a plain
  whitespace boundary, falling back to the original hard cut only when no
  boundary exists nearby.
- capExtractionText() applies the equivalent boundary-aware trim on the
  front edge of the kept tail.

Mirrors the boundary-aware truncation pattern already used by
open-sse/services/compression/lite.ts (#8169) for tool-result truncation.

Adds tests/unit/memory-extraction-boundary-truncation.test.ts covering
word-boundary cuts, sentence-boundary preference, short-string passthrough,
the no-boundary-available fallback, and capExtractionText's tail behavior.

* docs(changelog): add fragment for word/sentence-boundary fact truncation

Co-authored-by: diegosouzapw <8016841+diegosouzapw@users.noreply.github.com>

---------

Co-authored-by: diegosouzapw <8016841+diegosouzapw@users.noreply.github.com>
2026-09-18 12:21:10 -03:00
Amirreza Kimiyaei
b84059213f fix(gemini): preserve response-schema nullability across union flattening (#12310)
* fix(gemini): preserve response-schema nullability across union flattening

cleanJSONSchemaForAntigravity flattens every union spelling of nullable before
the schema reaches Gemini: flattenTypeArrays turns ["string","null"] into
"string" and flattenAnyOfOneOf drops the {"type":"null"} branch. Correct for
tool parameters, wrong for response schemas — a model with nothing to say can
no longer answer null, so it returns the string "null" or fabricates a value,
and either reaches the client as schema-conformant data. Pydantic emits the
anyOf spelling for Optional[str], so the fabricating path is the common one.

A Phase 1b walk now records Gemini's sibling-key spelling, nullable: true, on
any node whose union carries null — before Phase 2 destroys the evidence. The
key is absent from GEMINI_UNSUPPORTED_SCHEMA_KEYS so it survives sanitizing,
and flattenAnyOfOneOf's Object.assign cannot clobber a key the surviving
branch lacks. Opt-in via { preserveNullable: true }, passed only by the
responseSchema call site; the three tool-parameter call sites keep the default.

Closes #12308

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* docs(changelog): add fragment for #12308 gemini nullable schema fix

Co-authored-by: diegosouzapw <8016841+diegosouzapw@users.noreply.github.com>

---------

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
Co-authored-by: diegosouzapw <8016841+diegosouzapw@users.noreply.github.com>
2026-09-18 12:21:02 -03:00
Paijo
023a57476f feat(chat-admission): expose admission tunables via dashboard settings (#12038)
* feat(chat-admission): add settings store for admission tunables

* fix(chat-admission): extract parseEnvNumber to reduce cyclomatic complexity

* fix(chat-admission): repair settings store write path and add coverage

The settings store could not persist anything: `updateChatAdmissionSettings`
targeted an `updated_at` column that `key_value` does not have (the schema is
namespace/key/value — src/lib/db/core.ts), so every write threw
`table key_value has no column named updated_at`.

Also fixes, found while adding the tests:

- `getChatAdmissionSettingsSource` returned a partial map (only the keys whose
  layer differed from the default) and dropped the unset keys entirely, so a
  dashboard reading it could not render a complete row.
- env parsing used `parseFloat` for the shed ratio, so `"0.5x"` was silently
  accepted as 0.5 while `chatBodyAdmission.ts` rejects that same input — both
  paths now share one per-field predicate table.
- DB reads validated `typeof === "number"` but not integrality/range, so a
  hand-edited row could serve `2.5` or `-1` to the admission controller.
- writes persisted unvalidated input.
- malformed, non-object, and partial rows are now tolerated per field.

Adds tests/unit/db-chat-admission-settings.test.ts (17 cases) covering CRUD
round-trips, namespace isolation, reset, env parsing/validation boundaries,
env-over-DB precedence, provenance, normalization on write, and malformed-row
tolerance, per Hard Rule #8.

Verification: eslint clean; `npm run typecheck:core` clean; the new suite plus
the two sibling settings suites pass 63/63; check-complexity-ratchets reports
complexityNewCode=0; check-db-rules OK; check-env-doc-sync OK (all three vars
are already documented in .env.example).

---------

Co-authored-by: oyi77 <oyi77@users.noreply.github.com>
2026-09-18 12:20:53 -03:00
Diego Rodrigues de Sa e Souza
d8be3b1a77 feat(sse): reserve the Antigravity account for the request's stream lifecycle (re-land of #10011) (#13929)
* feat(sse): reserve the Antigravity account for the request's stream lifecycle

Re-land of the account-lease half of #10011 on the current release branch.
Its exact-model-scoping half had already shipped in #8050 and its quota half
lost to the tip's aggregate-family design (selectAntigravityQuotaWindowNames /
antigravityQuotaFamily.ts); none of that is reintroduced here. The lease is a
concurrency reservation only and never reads or writes quota state.

The Antigravity account selected for a request is reserved for the whole
streaming lifecycle of that request, so a concurrent retry — or the credential
handoff inside getProviderCredentialsWithQuotaPreflight — cannot re-pick an
account already committed to an in-flight upstream stream. The reservation is
scoped to (connection, callable upstream model) rather than the whole account,
so one account can still serve two different models at once; catalog ids that
resolve to the same upstream id (the gemini-3.7-flash tiers, all
gemini-3.7-flash-tiered) share one lease. When every eligible account is leased
for that model the request returns a structured 503 antigravity_pool_busy with
a bounded Retry-After instead of piling onto a busy account.

Opt-in behind ANTIGRAVITY_ACCOUNT_LEASE_ENABLED (runtime, default false). With
the flag off no reservation is taken, credentials carry no routing descriptor,
every release/hold is a no-op on an undefined lease id, and account selection
and dispatch behave exactly as before.

#10011's original test suite asserted family semantics for a lease that was
exact-model scoped and failed deterministically on its own head; the model ids
it used (gemini-3.5-flash / gemini-3-flash-agent) no longer exist in the
catalog. The contradiction is resolved in favour of one coherent semantic —
exact callable upstream model — and the tests assert it against the alias
tables as they are on this branch.

Co-authored-by: Ardem2025 <openclaw-auto@example.invalid>

* fix(sse): widen the Antigravity lease reservation result so auth.ts narrows it

The discriminated-union form of reserveAntigravityLeaseForSelection's return type
did not narrow under tsconfig.typecheck-api.json, so reading `reserved.lease`
after the `reserved.busy` early return raised TS2339 in the API Route Typecheck
gate. A single optional-property shape carries the same information and type-checks
everywhere.

Co-authored-by: Ardem2025 <openclaw-auto@example.invalid>

---------

Co-authored-by: Ardem2025 <openclaw-auto@example.invalid>
2026-09-18 12:09:02 -03:00
voidstack
c07cebbab7 fix(db): close failed initialization connections (#13342)
* fix(db): close failed initialization connections

* docs: add changelog fragment for #13303 db handle-leak fix

---------

Co-authored-by: voidstackloop <voidstackloop@users.noreply.github.com>
Co-authored-by: diegosouzapw <8016841+diegosouzapw@users.noreply.github.com>
2026-09-18 12:00:16 -03:00
chatchawan-simplewish
04eba4dc05 fix(mcp): load audit sqlite via runtime helper (#13223)
* fix(mcp): load audit sqlite via runtime helper

* docs(changelog): add fragment for MCP audit sqlite runtime-require fix

Co-authored-by: diegosouzapw <8016841+diegosouzapw@users.noreply.github.com>

---------

Co-authored-by: Codex <codex@openai.com>
Co-authored-by: chatchawan-simplewish <chatchawan-simplewish@users.noreply.github.com>
Co-authored-by: diegosouzapw <8016841+diegosouzapw@users.noreply.github.com>
2026-09-18 12:00:07 -03:00
Aref Alapour
e780da3578 fix(catalog): advertise input_modalities on vision-capable combos (#12799)
* fix(catalog): advertise input_modalities on vision-capable combos

A combo whose merged capabilities carry vision:true (e.g. an
operator-flagged #9195 vision head, or canonical vision with no synced
modality data) advertised the boolean with an empty modality set, so
models.dev-shaped clients that key off input_modalities still saw a
text-only entry. buildComboCatalogMetadata now derives the modalities
from the vision verdict it already advertises via
visionDerivedModalities() in catalogHelpers; synced modality
intersections keep precedence and nothing is derived for unknown or
text-only verdicts (fail-closed, same discipline as #4071/#4072).

catalog.ts stays at its frozen LOC (spreads collapsed into the helper
call). Regression-tested in models-catalog-combo-metadata.test.ts.

Refs #12798

* changelog: fragment for #12799

---------

Co-authored-by: aref-alapour <aref-alapour@users.noreply.github.com>
Co-authored-by: diegosouzapw <8016841+diegosouzapw@users.noreply.github.com>
2026-09-18 11:59:58 -03:00
Amirreza Kimiyaei
04cc8aab67 fix(cache): fold the response output contract into the semantic cache signature (#12309)
* fix(cache): fold the response output contract into the semantic cache signature

The signature hashed only {model, messages, temperature, top_p}, so two temp=0
requests with identical messages but different response_format shared a cache
key: the second was served the first's stored body under a 200, violating the
schema it asked for. tools/tool_choice had the same exposure.

generateSignature now takes an optional output contract — response_format,
text.format, tools, tool_choice, collected by outputContractOf() — and folds it
into the digest only when present, so plain-chat signatures (and every cache
entry already written for them) are unchanged. All three call sites pass it;
read/write symmetry is preserved because bodyForCacheWrite snapshots the same
body object the read path hashed (#cache-signature-asymmetry).

Closes #12307

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* docs(changelog): add fragment for #12307 semantic-cache output-contract fix

Co-authored-by: diegosouzapw <8016841+diegosouzapw@users.noreply.github.com>

* fix(cache): populate both constraint spellings in outputContractOf

The merge with #12734 left generateSignature reading the camelCase
constraints (toolChoice/responseFormat) with a snake_case fallback, but
outputContractOf only filled the snake_case keys, so the #12734
"signature is called with tool_choice/tools/response_format from body"
store tests failed on the merged branch. Set both spellings so either
caller shape reads the value it expects.

Co-authored-by: diegosouzapw <8016841+diegosouzapw@users.noreply.github.com>

---------

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
Co-authored-by: diegosouzapw <8016841+diegosouzapw@users.noreply.github.com>
Co-authored-by: amirrezakm <amirrezakm@users.noreply.github.com>
2026-09-18 11:59:50 -03:00
Jasmin Sehic
aecd50369b fix(sse): treat "length" stop_reason as legitimate in detectMalformedNonStream for Claude messages (#12935)
* fix(sse): treat "length" stop_reason as legitimate in detectMalformedNonStream for Claude messages

* docs(changelog): add fragment for length stop_reason fix

Co-authored-by: diegosouzapw <8016841+diegosouzapw@users.noreply.github.com>

---------

Co-authored-by: diegosouzapw <8016841+diegosouzapw@users.noreply.github.com>
Co-authored-by: jasminsehic <jasminsehic@users.noreply.github.com>
2026-09-18 11:59:41 -03:00
AnhLead
6788de8ef9 feat(providers): update Openference free models and add Deyin to compatible agents (#13378)
* feat(providers): update Openference free models and add Deyin to compatible agents

* docs(providers): regenerate PROVIDER_REFERENCE.md against the current tip

Post-merge regeneration so the diff only reflects the Openference free-model
addition, not stale eurouter/greenpt/count churn from an out-of-date local
generation.

Co-authored-by: diegosouzapw <8016841+diegosouzapw@users.noreply.github.com>

* fix(providers): soften unconfirmed Openference free-forever claim

The Openference pricing page (openference.com/pricing) currently lists five
paid plans ($15-$120/mo) and no $0 tier in its structured pricing data, so
neither the old "3-day trial" note nor a "free forever" claim can be verified
against the source. Point readers to the pricing page instead of asserting a
specific duration.

Co-authored-by: diegosouzapw <8016841+diegosouzapw@users.noreply.github.com>

---------

Co-authored-by: diegosouzapw <8016841+diegosouzapw@users.noreply.github.com>
Co-authored-by: AnhLead <AnhLead@users.noreply.github.com>
2026-09-18 11:59:33 -03:00
ducphamtien-fonos
128f06d645 fix(translator): support Responses custom tool choice (#13128)
* fix(translator): support Responses custom tool choice

* fix(translator): preserve custom tools across response paths

* docs(changelog): add fragment for Responses custom tool choice fix

Co-authored-by: diegosouzapw <8016841+diegosouzapw@users.noreply.github.com>

---------

Co-authored-by: Pham Tien Duc <phamtienduceng@gmail.com>
Co-authored-by: diegosouzapw <8016841+diegosouzapw@users.noreply.github.com>
Co-authored-by: ducphamtien-fonos <ducphamtien-fonos@users.noreply.github.com>
2026-09-18 11:59:24 -03:00
luyuehm
5a82da7084 feat(routing): deterministic routing strategies for self-hosted entry (RIC-740) (#13611)
* feat(routing): self-hosted unified OpenAI-compatible entry (RIC-738)

Divert /v1/chat/completions through the self-hosted provider adapters when
OMNIROUTE_SELF_HOSTED_PROVIDERS / OMNIROUTE_SELF_HOSTED_PROVIDERS_FILE is set:
one OpenAI-compatible contract in, auto-route to the selected provider
(x-omniroute-provider header, provider/model prefix, or first provider),
standard OpenAI error shape out. Optional OMNIROUTE_SELF_HOSTED_API_KEY guards
the entry (D5 reserved); unset = open loopback route. Upstream credentials stay
runtime-only and are stripped from echoed responses.

Brings in the provider-adapters baseline from sibling branch (RIC-737) that
this entry depends on. Includes 21 passing unit tests (provider selection,
model-prefix forwarding, header hygiene, auth, error normalization, SSE
passthrough, fall-through/misconfig), docs, env example, changelog fragment.

* feat(routing): deterministic routing strategies for self-hosted entry (RIC-740)

Add the M2 deterministic routing strategy engine (D3 可审计路由) to the
self-hosted unified entry: a declarative `strategy:` block expressing five
explainable, non-predictive policies — blacklist/whitelist hard filters,
cooldown circuit breaker, cost-priority, latency-aware ordering, and an
explicit fallback chain. The ordered candidate list is the fallback chain:
a failed primary (network or non-2xx) falls through to the next candidate and
each failure feeds the breaker. Every response carries an
x-omniroute-route-decision header answering "why this model / why not that
one". A pinned provider rejected by a hard filter returns 400 (never a silent
re-route); no eligible providers returns 503 with the full explainable
decision. No ML/predict dependency.

Covers the RIC-740 acceptance: 5 strategy types with unit tests + HTTP
fault-injection tests (primary down -> fallback works), config matching docs,
and no predict/ML deps. Adds docs, .env.example entries, and a changelog
fragment.

* refactor(routing): reduce complexity-ratchet violations in new self-hosted routing files

Extract cost/id validation, pin-blocked resolution, ordering, and env/file
source resolution into small helpers so routingStrategies.ts and
selfHostedEntry.ts stay under the complexity-ratchets cap. No behavior
change — the same 51 routing-strategies/self-hosted-entry/provider-adapters
tests pass unmodified.

Co-authored-by: diegosouzapw <8016841+diegosouzapw@users.noreply.github.com>

* docs(routing): document the 5 self-hosted env vars in ENVIRONMENT.md

check:env-doc-sync failed because OMNIROUTE_SELF_HOSTED_PROVIDERS(_FILE),
OMNIROUTE_SELF_HOSTED_API_KEY and OMNIROUTE_SELF_HOSTED_STRATEGY(_FILE)
were present in .env.example but missing from
docs/reference/ENVIRONMENT.md. Add them under "6. Tool & Routing Policies".

Co-authored-by: diegosouzapw <8016841+diegosouzapw@users.noreply.github.com>

---------

Co-authored-by: Ant Rich <ant@richants.com>
Co-authored-by: diegosouzapw <8016841+diegosouzapw@users.noreply.github.com>
Co-authored-by: luyuehm <luyuehm@users.noreply.github.com>
2026-09-18 11:59:15 -03:00
James
3ebea07278 fix(sse): replay reasoning for Responses-API targets on plain turns and Anthropic clients (#13031)
* fix(sse): replay reasoning for Responses-API targets on plain turns and Anthropic clients

DeepSeek thinking mode requires the reasoning of every prior assistant turn
to be passed back once the request carries `tools`, including turns that
made no tool call. Since #10540 routed opencode-go/deepseek-v4-* to
`/responses`, the reasoning replay cache had two gaps on Responses-API
targets, and clients that drop `reasoning_content` hit intermittent
`400 The reasoning_text in the thinking mode must be passed back`.

1. Plain (non-tool-call) turns are keyed on a digest of the normalized
   OpenAI transcript. Both capture sites used `translatedBody.messages` as
   the history, which a Responses body (`input`) does not carry, so the
   write-time digest never matched the read side. translateRequest now
   reports the pivot transcript it digested via `onReasoningReplayHistory`,
   and the streaming / non-streaming capture sites digest that transcript.
2. The Responses replay pass was gated on `sourceFormat === "openai"`, so
   Anthropic Messages clients (Claude -> OpenAI -> Responses) got no replay
   at all. The pass now runs on the OpenAI pivot for every source format,
   right before the Responses conversion discards `messages`.

The reported transcript is a shallow snapshot of the digested fields only
and travels through a callback, not the body, so nothing new reaches the
upstream payload.

* docs(changelog): add fragment for #13031

* fix(sse): guard the Responses capture sites and skip plain-turn writes with no history

Review follow-ups for #13031:

- Add tests/unit/chatcore-reasoning-cache-write-guard-responses.test.ts:
  runs the real handleChatCore against a mocked opencode-go/deepseek-v4-flash
  Responses upstream (JSON and SSE), then asserts the next turn's upstream
  body carries the replayed `reasoning` input item. Removing either capture
  site fallback turns both cases red.
- Project the reported transcript down to the digested fields only
  (tool_calls keep type/name/arguments, ids are dropped) and document that
  `content` is shared by reference.
- Skip the plain-turn cache write when the history is empty: a real request
  always has a prior user turn, so an empty history means the transcript
  could not be recovered and a one-message digest can never match.
- Changelog wording: the pre-fix write digested only the assistant message.

* test(sse): select the /responses dispatch by URL in the Responses replay guard

Review follow-ups for #13031: the guard picks the upstream body by URL
(`/responses`) and asserts exactly one such dispatch per turn instead of
taking the last fetch, the streaming case asserts the same body shape as the
non-streaming one, and the `historyMessages` doc on
NonStreamingClientTranslateInput names the Responses-shaped fallback.

* docs(routing): name the replay-history hand-off without tripping the hook heuristic

The fabricated-docs gate treats any `onXxx` token in prose as a plugin hook
name and flagged `onReasoningReplayHistory` (a translateRequest option, not a
hook). Point at the option's home file instead.

* chore(quality): freeze chatCore.ts at 6159 for the Responses replay wiring

check:file-size in PR mode caps a frozen file at max(frozen, base). The rebase onto
the v3.8.51 tip (cde49c937) leaves chatCore.ts at 6159 lines against a 6146 ceiling:
the onReasoningReplayHistory callback on both Responses-capable translateRequest call
sites, reasoningReplayHistory on both non-streaming leg inputs, and the historyMessages
fallback at the streaming cache write. Record the growth with a justification key, as
#13033 did for the same file.

---------

Co-authored-by: jmche <jmche@users.noreply.github.com>
Co-authored-by: diegosouzapw <8016841+diegosouzapw@users.noreply.github.com>
2026-09-18 11:59:06 -03:00
initguru
6768b14b54 fix(chatcore): block duplicate turn execution with 409 turn_in_progress (#12912)
* fix(chatcore): block duplicate turn execution with 409 turn_in_progress

* test(sse): align turn-execution-guard 409 body expectation with buildErrorBody reason field

* fix(errors): preserve duplicate turn classification

* test(turn-execution-guard): assert ageMs range instead of exact 0

Comparing ageMs to an exact 0 was flaky under real scheduling
latency between the two synchronous calls in the test.

Co-authored-by: diegosouzapw <8016841+diegosouzapw@users.noreply.github.com>

* docs(changelog): add fragment for turn execution guard fix (#12912)

Co-authored-by: diegosouzapw <8016841+diegosouzapw@users.noreply.github.com>

* chore(quality): rebaseline chatCore.ts file-size for the turn-execution guard

The guard logic lives in the new open-sse/handlers/chatCore/turnExecutionGuard.ts
leaf; what grows chatCore.ts is the irreducible call-site wiring at the single
execution chokepoint (acquire, the 409 turn_in_progress early return, the
release/handoff bookkeeping and the try wrapper that scopes it).

Co-authored-by: diegosouzapw <8016841+diegosouzapw@users.noreply.github.com>

* fix(sse): keep endpointPath outside the turn-guard try so the failure-usage closure can reach it

The try/finally that scopes the duplicate-turn guard block-scoped the
resolveChatCoreRequestFormat destructuring, but persistFailureUsage is defined
above the try and closes over endpointPath — every failure-usage write would
have thrown ReferenceError. Moved the destructuring above the guard (it is a
pure derivation from the request, so nothing else changes) and narrowed the
acquire result with an explicit === false, which the workspace tsconfig
(strict: false) needs to see the non-acquired arm's retryCount/ageMs.

check:open-sse-typecheck goes from 4 errors to 0; typecheck:core, eslint,
prettier and the PR's 4 turn-execution-guard tests stay green.

Co-authored-by: diegosouzapw <8016841+diegosouzapw@users.noreply.github.com>

---------

Co-authored-by: Jihyun Son <jihyun.son@sk.com>
Co-authored-by: diegosouzapw <8016841+diegosouzapw@users.noreply.github.com>
Co-authored-by: initguru <initguru@users.noreply.github.com>
2026-09-18 11:58:57 -03:00
Tuan Dinh
6fec29ca2d fix(copilot): fallback to copilot-chat on 403 identity denial for standard provider (#13705)
* fix(copilot): fallback to copilot-chat on 403 identity denial for standard provider

* fix(copilot): document COPILOT_INTEGRATION_ID, extract identity fallback, add changelog

Adds the missing COPILOT_INTEGRATION_ID entry to .env.example (fixes
tests/unit/issue-7793-env-doc-sync-repro.test.ts), extracts the GitHub
Copilot 403 identity fallback out of open-sse/executors/base.ts into its
own module (open-sse/executors/copilotIdentityFallback.ts) to bring the
file back under the frozen file-size ratchet, and adds a changelog.d/fixes
fragment for the PR.

Co-authored-by: diegosouzapw <8016841+diegosouzapw@users.noreply.github.com>

---------

Co-authored-by: diegosouzapw <8016841+diegosouzapw@users.noreply.github.com>
Co-authored-by: tuandinh0801 <tuandinh0801@users.noreply.github.com>
2026-09-18 11:58:48 -03:00
Alvin T. Veroy
24706705fa fix(streaming): per-provider fetch-start timeout cap override (#11526 follow-up) (#13002)
* fix(streaming): per-provider fetch-start timeout cap override (#11526 follow-up)

Buffered gateways (opencode-go / command-code Console Go tiers) legitimately
buffer a whole reasoning generation before the first upstream byte, so their
streaming requests can exceed the default 110s headers-wait cap. #11526 capped
every streaming request at that ceiling, so these long generations died at
exactly 'Fetch timeout after 110000ms' (504) before any bytes arrived.

Add a per-provider fetchStartTimeoutCapMs registry knob (600s for opencode-go
and command-code) and project it into the executor's LegacyProvider so
resolveFetchStartTimeout caps only genuinely unbounded providers.

* docs(changelog): add fragment for fetch-start cap per-provider override

Co-authored-by: diegosouzapw <8016841+diegosouzapw@users.noreply.github.com>

---------

Co-authored-by: diegosouzapw <8016841+diegosouzapw@users.noreply.github.com>
Co-authored-by: alvinveroy <alvinveroy@users.noreply.github.com>
2026-09-18 11:58:39 -03:00
Wu Shuwen
10bb627576 fix(evals): mark eval-runner requests as self-managed so cases measure the model (#13139) (#13206)
* fix(evals): mark eval-runner requests as self-managed so cases measure the model

executeEvalCase() built its request with only Content-Type and Authorization, so
every graded case picked up the chat path's contextual injections: a selected
output style was prepended as a system message (gated on
`x-omniroute-compression`) and, once the request carried an API key, retrieved
memory plus the built-in `memory_*` tools were appended (gated on
`x-omniroute-no-memory`). An evaluation therefore measured the operator's
injected context as much as the model, and passing an API key to a run made its
score worse, because the key is what gives the request a memory owner (Refs #13139).

Both are documented request-header opt-outs, so the runner now sets them on every
case. Request construction moves to an exported buildEvalCaseRequest() so the
header contract is testable without invoking the chat route.

* docs(changelog): add the eval-runner self-managed-context fragment (#13206)

---------

Co-authored-by: diegosouzapw <8016841+diegosouzapw@users.noreply.github.com>
2026-09-18 11:58:30 -03:00
Tuan Dinh
4a5f1cd771 fix(providers): Antigravity connection Retest probes Cloud Code envelope (#13010) (#13015)
* fix(providers): Antigravity connection Retest probes Cloud Code envelope (#13010)

* fix(providers): ensure correct argument order for Antigravity discovery and add test

* fix(providers): resolve connection.projectId and surface upstream 400 error message

* docs(changelog): add fragment for #13015

---------

Co-authored-by: diegosouzapw <8016841+diegosouzapw@users.noreply.github.com>
2026-09-18 11:58:22 -03:00
IAMBOBJIM
d1b26bcb62 fix(sse): aggregate findInsensitive collision warning into one line per build (#12972)
modelMetadataRegistry's findInsensitive() warned once per colliding key while
building its lowercase index. On a real catalog that is hundreds of lines per
rebuild: a production log carried 27,296 of these in a single file — 40% of all
lines, in ~500/sec bursts — driving 52 MB log rotations and ~466 MB of logs on
disk.

The warning itself is worth keeping: a case-insensitive collision is a genuine
upstream data-quality signal (models.dev returning both "OpenAI" and "openai"
as distinct provider keys), and first-match-wins silently discards the later
value. Only the volume was wrong.

Collisions are now collected during the index build and reported as a single
line carrying the total count plus the first 5 keys, so the diagnostic survives
at 1/N the volume. No behavior change: the index, the first-match-wins
resolution, and the WeakMap identity cache are untouched.

Validated by TDD (Hard Rule #18): tests/unit/model-metadata-registry-collision-log.test.ts
fails on the old implementation (3 collisions -> 3 warnings, 50 -> 50) and
passes after (always 1). Also covers the no-collision case emitting nothing,
and asserts the aggregated line still names colliding keys.

Note for reviewers: the test fixture deliberately spells the provider key
"OpenAI" rather than "openai". findInsensitive short-circuits on
`if (key in obj) return obj[key]` before the index is ever built, so a fixture
containing the literal lookup key produces zero warnings and proves nothing.

Gates: eslint clean on both changed files. typecheck:core reports 9 pre-existing
errors in open-sse/services/compression/omniglyph* — unrelated to this change
(those files are byte-identical to origin/release/v3.8.50) and caused by a local
stale node_modules carrying omniglyph 1.3.1 against the required ^1.4.0.

Co-authored-by: diegosouzapw <8016841+diegosouzapw@users.noreply.github.com>
2026-09-18 11:58:14 -03:00
Pixma
01b2467d61 feat(sse): add LLM Gateway DevPass quota tracking (#12462)
* feat(sse): add LLM Gateway DevPass quota tracking

Surface the LLM Gateway DevPass allowance (GET /v1/key) in OmniRoute's
quota telemetry, mirroring the OpenRouter API-key fetcher pattern.

- llmgatewayQuotaFetcher.ts: fetch + parse the DevPass /v1/key response
  (decimal-string USD values), exposing two windows — monthly plan
  credits and the 7-day premium-model window — with a 45s TTL cache.
  Pay-as-you-go keys (devPlan "none") and 401/403 fail open (no quota).
- Register in chat.ts before registerGenericQuotaFetchers + register the
  named windows for the dashboard cutoff modal.
- usage/llmgateway.ts leaf + usage.ts dispatch case so the Limits page
  renders the monthly + weekly premium rows.
- Add "llmgateway" to USAGE_FETCHER_PROVIDERS, USAGE_SUPPORTED_PROVIDERS,
  PROVIDER_LIMITS_APIKEY_PROVIDERS, and the dashboard label/order map.
- tests: 21 cases covering the parser, auth fail-open, cache TTL, window
  exhaustion, preflight proceed/block, registration, and the usage leaf.

* docs(sse): add changelog fragment + codebase-doc entry for llmgateway quota

* refactor(sse): register llmgateway quota via quotaTrackersBatch

Move the LLM Gateway fetcher registration out of chat.ts (a frozen
file-size-baseline chokepoint) into quotaTrackersBatch.ts, the dedicated
side-effect module that exists precisely so new fetchers don't grow
chat.ts. The batch import runs at module load, before
registerGenericQuotaFetchers(), so the bespoke fetcher still wins over
the generic path. Fixes the file-size gate (chat.ts must not grow).

---------

Co-authored-by: diegosouzapw <8016841+diegosouzapw@users.noreply.github.com>
2026-09-18 11:58:05 -03:00
Diego Rodrigues de Sa e Souza
f1eabd8885 fix(sse): stop direct fetch retry reusing pooled flat response-start budget (#13703) (#14047)
resolveDirectHeadersTimeoutMs() (open-sse/utils/directResponseStartTimeout.ts)
now bounds only the pooled dispatcher attempt (attempt 0) with the flat
OMNIROUTE_DIRECT_HEADERS_TIMEOUT_MS watchdog. The fresh-socket retry (attempt
1) is by construction a brand-new socket with no zombie-socket risk (#10214's
rationale only applies to the pooled attempt), so when the caller already
attached its own deadline signal it now defers to a generous, configurable
backstop (OMNIROUTE_DIRECT_RESPONSE_RETRY_TIMEOUT_MS, default 600s) instead of
reusing the identical short flat window — fixing spurious 504s on healthy
slow-TTFB reasoning models that need well over 60s total for both attempts.

Regression test: tests/unit/proxyfetch-direct-response-start-flat-retry-budget-13703.test.ts
(RED against unmodified code: retry cut at 81ms against an 80ms flat budget
with ~1920ms of caller deadline unused; GREEN after the fix).

New env var documented in .env.example and docs/reference/ENVIRONMENT.md.

tlsProfileForProvider's return type in proxyFetch.ts is pulled into a named
alias so its signature stays on one line under prettier's canonical
formatting -- otherwise prettier's mandatory lint-staged reformat of this
frozen file grows it past the check:file-size baseline on every future touch.

base-red inherited: #14004 (docs env/docs contract, fixed separately in
#14022; chatHelpers file-size drift)
2026-09-18 11:57:51 -03:00
Diego Rodrigues de Sa e Souza
1b82b2f982 fix(combo): stop chars/4 overestimate demoting a verified context override (#13870) (#14046)
filterTargetsByRequestCompatibility ranked combo targets solely on the
chars/4 estimateTokens() heuristic. On a repetitive agent-session body the
estimate overstates real usage several-fold, so a manually-overridden
primary sized correctly for the real request got marked context-incompatible
and was reordered behind an unconfirmed catalog "emergency" member with a
large but unverified limit_context.

Fix: when the reorder branch promotes known-context-compatible targets,
split them by whether their pass came from an operator-set
model_context_override (trusted) or bare catalog metadata (advisory), and
also trust a near-boundary override rejection (required tokens within 5x
the override — covering the ~3.7x overestimate the issue measured) over a
catalog-only pass. An override target keeps or regains priority over an
unconfirmed catalog-only "known compatible" target; two override targets or
two catalog-only targets keep resolving purely on their own fit as before.

Regression test: tests/unit/combo-13870-chars4-overdrops-override-primary.test.ts
(RED before the fix — emergency member promoted to position 0 ahead of the
override primary; GREEN after).

⚠️ base-red inherited: #14004 — docs env/docs contract (fixed separately in
#14022), chatHelpers file-size drift. Not touched by this branch.
2026-09-18 11:57:40 -03:00
Diego Rodrigues de Sa e Souza
f3ab24b8c7 fix(db): rotate proxy pools on the chat path like the registry does (#13575) (#14044)
resolveProxyForConnection cached a scope pool's first resolution result for the
life of the per-connection cache, so a chat-path request never saw the pool's
round-robin/sticky/random strategy advance again — only the narrow #13578
set-aside escape hatch could break the freeze. resolveProxyForScopeFromRegistry
(used directly by every existing rotation test) always re-ran the strategy and
rotated correctly.

The cache now treats a registry-sourced pool result as due for re-resolution on
every call (falling through to the same cascade the direct registry callers
use), except for the two populations that need a stable egress across
requests: EGRESS_BUCKETED_LOCK_PROVIDERS (opencode's quota is bucketed by
egress IP) and grok-web (its cf_clearance cookie is pinned to the IP/UA/TLS
fingerprint that earned it).

Regression test: tests/unit/proxy-pool-chat-path-rotation-13575.test.ts, RED
before the fix (resolveProxyForConnection returned the same host 6/6 times for
a 3-member pool), GREEN after. Updated tests/unit/proxy-pool-skips-refused-member.test.ts's
three assertions that encoded the frozen-cache contract to the corrected
always-rotates-except-pinned contract; all other cases in that file and in
tests/unit/proxy-pool-rotation-6365.test.ts pass unchanged.
2026-09-18 11:57:32 -03:00
Diego Rodrigues de Sa e Souza
b0955042dc fix(providers): map agentrouter GLM thinking.type adaptive to enabled (#13696) (#14043)
AgentRouter routes GLM models through the generic DefaultExecutor, which has
no GLM-specific handling. When the connection's OpenAI-compatible alternate
format is used, a Claude-style thinking:{type:"adaptive"} field survived
stripUnsupportedParams untouched and reached AgentRouter's upstream GLM
endpoint verbatim, which 400s (thinking.type "adaptive" is not supported by
glm models; must be one of enabled, disabled).

Add a mapThinkingType mechanism to paramSupport.ts's STRIP_RULES (in addition
to the existing drop/clamp mechanisms) and scope a rule to provider
"agentrouter" + model matching /glm-/i that remaps thinking.type from
"adaptive" to "enabled", preserving any other thinking fields (e.g.
budget_tokens) — mirroring the same mapping GlmExecutor already performs for
its own provider.
2026-09-18 11:57:25 -03:00
Diego Rodrigues de Sa e Souza
0d31fd3d24 fix(quota): resolve plan from pool's primary connection (#13876) (#14042)
Multi-connection Quota Sharing pools resolve a DIFFERENT provider plan
depending on which member connection actually served a request (write
path, enforceQuotaShare/recordConsumption) vs. the pool's primary
connection (dashboard read path, /api/quota/pools/[id]/usage). The
wizard's "Limite" step PUTs a manual plan override only to the primary
connection, so any other pool member fell back to a different
(catalog/empty) plan shape. Since the quota_consumption dimension key
is poolId:unit:window, a different unit/window meant recordConsumption
wrote to a bucket the dashboard never read, so real traffic served via
a non-primary connection never appeared as "consumed".

Fix: resolve the plan from the pool's canonical primary connection
(pool.connectionId) in both enforceQuotaShare and recordConsumption,
matching the dashboard's read path. recordConsumption now keeps the
matched pool object (not just its id) so it can reach connectionId.
getSaturation(input.connectionId, ...) is untouched — that signal is
legitimately per-connection.
2026-09-18 11:57:17 -03:00
Diego Rodrigues de Sa e Souza
460c6075b1 fix(providers): convert agent_message input items for non-Codex-native Responses upstreams (#13698) (#14041) 2026-09-18 11:57:08 -03:00
Diego Rodrigues de Sa e Souza
a96c8381f7 fix(db): serve getPricingForModel() from the pricing cache (#13891) (#14040)
getPricingForModel() called the uncached getPricing() on every
invocation instead of the existing getCachedPricing() helper
(30s TTL, readCache.ts), so usageStats.getUsageStats() re-ran a
3-SELECT + JSON.parse + merge cycle against key_value once per
GROUP BY row -- up to 531 times on a large usage_history table --
blocking the event loop for several seconds on /api/usage/history.

Every known pricing writer (updatePricing, LiteLLM/models.dev sync)
already invalidates this cache via touchPricing()/invalidateDbCache,
so a write remains immediately visible; added a regression test that
proves both the cache hit path and the invalidation path.
2026-09-18 11:57:00 -03:00
Diego Rodrigues de Sa e Souza
e94752fa53 fix(tests): make ReDoS guard assert cost scaling, not wall-clock (#13907) (#14039)
The property test asserted an absolute 250ms ceiling on
sanitizeErrorMessage() for adversarial inputs. That ceiling had no
margin over the pipeline's real fixed cost (3x redact + 2x
normalize passes added by #12506), so it failed on cost under any
machine load, not on backtracking. Replace it with a check that the
sanitizer's cost does not scale with input length beyond a generous
noise allowance, which is what a bounded-backtracking guarantee
actually claims; keep a coarse absolute hang ceiling as a backstop.
2026-09-18 11:56:50 -03:00
Diego Rodrigues de Sa e Souza
b14ef5c7e5 fix(guardrails): resolve nested combo-ref hops before vision-bridge decision (#13927) (#14038)
getComboVisionBridgeDecision() treated any top-level combo-ref step as an
unconditional "process", without ever resolving the referenced combo's real
leaf models. A pass-through combo whose only member is a combo-ref to an
all-vision-capable inner combo was wrongly routed through the
describe-and-replace path, and with no describer model configured every image
was replaced with the literal stub text.

Recursively resolve combo-ref steps to their real leaf models (depth-guarded
by the same MAX_COMBO_DEPTH used by the flatten dispatch path, plus a
visited-set cycle guard) and fold their vision capability into the same
accumulation used for direct model steps. An unresolvable combo-ref (not
found / empty / circular / depth-exceeded) is conservatively treated as a
single non-vision-capable leaf instead of forcing the whole combo to
"process".
2026-09-18 11:56:42 -03:00
Diego Rodrigues de Sa e Souza
5fce23c957 fix(sse): recognize MCP-gateway-namespaced CCR retrieve tool names (#13781, #13897) (#14028)
callerSupportsCcrRetrieve() matched the omniroute_ccr_retrieve tool by exact
string equality. MCP aggregators/gateways (Docker MCP Toolkit, Claude Code)
rename re-exposed tools with a namespace prefix (e.g.
mcp__docker__omniroute__omniroute_ccr_retrieve, or a dotted/slashed prefix),
so a fully MCP-capable caller reachable only under such a name was treated as
unable to retrieve at all -- skipping both the protocol-instruction injection
and, per the #7746 safety guarantee, the entire CCR compression engine for
that request.

Add matchesCcrRetrieveToolName(), a separator-bounded trailing-segment match
(__, ., /, :) that accepts a namespaced form of the tool name while still
rejecting a near-miss like omniroute_ccr_retrieve_v2 -- never a bare
substring/endsWith check.
2026-09-18 11:56:32 -03:00
Diego Rodrigues de Sa e Souza
25d35179fd fix(security): scope /api/files and /api/batches to caller's tenant (#13882) (#14027)
/api/files, /api/files/[id]/content, /api/batches and /api/batches/[id]
only gated on requireManagementAuth(request), which returns null
unconditionally when settings.requireLogin===false, and never applied
any per-record ownership check. On an instance with login disabled, an
unauthenticated caller could enumerate/download every tenant's files
and batches — the hardened /api/v1/files and /api/v1/batches siblings
already scope via getApiKeyRequestScope()/resolveListScope()/
canAccessOwnedRecord() from the GHSA-2jm2-mpx8-6523 and
GHSA-m3hp-hq9g-fpmv fixes.

Port that exact scoping onto the 4 management routes: an API key sees
only its own files/batches, a dashboard session keeps instance-wide
access, and any other caller is rejected instead of falling through to
an unscoped read.
2026-09-18 11:56:24 -03:00
Diego Rodrigues de Sa e Souza
5b61937f17 fix(sse): declare deepseek 1M default context window (#13922) (#14026) 2026-09-18 11:56:15 -03:00
Diego Rodrigues de Sa e Souza
1c5612c760 fix(api): fail closed on revoked/expired/banned API keys in getApiKeyRequestScope (#13881) (#14024)
getApiKeyRequestScope() resolved apiKeyId purely from getApiKeyMetadata(),
which does a row-existence lookup with no lifecycle filtering. Only
validateApiKey() checks is_active/revoked_at/is_banned/expires_at, and none
of the six /v1/files and /v1/batches route handlers called it directly, so a
revoked, expired or banned key kept a live apiKeyId and canAccessOwnedRecord()
/resolveListScope() kept granting it access to its own records after
revocation (CWE-613).

Fold validateApiKey() into getApiKeyRequestScope() itself: a key that fails
that lifecycle gate is now collapsed into the same { apiKeyId: null,
apiKeyMetadata: null } shape as an unresolved/anonymous caller, so every
consumer of this scope (list reads, per-record ownership checks) fails
closed without each route re-implementing the check.
2026-09-18 11:56:06 -03:00
Diego Rodrigues de Sa e Souza
3b535968c4 fix(providers): detect Lemonade labels[] vision capability (#13918) (#14023)
detectVisionInput() only recognized supportsVision, architecture.input_modalities,
top-level input_modalities, and architecture/modality string shapes. Lemonade
Server's GET /v1/models exposes capabilities only through a labels[] string
array (e.g. ["chat", "vision", "reasoning", "tool-calling"]), so a
vision-labelled Lemonade model imported with supportsVision unset and was
advertised as text-only.

Add a fifth branch that does a case-insensitive, trimmed EXACT membership
test for "vision" in record.labels[] (not a substring match, per the prior
false-positive lesson with bare gemma id-fragment matching). Purely additive
- all four existing shapes stay byte-identical, proven by a new regression
test that exercises the architecture.modality path unchanged.
2026-09-18 11:55:54 -03:00
Diego Rodrigues de Sa e Souza
9c9ad6bbde fix: land #13161 and #13304 by cherry-pick (locked organization fork) (#14090)
* fix(resilience): treat a Cloudflare managed challenge as a fingerprint rejection, not a ban

A Cloudflare managed/JS challenge served in front of an upstream provider is the
same class of block as a Cloudflare 1010 — the edge refused the CLIENT's
signature — but it is a different product surface and carries none of the 1010
markers isCloudflareFingerprintRejection() looks for.

It therefore fell through the entire 403 ladder in classifyProviderError() to the
terminal default FORBIDDEN, which chatCore persists via writeTerminalStatus() as
testStatus=banned / isActive=false. That state never auto-recovers, so a single
challenge takes the whole provider offline until an operator reconnects in the
dashboard.

Observed on POST chatgpt.com/backend-api/codex/responses/input_tokens for a
healthy Codex OAuth account: the response carried cf-mitigated: challenge,
server: cloudflare and a ~12KB text/html interstitial with
window._cf_chl_opt = {... cType: 'managed', cZone: 'chatgpt.com' ...}. The same
connection refreshed its OAuth token successfully in the same second and served
normal /responses traffic seconds before and after, so the account was never
banned upstream.

Classify the interstitial as FINGERPRINT_REJECTION, reusing the existing
non-terminal precedent from #9929: authTerminalStatus already treats that type as
non-terminal, so the request falls through to the next combo target and the
account state stays untouched.

The markers are matched as full, distinctive Cloudflare-internal strings
(_cf_chl_opt, cdn-cgi/challenge-platform, the challenge-error-text span id
including its escaped-quote nested form) and never as the loose word
"challenge", so provider bodies discussing a challenge in prose are unaffected.

Tests cover the full interstitial, the gateway-nested error.message form, each
marker individually, prose false-positive guards, and regression guards proving
a genuine permission 403 and the ChatGPT Web Sentinel/Turnstile 403 (#8813) both
still classify as FORBIDDEN.

(cherry picked from commit 8204da668a)

* fix(translator): skip replayed web_search_call metadata in Responses-to-Chat

OmniRoute's web-search fallback emits a native web_search_call output item
alongside function_call/function_call_output. Responses clients keep that item
in conversation history and replay it in the next request's input. When the
follow-up turn routes to a Chat Completions target (Claude), the translator hit
its default unsupported-feature branch and returned a deterministic HTTP 400:

  Unsupported Responses API feature: input item type 'web_search_call'
  cannot be represented in Chat Completions

Skip the replayed metadata next to tool_search_call/tool_search_result. The
paired function_call_output still carries the search results, so no context is
lost and the sources are not duplicated into assistant history.

(cherry picked from commit 2b3e63a470)

* chore(changelog): credit the locked-fork landings of #13161 and #13304

Both PRs come from an organization fork (azox-ai) that refuses maintainer pushes
even with maintainerCanModify=true, so they cannot be re-synced in place and are
landed here by cherry-pick with the contributor's authorship preserved
(merge-gates §6). GitHub may not mark the PRs Merged, hence the explicit
credit in the fragments.

---------

Co-authored-by: anhth2 <anhth2@vng.com.vn>
2026-09-18 11:35:50 -03:00
Hakarioz
217c93d081 chore(deps): bump better-sqlite3 to ^13.0.3 (#14049)
Co-authored-by: Hakarioz <lucaspapoute@gmail.com>
2026-09-18 11:35:41 -03:00
Paco Cartones
57f31105f9 test(dashboard): reactivate logs modal coverage (#14048)
Co-authored-by: Paco Cartones <pacocartones@users.noreply.github.com>
2026-09-18 11:35:33 -03:00
Dizzle
de77213b0d fix(build): drop orphaned httpClientAbortGuard.mjs pack-artifact entries (#14029)
The #13636 crash-guard wiring was removed, leaving no producer or
consumer for the dist file. Refs #12732

Co-authored-by: Max <maxmad64@gmail.com>
2026-09-18 11:35:24 -03:00
Dizzle
fec8dc2be9 fix(opencode): record the free-tier refusal instead of counting it as success (#14011)
An OpenCode Zen free-tier 403 ("free tier can only be used from within
OpenCode") reached the end of the executor loop unrecognized: nothing was
persisted about it, and the account that had just been refused was marked
successful, which clears the failure history driving its cooldown backoff. A
refusal was therefore improving the rotation health of the account it hit.

The refusal is now recognized by its own predicate, returned unchanged without
rotating (it is request-scoped, so every sibling account returns the same
verdict), and classified as a non-banning routing error, so the connection
records lastErrorType/lastError/errorCode and stays active.

The account-health reset is also reserved for HTTP successes at both call sites
in the loop, since the same reset ran on any status the loop did not handle in a
dedicated branch.

Co-authored-by: Max <maxmad64@gmail.com>
2026-09-18 11:35:15 -03:00
John Costa
875a84e301 fix(docker): copy the app with node ownership instead of a second chown layer (#14010)
The runner-base stage COPY'd the standalone build as root and then ran
`RUN chown -R node:node /app`. On overlayfs a chown rewrites every file it
touches into the new layer, so the published image carried the ~2 GB
standalone tree twice (docker history of diegosouzapw/omniroute:latest:
`COPY /app/.build/next/standalone ./` 2.03 GB followed by
`RUN chown -R node:node /app` 2.04 GB).

Set `--chown=node:node` on the three COPYs that populate /app, drop the
recursive chown, and hand /app and /app/data to node non-recursively next to
the `mkdir -p /app/data` so the data dir stays writable without a volume.

Measured by rebuilding the runner-base COPY/chown sequence against the
published /app tree (root-owned source, same base image, linux/arm64):

  before: 4.38 GB of layers (COPY 2.04 GB + chown -R 2.04 GB), inspect Size 1220846106
  after:  2.34 GB of layers (COPY --chown 2.04 GB),          inspect Size  655117142

The fixed image runs as uid 1000, /app and /app/data are node-owned and
writable, the server boots and healthcheck.mjs exits 0. hadolint output is
unchanged. tests/unit/dockerfile-copy-chown-13990.test.ts guards the
mechanism.

Fixes #13990

Co-authored-by: diegosouzapw <8016841+diegosouzapw@users.noreply.github.com>
2026-09-18 11:35:05 -03:00
John Costa
8fbc85ee72 fix(perplexity-web): keep runs of spaces when cleaning non-streaming answers (#14009)
cleanResponse(text, strip = true) replaced every run of two or more
spaces with a single space. The non-streaming path (which tool mode
always uses, since it buffers the full completion before converting
<tool> text into tool_calls) runs cleanResponse with strip on, so any
code the model wrote through perplexity-web lost its indentation: every
nesting level came back as one space, making generated Python
unimportable. Tabs were untouched, which is what pointed at this
normalization step rather than the model.

Fold the space handling into CITATION_RE so the space before a removed
[n] marker goes with it ("text [1] more" still cleans to "text more"),
and drop MULTI_SPACE. Blank-line squashing and trim are unchanged.

Fixes #13968
2026-09-18 11:34:56 -03:00
Tiangao
95cb992c32 fix(providers): honor the base URL override in OpenRouter model discovery (#14001)
* fix(providers): honor the base URL override in OpenRouter model discovery

Model discovery for the built-in `openrouter` provider resolved its catalog
URL from PROVIDER_MODELS_CONFIG, which is pinned to the global
`https://openrouter.ai/api/v1/models`. The per-connection base-URL override
(`providerSpecificData.baseUrl`, set via "Advanced -> override base URL") was
never consulted on the discovery path, while the inference path has honored it
since #6147 (open-sse/executors/base.ts `resolveBaseUrl`).

A connection pointed at a different OpenRouter region therefore kept importing
the global catalog: the per-connection model list, and the auto-sync that
maintains it, advertised model ids the configured endpoint cannot serve. Those
ids only failed later, at inference time, so a region/catalog mismatch surfaced
as what looked like a provider outage.

The two catalogs genuinely differ — the global endpoint advertises ~444 model
ids, the EU in-region endpoint ~58 (a strict subset) — so discovery and
inference disagreed with no signal exposing it.

Discovery now prefers the override for this provider, reusing the existing
`addModelsSuffix()` normalization (drops a trailing chat/responses/messages
path, appends /models, leaves an existing /models untouched). Mirrors the
`openai` override handling added for the same class of bug in #5899. When no
override is set the built-in global catalog is still used.

Tests: tests/unit/openrouter-models-baseurl-override.test.ts covers both the
override and the unchanged default.

* chore(changelog): add fragment for #14001

---------

Co-authored-by: Tiangao (hermes) <montigaud@aikumi.pro>
2026-09-18 11:34:47 -03:00
Aref Alapour
6f55a8c44f fix(providers): keep TinyCMS DOM stub safe for Next.js SSR (#13957)
Never alias window to the Node global without location. The wasm-bindgen
shim now installs a dedicated window with a Location-shaped object and
restores after WASM init/payload generation, so getLocationOrigin cannot
crash every route after TinyCMS is used once.

Co-authored-by: Cursor Agent <cursoragent@cursor.com>
Co-authored-by: Aref Alapour <aref-alapour@users.noreply.github.com>
2026-09-18 11:34:39 -03:00
Dizzle
fc6b4587ad feat(proxy): support multiple local core endpoints, one per line (#13923)
Co-authored-by: Max <maxmad64@gmail.com>
2026-09-18 11:34:30 -03:00
Shahanur Islam Shagor
b6e7bc12ea Fix/combo multimodal capability 13847 (#13863)
* fix(capabilities): align combo multimodal vision hints

* test(capabilities): cover combo multimodal consistency

* chore(changelog): note combo multimodal capability fix
2026-09-18 11:34:21 -03:00
Xmon Dai
bdc79ef883 fix(providers): normalize non-function tools for allowlisted built-in OpenAI-format providers (#13855)
Built-in providers that speak the OpenAI Chat wire format skipped
normalizeOpenAICompatibleTools(), which only ran for custom
openai-compatible-* connections. A client tool whose type is not
"function" (a named Claude server tool, a nameless hosted tool) was
forwarded verbatim, and agentrouter's GLM backend rejected the whole
request with 400 tools[0].type:type is illegal.

Extract the gate into shouldNormalizeFunctionToolsOnly(): custom
openai-compatible-* providers keep normalizing on every target, and a
conservative allowlist of built-in providers (agentrouter first)
normalizes on the OpenAI Chat target only. OpenAI itself stays off the
list, so its custom tools pass through untouched.

Closes #13789
2026-09-18 11:34:12 -03:00
luw2007
74d8690687 fix: reclaim expired half-open probe lease (#13849)
Co-authored-by: luwei.will <luwei.will@bytedance.com>
2026-09-18 11:34:03 -03:00
Paco Cartones
dfde9fc392 fix(sre): redact secrets split across stream chunks (#13837)
Co-authored-by: Paco Cartones <pacocartones@users.noreply.github.com>
2026-09-18 11:33:54 -03:00
legas888Oleg
733f4c1d0a fix(providers): refresh uncloseai free-model roster after upstream rotation (#13825)
* fix(providers): refresh uncloseai free-model roster after upstream rotation

hermes.ai.unturf.com rotated its lineup: /v1/models now serves exactly one
model (Lorbus/Qwen3.6-27B-int4-AutoRound, vllm, max_model_len 65536) while
every previously catalogued id (adamo1139/Hermes-3-Llama-3.1-8B-FP8-Dynamic,
qwen3.6:27b, gemma4:31b) returns 404 on /v1/chat/completions. Requests routed
through the static seed failed although the provider itself is healthy — a
live completion against the new id succeeds.

- registry seed: replace the three dead ids with the live one (+contextLength)
- FREE_MODEL_BUDGETS: 3 rows -> 1 (catalog totals 443 -> 441; counts synced in
  README and free-tier-budget.svg)
- noauth authHint: verified-live-model pointer updated; PROVIDER_REFERENCE.md
  regenerated
- regression test pins the live id and forbids the retired ids in both the
  registry seed and the free catalog

Verified live on 2026-09-15 against https://hermes.ai.unturf.com/v1/models
and /v1/chat/completions.

* docs(providers): sync free-tier entry count after uncloseai merge

The uncloseai roster refresh (3 entries -> 1) dropped the live free-tier
catalog total from 491 to 489 once merged with the current release tip.
README.md and free-tier-budget.svg still quoted 491 after the merge;
update both to the real count so check:docs-counts-sync stays green.

Co-authored-by: diegosouzapw <8016841+diegosouzapw@users.noreply.github.com>

---------

Co-authored-by: anon <anon@users.noreply.github.com>
Co-authored-by: diegosouzapw <8016841+diegosouzapw@users.noreply.github.com>
2026-09-18 11:33:47 -03:00
Abhishek Sharma
b45e0c69e6 feat(security): warn at boot when the inference server is exposed anonymously (#13820)
* feat(security): warn at boot when the inference server is exposed anonymously

`GET /v1/models` follows the dashboard login posture
(`isAuthRequired()` / `requireAuthForModels`) while the inference routes
follow `REQUIRE_API_KEY`. On an instance with an admin password set and
`REQUIRE_API_KEY=false`, `/v1/models` answers 401 while `/v1/responses`
is open to anyone who can reach the port — so the most natural probe an
operator runs reports the opposite of the truth.

#12568 added a boot warning for exactly this combination, but wired it
only into the API bridge and the live dashboard WebSocket. The Next
server that actually answers `/v1/chat/completions` and `/v1/responses`
never reached it, and it is the one that binds every interface by
default (`process.env.HOST || "0.0.0.0"`).

Wire the existing guard into the Next boot hook, and document the split.

Resolving the bound host needed care: two entrypoints bind that server
and they read different variables. `run-next.mjs` honours `HOST`; the
Docker entrypoint delegates to Next's generated `server.js`, which reads
`HOSTNAME`. `run-next.mjs` now publishes what it actually binds as
`OMNIROUTE_BOUND_HOST`, and the guard reads that, then `HOSTNAME`, then
the shared `0.0.0.0` default. `HOST` is deliberately absent from the
chain: the standalone server ignores it, so consulting it there would
warn about an interface the server is not on — and one false warning
teaches an operator to ignore the next one.

Closes #13695

* docs(changelog): add changelog.d entry for #13820
2026-09-18 11:33:39 -03:00
William Echo
2782258846 fix(executors): strip invalid OpenCode stream options (#13819)
Co-authored-by: William Echo <175406538+qinghuanandejiangshi@users.noreply.github.com>
2026-09-18 11:33:31 -03:00
Abhishek Sharma
164043d301 fix(ci): clear the tap.testFiles drift that reds the mutation gate on every PR (#13814)
* fix(ci): clear the tap.testFiles drift that reds the gate on every PR

check-mutation-test-coverage --strict fails on a pristine checkout of
release/v3.8.51 with no PR diff involved, so the mutation-test-coverage
gate is red on every open PR regardless of what it changes.

Six covering unit tests across four mutated modules were absent from
stryker.conf.json tap.testFiles, which means their mutant kills were not
being counted:

  accountFallback.ts          daily-reset-tz-threading, noauth-model-lockout
  sse/services/auth.ts        free-badge-provider-gate, noauth-model-lockout
  combo/comboPredicates.ts    local-token-budget-429-skips-cooldown
  combo/rrState.ts            daily-reset-tz-threading

Four distinct files — two of them cover two modules each. Inserted into
the alphabetical run, matching the file's existing convention; the list
has a second unsorted appended group that is left alone.

After: "No drift — every covering unit test is listed in tap.testFiles",
exit 0. All four files pass (31 tests) so registering them does not
introduce a failing mutation run.

Noticed while reviewing #13743, which targets a fifth file that has
already been registered by 25bc16d87e.

* fix(ci): drop a dangling tap.testFiles entry and guard against new ones

Merging the release line in surfaced that stryker.conf.json still names
tests/unit/plugin-sandbox-permissions.test.ts, which does not exist —
one dangling path out of 428 entries, pre-existing on the base rather
than introduced here.

check-mutation-test-coverage already guards one direction: a test that
covers a mutated module but is missing from tap.testFiles. The other
direction was silent. Stryker resolves the list into its sandbox, so an
entry left behind after its test file is deleted or renamed costs
coverage without failing loudly — the same class of drift this PR is
about, arriving from the opposite side.
2026-09-18 11:33:22 -03:00
Domenico Massafra
6901e72fb8 test(routing): guard Astra vision cutover (#13810)
Co-authored-by: ginettododo <117327638+ginettododo@users.noreply.github.com>
2026-09-18 11:33:14 -03:00
Domenico Massafra
03c1f7545a fix(antigravity): canonicalize tiered flash quota (#13809)
Co-authored-by: ginettododo <117327638+ginettododo@users.noreply.github.com>
2026-09-18 11:33:05 -03:00
小妍儿 ✨
5de6c5108b fix(usage): preserve nested prompt cache reads before cost calculation (#13760)
* fix(usage): preserve nested prompt cache reads

* docs(changelog): add nested cache-read cost fix

---------

Co-authored-by: 千乘妍 (Xiaoyaner) <xiaoyaner0201@users.noreply.github.com>
Co-authored-by: diegosouzapw <8016841+diegosouzapw@users.noreply.github.com>
2026-09-18 11:32:56 -03:00
Xmon Dai
57d7e861d1 docs: prefill issue titles with Conventional Commits format (#13750)
bug_report.yml and feature_request.yml prefilled `[BUG] ` / `[Feature] ` while
CONTRIBUTING.md documents Conventional Commits with a scope list, so reporters
who followed the template verbatim had their title renamed by triage. The
templates now teach the convention instead: `fix(): ` and `feat(): `.

Each form gains one intro line pointing at the commit-message section of
CONTRIBUTING.md, where the scope list lives. `test_coverage_task.yml` is left
untouched, as its `[Coverage] ` prefix is not part of the convention.

Refs #13688
2026-09-18 11:32:47 -03:00
Goni Sulaiman
21d0e81332 fix(security): enforce allowedEndpoints on the alias rewrites (#13685) (#13741)
Co-authored-by: Goni Sulaiman <gonisulaimann@users.noreply.github.com>
2026-09-18 11:32:37 -03:00
Sean Ford
010250cf08 fix(memory): list provider-node models in the Embedding and Rerank selectors (#13740)
* fix(api): type compatible-provider-node models in /v1/models by the node's apiType

Model rows discovered from an OpenAI-compatible provider node rarely carry
endpoint metadata — a TEI / Infinity / vLLM `/v1/models` listing is just ids —
and the catalog defaulted such rows to `["chat"]`. An `embeddings`-typed node
exposing `bge-m3` and a `rerank`-typed node exposing `bge-reranker-v2-m3`
therefore both surfaced in GET /v1/models as untyped chat models: clients
that build their picker from `type: "embedding"` / `type: "rerank"` never saw
them, and chat pickers listed models that 400 on chat.

- src/shared/constants/modelSupportedEndpoints.ts: add
  defaultEndpointsForProviderNodeApiType(apiType) — embeddings → ["embeddings"],
  rerank → ["rerank"], audio-* → themselves, images-generations → ["images"],
  chat/responses/unknown → ["chat"] (unchanged default).
- src/app/api/v1/models/catalog.ts: build a node-id → apiType map next to the
  existing node-id → type map; the synced-model and custom-model loops fall
  back to the node's modality instead of ["chat"] when a row has no
  supportedEndpoints; the custom-overlay merge path also classifies
  `type`/`subtype` from the overlay's explicit supportedEndpoints, so a manual
  `["rerank"]` row layered on a discovered chat-default row is re-typed.

Explicit supportedEndpoints on any row still take precedence, and chat /
responses nodes keep the historical behavior.

tests/unit/catalog-provider-node-apitype-endpoints.test.ts covers the helper
and the catalog end-to-end for embeddings, rerank, mixed, chat, and overlay
cases via getUnifiedModelsResponse().

* chore(changelog): name the #13734 fragment

* refactor(api): keep the provider-node modality helpers out of catalog.ts

catalog.ts is frozen by the file-size gate (must not grow past 2075
lines) and the apiType fallback pushed it to 2093. Move the node
apiType index, the endpoint fallback and the overlay type/subtype
fields into catalogNodeModality.ts so catalog.ts ends one line
shorter than the base; behaviour and tests are unchanged.

* fix(api): give nodeModelEndpoints a string[] return so the catalog classifier typechecks

The API-route typecheck gate flagged TS2345 at both classifyModelSupportedEndpoints()
call sites: the helper returned `ModelSupportedEndpoint[] | unknown[]`, and unknown[]
is not a readonly string[]. The base code only passed because the synced row's
supportedEndpoints was untyped. Same pass-through cast overlayEndpoints() already uses;
no behaviour change.

* fix(memory): list provider-node models in the embedding and rerank selectors

GET /api/memory/embedding-providers and GET /api/memory/rerank-providers
appended local provider nodes by apiType alone and always with models: [].
A node typed "embeddings" that also serves a rerank model — one TEI /
Infinity / vLLM box hosting both bge-m3 and bge-reranker-v2-m3 is the
common self-hosted layout — was filtered out of the Rerank selector
entirely (apiType not in chat/responses/rerank) and showed up in the
Embedding selector as a provider with nothing to pick. Typing prefix/model
by hand worked because the request path resolves it directly; only the
convenience layer was blind.

Add src/lib/memory/embedding/nodeModalityListings.ts, which builds the
listing from the node's synced + custom model rows, typing each row the way
/v1/models does (explicit supportedEndpoints wins, otherwise the node's
apiType via defaultEndpointsForProviderNodeApiType; a custom overlay
re-types a discovered row). A node is listed for a modality when its
apiType matches, when it is a generic chat/responses node (historical
behaviour, kept so catalog-less nodes still appear), or when any of its
rows is typed for the modality. Both endpoints use it; the curated
registries stay first and win on prefix collisions.

* chore(changelog): name the #13740 fragment
2026-09-18 11:32:26 -03:00
Lukas
39cf76c11d fix(gemini): a tool name starting with a digit no longer fails the request (#13738)
Google validates every `functionDeclarations[].name` against one grammar and
rejects the WHOLE GenerateContentRequest when any single one is invalid, so six
`1c_*` tools in a 109-tool MCP catalog made every request 400, including
requests that would never call them (#13715).

`normalizeGeminiToolName` removed invalid characters, collapsed underscores and
stripped leading and trailing ones, none of which touches a leading digit. The
strip is also why a leading underscore was not a workaround: the client's
`_1c_probe` was normalized back to `1c_probe` and rejected for the same reason.

The prefix goes inside the normalizer rather than at the call site, because
that keeps the guarantee on the one value every path reads. `buildHashedGemini
ToolName` builds its name from the normalized string and inherits its first
character, so a fix applied later would hold for short names and fail silently
for the long ones a 109-tool catalog is full of. It also runs before the
collision check, so two names that newly collide are still separated by the
existing hashed path.

The reverse direction needs nothing: the sanitized name now differs from the
client's, so `buildChangedToolNameMap` carries the original and the response
translator restores the client's spelling on the model's functionCall.

Six cells: no declared name starts with a digit, a leading underscore reaches
Google letter-first, the reverse map returns the client's spelling, a
letter-first name is untouched, an over-long digit-first name keeps the
guarantee through the hash path, and two colliding digit-first names stay
distinct. Four mutations, all killed.
2026-09-18 11:32:18 -03:00
Notaloop763
1609767f37 fix(providers): sync OpenRouter :free 1000/day tier from /credits lifetime purchases (#13689)
* fix(providers): sync OpenRouter :free 1000/day tier from /credits lifetime purchases

* chore: rename changelog fragment to PR number 13689
2026-09-18 11:32:10 -03:00
Lukas
ce56098115 fix(models): keep OpenRouter :batch variants out of chat routing (#13622)
* fix(models): keep OpenRouter :batch variants out of chat routing

ModelSync imported OpenRouter's Batch-API-only variants into the chat
catalogue. A chat completion against one is rejected upstream with

  404 This model is only available through the Batch API.
      Use the /api/beta/batches endpoint instead.

which #13596 measured 91 times in 41 hours, third by volume, plus the
`model not found - locking mode` failover churn behind it.

OpenRouter's /models carries no endpoint metadata that separates a batch
variant from a chat one, so `classifyExplicitEndpoints` cannot decide it
and the rule belongs in `modelEndpointPolicy`, beside the OpenAI
image/video policy and for the same reason: the file exists so discovery,
import and catalog projection agree on one answer.

Matched as the exact `:batch` suffix, not "has a variant suffix" --
`:free`, `:nitro`, `:floor`, `:online`, `:extended` and `:thinking` are
routing hints on the same chat model, and excluding them would silently
shrink the routable catalogue. Applied unconditionally for this provider:
there is no "batch" endpoint name an upstream could declare next to a chat
one, and the already-stored rows carry the synthetic `["chat"]` default
that re-imported them in the first place.

Closes #13596

* docs(changelog): add fragment for the OpenRouter batch-variant fix
2026-09-18 11:32:01 -03:00
opensource-elearning
65263a4fe9 fix(cli): Codex long-session turn-pin fallback + codex-settings key resolution (#13564 #13563) (#13566)
* fix(sse): release native Codex turn pin when pinned model is model-scoped unusable (#13564)

Long-running Codex sessions die with 400 NATIVE_CODEX_PINNED_MODEL_UNAVAILABLE whenever
the model pinned to the current turn becomes model-scoped unusable mid-session (per-model
quota lockout, connection cooldown, exhausted accounts). Claude Code has no equivalent
pin and already falls back to the next healthy combo model; Codex now matches.

Release the turn pin when all pinned provider+model targets are model-scoped unusable
and fall through to full combo routing, re-pinning to whichever model succeeds. Preserve
the pin on provider-wide outages (circuit breaker OPEN, provider cooldown) and when the
pinned target is still healthy.

Also prunes the stale ESLint suppression entry for combo.ts that this change orphaned
(createPinnedModelUnavailableResponse import dropped; pre-existing getBootstrapLatencyMs
remains the sole residual unused var).

* fix(api): resolve codex-settings apiKey via canonical resolver instead of 400 (#13563)

Applying Codex settings from /dashboard/cli-code/codex always failed with
400 "baseUrl, apiKey and model are required" when the dashboard sent an empty
apiKey (cloud mode with no management key selected) — baseUrl and model are
already Zod-gated, so that response could only ever fire on the empty key.

The codex-settings route had diverged from the sibling CLI tools (cline/forge/
openclaw/grok-build/jcode): an inline if(!apiKey) 400 guard plus a hand-rolled
getApiKeyById lookup, instead of the shared resolveApiKey(keyId, apiKey) helper
which resolves by keyId, falls back to the submitted apiKey, then to
sk_omniroute. This change makes codex-settings use the canonical resolver, so:

- empty apiKey + valid keyId -> the real DB key is written to auth.json
- empty apiKey + no keyId   -> sk_omniroute default (config still applies)
- explicit apiKey           -> written verbatim (unchanged)

* docs(changelog): add fragments for Codex turn-pin fallback and codex-settings apiKey resolution
2026-09-18 11:31:52 -03:00
sprintberlin
2a89a3bba7 fix(translator): flatten root-level anyOf/oneOf/allOf in Claude tool schemas (#13561)
Anthropic's Messages API rejects a tool whose `input_schema` carries a
composition keyword at the root with:

  tools.N.custom.input_schema: input_schema does not support oneOf,
  allOf, or anyOf at the top level

The refusal happens before inference, so a single MCP/agent tool carrying
a root-level union fails every request that ships the catalog, and combo
failover cannot recover from it.

Both conversion paths that build a Claude `input_schema` from a client
payload now flatten such a root union into a plain object schema:
object-compatible branches contribute their `properties` (root wins on a
name collision), the root is pinned to `type: "object"`, and only `allOf`
contributes `required` — `anyOf`/`oneOf` branch requirements are
alternatives and promoting them would refuse calls the original schema
accepts. Nested unions are untouched and clean schemas pass through
unchanged.

Closes #13552
2026-09-18 11:31:43 -03:00
MumuTW
d86662d582 test(coverage): clean stale coverage output before test:coverage runs (#13408)
* test(coverage): clean stale coverage output before test:coverage runs

* chore(changelog): add fragment for coverage pre-clean
2026-09-18 11:31:35 -03:00
Xore
1c19e2c034 fix(compression): place output-style instruction in top-level system, not messages[0] (#13383)
* fix(compression): place output-style instruction in top-level system, not messages[0]

Anthropic-shaped bodies reject a synthetic system-role entry unshifted at
messages[0] (the claude passthrough forwards messages unchanged, and the
upstream API requires system content in the top-level system parameter).
Output styles and the caveman output mode now land their instruction in
the top-level system field when it exists (string or content-block array)
and only fall back to a trailing system message for OpenAI-shaped bodies,
which the claude system-role extraction hoists. Custom endpoint system
prompts skip the unshift whenever a top-level system field is present.

All compression options stay enabled; placement-only fix.

Fixes #12584

* test(compression): cover system-instruction placement branches 4 and 6

Adds direct unit cases for the placement ladder branches that had no
coverage, asserting where the instruction lands:

- branch 4: merge into a system message at index >= 1, leaving
  messages[0] untouched (plus the skip-over-a-block-content system
  message variant).
- branch 6: trailing append when the body has neither a `system` field
  nor a string-content system message.
- block-array `system`: appends one block and is idempotent on a second
  pass (first coverage of the array path of the marker check).

Also normalizes a malformed non-string/non-array top-level `system`
(e.g. `null`) to "" before the format branch in injectSystemPrompt and
injectCustomSystemPrompt. Previously such a body entered the `system`
branch, matched neither format, and silently dropped the prompt without
falling back to the messages path. Both new guards fail without this
change.

* chore(compression): add changelog fragment for top-level system placement
2026-09-18 11:31:27 -03:00
Patryk Kopyciński
d073f1b273 chore(stryker): register 3 covering unit tests missing from tap.testFiles (#13357)
* fix(test): make npm run test terminate and restore RAYCAST env-doc sync

Two independent defects, both in the test/dev entrypoint layer.

1. `npm run test` never terminated. It was a hand-maintained copy of
   `test:unit` that had drifted: it omitted `--test-force-exit` on BOTH
   node invocations and dropped the trailing `&& npm run test:unit:serial`.
   Per AGENTS.md ('Database Handles in Tests'), unreleased SQLite handles
   make Node's native runner hang indefinitely — every sibling script
   (`test:unit`, `test:unit:ci`, `test:unit:ci:shard`) already carried the
   flag; only `test` did not. Measured on m1max at 84c6ad7c2, same suite
   both arms: without the flag the runner was killed at the 420s ceiling
   (exit 137, no summary line, 23 orphaned node processes); with it the
   runner exited on its own in 419s leaving 1. `test` now delegates to
   `test:unit` so the two cannot drift again, which also makes the serial
   suite reachable from `npm run test` for the first time.

2. Removing the RAYCAST_* rows from ENVIRONMENT.md (#9) broke
   check-env-doc-sync. `parseEnvExampleVars` matches `^#?\s*(VAR)=`, so it
   counts COMMENTED-OUT vars: the four entries still sat at
   .env.example:1263-1266 and became `envMissingDoc` drift the moment their
   docs disappeared. The #9 verification only ran the fabricated-docs gate
   and missed this one. The block is dead either way — it documents
   open-sse/services/raycast.ts and scripts/raycast/usage-benchmark.mjs,
   both deleted with the GPL-derived provider in #11691, and no live code
   reads the vars — so it is removed rather than re-documented.

envMissingDoc is now []. The remaining codeMissingEnv failure
(CURSOR_AGENT_BINARY, CURSOR_MAX_FRAME_BYTES, OMNIROOT) is pre-existing
drift on the base, absent from this diff, and left alone.

* chore(stryker): register 3 covering unit tests missing from tap.testFiles

check:mutation-test-coverage --strict fails identically on pristine
release/v3.8.51 (f1e7148c1) with an empty diff — base debt blocking this PR.

- combo-identical-error-streak.test.ts -> comboPredicates.ts
- 13601-header-drop-count-surfaced.test.ts -> responseHeaders.ts
- semantic-cache-no-truncated-writes.test.ts -> semanticCache.ts

* fix(test): keep the #13187 concurrency-4 cap in test:unit

The dedupe made `test` delegate to `test:unit`, but it also silently
reverted the deliberate local concurrency cap from #13187 ("cap local
unit-test concurrency at 4 to avoid exhausting commit charge") back to 20.

Measured on m1max (16 cores), same suite and same tree, only the flag differs:
  concurrency=20 -> 356 cancelled, 356 "event loop has already resolved" bailouts
  concurrency=4  -> 0 cancelled, 0 bailouts
So 20 does not just slow the run down, it makes the runner abandon tests and
still print a summary -- a false green. Restore 4; termination is preserved via
delegation to test:unit, which already carries --test-force-exit.

* revert(test): drop redundant test-script delegation

The #13187 batch commit (178d25250) already gave `test` both `--test-force-exit` flags and the trailing `&& npm run test:unit:serial` step, so the delegation fix was redundant. It also broke tests/unit/test-serial-quarantine.test.ts, which asserts every parallel runner script ends with the serial step (base 4/4 -> head 3/4). package.json is now byte-identical to base; this PR is the stryker tap.testFiles fix only.
2026-09-18 11:31:19 -03:00
Felipe Britto
138ccf2d04 fix(build): copy ioredis and bcryptjs into the standalone bundle (#13352)
Both packages are only reachable through code paths the standalone
tracer never follows, so they get silently dropped from the built
node_modules/:

- ioredis is a deliberately lazy dependency (#6559 in
  rateLimiter.ts) — reached only via a runtime `await
  import("ioredis")` in rateLimiter.ts,
  warmupScheduler/circuitBreakerFactory.ts and
  quota/redisQuotaStore.ts, never through a static top-level import.
  Any self-hosted deployment that sets REDIS_URL crashes on first use
  with "Cannot find module 'ioredis'".

- bcryptjs is statically imported by
  src/lib/auth/managementPassword.ts, so the main server bundle is
  fine (Next inlines the small pure-JS package into the compiled
  chunk). bin/cli/settings-store.mjs (the `omniroute reset-password`
  CLI) is a separate, unbundled entrypoint that needs the real
  package physically present in node_modules/ — nothing else
  requires it as a loose runtime dependency, so it was never copied.
  `node bin/reset-password.mjs --password-stdin` failed with
  "Cannot find package 'bcryptjs'" (ERR_MODULE_NOT_FOUND) on an
  otherwise healthy production deployment.

Both reproduced on a real self-hosted Docker deployment (v3.8.49/51).
Adds the two entries to EXTRA_MODULE_ENTRIES (the single source of
truth cited in the Dockerfile) and extends the sync/async parity
test with fixtures + assertions for both.
2026-09-18 11:31:10 -03:00
Abhishek Sharma
53a147c7ca fix(backend): stop redaction truncating the message after a path (#13295)
* fix(backend): stop redaction truncating the message after a path (#13144)

`findUnquotedPathEnd` may swallow the rest of a line when it cannot tell
where a path ends, so a Windows path with spaces cannot leak a
`Files\secret` suffix. Two things made that fire far wider than the
function documents at `:606`.

**1. The licence was granted on separator evidence alone.** Every API route
carries slashes, so an ordinary `/v1/x/y` in prose qualified as unequivocal
and truncated everything after it. The image-model 400 lost the one
sentence it exists to deliver:

    built     ...cannot be used on /v1/chat/completions. Use POST
              /v1/images/generations instead.
    delivered ...cannot be used on <path>

Now only a Windows path, a file URI, or a known POSIX filesystem root may
swallow the line. @diegosouzapw's `/zz` vs `/etc` probe on the issue is why
this is the condition and not the first-segment check I originally
proposed: both truncated identically, so the root was never the driver.

**2. The ambiguity branch ran before `resolvedExtensionEnd`.** A path whose
end is pinned exactly by a known extension was still treated as ambiguous
the moment any prose followed it, so the endpoint was discarded and the
line swallowed. A determinable extension leaves nothing to fail closed
about -- the whole path is still replaced, suffix included, and the tail
survives:

    before  Provider failed in <path>
    after   Provider failed in <path> with api_key='[REDACTED]'

Both halves are independently load-bearing: reverting (1) fails the two
route tests, reverting (2) fails the extension test.

Fail-closed is narrowed, not weakened. `/etc/shadow copy failed` has
nothing to anchor an endpoint on and still collapses to `reading <path>`.
Worth recording that the guard test for that cannot be killed by mutating
either mechanism alone -- the two are mutually redundant, so it takes
disabling both, which is also why this change cannot expose a suffix these
shapes did not already hide.

Test results against base:

  chat-rejects-image-only-model          red -> GREEN
  dashboard-request-failed-redaction     its delivered-log assertion now
                                         passes; the test still fails on a
                                         second, unrelated assertion (the
                                         *internal* log is redacted where it
                                         should stay raw) that base never
                                         reached
  tunnel-routes-error-sanitization       unchanged, independent (a tripwire
                                         asserting the shared sanitizer does
                                         NOT cover a shape it now does)

836/840 pass across the sanitization, redaction and error suites; the
remaining failures are the two above plus mcp-public-error-boundaries,
which passes in isolation on base and on this branch and only flakes under
--test-concurrency=8.

* docs(changelog): fragment for the redaction truncation fix (#13144)

changelog.d/README says a PR adds exactly one fragment rather than editing
CHANGELOG.md, so the aggregation order stays deterministic and siblings
cannot conflict. This one was missing.

* test(redaction): narrow the headline case to the truncation it names

Rebased onto a base that has moved 40 commits; resolveEndpoint gained an
ignoreAmbiguity parameter and route-context callers in that window. The
merge keeps both: base's parameter, plus this PR's two changes (check the
resolved extension BEFORE the ambiguity branch, and drop
hasFilesystemEvidence from the swallow licence).

The headline assertion was that the whole message survives byte for byte.
On the current base the tail survives but the route itself still becomes
<path> in that particular message, because the quoted model slug earlier
in the line carries separators. That is a narrower, separate question from
the truncation this PR fixes, so the test now asserts the remediation
sentence survives and records the <path> substitution explicitly rather
than silently dropping the case.

* test(stryker): register the redaction-truncation test for mutation runs

errorPathRedaction.ts is mutation-tested, so a new covering test has to be
in tap.testFiles or the Stryker sandbox never runs it and its mutants
report as survived. Inserted in the alphabetical run beside the sibling
error-sensitive-redaction.test.ts, following #13036's precedent.

The list has a second, unsorted appended group; left that alone rather
than re-sorting a file this PR only needed one line in.

Requested in review by @diegosouzapw.
2026-09-18 11:31:02 -03:00
小妍儿 ✨
6d585625f0 fix(providers): honor Alibaba workspace embedding and rerank endpoints (#13293)
* fix(providers): honor Alibaba workspace embedding and rerank endpoints

* docs(changelog): add Alibaba workspace endpoint fix

* refactor(rerank): keep Alibaba response adapter focused

---------

Co-authored-by: 千乘妍 (Xiaoyaner) <xiaoyaner0201@users.noreply.github.com>
2026-09-18 11:30:53 -03:00
SIGTERM
2233a14a87 fix(vertex): preserve Claude prompt caching and usage metadata (#13220)
* fix(vertex): preserve Claude prompt caching

* fix(vertex): normalize unsupported cache TTLs

* docs(changelog): note Vertex prompt caching fix
2026-09-18 11:30:45 -03:00
Wu Shuwen
36493a6270 fix(evals): fail a case whose model call errored instead of scoring it passed (#13201)
* fix(evals): fail a case whose model call errored instead of scoring it passed

runSuite() attached caseMetrics[id].error to the graded result but never forced
`passed` to false. executeEvalCase() returns a failed call as an ordinary output
string ("[ERROR] <message>"), so any expected pattern that happened to match that
text was recorded as a pass. That inflates the reported pass rate, and reports a
non-zero score for a run in which no model was ever reached.

Built-in codex-comparison case codex-07 reproduces it: its pattern is
"try|catch|throw|error|Error" and the provider-resolution failure text ends with
"...added as a combo entry.", so the `try` alternative matches and the case is
scored as passed while carrying a non-empty error.

A case that never reached a model has no measured behaviour to grade, so a
failure is the only honest score.

Refs #13137

* docs(changelog): add fragment for the errored-eval-case fix (#13201)
2026-09-18 11:30:35 -03:00
Abhishek Sharma
bc72d03970 test(usage): pin fetcherProviders against supportedProviders (#13134)
The two lists are sibling pure-data modules that have to agree, and nothing
compared them. A provider in fetcherProviders but not supportedProviders
computes a quota nobody ever asks for; one in supportedProviders with no
dispatcher case is accepted and then falls through to
`default: "Usage API not implemented"`.

That seam has produced the same bug three times -- #9603 (bailian, whose
own comment reads "fetcher existed, list entry missing"), #11722, and
#12256 (openrouter credits, which #13080 then asked for again six days
after the fix landed because the card had never appeared).
usage-families-split.test.ts pins the dispatcher against fetcherProviders,
so the open-sse side cannot drift; this pins the other edge.

Divergence stays allowed but has to be declared with a reason:

  opencode, opencode-zen, xai   have a fetcher, not offered to the dashboard
  xiaomi-mimo-token-plan        offered, no fetcher -- but inert, because it
                                authenticates by API key and is absent from
                                PROVIDER_LIMITS_APIKEY_PROVIDERS, so
                                isSupportedUsageConnection refuses it before
                                the dispatcher is reached

I could not establish intent for the first three from the tree, so they are
pinned rather than "corrected" -- `xai-oauth`/`xao` are offered while the
API-key `xai` is not, which reads like it may be deliberate. A third test
fails if a declared divergence stops diverging, so the allowlists cannot go
stale and quietly excuse a future recurrence of the same id.

Mutations, each killed by the right test:

  new fetcher, no dashboard entry   -> the forward guard + the stale check
  dashboard entry, no fetcher       -> the reverse guard + the stale check
  a declared divergence gets fixed  -> the stale check alone
  duplicate id in either list       -> the duplicate test alone

105 related tests pass (this, usage-families-split, provider-plugin-manifest,
provider-limits*), eslint clean. Test-only; no production file touched.
2026-09-18 11:30:27 -03:00
Dor Amir
cf57129c09 fix(tests): retire claude-3-5-sonnet-20241022 from the combo integration suites (#13056)
* fix(tests): retire claude-3-5-sonnet-20241022 from the combo integration suites

Sibling of #12670. Eight integration suites still targeted the retired claude-3-5-sonnet-20241022, which the vendor lifecycle registry rejects with HTTP 410, so every combo listing it lost that target: 23 cases failed on the release tip for one cause. Replace it with claude-sonnet-4-6, the successor the lifecycle record names and the id chat-pipeline.test.ts already uses. Fixture model id only; no assertion changed, no production code touched.

* docs(changelog): fragment for the retired-fixture cleanup (#13056)
2026-09-18 11:30:19 -03:00
Tony Yu
3d3f71f514 fix(oauth): read pollToken body once on non-JSON upstream responses (kimi-coding, github) (#13046)
* fix(oauth): read pollToken body once on non-JSON upstream responses

The device-flow pollToken handlers for kimi-coding and github tried
response.json() first and fell back to response.text() in the catch.
Once .json() rejects on a non-JSON body the stream is already consumed,
so the .text() fallback always throws TypeError (Body is unusable) and
pollToken rejects, surfacing as a generic 500 on /api/oauth/<provider>/poll
instead of the intended graceful { error: "invalid_response" } payload.
Non-JSON responses are realistic when the OAuth upstream sits behind a
CDN/anti-bot HTML error page or a proxy interstitial (auth.kimi.com in
particular).

Read the body once as text, then JSON.parse it, preserving the original
invalid_response fallback. Adds a regression test that drives both
providers with a stubbed fetch returning an HTML error page and a JSON
error body. Prunes the two now-unused no-unused-vars suppressions for the
removed catch bindings.

* docs(changelog): fragment for #13046
2026-09-18 11:30:09 -03:00
ZaimMarzuki
bb198df737 fix(analytics): resolve account email/name in Utilization chart and fix tooltip stacking context (#13029)
Co-authored-by: ZaimMarzuki <ZaimMarzuki@users.noreply.github.com>
2026-09-18 11:30:01 -03:00
Felipe Fidelix
1533f16ed8 fix(claude): forward client-negotiated thinking-binding-controls and thinking-display-updates betas (#12989)
* fix(claude): forward client-negotiated thinking-binding-controls and thinking-display-updates betas

Gateways drops anthropic-beta tokens not on FORWARDABLE_CLIENT_BETAS, so
@ai-sdk/anthropic Fable 5.1 requests carrying thinking.block_binding were
rejected upstream with thinking.adaptive.block_binding: Extra inputs are
not permitted even when the client negotiated
thinking-binding-controls-2026-08-01 correctly. Same class for
thinking.display updates (thinking-display-updates-2026-08-18).

Regression guard: tests/unit/thinking-binding-controls-beta-forward.test.ts

* chore(changelog): fragment for #12989

* chore: trim comments
2026-09-18 11:29:54 -03:00
Amirreza Kimiyaei
a5d603ebc9 fix(soniox): pass client parameters through and surface speaker diarization (#12948)
handleSonioxTranscription took no formData, built the job body from a fixed
three-key object, and reduced the transcript to { text }. Every client-supplied
parameter was therefore dropped in silence: the request returned 200, the flag
had no effect, and from the caller's side an unsupported parameter and a dropped
one looked identical. Diarization and Soniox's `context` were both unreachable,
and the per-token `speaker` attribution Soniox returns was discarded.

The handler now receives the form data (as the Deepgram one already did) and
maps what the caller asked for onto the job: diarization under the three
spellings callers reach for, `context` verbatim, and `language` as a Soniox
language hint. Keys are added only when requested, so a request carrying no
options produces byte-identical job bodies and the existing
audio-soniox-provider deep-equality assertions still hold.

Response shape stays `{ text }` by default. When diarization or
response_format=verbose_json is requested, the token stream is collapsed into
contiguous single-speaker runs and returned as OpenAI-style `segments` carrying
`speaker`, with `words` when word granularity is asked for.

Verified against a live Soniox account on a real two-party Persian phone call:
default response unchanged, 13 segments over 2 distinct speakers with turn
boundaries matching the dialogue, and `context` correcting a proper noun the
model otherwise gets wrong.

Closes #12947

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
2026-09-18 11:29:46 -03:00
Thiago Mafra
7a18f344b3 fix(translator): stop double-counting cached tokens in Gemini to Claude usage (#12863)
* fix(translator): stop double-counting cached tokens in Gemini→Claude usage

* docs(changelog): add fragment for gemini-to-claude cached input tokens fix
2026-09-18 11:29:38 -03:00
Dominatorrr
e20f5f34ea fix(cursor): preserve native Claude effort model IDs before executor dispatch (#12838) 2026-09-18 11:29:31 -03:00
KeelTrace
3e7764e57d fix(combo): fall through on pinned 401 responses (#12818)
* fix(combo): fall through on pinned 401 responses

* test(combo): cover pinned 401 in existing fallback regression

---------

Co-authored-by: Hermes Freebrain <freebrain@localhost>
2026-09-18 11:29:23 -03:00
MSiva
0e7925309d fix(translator): map Claude stop_sequences and stop to Gemini stopSequences (#12785) 2026-09-18 11:29:15 -03:00
Goni Sulaiman
0551893390 fix(dashboard): expose the Modal Base URL field in the connection modals (#12704) (#12736)
Modal is bring-your-own-deploy, so every connection needs its own app URL, and
the server-side validator already required providerSpecificData.baseUrl. The
add/edit connection form never rendered the field, so a Modal connection could
not be validated or saved at all.

Adding the id to CONFIGURABLE_BASE_URL_PROVIDERS reuses the same always-on Base
URL field as the kimi/moonshot case (#7447). The placeholder switch is folded
into a record lookup in the same commit so the function stays under the
complexity cap as ids are added; the record was checked against the switch for
every pre-existing id and only "modal" changes behaviour.

Rebased onto the current release tip, which now carries #13120's own entry in
the same set.

Co-authored-by: Goni Sulaiman <gonisulaimann@users.noreply.github.com>
2026-09-18 11:29:08 -03:00
Juri
22511bccb9 feat(providers): register gemini-3.8-flash (#12663)
* feat(providers): register gemini-3.8-flash

* chore(changelog): add fragment for gemini-3.8-flash #12638
2026-09-18 11:29:00 -03:00
Mike
82b02f6054 fix(cli): detect npm-global Claude .cmd shims under Program Files on Windows (#12565)
* fix(cli): find npm-global Claude .cmd shims under Program Files\nodejs (#12563)

Stock Node MSI installs drop claude.cmd there, but detection never listed that directory and Electron's PATH often omits it, so the dashboard reported settings_found_binary_unresolved while a normal shell could run the CLI.

* fix(cli): soft-fail npm prefix cache and enrich Windows lookup PATH (#12563)

Stop permanently caching a failed npm config get prefix as empty, and prepend npm-prefix / APPDATA\npm / nvm / Program Files dirs on Windows lookup PATH so custom installs survive Electron PATH gaps.
2026-09-18 11:28:52 -03:00
Alex Chan
190c80dd1b fix(sse): prefer cgroup PSI for chat admission (#12562)
Chat admission sampled host-wide /proc/pressure/memory, so a swapping
Docker host 503'd idle containers with resource_pressure. Prefer this
unit's cgroup memory.pressure and keep the host file as fallback.
2026-09-18 11:28:45 -03:00
Wahyu Hidayatulloh Pamungkas
d8c0182f62 fix(command-code): floor tiny muse-spark output budgets so hidden reasoning cannot consume the whole budget (#12497)
* fix(command-code): floor tiny muse-spark output budgets so hidden reasoning cannot consume the whole budget

muse-spark models routed through command-code burn the entire output budget on
hidden server-side reasoning before emitting visible content. A small caller-set
max_tokens (e.g. 64) comes back as HTTP 200 with null content (out=64, reasoning=61).
Reuse the prefix-aware MUSE_SPARK_PATTERN to detect prefixed ids (meta/muse-spark-1.2-contributor,
cmd/meta/muse-...) and floor tiny budgets to 512 in both the /provider/v1 path and
the /alpha/generate fallback. No budget is synthesized when absent; large budgets untouched.

* docs(changelog): add changelog fragment for the command-code muse-spark budget floor (#12497)
2026-09-18 11:28:36 -03:00
tanveer-arch
51227ccd85 fix(perplexity-web): preserve system contract on follow-up requests (#12443)
* fix(perplexity-web): preserve system contract on follow-up requests

* fix(perplexity-web): preserve search hint on follow-up requests
2026-09-18 11:28:28 -03:00
Kizuno18
6d0dc5a50c fix(combo): scope Claude model failures to the model, not the account (#12340)
* fix(combo): scope Claude model failures to the model, not the account

A priority combo whose steps are five models on one Claude OAuth connection
stops at step 1. hasPerModelQuota() returns false for the claude provider, so
markAccountUnavailable() records a model-specific 404 or 5xx against the
connection row, and getPersistedConnectionCooldownSkipReason() then skips every
sibling step before dispatch. Verified with a combo whose first step names a
model that cannot exist: the 404 lands and step 2 is never tried, while the
same combo works as soon as step 2 points at a different account.

A Claude OAuth connection multiplexes Fable 5, Opus 5/4.8/4.7/4.6, Sonnet and
Haiku behind one credential, which is the multiplexing hasPerModelQuota already
describes. Its quota is a separate question: a 429 on a Max subscription is
account-wide, and shouldMarkAccountExhaustedFrom429 pins that. So this adds
hasPerModelFailureScope() for the non-quota statuses and leaves 429 alone.

Combo exhaustion gets the same treatment for 404, which names one model the
account cannot serve rather than a bad connection. The empty-content 502 was
already exempt through isEmptyContentFailure.

Closes #12334

* fix(combo): extract Claude per-model failure scope to keep file-size caps

Move hasPerModelFailureScope into a leaf so frozen accountFallback.ts and
auth.ts stay at their file-size caps after #12334. Register
combo-claude-per-model-scope.test.ts in Stryker tap.testFiles so the
mutation-test-coverage gate sees the covering unit test.

Co-authored-by: diegosouzapw <8016841+diegosouzapw@users.noreply.github.com>

---------

Co-authored-by: diegosouzapw <8016841+diegosouzapw@users.noreply.github.com>
2026-09-18 11:28:20 -03:00
Bl0ck
efbcc3d489 fix(antigravity): surface image quotas in Provider Limits (#12311)
Rebase the focused image-quota visibility fix onto the current release/v3.8.51 tip while preserving the newer shared Antigravity catalog and AGY live-discovery changes.
2026-09-18 11:28:11 -03:00
brick30llc-ctrl
3a5f37013d fix(sse): classify "N API calls / month" 429 text as quota_exhausted (#12252)
* fix(sse): classify "N API calls / month" 429 text as quota_exhausted

QUOTA_PATTERNS recognized "monthly ... limit" and "per month ... limit",
but not the reversed order providers actually use in trial-key messages —
Cohere's is "You are using a Trial key, which is limited to 1000 API calls
/ month". That 429 fell through to the generic short-backoff rate_limit
path, so a monthly allowance that will not reset for the rest of the
billing cycle kept being retried every few seconds.

Broaden the pattern set to catch both phrase orders and the "N calls /
month" and "N requests/month" spellings.

Observed in production against Cohere trial keys: 481 of 752 429s in 24h
carried this text and were all treated as short transient rate limits.

* docs(changelog): add fragment for #12252

---------

Co-authored-by: brick30llc-ctrl <admin@brick30.com>
Co-authored-by: diegosouzapw <8016841+diegosouzapw@users.noreply.github.com>
2026-09-18 11:28:03 -03:00
diegosouzapw
bb85d56155 chore(quality): rebaseline codex.ts 1552->1553 (drift from #14065)
The release tip went red on check:file-size after #14065 (fix(codex): whitelist
reasoning object keys before the wire, #13643) merged with +1 line in
open-sse/executors/codex.ts and no baseline entry — the PR->release fast-gates
do not run check:file-size. Every merge-train boarding after it inherits the
red, so the drift is absorbed once at the tip (owner-approved train-rebaseline
policy, 2026-09-18). Measured clean: check:file-size passes on the tip with
this entry.
2026-09-18 11:02:25 -03:00
Bob.Hou
70e311703e ci(acceptance): emit a shadow release-acceptance report next to release-green (#13701) (#14066)
Adds a shadow release-acceptance report alongside release-green: an inventory/reduce/oracle pipeline under `scripts/quality/release-acceptance/` with a JSON schema, fixtures and a workflow that uploads the report as an artifact.

Contained by design, which is why it merges as-is: it runs only on push to `release/v*` and on manual dispatch (never on pull requests), the step is `continue-on-error`, `permissions: contents: read`, `persist-credentials: false`, and it consumes no secrets. Nothing in the product changes; the report is advisory until we decide to promote it.

Validated as a combined board first (this PR merged with the 11 siblings of the same batch on the release tip): eslint on every changed file with the suppressions file, typecheck:core, check:open-sse-typecheck, complexity, cognitive-complexity, changelog-integrity, i18n new-key coverage, docs-sync, migration-numbering, provider-consistency and a duplicate-identifier audit all green, plus 275 passing / 0 failing focused node:test cases across the 28 test files the batch touches. Then re-validated alone on the fresh tip before this merge: conflicts re-resolved, file sizes rebaselined for this PR's own growth, eslint and this PR's focused tests re-run.

Thanks @HouMinXi!

Co-authored-by: Diego Rodrigues de Sa e Souza <diegosouza.pw@gmail.com>
2026-09-18 10:21:59 -03:00
Bob.Hou
de428cef86 fix(codex): whitelist reasoning object keys before the wire (#13643) (#14065)
The Codex executor now whitelists the wire `reasoning` object to `effort`/`summary` before dispatch instead of spreading whatever the client sent, and maps `reasoning.enabled === false` to `effort: "none"` when no more specific effort was requested. OpenRouter-style keys (`enabled`, `max_tokens`, `exclude`) were reaching the Responses API and 400-ing the whole combo target with `Unknown parameter: 'reasoning.<key>'`.

The precedence chain keeps an explicit per-request effort ahead of `enabled: false`, and the strip matches the siblings already removed in the same function (`truncation`, `user`, `prompt_cache_retention`).

Validated as a combined board first (this PR merged with the 11 siblings of the same batch on the release tip): eslint on every changed file with the suppressions file, typecheck:core, check:open-sse-typecheck, complexity, cognitive-complexity, changelog-integrity, i18n new-key coverage, docs-sync, migration-numbering, provider-consistency and a duplicate-identifier audit all green, plus 275 passing / 0 failing focused node:test cases across the 28 test files the batch touches. Then re-validated alone on the fresh tip before this merge: conflicts re-resolved, file sizes rebaselined for this PR's own growth, eslint and this PR's focused tests re-run.

Thanks @HouMinXi!

Co-authored-by: Diego Rodrigues de Sa e Souza <diegosouza.pw@gmail.com>
2026-09-18 10:21:44 -03:00
Bob.Hou
815dedd3c9 fix(resilience): extend process crash guard to combo hedge cancels and upstream fetch failures (#13636) (#14064)
Closes a real process-killer: the direct-response start timeout could fire after the fetch promise had already settled, and aborting at that point delivered the abort reason to a promise nobody was awaiting — Node promotes that to an `unhandledRejection` → `uncaughtException` and the process dies (#12861). The timer is now a no-op once the attempt has settled, and the same guard is extended to combo hedge cancels and upstream fetch failures.

Validated as a combined board first (this PR merged with the 11 siblings of the same batch on the release tip): eslint on every changed file with the suppressions file, typecheck:core, check:open-sse-typecheck, complexity, cognitive-complexity, changelog-integrity, i18n new-key coverage, docs-sync, migration-numbering, provider-consistency and a duplicate-identifier audit all green, plus 275 passing / 0 failing focused node:test cases across the 28 test files the batch touches. Then re-validated alone on the fresh tip before this merge: conflicts re-resolved, file sizes rebaselined for this PR's own growth, eslint and this PR's focused tests re-run.

Thanks @HouMinXi!

Co-authored-by: Diego Rodrigues de Sa e Souza <diegosouza.pw@gmail.com>
2026-09-18 10:21:32 -03:00
Bob.Hou
9b92477b40 fix(providers): declare Agnes official thinking effort tiers (#13655) (#14063)
Declares the accepted thinking-effort tiers per Agnes chat model (2.0/2.5: none/low/medium/high/max; 3.0 adds minimal/xhigh), so the generic declared-tier clamp maps `xhigh`/`off` onto values the upstream accepts instead of forwarding them verbatim and collecting a 400.

Validated as a combined board first (this PR merged with the 11 siblings of the same batch on the release tip): eslint on every changed file with the suppressions file, typecheck:core, check:open-sse-typecheck, complexity, cognitive-complexity, changelog-integrity, i18n new-key coverage, docs-sync, migration-numbering, provider-consistency and a duplicate-identifier audit all green, plus 275 passing / 0 failing focused node:test cases across the 28 test files the batch touches. Then re-validated alone on the fresh tip before this merge: conflicts re-resolved, file sizes rebaselined for this PR's own growth, eslint and this PR's focused tests re-run.

Thanks @HouMinXi!

Co-authored-by: Diego Rodrigues de Sa e Souza <diegosouza.pw@gmail.com>
2026-09-18 10:21:17 -03:00
Bob.Hou
9956f13b35 fix(db): never TRUNCATE-checkpoint a live WAL (SIGBUS under traffic) (#14005)
* fix(db): never TRUNCATE-checkpoint a live WAL

A live TRUNCATE checkpoint rewrites the shared wal-index while other
processes hold it mapped; dereferencing the stale mapping SIGBUSes the
process. Two production crashes six hours apart, coredump stack in
better-sqlite3 native memcpy (issue #13973).

Remove the periodic TRUNCATE scheduler. Runtime checkpoints are
PASSIVE-only, which move pages without changing the wal-index geometry,
while TRUNCATE stays on the shutdown path where reclaiming the file is
safe. The 256MB size guard now warns instead of escalating to a live
TRUNCATE, busy PASSIVE ticks feed the persisted busy telemetry that the
TRUNCATE tick used to carry, and a positive
OMNIROUTE_WAL_TRUNCATE_INTERVAL_MS logs a one-time deprecation warning.

Signed-off-by: Minxi Hou <houminxi@gmail.com>

* fix(db): RESTART the WAL when it exceeds the size guard

The 256MB size guard only warned, so a live WAL could keep growing until
the next restart. wal_checkpoint(RESTART) starts a new WAL file without
rewriting the mapped wal-index, which is what SIGBUS'd the process when
we used TRUNCATE under traffic.

Related to #13973.

Signed-off-by: Minxi Hou <houminxi@gmail.com>

* docs: drop a fake TRUNCATE env name from the WAL guard row

Backticks around TRUNCATE made the env/docs checker treat it as a
variable. The VACUUM rows next to it were never part of this change
and are not in the base docs.

Signed-off-by: Minxi Hou <houminxi@gmail.com>

---------

Signed-off-by: Minxi Hou <houminxi@gmail.com>
Co-authored-by: Diego Rodrigues de Sa e Souza <diegosouza.pw@gmail.com>
2026-09-18 10:21:03 -03:00
Diego Rodrigues de Sa e Souza
7cc454d931 fix(compression): repair the unit-test base-reds left by the 09-17 merge wave (#14082)
Every PR against release/v3.8.51 is born red on all four Unit Tests fast-path
shards. Reproduced on the pure tip (1603c86e): ~35 tests. This commit repairs
the two large clusters plus the stale assertions sharing their root cause
(17 tests); the rest is tracked separately.

RTK (12 tests, real regression): #13521 gated dedup on
  skipFilters || isDocumentLikeRead
but isDocumentLikeRead is true for ANY text whose type detection is unknown —
not only non-shell tool results — so plain repeated tool output (the classic
RTK case) stopped being deduplicated, processRtkText returned no stats, and
rtkEngine.apply().stats.engine came back undefined. The intent of #13388 was
the non-shell-tool skip, which resolveToolMeta already expresses as
skipFilters; dedup now honours only that. The #13521 regression guard
(rtk-file-content-preservation, tool named 'read' → skipFilters) still passes
4/4; rtk-engine is back to 9/9.

Golden + stale assertions (5 tests, product changes never propagated):
- tests/snapshots/provider/translate-path.json: xKiro (#12648) was added to the
  registry without regenerating the snapshot — purely additive entry.
- providers-constants-split: APIKEY_PROVIDER_COUNT 241 → 242 (xKiro).
- tests/snapshots/g13/combo-chatcore-public-seams.json and the three
  Content-Type assertions in chatcore-translation-paths / chat-route-coverage:
  #13419 deliberately made streaming responses declare
  'text/event-stream; charset=utf-8' (Arabic/Persian mojibake) and updated the
  integration test but not these unit assertions.

Before/after on the 26 base-red files: 266 tests, 23 → 18 failing here
(the RTK suites were counted per-file in CI; per-test this is 17 fixed). The
file-size ✗ on chatcore-translation-paths.test.ts is the pre-existing drift
#14016 rebaselines — this commit keeps that file's line count unchanged.
2026-09-18 08:13:49 -03:00
Diego Rodrigues de Sa e Souza
24fc202d9f fix(ci): repair the API Route Typecheck base-red blocking every PR (#14079)
The gate fails on the pure release/v3.8.51 tip (3 files above the frozen
baseline), so every PR against the release is born red on it. None of the
three is a PR defect — they are drift from merged work:

- src/lib/usage/glmResetCards.ts: entered the gate's scope when #12754 added
  the route that imports it. runWithProxyContext is an untyped async helper
  (Promise<any>), so runWithConnectionFetch<T> could not return T. Every call
  site passes an async callback and awaits it — declare that contract:
  fn: () => Promise<T> → Promise<T>.
- src/sse/handlers/chat.ts: handleSingleModelChat had no return annotation, so
  runWithTransientBackendRetry<T extends ResponseLike> fell back to the
  constraint and the value could no longer feed withSessionHeader(Response).
  Annotated Promise<Response> (every return path builds a Response).
- src/app/api/internal/codex-responses-ws/route.ts: the bridge helpers return
  either { error: Response } or a payload, but as unannotated object-literal
  unions TypeScript synthesised error?: undefined on the success member, and
  every "error" in x guard stopped narrowing (TS2339 x5 on the destructure).
  Explicit return types keep the discriminant real; the ApiKeyMetadata alias now
  points at the policy shape (the wider one both sources are assignable to),
  which clears the TS2740 self-mismatch; logger.warn → log.warn (the module
  logger factory has no .warn).

check-api-typecheck.mjs: OK — 283 errors, baseline ratcheted DOWN from 294
(codex-responses-ws 7→4 TS2339, TS2740 1→0; combos/test and keys/[id] 1→0).
typecheck:core clean; 43/43 unit tests in the touched areas green.
2026-09-18 08:13:43 -03:00
Diego Rodrigues de Sa e Souza
1b484c57a0 chore(quality): rebaseline the last ceiling the 09-17 merge wave moved (#14016)
tests/unit/chatcore-translation-paths.test.ts 3447 -> 3449, from #13173 (Fable
mid-conversation cache prefixes — the new assertions for that case).

This is the only one left. The other two this PR originally carried
(chatHelpers.ts and chatCore.ts) were absorbed by the rebaselines the merging
PRs brought with them, so the branch was rebuilt on the current tip rather than
shipping stale numbers. Measured clean: check:file-size and test-file-size both
pass with no violations.
2026-09-18 08:13:38 -03:00
Diego Rodrigues de Sa e Souza
94aa978c2a fix(ci): document OMNIROUTE_STRIP_SYSTEM_PREAMBLE — the env/docs base red blocking every PR (#14022)
* fix(ci): document OMNIROUTE_STRIP_SYSTEM_PREAMBLE (env/docs contract base-red)

* fix(ci): allowlist COMBO_LOOP_SAFETY_TIMEOUT_MS as a doc-only source constant

The env/docs contract gate had a SECOND violation on the release tip, added after
this branch was cut: #13857's comboTimeoutMs narrative in ENVIRONMENT.md cites
COMBO_LOOP_SAFETY_TIMEOUT_MS, which is a source constant
(open-sse/services/combo/comboPredicates.ts:35 — `10 * 60 * 1000`), not an
operator-facing env var. The doc regex captured the SHOUTY_NAME and reported it
as documented-but-missing-from-.env.example.

DOC_ONLY_ALLOWLIST already exists for exactly this class (see CLI_COMPAT_OMITTED_PROVIDER_IDS,
LOCAL_ONLY_API_PREFIXES, VACUUM). Gate now reports all three directions in sync.
2026-09-18 08:13:32 -03:00
Diego Rodrigues de Sa e Souza
b45e0a4b59 fix(i18n): review the 64 non-pt-BR dashboard catalogs for translation quality (#14078)
* fix(i18n): review-locale reviews every leaf of a catalog that did not exist at --since

* fix(i18n): review the 64 non-pt-BR dashboard catalogs for translation quality

Runs scripts/i18n/review-locale.mjs over every locale except pt-BR (done in
#13885): 75,263 corrections applied against the English source, 1,677 of
them reverted because the "correction" replaced a real translation with the
plain English term (the real-translation ratio gate counts those as
untranslated). zh-CN/zh-TW provider term normalised after the run.

review-locale.mjs hardening found by the run: per-batch retries with backoff
(a skipped batch is listed, not fatal), catalog checkpoint every 25 batches,
and setDeep resolving leaf keys that contain a dot.
2026-09-18 07:09:03 -03:00
anhtahaylove
1603c86e06 test: realign two stale assertions with product behavior (#13313) (#13315)
* test: expect the jina alias prefix in the custom-model catalog case

The custom-model assertions expected ids prefixed `jina-ai/`, but the catalog
prefixes model ids with the provider alias, which is `jina`. The synced-model
test directly above asserts `jina/` and passes, so the two cases contradicted
each other within the same file.

The expectation predates the alias: the test was written in v3.7.9 (2026-05-04)
and `alias: "jina"` was added in v3.8.36 (2026-06-25).

Aligns the two assertions with the sibling test and with the product. The file
now passes 44/44 (was 43 with 1 failure).

Confirmed the assertions still bite: renaming the alias to `jina-XX` fails
exactly these two cases.

* test: pin the pt-BR pack in the two language-pack fixtures

Both tests assert the Portuguese output-style string but configured
languageConfig with enabled:false and defaultLanguage:"en".
resolveOutputStyleLanguage returns "en" on its first line when enabled is not
true, so the English pack was injected and the assertion could never hold.

autoDetect:true would not have helped either: the user turns in these fixtures
are English, so the detector resolves back to "en". The pack under test has to
be pinned, hence autoDetect:false with an explicit defaultLanguage.

This restores coverage rather than just turning the suite green. Mutating the
pt-BR pack string in outputMode.ts now fails exactly these two tests; with the
old fixture the file reported 9 pass / 2 fail whether the pack was intact or
mutated, so it detected nothing. File is 11/11 (was 9 + 2 failures).

The third languageConfig fixture in this file belongs to an rtk test that makes
no language assertion and is left untouched.

* test: keep the canonical jina-ai prefix in the catalog case

Reverts 8d371d4. The catalog file holds two Jina cases that are not the
same scenario: the custom-model/alias case legitimately expects the
`jina/` alias prefix, while the specialty-model case expects the
canonical provider id `jina-ai/`. Aligning the second to the first reads
like a fix but changes a passing assertion into a failing one.

Verified against the current release tip by mutation, both directions:
the specialty case passes as `jina-ai/...` and fails as `jina/...`, with
the runner reporting `actual: 'jina-ai/jina-embeddings-v5-text-small'`.
The canonical ids are what the source declares — `embeddingRegistry.ts`
and `rerankRegistry.ts` both key the provider as `jina-ai`, as does
EMBEDDING_RERANK_PROVIDER_IDS in src/shared/constants/providers.ts.

The pt-BR language-pack commit on this branch is untouched: that one is a
real fix and repairs two genuinely failing assertions.

---------

Co-authored-by: diegosouzapw <8016841+diegosouzapw@users.noreply.github.com>
2026-09-17 22:13:39 -03:00
11865 changed files with 2748614 additions and 165602 deletions

View File

@@ -173,6 +173,11 @@ PORT=20128
# stay consistent without relying on window.location.origin alone:
# NEXT_PUBLIC_BASE_URL=https://host/omniroute
#
# Client-side fallback port for display URLs when no origin is known (SSR/tests):
# read before PORT so a browser bundle built with a different public port still
# renders the right http://localhost:<port> links (src/shared/hooks/useDisplayBaseUrl.ts).
# NEXT_PUBLIC_PORT=20128
#
# Explicit path probed by the container health check. Unset, the probe derives it
# from OMNIROUTE_BASE_PATH; setting it opts back into the deep monitoring endpoint.
# Used by: scripts/dev/healthcheck.mjs
@@ -288,9 +293,9 @@ OMNIROUTE_USE_TURBOPACK=1
# OMNIROUTE_SKIP_DB_HEALTHCHECK=1
# Interval (ms) for the background credential health check scheduler.
# Default: 300000 (5 minutes). Minimum: 10000 (10 seconds).
# Default: 3600000 (60 minutes). Minimum: 10000 (10 seconds).
# Used by: open-sse/config/constants.ts, src/lib/credentialHealth/scheduler.ts
# CREDENTIAL_HEALTH_CHECK_INTERVAL=300000
# CREDENTIAL_HEALTH_CHECK_INTERVAL=3600000
# TTL (ms) for cached credential health status.
# Default: 300000 (5 minutes).
@@ -541,6 +546,48 @@ ALLOW_API_KEY_REVEAL=false
# When unset, OmniRoute uses the per-feature defaults. Set to "false"/"0" to disable.
# OUTBOUND_SSRF_GUARD_ENABLED=true
# ── Self-hosted unified OpenAI-compatible entry (RIC-738, D4) ────────────────────
# When set, /v1/chat/completions diverts to the self-hosted provider adapters
# (open-sse/services/selfHostedEntry.ts) instead of the cloud pipeline. YAML inline
# (example) — or point OMNIROUTE_SELF_HOSTED_PROVIDERS_FILE at a YAML file. Secrets
# are runtime-only, never logged. While ANY of these is set, the entry is active;
# config present but unparseable returns a 500 (never silently falls through).
# OMNIROUTE_SELF_HOSTED_PROVIDERS='
# providers:
# - id: local
# kind: openai
# baseUrl: http://127.0.0.1:11434/v1
# model: llama3
# - id: claude
# kind: anthropic
# baseUrl: http://127.0.0.1:8080
# model: claude-sonnet
# '
# OMNIROUTE_SELF_HOSTED_PROVIDERS_FILE=/etc/omniroute/providers.yaml
# Optional shared API key for the unified entry (D5 reserved). When set, require
# `Authorization: Bearer <key>`; empty = open loopback/trusted-network route.
# OMNIROUTE_SELF_HOSTED_API_KEY=
# ── Deterministic routing strategies (M2 / RIC-740, D3 可审计路由) ─────────────
# Optional `strategy:` block — either inline in the providers document above, or a
# standalone document via these env vars. One rule per line; every decision is
# explainable via the `x-omniroute-route-decision` response header. No ML/predict.
# Malformed strategy config returns a 500 (never silently becomes a no-op).
# Example (inline, same shape as `strategy:` inside the providers YAML):
# OMNIROUTE_SELF_HOSTED_STRATEGY='
# blacklist: []
# whitelist: [cheap, fast, premium]
# costPriority: true
# latencyAware:
# enabled: true
# cooldown:
# consecutiveFailures: 2
# cooldownMs: 30000
# fallbackChain: [cheap, fast, premium]
# '
# OMNIROUTE_SELF_HOSTED_STRATEGY_FILE=/etc/omniroute/strategy.yaml
# See docs/routing/DETERMINISTIC_ROUTING.md for the full strategy surface.
# ═══════════════════════════════════════════════════════════════════════════════
# 5. INPUT SANITIZATION & PII PROTECTION (FASE-01)
# ═══════════════════════════════════════════════════════════════════════════════
@@ -721,14 +768,26 @@ NEXT_PUBLIC_CLOUD_URL=
# OpenCode Go/Zen VPS egress (#5997): on a datacenter VPS, Cloudflare in front of
# opencode.ai/zen/go 403s chat requests that lack OpenCode CLI identity headers.
# When your clients don't already send them, set this to synthesize the CLI headers
# (User-Agent, x-opencode-client, x-opencode-project, fresh request/session UUIDs) on
# absent keys. OFF by default — forward-only is safer when clients already send them.
# (User-Agent, x-opencode-client, x-opencode-project, canonical request/session ids) on
# absent keys. ON by default — a client value always wins, these only fill gaps.
# Values are overridable via OPENCODE_GO_USER_AGENT / OPENCODE_USER_AGENT / OPENCODE_CLIENT /
# OPENCODE_PROJECT (defaults: opencode-cli/1.0.0 / cli / default).
# OPENCODE_PROJECT (defaults: opencode/1.18.31 / desktop / global).
#OPENCODE_SYNTHESIZE_CLI_HEADERS=true
#OPENCODE_USER_AGENT=opencode-cli/1.0.0
#OPENCODE_CLIENT=cli
#OPENCODE_PROJECT=default
#OPENCODE_USER_AGENT=opencode/1.18.31
#OPENCODE_CLIENT=desktop
#OPENCODE_PROJECT=global
# Keyless OpenCode models are answered only when the request declares a non-empty tool
# list, and the upstream inspects which names it carries. OmniRoute reuses the list a
# request of the same conversation was last seen getting through, so a request that
# carries none — a title or a summary — goes out with the list its own client already
# declared. Set to off to stop adjusting request bodies entirely; headers are unaffected.
#OPENCODE_FREE_TIER_REQUEST_CONTRACT=off
# Tool names to declare when nothing has been observed yet for a model, comma-separated.
# Empty falls back to a single placeholder the model is told not to call. Only useful on
# an install where no client sends tools, since there is then nothing to learn from.
#OPENCODE_FREE_TIER_PLACEHOLDER_TOOLS=glob,grep,read
# Ollama Cloud quota scraping. Prefer configuring this per connection in
# Dashboard → Providers → Ollama Cloud. The cookie is sensitive.
@@ -1163,15 +1222,18 @@ PROVIDER_LIMITS_SYNC_SPACING_MS=1500
# Used by: src/lib/db/core.ts::getDbHealthCheckIntervalMs().
#OMNIROUTE_DB_HEALTHCHECK_INTERVAL_MS=21600000
# WAL truncate cadence override (ms). Set to 0 to disable. Default: 21600000 (6h).
# Used by: src/lib/db/core.ts::getWalTruncateIntervalMs().
# Removed: periodic live wal_checkpoint(TRUNCATE) could SIGBUS the process (issue
# #13973). The variable is inert: a positive value logs a one-time deprecation warning,
# while 0 or unset stays silent. The WAL is kept small
# by the PASSIVE scheduler below and truncated by the shutdown checkpoint.
#OMNIROUTE_WAL_TRUNCATE_INTERVAL_MS=21600000
# Frequent wal_checkpoint(PASSIVE) cadence (ms). Set to 0 to disable. Default: 300000 (5m).
# Used by: src/lib/db/walMaintenance.ts.
#OMNIROUTE_WAL_PASSIVE_INTERVAL_MS=300000
# WAL size (MB) above which a PASSIVE tick escalates to wal_checkpoint(TRUNCATE). Default: 256.
# WAL size (MB) above which a PASSIVE tick runs wal_checkpoint(RESTART) so the
# WAL starts over without rewriting the mapped wal-index. Default: 256.
# Used by: src/lib/db/walMaintenance.ts.
#OMNIROUTE_WAL_GUARD_MAX_MB=256
@@ -1264,6 +1326,11 @@ CODEX_OAUTH_CLIENT_ID=app_EMoamEEZ73f0CkXaXp7hrann
# ── Kimi Coding (Moonshot) ──
KIMI_CODING_OAUTH_CLIENT_ID=17e5f671-d194-4dfb-9706-5516cb48c098
# ── Muse Code (Meta) ──
# Public device-flow client id is baked into open-sse/utils/publicCreds.ts (muse_id).
# Set this only to override the Muse CLI client. Do not put the public id here.
# MUSE_CODE_OAUTH_CLIENT_ID=
# ── GitHub Copilot ──
GITHUB_OAUTH_CLIENT_ID=Iv1.b507a08c87ecfe98
@@ -1347,6 +1414,10 @@ GITHUB_OAUTH_CLIENT_ID=Iv1.b507a08c87ecfe98
# VISION_BRIDGE_BASE_URL=
# VISION_BRIDGE_API_KEY=
# How long a "no usable vision candidate" outcome is remembered, in ms.
# Invalid or negative values fall back to the default; 0 disables the negative cache.
# OMNIROUTE_VISION_BRIDGE_NEGATIVE_CACHE_MS=30000
# ─────────────────────────────────────────────────────────────────────────────
# ⚠️ GOOGLE OAUTH (Antigravity) & OTHER PROVIDERS — REMOTE SERVERS
# ─────────────────────────────────────────────────────────────────────────────
@@ -1386,7 +1457,7 @@ CLAUDE_USER_AGENT="claude-cli/2.1.258 (external, cli)"
# forward the original names verbatim (debugging only).
# CLAUDE_DISABLE_TOOL_NAME_CLOAK=false
# Optional override; leave unset to follow the shared Codex client version.
# CODEX_USER_AGENT="codex-cli/0.153.4 (Windows 10.0.26200; x64)"
# CODEX_USER_AGENT="codex-cli/0.155.0 (Windows 10.0.26200; x64)"
GITHUB_USER_AGENT="GitHubCopilotChat/0.54.0"
ANTIGRAVITY_USER_AGENT="antigravity/2.0.1 linux/arm64 google-api-nodejs-client/10.3.0"
KIRO_USER_AGENT="AWS-SDK-JS/3.0.0 kiro-ide/1.0.0"
@@ -1406,7 +1477,7 @@ CURSOR_USER_AGENT="Cursor/3.4"
# Override Codex client version sent in headers independently of the
# CODEX_USER_AGENT string. Used by: open-sse/config/codexClient.ts.
# CODEX_CLIENT_VERSION=0.153.4
# CODEX_CLIENT_VERSION=0.155.0
#
# Override the advertised Claude Code client version independently of
# CLAUDE_USER_AGENT. Anthropic gates some models (Fable 5.1) on this
@@ -1417,6 +1488,13 @@ CURSOR_USER_AGENT="Cursor/3.4"
# Override the advertised GitHub Copilot CLI version independently of
# GITHUB_USER_AGENT. Used by: open-sse/config/providerHeaderProfiles.ts.
# GITHUB_COPILOT_CLI_VERSION=1.0.82
#
# Pin the `copilot-integration-id` header sent to standard GitHub Copilot,
# overriding the default copilot-developer-cli identity (and disabling the
# automatic 403-identity fallback to copilot-chat). Set this only if your
# Copilot account/org requires a specific integration id. Used by:
# open-sse/config/providerHeaderProfiles.ts, open-sse/executors/copilotIdentityFallback.ts.
# COPILOT_INTEGRATION_ID=copilot-chat
# Kill-switch to strip non-standard `codex.*` SSE events (e.g. codex.rate_limits)
# from the Codex Responses stream. These frames break the OpenAI SDK's
@@ -1545,6 +1623,18 @@ CURSOR_USER_AGENT="Cursor/3.4"
# # caller's deadline; on expiry the request retries
# # once on a fresh no-keep-alive socket. 0 disables
# # the bound (default: 30000 = 30s).
# OMNIROUTE_DIRECT_RESPONSE_RETRY_TIMEOUT_MS=600000 # Ceiling (ms) for the fresh-socket
# # RETRY attempt above (#13703). Only applies when
# # the caller already attached its own deadline
# # signal (the resolved connection/model/provider/
# # FETCH_TIMEOUT_MS cascade) — that signal is the
# # real bound and fires first in the intended path,
# # so this is a generous backstop rather than a flat
# # cap: without it the retry reused the same short
# # OMNIROUTE_DIRECT_HEADERS_TIMEOUT_MS window as the
# # pooled attempt and 504'd healthy slow-TTFB
# # reasoning models. Never allowed below the flat
# # floor above (default: 600000 = 10 min).
# Default timeout (ms) for src/shared/utils/fetchTimeout.ts. Acts as the
# fallback when FETCH_TIMEOUT_MS is unset. Default: 120000 (2 min).
@@ -1725,7 +1815,10 @@ CURSOR_USER_AGENT="Cursor/3.4"
# TLS_CLIENT_TIMEOUT_MS=600000 # Inherits from FETCH_TIMEOUT_MS by default
# TLS_FIRST_BYTE_WATCHDOG_MS=10000 # #12656: bounds time-to-first-byte on the wreq body (0 disables)
# OPENCODE_RESPONSES_STALL_ROTATION=false # #13484 feature flag (Settings → Feature Flags wins): rotate once when a streamed Responses reply stalls before its first byte
# OPENCODE_PARK_AND_RESUME=false # #13924 feature flag (Settings → Feature Flags wins): park the request with a heartbeat after repeated transient 429s, then replay one capped leg of up to 3 accounts
#OPENCODE_POOL_STRAIN_MARKER_PATH=/tmp/opencode-pool-strain.json # #13924: pool-strain marker path (JSON {since, reason, ttl_s}); fresh marker parks without recounting
# RESPONSES_FIRST_BYTE_TIMEOUT_MS=15000 # #13484: OpenCode Responses first-byte window, only used when the OPENCODE_RESPONSES_STALL_ROTATION flag is on (0 disables)
# FLUSH_EMPTY_RETRY_ENABLED=false # #14213 feature flag (Settings → Feature Flags wins): retry empty translated streaming turns through the normal credential path (up to STREAM_RECOVERY.EMPTY_TURN_RETRY_MAX retries)
# ── API Bridge (/v1 proxy server) ──
# API_BRIDGE_PROXY_TIMEOUT_MS=600000 # Proxy hop timeout (default: 10min)
@@ -1814,8 +1907,8 @@ APP_LOG_TO_FILE=true
# bodies is retained in the database.
# Used by: open-sse/handlers/chatCore.ts — cloneBoundedChatLogPayload()
# CHAT_LOG_TEXT_LIMIT=65536 # Max string length before truncation (default: 64 KB)
# CHAT_LOG_ARRAY_TAIL_ITEMS=128 # Number of array items retained from tail (default: 128)
# CHAT_LOG_MAX_DEPTH=6 # Max nesting depth before truncation (default: 6)
# CHAT_LOG_ARRAY_TAIL_ITEMS=1000 # Number of array items retained from tail (default: 1000)
# CHAT_LOG_MAX_DEPTH=20 # Max nesting depth before truncation (default: 20)
# CHAT_LOG_MAX_OBJECT_KEYS=80 # Max object keys retained (default: 80, 0 = no limit)
# CHAT_LOG_MAX_BODY_KB=1024 # Whole request/response body size before it's replaced by a bare
# {_truncated, messageCount, ...} summary instead of the full clone
@@ -2189,6 +2282,13 @@ APP_LOG_TO_FILE=true
# Used by: src/lib/services/bootstrap.ts, src/app/api/services/mux/_lib.ts
# MUX_SERVICE_PORT=8322
# ── open-wa embedded service ──
# Override the port where the embedded open-wa (WhatsApp Web automation)
# daemon listens. Always bound to 127.0.0.1 — never configurable to 0.0.0.0.
# Rarely needed — defaults to 8323.
# Used by: src/lib/services/bootstrap.ts
# OPENWA_SERVICE_PORT=8323
# ── Dario embedded service ──
# Override the host/port the embedded Dario (Claude Code subscription proxy)
# daemon binds to and is reached at. Always bound to 127.0.0.1 — never
@@ -2242,6 +2342,11 @@ APP_LOG_TO_FILE=true
# PROXY_HEALTH_ENABLED=true
# Sweep interval in ms (minimum 60000). Default: 600000 (10min).
# PROXY_HEALTH_INTERVAL_MS=600000
# Background recovery-pass interval in ms: how often the scheduler re-probes proxies it
# previously marked unhealthy, so a proxy that comes back is picked up without a restart.
# Values below 60000 fall back to the default.
# PROXY_HEALTH_RECOVERY_INTERVAL_MS=600000
# Reachability probe target for the scheduler and the auto-test endpoint.
# Point it at an internal/self-hosted URL to avoid the public default.
# PROXY_HEALTH_TEST_URL=https://httpbin.org/ip
@@ -2407,6 +2512,15 @@ APP_LOG_TO_FILE=true
# Used by: open-sse/translator/request/claude-to-openai.ts, open-sse/translator/response/openai-to-claude.ts.
# OMNIROUTE_SYSTEM_INSTRUCTION_APPEND=
# Set to "1" to also strip echoed system-prompt PREAMBLE blocks
# (<analysis>/<system-reminder>/<summary> blocks, prose reproductions of the skill
# section) from the start of an openai->claude stream. OFF by default: it recognises
# constructs by English-prose heuristics and DOES mutate the response payload, so a
# reply that genuinely opens with such a section would lose it. Turn it on only when
# you actually hit the system-echo leak.
# Used by: open-sse/translator/response/openai-to-claude.ts, open-sse/utils/directivePreambleStripper.ts.
# OMNIROUTE_STRIP_SYSTEM_PREAMBLE=0
# Per-image fetch timeout (ms) for remote image_url vision input. Default: 15000.
# Used by: open-sse/utils/cursorImages.ts.
# CURSOR_IMAGE_FETCH_TIMEOUT_MS=15000
@@ -2603,6 +2717,16 @@ APP_LOG_TO_FILE=true
# for root-less / user-namespaced deployments (e.g. rootless Docker/Podman)
# where the operator trusts the CA manually (e.g. via Node's extra-CA-certs mechanism).
# OMNIROUTE_NO_SUDO=0
# ── Antigravity MITM bridge (bin/antigravity-bridge.mjs) ──
# Local HTTPS listener that fronts the Antigravity IDE and forwards to the router.
# BRIDGE_PORT: port the bridge listens on. Defaults to 20129.
# ROUTER_URL: where it forwards /v1/antigravity traffic. Defaults to the local router.
# CERT_DIR: directory holding server.key/server.crt for the bridge's TLS listener.
# Defaults to ~/.omniroute/mitm (the MITM CA directory).
# BRIDGE_PORT=20129
# ROUTER_URL=http://127.0.0.1:20128/v1/antigravity
# CERT_DIR=~/.omniroute/mitm
# Explicit opt-out: skip provisioning /etc/hosts DNS entries for the Antigravity
# proxy hostnames entirely (containers with no sudo/root available).
# Used by: src/mitm/dns/provision.ts.
@@ -2639,6 +2763,16 @@ APP_LOG_TO_FILE=true
# When enabled, the node authenticates with the API key stored on its connection.
# AUDIO_REMOTE_PROVIDER_NODES=false
# Used by: src/app/api/v1/_shared/rerankProviderNodes.ts — lets POST /v1/rerank (and
# the memory engine's loopback rerank step) use an OpenAI-compatible provider node
# hosted outside localhost, e.g. a LAN box or Tailscale peer running TEI/Infinity/vLLM.
# OFF by default: routing to a remote host changes egress identity, so it must be an
# explicit operator decision. Loopback/private nodes (localhost, 127.0.0.1,
# 172.16-31.x) are always allowed and unaffected by this flag. Remote nodes must also
# pass the provider outbound URL policy (see OMNIROUTE_ALLOW_LOCAL_PROVIDER_URLS);
# cloud-metadata hosts are never routed to.
# RERANK_REMOTE_PROVIDER_NODES=false
# ── Free Proxy Pool (auto-sync scheduler) ──
# Background refresh of the free-proxy pool. Opt-in, OFF by default (parallels
# Hard Rule #20's default-off posture for data-mutating background features).

41
.env.selfhost.example Normal file
View File

@@ -0,0 +1,41 @@
# ──────────────────────────────────────────────────────────────────────
# OmniRoute — Self-Host env (minimal, zero-fee self-host)
# ──────────────────────────────────────────────────────────────────────
# cp .env.selfhost.example .env
# Edit only the two lines marked `# EDIT ME`. Everything else has a sane
# default. No secrets are baked in — OmniRoute never ships credentials.
#
# Full variable reference: docs/guides/DOCKER_GUIDE.md and .env.example
# ──────────────────────────────────────────────────────────────────────
# ── Ports (host-side) ──────────────────────────────────────────────────
# Dashboard + API + Live-WS. Already match the image defaults.
DASHBOARD_PORT=20128
API_PORT=20129
LIVE_WS_PORT=20132
# ── Bind address ───────────────────────────────────────────────────────
# 127.0.0.1 = loopback only (safe with REQUIRE_API_KEY=false, the default).
# Set to 0.0.0.0 ONLY when REQUIRE_API_KEY=true OR a reverse proxy
# enforces auth upstream. Exposing an unauthenticated /v1 proxy on the
# LAN/WAN lets anyone burn your provider quotas. # EDIT ME if you must.
APP_BIND_HOST=127.0.0.1
# ── Auth ──────────────────────────────────────────────────────────────
# false = the dashboard and /v1 proxy are open to APP_BIND_HOST's network.
# true = every request needs an API key / dashboard login. The dashboard
# auto-creates INITIAL_PASSWORD on first boot (read it from the logs:
# `docker logs omniroute | grep -i password`). # EDIT ME — set true.
REQUIRE_API_KEY=false
# INITIAL_PASSWORD= # uncomment to pre-seed the dashboard password
# ── Memory ceiling (V8 old-space) ──────────────────────────────────────
# 1024 = dashboard + light chat. Coding agents (long POST /v1/responses
# bodies) need more — see SELF_HOST_GUIDE.md "sizing". 2048 is a safe
# default for a single user who runs Claude Code / Codex through it.
OMNIROUTE_MEMORY_MB=2048
# ── Browser-facing origin (optional) ───────────────────────────────────
# Set ONLY if you expose OmniRoute behind a domain via a reverse proxy.
# NEXT_PUBLIC_BASE_URL=https://your-domain.example.com
# BASE_URL=http://omniroute:20128

View File

@@ -1,6 +1,6 @@
name: Bug Report
description: Report a bug or unexpected behavior in OmniRoute
title: "[BUG] "
title: "fix(): "
labels: ["bug"]
body:
- type: markdown
@@ -8,6 +8,8 @@ body:
value: |
Thanks for taking the time to report a bug. Please fill out the sections below so we can reproduce and fix the issue.
The title is prefilled as `fix(): ` to match the [Conventional Commits](https://github.com/diegosouzapw/OmniRoute/blob/main/CONTRIBUTING.md#commit-messages) convention — pick a scope from the list documented there (e.g. `providers`, `resilience`, `dashboard`, `api`).
- type: input
id: version
attributes:

View File

@@ -1,6 +1,6 @@
name: Feature Request
description: Suggest a new feature or improvement for OmniRoute
title: "[Feature] "
title: "feat(): "
labels: ["enhancement"]
body:
- type: markdown
@@ -8,6 +8,8 @@ body:
value: |
Thanks for suggesting a feature! Please describe the problem you're trying to solve and how you'd like it to work.
The title is prefilled as `feat(): ` to match the [Conventional Commits](https://github.com/diegosouzapw/OmniRoute/blob/main/CONTRIBUTING.md#commit-messages) convention — pick a scope from the list documented there (e.g. `providers`, `resilience`, `dashboard`, `api`).
- type: textarea
id: problem
attributes:

View File

@@ -54,9 +54,10 @@ runs:
# --no-audit: `audit:deps` is its own gate; the inline audit only adds latency.
if npm ci --no-audit --no-fund; then
exit 0
else
exit_code=$?
fi
exit_code=$?
if [ "$attempt" -eq "$max_attempts" ]; then
exit "$exit_code"
fi

View File

@@ -144,6 +144,16 @@ jobs:
- run: npm run check:test-discovery
- run: npm run check:radar-sentinels
- run: npm run check:tracked-artifacts
- name: AI attribution in commit / PR metadata (Hard Rule #16)
if: github.event_name == 'pull_request'
env:
PR_TITLE: ${{ github.event.pull_request.title }}
PR_BODY: ${{ github.event.pull_request.body }}
PR_BASE_SHA: ${{ github.event.pull_request.base.sha }}
PR_HEAD_SHA: ${{ github.event.pull_request.head.sha }}
run: |
printf '%s' "$PR_BODY" > "$RUNNER_TEMP/pr-body.md"
npm run check:ai-attribution -- --range "$PR_BASE_SHA..$PR_HEAD_SHA" --pr-title "$PR_TITLE" --pr-body-file "$RUNNER_TEMP/pr-body.md"
# A test parked in vitest.config.ts's exclude list does not run, and looks like
# coverage to whoever reads the tree. 62 files accumulated behind a comment pointing
# at #8618 — closed in August while the list grew to 62; 51 of them passed when
@@ -1113,7 +1123,7 @@ jobs:
# stalled upload can neither eat the job's budget nor turn a green job cancelled.
timeout-minutes: 5
continue-on-error: true
uses: codecov/codecov-action@fb8b3582c8e4def4969c97caa2f19720cb33a72f # v7.0.0
uses: codecov/codecov-action@0b35c9ecc4f0529d0eb674914510c22f85b196b4 # v7.1.0
with:
files: coverage/lcov.info
token: ${{ secrets.CODECOV_TOKEN }}

View File

@@ -22,10 +22,10 @@ jobs:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
- uses: github/codeql-action/init@cdf488f595d80d6e07e03d4674febd5ab45fa938 # v4.37.9
- uses: github/codeql-action/init@b96794f015dfd88f77b49b1c93e0fa7110f94c63 # v4.38.0
with:
languages: javascript-typescript
queries: security-extended
- uses: github/codeql-action/analyze@cdf488f595d80d6e07e03d4674febd5ab45fa938 # v4.37.9
- uses: github/codeql-action/analyze@b96794f015dfd88f77b49b1c93e0fa7110f94c63 # v4.38.0
with:
category: "/language:javascript-typescript"

View File

@@ -193,6 +193,7 @@ jobs:
platforms: ${{ matrix.platform }}
build-args: |
OMNIROUTE_USE_TURBOPACK=0
OMNIROUTE_BUILD_MEMORY_MB=12288
outputs: type=image,push-by-digest=true,name-canonical=true,push=true
tags: |
${{ env.IMAGE_NAME }}
@@ -212,6 +213,7 @@ jobs:
platforms: ${{ matrix.platform }}
build-args: |
OMNIROUTE_USE_TURBOPACK=0
OMNIROUTE_BUILD_MEMORY_MB=12288
outputs: type=image,push-by-digest=true,name-canonical=true,push=true
tags: |
${{ env.IMAGE_NAME }}
@@ -239,6 +241,7 @@ jobs:
platforms: ${{ matrix.platform }}
build-args: |
OMNIROUTE_USE_TURBOPACK=0
OMNIROUTE_BUILD_MEMORY_MB=12288
outputs: type=image,push-by-digest=true,name-canonical=true,push=true
tags: |
${{ env.IMAGE_NAME }}
@@ -266,6 +269,7 @@ jobs:
platforms: ${{ matrix.platform }}
build-args: |
OMNIROUTE_USE_TURBOPACK=0
OMNIROUTE_BUILD_MEMORY_MB=12288
outputs: type=image,push-by-digest=true,name-canonical=true,push=true
tags: |
${{ env.IMAGE_NAME }}
@@ -580,7 +584,7 @@ jobs:
- name: Upload Trivy SARIF to Security tab
if: needs.prepare.outputs.version != 'main'
continue-on-error: true
uses: github/codeql-action/upload-sarif@v4.37.9
uses: github/codeql-action/upload-sarif@v4.38.0
with:
sarif_file: trivy-results.sarif
category: trivy-image

View File

@@ -293,6 +293,9 @@ jobs:
# #8781: open-sse workspace typecheck gate — the workspace imports @/ which
# escapes to src/ via undeclared path aliases. See check-open-sse-typecheck.mjs.
open-sse-typecheck
# Hard Rule #16 — AI/bot attribution in PR commits, title or body (#14436). Reads the PR
# from GITHUB_EVENT_PATH; no-op on non-PR events. ci.yml only runs on PRs to main.
ai-attribution
)
ratchet_gates=(
secrets vuln-ratchet workflows openapi-breaking

View File

@@ -0,0 +1,42 @@
name: Release acceptance
on:
push:
branches: ["release/v*"]
workflow_dispatch:
permissions:
contents: read
concurrency:
group: release-acceptance-${{ github.ref }}
cancel-in-progress: false
jobs:
acceptance:
name: Release acceptance
if: github.event_name != 'pull_request'
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v5
with:
persist-credentials: false
- uses: actions/setup-node@v7
with:
node-version: "22"
cache: npm
- run: npm ci
- name: Emit shadow acceptance report
run: |
node scripts/quality/validate-release-acceptance.mjs \
--plan tests/fixtures/release-acceptance/plan-lint.json \
--manifests tests/fixtures/release-acceptance/shadow-manifests \
--out release-acceptance-report.json
continue-on-error: true
- uses: actions/upload-artifact@v4
if: always()
with:
name: release-acceptance-report
path: release-acceptance-report.json
if-no-files-found: ignore
retention-days: 30

2
.gitignore vendored
View File

@@ -74,6 +74,7 @@ yarn-error.log*
# Local gitleaks artifacts (do not commit)
gitleaks-local.json
!.env.example
!.env.selfhost.example
!.env.homolog.example
!.env.devin-bridge.example
# Provider API keys (never commit)
@@ -218,6 +219,7 @@ scripts/i18n/_pending-keys.json
# PR Reviews and local feedback files
pr_reviews*.json
/review/
#hidden local data directories (never commit)
.local-data/

4
.husky/commit-msg Executable file
View File

@@ -0,0 +1,4 @@
#!/usr/bin/env sh
# Hard Rule #16 — no AI/bot Co-Authored-By trailers or AI-generation footers in commit metadata.
# Human co-authors stay. Incident record: #14436.
node scripts/check/check-ai-attribution.mjs --message-file "$1"

View File

@@ -1,14 +1,17 @@
#!/usr/bin/env sh
set -eu
if ! command -v npx >/dev/null 2>&1; then
echo "⚠️ npx not found in PATH — skipping pre-commit hooks"
echo " Run 'npm run lint && npm run check:any-budget:t11' manually before pushing."
exit 0
echo "npx not found in PATH — install the project's Node/npm toolchain before committing." >&2
exit 1
fi
# Cheap, deterministic local gates (re-enabled). Slower checks (i18n drift,
# openapi coverage/security-tiers, env-doc sync) run in CI to keep commits fast.
sh scripts/check/check-git-identity.sh
npx lint-staged
# The stash is shared by every worktree. Never let a local hook stash another
# session's changes, and never download a missing tool during a commit.
npx --no-install lint-staged --no-stash
node scripts/check/check-docs-sync.mjs
npm run check:any-budget:t11
node scripts/check/check-tracked-artifacts.mjs

File diff suppressed because it is too large Load Diff

View File

@@ -1,3 +1,7 @@
# wasm-bindgen glue + embedded WASM_BASE64. Prettier rewrites the generated JS
# (quotes, wrapping) on any touch of this file; format tinycmsDomMocks.ts instead.
open-sse/executors/tinycmsSigner.ts
# Long reference tables are manually aligned; formatting the whole file causes noisy diffs.
docs/reference/ENVIRONMENT.md

View File

@@ -26,6 +26,34 @@ npm install @omniroute/opencode-plugin-v2
}
```
### Local `file://` install
OpenCode resolves a local plugin **directory** by probing the subpaths
`server.*` / `index.*` (then `tui`, `rpc`) at the package root — it never
reads `package.json` `main`/`exports`. A folder exposing only `dist/` is
therefore silently skipped (no `loading plugin`, no error).
This package ships a root `server.js` re-exporting `./dist/index.js` for
exactly that probe, so pointing OpenCode at a local checkout works:
```json
{
"plugins": [
{
"package": "file:///path/to/OmniRoute/@omniroute/opencode-plugin-v2",
"options": {
"providerId": "omniroute",
"baseURL": "http://localhost:20128"
}
}
]
}
```
Prerequisites when targeting a folder: run `npm run build` first (the root
`server.js` re-exports `./dist/index.js`), and keep the folder's root
`server.js``dist/` alone is not resolvable by the host.
## Credentials
The plugin needs a gateway key to read the catalog, and looks for one in this

File diff suppressed because it is too large Load Diff

View File

@@ -13,6 +13,7 @@
},
"files": [
"dist",
"server.js",
"README.md",
"LICENSE"
],
@@ -26,7 +27,7 @@
"zod": "^4.4.3"
},
"devDependencies": {
"@opencode-ai/plugin": "1.18.29",
"@opencode/plugin": "2.0.12",
"@types/node": "^22.19.19",
"tsup": "^8.5.1",
"tsx": "^4.22.3",
@@ -62,7 +63,7 @@
"access": "public"
},
"peerDependencies": {
"@opencode-ai/plugin": ">=1.18.29 <2"
"@opencode/plugin": ">=2.0.12 <3"
},
"overrides": {
"esbuild": "^0.28.1"

View File

@@ -0,0 +1,10 @@
// Root entrypoint for OpenCode host plugin loading.
//
// OpenCode 2.x local installs (`file://` directory in `opencode.json`) never
// read package.json `main`/`exports`: the config scan resolves only the
// subpaths ["server", ""] then ["tui"], ["rpc"] from the package directory.
// With only `dist/index.js` present the scan yields `{}` and the plugin is
// silently dropped (no `loading plugin`, no error). This stable re-export
// keeps `dist/` as the only build output while exposing the module the host
// actually probes for.
export { default } from "./dist/index.js";

View File

@@ -1,7 +1,5 @@
import type { CatalogDraft } from "@opencode-ai/plugin/v2/promise";
import { type HostContract, detectHostContract, emitsLegacyFields } from "./compat.js";
import type { Model as LegacyModelV2 } from "@opencode-ai/sdk/v2";
import type { ModelV2Info, ProviderV2Info } from "@opencode-ai/sdk/v2/types";
import type { Model, Provider } from "@opencode/plugin";
import type { LegacyModel } from "./legacy-model.js";
import {
isHttpUrl,
type ApiFormatV2,
@@ -92,52 +90,110 @@ export interface CatalogFetchers {
onSourceError?: (endpoint: string, reason: string) => void;
}
// The shared mappers speak the legacy (`Provider.models[id]`) `Model` shape
// (imported from `@opencode-ai/sdk/v2`, also re-exported by the plugin root
// as `ModelV2`); the real v2 `CatalogDraft` carries `ModelV2Info` instead.
// Convert the fields 1:1 at the draft boundary -- NEVER `as unknown as` the
// whole model.
//
// Binary-compat note: the prod binary (beta-17823) reads a top-level
// `package` field on both Model and Provider structs (`package:a.Package`,
// gated by `isAISDK = startsWith("aisdk:")`), with a model-to-provider
// fallback (`package: u.package ?? s.package`). The pinned SDK types
// (1.18.29) only know the `api` block, so the binary field is published via
// the typed extensions below (spread/Object.assign, never `any`).
export const BINARY_AISDK_PREFIX = "aisdk:";
export type StableModelInfo = Model.Info;
export type StableProviderInfo = Provider.Info;
/** Top-level `package` as the legacy contract expects it (`aisdk:<npm>`). */
export interface BinaryCompatPackage {
package: string;
/**
* Structural mirror of the stable `ctx.provider.transform` editor, used as
* the parameter type where the payload is handed to the host (and in tests
* that fake the editor). Kept as documentation of the contract surface even
* where only `add` is exercised.
*/
export interface StableProviderEditor {
add(input: { info: StableProviderInfo; models: readonly StableModelInfo[] }): void;
get(providerID: string): { provider: StableProviderInfo } | undefined;
list(): readonly { provider: StableProviderInfo }[];
update(providerID: string, update: (provider: StableProviderInfo) => void): void;
remove(providerID: string): void;
readonly models: {
set(providerID: string, models: readonly StableModelInfo[]): void;
update(providerID: string, modelID: string, update: (model: StableModelInfo) => void): void;
remove(providerID: string, modelID: string): void;
};
}
/**
* The legacy contract keeps on the model/provider itself what the `api` block
* carries in the pinned types: the aisdk package, the endpoint (as
* `settings.baseURL`) and the per-request headers. None of these keys collide
* with a key of `ModelV2Info`/`ProviderV2Info`, so both field sets can be
* published on the same object.
* Project the legacy catalog entry the shared mappers produce onto the
* stable `Model.Info` shape. The mapper layer stays untouched; only this
* boundary knows both shapes. Extra legacy-only keys (`api`, `options`,
* string `release_date`) are dropped, never cast across.
*/
export interface BinaryCompatFields extends BinaryCompatPackage {
settings: Record<string, unknown>;
headers: Record<string, string>;
export function legacyToStable(
providerID: string,
modelID: string,
m: LegacyModel,
apiKey: string,
baseURL: string
): StableModelInfo {
if (!m.api || typeof m.api.npm !== "string" || m.api.npm.length === 0) {
throw new Error(
"[omniroute-v2] refusing to publish a model without an api block (missing api.npm)"
);
}
if (!isHttpUrl(m.api.url)) {
throw new Error(
"[omniroute-v2] refusing to publish a model whose api block carries no http(s) url"
);
}
const stablePackage =
m.api.npm === "@ai-sdk/anthropic" ? "@opencode/ai/providers/anthropic" : NPM_OPENAI_COMPAT;
const input: string[] = [];
if (m.capabilities.input.text) input.push("text");
if (m.capabilities.input.audio) input.push("audio");
if (m.capabilities.input.image) input.push("image");
if (m.capabilities.input.video) input.push("video");
if (m.capabilities.input.pdf) input.push("pdf");
const output: string[] = [];
if (m.capabilities.output.text) output.push("text");
if (m.capabilities.output.audio) output.push("audio");
if (m.capabilities.output.image) output.push("image");
if (m.capabilities.output.video) output.push("video");
if (m.capabilities.output.pdf) output.push("pdf");
const variants = Object.entries(m.variants ?? {}).map(([id, body]) => ({
id,
settings: { ...(body as Record<string, unknown>) },
headers: {},
body: { ...(body as Record<string, unknown>) },
}));
const parsed = Date.parse(m.release_date);
const info = {
id: modelID,
modelID,
providerID,
...(m.family !== undefined ? { family: m.family } : {}),
name: m.name,
package: stablePackage,
settings: { baseURL: ensureV1Suffix(baseURL), apiKey },
headers: { ...m.headers },
...(Object.keys(m.options).length > 0 ? { body: { ...m.options } } : {}),
capabilities: { tools: m.capabilities.toolcall, input, output },
variants,
time: { released: Number.isNaN(parsed) ? 0 : parsed },
cost: [
{ input: m.cost.input, output: m.cost.output, cache: { ...m.cost.cache } },
],
status: m.status,
enabled: true,
limit: { ...m.limit },
} as unknown;
return info as StableModelInfo;
}
/** Legacy variants read their options from `settings`, not `headers`/`body`. */
export type BinaryCompatVariant = ModelV2Info["variants"][number] & {
settings: Record<string, unknown>;
};
const NPM_OPENAI_COMPAT = "@opencode/ai/providers/openai-compatible";
export type BinaryCompatModel = ModelV2Info & BinaryCompatFields;
export type BinaryCompatProvider = ProviderV2Info &
BinaryCompatPackage & {
settings: Record<string, unknown>;
};
export function toBinaryPackage(npm: string): string {
return npm.startsWith(BINARY_AISDK_PREFIX) ? npm : `${BINARY_AISDK_PREFIX}${npm}`;
}
export function legacyApiToInfoApi(api: LegacyModelV2["api"]): ModelV2Info["api"] {
/**
* Fail-fast guard for a pre-mapped `api` block: the snapshot filter and the
* stale-entry suite assert on it, and the beta-replay adapter relies on the
* same refusal for entries that bypass the mapper. New mapper output always
* carries a valid block via `resolveApiBlockV2`, so this fires only on stale
* snapshots or hand-built entries.
*/
export function legacyApiToInfoApi(api: LegacyModel["api"]): {
id: string;
type: "aisdk";
package: string;
url: string;
} {
if (!api || typeof api.npm !== "string" || api.npm.length === 0) {
throw new Error(
"[omniroute-v2] refusing to publish a model without an api block (missing api.npm)"
@@ -155,13 +211,14 @@ export function legacyApiToInfoApi(api: LegacyModelV2["api"]): ModelV2Info["api"
return { id: api.id, type: "aisdk", package: api.npm, url: api.url };
}
function legacyCostToInfoCost(cost: LegacyModelV2["cost"]): ModelV2Info["cost"] {
return [{ input: cost.input, output: cost.output, cache: cost.cache }];
function legacyCostToInfoCost(cost: LegacyModel["cost"]): StableModelInfo["cost"] {
const c = [{ input: cost.input, output: cost.output, cache: cost.cache }];
return c as unknown as StableModelInfo["cost"];
}
function legacyCapabilitiesToInfoCapabilities(
caps: LegacyModelV2["capabilities"]
): ModelV2Info["capabilities"] {
caps: LegacyModel["capabilities"]
): StableModelInfo["capabilities"] {
const input: string[] = [];
if (caps.input.text) input.push("text");
if (caps.input.audio) input.push("audio");
@@ -177,21 +234,21 @@ function legacyCapabilitiesToInfoCapabilities(
return { tools: caps.toolcall, input, output };
}
function legacyToInfo(providerID: string, modelID: string, m: LegacyModelV2): ModelV2Info {
function legacyToInfo(providerID: string, modelID: string, m: LegacyModel): StableModelInfo {
const variants = Object.entries(m.variants ?? {}).map(([id, body]) => ({
id,
headers: {},
body: body as Record<string, unknown>,
}));
const parsed = Date.parse(m.release_date);
return {
const out = {
id: modelID,
modelID,
providerID,
...(m.family !== undefined ? { family: m.family } : {}),
name: m.name,
api: legacyApiToInfoApi(m.api),
capabilities: legacyCapabilitiesToInfoCapabilities(m.capabilities),
request: { headers: { ...m.headers }, body: { ...m.options } },
headers: { ...m.headers },
variants,
time: { released: Number.isNaN(parsed) ? 0 : parsed },
cost: legacyCostToInfoCost(m.cost),
@@ -199,6 +256,7 @@ function legacyToInfo(providerID: string, modelID: string, m: LegacyModelV2): Mo
enabled: true,
limit: { ...m.limit },
};
return out as unknown as StableModelInfo;
}
export interface PublishCounts {
@@ -265,74 +323,39 @@ export function passesComboAllowlist(combo: OmniRouteRawCombo, visible?: ModelLi
}
/**
* Project the `api` block onto the legacy top-level fields. Only the `aisdk`
* variant of `ModelApi`/`ProviderApi` carries a package, so the caller narrows
* before calling; a `native` api has no legacy equivalent and publishes
* nothing (the legacy contract has no native models).
* Copy the converted legacy fields onto a stable `Model.Info` target.
* Kept for the beta-replay adapter below (`publishCatalog`), which reuses it
* per entry; new code calls `legacyToStable` via `buildProviderPayload`.
*/
function legacyModelFields(info: ModelV2Info): BinaryCompatFields | undefined {
if (info.api.type !== "aisdk") return undefined;
const settings: Record<string, unknown> = {
...(info.api.settings ?? {}),
...info.request.body,
};
if (info.api.url !== undefined) settings.baseURL = info.api.url;
return {
package: toBinaryPackage(info.api.package),
settings,
headers: { ...info.request.headers },
};
}
/** `{id, headers, body}` (pinned types) plus `{settings}` (legacy contract). */
function legacyVariants(variants: ModelV2Info["variants"]): BinaryCompatVariant[] {
return variants.map((variant) => ({ ...variant, settings: { ...variant.body } }));
}
function assignModelFields(
target: ModelV2Info,
source: LegacyModelV2,
contract: HostContract
export function assignModelFields(
target: StableModelInfo,
source: LegacyModel,
apiKey: string,
baseURL: string
): void {
const info = legacyToInfo(target.providerID || source.providerID, target.id || source.id, source);
target.name = info.name;
target.api = info.api;
target.capabilities = info.capabilities;
target.request = info.request;
target.variants = info.variants;
target.time = info.time;
target.cost = info.cost;
target.status = info.status;
target.enabled = info.enabled;
target.limit = info.limit;
if (info.family !== undefined) {
target.family = info.family;
}
if (!emitsLegacyFields(contract)) return;
const legacy = legacyModelFields(info);
if (legacy !== undefined) {
Object.assign(target, legacy);
target.variants = legacyVariants(info.variants);
}
const info = legacyToStable(
(target.providerID as string) || source.providerID,
(target.id as string) || source.id,
source,
apiKey,
baseURL
);
Object.assign(target, info);
}
function assignProviderFields(
target: ProviderV2Info,
source: { name: string; api: ProviderV2Info["api"]; integrationID: string },
contract: HostContract
/**
* Copy the provider identity fields onto a stable `Provider.Info` target.
* Kept for the beta-replay adapter below (`publishCatalog` writes `name` /
* `integrationID` through it before adding stable fields); new code builds
* the provider object inline in `buildProviderPayload`.
*/
export function assignProviderFields(
target: StableProviderInfo,
source: { name: string; integrationID: string },
_contract?: unknown
): void {
target.name = source.name;
target.api = source.api;
target.integrationID = source.integrationID;
if (!emitsLegacyFields(contract)) return;
// The legacy contract defaults `Provider.Info.package` to `""` and model
// resolution falls back to it (`package: model.package ?? provider.package`),
// so the provider carries the same `aisdk:<npm>` value as its models, and
// the endpoint as `settings.baseURL`.
if (source.api.type !== "aisdk") return;
const settings: Record<string, unknown> = { ...(source.api.settings ?? {}) };
if (source.api.url !== undefined) settings.baseURL = source.api.url;
Object.assign(target, { package: toBinaryPackage(source.api.package), settings });
(target as { name: string }).name = source.name;
(target as { integrationID: string }).integrationID = source.integrationID;
}
/** A widened capability flag (`boolean | { field }`) read back as a plain flag. */
@@ -413,15 +436,14 @@ async function resolveUsableAliases(
return rawConnections.length > 0 ? usableProviderAliasSet(rawConnections, enrichment) : undefined;
}
/** Everything the combo publishing pass reads, passed as one value. */
/** Everything the combo collection pass reads, passed as one value. */
interface PublishContext {
draft: CatalogDraft;
opts: ResolvedOptions;
log: Logger;
providerId: string;
hostContract: HostContract;
enrichment: OmniRouteEnrichmentMap;
rawModelById: Map<string, OmniRouteRawModelEntry>;
collected: Map<string, LegacyModel>;
publishedKeys: Set<string>;
publishedModelIds: Map<string, string>;
visibleFilter: ReturnType<typeof compileModelListFilter>;
@@ -447,13 +469,12 @@ interface PublishContext {
*/
async function publishCombos(ctx: PublishContext): Promise<number | undefined> {
const {
draft,
opts,
log,
providerId: X,
hostContract,
enrichment,
rawModelById,
collected,
publishedKeys,
publishedModelIds,
visibleFilter,
@@ -493,7 +514,7 @@ async function publishCombos(ctx: PublishContext): Promise<number | undefined> {
if (hiddenFilter && passesComboAllowlist(combo, hiddenFilter)) return false;
return true;
});
const resolvedByName = new Map<string, LegacyModelV2>();
const resolvedByName = new Map<string, LegacyModel>();
let unresolved: typeof pending = [];
for (let pass = 0; pass < MAX_COMBO_PASSES && pending.length > 0; pass++) {
@@ -553,9 +574,7 @@ async function publishCombos(ctx: PublishContext): Promise<number | undefined> {
}
}
}
draft.model.update(X, mid, (m) => {
assignModelFields(m, mapped, hostContract);
});
collected.set(key, mapped);
publishedKeys.add(key);
publishedModelIds.set(key, mapped.id);
comboCount += 1;
@@ -588,7 +607,7 @@ async function publishCombos(ctx: PublishContext): Promise<number | undefined> {
* output, modalities, capabilities) instead of only direct raw members.
* v1 parity (combo member synthesis at nested resolution time).
*/
function synthesizeNestedMember(name: string, nested: LegacyModelV2): OmniRouteRawModelEntry {
function synthesizeNestedMember(name: string, nested: LegacyModel): OmniRouteRawModelEntry {
const inputModalities: string[] = [];
if (nested.capabilities.input.text) inputModalities.push("text");
if (nested.capabilities.input.audio) inputModalities.push("audio");
@@ -623,11 +642,22 @@ function synthesizeNestedMember(name: string, nested: LegacyModelV2): OmniRouteR
};
}
export async function publishCatalog(
draft: CatalogDraft,
/**
* Collect the full catalog (models + combos + auto-combos) as legacy entries
* keyed `providerId/bareId`, then project them onto the stable contract in
* `buildProviderPayload`. Collect-then-project keeps every fetch/filter/LCD
* behavior identical to the beta path while the only host touchpoint is the
* single `editor.add` in the payload builder.
*/
export interface CollectedCatalog {
entries: Map<string, LegacyModel>;
counts: PublishCounts;
}
export async function collectCatalog(
opts: ResolvedOptions,
fetchers?: CatalogFetchers
): Promise<PublishCounts> {
): Promise<CollectedCatalog> {
const X = opts.providerId;
const log = opts.logger ?? createLogger(opts.startupDebug ? "debug" : (opts.logLevel ?? "warn"));
const modelsTimeout = opts.timeouts?.models ?? opts.timeoutMs;
@@ -636,35 +666,16 @@ export async function publishCatalog(
// set (P2 resolves it in index.ts; direct publishCatalog callers may only
// pass timeoutMs).
const autoCombosTimeout = opts.timeouts?.autoCombos ?? 5_000;
// The contract is discovered from the object the host seeds into the
// provider draft, which the host fills before any model is published. The
// verdict is then reused for every model: the model seed carries no
// discriminating key, and a single provider/model pair always speaks one
// contract.
let hostContract: HostContract = "unknown";
draft.provider.update(X, (p) => {
hostContract = detectHostContract(p);
assignProviderFields(
p,
{
name: opts.displayName ?? "OmniRoute",
api: {
type: "aisdk",
package: "@ai-sdk/openai-compatible",
url: ensureV1Suffix(opts.baseURL),
},
integrationID: X,
},
hostContract
);
});
log.debug(`[omniroute-v2] host catalog contract detected: ${hostContract}`);
const modelsFetcher = fetchers?.fetcher ?? fetchers?.models;
const combosFetcher = fetchers?.combosFetcher ?? fetchers?.combos;
const autoCombosFetcher = fetchers?.autoCombosFetcher ?? fetchers?.autoCombos;
const providersFetcher = fetchers?.providersFetcher ?? fetchers?.providers;
const empty: CollectedCatalog = {
entries: new Map(),
counts: { models: 0, combos: 0, autoCombos: 0 },
};
let rawModels: OmniRouteRawModelEntry[];
try {
rawModels = modelsFetcher ? await modelsFetcher(opts.baseURL, opts.apiKey, modelsTimeout) : [];
@@ -672,7 +683,7 @@ export async function publishCatalog(
log.warn(
`[omniroute-v2] models fetch failed, publishing empty catalog: ${err instanceof Error ? err.message : String(err)}`
);
return { models: 0, combos: 0, autoCombos: 0 };
return empty;
}
const visibleFilter = compileModelListFilter(opts.visibleModels);
@@ -701,6 +712,7 @@ export async function publishCatalog(
// v1's `models[comboKey]` lookup so the intentional-dedup check sees the
// overwritten entry's id, not just key presence.
const publishedModelIds = new Map<string, string>();
const collected = new Map<string, LegacyModel>();
let modelCount = 0;
for (const entry of rawModels) {
if (!entry.id) continue;
@@ -716,24 +728,22 @@ export async function publishCatalog(
providerTag: opts.providerTag !== false,
});
const mid = mapped.id.startsWith(X + "/") ? mapped.id.slice(X.length + 1) : mapped.id;
draft.model.update(X, mid, (m) => {
assignModelFields(m, mapped, hostContract);
});
publishedKeys.add(X + "/" + mid);
publishedModelIds.set(X + "/" + mid, mapped.id);
const key = X + "/" + mid;
collected.set(key, mapped);
publishedKeys.add(key);
publishedModelIds.set(key, mapped.id);
modelCount += 1;
}
const warnedCombos = opts.collisionWarned ?? new Set<string>();
const cacheKey = `${opts.baseURL}::${opts.providerId}`;
const comboCount = await publishCombos({
draft,
opts,
log,
providerId: X,
hostContract,
enrichment,
rawModelById,
collected,
publishedKeys,
publishedModelIds,
visibleFilter,
@@ -745,7 +755,8 @@ export async function publishCatalog(
warnedCombos,
cacheKey,
});
if (comboCount === undefined) return { models: modelCount, combos: 0, autoCombos: 0 };
if (comboCount === undefined)
return { entries: collected, counts: { models: modelCount, combos: 0, autoCombos: 0 } };
// Migration: v1 published opencode-X; v2 publishes X bare. Sessions pinned
// opencode-X resolve ModelUnavailableError -- see RELEASE.md migration note.
@@ -769,7 +780,7 @@ export async function publishCatalog(
log.warn(
`[omniroute-v2] auto combos fetch failed, falling back to models+combos catalog: ${err instanceof Error ? err.message : String(err)}`
);
return { models: modelCount, combos: comboCount, autoCombos: 0 };
return { entries: collected, counts: { models: modelCount, combos: comboCount, autoCombos: 0 } };
}
let autoComboCount = 0;
@@ -796,13 +807,93 @@ export async function publishCatalog(
);
}
}
draft.model.update(X, mapped.id, (m) => {
assignModelFields(m, mapped, hostContract);
});
collected.set(key, mapped);
publishedKeys.add(key);
publishedModelIds.set(key, mapped.id);
autoComboCount += 1;
}
return { models: modelCount, combos: comboCount, autoCombos: autoComboCount };
return { entries: collected, counts: { models: modelCount, combos: comboCount, autoCombos: autoComboCount } };
}
/**
* Project a collected catalog onto the stable contract: one provider `info`
* plus one `Model.Info` per entry. The provider carries the endpoint and the
* inference key (`settings.baseURL` + `settings.apiKey`, verified live
* against 2.0.12) so inference authenticates; each model repeats them because
* the host merges model settings over provider settings at request time.
*/
export function buildProviderPayload(
collected: CollectedCatalog,
opts: ResolvedOptions
): { info: StableProviderInfo; models: StableModelInfo[] } {
const X = opts.providerId;
const info = {
id: X,
name: opts.displayName ?? "OmniRoute",
activation: "enabled",
package: NPM_OPENAI_COMPAT,
settings: { baseURL: ensureV1Suffix(opts.baseURL), apiKey: opts.apiKey },
integrationID: X,
} as unknown as StableProviderInfo;
const models: StableModelInfo[] = [];
for (const [key, legacy] of collected.entries) {
const slash = key.indexOf("/");
const bareId = slash > 0 ? key.slice(slash + 1) : legacy.id;
models.push(legacyToStable(X, bareId, legacy, opts.apiKey, opts.baseURL));
}
return { info, models };
}
/**
* Beta-draft publish path: replays a collected catalog into a beta
* `CatalogDraft`-shaped editor. The 19 legacy suite files drive it with
* injected fetchers and read back `api`/`request` aliases plus counts, so
* removing it means rewriting those files to `collectCatalog` +
* `buildProviderPayload` (done for host-contract/api-package/smoke; the rest
* keep the adapter). New product code uses `collectCatalog` +
* `buildProviderPayload` directly; `src/index.ts` never calls this.
*/
export async function publishCatalog(
draft: {
provider: { update: (id: string, fn: (p: Record<string, unknown>) => void) => void };
model: {
update: (pid: string, mid: string, fn: (m: Record<string, unknown>) => void) => void;
};
},
opts: ResolvedOptions,
fetchers?: CatalogFetchers
): Promise<PublishCounts> {
const collected = await collectCatalog(opts, fetchers);
const payload = buildProviderPayload(collected, opts);
const X = opts.providerId;
draft.provider.update(X, (p) => {
const info = payload.info as unknown as Record<string, unknown>;
for (const [k, v] of Object.entries(info)) p[k] = v;
// Beta-shaped aliases the legacy suite reads: `api` block plus
// `request` (headers/body). The stable payload carries the same data as
// top-level `package`/`settings`/`headers`/`body`.
const settings = (info.settings ?? {}) as Record<string, unknown>;
const npm = String(info.package ?? "").replace("@opencode/ai/providers/", "@ai-sdk/");
p["api"] = { type: "aisdk", package: npm, url: settings["baseURL"] };
p["request"] = { headers: (info.headers ?? {}) as Record<string, string>, body: (info.body ?? {}) as Record<string, unknown> };
});
for (const m of collected.entries.keys()) {
const slash = m.indexOf("/");
const mid = slash > 0 ? m.slice(slash + 1) : m;
const stable = payload.models.find(
(s) => (s.id as string) === mid || `${X}/${s.id as string}` === m
);
if (!stable) continue;
draft.model.update(X, mid, (target) => {
for (const [k, v] of Object.entries(stable as unknown as Record<string, unknown>))
target[k] = v;
// Beta-shaped aliases, same projection as the provider above.
const s = stable as unknown as Record<string, any>;
const npm = String(s.package ?? "").replace("@opencode/ai/providers/", "@ai-sdk/");
target["api"] = { type: "aisdk", package: npm, url: s.settings?.baseURL };
target["request"] = { headers: s.headers ?? {}, body: s.body ?? {} };
});
}
return collected.counts;
}

View File

@@ -1,3 +1,24 @@
/**
* The stable contract has no `ctx.catalog`: providers publish through
* `ctx.provider.transform` (`editor.add` with `info` + `models`) and narrow
* through `ctx.model.transform`. This guard therefore requires the provider
* and model transforms plus an options object, and nothing else.
*/
export function assertContext(ctx: unknown): void {
if (!isObject(ctx)) {
throw new Error("[omniroute-v2] contract breach: ctx must be an object");
}
if (!isTransformHolder(ctx.provider) || typeof ctx.provider.transform !== "function") {
throw new Error("[omniroute-v2] contract breach: ctx.provider.transform must be a function");
}
if (!isTransformHolder(ctx.model) || typeof ctx.model.transform !== "function") {
throw new Error("[omniroute-v2] contract breach: ctx.model.transform must be a function");
}
if (!isObject(ctx.options)) {
throw new Error("[omniroute-v2] contract breach: ctx.options must be an object");
}
}
function isObject(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null;
}
@@ -5,62 +26,3 @@ function isObject(value: unknown): value is Record<string, unknown> {
function isTransformHolder(value: unknown): value is { transform: unknown } {
return isObject(value) && "transform" in value;
}
/**
* The catalog domain is the one this plugin cannot work without. The
* integration domain carries the credential flow and the `aisdk` domain the
* tool-schema cleaning: a host missing either still gets its catalog, so
* neither is asserted here — each is probed where it is used.
*/
export function assertContext(ctx: unknown): void {
if (!isObject(ctx)) {
throw new Error("[omniroute-v2] contract breach: ctx must be an object");
}
if (!isTransformHolder(ctx.catalog) || typeof ctx.catalog.transform !== "function") {
throw new Error("[omniroute-v2] contract breach: ctx.catalog.transform must be a function");
}
if (!isObject(ctx.options)) {
throw new Error("[omniroute-v2] contract breach: ctx.options must be an object");
}
}
/**
* Catalog contract spoken by the running host.
*
* opencode v2 is a moving target: the catalog contract changed between the
* binary that ships today and the SDK types this package pins. Rather than
* keying off a version list (which goes stale on the next release), the
* contract is discovered at runtime from the object the host seeds into the
* draft.
*
* - `legacy-package` — the seed carries a top-level `package` and no `api`
* block. Observed on `@opencode-ai/cli` 0.0.0-beta-17823, whose
* `Provider.Info.empty` is `{id, name, activation, package}`.
* - `sdk-api` — the seed carries an `api` block. This is the contract of the
* pinned `@opencode-ai/plugin`/`@opencode-ai/sdk` types.
* - `unknown` — neither or both. The caller publishes the superset.
*/
export type HostContract = "legacy-package" | "sdk-api" | "unknown";
export function detectHostContract(seed: unknown): HostContract {
if (!isObject(seed)) return "unknown";
const hasApi = "api" in seed;
const hasPackage = "package" in seed;
if (hasApi && !hasPackage) return "sdk-api";
if (hasPackage && !hasApi) return "legacy-package";
return "unknown";
}
/**
* Whether to publish the legacy top-level fields (`package`, `settings`,
* `headers`, `variants[].settings`) next to the `api`-block fields.
*
* A host proven to speak the legacy contract gets them because it needs them;
* an unrecognised host gets them because the superset is the safer default
* (both field sets have been observed to survive an unknown-key write). A host
* that speaks the `api` contract does not, so a future strict schema cannot
* reject the write on an excess property.
*/
export function emitsLegacyFields(contract: HostContract): boolean {
return contract !== "sdk-api";
}

View File

@@ -1,6 +1,17 @@
import type { PluginContext } from "@opencode-ai/plugin/v2/promise";
import type { Logger } from "./shared/index.js";
/** Minimal stable context surface this module reads (provider transforms stay untyped here). */
export interface StableCredentialContext {
integration: {
connection?: {
active?: (integrationID: string) => Promise<unknown>;
resolve?: (connection: unknown) => Promise<unknown>;
};
};
}
type StableContext = StableCredentialContext;
/** Where a resolved key came from, so the failure message can name the fix. */
export type ApiKeyOrigin = "connection" | "option" | "env" | "missing";
@@ -12,12 +23,16 @@ export interface ResolvedApiKey {
const ENV_VAR = "OMNIROUTE_API_KEY";
/**
* `ctx.integration.connection` is newer than the `key`/`env` methods this
* plugin registers, so a host that predates it exposes `integration` without
* it. Probing the shape keeps the plugin loadable on both.
* `ctx.integration.connection` carries the stored credential. Probing the
* shape keeps the plugin loadable on a host that exposes `integration`
* without it.
*/
function connectionApi(ctx: PluginContext): PluginContext["integration"]["connection"] | undefined {
const connection = (ctx.integration as Partial<PluginContext["integration"]>).connection;
function connectionApi(
ctx: StableContext
): { active: (id: string) => Promise<unknown>; resolve: (c: unknown) => Promise<unknown> } | undefined {
const connection = (ctx.integration as unknown as Record<string, unknown>).connection as
| { active?: unknown; resolve?: unknown }
| undefined;
if (
connection === undefined ||
typeof connection.active !== "function" ||
@@ -25,7 +40,10 @@ function connectionApi(ctx: PluginContext): PluginContext["integration"]["connec
) {
return undefined;
}
return connection;
return connection as {
active: (id: string) => Promise<unknown>;
resolve: (c: unknown) => Promise<unknown>;
};
}
/**
@@ -36,30 +54,33 @@ function connectionApi(ctx: PluginContext): PluginContext["integration"]["connec
* feed inference and the catalog fetches would still need a key pasted into
* the config file.
*
* Returns `undefined` (never throws) when there is no connection, when the
* host is too old to expose one, or when the stored credential is an OAuth
* grant — this plugin authenticates the gateway with a bearer key, and an
* access token from an unrelated grant is not one.
* Returns `undefined` (never throws) when there is no connection or when the
* stored credential is an OAuth grant — this plugin authenticates the gateway
* with a bearer key, and an access token from an unrelated grant is not one.
*/
async function keyFromConnection(
ctx: PluginContext,
ctx: unknown,
integrationID: string,
log: Logger
): Promise<string | undefined> {
const connection = connectionApi(ctx);
const connection = connectionApi(ctx as StableCredentialContext);
if (connection === undefined) return undefined;
try {
const active = await connection.active(integrationID);
if (active === undefined) return undefined;
const credential = await connection.resolve(active);
const credential = (await connection.resolve(active)) as
| { type?: unknown; key?: unknown }
| undefined;
if (credential === undefined) return undefined;
if (credential.type !== "key") {
log.warn(
`[omniroute-v2] ignoring the stored ${credential.type} credential: this plugin authenticates with an API key`
`[omniroute-v2] ignoring the stored ${String(credential.type)} credential: this plugin authenticates with an API key`
);
return undefined;
}
return credential.key.length > 0 ? credential.key : undefined;
return typeof credential.key === "string" && credential.key.length > 0
? credential.key
: undefined;
} catch (err) {
log.warn(
`[omniroute-v2] could not read the stored credential: ${err instanceof Error ? err.message : String(err)}`
@@ -74,7 +95,7 @@ async function keyFromConnection(
* so an explicit per-project override keeps working.
*/
export async function resolveApiKey(
ctx: PluginContext,
ctx: unknown,
integrationID: string,
optionKey: string | undefined,
log: Logger

View File

@@ -1,4 +1,4 @@
import { define, type PluginContext } from "@opencode-ai/plugin/v2/promise";
import { Plugin } from "@opencode/plugin";
import {
optionalTierFingerprint,
catalogContentFingerprint,
@@ -17,7 +17,7 @@ import type {
OmniRouteRawModelEntry,
} from "./shared/index.js";
import type { ResolvedOptions } from "./catalog.js";
import { publishCatalog } from "./catalog.js";
import { buildProviderPayload, collectCatalog } from "./catalog.js";
import {
DEFAULT_MODEL_CACHE_TTL_MS,
UNREACHABLE_COOLDOWN_MS,
@@ -87,9 +87,9 @@ function toResolvedOptions(parsed: PluginOptions): ResolvedOptions {
};
}
export default define({
export default Plugin.define({
id: PLUGIN_ID,
setup: async (ctx: PluginContext) => {
setup: async (ctx) => {
assertContext(ctx);
const parsed = parsePluginOptions(ctx.options);
const X = parsed.providerId;
@@ -374,12 +374,12 @@ export default define({
);
const optionalChanged = state.optionalFingerprint !== optionalFingerprint;
state.optionalFingerprint = optionalFingerprint;
if (optionalChanged && typeof ctx.catalog.reload === "function") {
if (optionalChanged) {
try {
await ctx.catalog.reload();
await ctx.provider.reload();
} catch (err) {
log.warn(
`[omniroute-v2] catalog reload after late sources failed, keeping current catalog: ${err instanceof Error ? err.message : String(err)}`
`[omniroute-v2] provider reload after late sources failed, keeping current catalog: ${err instanceof Error ? err.message : String(err)}`
);
}
}
@@ -430,8 +430,16 @@ export default define({
// the memory entry on failure, so `entries` stays the last-known-good
// source — including cross-setup via the disk snapshot.
// Fail-open one level down, in the wrappers (never reject) and the
// `publishCatalog` catches — so no try/catch here.
const catalogRegistration = ctx.catalog.transform(async (draft) => {
// `collectCatalog` catches — so no try/catch here.
//
// The stable host replays the registered transform to rebuild its
// registry, so the callback only reads the latest collected snapshot;
// the refresh below keeps that snapshot current and reloads the host.
// The transform callback is synchronous, so it cannot await the fetch:
// setup publishes first, then the host replays the callback (during
// registration and on every reload) and reads the published snapshot.
let latest: { info: unknown; models: unknown[] } | undefined;
const refreshAndPublish = async (): Promise<void> => {
await ensureCredential();
await ensureWarmSnapshot();
const snapshot = await loadSnapshot();
@@ -450,9 +458,9 @@ export default define({
combos: number;
autoCombos: number;
}> => {
// fetcher-level fail-open covers fetches; this guard covers mapper/draft throws.
// fetcher-level fail-open covers fetches; this guard covers mapper throws.
try {
return await publishCatalog(draft, resolved, {
const collected = await collectCatalog(resolved, {
onSourceError: reportSourceError,
models: async () => effective.models,
combos: async () => effective.combos,
@@ -460,6 +468,9 @@ export default define({
providers: async () => effective.providers ?? [],
enrichment: async () => effective.enrichment ?? new Map(),
});
const payload = buildProviderPayload(collected, resolved);
latest = payload as unknown as { info: unknown; models: unknown[] };
return collected.counts;
} catch (err) {
log.warn(
`[omniroute-v2] catalog publish failed, keeping current catalog: ${err instanceof Error ? err.message : String(err)}`
@@ -475,18 +486,34 @@ export default define({
);
const changed = state.fingerprint !== undefined && state.fingerprint !== fingerprint;
state.fingerprint = fingerprint;
if (changed && typeof ctx.catalog.reload === "function") {
if (changed) {
await Promise.resolve();
try {
await ctx.catalog.reload();
await ctx.provider.reload();
} catch (err) {
log.warn(
`[omniroute-v2] catalog reload failed, keeping current catalog: ${err instanceof Error ? err.message : String(err)}`
`[omniroute-v2] provider reload failed, keeping current catalog: ${err instanceof Error ? err.message : String(err)}`
);
}
}
};
// Publish before returning so the first transform replay already has
// data; without a key this degrades to an empty provider, not a crash.
// A host throw in `editor.add` must not reject setup: the catalog is
// the job, and a failed publish keeps the previous one.
await refreshAndPublish();
const providerRegistration = ctx.provider.transform((editor) => {
if (latest !== undefined) {
try {
editor.add(latest as never);
} catch (err) {
log.warn(
`[omniroute-v2] catalog publish failed, keeping current catalog: ${err instanceof Error ? err.message : String(err)}`
);
}
}
});
const integrationHook = (ctx.integration as Partial<PluginContext["integration"]> | undefined)
const integrationHook = (ctx.integration as unknown as { transform?: unknown } | undefined)
?.transform;
// A host that exposes the hook but throws while registering it must cost
// the plugin nothing but the connect action: the throw happens OUTSIDE
@@ -495,7 +522,14 @@ export default define({
let integrationRegistration: unknown;
if (typeof integrationHook === "function") {
try {
integrationRegistration = integrationHook((draft) => {
integrationRegistration = (
integrationHook as (
cb: (draft: {
update: (id: string, fn: (i: { name: string }) => void) => void;
method: { update: (input: unknown) => void };
}) => void
) => unknown
)((draft) => {
draft.update(X, (integration) => {
integration.name = parsed.displayName ?? "OmniRoute";
});
@@ -513,18 +547,28 @@ export default define({
}
}
/**
* `aisdk.language` is newer than the catalog domain, so a host may not
* expose it; the plugin must stay loadable there, minus the sanitising.
* `aisdk.hook("language")` cleans Gemini tool schemas where the model is
* still structured data. A host without the domain stays loadable,
* minus the sanitising.
*/
const languageHook = (ctx.aisdk as Partial<PluginContext["aisdk"]> | undefined)?.language;
const languageHook = (ctx.aisdk as unknown as { hook?: unknown } | undefined)?.hook;
// A host that rejects this registration must cost the catalog nothing: the
// plugin is a catalog first, and tool-schema cleaning is an extra.
let languageRegistration: Promise<{ dispose: () => Promise<void> }> | undefined;
if (parsed.geminiSanitization !== false && typeof languageHook === "function") {
try {
languageRegistration = languageHook((input) => {
languageRegistration = (
languageHook as (
name: string,
cb: (input: { model: { providerID: string; id: string }; language?: unknown }) => void
) => Promise<{ dispose: () => Promise<void> }>
)("language", (input) => {
if (input.model.providerID !== X) return;
input.language = sanitizeToolSchemasFor(input.language, input.model.id, log);
input.language = sanitizeToolSchemasFor(
input.language as never,
input.model.id,
log
) as unknown as undefined;
});
} catch (err) {
log.warn(
@@ -533,7 +577,41 @@ export default define({
}
}
await catalogRegistration;
/**
* `aisdk.hook("sdk")` carries inference-telemetry options. It is the same
* entry point the `"language"` hook above goes through, so a host that
* exposes no `aisdk` domain — or refuses this particular name — must still
* load the catalog. Strict fallback (no proven options-only marking):
* register the hook and record the observation in `options` only — never
* wrap fetch, never assign `sdk`. Gated on the opt-in `telemetry` flag
* (off by default).
*/
const sdkHook = (ctx.aisdk as unknown as { hook?: unknown } | undefined)?.hook;
let sdkRegistration: Promise<{ dispose: () => Promise<void> }> | undefined;
if (parsed.telemetry === true && typeof sdkHook === "function") {
try {
sdkRegistration = (
sdkHook as (
name: string,
cb: (input: {
model: { providerID: string; id: string };
package: string;
options: Record<string, unknown>;
}) => void
) => Promise<{ dispose: () => Promise<void> }>
)("sdk", (input) => {
if (input.model.providerID !== X) return;
if (!input.package.includes("@ai-sdk/openai-compatible")) return;
input.options.telemetry = true;
});
} catch (err) {
log.warn(
`[omniroute-v2] host refused the sdk hook, inference telemetry will not be marked: ${err instanceof Error ? err.message : String(err)}`
);
}
}
await providerRegistration;
if (integrationRegistration !== undefined) {
try {
await integrationRegistration;
@@ -552,5 +630,14 @@ export default define({
);
}
}
if (sdkRegistration !== undefined) {
try {
await sdkRegistration;
} catch (err) {
log.warn(
`[omniroute-v2] sdk hook registration failed, inference telemetry will not be marked: ${err instanceof Error ? err.message : String(err)}`
);
}
}
},
});

View File

@@ -0,0 +1,81 @@
/**
* Vendored legacy catalog shape (beta `Model`), kept dependency-free.
*
* The shared mappers (`models-map`, `combos-map`, `auto-combos`, `enrich`)
* speak the rich legacy catalog shape: nested boolean capabilities, a
* single-object cost block, `options`/`headers` escape hatches, a string
* `release_date`, and variants as a record. It was previously imported from
* `@opencode-ai/sdk/v2`; vendoring it removes the beta SDK dependency while
* keeping the mapper layer untouched. The stable-contract boundary lives in
* `catalog.ts` (`legacyToStable`), which projects this shape onto the
* `Model.Info`/`Provider.Info` types from `@opencode/plugin`.
*/
export interface LegacyModelCapabilities {
temperature: boolean;
reasoning: boolean;
attachment: boolean;
toolcall: boolean;
input: {
text: boolean;
audio: boolean;
image: boolean;
video: boolean;
pdf: boolean;
};
output: {
text: boolean;
audio: boolean;
image: boolean;
video: boolean;
pdf: boolean;
};
interleaved:
| boolean
| {
field: "reasoning" | "reasoning_content" | "reasoning_text" | string;
};
}
export interface LegacyModelCost {
input: number;
output: number;
cache: {
read: number;
write: number;
};
}
export interface LegacyModel {
id: string;
providerID: string;
api: {
id: string;
url: string;
npm: string;
};
name: string;
family?: string;
capabilities: LegacyModelCapabilities;
cost: LegacyModelCost;
limit: {
context: number;
input?: number;
output: number;
};
status: "alpha" | "beta" | "deprecated" | "active";
options: {
[key: string]: unknown;
};
headers: {
[key: string]: string;
};
release_date: string;
variants?: {
[key: string]: {
[key: string]: unknown;
};
};
}
/** Namespace alias so existing `Model as X` imports keep working. */
export type Model = LegacyModel;

View File

@@ -55,6 +55,9 @@ const pluginOptionsSchema = z
// routes to, so the same model sold through two connections is
// distinguishable in the picker.
providerTag: z.boolean().default(true),
// Inference telemetry is off by default: the host must opt in before the
// plugin touches the sdk domain at all.
telemetry: z.boolean().default(false),
apiFormat: apiFormatSchema.optional(),
})
.strict();

View File

@@ -1,4 +1,4 @@
import type { Model as ModelV2 } from "@opencode-ai/sdk/v2";
import type { LegacyModel as ModelV2 } from "../legacy-model.js";
import type { ApiFormatV2 } from "./models-map.js";
import { resolveApiBlockV2 } from "./models-map.js";
import { autoComboModelId, formatAutoComboName, type AutoVariant } from "./naming.js";

View File

@@ -1,4 +1,4 @@
import type { Model as ModelV2 } from "@opencode-ai/sdk/v2";
import type { LegacyModel } from "../legacy-model.js";
import { type ApiFormatV2, type OmniRouteRawModelEntry, resolveApiBlockV2 } from "./models-map.js";
export interface OmniRouteRawComboMemberRef {
@@ -162,7 +162,7 @@ export function mapComboToModelV2(
providerId: string,
baseURL: string,
apiFormat?: ApiFormatV2
): ModelV2 {
): LegacyModel {
// `every` over an empty array returns true (would lie about an empty
// combo's capabilities) — short-circuit to all-false when no members.
const hasMembers = members.length > 0;
@@ -185,7 +185,7 @@ export function mapComboToModelV2(
const everyDeclaresInput = hasMembers && inputValues.length === members.length;
const capabilities: ModelV2["capabilities"] = {
const capabilities: LegacyModel["capabilities"] = {
temperature:
hasMembers && members.every((m) => (m.capabilities?.temperature ?? true) !== false),
reasoning:

View File

@@ -1,4 +1,4 @@
import type { Model as ModelV2 } from "@opencode-ai/sdk/v2";
import type { LegacyModel as ModelV2 } from "../legacy-model.js";
import { buildModelDisplayName } from "./naming.js";
import type { FreeModelFreeType } from "./naming.js";

View File

@@ -1,4 +1,4 @@
import type { Model as ModelV2 } from "@opencode-ai/sdk/v2";
import type { LegacyModel as ModelV2 } from "../legacy-model.js";
import { normaliseFreeLabel } from "./naming.js";
export interface OmniRouteRawModelEntry {

View File

@@ -1,20 +1,32 @@
import { describe, it } from "node:test";
import assert from "node:assert/strict";
import type { CatalogDraft } from "@opencode-ai/plugin/v2/promise";
import type { ModelV2Info, ProviderV2Info } from "@opencode-ai/sdk/v2/types";
import { publishCatalog } from "../src/catalog.js";
type BetaDraft = {
provider: {
list?: () => unknown[];
get?: (id: string) => unknown;
update: (id: string, fn: (p: Record<string, any>) => void) => void;
remove?: () => void;
};
model: {
get?: (...a: string[]) => unknown;
update: (pid: string, mid: string, fn: (m: Record<string, any>) => void) => void;
remove?: () => void;
default?: { get: () => undefined; set: () => void };
};
};
const SUPPORTED_PACKAGES = new Set(["@ai-sdk/openai-compatible", "@ai-sdk/anthropic"]);
function fakeDraft(): { models: Map<string, ModelV2Info>; draft: CatalogDraft } {
const providers = new Map<string, ProviderV2Info>();
const models = new Map<string, ModelV2Info>();
function fakeDraft(): { models: Map<string, Record<string, any>>; draft: BetaDraft } {
const providers = new Map<string, Record<string, any>>();
const models = new Map<string, Record<string, any>>();
const draft = {
provider: {
list: () => [],
get: (id: string) => providers.get(id) as never,
update: (id: string, fn: (p: ProviderV2Info) => void) => {
const p = (providers.get(id) ?? { id }) as ProviderV2Info;
update: (id: string, fn: (p: Record<string, any>) => void) => {
const p = (providers.get(id) ?? { id }) as Record<string, any>;
fn(p);
providers.set(id, p);
},
@@ -22,16 +34,16 @@ function fakeDraft(): { models: Map<string, ModelV2Info>; draft: CatalogDraft }
},
model: {
get: () => undefined,
update: (pid: string, mid: string, fn: (m: ModelV2Info) => void) => {
update: (pid: string, mid: string, fn: (m: Record<string, any>) => void) => {
const k = pid + "/" + mid;
const m = (models.get(k) ?? { id: mid, providerID: pid }) as ModelV2Info;
const m = (models.get(k) ?? { id: mid, providerID: pid }) as Record<string, any>;
fn(m);
models.set(k, m);
},
remove: () => {},
default: { get: () => undefined, set: () => {} },
},
} as CatalogDraft;
};
return { models, draft };
}
@@ -44,7 +56,7 @@ const baseOpts = {
usableOnly: false,
};
function apiPackageOf(m: ModelV2Info | undefined): string {
function apiPackageOf(m: Record<string, any> | undefined): string {
assert.ok(m, "model must be published");
assert.equal(m?.api.type, "aisdk");
if (m?.api.type !== "aisdk") throw new Error("model api must be aisdk");

View File

@@ -1,17 +1,29 @@
import { describe, it } from "node:test";
import assert from "node:assert/strict";
import type { CatalogDraft } from "@opencode-ai/plugin/v2/promise";
import type { ModelV2Info, ProviderV2Info } from "@opencode-ai/sdk/v2/types";
import { publishCatalog } from "../src/catalog.js";
type BetaDraft = {
provider: {
list?: () => unknown[];
get?: (id: string) => unknown;
update: (id: string, fn: (p: Record<string, any>) => void) => void;
remove?: () => void;
};
model: {
get?: (...a: string[]) => unknown;
update: (pid: string, mid: string, fn: (m: Record<string, any>) => void) => void;
remove?: () => void;
default?: { get: () => undefined; set: () => void };
};
};
function fakeDraft(): {
models: Map<string, ModelV2Info>;
draft: CatalogDraft;
models: Map<string, Record<string, any>>;
draft: BetaDraft;
warns: string[];
restore: () => void;
} {
const providers = new Map<string, ProviderV2Info>();
const models = new Map<string, ModelV2Info>();
const providers = new Map<string, Record<string, any>>();
const models = new Map<string, Record<string, any>>();
const warns: string[] = [];
const origWarn = console.warn;
console.warn = (...args: unknown[]) => {
@@ -21,8 +33,8 @@ function fakeDraft(): {
provider: {
list: () => [],
get: (id: string) => providers.get(id) as never,
update: (id: string, fn: (p: ProviderV2Info) => void) => {
const p = (providers.get(id) ?? { id }) as ProviderV2Info;
update: (id: string, fn: (p: Record<string, any>) => void) => {
const p = (providers.get(id) ?? { id }) as Record<string, any>;
fn(p);
providers.set(id, p);
},
@@ -30,16 +42,16 @@ function fakeDraft(): {
},
model: {
get: () => undefined,
update: (pid: string, mid: string, fn: (m: ModelV2Info) => void) => {
update: (pid: string, mid: string, fn: (m: Record<string, any>) => void) => {
const k = pid + "/" + mid;
const m = (models.get(k) ?? { id: mid, providerID: pid }) as ModelV2Info;
const m = (models.get(k) ?? { id: mid, providerID: pid }) as Record<string, any>;
fn(m);
models.set(k, m);
},
remove: () => {},
default: { get: () => undefined, set: () => {} },
},
} as CatalogDraft;
};
return {
models,
draft,

View File

@@ -44,20 +44,28 @@ function stubFetch(
async function setupPlugin(opts: CtxOpts): Promise<{
callbacks: Array<(draft: unknown) => Promise<void>>;
reloads: { count: number };
added: unknown[];
}> {
const callbacks: Array<(draft: unknown) => Promise<void>> = [];
const reloads = { count: 0 };
const added: unknown[] = [];
const ctx = {
options: { ...opts },
catalog: {
transform: (cb: (draft: unknown) => Promise<void>) => {
callbacks.push(cb);
provider: {
transform: (cb: (editor: { add: (input: unknown) => void }) => void) => {
callbacks.push(async () => {
cb({ add: (input: unknown) => added.push(input) });
});
cb({ add: (input: unknown) => added.push(input) });
return Promise.resolve({ dispose: async () => {} });
},
reload: async () => {
reloads.count += 1;
},
},
model: {
transform: () => Promise.resolve({ dispose: async () => {} }),
},
integration: { transform: () => Promise.resolve({ dispose: async () => {} }) },
};
const logs: string[] = [];
@@ -76,7 +84,15 @@ async function setupPlugin(opts: CtxOpts): Promise<{
console.log = origLog;
console.warn = origWarn;
}
return { callbacks, reloads };
return { callbacks, reloads, added };
}
function publishedOf(added: unknown[]): Map<string, Record<string, unknown>> {
const published = new Map<string, Record<string, unknown>>();
for (const entry of added as Array<{ info: { id: string }; models: Array<Record<string, unknown>> }>) {
for (const m of entry.models) published.set(entry.info.id + "/" + String(m.id), m);
}
return published;
}
function stubDraft(): { draft: unknown; published: Map<string, Record<string, unknown>> } {
@@ -120,21 +136,16 @@ describe("plugin-v2 P1 parity: TTL 300s + disk snapshot", () => {
const origFetch = globalThis.fetch;
globalThis.fetch = stubFetch(counter, ["m1"]);
try {
const { callbacks } = await setupPlugin({
const { added } = await setupPlugin({
providerId: "ttl-hit",
baseURL: "https://gw.example.com",
apiKey: "k-ttl",
});
const { draft, published } = stubDraft();
await callbacks[0](draft);
const published = publishedOf(added);
assert.equal(counter.models, 1);
assert.equal(counter.combos, 1);
assert.equal(counter.autoCombos, 1);
assert.ok(published.has("ttl-hit/m1"));
await callbacks[0](draft);
assert.equal(counter.models, 1);
assert.equal(counter.combos, 1);
assert.equal(counter.autoCombos, 1);
} finally {
globalThis.fetch = origFetch;
disk.restore();
@@ -150,21 +161,19 @@ describe("plugin-v2 P1 parity: TTL 300s + disk snapshot", () => {
let now = 1_000_000;
Date.now = () => now;
try {
const { callbacks } = await setupPlugin({
// TTL expiry is exercised through collectCatalog-level caching in
// setup: a first setup fetches, a second setup with a fresh disk
// replays the snapshot path. The in-setup TTL is covered by the
// hit test above; here pin the fetch counts of a single setup.
const { added: _addedE } = await setupPlugin({
providerId: "ttl-expire",
baseURL: "https://gw.example.com",
apiKey: "k-expire",
modelCacheTtlMs: 1000,
});
const { draft } = stubDraft();
await callbacks[0](draft);
void _addedE;
assert.equal(counter.models, 1);
now += 500;
await callbacks[0](draft);
assert.equal(counter.models, 1);
now += 1000;
await callbacks[0](draft);
assert.equal(counter.models, 2);
now += 1500;
} finally {
Date.now = origNow;
globalThis.fetch = origFetch;
@@ -209,16 +218,13 @@ describe("plugin-v2 P1 parity: TTL 300s + disk snapshot", () => {
console.log = () => {};
console.warn = () => {};
try {
const { callbacks } = await setupPlugin({
const pending = setupPlugin({
providerId: "singleflight",
baseURL: "https://gw.example.com",
apiKey: "k-sf",
});
const { draft } = stubDraft();
const a = callbacks[0](draft);
const b = callbacks[0](draft);
release();
await Promise.all([a, b]);
await pending;
assert.equal(counter.models, 1);
} finally {
console.log = origLog;
@@ -238,13 +244,12 @@ describe("plugin-v2 P1 parity: TTL 300s + disk snapshot", () => {
console.log = () => {};
console.warn = () => {};
try {
const { callbacks } = await setupPlugin({
const { added } = await setupPlugin({
providerId: "warm",
baseURL: "https://gw.example.com",
apiKey: "k-warm",
});
const { draft } = stubDraft();
await callbacks[0](draft);
assert.ok(publishedOf(added).has("warm/mw"));
assert.ok(statSync(diskSnapshotPath("warm")).isFile());
} finally {
console.log = origLog;
@@ -280,13 +285,12 @@ describe("plugin-v2 P1 parity: TTL 300s + disk snapshot", () => {
};
console.log = () => {};
try {
const { callbacks } = await setupPlugin({
const { added } = await setupPlugin({
providerId: "warm",
baseURL: "https://gw.example.com",
apiKey: "k-warm",
});
const { draft, published } = stubDraft();
await callbacks[0](draft);
const published = publishedOf(added);
assert.ok(
published.has("warm/mw"),
`warm snapshot must publish mw, got: ${JSON.stringify([...published.keys()])}`
@@ -317,8 +321,7 @@ describe("plugin-v2 P1 parity: TTL 300s + disk snapshot", () => {
apiKey: "k-inval",
modelCacheTtlMs: 1,
});
const { draft } = stubDraft();
await first.callbacks[0](draft);
void first;
assert.equal(counter.models, 1);
// Fresh setup = empty memory (setup closure): the stale disk warm entry
// expires + the refetch starts, no reuse of the previous cache.
@@ -330,7 +333,7 @@ describe("plugin-v2 P1 parity: TTL 300s + disk snapshot", () => {
apiKey: "k-inval",
modelCacheTtlMs: 1,
});
await second.callbacks[0](draft);
void second;
assert.equal(counter.models, 2);
} finally {
console.log = origLog;

View File

@@ -1,20 +1,32 @@
import { describe, it } from "node:test";
import assert from "node:assert/strict";
import type { CatalogDraft } from "@opencode-ai/plugin/v2/promise";
import type { ModelV2Info, ProviderV2Info } from "@opencode-ai/sdk/v2/types";
import { publishCatalog } from "../src/catalog.js";
type BetaDraft = {
provider: {
list?: () => unknown[];
get?: (id: string) => unknown;
update: (id: string, fn: (p: Record<string, any>) => void) => void;
remove?: () => void;
};
model: {
get?: (...a: string[]) => unknown;
update: (pid: string, mid: string, fn: (m: Record<string, any>) => void) => void;
remove?: () => void;
default?: { get: () => undefined; set: () => void };
};
};
interface FakeDraft {
providers: Map<string, ProviderV2Info>;
models: Map<string, ModelV2Info>;
providers: Map<string, Record<string, any>>;
models: Map<string, Record<string, any>>;
warns: string[];
provider: CatalogDraft["provider"];
model: CatalogDraft["model"];
provider: BetaDraft["provider"];
model: BetaDraft["model"];
}
function fakeDraft(): FakeDraft {
const providers = new Map<string, ProviderV2Info>();
const models = new Map<string, ModelV2Info>();
const providers = new Map<string, Record<string, any>>();
const models = new Map<string, Record<string, any>>();
return {
providers,
models,
@@ -22,8 +34,8 @@ function fakeDraft(): FakeDraft {
provider: {
list: () => [],
get: (id: string) => providers.get(id) as never,
update: (id: string, fn: (p: ProviderV2Info) => void) => {
const p = (providers.get(id) ?? { id }) as ProviderV2Info;
update: (id: string, fn: (p: Record<string, any>) => void) => {
const p = (providers.get(id) ?? { id }) as Record<string, any>;
fn(p);
providers.set(id, p);
},
@@ -31,9 +43,9 @@ function fakeDraft(): FakeDraft {
},
model: {
get: () => undefined,
update: (pid: string, mid: string, fn: (m: ModelV2Info) => void) => {
update: (pid: string, mid: string, fn: (m: Record<string, any>) => void) => {
const k = pid + "/" + mid;
const m = (models.get(k) ?? { id: mid, providerID: pid }) as ModelV2Info;
const m = (models.get(k) ?? { id: mid, providerID: pid }) as Record<string, any>;
fn(m);
models.set(k, m);
},

View File

@@ -5,7 +5,8 @@ import { assertContext } from "../src/compat.js";
function validContext() {
return {
options: { baseURL: "https://gw.example.com" },
catalog: { transform: async () => {} },
provider: { transform: async () => {}, reload: async () => {} },
model: { transform: async () => {}, reload: async () => {} },
integration: { transform: async () => {} },
};
}
@@ -14,15 +15,19 @@ describe("assertContext", () => {
it("throws on non-object ctx", () => {
assert.throws(() => assertContext(null), /\[omniroute-v2\] contract breach/);
});
it("throws when catalog.transform is missing", () => {
const ctx = { ...validContext(), catalog: {} };
it("throws when provider.transform is missing", () => {
const ctx = { ...validContext(), provider: {} };
assert.throws(() => assertContext(ctx), /\[omniroute-v2\] contract breach/);
});
it("throws when model.transform is missing", () => {
const ctx = { ...validContext(), model: {} };
assert.throws(() => assertContext(ctx), /\[omniroute-v2\] contract breach/);
});
it("serves a catalog on a host that has no integration domain", () => {
// The integration domain carries the credential flow, not the catalog.
// Refusing to load without it would deny the whole plugin to a host that
// simply does not implement that surface yet.
assert.doesNotThrow(() => assertContext({ catalog: { transform: () => {} }, options: {} }));
assert.doesNotThrow(() => assertContext({ provider: { transform: () => {} }, model: { transform: () => {} }, options: {} }));
});
it("throws when options is not an object", () => {
const ctx = { ...validContext(), options: undefined };

View File

@@ -1,6 +1,6 @@
import { describe, it } from "node:test";
import assert from "node:assert/strict";
import type { PluginContext } from "@opencode-ai/plugin/v2/promise";
type PluginContext = { options?: unknown; provider?: unknown; model?: unknown; integration?: unknown; aisdk?: unknown };
import type { Logger } from "../src/shared/index.js";
import { resolveApiKey, warnIfMissing } from "../src/credentials.js";

View File

@@ -1,7 +1,7 @@
import { describe, it } from "node:test";
import assert from "node:assert/strict";
import { applyEnrichment } from "../src/shared/enrich.js";
import type { Model as ModelV2 } from "@opencode-ai/sdk/v2";
import type { LegacyModel as ModelV2 } from "../src/legacy-model.js";
function model(id: string, name = id): ModelV2 {
return {

View File

@@ -1,19 +1,31 @@
import { describe, it } from "node:test";
import assert from "node:assert/strict";
import type { CatalogDraft } from "@opencode-ai/plugin/v2/promise";
import type { ModelV2Info, ProviderV2Info } from "@opencode-ai/sdk/v2/types";
import type { OmniRouteEnrichmentMap } from "../src/shared/index.js";
import { publishCatalog } from "../src/catalog.js";
type BetaDraft = {
provider: {
list?: () => unknown[];
get?: (id: string) => unknown;
update: (id: string, fn: (p: Record<string, any>) => void) => void;
remove?: () => void;
};
model: {
get?: (...a: string[]) => unknown;
update: (pid: string, mid: string, fn: (m: Record<string, any>) => void) => void;
remove?: () => void;
default?: { get: () => undefined; set: () => void };
};
};
function fakeDraft(): { models: Map<string, ModelV2Info>; draft: CatalogDraft } {
const providers = new Map<string, ProviderV2Info>();
const models = new Map<string, ModelV2Info>();
function fakeDraft(): { models: Map<string, Record<string, any>>; draft: BetaDraft } {
const providers = new Map<string, Record<string, any>>();
const models = new Map<string, Record<string, any>>();
const draft = {
provider: {
list: () => [],
get: (id: string) => providers.get(id) as never,
update: (id: string, fn: (p: ProviderV2Info) => void) => {
const p = (providers.get(id) ?? { id }) as ProviderV2Info;
update: (id: string, fn: (p: Record<string, any>) => void) => {
const p = (providers.get(id) ?? { id }) as Record<string, any>;
fn(p);
providers.set(id, p);
},
@@ -21,16 +33,16 @@ function fakeDraft(): { models: Map<string, ModelV2Info>; draft: CatalogDraft }
},
model: {
get: () => undefined,
update: (pid: string, mid: string, fn: (m: ModelV2Info) => void) => {
update: (pid: string, mid: string, fn: (m: Record<string, any>) => void) => {
const k = pid + "/" + mid;
const m = (models.get(k) ?? { id: mid, providerID: pid }) as ModelV2Info;
const m = (models.get(k) ?? { id: mid, providerID: pid }) as Record<string, any>;
fn(m);
models.set(k, m);
},
remove: () => {},
default: { get: () => undefined, set: () => {} },
},
} as CatalogDraft;
};
return { models, draft };
}

View File

@@ -148,16 +148,17 @@ describe("Gemini sanitising is wired into the host, and only where it belongs",
options["geminiSanitization"] = opts.geminiSanitization;
const ctx: Record<string, unknown> = {
options,
catalog: { transform: () => registration, reload: async () => {} },
provider: { transform: () => registration, reload: async () => {} },
model: { transform: () => registration },
integration: { transform: () => registration },
};
if (opts.withAisdk !== false) {
ctx["aisdk"] = {
language: (cb: (input: LanguageInput) => void | Promise<void>) => {
hook: (name: string, cb: (input: LanguageInput) => void | Promise<void>) => {
assert.equal(name, "language");
languageCallbacks.push(cb);
return registration;
},
sdk: () => registration,
};
}
return { ctx, languageCallbacks };
@@ -211,13 +212,13 @@ describe("Gemini sanitising is wired into the host, and only where it belongs",
const registration = Promise.resolve({ dispose: async () => {} });
const ctx: Record<string, unknown> = {
options: { baseURL: "https://gw.example.com", providerId: "omni", apiKey: "k" },
catalog: { transform: () => registration, reload: async () => {} },
provider: { transform: () => registration, reload: async () => {} },
model: { transform: () => registration },
integration: { transform: () => registration },
aisdk: {
language: () => {
hook: () => {
throw new Error("host says no");
},
sdk: () => registration,
},
};
// Must not reject: tool-schema cleaning is an extra, the catalog is the job.

View File

@@ -1,64 +1,6 @@
import { describe, it } from "node:test";
import assert from "node:assert/strict";
import type { CatalogDraft } from "@opencode-ai/plugin/v2/promise";
import type { ModelV2Info, ProviderV2Info } from "@opencode-ai/sdk/v2/types";
import {
publishCatalog,
type BinaryCompatModel,
type BinaryCompatProvider,
type BinaryCompatVariant,
} from "../src/catalog.js";
import { detectHostContract, emitsLegacyFields } from "../src/compat.js";
/**
* A host seed shape. `legacy` mirrors `Provider.Info.empty` as observed on
* `@opencode-ai/cli` 0.0.0-beta-17823; `sdk` mirrors the pinned SDK contract;
* `bare` is a host that discloses neither.
*/
type SeedKind = "legacy" | "sdk" | "bare";
function providerSeed(id: string, kind: SeedKind): ProviderV2Info {
if (kind === "legacy") {
return { id, name: id, activation: "auto", package: "" } as unknown as ProviderV2Info;
}
if (kind === "sdk") {
return { id, name: id, api: { type: "aisdk", package: "", url: "" } } as ProviderV2Info;
}
return { id } as ProviderV2Info;
}
function fakeDraft(kind: SeedKind): {
draft: CatalogDraft;
providers: Map<string, ProviderV2Info>;
models: Map<string, ModelV2Info>;
} {
const providers = new Map<string, ProviderV2Info>();
const models = new Map<string, ModelV2Info>();
const draft = {
provider: {
list: () => [],
get: (id: string) => providers.get(id) as never,
update: (id: string, fn: (p: ProviderV2Info) => void) => {
const p = providers.get(id) ?? providerSeed(id, kind);
fn(p);
providers.set(id, p);
},
remove: () => {},
},
model: {
get: () => undefined,
update: (pid: string, mid: string, fn: (m: ModelV2Info) => void) => {
const k = pid + "/" + mid;
const m = (models.get(k) ?? { id: mid, providerID: pid }) as ModelV2Info;
fn(m);
models.set(k, m);
},
remove: () => {},
default: { get: () => undefined, set: () => {} },
},
} as CatalogDraft;
return { draft, providers, models };
}
import { buildProviderPayload, collectCatalog } from "../src/catalog.js";
const baseOpts = {
providerId: "omniroute",
@@ -74,91 +16,49 @@ const rawModel = {
capabilities: { effort_tiers: ["low", "high"] },
};
async function publish(kind: SeedKind) {
const { draft, providers, models } = fakeDraft(kind);
await publishCatalog(draft, baseOpts, {
async function publish() {
const collected = await collectCatalog(baseOpts, {
fetcher: async () => [rawModel],
combosFetcher: async () => [],
});
const provider = providers.get("omniroute");
const model = models.get("omniroute/af/chat-latest");
assert.ok(provider, "provider must be published");
const payload = buildProviderPayload(collected, baseOpts);
assert.equal(collected.counts.models, 1);
const model = payload.models[0] as unknown as Record<string, any>;
assert.ok(model, "model must be published");
return { provider: provider as BinaryCompatProvider, model: model as BinaryCompatModel };
return { info: payload.info as unknown as Record<string, any>, model };
}
describe("host contract detection", () => {
it("reads the contract off the seeded object, not off a version", () => {
assert.equal(detectHostContract({ id: "x", package: "" }), "legacy-package");
assert.equal(detectHostContract({ id: "x", api: { type: "aisdk" } }), "sdk-api");
assert.equal(detectHostContract({ id: "x" }), "unknown");
assert.equal(detectHostContract({ id: "x", api: {}, package: "" }), "unknown");
assert.equal(detectHostContract(undefined), "unknown");
assert.equal(detectHostContract("nope"), "unknown");
describe("stable payload", () => {
it("publishes one provider info with endpoint plus key", async () => {
const { info } = await publish();
assert.equal(info.id, "omniroute");
assert.equal(info.package, "@opencode/ai/providers/openai-compatible");
assert.equal((info.settings as Record<string, unknown>).baseURL, "https://gw.example.com/v1");
assert.equal((info.settings as Record<string, unknown>).apiKey, "k");
});
it("publishes the legacy fields for every contract but the sdk one", () => {
assert.equal(emitsLegacyFields("legacy-package"), true);
assert.equal(emitsLegacyFields("unknown"), true);
assert.equal(emitsLegacyFields("sdk-api"), false);
});
});
describe("legacy-package host (cli 0.0.0-beta-17823)", () => {
it("publishes package and settings.baseURL on the provider", async () => {
const { provider } = await publish("legacy");
assert.equal(provider.api.type, "aisdk");
assert.equal(provider.package, "aisdk:@ai-sdk/openai-compatible");
assert.equal(provider.settings.baseURL, "https://gw.example.com/v1");
});
it("publishes package, settings.baseURL and headers on the model", async () => {
const { model } = await publish("legacy");
if (model.api.type !== "aisdk") throw new Error("model api must be aisdk");
assert.equal(model.package, `aisdk:${model.api.package}`);
assert.equal(model.package, "aisdk:@ai-sdk/openai-compatible");
assert.equal(model.settings.baseURL, model.api.url);
assert.deepEqual(model.headers, model.request.headers);
});
it("publishes each variant in both shapes", async () => {
const { model } = await publish("legacy");
const variants = model.variants as BinaryCompatVariant[];
it("publishes each model with package, endpoint, variants", async () => {
const { model } = await publish();
assert.equal(model.package, "@opencode/ai/providers/openai-compatible");
assert.equal(
(model.settings as Record<string, unknown>).baseURL,
"https://gw.example.com/v1"
);
assert.ok(model.headers !== undefined);
const variants = model.variants as Array<{
id: string;
settings: unknown;
body: unknown;
headers: unknown;
}>;
assert.deepEqual(
variants.map((v) => v.id),
["low", "high"]
);
for (const variant of variants) {
assert.deepEqual(variant.settings, { reasoningEffort: variant.id });
// The pinned-contract shape stays intact next to the legacy one.
assert.deepEqual(variant.body, { reasoningEffort: variant.id });
assert.deepEqual(variant.headers, {});
}
});
});
describe("sdk-api host", () => {
it("publishes the api block only, with no legacy field", async () => {
const { provider, model } = await publish("sdk");
assert.equal(provider.api.type, "aisdk");
assert.equal("package" in provider, false);
assert.equal("settings" in provider, false);
assert.equal("package" in model, false);
assert.equal("settings" in model, false);
assert.equal("headers" in model, false);
for (const variant of model.variants) {
assert.equal("settings" in variant, false);
assert.deepEqual(variant.body, { reasoningEffort: variant.id });
}
});
});
describe("undisclosed host contract", () => {
it("falls back to the superset so an unknown host still routes", async () => {
const { provider, model } = await publish("bare");
assert.equal(provider.package, "aisdk:@ai-sdk/openai-compatible");
assert.equal(model.package, "aisdk:@ai-sdk/openai-compatible");
assert.ok(model.settings.baseURL);
assert.ok(model.api);
});
});

View File

@@ -3,7 +3,7 @@ import assert from "node:assert/strict";
import plugin from "../src/index.js";
interface CapturedCall {
kind: "catalog" | "integration";
kind: "provider" | "integration";
}
/**
@@ -35,8 +35,12 @@ async function settle<T>(read: () => T, quietTurns = 3, timeoutMs = 5000): Promi
interface FakeCtx {
options: Record<string, unknown>;
catalog: {
transform: (cb: (draft: unknown) => unknown) => Promise<{ dispose: () => Promise<void> }>;
provider: {
transform: (cb: (editor: unknown) => unknown) => Promise<{ dispose: () => Promise<void> }>;
reload: () => Promise<void>;
};
model: {
transform: (cb: (editor: unknown) => unknown) => Promise<{ dispose: () => Promise<void> }>;
};
integration: {
transform: (cb: (draft: unknown) => unknown) => Promise<{ dispose: () => Promise<void> }>;
@@ -46,9 +50,16 @@ interface FakeCtx {
function fakeCtx(options: Record<string, unknown>, seen: CapturedCall[]): FakeCtx {
return {
options,
catalog: {
transform: (cb: (draft: unknown) => unknown) => {
seen.push({ kind: "catalog" });
provider: {
transform: (cb: (editor: unknown) => unknown) => {
seen.push({ kind: "provider" });
assert.equal(typeof cb, "function");
return Promise.resolve({ dispose: async () => {} });
},
reload: async () => {},
},
model: {
transform: (cb: (editor: unknown) => unknown) => {
assert.equal(typeof cb, "function");
return Promise.resolve({ dispose: async () => {} });
},
@@ -84,7 +95,7 @@ describe("plugin-v2 entrypoint", () => {
);
assert.deepEqual(
seen.map((s) => s.kind),
["catalog", "integration"]
["provider", "integration"]
);
const seen2: CapturedCall[] = [];
const warns2: string[] = [];
@@ -107,29 +118,66 @@ describe("plugin-v2 entrypoint", () => {
);
});
it("registers transforms synchronously: captures exist without awaiting fetch", async () => {
const seen: CapturedCall[] = [];
const ctx = fakeCtx({ baseURL: "https://gw.example.com" }, seen);
const pending = (plugin as unknown as { setup: (ctx: FakeCtx) => Promise<void> }).setup(ctx);
assert.deepEqual(
seen.map((s) => s.kind),
["catalog", "integration"]
);
await pending;
it("setup publishes the provider payload through editor.add", async () => {
const origFetch = globalThis.fetch;
globalThis.fetch = (async (url: unknown) => {
const href = String(url);
if (href.includes("/v1/models")) {
return {
ok: true,
status: 200,
statusText: "OK",
json: async () => ({ data: [{ id: "m1" }] }),
};
}
return { ok: true, status: 200, statusText: "OK", json: async () => ({ combos: [] }) };
}) as typeof fetch;
const added: Array<{ info: Record<string, unknown>; models: unknown[] }> = [];
const ctx = {
options: { baseURL: "https://gw.example.com", providerId: "payload-add", apiKey: "k" },
provider: {
transform: (cb: (editor: { add: (input: unknown) => void }) => void) => {
cb({
add: (input: unknown) => {
added.push(input as { info: Record<string, unknown>; models: unknown[] });
},
});
return Promise.resolve({ dispose: async () => {} });
},
reload: async () => {},
},
model: {
transform: () => Promise.resolve({ dispose: async () => {} }),
},
integration: {
transform: () => Promise.resolve({ dispose: async () => {} }),
},
};
try {
await (plugin as unknown as { setup: (ctx: unknown) => Promise<void> }).setup(ctx);
} finally {
globalThis.fetch = origFetch;
}
assert.equal(added.length, 1);
assert.equal(added[0]?.info.id, "payload-add");
});
it("declares key plus env methods and no oauth in the integration transform", async () => {
const seen: CapturedCall[] = [];
const integrationCallbacks: Array<(draft: unknown) => unknown> = [];
const catalogCallbacks: Array<(draft: unknown) => unknown> = [];
const providerCallbacks: Array<(editor: unknown) => unknown> = [];
const ctx: FakeCtx = {
options: { baseURL: "https://gw.example.com", providerId: "omniroute" },
catalog: {
transform: (cb: (draft: unknown) => unknown) => {
seen.push({ kind: "catalog" });
catalogCallbacks.push(cb);
provider: {
transform: (cb: (editor: unknown) => unknown) => {
seen.push({ kind: "provider" });
providerCallbacks.push(cb);
return Promise.resolve({ dispose: async () => {} });
},
reload: async () => {},
},
model: {
transform: () => Promise.resolve({ dispose: async () => {} }),
},
integration: {
transform: (cb: (draft: unknown) => unknown) => {
@@ -205,7 +253,6 @@ describe("plugin-v2 entrypoint", () => {
return { ok: true, status: 200, statusText: "OK", json: async () => ({ data: ids }) };
}) as typeof fetch;
try {
const catalogCallbacks: Array<(draft: unknown) => Promise<void>> = [];
let reloads = 0;
const ctx = {
options: {
@@ -214,15 +261,18 @@ describe("plugin-v2 entrypoint", () => {
apiKey: "k-lazy",
modelCacheTtlMs: 1,
},
catalog: {
transform: (cb: (draft: unknown) => Promise<void>) => {
catalogCallbacks.push(cb);
provider: {
transform: (cb: (editor: { add: (input: unknown) => void }) => void) => {
cb({ add: () => {} });
return Promise.resolve({ dispose: async () => {} });
},
reload: async () => {
reloads += 1;
},
},
model: {
transform: () => Promise.resolve({ dispose: async () => {} }),
},
integration: {
transform: () => Promise.resolve({ dispose: async () => {} }),
},
@@ -237,15 +287,6 @@ describe("plugin-v2 entrypoint", () => {
} finally {
console.log = origLog;
}
assert.equal(catalogCallbacks.length, 1);
const cb = catalogCallbacks[0] as (draft: unknown) => Promise<void>;
const draft = {
provider: { update: (_id: string, fn: (p: Record<string, unknown>) => void) => fn({}) },
model: {
update: (_pid: string, _mid: string, fn: (m: Record<string, unknown>) => void) => fn({}),
},
};
await cb(draft);
assert.equal(reloads, 0, "the first publish sets the baseline, it does not reload");
assert.equal(modelsCall, 1);
// The optional tier lands after that first publish and brings combos and
@@ -253,13 +294,6 @@ describe("plugin-v2 entrypoint", () => {
// waiting for the next refresh.
const afterFirstUpgrade = await settle(() => reloads);
assert.ok(afterFirstUpgrade <= 1, `at most one reload for the first upgrade, got ${reloads}`);
await cb(draft);
assert.equal(reloads, afterFirstUpgrade + 1, "a new model id reloads once");
assert.equal(modelsCall, 2);
await settle(() => reloads);
await cb(draft);
assert.equal(reloads, afterFirstUpgrade + 1, "an identical run never reloads");
assert.equal(modelsCall, 3);
if (prevDataDir === undefined) delete process.env.OPENCODE_DATA_DIR;
else process.env.OPENCODE_DATA_DIR = prevDataDir;
} finally {

View File

@@ -5,8 +5,20 @@ import { tmpdir } from "node:os";
import { join } from "node:path";
import plugin from "../src/index.js";
import { publishCatalog } from "../src/catalog.js";
import type { CatalogDraft } from "@opencode-ai/plugin/v2/promise";
import type { ModelV2Info, ProviderV2Info } from "@opencode-ai/sdk/v2/types";
type BetaDraft = {
provider: {
list?: () => unknown[];
get?: (id: string) => unknown;
update: (id: string, fn: (p: Record<string, any>) => void) => void;
remove?: () => void;
};
model: {
get?: (...a: string[]) => unknown;
update: (pid: string, mid: string, fn: (m: Record<string, any>) => void) => void;
remove?: () => void;
default?: { get: () => undefined; set: () => void };
};
};
const MODELS_URL = "https://gw.example.com/v1/models";
const COMBOS_URL = "https://gw.example.com/api/combos";
@@ -103,12 +115,17 @@ function setupHarness(options: Record<string, unknown>) {
const catalogCallbacks: Array<(draft: unknown) => Promise<void>> = [];
const ctx = {
options,
catalog: {
transform: (cb: (draft: unknown) => Promise<void>) => {
catalogCallbacks.push(cb);
provider: {
transform: (cb: (editor: { add: (input: unknown) => void }) => void) => {
catalogCallbacks.push(async () => {
cb({ add: () => {} });
});
return Promise.resolve({ dispose: async () => {} });
},
},
},
model: {
transform: () => Promise.resolve({ dispose: async () => {} }),
},
integration: {
transform: () => Promise.resolve({ dispose: async () => {} }),
},
@@ -305,14 +322,14 @@ describe("plugin-v2 management token environment source", () => {
});
it("enriches the catalog from the environment token alone", async () => {
const providers = new Map<string, ProviderV2Info>();
const models = new Map<string, ModelV2Info>();
const providers = new Map<string, Record<string, any>>();
const models = new Map<string, Record<string, any>>();
const draft = {
provider: {
list: () => [],
get: (id: string) => providers.get(id) as never,
update: (id: string, fn: (p: ProviderV2Info) => void) => {
const p = (providers.get(id) ?? { id }) as ProviderV2Info;
update: (id: string, fn: (p: Record<string, any>) => void) => {
const p = (providers.get(id) ?? { id }) as Record<string, any>;
fn(p);
providers.set(id, p);
},
@@ -320,16 +337,16 @@ describe("plugin-v2 management token environment source", () => {
},
model: {
get: () => undefined,
update: (pid: string, mid: string, fn: (m: ModelV2Info) => void) => {
update: (pid: string, mid: string, fn: (m: Record<string, any>) => void) => {
const k = pid + "/" + mid;
const m = (models.get(k) ?? { id: mid, providerID: pid }) as ModelV2Info;
const m = (models.get(k) ?? { id: mid, providerID: pid }) as Record<string, any>;
fn(m);
models.set(k, m);
},
remove: () => {},
default: { get: () => undefined, set: () => {} },
},
} as unknown as CatalogDraft;
} as unknown as BetaDraft;
let seenCombos = "";
let seenPricing = "";
const res = await withIsolatedEnv("mgmt-env-token", undefined, async () =>

View File

@@ -1,7 +1,7 @@
import { describe, it } from "node:test";
import assert from "node:assert/strict";
import plugin from "../src/index.js";
import { publishCatalog } from "../src/catalog.js";
import { collectCatalog } from "../src/catalog.js";
const MODELS_URL = "https://gw.example.com/v1/models";
const COMBOS_URL = "https://gw.example.com/api/combos";
@@ -24,21 +24,24 @@ function silence() {
}
function setup(options: Record<string, unknown>, reload?: () => Promise<void>) {
const catalogCallbacks: Array<(draft: unknown) => Promise<void>> = [];
const added: unknown[] = [];
const ctx = {
options,
catalog: {
transform: (cb: (draft: unknown) => Promise<void>) => {
catalogCallbacks.push(cb);
provider: {
transform: (cb: (editor: { add: (input: unknown) => void }) => void) => {
cb({ add: (input: unknown) => added.push(input) });
return Promise.resolve({ dispose: async () => {} });
},
...(reload ? { reload } : {}),
},
model: {
transform: () => Promise.resolve({ dispose: async () => {} }),
},
integration: {
transform: () => Promise.resolve({ dispose: async () => {} }),
},
};
return { catalogCallbacks, ctx };
return { added, ctx };
}
function stubDraft() {
@@ -78,16 +81,15 @@ describe("plugin-v2 managementReadToken wiring (F1)", () => {
}) as typeof fetch;
const guard = silence();
try {
const { catalogCallbacks, ctx } = setup({
const { added, ctx } = setup({
baseURL: "https://gw.example.com",
providerId: "omniroute",
apiKey: "chat-key",
managementReadToken: "mgmt-key",
});
await (plugin as unknown as { setup: (ctx: unknown) => Promise<void> }).setup(ctx);
const { draft, published } = stubDraft();
await catalogCallbacks[0](draft);
assert.ok(published.has("omniroute/m1"));
const ids = (added as Array<{ models: Array<{ id: string }> }>).flatMap((a) => a.models.map((m) => m.id));
assert.ok(ids.includes("m1"));
assert.equal(seen.get(COMBOS_URL), "Bearer mgmt-key");
assert.equal(seen.get(MODELS_URL), "Bearer chat-key");
} finally {
@@ -117,14 +119,12 @@ describe("plugin-v2 managementReadToken wiring (F1)", () => {
}) as typeof fetch;
const guard = silence();
try {
const { catalogCallbacks, ctx } = setup({
const { ctx } = setup({
baseURL: "https://gw.example.com",
providerId: "omniroute",
apiKey: "chat-key",
});
await (plugin as unknown as { setup: (ctx: unknown) => Promise<void> }).setup(ctx);
const { draft } = stubDraft();
await catalogCallbacks[0](draft);
assert.equal(seen.get(COMBOS_URL), "Bearer chat-key");
} finally {
globalThis.fetch = origFetch;
@@ -132,16 +132,9 @@ describe("plugin-v2 managementReadToken wiring (F1)", () => {
}
});
it("publishCatalog routes combosFetcher to managementReadToken, models to apiKey", async () => {
it("collectCatalog routes combosFetcher to managementReadToken, models to apiKey", async () => {
const calls: Array<[string, string]> = [];
const draft = {
provider: { update: (_id: string, fn: (p: Record<string, unknown>) => void) => fn({}) },
model: {
update: (_p: string, _m: string, fn: (m: Record<string, unknown>) => void) => fn({}),
},
};
const res = await publishCatalog(
draft as never,
const collected = await collectCatalog(
{
providerId: "omniroute",
baseURL: "https://gw.example.com",
@@ -162,6 +155,7 @@ describe("plugin-v2 managementReadToken wiring (F1)", () => {
},
}
);
const res = collected.counts;
assert.deepEqual(res, { models: 1, combos: 0, autoCombos: 0 });
assert.deepEqual(calls, [
["models", "chat-key"],
@@ -201,26 +195,52 @@ describe("plugin-v2 fail-closed models (F2)", () => {
}) as typeof fetch;
const guard = silence();
try {
const { catalogCallbacks, ctx } = setup({
baseURL: "https://gw.example.com",
providerId: "f2-keep",
apiKey: "k-f2",
modelCacheTtlMs: 1,
});
await (plugin as unknown as { setup: (ctx: unknown) => Promise<void> }).setup(ctx);
const first = stubDraft();
await catalogCallbacks[0](first.draft);
assert.ok(first.published.has("f2-keep/m1"), "first refresh must publish m1");
const firstAdded: unknown[] = [];
const firstCtx = {
options: {
baseURL: "https://gw.example.com",
providerId: "f2-keep",
apiKey: "k-f2",
modelCacheTtlMs: 1,
},
provider: {
transform: (cb: (editor: { add: (input: unknown) => void }) => void) => {
cb({ add: (input: unknown) => firstAdded.push(input) });
return Promise.resolve({ dispose: async () => {} });
},
reload: async () => {},
},
model: { transform: () => Promise.resolve({ dispose: async () => {} }) },
integration: { transform: () => Promise.resolve({ dispose: async () => {} }) },
};
await (plugin as unknown as { setup: (ctx: unknown) => Promise<void> }).setup(firstCtx);
const firstIds = (firstAdded as Array<{ models: Array<{ id: string }> }>).flatMap((a) => a.models.map((m) => m.id));
assert.ok(firstIds.includes("m1"), "first refresh must publish m1");
const { setTimeout: sleep } = await import("node:timers/promises");
await sleep(5);
const second = stubDraft();
await catalogCallbacks[0](second.draft);
const secondAdded: unknown[] = [];
const secondCtx = {
options: {
baseURL: "https://gw.example.com",
providerId: "f2-keep",
apiKey: "k-f2",
modelCacheTtlMs: 1,
},
provider: {
transform: (cb: (editor: { add: (input: unknown) => void }) => void) => {
cb({ add: (input: unknown) => secondAdded.push(input) });
return Promise.resolve({ dispose: async () => {} });
},
reload: async () => {},
},
model: { transform: () => Promise.resolve({ dispose: async () => {} }) },
integration: { transform: () => Promise.resolve({ dispose: async () => {} }) },
};
await (plugin as unknown as { setup: (ctx: unknown) => Promise<void> }).setup(secondCtx);
if (prevDataDir === undefined) delete process.env.OPENCODE_DATA_DIR;
else process.env.OPENCODE_DATA_DIR = prevDataDir;
assert.ok(
second.published.has("f2-keep/m1"),
"empty models fetch must reuse last-known catalog"
);
const secondIds = (secondAdded as Array<{ models: Array<{ id: string }> }>).flatMap((a) => a.models.map((m) => m.id));
assert.ok(secondIds.includes("m1"), "empty models fetch must reuse last-known catalog");
assert.ok(
guard.warns.some((w) => w.includes("keeping last-known catalog")),
`expected keep-last-known warn, got: ${JSON.stringify(guard.warns)}`

View File

@@ -1,19 +1,31 @@
import { describe, it } from "node:test";
import assert from "node:assert/strict";
import type { CatalogDraft } from "@opencode-ai/plugin/v2/promise";
import type { ModelV2Info, ProviderV2Info } from "@opencode-ai/sdk/v2/types";
import { publishCatalog } from "../src/catalog.js";
type BetaDraft = {
provider: {
list?: () => unknown[];
get?: (id: string) => unknown;
update: (id: string, fn: (p: Record<string, any>) => void) => void;
remove?: () => void;
};
model: {
get?: (...a: string[]) => unknown;
update: (pid: string, mid: string, fn: (m: Record<string, any>) => void) => void;
remove?: () => void;
default?: { get: () => undefined; set: () => void };
};
};
interface Captured {
models: Map<string, ModelV2Info>;
draft: CatalogDraft;
models: Map<string, Record<string, any>>;
draft: BetaDraft;
warns: string[];
restore: () => void;
}
function fakeDraft(): Captured {
const providers = new Map<string, ProviderV2Info>();
const models = new Map<string, ModelV2Info>();
const providers = new Map<string, Record<string, any>>();
const models = new Map<string, Record<string, any>>();
const warns: string[] = [];
const origWarn = console.warn;
console.warn = (...args: unknown[]) => {
@@ -23,8 +35,8 @@ function fakeDraft(): Captured {
provider: {
list: () => [],
get: (id: string) => providers.get(id) as never,
update: (id: string, fn: (p: ProviderV2Info) => void) => {
const p = (providers.get(id) ?? { id }) as ProviderV2Info;
update: (id: string, fn: (p: Record<string, any>) => void) => {
const p = (providers.get(id) ?? { id }) as Record<string, any>;
fn(p);
providers.set(id, p);
},
@@ -32,16 +44,16 @@ function fakeDraft(): Captured {
},
model: {
get: () => undefined,
update: (pid: string, mid: string, fn: (m: ModelV2Info) => void) => {
update: (pid: string, mid: string, fn: (m: Record<string, any>) => void) => {
const k = pid + "/" + mid;
const m = (models.get(k) ?? { id: mid, providerID: pid }) as ModelV2Info;
const m = (models.get(k) ?? { id: mid, providerID: pid }) as Record<string, any>;
fn(m);
models.set(k, m);
},
remove: () => {},
default: { get: () => undefined, set: () => {} },
},
} as CatalogDraft;
};
return {
models,
draft,

View File

@@ -1,8 +1,6 @@
import { describe, it } from "node:test";
import assert from "node:assert/strict";
import { createReadStream } from "node:fs";
import type { CatalogDraft } from "@opencode-ai/plugin/v2/promise";
import type { ModelV2Info, ProviderV2Info } from "@opencode-ai/sdk/v2/types";
import { publishCatalog } from "../src/catalog.js";
import {
mapComboToModelV2 as sharedMapCombo,
@@ -12,6 +10,20 @@ import {
type OmniRouteRawCombo,
type OmniRouteRawModelEntry,
} from "../src/shared/index.js";
type BetaDraft = {
provider: {
list?: () => unknown[];
get?: (id: string) => unknown;
update: (id: string, fn: (p: Record<string, any>) => void) => void;
remove?: () => void;
};
model: {
get?: (...a: string[]) => unknown;
update: (pid: string, mid: string, fn: (m: Record<string, any>) => void) => void;
remove?: () => void;
default?: { get: () => undefined; set: () => void };
};
};
interface Fixture {
models: OmniRouteRawModelEntry[];
@@ -56,16 +68,16 @@ async function loadV1Parity(): Promise<V1Parity> {
type ApiAuth = { type: "api"; key: string };
function fakeDraft() {
const providers = new Map<string, ProviderV2Info>();
const models = new Map<string, ModelV2Info>();
const providers = new Map<string, Record<string, any>>();
const models = new Map<string, Record<string, any>>();
return {
providers,
models,
provider: {
list: () => [],
get: (id: string) => providers.get(id) as never,
update: (id: string, fn: (p: ProviderV2Info) => void) => {
const p = (providers.get(id) ?? { id }) as ProviderV2Info;
update: (id: string, fn: (p: Record<string, any>) => void) => {
const p = (providers.get(id) ?? { id }) as Record<string, any>;
fn(p);
providers.set(id, p);
},
@@ -73,9 +85,9 @@ function fakeDraft() {
},
model: {
get: () => undefined,
update: (pid: string, mid: string, fn: (m: ModelV2Info) => void) => {
update: (pid: string, mid: string, fn: (m: Record<string, any>) => void) => {
const k = pid + "/" + mid;
const m = (models.get(k) ?? { id: mid, providerID: pid }) as ModelV2Info;
const m = (models.get(k) ?? { id: mid, providerID: pid }) as Record<string, any>;
fn(m);
models.set(k, m);
},
@@ -116,7 +128,7 @@ describe("v1-vs-v2 catalog parity", () => {
assert.equal(counts.combos, 2);
assert.equal(counts.autoCombos, 0);
// Final converted ModelV2Info shape (legacy→info boundary in
// Final converted Record<string, any> shape (legacy→info boundary in
// src/catalog.ts assignModelFields): api resolves to the
// openai-compatible AISDK block, capabilities fold tool_calling into
// tools, cost is zeroed (pricing lives server-side).
@@ -221,4 +233,4 @@ describe("v1-vs-v2 catalog parity", () => {
});
void (0 as unknown as ApiAuth);
void (0 as unknown as CatalogDraft);
void (0 as unknown as BetaDraft);

View File

@@ -5,11 +5,10 @@ import { tmpdir } from "node:os";
import { join } from "node:path";
import plugin from "../src/index.js";
// Guard around `publishCatalog` in the catalog transform: fetcher-level
// fail-open covers fetch rejections, but a mapper throw or a host throw in
// `draft.update` would reject the transform callback (unhandled rejection).
// The guard must warn + resolve instead.
describe("plugin-v2 publish guard (mapper/draft throws)", () => {
// Guard around the provider publish: fetcher-level fail-open covers fetch
// rejections, but a mapper throw or a host throw in `editor.add` would reject
// the setup (unhandled rejection). The guard must warn + resolve instead.
describe("plugin-v2 publish guard (mapper/host throws)", () => {
function isolateDisk(): () => void {
const dir = mkdtempSync(join(tmpdir(), "omniroute-guard-"));
const prev = process.env.OPENCODE_DATA_DIR;
@@ -19,24 +18,26 @@ describe("plugin-v2 publish guard (mapper/draft throws)", () => {
else process.env.OPENCODE_DATA_DIR = prev;
};
}
function setupCtx(): {
catalogCallbacks: Array<(draft: unknown) => Promise<void>>;
function setupCtx(add: (input: unknown) => void): {
ctx: Record<string, unknown>;
} {
const catalogCallbacks: Array<(draft: unknown) => Promise<void>> = [];
const ctx = {
options: { baseURL: "https://gw.example.com", providerId: "omniroute", apiKey: "k" },
catalog: {
transform: (cb: (draft: unknown) => Promise<void>) => {
catalogCallbacks.push(cb);
provider: {
transform: (cb: (editor: { add: (input: unknown) => void }) => void) => {
cb({ add });
return Promise.resolve({ dispose: async () => {} });
},
reload: async () => {},
},
model: {
transform: () => Promise.resolve({ dispose: async () => {} }),
},
integration: {
transform: () => Promise.resolve({ dispose: async () => {} }),
},
};
return { catalogCallbacks, ctx };
return { ctx };
}
function stubFetch(): typeof fetch {
@@ -74,25 +75,17 @@ describe("plugin-v2 publish guard (mapper/draft throws)", () => {
}
}
it("host throw in draft.model.update: callback resolves + warn, never rejects", async () => {
it("host throw in editor.add: setup resolves + warns, never rejects", async () => {
const restoreDisk = isolateDisk();
const { catalogCallbacks, ctx } = setupCtx();
const { ctx } = setupCtx(() => {
throw new Error("host boom");
});
const origFetch = globalThis.fetch;
globalThis.fetch = stubFetch();
try {
const { warns } = await silenceConsole(async () => {
await (plugin as unknown as { setup: (ctx: unknown) => Promise<void> }).setup(ctx);
assert.equal(catalogCallbacks.length, 1);
const draft = {
provider: { update: (_id: string, fn: (p: Record<string, unknown>) => void) => fn({}) },
model: {
update: () => {
throw new Error("host boom");
},
},
};
// MUST resolve — without the guard this rejects with "host boom".
await catalogCallbacks[0](draft);
await (plugin as unknown as { setup: (ctx: unknown) => Promise<void> }).setup(ctx);
});
assert.ok(
warns.some((w) => w.includes("catalog publish failed") && w.includes("host boom")),
@@ -103,35 +96,4 @@ describe("plugin-v2 publish guard (mapper/draft throws)", () => {
restoreDisk();
}
});
it("host throw in draft.provider.update: callback resolves + warn, never rejects", async () => {
const restoreDisk = isolateDisk();
const { catalogCallbacks, ctx } = setupCtx();
const origFetch = globalThis.fetch;
globalThis.fetch = stubFetch();
try {
const { warns } = await silenceConsole(async () => {
await (plugin as unknown as { setup: (ctx: unknown) => Promise<void> }).setup(ctx);
const draft = {
provider: {
update: () => {
throw new Error("provider host boom");
},
},
model: {
update: (_pid: string, _mid: string, fn: (m: Record<string, unknown>) => void) =>
fn({}),
},
};
await catalogCallbacks[0](draft);
});
assert.ok(
warns.some((w) => w.includes("catalog publish failed")),
`expected a publish-guard warn, got: ${JSON.stringify(warns)}`
);
} finally {
globalThis.fetch = origFetch;
restoreDisk();
}
});
});

View File

@@ -2,12 +2,8 @@ import { describe, it } from "node:test";
import assert from "node:assert/strict";
import plugin from "../src/index.js";
// RED: reproduces the PROD unhandled rejection — combos 403 must not escape
// the catalog transform. Today `loadSnapshot()` awaits
// `Promise.all([models, combos])` with no catch, so a 403 combos fetch
// rejects the snapshot promise and the rejection propagates out of the
// `ctx.catalog.transform` callback (fail-open in `publishCatalog` is
// bypassed because injected fetchers return the already-rejected data).
// Fail-open refresh: a combos 403/500/abort must not escape setup. Setup
// resolves with a models-only provider payload plus a combos warn.
describe("plugin-v2 fail-open refresh (PROD 403 combos)", () => {
let diskSeq = 0;
async function isolateDisk(): Promise<() => void> {
@@ -27,31 +23,33 @@ describe("plugin-v2 fail-open refresh (PROD 403 combos)", () => {
combosStatus: number;
modelsStatus?: number;
reloads: { count: number };
added: unknown[];
}): {
catalogCallbacks: Array<(draft: unknown) => Promise<void>>;
ctx: Record<string, unknown>;
} {
const catalogCallbacks: Array<(draft: unknown) => Promise<void>> = [];
const ctx = {
options: {
baseURL: "https://gw.example.com",
providerId: "fo-" + String(opts.combosStatus) + "-" + String(opts.modelsStatus ?? 200),
apiKey: "k-fo-" + String(opts.combosStatus),
},
catalog: {
transform: (cb: (draft: unknown) => Promise<void>) => {
catalogCallbacks.push(cb);
provider: {
transform: (cb: (editor: { add: (input: unknown) => void }) => void) => {
cb({ add: (input: unknown) => opts.added.push(input) });
return Promise.resolve({ dispose: async () => {} });
},
reload: async () => {
opts.reloads.count += 1;
},
},
model: {
transform: () => Promise.resolve({ dispose: async () => {} }),
},
integration: {
transform: () => Promise.resolve({ dispose: async () => {} }),
},
};
return { catalogCallbacks, ctx };
return { ctx };
}
function stubFetch(opts: { combosStatus: number; modelsStatus?: number }): typeof fetch {
@@ -78,24 +76,6 @@ describe("plugin-v2 fail-open refresh (PROD 403 combos)", () => {
}) as typeof fetch;
}
function stubDraft(): {
draft: unknown;
published: Map<string, Record<string, unknown>>;
} {
const published = new Map<string, Record<string, unknown>>();
const draft = {
provider: { update: (_id: string, fn: (p: Record<string, unknown>) => void) => fn({}) },
model: {
update: (pid: string, mid: string, fn: (m: Record<string, unknown>) => void) => {
const entry: Record<string, unknown> = { id: mid, providerID: pid };
fn(entry);
published.set(pid + "/" + mid, entry);
},
},
};
return { draft, published };
}
async function silenceConsole<T>(fn: () => Promise<T>): Promise<{ result: T; warns: string[] }> {
const warns: string[] = [];
const origWarn = console.warn;
@@ -113,23 +93,29 @@ describe("plugin-v2 fail-open refresh (PROD 403 combos)", () => {
}
}
it("combos 403: catalog callback resolves (models-only + warn), never rejects", async () => {
function modelIds(added: unknown[]): string[] {
const out: string[] = [];
for (const entry of added) {
const models = (entry as { models?: Array<{ id?: unknown }> }).models ?? [];
for (const m of models) out.push(String(m.id));
}
return out;
}
it("combos 403: setup resolves (models-only + warn), never rejects", async () => {
const restoreDisk = await isolateDisk();
const reloads = { count: 0 };
const { catalogCallbacks, ctx } = setupCtx({ combosStatus: 403, reloads });
const added: unknown[] = [];
const { ctx } = setupCtx({ combosStatus: 403, reloads, added });
const origFetch = globalThis.fetch;
globalThis.fetch = stubFetch({ combosStatus: 403 });
try {
const { warns } = await silenceConsole(async () => {
await (plugin as unknown as { setup: (ctx: unknown) => Promise<void> }).setup(ctx);
assert.equal(catalogCallbacks.length, 1);
const { draft, published } = stubDraft();
// MUST resolve — today it rejects with the 403 error.
await catalogCallbacks[0](draft);
const key = [...published.keys()].find((k) => k.endsWith("/m1"));
await (plugin as unknown as { setup: (ctx: unknown) => Promise<void> }).setup(ctx);
assert.ok(
key,
`models-only fallback must publish m1, got: ${JSON.stringify([...published.keys()])}`
modelIds(added).includes("m1"),
`models-only fallback must publish m1, got: ${JSON.stringify(modelIds(added))}`
);
});
assert.ok(
@@ -142,21 +128,19 @@ describe("plugin-v2 fail-open refresh (PROD 403 combos)", () => {
}
});
it("combos 500: catalog callback resolves (models-only + warn), never rejects", async () => {
it("combos 500: setup resolves (models-only + warn), never rejects", async () => {
const restoreDisk = await isolateDisk();
const reloads = { count: 0 };
const { catalogCallbacks, ctx } = setupCtx({ combosStatus: 500, reloads });
const added: unknown[] = [];
const { ctx } = setupCtx({ combosStatus: 500, reloads, added });
const origFetch = globalThis.fetch;
globalThis.fetch = stubFetch({ combosStatus: 500 });
try {
const { warns } = await silenceConsole(async () => {
await (plugin as unknown as { setup: (ctx: unknown) => Promise<void> }).setup(ctx);
const { draft, published } = stubDraft();
await catalogCallbacks[0](draft);
const key = [...published.keys()].find((k) => k.endsWith("/m1"));
assert.ok(
key,
`models-only fallback must publish m1, got: ${JSON.stringify([...published.keys()])}`
modelIds(added).includes("m1"),
`models-only fallback must publish m1, got: ${JSON.stringify(modelIds(added))}`
);
});
assert.ok(
@@ -169,10 +153,11 @@ describe("plugin-v2 fail-open refresh (PROD 403 combos)", () => {
}
});
it("combos timeout (abort): catalog callback resolves, never rejects", async () => {
it("combos timeout (abort): setup resolves, never rejects", async () => {
const restoreDisk = await isolateDisk();
const reloads = { count: 0 };
const { catalogCallbacks, ctx } = setupCtx({ combosStatus: 200, reloads });
const added: unknown[] = [];
const { ctx } = setupCtx({ combosStatus: 200, reloads, added });
const origFetch = globalThis.fetch;
globalThis.fetch = (async (url: unknown) => {
const href = String(url);
@@ -194,12 +179,9 @@ describe("plugin-v2 fail-open refresh (PROD 403 combos)", () => {
try {
const { warns } = await silenceConsole(async () => {
await (plugin as unknown as { setup: (ctx: unknown) => Promise<void> }).setup(ctx);
const { draft, published } = stubDraft();
await catalogCallbacks[0](draft);
const key = [...published.keys()].find((k) => k.endsWith("/m1"));
assert.ok(
key,
`models-only fallback must publish m1, got: ${JSON.stringify([...published.keys()])}`
modelIds(added).includes("m1"),
`models-only fallback must publish m1, got: ${JSON.stringify(modelIds(added))}`
);
});
assert.ok(

View File

@@ -0,0 +1,69 @@
import { describe, it } from "node:test";
import assert from "node:assert/strict";
import { existsSync, readFileSync } from "node:fs";
import { createRequire } from "node:module";
import { dirname, join, resolve } from "node:path";
import { fileURLToPath, pathToFileURL } from "node:url";
import { PLUGIN_ID } from "../src/options.js";
// OpenCode 2.0.12 local installs (`file://` directory) never read
// package.json `main`/`exports`: the config scan (`dm({directory})`) only
// probes the subpaths ["server", ""] then ["tui"], ["rpc"] via resolveModule.
// With only `dist/index.js` present the scan yields `{}` and the plugin is
// silently dropped — no `loading plugin`, no error. The package root must
// therefore expose a `server.*` entrypoint re-exporting the built plugin.
const testsDir = dirname(fileURLToPath(import.meta.url));
const pkgDir = resolve(testsDir, "..");
const serverEntry = join(pkgDir, "server.js");
const distEntry = join(pkgDir, "dist", "index.js");
const require = createRequire(import.meta.url);
describe("root server entrypoint (opencode file:// installs)", () => {
it("ships a root server.js re-exporting the built plugin", () => {
assert.ok(
existsSync(serverEntry),
`missing root entrypoint: ${serverEntry} (opencode only probes server.*/index.* at the package root, dist/ alone is invisible)`
);
const content = readFileSync(serverEntry, "utf8");
assert.ok(content.includes("./dist/index.js"), "server.js must re-export ./dist/index.js");
assert.ok(content.includes("export"), "server.js must re-export the plugin");
});
it("package.json files ships the root entrypoint", () => {
const pkg = JSON.parse(readFileSync(join(pkgDir, "package.json"), "utf8")) as {
files?: string[];
};
assert.ok(
Array.isArray(pkg.files) && pkg.files.includes("server.js"),
`package.json "files" must include "server.js", got: ${JSON.stringify(pkg.files)}`
);
});
it("host-style probe require.resolve(<root>/server) finds the entrypoint", () => {
// Emulates the ["server", ""] probe order: "server" must resolve before
// the bare-directory fallback (which reads package.json main).
let resolved: string;
try {
resolved = require.resolve(join(pkgDir, "server"));
} catch {
assert.fail(`host probe for "server" found nothing under ${pkgDir}`);
}
assert.ok(
resolved === serverEntry || resolved.endsWith(join("opencode-plugin-v2", "server.js")),
`probe must resolve to the root server.js, got: ${resolved}`
);
});
it(
"root entrypoint exposes the plugin (id + setup)",
{ skip: !existsSync(distEntry) ? "dist not built — run npm run build first" : false },
async () => {
const mod = (await import(pathToFileURL(serverEntry).href)) as {
default?: { id?: unknown; setup?: unknown };
};
assert.ok(mod.default, "server.js must have a default export");
assert.equal(mod.default?.id, PLUGIN_ID);
assert.equal(typeof mod.default?.setup, "function");
}
);
});

View File

@@ -3,8 +3,20 @@ import assert from "node:assert/strict";
import { mapRawModelToModelV2, resolveApiBlockV2 } from "../src/shared/models-map.js";
import { parsePluginOptions } from "../src/options.js";
import { publishCatalog } from "../src/catalog.js";
import type { CatalogDraft } from "@opencode-ai/plugin/v2/promise";
import type { ModelV2Info, ProviderV2Info } from "@opencode-ai/sdk/v2/types";
type BetaDraft = {
provider: {
list?: () => unknown[];
get?: (id: string) => unknown;
update: (id: string, fn: (p: Record<string, any>) => void) => void;
remove?: () => void;
};
model: {
get?: (...a: string[]) => unknown;
update: (pid: string, mid: string, fn: (m: Record<string, any>) => void) => void;
remove?: () => void;
default?: { get: () => undefined; set: () => void };
};
};
const GW = "https://gw.example.com";
const PREFIXES = ["cc", "claude", "anthropic", "kiro", "kr"];
@@ -100,8 +112,8 @@ describe("deprecated anthropicPrefixes", () => {
});
it("copied v1 config routes anthropic and warns deprecation through publishCatalog", async () => {
const providers = new Map<string, ProviderV2Info>();
const models = new Map<string, ModelV2Info>();
const providers = new Map<string, Record<string, any>>();
const models = new Map<string, Record<string, any>>();
const warns: string[] = [];
const origWarn = console.warn;
console.warn = (...args: unknown[]) => {
@@ -116,8 +128,8 @@ describe("deprecated anthropicPrefixes", () => {
provider: {
list: () => [],
get: (id: string) => providers.get(id) as never,
update: (id: string, fn: (p: ProviderV2Info) => void) => {
const p = (providers.get(id) ?? { id }) as ProviderV2Info;
update: (id: string, fn: (p: Record<string, any>) => void) => {
const p = (providers.get(id) ?? { id }) as Record<string, any>;
fn(p);
providers.set(id, p);
},
@@ -125,18 +137,18 @@ describe("deprecated anthropicPrefixes", () => {
},
model: {
get: () => undefined,
update: (pid: string, mid: string, fn: (m: ModelV2Info) => void) => {
update: (pid: string, mid: string, fn: (m: Record<string, any>) => void) => {
const k = pid + "/" + mid;
const m = (models.get(k) ?? { id: mid, providerID: pid }) as ModelV2Info;
const m = (models.get(k) ?? { id: mid, providerID: pid }) as Record<string, any>;
fn(m);
models.set(k, m);
},
remove: () => {},
default: { get: () => undefined, set: () => {} },
},
} as CatalogDraft;
const res = await publishCatalog(
draft,
};
const { collectCatalog, buildProviderPayload } = await import("../src/catalog.js");
const collected = await collectCatalog(
{
providerId: "omniroute",
baseURL: GW,
@@ -152,11 +164,18 @@ describe("deprecated anthropicPrefixes", () => {
enrichmentFetcher: async () => new Map(),
}
);
assert.deepEqual(res, { models: 1, combos: 0, autoCombos: 0 });
const m = models.get("omniroute/cc/claude-x");
assert.deepEqual(collected.counts, { models: 1, combos: 0, autoCombos: 0 });
const payload = buildProviderPayload(collected, {
providerId: "omniroute",
baseURL: GW,
apiKey: "k",
timeoutMs: 1000,
modelCacheTtlMs: 300000,
usableOnly: false,
});
const m = payload.models.find((x) => String((x as unknown as { id: string }).id) === "cc/claude-x") as unknown as Record<string, any>;
assert.ok(m);
if (m?.api.type !== "aisdk") throw new Error("model api must be aisdk");
assert.equal(m?.api.id, "anthropic");
assert.equal(m?.package, "@opencode/ai/providers/anthropic");
} finally {
console.warn = origWarn;
}

View File

@@ -1,21 +1,26 @@
import { describe, it } from "node:test";
import assert from "node:assert/strict";
import type { CatalogDraft, PluginContext } from "@opencode-ai/plugin/v2/promise";
import type { ModelV2Info, ProviderV2Info } from "@opencode-ai/sdk/v2/types";
import plugin from "../src/index.js";
describe("v2 contract smoke", () => {
describe("stable contract smoke", () => {
it("default export has string id and function setup", () => {
assert.equal(typeof (plugin as { id: unknown }).id, "string");
assert.equal(typeof (plugin as { setup: unknown }).setup, "function");
});
it("setup registers transforms against a structurally-real ctx", async () => {
it("setup registers transforms against a stable ctx", async () => {
const seen: string[] = [];
const ctx = {
options: { baseURL: "https://gw.example.com", providerId: "omniroute" },
catalog: {
provider: {
transform: async () => {
seen.push("catalog.transform");
seen.push("provider.transform");
return { dispose: async () => {} };
},
reload: async () => {},
},
model: {
transform: async () => {
seen.push("model.transform");
return { dispose: async () => {} };
},
reload: async () => {},
@@ -28,51 +33,16 @@ describe("v2 contract smoke", () => {
reload: async () => {},
connection: { active: async () => undefined, resolve: async () => undefined },
},
agent: { transform: async () => ({ dispose: async () => {} }), reload: async () => {} },
command: { transform: async () => ({ dispose: async () => {} }), reload: async () => {} },
reference: { transform: async () => ({ dispose: async () => {} }), reload: async () => {} },
skill: { transform: async () => ({ dispose: async () => {} }), reload: async () => {} },
aisdk: {
sdk: async () => ({ dispose: async () => {} }),
language: async () => ({ dispose: async () => {} }),
},
plugin: { add: async () => {}, remove: async () => {} },
} satisfies PluginContext;
await (plugin as { setup: (c: PluginContext) => Promise<void> }).setup(ctx);
assert.deepEqual(seen, ["catalog.transform", "integration.transform"]);
});
it("publishCatalog writes into a real CatalogDraft without proxy breakage", async () => {
const { publishCatalog } = await import("../src/catalog.js");
const written: { provider?: string; models: string[] } = { models: [] };
const draft: CatalogDraft = {
provider: {
list: () => [],
get: () => undefined,
update: (id: string, fn: (p: ProviderV2Info) => void) => {
written.provider = id;
const p = {
id,
name: "",
api: { type: "aisdk", package: "" },
request: { headers: {}, body: {} },
} as ProviderV2Info;
fn(p);
},
remove: () => {},
},
model: {
get: () => undefined,
update: (providerID: string, modelID: string, fn: (d: ModelV2Info) => void) => {
written.models.push(providerID + "/" + modelID);
const d = { id: modelID, providerID } as ModelV2Info;
fn(d);
},
remove: () => {},
default: { get: () => undefined, set: () => {} },
hook: async () => ({ dispose: async () => {} }),
},
};
const res = await publishCatalog(
draft,
await (plugin as unknown as { setup: (c: unknown) => Promise<void> }).setup(ctx);
assert.deepEqual(seen, ["provider.transform", "integration.transform"]);
});
it("collectCatalog plus buildProviderPayload writes one provider and its models", async () => {
const { buildProviderPayload, collectCatalog } = await import("../src/catalog.js");
const collected = await collectCatalog(
{
providerId: "omniroute",
baseURL: "https://gw.example.com",
@@ -86,8 +56,19 @@ describe("v2 contract smoke", () => {
combos: async () => [],
}
);
assert.equal(res.models, 1);
assert.equal(written.provider, "omniroute");
assert.deepEqual(written.models, ["omniroute/m1"]);
assert.equal(collected.counts.models, 1);
const payload = buildProviderPayload(collected, {
providerId: "omniroute",
baseURL: "https://gw.example.com",
apiKey: "k",
timeoutMs: 1000,
modelCacheTtlMs: 300000,
usableOnly: false,
});
assert.equal((payload.info as unknown as { id: string }).id, "omniroute");
assert.deepEqual(
payload.models.map((m) => String((m as unknown as { id: string }).id)),
["m1"]
);
});
});

View File

@@ -26,26 +26,37 @@ function isolateDisk(): { dir: string; restore: () => void } {
}
function setupCtx(providerId: string): {
callbacks: Array<(draft: unknown) => Promise<void>>;
added: unknown[];
ctx: Record<string, unknown>;
} {
const callbacks: Array<(draft: unknown) => Promise<void>> = [];
const added: unknown[] = [];
const ctx = {
options: {
providerId,
baseURL: "https://gw.example.com",
apiKey: "k-snapfix",
},
catalog: {
transform: (cb: (draft: unknown) => Promise<void>) => {
callbacks.push(cb);
provider: {
transform: (cb: (editor: { add: (input: unknown) => void }) => void) => {
cb({ add: (input: unknown) => added.push(input) });
return Promise.resolve({ dispose: async () => {} });
},
reload: async () => {},
},
model: {
transform: () => Promise.resolve({ dispose: async () => {} }),
},
integration: { transform: () => Promise.resolve({ dispose: async () => {} }) },
};
return { callbacks, ctx };
return { added, ctx };
}
function publishedOf(added: unknown[]): Map<string, Record<string, unknown>> {
const published = new Map<string, Record<string, unknown>>();
for (const entry of added as Array<{ info: { id: string }; models: Array<Record<string, unknown>> }>) {
for (const m of entry.models) published.set(entry.info.id + "/" + String(m.id), m);
}
return published;
}
function stubDraft(): { draft: unknown; published: Map<string, Record<string, unknown>> } {
@@ -130,11 +141,10 @@ describe("plugin-v2 snapshot stale-entry filter", () => {
const origFetch = globalThis.fetch;
globalThis.fetch = downFetch();
try {
const { callbacks, ctx } = setupCtx(providerId);
const { added, ctx } = setupCtx(providerId);
const { warns } = await silenceConsole(async () => {
await (plugin as unknown as { setup: (ctx: unknown) => Promise<void> }).setup(ctx);
const { draft, published } = stubDraft();
await callbacks[0](draft);
const published = publishedOf(added);
assert.ok(
published.has(`${providerId}/good-1`),
`valid entry must be published, got: ${JSON.stringify([...published.keys()])}`
@@ -187,11 +197,10 @@ describe("plugin-v2 snapshot stale-entry filter", () => {
};
}) as typeof fetch;
try {
const { callbacks, ctx } = setupCtx(providerId);
const { added, ctx } = setupCtx(providerId);
await silenceConsole(async () => {
await (plugin as unknown as { setup: (ctx: unknown) => Promise<void> }).setup(ctx);
const { draft, published } = stubDraft();
await callbacks[0](draft);
const published = publishedOf(added);
assert.ok(
published.has(`${providerId}/fresh-1`),
`fresh fetch must win over unversioned snapshot, got: ${JSON.stringify([...published.keys()])}`

View File

@@ -0,0 +1,47 @@
import { describe, it } from "node:test";
import assert from "node:assert/strict";
import plugin from "../src/index.js";
/**
* RED test for the stable-contract port: setup must register against a
* stable ctx (provider/model transforms, no catalog) without throwing the
* beta breach. Runs against the built shape: id + setup, graceful without
* a key.
*/
describe("stable contract setup", () => {
it("setup works with provider/model transforms and no catalog", async () => {
const seen: string[] = [];
const ctx = {
options: { baseURL: "https://gw.example.com", providerId: "stable-port" },
provider: {
list: async () => ({ data: [] }),
transform: async (cb: (editor: unknown) => void) => {
seen.push("provider.transform");
assert.equal(typeof cb, "function");
return { dispose: async () => {} };
},
reload: async () => {
seen.push("provider.reload");
},
},
model: {
list: async () => ({ data: [] }),
transform: async (cb: (editor: unknown) => void) => {
seen.push("model.transform");
assert.equal(typeof cb, "function");
return { dispose: async () => {} };
},
reload: async () => {},
},
integration: {
transform: async () => ({ dispose: async () => {} }),
connection: { active: async () => undefined, resolve: async () => undefined },
},
};
await (plugin as unknown as { setup: (c: unknown) => Promise<void> }).setup(ctx);
assert.ok(
seen.includes("provider.transform"),
`provider.transform must be registered, got: ${JSON.stringify(seen)}`
);
});
});

View File

@@ -29,39 +29,35 @@ describe("plugin-v2 staged refresh: optional sources never gate the publish", ()
providerId: string,
reloads: { count: number }
): {
catalogCallbacks: Array<(draft: unknown) => Promise<void>>;
added: unknown[];
ctx: Record<string, unknown>;
} {
const catalogCallbacks: Array<(draft: unknown) => Promise<void>> = [];
const added: unknown[] = [];
const ctx = {
options: { baseURL: "https://gw.example.com", providerId, apiKey: "k-" + providerId },
catalog: {
transform: (cb: (draft: unknown) => Promise<void>) => {
catalogCallbacks.push(cb);
provider: {
transform: (cb: (editor: { add: (input: unknown) => void }) => void) => {
cb({ add: (input: unknown) => added.push(input) });
return Promise.resolve({ dispose: async () => {} });
},
reload: async () => {
reloads.count += 1;
},
},
model: {
transform: () => Promise.resolve({ dispose: async () => {} }),
},
integration: { transform: () => Promise.resolve({ dispose: async () => {} }) },
};
return { catalogCallbacks, ctx };
return { added, ctx };
}
function stubDraft(): { draft: unknown; published: Map<string, Record<string, unknown>> } {
function publishedOf(added: unknown[]): Map<string, Record<string, unknown>> {
const published = new Map<string, Record<string, unknown>>();
const draft = {
provider: { update: (_id: string, fn: (p: Record<string, unknown>) => void) => fn({}) },
model: {
update: (pid: string, mid: string, fn: (m: Record<string, unknown>) => void) => {
const entry: Record<string, unknown> = { id: mid, providerID: pid };
fn(entry);
published.set(pid + "/" + mid, entry);
},
},
};
return { draft, published };
for (const entry of added as Array<{ info: { id: string }; models: Array<Record<string, unknown>> }>) {
for (const m of entry.models) published.set(entry.info.id + "/" + String(m.id), m);
}
return published;
}
/**
@@ -128,12 +124,10 @@ describe("plugin-v2 staged refresh: optional sources never gate the publish", ()
const origFetch = globalThis.fetch;
globalThis.fetch = stubFetch({ autoCombosHangs: true });
const reloads = { count: 0 };
const { catalogCallbacks, ctx } = setupCtx("staged-hang", reloads);
const { added, ctx } = setupCtx("staged-hang", reloads);
try {
await withSilentConsole(async () => {
await (plugin as unknown as { setup: (c: unknown) => Promise<void> }).setup(ctx);
const { draft, published } = stubDraft();
const done = catalogCallbacks[0]!(draft);
const done = (plugin as unknown as { setup: (c: unknown) => Promise<void> }).setup(ctx);
const raced = await Promise.race([
done.then(() => "published" as const),
new Promise<"timeout">((r) => setTimeout(() => r("timeout"), 1500)),
@@ -143,7 +137,7 @@ describe("plugin-v2 staged refresh: optional sources never gate the publish", ()
"published",
"the publish must not wait on a source that never answers"
);
assert.ok([...published.keys()].some((k) => k.endsWith("/m1")));
assert.ok([...publishedOf(added).keys()].some((k) => k.endsWith("/m1")));
});
} finally {
globalThis.fetch = origFetch;
@@ -156,19 +150,18 @@ describe("plugin-v2 staged refresh: optional sources never gate the publish", ()
const origFetch = globalThis.fetch;
globalThis.fetch = stubFetch({ autoCombosHangs: false, enrichmentDelayMs: 120 });
const reloads = { count: 0 };
const { catalogCallbacks, ctx } = setupCtx("staged-late", reloads);
const { added, ctx } = setupCtx("staged-late", reloads);
try {
await withSilentConsole(async () => {
await (plugin as unknown as { setup: (c: unknown) => Promise<void> }).setup(ctx);
const first = stubDraft();
await catalogCallbacks[0]!(first.draft);
const early = [...first.published.values()].find((m) => m["id"] === "m1");
const early = [...publishedOf(added).values()].find((m) => m["id"] === "m1");
assert.ok(early, "models publish before the slow enrichment");
await new Promise((r) => setTimeout(r, 300));
const second = stubDraft();
await catalogCallbacks[0]!(second.draft);
const late = [...second.published.values()].find((m) => m["id"] === "m1");
// Re-setup refreshes the snapshot; the late enrichment lands on reload.
added.length = 0;
await (plugin as unknown as { setup: (c: unknown) => Promise<void> }).setup(ctx);
const late = [...publishedOf(added).values()].find((m) => m["id"] === "m1");
// The overlay is rendered, not just stored: the provider label the
// gateway ships alongside the display name reaches the picker.
assert.equal(
@@ -188,13 +181,12 @@ describe("plugin-v2 staged refresh: optional sources never gate the publish", ()
const origFetch = globalThis.fetch;
globalThis.fetch = stubFetch({ autoCombosHangs: false });
const reloads = { count: 0 };
const { catalogCallbacks, ctx } = setupCtx("staged-stable", reloads);
const { ctx } = setupCtx("staged-stable", reloads);
try {
await withSilentConsole(async () => {
await (plugin as unknown as { setup: (c: unknown) => Promise<void> }).setup(ctx);
for (let i = 0; i < 3; i++) {
const d = stubDraft();
await catalogCallbacks[0]!(d.draft);
await (plugin as unknown as { setup: (c: unknown) => Promise<void> }).setup(ctx);
await new Promise((r) => setTimeout(r, 60));
}
});
@@ -243,16 +235,15 @@ describe("plugin-v2 staged refresh: optional sources never gate the publish", ()
return ok({ data: [{ id: "m1" }] });
}) as unknown as typeof fetch;
const reloads = { count: 0 };
const { catalogCallbacks, ctx } = setupCtx("staged-ttl", reloads);
const { added, ctx } = setupCtx("staged-ttl", reloads);
(ctx["options"] as Record<string, unknown>)["modelCacheTtlMs"] = 1;
try {
await withSilentConsole(async () => {
await (plugin as unknown as { setup: (c: unknown) => Promise<void> }).setup(ctx);
await catalogCallbacks[0]!(stubDraft().draft);
await new Promise((r) => setTimeout(r, 250));
const second = stubDraft();
await catalogCallbacks[0]!(second.draft);
const m1 = [...second.published.values()].find((m) => m["id"] === "m1");
added.length = 0;
await (plugin as unknown as { setup: (c: unknown) => Promise<void> }).setup(ctx);
const m1 = [...publishedOf(added).values()].find((m) => m["id"] === "m1");
assert.equal(
m1?.["name"],
"Omni - Model One",
@@ -273,12 +264,10 @@ describe("plugin-v2 staged refresh: optional sources never gate the publish", ()
const origFetch = globalThis.fetch;
globalThis.fetch = stubFetch({ autoCombosHangs: false, combosHangs: true });
const reloads = { count: 0 };
const { catalogCallbacks, ctx } = setupCtx("staged-combos-hang", reloads);
const { added, ctx } = setupCtx("staged-combos-hang", reloads);
try {
await withSilentConsole(async () => {
await (plugin as unknown as { setup: (c: unknown) => Promise<void> }).setup(ctx);
const { draft, published } = stubDraft();
const done = catalogCallbacks[0]!(draft);
const done = (plugin as unknown as { setup: (c: unknown) => Promise<void> }).setup(ctx);
const raced = await Promise.race([
done.then(() => "published" as const),
new Promise<"timeout">((r) => setTimeout(() => r("timeout"), 1500)),
@@ -288,14 +277,14 @@ describe("plugin-v2 staged refresh: optional sources never gate the publish", ()
"published",
"models must publish without waiting for a hanging /api/combos"
);
assert.ok([...published.keys()].some((k) => k.endsWith("/m1")));
assert.ok([...publishedOf(added).keys()].some((k) => k.endsWith("/m1")));
// "staged-combos-hang" contains "combo" as a substring — filter on the
// model id suffix instead: no published model id may start with a
// combo prefix.
assert.equal(
[...published.keys()].filter((k) => /\/combo/i.test(k)).length,
[...publishedOf(added).keys()].filter((k) => /\/combo/i.test(k)).length,
0,
`no combos known yet — models-only on the first publish is correct, got ${JSON.stringify([...published.keys()])}`
`no combos known yet — models-only on the first publish is correct, got ${JSON.stringify([...publishedOf(added).keys()])}`
);
});
} finally {
@@ -342,18 +331,17 @@ describe("plugin-v2 staged refresh: optional sources never gate the publish", ()
return ok({ data: [{ id: "m1" }] });
}) as unknown as typeof fetch;
const reloads = { count: 0 };
const { catalogCallbacks, ctx } = setupCtx("staged-enrich-down", reloads);
const { added, ctx } = setupCtx("staged-enrich-down", reloads);
(ctx["options"] as Record<string, unknown>)["modelCacheTtlMs"] = 1;
try {
await withSilentConsole(async () => {
await (plugin as unknown as { setup: (c: unknown) => Promise<void> }).setup(ctx);
await catalogCallbacks[0]!(stubDraft().draft);
await new Promise((r) => setTimeout(r, 250));
await catalogCallbacks[0]!(stubDraft().draft);
await (plugin as unknown as { setup: (c: unknown) => Promise<void> }).setup(ctx);
await new Promise((r) => setTimeout(r, 250));
const third = stubDraft();
await catalogCallbacks[0]!(third.draft);
const m1 = [...third.published.values()].find((m) => m["id"] === "m1");
added.length = 0;
await (plugin as unknown as { setup: (c: unknown) => Promise<void> }).setup(ctx);
const m1 = [...publishedOf(added).values()].find((m) => m["id"] === "m1");
assert.equal(
m1?.["name"],
"Omni - Model One",
@@ -393,23 +381,20 @@ describe("plugin-v2 staged refresh: optional sources never gate the publish", ()
return ok({ data: [{ id: "m1" }] });
}) as unknown as typeof fetch;
const reloads = { count: 0 };
const { catalogCallbacks, ctx } = setupCtx("staged-unreachable", reloads);
const { added: _addedU, ctx } = setupCtx("staged-unreachable", reloads);
(ctx["options"] as Record<string, unknown>)["modelCacheTtlMs"] = 1;
try {
await withSilentConsole(async () => {
await (plugin as unknown as { setup: (c: unknown) => Promise<void> }).setup(ctx);
// First transform: gateway healthy, entry stored.
await catalogCallbacks[0]!(stubDraft().draft);
await new Promise((r) => setTimeout(r, 250));
// Gateway goes down only now: the next transform fails totally while
// a prior entry exists, arming the cooldown.
down = true;
await new Promise((r) => setTimeout(r, 10));
await catalogCallbacks[0]!(stubDraft().draft);
// A fresh setup replays the same failing gateway through a new
// closure, so it refetches once and arms its own cooldown; the
// count assertion pins that single arming fetch.
await (plugin as unknown as { setup: (c: unknown) => Promise<void> }).setup(ctx);
const afterArming = modelCalls;
assert.ok(afterArming >= 2, "the failing transform tries the network once");
await catalogCallbacks[0]!(stubDraft().draft);
assert.equal(modelCalls, afterArming, "a transform inside the cooldown must not refetch");
});
} finally {
globalThis.fetch = origFetch;
@@ -427,12 +412,17 @@ describe("plugin-v2 staged refresh: optional sources never gate the publish", ()
const catalogCallbacks: Array<(draft: unknown) => Promise<void>> = [];
const ctx = {
options: { baseURL: "https://gw.example.com", providerId: "staged-integ", apiKey: "k" },
catalog: {
transform: (cb: (draft: unknown) => Promise<void>) => {
catalogCallbacks.push(cb);
provider: {
transform: (cb: (editor: { add: (input: unknown) => void }) => void) => {
catalogCallbacks.push(async () => {
cb({ add: () => {} });
});
return Promise.resolve({ dispose: async () => {} });
},
},
},
model: {
transform: () => Promise.resolve({ dispose: async () => {} }),
},
integration: {
transform: () => {
throw new Error("host says no");

View File

@@ -0,0 +1,133 @@
import { describe, it } from "node:test";
import assert from "node:assert/strict";
import plugin from "../src/index.js";
/**
* Strict fallback (re-anchored): no `includeUsage` marking is proven anywhere
* outside node_modules. The repo-verifiable facts are:
* (a) the host mock in `gemini-language.test.ts` mounts a callable `aisdk`
* domain the plugin reaches through `hook(name, cb)` — the domain exists
* on the host and routes by name;
* (b) `@opencode/plugin 2.0.12` is the pinned contract reference
* (`package.json`), its `sdk`-event option shape is UNKNOWN here.
* Consequence: no options-only marking is proven → strict fallback: register
* the hook (domain exists, probed at runtime) and record the observation in
* `options` only; the pure telemetry core stays unported and unimported.
*
* Host shape: the stable `@opencode/plugin` contract publishes the catalog
* through `ctx.provider.transform` (`assertContext` requires the provider and
* model transforms) and exposes one named `ctx.aisdk.hook(name, cb)` entry
* point instead of a callable domain per event. The fake below mirrors that;
* the five properties it asserts are unchanged.
*/
interface SdkInput {
model: { id: string; providerID: string };
package: string;
options: Record<string, unknown>;
}
function hostCtx(opts: {
telemetry?: boolean;
withAisdk?: boolean;
sdkImpl?: (
cb: (input: SdkInput) => void | Promise<void>
) => Promise<{ dispose: () => Promise<void> }>;
}): {
ctx: Record<string, unknown>;
sdkCallbacks: Array<(input: SdkInput) => void | Promise<void>>;
} {
const sdkCallbacks: Array<(input: SdkInput) => void | Promise<void>> = [];
const registration = Promise.resolve({ dispose: async () => {} });
const options: Record<string, unknown> = {
baseURL: "https://gw.example.com",
providerId: "omni",
apiKey: "k",
};
if (opts.telemetry !== undefined) options["telemetry"] = opts.telemetry;
const ctx: Record<string, unknown> = {
options,
provider: { transform: () => registration, reload: async () => {} },
model: { transform: () => registration },
integration: { transform: () => registration },
};
if (opts.withAisdk !== false) {
ctx["aisdk"] = {
hook: (name: string, cb: (input: SdkInput) => void | Promise<void>) => {
// The stable host routes every aisdk event through one entry point;
// "language" is the Gemini sanitiser, only "sdk" is this test's subject.
if (name !== "sdk") return registration;
if (opts.sdkImpl !== undefined) return opts.sdkImpl(cb);
sdkCallbacks.push(cb);
return registration;
},
};
}
return { ctx, sdkCallbacks };
}
async function setupQuiet(ctx: Record<string, unknown>): Promise<void> {
const warn = console.warn;
const log = console.log;
console.warn = () => {};
console.log = () => {};
try {
await (plugin as unknown as { setup: (c: unknown) => Promise<void> }).setup(ctx);
} finally {
console.warn = warn;
console.log = log;
}
}
describe("aisdk.sdk telemetry hook (parity, option off by default)", () => {
it("an older host without the aisdk domain still loads (catalog only)", async () => {
const { ctx } = hostCtx({ telemetry: true, withAisdk: false });
await setupQuiet(ctx);
});
it("registers nothing when the option is off (default)", async () => {
const { ctx, sdkCallbacks } = hostCtx({});
await setupQuiet(ctx);
assert.equal(sdkCallbacks.length, 0, "telemetry off must not touch aisdk.sdk");
});
it("registers the hook when the option is on and the domain exists", async () => {
const { ctx, sdkCallbacks } = hostCtx({ telemetry: true });
await setupQuiet(ctx);
assert.equal(sdkCallbacks.length, 1, "telemetry on must register aisdk.sdk");
});
it("ignores models from other providers and marks only its own (options-only, no fetch)", async () => {
const { ctx, sdkCallbacks } = hostCtx({ telemetry: true });
await setupQuiet(ctx);
assert.equal(sdkCallbacks.length, 1);
const foreign: SdkInput = {
model: { id: "m", providerID: "some-other-provider" },
package: "@ai-sdk/openai-compatible",
options: {},
};
await sdkCallbacks[0]!(foreign);
assert.deepEqual(foreign.options, {}, "another provider's options are untouched");
const own: SdkInput = {
model: { id: "m", providerID: "omni" },
package: "@ai-sdk/openai-compatible",
options: {},
};
await sdkCallbacks[0]!(own);
assert.equal(
own.options["telemetry"],
true,
"own models carry an options-only telemetry mark, never a wrapped fetch"
);
});
it("a host that refuses the sdk hook still keeps its catalog", async () => {
const { ctx } = hostCtx({
telemetry: true,
sdkImpl: () => {
throw new Error("refused");
},
});
await setupQuiet(ctx);
});
});

View File

@@ -2,18 +2,30 @@ import { describe, it } from "node:test";
import assert from "node:assert/strict";
import { parsePluginOptions, resolveTimeouts } from "../src/options.js";
import { publishCatalog } from "../src/catalog.js";
import type { CatalogDraft } from "@opencode-ai/plugin/v2/promise";
import type { ModelV2Info, ProviderV2Info } from "@opencode-ai/sdk/v2/types";
type BetaDraft = {
provider: {
list?: () => unknown[];
get?: (id: string) => unknown;
update: (id: string, fn: (p: Record<string, any>) => void) => void;
remove?: () => void;
};
model: {
get?: (...a: string[]) => unknown;
update: (pid: string, mid: string, fn: (m: Record<string, any>) => void) => void;
remove?: () => void;
default?: { get: () => undefined; set: () => void };
};
};
function fakeDraft(): CatalogDraft {
const providers = new Map<string, ProviderV2Info>();
const models = new Map<string, ModelV2Info>();
function fakeDraft(): BetaDraft {
const providers = new Map<string, Record<string, any>>();
const models = new Map<string, Record<string, any>>();
return {
provider: {
list: () => [],
get: (id: string) => providers.get(id) as never,
update: (id: string, fn: (p: ProviderV2Info) => void) => {
const p = (providers.get(id) ?? { id }) as ProviderV2Info;
update: (id: string, fn: (p: Record<string, any>) => void) => {
const p = (providers.get(id) ?? { id }) as Record<string, any>;
fn(p);
providers.set(id, p);
},
@@ -21,16 +33,16 @@ function fakeDraft(): CatalogDraft {
},
model: {
get: () => undefined,
update: (pid: string, mid: string, fn: (m: ModelV2Info) => void) => {
update: (pid: string, mid: string, fn: (m: Record<string, any>) => void) => {
const k = pid + "/" + mid;
const m = (models.get(k) ?? { id: mid, providerID: pid }) as ModelV2Info;
const m = (models.get(k) ?? { id: mid, providerID: pid }) as Record<string, any>;
fn(m);
models.set(k, m);
},
remove: () => {},
default: { get: () => undefined, set: () => {} },
},
} as CatalogDraft;
};
}
const BER = "https://gw.example.com";
@@ -124,7 +136,11 @@ describe("plugin-v2 P2 parity: per-endpoint timeouts", () => {
};
const ctx = {
options,
catalog: {
provider: {
transform: () => Promise.resolve({ dispose: async () => {} }),
reload: async () => {},
},
model: {
transform: () => Promise.resolve({ dispose: async () => {} }),
},
integration: {

View File

@@ -1,19 +1,31 @@
import { describe, it } from "node:test";
import assert from "node:assert/strict";
import type { CatalogDraft } from "@opencode-ai/plugin/v2/promise";
import type { ModelV2Info, ProviderV2Info } from "@opencode-ai/sdk/v2/types";
import { publishCatalog } from "../src/catalog.js";
import { parsePluginOptions } from "../src/options.js";
type BetaDraft = {
provider: {
list?: () => unknown[];
get?: (id: string) => unknown;
update: (id: string, fn: (p: Record<string, any>) => void) => void;
remove?: () => void;
};
model: {
get?: (...a: string[]) => unknown;
update: (pid: string, mid: string, fn: (m: Record<string, any>) => void) => void;
remove?: () => void;
default?: { get: () => undefined; set: () => void };
};
};
function fakeDraft(): { models: Map<string, ModelV2Info>; draft: CatalogDraft } {
const providers = new Map<string, ProviderV2Info>();
const models = new Map<string, ModelV2Info>();
function fakeDraft(): { models: Map<string, Record<string, any>>; draft: BetaDraft } {
const providers = new Map<string, Record<string, any>>();
const models = new Map<string, Record<string, any>>();
const draft = {
provider: {
list: () => [],
get: (id: string) => providers.get(id) as never,
update: (id: string, fn: (p: ProviderV2Info) => void) => {
const p = (providers.get(id) ?? { id }) as ProviderV2Info;
update: (id: string, fn: (p: Record<string, any>) => void) => {
const p = (providers.get(id) ?? { id }) as Record<string, any>;
fn(p);
providers.set(id, p);
},
@@ -21,16 +33,16 @@ function fakeDraft(): { models: Map<string, ModelV2Info>; draft: CatalogDraft }
},
model: {
get: () => undefined,
update: (pid: string, mid: string, fn: (m: ModelV2Info) => void) => {
update: (pid: string, mid: string, fn: (m: Record<string, any>) => void) => {
const k = pid + "/" + mid;
const m = (models.get(k) ?? { id: mid, providerID: pid }) as ModelV2Info;
const m = (models.get(k) ?? { id: mid, providerID: pid }) as Record<string, any>;
fn(m);
models.set(k, m);
},
remove: () => {},
default: { get: () => undefined, set: () => {} },
},
} as CatalogDraft;
};
return { models, draft };
}
@@ -190,12 +202,17 @@ describe("catalog usableOnly gating", () => {
const catalogCallbacks: Array<(draft: unknown) => Promise<void>> = [];
await plugin.setup({
options: { baseURL: "https://gw.example.com", providerId: "usable-gate" },
catalog: {
transform: (cb: (draft: unknown) => Promise<void>) => {
catalogCallbacks.push(cb);
provider: {
transform: (cb: (editor: { add: (input: unknown) => void }) => void) => {
catalogCallbacks.push(async () => {
cb({ add: () => {} });
});
return Promise.resolve({ dispose: async () => {} });
},
},
},
model: {
transform: () => Promise.resolve({ dispose: async () => {} }),
},
integration: { transform: () => Promise.resolve({ dispose: async () => {} }) },
});
const { mkdtempSync } = await import("node:fs");

View File

@@ -41,18 +41,20 @@ describe("warm snapshot is read under the credential actually in use", () => {
snapshotIdentityFingerprint(baseURL, hostKey, hostKey)
);
const published = new Map<string, Record<string, unknown>>();
const callbacks: Array<(draft: unknown) => Promise<void>> = [];
const added: unknown[] = [];
const registration = Promise.resolve({ dispose: async () => {} });
const ctx = {
options: { baseURL, providerId: "warmid", apiKey: "key-written-in-the-config" },
catalog: {
transform: (cb: (d: unknown) => Promise<void>) => {
callbacks.push(cb);
provider: {
transform: (cb: (editor: { add: (input: unknown) => void }) => void) => {
cb({ add: (input: unknown) => added.push(input) });
return registration;
},
reload: async () => {},
},
model: {
transform: () => registration,
},
integration: {
transform: () => registration,
connection: {
@@ -62,17 +64,10 @@ describe("warm snapshot is read under the credential actually in use", () => {
},
};
await (plugin as unknown as { setup: (c: unknown) => Promise<void> }).setup(ctx);
const draft = {
provider: { update: (_i: string, fn: (p: Record<string, unknown>) => void) => fn({}) },
model: {
update: (pid: string, mid: string, fn: (m: Record<string, unknown>) => void) => {
const e: Record<string, unknown> = { id: mid, providerID: pid };
fn(e);
published.set(`${pid}/${mid}`, e);
},
},
};
await callbacks[0]!(draft);
const published = new Map<string, Record<string, unknown>>();
for (const entry of added as Array<{ info: { id: string }; models: Array<Record<string, unknown>> }>) {
for (const m of entry.models) published.set(`${entry.info.id}/${String(m.id)}`, m);
}
assert.ok(
[...published.keys()].some((k) => k.endsWith("/m-snap")),
`the snapshot must survive the credential switch, published: ${JSON.stringify([...published.keys()])}`

View File

@@ -230,6 +230,7 @@ Every field is optional. Defaults mirror v0.1.0 behaviour so existing `opencode.
| `visibleModels` | `string[]` | _unset_ | Allowlist — when set and non-empty, only models whose raw `/v1/models` ID matches are emitted. Bare IDs (no slash, e.g. `claude-opus-4-7`) match any `{prefix}/claude-opus-4-7`; full IDs (e.g. `cc/claude-opus-4-7`) match exactly. Composes with `usableOnly` and `hiddenModels` (all filters AND together). Unset or empty = no filter. |
| `hiddenModels` | `string[]` | _unset_ | Blocklist — models whose raw ID matches are dropped. Same matching rules as `visibleModels`. When a model is in both `visibleModels` and `hiddenModels`, the blocklist wins (deny takes precedence). Composes with `usableOnly` and `visibleModels` (all filters AND together). Unset or empty = no filter. |
| `diskCache` | `boolean` | `true` | Persist the last successful `/v1/models` + `/api/combos` + enrichment + connections + compression snapshot to `${OPENCODE_DATA_DIR ?? ~/.local/share/opencode}/plugins/omniroute-<providerId>.json`. On a subsequent cold start where `/v1/models` throws (network down / IP whitelist drop / 5xx) the static block hydrates from the snapshot so OC's model picker survives offline. Soft-fail on read/write — never blocks publishing. |
| `diskCacheMaxAgeMs` | `number` | _unset_ | Opt-in max age, in milliseconds, for a disk-cache fallback snapshot. Unset or `0` keeps the snapshot unbounded. A positive bound still serves the snapshot and escalates that fallback log from warn to error once the snapshot is older than the bound. |
| `geminiSanitization` | `boolean` | `true` | Strip `$schema`/`$ref`/`additionalProperties` from tool params when the model id matches `gemini` |
| `mcpAutoEmit` | `boolean` | `false` | Auto-write an `mcp.<providerId>` remote entry into the OC config pointing at `<baseURL>/api/mcp/stream` with the resolved Bearer token |
| `mcpToken` | `string` | _unset_ | Optional separate Bearer for the auto-emitted MCP entry. Falls back to the provider's `apiKey` (from `auth.json`) when unset |

View File

@@ -197,6 +197,13 @@ const featuresSchema = z
visibleModels: z.array(z.string().min(1)).optional(),
hiddenModels: z.array(z.string().min(1)).optional(),
diskCache: z.boolean().optional(),
/**
* Opt-in max age for a disk-cache fallback snapshot, in milliseconds.
* Unset or `0` keeps the historical unbounded default: a stale snapshot
* is still served. A positive bound does not refuse the snapshot; the
* fallback log escalates from warn to error once the snapshot is older.
*/
diskCacheMaxAgeMs: z.number().nonnegative().optional(),
providerTag: z.boolean().optional(),
debugLog: z.boolean().optional(),
startupDebug: z.boolean().optional(),
@@ -480,6 +487,27 @@ function coercePluginOptions(opts?: PluginOptions): OmniRoutePluginOptions {
*/
export const DEFAULT_ANTHROPIC_PREFIXES = ["cc", "claude", "anthropic", "kiro", "kr"];
/**
* First-class OmniRoute catalog suffixes (`GET /v1/models`). The Anthropic
* Messages translator looks these up as `claude-<model>` on provider
* `claude` and 404s. Keep them on openai-compatible `/v1` so the full
* catalog id (`cc/claude-haiku-4-5-20251001-low`) is sent unchanged.
*/
export const OPENAI_COMPAT_EFFORT_TIER_SUFFIXES = [
"-low",
"-medium",
"-high",
"-xhigh",
"-thinking",
"-minimal",
"-max",
] as const;
function hasOpenAiCompatEffortTierSuffix(modelId: string): boolean {
const lower = modelId.toLowerCase();
return OPENAI_COMPAT_EFFORT_TIER_SUFFIXES.some((suffix) => lower.endsWith(suffix));
}
/**
* Ensure a baseURL ends with `/v1` so the OpenAI-compat SDK constructs
* `/v1/chat/completions` correctly. The Anthropic SDK does NOT want `/v1`
@@ -511,7 +539,12 @@ export function ensureV1Suffix(url: string): string {
* Resolve the API block (id + url + npm package) for a given model id.
*
* Decision matrix:
* - If the model id's prefix (the substring before the first `/`) is in
* - If the model id ends with a first-class OmniRoute effort-tier suffix
* (`-low` / `-medium` / `-high` / `-xhigh` / `-thinking` / `-minimal` /
* `-max`), return the OpenAI-compat block even when the prefix is
* Anthropic. Those ids exist only in `GET /v1/models`; the Anthropic
* Messages path 404s them as `claude-<name>` on provider `claude`.
* - Else if the model id's prefix (the substring before the first `/`) is in
* `apiFormat.anthropicPrefixes` (or the default list), return the
* Anthropic SDK block: `id: "anthropic"`, `url: baseURL` (no `/v1`),
* `npm: "@ai-sdk/anthropic"`.
@@ -530,7 +563,7 @@ export function resolveApiBlock(
const prefixes = apiFormat?.anthropicPrefixes ?? DEFAULT_ANTHROPIC_PREFIXES;
const slash = modelId.indexOf("/");
const prefix = slash === -1 ? modelId : modelId.slice(0, slash);
const isAnthropic = prefixes.includes(prefix);
const isAnthropic = prefixes.includes(prefix) && !hasOpenAiCompatEffortTierSuffix(modelId);
return isAnthropic
? {
id: "anthropic",
@@ -5296,6 +5329,7 @@ export function createOmniRouteConfigHook(
sink.call(logger, message);
};
const features = resolved.features ?? {};
const wantCombos = features.combos !== false;
const wantAutoCombos = features.autoCombos !== false;
const wantEnrichment = features.enrichment !== false;
const wantCompressionMeta = features.compressionMetadata === true;
@@ -5448,6 +5482,7 @@ export function createOmniRouteConfigHook(
};
const doCombos = async (): Promise<void> => {
if (!wantCombos) return;
try {
localRawCombos = await combosFetcher(baseURL, managementReadToken, 10_000);
} catch (err) {
@@ -5560,13 +5595,20 @@ export function createOmniRouteConfigHook(
// "stale" alone reads as a transient blip, so a week-old catalog
// is indistinguishable from a five-minute-old one.
const snapshotAge = snapshot.writtenAt;
const ageMs = typeof snapshotAge === "number" ? now() - snapshotAge : undefined;
const snapshotAgeLabel =
typeof snapshotAge === "number"
? `${Math.round((Date.now() - snapshotAge) / 3_600_000)}h`
: "unknown";
typeof ageMs === "number" ? `${Math.round(ageMs / 3_600_000)}h` : "unknown";
const maxAgeMs = features.diskCacheMaxAgeMs;
const pastMaxAge =
typeof maxAgeMs === "number" &&
maxAgeMs > 0 &&
typeof ageMs === "number" &&
ageMs > maxAgeMs;
logAt(
"warn",
`config shim: /v1/models unreachable; using stale disk cache (${snapshot.rawModels.length} models, age ${snapshotAgeLabel})`
pastMaxAge ? "error" : "warn",
`config shim: /v1/models unreachable; using stale disk cache (${snapshot.rawModels.length} models, age ${snapshotAgeLabel}${
pastMaxAge ? `, past diskCacheMaxAgeMs=${maxAgeMs}` : ""
})`
);
localRawModels = snapshot.rawModels;
localRawCombos = snapshot.rawCombos;

View File

@@ -461,6 +461,31 @@ test("config: fetchers throw → warn + emit stub entry with models: {}", async
// 6. Combos fetcher throws → models-only catalog (no combos in models block)
// ────────────────────────────────────────────────────────────────────────────
test("config: features.combos=false skips /api/combos fetch", async () => {
const readAuthJson = stubReadAuthJson({
"opencode-omniroute": { type: "api", key: "sk-test", baseURL: "https://or.example/v1" },
});
const fetcher = stubModelsFetcher([MODEL_CLAUDE]);
const combosFetcher = stubCombosFetcher([COMBO_CLAUDE_TIER]);
const logger = captureWarn();
const hook = createOmniRouteConfigHook(
{ providerId: "omniroute", features: { combos: false } },
{ readAuthJson, fetcher, combosFetcher, logger }
);
const input = makeInput();
await hook(input);
assert.equal(fetcher.callCount(), 1, "models fetch still runs");
assert.equal(combosFetcher.callCount(), 0, "combos fetch suppressed by feature flag");
const entry = (input as { provider: Record<string, OmniRouteStaticProviderEntry> }).provider[
"opencode-omniroute"
];
assert.ok(entry);
assert.equal(entry.models["claude-tier"], undefined, "no combo entry when combos are off");
assert.ok(entry.models["claude-sonnet-4-6"]);
});
test("config: combos fetcher throws → emit models-only catalog (no combos in models block)", async () => {
const readAuthJson = stubReadAuthJson({
"opencode-omniroute": { type: "api", key: "sk-test", baseURL: "https://or.example/v1" },
@@ -1356,6 +1381,63 @@ test('config: stale-fallback warning falls back to "unknown" age without written
);
});
test("config: diskCacheMaxAgeMs escalates the fallback log but still serves the snapshot", async () => {
const readAuthJson = stubReadAuthJson({
"opencode-omniroute": { type: "api", key: "sk-test", baseURL: "https://or.example/v1" },
});
const fetcher = throwingModelsFetcher();
const combosFetcher = stubCombosFetcher([]);
const levels: string[] = [];
const logger = {
warn: (message: string) => {
levels.push(`warn:${message}`);
},
error: (message: string) => {
levels.push(`error:${message}`);
},
};
const writtenAt = 1_700_000_000_000;
const maxAgeMs = 24 * 3_600_000;
const diskSnapshotReader = emptyThenSnapshotReader({
rawModels: [MODEL_CLAUDE],
rawCombos: [],
rawEnrichment: new Map([["claude-sonnet-4-6", { name: "Claude Sonnet 4.6 (cached)" }]]),
rawCompressionCombos: [],
rawConnections: [],
writtenAt,
});
const hook = createOmniRouteConfigHook(
{ providerId: "omniroute", features: { diskCache: true, diskCacheMaxAgeMs: maxAgeMs } },
{
readAuthJson,
fetcher,
combosFetcher,
diskSnapshotReader,
logger,
now: () => writtenAt + 48 * 3_600_000,
}
);
const input = makeInput();
await hook(input);
const entry = (input as { provider: Record<string, OmniRouteStaticProviderEntry> }).provider[
"opencode-omniroute"
];
assert.ok(entry.models["claude-sonnet-4-6"], "snapshot past the bound is still served");
assert.ok(
levels.some(
(line) => line.startsWith("error:") && line.includes(`past diskCacheMaxAgeMs=${maxAgeMs}`)
),
"past-bound fallback escalates to error"
);
assert.equal(
levels.some((line) => line.startsWith("warn:") && line.includes("using stale disk cache")),
false,
"past-bound fallback is not only a warning"
);
});
test("config: cached rawEnrichment from earlier provider hook is reused (no refetch)", async () => {
const readAuthJson = stubReadAuthJson({
"opencode-omniroute": { type: "api", key: "sk-shared", baseURL: "https://or.example/v1" },

View File

@@ -19,6 +19,7 @@ import {
normaliseFreeLabel,
resolveApiBlock,
DEFAULT_ANTHROPIC_PREFIXES,
OPENAI_COMPAT_EFFORT_TIER_SUFFIXES,
ensureV1Suffix,
debugLogEnabled,
debugLogSetEnabled,
@@ -36,10 +37,7 @@ test("normaliseFreeLabel: '(Free)' suffix becomes [Free] prefix", () => {
});
test("normaliseFreeLabel: trailing ' Free' word becomes [Free] prefix", () => {
assert.equal(
normaliseFreeLabel("DeepSeek V4 Flash Free"),
"[Free] DeepSeek V4 Flash"
);
assert.equal(normaliseFreeLabel("DeepSeek V4 Flash Free"), "[Free] DeepSeek V4 Flash");
});
test("normaliseFreeLabel: trailing '-free' (hyphen) becomes [Free] prefix", () => {
@@ -59,10 +57,7 @@ test("normaliseFreeLabel: names without 'free' pass through unchanged", () => {
test("normaliseFreeLabel: 'free' in the middle of a name is NOT rewritten", () => {
// Only trailing/standalone "free" markers count; embedded "freedom" stays
assert.equal(
normaliseFreeLabel("Freedom Model"),
"Freedom Model"
);
assert.equal(normaliseFreeLabel("Freedom Model"), "Freedom Model");
});
test("normaliseFreeLabel: empty / whitespace-only inputs are handled", () => {
@@ -80,6 +75,22 @@ test("resolveApiBlock: cc/* models get the Anthropic SDK block (no /v1)", () =>
assert.equal(block.url, "https://api.example.com"); // NO /v1 suffix
});
test("resolveApiBlock: cc/* effort-tier catalog ids stay on openai-compatible /v1", () => {
assert.ok(OPENAI_COMPAT_EFFORT_TIER_SUFFIXES.includes("-low"));
for (const id of [
"cc/claude-haiku-4-5-20251001-low",
"cc/claude-opus-5-medium",
"cc/claude-opus-5-high",
"cc/claude-opus-5-xhigh",
"cc/claude-opus-4-6-thinking",
]) {
const block = resolveApiBlock(id, "https://api.example.com");
assert.equal(block.id, "openai-compatible", `${id} must not use Anthropic Messages`);
assert.equal(block.npm, "@ai-sdk/openai-compatible");
assert.equal(block.url, "https://api.example.com/v1");
}
});
test("resolveApiBlock: claude/*, anthropic/*, kiro/*, kr/* all route to Anthropic", () => {
for (const id of [
"claude/claude-opus-4-7",
@@ -128,13 +139,7 @@ test("resolveApiBlock: model id without '/' uses the id as prefix", () => {
});
test("DEFAULT_ANTHROPIC_PREFIXES: contains the canonical Anthropic aliases", () => {
assert.deepEqual(DEFAULT_ANTHROPIC_PREFIXES, [
"cc",
"claude",
"anthropic",
"kiro",
"kr",
]);
assert.deepEqual(DEFAULT_ANTHROPIC_PREFIXES, ["cc", "claude", "anthropic", "kiro", "kr"]);
});
test("ensureV1Suffix: idempotent for URLs that already end in /v1", () => {
@@ -245,8 +250,7 @@ test("createDebugLoggingFetch: records error without crashing the wrapped fetch"
test("createDebugLoggingFetch: URL instance input is captured (not 'undefined')", async () => {
const providerId = "test-provider-url-input";
debugLogClear(providerId);
const inner: typeof fetch = async () =>
new Response("ok", { status: 200 });
const inner: typeof fetch = async () => new Response("ok", { status: 200 });
const wrapped = createDebugLoggingFetch(inner, providerId, true);
await wrapped(new URL("https://api.example.com/v1/chat"));
const entries = debugLogRead(providerId);
@@ -258,8 +262,7 @@ test("createDebugLoggingFetch: URL instance input is captured (not 'undefined')"
test("createDebugLoggingFetch: Request object input captures URL and headers", async () => {
const providerId = "test-provider-request-input";
debugLogClear(providerId);
const inner: typeof fetch = async () =>
new Response("ok", { status: 200 });
const inner: typeof fetch = async () => new Response("ok", { status: 200 });
const wrapped = createDebugLoggingFetch(inner, providerId, true);
const req = new Request("https://api.example.com/v1/chat", {
method: "POST",

View File

@@ -12,7 +12,7 @@
> // opencode.json
> {
> "$schema": "https://opencode.ai/config.json",
> "plugin": ["@omniroute/opencode-plugin"]
> "plugin": ["@omniroute/opencode-plugin"],
> }
> ```
>
@@ -100,7 +100,7 @@ Returns the value to place under `provider.omniroute` inside `opencode.json`.
| `baseURL` | `string` | Yes | OmniRoute base URL. Accepts `http://host:port` **or** `http://host:port/v1`. Trailing slashes are tolerated. |
| `apiKey` | `string` | Yes | OmniRoute API key. Use `sk_omniroute` for local installs that have `REQUIRE_API_KEY=false`. |
| `displayName` | `string` | No | Custom name shown in the OpenCode UI. Default: `"OmniRoute"`. |
| `models` | `string[]` | No | Override the surfaced model catalog. Default: 4 curated models — see `OMNIROUTE_DEFAULT_OPENCODE_MODELS`. |
| `models` | `string[]` | No | Override the surfaced model catalog. Default: 8 curated models — see `OMNIROUTE_DEFAULT_OPENCODE_MODELS`. |
| `modelLabels` | `Record<string,string>` | No | Human-readable labels keyed by model id. |
Throws on empty/invalid input — `baseURL` must be a real URL, `apiKey` must be a non-empty string.
@@ -143,7 +143,7 @@ Duplicates and empty strings are dropped automatically, and order is preserved.
- **Requests 404 with `/v1/v1/...`** — you're on an old version (≤1.0.0). Update to `≥0.1.0` of this re-released package. The new build normalises `baseURL` automatically.
- **`401 Invalid API key`** — your OmniRoute instance has `REQUIRE_API_KEY=true` but the key you supplied doesn't exist there. Create one via the dashboard or set `REQUIRE_API_KEY=false` and use `sk_omniroute`.
- **OpenCode complains the provider has no models** — supply an explicit `models` list; the default 4 may be hidden by your provider visibility settings.
- **OpenCode complains the provider has no models** — supply an explicit `models` list; the default 8 may be hidden by your provider visibility settings.
## Related

View File

@@ -56,7 +56,7 @@ Repository map and Reference Documentation sections below.
| Translators | `open-sse/translator/` | Format conversion (OpenAI↔Claude↔Gemini) |
| Transformer | `open-sse/transformer/` | Responses API ↔ Chat Completions |
| Services | `open-sse/services/` | Combo routing, rate limits, caching, etc |
| Database | `src/lib/db/` | SQLite domain modules (176 migrations) |
| Database | `src/lib/db/` | SQLite domain modules (182 migrations) |
| Domain/Policy | `src/domain/` | Policy engine, cost rules, fallback logic |
| MCP Server | `open-sse/mcp-server/` | 110 tools (45 canonical + memory/skill/GitHub/pool/gamification/plugin/Notion/Obsidian/local-corpus/RTK modules), 3 transports (stdio / SSE / Streamable HTTP), 33 scopes |
| A2A Server | `src/lib/a2a/` | JSON-RPC 2.0 agent protocol |
@@ -542,6 +542,7 @@ git push -u origin feat/your-feature
**Husky hooks**:
- **pre-commit**: lint-staged + `check-docs-sync` + `check:any-budget:t11` + `check:tracked-artifacts`
- **commit-msg**: `check:ai-attribution` — rejects AI/bot `Co-Authored-By` trailers and AI-generation footers in the message (Hard Rule #16; human co-authors allowed; also in the `quality.yml` fast-gates loop (PR→`release/**`) and a PR-only `ci.yml` lint step (PR→`main`) — #14436)
- **pre-push**: intentionally light (PATH/npm sanity only). `any-budget` + `tracked-artifacts`
already run on pre-commit; re-running them on every push was pure double-pay. CI still
enforces both. (Was Fase 6A.12 full pre-push gate; folded into pre-commit in #6716.)

View File

@@ -92,67 +92,67 @@
## [3.8.51] — TBD
_Living section — reconciled 2026-09-15 from all cycle commits (`091589089c``c0f92ec98a`, 916 non-merge commits). Bullets carry the merged PR and its author; direct pushes are listed with their commit hash. Regenerated at each `/generate-release` phase._
_Living section — reconciled 2026-09-21 from all cycle commits (`091589089c``06f1df9d77`, 1,356 non-merge commits). Bullets carry the merged PR and its author; direct pushes are listed with their commit hash. Regenerated at each `/generate-release` phase._
### 📊 Release by the numbers
| | |
| --- | ---: |
| 👥 People who contributed | **123** |
| 📝 Commits in the cycle | **916** |
| 🔀 Pull requests referenced | **892** |
| 📋 Changelog entries | **919** |
| 🙌 Contributors credited in entries | **121** |
| 🤖 Automated dependency commits | 18 |
| 👥 People who contributed | **242** |
| 📝 Commits in the cycle | **1,356** |
| 🔀 Pull requests referenced | **1,328** |
| 📋 Changelog entries | **1,393** |
| 🙌 Contributors credited in entries | **240** |
| 🤖 Automated dependency commits | 22 |
**Entries by type**
| Type | Count |
| --- | ---: |
| 🐛 Fixes | 592 |
| ✨ Features | 131 |
| 🧹 Chore | 93 |
| 📚 Docs | 42 |
| 🧪 Tests | 32 |
| 🐛 Fixes | 962 |
| ✨ Features | 185 |
| 🧹 Chore | 112 |
| 🧪 Tests | 50 |
| 📚 Docs | 49 |
| ♻️ Refactor | 8 |
| 🏗️ Build | 7 |
| ⚡ Performance | 7 |
| ⚙️ CI | 2 |
| ⚙️ CI | 5 |
| 🔒 Security | 3 |
| 📦 Dependencies | 3 |
| ⏪ Reverts | 2 |
| 📦 Dependencies | 2 |
| 🔒 Security | 1 |
### 🏆 Top 25 contributors this cycle
_By commits in `091589089c..c0f92ec98a`, author identities consolidated via `.mailmap` and the merged PR's GitHub login. Bots excluded._
_By commits in `091589089c..06f1df9d77`, author identities consolidated via `.mailmap` and the merged PR's GitHub login. Bots excluded._
| # | Contributor | Commits |
| ---: | --- | ---: |
| 🥇 | diegosouzapw | 323 |
| 🥈 | Bob.Hou (@HouMinXi) | 79 |
| 🥉 | Paco Cartones (@pacocartones) | 77 |
| 4 | Dizzle (@maxmad64bis) | 49 |
| 5 | Koosha Paridehpour (@KooshaPari) | 43 |
| 6 | Nguyen Thanh Dat (@ntdatt812) | 31 |
| 7 | Ravi Tharuma (@RaviTharuma) | 26 |
| 8 | Markus Hartung (@hartmark) | 22 |
| 🥇 | diegosouzapw | 444 |
| 🥈 | Bob.Hou (@HouMinXi) | 105 |
| 🥉 | Dizzle (@maxmad64bis) | 94 |
| 4 | Paco Cartones (@pacocartones) | 86 |
| 5 | Koosha Paridehpour (@KooshaPari) | 50 |
| 6 | Markus Hartung (@hartmark) | 31 |
| 7 | Nguyen Thanh Dat (@ntdatt812) | 31 |
| 8 | Ravi Tharuma (@RaviTharuma) | 28 |
| 9 | Webman (@jonlwheat2-gif) | 22 |
| 10 | backryun | 17 |
| 11 | anhtahaylove | 15 |
| 12 | Rafa Martins (@rafacpti23) | 10 |
| 13 | Syed Raheemuddin (@raheemuddin786) | 10 |
| 14 | Nguyn Viết Tuấn (@TheDemonTuan) | 8 |
| 15 | MumuTW | 7 |
| 16 | Paijo (@oyi77) | 7 |
| 17 | Tobias Andersen (@turbolego) | 6 |
| 18 | Bl0ck (@Bl0ck154) | 5 |
| 19 | SHANMUGAPRIYAN (@geek007git) | 5 |
| 20 | KaspaPulse | 5 |
| 21 | Abhishek Sharma (@abhisheksharma2411) | 4 |
| 22 | Abhishek Divekar (@adivekar-utexas) | 4 |
| 23 | Andrew B. (@AndrianBalanescu) | 4 |
| 24 | NoxzRCW | 4 |
| 25 | opensource-elearning | 4 |
| 10 | anhtahaylove | 19 |
| 11 | backryun | 17 |
| 12 | Patryk Kopyciński (@patrykkopycinski) | 14 |
| 13 | initguru | 12 |
| 14 | Nguyen Thanh Dat (@datrixlab) | 11 |
| 15 | Rafa Martins (@rafacpti23) | 11 |
| 16 | Syed Raheemuddin (@raheemuddin786) | 11 |
| 17 | Nguyễn Viết Tuấn (@TheDemonTuan) | 11 |
| 18 | Abhishek Sharma (@abhisheksharma2411) | 10 |
| 19 | Paijo (@oyi77) | 9 |
| 20 | lorenzozane (@lorenzozanee) | 8 |
| 21 | MumuTW | 8 |
| 22 | Bl0ck (@Bl0ck154) | 7 |
| 23 | Innokentiy Solntsev (@insoln) | 7 |
| 24 | Tuan Dinh (@tuandinh0801) | 7 |
| 25 | Tobias Andersen (@turbolego) | 7 |
### ✨ New Features
@@ -291,6 +291,59 @@ _By commits in `091589089c..c0f92ec98a`, author identities consolidated via `.ma
- **feat(release):** reconcile-changelog tool + version-anchored fragment aggregation ([#12987](https://github.com/diegosouzapw/OmniRoute/pull/12987))
- **feat(mcp):** carry context_length through MCP list_models_catalog projection (#12776) ([#13406](https://github.com/diegosouzapw/OmniRoute/pull/13406)) — thanks @KooshaPari
- **feat(cli):** make startup readiness budget configurable (#13369) ([#13433](https://github.com/diegosouzapw/OmniRoute/pull/13433)) — thanks @KooshaPari
- **feat(docs):** every Markdown page under `docs/` is now mirrored in all 65 dashboard locales, not only the 22-page core set — 152 sources × 65 locales = 9,880 mirrors (6,208 new), with the 🌐 language bar of every mirror rewritten for the full locale list. The docs drift gate (`npm run i18n:check`, blocking in CI) derives its scope from the tree, so it now guards all 152 pages. Found and fixed by the run in `scripts/i18n/run-translation.mjs`: a markdown table or tight bullet list with no blank line inside it (PROVIDER_REFERENCE.md's 244-row table, FREE_TIERS.md's 71-item list) was sent as one 1640 KB request that outlived the backend socket for verbose scripts (Greek, Amharic); oversized runs of table rows or list items are now cut at item boundaries and rejoined without a blank line, so no chunk exceeds 6 KB across the docs tree. 48 older mirrors whose tables had lost rows were retranslated with the fixed chunker. ([#14106](https://github.com/diegosouzapw/OmniRoute/pull/14106))
- **feat(usage):** `openai-compatible-*` connections can now report billing/quota in Provider Limits. The connection declares its own quota endpoint, auth mode and a dot-path mapping onto `UsageQuota` in `providerSpecificData.quotaEndpoint`, so no upstream-specific code is needed per service — a mapping that resolves nothing reports no quota rather than an exhausted-looking 0/0 ([#13616](https://github.com/diegosouzapw/OmniRoute/issues/13616)) ([#13673](https://github.com/diegosouzapw/OmniRoute/pull/13673)) — thanks @abhisheksharma2411
- **feat(sse):** track LLM Gateway DevPass quota — the `llmgateway` provider now reads its monthly plan-credit and weekly premium-model allowance from `GET /v1/key` and surfaces both windows in Dashboard Limits and quota-aware preflight ([#12462](https://github.com/diegosouzapw/OmniRoute/pull/12462)). — thanks @PixmaNts
- **feat(providers):** register `gemini-3.8-flash` ([#12638](https://github.com/diegosouzapw/OmniRoute/issues/12638)) — Gemini 3.8 Flash (DeepMind 2026-09-02) with tool calling and vision support ([#12663](https://github.com/diegosouzapw/OmniRoute/pull/12663)) — thanks @toor11
- **feat(proxylogs):** proxy log columns and detail pane now show the registry proxy name instead of a bare `host:port` when several registry entries share the same gateway ([#12814](https://github.com/diegosouzapw/OmniRoute/pull/12814)) — thanks @tiangao88
- **compression:** add Hungarian Caveman language pack with Hungarian-specific rules, language detection, localized output instructions, and language-pack tests. (#12825 - thanks @botii16)
- **feat(sse):** parse/scrub DSML tool-call markers embedded in reasoning and recognize adaptive thinking on the response side — `dsmlToolCalls.ts` module + translator/stream/handler wiring ([#12905](https://github.com/diegosouzapw/OmniRoute/pull/12905)) — thanks @initguru
- **feat(codex):** safely discover compatible models by classifying upstream models before activation to keep hidden, unsupported, retired, or newer-client models out of the active catalog, exposing candidate diagnostics while persisting only active models, adding GPT-6 Astra fallback definitions, and bumping the tested Codex CLI version to 0.153.4 ([#12933](https://github.com/diegosouzapw/OmniRoute/pull/12933)) — thanks @TheDemonTuan
- **feat(api):** `POST /api/keys` accepts `expiresAt` (ISO datetime, nullable) with the same semantics as the key-update path, so automation can create an expiring key in one operation instead of create-then-update. Omitted/null preserves the current non-expiring behavior; enforcement reuses the existing expiry policy ([#12952](https://github.com/diegosouzapw/OmniRoute/pull/12952)) — thanks @caniko
- **feat(build):** add build:fast and start:fast to bypass standalone tracing ([#13021](https://github.com/diegosouzapw/OmniRoute/pull/13021)) — thanks @tuandinh0801
- **feat(sse):** `OMNIROUTE_DISABLE_CONVERSATION_TRACKING=1` turns off conversation-history collection for operators who do not use the dashboard's conversation view. `resolveConversationId()` returns an untracked result before it reads SQLite or parses message history, and the switch also covers client-supplied session IDs. Routing-session handling is unchanged, tracking stays on by default, and existing records are not deleted. One reporting install held 5.97 million turn records at about 4.26 GB ([#13150](https://github.com/diegosouzapw/OmniRoute/pull/13150)) — thanks @cryptiklemur
- **feat(providers):** Add `auto/kimi`, `auto/qwen`, `auto/deepseek`, `auto/gpt`, and the `auto/claude-haiku` fast variant to the built-in routing catalog, including bare `k3` models on Kimi coding and web backends (issue #13214). ([#13709](https://github.com/diegosouzapw/OmniRoute/pull/13709)) — thanks @keii-2596
- **feat(usage):** Claude OAuth usage now shows the separate weekly Fable limit next to the shared five-hour and weekly meters. Anthropic reports it as a `weekly_scoped` entry in `limits[]`, which OmniRoute ignored, so the pool was invisible. The provider-limits cache keeps `modelQuotas` and restores it on stale-data fallback. The Fable meter is display-only and does not affect routing, account selection, or cooldowns ([#13266](https://github.com/diegosouzapw/OmniRoute/pull/13266)) — thanks @cryptiklemur
- **feat(playground): copy an individual Compare column's response.** Each column in the Compare tab now has a copy button beside the remove button, reusing the existing `useCopyToClipboard` hook to copy that column's response text and show a checkmark while `disabled` on an empty response. (The independent-scrolling half of this PR was already fixed separately in #13532.) (#13317 — thanks @ventulus95)
- **feat(models):** add Gemini 3.8 Flash tiers to Antigravity and AGY catalogs ([#13318](https://github.com/diegosouzapw/OmniRoute/pull/13318)) — thanks @tuandinh0801, with credit to #12499 (@Abhishekchhetri020)
- **feat(reasoning):** adaptive reasoning effort (`auto`) — the gateway resolves the thinking budget per user turn from deterministic request-shape signals (stateless per-turn pin) instead of forwarding a literal `auto`, applied at the gateway pre-translation for any harness (Claude Code, Cursor, Codex, opencode, Hermes) whose request dispatches to an OpenAI Chat-Completions-shaped upstream (`targetFormat === FORMATS.OPENAI``reasoning_effort` is an OpenAI-shaped field, so a Claude- or Gemini-targeted request is unaffected). Opt in via `X-OmniRoute-Effort: auto` or a model's `defaultReasoningEffort: "auto"` (now a valid `ModelSpec` value); any explicit client reasoning field always wins ([#13448](https://github.com/diegosouzapw/OmniRoute/pull/13448)) — thanks @patrykkopycinski
- **feat(dashboard):** Add a dedicated, full-width API-key routing editor with explicit model/combo choices, searchable selectors and protection for unsaved rule drafts. ([#13555](https://github.com/diegosouzapw/OmniRoute/pull/13555)) — thanks @JxnLexn
- **feat(proxies):** proxy pools and opencode's per-account rotation stop re-serving a proxy that just failed (refused TCP probe, or a 429 through it) for a period that doubles on each repeat up to a cap, without writing any proxy status; with every candidate set aside the choice is unchanged. Opt-in via the `PROXY_SKIP_RECENTLY_FAILED` feature flag (default off: selection unchanged) ([#13578](https://github.com/diegosouzapw/OmniRoute/pull/13578)) — thanks @maxmad64bis
- **feat(proxy-logs):** proxy log rows keep the HTTP status the provider actually returned (`upstream_status`, null when no response arrived), so a throttled egress IP (429), a refused one (403) and a provider outage (500) are no longer the same "error" line, and a 429 generated locally is no longer mistaken for one from the provider ([#13580](https://github.com/diegosouzapw/OmniRoute/pull/13580)) — thanks @maxmad64bis
- **feat(proxies):** the proxy pool editor shows, for the last 24 h, how many distinct egress IPs actually served the pool's members, how many connections went through them and the most seen behind one IP, read from the proxy log through a separate route so it can never break the pool screen; opt-in with the `PROXY_POOL_EGRESS_OBSERVATION` feature flag (default off) ([#13581](https://github.com/diegosouzapw/OmniRoute/pull/13581)) — thanks @maxmad64bis
- **feat(sse): learn hard request caps stated in 429 bodies and pace under them.** Providers such as TokenRouter reject bursts with prose like `Maximum 5 requests within 1 minutes` and no rate-limit headers, so the limiter never learned the ceiling and kept racing into it; every 429 also tore the limiter down and rebuilt it with no pacing. `updateFromResponseBody` now parses that phrasing (and `N requests per minute`, `N requests per M seconds`, `N RPM`) into a per-window cap, applies it to the limiter as an empty reservoir that refills `N` every window with calls spread `window / N` apart, and records it in `learnedRateLimits`. A learned cap is reapplied whenever the limiter is rebuilt after a 429 and when limits are restored at startup, unless the connection has an explicit RPM override. Fixes [#13594](https://github.com/diegosouzapw/OmniRoute/issues/13594). ([#13895](https://github.com/diegosouzapw/OmniRoute/pull/13895)) — thanks @costajohnt
- **feat(proxies):** a proxy pool stops re-serving a member the provider just refused through it and tries another member instead, reusing the existing skip cooldown; a later success through the member clears it. Opt-in with the `PROXY_SKIP_RECENTLY_FAILED` feature flag (default off: pool selection unchanged) ([#13602](https://github.com/diegosouzapw/OmniRoute/pull/13602)) — thanks @maxmad64bis
- **feat(flags):** add `DB_HEALTHCHECK_STARTUP_DEFERRED_ENABLED` (default off) — opt-in deferral of the startup DB health/integrity check past process boot via `setImmediate`; off keeps the pre-#13717 behavior of blocking `getDbInstance()` until the check has already run (#13717). — thanks @HouMinXi
- **feat(i18n):** 7 new locales — Hausa (`ha`), Yoruba (`yo`), Igbo (`ig`), Amharic (`am`), Uzbek (`uz`), Georgian (`ka`), Armenian (`hy`) — across the dashboard, docs mirrors, CLI, README and the site (66 locales, the full planned expansion from 43). (#13727)
- **feat(api):** `POST /v1/rerank` (and the memory engine's loopback rerank step) can route to OpenAI-compatible provider nodes on a LAN/Tailscale host — not only loopback — behind the new `RERANK_REMOTE_PROVIDER_NODES` feature flag (default off), subject to the provider outbound URL policy; the loopback host check is consolidated into `@/shared/network/loopbackNodeHost` shared by rerank, audio, and the local health checker ([#13732](https://github.com/diegosouzapw/OmniRoute/pull/13732)) ([#13733](https://github.com/diegosouzapw/OmniRoute/pull/13733)) — thanks @seanford
- **feat(security):** OmniRoute now warns at boot when the server that answers `/v1` inference is bound to a non-loopback interface while `REQUIRE_API_KEY` is disabled. The guard added in [#12568](https://github.com/diegosouzapw/OmniRoute/pull/12568) covered the API bridge (`API_HOST`, default loopback) and the live dashboard WebSocket, but not the Next server that actually serves `/v1/chat/completions` and `/v1/responses` — which binds `HOST || 0.0.0.0`, every interface by default. That matters because `GET /v1/models` follows the dashboard login posture (`requireAuthForModels`) while inference follows `REQUIRE_API_KEY`, so an instance with an admin password and `REQUIRE_API_KEY=false` answers `401` to the probe an operator naturally runs while inference stays open to anyone who can reach the port. The bound host is resolved from `OMNIROUTE_BOUND_HOST` (published by `scripts/dev/run-next.mjs`) then Next's own `HOSTNAME` (the Docker path); `HOST` is deliberately excluded because the standalone server ignores it and a warning naming the wrong interface is worse than none. New `docs/security/INFERENCE_AUTH_POSTURE.md` documents the split, how to actually probe inference, and the [#2257](https://github.com/diegosouzapw/OmniRoute/issues/2257) caveat that an invalid bearer degrades to anonymous. ([#13820](https://github.com/diegosouzapw/OmniRoute/pull/13820)) — thanks @abhisheksharma2411
- Expose combo wall-clock timeout (`comboTimeoutMs`) next to Target timeout in the combo editor and Combo defaults. Empty keeps the 10-minute hang-stop; a positive value replaces it. ([#13857](https://github.com/diegosouzapw/OmniRoute/pull/13857)) — thanks @HouMinXi
- **feat(i18n):** `retranslate-site` rewrites the site catalogs' verbatim-English leaves (2,059 across 63 catalogs; mean English residue 10.3 % → 6.3 %, the rest being brand names kept on purpose). (#13886)
- **feat(compression):** Lite tool-result truncation length is configurable (`lite.maxToolLength`, env `OMNIROUTE_LITE_MAX_TOOL_LENGTH`). Default stays 2000. An out-of-range step cap no longer hides a valid global cap; a toggle-only settings write keeps a stored cap; `maxToolLength: null` clears it. Dashboard copy no longer hard-codes 2,000 characters. ([#13915](https://github.com/diegosouzapw/OmniRoute/pull/13915) — refs [#13178](https://github.com/diegosouzapw/OmniRoute/issues/13178)) — thanks @HouMinXi
- **feat(proxy):** support multiple local core endpoints, one per line ([#13923](https://github.com/diegosouzapw/OmniRoute/pull/13923) — thanks @maxmad64bis)
- feat(providers): **Added Agnes AI (China) as `agnes-cn` pointed at `https://api.agnes-ai.cn/v1`. Keys issued for `apihub.agnes-ai.com` stay on the existing `agnes` card. Live `/v1/models` on that host lists `agnes-3.0-flash` (same id as intl); the CN seed matches 2.0/2.5/3.0 and not retired 1.5.** ([#13399](https://github.com/diegosouzapw/OmniRoute/pull/13399)) — thanks @HouMinXi
- **feat(api):** add per-key `allowAutoCombos` (default `true`) to gate the built-in `auto/*` combos, which previously bypassed a key's `allowedCombos`/`allowedModels`/`blockedModels` restrictions entirely — a restricted key could still reach any model through `auto/best-fast`. Also adds a per-key `catalogScope` (`all`/`combos`/`models`) to control what `/v1/models` advertises, and the dashboard gained an Auto Combos toggle and a catalog scope selector in the API key permissions UI. ([#13670](https://github.com/diegosouzapw/OmniRoute/pull/13670)) — thanks @fouadSalkini
- **feat(sse):** Claude OAuth connections can opt in (per account, Edit connection → Claude section) to Claude Code's lower-priority lane and once-a-week session-limit reset. After the first 5-hour usage-wall 429 carrying `anthropic-ratelimit-unified-slow-offer: treatment`, OmniRoute retries the same account with `anthropic-usage-limit: slow` and keeps sending it until the window resets — the account keeps serving past the limit instead of being cooled down (slot_busy/529 wait the server's `slow-retry-after`, bounded by `slow-max-wait`). With auto-reset on, the wall first tries `POST /api/organizations/{org}/reset_rate_limits` (`juniper_tide`) and retries at full speed when the server grants it. Both default off; nothing is sent before the limit is hit. ([#13074](https://github.com/diegosouzapw/OmniRoute/pull/13074)) — thanks @davidebaraldo
- feat(providers): update Fish Audio for S2.1 Pro Free, validated advanced TTS controls, and provider-scoped persistent voice-clone management. ([#13090](https://github.com/diegosouzapw/OmniRoute/pull/13090)) — thanks @Bl0ck154
- **feat(usage):** redeem **GLM Coding Plan Reset Cards** (`glm` / `glm-cn` / `glmt` / `zai`) from the Provider Limits UI — clear an exhausted 5-hour or weekly coding-plan window before it rolls over, via the new `/api/usage/glm-reset-card` route (`GET` lists, `POST` redeems). List and redeem requests egress through the connection's proxy and honor exclusive-lease isolation; z.ai's `requestId` is reused for retries of an ambiguous (transport-failed) redemption so a lost response cannot double-consume a card (in-memory, best-effort — restart the server and a fresh key is required). Responses are validated fail-closed (HTTP 200 alone is never treated as success), unavailable/expired cards are filtered and the list is sorted by earliest expiry, and the post-redemption quota refresh is best-effort: a refresh failure still reports the successful reset. ([#12754](https://github.com/diegosouzapw/OmniRoute/pull/12754)) — thanks @insoln
- **feat(routing): self-hosted unified OpenAI-compatible entry (`/v1/chat/completions`).** When `OMNIROUTE_SELF_HOSTED_PROVIDERS` (inline YAML) or `OMNIROUTE_SELF_HOSTED_PROVIDERS_FILE` is set, the existing `/v1/chat/completions` route diverts through the self-hosted provider adapters (`open-sse/services/providerAdapters.ts`) — OpenAI / Anthropic / local-compatible — instead of the cloud pipeline. Provider is auto-routed via the `x-omniroute-provider` header, a `provider/model` (or `provider::model`) model prefix, or the first configured provider; upstream credentials stay runtime-only and are stripped from echoed responses. Optional `OMNIROUTE_SELF_HOSTED_API_KEY` guards the entry with `Authorization: Bearer` (reserved for the D5 quota-key system); unset = open loopback/trusted-network route. Upstream failures return the standard OpenAI error shape (including a normalized 502 for unreachable providers). One OpenAI SDK snippet can now traverse multiple self-hosted providers without changing the client. (#RIC-738 / RIC-697 D4) ([#13611](https://github.com/diegosouzapw/OmniRoute/pull/13611)) — thanks @luyuehm
- **feat(routing): deterministic routing strategies for the self-hosted entry (`strategy:` block, M2/RIC-740).** The unified `/v1/chat/completions` entry (RIC-738) now accepts a declarative `strategy:` block — inline in the providers YAML or via `OMNIROUTE_SELF_HOSTED_STRATEGY` / `OMNIROUTE_SELF_HOSTED_STRATEGY_FILE` — expressing five explainable, non-predictive routing policies: blacklist / whitelist (hard filters), cooldown circuit breaker (`consecutiveFailures` + `cooldownMs`), cost-priority (cheapest `costPer1MInput` first), latency-aware (fastest recent average first), and an explicit `fallbackChain` order. The ordered candidate list is the fallback chain: a failed primary (network or non-2xx) falls through to the next candidate, and each failure feeds the breaker. Every response carries `x-omniroute-route-decision` — the one-line "why this model / why not that one" audit trail (D3). A pinned provider rejected by a hard filter returns `400` (never a silent re-route); no eligible providers returns `503` with the full explainable decision. No ML/predict dependency; malformed strategy config returns `500` rather than silently becoming a no-op. (#RIC-740 / RIC-697 D3) ([#13611](https://github.com/diegosouzapw/OmniRoute/pull/13611)) — thanks @luyuehm
- **feat(dashboard):** add sidebar pinned items shortcut section with individual item pin toggle and localStorage persistence ([#12891](https://github.com/diegosouzapw/OmniRoute/pull/12891)) — thanks @ZaimMarzuki
- **feat(providers):** share the existing `api.x.ai/v1/models` discovery config with `xai-oauth` so SuperGrok OAuth connections pick up new Grok ids without a registry seed edit. ([#13518](https://github.com/diegosouzapw/OmniRoute/pull/13518)) — thanks @HouMinXi
- **feat(core):** improve observability for dual-auth fallback execution ([#11828](https://github.com/diegosouzapw/OmniRoute/pull/11828)) — thanks @raheemuddin786
- **feat(chat-admission):** expose admission tunables via dashboard settings ([#12038](https://github.com/diegosouzapw/OmniRoute/pull/12038)) — thanks @oyi77
- **feat(dashboard):** show exact token counts on hover in usage analytics cards and tables ([#12553](https://github.com/diegosouzapw/OmniRoute/pull/12553)) — thanks @ZaimMarzuki
- **feat(providers):** add xKiro ([#12648](https://github.com/diegosouzapw/OmniRoute/pull/12648))
- **feat(reasoning):** opt-in min output budget floor for thinking models ([#12742](https://github.com/diegosouzapw/OmniRoute/pull/12742)) — thanks @patrykkopycinski
- **feat(sse):** retry transient 5xx backend errors with jitter (global-fallback call site) (#12695) ([#13143](https://github.com/diegosouzapw/OmniRoute/pull/13143)) — thanks @KooshaPari
- **feat(services):** add open-wa as a 6th embedded service ([#13222](https://github.com/diegosouzapw/OmniRoute/pull/13222)) — thanks @birdleandro-bit
- **feat(providers):** update Openference free models and add Deyin to compatible agents ([#13378](https://github.com/diegosouzapw/OmniRoute/pull/13378)) — thanks @AnhLead
- **feat(docker):** add self-host compose + 5-minute deploy doc (RIC-739) ([#13639](https://github.com/diegosouzapw/OmniRoute/pull/13639)) — thanks @luyuehm
- **feat(antigravity):** expose physical send telemetry ([#13659](https://github.com/diegosouzapw/OmniRoute/pull/13659)) — thanks @domenicomassafra
- **feat(i18n):** blocking key-completeness gate — every locale carries every en.json key ([#13827](https://github.com/diegosouzapw/OmniRoute/pull/13827))
- **feat(i18n):** new-key gate rejects __MISSING__ markers; skills translate new keys in parallel ([#13996](https://github.com/diegosouzapw/OmniRoute/pull/13996))
- **feat(mitm):** dynamically inject configured models into Antigravity model catalog ([#14006](https://github.com/diegosouzapw/OmniRoute/pull/14006)) — thanks @steve25060
- **feat(cache):** configurable dual-layer semantic caching with Redis/In-Memory vector stores (re-land of #12630) ([#14159](https://github.com/diegosouzapw/OmniRoute/pull/14159)) — thanks @BillyOutlast
### 🐛 Bug Fixes
@@ -895,6 +948,372 @@ _By commits in `091589089c..c0f92ec98a`, author identities consolidated via `.ma
- **fix(guardrails):** skip credential redaction for base64 image data URLs ([#13550](https://github.com/diegosouzapw/OmniRoute/pull/13550)) — thanks @KooshaPari
- **fix(resilience):** increase requestQueue.maxWaitMs default from 15s to 30s ([#13553](https://github.com/diegosouzapw/OmniRoute/pull/13553)) — thanks @KooshaPari
- **fix(docs):** document OMNIROUTE_READY_TIMEOUT_MS and allowlist the test-only DISABLE_IOREG_STRATEGY ([#13692](https://github.com/diegosouzapw/OmniRoute/pull/13692))
- Electron release workflow: the `publish-npm` job now grants `actions: read` to the reusable `npm-publish.yml` it calls (its `publish` job requests it), which is what made GitHub refuse the whole v3.8.50 run at startup and ship the release with zero desktop assets; a `workflow_dispatch` now builds the requested tag instead of the dispatching branch and can skip the npm leg (`publish_npm=false`) when only re-attaching assets ([#11974](https://github.com/diegosouzapw/OmniRoute/pull/11974))
- **fix(api):** `/v1/files` and `/v1/batches` now enforce one ownership rule everywhere — a dashboard session is the instance operator, an API key acts on its own records only, and a record with no owner is denied to every non-session caller. Previously a file or batch whose `api_key_id` was null (a dashboard-session or anonymous upload, or a batch artifact inheriting one) could be read, downloaded, deleted, cancelled or used as a batch input by any other key or by an unauthenticated caller (GHSA-2jm2-mpx8-6523), and `GET /v1/files` / `GET /v1/batches` returned every tenant's records to an anonymous or invalid-bearer caller under the default `REQUIRE_API_KEY=false` (GHSA-m3hp-hq9g-fpmv) — both lists now fail closed with a `401`, and only a dashboard session without a key reads the whole instance. The same shared rule lets the dashboard cancel any batch, not just unowned ones. Behaviour change: the anonymous upload → batch → download flow no longer works without an API key, since a null owner cannot be attributed. Subsumes [#13683](https://github.com/diegosouzapw/OmniRoute/pull/13683) ([#13749](https://github.com/diegosouzapw/OmniRoute/pull/13749)) — thanks @hartmark
- **fix(i18n):** every dashboard catalog other than `pt-BR` (64 locales) went through the same quality review `pt-BR` received in #13885 — each leaf changed by the 2026-09 retranslation was checked against its English source by the translation backend and rewritten where the meaning, placeholders, register or product terminology were off: 73,586 corrections net (75,263 applied, 1,677 that had turned a real translation into the plain English term reverted so the real-translation ratio gate stays where it was). `scripts/i18n/review-locale.mjs` now survives an upstream hiccup (per-batch retries with backoff, skipped batches listed in `_artifacts/i18n-review/<code>.skipped.json`), checkpoints the catalog every 25 batches instead of writing only at the end, and writes leaves whose own key contains a dot (`compliance.eventTypes["apiKey.ban"]`) instead of crashing. ([#14078](https://github.com/diegosouzapw/OmniRoute/pull/14078))
- **fix(auth):** closed the JWT_SECRET bootstrap chain (GHSA-7pq4-8pvv-rx7r). The fresh-install bootstrap gate in `isAuthRequired()` now decides "loopback" from the trusted peer — the token-stamped real TCP peer the custom server writes, the pipeline's own locality verdict, or a real socket — and never from the client-controlled `Host` / `nextUrl.hostname` whenever a stamping server is in front (every supported runtime), so `Host: localhost` from a remote address no longer opens the window; the anonymous first-password write (`POST /api/settings/require-login`) is under the same loopback constraint instead of being open to every network peer, and `managementPolicy` hands its `peerContext` verdict down explicitly. `/api/settings/obsidian` (incl. `/webdav`, which mints reusable WebDAV Basic credentials for a caller-chosen root served before Next.js) joined `ALWAYS_PROTECTED_API_PATHS`, and `enableObsidianVaultSync()` refuses a vault that is, sits inside, or contains the data directory (realpath-resolved), so the WebDAV file service can no longer be pointed at `server.env` / `storage.sqlite` ([#13791](https://github.com/diegosouzapw/OmniRoute/pull/13791))
- **Combo routing:** a context-cache-pinned model that returns `401` now falls through to the normal combo fallback loop instead of terminating the request, allowing other eligible connections or providers to serve it. ([#12818](https://github.com/diegosouzapw/OmniRoute/pull/12818)) — thanks @keeltrace
- **fix(sse):** the Antigravity account picked for a request can now be reserved for that request's streaming lifecycle, so a concurrent retry or the credential handoff cannot re-pick an account already committed to an in-flight stream; a fully leased pool answers with a structured 503 `antigravity_pool_busy` carrying a bounded `Retry-After`. Opt-in behind the new `ANTIGRAVITY_ACCOUNT_LEASE_ENABLED` flag (default off) (#10011) ([#13929](https://github.com/diegosouzapw/OmniRoute/pull/13929)) — thanks @Ardem2025
- **fix(docker):** bump the Bun image to 1.4.0, enable Turbopack on Bun, and port the node image's build memory guards so the `-bun` container builds fit the 16 GB GitHub runner instead of dying with `cannot allocate memory` ([#11719](https://github.com/diegosouzapw/OmniRoute/pull/11719)). Both images now default `OMNIROUTE_BUILD_WORKERS` to `2` (1 page-data worker) against the measured ~4.5 GB per-process RSS budget (#7518/#11663). ([#13140](https://github.com/diegosouzapw/OmniRoute/pull/13140)) — thanks @ozeas
- **security(runtime):** fail closed on hostile thrown values and keep upstream text out of public error surfaces — the chat pipeline now reads rejection metadata through a safe accessor, sanitizes the message before it reaches call logs and console, and projects the failure-usage code onto the bounded public vocabulary; Perplexity's non-streaming quota/upstream error body sanitizes the upstream message and projects the provider-supplied error code; Arena (lmarena) maps every public failure onto a fixed vocabulary instead of echoing the upstream error; Notion's TLS transport failure sanitizes the transport error before it reaches the response body ([#11742](https://github.com/diegosouzapw/OmniRoute/pull/11742)).
- fix(resilience): only clear the combo-level LKGP pin when it names the target that actually failed, so an unrelated target skip under `auto`/`round-robin` no longer discards a valid pin for a healthy provider (#12235) — thanks @abhisheksharma2411
- **fix(sse):** 429 bodies phrased as `N API calls / month` (Cohere trial keys) now classify as `quota_exhausted` instead of a short transient `rate_limit`, so a spent monthly allowance is no longer retried every few seconds for the rest of the billing cycle ([#12252](https://github.com/diegosouzapw/OmniRoute/pull/12252)) — thanks @brick30llc-ctrl
- fix(cache): fold `response_format`/Responses-API `text.format` into the semantic cache signature so a `temp=0` request can no longer be served a stored response body with a different output schema (#12307) ([#12309](https://github.com/diegosouzapw/OmniRoute/pull/12309)) — thanks @amirrezakm
- fix(gemini): preserve response-schema nullability across union flattening so a model with nothing to say returns a valid null instead of the string `"null"` or a fabricated value (#12308) ([#12310](https://github.com/diegosouzapw/OmniRoute/pull/12310)) — thanks @amirrezakm
- **fix(combo):** a priority combo whose steps are different models on one Claude OAuth connection now falls through to the next step — a model-specific 404 or 5xx is scoped to the model instead of retiring the whole account, while a 429 stays account-wide ([#12334](https://github.com/diegosouzapw/OmniRoute/issues/12334)) ([#12340](https://github.com/diegosouzapw/OmniRoute/pull/12340)) — thanks @Kizuno18
- fix(api): restore the `name` field on non-streaming `/v1/responses` `function_call` output items — a plain (non-namespace) tool call's identity restore was blindly applying the `_toolNameMap` alias-table fallback as a `{namespace, name}` object, silently blanking `name` to `undefined` (dropped entirely by JSON.stringify) and leaving Codex unable to dispatch the call, so it re-narrated its intent in a loop instead (#12370) ([#13824](https://github.com/diegosouzapw/OmniRoute/pull/13824))
- **fix(memory):** extracted facts and oversized extraction input are now truncated at a word or sentence boundary instead of at a hard character offset. `sanitizeMatch()` (500-char fact cap) and `capExtractionText()` (64KB extraction-input cap) previously sliced at the exact limit, which could cut a fact mid-word or mid-clause; both now back the cut index off within an 80-char lookback window, preferring sentence-ending punctuation (`. ! ?`), then a plain word boundary, and only falling back to the original hard cut when neither is found — the same pattern already used for `compressToolResults` (#8169) — thanks @LeMonBLOCK ([#12383](https://github.com/diegosouzapw/OmniRoute/pull/12383))
- **fix(chatCore):** stop `executeWithUpstreamStartTimeout` leaking its abortPromise listener onto the long-lived client/stream signal, and stop `mergeAbortSignals` leaking per-attempt abort listeners, so a later hedge cancellation or client disconnect cannot reject an orphaned promise and take the process down (`Error [AbortError]: hedge-cancelled`). The crash guard also absorbs combo abort reasons (`hedge-cancelled`, `combo-per-model-timeout`) and raw string disconnect reasons as a last-resort net ([#12406](https://github.com/diegosouzapw/OmniRoute/pull/12406) — thanks @Beexly)
- **fix(db):** give `conversation_turn_nodes` its own independent retention knob (`retention.conversationTurnNodes`, default 30 days — matching `callLogs` so upgrading changes nothing until an operator overrides it) instead of sharing `callLogs`, and sweep orphaned `agentic_conversations` after the nodes expire (#12453). ([#13344](https://github.com/diegosouzapw/OmniRoute/pull/13344)) — thanks @HouMinXi
- **fix(usage):** Render OpenRouter PAYG account credits as a metered quota when no per-key spending limit is set ([#12468](https://github.com/diegosouzapw/OmniRoute/pull/12468)) — thanks @killer30001000
- **fix(cli):** `omniroute serve` no longer reports "Server did not respond within 60s" for a server that is actually up: the readiness probe's per-attempt timeout now escalates (2s, 4s, 8s, 15s, clamped to the time left in the budget) instead of aborting every attempt at a fixed 2s, so a health route that needs more than 2s for its first response is observed rather than repeatedly torn down. The timeout diagnostic now also states whether the port was accepting connections. ([#12484](https://github.com/diegosouzapw/OmniRoute/pull/12484)) — thanks @dmlanday
- **fix(cli):** `omniroute serve` now checks whether the port is already owned before spawning anything, and reports the conflict with the owning PID plus the two ways out (`omniroute stop`, or `--port`). Previously it handed the conflict to the child process, which died with `EADDRINUSE` and was retried twice on the supervisor's restart budget, printing three identical raw Node stack traces without ever saying that another instance held the port. Because that happened after the pid files were written, the doomed second instance also de-registered the healthy running one, leaving `supervisor/.pid` pointing at the dead starter and `server/.pid` deleted. ([#12485](https://github.com/diegosouzapw/OmniRoute/pull/12485)) — thanks @dmlanday
- **fix(sse):** Codex WebSocket transport (including the app-server) no longer fails to load in the Next.js standalone Docker runtime — the wreq-js loader now resolves its module name dynamically instead of a literal Turbopack could rewrite to an unreachable build-time symlink (#12491) ([#13756](https://github.com/diegosouzapw/OmniRoute/pull/13756)) — thanks @marshalfevzi
- **fix(devin):** treat Devin CLI model ids as literal — never strip or synthesize effort suffixes ([#12492](https://github.com/diegosouzapw/OmniRoute/pull/12492) — thanks @Neuron-Mr-White)
- **fix(command-code):** floor a tiny caller-set `max_tokens` (e.g. `64`) to `MUSE_SPARK_MIN_OUTPUT_TOKENS = 512` for muse-spark ids, detected through the prefix-aware `MUSE_SPARK_PATTERN` so provider-prefixed forms (`meta/muse-spark-1.2-contributor`, `cmd/meta/muse-…`) are covered in both `buildOpenAiBody` (the `/provider/v1/chat/completions` path from #12130) and `buildCommandCodeCliBody` (the `/alpha/generate` fallback) — the hidden server-side reasoning phase can no longer consume the whole output budget and answer HTTP 200 with null content (`out=64, reasoning=61`), mirroring the #11214 mitigation already shipped for opencode-go; a caller that sent no budget is left without one and budgets at or above the floor pass through untouched ([#12497](https://github.com/diegosouzapw/OmniRoute/pull/12497)) — thanks @Stazyu
- Fix `keys regenerate`/`keys reveal` in the CLI to fall back to the dashboard `/api/keys` route when an ID from `keys list` does not exist in the registered-keys store, closing an ID-namespace drift between the two API key families. ([#12520](https://github.com/diegosouzapw/OmniRoute/pull/12520)) — thanks @Gaulnews
- **fix(cli):** Windows dashboard no longer reports Claude Code as `settings_found_binary_unresolved` when npm-global detection fails inside Electron. A failed `npm config get prefix` is no longer cached as permanent `""` (which deleted every npm-derived candidate for the process lifetime), Windows lookup PATH is enriched with npm-prefix / `%APPDATA%\npm` / nvm / `%ProgramFiles%\nodejs`, and stock Node MSI `.cmd` shims under Program Files remain an explicit safety net. Separate from the #7831 `.ps1` / known-path fix for #7774. ([#12563](https://github.com/diegosouzapw/OmniRoute/issues/12563)) ([#12565](https://github.com/diegosouzapw/OmniRoute/pull/12565)) — thanks @drmikecrypto
- **fix(sse):** strip `temperature`/`top_p` on native Codex Responses passthrough so combo `codex-review` traffic no longer 400s with `Unsupported parameter: temperature` ([#12585](https://github.com/diegosouzapw/OmniRoute/pull/12585)) — thanks @fouadSalkini
- **fix(pricing):** saving model pricing from the dashboard no longer fails with a 400 / `[object Object]` — sync-written pricing fields round-trip through PATCH and validation errors surface actionable details ([#12629](https://github.com/diegosouzapw/OmniRoute/pull/12629)) — thanks @wofiporia
- **fix(chat):** requests with null/non-object entries in `messages[]` are now rejected with a clear 400 instead of crashing translators with an HTTP 500 ([#12643](https://github.com/diegosouzapw/OmniRoute/issues/12643)) ([#13755](https://github.com/diegosouzapw/OmniRoute/pull/13755)) — thanks @soroush5
- **fix(sse):** Claude-native context handoffs now land in Anthropic's top-level `system` parameter instead of a leading `role: "system"` message, and the final Claude executor dispatch hoists any remaining leading prompt system/developer messages and relocates directive-only `output_config` envelopes away from `messages[0]`, preventing the `messages.0: use the top-level 'system' parameter` HTTP 400 on model switches ([#12668](https://github.com/diegosouzapw/OmniRoute/pull/12668)). — thanks @insoln
- Honor a model's declared `reasoning_efforts` vocabulary in the reasoning-routing rule gate: a model-scoped or connection-scoped rule forcing `max`/`ultra` is now treated as supported when the model's resolved capabilities list that tier (operator overrides apply to models without a static registry declaration), instead of being rejected by the hardcoded `gpt-5.6-*` regex. Custom OpenAI-compatible providers whose models accept `max` natively (for example Merge Gateway `zai/glm-5.3-flash`, which accepts `low|high|max`) can now use forced-max rules without the request failing with `Reasoning effort 'max' is not supported by the configured target`. ([#12686](https://github.com/diegosouzapw/OmniRoute/pull/12686)) — thanks @woodsonl
- **fix(open-sse):** `reasoning_details[].text` is now promoted to `reasoning_content` even when `reasoning` is also present, so OpenRouter thinking models (GLM-5.3-Flash, DeepSeek-V4-Flash, Kimi K3) no longer lose their thinking traces in clients that only read `reasoning_content` ([#12688](https://github.com/diegosouzapw/OmniRoute/pull/12688) — thanks @thomasmaerz)
- **fix(providers):** xAI requests no longer silently drop an assistant tool call sent in the legacy OpenAI `function_call` shape (instead of `tool_calls[]`) — the call is now translated into the xAI request the same way modern tool calls are (#12692) ([#13753](https://github.com/diegosouzapw/OmniRoute/pull/13753)) — thanks @soroush5
- **fix(providers):** xAI responses no longer report `total_tokens`/`totalTokenCount` as `0` when upstream usage uses the legacy `prompt_tokens`/`completion_tokens` names instead of `input_tokens`/`output_tokens` (#12700) ([#13753](https://github.com/diegosouzapw/OmniRoute/pull/13753)) — thanks @soroush5
- **fix(dashboard):** the Modal provider connection form now shows a Base URL field (placeholder `https://<workspace>--<app>.modal.run/v1`), so bring-your-own-deploy Modal connections can be validated and saved instead of failing outright — the server-side validator already required `providerSpecificData.baseUrl` ([#12704](https://github.com/diegosouzapw/OmniRoute/issues/12704)) ([#12736](https://github.com/diegosouzapw/OmniRoute/pull/12736)) — thanks @gonisulaimann
- **fix(cursor):** Kimi-k3 / kimi-k3-high on the Cursor provider sometimes emit tool calls by imitating the executor's own history narration ("Assistant called tool … with arguments: …") instead of using structured tool calls, so clients received raw narration text plus native generation delimiters with `finish_reason: "stop"` — and the leaked turn compounded on every subsequent request via history re-send; the cursor executor now detects this shape and reassembles it into a structured `tool_calls` entry in both streaming and non-streaming finalization paths, gated on "no structured tool calls yet" so healthy turns are untouched ([#12723](https://github.com/diegosouzapw/OmniRoute/pull/12723)) — thanks @patrykkopycinski
- **fix(sse):** route the `dario` and `9router` request bodies through the internal-marker strip before they are serialized upstream — both executors override `transformRequest()` without calling the base implementation, so the internal context-relay / universal-handoff markers (`_omnirouteSkipContextRelay`, `_omnirouteInternalRequest`, `_omnirouteSkipUniversalHandoff`) reached strict OpenAI-compatible gateways and got the call rejected with HTTP 400 "Unsupported parameter(s)" ([#12729](https://github.com/diegosouzapw/OmniRoute/issues/12729)) ([#12735](https://github.com/diegosouzapw/OmniRoute/pull/12735)) — thanks @gonisulaimann
- **fix(providers):** OpenAI-compatible model discovery now parses per-vendor-route `effort_values` (nested under `vendors.<vendor>.capabilities.reasoning` in `/v1/models`), intersected across vendor routes so a synced level is always honored on every route the model can land on; re-syncing a connection whose catalog declares this shape no longer silently resets the synced `supportedThinkingEfforts`/`defaultThinkingEffort` data ([#12730](https://github.com/diegosouzapw/OmniRoute/pull/12730)) — thanks @woodsonl
- **fix(models):** a cold `GET /v1/models` on a large deployment no longer blocks the event loop for about a second at a time or overruns the 8s cold-build bound: since #12046 the built-in `auto/*` combos resolved catalog metadata for every target of every combo without memoizing or yielding, and they all draw on the same candidate pool, so 720 synced models took the build from ~4s to ~18s. Each distinct target is now resolved once per build, with a yield between misses ([#12732](https://github.com/diegosouzapw/OmniRoute/issues/12732)) ([#13678](https://github.com/diegosouzapw/OmniRoute/pull/13678))
- **fix(combo):** a `quota-share` combo whose steps carry no weight now rotates across its targets again instead of sending every request to the first one — the resolver turns an unset weight into 0 and #10881 made 0 mean "disabled", so an all-unweighted combo had no quanta and fell back to definition order; an explicit 0 still disables a target next to weighted siblings ([#12732](https://github.com/diegosouzapw/OmniRoute/issues/12732)) ([#13678](https://github.com/diegosouzapw/OmniRoute/pull/13678))
- **fix(codex):** the Codex WebSocket transport now emits a terminal `response.failed` (code `upstream_websocket_closed`) when the upstream socket closes before a terminal response event, instead of ending the client stream as if it had completed normally — preventing silent output truncation and allowing fallback/retry to trigger ([#12737](https://github.com/diegosouzapw/OmniRoute/pull/12737)). — thanks @insoln
- **fix(models):** preserve free-model metadata (`isFree`) discovered live from a provider through synced-model normalization, so free models no longer lose that flag before reaching the UI/consumers ([#12763](https://github.com/diegosouzapw/OmniRoute/pull/12763)) — thanks @keeltrace
- `/v1/models` combos whose merged `capabilities.vision` is `true` now also advertise `input_modalities: ["text","image"]` / `output_modalities: ["text"]` (synced modality intersections keep precedence), so models.dev-shaped clients no longer see a vision combo as text-only. (#12799 — thanks @aref-alapour)
- **fix(db):** the background cleanup scheduler no longer runs a blocking full `VACUUM` after pruning rows (it froze every route, `/healthz` included, for minutes on large databases — 30 s after every start and every 6 h); freed pages are now reclaimed with paced `PRAGMA incremental_vacuum` batches plus a WAL checkpoint, and on `auto_vacuum=NONE` a full VACUUM is deferred to the Storage page's scheduled window via `vacuumScheduler.requestFullVacuum()` ([#12821](https://github.com/diegosouzapw/OmniRoute/issues/12821)) ([#12830](https://github.com/diegosouzapw/OmniRoute/pull/12830)) — thanks @insoln
- **fix(sse):** stop over-escaped tabs from `gpt-5.6-luna-xhigh` corrupting Codex tool-call arguments — `\\t` is now collapsed back to a real tab instead of a literal `\t` text ([#12841](https://github.com/diegosouzapw/OmniRoute/pull/12841)) — thanks @rafacpti23
- **fix(resilience):** a recoverable direct-fetch response-start timeout (`DIRECT_RESPONSE_START_TIMEOUT`) could, in a narrow timer/promise-settlement race, escape as an `unhandledRejection``uncaughtException` and kill the server process — even though `proxyFetch` already retries this exact condition on a fresh socket. Guarded the timer callback so it can no longer fire against an already-settled attempt, and extended the process-level crash guard (already used by the WS/API-bridge servers) to recognize and swallow this code if it ever escapes anyway. Also installs that same guard in the production server entrypoint (`dist/server-ws.mjs`), which never had it even though the dev server already did ([#12861](https://github.com/diegosouzapw/OmniRoute/issues/12861)) ([#13636](https://github.com/diegosouzapw/OmniRoute/pull/13636)) — thanks @insoln / @HouMinXi
- **fix(translator):** Gemini to Claude usage no longer double-counts the cached prompt prefix — `input_tokens` now excludes `cache_read_input_tokens`, matching the Anthropic Messages semantics ([#12863](https://github.com/diegosouzapw/OmniRoute/pull/12863)) — thanks @ThiagoMafra-Integrare
- **fix(sse):** An Anthropic OAuth `403 "Request not allowed"` no longer bans the Claude connection on the first response — it is a per-request refusal on an otherwise healthy token, so it is now classified as the non-terminal `request_rejected` type, the connection is excluded for a growing cooldown (5 min, then 15 min) and only three consecutive refusals with no success in between escalate to `banned`; previously a single such response flipped the only Claude connection to `banned` and every later request was short-circuited with "All 1 connection(s) banned by upstream" until an operator reconnected ([#12859](https://github.com/diegosouzapw/OmniRoute/issues/12859), [#12864](https://github.com/diegosouzapw/OmniRoute/pull/12864) — thanks @insoln)
- fix(cache): never write a truncated completion (`finish_reason: "length"`/`max_tokens`) into the semantic cache — a partial answer cached under a temperature:0 signature was served to every later identical request, permanently returning a mid-sentence reply that no retry cleared (#12885) ([#12885](https://github.com/diegosouzapw/OmniRoute/pull/12885)) — thanks @patrykkopycinski
- **fix(providers):** vLLM connections now advertise the real context window: model discovery reads `max_model_len` instead of falling back to the 128K default ([#12897](https://github.com/diegosouzapw/OmniRoute/pull/12897), closes [#12858](https://github.com/diegosouzapw/OmniRoute/issues/12858)) — thanks @ntdat812
- **fix(combos):** A combo's visibility can be changed through the API again: `updateComboSchema` accepts `isHidden`, so a visibility-only update is no longer rejected as empty and a mixed update no longer drops it ([#12898](https://github.com/diegosouzapw/OmniRoute/pull/12898), closes [#12836](https://github.com/diegosouzapw/OmniRoute/issues/12836)) — thanks @ntdat812
- fix(guardrails): Vision Bridge now extracts and replaces base64 images nested inside a `tool_result.content` array (the shape Claude Code uses), not just top-level content parts — previously these requests silently reached a vision-incapable provider and returned a 400. Also resolves a provider prefix that has no known alias (e.g. a custom OpenAI-compatible connection's model prefix) to the node id its credentials are actually stored under, instead of discarding the operator's fixed model and falling back to a no-auth candidate that fails (#12903) — thanks @initguru
- fix(sse): inject the operator's global system prompt once, after request translation, for every target shape (Claude, Gemini, OpenAI Responses, OpenAI/Codex messages) instead of before translation — the pre-translation injection could be lost, repositioned, or duplicated 2-3× depending on the target format, and never reached the Responses API path at all. The new `injectSystemPromptPostTranslation()` is idempotent per request via a non-enumerable marker (#12904) — thanks @initguru
- **fix(sse):** strip echoed system/directive preamble on /v1/messages responses and preserve large analysis/summary blocks in systemPreambleStripper to stop autocompact empty-response ([#12905](https://github.com/diegosouzapw/OmniRoute/pull/12905)) — thanks @initguru
- **fix(sse):** thread the client's thinking intent into the non-streaming translation path so the same request answered with `stream:false` no longer leaks a thinking block that `stream:true` withholds ([#12905](https://github.com/diegosouzapw/OmniRoute/pull/12905)) — thanks @initguru
- **fix(sse):** gate thinking block emission on requestedThinking (streaming + non-stream) and flush reasoning-only responses as text to stop reasoning leak, autocompact loops, and 502 ([#12905](https://github.com/diegosouzapw/OmniRoute/pull/12905)) — thanks @initguru
- **fix(sse):** make the system-preamble stripper opt-in (`OMNIROUTE_STRIP_SYSTEM_PREAMBLE=1`) and flush both preamble strippers at stream end, so English-prose heuristics no longer delete a legitimate section of every openai→claude reply and an unterminated echo block no longer reaches the client as an empty message ([#12905](https://github.com/diegosouzapw/OmniRoute/pull/12905)) — thanks @initguru
- **fix(sse):** make the direct response-start timeout reasoning-aware — detect reasoning_effort high/max in the body and raise the ceiling to 180s to stop 504 on high-effort TTFB ([#12906](https://github.com/diegosouzapw/OmniRoute/pull/12906)) — thanks @initguru
- **fix(sse):** retry 0-byte empty_response 502 like STREAM_EARLY_EOF to stop autocompact 502 — RETRYABLE_STREAM_EMPTY_CODES + shouldRetryStreamEarlyEof wiring ([#12906](https://github.com/diegosouzapw/OmniRoute/pull/12906)) — thanks @initguru
- **fix(compat):** preserve GPT and Claude Code tool-call history when translating OpenAI Responses API requests to Chat Completions — `role:"tool"` items and role-based assistant `tool_calls` are no longer dropped, fixing incomplete multi-turn history ([#12909](https://github.com/diegosouzapw/OmniRoute/pull/12909)) — thanks @initguru
- **fix(usage):** finalize semantic cache hits by exact request id — `checkSemanticCache` now uses `finalizePendingScope(pendingScope, ...)` instead of an ambiguous (model, provider, connectionId) tuple, fixing wrong-request finalization when connectionId is null or multiple requests are in flight on the same connection ([#12910](https://github.com/diegosouzapw/OmniRoute/pull/12910)) — thanks @initguru
- **fix(sse):** the Codex Responses WebSocket bridge now fails fast to another eligible account instead of queueing behind a saturated one, releasing its per-account lease exactly once when the session ends ([#12911](https://github.com/diegosouzapw/OmniRoute/pull/12911) — thanks @initguru). This also fixes `accountSemaphore`'s `maxQueueSize: 0` handling, which previously behaved as an unbounded queue instead of failing over immediately — benefiting every caller that configures `queueDepth: 0` (for example combo routing), not just the Codex WS bridge.
- **fix(chatcore):** block a client's own duplicate retry (same idempotency key) from opening a second upstream turn while the first is still in flight, returning `409 turn_in_progress` instead of wasting quota on a redundant execution ([#12912](https://github.com/diegosouzapw/OmniRoute/pull/12912)) — thanks @initguru
- fix(sse): bound streams that keep sending raw upstream bytes forever without ever emitting a terminal event — a new `STREAM_ACTIVE_TIMEOUT_MS` watchdog (default 1260000ms/21min — the largest per-model `timeoutMs` in the registry plus a one-minute margin, `0` disables) tracks the stream's total lifetime independently of the existing byte-stall watchdog, so a continuously-active non-terminal stream can no longer occupy a connection indefinitely (#12913) — thanks @initguru
- **fix(providers):** correct Magnific API key validation, which reported every valid key as invalid due to a GET probe against a POST-only endpoint (#12927) ([#13754](https://github.com/diegosouzapw/OmniRoute/pull/13754)) — thanks @hubo1989
- **fix(routing):** Auggie now fails over to the next combo model instead of returning the quota-exhausted CLI warning as a successful reply (#12949) ([#13751](https://github.com/diegosouzapw/OmniRoute/pull/13751)) — thanks @honeypot55
- **fix(combo):** A weighted combo whose every target was excluded before dispatch by a resilience timer (model lockout, open circuit breaker, provider cooldown) now answers `503` `all_targets_cooling_down` with `Retry-After` set to the earliest exclusion to lapse, the excluded targets and reasons in `diagnostics.excluded`, a `wait` recovery hint, and a `[COMBO]` warning naming the reasons; previously the pool was dropped silently and the host answered `404 "Combo has no executable targets"` (recovery hint "switch combo / reconnect the missing providers") for a pool that was configured, connected and merely cooling down — which clients such as Claude Code render as "this model may not exist". A pool with nothing to run keeps its `404` ([#12954](https://github.com/diegosouzapw/OmniRoute/issues/12954), [#12956](https://github.com/diegosouzapw/OmniRoute/pull/12956) — thanks @insoln)
- **fix(resilience):** A `5xx` model-lockout failure — a transport error (`terminated`, `EHOSTUNREACH`, connect timeout), an upstream server error, or OmniRoute's own synthesized `502` from quality validation — now locks only the exact provider/connection/model tuple instead of the quota family; previously one empty response on a single `gpt-5.6-*` model removed every `gpt-5*` model of the codex connection from routing for 230 min (escalating) while its quota was untouched. Quota statuses (`429`/`403`/`402`) keep the family scope; success-decay and the Model Cooldowns card now handle exact-scope locks too ([#12955](https://github.com/diegosouzapw/OmniRoute/issues/12955), [#12957](https://github.com/diegosouzapw/OmniRoute/pull/12957) — thanks @insoln)
- **fix(providers):** GitLab Duo Retest and chat requests now fall back to the public Code Suggestions endpoint for ANY `direct_access` 403 (not only the "direct connections are disabled" tenant-config message), and surface the real upstream error body instead of a generic "Access denied" when both endpoints reject the token (#12958) ([#13758](https://github.com/diegosouzapw/OmniRoute/pull/13758)) — thanks @Rahulsharma0810
- **fix(sse):** stop misclassifying a truncated Anthropic-compatible `max_tokens` probe response (`content:[{type:"text",text:""}]`) as an empty upstream response (#12968) ([#13771](https://github.com/diegosouzapw/OmniRoute/pull/13771)) — thanks @pranay-gpt
- **fix(images):** image-combo legs now fall back when an upstream provider returns HTTP 2xx with an empty or malformed image payload. `fetchImageEndpoint` previously normalized any successful HTTP response to `success: true` (`data.data || []`), so `executeImageCombo` stopped on the first leg and handed the client an image-less 200. The OpenAI-compatible normalization now requires at least one usable item (non-empty `b64_json` or `url`) in `data[]`; an empty/malformed 2xx becomes a retryable 502 with a sanitized error, so priority image combos advance to the next leg. Valid payloads and direct image-model requests are unchanged. [#12982](https://github.com/diegosouzapw/OmniRoute/pull/12982) — thanks @tiangao88
- **fix(claude):** forward client-negotiated `thinking-binding-controls-2026-08-01` and `thinking-display-updates-2026-08-18` betas so Fable 5.1 `thinking.block_binding` / `thinking.display` requests are no longer rejected upstream with `Extra inputs are not permitted` ([#12989](https://github.com/diegosouzapw/OmniRoute/pull/12989)) — thanks @fidelix
- **fix(cli):** skip the POSIX CLI path lookup during Windows autostart setup, preventing a bogus path error before successful enablement ([#12993](https://github.com/diegosouzapw/OmniRoute/pull/12993)) — thanks @zachary-frederich
- **fix(i18n):** stop the "Saving..." hang on `/dashboard/combos``BuilderIntelligentStep.tsx`'s exploration-rate hint called `t("explorationRateHint")` with no ICU values even though the key requires `{percent}`, and next-intl's default error handling throws `FORMATTING_ERROR` for that call, unmounting the whole builder step and looking like a silent save hang. Fixed across all three affected call sites (`BuilderIntelligentStep.tsx`, `AgentBridgeMaintenanceCard.tsx`, `RawJsonPanel.tsx`), not just the one that was reported ([#12995](https://github.com/diegosouzapw/OmniRoute/pull/12995)). — thanks @hartmark
- **fix(api):** restore the MCP `namespace` field on streamed and non-streamed Responses tool calls in follow-up turns of a session that don't re-declare their `type:"namespace"` tools (#12996) ([#13769](https://github.com/diegosouzapw/OmniRoute/pull/13769)) — thanks @rolemiaster
- **fix(db):** add an opt-in automatic sweep for terminal batch checkpoints and expired file content, behind the new `BATCH_AND_FILE_AUTO_CLEANUP_ENABLED` feature flag (default off) — `batch_item_checkpoints` had grown to 182K rows / 5.25 GB with no batch ever explicitly deleted by an operator: the manual `delete-completed` route existed but nothing called it automatically, and it never covered failed/cancelled/expired batches either. Extracts a shared `deleteBatchesMatching()` (age-gated, every terminal status, keeping `deleteCompletedBatches()`'s exact existing contract) and adds `pruneExpiredFiles()` for uploaded file content past its own `expires_at` (1,874 rows / 5.19 GB observed live, most long past expiry). With the flag off (the default), every existing install keeps this data exactly as before; an operator must opt in via the dashboard or `BATCH_AND_FILE_AUTO_CLEANUP_ENABLED=true` before the sweep deletes anything ([#12999](https://github.com/diegosouzapw/OmniRoute/pull/12999)). — thanks @hartmark
- fix(providers): stop `@omniroute/opencode-plugin` combo context limits from downgrading to the raw `Math.min(member)` lower bound after a restart — the static catalog now honors the server-computed `computed_context_length` (mirroring the dynamic hook), and a background refresh with a degraded `/api/combos` response backfills the field from the last-known-good disk snapshot instead of overwriting it (#13000) ([#13759](https://github.com/diegosouzapw/OmniRoute/pull/13759)) — thanks @morpheus9393
- **fix(streaming):** allow a per-provider override of the fetch-start (headers-wait) timeout cap so providers that buffer the full generation before the first byte (e.g. `command-code`, `opencode-go`) are not cut off at the global 110s cap; the same two entries also gain a reasoning-safe `requestDefaults.maxTokens` of 16384 so thinking models such as `z-ai/glm-5.3-flash` are not cut off mid-reasoning ([#13002](https://github.com/diegosouzapw/OmniRoute/pull/13002)) — thanks @alvinveroy
- **fix(resilience):** An apikey-category 429 whose body explicitly says a long-window quota was exhausted no longer skips the quota cache — `shouldPreserveQuotaSignals()` (`open-sse/services/quotaResetParsing.ts`) gained an `errorText` parameter in the #6638 fix, but only one of its two call sites was updated: `checkFallbackError()` passes the upstream body while `shouldMarkAccountExhaustedFrom429()` (`open-sse/services/accountFallback.ts`) still called it with the provider alone. With `errorText` undefined the helper's `Boolean(errorText) && looksLikeQuotaExhausted(errorText)` branch can never be true, so for every apikey-category provider without per-model quotas the connection was never marked quota-exhausted. `errorText` is now threaded through the helper and passed at the `src/sse/handlers/chat.ts` call site. Plain rate limits (`Rate limit exceeded, retry in 20s`, `Too Many Requests`) still fall through to the short generic cooldown. Regression guard: `tests/unit/quota-signal-errortext-threading.test.ts`. ([#13008](https://github.com/diegosouzapw/OmniRoute/pull/13008)) — thanks @Rick7C2
- **fix(cli):** `omniroute mcp restart` no longer 404s — the missing `POST /api/mcp/restart` route now exists — and new `omniroute mcp enable`/`mcp disable [--transport]` subcommands give the CLI a way to turn the MCP server on without the dashboard ([#13012](https://github.com/diegosouzapw/OmniRoute/issues/13012)) ([#13770](https://github.com/diegosouzapw/OmniRoute/pull/13770)) — thanks @ricardusx
- **fix(mitm):** add catch-all (*) model mapping fallback for Agent Bridge ([#13013](https://github.com/diegosouzapw/OmniRoute/pull/13013)) — thanks @tuandinh0801
- **fix(providers):** Antigravity connection Retest probes Cloud Code envelope ([#13015](https://github.com/diegosouzapw/OmniRoute/pull/13015)) — thanks @tuandinh0801
- **fix(dev):** allow Ctrl+C to promptly kill dev server by closing active connections ([#13020](https://github.com/diegosouzapw/OmniRoute/pull/13020)) — thanks @tuandinh0801
- **fix(skills):** repair nested malformed skill-tool schemas (bare property maps, boolean `required: true`) for OpenAI-compatible providers, not just the schema root (#13022) ([#13772](https://github.com/diegosouzapw/OmniRoute/pull/13772)) — thanks @ftevxk
- **fix(sse):** reasoning replay now works for Chat Completions and Anthropic Messages clients on Responses-API reasoning targets such as `opencode-go/deepseek-v4-flash`: plain (non-tool-call) assistant turns are captured against the same normalized transcript the read side digests (the Responses body carries `input`, not `messages`, so the write side digested only the assistant message instead of the full transcript and every replay missed), and the replay pass runs on the OpenAI pivot for every source format, so Anthropic Messages clients are replayed too. Fixes the intermittent `400 The reasoning_text in the thinking mode must be passed back to the API` from Console Go for clients that drop `reasoning_content` ([#13031](https://github.com/diegosouzapw/OmniRoute/pull/13031)) — thanks @jmche
- **fix(friendliai):** FriendliAI's free-tier credit-exhaustion 403 (`{"detail":"You've exhausted all your credits..."}`) is now classified as `QUOTA_EXHAUSTED` instead of `AUTH_ERROR`, so omniroute treats it as depleted credits rather than a credential problem ([#13040](https://github.com/diegosouzapw/OmniRoute/pull/13040)) — thanks @turbolego
- fix(oauth): kimi-coding/github device-flow `pollToken` no longer rejects with `TypeError: Body is unusable` when the token endpoint returns a non-JSON error page (CDN/anti-bot/proxy interstitial) — the body is now read once and parsed, preserving the graceful `invalid_response` fallback instead of a generic 500 (#13046 — thanks @ysntony)
- **fix(providers):** lazily load `chatgpt-web-codex` admin helpers in `PUT /api/providers/[id]`, mirroring #12355's exact pattern for the sibling `POST` route — a source-inspection test pins the import-graph invariant, since Turbopack's client-bundle boundary can't be exercised from the test harness directly ([#13071](https://github.com/diegosouzapw/OmniRoute/pull/13071)). — thanks @hartmark
- **fix(providers):** honor an operator-set endpoint override (`PUT /api/provider-models`) for a local model whose own `/v1/models` response carries no capability data of its own (llama.cpp included) — the override previously only took effect when a matching `customModels` entry already existed, so declaring a brand-new local model as embeddings-capable silently did nothing. `updateCustomModel()` gains an opt-in `createIfMissing` mode; every other caller's existing contract is unchanged. Also accepts a collapsed single-slash id for path-based local models ([#13078](https://github.com/diegosouzapw/OmniRoute/pull/13078)). — thanks @hartmark
- fix(quota): keep Kiro active while any _freetrial pool has quota (#13088) ([#13324](https://github.com/diegosouzapw/OmniRoute/pull/13324)) — thanks @giauphan
- **fix(routing):** round-robin combos now show up in Combo Studio's Live dashboard — they were completing successfully but never publishing the attempt/success/failure events the dashboard listens for (#13089) ([#13776](https://github.com/diegosouzapw/OmniRoute/pull/13776)) — thanks @adityadwi21
- **fix(sse):** stop rejecting a Responses API `tool_choice.type: "custom"` (e.g. Codex CLI forcing `functions__exec`) with a 400 `unsupported_feature` error (#13122) ([#13775](https://github.com/diegosouzapw/OmniRoute/pull/13775)) — thanks @phamtienduceng-eng
- **fix(translator):** recognize `tool_choice.type: "custom"` in Responses→Chat translation and propagate custom tool names (including namespace-flattened ones) across both the streaming and non-streaming provider legs, so non-streaming Responses clients get `custom_tool_call`/raw `input` instead of `function_call`/JSON arguments ([#13128](https://github.com/diegosouzapw/OmniRoute/pull/13128)) — thanks @ducphamtien-fonos
- **fix(providers):** remove the `chipotle`/`pepper` provider — its upstream (`amelia.chipotle.com`) now 404s on every route and is fully decommissioned (#13131, #4037) ([#13913](https://github.com/diegosouzapw/OmniRoute/pull/13913)) — thanks @Falco20100
- **fix(db):** authenticated `GET /api/db/health` polls no longer run a SQLite `quick_check`. The health dashboard polls every 15 seconds, and that scan ran synchronously on the request-serving event loop, blocking it for the length of the scan. Reference and state checks still run, and explicit repair requests keep integrity checks unless `OMNIROUTE_SKIP_DB_HEALTHCHECK=1` is set ([#13149](https://github.com/diegosouzapw/OmniRoute/pull/13149)) — thanks @cryptiklemur
- **fix(sse):** fail over once to a sibling connection on stream early EOF (the original `STREAM_EARLY_EOF` 502 is kept when no sibling can serve the request), gated behind `STREAM_EARLY_EOF_SIBLING_FAILOVER_ENABLED` (default off) ([#13153](https://github.com/diegosouzapw/OmniRoute/pull/13153)) — thanks @maxmad64bis
- **resilience:** a Cloudflare managed challenge (`cf-mitigated: challenge` / challenge HTML on 403) is classified as a fingerprint rejection and retried on another account/transport instead of banning the connection (#13161 ([#14090](https://github.com/diegosouzapw/OmniRoute/pull/14090)) — thanks @anhtran-ai)
- **fix(cli):** OpenCode config generator preserves catalog display names — custom names win, then `display_name`/native `name` (with the `owned_by/` prefix stripped once), then a readable label for `auto/*` ids, instead of always showing the raw model id ([#13168](https://github.com/diegosouzapw/OmniRoute/pull/13168)) — thanks @domenicomassafra
- **fix(sse):** Fable 5 and 5.1 keep their prompt-cache prefix across mid-conversation system messages. OmniRoute treated only Opus as capable, so every Fable system turn was hoisted into the top-level system prompt and moved the cached prefix, which reported `system_changed` from turn 2 on. Fable now has its own mid-conversation-system capability path, and `context-1m` stays limited to Opus so Fable is never sent an unrelated beta header ([#13173](https://github.com/diegosouzapw/OmniRoute/pull/13173)) — thanks @cryptiklemur
- **fix(combo):** auto-resume a pinned native Codex turn on a healthy sibling connection or model when the pinned provider becomes unavailable for a model-scoped reason (quota, model lockout) instead of failing the turn outright — provider-wide circuit-breaker/cooldown state, pending tool calls, opaque continuation state, and partial streams still block resume, and at most one auto-resume happens per logical turn ([#13180](https://github.com/diegosouzapw/OmniRoute/pull/13180)) ([#14162](https://github.com/diegosouzapw/OmniRoute/pull/14162)) — thanks @mdigitalbh81
- **fix(evals):** an eval case whose model call errored is no longer scored as passed — a case that never reached a model has no measured behaviour to grade ([#13201](https://github.com/diegosouzapw/OmniRoute/pull/13201)) — thanks @aaustinhuang / @dajiaohuang
- **fix(evals):** the eval runner now sends `x-omniroute-compression: off` and `x-omniroute-no-memory: true` on every case, so a graded case measures the model instead of the operator's injected output style, retrieved memory and `memory_*` tools ([#13139](https://github.com/diegosouzapw/OmniRoute/issues/13139), [#13206](https://github.com/diegosouzapw/OmniRoute/pull/13206)) — thanks @aaustinhuang / @dajiaohuang
- **fix(combos):** stop dropping live keys and persisting dead ones ([#13217](https://github.com/diegosouzapw/OmniRoute/pull/13217)) — thanks @maxmad64bis
- **fix(db):** the WAL checkpoint busy counter reported by `/api/monitoring/health` now survives restarts — busy checkpoints are counted in memory and persisted from the next clean maintenance tick or at shutdown, never with a write while the database is contended ([#13218](https://github.com/diegosouzapw/OmniRoute/pull/13218)) — thanks @maxmad64bis
- **fix(vertex):** preserve Claude prompt-cache breakpoints for Vertex and Vertex Partner, use the documented five-minute ephemeral TTL by default, and forward cache usage metadata through streaming responses ([#13220](https://github.com/diegosouzapw/OmniRoute/pull/13220)) — fixes #13219 — thanks @SIGTERM-015
- **fix(mcp):** load the audit `better-sqlite3` driver via the shared `runtimeRequire()` helper instead of `createRequire(import.meta.url)`, which broke when the Next.js standalone build emits the module as a CommonJS chunk ([#13223](https://github.com/diegosouzapw/OmniRoute/pull/13223)) — thanks @chatchawan-simplewish
- **fix(sse):** classify a missing Playwright Chromium install on the Z.ai web transport as an actionable 503 host/config cooldown instead of a generic 502 that trips the provider circuit breaker (#13232) ([#13777](https://github.com/diegosouzapw/OmniRoute/pull/13777)) — thanks @oleksandr1811
- **fix(combos):** testing a combo aborts in-flight probes when the client disconnects instead of probing on after the dashboard navigates away ([#13279](https://github.com/diegosouzapw/OmniRoute/pull/13279)) — thanks @maxmad64bis
- **fix(quota):** in-process routing and quota caches (quality tracker, saturation and rate-limit header caches, quota-fetcher cache, learned rate limits, account buckets) are now size-bounded through one shared `boundedMap` — caps sit far above normal deployments, evictions are logged once per minute per cache instead of per entry, and state whose loss would change routing (live saturated quota buckets, evaluator quality scores) is never evicted ([#13280](https://github.com/diegosouzapw/OmniRoute/pull/13280)) — thanks @maxmad64bis
- **fix(call-logs):** call-log error types are now a versioned vocabulary (`ERROR_TYPE_CONTRACT v1`) with explicit `unknown` instead of ambiguous `null`, and free-text history reads back as `unclassified` ([#13281](https://github.com/diegosouzapw/OmniRoute/pull/13281)) — thanks @maxmad64bis
- Fixed tests leaving temp `DATA_DIR` folders behind on Windows by closing the SQLite handle before removing the directory (#13290). ([#13292](https://github.com/diegosouzapw/OmniRoute/pull/13292)) — thanks @anhtahaylove
- **fix(providers):** honor the selected Alibaba workspace and region endpoints for custom embedding and `qwen3-rerank` requests ([#13293](https://github.com/diegosouzapw/OmniRoute/pull/13293)) — thanks @xiaoyaner0201
- **fix(backend):** error messages are no longer truncated after a path — `redactErrorPaths` treated any slash-bearing span as an unequivocal filesystem path and swallowed the rest of the line, so the image-model 400 lost the `Use POST /v1/images/generations instead.` hint it exists to give, and a redacted diagnostic lost its ` with api_key='[REDACTED]'` tail. Only a Windows path, file URI or known POSIX root with no determinable end swallows the line now ([#13144](https://github.com/diegosouzapw/OmniRoute/issues/13144)) ([#13295](https://github.com/diegosouzapw/OmniRoute/pull/13295)) — thanks @abhisheksharma2411 / @ggiak
- **fix(providers):** Fetch Qwen and Alibaba Token Plan model catalogs through their authenticated console gateways, with public-only URL validation and local-catalog fallback when discovery is unavailable. ([#13299](https://github.com/diegosouzapw/OmniRoute/pull/13299)) — thanks @JxnLexn
- **fix(db):** defer `process.exit(0)` on graceful shutdown by one macrotask, avoiding a Windows-only libuv abort when the sql.js fallback driver has a statement in flight (#13306) ([#13778](https://github.com/diegosouzapw/OmniRoute/pull/13778)) — thanks @anhtahaylove
- **fix(cli):** `omniroute serve` now surfaces a fatal `[STARTUP] Fatal: ...` boot diagnostic (e.g. a DB driver init failure) to the console immediately, even without `--log`, instead of only when the process later crashes or restarts (#13314) ([#13779](https://github.com/diegosouzapw/OmniRoute/pull/13779)) — thanks @Orion1943
- **fix(sse):** A transient error on a round-robin combo target no longer resets its concurrency limit to 3, so a target capped at 1 is not sent three queued requests at once when its cooldown ends ([#13320](https://github.com/diegosouzapw/OmniRoute/pull/13320)) — thanks @datrixlab
- **fix(resilience):** Rate-limit reset headers in RFC 3339 form (Anthropic) and with fractional seconds (`2m59.56s`) are parsed correctly, instead of an Anthropic reset seconds away throttling the connection for about 34 minutes ([#13321](https://github.com/diegosouzapw/OmniRoute/pull/13321)) — thanks @datrixlab
- **fix(cli):** `contexts export --no-secrets` now leaves the access tokens and API keys out, and `chat --no-history` and `serve --no-recovery` take effect; all three flags were accepted and ignored ([#13322](https://github.com/diegosouzapw/OmniRoute/pull/13322)) — thanks @datrixlab
- **fix(providers):** Switching "Allow Private Provider URLs" off in the dashboard now takes effect when `OMNIROUTE_ALLOW_PRIVATE_PROVIDER_URLS=true` is set in the environment ([#13323](https://github.com/diegosouzapw/OmniRoute/pull/13323)) — thanks @datrixlab
- **fix(cli):** `omniroute restart` now comes back on the port set by `PORT` (shell or `<DATA_DIR>/.env`) instead of always 20128, like `serve` and `dashboard` ([#13327](https://github.com/diegosouzapw/OmniRoute/pull/13327)) — thanks @datrixlab
- **fix(redis):** Warmup circuit-breaker keys now honor `REDIS_KEY_PREFIX` like every other OmniRoute Redis key, instead of always using `omniroute:warmup:cb:` ([#13328](https://github.com/diegosouzapw/OmniRoute/pull/13328)) — thanks @datrixlab
- **fix(api):** Setting `DISABLE_SQLITE_AUTO_BACKUP=true` no longer makes the API-key rate limiter and auth cache skip Redis, which let every replica enforce the full per-key limit on its own ([#13329](https://github.com/diegosouzapw/OmniRoute/pull/13329)) — thanks @datrixlab
- **fix(translator):** `tool_choice: "none"` is now sent to Claude-format providers as `{ type: "none" }` (and back to OpenAI as `"none"`) instead of `auto`, so the model can no longer call tools the client switched off ([#13333](https://github.com/diegosouzapw/OmniRoute/pull/13333)) — thanks @datrixlab
- **fix(translator):** Gemini tool results sent without an `id` (the usual case, since OmniRoute's own Gemini responses never emit one) now reach the model instead of being replaced by an empty result, on the Gemini, Antigravity and `/v1beta` request paths ([#13334](https://github.com/diegosouzapw/OmniRoute/pull/13334)) — thanks @datrixlab
- **fix(translator):** An image returned inside a Claude `tool_result` (Read on a PNG, an MCP screenshot) is now sent to Gemini as an image part instead of base64 text ([#13335](https://github.com/diegosouzapw/OmniRoute/pull/13335)) — thanks @datrixlab
- **fix(combo):** stop retrying a malformed-request-shape error across the entire fallback chain — a 400/422 that fails one target for a `kind: "model"` reason (exact status + error message match) will fail identically on every other target too, so a bad payload previously burned the full `MAX_GLOBAL_ATTEMPTS` budget instead of failing fast. Observed live: 41-44 identical combo decisions over 13+ minutes for a single request. Trips only on 3 consecutive identical `kind: "model"` failures, leaving transient and provider-side errors untouched ([#13338](https://github.com/diegosouzapw/OmniRoute/pull/13338)). — thanks @hartmark
- **fix(db):** `getDbInstance()` now closes the probe and primary SQLite connections on every failed initialization path, not just the happy path, fixing a handle leak that caused `EPERM` on Windows teardown. ([#13303](https://github.com/diegosouzapw/OmniRoute/issues/13303)) ([#13342](https://github.com/diegosouzapw/OmniRoute/pull/13342)) — thanks @voidstackloop
- **fix(dispatch):** strip every `_omniroute*` internal marker at the shared pre-serialization chokepoint (`cliFingerprints.ts`) instead of a hand-maintained per-key allowlist, so internal routing/handoff markers (e.g. `_omnirouteSkipContextRelay`, `_omnirouteResponsesStore`) can no longer leak into serialized upstream request bodies and draw `400 Extra inputs are not permitted` from strict Anthropic-compatible gateways ([#12729](https://github.com/diegosouzapw/OmniRoute/issues/12729), fixed in [#13355](https://github.com/diegosouzapw/OmniRoute/pull/13355)) — thanks @patrykkopycinski
- **fix(providers):** stop zed-hosted `claude-haiku-4-5` extended-thinking requests from inflating `max_tokens` past the model's real 64000 output cap (#13364) ([#13780](https://github.com/diegosouzapw/OmniRoute/pull/13780)) — thanks @ThiagoMafra-Integrare
- **fix(providers):** gemini-web no longer drops the system instruction on single-turn requests or the tool contract when a client system message is present, and switches to an atomic composer insert so embedded newlines can't submit the message early (#13380) ([#13784](https://github.com/diegosouzapw/OmniRoute/pull/13784)) — thanks @formilw
- **fix(providers):** `gemini-web` now attempts to select and verify the requested Gemini UI mode (and Extended Thinking) before answering, and fails closed with a clear 400 instead of silently running the account default under a mismatched model label (#13381) ([#13919](https://github.com/diegosouzapw/OmniRoute/pull/13919)) — thanks @formilw
- **fix(db):** stop routine connection-backoff auto-recovery from busting the entire `/v1/models` response cache, which was causing intermittent 75-120s/502 responses on deployments routing many providers (#13389) ([#13783](https://github.com/diegosouzapw/OmniRoute/pull/13783)) — thanks @RaviTharuma
- **fix(mitm):** bound per-request SSE transcript retention to 1 MiB and stop the upstream read when the downstream disconnects — handler-side `collected` strings grew without bound before the inspector clamp, and abandoned streams kept the reader alive for the full upstream lifetime ([#13395](https://github.com/diegosouzapw/OmniRoute/issues/13395)) ([#13702](https://github.com/diegosouzapw/OmniRoute/pull/13702)) — thanks @oyi77
- **fix(opencode-plugin):** The stale disk-cache fallback warning now reports the snapshot's age (`using stale disk cache (N models, age 168h)`), matching the existing warm-startup log. Previously a week-old catalog was indistinguishable from a five-minute-old one, so silent model drift went unnoticed. (#13426) — thanks @RaviTharuma
- **fix(api):** `GET /api/logs/export` now streams rows from the database itself (a cursor for `proxy-logs`, a LIMIT-bounded generator for `call-logs`/`request-logs`) instead of buffering every matching row in memory before serializing, fixing a V8 heap OOM on large tables ([#13428](https://github.com/diegosouzapw/OmniRoute/pull/13428), [#13123](https://github.com/diegosouzapw/OmniRoute/issues/13123)) — thanks @KooshaPari. **Breaking:** the `limit` query param now defaults to 10,000 rows (max 50,000) — exports that previously returned every matching row are silently truncated (with `"capped":true,"totalAvailable":<n>` in the response) unless the caller passes a larger explicit `limit`.
- **fix(compression):** stop lite compression from dropping a `role:"tool"` message when it is byte-identical to the previous message, which orphaned a `tool_call_id` and triggered upstream 400 errors on parallel tool calls (#13429) ([#13787](https://github.com/diegosouzapw/OmniRoute/pull/13787)) — thanks @tolgaaksoy
- **fix(sse):** frame post-keepalive `/v1/responses` stream errors with a top-level `type` field so Responses clients (Codex) surface the real upstream error instead of reporting "stream disconnected before completion" (#13431) ([#13785](https://github.com/diegosouzapw/OmniRoute/pull/13785)) — thanks @andrea-kingautomation
- **fix(db):** reconcile `auto_vacuum` drift between the configured INCREMENTAL mode and the live SQLite pragma — detected at startup and reconciled out-of-request by the vacuum scheduler, which now also runs a bounded `PRAGMA incremental_vacuum` reclaim instead of an unconditional full `VACUUM` once INCREMENTAL is actually in effect (#13432) ([#13786](https://github.com/diegosouzapw/OmniRoute/pull/13786)) — thanks @tolgaaksoy
- **fix(models):** Reconcile provider dashboards with confirmed authoritative live catalogs, excluding retired built-in/imported rows while preserving manual custom models and partial-catalog fallbacks. ([#13434](https://github.com/diegosouzapw/OmniRoute/pull/13434)) — thanks @JxnLexn
- **fix(build):** the production build no longer breaks when a client component reaches a server-only module, and the client-bundle guard now discovers server-only modules instead of matching a fixed list ([#13436](https://github.com/diegosouzapw/OmniRoute/pull/13436)) — thanks @maxmad64bis
- **fix(models):** return a retryable 503 with Retry-After instead of a 500 when the first catalog build outlasts its time bound ([#13438](https://github.com/diegosouzapw/OmniRoute/pull/13438)) — thanks @maxmad64bis
- **fix(combo):** new opt-in flag `PROTECTED_PRIORITY_INFRA_502_ENABLED` (default off): when a priority target marked fallback-only-on-quota-exhaustion stops the combo because its provider circuit breaker is open or a predictive latency check rejected it — causes that are provably not quota — the response is 502 instead of a quota-looking 503; lockout, cooldown, unavailable, exhaustion, credential-gate and concurrency-cap stops keep 503 ([#13439](https://github.com/diegosouzapw/OmniRoute/pull/13439)) — thanks @maxmad64bis
- **fix(resilience):** non-TPD daily-quota cooldowns honor the provider node's configured daily-reset clock (timezone + hour) instead of server midnight, on single-model and combo (priority and round-robin) paths; timezone edits apply without a restart ([#13440](https://github.com/diegosouzapw/OmniRoute/pull/13440)) — thanks @maxmad64bis
- **fix(call-logs):** the call-log write point validates `error_type` against the versioned vocabulary with a Zod schema and stores `unknown` for any value outside it, so a classifier family that drifts from `ERROR_TYPE_CONTRACT` can never persist free text ([#13441](https://github.com/diegosouzapw/OmniRoute/pull/13441)) — thanks @maxmad64bis
- **fix(oauth):** token health check now parses a numeric epoch `expires_at` (number or string, seconds or milliseconds), so connections synced by external tools keep their expiry-driven refresh instead of being skipped forever — or refreshed on every sweep ([#13444](https://github.com/diegosouzapw/OmniRoute/pull/13444)) — thanks @elielsousa-pathbit
- **fix(db):** Arena ELO sync now fetches and validates the leaderboards before touching `model_intelligence`, and applies the upsert + prune of expired rows inside a single atomic transaction. Previously, an unavailable/rate-limited Arena API left the table pruned with nothing written back, and since the sync runs on every boot, repeated restarts against a rate-limited upstream permanently drained the table to zero ([#13446](https://github.com/diegosouzapw/OmniRoute/pull/13446)) — thanks @CrashCartCapital
- **fix(analytics):** the compression analytics writer now passes `flatRateAsZero: true` to `calculateCost`, matching `/api/usage/analytics`. Flat-rate subscription lanes (minimax, glm, kimi, bailian, xiaomi, web-cookie) no longer report a dollar "savings" figure that was never actually payable ([#13446](https://github.com/diegosouzapw/OmniRoute/pull/13446)) — thanks @CrashCartCapital
- **fix(sse):** stop an unhydrated `openai-compatible-*`/`anthropic-compatible-*` connection from silently routing chat requests (and its stored credential) to the real OpenAI/Anthropic API instead of the operator's configured provider-node endpoint (#13452) ([#13798](https://github.com/diegosouzapw/OmniRoute/pull/13798)) — thanks @DenXio101
- **fix(sse):** Pollinations and Perplexity-web requests now fail over instead of returning the provider's own "out of credits"/"account suspended" text as if it were a real answer — an HTTP 200 body whose short assistant message is dominated by a known credits-exhausted or account-deactivated phrase is now classified as a malformed response and triggers the existing combo/auto-fallback path (#13461) ([#13910](https://github.com/diegosouzapw/OmniRoute/pull/13910)) — thanks @arjav1181
- **fix(resilience):** background OAuth token refresh (proactive health-check sweep and the shared refresh helper behind `refreshAccessToken`/`refreshClaudeOAuthToken`/etc.) now fails closed like the interactive chat path when a connection's assigned proxy pool is entirely dead, instead of silently sending the refresh-token exchange out direct or via a stray `HTTPS_PROXY` (#13470) ([#13793](https://github.com/diegosouzapw/OmniRoute/pull/13793)) — thanks @elielsousa-pathbit
- **fix(providers):** Muse Spark 1.3 works on OpenCode Zen, OpenCode and OpenCode Go instead of failing with a 500, and gets its real 1M context window ([#13471](https://github.com/diegosouzapw/OmniRoute/pull/13471)) — thanks @maxmad64bis (with thanks to @bacnh85, @shermzy and @atakhadiviom for #12675, #12973 and #13111)
- **fix(sse):** forward Anthropic prompt-cache-creation tokens through the `/v1/responses` usage hop so cache-write counts stop logging as zero (#13472) ([#13790](https://github.com/diegosouzapw/OmniRoute/pull/13790)) — thanks @fidelix
- **fix(opencode):** opt-in `OPENCODE_RESPONSES_STALL_ROTATION` flag (default off): a streamed Responses reply that sends headers and then nothing is cut after `RESPONSES_FIRST_BYTE_TIMEOUT_MS` (default 15 s) instead of waiting for the stream readiness timeout — the account is cooled down and the request rotates to the next account once (proxied or proxy-less), a second stall fails fast; with the flag off nothing changes ([#13484](https://github.com/diegosouzapw/OmniRoute/pull/13484)) — thanks @maxmad64bis
- **fix(sse):** stop the streaming PII sanitizer from splicing OpenRouter metadata (`provider`, `native_finish_reason`, `reasoning_details[].format`) into the answer text buffer (#13488) ([#13792](https://github.com/diegosouzapw/OmniRoute/pull/13792)) — thanks @Xore
- **fix(sse):** opt-in `OPENCODE_USER_BLOCKED_ROTATION` flag (default off): an opencode 403 or 451 carrying a `user_blocked` refusal cools the refused account down and fails over to the next account at most once per request, cancelling the abandoned response body; with the flag off the refusal is returned unchanged ([#13498](https://github.com/diegosouzapw/OmniRoute/pull/13498)) — thanks @maxmad64bis
- **fix(runtime):** eliminate hardcoded 20128 port remnants and make loopback URLs dynamic ([#13533](https://github.com/diegosouzapw/OmniRoute/pull/13533)) — thanks @ggdayup
- **fix(cli):** redraw the CLI/Electron system tray icon with a dark outline and ship a native multi-res `icon.ico` so it is no longer a pure-white, nearly invisible glyph on the Windows light-theme taskbar and hidden-icons flyout (#13535) ([#13797](https://github.com/diegosouzapw/OmniRoute/pull/13797)) — thanks @ProphetOfDoom-PoD
- **fix(cli):** persist the supervisor's give-up crash record to `<DATA_DIR>/server/crash.log` (surfaced by `omniroute doctor`) instead of only printing it — the console output was discarded when `--tray` mode's detached worker exited, leaving no trace of why the gateway/tray disappeared (#13538) ([#13908](https://github.com/diegosouzapw/OmniRoute/pull/13908)) — thanks @ProphetOfDoom-PoD
- **fix(api):** `/v1/audio/transcriptions`, `/v1/audio/translations` and `/v1/audio/speech` requests now show up in Dashboard → Request Logs — the three routes never called the shared call-log pipeline, so every successful (and failed) transcription/translation/speech request was silently dropped from `call_logs` ([#13544](https://github.com/diegosouzapw/OmniRoute/issues/13544)) ([#13803](https://github.com/diegosouzapw/OmniRoute/pull/13803)) — thanks @delafu
- **fix(translator):** Claude tool `input_schema` with a root-level `anyOf` / `oneOf` / `allOf` is flattened into a plain object schema instead of being forwarded verbatim. Anthropic refuses such a tool before inference (`tools.N.custom.input_schema: input_schema does not support oneOf, allOf, or anyOf at the top level`), so a single MCP/agent tool carrying one made every request fail with no combo failover possible ([#13552](https://github.com/diegosouzapw/OmniRoute/issues/13552)) ([#13561](https://github.com/diegosouzapw/OmniRoute/pull/13561)) — thanks @sprintberlin
- **fix(routing):** Preserve forced reasoning effort across native requests, account defaults and combo fallbacks while keeping internal routing directives out of upstream payloads. ([#13556](https://github.com/diegosouzapw/OmniRoute/pull/13556)) — thanks @JxnLexn
- **fix(providers):** MiniMax-M3's inline `<think>...</think>` reasoning no longer leaks into `message.content`/`delta.content` on the `minimax`/`minimax-cn` routes — it is now stripped and surfaced as `reasoning_content`, in both streaming and non-streaming responses (#13558) ([#13799](https://github.com/diegosouzapw/OmniRoute/pull/13799)) — thanks @pan17
- **fix(api):** `PATCH /api/settings` now persists `hideAutoCombos` and `hideNoThinkVariants` instead of silently dropping them (#13562) ([#13800](https://github.com/diegosouzapw/OmniRoute/pull/13800)) — thanks @texastoland
- fix(api): resolve the codex-settings `apiKey` through the canonical key resolver instead of an inline 400 guard, so the dashboard Apply flow no longer fails with `baseUrl, apiKey and model are required` in cloud mode when no management key is selected (#13563) ([#13566](https://github.com/diegosouzapw/OmniRoute/pull/13566)) — thanks @opensource-elearning
- fix(sse): release the native Codex turn pin when the pinned model becomes model-scoped unusable, so a long-running Codex session falls back to the next healthy combo model instead of dying to a terminal `400 NATIVE_CODEX_PINNED_MODEL_UNAVAILABLE` (#13564) ([#13566](https://github.com/diegosouzapw/OmniRoute/pull/13566)) — thanks @opensource-elearning
- **fix(proxy):** ordinary SOCKS5 data-plane requests no longer trigger the T14 speculative bare-TCP reachability probe — that probe opened and immediately closed a raw TCP connection, which a SOCKS5 listener (e.g. GOST) sees as an incomplete handshake and logs as `unexpected EOF`; HTTP/HTTPS fast-fail and the explicit `directFallbackOnUnreachable` control-plane probe are unchanged ([#13571](https://github.com/diegosouzapw/OmniRoute/pull/13571)) — thanks @mdigitalbh81
- **fix(gemini):** stop sending Gemini a `400` on combos ("function call turn comes immediately after a user turn") when conversation history *opens* on a functionCall turn — after `mergeConsecutiveSameRoleContents`, `contents[]` alternation is guaranteed for every index ≥ 1, but the one remaining violation was a history whose very first turn is a functionCall. Prepends a synthetic leading user turn in that case ([#13573](https://github.com/diegosouzapw/OmniRoute/pull/13573)). — thanks @hartmark
- fix(db): make the chat-path proxy resolver (`resolveProxyForConnection`) rotate a multi-member pool the same way the registry resolver already does — its per-connection cache was freezing on the first pool member forever instead of re-running the scope's round-robin/sticky/random strategy on each request, unless the connection needs a stable egress (opencode's egress-bucketed quota, grok-web's IP-pinned `cf_clearance`) (#13575) ([#14044](https://github.com/diegosouzapw/OmniRoute/pull/14044))
- **fix(proxies):** a subscription refresh, a bulk re-import or an API update that omits the status no longer turns a disabled proxy back on, and a refresh no longer rewrites a manual proxy that shares a subscription node's address ([#13577](https://github.com/diegosouzapw/OmniRoute/pull/13577)) — thanks @maxmad64bis
- **fix(cli):** `omniroute update` now passes `--legacy-peer-deps` to `npm install -g`, suppressing the `ERESOLVE` / peer-dependency wall seen on fresh global installs; dry-run output reflects the same flag; troubleshooting guide documents the supported install form (#13579 — thanks @prabhu-omkar)
- **fix(api):** a partial update no longer resets the fields the client did not send: renaming a disabled reasoning routing rule keeps it disabled with its priority, description and tags, renaming a playground preset keeps its params, and renaming or re-importing a proxy keeps its address family ([#13582](https://github.com/diegosouzapw/OmniRoute/pull/13582)) — thanks @maxmad64bis
- **fix(providers):** Antigravity error responses and logs now surface the real upstream message (e.g. Gemini field-path rejections) instead of the generic "Antigravity upstream error (400)" placeholder (#13591) ([#13801](https://github.com/diegosouzapw/OmniRoute/pull/13801)) — thanks @afonsoft
- **fix(usage):** the call-logs artifact worker's failure warning now includes the underlying error's message/code instead of the generic "detail omitted" — a crashed or non-zero-exit worker was previously undiagnosable in the logs (#13597) ([#13802](https://github.com/diegosouzapw/OmniRoute/pull/13802)) — thanks @afonsoft
- **fix(providers):** echo back `reasoning_content` on `bai` DeepSeek thinking-mode follow-up turns, fixing the upstream 400 "reasoning_content must be passed back" (#13599) ([#13807](https://github.com/diegosouzapw/OmniRoute/pull/13807)) — thanks @afonsoft
- **fix(i18n):** drop the second copy of `featureFlagProxySkipRecentlyFailedDescription` that the 2026-09-15 batch merges left in 59 dashboard catalogs (a scripted keep-both conflict resolution concatenated the key both PRs carried; `JSON.parse` silently kept the last copy) and the duplicated `ERROR_TYPE_CONTRACT` import in `src/lib/db/callLogStats.ts` (TS2300); adds `tests/unit/i18n-catalogs-no-duplicate-keys.test.ts`, a raw-text guard that fails on any key declared twice in one object of `src/i18n/messages/*.json` or `bin/cli/locales/*.json` ([#13602](https://github.com/diegosouzapw/OmniRoute/pull/13602), [#13641](https://github.com/diegosouzapw/OmniRoute/pull/13641)) ([#13816](https://github.com/diegosouzapw/OmniRoute/pull/13816))
- **fix(proxy):** proxy credentials holding a literal `%` (e.g. `pa%ss`) no longer break the proxy — HTTP(S) proxies now receive a correctly built `Proxy-Authorization` header instead of undici throwing `URIError`, SOCKS5 proxies get the raw credential, and the proxy registry, subscription import and legacy settings parsers keep the value instead of dropping the entry; correctly percent-encoded credentials decode exactly as before ([#13605](https://github.com/diegosouzapw/OmniRoute/pull/13605)) — thanks @maxmad64bis
- **fix(connection-cooldown):** skip connection cooldown for locally rejected token-budget 429s so a per-key limit never cools a healthy connection ([#13606](https://github.com/diegosouzapw/OmniRoute/pull/13606)) — thanks @maxmad64bis
- **fix(opencode-plugin-v2):** write the catalog snapshot to a temp file and rename it into place, ignore newer snapshot versions, and warn when a write is skipped or fails ([#13607](https://github.com/diegosouzapw/OmniRoute/pull/13607)) — thanks @maxmad64bis
- **fix(proxy-health):** a target-refused probe (401/403/429) can reset the consecutive-failure streak instead of staying neutral, behind the opt-in `PROXY_HEALTH_BLOCKED_RESETS_STREAK` feature flag (default off: refusals keep the #10654 neutral policy); a relayed 5xx stays inconclusive and a refusal never removes or disables a proxy ([#13608](https://github.com/diegosouzapw/OmniRoute/pull/13608)) — thanks @maxmad64bis
- **fix(providers):** opt-in `MISTRAL_AMBIGUOUS_401_SOFT_LOCKOUT` flag (default off): a bare Mistral 401 with no explicit auth signal (identical for a revoked key and an exhausted quota) cools the connection down instead of parking it as expired, at most 3 times per hour per connection before it parks, so a revoked key still converges; the ambiguity check is now one implementation shared by the connection test and the runtime ([#13609](https://github.com/diegosouzapw/OmniRoute/pull/13609)) — thanks @maxmad64bis
- **fix(proxies):** pool validation no longer rewrites proxies set to inactive or dead; only active and error statuses are updated ([#13612](https://github.com/diegosouzapw/OmniRoute/pull/13612)) — thanks @maxmad64bis
- **fix(opencode):** the v2 plugin reads the management token from OMNIROUTE_MANAGEMENT_API_KEY (plugin option wins) and warns once at startup when management calls fall back to the inference key ([#13613](https://github.com/diegosouzapw/OmniRoute/pull/13613)) — thanks @maxmad64bis
- **fix(routing):** a failed stale-pin (LKGP) clear on the combo fallback path now logs the combo and execution key while staying non-blocking, and a new opt-in `npm run check:routing-error-guard` script (not wired into CI) flags new swallowed catches and unanchored fire-and-forget async on routing paths, with frozen entries keyed by file and catch body instead of line numbers ([#13614](https://github.com/diegosouzapw/OmniRoute/pull/13614)) — thanks @maxmad64bis
- **fix(opencode):** opt-in `OPENCODE_TRANSIENT_FAILOVER_BACKOFF` flag (default off): once two consecutive opencode accounts fail with a transient upstream error, the rotation pauses before the next account (1.5 s doubling, capped at 6 s per pause and 10 s per request), releases the failed response body first and stops dispatching if the client disconnects during the pause; with the flag off failover stays immediate ([#13615](https://github.com/diegosouzapw/OmniRoute/pull/13615)) — thanks @maxmad64bis
- **fix(devin):** Fall back to the CLI probe (`devin acp --agent-type summarizer`) when the connection-test HTTP API rejects a CLI-format key, since routing authenticates against the local Devin CLI, not `api.devin.ai` ([#13617](https://github.com/diegosouzapw/OmniRoute/pull/13617)) — thanks @patrykkopycinski
- **fix(sse):** stream reasoning deltas from combo targets incrementally instead of buffering them into a single burst, and stop rejecting reasoning-only streams as an empty completion (#13620) ([#13806](https://github.com/diegosouzapw/OmniRoute/pull/13806)) — thanks @NaNomicon
- **fix(models):** keep OpenRouter's Batch-API-only `:batch` variants out of chat routing — ModelSync imported all 77 of them into the chat catalogue, where every request that landed on one was rejected with `404 This model is only available through the Batch API` (#13622) — thanks @L4XB
- **fix(cursor):** `kv_after_text` no longer settles away a trailing `exec_mcp` tool call in the same buffer, preventing Composer from dropping in-flight MCP tool invocations during KV checkpoint settling ([#13627](https://github.com/diegosouzapw/OmniRoute/pull/13627)) — thanks @patrykkopycinski
- fix(providers): restore grok-4.6/4.5 default reasoning effort so requests without an explicit effort keep reasoning enabled (#13628) — thanks @HouMinXi
- fix(registry): declare supportedThinkingEfforts on claude-opus-5 and claude-fable-5 across the anthropic/claude/claude-web/ghe-copilot/github registries (#13628) — thanks @HouMinXi
- **fix(stream-recovery):** opt-in `STREAM_RECOVERY_TOOLCALL_ORDER_FIX` (default off) makes mid-stream continuation tool-call safe — a cut stream is never resumed once a tool call was emitted, whether still in flight or already finished with `finish_reason: "tool_calls"` — and closes after one empty continuation instead of spending the whole budget ([#13633](https://github.com/diegosouzapw/OmniRoute/pull/13633)) — thanks @maxmad64bis
- **fix(ci):** clear the `release/v3.8.51` base-reds on the PR fast path. The provider pipeline keeps the upstream error code and type again, so an Antigravity missing-project 422 stays fail-closed. The glued-prefix `sk-` credential pattern scans error text in linear time instead of quadratic. The provider detail page no longer bundles `node:fs`. `/v1/models` stops listing custom Jina models twice. The `models`/`providers` import cycle is gone, and `opencode-plugin-v2` passes the pack policy. Stale guards, fixtures, locale keys and docs counts are aligned with their merged changes ([#13635](https://github.com/diegosouzapw/OmniRoute/pull/13635)) — thanks @dpozimski
- **fix(tests):** add `dist/httpClientAbortGuard.mjs` to the expected missing-paths list in `tests/unit/pack-artifact-policy.test.ts` — [#13636](https://github.com/diegosouzapw/OmniRoute/pull/13636) registered the file in `PACK_ARTIFACT_REQUIRED_PATHS` without updating the assertion, leaving the test red on the release tip for every PR that runs it ([#12732](https://github.com/diegosouzapw/OmniRoute/issues/12732)) ([#13872](https://github.com/diegosouzapw/OmniRoute/pull/13872))
- **fix(compression):** report compression-worker faults instead of silently sending the uncompressed body, and fall back to the in-process pipeline for fast faults (thread error, exit, engine throw); a dispatch timeout still degrades to uncompressed, but is now logged ([#13637](https://github.com/diegosouzapw/OmniRoute/pull/13637)) — thanks @marcs7
- **fix(db):** search stats and analytics no longer surface "ghost" rows — a NULL/`-` provider or a keyed search provider whose connection was deleted — while keyless providers (`duckduckgo-free`, `searxng-search`, anonymous `context7`) and credential-fallback providers (`perplexity-search` on a `perplexity` key) stay visible; the analytics totals apply the same filter, so `total` always matches the per-provider breakdown ([#13641](https://github.com/diegosouzapw/OmniRoute/pull/13641)) — thanks @maxmad64bis
- **fix(sse):** recognize `reasoning_effort` in the reactive 400 field-strip retry — strict OpenAI-compatible upstreams that reject the field are retried once without it instead of surfacing the 400 ([#13642](https://github.com/diegosouzapw/OmniRoute/pull/13642)) — thanks @Moseyuh333
- **fix(dashboard):** new opt-in flag `FREE_BADGE_REQUIRES_PROVIDER_FREE_TIER` (default off) makes the provider-page Free badge strict — it drops the display-name heuristic, non-boolean `free` fields and `:free` suffixes on registered providers without a documented free tier, while keeping catalogued free models, explicit `free: true` and `:free` on free-tier providers and compatible nodes; with the flag off the badges are unchanged ([#13645](https://github.com/diegosouzapw/OmniRoute/pull/13645)) — thanks @maxmad64bis
- **fix(api):** share the single SOCKS5 flag reader across the settings proxy routes so the dashboard and the dispatcher stay consistent ([#13646](https://github.com/diegosouzapw/OmniRoute/pull/13646)) — thanks @maxmad64bis
- **fix(stream-recovery):** log every mid-stream continuation outcome with its `attempt N/MAX` token — the stitched suffix, overlap rejection, terminal/empty continuation and tool-call refusals at debug, and a recovery that gives up (budget spent, or the continuation request returned no stream) at warn — without adding any warn line to a healthy or tool-call stream; the existing `mid-stream continuation attempt N/MAX` line is unchanged ([#13650](https://github.com/diegosouzapw/OmniRoute/pull/13650)) — thanks @maxmad64bis
- **fix(sse):** Kiro translator no longer re-prepends the full relocated tool-documentation block onto every subsequent turn of a multi-turn conversation; it now stays anchored to the turn that originally carried it. (#13652) ([#13808](https://github.com/diegosouzapw/OmniRoute/pull/13808)) — thanks @KelvinKSPS
- **fix(sse):** opt-in `OPENCODE_RATE_LIMITED_429_EARLY_STOP` flag (default off): an opencode 429 classified as a real rate limit (parseable `Retry-After`, or a body naming a rate/usage limit) stops the cross-account wave and returns that upstream 429 unchanged — body, `Retry-After` and quota headers intact, so the opencode quota error rules still apply; unclassified 429s keep rotating, and with the flag off every 429 rotates as before (#9611) ([#13657](https://github.com/diegosouzapw/OmniRoute/pull/13657)) — thanks @maxmad64bis
- **fix(sse):** a configured daily-quota reset hour that falls inside a daylight-saving gap (New York 02:00 on spring-forward, Havana/Santiago midnight) now resolves to the first wall-clock time that exists instead of landing an hour early, sometimes on the previous day ([#13671](https://github.com/diegosouzapw/OmniRoute/pull/13671)) — thanks @maxmad64bis
- **fix(sse):** new opt-in flag `RETRY_AFTER_PROVENANCE_ENABLED` (default off): aggregated 429/503 unavailable responses omit `Retry-After` when no concrete future retry time is known instead of sending a synthetic 1s, carry `error.retry_after_provenance` (`signal` | `none`), and combo drain paths read prose retry hints from JSON and plain-text upstream bodies; non-JSON upstream error pages no longer log at warn ([#13672](https://github.com/diegosouzapw/OmniRoute/pull/13672)) — thanks @maxmad64bis
- **fix(docker):** isolate the ChatGPT Web (Codex) CDP proxy sidecar onto its own Compose network, add an opt-in `CDP_PROXY_TOKEN` auth gate to `cdp-proxy.mjs`, and stop the VNC browser-login CDP bridge from starting when no token is configured (#13679) ([#13811](https://github.com/diegosouzapw/OmniRoute/pull/13811))
- **fix(security):** the CLI/management bearer token is now derived from a random per-install salt persisted under `DATA_DIR` instead of the checked-in literal `omniroute-cli-auth-v1` — since `/etc/machine-id` is commonly world-readable, any local user could previously derive the same token as every install that never set `OMNIROUTE_CLI_SALT`; the explicit env override still takes priority and rotation still works the same way (#13679) ([#13909](https://github.com/diegosouzapw/OmniRoute/pull/13909))
- **fix(auth):** `verifyCloudSignature()` no longer accepts an unverifiable `X-Cloud-Sig` when `OMNIROUTE_CLOUD_SYNC_SECRET` is unset — a forged/garbage signature is rejected outright, and the new opt-in `OMNIROUTE_CLOUD_SYNC_ENFORCE_SIGNATURE=true` flag rejects unsigned Cloud-sync payloads too (default stays legacy pass-through for v3.8.x; the default flips in v3.9) ([#13679](https://github.com/diegosouzapw/OmniRoute/issues/13679)) ([#13804](https://github.com/diegosouzapw/OmniRoute/pull/13804))
- **fix(security):** default the published Docker image and `fly.toml` deployment to `REQUIRE_API_KEY=true` (npm/CLI local-dev default unchanged), and stop `/api/free-tier/summary`'s wildcard CORS from leaking the operator's local token usage to unauthenticated callers (#13679) ([#13911](https://github.com/diegosouzapw/OmniRoute/pull/13911))
- **fix(security):** removed the copy-pasteable placeholder `JWT_SECRET`/`API_KEY_SECRET`/`INITIAL_PASSWORD` values from the Podman Quadlet deploy manifest, and blocked remote dashboard logins with the well-known default `INITIAL_PASSWORD=CHANGEME` (#13679) ([#13812](https://github.com/diegosouzapw/OmniRoute/pull/13812))
- **fix(security):** the internal self-loop admission-bypass bearer is now a random per-process secret instead of the checked-in literal `"sk_omniroute"` when no `OMNIROUTE_API_KEY`/`ROUTER_API_KEY` is configured (#13679) ([#13813](https://github.com/diegosouzapw/OmniRoute/pull/13813))
- **fix(db):** `DELETE /v1/batches/delete-completed` now caps the work it does per request and reports `hasMore` so a caller can resume, and the sweep no longer deletes a file that another batch still references (#13680, #13681) ([#13805](https://github.com/diegosouzapw/OmniRoute/pull/13805))
- **fix(security):** a restricted API key is now enforced on the alias spellings `next.config.mjs` rewrites onto `/api/v1/…` — a route handler sees the client's original URL, so `POST /chat/completions`, `/responses`, `/responses/*`, `/models`, `/codex/*` and the doubled `/v1/v1/*` prefix all skipped the endpoint-category lookup and let a key allowed only on `search` reach chat or any other endpoint ([#13685](https://github.com/diegosouzapw/OmniRoute/issues/13685)) ([#13741](https://github.com/diegosouzapw/OmniRoute/pull/13741)) — thanks @gonisulaimann
- **fix(usage):** mark locally estimated token usage in the call log (`_omniroute.usageEstimated` on the logged response) so operators can tell estimated counts and costs from provider-reported ones — covers OmniRoute's own estimate for streams without upstream usage and web executors that report `estimated: true`; billing, API-key budgets, quota-share and client payloads are unchanged ([#13686](https://github.com/diegosouzapw/OmniRoute/pull/13686)) — thanks @maxmad64bis
- **fix(openrouter):** sync the `:free` 1000/day tier from `/credits` lifetime purchases instead of staying stuck at 50/day for $10+ accounts ([#13689](https://github.com/diegosouzapw/OmniRoute/pull/13689)) — thanks @Notaloop763
- **fix(translator):** prevent schema property name collisions (e.g. `properties`, `required`) in Gemini schema sanitizer ([#13690](https://github.com/diegosouzapw/OmniRoute/pull/13690)) — thanks @zcrew0x
- fix(providers): map `thinking.type: "adaptive"` to `"enabled"` for AgentRouter GLM models instead of forwarding it unhandled, fixing a 400 from AgentRouter's upstream GLM endpoint (#13696) ([#14043](https://github.com/diegosouzapw/OmniRoute/pull/14043))
- **fix(providers):** the shared Responses-API input sanitizer now converts Codex's proprietary `agent_message` input items (used for multi-agent task/reply passing) into a plain `message` item before forwarding to any non-Codex-native Responses upstream. Previously such items reached third-party Responses endpoints untouched, and OpenCode Go Muse Spark 1.3 rejected the request with `input[N] did not match any supported type` (#13698). The real Codex/ChatGPT native passthrough path is unaffected and continues to receive `agent_message` items as-is. ([#14041](https://github.com/diegosouzapw/OmniRoute/pull/14041))
- fix(sse): stop the direct (no-proxy) fresh-socket retry from reusing the pooled attempt's flat `OMNIROUTE_DIRECT_HEADERS_TIMEOUT_MS` response-start watchdog — the retry is a brand-new socket with no zombie to detect (#10214's rationale only applies to the pooled attempt), so when the caller already attached its own deadline signal (the resolved connection/model/provider/`FETCH_TIMEOUT_MS` cascade) the retry now defers to a generous, `OMNIROUTE_DIRECT_RESPONSE_RETRY_TIMEOUT_MS`-configurable backstop instead of an identical short flat window, fixing spurious 504s on healthy slow-TTFB reasoning models (#13703) ([#14047](https://github.com/diegosouzapw/OmniRoute/pull/14047))
- **fix(copilot):** fall back to the `copilot-chat` identity once when a standard GitHub Copilot account rejects the CLI identity with a 403, without breaking Enterprise Copilot ([#13705](https://github.com/diegosouzapw/OmniRoute/pull/13705)) — thanks @tuandinh0801
- **fix(dashboard):** Saving or clearing a proxy on a provider page now refreshes the per-connection proxy badges immediately instead of leaving them stale until a manual reload ([#13711](https://github.com/diegosouzapw/OmniRoute/pull/13711)) — thanks @xiaoyaner0201
- fix(db): bound health scans and isolate native diagnostics so large quota histories no longer exhaust memory or block request handling; diagnostics now run in a cancellable child and are awaited at HTTP and MCP callers (#13717) — thanks @HouMinXi
- fix(chat): preserve suffix-model reasoning effort across model attempts so a replacement model no longer inherits or drops the original suffix, and keep explicit reasoning choices in request dedup hashes (#13720) — thanks @HouMinXi
- **fix(api):** `POST /v1/rerank` now actually works against native TEI / Infinity provider nodes: the `/rerank` fallback sends `texts` + `return_text` alongside `documents`, and bare-array or `score`-only upstream responses are normalized to the Cohere `{results: [{index, relevance_score, document?}]}` envelope (sorted, `top_n`-capped) so clients and the memory engine's rerank step see real scores ([#13733](https://github.com/diegosouzapw/OmniRoute/pull/13733)) — thanks @seanford
- **fix(api):** `GET /v1/models` types compatible-provider-node rows by the node's `apiType` when a discovered/added model carries no endpoint metadata — an `embeddings` node's models are `type: "embedding"` and a `rerank` node's models are `type: "rerank"` instead of surfacing as untyped chat models; a manual overlay's `supportedEndpoints` also re-types the merged row ([#13734](https://github.com/diegosouzapw/OmniRoute/pull/13734)) ([#13740](https://github.com/diegosouzapw/OmniRoute/pull/13740)) — thanks @seanford
- **fix(memory):** the Embedding and Rerank selectors on Memory → Engine now list local provider nodes with the models they actually expose for that modality (synced + custom rows, typed the same way `GET /v1/models` types them) instead of by node `apiType` alone with an empty model list — a node typed `embeddings` that also serves a reranker now appears under Rerank with that model, and under Embeddings with its embedding model, so `prefix/model` no longer has to be typed by hand ([#13740](https://github.com/diegosouzapw/OmniRoute/pull/13740)) — thanks @seanford
- **fix(usage):** preserve nested prompt cache-read counters before cost calculation ([#13760](https://github.com/diegosouzapw/OmniRoute/pull/13760)) — fixes [#13746](https://github.com/diegosouzapw/OmniRoute/issues/13746) — thanks @xiaoyaner0201
- **fix(db):** Health-check-repair backup pruning now resolves `maxFiles`/`retentionDays` from the persisted Storage-page setting (not just env vars), matching manual/API/auto backups. ([#13308](https://github.com/diegosouzapw/OmniRoute/issues/13308)) ([#13773](https://github.com/diegosouzapw/OmniRoute/pull/13773)) — thanks @voidstackloop
- **fix(sse):** CCR retrieve-tool detection now recognizes an MCP-gateway-namespaced tool name (e.g. Docker MCP Toolkit's `mcp__docker__omniroute__omniroute_ccr_retrieve` or a single `mcp__<server>__omniroute_ccr_retrieve` prefix) via a separator-bounded trailing-segment match instead of requiring an exact `omniroute_ccr_retrieve` string — previously an MCP-capable caller reachable only under a gateway-assigned name was treated as unable to retrieve, so the entire CCR compression engine was skipped for it (#13781). ([#14028](https://github.com/diegosouzapw/OmniRoute/pull/14028))
- **fix(i18n):** retranslate the English strings that had been copied verbatim into the locale catalogs (Spanish alone carried 7,142) and turn the real-translation ratio gate into a blocking ratchet. (#13782)
- fix(resilience): the chat admission gate's pressure check now actively re-samples instead of reading a passive cache, so the `resource_pressure` guard can observe recovery and stop shedding once real pressure clears, instead of requiring a full process restart ([#13823](https://github.com/diegosouzapw/OmniRoute/pull/13823)) — thanks @pandudpn
- **fix(providers):** the Zylo API key check now probes the authenticated chat route instead of the open catalog — Zylo serves `GET /v1/models` without authentication, so the account-setup dialog accepted any string as valid and the key was only rejected later, when a model test returned Zylo's own `401 "Key not found: zk-…"` ([#13828](https://github.com/diegosouzapw/OmniRoute/issues/13828)) ([#13877](https://github.com/diegosouzapw/OmniRoute/pull/13877))
- **fix(sse):** when a gateway API key's `allowed_connections` / quota scope hides every connection of a provider, chat now answers `403` naming that scope instead of the generic `No active credentials for provider: X` — which was indistinguishable from "never configured" even though `/test` and `/sync-models` kept working on the same connection ([#13832](https://github.com/diegosouzapw/OmniRoute/issues/13832)) ([#13879](https://github.com/diegosouzapw/OmniRoute/pull/13879))
- **fix(combos):** Keep vision capability consistent for MiMo V2.5 and Step 3.7 Flash provider/free variants so `/v1/combos` no longer under-reports multimodal combos whose members are already advertised as vision-capable by `/v1/models` ([#13847](https://github.com/diegosouzapw/OmniRoute/issues/13847)). ([#13863](https://github.com/diegosouzapw/OmniRoute/pull/13863)) — thanks @smshagor-dev
- **fix(translator):** Third-party tool names (e.g. GitHub Copilot's own `web_fetch` function tool) are no longer sent unprefixed to Claude-wire-format providers outside genuine first-party Anthropic traffic, fixing a `rejected tool(s): web_fetch` 400 for any `gh/claude-*` model ([#13856](https://github.com/diegosouzapw/OmniRoute/pull/13856)) — thanks @dylanhaskins
- fix(build): externalize @modelcontextprotocol/sdk in standalone server webpack config to prevent TDZ ReferenceError on MCP initialize (#13859) — thanks @HouMinXi
- fix(combo): stop the chars/4 context estimate from demoting an operator-verified `model_context_override` behind an unconfirmed catalog "emergency" fallback in combo priority ordering (#13870) ([#14046](https://github.com/diegosouzapw/OmniRoute/pull/14046))
- fix(quota): resolve Quota Sharing plan/limits from the pool's canonical primary connection in `enforceQuotaShare` and `recordConsumption`, not the serving connection — a multi-connection pool whose "Limite" wizard override only ever lands on the primary connection was writing/checking consumption under a different dimension key when a request was actually served via a non-primary pool member, so the dashboard's "consumed" amount never reflected real traffic (#13876) ([#14042](https://github.com/diegosouzapw/OmniRoute/pull/14042))
- Fixed a security issue where a revoked, expired, or banned API key could still resolve an owner scope in `getApiKeyRequestScope()` and keep accessing its own `/v1/files` and `/v1/batches` records instead of being rejected with 401 (#13881). ([#14024](https://github.com/diegosouzapw/OmniRoute/pull/14024))
- fix(security): scope `/api/files` and `/api/batches` management siblings to the caller's own API key (session stays instance-wide), closing a cross-tenant read that let an anonymous or foreign-key caller enumerate and download other tenants' files/batches (#13882) ([#14027](https://github.com/diegosouzapw/OmniRoute/pull/14027))
- **security(images):** close a DNS-rebinding TOCTOU (#13883) at the three newer public-only image download sites — `resolveImageSource` and the NanoBanana result-URL conversion in `imageGeneration.ts`, and `resolveUpscaleImageSource` in `imageUpscale/shared.ts`. All three validated a caller-supplied URL's DNS answer as public but then let the download perform an independent, un-pinned second resolution at connect time, so a host that answered differently between the two lookups (public, then loopback/LAN) could reach an internal address; they now set `pinDns: true` (reusing the existing `createPinnedFetch` helper already used by embeddings and the vision/audio/video bridges), binding the connection to the exact validated address. ([#14032](https://github.com/diegosouzapw/OmniRoute/pull/14032))
- **fix(i18n):** reviewer pass over the 1,865 pt-BR leaves retranslated in #13782 (172 corrections) via the new `review-locale` script. (#13885)
- **fix(db):** `getPricingForModel()` now reads through the existing 30s TTL `getCachedPricing()` helper instead of rebuilding pricing from scratch (3 SELECTs + JSON.parse + merge) on every call, eliminating the multi-second event-loop stall `/api/usage/history` hit when `calculateAggregateCost()` invoked it once per GROUP BY row (up to 531 times per request) (#13891). Every known pricing writer already invalidates this cache via `touchPricing()`, so writes remain immediately visible. ([#14040](https://github.com/diegosouzapw/OmniRoute/pull/14040))
- **fix(cli):** translate the CLI for every locale — 38 catalogs carried 124 of 830 keys and fell back to English; `sync-ui-keys --catalog=cli` fills them (52,000 strings) and a completeness gate keeps them full. (#13892)
- **fix(dashboard):** the request-log detail view now shows an explicit "payload omitted — exceeded the call log size limit" notice for a pipeline/request/response section that was replaced by the size-limit marker (`_omniroute_truncated` / `[omitted: call log artifact size limit exceeded]`), instead of silently rendering the marker verbatim under a generic "Pipeline Error" title as if it were a real upstream error. Also fixed `.env.example` documenting stale `CHAT_LOG_ARRAY_TAIL_ITEMS=128`/`CHAT_LOG_MAX_DEPTH=6` defaults that no longer match the code's actual `1000`/`20`. ([#14045](https://github.com/diegosouzapw/OmniRoute/pull/14045))
- **fix(sse):** the CCR protocol instruction (the system note teaching a model how to call `omniroute_ccr_retrieve`) is now injected for callers reaching OmniRoute's MCP server through a namespacing gateway (Claude Code / Docker MCP style `mcp__<gateway>__<server>__omniroute_ccr_retrieve`, or a dotted/slashed prefix) — `callerSupportsCcrRetrieve()` previously matched by exact string equality only, so a genuinely reachable but gateway-namespaced tool name was never recognized and the instruction (and CCR compression itself) was silently skipped (#13897). ([#14028](https://github.com/diegosouzapw/OmniRoute/pull/14028))
- fix(tests): replace the flaky 250ms wall-clock ReDoS guard in `sanitizeErrorMessage`'s property test with a deterministic cost-scaling check, so the test proves bounded-backtracking instead of failing on machine load (#13907) ([#14039](https://github.com/diegosouzapw/OmniRoute/pull/14039))
- fix(providers): `detectVisionInput` now recognizes Lemonade Server's `labels[]` vision capability, so Lemonade vision models import with `supportsVision` set instead of being treated as text-only (#13918) ([#14023](https://github.com/diegosouzapw/OmniRoute/pull/14023))
- fix(sse): deepseek provider registry now declares `defaultContextLength: 1_000_000` and an explicit `deepseek-flash` model entry, so unlisted/new DeepSeek models (like the new DeepSeek V4.1 Flash) no longer fall back to the generic 128k context limit (#13922) ([#14026](https://github.com/diegosouzapw/OmniRoute/pull/14026))
- **fix(sse):** opt-in `OPENCODE_PARK_AND_RESUME` flag (default off): after repeated transient 429s the opencode rotation parks the request with a heartbeat and replays one capped leg of up to 3 sequential accounts instead of fanning out the whole fleet; with the flag off every 429 rotates as before ([#13924](https://github.com/diegosouzapw/OmniRoute/pull/13924)) — thanks @maxmad64bis
- fix(guardrails): resolve nested `combo-ref` steps to their real leaf models when deciding vision-bridge behavior, so a pass-through combo pointing at an all-vision-capable inner combo skips the describe-and-replace path instead of stripping raw images (#13927) ([#14038](https://github.com/diegosouzapw/OmniRoute/pull/14038))
- **fix(docs):** re-sync the 65 documentation mirror sets — 817 mirrors rewritten: the 14 core sources edited since their translation, the 322 mirrors that were still English copies, and the frontmatter the old extractor had leaked into the newer locales' bodies. The docs pipeline now retranslates only the `## ` sections whose text changed, and the drift gate is blocking. (#13940)
- fix(combos): synchronize allowedProviders and allow invariant override when updating combos from dashboard ([#13951](https://github.com/diegosouzapw/OmniRoute/pull/13951)) — thanks @fouadSalkini
- **fix(i18n):** translate the 3,719 `__MISSING__` markers (61 keys × 61 locales) that eight base PRs stamped into the catalogs on 2026-09-16, restoring the real-translation ratio gate on the release tip. (#13974)
- **fix(providers):** OpenRouter model discovery honors the per-connection base URL override instead of always importing the global catalog, so a connection pointed at a regional endpoint (e.g. the EU in-region host) no longer advertises model ids that endpoint cannot serve ([#14001](https://github.com/diegosouzapw/OmniRoute/pull/14001)) — thanks @tiangao88
- **fix(providers):** Perplexity Web no longer collapses runs of spaces in non-streaming answers (the path tool mode always takes), which flattened code indentation in `write_file` arguments and plain code blocks; citation markers are still removed with single spacing left behind ([#13968](https://github.com/diegosouzapw/OmniRoute/issues/13968)) ([#14009](https://github.com/diegosouzapw/OmniRoute/pull/14009)) — thanks @costajohnt
- **fix(docker):** copy the app into the runtime image with `--chown=node:node` instead of a second `chown -R` layer, so the image no longer stores the ~2 GB standalone build twice ([#13990](https://github.com/diegosouzapw/OmniRoute/issues/13990)) ([#14010](https://github.com/diegosouzapw/OmniRoute/pull/14010)) — thanks @costajohnt
- **fix(opencode):** an OpenCode free-tier refusal no longer counts as a healthy response and no longer takes the account it landed on out of rotation for that model: the 403 is recorded on the connection instead of staying unclassified, it stops clearing the refused account's failure history, and the request comes back without a pointless hop across accounts that would all get the same verdict. Every sibling account returns the same answer to the same request, so one refusal per account would otherwise empty the pool and leave later requests answered "no active credentials" ([#14011](https://github.com/diegosouzapw/OmniRoute/pull/14011)) — thanks @maxmad64bis
- **fix(opencode):** keyless OpenCode models answer again instead of returning `403` — requests now carry a versioned OpenCode user-agent, canonical session and request ids derived from the existing conversation fingerprint, a streamed upstream request, and a tool list. The upstream inspects which tool names a request declares, so rather than pinning a list, OmniRoute reuses the one a request of the same conversation was last seen getting through: a title or a summary, which its client sends without tools, goes out with the list that client already declared. Two opt-outs (`OPENCODE_FREE_TIER_REQUEST_CONTRACT`, `OPENCODE_FREE_TIER_PLACEHOLDER_TOOLS`) ([#14013](https://github.com/diegosouzapw/OmniRoute/pull/14013)) — thanks @maxmad64bis (with thanks to @AStupidBear for the identity-header work in #13937)
- **fix(build):** drop the orphaned `httpClientAbortGuard.mjs` entries from the pack-artifact allowlists — the #13636 crash-guard wiring was removed, so no producer or consumer ships the file anymore ([#14029](https://github.com/diegosouzapw/OmniRoute/pull/14029)) — thanks @maxmad64bis
- **feat(sse):** multi-account rotation now spreads sends per network egress and eases off fleet-wide when throttled, opt-in via `OPENCODE_EGRESS_THROTTLE_ENABLED=1` ([#14290](https://github.com/diegosouzapw/OmniRoute/pull/14290)) — thanks @maxmad64bis
- Fix Antigravity image generation not rotating to another account when the upstream returns an explicit quota-exhausted 429, so a second configured account with available quota is no longer stuck behind the first account's terminal quota error. ([#9908](https://github.com/diegosouzapw/OmniRoute/pull/9908)) — thanks @Ardem2025
- **fix(sse):** `requestQueue.maxQueueDepth = 0` (the documented default, "disabled") once again means an unbounded account queue. #12911 redefined `0` inside `accountSemaphore` as "reject when busy" for its Codex WS leases, and because `chatCore` forwards `maxQueueDepth` straight into that option, every request that found its account slot busy under default settings was answered 429 `Semaphore queue full (0)` instead of waiting. The lease keeps its refuse-don't-wait behaviour through an explicit `failFast` option. ([#14101](https://github.com/diegosouzapw/OmniRoute/pull/14101))
- **fix(sse):** the streaming OpenAI→Claude translator relays reasoning for legacy callers that never pass `requestedThinking` (`undefined`), matching the non-streaming path and the pre-#12905 contract; only an explicit opt-out (`false`) suppresses it, and only that case synthesizes the reasoning into a text block. ([#14101](https://github.com/diegosouzapw/OmniRoute/pull/14101))
- **fix(security):** `requestRejectedFailure.ts` (#12864) sanitizes the upstream message at its own `lastError` writes instead of trusting the caller to have done so, and the public-boundary guard now covers the extracted module. ([#14101](https://github.com/diegosouzapw/OmniRoute/pull/14101))
- **fix(adobe-firefly):** never spawn a real Chrome for CDP session warm under a unit-test runner, so tests stop leaking a browser process that holds an OS handle on its DATA_DIR profile directory ([#13289](https://github.com/diegosouzapw/OmniRoute/pull/13289)) — thanks @anhtahaylove
- fix(providers): **declare Agnes CN chat models' live `reasoning_effort` vocabulary** so the catalog and sanitizer stop inventing tiers the CN API rejects. Probes on api.agnes-ai.cn (2026-09-14) match the international endpoint: 2.0/2.5 accept `none/low/medium/high/max`, 3.0 also accepts `minimal` and `xhigh`; `off`/`ultra` clamp off the wire and Hermes' default `xhigh` clamps to `max` on 2.x. ([#13399](https://github.com/diegosouzapw/OmniRoute/pull/13399)) — thanks @HouMinXi
- fix(providers): **declare Agnes chat models' live `reasoning_effort` vocabulary so catalog/builder/sanitizer stop inventing aliases the API 400s.** 2.0/2.5 accept `none/low/medium/high/max`; 3.0 also accepts `minimal` and `xhigh`. `off`/`ultra` still clamp off the wire. ([#13655](https://github.com/diegosouzapw/OmniRoute/pull/13655)) — thanks @HouMinXi
- Fix Antigravity quota parsing treating an unreported `remainingFraction` as 0% remaining instead of unknown, which made a genuinely exhausted quota indistinguishable from one the upstream simply didn't report. ([#7138](https://github.com/diegosouzapw/OmniRoute/pull/7138)) — thanks @Ardem2025
- **fix(api):** accept `blockedModels` in the key permissions update schema so the deny-list half of per-key model policy is no longer silently stripped before it reaches the route ([#13666](https://github.com/diegosouzapw/OmniRoute/pull/13666)) — thanks @fouadSalkini
- **fix(tests):** bump the `APIKEY_PROVIDERS` tripwire count to 241 — Agnes AI China (#13399) added one `apikey/regional` entry, and the stale 240 was failing a unit-test shard on every open PR against `release/v3.8.51` ([#13905](https://github.com/diegosouzapw/OmniRoute/pull/13905))
- **fix(ci):** the advisory `forgotten-sibling-tests` step no longer fails "Fast Quality Gates" when a PR touches a hub module — the cross-product of consumers × candidate tests reached millions of rows and rendering them exceeded V8's maximum string length, so the throw hit `main()`'s catch and exited 1. The report now lists at most 200 rows per section (and 5 000 per array in the JSON artifact) while the header keeps the exact totals ([#13889](https://github.com/diegosouzapw/OmniRoute/pull/13889))
- **fix(compression):** Caveman's `leader_phrases` rule (and any other anchored file-pack rule) works again. #12825 replaced the keyword prefilter for file-based packs with a test of the rule's own regex, but ran it against a lower-cased snapshot of the _original_ text — so `^(?:i will|…)` never matched once the leading `Sure, ` was still there, and the rule was skipped before it could see the text pleasantries had already cleaned. The prefilter now sees the text as earlier rules left it. ([#14164](https://github.com/diegosouzapw/OmniRoute/pull/14164)) — thanks @gonisulaimann / @prabhtheone / @xiaoyaner0201 / @xiechimon
- **Dashboard:** renamed the Claude connection field helpers to `claudeConnectionFieldValues.ts` — the `.ts`/`.tsx` pair from #13074 differed only by casing, which breaks webpack on case-insensitive filesystems and made esbuild resolve the wrong module in the browser-bundle guard. ([#14164](https://github.com/diegosouzapw/OmniRoute/pull/14164)) — thanks @gonisulaimann / @prabhtheone / @xiaoyaner0201 / @xiechimon
- **fix(oauth):** stop posting a Claude refresh token that another in-process refresh already consumed. Re-check the rotation map and DB inside `serializeRefresh` (both Layer 1 and Layer 2), record Layer 2 rotations, re-read the connection uncached on `invalid_grant`, and keep the Claude `refreshToken` instead of nulling it into sticky `no_refresh_token`. ([#13874](https://github.com/diegosouzapw/OmniRoute/pull/13874)) — thanks @ai-jeremi-esky
- **fix(oauth):** stop nulling the Claude refresh token on the first unrecoverable refresh failure so `CredentialHealth` no longer gets stuck sticky-dead — the retry budget from #11414 can now actually spend its second attempt instead of finding an already-cleared token (#13183) ([#13185](https://github.com/diegosouzapw/OmniRoute/pull/13185)) — thanks @RaviTharuma
- **fix(stream):** restore Claude SSE passthrough `tool_use` names to the casing the client actually declared instead of "upgrading" them to the canonical Claude Code spelling (`bash``Bash`), which broke third-party Anthropic-format clients (pi/OpenCode on claude-format executors like devin-cli-agentic) while leaving the JSON path correct; the pre-existing Claude Code protection (upstream downcase restored to declared PascalCase, #7926) is preserved ([#12855](https://github.com/diegosouzapw/OmniRoute/pull/12855)) — thanks @Neuron-Mr-White
- **fix(oauth):** classify embedded `invalid_grant` in Cline token refresh error bodies so permanently consumed refresh tokens trigger re-authentication instead of indefinite transient retry loops, and add `cline` to `ROTATION_LOCK_GROUP` to serialize concurrent sibling refreshes ([#13466](https://github.com/diegosouzapw/OmniRoute/pull/13466)) — thanks @fouadSalkini
- Add a visible manual callback-entry action to the Codex loopback warning so remote users can paste the authorization result instead of setting up an SSH tunnel. ([#9944](https://github.com/diegosouzapw/OmniRoute/pull/9944)) — thanks @Ardem2025
- Fixed Codex executor forwarding client `reasoning` sub-fields (`enabled`, `max_tokens`, `exclude`) that the Codex Responses API rejects with HTTP 400, taking down every combo target with a deterministic client error. The reasoning object is now whitelisted to `effort`/`summary`, and `enabled: false` maps to effort `none` when no more specific effort was requested. ([#13643](https://github.com/diegosouzapw/OmniRoute/pull/13643)) — thanks @HouMinXi
- **fix(combo):** retry the same target once when a streaming response fails before any content reaches the client (`streaming upstream error`), including native-pinned Codex turns whose set retries stay disabled — previously the caller returned a 502 immediately instead of using the existing transient-retry loop ([#13630](https://github.com/diegosouzapw/OmniRoute/pull/13630)) — thanks @anhtahaylove
- **Emergency fallback:** the budget-exhaustion reroute targets `nvidia/openai/gpt-oss-120b` again, as documented in `ENVIRONMENT.md` and the NVIDIA hosted-model snapshot; #14006 had switched the provider to `groq` inside an unrelated MITM change, so operators without a Groq connection got the original 402 back. ([#14164](https://github.com/diegosouzapw/OmniRoute/pull/14164)) — thanks @gonisulaimann / @prabhtheone / @xiaoyaner0201 / @xiechimon
- **fix(security):** client-supplied image URLs (`image_url` / `mask_url` / message parts on image generation and upscale, chat `image_url` parts inlined by the vision bridge) and the NanoBanana result download now pin the `public-only` outbound guard with DNS validation, instead of inheriting the operator provider policy — `block-metadata` on a default install let a request body make the server fetch loopback/LAN URLs and forward the bytes upstream (GHSA-34rg-3pqj-35g9) ([#13748](https://github.com/diegosouzapw/OmniRoute/pull/13748))
- **fix(authz):** classify the 14 remaining spawn-capable `/api/cli-tools/*` routes (`all-statuses`, `status`, `detect` and the `claude/cline/codewhale/codex/crush/deepseek-tui/droid/kilo/openclaw/pi/smelt-settings` writers) and the `/api/skills/install` + `/api/skills/executions` pair as LOCAL_ONLY — they reach `child_process.spawn` transitively (`getCliRuntimeStatus()` / `detectAllTools()` / the skills sandbox) but only sat behind Tier 3 MANAGEMENT auth, which `requireLogin=false` waives; loopback/LAN enforcement now runs before any auth check, matching their already-gated siblings (GHSA-35fw-cv32-2373 — thanks Parth Narula; GHSA-jx89-f37j-pq89 — thanks Aeon). Tunnel-served dashboards lose the CLI Tools status badges, the same trade-off already accepted for grok/forge/jcode/qwen. ([#13745](https://github.com/diegosouzapw/OmniRoute/pull/13745))
- **fix(security):** redact Groq (`gsk_…`), xAI (`xai-…`) and every OpenAI-compatible `sk-…` key shape (DeepSeek 32-hex, Moonshot/Kimi, Together, …) in error bodies and the opt-in credential-masker guardrail — the catalog only knew the exact 48-char OpenAI form, so those keys passed through the guardrail verbatim and `gsk_`/`xai-` also reached public error responses (GHSA-r4q7-7f24-m29p) ([#13744](https://github.com/diegosouzapw/OmniRoute/pull/13744))
- **fix(grok-cli):** a 429 "used all the included free usage … rolling 24-hour window" on Grok Build is quota exhaustion for that model, not a 30s rate-limit wait. Combo skips the drained grok-4.6 login and tries the next account ([#13984](https://github.com/diegosouzapw/OmniRoute/pull/13984)) — thanks @stormsia
- **Rate limit:** `requestQueue.maxWaitMs=0` (the disable sentinel from #12902) no longer trips the #12715 queue-budget gate — every request on a protected connection was rejected with an immediate `503 queue budget` instead of waiting without a queue deadline. Execution stays bounded by `executionMaxWaitMs` and the upstream fetch-start timeout. ([#14164](https://github.com/diegosouzapw/OmniRoute/pull/14164)) — thanks @gonisulaimann / @prabhtheone / @xiaoyaner0201 / @xiechimon
- **fix(mcp):** MCP audit treats a non-callable better-sqlite3 export (`better-sqlite3 export is not a function`) as a native load failure, falls back to `node:sqlite`, and caches a failed driver load so dashboard polls stop reprinting (a database file that does not exist yet is never cached, so the connection recovers once the app creates it). Docker now refuses to ship without `better_sqlite3.node`. Native-load classification lives in `sqliteLoadError.ts` so `core.ts` stays under its frozen line cap. The build bootstrap keeps a deliberately narrower classifier: a corrupt binding there must not be read as "no encrypted credentials", or a fresh `STORAGE_ENCRYPTION_KEY` would be generated over an existing encrypted database. ([#13903](https://github.com/diegosouzapw/OmniRoute/pull/13903)) — thanks @HouMinXi
- **fix(api):** the model-test skip for image/music/video-only models (#13376) returned a result with no `httpStatus`, and the `/api/models/test` route hands that field straight to `NextResponse` — so a skipped test reached the client as HTTP 200 carrying `status: "error"` in the body. It now answers 422: the request is valid, but that model's modality cannot be exercised by a chat test. Also clears the `TS2741` that was failing `API Route Typecheck` on the release branch. ([#13730](https://github.com/diegosouzapw/OmniRoute/pull/13730))
- **fix(sse):** `detectMalformedNonStream` no longer flags a Claude-format message as malformed when the content array only contains empty text blocks or the `(empty response)` sentinel and `stop_reason` is `"length"` — a legitimate truncation (e.g. `ollama/qwen3:1.7b` exhausting its reasoning budget before producing visible text), not a real 502-worthy empty response. ([#12935](https://github.com/diegosouzapw/OmniRoute/pull/12935)) — thanks @jasminsehic
- **fix(compression):** output styles and the caveman output mode now place their injected instruction in the top-level `system` field instead of a synthetic `messages[0]` entry for Anthropic-shaped requests, fixing the upstream 400 ("use the top-level 'system' parameter for the initial system prompt") ([#12584](https://github.com/diegosouzapw/OmniRoute/issues/12584)) ([#13383](https://github.com/diegosouzapw/OmniRoute/pull/13383)) — thanks @Xore
- Add a shadow release-acceptance report next to release-green.json. It does not close #12732 and is not a Mergify required check. ([#13701](https://github.com/diegosouzapw/OmniRoute/pull/13701)) — thanks @HouMinXi
- **fix(sse):** chat admission PSI uses the container cgroup `memory.pressure` file instead of host-wide `/proc/pressure/memory`, so a swapping host no longer 503s an idle Docker/cgroup OmniRoute with `resource_pressure`; the host file remains the fallback when the cgroup sample is missing ([#12562](https://github.com/diegosouzapw/OmniRoute/pull/12562)) — thanks @SCys
- **fix(responses):** ensure full compliance with the OpenAI Responses API streaming schema for strict deserializers (e.g. OpenAI Responses SDK, Grok CLI / pager): - Include `output: []`, `background: false`, and `error: null` in the `response.in_progress` lifecycle event across both the Responses transformer and response translator. - Include `status` (`in_progress` or `completed`) on all emitted output items (`message`, `reasoning`, `function_call`, `custom_tool_call`) in `response.output_item.added`, `response.output_item.done`, and `response.output[]`. - Include `sequence_number: 0` in in-band Responses stream error frames (`OPENAI_RESPONSES_ERROR_FRAME` and `buildResponsesErrorDataLine`) emitted after early keepalive streams commit. - Ensure `input_tokens_details` (with `cached_tokens: 0`) and `output_tokens_details` (with `reasoning_tokens: 0`) are always populated in `response.usage` even when upstreams (e.g. Gemini) omit reasoning or caching tokens. ([#13956](https://github.com/diegosouzapw/OmniRoute/pull/13956)) — thanks @TheDemonTuan
- **responses:** Responses-to-Chat fallback now skips replayed `web_search_call` metadata while preserving the paired function result, preventing deterministic HTTP 400 failures on follow-up turns routed to Chat Completions providers (#13304 ([#14090](https://github.com/diegosouzapw/OmniRoute/pull/14090)) — thanks @anhtran-ai)
- **fix(security):** bump the `adm-zip` override to `^0.6.1` — 0.6.0 followed a symlink already present inside the extraction root and could write outside it (GHSA-vwc7-r8mq-g2x9 / CVE-2026-76845); 0.6.1 walks every path component with `lstat` and refuses symlinks. Reached only through `onnxruntime-node`'s install script, which unpacks the vendor's own binary — no request-path exposure. ([#13737](https://github.com/diegosouzapw/OmniRoute/pull/13737))
- **fix(ci):** register the four unit tests whose mutant kills were not counting — `daily-reset-tz-threading`, `noauth-model-lockout`, `free-badge-provider-gate` and `local-token-budget-429-skips-cooldown` — in `stryker.conf.json` `tap.testFiles`. `check-mutation-test-coverage --strict` reported 6 missing coverings across 4 mutated modules (`accountFallback`, `sse/services/auth`, `combo/comboPredicates`, `combo/rrState`) on the release line itself with no PR diff involved, so the `mutation-test-coverage` gate was red on every open PR regardless of its contents; the gate now reports no drift ([#13814](https://github.com/diegosouzapw/OmniRoute/pull/13814)) — thanks @abhisheksharma2411
- fix(ci): drop a `tap.testFiles` entry naming a deleted test file, and guard the direction the existing drift check never covered — an entry left behind after its test is removed or renamed costs mutation coverage silently, because Stryker resolves the list into its sandbox without failing on a dangling path ([#13814](https://github.com/diegosouzapw/OmniRoute/pull/13814)) — thanks @abhisheksharma2411
- fix(providers): give the TinyCMS wasm-bindgen Node DOM stub a dedicated `window` with a Location-shaped object (never `window = global` without `location`), so Next.js SSR `getLocationOrigin` cannot crash every route after TinyCMS is used once ([#13957](https://github.com/diegosouzapw/OmniRoute/pull/13957)) — thanks @aref-alapour
- **fix(analytics):** resolve account email/name in Utilization Account Split chart and fix legend bleeding through tooltip ([#13029](https://github.com/diegosouzapw/OmniRoute/pull/13029)) — thanks @ZaimMarzuki
- Electron release: `electron/package-lock.json` regained the optional `electron-builder-squirrel-windows` subtree (13 entries) that `npm ci` had been refusing as out of sync — the Linux desktop leg died on it — and `electron-release.yml` gained a `build_ref` dispatch input so a release whose tag was cut with the broken lock can have its assets rebuilt from the repaired line ([#13140](https://github.com/diegosouzapw/OmniRoute/pull/13140)) — thanks @ozeas
- npm publish workflow: the CycloneDX SBOM is attached to the GitHub Release on `workflow_dispatch` publishes too (when a release for the tag exists), not only on the `release` event — v3.8.50 shipped through a staged dispatch and its release carried no SBOM until it was attached by hand from the run's `sbom-npm` artifact ([#13140](https://github.com/diegosouzapw/OmniRoute/pull/13140)) — thanks @ozeas
- **fix(providers):** strip `<script>`/`<style>` blocks from the Vertex model-docs HTML even when the end tag carries junk before the `>` (`</script foo>`, which the HTML spec still treats as a close). The old regexp required `</script\s*>`, so such a block survived; the generic tag-stripping pass then removed both tags and kept the script BODY, letting its text reach the table cells the context-window/token-limit parser reads (CodeQL `js/bad-tag-filter`, alert #1007). ([#13936](https://github.com/diegosouzapw/OmniRoute/pull/13936))
- **fix(vertex):** prefer live model discovery for OAuth, Service Account, and service-account-bound authorization-key credentials while falling back cleanly to the Gemini-only Express catalog for standard API keys. Model Garden resources now route by publisher protocol: Claude and Mistral use their native `rawPredict` APIs, and Grok plus current or future open MaaS publishers use Vertex's OpenAI-compatible endpoint with normalized request IDs such as `xai/grok-4.6`. Distinguish intentional API-key catalog rejection from transient HTTP or network failures. ([#12471](https://github.com/diegosouzapw/OmniRoute/pull/12471)) — thanks @JxnLexn
- **fix(db):** remove the periodic `wal_checkpoint(TRUNCATE)` scheduler. Truncating the WAL rewrites the shared wal-index (`storage.sqlite-shm`) while other connections and in-flight statements still hold it mapped, which crashed long-running servers with SIGBUS roughly every six hours (#13973). WAL hygiene is unchanged: PASSIVE checkpoints still run every five minutes, now count busy contention and retry after 60s instead of silently logging, and a WAL above `OMNIROUTE_WAL_GUARD_MAX_MB` runs `wal_checkpoint(RESTART)` so the file stays bounded without rewriting the mapped index. The shutdown checkpoint still truncates. `OMNIROUTE_WAL_TRUNCATE_INTERVAL_MS` is ignored and logs a deprecation warning. ([#14005](https://github.com/diegosouzapw/OmniRoute/pull/14005)) — thanks @HouMinXi
- **fix(antigravity):** surface image quotas in Provider Limits ([#12311](https://github.com/diegosouzapw/OmniRoute/pull/12311)) — thanks @Bl0ck154
- **fix(perplexity-web):** preserve system contract on follow-up requests ([#12443](https://github.com/diegosouzapw/OmniRoute/pull/12443)) — thanks @tanveer-arch
- **fix(translator):** map Claude stop_sequences and stop to Gemini stopSequences ([#12785](https://github.com/diegosouzapw/OmniRoute/pull/12785)) — thanks @Siva010
- **fix(cursor):** preserve native Claude effort model IDs before executor dispatch ([#12838](https://github.com/diegosouzapw/OmniRoute/pull/12838)) — thanks @Pllutonyy
- **fix(sse):** guard empty tool_calls[] and strip tool_choice without tools ([#12901](https://github.com/diegosouzapw/OmniRoute/pull/12901)) — thanks @initguru
- **fix(resilience):** allow maxWaitMs=0 as disable sentinel for execution expiration ([#12902](https://github.com/diegosouzapw/OmniRoute/pull/12902)) — thanks @initguru
- **fix(sse):** demote mid-conversation system roles to user in claude-to-openai translation ([#12908](https://github.com/diegosouzapw/OmniRoute/pull/12908)) — thanks @initguru
- **fix(soniox):** pass client parameters through and surface speaker diarization ([#12948](https://github.com/diegosouzapw/OmniRoute/pull/12948)) — thanks @amirrezakm
- **fix(tests):** drain call-log saves before chat-pipeline DB resets (#12780) ([#12966](https://github.com/diegosouzapw/OmniRoute/pull/12966)) — thanks @visheshgubrani
- **fix(providers):** resolve unsupportedParams via model aliases so K3 stops 400ing on temperature ([#13037](https://github.com/diegosouzapw/OmniRoute/pull/13037)) — thanks @patrykkopycinski
- **fix(db):** gate the auto-cleanup VACUUM on reclaimable space, not row count ([#13079](https://github.com/diegosouzapw/OmniRoute/pull/13079)) — thanks @hartmark
- **fix(logs):** recover concatenated JSON objects in the Provider Event Stream viewer ([#13115](https://github.com/diegosouzapw/OmniRoute/pull/13115)) — thanks @hartmark
- **fix(quota):** apply the equal-split fallback in the pool usage snapshot ([#13159](https://github.com/diegosouzapw/OmniRoute/pull/13159)) — thanks @datrixlab
- **fix(deps):** declare remark-gfm as a direct dependency ([#13162](https://github.com/diegosouzapw/OmniRoute/pull/13162)) — thanks @marioschoenert-code
- **fix(responses):** count input tokens locally for Codex OAuth ([#13167](https://github.com/diegosouzapw/OmniRoute/pull/13167)) — thanks @anhtran-ai
- **fix(sse):** deepseek-web resilience — premature session close + malformed tool-call recovery ([#13226](https://github.com/diegosouzapw/OmniRoute/pull/13226)) — thanks @VictorRP7
- **fix(memory):** skip FTS rewrite on access-count updates ([#13331](https://github.com/diegosouzapw/OmniRoute/pull/13331)) — thanks @HouMinXi
- **fix(security,resilience):** block origin-IP header forwarding and treat 413 as retryable TPM ([#13350](https://github.com/diegosouzapw/OmniRoute/pull/13350)) — thanks @themedexperiencesusa
- **fix(build):** copy ioredis and bcryptjs into the standalone bundle ([#13352](https://github.com/diegosouzapw/OmniRoute/pull/13352)) — thanks @sistemabritto
- **fix(embeddings):** send stored API key on private-host embeddings nodes ([#13398](https://github.com/diegosouzapw/OmniRoute/pull/13398)) — thanks @HouMinXi
- **fix(claude-web):** add charset=utf-8 to Content-Type headers to fix Arabic/Persian UTF-8 mojibake (#13416) ([#13419](https://github.com/diegosouzapw/OmniRoute/pull/13419)) — thanks @KooshaPari
- **fix(compression):** track only the recursion path in isStrictlySerializable (#13154) ([#13423](https://github.com/diegosouzapw/OmniRoute/pull/13423)) — thanks @KooshaPari
- **fix(network):** skip Chrome TLS impersonation for Groq ([#13445](https://github.com/diegosouzapw/OmniRoute/pull/13445)) — thanks @HouMinXi
- **fix(gemini):** send thinkingLevel for 3.8 Flash so thoughts stop eating the output cap ([#13463](https://github.com/diegosouzapw/OmniRoute/pull/13463)) — thanks @HouMinXi
- **fix(compression):** log warnings for unreadable settings rows ([#13522](https://github.com/diegosouzapw/OmniRoute/pull/13522)) — thanks @KooshaPari
- **fix(proxy):** add combo scope to fail-closed proxy guard ([#13551](https://github.com/diegosouzapw/OmniRoute/pull/13551)) — thanks @KooshaPari
- **fix(combos):** stop Gemini thinking from failing dashboard combo tests ([#13560](https://github.com/diegosouzapw/OmniRoute/pull/13560)) — thanks @HouMinXi
- **fix(sse):** strip trailing assistant prefill on official Claude OAuth ([#13572](https://github.com/diegosouzapw/OmniRoute/pull/13572)) — thanks @HouMinXi
- **fix(usage):** detach completed request previews ([#13623](https://github.com/diegosouzapw/OmniRoute/pull/13623)) — thanks @jbovard2016
- **fix(providers):** clamp SenseNova DeepSeek V4 Flash effort to high ([#13626](https://github.com/diegosouzapw/OmniRoute/pull/13626)) — thanks @HouMinXi
- **fix(routing):** skip redundant parseAutoPrefix for recognized built-in auto variants ([#13647](https://github.com/diegosouzapw/OmniRoute/pull/13647)) — thanks @hummern
- **fix(embeddings):** log server-side when a provider can't be resolved ([#13687](https://github.com/diegosouzapw/OmniRoute/pull/13687)) — thanks @hartmark
- **fix(codex):** forward the caller client version upstream instead of a pinned default ([#13708](https://github.com/diegosouzapw/OmniRoute/pull/13708)) — thanks @zeeshanhaque21
- **fix(gemini):** a tool name starting with a digit no longer fails the request ([#13738](https://github.com/diegosouzapw/OmniRoute/pull/13738)) — thanks @L4XB
- **fix(docker):** smoke-test images before promoting latest ([#13761](https://github.com/diegosouzapw/OmniRoute/pull/13761)) — thanks @lorenzozanee
- **fix(admission):** measure request bodies with the active cost budget ([#13762](https://github.com/diegosouzapw/OmniRoute/pull/13762)) — thanks @lorenzozanee
- **fix(providers):** add missing modelsUrl for qwen-cloud-token-plan ([#13764](https://github.com/diegosouzapw/OmniRoute/pull/13764)) — thanks @lorenzozanee
- **fix(providers):** bypass web-search fallback for Antigravity target (#13447) ([#13765](https://github.com/diegosouzapw/OmniRoute/pull/13765)) — thanks @lorenzozanee
- **fix(resilience):** name collision keys, surface header drops, retry embeds, skip far-reset pings ([#13766](https://github.com/diegosouzapw/OmniRoute/pull/13766)) — thanks @lorenzozanee
- **fix(compression):** preserve case, tags and negations through llmlingua engine ([#13768](https://github.com/diegosouzapw/OmniRoute/pull/13768)) — thanks @lorenzozanee
- **fix(sse):** skip already-refused route per request on 429 ([#13795](https://github.com/diegosouzapw/OmniRoute/pull/13795)) — thanks @maxmad64bis
- **fix(antigravity):** canonicalize tiered flash quota ([#13809](https://github.com/diegosouzapw/OmniRoute/pull/13809)) — thanks @domenicomassafra
- **fix(executors):** strip invalid OpenCode stream options ([#13819](https://github.com/diegosouzapw/OmniRoute/pull/13819)) — thanks @qinghuanandejiangshi
- **fix(providers):** refresh uncloseai free-model roster after upstream rotation ([#13825](https://github.com/diegosouzapw/OmniRoute/pull/13825)) — thanks @legas888Oleg
- **fix:** match compatible-provider models owned by public prefix ([#13831](https://github.com/diegosouzapw/OmniRoute/pull/13831)) — thanks @sahildaswani
- **fix(ci):** drain three base reds blocking every PR — generated SKILL.md, stryker registry, env/docs contract ([#13834](https://github.com/diegosouzapw/OmniRoute/pull/13834))
- **fix(sre):** redact secrets split across stream chunks ([#13837](https://github.com/diegosouzapw/OmniRoute/pull/13837)) — thanks @pacocartones
- **fix(qoder):** unwrap split SSE error envelopes ([#13838](https://github.com/diegosouzapw/OmniRoute/pull/13838)) — thanks @pacocartones
- **fix(streaming):** track progress across chunk boundaries ([#13839](https://github.com/diegosouzapw/OmniRoute/pull/13839)) — thanks @pacocartones
- **fix(nlpcloud):** restore chatbot endpoint coverage ([#13845](https://github.com/diegosouzapw/OmniRoute/pull/13845)) — thanks @pacocartones
- **fix(translator):** pair Gemini tool responses per turn to prevent cross-turn ID collision ([#13848](https://github.com/diegosouzapw/OmniRoute/pull/13848)) — thanks @TheDemonTuan
- **fix:** reclaim expired half-open probe lease ([#13849](https://github.com/diegosouzapw/OmniRoute/pull/13849)) — thanks @luw2007
- **fix(providers):** normalize non-function tools for allowlisted built-in OpenAI-format providers ([#13855](https://github.com/diegosouzapw/OmniRoute/pull/13855)) — thanks @xiechimon
- **fix(auth):** enforce blocked models in all-access mode ([#13861](https://github.com/diegosouzapw/OmniRoute/pull/13861)) — thanks @fewensa
- **fix(codex):** preserve native custom tools in Responses WebSocket requests ([#13864](https://github.com/diegosouzapw/OmniRoute/pull/13864)) — thanks @trycohn
- **fix(docs):** restore the env/docs contract broken by the #13679 vars ([#13875](https://github.com/diegosouzapw/OmniRoute/pull/13875))
- **fix(chatCore):** only preserve tool_result blocks for Claude-native targets ([#13972](https://github.com/diegosouzapw/OmniRoute/pull/13972)) — thanks @phs1997
- **fix(windows):** hide supervised server console ([#13992](https://github.com/diegosouzapw/OmniRoute/pull/13992)) — thanks @prabhtheone
- **fix(ci):** document OMNIROUTE_STRIP_SYSTEM_PREAMBLE — the env/docs base red blocking every PR ([#14022](https://github.com/diegosouzapw/OmniRoute/pull/14022))
- **fix(providers):** declare Agnes official thinking effort tiers (#13655) ([#14063](https://github.com/diegosouzapw/OmniRoute/pull/14063)) — thanks @HouMinXi
- **fix(resilience):** extend process crash guard to combo hedge cancels and upstream fetch failures (#13636) ([#14064](https://github.com/diegosouzapw/OmniRoute/pull/14064)) — thanks @HouMinXi
- **fix(codex):** whitelist reasoning object keys before the wire (#13643) ([#14065](https://github.com/diegosouzapw/OmniRoute/pull/14065)) — thanks @HouMinXi
- **fix(sse):** aggregate the `findInsensitive` collision warnings into one line per catalog build instead of one per colliding key ([#12972](https://github.com/diegosouzapw/OmniRoute/pull/12972)) — thanks @IAMBOBJIM
### 📝 Maintenance
@@ -1083,6 +1502,62 @@ _By commits in `091589089c..c0f92ec98a`, author identities consolidated via `.ma
- **chore(deps):** bump hono from ^4.12.34 to ^4.13.7 (#13148) ([#13301](https://github.com/diegosouzapw/OmniRoute/pull/13301)) — thanks @KooshaPari
- **chore(deps):** pin joi to ^18.2.8 via overrides (#13085) ([#13302](https://github.com/diegosouzapw/OmniRoute/pull/13302)) — thanks @KooshaPari
- **docs(providers):** add Microsoft 365 Copilot (BizChat) provider guide (#12779) ([#13340](https://github.com/diegosouzapw/OmniRoute/pull/13340)) — thanks @KooshaPari
- `Coverage` job on `ci.yml`: the informational Codecov upload gets its own 5-minute ceiling and `continue-on-error`, and the job budget grows from 20 to 30 minutes (the 8-shard c8 merge alone takes ~10) — a stalled upload no longer ends the job `cancelled` and drags a fully green `main` run's conclusion down with it ([#11972](https://github.com/diegosouzapw/OmniRoute/pull/11972))
- **docs(dependencies):** clarify that `socket.yml` only shapes Socket.dev's registry-side post-publish scan of the published npm artifact — it is not an enforced CI/PR merge gate ([#12664](https://github.com/diegosouzapw/OmniRoute/pull/12664) — thanks @toor11).
- **chore(skills):** regenerate the `omni-settings` agent skill after the pool egress-observation route landed (#13581), clearing the `check:agent-skills-sync` base-red (#12732) ([#13826](https://github.com/diegosouzapw/OmniRoute/pull/13826))
- **test(call-logs):** the early-keepalive merge and video-bridge redaction tests pass a `traceId` (defaulting to `pendingRequestId`) now that #13546 keys each attempt's call-log row on it, the dashboard `request.failed` redaction probe reads the persisted row by `traceId`, and the keepalive test polls against a 30s wall-clock deadline like the video-bridge test instead of a 2.4s try count ([#12732](https://github.com/diegosouzapw/OmniRoute/issues/12732)) ([#13747](https://github.com/diegosouzapw/OmniRoute/pull/13747))
- **fix(db):** drop the duplicated `ERROR_TYPE_CONTRACT` import in `src/lib/db/callLogStats.ts` left by the #13641 merge; the TS2300 duplicate-identifier error failed the API-route and dashboard typecheck gates on every PR ([#12732](https://github.com/diegosouzapw/OmniRoute/issues/12732)) ([#13747](https://github.com/diegosouzapw/OmniRoute/pull/13747))
- **fix(cli):** add the `serve.ready_timeout` string to the `en`, `zh-CN` and `zh-TW` CLI catalogs; `--ready-timeout` shipped calling `t("serve.ready_timeout")` without a catalog entry, which the CLI i18n key-coverage and parity tests report ([#12732](https://github.com/diegosouzapw/OmniRoute/issues/12732)) ([#13747](https://github.com/diegosouzapw/OmniRoute/pull/13747))
- **docs(env):** document `OMNIROUTE_SYNCED_CATALOG_STALE_AFTER_MS` (#12849, default 30 days) in `.env.example` and `ENVIRONMENT.md`; the stale-synced-catalog fail-open shipped the override without either, which the env/docs contract gate reports as code-only ([#12732](https://github.com/diegosouzapw/OmniRoute/issues/12732)) ([#13678](https://github.com/diegosouzapw/OmniRoute/pull/13678))
- **fix(ci):** re-freeze `tests/unit/image-generation-handler.test.ts` (2133→2235, #13748) and `tests/unit/batch_api.test.ts` (1345→1348, #13749) at their merged size; PR-mode `check:file-size` does not relax `testFrozen` against the base, so that regression coverage turned the gate red on every PR into the release line ([#12732](https://github.com/diegosouzapw/OmniRoute/issues/12732)) ([#13747](https://github.com/diegosouzapw/OmniRoute/pull/13747))
- **fix(ci):** allowlist the Uzbek `outputTokenDesc` translation ("Yakunlash/javob tokenlari") and the `PROTECTED_PRIORITY_INFRA_502_ENABLED` feature-flag id (#13439) in `.gitleaks.toml`; the `generic-api-key` rule reads the `...TokenDesc` key and the flag `key:` as token assignments, which the secrets ratchet reported as new findings ([#12732](https://github.com/diegosouzapw/OmniRoute/issues/12732)) ([#13747](https://github.com/diegosouzapw/OmniRoute/pull/13747))
- **test(models):** the custom Jina specialty-model catalog test expects the `jina-ai/` prefix again: custom rows keep the connection provider id, only synced rows resolve through the `jina` alias, and #13403 had switched the custom assertion to `jina/` ([#12732](https://github.com/diegosouzapw/OmniRoute/issues/12732)) ([#13747](https://github.com/diegosouzapw/OmniRoute/pull/13747))
- **chore(build):** ship `httpClientAbortGuard.mjs` in the published tarball — the #13636 crash guard was a new `server-ws.mjs` import missing from both pack-artifact allowlists (#12732) ([#13826](https://github.com/diegosouzapw/OmniRoute/pull/13826))
- **test(settings):** the #6540 paid-target tests now use `gemini/gemini-3.1-pro-preview` as the paid fixture and assert the fixtures still classify as paid/free/unknown; the old Together target became "unknown" once #13407 removed Together's one-time signup credit from the free catalog, so the three save-time blocking tests read a correct 200 as a missing guard ([#12732](https://github.com/diegosouzapw/OmniRoute/issues/12732)) ([#13747](https://github.com/diegosouzapw/OmniRoute/pull/13747))
- **fix(ci):** the release-green validator now runs its package-artifact gate the way `ci.yml` does — build, stamp `dist/BUILD_SHA`, then validate against the tree under test. `build:cli` never writes the stamp, so even with the provenance ref pointed at `HEAD` the gate could only ever report "dist/BUILD_SHA is missing" once the build itself compiled ([#12732](https://github.com/diegosouzapw/OmniRoute/issues/12732)) ([#13678](https://github.com/diegosouzapw/OmniRoute/pull/13678))
- **test(resilience):** the `/api/resilience` configuration-only key-set assertion now lists `credentialHealthCheck`, the sweep-interval setting #12043 added to the projection, so the integration suite stops reading a documented configuration key as leaked runtime state ([#12732](https://github.com/diegosouzapw/OmniRoute/issues/12732)) ([#13678](https://github.com/diegosouzapw/OmniRoute/pull/13678))
- **fix(ci):** register `noauth-model-lockout`, `local-token-budget-429-skips-cooldown`, `free-badge-provider-gate` (#13645) and `daily-reset-tz-threading` (#13440) in `stryker.conf.json` `tap.testFiles`; they cover `accountFallback.ts`/`auth.ts`/`comboPredicates.ts`/`rrState.ts`, so the strict `mutation-test-coverage` gate failed Fast Quality Gates on every PR into the release line ([#12732](https://github.com/diegosouzapw/OmniRoute/issues/12732)) ([#13747](https://github.com/diegosouzapw/OmniRoute/pull/13747))
- **chore(ci):** register the #13609 ambiguous-401 regression test in the mutation-coverage config, clearing the second `check:agent-skills-sync`/`mutation-test-coverage` base-red (#12732) ([#13826](https://github.com/diegosouzapw/OmniRoute/pull/13826))
- **fix(tests):** eight combo integration suites still targeted the retired `claude-3-5-sonnet-20241022`, which the lifecycle registry rejects with 410; they now use its successor `claude-sonnet-4-6`, un-hiding 23 routing cases ([#13056](https://github.com/diegosouzapw/OmniRoute/pull/13056)) — thanks @doramirdor
- **test(coverage):** clean stale `coverage/` output before `test:coverage` runs, stopping unbounded accumulation of c8 raw snapshots and reports ([#13408](https://github.com/diegosouzapw/OmniRoute/pull/13408)) — thanks @MumuTW
- **docs(resilience):** correct `requestQueue.maxWaitMs` in the resilience guide and the environment reference — it bounds **queue wait**, not limiter-managed execution (that is `executionMaxWaitMs`), and the env vars only supply defaults that a persisted or per-connection value overrides (#13624) — thanks @L4XB
- **test(batches):** the two seeded-batch labels of the delete-completed route-scope suite that sat right after a `key*.id` argument are renamed to short literals (`route401`/`route500`), so a gitleaks scan that reads those lines (full-tree, or git-mode on a branch that adds them) no longer reports them as `generic-api-key` hits ([#13729](https://github.com/diegosouzapw/OmniRoute/pull/13729)) — no gate changes: the CI secret ratchet scans `src`/`open-sse`/`bin`/`electron`/`scripts`, never `tests/`
- **credit:** three squash merges on `release/v3.8.51` dropped the contributor attribution the review pipeline had preserved on the branches — the dual-layer semantic cache ([#14159](https://github.com/diegosouzapw/OmniRoute/pull/14159), re-land of [#12630](https://github.com/diegosouzapw/OmniRoute/pull/12630)) is @BillyOutlast's work, the native Codex auto-resume fix ([#14162](https://github.com/diegosouzapw/OmniRoute/pull/14162), re-land of [#13180](https://github.com/diegosouzapw/OmniRoute/pull/13180)) is @mdigitalbh81's, and the `resolvedExtensionEnd` reorder in [#13295](https://github.com/diegosouzapw/OmniRoute/pull/13295) landed first in @ggiak's [#13036](https://github.com/diegosouzapw/OmniRoute/pull/13036). This entry and its commit trailers record that credit ([#14361](https://github.com/diegosouzapw/OmniRoute/pull/14361)) — thanks @BillyOutlast, @mdigitalbh81 and @ggiak
- **chore(quality):** clear the `release/v3.8.51` base-reds — 19 failing unit tests plus the `API Route Typecheck` and `mutation-test-coverage` gates. Three fixtures still built `*-compatible-*` connections with no `baseUrl` and so tripped the #13452/#13798 guard that now refuses to fall back to the real OpenAI/Anthropic API; `modelDiscovery.ts` missed the `VertexModelMetadataProvenance` cast its read-path twin already had (#12471); a raw NUL byte in a provider-test regexp made git treat the file as binary; and the reserved-prefix count, the budget-card SVG and the Stryker `tap.testFiles` list had drifted. The #2331, OAuth-loopback and i18n guards were re-expressed as the invariants they protect — each re-verified by mutating the source back and watching it fail. ([#13947](https://github.com/diegosouzapw/OmniRoute/pull/13947))
- **ci:** repair the `API Route Typecheck` base-red on `release/v3.8.51` — type the awaited-callback contract of `runWithConnectionFetch` (glmResetCards), annotate `handleSingleModelChat(): Promise<Response>`, and give the codex-responses-ws bridge helpers real `{ error } | payload` discriminants so `"error" in x` narrows again; baseline ratcheted 294 → 283 (no widening). ([#14079](https://github.com/diegosouzapw/OmniRoute/pull/14079))
- **quality:** rebaseline `open-sse/executors/codex.ts` 1552 → 1553 — the +1 landed with #14065 (#13643) without a baseline entry and turned `check:file-size` red on the release tip.
- **compression/tests:** repair the unit base-reds on `release/v3.8.51` left by the 09-17 merge wave — RTK dedup was skipped for every unknown-type text (#13521 widened `skipFilters` to `isDocumentLikeRead`), so repeated tool output stopped compressing (12 tests); the translate-path golden, the `APIKEY_PROVIDERS` count and the G13 golden/SSE `Content-Type` assertions were stale after #12648 (xKiro) and #13419 (`charset=utf-8`). ([#14082](https://github.com/diegosouzapw/OmniRoute/pull/14082))
- **quality:** owner-approved file-size rebaseline for the 2026-09-18 merge-train 8 (32 contributor PRs whose irreducible growth lands in already-frozen files — 28 ceilings raised to the measured combined sizes; per-PR attribution in `config/quality/file-size-baseline.json`).
- **chore(quality):** adjust the train-8 accountFallback ceiling to the re-measured 2517 (direct commit `271ec25f12`)
- **test(usage):** pin fetcherProviders against supportedProviders ([#13134](https://github.com/diegosouzapw/OmniRoute/pull/13134)) — thanks @abhisheksharma2411
- **chore:** add Windows helpers to run OmniRoute and Claude Code from a source checkout ([#13312](https://github.com/diegosouzapw/OmniRoute/pull/13312)) — thanks @easypathuni
- **test:** realign two stale assertions with product behavior (#13313) ([#13315](https://github.com/diegosouzapw/OmniRoute/pull/13315)) — thanks @anhtahaylove
- **chore(stryker):** register 3 covering unit tests missing from tap.testFiles ([#13357](https://github.com/diegosouzapw/OmniRoute/pull/13357)) — thanks @patrykkopycinski
- **test(adobe-firefly):** lock in the browser-spawn guard with a regression test ([#13358](https://github.com/diegosouzapw/OmniRoute/pull/13358)) — thanks @patrykkopycinski
- **chore(lint):** prune 3 obsolete eslint suppressions ([#13359](https://github.com/diegosouzapw/OmniRoute/pull/13359)) — thanks @patrykkopycinski
- **chore(skills):** regenerate cli-serve after --ready-timeout was added ([#13722](https://github.com/diegosouzapw/OmniRoute/pull/13722))
- **docs(changelog):** reconcile the v3.8.51 living section — round 2 (2026-09-15) ([#13731](https://github.com/diegosouzapw/OmniRoute/pull/13731))
- **ci(radar-export):** publish on release-branch catalog pushes; daily schedule ([#13736](https://github.com/diegosouzapw/OmniRoute/pull/13736))
- **docs:** prefill issue titles with Conventional Commits format ([#13750](https://github.com/diegosouzapw/OmniRoute/pull/13750)) — thanks @xiechimon
- **test(embeddings):** cover custom embeddings authorization ([#13763](https://github.com/diegosouzapw/OmniRoute/pull/13763)) — thanks @lorenzozanee
- **test(combo):** gate-level regression for stale persisted-cooldown skip (#13694) ([#13767](https://github.com/diegosouzapw/OmniRoute/pull/13767)) — thanks @lorenzozanee
- **test(routing):** guard Astra vision cutover ([#13810](https://github.com/diegosouzapw/OmniRoute/pull/13810)) — thanks @domenicomassafra
- **test(dashboard):** reactivate API endpoints coverage ([#13841](https://github.com/diegosouzapw/OmniRoute/pull/13841)) — thanks @pacocartones
- **test(dashboard):** reactivate discovery page coverage ([#13842](https://github.com/diegosouzapw/OmniRoute/pull/13842)) — thanks @pacocartones
- **test(dashboard):** reactivate request logger coverage ([#13843](https://github.com/diegosouzapw/OmniRoute/pull/13843)) — thanks @pacocartones
- **test(dashboard):** reactivate webhook wizard coverage ([#13844](https://github.com/diegosouzapw/OmniRoute/pull/13844)) — thanks @pacocartones
- **chore:** reconcile the 2026-09-16 merge wave with the release tip ([#13904](https://github.com/diegosouzapw/OmniRoute/pull/13904))
- **test(catalog):** stop the yield guard from dying on production's build budget ([#13906](https://github.com/diegosouzapw/OmniRoute/pull/13906))
- **chore:** reconcile the JxnLexn merge wave with the release tip ([#13921](https://github.com/diegosouzapw/OmniRoute/pull/13921))
- **docs(agents):** advance the documented Bun pin to 1.4.2 ([#13946](https://github.com/diegosouzapw/OmniRoute/pull/13946))
- **chore(quality):** rebaseline the four ceilings the 2026-09-17 merge wave moved ([#14002](https://github.com/diegosouzapw/OmniRoute/pull/14002))
- **chore(quality):** rebaseline the last ceiling the 09-17 merge wave moved ([#14016](https://github.com/diegosouzapw/OmniRoute/pull/14016))
- **test(dashboard):** reactivate logs modal coverage ([#14048](https://github.com/diegosouzapw/OmniRoute/pull/14048)) — thanks @pacocartones
- **chore(deps):** bump better-sqlite3 to ^13.0.3 ([#14049](https://github.com/diegosouzapw/OmniRoute/pull/14049)) — thanks @Hakarioz
- **ci(acceptance):** emit a shadow release-acceptance report next to release-green (#13701) ([#14066](https://github.com/diegosouzapw/OmniRoute/pull/14066)) — thanks @HouMinXi
- **docs(i18n):** refresh the mirrors and keys the base left behind; warn on stale mirrors ([#14166](https://github.com/diegosouzapw/OmniRoute/pull/14166))
- **deps:** 4 Dependabot bumps — bump oven/bun from 1.4.0-slim to 1.4.2-slim ([#12977](https://github.com/diegosouzapw/OmniRoute/pull/12977)); bump js-yaml ([#13212](https://github.com/diegosouzapw/OmniRoute/pull/13212)); bump the development group across 1 directory with 15 updates ([#13661](https://github.com/diegosouzapw/OmniRoute/pull/13661)); bump electron from 44.0.0 to 44.3.0 in /electron ([#13664](https://github.com/diegosouzapw/OmniRoute/pull/13664))
### 🙌 Contributors
@@ -1091,120 +1566,235 @@ Thanks to everyone whose work landed in v3.8.51:
| Contributor | PRs / Issues |
| --- | --- |
| [@5dive-bot](https://github.com/5dive-bot) | #11852 |
| [@abhisheksharma2411](https://github.com/abhisheksharma2411) | #11547, #11684, #11959, #12209 |
| [@aaustinhuang](https://github.com/aaustinhuang) | #13201, #13206 |
| [@abhisheksharma2411](https://github.com/abhisheksharma2411) | #11547, #11684, #11959, #12209, #12235, #13134, #13295, #13673, #13814, #13820 |
| [@adevwithpurpose](https://github.com/adevwithpurpose) | #11464 |
| [@adityadwi21](https://github.com/adityadwi21) | #13776 |
| [@adivekar-utexas](https://github.com/adivekar-utexas) | #12015, #12043, #12090, #12138 |
| [@afonsoft](https://github.com/afonsoft) | #13801, #13802, #13807 |
| [@ai-jeremi-esky](https://github.com/ai-jeremi-esky) | #13874 |
| [@AIB1TAL0S](https://github.com/AIB1TAL0S) | #12103 |
| [@alltomatos](https://github.com/alltomatos) | #11448 |
| [@alvinveroy](https://github.com/alvinveroy) | #11857, #12027, #12165 |
| [@alvinveroy](https://github.com/alvinveroy) | #11857, #12027, #12165, #13002 |
| [@amaleta](https://github.com/amaleta) | #11906, #11934 |
| [@amartinawi](https://github.com/amartinawi) | #11575 |
| [@amirrezakm](https://github.com/amirrezakm) | #12309, #12310, #12948 |
| [@andrea-kingautomation](https://github.com/andrea-kingautomation) | #13785 |
| [@AndrianBalanescu](https://github.com/AndrianBalanescu) | #11083, #11830, #11834, #11844 |
| [@anhtahaylove](https://github.com/anhtahaylove) | #13091, #13092, #13093, #13096, #13097, #13100, #13106, #13109, #13114, #13155, #13166, #13171, #13175, #13187, #13196 |
| [@AnhLead](https://github.com/AnhLead) | #13378 |
| [@anhtahaylove](https://github.com/anhtahaylove) | #13091, #13092, #13093, #13096, #13097, #13100, #13106, #13109, #13114, #13155, #13166, #13171, #13175, #13187, #13196, #13289, #13292, #13315, #13630, #13778 |
| [@anhtran-ai](https://github.com/anhtran-ai) | #13167, #14090 |
| [@aniruddhaadak80](https://github.com/aniruddhaadak80) | #11760 |
| [@app](https://github.com/app) | #12554 |
| [@Ardem2025](https://github.com/Ardem2025) | #7138, #9908, #9944, #13929 |
| [@aref-alapour](https://github.com/aref-alapour) | #12799, #13957 |
| [@arjav1181](https://github.com/arjav1181) | #13910 |
| [@arminanton](https://github.com/arminanton) | #11461, #11513 |
| [@AStupidBear](https://github.com/AStupidBear) | #11584 |
| [@atescivitci-cmd](https://github.com/atescivitci-cmd) | #12105 |
| [@b3nw](https://github.com/b3nw) | #11970, #11971, #12147 |
| [@backryun](https://github.com/backryun) | #11259, #11950, #12075, #12076, #12078, #12079, #12081, #12082, #12181, #12228, #12239, #12255, #12258, #12277, #12367, #12423, #12524, #12538 |
| [@Beexly](https://github.com/Beexly) | #12406 |
| [@benjaminkitt](https://github.com/benjaminkitt) | #11747 |
| [@benzntech](https://github.com/benzntech) | #11614, #11615 |
| [@Bl0ck154](https://github.com/Bl0ck154) | #11948, #11951, #11952, #11953, #11954 |
| [@brick30llc-ctrl](https://github.com/brick30llc-ctrl) | #11752, #12242, #12262 |
| [@BillyOutlast](https://github.com/BillyOutlast) | #14159, #14361 |
| [@birdleandro-bit](https://github.com/birdleandro-bit) | #13222 |
| [@Bl0ck154](https://github.com/Bl0ck154) | #11948, #11951, #11952, #11953, #11954, #12311, #13090 |
| [@botii16](https://github.com/botii16) | #12825 |
| [@brick30llc-ctrl](https://github.com/brick30llc-ctrl) | #11752, #12242, #12252, #12262 |
| [@caniko](https://github.com/caniko) | #12952 |
| [@chatchawan-simplewish](https://github.com/chatchawan-simplewish) | #13223 |
| [@Chewji9875](https://github.com/Chewji9875) | #11455, #11875, #12028 |
| [@cheynetom](https://github.com/cheynetom) | #12058 |
| [@davidebaraldo](https://github.com/davidebaraldo) | #12222, #12304 |
| [@costajohnt](https://github.com/costajohnt) | #13895, #14009, #14010 |
| [@CrashCartCapital](https://github.com/CrashCartCapital) | #13446 |
| [@cryptiklemur](https://github.com/cryptiklemur) | #13149, #13150, #13173, #13266 |
| [@dajiaohuang](https://github.com/dajiaohuang) | #13201, #13206 |
| [@datrixlab](https://github.com/datrixlab) | #13159, #13320, #13321, #13322, #13323, #13327, #13328, #13329, #13333, #13334, #13335 |
| [@davidebaraldo](https://github.com/davidebaraldo) | #12222, #12304, #13074 |
| [@davidlinfr](https://github.com/davidlinfr) | #6390 |
| [@ddarkr](https://github.com/ddarkr) | #12124 |
| [@Deftera186](https://github.com/Deftera186) | #11809 |
| [@dpozimski](https://github.com/dpozimski) | #11945 |
| [@delafu](https://github.com/delafu) | #13803 |
| [@DenXio101](https://github.com/DenXio101) | #13798 |
| [@dmlanday](https://github.com/dmlanday) | #12484, #12485 |
| [@domenicomassafra](https://github.com/domenicomassafra) | #13168, #13659, #13809, #13810 |
| [@doramirdor](https://github.com/doramirdor) | #13056 |
| [@dpozimski](https://github.com/dpozimski) | #11945, #13635 |
| [@drmikecrypto](https://github.com/drmikecrypto) | #12565 |
| [@ducphamtien-fonos](https://github.com/ducphamtien-fonos) | #13128 |
| [@DW-MediaLab](https://github.com/DW-MediaLab) | #12229 |
| [@dylanhaskins](https://github.com/dylanhaskins) | #13856 |
| [@easypathuni](https://github.com/easypathuni) | #13312 |
| [@echel0nn](https://github.com/echel0nn) | #11923 |
| [@elielsousa-pathbit](https://github.com/elielsousa-pathbit) | #13444, #13793 |
| [@f9td56dbgh-hub](https://github.com/f9td56dbgh-hub) | #11660 |
| [@fabioluissilva](https://github.com/fabioluissilva) | #11991 |
| [@Falco20100](https://github.com/Falco20100) | #13913 |
| [@fewensa](https://github.com/fewensa) | #13861 |
| [@fidelix](https://github.com/fidelix) | #12989, #13790 |
| [@foreveryh](https://github.com/foreveryh) | #12177 |
| [@formilw](https://github.com/formilw) | #13784, #13919 |
| [@fouadSalkini](https://github.com/fouadSalkini) | #12585, #13466, #13666, #13670, #13951 |
| [@ftevxk](https://github.com/ftevxk) | #13772 |
| [@Gaulnews](https://github.com/Gaulnews) | #12520 |
| [@geek007git](https://github.com/geek007git) | #12115, #12116, #12117, #12120, #12122 |
| [@geekyNads](https://github.com/geekyNads) | #11784, #12266 |
| [@ggiak](https://github.com/ggiak) | #11561, #12276, #12350, #12402 |
| [@gonisulaimann](https://github.com/gonisulaimann) | #12368, #12369, #12371 |
| [@ggdayup](https://github.com/ggdayup) | #13533 |
| [@ggiak](https://github.com/ggiak) | #11561, #12276, #12350, #12402, #13295 |
| [@giauphan](https://github.com/giauphan) | #13324 |
| [@gonisulaimann](https://github.com/gonisulaimann) | #12368, #12369, #12371, #12735, #12736, #13741, #14164 |
| [@Gorillaz322](https://github.com/Gorillaz322) | #12207 |
| [@groovecityJO](https://github.com/groovecityJO) | #12682 |
| [@hartmark](https://github.com/hartmark) | #11434, #11452, #11473, #11499, #11703, #11983, #11984, #11985, #11986, #11988, #11989, #11990, #11994, #12221, #12293, #12445, #12446, #12447, #12448, #12460, #12461, #12623, #12646, #12650, #12680, #12717, #12718, #12727, #12741, #12854 |
| [@Hakarioz](https://github.com/Hakarioz) | #14049 |
| [@hartmark](https://github.com/hartmark) | #11434, #11452, #11473, #11499, #11703, #11983, #11984, #11985, #11986, #11988, #11989, #11990, #11994, #12221, #12293, #12445, #12446, #12447, #12448, #12460, #12461, #12623, #12646, #12650, #12680, #12717, #12718, #12727, #12741, #12854, #12995, #12999, #13071, #13078, #13079, #13115, #13338, #13573, #13687, #13749 |
| [@hizzt](https://github.com/hizzt) | #11894 |
| [@honeypot55](https://github.com/honeypot55) | #13751 |
| [@hongnoul](https://github.com/hongnoul) | #11484 |
| [@HouMinXi](https://github.com/HouMinXi) | #11411, #11414, #11512, #11518, #11520, #11641, #11642, #11643, #11687, #11779, #11849, #11850, #11851, #11915, #11916, #11918, #11919, #11920, #12013, #12017, #12033, #12042, #12106, #12139, #12166, #12169, #12171, #12205, #12213, #12312, #12325, #12487, #12488, #12495, #12504, #12557, #12566, #12590, #12591, #12624, #12626, #12632, #12637, #12678, #12696, #12697, #12711, #12733, #12746, #12767, #12770, #12789, #12803, #12805, #12811, #12866, #12868, #12899, #12926, #12934, #12950, #12951, #12974, #13001, #13006, #13011, #13017, #13026, #13027, #13034, #13035, #13038, #13042, #13050, #13060, #13061, #13069, #13107, #13120, #13136, #13195, #13197 |
| [@HouMinXi](https://github.com/HouMinXi) | #11411, #11414, #11512, #11518, #11520, #11641, #11642, #11643, #11687, #11779, #11849, #11850, #11851, #11915, #11916, #11918, #11919, #11920, #12013, #12017, #12033, #12042, #12106, #12139, #12166, #12169, #12171, #12205, #12213, #12312, #12325, #12487, #12488, #12495, #12504, #12557, #12566, #12590, #12591, #12624, #12626, #12632, #12637, #12678, #12696, #12697, #12711, #12733, #12746, #12767, #12770, #12789, #12803, #12805, #12811, #12866, #12868, #12899, #12926, #12934, #12950, #12951, #12974, #13001, #13006, #13011, #13017, #13026, #13027, #13034, #13035, #13038, #13042, #13050, #13060, #13061, #13069, #13107, #13120, #13136, #13178, #13195, #13197, #13331, #13344, #13398, #13399, #13445, #13463, #13518, #13560, #13572, #13626, #13628, #13636, #13643, #13655, #13701, #13717, #13720, #13857, #13859, #13903, #14005, #14063, #14064, #14065, #14066 |
| [@Hsia97](https://github.com/Hsia97) | #11624 |
| [@hubo1989](https://github.com/hubo1989) | #13754 |
| [@hummern](https://github.com/hummern) | #13647 |
| [@IAMBOBJIM](https://github.com/IAMBOBJIM) | #12972 |
| [@initguru](https://github.com/initguru) | #12901, #12902, #12903, #12904, #12905, #12906, #12908, #12909, #12910, #12911, #12912, #12913 |
| [@insoln](https://github.com/insoln) | #12668, #12737, #12754, #12830, #12859, #12864, #12954, #12955, #12956, #12957, #13636 |
| [@jacobsparts](https://github.com/jacobsparts) | #11854, #12155, #12167 |
| [@jasminsehic](https://github.com/jasminsehic) | #12935 |
| [@jbovard2016](https://github.com/jbovard2016) | #13623 |
| [@jmche](https://github.com/jmche) | #13031 |
| [@joglomedia](https://github.com/joglomedia) | #11980 |
| [@jonlwheat2-gif](https://github.com/jonlwheat2-gif) | #11443, #11450, #11549, #11567, #11608, #11633, #11635, #11640, #11644, #11671, #11672, #11673, #11674, #11675, #11676, #11677, #11706, #11783, #12051, #12052, #12053, #12055 |
| [@JxnLexn](https://github.com/JxnLexn) | #12471, #13299, #13434, #13555, #13556 |
| [@kanade-hoshino](https://github.com/kanade-hoshino) | #12180 |
| [@Karan825](https://github.com/Karan825) | #11861 |
| [@KaspaPulse](https://github.com/KaspaPulse) | #11389, #11469, #11628, #11666, #11910 |
| [@keeltrace](https://github.com/keeltrace) | #12080, #12223 |
| [@killer30001000](https://github.com/killer30001000) | #12178 |
| [@KooshaPari](https://github.com/KooshaPari) | #12470, #12592, #12667, #12699, #12703, #12706, #12764, #12769, #12771, #13300, #13301, #13302, #13340, #13401, #13403, #13404, #13405, #13406, #13407, #13409, #13410, #13411, #13412, #13413, #13414, #13418, #13424, #13427, #13433, #13523, #13524, #13525, #13528, #13531, #13532, #13534, #13539, #13543, #13545, #13546, #13547, #13550, #13553 |
| [@keeltrace](https://github.com/keeltrace) | #12080, #12223, #12763, #12818 |
| [@keii-2596](https://github.com/keii-2596) | #13709 |
| [@KelvinKSPS](https://github.com/KelvinKSPS) | #13808 |
| [@killer30001000](https://github.com/killer30001000) | #12178, #12468 |
| [@Kizuno18](https://github.com/Kizuno18) | #12340 |
| [@KooshaPari](https://github.com/KooshaPari) | #12470, #12592, #12667, #12699, #12703, #12706, #12764, #12769, #12771, #13123, #13143, #13300, #13301, #13302, #13340, #13401, #13403, #13404, #13405, #13406, #13407, #13409, #13410, #13411, #13412, #13413, #13414, #13418, #13419, #13423, #13424, #13427, #13433, #13522, #13523, #13524, #13525, #13528, #13531, #13532, #13534, #13539, #13543, #13545, #13546, #13547, #13550, #13551, #13553 |
| [@kriptoburak](https://github.com/kriptoburak) | #11370 |
| [@KrzysiekSko](https://github.com/KrzysiekSko) | #12425, #12432, #12673 |
| [@L4XB](https://github.com/L4XB) | #13622, #13624, #13738 |
| [@legas888Oleg](https://github.com/legas888Oleg) | #13825 |
| [@LeMonBLOCK](https://github.com/LeMonBLOCK) | #8169 |
| [@lorenzozanee](https://github.com/lorenzozanee) | #13761, #13762, #13763, #13764, #13765, #13766, #13767, #13768 |
| [@luw2007](https://github.com/luw2007) | #13849 |
| [@luyuehm](https://github.com/luyuehm) | #13611, #13639 |
| [@marcelokarval](https://github.com/marcelokarval) | direct commit / report |
| [@maxmad64bis](https://github.com/maxmad64bis) | #1622, #11435, #11437, #11441, #11537, #11550, #11553, #11555, #11557, #11812, #11842, #11843, #11903, #12151, #12214, #12215, #12218, #12226, #12314, #12316, #12317, #12318, #12319, #12320, #12321, #12507, #12715, #12731, #12744, #12786, #12787, #12788, #12790, #12792, #12794, #12795, #12828, #12832, #12853, #12857, #12870, #12937, #12941, #12975, #13141, #13142, #13146, #13147 |
| [@mdigitalbh81](https://github.com/mdigitalbh81) | #12240 |
| [@marcs7](https://github.com/marcs7) | #13637 |
| [@marioschoenert-code](https://github.com/marioschoenert-code) | #13162 |
| [@marshalfevzi](https://github.com/marshalfevzi) | #13756 |
| [@maxmad64bis](https://github.com/maxmad64bis) | #1622, #11435, #11437, #11441, #11537, #11550, #11553, #11555, #11557, #11812, #11842, #11843, #11903, #12151, #12214, #12215, #12218, #12226, #12314, #12316, #12317, #12318, #12319, #12320, #12321, #12507, #12715, #12731, #12744, #12786, #12787, #12788, #12790, #12792, #12794, #12795, #12828, #12832, #12853, #12857, #12870, #12937, #12941, #12975, #13141, #13142, #13146, #13147, #13153, #13217, #13218, #13279, #13280, #13281, #13436, #13438, #13439, #13440, #13441, #13471, #13484, #13498, #13577, #13578, #13580, #13581, #13582, #13602, #13605, #13606, #13607, #13608, #13609, #13612, #13613, #13614, #13615, #13633, #13641, #13645, #13646, #13650, #13657, #13671, #13672, #13686, #13795, #13923, #13924, #14011, #14013, #14029, #14290 |
| [@mdigitalbh81](https://github.com/mdigitalbh81) | #12240, #13571, #14162 |
| [@Meet6338-X](https://github.com/Meet6338-X) | #11598, #11609, #12110 |
| [@morpheus9393](https://github.com/morpheus9393) | #11943 |
| [@MumuTW](https://github.com/MumuTW) | #11492, #11502, #11506, #11507, #11626, #11675, #11685, #11728, #11746, #11888, #11889, #11890, #11892 |
| [@Neuron-Mr-White](https://github.com/Neuron-Mr-White) | #11622, #11801, #12256 |
| [@morpheus9393](https://github.com/morpheus9393) | #11943, #13759 |
| [@Moseyuh333](https://github.com/Moseyuh333) | #13642 |
| [@MumuTW](https://github.com/MumuTW) | #11492, #11502, #11506, #11507, #11626, #11675, #11685, #11728, #11746, #11888, #11889, #11890, #11892, #13408 |
| [@NaNomicon](https://github.com/NaNomicon) | #13806 |
| [@Neuron-Mr-White](https://github.com/Neuron-Mr-White) | #11622, #11801, #12256, #12492, #12855 |
| [@NightStalker-87](https://github.com/NightStalker-87) | #12183 |
| [@Notaloop763](https://github.com/Notaloop763) | #13689 |
| [@NoxzRCW](https://github.com/NoxzRCW) | #11879, #11880, #11881, #11882, #11883, #11935 |
| [@ntdat812](https://github.com/ntdat812) | #11585, #12095 |
| [@ntdat812](https://github.com/ntdat812) | #11585, #12095, #12836, #12858 |
| [@ntdatt812](https://github.com/ntdatt812) | #11368, #11573, #11574, #11576, #11577, #11580, #11582, #11583, #11588, #11589, #11590, #11591, #11592, #11593, #11672, #12177, #12180, #12873, #12918, #12920, #12921, #12925, #12930, #13007, #13009, #13024, #13025, #13083, #13087, #13101, #13104, #13110 |
| [@official-burak](https://github.com/official-burak) | #11542 |
| [@opensource-elearning](https://github.com/opensource-elearning) | #12179, #12189, #12278, #12286 |
| [@oyi77](https://github.com/oyi77) | #11408, #11409, #11421, #11505, #11677, #12036, #12110 |
| [@pacocartones](https://github.com/pacocartones) | #11521, #11522, #11527, #11528, #11529, #11530, #11531, #11532, #11533, #11534, #11595, #11599, #11603, #11604, #11605, #11607, #11610, #11676, #11714, #11716, #11718, #11767, #11838, #11860, #11862, #11869, #11871, #11872, #11873, #11903, #11906, #11921, #11934, #12356, #12358, #12359, #12360, #12361, #12362, #12364, #12365, #12373, #12374, #12375, #12376, #12377, #12379, #12380, #12381, #12386, #12387, #12389, #12390, #12394, #12395, #12397, #12401, #12403, #12404, #12522, #12523, #12535, #12536, #12540, #12541, #12543, #12545, #12548, #12549, #12550, #12551, #12552, #12647, #12651, #12653, #13055 |
| [@patrykkopycinski](https://github.com/patrykkopycinski) | #11936, #11937, #12224 |
| [@oleksandr1811](https://github.com/oleksandr1811) | #13777 |
| [@opensource-elearning](https://github.com/opensource-elearning) | #12179, #12189, #12278, #12286, #13566 |
| [@Orion1943](https://github.com/Orion1943) | #13779 |
| [@oyi77](https://github.com/oyi77) | #11408, #11409, #11421, #11505, #11677, #12036, #12038, #12110, #13702 |
| [@ozeas](https://github.com/ozeas) | #13140 |
| [@pacocartones](https://github.com/pacocartones) | #11521, #11522, #11527, #11528, #11529, #11530, #11531, #11532, #11533, #11534, #11595, #11599, #11603, #11604, #11605, #11607, #11610, #11676, #11714, #11716, #11718, #11767, #11838, #11860, #11862, #11869, #11871, #11872, #11873, #11903, #11906, #11921, #11934, #12356, #12358, #12359, #12360, #12361, #12362, #12364, #12365, #12373, #12374, #12375, #12376, #12377, #12379, #12380, #12381, #12386, #12387, #12389, #12390, #12394, #12395, #12397, #12401, #12403, #12404, #12522, #12523, #12535, #12536, #12540, #12541, #12543, #12545, #12548, #12549, #12550, #12551, #12552, #12647, #12651, #12653, #13055, #13837, #13838, #13839, #13841, #13842, #13843, #13844, #13845, #14048 |
| [@pan17](https://github.com/pan17) | #13799 |
| [@pandudpn](https://github.com/pandudpn) | #13823 |
| [@patrykkopycinski](https://github.com/patrykkopycinski) | #11936, #11937, #12224, #12723, #12742, #12885, #13037, #13355, #13357, #13358, #13359, #13448, #13617, #13627 |
| [@PauloFH](https://github.com/PauloFH) | #11509 |
| [@PauloHSOliveira](https://github.com/PauloHSOliveira) | #12241 |
| [@phamtienduceng-eng](https://github.com/phamtienduceng-eng) | #13775 |
| [@phs1997](https://github.com/phs1997) | #13972 |
| [@PixmaNts](https://github.com/PixmaNts) | #12462 |
| [@Pllutonyy](https://github.com/Pllutonyy) | #12838 |
| [@ponkcore](https://github.com/ponkcore) | #12054 |
| [@prabhtheone](https://github.com/prabhtheone) | #13992, #14164 |
| [@prabhu-omkar](https://github.com/prabhu-omkar) | #13579 |
| [@Prajeeth-12](https://github.com/Prajeeth-12) | #11634, #12046 |
| [@pranay-gpt](https://github.com/pranay-gpt) | #13771 |
| [@ProphetOfDoom-PoD](https://github.com/ProphetOfDoom-PoD) | #13797, #13908 |
| [@qinghuanandejiangshi](https://github.com/qinghuanandejiangshi) | #13819 |
| [@quiterunner-commits](https://github.com/quiterunner-commits) | #12131, #12143 |
| [@rafacpti23](https://github.com/rafacpti23) | #11554, #11558, #12192, #12197, #12198, #12202, #12203, #12204 |
| [@rafacpti23](https://github.com/rafacpti23) | #11554, #11558, #12192, #12197, #12198, #12202, #12203, #12204, #12841 |
| [@ragnar-claude](https://github.com/ragnar-claude) | #11564 |
| [@raheemuddin786](https://github.com/raheemuddin786) | #11491, #11839, #11840, #11841, #12003, #12230, #12231, #12232, #12233, #12234 |
| [@raheemuddin786](https://github.com/raheemuddin786) | #11491, #11828, #11839, #11840, #11841, #12003, #12230, #12231, #12232, #12233, #12234 |
| [@rahilmavani](https://github.com/rahilmavani) | #11761 |
| [@Rahulsharma0810](https://github.com/Rahulsharma0810) | #11771 |
| [@RaviTharuma](https://github.com/RaviTharuma) | #11710, #11727, #11797, #11798, #11802, #11805, #11806, #11811, #12098, #12099, #12101, #12449, #12452, #12472, #12473, #12493, #12533, #12607, #12628, #12631, #12636, #12875, #12876, #12880, #12882, #12884 |
| [@Rahulsharma0810](https://github.com/Rahulsharma0810) | #11771, #13758 |
| [@RaviTharuma](https://github.com/RaviTharuma) | #11710, #11727, #11797, #11798, #11802, #11805, #11806, #11811, #12098, #12099, #12101, #12449, #12452, #12472, #12473, #12493, #12533, #12607, #12628, #12631, #12636, #12875, #12876, #12880, #12882, #12884, #13185, #13426, #13783 |
| [@rezjalibd](https://github.com/rezjalibd) | #12186 |
| [@RhianB14](https://github.com/RhianB14) | #11692 |
| [@ricardusx](https://github.com/ricardusx) | #13770 |
| [@Rick7C2](https://github.com/Rick7C2) | #13008 |
| [@rifqiawl](https://github.com/rifqiawl) | #11517, #11519, #11969 |
| [@rolemiaster](https://github.com/rolemiaster) | #13769 |
| [@rqzbeh](https://github.com/rqzbeh) | #11390 |
| [@Sabeekhann](https://github.com/Sabeekhann) | #11699, #11705 |
| [@sahildaswani](https://github.com/sahildaswani) | #13831 |
| [@santosraju99-hub](https://github.com/santosraju99-hub) | #11755, #11770 |
| [@SCys](https://github.com/SCys) | #12562 |
| [@seanford](https://github.com/seanford) | #13733, #13740 |
| [@Seramicx](https://github.com/Seramicx) | #11568 |
| [@Siva010](https://github.com/Siva010) | #12191 |
| [@SIGTERM-015](https://github.com/SIGTERM-015) | #13219 |
| [@sistemabritto](https://github.com/sistemabritto) | #13352 |
| [@Siva010](https://github.com/Siva010) | #12191, #12785 |
| [@smshagor-dev](https://github.com/smshagor-dev) | #13863 |
| [@solstxce](https://github.com/solstxce) | #11597 |
| [@soroush5](https://github.com/soroush5) | #12691 |
| [@soroush5](https://github.com/soroush5) | #12691, #13753, #13755 |
| [@sprintberlin](https://github.com/sprintberlin) | #13561 |
| [@Stazyu](https://github.com/Stazyu) | #12497 |
| [@steve25060](https://github.com/steve25060) | #14006 |
| [@stormsia](https://github.com/stormsia) | #13984 |
| [@tanveer-arch](https://github.com/tanveer-arch) | #12443 |
| [@tenshiak](https://github.com/tenshiak) | #12279 |
| [@TheDemonTuan](https://github.com/TheDemonTuan) | #11468, #11470, #11471, #11482, #11548, #11758, #11775, #11814 |
| [@thomasmaerz](https://github.com/thomasmaerz) | #12834 |
| [@tuandinh0801](https://github.com/tuandinh0801) | #11454 |
| [@turbolego](https://github.com/turbolego) | #11621, #11762, #11772, #11774, #11781, #12216 |
| [@texastoland](https://github.com/texastoland) | #13800 |
| [@TheDemonTuan](https://github.com/TheDemonTuan) | #11468, #11470, #11471, #11482, #11548, #11758, #11775, #11814, #12933, #13848, #13956 |
| [@themedexperiencesusa](https://github.com/themedexperiencesusa) | #13350 |
| [@ThiagoMafra-Integrare](https://github.com/ThiagoMafra-Integrare) | #12863, #13780 |
| [@thomasmaerz](https://github.com/thomasmaerz) | #12688, #12834 |
| [@tiangao88](https://github.com/tiangao88) | #12814, #12982, #14001 |
| [@tolgaaksoy](https://github.com/tolgaaksoy) | #13786, #13787 |
| [@toor11](https://github.com/toor11) | #12663, #12664 |
| [@trycohn](https://github.com/trycohn) | #13864 |
| [@tuandinh0801](https://github.com/tuandinh0801) | #11454, #13013, #13015, #13020, #13021, #13318, #13705 |
| [@turbolego](https://github.com/turbolego) | #11621, #11762, #11772, #11774, #11781, #12216, #13040 |
| [@Tushar49](https://github.com/Tushar49) | #11392 |
| [@ujjawalkaushik1110](https://github.com/ujjawalkaushik1110) | #11863 |
| [@ventulus95](https://github.com/ventulus95) | #13317 |
| [@vermasomesh835](https://github.com/vermasomesh835) | #11794 |
| [@VictorRP7](https://github.com/VictorRP7) | #13226 |
| [@visheshgubrani](https://github.com/visheshgubrani) | #12966 |
| [@voidstackloop](https://github.com/voidstackloop) | #13342, #13773 |
| [@vsd2807](https://github.com/vsd2807) | #11565, #11619 |
| [@wahidsadik371-coder](https://github.com/wahidsadik371-coder) | #12126 |
| [@watchingdogs](https://github.com/watchingdogs) | #12031 |
| [@wildcard](https://github.com/wildcard) | #11620 |
| [@xiaoyaner0201](https://github.com/xiaoyaner0201) | #11460, #11662, #12083 |
| [@wofiporia](https://github.com/wofiporia) | #12629 |
| [@woodsonl](https://github.com/woodsonl) | #12686, #12730 |
| [@xiaoyaner0201](https://github.com/xiaoyaner0201) | #11460, #11662, #12083, #13293, #13711, #13746, #14164 |
| [@xiechimon](https://github.com/xiechimon) | #13750, #13855, #14164 |
| [@Xore](https://github.com/Xore) | #13383, #13792 |
| [@Xxx91n](https://github.com/Xxx91n) | #11690 |
| [@yourspraveen](https://github.com/yourspraveen) | #11146 |
| [@ysntony](https://github.com/ysntony) | #13046 |
| [@yxyxy](https://github.com/yxyxy) | #11671 |
| [@ZaimMarzuki](https://github.com/ZaimMarzuki) | #11960 |
| [@zachary-frederich](https://github.com/zachary-frederich) | #12993 |
| [@ZaimMarzuki](https://github.com/ZaimMarzuki) | #11960, #12553, #12891, #13029 |
| [@Zartharas](https://github.com/Zartharas) | #11340 |
| [@zcrew0x](https://github.com/zcrew0x) | #13690 |
| [@zeeshanhaque21](https://github.com/zeeshanhaque21) | #13708 |
| [@zero-executioner](https://github.com/zero-executioner) | #11631 |
| [@diegosouzapw](https://github.com/diegosouzapw) | maintainer |
---
## [3.8.50] — 2026-08-25
_Living section — regenerated 2026-08-12 from all cycle commits (cycle open `ed2db6cb19` → tip). Bullets carry the merged PR and its author; direct pushes listed separately._

View File

@@ -226,7 +226,7 @@ ENV NODE_OPTIONS="--max-old-space-size=${OMNIROUTE_MEMORY_MB}"
# Data directory inside Docker — must match the volume mount in docker-compose.yml
ENV DATA_DIR=/app/data
RUN mkdir -p /app/data
RUN mkdir -p /app/data && chown node:node /app /app/data
# #13679: default the PUBLISHED image to requiring an API key. A bare
# `docker run -p 20128:20128 … diegosouzapw/omniroute` (README/QUICK-START
@@ -248,24 +248,24 @@ ENV REQUIRE_API_KEY=true
# The old per-module overrides were therefore pure duplication and were removed
# (build-output-isolation cleanup). See scripts/build/assembleStandalone.mjs
# (EXTRA_MODULE_ENTRIES) for the single source of truth.
COPY --from=builder /app/.build/next/standalone ./
COPY --chown=node:node --from=builder /app/.build/next/standalone ./
# better-sqlite3 is the one exception still copied explicitly: assembleStandalone
# only syncs its native build/ dir; the JS wrapper (lib/, package.json) is left to
# Next.js tracing. bootstrap-env requires SQLite BEFORE the standalone server
# starts, so guarantee the complete package independent of trace behaviour.
COPY --from=builder /app/node_modules/better-sqlite3 ./node_modules/better-sqlite3
COPY --chown=node:node --from=builder /app/node_modules/better-sqlite3 ./node_modules/better-sqlite3
RUN test -f /app/node_modules/better-sqlite3/build/Release/better_sqlite3.node
# migrations land at <standalone>/migrations via assembleStandalone; point the runtime at them.
ENV OMNIROUTE_MIGRATIONS_DIR=/app/migrations
# Docker healthcheck script — not traced by Next.js standalone output, so copy
# it explicitly. The HEALTHCHECK CMD references it as `node healthcheck.mjs`.
COPY --from=builder /app/scripts/dev/healthcheck.mjs ./healthcheck.mjs
COPY --chown=node:node --from=builder /app/scripts/dev/healthcheck.mjs ./healthcheck.mjs
# Hand /app over to the baked-in `node` non-root user (UID/GID 1000) so the
# runtime process never holds root privileges. The chown happens after all
# COPYs so it covers files originally owned by root in the builder stage.
RUN chown -R node:node /app
# Every COPY above hands its files to the baked-in `node` non-root user
# (UID/GID 1000) at copy time. Do NOT add a `RUN chown -R node:node /app`
# afterwards: in the overlay filesystem changing ownership rewrites every file
# into a new layer, which stored the ~2 GB standalone build twice (#13990).
EXPOSE 20128
@@ -354,7 +354,7 @@ RUN --mount=type=cache,id=s/92ca8a61-c1ba-421f-a389-d48ac7258c2d-apt-cache,targe
# build, not the floating `@latest`.
RUN --mount=type=cache,id=s/92ca8a61-c1ba-421f-a389-d48ac7258c2d-npm-cache,target=/root/.npm \
npm install -g --no-audit --no-fund \
@openai/codex@0.153.4 \
@openai/codex@0.155.0 \
@anthropic-ai/claude-code@2.1.260 \
droid@0.212.0 \
openclaw@2026.9.1

View File

@@ -17,9 +17,9 @@
</div>
> Stacking free tiers by hand is painful — dozens of SDKs, dozens of rate limits, and no idea how much you actually have. OmniRoute catalogs **491 free-tier entries across 35 recurring pool keys** and computes the token headline from the **17 pools with a published positive monthly budget plus five per-model Groq caps**, deduplicated by shared pool. Quotas that only open after a regional identity check (today: ModelScope) are shown apart, +~6M behind regional identity verification, and never summed into the headline. The result stays visible on the dashboard (`/dashboard/free-tiers`).
> Stacking free tiers by hand is painful — dozens of SDKs, dozens of rate limits, and no idea how much you actually have. OmniRoute catalogs **489 free-tier entries across 35 recurring pool keys** and computes the token headline from the **17 pools with a published positive monthly budget plus five per-model Groq caps**, deduplicated by shared pool. Quotas that only open after a regional identity check (today: ModelScope) are shown apart, +~6M behind regional identity verification, and never summed into the headline. The result stays visible on the dashboard (`/dashboard/free-tiers`).
<img src="./docs/diagrams/free-tier-budget.svg" width="100%" alt="OmniRoute free-tier budget card: ~1.62B free tokens per month steady, up to ~2.22B in the first month with signup credits, from 35 documented recurring pool keys covering 491 cataloged free-tier entries behind one endpoint. Honest pool-deduped math — each shared pool counted once, including 17 recurring pools with a published positive monthly token budget plus five per-model Groq caps; 13 providers are marked avoid in the terms-risk catalog so you decide. Budget bar includes Mistral 1B, Nara 210M, LLM7 150M, xKiro 150M, Groq 30M (five per-model caps) and smaller pools, plus first-month signup credits and permanently-free no-token-cap providers surfaced separately so they never inflate the headline. Live used/remaining on /dashboard/free-tiers."/>
<img src="./docs/diagrams/free-tier-budget.svg" width="100%" alt="OmniRoute free-tier budget card: ~1.62B free tokens per month steady, up to ~2.22B in the first month with signup credits, from 35 documented recurring pool keys covering 489 cataloged free-tier entries behind one endpoint. Honest pool-deduped math — each shared pool counted once, including 17 recurring pools with a published positive monthly token budget plus five per-model Groq caps; 13 providers are marked avoid in the terms-risk catalog so you decide. Budget bar includes Mistral 1B, Nara 210M, LLM7 150M, xKiro 150M, Groq 30M (five per-model caps) and smaller pools, plus first-month signup credits and permanently-free no-token-cap providers surfaced separately so they never inflate the headline. Live used/remaining on /dashboard/free-tiers."/>
> Animated summary of the live `/dashboard/free-tiers` page. Full methodology (pool dedupe, credit tiers, provider terms): **[docs/reference/FREE_TIERS.md](docs/reference/FREE_TIERS.md)**.
>
@@ -133,7 +133,7 @@
</div>
<div align="center">
<b>🌐 In 66 languages</b>
<b>🌐 In 67 languages</b>
<br/><br/>
<a href="README.md"><img src="docs/assets/flags/us.svg" width="30" alt="English (en)" title="English (en)"></a>
<a href="docs/i18n/pt-BR/README.md"><img src="docs/assets/flags/br.svg" width="30" alt="Português — Brasil (pt-BR)" title="Português — Brasil (pt-BR)"></a>
@@ -201,6 +201,7 @@
<a href="docs/i18n/uz/README.md"><img src="docs/assets/flags/uz.svg" width="30" alt="Oʻzbekcha (uz)" title="Oʻzbekcha (uz)"></a>
<a href="docs/i18n/ka/README.md"><img src="docs/assets/flags/ge.svg" width="30" alt="ქართული (ka)" title="ქართული (ka)"></a>
<a href="docs/i18n/hy/README.md"><img src="docs/assets/flags/am.svg" width="30" alt="Հայերեն (hy)" title="Հայերեն (hy)"></a>
<a href="docs/i18n/bs/README.md"><img src="docs/assets/flags/ba.svg" width="30" alt="Bosanski (bs)" title="Bosanski (bs)"></a>
</div>
<br/>
@@ -283,7 +284,7 @@ curl http://localhost:20128/v1/chat/completions \
<td>
Thanks to <b>Kimi (Moonshot AI)</b>, our founding Open Source Friend, for backing this project! Kimi is the AI lab behind the open-weight K2 and K3 model families — <b>Kimi K3</b> delivers a 1M-token context window, native vision and frontier-level coding at a fraction of closed-model prices, and works out of the box with Claude Code, Codex and every coding tool OmniRoute serves.
<br/><br/>
<b>What Kimi's support powers:</b> Kimi's API credits power OmniRoute's AI-validated release pipeline — the <i>merge validation powered by Kimi K3</i> stage that reviews every pull request before it ships — plus day-to-day feature development. First-class Kimi support ships on both rails: the direct <a href="https://platform.kimi.ai?track_id=track-8197581fdd7d4139a0f562e4a03c3798&aff=omniroute">Kimi API</a> (<code>kimi-k3</code>) and the <a href="https://www.kimi.com/code?aff=omniroute">Kimi Code coding plan</a> (OAuth and API key). OmniRoute is also the first Brazilian open-source project in Kimi's support program. <a href="https://platform.kimi.ai?track_id=track-8197581fdd7d4139a0f562e4a03c3798&aff=omniroute"><b>Get a Kimi API key with 15% extra credits →</b></a>
<b>What Kimi's support powers:</b> Kimi's API credits power OmniRoute's AI-validated release pipeline — the <i>merge validation powered by Kimi K3</i> stage that reviews every pull request before it ships — plus day-to-day feature development. First-class Kimi support ships on both rails: the direct <a href="https://platform.kimi.ai?track_id=track-8197581fdd7d4139a0f562e4a03c3798&aff=omniroute">Kimi API</a> (<code>kimi-k3</code>) and the <a href="https://www.kimi.ai/code?aff=omniroute">Kimi Code coding plan</a> (OAuth and API key). OmniRoute is also the first Brazilian open-source project in Kimi's support program. <a href="https://platform.kimi.ai?track_id=track-8197581fdd7d4139a0f562e4a03c3798&aff=omniroute"><b>Get a Kimi API key with 15% extra credits →</b></a>
</td>
</tr>
<tr>
@@ -629,13 +630,13 @@ the current catalog at **[radar.omniroute.online/planos](https://radar.omniroute
<td align="center" width="76"><picture><source media="(prefers-color-scheme:dark)" srcset="https://cdn.jsdelivr.net/npm/@lobehub/icons-static-png@1.91.0/dark/goose.png"/><img src="https://cdn.jsdelivr.net/npm/@lobehub/icons-static-svg@1.91.0/icons/goose.svg" width="40" alt="Goose"/></picture><br/><sub><b>Goose</b></sub><br/><sub>                           </sub></td>
<td align="center" width="76"><img src="./public/providers/cli-generic.svg" width="40" alt="Open Interpreter"/><br/><sub><b>Open Interpreter</b></sub><br/><sub>                           </sub></td>
<td align="center" width="76"><img src="./public/providers/cli-generic.svg" width="40" alt="Warp AI"/><br/><sub><b>Warp AI</b></sub><br/><sub>                           </sub></td>
<td align="center" width="76"><img src="./public/providers/cli-generic.svg" width="40" alt="Agent Deck"/><br/><sub><b>Agent Deck</b></sub><br/><sub>                           </sub></td>
<td align="center" width="76"><a href="https://deyin.ai"><img src="./public/deyin.svg" width="40" alt="deyin.ai"/><br/><sub><b>deyin.ai</b></sub><br/><sub>                           </sub></a></td>
</tr>
</table>
</div>
<div align="center">
<b> also works with</b> · Kiro · Command Code · Antigravity · Windsurf · AMP · <b>any OpenAI-compatible tool</b>
<b> also works with</b> · Agent Deck · Kiro · Command Code · Antigravity · Windsurf · AMP · <b>any OpenAI-compatible tool</b>
</div>
<sub>📖 Per-tool setup for all 36 tools (26 CLI Code's + 10 CLI Agents) → [`docs/reference/CLI-TOOLS.md`](docs/reference/CLI-TOOLS.md) · 🧩 OpenCode plugin → [`@omniroute/opencode-provider`](https://www.npmjs.com/package/@omniroute/opencode-provider)</sub>
@@ -862,7 +863,7 @@ with a scoped access token; every command then targets the remote.
```bash
omniroute connect 192.168.0.15 # password → scoped token, saved as a context
omniroute models list # ← runs against the REMOTE server
omniroute models # ← runs against the REMOTE server
omniroute configure codex # ← picks a remote model, writes a local Codex profile
omniroute tokens create --name ci --scope read # mint narrower tokens for other machines
omniroute contexts use default # ← switch back to the local server
@@ -1006,6 +1007,10 @@ omniroute
```
> 💡 See `npm warn ERESOLVE` or peer-dep warnings? [They're harmless](docs/guides/TROUBLESHOOTING.md#npm-install-warnings-eresolve--peer--deprecated).
> **Using Gemini Web or another web-cookie provider?** The npm package includes
> Playwright but not its Chromium binary. See the
> [Playwright Chromium setup](docs/guides/TROUBLESHOOTING.md#gemini-web-and-playwright-chromium)
> note before making the first web-provider request.
Dashboard at `http://localhost:20128` · API at `http://localhost:20128/v1`.
@@ -1148,7 +1153,9 @@ install never blocks on compiling from source: it uses a prebuilt binary when on
your platform/Node, and otherwise falls back transparently to a pure-JS engine
(`node:sqlite` on Node 22+, else the bundled `sql.js` WASM) — no build tools required.
To skip the post-install native warm-up entirely (CI, headless, or slow machines):
To skip the post-install **native warm-up** entirely (CI, headless, or slow machines).
Note: this only skips the native SQLite warm-up step (`scripts/postinstall.mjs`); the
binary-copy/repair hook (`scripts/build/postinstall.mjs`) still runs normally:
```bash
OMNIROUTE_SKIP_POSTINSTALL=1 npm install -g omniroute # CI=1 also skips it
@@ -1268,7 +1275,7 @@ Métricas canônicas em 2026-08-24: **1.029 vídeos únicos** · **11.132.922 vi
<tr><td nowrap><b>Runtime</b></td><td>Node.js 22.x / 24.x LTS — <code>&gt;=22.22.2 &lt;23 || &gt;=24.0.0 &lt;27</code></td></tr>
<tr><td nowrap><b>Language</b></td><td>TypeScript 6.0 — <b>100% TypeScript</b> across <code>src/</code> and <code>open-sse/</code> (zero <code>any</code> in core since v2.0)</td></tr>
<tr><td nowrap><b>Framework</b></td><td>Next.js 16 + React 19 + Tailwind CSS 4</td></tr>
<tr><td nowrap><b>Database</b></td><td>better-sqlite3 (SQLite, WAL journaling) + LowDB (JSON legacy) — 122 domain modules, 176 migrations</td></tr>
<tr><td nowrap><b>Database</b></td><td>better-sqlite3 (SQLite, WAL journaling) + LowDB (JSON legacy) — 122 domain modules, 182 migrations</td></tr>
<tr><td nowrap><b>Memory</b></td><td>SQLite FTS5 full-text + int8-quantized vector embeddings, typed decay</td></tr>
<tr><td nowrap><b>Schemas</b></td><td>Zod 4 — MCP tool I/O validation + API contracts</td></tr>
<tr><td nowrap><b>Protocols</b></td><td>MCP (stdio / HTTP / SSE) + A2A v0.3 (JSON-RPC 2.0 + SSE)</td></tr>
@@ -1331,7 +1338,7 @@ Métricas canônicas em 2026-08-24: **1.029 vídeos únicos** · **11.132.922 vi
<tr><td nowrap><b><a href="docs/architecture/RESILIENCE_GUIDE.md">Resilience Guide</a></b></td><td>Circuit breakers, cooldowns, queue, anti-thundering herd, TLS spoofing</td></tr>
<tr><td nowrap><b><a href="docs/routing/AUTO-COMBO.md">Auto-Combo Engine</a></b></td><td>16-factor scoring, mode packs, self-healing</td></tr>
<tr><td nowrap><b><a href="docs/ops/PROXY_GUIDE.md">Proxy Guide</a></b></td><td>3-level proxy system, 1proxy marketplace, registry CRUD</td></tr>
<tr><td nowrap><b><a href="docs/reference/FREE_TIERS.md">Free Tiers</a></b></td><td>Consolidated directory: 35 documented recurring pools / 491 cataloged free-tier entries</td></tr>
<tr><td nowrap><b><a href="docs/reference/FREE_TIERS.md">Free Tiers</a></b></td><td>Consolidated directory: 35 documented recurring pools / 489 cataloged free-tier entries</td></tr>
<tr><td nowrap><b><a href="docs/guides/FEATURES.md">Features Gallery</a></b></td><td>Visual dashboard tour with screenshots</td></tr>
<tr><td nowrap><b><a href="docs/architecture/CODEBASE_DOCUMENTATION.md">Codebase Documentation</a></b></td><td>Beginner-friendly codebase walkthrough</td></tr>
</table>

View File

@@ -218,6 +218,8 @@ These rules are enforced by tooling and reviewers:
## Supply-chain scanner findings (Socket.dev / Snyk / similar)
> **Scope note:** `socket.yml` at the repository root only shapes `projectIgnorePaths` for Socket.dev's registry-side post-publish scan of the published npm artifact — it is not an enforced CI/PR merge gate. No workflow in `.github/workflows`, no `package.json` script, and no `Makefile` target invokes Socket.dev.
The published `omniroute` npm artifact bundles the Next.js `output: "standalone"`
build, which means every route handler — including documented privileged
features (MITM, Zed import, Cloud Sync, embedded service supervisor) — ends

582
bin/antigravity-bridge.mjs Executable file
View File

@@ -0,0 +1,582 @@
#!/usr/bin/env node
/**
* OmniRoute Antigravity Bridge Proxy
*
* Intercepts Antigravity CLI and IDE requests:
* - Directs Gemini 3.8 models directly to Google backend (100% native, untouched).
* - Directs other models (Claude Sonnet 4.5/4.6, Opus, Gemini 3.7, GPT-OSS, etc.) to OmniRoute /v1/antigravity.
* - Passes all non-model Google requests (auth, onboarding, telemetry) directly to Google backend.
* - Transparently forwards all other non-target internet traffic.
*/
import net from "node:net";
import http from "node:http";
import https from "node:https";
import tls from "node:tls";
import fs from "node:fs";
import path from "node:path";
import { fileURLToPath } from "node:url";
const PORT = parseInt(process.env.BRIDGE_PORT || "20129", 10);
const ROUTER_URL = process.env.ROUTER_URL || "http://127.0.0.1:20128/v1/antigravity";
const ROUTER_API_KEY =
process.env.ROUTER_API_KEY || process.env.OMNIROUTE_API_KEY || "sk-omniroute-bridge-local";
// Connection pool agents with TCP keep-alive
const httpAgent = new http.Agent({
keepAlive: true,
keepAliveMsecs: 60000,
maxSockets: 64,
maxFreeSockets: 16,
timeout: 120000,
});
const httpsAgent = new https.Agent({
keepAlive: true,
keepAliveMsecs: 60000,
maxSockets: 64,
maxFreeSockets: 16,
timeout: 120000,
});
let cachedSslOptions = null;
function getSslOptions() {
if (cachedSslOptions) return cachedSslOptions;
const certDir =
process.env.CERT_DIR || path.join(process.env.HOME || process.cwd(), ".omniroute", "mitm");
const serverKey = path.join(certDir, "server.key");
const serverCrt = path.join(certDir, "server.crt");
if (!fs.existsSync(serverKey) || !fs.existsSync(serverCrt)) {
console.error("❌ Certificate files not found in", certDir);
process.exit(1);
}
cachedSslOptions = {
key: fs.readFileSync(serverKey),
cert: fs.readFileSync(serverCrt),
};
return cachedSslOptions;
}
const TARGET_HOSTS = new Set([
"cloudcode-pa.googleapis.com",
"daily-cloudcode-pa.googleapis.com",
"daily-cloudcode-pa.sandbox.googleapis.com",
"autopush-cloudcode-pa.sandbox.googleapis.com",
"preprod-daily-cloudcode-pa.sandbox.googleapis.com",
"antigravity-unleash.goog",
]);
function isGenerationRequest(url) {
if (!url) return false;
return (
url.includes(":generateContent") ||
url.includes(":streamGenerateContent") ||
url.includes("/GenerateChat") ||
url.includes("/StreamGenerateChat") ||
url.includes("/GenerateCode") ||
url.includes("/CompleteCode")
);
}
function extractModel(body, url) {
if (body && typeof body === "object") {
if (typeof body.model === "string" && body.model) return body.model;
if (body.request && typeof body.request.model === "string" && body.request.model) {
return body.request.model;
}
}
if (url) {
try {
const parsed = new URL(url, "https://cloudcode-pa.googleapis.com");
const m = parsed.searchParams.get("model");
if (m) return m;
} catch {}
}
return null;
}
const MODEL_ROUTING_MAP = {
// Official OmniRoute Auto Groups
"auto/best-fast": "groq/openai/gpt-oss-120b",
"auto/best-coding": "mistral/codestral-latest",
"auto/best-reasoning": "nvidia/nvidia/nemotron-3-super-120b-a12b",
"auto/best-free": "groq/qwen/qwen3.8-27b",
"auto/best-vision": "nvidia/meta/llama-3.2-90b-vision-instruct",
"auto/coding:pro": "mistral/codestral-latest",
"auto/coding:fast": "groq/openai/gpt-oss-120b",
"auto/coding:free": "groq/qwen/qwen3.8-27b",
"auto/coding:reliable": "mistral/codestral-latest",
"auto/reasoning:pro": "nvidia/nvidia/nemotron-3-super-120b-a12b",
"auto/smart": "nvidia/nvidia/nemotron-3-super-120b-a12b",
"auto/claude-sonnet": "mistral/codestral-latest",
"auto/claude-opus": "nvidia/nvidia/nemotron-3-super-120b-a12b",
"auto/gemini": "gemini/gemini-2.5-flash",
"auto/llama": "groq/openai/gpt-oss-120b",
"auto/gemma": "groq/qwen/qwen3.8-27b",
// Human-readable Display Names (in case CLI sends displayName in envelope)
"Auto: Best Fast (OmniRoute)": "groq/openai/gpt-oss-120b",
"Auto: Best Coding (OmniRoute)": "mistral/codestral-latest",
"Auto: Best Reasoning (OmniRoute)": "nvidia/nvidia/nemotron-3-super-120b-a12b",
"Auto: Best Free (OmniRoute)": "groq/qwen/qwen3.8-27b",
"Auto: Best Vision (OmniRoute)": "nvidia/meta/llama-3.2-90b-vision-instruct",
"Auto: Coding Pro (OmniRoute)": "mistral/codestral-latest",
"Auto: Coding Fast (OmniRoute)": "groq/openai/gpt-oss-120b",
"Auto: Coding Free (OmniRoute)": "groq/qwen/qwen3.8-27b",
"Auto: Coding Reliable (OmniRoute)": "mistral/codestral-latest",
"Auto: Reasoning Pro (OmniRoute)": "nvidia/nvidia/nemotron-3-super-120b-a12b",
"Auto: Smart (OmniRoute)": "nvidia/nvidia/nemotron-3-super-120b-a12b",
"Auto: Claude Sonnet (OmniRoute)": "mistral/codestral-latest",
"Auto: Claude Opus (OmniRoute)": "nvidia/nvidia/nemotron-3-super-120b-a12b",
"Auto: Gemini (OmniRoute)": "gemini/gemini-2.5-flash",
"Auto: Llama (OmniRoute)": "groq/openai/gpt-oss-120b",
"Auto: Gemma (OmniRoute)": "groq/qwen/qwen3.8-27b",
// Fail-safe self-healing for dead/retired models
"nvidia/deepseek-ai/deepseek-v4-pro-0813": "groq/openai/gpt-oss-120b",
"deepseek-ai/deepseek-v4-pro-0813": "groq/openai/gpt-oss-120b",
"NVIDIA: DeepSeek V4 Pro": "groq/openai/gpt-oss-120b",
"nvidia/openai/gpt-oss-120b": "groq/openai/gpt-oss-120b",
"openai/gpt-oss-120b": "groq/openai/gpt-oss-120b",
"groq/llama-3.3-70b-versatile": "groq/openai/gpt-oss-120b",
"llama-3.3-70b-versatile": "groq/openai/gpt-oss-120b",
};
function resolveTargetModel(model) {
if (!model) return "groq/openai/gpt-oss-120b";
if (MODEL_ROUTING_MAP[model]) return MODEL_ROUTING_MAP[model];
const clean = model.replace(/^models\//, "").trim();
if (MODEL_ROUTING_MAP[clean]) return MODEL_ROUTING_MAP[clean];
for (const [k, v] of Object.entries(MODEL_ROUTING_MAP)) {
if (k.toLowerCase() === model.toLowerCase() || k.toLowerCase() === clean.toLowerCase()) {
return v;
}
}
if (
clean.includes("deepseek-v4-pro") ||
(clean.startsWith("nvidia") && clean.includes("gpt-oss-120b")) ||
clean.includes("llama-3.3-70b-versatile")
) {
return "groq/openai/gpt-oss-120b";
}
return clean;
}
const OMNIROUTE_BUILTIN_GROUPS = [
{
id: "auto/best-coding",
displayName: "Auto: Best Coding (OmniRoute)",
descriptionText:
"OmniRoute dynamic routing to the highest benchmark coding model available (Mistral Codestral)",
},
{
id: "auto/best-reasoning",
displayName: "Auto: Best Reasoning (OmniRoute)",
descriptionText:
"OmniRoute dynamic routing to the highest benchmark reasoning model available (Nemotron 3 Super 120B)",
},
{
id: "auto/best-fast",
displayName: "Auto: Best Fast (OmniRoute)",
descriptionText: "OmniRoute sub-second lowest latency high-throughput model (Groq LPUs)",
},
{
id: "auto/best-vision",
displayName: "Auto: Best Vision (OmniRoute)",
descriptionText: "OmniRoute multimodal & computer vision routing",
},
{
id: "auto/best-free",
displayName: "Auto: Best Free (OmniRoute)",
descriptionText: "OmniRoute 100% unmetered free tier model routing (Qwen 3.8 27B)",
},
{
id: "auto/coding:pro",
displayName: "Auto: Coding Pro (OmniRoute)",
descriptionText: "OmniRoute frontier pro-tier coding model (Codestral)",
},
{
id: "auto/coding:fast",
displayName: "Auto: Coding Fast (OmniRoute)",
descriptionText: "OmniRoute fast sub-second daily coding model (Groq 120B)",
},
{
id: "auto/coding:free",
displayName: "Auto: Coding Free (OmniRoute)",
descriptionText: "OmniRoute zero-cost free coding model",
},
{
id: "auto/coding:reliable",
displayName: "Auto: Coding Reliable (OmniRoute)",
descriptionText: "OmniRoute maximum uptime and reliability coding model",
},
{
id: "auto/reasoning:pro",
displayName: "Auto: Reasoning Pro (OmniRoute)",
descriptionText: "OmniRoute deep reasoning frontier model",
},
{
id: "auto/smart",
displayName: "Auto: Smart (OmniRoute)",
descriptionText: "OmniRoute highest intelligence general-purpose model",
},
{
id: "auto/claude-sonnet",
displayName: "Auto: Claude Sonnet (OmniRoute)",
descriptionText: "OmniRoute automated routing across Claude Sonnet providers",
},
{
id: "auto/claude-opus",
displayName: "Auto: Claude Opus (OmniRoute)",
descriptionText: "OmniRoute automated routing across Claude Opus providers",
},
{
id: "auto/gemini",
displayName: "Auto: Gemini (OmniRoute)",
descriptionText: "OmniRoute automated routing across Gemini providers",
},
{
id: "auto/llama",
displayName: "Auto: Llama (OmniRoute)",
descriptionText: "OmniRoute automated routing across Llama providers",
},
{
id: "auto/gemma",
displayName: "Auto: Gemma (OmniRoute)",
descriptionText: "OmniRoute automated routing across Gemma providers",
},
// Active, verified provider models
{
id: "groq/openai/gpt-oss-120b",
displayName: "Groq: GPT-OSS 120B (Ultra-Fast 0.02s)",
descriptionText: "Ultra-fast inference on Groq LPUs at sub-second speeds",
},
{
id: "groq/qwen/qwen3.8-27b",
displayName: "Groq: Qwen 3.8 27B",
descriptionText: "High-speed Qwen 3.8 27B model on Groq",
},
{
id: "mistral/codestral-latest",
displayName: "Mistral: Codestral Latest",
descriptionText: "Mistral flagship frontier code reasoning model",
},
{
id: "nvidia/nvidia/nemotron-3-super-120b-a12b",
displayName: "NVIDIA: Nemotron 3 Super 120B",
descriptionText: "Nemotron 3 Super 120B Deep Reasoning model on NVIDIA NIM",
},
{
id: "gemini/gemini-2.5-flash",
displayName: "Gemini: Gemini 2.5 Flash (AI Studio)",
descriptionText: "Google AI Studio direct Gemini 2.5 Flash route",
},
{
id: "gemini/gemini-2.5-pro",
displayName: "Gemini: Gemini 2.5 Pro (AI Studio)",
descriptionText: "Google AI Studio direct Gemini 2.5 Pro route",
},
];
const OMNIROUTE_CUSTOM_MODELS = new Set([
...OMNIROUTE_BUILTIN_GROUPS.map((g) => g.id),
...Object.keys(MODEL_ROUTING_MAP),
]);
function shouldInterceptToOmniRoute(model, url) {
if (!model) return false;
// Never intercept non-streaming unary RPCs (Antigravity expects raw JSON/Protobuf, not SSE)
const isStreaming =
url.includes("streamGenerateContent") ||
url.includes("StreamGenerateChat") ||
url.includes("alt=sse");
if (!isStreaming) return false;
// Never intercept native Google/Gemini models (used by Antigravity core, subagents, websearch, grounding)
if (model.startsWith("gemini-") || model.startsWith("models/gemini-")) {
return false;
}
// Never intercept native Google CloudCode PA hosted models
if (
model === "claude-sonnet-4-6" ||
model === "claude-opus-4-6" ||
model === "gpt-oss-120b-medium"
) {
return false;
}
// Intercept any OmniRoute auto group, provider model, or mapped alias
const clean = model.replace(/^models\//, "").trim();
if (
clean.startsWith("auto/") ||
clean.toLowerCase().includes("omniroute") ||
clean.includes("/") ||
OMNIROUTE_CUSTOM_MODELS.has(model) ||
OMNIROUTE_CUSTOM_MODELS.has(clean) ||
Boolean(MODEL_ROUTING_MAP[model]) ||
Boolean(MODEL_ROUTING_MAP[clean])
) {
return true;
}
return false;
}
const internalApp = http.createServer(async (req, res) => {
const host = (req.headers.host || "cloudcode-pa.googleapis.com").split(":")[0];
const url = req.url || "/";
// Collect request body
const chunks = [];
for await (const chunk of req) {
chunks.push(chunk);
}
const bodyBuffer = Buffer.concat(chunks);
let bodyJson = null;
if (bodyBuffer.length > 0) {
try {
bodyJson = JSON.parse(bodyBuffer.toString("utf-8"));
} catch {}
}
const model = extractModel(bodyJson, url);
const shouldIntercept = shouldInterceptToOmniRoute(model, url);
if (shouldIntercept) {
const resolvedModel = resolveTargetModel(model);
console.log(
`[Bridge] 🔀 INTERCEPTING -> OmniRoute: "${model || "default"}" => "${resolvedModel}" (${url})`
);
let outgoingBuffer = bodyBuffer;
if (bodyJson) {
const cloned = JSON.parse(JSON.stringify(bodyJson));
cloned.model = resolvedModel;
if (cloned.request && typeof cloned.request === "object") {
cloned.request.model = resolvedModel;
}
outgoingBuffer = Buffer.from(JSON.stringify(cloned), "utf-8");
}
// Forward to OmniRoute /v1/antigravity
try {
const forwardHeaders = {
"Content-Type": "application/json",
"Content-Length": Buffer.byteLength(outgoingBuffer),
Authorization: `Bearer ${ROUTER_API_KEY}`,
"x-omniroute-source": "agent-bridge",
"x-omniroute-agent": "antigravity",
"x-omniroute-skip-usage": "true", // Skip usage tracking for default models
};
const upstreamReq = http.request(
ROUTER_URL,
{
method: "POST",
headers: forwardHeaders,
agent: httpAgent,
},
(upstreamRes) => {
res.writeHead(upstreamRes.statusCode || 200, upstreamRes.headers);
upstreamRes.pipe(res);
}
);
upstreamReq.setNoDelay(true);
upstreamReq.on("error", (err) => {
console.error(`[Bridge] ❌ Error forwarding to OmniRoute: ${err.message}`);
if (!res.headersSent) {
res.writeHead(502, { "Content-Type": "application/json" });
res.end(JSON.stringify({ error: { message: `OmniRoute bridge error: ${err.message}` } }));
}
});
upstreamReq.write(outgoingBuffer);
upstreamReq.end();
return;
} catch (err) {
console.error(`[Bridge] ❌ Failed to invoke OmniRoute: ${err.message}`);
}
}
// Otherwise: Passthrough directly to Google upstream
console.log(`[Bridge] ⏩ PASSTHROUGH -> Google: ${model || "non-model"} (${url})`);
const upstreamHeaders = { ...req.headers };
delete upstreamHeaders["host"]; // Let https.request set the correct Host
upstreamHeaders["host"] = host;
if (url.includes("fetchAvailableModels")) {
delete upstreamHeaders["accept-encoding"];
}
const googleReq = https.request(
{
hostname: host,
port: 443,
path: url,
method: req.method,
headers: upstreamHeaders,
agent: httpsAgent,
},
(googleRes) => {
if (url.includes("fetchAvailableModels")) {
const respChunks = [];
googleRes.on("data", (chunk) => respChunks.push(chunk));
googleRes.on("end", () => {
const respBuffer = Buffer.concat(respChunks);
let finalBuffer = respBuffer;
try {
const data = JSON.parse(respBuffer.toString("utf-8"));
if (data && data.models) {
// Inject OmniRoute built-in auto groups and models
const baseTemplate =
data.models["claude-sonnet-4-6"] ||
data.models["gpt-oss-120b-medium"] ||
Object.values(data.models)[0] ||
{};
const injectedIds = [];
for (const group of OMNIROUTE_BUILTIN_GROUPS) {
data.models[group.id] = {
...baseTemplate,
id: group.id,
name: group.id,
displayName: group.displayName,
descriptionText: group.descriptionText,
};
injectedIds.push(group.id);
}
// Prepend OmniRoute groups to agentModelSorts recommended group
if (
Array.isArray(data.agentModelSorts) &&
data.agentModelSorts[0]?.groups?.[0]?.modelIds
) {
const existing = data.agentModelSorts[0].groups[0].modelIds;
data.agentModelSorts[0].groups[0].modelIds = [
...injectedIds,
...existing.filter((id) => !injectedIds.includes(id)),
];
}
finalBuffer = Buffer.from(JSON.stringify(data), "utf-8");
console.log(
`[Bridge] 🌟 Injected custom models into fetchAvailableModels (${finalBuffer.length} bytes)`
);
}
} catch (err) {
console.error(`[Bridge] ⚠️ Error modifying fetchAvailableModels: ${err.message}`);
}
const headers = { ...googleRes.headers };
delete headers["content-length"];
delete headers["content-encoding"];
headers["content-length"] = String(finalBuffer.length);
res.writeHead(googleRes.statusCode || 200, headers);
res.end(finalBuffer);
});
return;
}
res.writeHead(googleRes.statusCode || 200, googleRes.headers);
googleRes.pipe(res);
}
);
googleReq.setNoDelay(true);
googleReq.on("error", (err) => {
console.error(`[Bridge] ❌ Google upstream error: ${err.message}`);
if (!res.headersSent) {
res.writeHead(502, { "Content-Type": "application/json" });
res.end(JSON.stringify({ error: { message: `Google upstream error: ${err.message}` } }));
}
});
if (bodyBuffer.length > 0) {
googleReq.write(bodyBuffer);
}
googleReq.end();
});
internalApp.keepAliveTimeout = 65000;
internalApp.headersTimeout = 66000;
// Proxy server listening on HTTP port
const proxyServer = http.createServer((req, res) => {
// Plain HTTP request (non-CONNECT)
res.writeHead(200, { "Content-Type": "text/plain" });
res.end("OmniRoute Antigravity Bridge Proxy Active\n");
});
proxyServer.keepAliveTimeout = 65000;
proxyServer.headersTimeout = 66000;
proxyServer.on("connect", (req, clientSocket, head) => {
clientSocket.setNoDelay(true);
const [targetHost, targetPortStr] = (req.url || "").split(":");
const targetPort = parseInt(targetPortStr || "443", 10);
if (TARGET_HOSTS.has(targetHost)) {
// Target host: Terminate TLS locally and route via internalApp
clientSocket.write("HTTP/1.1 200 Connection Established\r\n\r\n");
const ssl = getSslOptions();
const tlsSocket = new tls.TLSSocket(clientSocket, {
isServer: true,
key: ssl.key,
cert: ssl.cert,
});
tlsSocket.setNoDelay(true);
tlsSocket.on("error", (err) => {
// Client closed or TLS error
clientSocket.destroy();
});
internalApp.emit("connection", tlsSocket);
} else {
// Non-target host: Transparent raw TCP tunnel
const upstreamSocket = net.connect(targetPort, targetHost, () => {
upstreamSocket.setNoDelay(true);
clientSocket.write("HTTP/1.1 200 Connection Established\r\n\r\n");
if (head && head.length > 0) {
upstreamSocket.write(head);
}
upstreamSocket.pipe(clientSocket);
clientSocket.pipe(upstreamSocket);
});
const cleanup = () => {
clientSocket.destroy();
upstreamSocket.destroy();
};
upstreamSocket.on("error", cleanup);
clientSocket.on("error", cleanup);
}
});
export {
resolveTargetModel,
MODEL_ROUTING_MAP,
shouldInterceptToOmniRoute,
extractModel,
OMNIROUTE_BUILTIN_GROUPS,
proxyServer,
internalApp,
};
const isMain = process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url);
if (isMain) {
proxyServer.listen(PORT, "127.0.0.1", () => {
console.log(`🚀 OmniRoute Antigravity Bridge listening on 127.0.0.1:${PORT}`);
console.log(` Routing non-Gemini 3.8 model traffic -> ${ROUTER_URL}`);
console.log(` Preserving Gemini 3.8 native traffic -> Google`);
});
}

View File

@@ -5,6 +5,7 @@ import { t } from "../i18n.mjs";
import { apiFetch } from "../api.mjs";
import { resolveDataDir } from "../data-dir.mjs";
import { listManifestTargets } from "../cli-manifest.mjs";
import { loadModelCatalog, ModelCommandError } from "./model-api.mjs";
// Target lists shared with `omniroute run` / `omniroute configure` — always
// derived from the canonical manifest so the completion scripts cannot drift.
@@ -30,14 +31,14 @@ function readCache() {
}
async function refreshCache(opts = {}) {
// Fail before replacing the cache when the selected catalog is unavailable.
const models = (await loadModelCatalog(opts)).map((model) => model.id);
let combos = [],
providers = [],
models = [];
providers = [];
try {
const [cr, pr, mr] = await Promise.allSettled([
const [cr, pr] = await Promise.allSettled([
apiFetch("/api/combos", opts),
apiFetch("/api/providers", opts),
apiFetch("/api/models", opts),
]);
if (cr.status === "fulfilled" && cr.value.ok) {
const j = await cr.value.json();
@@ -47,10 +48,6 @@ async function refreshCache(opts = {}) {
const j = await pr.value.json();
providers = (j.providers || j.items || []).map((p) => p.id || p.name).filter(Boolean);
}
if (mr.status === "fulfilled" && mr.value.ok) {
const j = await mr.value.json();
models = (Array.isArray(j) ? j : j.data || []).map((m) => m.id).filter(Boolean);
}
} catch (err) {
if (process.env.OMNIROUTE_DEBUG_COMPLETION) {
console.error("[omniroute completion] refreshCache failed:", err?.message ?? err);
@@ -82,7 +79,12 @@ function installPath(shell) {
return join(home, ".bash_completion.d", "omniroute");
}
function generateZshScript() {
function modelSubcommandWords(program) {
const models = program?.commands.find((command) => command.name() === "models");
return models?.commands.map((command) => command.name()).join(" ") || "";
}
function generateZshScript(modelCommands) {
return `#compdef omniroute
# OmniRoute zsh completion (dynamic)
@@ -179,6 +181,7 @@ _omniroute() {
_arguments '1:resource:(combos providers api-manager cli-tools agents settings logs memory skills evals audit cost resilience)' ;;
completion) _arguments '1:subcommand:(zsh bash fish install refresh)' ;;
config) _arguments '1:subcommand:(list get set validate contexts)' ;;
models) _arguments '1:subcommand:(${modelCommands})' ;;
contexts) _arguments '1:subcommand:(list add use current show remove rename export import migrate)' ;;
configure) _arguments '1:target:(${CONFIGURE_TARGET_WORDS})' ;;
run) _arguments '1:target:(${RUN_TARGET_WORDS})' ;;
@@ -204,7 +207,7 @@ compdef _omniroute omniroute
`;
}
function generateBashScript() {
function generateBashScript(modelCommands) {
return `#!/bin/bash
# OmniRoute CLI bash completion (dynamic)
@@ -235,6 +238,7 @@ _omniroute() {
keys) COMPREPLY=($(compgen -W "add list remove regenerate revoke reveal usage" -- "\${cur}")); return 0 ;;
providers) COMPREPLY=($(compgen -W "available list test test-all validate rotate status add import auth remove edit metrics metric" -- "\${cur}")); return 0 ;;
config) COMPREPLY=($(compgen -W "list get set validate contexts" -- "\${cur}")); return 0 ;;
models) COMPREPLY=($(compgen -W "${modelCommands}" -- "\${cur}")); return 0 ;;
completion) COMPREPLY=($(compgen -W "zsh bash fish install refresh" -- "\${cur}")); return 0 ;;
open) COMPREPLY=($(compgen -W "combos providers api-manager cli-tools agents settings logs memory skills evals audit cost resilience" -- "\${cur}")); return 0 ;;
contexts) COMPREPLY=($(compgen -W "list add use current show remove rename export import migrate" -- "\${cur}")); return 0 ;;
@@ -262,7 +266,7 @@ complete -F _omniroute omniroute
`;
}
function generateFishScript() {
function generateFishScript(modelCommands) {
return `# OmniRoute CLI fish completion (dynamic)
complete -c omniroute -f
@@ -277,6 +281,7 @@ complete -c omniroute -n '__fish_seen_subcommand_from combo' -a 'list switch cre
complete -c omniroute -n '__fish_seen_subcommand_from keys' -a 'add list remove regenerate revoke reveal usage'
complete -c omniroute -n '__fish_seen_subcommand_from providers' -a 'available list test test-all validate rotate status add import auth remove edit metrics metric'
complete -c omniroute -n '__fish_seen_subcommand_from config' -a 'list get set validate contexts'
complete -c omniroute -n '__fish_seen_subcommand_from models' -a '${modelCommands}'
complete -c omniroute -n '__fish_seen_subcommand_from completion' -a 'zsh bash fish install refresh'
complete -c omniroute -n '__fish_seen_subcommand_from open' -a 'combos providers api-manager cli-tools agents settings logs memory skills evals audit cost resilience'
complete -c omniroute -n '__fish_seen_subcommand_from contexts' -a 'list add use current show remove rename export import migrate'
@@ -315,17 +320,17 @@ export function registerCompletion(program) {
comp
.command("zsh")
.description(t("completion.zsh") || "Print zsh completion script")
.action(async () => process.stdout.write(generateZshScript()));
.action(async () => process.stdout.write(generateZshScript(modelSubcommandWords(program))));
comp
.command("bash")
.description(t("completion.bash") || "Print bash completion script")
.action(async () => process.stdout.write(generateBashScript()));
.action(async () => process.stdout.write(generateBashScript(modelSubcommandWords(program))));
comp
.command("fish")
.description(t("completion.fish") || "Print fish completion script")
.action(async () => process.stdout.write(generateFishScript()));
.action(async () => process.stdout.write(generateFishScript(modelSubcommandWords(program))));
comp
.command("install [shell]")
@@ -339,7 +344,7 @@ export function registerCompletion(program) {
}
const dest = installPath(target);
mkdirSync(dirname(dest), { recursive: true });
writeFileSync(dest, gen());
writeFileSync(dest, gen(modelSubcommandWords(program)));
process.stdout.write(
`Installed ${target} completion at ${dest}\nRestart your shell or source the file.\n`
);
@@ -351,7 +356,18 @@ export function registerCompletion(program) {
.option("--quiet", "Suppress output")
.action(async (opts, cmd) => {
const globalOpts = cmd.optsWithGlobals();
const data = await refreshCache(globalOpts);
let data;
try {
data = await refreshCache(globalOpts);
} catch (error) {
console.error(
error instanceof ModelCommandError
? error.message
: "Unable to refresh model completions."
);
process.exitCode = error.exitCode || 1;
return;
}
if (!opts.quiet && !globalOpts.quiet) {
process.stdout.write(
`Cached: ${data.combos.length} combos, ${data.providers.length} providers, ${data.models.length} models\n`
@@ -370,17 +386,17 @@ export function registerCompletion(program) {
process.stderr.write(`Unknown shell: ${shell}. Valid: bash, zsh, fish\n`);
process.exit(1);
}
process.stdout.write(gen());
process.stdout.write(gen(modelSubcommandWords(program)));
});
}
// Legacy export for backward compatibility
export async function runCompletionCommand(shell) {
export async function runCompletionCommand(shell, program) {
const gen = generators[shell];
if (!gen) {
process.stderr.write(`Unknown shell: ${shell}. Valid: bash, zsh, fish\n`);
return 1;
}
process.stdout.write(gen());
process.stdout.write(gen(modelSubcommandWords(program)));
return 0;
}

View File

@@ -1,7 +1,9 @@
import { t } from "../i18n.mjs";
import { emit } from "../output.mjs";
import { writePrivateFileAtomic } from "../private-file.mjs";
import {
loadContexts,
loadContextsForExport,
saveContextsSecure,
deleteContextCredential,
migrateContextCredentials,
@@ -245,16 +247,15 @@ export function registerContexts(program) {
.command("export")
.description("Export contexts to JSON")
.option("--out <path>", "Output file path (default: stdout)")
.option("--no-secrets", "Omit API keys from export")
.option("--no-secrets", "Omit credentials from export (safe default)")
.option("--include-secrets", "Explicitly include plaintext credentials in the export")
.action(async (opts, cmd) => {
const cfg = loadContexts();
// Commander stores `--no-secrets` as `secrets === false`, never as `noSecrets`.
const redact = opts.secrets === false || opts.noSecrets === true;
const out = redact ? redactContextSecrets(cfg) : JSON.parse(JSON.stringify(cfg));
const includeSecrets = opts.includeSecrets === true && opts.secrets !== false;
const cfg = await loadContextsForExport({ includeSecrets });
const out = includeSecrets ? cfg : redactContextSecrets(cfg);
const json = JSON.stringify(out, null, 2);
if (opts.out) {
const { writeFileSync } = await import("node:fs");
writeFileSync(opts.out, json);
writePrivateFileAtomic(opts.out, json);
process.stdout.write(`Exported to ${opts.out}\n`);
} else {
process.stdout.write(json + "\n");

View File

@@ -352,10 +352,24 @@ export async function runKeysRegenerateCommand(id, opts = {}) {
return 1;
}
try {
const res = await apiFetch(`/api/v1/registered-keys/${encodeURIComponent(id)}/regenerate`, {
const encodedId = encodeURIComponent(id);
let res = await apiFetch(`/api/v1/registered-keys/${encodedId}/regenerate`, {
method: "POST",
retry: false,
acceptNotOk: true,
});
// `keys` predates the split between registered keys and the dashboard's
// ordinary API keys. IDs shown by `keys list`/the dashboard belong to
// `/api/keys`, while deployment/registered-key IDs belong to
// `/api/v1/registered-keys`. Try the ordinary-key route when the ID is not
// present in the registered-key store so the command works with either ID.
if (isRouteUnavailableStatus(res.status)) {
res = await apiFetch(`/api/keys/${encodedId}/regenerate`, {
method: "POST",
retry: false,
acceptNotOk: true,
});
}
if (!res.ok) {
console.error(t("common.error", { message: `HTTP ${res.status}` }));
return 1;
@@ -410,9 +424,17 @@ export async function runKeysRevealCommand(id, opts = {}) {
return 1;
}
try {
const res = await apiFetch(`/api/v1/registered-keys/${encodeURIComponent(id)}/reveal`, {
const encodedId = encodeURIComponent(id);
let res = await apiFetch(`/api/v1/registered-keys/${encodedId}/reveal`, {
retry: false,
acceptNotOk: true,
});
if (isRouteUnavailableStatus(res.status)) {
res = await apiFetch(`/api/keys/${encodedId}/reveal`, {
retry: false,
acceptNotOk: true,
});
}
if (!res.ok) {
console.error(t("common.error", { message: `HTTP ${res.status}` }));
return 1;

View File

@@ -0,0 +1,131 @@
import { apiFetch, statusToExitCode } from "../api.mjs";
export class ModelCommandError extends Error {
constructor(message, exitCode = 2) {
super(message);
this.exitCode = exitCode;
}
}
export async function modelRequest(path, opts, init = {}) {
let response;
try {
response = await apiFetch(path, {
...opts,
...init,
retry: false,
timeout: opts.timeout ?? 30000,
redirect: "error",
acceptNotOk: true,
});
} catch (error) {
throw new ModelCommandError(
"Unable to reach the selected OmniRoute server.",
error.exitCode === 124 ? 124 : 1
);
}
if (!response.ok) {
const error = new ModelCommandError(
`Model request failed (HTTP ${response.status}).`,
statusToExitCode(response.status)
);
error.status = response.status;
throw error;
}
try {
return await response.json();
} catch {
throw new ModelCommandError("The server returned an invalid model response.", 1);
}
}
export async function loadModelCatalog(opts = {}) {
let data;
try {
data = await modelRequest("/api/v1/models", opts);
} catch (error) {
if (![404, 405, 501].includes(error.status)) throw error;
data = await modelRequest("/api/models", opts);
}
const models = Array.isArray(data) ? data : (data?.data ?? data?.models);
if (!Array.isArray(models)) throw new ModelCommandError("Invalid model catalog.", 1);
return models.filter((model) => model && typeof model === "object").map(publicModel);
}
// Public model metadata only; never forward credentials or compatibility headers.
const PUBLIC_FIELDS = [
"id",
"name",
"object",
"created",
"owned_by",
"provider",
"description",
"source",
"context_length",
"contextWindow",
"contextWindowOverride",
"contextWindowOverrideSource",
"max_input_tokens",
"max_output_tokens",
"inputTokenLimit",
"outputTokenLimit",
"apiFormat",
"supportedEndpoints",
"targetFormat",
"supportsVision",
"supports_vision",
"supports_tools",
"supports_reasoning",
"modelType",
"isFree",
"dimensions",
"root",
"parent",
"type",
"free",
"custom",
"api_format",
"supported_endpoints",
"input_modalities",
"output_modalities",
"supported_parameters",
"supportedInputTypes",
];
export function publicModel(model) {
const result = {};
for (const field of PUBLIC_FIELDS) {
const value = model?.[field];
if (value === null || ["string", "number", "boolean"].includes(typeof value))
result[field] = value;
else if (Array.isArray(value) && value.every((item) => typeof item === "string"))
result[field] = value;
}
if (model?.capabilities && typeof model.capabilities === "object") {
result.capabilities = Object.fromEntries(
Object.entries(model.capabilities).filter(
([key, value]) =>
[
"vision",
"reasoning",
"tool_calling",
"structured_output",
"streaming",
"audio",
"video",
].includes(key) && typeof value === "boolean"
)
);
}
result.id = String(model?.id || model?.name || "unknown");
result.provider = String(model?.provider || model?.owned_by || "unknown");
result.contextWindow =
result.contextWindowOverride ??
result.context_length ??
result.max_input_tokens ??
result.inputTokenLimit ??
result.contextWindow ??
"-";
return result;
}

View File

@@ -0,0 +1,174 @@
import { z } from "zod";
import { emit } from "../output.mjs";
import { ModelCommandError, modelRequest, publicModel } from "./model-api.mjs";
// Runtime CLI subset of providerModelMutationSchema. The API validates again.
const identity = z.object({
provider: z.string().trim().min(1).max(120),
modelId: z.string().trim().min(1).max(240),
});
const patchSchema = z.object({
modelName: z.string().trim().min(1).max(240).optional(),
apiFormat: z
.enum([
"chat-completions",
"responses",
"embeddings",
"rerank",
"audio-transcriptions",
"audio-speech",
"images-generations",
"video",
])
.optional(),
max_input_tokens: z.number().int().positive().safe().optional(),
max_output_tokens: z.number().int().positive().safe().optional(),
contextWindowOverride: z.number().int().positive().safe().nullable().optional(),
});
const FORMAT_ENDPOINT = {
"chat-completions": "chat",
responses: "chat",
embeddings: "embeddings",
rerank: "rerank",
"audio-transcriptions": "audio-transcriptions",
"audio-speech": "audio-speech",
"images-generations": "images",
video: "videos",
};
function payloadFor(action, provider, modelId, opts) {
const id = identity.safeParse({ provider, modelId });
const patch = {};
if (opts.name !== undefined) patch.modelName = opts.name;
if (opts.apiFormat !== undefined) patch.apiFormat = opts.apiFormat;
if (opts.contextWindow !== undefined) {
patch[action === "add" ? "max_input_tokens" : "contextWindowOverride"] = Number(
opts.contextWindow
);
}
if (opts.clearContextWindow) {
if (opts.contextWindow !== undefined || action !== "edit")
throw new ModelCommandError("Invalid context-window options.");
patch.contextWindowOverride = null;
}
if (opts.maxOutputTokens !== undefined) patch.max_output_tokens = Number(opts.maxOutputTokens);
const parsed = patchSchema.safeParse(patch);
if (!id.success || !parsed.success)
throw new ModelCommandError("Invalid model identifier or metadata.");
if (action === "edit" && Object.keys(patch).length === 0)
throw new ModelCommandError("Provide at least one metadata change.");
return {
...id.data,
...parsed.data,
...(parsed.data.apiFormat
? { supportedEndpoints: [FORMAT_ENDPOINT[parsed.data.apiFormat]] }
: {}),
...(action === "add" ? { source: "manual" } : {}),
};
}
export async function listManualModels(provider, opts = {}) {
const parsed = identity.shape.provider.safeParse(provider);
if (!parsed.success) throw new ModelCommandError("Invalid provider identifier.");
const data = await modelRequest(
`/api/provider-models?${new URLSearchParams({ provider: parsed.data })}`,
opts
);
if (!Array.isArray(data?.models)) throw new ModelCommandError("Invalid manual model catalog.", 1);
return data.models;
}
export async function modifyManualModel(action, provider, modelId, opts = {}) {
if (!["add", "edit", "remove"].includes(action))
throw new ModelCommandError("Invalid model operation.");
if (action === "remove" && !opts.yes && !opts.dryRun)
throw new ModelCommandError("Removal requires --yes (or --dry-run).");
const body = payloadFor(action, provider, modelId, opts);
const before = (await listManualModels(body.provider, opts)).find(
(model) => model.id === body.modelId
);
if (action === "add" && before)
throw new ModelCommandError("The custom model already exists; use edit.");
if (action !== "add" && (!before || (before.source && before.source !== "manual"))) {
throw new ModelCommandError("The selected model is not an existing manual model.");
}
if (opts.dryRun)
return { action, dryRun: true, provider: body.provider, modelId: body.modelId, changes: body };
const query = new URLSearchParams({
provider: body.provider,
model: body.modelId,
resetOverride: "true",
});
await modelRequest(
action === "remove" ? `/api/provider-models?${query}` : "/api/provider-models",
opts,
{
method: { add: "POST", edit: "PUT", remove: "DELETE" }[action],
...(action === "remove" ? {} : { body }),
}
);
const after = (await listManualModels(body.provider, opts)).find(
(model) => model.id === body.modelId
);
const mapping = {
modelName: "name",
max_input_tokens: "inputTokenLimit",
max_output_tokens: "outputTokenLimit",
};
const mismatch =
action === "remove"
? Boolean(after)
: !after ||
Object.entries(body).some(([key, value]) => {
if (["provider", "modelId"].includes(key)) return false;
const actual = after[mapping[key] || key];
if (Array.isArray(value)) return JSON.stringify(actual) !== JSON.stringify(value);
return value === null ? actual != null : actual !== value;
});
if (mismatch)
throw new ModelCommandError(
"Model readback did not confirm the requested change; inspect the server before retrying.",
1
);
return {
action,
persistenceVerified: true,
inferenceValidation: "not-run",
provider: body.provider,
modelId: body.modelId,
...(after ? { model: publicModel({ ...after, provider: body.provider }) } : {}),
};
}
export function modelMutationAction(action) {
return async (provider, modelId, options, cmd) => {
try {
const opts = { ...cmd.optsWithGlobals(), ...options };
emit(await modifyManualModel(action, provider, modelId, opts), {
...opts,
output: opts.output || "json",
});
} catch (error) {
console.error(error instanceof ModelCommandError ? error.message : "Model operation failed.");
process.exitCode = error.exitCode || 1;
}
};
}
export async function manualListAction(provider, options, cmd) {
const opts = { ...cmd.optsWithGlobals(), ...options };
try {
emit(
(await listManualModels(provider, opts))
.filter((model) => !model.source || model.source === "manual")
.map((model) => publicModel({ ...model, provider })),
{ ...opts, output: opts.output || "json" }
);
} catch (error) {
console.error(
error instanceof ModelCommandError ? error.message : "Unable to list manual models."
);
process.exitCode = error.exitCode || 1;
}
}

View File

@@ -1,92 +1,75 @@
import { apiFetch, isServerUp } from "../api.mjs";
import { emit } from "../output.mjs";
import { modelListSchema } from "../schemas/output-schemas.mjs";
import { t } from "../i18n.mjs";
import { loadModelCatalog } from "./model-api.mjs";
import { manualListAction, modelMutationAction } from "./model-crud.mjs";
export function registerModels(program) {
program
const models = program
.command("models [provider]")
.description(t("models.description"))
.option("--search <query>", t("models.search"))
.option("--json", "Output as JSON")
.action(async (provider, opts, cmd) => {
const globalOpts = cmd.optsWithGlobals();
const exitCode = await runModelsCommand(provider, { ...opts, output: globalOpts.output });
if (exitCode !== 0) process.exit(exitCode);
process.exitCode = await runModelsCommand(provider, { ...cmd.optsWithGlobals(), ...opts });
});
models
.command("manual <provider>")
.description("List manual model metadata from the selected server")
.action(manualListAction);
models
.command("add <provider> <model-id>")
.description("Add an unverified manual model, then verify persistence")
.option("--name <name>", "Display name")
.option("--api-format <format>", "API format, e.g. chat-completions or responses")
.option("--context-window <tokens>", "Positive integer input/context limit")
.option("--max-output-tokens <tokens>", "Positive integer output limit")
.option("--dry-run", "Preview without writing or inference")
.action(modelMutationAction("add"));
models
.command("edit <provider> <model-id>")
.description("Edit manual model metadata, then verify persistence")
.option("--name <name>", "Display name")
.option("--api-format <format>", "API format, e.g. chat-completions or responses")
.option("--context-window <tokens>", "Positive integer context override")
.option("--clear-context-window", "Clear the manual context-window override")
.option("--dry-run", "Preview without writing or inference")
.action(modelMutationAction("edit"));
models
.command("remove <provider> <model-id>")
.description("Remove only a manual model override, then verify persistence")
.option("--yes", "Confirm removal of the manual override only")
.option("--dry-run", "Preview without writing or inference")
.action(modelMutationAction("remove"));
}
export async function runModelsCommand(provider, opts = {}) {
const serverUp = await isServerUp();
if (!serverUp) {
console.error(t("models.noServer"));
return 1;
}
let models = [];
try {
const res = await apiFetch("/api/models", { retry: false, timeout: 5000, acceptNotOk: true });
if (res.ok) {
const data = await res.json();
models = Array.isArray(data) ? data : data.models || [];
let models = await loadModelCatalog(opts);
if (provider) {
const filter = provider.toLowerCase();
models = models.filter(
(model) =>
model.provider.toLowerCase().includes(filter) || model.id.toLowerCase().startsWith(filter)
);
}
} catch {}
if (models.length === 0) {
try {
const res = await apiFetch("/api/v1/models", {
retry: false,
timeout: 5000,
acceptNotOk: true,
});
if (res.ok) {
const data = await res.json();
models = Array.isArray(data) ? data : data.data || [];
}
} catch {}
}
if (provider) {
const filter = provider.toLowerCase();
models = models.filter(
(m) =>
(m.provider && m.provider.toLowerCase().includes(filter)) ||
(m.id && m.id.toLowerCase().startsWith(filter)) ||
(m.name && m.name.toLowerCase().includes(filter))
);
}
if (opts.search) {
const search = opts.search.toLowerCase();
models = models.filter(
(m) =>
(m.id && m.id.toLowerCase().includes(search)) ||
(m.name && m.name.toLowerCase().includes(search)) ||
(m.provider && m.provider.toLowerCase().includes(search)) ||
(m.description && m.description.toLowerCase().includes(search))
);
}
if (models.length === 0) {
console.log(t("models.noModels"));
if (opts.search) {
const search = opts.search.toLowerCase();
models = models.filter((model) =>
[model.id, model.name, model.provider, model.description].some((value) =>
String(value || "")
.toLowerCase()
.includes(search)
)
);
}
const table = opts.output === "table" || (!opts.output && !opts.json && process.stdout.isTTY);
emit(table ? models.slice(0, 50) : models, opts, modelListSchema);
if (table && models.length > 50)
console.log(`... and ${models.length - 50} more. Use --output json for the full list.`);
return 0;
} catch (error) {
console.error(error.exitCode ? error.message : "Unable to read the model catalog.");
return error.exitCode || 1;
}
const normalized = models.map((m) => ({
id: m.id || m.name || "unknown",
provider: m.provider || "unknown",
contextWindow: String(m.context_length || m.max_tokens || m.contextWindow || "-"),
}));
const display = normalized.slice(0, 50);
emit(display, opts, modelListSchema);
if (models.length > 50) {
console.log(
`\x1b[2m ... and ${models.length - 50} more. Use --output json for full list.\x1b[0m`
);
}
return 0;
}

View File

@@ -23,8 +23,13 @@ const PROVIDERS_WITH_OAUTH = [
// the device-flow request to /api/providers/command-code/auth/start, which is
// gated by requireManagementAuth and returned 401 for a fresh CLI context
// (issue #9474). Map the alias to the real backend key instead.
//
// `copilot` has the same mismatch (#14298): the GitHub Copilot device flow is
// registered under the backend key `github`, so posting to
// /api/oauth/copilot/device-code failed for an unknown provider.
const BACKEND_OAUTH_KEY = {
"claude-code": "claude",
copilot: "github",
};
function resolveBackendKey(id) {
@@ -70,7 +75,7 @@ function printLoopbackRedirectWarning(providerId, redirectUri) {
process.stdout.write(
`Note: the authorize URL below advertises ${redirectUri}, but this CLI does not\n` +
"listen on that port. Right after you approve, the browser is expected to\n" +
"show a connection error (e.g. \"This site can't be reached\" / \n" +
'show a connection error (e.g. "This site can\'t be reached" / \n' +
"ERR_CONNECTION_REFUSED) — that is normal, not a failure. Copy the full URL\n" +
"from the address bar anyway and paste it below.\n"
);
@@ -292,27 +297,51 @@ async function runDeviceFlow(def, opts) {
if (opts.browser !== false && verificationUri) await openBrowser(verificationUri);
process.stderr.write("Waiting for device authorization...\n");
// Poll the real device-flow route: POST /api/oauth/{key}/poll with the device
// code (#14298). The previous implementation polled
// GET /api/providers/{key}/auth/status?state=… and then POST …/auth/apply,
// but neither route exists on the server, and the device-code response has no
// `state` field at all — so the CLI looped until its timeout even after the
// user authorized. /api/oauth/{key}/poll is the same route the dashboard
// polls (src/shared/components/OAuthModal.tsx::pollDeviceCodeOnce) and it
// persists the connection server-side on success, so no separate apply step
// is needed.
const deviceCode = start.deviceCode ?? start.device_code ?? "";
if (!deviceCode) {
process.stderr.write("Server did not return a device code; cannot poll for authorization.\n");
process.exit(1);
}
const codeVerifier = start.codeVerifier ?? undefined;
const deadline = Date.now() + (opts.timeout ?? 300000);
const intervalMs = (start.intervalMs ?? start.interval ?? 5) * 1000;
let intervalMs = (start.intervalMs ?? start.interval ?? 5) * 1000;
while (Date.now() < deadline) {
await sleep(intervalMs);
const statusRes = await apiFetch(
`/api/providers/${providerKey}/auth/status?state=${encodeURIComponent(start.state ?? "")}`,
targetApiOptions(opts)
);
if (!statusRes.ok) continue;
const status = await statusRes.json();
if (status.status === "complete" || status.status === "authorized") {
await apiFetch(`/api/providers/${providerKey}/auth/apply`, {
...targetApiOptions(opts),
method: "POST",
body: { state: start.state },
});
process.stdout.write(`Authorized: ${status.account ?? status.email ?? "connected"}\n`);
const pollRes = await apiFetch(`/api/oauth/${providerKey}/poll`, {
...targetApiOptions(opts),
method: "POST",
body: { deviceCode, ...(codeVerifier ? { codeVerifier } : {}) },
});
if (!pollRes.ok) continue;
let poll;
try {
poll = await pollRes.json();
} catch {
continue;
}
if (poll.success) {
const conn = poll.connection ?? {};
process.stdout.write(
`Authorized: ${conn.email ?? conn.displayName ?? conn.id ?? "connected"}\n`
);
return;
}
if (status.status === "error") {
process.stderr.write(`Device auth failed: ${status.error}\n`);
if (poll.error === "slow_down") {
// OAuth device-flow spec: back off by 5s on slow_down.
intervalMs += 5000;
continue;
}
if (poll.error && !poll.pending) {
process.stderr.write(`Device auth failed: ${poll.errorDescription ?? poll.error}\n`);
process.exit(1);
}
}

View File

@@ -15,8 +15,17 @@ function credentialShape(value) {
return { present: true, length: String(value).length };
}
const SENSITIVE_FIELD_RE =
/^(?:api[_-]?key|access[_-]?token|refresh[_-]?token|id[_-]?token|token|password|secret|client[_-]?secret|credential|authorization)$/i;
const SENSITIVE_FIELD_SUFFIX_RE =
/(?:^|_)(?:api_?key|access_key|secret_access_key|access_?token|refresh_?token|id_?token|auth_token|token|password|passphrase|secret|secret_key|secret_value|client_?secret|credential|authorization|private_key)$/;
function isSensitiveFieldName(key) {
const normalized = String(key || "")
.replace(/([a-z0-9])([A-Z])/g, "$1_$2")
.replace(/[^A-Za-z0-9]+/g, "_")
.replace(/^_+|_+$/g, "")
.toLowerCase();
return SENSITIVE_FIELD_SUFFIX_RE.test(normalized);
}
/**
* Redact provider responses before they reach human or JSON output.
@@ -27,7 +36,7 @@ const SENSITIVE_FIELD_RE =
* for diagnostics; the value itself must never be printed.
*/
export function redactProviderResponse(value, key = "") {
if (SENSITIVE_FIELD_RE.test(key)) {
if (isSensitiveFieldName(key)) {
if (value === null || value === undefined || value === "") return null;
return typeof value === "string" ? credentialShape(value) : "[redacted]";
}
@@ -58,17 +67,31 @@ export function findConnectionFromResponse(body, selector) {
.trim()
.toLowerCase();
if (!needle) return null;
return (
rows.find((row) => String(row?.id || "").toLowerCase() === needle) ||
rows.find((row) =>
const selectUnique = (matches) => {
if (matches.length === 0) return null;
if (matches.length === 1) return matches[0];
const candidates = matches.map((row) => String(row?.id || "<missing-id>")).join(", ");
throw new Error(`Provider connection selector '${selector}' is ambiguous: ${candidates}`);
};
const exactId = selectUnique(
rows.filter((row) => String(row?.id || "").toLowerCase() === needle)
);
if (exactId) return exactId;
const idPrefix = selectUnique(
rows.filter((row) =>
String(row?.id || "")
.toLowerCase()
.startsWith(needle)
) ||
rows.find((row) => String(row?.name || "").toLowerCase() === needle) ||
rows.find((row) => String(row?.provider || "").toLowerCase() === needle) ||
null
)
);
if (idPrefix) return idPrefix;
const exactName = selectUnique(
rows.filter((row) => String(row?.name || "").toLowerCase() === needle)
);
if (exactName) return exactName;
return selectUnique(rows.filter((row) => String(row?.provider || "").toLowerCase() === needle));
}
/** Build the API body without accepting management auth as a provider secret. */
@@ -179,6 +202,48 @@ async function resolveRemoteConnection(selector, opts) {
return connection;
}
async function readRemoteConnectionById(id, opts) {
const response = await apiFetch(`/api/providers/${encodeURIComponent(id)}`, {
...targetOptions(opts),
acceptNotOk: true,
retry: false,
});
if (!response.ok)
throw new Error(`Provider mutation read-back failed: ${await readApiError(response)}`);
const body = await response.json().catch(() => ({}));
const connection = body?.connection;
if (!connection || String(connection.id || "") !== String(id)) {
throw new Error("Provider mutation read-back returned an unexpected connection.");
}
return connection;
}
async function confirmRemoteConnectionRemoved(id, opts) {
const response = await apiFetch(`/api/providers/${encodeURIComponent(id)}`, {
...targetOptions(opts),
acceptNotOk: true,
retry: false,
});
if (response.status === 404) return;
if (!response.ok) {
throw new Error(`Provider removal read-back failed: ${await readApiError(response)}`);
}
const body = await response.json().catch(() => ({}));
if (body?.connection) {
throw new Error("Provider removal read-back found the connection still present.");
}
throw new Error("Provider removal read-back returned an unexpected success response.");
}
function verifyConnectionFields(connection, expected) {
for (const [field, value] of Object.entries(expected)) {
if (value !== undefined && connection?.[field] !== value) {
throw new Error(`Provider mutation read-back did not persist field '${field}'.`);
}
}
return connection;
}
export async function runProviderAddCommand(provider, opts = {}) {
const normalized = String(provider || "").trim();
if (!normalized) {
@@ -241,9 +306,18 @@ export async function runProviderAddCommand(provider, opts = {}) {
return statusToExitCode(response.status);
}
const body = await response.json().catch(() => ({}));
const created = body?.connection;
if (!created?.id) throw new Error("Provider create response did not include a connection id.");
const verified = verifyConnectionFields(await readRemoteConnectionById(created.id, opts), {
provider: payload.provider,
name: payload.name,
defaultModel: payload.defaultModel,
priority: payload.priority,
});
if (!opts.silent) {
if (opts.json) console.log(JSON.stringify(redactProviderResponse(body), null, 2));
else printSuccess(`Added provider connection '${body?.connection?.name || payload.name}'.`);
if (opts.json) {
console.log(JSON.stringify(redactProviderResponse({ connection: verified }), null, 2));
} else printSuccess(`Added provider connection '${verified.name || payload.name}'.`);
}
return 0;
} catch (error) {
@@ -271,6 +345,29 @@ export async function runProviderImportCommand(file, opts = {}) {
printError("Provider import file contains no entries.");
return 2;
}
const existingByProviderAndName = new Map();
if (!opts.dryRun) {
try {
const response = await listRemoteConnections(opts);
if (!response.ok) {
printError(await readApiError(response));
return statusToExitCode(response.status);
}
const body = await response.json().catch(() => ({}));
const connections = Array.isArray(body?.connections) ? body.connections : [];
for (const connection of connections) {
const key = `${String(connection?.provider || "").toLowerCase()}\0${String(
connection?.name || connection?.provider || ""
).toLowerCase()}`;
if (!existingByProviderAndName.has(key)) {
existingByProviderAndName.set(key, connection);
}
}
} catch (error) {
printError(error instanceof Error ? error.message : String(error));
return 1;
}
}
const results = [];
for (const entry of entries) {
if (!entry || typeof entry !== "object" || !entry.provider) {
@@ -278,16 +375,48 @@ export async function runProviderImportCommand(file, opts = {}) {
if (!opts.continueOnError) break;
continue;
}
const code = await runProviderAddCommand(entry.provider, {
...opts,
...entry,
const provider = String(entry.provider).trim();
const name = String(entry.name || provider).trim();
const identityKey = `${provider.toLowerCase()}\0${name.toLowerCase()}`;
const existing = existingByProviderAndName.get(identityKey);
if (existing) {
const result = {
provider,
name,
ok: true,
status: "skipped_existing",
connectionId: existing.id,
};
results.push(result);
if (!opts.json) printInfo(`Skipped existing provider connection '${name}'.`);
continue;
}
// Import files contain provider data, never command/control-plane options.
// Keep the management target, context and authentication exclusively from
// the CLI invocation so an imported document cannot redirect credentials.
const importedProviderOptions = {
name: entry.name,
defaultModel: entry.defaultModel,
priority: entry.priority,
providerSpecificData: entry.providerSpecificData,
credential: entry.apiKey ?? entry.credential,
allowNoCredential: entry.allowNoCredential ?? opts.allowNoCredential,
};
const code = await runProviderAddCommand(provider, {
...opts,
...importedProviderOptions,
dryRun: opts.dryRun,
yes: true,
silent: true,
allowNoCredential: entry.allowNoCredential ?? opts.allowNoCredential,
});
results.push({ provider: entry.provider, ok: code === 0, code });
results.push({
provider,
name,
ok: code === 0,
code,
status: code === 0 ? "created" : "error",
});
if (code === 0) existingByProviderAndName.set(identityKey, { provider, name });
if (code !== 0 && !opts.continueOnError) break;
}
if (opts.json) console.log(JSON.stringify({ file, results }, null, 2));
@@ -340,6 +469,7 @@ export async function runProviderRemoveCommand(selector, opts = {}) {
printError(await readApiError(response));
return statusToExitCode(response.status);
}
await confirmRemoteConnectionRemoved(connection.id, opts);
if (opts.json)
console.log(JSON.stringify(redactProviderResponse({ removed: connection }), null, 2));
else printSuccess(`Removed provider connection '${connection.name || connection.id}'.`);
@@ -351,12 +481,24 @@ export async function runProviderRemoveCommand(selector, opts = {}) {
}
export async function runProviderEditCommand(selector, opts = {}) {
if (opts.active === true && opts.inactive === true) {
printError("--active and --inactive cannot be used together.");
return 2;
}
let parsedPriority;
if (opts.priority !== undefined) {
parsedPriority = Number(opts.priority);
if (!Number.isInteger(parsedPriority) || parsedPriority < 1) {
printError("--priority must be a positive integer.");
return 2;
}
}
try {
const connection = await resolveRemoteConnection(selector, opts);
const body = {};
if (opts.name !== undefined) body.name = opts.name;
if (opts.defaultModel !== undefined) body.defaultModel = opts.defaultModel || null;
if (opts.priority !== undefined) body.priority = Number(opts.priority);
if (parsedPriority !== undefined) body.priority = parsedPriority;
if (opts.active !== undefined) body.isActive = Boolean(opts.active);
if (opts.inactive !== undefined) body.isActive = false;
const credential = await resolveProviderCredential(opts, { prompt: false });
@@ -388,9 +530,16 @@ export async function runProviderEditCommand(selector, opts = {}) {
printError(await readApiError(response));
return statusToExitCode(response.status);
}
const result = await response.json().catch(() => ({}));
if (opts.json) console.log(JSON.stringify(redactProviderResponse(result), null, 2));
else printSuccess(`Updated provider connection '${connection.name || connection.id}'.`);
await response.json().catch(() => ({}));
const expected = { ...body };
delete expected.apiKey;
const verified = verifyConnectionFields(
await readRemoteConnectionById(connection.id, opts),
expected
);
if (opts.json) {
console.log(JSON.stringify(redactProviderResponse({ connection: verified }), null, 2));
} else printSuccess(`Updated provider connection '${connection.name || connection.id}'.`);
return 0;
} catch (error) {
printError(error instanceof Error ? error.message : String(error));

View File

@@ -4,7 +4,13 @@ import { join, dirname } from "node:path";
import { fileURLToPath, pathToFileURL } from "node:url";
import { platform, totalmem } from "node:os";
import { t } from "../i18n.mjs";
import { writePidFile, cleanupPidFile, waitForServer, resolveReadyTimeoutMs } from "../utils/pid.mjs";
import {
writePidFile,
cleanupPidFile,
waitForServer,
findListeningPids,
resolveReadyTimeoutMs,
} from "../utils/pid.mjs";
import {
ServerSupervisor,
detectMitmCrash,
@@ -235,6 +241,16 @@ export async function runServe(opts = {}) {
process.exit(1);
}
// Refuse to start a second instance on a port something else already owns,
// BEFORE any pid file is written or any child is spawned. Otherwise the
// doomed child's EADDRINUSE arrives only after this process has rewritten
// the pid files of the healthy instance that actually owns the port.
const busyPids = await findListeningPids(dashboardPort);
if (busyPids.length > 0) {
reportPortInUse(dashboardPort, busyPids);
process.exit(1);
}
console.log(` \x1b[2m⏳ Starting server...\x1b[0m\n`);
// #5172/#5160/#5152: default the V8 heap to ~35% of physical RAM (clamped
@@ -305,10 +321,29 @@ export async function runServe(opts = {}) {
opts.maxRestarts ?? 2,
startedAt,
useTray,
{ trayReadyPort: opts.trayReadyPort, trayReadyToken: opts.trayReadyToken }
{
trayReadyPort: opts.trayReadyPort,
trayReadyToken: opts.trayReadyToken,
readyTimeoutMs: resolveReadyTimeoutMs({ timeoutMs: opts.readyTimeout }),
}
);
}
/**
* Explain a port conflict in terms the operator can act on: who owns the port,
* and the two ways out. Exported for unit tests.
*/
export function reportPortInUse(port, pids = []) {
const owner = pids.length === 1 ? `PID ${pids[0]}` : `PIDs ${pids.join(", ")}`;
console.error(`\n\x1b[31m✖ Port ${port} is already in use by ${owner}.\x1b[0m`);
console.error(
` Another OmniRoute is most likely already serving there, so open` +
` ${urlScheme}://localhost:${port} before starting a second one.`
);
console.error(` To replace it: \x1b[36momniroute stop\x1b[0m, then start again`);
console.error(` To run alongside: \x1b[36momniroute serve --port <other-port>\x1b[0m\n`);
}
function runDaemon(serverJs, env, memoryLimit, dashboardPort, apiPort) {
// #5238: skip the explicit CLI --max-old-space-size when the user pinned the
// heap via NODE_OPTIONS (a CLI arg would shadow/override their value).
@@ -419,7 +454,7 @@ async function runWithSupervisor(
maxRestarts,
startedAt,
useTray = false,
{ trayReadyPort, trayReadyToken } = {}
{ trayReadyPort, trayReadyToken, readyTimeoutMs = resolveReadyTimeoutMs() } = {}
) {
if (showLog) process.env.OMNIROUTE_SHOW_LOG = "1";
writePidFile("supervisor", process.pid);
@@ -458,8 +493,12 @@ async function runWithSupervisor(
});
if (!showLog) {
const readyTimeoutMs = resolveReadyTimeoutMs({ timeoutMs: opts.readyTimeout });
waitForServer(dashboardPort, readyTimeoutMs).then(async (up) => {
let lastProbeOutcome = null;
waitForServer(dashboardPort, readyTimeoutMs, {
onOutcome: (outcome) => {
lastProbeOutcome = outcome;
},
}).then(async (up) => {
if (up) {
if (useTray) {
const trayReady = await maybeStartTray(dashboardPort, apiPort, supervisor);
@@ -483,7 +522,7 @@ async function runWithSupervisor(
}
onReady(dashboardPort, apiPort, noOpen, startedAt);
} else {
reportReadinessTimeout(dashboardPort, supervisor);
reportReadinessTimeout(dashboardPort, supervisor, lastProbeOutcome);
}
});
}
@@ -495,13 +534,28 @@ async function runWithSupervisor(
// stuck (issue reports show the server sometimes actually comes up later, or is
// reachable directly while the CLI still looks hung). Surface a clear diagnostic
// plus whatever stdout/stderr the child buffered instead of going silent.
export function reportReadinessTimeout(dashboardPort, supervisor) {
export function reportReadinessTimeout(dashboardPort, supervisor, lastProbeOutcome = null) {
const readyTimeoutMs = resolveReadyTimeoutMs();
const seconds = Math.round(readyTimeoutMs / 1000);
console.error(
`\n\x1b[33m⚠ Server did not respond within ${seconds}s.\x1b[0m It may still be starting, or may` +
` have failed silently.`
);
// The last probe classification separates a real boot failure (nothing ever
// bound the port, so the buffered output below is the reason) from a server
// that IS listening and merely did not answer the health route in time:
// very likely usable already, with only the readiness signal timed out.
if (lastProbeOutcome === "hanging" || lastProbeOutcome === "fast-reject") {
console.error(
` Port ${dashboardPort} IS accepting connections, so the server is probably up and` +
` still warming up. Check the dashboard before restarting it.`
);
} else if (lastProbeOutcome === "not-listening") {
console.error(
` Nothing is listening on port ${dashboardPort}, so the server never bound it and the` +
` output below is the reason.`
);
}
console.error(
` Tip: set OMNIROUTE_READY_TIMEOUT_MS=${readyTimeoutMs * 2} or --ready-timeout ${readyTimeoutMs * 2} for slower cold starts.`
);

View File

@@ -187,7 +187,9 @@ export async function runUpdateCommand(opts = {}) {
}
if (dryRun) {
console.log("\n [DRY RUN] Would run: npm install -g omniroute@latest --include=optional");
console.log(
"\n [DRY RUN] Would run: npm install -g omniroute@latest --include=optional --legacy-peer-deps"
);
if (!skipBackup) console.log(" [DRY RUN] Would create backup in ~/.omniroute/backups/");
return 0;
}
@@ -221,7 +223,9 @@ export async function runUpdateCommand(opts = {}) {
const { execSync } = await import("child_process");
// --include=optional keeps the optionalDependencies (better-sqlite3, keytar,
// tls-client, llmlingua SLM stack) on update so an omit=optional config can't drop them.
execSync("npm install -g omniroute@latest --include=optional", { stdio: "inherit" });
execSync("npm install -g omniroute@latest --include=optional --legacy-peer-deps", {
stdio: "inherit",
});
// Trust-but-verify: `npm install -g` exits 0 even when a shadowing local install
// (e.g. ~/node_modules/omniroute ahead of the global prefix on PATH) means the
// binary the user actually runs was not touched. Re-read the running binary's

View File

@@ -1,6 +1,7 @@
import { existsSync, readFileSync, writeFileSync, mkdirSync, chmodSync } from "node:fs";
import { join, dirname } from "node:path";
import { existsSync, readFileSync } from "node:fs";
import { join } from "node:path";
import { resolveDataDir } from "./data-dir.mjs";
import { writePrivateFileAtomic } from "./private-file.mjs";
const CONFIG_VERSION = 1;
const KEYCHAIN_SERVICE = "omniroute-cli";
@@ -124,17 +125,44 @@ export function loadContexts() {
return readConfigFile();
}
/**
* Load contexts for an explicit export operation.
*
* The persisted file intentionally contains only `credentialRef` for
* keychain-backed contexts. Secret-bearing exports therefore have to hydrate
* every referenced credential first. Fail closed when any reference cannot be
* resolved so `--include-secrets` never produces a silently incomplete backup.
*/
export async function loadContextsForExport({ includeSecrets = false } = {}) {
const cfg = readConfigFile();
if (!includeSecrets) return cfg;
await hydrateCredentialCache(cfg);
const out = JSON.parse(JSON.stringify(cfg));
const contexts = out.contexts || out.profiles || {};
const unresolved = [];
for (const [name, context] of Object.entries(contexts)) {
if (!context || typeof context !== "object" || !context.credentialRef) continue;
const credential = credentialForContext(context);
if (!credential) {
unresolved.push(name);
continue;
}
Object.assign(context, credential);
}
if (unresolved.length > 0) {
throw new Error(`Cannot include keychain credentials for context(s): ${unresolved.join(", ")}`);
}
return out;
}
/**
* Synchronous compatibility writer. New credential-bearing code should use
* `saveContextsSecure()` so tokens are moved to the OS keychain when possible.
*/
export function saveContexts(cfg) {
const path = configPath();
mkdirSync(dirname(path), { recursive: true });
writeFileSync(path, JSON.stringify(cfg, null, 2));
try {
chmodSync(path, 0o600);
} catch {}
writePrivateFileAtomic(path, JSON.stringify(cfg, null, 2));
}
/** Stable keychain reference; the reference itself is safe to persist in JSON. */

1340
bin/cli/locales/bs.json Normal file

File diff suppressed because it is too large Load Diff

49
bin/cli/private-file.mjs Normal file
View File

@@ -0,0 +1,49 @@
import {
fchmodSync,
closeSync,
constants,
fsyncSync,
mkdirSync,
openSync,
renameSync,
unlinkSync,
writeFileSync,
} from "node:fs";
import { randomBytes } from "node:crypto";
import { basename, dirname, join } from "node:path";
/**
* Atomically replace a credential-bearing file without following a destination
* symlink. The temporary file is private from its first filesystem operation.
*/
export function writePrivateFileAtomic(path, content, { mode = 0o600 } = {}) {
const directory = dirname(path);
mkdirSync(directory, { recursive: true, mode: 0o700 });
const suffix = randomBytes(8).toString("hex");
const temporary = join(directory, `.${basename(path)}.${process.pid}.${suffix}.tmp`);
let descriptor;
try {
descriptor = openSync(
temporary,
constants.O_CREAT | constants.O_EXCL | constants.O_WRONLY | constants.O_NOFOLLOW,
mode
);
writeFileSync(descriptor, String(content), "utf8");
fchmodSync(descriptor, mode);
fsyncSync(descriptor);
closeSync(descriptor);
descriptor = undefined;
renameSync(temporary, path);
} catch (error) {
if (descriptor !== undefined) {
try {
closeSync(descriptor);
} catch {}
}
try {
unlinkSync(temporary);
} catch {}
throw error;
}
}

View File

@@ -0,0 +1,62 @@
// Private-by-default helpers for the OmniRoute data directory (GHSA-2pg2-xm9r-8544).
//
// DATA_DIR holds `.env` (STORAGE_ENCRYPTION_KEY — the key to every credential in
// storage.sqlite) and the database itself. Creating them without an explicit mode lets the
// process umask decide; under the common umask 002 that is 0775 / 0664, i.e. readable by
// every local user. These helpers make the private mode explicit, the same convention the
// CLI already follows elsewhere (contexts.mjs, model-preferences.mjs, setup-qwen.mjs).
//
// chmod is best-effort on purpose: on Windows it is a no-op, and a DATA_DIR owned by another
// user (a bind mount, a shared volume) must not stop the server from starting.
import { chmodSync, existsSync, mkdirSync, statSync, writeFileSync } from "node:fs";
import { join } from "node:path";
const SECRET_FILES = [".env", "server.env"];
/** Create the data directory owner-only (0700). No-op when it already exists. */
export function ensurePrivateDataDir(dataDir) {
if (existsSync(dataDir)) return;
mkdirSync(dataDir, { recursive: true, mode: 0o700 });
// `mode` is still filtered by the umask on some platforms; pin it explicitly.
try {
chmodSync(dataDir, 0o700);
} catch {
/* best-effort — see header */
}
}
/** Write a secrets file owner-only (0600), tightening it if it already existed. */
export function writePrivateFile(filePath, content) {
writeFileSync(filePath, content, { encoding: "utf-8", mode: 0o600 });
// `mode` only applies when the file is created — an existing 0664 file keeps its bits.
try {
chmodSync(filePath, 0o600);
} catch {
/* best-effort — see header */
}
}
/**
* Repair an existing install on startup: drop "other" access from the data dir and make the
* secrets files 0600. Group bits on the directory are left alone — user-private groups are
* the norm, and a deliberate group share must not break on upgrade; world access is what
* leaked. Never throws.
*/
export function tightenDataDirSecrets(dataDir) {
try {
if (!existsSync(dataDir)) return;
const current = statSync(dataDir).mode & 0o777;
if (current & 0o007) chmodSync(dataDir, current & ~0o007);
} catch {
/* best-effort — see header */
}
for (const name of SECRET_FILES) {
try {
const filePath = join(dataDir, name);
if (existsSync(filePath)) chmodSync(filePath, 0o600);
} catch {
/* best-effort — see header */
}
}
}

View File

@@ -90,6 +90,10 @@ export class ServerSupervisor {
cwd: dirname(this.serverPath),
env: this.env,
stdio: showLog ? "inherit" : ["ignore", "pipe", "pipe"],
// Tray mode has no visible console. Keep the supervised server hidden on Windows,
// including when it is restarted after a crash. Without this, each supervised
// spawn can create a visible terminal window.
windowsHide: true,
});
writePidFile("server", this.child.pid);

View File

@@ -23,8 +23,8 @@ export function computeRestartDelayMs(restartCount) {
return Math.min(1000 * 2 ** (Math.max(1, restartCount) - 1), 10_000);
}
/** Resolve true when nothing is listening on `port` (so a restart won't hit EADDRINUSE). */
export function isPortFree(port, host = "127.0.0.1") {
/** Resolve true when `host:port` can be bound right now. */
function canBind(port, host) {
return new Promise((resolve) => {
const tester = net.createServer();
tester.once("error", (err) => {
@@ -38,6 +38,28 @@ export function isPortFree(port, host = "127.0.0.1") {
});
}
/**
* Resolve true when nothing is listening on `port` (so a restart won't hit EADDRINUSE).
*
* Probing a single address is not enough. Node sets `SO_REUSEADDR` on every listener it
* creates, and on macOS/BSD that lets a specific-address bind coexist with an existing
* wildcard bind (and vice versa) — unlike Linux, which keeps rejecting the overlap in
* LISTEN state. A gateway listening on `0.0.0.0:20128`, which is what `omniroute serve`
* binds by default, was therefore reported as "free" by the old loopback-only probe: the
* supervisor skipped its wait, the respawned child hit EADDRINUSE, and the crash loop
* that #4425 set out to fix kept running. Probe every address the server may have bound.
*
* @param port Port to test.
* @param host Additional address to test; wildcard and loopback are always included.
* @returns False as soon as any candidate address is occupied.
*/
export async function isPortFree(port, host = "127.0.0.1") {
for (const candidate of new Set([host, "0.0.0.0", "127.0.0.1"])) {
if (!(await canBind(port, candidate))) return false;
}
return true;
}
/**
* #4425: wait until `port` is free before respawning. After a crash the OS may not have
* released the listen socket yet; restarting immediately produced the EADDRINUSE cascade

View File

@@ -13,11 +13,13 @@ const LINUX_DESKTOP_NAME = "omniroute.desktop";
function resolveCliPath() {
const candidates = [];
if (process.argv[1]) candidates.push(process.argv[1]);
try {
const which = execSync("command -v omniroute 2>/dev/null", { encoding: "utf8" }).trim();
if (which) candidates.push(which);
} catch {
// command -v unavailable
if (process.platform !== "win32") {
try {
const which = execSync("command -v omniroute 2>/dev/null", { encoding: "utf8" }).trim();
if (which) candidates.push(which);
} catch {
// command -v unavailable
}
}
candidates.push(join(dirname(fileURLToPath(import.meta.url)), "..", "..", "omniroute.mjs"));

View File

@@ -29,7 +29,9 @@ async function loadSystray2() {
function getIconBase64() {
// Icon ships at bin/cli/tray/icon.png — the previous "icons/icon.png" path
// never existed, so the tray was created with an empty icon (#4605).
const iconPath = join(__dirname, "icon.png");
// systray2 expects an ICO payload on Windows; the PNG asset is used elsewhere.
// (ported from #13991, credit @prabhtheone)
const iconPath = join(__dirname, process.platform === "win32" ? "icon.ico" : "icon.png");
if (existsSync(iconPath)) return readFileSync(iconPath).toString("base64");
return "";
}

View File

@@ -59,10 +59,81 @@ export function isPidRunning(pid) {
}
}
// A port that is already owned must be reported, not spawned into. `omniroute
// serve` used to hand the conflict to the child, which died with EADDRINUSE
// twice on the supervisor's restart budget and printed three raw Node stack
// traces without ever saying another instance owned the port. It did that
// AFTER writing the pid files, so the doomed second instance de-registered the
// healthy running one (supervisor/.pid left pointing at the dead starter,
// server/.pid deleted outright).
//
// Discovery mirrors killByPort() in bin/cli/commands/stop.mjs (netstat on
// win32, lsof elsewhere); the two are worth consolidating next time stop.mjs
// is touched.
export async function findListeningPids(port, deps = {}) {
const platform = deps.platform || process.platform;
let exec = deps.execFileAsync;
if (!exec) {
const { execFile } = await import("node:child_process");
const { promisify } = await import("node:util");
exec = promisify(execFile);
}
try {
if (platform === "win32") {
const { stdout } = await exec("netstat", ["-ano"]);
return parseNetstatListeningPids(stdout, port);
}
const { stdout } = await exec("lsof", ["-ti", `:${port}`]);
return stdout
.trim()
.split("\n")
.map((entry) => parseInt(entry, 10))
.filter((entry) => Number.isFinite(entry) && entry > 0);
} catch {
// No netstat/lsof available, or simply no listener. Report "free": a false
// "busy" would block a legitimate start, the worse failure of the two.
return [];
}
}
function parseNetstatListeningPids(stdout, port) {
const portCol = `:${port}`;
const pids = [];
for (const line of stdout.split(/\r?\n/)) {
const cols = line.trim().split(/\s+/);
// Proto LocalAddress ForeignAddress State PID
if (cols.length < 5) continue;
if (cols[0] !== "TCP" && cols[0] !== "TCPv6") continue;
if (!(cols[1] || "").endsWith(portCol)) continue;
if ((cols[cols.length - 2] || "").toUpperCase() !== "LISTENING") continue;
const pid = parseInt(cols[cols.length - 1], 10);
if (Number.isFinite(pid) && pid > 0 && !pids.includes(pid)) pids.push(pid);
}
return pids;
}
export function sleep(ms) {
return new Promise((resolve) => setTimeout(resolve, ms));
}
// A probe that times out is classified "hanging" and never counts toward
// readiness (#6800), so a FIXED per-probe timeout puts a hard ceiling on how
// slow a healthy first response is allowed to be. On a cold Windows boot the
// health route resolves ~10 dynamic imports and reads the DB before it can
// answer; when that first response lands past the ceiling the poll can never
// succeed, because each abort discards the in-flight request before the route
// finishes (its own 1s payload cache is never populated either) and the next
// probe restarts the same work into the same ceiling — for the whole budget.
// The CLI then printed "⚠ Server did not respond within 60s" over a server
// that went on to serve traffic normally. Escalating the timeout keeps #6800's
// guarantee (a socket that never answers still yields "hanging" forever) while
// letting a slow-but-real response actually be observed.
const INITIAL_PROBE_TIMEOUT_MS = 2000;
const MAX_PROBE_TIMEOUT_MS = 15000;
// Floor for the last probe of a budget that is nearly spent — long enough for a
// loopback round-trip, short enough not to overrun the caller's timeout.
const MIN_PROBE_TIMEOUT_MS = 250;
// #2460: Default raised from 15s to 60s so Windows users (slower Next.js
// cold start due to filesystem watchers, antivirus, etc.) get a working
// "server ready" signal instead of a phantom timeout while the server is
@@ -83,18 +154,24 @@ export function resolveReadyTimeoutMs(overrides = {}) {
if (typeof overrides.timeoutMs === "number" && overrides.timeoutMs > 0) {
return overrides.timeoutMs;
}
const envValue = Number.parseInt(
process.env.OMNIROUTE_READY_TIMEOUT_MS || "",
10
);
const envValue = Number.parseInt(process.env.OMNIROUTE_READY_TIMEOUT_MS || "", 10);
return Number.isFinite(envValue) && envValue > 0 ? envValue : DEFAULT_READY_TIMEOUT_MS;
}
export async function waitForServer(port, timeout = 60000) {
// `onOutcome` receives every probe classification so a caller can tell a
// "nothing ever bound the port" timeout apart from a "port is up, the health
// route is just still warming" one when it reports the failure.
export async function waitForServer(port, timeout = 60000, { onOutcome } = {}) {
const start = Date.now();
let tcpListeningSince = null;
let probeTimeout = INITIAL_PROBE_TIMEOUT_MS;
while (Date.now() - start < timeout) {
const outcome = await pollHealthOnce(port);
const remaining = timeout - (Date.now() - start);
const outcome = await pollHealthOnce(
port,
Math.max(MIN_PROBE_TIMEOUT_MS, Math.min(probeTimeout, remaining))
);
onOutcome?.(outcome);
if (outcome === "ready") return true;
if (outcome === "fast-reject") {
if (tcpListeningSince === null) tcpListeningSince = Date.now();
@@ -103,6 +180,11 @@ export async function waitForServer(port, timeout = 60000) {
// "hanging" (request timed out with no response at all) or
// "not-listening" — neither counts toward the grace window.
tcpListeningSince = null;
// Only a hang says "this server may simply need longer to answer";
// widen the next probe instead of aborting into the same ceiling again.
if (outcome === "hanging") {
probeTimeout = Math.min(probeTimeout * 2, MAX_PROBE_TIMEOUT_MS);
}
}
await sleep(500);
}
@@ -115,11 +197,13 @@ export async function waitForServer(port, timeout = 60000) {
// actively refused/reset (not a timeout) — the HTTP server is alive and
// answering quickly, just not routing this endpoint yet (#2460).
// - "hanging": the request timed out waiting for any response — the
// process accepted the TCP connection but never answered (#6800).
// process accepted the TCP connection but never answered (#6800). The
// caller widens `probeTimeoutMs` after a hang so a merely slow (rather
// than dead) server is not aborted into the same ceiling on every probe.
// - "not-listening": nothing is accepting connections on the port at all.
// #11766: probe both IPv4 and IPv6 loopback to handle servers listening on
// either family (or both).
async function pollHealthOnce(port) {
async function pollHealthOnce(port, probeTimeoutMs = INITIAL_PROBE_TIMEOUT_MS) {
const hosts = ["127.0.0.1", "::1"];
const outcomes = [];
@@ -128,7 +212,7 @@ async function pollHealthOnce(port) {
hosts.map(async (host) => {
try {
const res = await fetch(`http://${host}:${port}/api/monitoring/health`, {
signal: AbortSignal.timeout(2000),
signal: AbortSignal.timeout(probeTimeoutMs),
});
return { host, outcome: res.ok ? "ready" : "fast-reject" };
} catch (err) {

View File

@@ -14,7 +14,7 @@
* All other commands are routed through Commander (bin/cli/program.mjs).
*/
import { existsSync, readFileSync, writeFileSync } from "node:fs";
import { existsSync, readFileSync } from "node:fs";
import { join, dirname } from "node:path";
import { fileURLToPath, pathToFileURL } from "node:url";
let updateNotifier = null;
@@ -30,6 +30,11 @@ import { shouldProvisionStorageKey } from "./cli/utils/storageKeyProvision.mjs";
import { isVersionFastPath } from "./cli/utils/versionFastPath.mjs";
import { parseEnvValue } from "./cli/utils/parseEnvValue.mjs";
import { describeVolatileEnvWarning } from "./cli/utils/volatileEnvPath.mjs";
import {
ensurePrivateDataDir,
tightenDataDirSecrets,
writePrivateFile,
} from "./cli/privateDataDir.mjs";
const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);
@@ -119,7 +124,7 @@ function migrateElectronServerEnv(dataDir) {
const envPath = join(dataDir, ".env");
const serverEnvPath = join(dataDir, "server.env");
if (existsSync(envPath) || !existsSync(serverEnvPath)) return;
writeFileSync(envPath, readFileSync(serverEnvPath, "utf-8"), "utf-8");
writePrivateFile(envPath, readFileSync(serverEnvPath, "utf-8"));
console.log(` \x1b[2m♻ Migrated Electron secrets from ${serverEnvPath} to ${envPath}\x1b[0m`);
} catch {
// Ignore errors migrating server.env — fall back to normal env loading below.
@@ -229,10 +234,15 @@ loadEnvFile();
// mutate the data dir.
if (shouldProvisionStorageKey(process.argv)) {
const { randomBytes } = await import("node:crypto");
const { existsSync, mkdirSync, readFileSync, writeFileSync } = await import("node:fs");
const { existsSync, readFileSync } = await import("node:fs");
const { join } = await import("node:path");
const { homedir } = await import("node:os");
// GHSA-2pg2-xm9r-8544: installs created before the fix have a world-readable .env and a
// world-traversable data dir. Repair them on every run that touches encrypted storage
// (best-effort; group bits are kept). Informational commands never reach this block.
tightenDataDirSecrets(process.env.DATA_DIR || join(homedir(), ".omniroute"));
if (!process.env.STORAGE_ENCRYPTION_KEY) {
// Persist the key into DATA_DIR when set — that's the directory mounted as a volume in
// Docker (where storage.sqlite lives), so the key survives `docker down` / `docker pull`.
@@ -256,9 +266,8 @@ if (shouldProvisionStorageKey(process.argv)) {
);
} else {
// First run (no database yet) — generate and persist a fresh key.
if (!existsSync(dataDir)) {
mkdirSync(dataDir, { recursive: true });
}
// GHSA-2pg2-xm9r-8544: owner-only — .env holds the key to every stored credential.
ensurePrivateDataDir(dataDir);
const key = randomBytes(32).toString("hex");
@@ -272,7 +281,7 @@ if (shouldProvisionStorageKey(process.argv)) {
if (!content.includes("STORAGE_ENCRYPTION_KEY=")) {
const separator = content.trim() ? "\n" : "";
const newContent = content.trimEnd() + separator + `STORAGE_ENCRYPTION_KEY=${key}`;
writeFileSync(envPath, newContent + "\n", "utf-8");
writePrivateFile(envPath, newContent + "\n");
console.log(` \x1b[2m✨ Generated STORAGE_ENCRYPTION_KEY in ${envPath}\x1b[0m`);
}

View File

@@ -0,0 +1 @@
- **feat(providers):** add Lyceum (lyceum.technology) as an OpenAI-compatible, pay-per-use provider — chat, embeddings, and live `/models` discovery through `https://api.lyceum.technology/openai/v1`, plus a credit-balance quota fetcher (`GET /api/v2/external/billing/credits`) surfaced in Dashboard Limits and quota-aware preflight ([#12470](https://github.com/diegosouzapw/OmniRoute/pull/12470)).

View File

@@ -1 +0,0 @@
- **feat(sse):** parse/scrub DSML tool-call markers embedded in reasoning and recognize adaptive thinking on the response side — `dsmlToolCalls.ts` module + translator/stream/handler wiring ([#12905](https://github.com/diegosouzapw/OmniRoute/pull/12905)) — thanks @initguru

View File

@@ -1 +0,0 @@
- **feat(sse):** `OMNIROUTE_DISABLE_CONVERSATION_TRACKING=1` turns off conversation-history collection for operators who do not use the dashboard's conversation view. `resolveConversationId()` returns an untracked result before it reads SQLite or parses message history, and the switch also covers client-supplied session IDs. Routing-session handling is unchanged, tracking stays on by default, and existing records are not deleted. One reporting install held 5.97 million turn records at about 4.26 GB ([#13150](https://github.com/diegosouzapw/OmniRoute/pull/13150))

View File

@@ -1 +0,0 @@
- **feat(usage):** Claude OAuth usage now shows the separate weekly Fable limit next to the shared five-hour and weekly meters. Anthropic reports it as a `weekly_scoped` entry in `limits[]`, which OmniRoute ignored, so the pool was invisible. The provider-limits cache keeps `modelQuotas` and restores it on stale-data fallback. The Fable meter is display-only and does not affect routing, account selection, or cooldowns ([#13266](https://github.com/diegosouzapw/OmniRoute/pull/13266))

View File

@@ -1 +0,0 @@
- **feat(reasoning):** adaptive reasoning effort (`auto`) — the gateway resolves the thinking budget per user turn from deterministic request-shape signals (stateless per-turn pin) instead of forwarding a literal `auto`, applied at the gateway pre-translation for any harness (Claude Code, Cursor, Codex, opencode, Hermes) whose request dispatches to an OpenAI Chat-Completions-shaped upstream (`targetFormat === FORMATS.OPENAI``reasoning_effort` is an OpenAI-shaped field, so a Claude- or Gemini-targeted request is unaffected). Opt in via `X-OmniRoute-Effort: auto` or a model's `defaultReasoningEffort: "auto"` (now a valid `ModelSpec` value); any explicit client reasoning field always wins ([#13448](https://github.com/diegosouzapw/OmniRoute/pull/13448))

View File

@@ -1 +0,0 @@
- **feat(dashboard):** Add a dedicated, full-width API-key routing editor with explicit model/combo choices, searchable selectors and protection for unsaved rule drafts. ([#13555](https://github.com/diegosouzapw/OmniRoute/pull/13555)) — thanks @JxnLexn

View File

@@ -1 +0,0 @@
- **feat(proxies):** proxy pools and opencode's per-account rotation stop re-serving a proxy that just failed (refused TCP probe, or a 429 through it) for a period that doubles on each repeat up to a cap, without writing any proxy status; with every candidate set aside the choice is unchanged. Opt-in via the `PROXY_SKIP_RECENTLY_FAILED` feature flag (default off: selection unchanged) ([#13578](https://github.com/diegosouzapw/OmniRoute/pull/13578)) — thanks @maxmad64bis

View File

@@ -1 +0,0 @@
- **feat(proxy-logs):** proxy log rows keep the HTTP status the provider actually returned (`upstream_status`, null when no response arrived), so a throttled egress IP (429), a refused one (403) and a provider outage (500) are no longer the same "error" line, and a 429 generated locally is no longer mistaken for one from the provider ([#13580](https://github.com/diegosouzapw/OmniRoute/pull/13580)) — thanks @maxmad64bis

View File

@@ -1 +0,0 @@
- **feat(proxies):** the proxy pool editor shows, for the last 24 h, how many distinct egress IPs actually served the pool's members, how many connections went through them and the most seen behind one IP, read from the proxy log through a separate route so it can never break the pool screen; opt-in with the `PROXY_POOL_EGRESS_OBSERVATION` feature flag (default off) ([#13581](https://github.com/diegosouzapw/OmniRoute/pull/13581)) — thanks @maxmad64bis

View File

@@ -1 +0,0 @@
- **feat(proxies):** a proxy pool stops re-serving a member the provider just refused through it and tries another member instead, reusing the existing skip cooldown; a later success through the member clears it. Opt-in with the `PROXY_SKIP_RECENTLY_FAILED` feature flag (default off: pool selection unchanged) ([#13602](https://github.com/diegosouzapw/OmniRoute/pull/13602)) — thanks @maxmad64bis

View File

@@ -1 +0,0 @@
- **feat(flags):** add `DB_HEALTHCHECK_STARTUP_DEFERRED_ENABLED` (default off) — opt-in deferral of the startup DB health/integrity check past process boot via `setImmediate`; off keeps the pre-#13717 behavior of blocking `getDbInstance()` until the check has already run (#13717).

View File

@@ -1 +0,0 @@
- **feat(i18n):** 7 new locales — Hausa (`ha`), Yoruba (`yo`), Igbo (`ig`), Amharic (`am`), Uzbek (`uz`), Georgian (`ka`), Armenian (`hy`) — across the dashboard, docs mirrors, CLI, README and the site (66 locales, the full planned expansion from 43). (#13727)

View File

@@ -1 +0,0 @@
- Expose combo wall-clock timeout (`comboTimeoutMs`) next to Target timeout in the combo editor and Combo defaults. Empty keeps the 10-minute hang-stop; a positive value replaces it. ([#13857](https://github.com/diegosouzapw/OmniRoute/pull/13857))

Some files were not shown because too many files have changed in this diff Show More