From 891cb26b2cc647333893aed32c72a897dd184459 Mon Sep 17 00:00:00 2001 From: Koosha Paridehpour <42529354+KooshaPari@users.noreply.github.com> Date: Fri, 4 Sep 2026 22:33:16 -0700 Subject: [PATCH 01/18] fix(db): back-fill last_ping_at + last_pinged_reset_key on provider_connections (#12470) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Merged. Clean, surgical fix with its own regression guard. `ensureProviderConnectionsColumns()` reconciles the base columns that later data migrations assume, but `last_ping_at` / `last_pinged_reset_key` were only ever created by `123_quota_auto_ping` — so a lineage that skipped it kept a table that the quota auto-ping writes cannot target. Adding them to the reconciliation list is exactly the right place. Validated on `release/v3.8.51`: `tests/unit/db-schema-columns-split.test.ts` 10/10, including your new `back-fills last_ping columns on a pre-123 lineage` case and the idempotency re-run. `typecheck:core` clean, `check-file-size` OK. The `changelog.d/fixes/` fragment was already correct. Thank you — this is the shape a fix should have: root cause named, minimal diff, test that fails without it. --- .../fixes/12470-last-ping-at-backfill.md | 1 + src/lib/db/schemaColumns.ts | 2 ++ tests/unit/db-schema-columns-split.test.ts | 19 +++++++++++++++++++ 3 files changed, 22 insertions(+) create mode 100644 changelog.d/fixes/12470-last-ping-at-backfill.md diff --git a/changelog.d/fixes/12470-last-ping-at-backfill.md b/changelog.d/fixes/12470-last-ping-at-backfill.md new file mode 100644 index 0000000000..09d45f4acd --- /dev/null +++ b/changelog.d/fixes/12470-last-ping-at-backfill.md @@ -0,0 +1 @@ +- **fix(db):** back-fill `last_ping_at` and `last_pinged_reset_key` on `provider_connections` during schema reconciliation so divergent lineages that skipped `123_quota_auto_ping` still accept quota auto-ping writes ([#12470](https://github.com/diegosouzapw/OmniRoute/pull/12470) — thanks @KooshaPari) diff --git a/src/lib/db/schemaColumns.ts b/src/lib/db/schemaColumns.ts index 2b58470948..068c140c69 100644 --- a/src/lib/db/schemaColumns.ts +++ b/src/lib/db/schemaColumns.ts @@ -27,6 +27,8 @@ export function ensureProviderConnectionsColumns(db: SqliteDatabase) { ["rate_limit_protection", "INTEGER DEFAULT 0"], ["last_used_at", "TEXT"], ["default_model", "TEXT"], // legacy-schema hole; later data migrations read it + ["last_ping_at", "TEXT"], // added by 123_quota_auto_ping; back-filled here for divergent lineages + ["last_pinged_reset_key", "TEXT"], // added by 123_quota_auto_ping; back-filled here for divergent lineages ]) { if (!columnNames.has(column)) { db.exec(`ALTER TABLE provider_connections ADD COLUMN ${column} ${type}`); diff --git a/tests/unit/db-schema-columns-split.test.ts b/tests/unit/db-schema-columns-split.test.ts index 9adebe3b93..9e7e249a6a 100644 --- a/tests/unit/db-schema-columns-split.test.ts +++ b/tests/unit/db-schema-columns-split.test.ts @@ -136,6 +136,8 @@ test("ensureProviderConnectionsColumns restores base columns required by later m assert.equal(hasColumn(db, "provider_connections", "provider_specific_data"), true); assert.equal(hasColumn(db, "provider_connections", "default_model"), true); + assert.equal(hasColumn(db, "provider_connections", "last_ping_at"), true); + assert.equal(hasColumn(db, "provider_connections", "last_pinged_reset_key"), true); const columnsAfterFirstRun = getTableColumns(db, "provider_connections").sort(); const indexesAfterFirstRun = ( db.prepare("PRAGMA index_list(provider_connections)").all() as Array<{ name: string }> @@ -163,3 +165,20 @@ test("ensureProviderConnectionsColumns restores base columns required by later m db.close?.(); } }); + +test("ensureProviderConnectionsColumns back-fills last_ping columns on a pre-123 lineage", () => { + const db = openMemoryDb(); + try { + db.exec("CREATE TABLE provider_connections (id TEXT PRIMARY KEY, provider TEXT NOT NULL)"); + assert.equal(hasColumn(db, "provider_connections", "last_ping_at"), false); + assert.equal(hasColumn(db, "provider_connections", "last_pinged_reset_key"), false); + + ensureProviderConnectionsColumns(db); + + assert.equal(hasColumn(db, "provider_connections", "last_ping_at"), true); + assert.equal(hasColumn(db, "provider_connections", "last_pinged_reset_key"), true); + assert.doesNotThrow(() => ensureProviderConnectionsColumns(db)); + } finally { + db.close?.(); + } +}); From 82f78b3b3b8cdb861f5bc3d804494dbd51687bb3 Mon Sep 17 00:00:00 2001 From: Koosha Paridehpour <42529354+KooshaPari@users.noreply.github.com> Date: Fri, 4 Sep 2026 22:33:33 -0700 Subject: [PATCH 02/18] fix(api/pricing): surface validation error message as string, not raw object (#12494) (#12771) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Merged, with one adjustment. Confirmed the bug end to end: `PricingTab.tsx:369` types the payload as `{ error?: string }` and feeds it to `new Error(errorPayload.error || ...)`, so the `{ message, details }` object landed in the toast as `[object Object]` — exactly what #12494 reported. The one change I made before merging: `validation.error.message` is the fixed constant `"Invalid request"` (see `validateBody` in `src/shared/validation/helpers.ts:44`), so it would have swapped an unreadable toast for an uninformative one. The repo already has `formatValidationMessage()`, added in #10849 for precisely this case — it returns `"field: reason"` naming the first offending field. Merged with that instead, so a bad pricing value now says which field it was. Validated on `release/v3.8.51`: `typecheck:core` clean, `check-file-size` OK. Rebased onto the release branch — the PR was cut from `main`, which is ~3695 commits behind the active branch. Thank you for the report and the fix. --- src/app/api/pricing/route.ts | 16 ++++++++++++++-- 1 file changed, 14 insertions(+), 2 deletions(-) diff --git a/src/app/api/pricing/route.ts b/src/app/api/pricing/route.ts index e1849e672b..e062ef3530 100644 --- a/src/app/api/pricing/route.ts +++ b/src/app/api/pricing/route.ts @@ -8,7 +8,11 @@ import { resetAllPricing, } from "@/lib/db/settings"; import { updatePricingSchema } from "@/shared/validation/schemas"; -import { isValidationFailure, validateBody } from "@/shared/validation/helpers"; +import { + formatValidationMessage, + isValidationFailure, + validateBody, +} from "@/shared/validation/helpers"; /** * GET /api/pricing @@ -59,7 +63,15 @@ export async function PATCH(request) { try { const validation = validateBody(updatePricingSchema, rawBody); if (isValidationFailure(validation)) { - return NextResponse.json({ error: validation.error }, { status: 400 }); + // #12494: PricingTab reads this payload as `{ error?: string }` and feeds it + // straight to `new Error(...)`, so handing back the `{ message, details }` + // object rendered as "Falha ao salvar preços: [object Object]". Send a string. + // `formatValidationMessage` names the offending field ("field: reason") instead + // of the bare "Invalid request" constant, so the toast stays actionable (#10849). + return NextResponse.json( + { error: formatValidationMessage(validation.error) }, + { status: 400 } + ); } const body = validation.data; From 366099a08cadf8789437e95355238807ac831eb6 Mon Sep 17 00:00:00 2001 From: Koosha Paridehpour <42529354+KooshaPari@users.noreply.github.com> Date: Fri, 4 Sep 2026 22:33:37 -0700 Subject: [PATCH 03/18] fix(i18n): quote placeholder in OMNIROUTE_AUTO_SYNC_CLAUDE_PROFILES description (#12505) (#12769) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Merged, with the fix moved to where the bug actually lives — and thank you, because the issue analysis in #12505 is what made that possible. The diagnosis was right: `FeatureFlagsGrid.tsx:422-428` renders descriptions through a plain `t()`, so next-intl compiles the value as ICU and a bare `` parses as an unknown rich-text tag. But the branch changed `src/shared/constants/featureFlagDefinitions.ts` — the TS default, which is the `flag.description` **fallback** rendered raw, never through ICU. Two consequences: the reported bug stayed live (all 42 locale files still carried the raw tag — `grep -l "profiles//settings.json" src/i18n/messages/*.json` returned 42, and 0 for the escaped form), and the quotes would have shown up literally in the one place that string does render. So this merge reverts the TS default to the raw path and applies the ICU escape to the 42 locale files instead — follow-up 1 from your issue, inverted to hit the file that matters. I also added follow-up 2 as a real guard: `tests/unit/feature-flag-description-icu-parse-12505.test.ts` compiles every `featureFlags.definitions.*` message in every locale through `intl-messageformat` (the parser next-intl uses) and asserts the placeholder renders as a literal ``. Verified red-then-green — reverting `en.json` alone fails both cases; restored, 2/2 pass. Validated on `release/v3.8.51`: all locale files re-parse as valid JSON, `typecheck:core` clean, `check-file-size` OK. `i18n:check` drift is pre-existing on the tip, unrelated. Closes #12505. --- src/i18n/messages/ar.json | 2 +- src/i18n/messages/az.json | 2 +- src/i18n/messages/bg.json | 2 +- src/i18n/messages/bn.json | 2 +- src/i18n/messages/cs.json | 2 +- src/i18n/messages/da.json | 2 +- src/i18n/messages/de.json | 2 +- src/i18n/messages/en.json | 2 +- src/i18n/messages/es.json | 2 +- src/i18n/messages/fa.json | 2 +- src/i18n/messages/fi.json | 2 +- src/i18n/messages/fr.json | 2 +- src/i18n/messages/gu.json | 2 +- src/i18n/messages/he.json | 2 +- src/i18n/messages/hi.json | 2 +- src/i18n/messages/hu.json | 2 +- src/i18n/messages/id.json | 2 +- src/i18n/messages/it.json | 2 +- src/i18n/messages/ja.json | 2 +- src/i18n/messages/ko.json | 2 +- src/i18n/messages/mr.json | 2 +- src/i18n/messages/ms.json | 2 +- src/i18n/messages/nl.json | 2 +- src/i18n/messages/no.json | 2 +- src/i18n/messages/phi.json | 2 +- src/i18n/messages/pl.json | 2 +- src/i18n/messages/pt-BR.json | 2 +- src/i18n/messages/pt.json | 2 +- src/i18n/messages/ro.json | 2 +- src/i18n/messages/ru.json | 2 +- src/i18n/messages/sk.json | 2 +- src/i18n/messages/sv.json | 2 +- src/i18n/messages/sw.json | 2 +- src/i18n/messages/ta.json | 2 +- src/i18n/messages/te.json | 2 +- src/i18n/messages/th.json | 2 +- src/i18n/messages/tr.json | 2 +- src/i18n/messages/uk-UA.json | 2 +- src/i18n/messages/ur.json | 2 +- src/i18n/messages/vi.json | 2 +- src/i18n/messages/zh-CN.json | 2 +- src/i18n/messages/zh-TW.json | 2 +- ...e-flag-description-icu-parse-12505.test.ts | 68 +++++++++++++++++++ 43 files changed, 110 insertions(+), 42 deletions(-) create mode 100644 tests/unit/feature-flag-description-icu-parse-12505.test.ts diff --git a/src/i18n/messages/ar.json b/src/i18n/messages/ar.json index 47efa8dc95..0f44ff6a8d 100644 --- a/src/i18n/messages/ar.json +++ b/src/i18n/messages/ar.json @@ -12982,7 +12982,7 @@ "description": "بعد مزامنة المزود والنموذج، أعد إنشاء ملفات التعريف ~/.codex/*.config.toml من الكتالوج المباشر. لا يغير هذا أبداً تكوين Codex النشط أو الافتراضي ويكون معطلاً بشكل افتراضي." }, "OMNIROUTE_AUTO_SYNC_CLAUDE_PROFILES": { - "description": "بعد مزامنة المزود والنموذج، أعد إنشاء ملفات التعريف ~/.claude/profiles//settings.json من الكتالوج المباشر. لا يغير هذا أبداً تكوين Claude النشط أو الافتراضي ويكون معطلاً بشكل افتراضي." + "description": "بعد مزامنة المزود والنموذج، أعد إنشاء ملفات التعريف ~/.claude/profiles/''/settings.json من الكتالوج المباشر. لا يغير هذا أبداً تكوين Claude النشط أو الافتراضي ويكون معطلاً بشكل افتراضي." }, "OMNIROUTE_DISABLE_LOCAL_HEALTHCHECK": { "description": "تعطيل نقطة نهاية فحص صحة المثيل المحلي." diff --git a/src/i18n/messages/az.json b/src/i18n/messages/az.json index 9f9efb9640..f054911477 100644 --- a/src/i18n/messages/az.json +++ b/src/i18n/messages/az.json @@ -12982,7 +12982,7 @@ "description": "Provayder-model sinxronizasiyasından sonra canlı kataloqdan ~/.codex/*.config.toml profillərini yenidən yaradın. Bu, heç vaxt aktiv və ya defolt Codex konfiqurasiyasını dəyişmir və defolt olaraq qapalıdır." }, "OMNIROUTE_AUTO_SYNC_CLAUDE_PROFILES": { - "description": "Provayder-model sinxronizasiyasından sonra canlı kataloqdan ~/.claude/profiles//settings.json profillərini yenidən yaradın. Bu, heç vaxt aktiv və ya defolt Claude konfiqurasiyasını dəyişmir və defolt olaraq qapalıdır." + "description": "Provayder-model sinxronizasiyasından sonra canlı kataloqdan ~/.claude/profiles/''/settings.json profillərini yenidən yaradın. Bu, heç vaxt aktiv və ya defolt Claude konfiqurasiyasını dəyişmir və defolt olaraq qapalıdır." }, "OMNIROUTE_DISABLE_LOCAL_HEALTHCHECK": { "description": "Yerli instansiyanın sağlamlıq yoxlaması son nöqtəsini sıradan çıxarın." diff --git a/src/i18n/messages/bg.json b/src/i18n/messages/bg.json index e442c29e0d..ced22ca308 100644 --- a/src/i18n/messages/bg.json +++ b/src/i18n/messages/bg.json @@ -12982,7 +12982,7 @@ "description": "След синхронизиране на доставчик-модел, регенериране на ~/.codex/*.config.toml профили от каталога на живо. Това никога не променя активната или подразбиращата се конфигурация на Codex и е изключено по подразбиране." }, "OMNIROUTE_AUTO_SYNC_CLAUDE_PROFILES": { - "description": "After provider-model synchronization, regenerate ~/.claude/profiles//settings.json profiles from the live catalog. This never changes the active or default Claude configuration and is off by default." + "description": "After provider-model synchronization, regenerate ~/.claude/profiles/''/settings.json profiles from the live catalog. This never changes the active or default Claude configuration and is off by default." }, "OMNIROUTE_DISABLE_LOCAL_HEALTHCHECK": { "description": "Деактивиране на крайната точка за проверка на състоянието на локалния екземпляр." diff --git a/src/i18n/messages/bn.json b/src/i18n/messages/bn.json index fb1e088781..f592a899d4 100644 --- a/src/i18n/messages/bn.json +++ b/src/i18n/messages/bn.json @@ -12982,7 +12982,7 @@ "description": "প্রোভাইডার-মডেল সিঙ্ক্রোনাইজেশনের পরে, লাইভ ক্যাটালগ থেকে ~/.codex/*.config.toml প্রোফাইলগুলি পুনরায় তৈরি করুন। এটি সক্রিয় বা ডিফল্ট Codex কনফিগারেশন কখনই পরিবর্তন করে না এবং ডিফল্টভাবে বন্ধ থাকে।" }, "OMNIROUTE_AUTO_SYNC_CLAUDE_PROFILES": { - "description": "প্রোভাইডার-মডেল সিঙ্ক্রোনাইজেশনের পরে, লাইভ ক্যাটালগ থেকে ~/.claude/profiles//settings.json প্রোফাইলগুলি পুনরায় তৈরি করুন। এটি সক্রিয় বা ডিফল্ট Claude কনফিগারেশন কখনই পরিবর্তন করে না এবং ডিফল্টভাবে বন্ধ থাকে।" + "description": "প্রোভাইডার-মডেল সিঙ্ক্রোনাইজেশনের পরে, লাইভ ক্যাটালগ থেকে ~/.claude/profiles/''/settings.json প্রোফাইলগুলি পুনরায় তৈরি করুন। এটি সক্রিয় বা ডিফল্ট Claude কনফিগারেশন কখনই পরিবর্তন করে না এবং ডিফল্টভাবে বন্ধ থাকে।" }, "OMNIROUTE_DISABLE_LOCAL_HEALTHCHECK": { "description": "স্থানীয় ইনস্ট্যান্স হেলথ-চেক এন্ডপয়েন্ট নিষ্ক্রিয় করুন।" diff --git a/src/i18n/messages/cs.json b/src/i18n/messages/cs.json index 14bd25e496..c2ef9a2d68 100644 --- a/src/i18n/messages/cs.json +++ b/src/i18n/messages/cs.json @@ -12982,7 +12982,7 @@ "description": "Po synchronizaci poskytovatelů a modelů regenerovat profily ~/.codex/*.config.toml z živého katalogu. Toto nikdy nemění aktivní nebo výchozí konfiguraci Codexu a je ve výchozím nastavení vypnuto." }, "OMNIROUTE_AUTO_SYNC_CLAUDE_PROFILES": { - "description": "Po synchronizaci poskytovatelů a modelů regenerovat profily ~/.claude/profiles//settings.json z živého katalogu. Toto nikdy nemění aktivní nebo výchozí konfiguraci Claude a je ve výchozím nastavení vypnuto." + "description": "Po synchronizaci poskytovatelů a modelů regenerovat profily ~/.claude/profiles/''/settings.json z živého katalogu. Toto nikdy nemění aktivní nebo výchozí konfiguraci Claude a je ve výchozím nastavení vypnuto." }, "OMNIROUTE_DISABLE_LOCAL_HEALTHCHECK": { "description": "Zakázat koncový bod kontroly stavu lokální instance." diff --git a/src/i18n/messages/da.json b/src/i18n/messages/da.json index 7309787fd2..2533f08ad3 100644 --- a/src/i18n/messages/da.json +++ b/src/i18n/messages/da.json @@ -12982,7 +12982,7 @@ "description": "Efter udbyder-model-synkronisering regenereres ~/.codex/*.config.toml-profiler fra det aktive katalog. Dette ændrer aldrig den aktive eller standardmæssige Codex-konfiguration og er deaktiveret som standard." }, "OMNIROUTE_AUTO_SYNC_CLAUDE_PROFILES": { - "description": "Efter udbyder-model-synkronisering regenereres ~/.claude/profiles//settings.json-profiler fra det aktive katalog. Dette ændrer aldrig den aktive eller standardmæssige Claude-konfiguration og er deaktiveret som standard." + "description": "Efter udbyder-model-synkronisering regenereres ~/.claude/profiles/''/settings.json-profiler fra det aktive katalog. Dette ændrer aldrig den aktive eller standardmæssige Claude-konfiguration og er deaktiveret som standard." }, "OMNIROUTE_DISABLE_LOCAL_HEALTHCHECK": { "description": "Deaktivér slutpunktet for den lokale instans' tilstandstjek." diff --git a/src/i18n/messages/de.json b/src/i18n/messages/de.json index 41ea050e7b..62e80a3082 100644 --- a/src/i18n/messages/de.json +++ b/src/i18n/messages/de.json @@ -12989,7 +12989,7 @@ "description": "Nach der Anbieter-Modell-Synchronisierung ~/.codex/*.config.toml-Profile aus dem Live-Katalog neu generieren. Dies ändert niemals die aktive oder Standard-Codex-Konfiguration und ist standardmäßig deaktiviert." }, "OMNIROUTE_AUTO_SYNC_CLAUDE_PROFILES": { - "description": "Nach der Anbieter-Modell-Synchronisierung ~/.claude/profiles//settings.json-Profile aus dem Live-Katalog neu generieren. Dies ändert niemals die aktive oder Standard-Claude-Konfiguration und ist standardmäßig deaktiviert." + "description": "Nach der Anbieter-Modell-Synchronisierung ~/.claude/profiles/''/settings.json-Profile aus dem Live-Katalog neu generieren. Dies ändert niemals die aktive oder Standard-Claude-Konfiguration und ist standardmäßig deaktiviert." }, "OMNIROUTE_DISABLE_LOCAL_HEALTHCHECK": { "description": "Den Health-Check-Endpunkt der lokalen Instanz deaktivieren." diff --git a/src/i18n/messages/en.json b/src/i18n/messages/en.json index ae4b3b28fe..6d89d67153 100644 --- a/src/i18n/messages/en.json +++ b/src/i18n/messages/en.json @@ -12992,7 +12992,7 @@ "description": "After provider-model synchronization, regenerate ~/.codex/*.config.toml profiles from the live catalog. This never changes the active or default Codex configuration and is off by default." }, "OMNIROUTE_AUTO_SYNC_CLAUDE_PROFILES": { - "description": "After provider-model synchronization, regenerate ~/.claude/profiles//settings.json profiles from the live catalog. This never changes the active or default Claude configuration and is off by default." + "description": "After provider-model synchronization, regenerate ~/.claude/profiles/''/settings.json profiles from the live catalog. This never changes the active or default Claude configuration and is off by default." }, "OMNIROUTE_DISABLE_LOCAL_HEALTHCHECK": { "description": "Disable the local instance health-check endpoint." diff --git a/src/i18n/messages/es.json b/src/i18n/messages/es.json index 348db0b960..6dc13a0fde 100644 --- a/src/i18n/messages/es.json +++ b/src/i18n/messages/es.json @@ -12982,7 +12982,7 @@ "description": "After provider-model synchronization, regenerate ~/.codex/*.config.toml profiles from the live catalog. This never changes the active or default Codex configuration and is off by default." }, "OMNIROUTE_AUTO_SYNC_CLAUDE_PROFILES": { - "description": "After provider-model synchronization, regenerate ~/.claude/profiles//settings.json profiles from the live catalog. This never changes the active or default Claude configuration and is off by default." + "description": "After provider-model synchronization, regenerate ~/.claude/profiles/''/settings.json profiles from the live catalog. This never changes the active or default Claude configuration and is off by default." }, "OMNIROUTE_DISABLE_LOCAL_HEALTHCHECK": { "description": "Disable the local instance health-check endpoint." diff --git a/src/i18n/messages/fa.json b/src/i18n/messages/fa.json index 0fa364611b..6f9678b74f 100644 --- a/src/i18n/messages/fa.json +++ b/src/i18n/messages/fa.json @@ -12982,7 +12982,7 @@ "description": "پس از همگام‌سازی ارائه‌دهنده-مدل، پروفایل‌های ~/.codex/*.config.toml را از کاتالوگ زنده بازسازی کنید. این کار هرگز پیکربندی فعال یا پیش‌فرض Codex را تغییر نمی‌دهد و به طور پیش‌فرض غیرفعال است." }, "OMNIROUTE_AUTO_SYNC_CLAUDE_PROFILES": { - "description": "پس از همگام‌سازی ارائه‌دهنده-مدل، پروفایل‌های ~/.claude/profiles//settings.json را از کاتالوگ زنده بازسازی کنید. این کار هرگز پیکربندی فعال یا پیش‌فرض Claude را تغییر نمی‌دهد و به طور پیش‌فرض غیرفعال است." + "description": "پس از همگام‌سازی ارائه‌دهنده-مدل، پروفایل‌های ~/.claude/profiles/''/settings.json را از کاتالوگ زنده بازسازی کنید. این کار هرگز پیکربندی فعال یا پیش‌فرض Claude را تغییر نمی‌دهد و به طور پیش‌فرض غیرفعال است." }, "OMNIROUTE_DISABLE_LOCAL_HEALTHCHECK": { "description": "غیرفعال کردن نقطه پایانی بررسی سلامت نمونه محلی." diff --git a/src/i18n/messages/fi.json b/src/i18n/messages/fi.json index e5a09ea7dc..b7a930e02c 100644 --- a/src/i18n/messages/fi.json +++ b/src/i18n/messages/fi.json @@ -12982,7 +12982,7 @@ "description": "Tarjoajamallien synkronoinnin jälkeen luo ~/.codex/*.config.toml -profiilit uudelleen reaaliaikaisesta luettelosta. Tämä ei koskaan muuta aktiivista tai oletusarvoista Codex-konfiguraatiota ja on oletuksena pois päältä." }, "OMNIROUTE_AUTO_SYNC_CLAUDE_PROFILES": { - "description": "Tarjoajamallien synkronoinnin jälkeen luo ~/.claude/profiles//settings.json -profiilit uudelleen reaaliaikaisesta luettelosta. Tämä ei koskaan muuta aktiivista tai oletusarvoista Claude-konfiguraatiota ja on oletuksena pois päältä." + "description": "Tarjoajamallien synkronoinnin jälkeen luo ~/.claude/profiles/''/settings.json -profiilit uudelleen reaaliaikaisesta luettelosta. Tämä ei koskaan muuta aktiivista tai oletusarvoista Claude-konfiguraatiota ja on oletuksena pois päältä." }, "OMNIROUTE_DISABLE_LOCAL_HEALTHCHECK": { "description": "Poista käytöstä paikallisen instanssin terveystarkistuksen päätepiste." diff --git a/src/i18n/messages/fr.json b/src/i18n/messages/fr.json index 48bcdae45a..f3cb535451 100644 --- a/src/i18n/messages/fr.json +++ b/src/i18n/messages/fr.json @@ -12982,7 +12982,7 @@ "description": "Après la synchronisation fournisseur-modèle, régénérer les profils ~/.codex/*.config.toml à partir du catalogue en direct. Cela ne modifie jamais la configuration Codex active ou par défaut et est désactivé par défaut." }, "OMNIROUTE_AUTO_SYNC_CLAUDE_PROFILES": { - "description": "Après la synchronisation fournisseur-modèle, régénérer les profils ~/.claude/profiles//settings.json à partir du catalogue en direct. Cela ne modifie jamais la configuration Claude active ou par défaut et est désactivé par défaut." + "description": "Après la synchronisation fournisseur-modèle, régénérer les profils ~/.claude/profiles/''/settings.json à partir du catalogue en direct. Cela ne modifie jamais la configuration Claude active ou par défaut et est désactivé par défaut." }, "OMNIROUTE_DISABLE_LOCAL_HEALTHCHECK": { "description": "Désactiver le point de terminaison de vérification de l'état de l'instance locale." diff --git a/src/i18n/messages/gu.json b/src/i18n/messages/gu.json index c9014de70b..abe98e5397 100644 --- a/src/i18n/messages/gu.json +++ b/src/i18n/messages/gu.json @@ -12982,7 +12982,7 @@ "description": "પ્રદાતા-મોડલ સિંક્રનાઇઝેશન પછી, લાઇવ કૅટેલોગમાંથી ~/.codex/*.config.toml પ્રોફાઇલ્સ ફરીથી જનરેટ કરો. આ ક્યારેય સક્રિય અથવા ડિફોલ્ટ Codex રૂપરેખાંકનને બદલતું નથી અને ડિફોલ્ટ રૂપે off હોય છે." }, "OMNIROUTE_AUTO_SYNC_CLAUDE_PROFILES": { - "description": "પ્રદાતા-મોડલ સિંક્રનાઇઝેશન પછી, લાઇવ કૅટેલોગમાંથી ~/.claude/profiles//settings.json પ્રોફાઇલ્સ ફરીથી જનરેટ કરો. આ ક્યારેય સક્રિય અથવા ડિફોલ્ટ Claude રૂપરેખાંકનને બદલતું નથી અને ડિફોલ્ટ રૂપે off હોય છે." + "description": "પ્રદાતા-મોડલ સિંક્રનાઇઝેશન પછી, લાઇવ કૅટેલોગમાંથી ~/.claude/profiles/''/settings.json પ્રોફાઇલ્સ ફરીથી જનરેટ કરો. આ ક્યારેય સક્રિય અથવા ડિફોલ્ટ Claude રૂપરેખાંકનને બદલતું નથી અને ડિફોલ્ટ રૂપે off હોય છે." }, "OMNIROUTE_DISABLE_LOCAL_HEALTHCHECK": { "description": "સ્થાનિક ઇન્સ્ટન્સ હેલ્થ-ચેક એન્ડપોઇન્ટ નિષ્ક્રિય કરો." diff --git a/src/i18n/messages/he.json b/src/i18n/messages/he.json index 8305333bb0..0c6c5933e7 100644 --- a/src/i18n/messages/he.json +++ b/src/i18n/messages/he.json @@ -12982,7 +12982,7 @@ "description": "לאחר סנכרון ספק-מודל, יצירה מחדש של פרופילי ~/.codex/*.config.toml מתוך הקטלוג הפעיל. פעולה זו אינה משנה לעולם את תצורת Codex הפעילה או כברירת מחדל, והיא כבויה כברירת מחדל." }, "OMNIROUTE_AUTO_SYNC_CLAUDE_PROFILES": { - "description": "לאחר סנכרון ספק-מודל, יצירה מחדש של פרופילי ~/.claude/profiles//settings.json מתוך הקטלוג הפעיל. פעולה זו אינה משנה לעולם את תצורת Claude הפעילה או כברירת מחדל, והיא כבויה כברירת מחדל." + "description": "לאחר סנכרון ספק-מודל, יצירה מחדש של פרופילי ~/.claude/profiles/''/settings.json מתוך הקטלוג הפעיל. פעולה זו אינה משנה לעולם את תצורת Claude הפעילה או כברירת מחדל, והיא כבויה כברירת מחדל." }, "OMNIROUTE_DISABLE_LOCAL_HEALTHCHECK": { "description": "השבתת נקודת הקצה לבדיקת תקינות של המופע המקומי." diff --git a/src/i18n/messages/hi.json b/src/i18n/messages/hi.json index 4a7226cbfe..0d761462ca 100644 --- a/src/i18n/messages/hi.json +++ b/src/i18n/messages/hi.json @@ -12982,7 +12982,7 @@ "description": "प्रदाता-मॉडल सिंक्रनाइज़ेशन के बाद, लाइव कैटलॉग से ~/.codex/*.config.toml प्रोफाइल को पुनरुत्पादित करें। यह सक्रिय या डिफ़ॉल्ट Codex कॉन्फ़िगरेशन को कभी नहीं बदलता है और डिफ़ॉल्ट रूप से बंद रहता है।" }, "OMNIROUTE_AUTO_SYNC_CLAUDE_PROFILES": { - "description": "प्रदाता-मॉडल सिंक्रनाइज़ेशन के बाद, लाइव कैटलॉग से ~/.claude/profiles//settings.json प्रोफाइल को पुनरुत्पादित करें। यह सक्रिय या डिफ़ॉल्ट Claude कॉन्फ़िगरेशन को कभी नहीं बदलता है और डिफ़ॉल्ट रूप से बंद रहता है।" + "description": "प्रदाता-मॉडल सिंक्रनाइज़ेशन के बाद, लाइव कैटलॉग से ~/.claude/profiles/''/settings.json प्रोफाइल को पुनरुत्पादित करें। यह सक्रिय या डिफ़ॉल्ट Claude कॉन्फ़िगरेशन को कभी नहीं बदलता है और डिफ़ॉल्ट रूप से बंद रहता है।" }, "OMNIROUTE_DISABLE_LOCAL_HEALTHCHECK": { "description": "स्थानीय इंस्टेंस स्वास्थ्य-जांच एंडपॉइंट को अक्षम करें।" diff --git a/src/i18n/messages/hu.json b/src/i18n/messages/hu.json index 99d95becd5..192aab7262 100644 --- a/src/i18n/messages/hu.json +++ b/src/i18n/messages/hu.json @@ -12982,7 +12982,7 @@ "description": "A szolgáltató-modell szinkronizálás után a ~/.codex/*.config.toml profilok újragenerálása az élő katalógusból. Ez soha nem változtatja meg az aktív vagy alapértelmezett Codex konfigurációt, és alapértelmezés szerint ki van kapcsolva." }, "OMNIROUTE_AUTO_SYNC_CLAUDE_PROFILES": { - "description": "A szolgáltató-modell szinkronizálás után a ~/.claude/profiles//settings.json profilok újragenerálása az élő katalógusból. Ez soha nem változtatja meg az aktív vagy alapértelmezett Claude konfigurációt, és alapértelmezés szerint ki van kapcsolva." + "description": "A szolgáltató-modell szinkronizálás után a ~/.claude/profiles/''/settings.json profilok újragenerálása az élő katalógusból. Ez soha nem változtatja meg az aktív vagy alapértelmezett Claude konfigurációt, és alapértelmezés szerint ki van kapcsolva." }, "OMNIROUTE_DISABLE_LOCAL_HEALTHCHECK": { "description": "A helyi példány állapotellenőrző végpontjának letiltása." diff --git a/src/i18n/messages/id.json b/src/i18n/messages/id.json index 6ac4539837..749c54878f 100644 --- a/src/i18n/messages/id.json +++ b/src/i18n/messages/id.json @@ -12982,7 +12982,7 @@ "description": "Setelah sinkronisasi model penyedia, buat ulang profil ~/.codex/*.config.toml dari katalog langsung. Ini tidak pernah mengubah konfigurasi Codex yang aktif atau default dan nonaktif secara default." }, "OMNIROUTE_AUTO_SYNC_CLAUDE_PROFILES": { - "description": "Setelah sinkronisasi model penyedia, buat ulang profil ~/.claude/profiles//settings.json dari katalog langsung. Ini tidak pernah mengubah konfigurasi Claude yang aktif atau default dan nonaktif secara default." + "description": "Setelah sinkronisasi model penyedia, buat ulang profil ~/.claude/profiles/''/settings.json dari katalog langsung. Ini tidak pernah mengubah konfigurasi Claude yang aktif atau default dan nonaktif secara default." }, "OMNIROUTE_DISABLE_LOCAL_HEALTHCHECK": { "description": "Nonaktifkan titik akhir health-check instans lokal." diff --git a/src/i18n/messages/it.json b/src/i18n/messages/it.json index 7bf00a4c77..548717ca56 100644 --- a/src/i18n/messages/it.json +++ b/src/i18n/messages/it.json @@ -12982,7 +12982,7 @@ "description": "Dopo la sincronizzazione provider-modello, rigenera i profili ~/.codex/*.config.toml dal catalogo live. Questa operazione non modifica mai la configurazione attiva o predefinita di Codex ed è disattivata per impostazione predefinita." }, "OMNIROUTE_AUTO_SYNC_CLAUDE_PROFILES": { - "description": "Dopo la sincronizzazione provider-modello, rigenera i profili ~/.claude/profiles//settings.json dal catalogo live. Questa operazione non modifica mai la configurazione attiva o predefinita di Claude ed è disattivata per impostazione predefinita." + "description": "Dopo la sincronizzazione provider-modello, rigenera i profili ~/.claude/profiles/''/settings.json dal catalogo live. Questa operazione non modifica mai la configurazione attiva o predefinita di Claude ed è disattivata per impostazione predefinita." }, "OMNIROUTE_DISABLE_LOCAL_HEALTHCHECK": { "description": "Disabilita l'endpoint di health check dell'istanza locale." diff --git a/src/i18n/messages/ja.json b/src/i18n/messages/ja.json index 0948496009..8ab091a78c 100644 --- a/src/i18n/messages/ja.json +++ b/src/i18n/messages/ja.json @@ -12982,7 +12982,7 @@ "description": "After provider-model synchronization, regenerate ~/.codex/*.config.toml profiles from the live catalog. This never changes the active or default Codex configuration and is off by default." }, "OMNIROUTE_AUTO_SYNC_CLAUDE_PROFILES": { - "description": "After provider-model synchronization, regenerate ~/.claude/profiles//settings.json profiles from the live catalog. This never changes the active or default Claude configuration and is off by default." + "description": "After provider-model synchronization, regenerate ~/.claude/profiles/''/settings.json profiles from the live catalog. This never changes the active or default Claude configuration and is off by default." }, "OMNIROUTE_DISABLE_LOCAL_HEALTHCHECK": { "description": "Disable the local instance health-check endpoint." diff --git a/src/i18n/messages/ko.json b/src/i18n/messages/ko.json index 63d0b70434..7a768219e7 100644 --- a/src/i18n/messages/ko.json +++ b/src/i18n/messages/ko.json @@ -12982,7 +12982,7 @@ "description": "공급자-모델 동기화 후 라이브 카탈로그에서 ~/.codex/*.config.toml 프로필을 재생성합니다. 이는 활성 또는 기본 Codex 구성을 변경하지 않으며 기본적으로 꺼져 있습니다." }, "OMNIROUTE_AUTO_SYNC_CLAUDE_PROFILES": { - "description": "공급자-모델 동기화 후 라이브 카탈로그에서 ~/.claude/profiles//settings.json 프로필을 재생성합니다. 이는 활성 또는 기본 Claude 구성을 변경하지 않으며 기본적으로 꺼져 있습니다." + "description": "공급자-모델 동기화 후 라이브 카탈로그에서 ~/.claude/profiles/''/settings.json 프로필을 재생성합니다. 이는 활성 또는 기본 Claude 구성을 변경하지 않으며 기본적으로 꺼져 있습니다." }, "OMNIROUTE_DISABLE_LOCAL_HEALTHCHECK": { "description": "로컬 인스턴스 상태 확인 엔드포인트를 비활성화합니다." diff --git a/src/i18n/messages/mr.json b/src/i18n/messages/mr.json index 140afbdda6..37934d34a8 100644 --- a/src/i18n/messages/mr.json +++ b/src/i18n/messages/mr.json @@ -12982,7 +12982,7 @@ "description": "प्रदाता-मॉडेल सिंक्रोनाइझेशननंतर, लाइव्ह कॅटलॉगमधून ~/.codex/*.config.toml प्रोफाइल्स पुन्हा तयार करा. हे सक्रिय किंवा डीफॉल्ट Codex कॉन्फिगरेशन कधीही बदलत नाही आणि डीफॉल्टनुसार बंद असते." }, "OMNIROUTE_AUTO_SYNC_CLAUDE_PROFILES": { - "description": "प्रदाता-मॉडेल सिंक्रोनाइझेशननंतर, लाइव्ह कॅटलॉगमधून ~/.claude/profiles//settings.json प्रोफाइल्स पुन्हा तयार करा. हे सक्रिय किंवा डीफॉल्ट Claude कॉन्फिगरेशन कधीही बदलत नाही आणि डीफॉल्टनुसार बंद असते." + "description": "प्रदाता-मॉडेल सिंक्रोनाइझेशननंतर, लाइव्ह कॅटलॉगमधून ~/.claude/profiles/''/settings.json प्रोफाइल्स पुन्हा तयार करा. हे सक्रिय किंवा डीफॉल्ट Claude कॉन्फिगरेशन कधीही बदलत नाही आणि डीफॉल्टनुसार बंद असते." }, "OMNIROUTE_DISABLE_LOCAL_HEALTHCHECK": { "description": "स्थानिक इन्स्टन्स हेल्थ-चेक एंडपॉइंट अक्षम करा." diff --git a/src/i18n/messages/ms.json b/src/i18n/messages/ms.json index 66e04377f7..8580748629 100644 --- a/src/i18n/messages/ms.json +++ b/src/i18n/messages/ms.json @@ -12982,7 +12982,7 @@ "description": "Selepas penyegerakan penyedia-model, jana semula profil ~/.codex/*.config.toml daripada katalog langsung. Ini tidak pernah mengubah konfigurasi Codex yang aktif atau lalai dan dinyahdayakan secara lalai." }, "OMNIROUTE_AUTO_SYNC_CLAUDE_PROFILES": { - "description": "Selepas penyegerakan penyedia-model, jana semula profil ~/.claude/profiles//settings.json daripada katalog langsung. Ini tidak pernah mengubah konfigurasi Claude yang aktif atau lalai dan dinyahdayakan secara lalai." + "description": "Selepas penyegerakan penyedia-model, jana semula profil ~/.claude/profiles/''/settings.json daripada katalog langsung. Ini tidak pernah mengubah konfigurasi Claude yang aktif atau lalai dan dinyahdayakan secara lalai." }, "OMNIROUTE_DISABLE_LOCAL_HEALTHCHECK": { "description": "Nyahdayakan titik akhir pemeriksaan kesihatan tika tempatan." diff --git a/src/i18n/messages/nl.json b/src/i18n/messages/nl.json index b16e43b7d8..bc13adaabe 100644 --- a/src/i18n/messages/nl.json +++ b/src/i18n/messages/nl.json @@ -12982,7 +12982,7 @@ "description": "Genereer na provider-modelsynchronisatie ~/.codex/*.config.toml-profielen opnieuw vanuit de live catalogus. Dit wijzigt nooit de actieve of standaard Codex-configuratie en is standaard uitgeschakeld." }, "OMNIROUTE_AUTO_SYNC_CLAUDE_PROFILES": { - "description": "Genereer na provider-modelsynchronisatie ~/.claude/profiles//settings.json-profielen opnieuw vanuit de live catalogus. Dit wijzigt nooit de actieve of standaard Claude-configuratie en is standaard uitgeschakeld." + "description": "Genereer na provider-modelsynchronisatie ~/.claude/profiles/''/settings.json-profielen opnieuw vanuit de live catalogus. Dit wijzigt nooit de actieve of standaard Claude-configuratie en is standaard uitgeschakeld." }, "OMNIROUTE_DISABLE_LOCAL_HEALTHCHECK": { "description": "Schakel het health-check-eindpunt van de lokale instantie uit." diff --git a/src/i18n/messages/no.json b/src/i18n/messages/no.json index 3e0aa602a7..c84dcf1a37 100644 --- a/src/i18n/messages/no.json +++ b/src/i18n/messages/no.json @@ -12982,7 +12982,7 @@ "description": "Etter synkronisering av leverandørmodell, regenerer ~/.codex/*.config.toml-profiler fra den aktive katalogen. Dette endrer aldri den aktive eller standard Codex-konfigurasjonen og er av som standard." }, "OMNIROUTE_AUTO_SYNC_CLAUDE_PROFILES": { - "description": "Etter synkronisering av leverandørmodell, regenerer ~/.claude/profiles//settings.json-profiler fra den aktive katalogen. Dette endrer aldri den aktive eller standard Claude-konfigurasjonen og er av som standard." + "description": "Etter synkronisering av leverandørmodell, regenerer ~/.claude/profiles/''/settings.json-profiler fra den aktive katalogen. Dette endrer aldri den aktive eller standard Claude-konfigurasjonen og er av som standard." }, "OMNIROUTE_DISABLE_LOCAL_HEALTHCHECK": { "description": "Deaktiver helsesjekk-endepunktet for den lokale instansen." diff --git a/src/i18n/messages/phi.json b/src/i18n/messages/phi.json index 282dc3c6f2..00a40118e9 100644 --- a/src/i18n/messages/phi.json +++ b/src/i18n/messages/phi.json @@ -12982,7 +12982,7 @@ "description": "Pagkatapos ng pag-synchronize ng provider-model, muling buuin ang mga profile ng ~/.codex/*.config.toml mula sa live catalog. Hindi nito kailanman binabago ang aktibo o default na configuration ng Codex at naka-off bilang default." }, "OMNIROUTE_AUTO_SYNC_CLAUDE_PROFILES": { - "description": "Pagkatapos ng pag-synchronize ng provider-model, muling buuin ang mga profile ng ~/.claude/profiles//settings.json mula sa live catalog. Hindi nito kailanman binabago ang aktibo o default na configuration ng Claude at naka-off bilang default." + "description": "Pagkatapos ng pag-synchronize ng provider-model, muling buuin ang mga profile ng ~/.claude/profiles/''/settings.json mula sa live catalog. Hindi nito kailanman binabago ang aktibo o default na configuration ng Claude at naka-off bilang default." }, "OMNIROUTE_DISABLE_LOCAL_HEALTHCHECK": { "description": "I-disable ang health-check endpoint ng lokal na instance." diff --git a/src/i18n/messages/pl.json b/src/i18n/messages/pl.json index 39b053ca78..680137f445 100644 --- a/src/i18n/messages/pl.json +++ b/src/i18n/messages/pl.json @@ -12982,7 +12982,7 @@ "description": "Po synchronizacji dostawców i modeli wygeneruj ponownie profile ~/.codex/*.config.toml z aktywnego katalogu. To nigdy nie zmienia aktywnej ani domyślnej konfiguracji Codex i jest domyślnie wyłączone." }, "OMNIROUTE_AUTO_SYNC_CLAUDE_PROFILES": { - "description": "Po synchronizacji dostawców i modeli wygeneruj ponownie profile ~/.claude/profiles//settings.json z aktywnego katalogu. To nigdy nie zmienia aktywnej ani domyślnej konfiguracji Claude i jest domyślnie wyłączone." + "description": "Po synchronizacji dostawców i modeli wygeneruj ponownie profile ~/.claude/profiles/''/settings.json z aktywnego katalogu. To nigdy nie zmienia aktywnej ani domyślnej konfiguracji Claude i jest domyślnie wyłączone." }, "OMNIROUTE_DISABLE_LOCAL_HEALTHCHECK": { "description": "Wyłącz punkt końcowy sprawdzania stanu lokalnej instancji." diff --git a/src/i18n/messages/pt-BR.json b/src/i18n/messages/pt-BR.json index c50d940f5e..c4c0bab8c4 100644 --- a/src/i18n/messages/pt-BR.json +++ b/src/i18n/messages/pt-BR.json @@ -12993,7 +12993,7 @@ "description": "Após a sincronização de modelos do provedor, regenera os perfis ~/.codex/*.config.toml a partir do catálogo ativo. Isso nunca altera a configuração ativa ou padrão do Codex e está desativado por padrão." }, "OMNIROUTE_AUTO_SYNC_CLAUDE_PROFILES": { - "description": "Após a sincronização de modelos do provedor, regenera os perfis ~/.claude/profiles//settings.json a partir do catálogo ativo. Isso nunca altera a configuração ativa ou padrão do Claude e está desativado por padrão." + "description": "Após a sincronização de modelos do provedor, regenera os perfis ~/.claude/profiles/''/settings.json a partir do catálogo ativo. Isso nunca altera a configuração ativa ou padrão do Claude e está desativado por padrão." }, "OMNIROUTE_DISABLE_LOCAL_HEALTHCHECK": { "description": "Desativa o endpoint de verificação de saúde da instância local." diff --git a/src/i18n/messages/pt.json b/src/i18n/messages/pt.json index aa75826e5f..43a27138b1 100644 --- a/src/i18n/messages/pt.json +++ b/src/i18n/messages/pt.json @@ -12982,7 +12982,7 @@ "description": "Após a sincronização de fornecedor-modelo, regenerar os perfis ~/.codex/*.config.toml a partir do catálogo ativo. Isto nunca altera a configuração ativa ou predefinida do Codex e está desativado por predefinição." }, "OMNIROUTE_AUTO_SYNC_CLAUDE_PROFILES": { - "description": "Após a sincronização de fornecedor-modelo, regenerar os perfis ~/.claude/profiles//settings.json a partir do catálogo ativo. Isto nunca altera a configuração ativa ou predefinida do Claude e está desativado por predefinição." + "description": "Após a sincronização de fornecedor-modelo, regenerar os perfis ~/.claude/profiles/''/settings.json a partir do catálogo ativo. Isto nunca altera a configuração ativa ou predefinida do Claude e está desativado por predefinição." }, "OMNIROUTE_DISABLE_LOCAL_HEALTHCHECK": { "description": "Desativar o endpoint de health-check da instância local." diff --git a/src/i18n/messages/ro.json b/src/i18n/messages/ro.json index 4475a38325..87f1f5f75f 100644 --- a/src/i18n/messages/ro.json +++ b/src/i18n/messages/ro.json @@ -12982,7 +12982,7 @@ "description": "După sincronizarea furnizor-model, regenerează profilurile ~/.codex/*.config.toml din catalogul live. Acest lucru nu modifică niciodată configurația Codex activă sau implicită și este dezactivat în mod implicit." }, "OMNIROUTE_AUTO_SYNC_CLAUDE_PROFILES": { - "description": "După sincronizarea furnizor-model, regenerează profilurile ~/.claude/profiles//settings.json din catalogul live. Acest lucru nu modifică niciodată configurația Claude activă sau implicită și este dezactivat în mod implicit." + "description": "După sincronizarea furnizor-model, regenerează profilurile ~/.claude/profiles/''/settings.json din catalogul live. Acest lucru nu modifică niciodată configurația Claude activă sau implicită și este dezactivat în mod implicit." }, "OMNIROUTE_DISABLE_LOCAL_HEALTHCHECK": { "description": "Dezactivează endpoint-ul de health-check al instanței locale." diff --git a/src/i18n/messages/ru.json b/src/i18n/messages/ru.json index d5ca918520..1a8ff09e40 100644 --- a/src/i18n/messages/ru.json +++ b/src/i18n/messages/ru.json @@ -12982,7 +12982,7 @@ "description": "After provider-model synchronization, regenerate ~/.codex/*.config.toml profiles from the live catalog. This never changes the active or default Codex configuration and is off by default." }, "OMNIROUTE_AUTO_SYNC_CLAUDE_PROFILES": { - "description": "After provider-model synchronization, regenerate ~/.claude/profiles//settings.json profiles from the live catalog. This never changes the active or default Claude configuration and is off by default." + "description": "After provider-model synchronization, regenerate ~/.claude/profiles/''/settings.json profiles from the live catalog. This never changes the active or default Claude configuration and is off by default." }, "OMNIROUTE_DISABLE_LOCAL_HEALTHCHECK": { "description": "Disable the local instance health-check endpoint." diff --git a/src/i18n/messages/sk.json b/src/i18n/messages/sk.json index bf82f9a7ea..c1fe3540c1 100644 --- a/src/i18n/messages/sk.json +++ b/src/i18n/messages/sk.json @@ -12982,7 +12982,7 @@ "description": "Po synchronizácii modelov poskytovateľov pregenerovať profily ~/.codex/*.config.toml zo živého katalógu. Toto nikdy nezmení aktívnu ani predvolenú konfiguráciu Codexu a je to predvolene vypnuté." }, "OMNIROUTE_AUTO_SYNC_CLAUDE_PROFILES": { - "description": "Po synchronizácii modelov poskytovateľov pregenerovať profily ~/.claude/profiles//settings.json zo živého katalógu. Toto nikdy nezmení aktívnu ani predvolenú konfiguráciu Claude a je to predvolene vypnuté." + "description": "Po synchronizácii modelov poskytovateľov pregenerovať profily ~/.claude/profiles/''/settings.json zo živého katalógu. Toto nikdy nezmení aktívnu ani predvolenú konfiguráciu Claude a je to predvolene vypnuté." }, "OMNIROUTE_DISABLE_LOCAL_HEALTHCHECK": { "description": "Zakázať koncový bod kontroly stavu lokálnej inštancie." diff --git a/src/i18n/messages/sv.json b/src/i18n/messages/sv.json index 42716bd720..b96ff937f0 100644 --- a/src/i18n/messages/sv.json +++ b/src/i18n/messages/sv.json @@ -12982,7 +12982,7 @@ "description": "Efter synkronisering av leverantörsmodell, generera om ~/.codex/*.config.toml-profiler från den aktiva katalogen. Detta ändrar aldrig den aktiva eller standardinställda Codex-konfigurationen och är inaktiverat som standard." }, "OMNIROUTE_AUTO_SYNC_CLAUDE_PROFILES": { - "description": "Efter synkronisering av leverantörsmodell, generera om ~/.claude/profiles//settings.json-profiler från den aktiva katalogen. Detta ändrar aldrig den aktiva eller standardinställda Claude-konfigurationen och är inaktiverat som standard." + "description": "Efter synkronisering av leverantörsmodell, generera om ~/.claude/profiles/''/settings.json-profiler från den aktiva katalogen. Detta ändrar aldrig den aktiva eller standardinställda Claude-konfigurationen och är inaktiverat som standard." }, "OMNIROUTE_DISABLE_LOCAL_HEALTHCHECK": { "description": "Inaktivera slutpunkten för hälsokontroll av den lokala instansen." diff --git a/src/i18n/messages/sw.json b/src/i18n/messages/sw.json index 50462df6b9..571e93ebfe 100644 --- a/src/i18n/messages/sw.json +++ b/src/i18n/messages/sw.json @@ -12982,7 +12982,7 @@ "description": "Baada ya ulandanishi wa mtoa huduma na mfano, zalisha upya wasifu wa ~/.codex/*.config.toml kutoka kwenye katalogi hai. Hii haibadilishi kamwe usanidi amilifu au wa chaguomsingi wa Codex na imezimwa kwa chaguomsingi." }, "OMNIROUTE_AUTO_SYNC_CLAUDE_PROFILES": { - "description": "After provider-model synchronization, regenerate ~/.claude/profiles//settings.json profiles from the live catalog. This never changes the active or default Claude configuration and is off by default." + "description": "After provider-model synchronization, regenerate ~/.claude/profiles/''/settings.json profiles from the live catalog. This never changes the active or default Claude configuration and is off by default." }, "OMNIROUTE_DISABLE_LOCAL_HEALTHCHECK": { "description": "Zima kituo cha mwisho cha ukaguzi wa afya wa mfano wa ndani." diff --git a/src/i18n/messages/ta.json b/src/i18n/messages/ta.json index 05f3494c76..efd3b61e9d 100644 --- a/src/i18n/messages/ta.json +++ b/src/i18n/messages/ta.json @@ -12982,7 +12982,7 @@ "description": "வழங்குநர்-மாடல் ஒத்திசைவுக்குப் பிறகு, நேரலை அட்டவணையில் இருந்து ~/.codex/*.config.toml சுயவிவரங்களை மீண்டும் உருவாக்கவும். இது செயலில் உள்ள அல்லது இயல்புநிலை Codex உள்ளமைவை ஒருபோதும் மாற்றாது மற்றும் இயல்பாகவே முடக்கப்பட்டிருக்கும்." }, "OMNIROUTE_AUTO_SYNC_CLAUDE_PROFILES": { - "description": "வழங்குநர்-மாடல் ஒத்திசைவுக்குப் பிறகு, நேரலை அட்டவணையில் இருந்து ~/.claude/profiles//settings.json சுயவிவரங்களை மீண்டும் உருவாக்கவும். இது செயலில் உள்ள அல்லது இயல்புநிலை Claude உள்ளமைவை ஒருபோதும் மாற்றாது மற்றும் இயல்பாகவே முடக்கப்பட்டிருக்கும்." + "description": "வழங்குநர்-மாடல் ஒத்திசைவுக்குப் பிறகு, நேரலை அட்டவணையில் இருந்து ~/.claude/profiles/''/settings.json சுயவிவரங்களை மீண்டும் உருவாக்கவும். இது செயலில் உள்ள அல்லது இயல்புநிலை Claude உள்ளமைவை ஒருபோதும் மாற்றாது மற்றும் இயல்பாகவே முடக்கப்பட்டிருக்கும்." }, "OMNIROUTE_DISABLE_LOCAL_HEALTHCHECK": { "description": "உள்ளூர் நிகழ்வு health-check இறுதிப்புள்ளியை முடக்கவும்." diff --git a/src/i18n/messages/te.json b/src/i18n/messages/te.json index 6aee70dfa5..4e1a200b16 100644 --- a/src/i18n/messages/te.json +++ b/src/i18n/messages/te.json @@ -12982,7 +12982,7 @@ "description": "ప్రొవైడర్-మోడల్ సమకాలీకరణ తర్వాత, లైవ్ కేటలాగ్ నుండి ~/.codex/*.config.toml ప్రొఫైల్‌లను తిరిగి సృష్టించండి. ఇది సక్రియ లేదా డిఫాల్ట్ Codex కాన్గ్రిగేషన్‌ను ఎప్పటికీ మార్చదు మరియు డిఫాల్ట్‌గా ఆఫ్‌లో ఉంటుంది." }, "OMNIROUTE_AUTO_SYNC_CLAUDE_PROFILES": { - "description": "ప్రొవైడర్-మోడల్ సమకాలీకరణ తర్వాత, లైవ్ కేటలాగ్ నుండి ~/.claude/profiles//settings.json ప్రొఫైల్‌లను తిరిగి సృష్టించండి. ఇది సక్రియ లేదా డిఫాల్ట్ Claude కాన్ఫిగరేషన్‌ను ఎప్పటికీ మార్చదు మరియు డిఫాల్ట్‌గా ఆఫ్‌లో ఉంటుంది." + "description": "ప్రొవైడర్-మోడల్ సమకాలీకరణ తర్వాత, లైవ్ కేటలాగ్ నుండి ~/.claude/profiles/''/settings.json ప్రొఫైల్‌లను తిరిగి సృష్టించండి. ఇది సక్రియ లేదా డిఫాల్ట్ Claude కాన్ఫిగరేషన్‌ను ఎప్పటికీ మార్చదు మరియు డిఫాల్ట్‌గా ఆఫ్‌లో ఉంటుంది." }, "OMNIROUTE_DISABLE_LOCAL_HEALTHCHECK": { "description": "స్థానిక ఇన్‌స్టాన్స్ హెల్త్-చెక్ ఎండ్‌పాయింట్‌ను నిలిపివేయండి." diff --git a/src/i18n/messages/th.json b/src/i18n/messages/th.json index b8ea2db512..8a7f414c63 100644 --- a/src/i18n/messages/th.json +++ b/src/i18n/messages/th.json @@ -12982,7 +12982,7 @@ "description": "หลังจากการซิงโครไนซ์ผู้ให้บริการ-โมเดล ให้สร้างโปรไฟล์ ~/.codex/*.config.toml ใหม่จากแคตตาล็อกที่ใช้งานอยู่ การดำเนินการนี้จะไม่เปลี่ยนการกำหนดค่า Codex ที่ใช้งานอยู่หรือค่าเริ่มต้น และปิดใช้งานเป็นค่าเริ่มต้น" }, "OMNIROUTE_AUTO_SYNC_CLAUDE_PROFILES": { - "description": "หลังจากการซิงโครไนซ์ผู้ให้บริการ-โมเดล ให้สร้างโปรไฟล์ ~/.claude/profiles//settings.json ใหม่จากแคตตาล็อกที่ใช้งานอยู่ การดำเนินการนี้จะไม่เปลี่ยนการกำหนดค่า Claude ที่ใช้งานอยู่หรือค่าเริ่มต้น และปิดใช้งานเป็นค่าเริ่มต้น" + "description": "หลังจากการซิงโครไนซ์ผู้ให้บริการ-โมเดล ให้สร้างโปรไฟล์ ~/.claude/profiles/''/settings.json ใหม่จากแคตตาล็อกที่ใช้งานอยู่ การดำเนินการนี้จะไม่เปลี่ยนการกำหนดค่า Claude ที่ใช้งานอยู่หรือค่าเริ่มต้น และปิดใช้งานเป็นค่าเริ่มต้น" }, "OMNIROUTE_DISABLE_LOCAL_HEALTHCHECK": { "description": "ปิดใช้งานปลายทาง health-check ของอินสแตนซ์ในเครื่อง" diff --git a/src/i18n/messages/tr.json b/src/i18n/messages/tr.json index 9875c62e73..1331af1acb 100644 --- a/src/i18n/messages/tr.json +++ b/src/i18n/messages/tr.json @@ -12982,7 +12982,7 @@ "description": "Sağlayıcı-model senkronizasyonundan sonra, canlı katalogdan ~/.codex/*.config.toml profillerini yeniden oluşturun. Bu işlem aktif veya varsayılan Codex yapılandırmasını asla değiştirmez ve varsayılan olarak kapalıdır." }, "OMNIROUTE_AUTO_SYNC_CLAUDE_PROFILES": { - "description": "Sağlayıcı-model senkronizasyonundan sonra, canlı katalogdan ~/.claude/profiles//settings.json profillerini yeniden oluşturun. Bu işlem aktif veya varsayılan Claude yapılandırmasını asla değiştirmez ve varsayılan olarak kapalıdır." + "description": "Sağlayıcı-model senkronizasyonundan sonra, canlı katalogdan ~/.claude/profiles/''/settings.json profillerini yeniden oluşturun. Bu işlem aktif veya varsayılan Claude yapılandırmasını asla değiştirmez ve varsayılan olarak kapalıdır." }, "OMNIROUTE_DISABLE_LOCAL_HEALTHCHECK": { "description": "Yerel örnek sağlık kontrolü (health-check) uç noktasını devre dışı bırakın." diff --git a/src/i18n/messages/uk-UA.json b/src/i18n/messages/uk-UA.json index 3acb5c19e9..2146ffee8f 100644 --- a/src/i18n/messages/uk-UA.json +++ b/src/i18n/messages/uk-UA.json @@ -12982,7 +12982,7 @@ "description": "Після синхронізації моделей провайдерів повторно генерувати профілі ~/.codex/*.config.toml з актуального каталогу. Це ніколи не змінює активну або стандартну конфігурацію Codex і вимкнено за замовчуванням." }, "OMNIROUTE_AUTO_SYNC_CLAUDE_PROFILES": { - "description": "Після синхронізації моделей провайдерів повторно генерувати профілі ~/.claude/profiles//settings.json з актуального каталогу. Це ніколи не змінює активну або стандартну конфігурацію Claude і вимкнено за замовчуванням." + "description": "Після синхронізації моделей провайдерів повторно генерувати профілі ~/.claude/profiles/''/settings.json з актуального каталогу. Це ніколи не змінює активну або стандартну конфігурацію Claude і вимкнено за замовчуванням." }, "OMNIROUTE_DISABLE_LOCAL_HEALTHCHECK": { "description": "Вимкнути кінцеву точку перевірки працездатності локального екземпляра." diff --git a/src/i18n/messages/ur.json b/src/i18n/messages/ur.json index 61baa9bfb6..a1b7ea9c02 100644 --- a/src/i18n/messages/ur.json +++ b/src/i18n/messages/ur.json @@ -12982,7 +12982,7 @@ "description": "فراہم کنندہ-ماڈل سنکرونائزیشن کے بعد، لائیو کیٹلاگ سے ~/.codex/*.config.toml پروفائلز کو دوبارہ تیار کریں۔ یہ فعال یا پہلے سے طے شدہ Codex کنفیگریشن کو کبھی تبدیل نہیں کرتا ہے اور پہلے سے طے شدہ طور پر بند ہے۔" }, "OMNIROUTE_AUTO_SYNC_CLAUDE_PROFILES": { - "description": "فراہم کنندہ-ماڈل سنکرونائزیشن کے بعد، لائیو کیٹلاگ سے ~/.claude/profiles//settings.json پروفائلز کو دوبارہ تیار کریں۔ یہ فعال یا پہلے سے طے شدہ Claude کنفیگریشن کو کبھی تبدیل نہیں کرتا ہے اور پہلے سے طے شدہ طور پر بند ہے۔" + "description": "فراہم کنندہ-ماڈل سنکرونائزیشن کے بعد، لائیو کیٹلاگ سے ~/.claude/profiles/''/settings.json پروفائلز کو دوبارہ تیار کریں۔ یہ فعال یا پہلے سے طے شدہ Claude کنفیگریشن کو کبھی تبدیل نہیں کرتا ہے اور پہلے سے طے شدہ طور پر بند ہے۔" }, "OMNIROUTE_DISABLE_LOCAL_HEALTHCHECK": { "description": "مقامی انسٹنس ہیلتھ چیک اینڈ پوائنٹ کو غیر فعال کریں۔" diff --git a/src/i18n/messages/vi.json b/src/i18n/messages/vi.json index 45fb862ce3..7b405b6587 100644 --- a/src/i18n/messages/vi.json +++ b/src/i18n/messages/vi.json @@ -12993,7 +12993,7 @@ "description": "Sau khi đồng bộ mô hình nhà cung cấp, tạo lại các hồ sơ ~/.codex/*.config.toml từ danh mục trực tiếp. Không bao giờ thay đổi cấu hình Codex đang hoạt động hoặc cấu hình mặc định. Tính năng này mặc định tắt." }, "OMNIROUTE_AUTO_SYNC_CLAUDE_PROFILES": { - "description": "Sau khi đồng bộ mô hình nhà cung cấp, tạo lại các hồ sơ Claude Code tại ~/.claude/profiles//settings.json từ danh mục trực tiếp. Không bao giờ thay đổi cấu hình Claude đang hoạt động hoặc cấu hình mặc định. Tính năng này mặc định tắt." + "description": "Sau khi đồng bộ mô hình nhà cung cấp, tạo lại các hồ sơ Claude Code tại ~/.claude/profiles/''/settings.json từ danh mục trực tiếp. Không bao giờ thay đổi cấu hình Claude đang hoạt động hoặc cấu hình mặc định. Tính năng này mặc định tắt." }, "OMNIROUTE_DISABLE_LOCAL_HEALTHCHECK": { "description": "Tắt endpoint kiểm tra tình trạng của phiên bản cục bộ." diff --git a/src/i18n/messages/zh-CN.json b/src/i18n/messages/zh-CN.json index 282545b274..38a381c461 100644 --- a/src/i18n/messages/zh-CN.json +++ b/src/i18n/messages/zh-CN.json @@ -12982,7 +12982,7 @@ "description": "提供者-模型同步后,从实时目录重新生成 ~/.codex/*.config.toml 配置文件。这绝不会更改活动或默认的 Codex 配置,并且默认关闭。" }, "OMNIROUTE_AUTO_SYNC_CLAUDE_PROFILES": { - "description": "提供者-模型同步后,从实时目录重新生成 ~/.claude/profiles//settings.json 配置文件。这绝不会更改活动或默认的 Claude 配置,并且默认关闭。" + "description": "提供者-模型同步后,从实时目录重新生成 ~/.claude/profiles/''/settings.json 配置文件。这绝不会更改活动或默认的 Claude 配置,并且默认关闭。" }, "OMNIROUTE_DISABLE_LOCAL_HEALTHCHECK": { "description": "禁用本地实例健康检查端点。" diff --git a/src/i18n/messages/zh-TW.json b/src/i18n/messages/zh-TW.json index e6c513e06e..73bd76989f 100644 --- a/src/i18n/messages/zh-TW.json +++ b/src/i18n/messages/zh-TW.json @@ -12982,7 +12982,7 @@ "description": "在提供者-模型同步後,從即時目錄重新產生 ~/.codex/*.config.toml 設定檔。這絕不會更改作用中或預設的 Codex 配置,且預設為關閉。" }, "OMNIROUTE_AUTO_SYNC_CLAUDE_PROFILES": { - "description": "在提供者-模型同步後,從即時目錄重新產生 ~/.claude/profiles//settings.json 設定檔。這絕不會更改作用中或預設的 Claude 配置,且預設為關閉。" + "description": "在提供者-模型同步後,從即時目錄重新產生 ~/.claude/profiles/''/settings.json 設定檔。這絕不會更改作用中或預設的 Claude 配置,且預設為關閉。" }, "OMNIROUTE_DISABLE_LOCAL_HEALTHCHECK": { "description": "停用本機執行個體的健康檢查端點。" diff --git a/tests/unit/feature-flag-description-icu-parse-12505.test.ts b/tests/unit/feature-flag-description-icu-parse-12505.test.ts new file mode 100644 index 0000000000..d0c2311b58 --- /dev/null +++ b/tests/unit/feature-flag-description-icu-parse-12505.test.ts @@ -0,0 +1,68 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { readdirSync, readFileSync } from "node:fs"; +import { join } from "node:path"; +import { IntlMessageFormat } from "intl-messageformat"; + +const messagesDir = join(import.meta.dirname, "../../src/i18n/messages"); + +/** + * #12505: `featureFlags.definitions.*.description` is rendered by + * `FeatureFlagsGrid.tsx` through a plain `t()` call, so next-intl compiles every + * value as an ICU message. A bare `` inside the value parses as a rich-text + * tag; no tag element is ever supplied, so the message fails to compile and the + * card silently falls back to printing the raw key. The path placeholder has to be + * ICU-escaped (`''`) rather than HTML-escaped, because the literal angle + * brackets are part of the file path the user is meant to read. + * + * Same class as #12302 (`ccOnboardingKeyPlaceholder`). + */ +const localeFiles = readdirSync(messagesDir).filter((f) => f.endsWith(".json")); + +function flatten(node: unknown, prefix: string, out: Map): void { + if (typeof node === "string") { + out.set(prefix, node); + return; + } + if (!node || typeof node !== "object" || Array.isArray(node)) return; + for (const [key, value] of Object.entries(node as Record)) { + flatten(value, prefix ? `${prefix}.${key}` : key, out); + } +} + +test("every featureFlags.definitions message compiles as ICU in every locale", () => { + assert.ok(localeFiles.length >= 40, `expected the full locale set, got ${localeFiles.length}`); + + const failures: string[] = []; + for (const file of localeFiles) { + const parsed = JSON.parse(readFileSync(join(messagesDir, file), "utf8")); + const flat = new Map(); + flatten(parsed?.featureFlags?.definitions, "", flat); + + for (const [key, value] of flat) { + try { + // Compilation is what next-intl does on render; a raw throws here. + new IntlMessageFormat(value, "en"); + } catch (err) { + failures.push(`${file} → featureFlags.definitions.${key}: ${(err as Error).message}`); + } + } + } + + assert.deepEqual(failures, [], `ICU-invalid feature-flag messages:\n${failures.join("\n")}`); +}); + +test("the OMNIROUTE_AUTO_SYNC_CLAUDE_PROFILES path placeholder renders literally", () => { + for (const file of localeFiles) { + const parsed = JSON.parse(readFileSync(join(messagesDir, file), "utf8")); + const value = parsed?.featureFlags?.definitions?.OMNIROUTE_AUTO_SYNC_CLAUDE_PROFILES + ?.description as string | undefined; + if (typeof value !== "string" || !value.includes("settings.json")) continue; + + const rendered = new IntlMessageFormat(value, "en").format() as string; + assert.ok( + rendered.includes(""), + `${file}: the escaped placeholder must render as a literal , got: ${rendered}` + ); + } +}); From c5d47dad8a9278bba15c7097dbc80b1791f02e8b Mon Sep 17 00:00:00 2001 From: Koosha Paridehpour <42529354+KooshaPari@users.noreply.github.com> Date: Fri, 4 Sep 2026 22:33:56 -0700 Subject: [PATCH 04/18] docs(security): document socket.yml scanner config + CI workflow link (#12575) (#12764) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Merged, with one sentence removed. The `socket.yml` half checks out: the file exists at the repo root, is `version: 2`, and its `projectIgnorePaths` really do list `tests/`, `_tasks/`, `_references/`, `_ideia/`, `_mono_repo/`, `docs/` — so the paragraph describes the config accurately. The closing sentence did not: there is no `.github/workflows/socket-dev.yml` in this repo (`ls .github/workflows | grep -i socket` is empty), and nothing auto-opens `supply-chain-review/` issues. Per the documentation-accuracy rule in `AGENTS.md` — every path and workflow named in docs has to survive an `rg`/`ls` — I replaced it with what is actually true: the scan is driven by the Socket GitHub App reading `socket.yml`, not by a workflow here. Everything else merged as written. Thanks — pointing readers of SECURITY.md at the scanner config was a real gap. --- SECURITY.md | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/SECURITY.md b/SECURITY.md index 59298ced57..ed22819804 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -224,6 +224,14 @@ features (MITM, Zed import, Cloud Sync, embedded service supervisor) — ends up in `.next/server/*.js` minified chunks. Heuristic supply-chain scanners frequently pattern-match those chunks against malware signatures. +The scanner configuration we use lives at [`socket.yml`](socket.yml) in the +repo root (Socket.dev GitHub App format v2 — see +). It explicitly excludes +non-shipped directories (`tests/`, `_tasks/`, `_references/`, `_ideia/`, +`_mono_repo/`, `docs/`, etc.) so the scanner only reports on code paths that +actually reach published users — the scan itself is driven by the Socket +GitHub App reading that file, not by a workflow in this repository. + For each finding category we maintain a per-finding maintainer attestation: - **[`docs/security/SOCKET_DEV_FINDINGS.md`](docs/security/SOCKET_DEV_FINDINGS.md)** — From f40c77e837b9a78790502e6a1da94cde1544cdf3 Mon Sep 17 00:00:00 2001 From: Koosha Paridehpour <42529354+KooshaPari@users.noreply.github.com> Date: Fri, 4 Sep 2026 22:33:59 -0700 Subject: [PATCH 05/18] fix(docker): document and harden cli profile trust boundary (#12570) (#12706) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Merged, with the threat model kept and two unverifiable claims dropped. The core warning is correct and worth having in both files: `/var/run/docker.sock` is a host-root trust boundary, the `cli` profile must not be published beyond `127.0.0.1`, and no extra host mounts belong in it. That is now in `docker-compose.yml` next to the mount and in the DOCKER_GUIDE. Two things I changed before merging, both `AGENTS.md` documentation-accuracy calls: 1. **The stated purpose.** The socket is not mounted so OmniRoute can "launch short-lived codex/claude-code/droid/openclaw containers" — I could not find any container-spawn path. It is there for the in-container auto-updater: `src/lib/system/autoUpdate.ts:236` probes for `/var/run/docker.sock` and skips the Docker path when it is absent, and the mount sits right beside `AUTO_UPDATE_HOST_REPO_DIR`. Rewrote the sentence around that and cited the file. 2. **Item 3, the audit log.** "recorded in the server log with the called tool, the prompt digest (not content), and the spawned image SHA" — no such logging exists (`grep -rn "prompt digest\|promptDigest\|imageSha" src/ open-sse/` is empty). A security doc promising forensics that are not implemented is worse than one that stays quiet, so I removed the item rather than soften it. The `MITM-TPROXY-DECRYPT.md` and `SUPPLY_CHAIN.md` cross-references both resolve and stayed. Thanks — the docker.sock boundary genuinely was undocumented. --- docker-compose.yml | 7 +++++++ docs/guides/DOCKER_GUIDE.md | 29 ++++++++++++++++++++++++++++- 2 files changed, 35 insertions(+), 1 deletion(-) diff --git a/docker-compose.yml b/docker-compose.yml index fc5759a996..a57e82f666 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -170,6 +170,13 @@ services: - "${LIVE_WS_PORT:-20132}:${LIVE_WS_PORT:-20132}" volumes: - ./data:/app/data + # SECURITY: mounting the host Docker socket gives this container full + # control over the host Docker daemon — it can create/list/stop/rm any + # container the host runs. It is here so the in-container auto-updater + # (src/lib/system/autoUpdate.ts) can recreate the stack. Only use this + # profile on a single-tenant workstation you trust, and never publish + # its ports beyond 127.0.0.1. See docs/guides/DOCKER_GUIDE.md → + # "Escape hatch: configure the container's own CLIs" for the threat model. - /var/run/docker.sock:/var/run/docker.sock - /usr/libexec/docker/cli-plugins:/usr/libexec/docker/cli-plugins:ro - ${AUTO_UPDATE_HOST_REPO_DIR:-.}:/workspace/omniroute:rw diff --git a/docs/guides/DOCKER_GUIDE.md b/docs/guides/DOCKER_GUIDE.md index 1bf026c0b8..69a4e5a9e5 100644 --- a/docs/guides/DOCKER_GUIDE.md +++ b/docs/guides/DOCKER_GUIDE.md @@ -132,13 +132,40 @@ A bind mount is what makes the path trustworthy: OmniRoute reads whose children are mounts, which is exactly the `/host-home` shape above) while still refusing unmounted ones. -### Escape hatch: configure the container's own CLIs +### Escape hatch: configure the container's own CLIs (use sparingly) When the CLIs genuinely live inside the container (the `cli` profile), the write is intentional. Pass `--allow-container-write` to any `setup-*` command, or set `OMNIROUTE_ALLOW_CONTAINER_CONFIG_WRITE=true` for the server. The write proceeds with a warning that it will not survive the container. +> **Security warning — `cli` profile + `docker.sock` mount.** +> The `cli` profile bind-mounts `/var/run/docker.sock` so the in-container +> auto-updater can recreate the stack from the host daemon +> (`src/lib/system/autoUpdate.ts` probes for that socket and skips the +> Docker path when it is absent). That socket is **a host-root trust +> boundary**: anything that can reach it drives the host Docker daemon as +> root — it can create, inspect, stop and remove any container on the host. +> Implications: +> +> 1. **Never expose the `cli` profile's port to the network.** Publish +> it on `127.0.0.1` (`ports: "127.0.0.1:${DASHBOARD_PORT:-20128}:..."`) +> — a LAN-reachable `cli` profile turns any dashboard-level RCE into +> full host compromise. +> 2. **Do not bind any extra host directories into the `cli` profile.** +> The Docker socket plus any further mount gives the container full +> read/write to your filesystem and host config. If you need a tool to +> see a project, run it locally with the CLI binary — do not mount it +> into the `cli` container. +> +> If you do not need in-container auto-update, leave the `cli` profile off +> (`COMPOSE_PROFILES=core,redis` or shorter). The other profiles do not +> mount the Docker socket. +> +> See `docs/security/MITM-TPROXY-DECRYPT.md` for the related threat model +> around MITM, and `docs/security/SUPPLY_CHAIN.md` for the +> `codex`/`claude-code`/`droid`/`openclaw` binary provenance chain. + ## Redis Sidecar OmniRoute relies on Redis to back the distributed rate limiter and shared cache. The `redis` service is **always defined** in `docker-compose.yml` (it has no profile gate) and starts alongside any other profile. From 0df5be5b095dfff3f3d217a71aa2fc1fe949ee5e Mon Sep 17 00:00:00 2001 From: Koosha Paridehpour <42529354+KooshaPari@users.noreply.github.com> Date: Fri, 4 Sep 2026 22:34:17 -0700 Subject: [PATCH 06/18] docs(gamification): align XP Rewards table with code (#12501) (#12667) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Merged, with the markdown repaired. Checked every row against `src/lib/gamification/xp.ts:138` — the table now matches `XP_REWARDS` exactly, keys and values, and the descriptions are the JSDoc lines verbatim. The old table was documenting actions that do not exist (`badge_earned`, `streak_milestone`, `referral`, `model_diversity`, `compression_use`, `skill_use`) and missing the three that do (`model_switch`, `invite_redeem`, `streak_bonus`). Good catch. Two formatting fixes before merge: the action names were padded inside the code spans (`` `request ` ``), which renders the trailing spaces as part of the identifier; and the unrelated MCP-tools table below had its header row flattened, losing the column alignment. Restored both and ran Prettier — the file is clean now. Thank you for reconciling this against the source instead of guessing. --- docs/frameworks/GAMIFICATION.md | 28 +++++++++++++--------------- 1 file changed, 13 insertions(+), 15 deletions(-) diff --git a/docs/frameworks/GAMIFICATION.md b/docs/frameworks/GAMIFICATION.md index 7287e46d0b..1d5697552f 100644 --- a/docs/frameworks/GAMIFICATION.md +++ b/docs/frameworks/GAMIFICATION.md @@ -236,20 +236,18 @@ xp_for_level(n) = floor(100 * n^1.5) ### XP Rewards -| Action | XP | Description | -| ------------------ | --- | --------------------------------------------------------- | -| `request` | 1 | Per successful LLM request | -| `provider_switch` | 5 | Switching to a different provider | -| `combo_create` | 10 | Creating a new combo configuration | -| `combo_use` | 2 | Using a combo (per target hit) | -| `badge_earned` | 25 | Earning any badge | -| `streak_milestone` | 15 | Reaching a streak milestone (7, 14, 30, 60, 90, 180, 365) | -| `referral` | 50 | Successfully referring a new user | -| `token_share` | 5 | Sharing tokens with another user | -| `daily_login` | 3 | First request of the day | -| `model_diversity` | 3 | Using a model not used in the past 7 days | -| `compression_use` | 2 | Using prompt compression | -| `skill_use` | 2 | Executing a skill via MCP | +| Action | XP | Description | +| ----------------- | --- | -------------------------------------------------------- | +| `request` | 1 | Per API request routed through OmniRoute | +| `provider_switch` | 5 | Switching to a different provider | +| `model_switch` | 3 | Switching to a different model | +| `combo_create` | 10 | Creating a new combo | +| `combo_use` | 2 | Using a combo for a request | +| `token_share` | 1 | Per 1 000 tokens shared with another user | +| `invite_redeem` | 50 | Redeeming an invite code | +| `daily_login` | 5 | Daily active usage (once per day) | +| `streak_bonus` | 2 | Per consecutive streak day (multiplied by streak length) | +| `badge_unlock` | 10 | Unlocking a badge | ### Award Flow @@ -812,7 +810,7 @@ Route → CORS preflight → Body validation (Zod) → Auth (extractApiKey) Registered in `open-sse/mcp-server/` alongside existing tools. Scoped under the `gamification` permission scope. -| Tool | Description | Input Schema | +| Tool | Description | Input Schema | | | -------------------------- | ------------------------------------- | ---------------------------- | --------- | | `gamification_leaderboard` | Get leaderboard for a scope/period | `{ scope, period?, limit? }` | | `gamification_rank` | Get caller's rank and neighbors | `{ scope }` | From 7da6e10c4eeb7a51ffcfec6e44f9cfc944ad9213 Mon Sep 17 00:00:00 2001 From: Koosha Paridehpour <42529354+KooshaPari@users.noreply.github.com> Date: Fri, 4 Sep 2026 22:34:20 -0700 Subject: [PATCH 07/18] fix(docker): pin 4 CLI tools to exact versions (#12576) (#12703) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Merged. Verified all four pins resolve on npm before landing: ``` @openai/codex@0.153.2 0.153.2 @anthropic-ai/claude-code@2.1.260 2.1.260 droid@0.212.0 0.212.0 openclaw@2026.9.1 2026.9.1 ``` The reproducibility argument holds — a floating `@latest` in a cached Docker layer means two builds of the same commit can ship different toolchains, and that is exactly the class of drift that makes a CI failure unattributable. Worth flagging for whoever maintains this next: pinning trades drift for staleness, so these four now need a periodic bump or the image ships increasingly old CLIs. The comment block you added explains the why, which makes that bump a safe mechanical change instead of a judgment call. Rebased onto `release/v3.8.51` (the PR was cut from `main`, ~3695 commits behind). Thanks. --- Dockerfile | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/Dockerfile b/Dockerfile index 471cbc86d5..235745535d 100644 --- a/Dockerfile +++ b/Dockerfile @@ -331,7 +331,18 @@ RUN --mount=type=cache,id=s/92ca8a61-c1ba-421f-a389-d48ac7258c2d-apt-cache,targe && git config --system url."https://github.com/".insteadOf "ssh://git@github.com/" # Install CLI tools globally. Separate layer from apt for better cache reuse. +# Pinned to exact versions per Diego's diagnosis in #12576 — floating +# `@latest` causes two CI failures: +# 1. `openclaw` ships a breaking major ~weekly; overnight builds silently +# advance to a version that no longer matches the tested combo stack. +# 2. `codex` / `claude-code` dev pre-releases (`@next`, dist-tags) mutate +# API surface without notice; reproducible builds need a SHA-pinned dev +# 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 @anthropic-ai/claude-code droid openclaw@latest + npm install -g --no-audit --no-fund \ + @openai/codex@0.153.2 \ + @anthropic-ai/claude-code@2.1.260 \ + droid@0.212.0 \ + openclaw@2026.9.1 USER node From 8c4fb8faf263f0336840aa39ff8616b713bedf61 Mon Sep 17 00:00:00 2001 From: Koosha Paridehpour <42529354+KooshaPari@users.noreply.github.com> Date: Fri, 4 Sep 2026 22:34:23 -0700 Subject: [PATCH 08/18] chore(deps): pin browserslist override to ^4.28.8 (#12592) Merged. One line in `overrides`, low blast radius, and pinning a transitive that every build tool reads is defensible on its own. Validated on `release/v3.8.51`: `package.json` re-parses, `typecheck:core` clean, `check-file-size` OK. For future dependency pins, a line in the body about what the floating range actually broke (a specific build failure, a CVE, a resolution conflict) makes these reviewable without guessing. Thanks. --- package.json | 1 + 1 file changed, 1 insertion(+) diff --git a/package.json b/package.json index 4925d88658..da12740fb9 100644 --- a/package.json +++ b/package.json @@ -463,6 +463,7 @@ "unrs-resolver": true }, "overrides": { + "browserslist": "^4.28.8", "onnxruntime-node": "1.24.3", "eslint-plugin-react-hooks": "7.1.1", "fast-xml-parser": "^5.10.1", From 3858923f68be771fb337b26139771f97bbcff808 Mon Sep 17 00:00:00 2001 From: Koosha Paridehpour <42529354+KooshaPari@users.noreply.github.com> Date: Fri, 4 Sep 2026 22:34:41 -0700 Subject: [PATCH 09/18] fix(ci): ship .npmrc in published package so legacy-peer-deps applies to consumers (#11544) (#12699) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Merged — one line, zero risk, and it costs nothing to have. One caveat recorded so nobody later reads this as "#11544 is solved": npm resolves config from the *installing* project's directory, the user config and the global config — it does not read the `.npmrc` shipped inside a dependency's tarball. So `legacy-peer-deps=true` traveling in the package will not change how `npm install -g omniroute` resolves peers on the consumer side. Our own `scripts/build/postinstall.mjs` does shell out to `npm rebuild` / `npm install better-sqlite3`, but with cwd set to `dist/`, so the package-root `.npmrc` is not in scope there either. Keeping it anyway: it makes the published tree self-documenting, and someone debugging inside an extracted package gets the same retry budget we use in CI. But #11544 (`npm install -g omniroute` failing on Windows, "root cause unclear from log") still needs the actual `npm-debug.log` from the reporter before it can be closed. Rebased onto `release/v3.8.51`; `package.json` re-parses and the `files` array kept both `config/i18n.json` and the new entry. Thanks. --- package.json | 1 + 1 file changed, 1 insertion(+) diff --git a/package.json b/package.json index da12740fb9..f984ef4314 100644 --- a/package.json +++ b/package.json @@ -22,6 +22,7 @@ "src/types/", ".env.example", "config/i18n.json", + ".npmrc", "scripts/build/postinstall.mjs", "scripts/build/fixPlaywrightAndroid.mjs", "bin/cli/runtime/", From ec4f951e39023aeb1f441919a4c46fc384302934 Mon Sep 17 00:00:00 2001 From: Diego Rodrigues de Sa e Souza Date: Sat, 5 Sep 2026 03:14:46 -0300 Subject: [PATCH 10/18] test(ci): pin the openapi-security-tiers two-arm contract with an executing gate test (#12581) (#12652) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Merged as a reduced diff, and worth recording why. The two-arm `ALWAYS_PROTECTED` read this PR proposed had already landed in #12605 while this branch was open — the tip carries `coveredByAlwaysProtected()` with both arms and the new error wording. I ran the gate on the current tip to be sure: `PASS — all security tier annotations match routeGuard.ts`. Merging the whole branch would have reintroduced the same logic under a different comment. What was genuinely missing, and is what merged: - **`tests/unit/openapi-security-tiers-gate.test.ts`** — executes the real gate and asserts exit 0 with no "NOT covered" line. #12605 fixed the defect but left no guard, so the LOCAL_ONLY-arm bug (#12350) could reappear on the ALWAYS_PROTECTED arm exactly as it did the first time. 1/1 green. - **The parse guard** — `ALWAYS_PROTECTED_PATTERNS.length === 0` now fails the constant-parse check with its own count in the message. Without it, a regex array that stops parsing degrades into "every pattern-covered route is an annotation mismatch" instead of saying so. A note for the record: my first read of this PR was wrong. I ran the gate in the main checkout, which was 11 commits behind `origin/release/v3.8.51`, saw the pre-#12605 failure, and classified this as fixing a live red. It was not — the checkout was stale. Corrected before anything was merged. --- .../check/check-openapi-security-tiers.mjs | 6 ++- .../unit/openapi-security-tiers-gate.test.ts | 38 +++++++++++++++++++ 2 files changed, 42 insertions(+), 2 deletions(-) create mode 100644 tests/unit/openapi-security-tiers-gate.test.ts diff --git a/scripts/check/check-openapi-security-tiers.mjs b/scripts/check/check-openapi-security-tiers.mjs index bd0096015e..ebcabedc69 100644 --- a/scripts/check/check-openapi-security-tiers.mjs +++ b/scripts/check/check-openapi-security-tiers.mjs @@ -115,12 +115,14 @@ const ALWAYS_PROTECTED_PATTERNS = parsePatterns("ALWAYS_PROTECTED_API_PATTERNS") if ( LOCAL_ONLY_PREFIXES.length === 0 || LOCAL_ONLY_PATTERNS.length === 0 || - ALWAYS_PROTECTED_PATHS.length === 0 + ALWAYS_PROTECTED_PATHS.length === 0 || + ALWAYS_PROTECTED_PATTERNS.length === 0 ) { console.error( `[openapi-security-tiers] FAIL — could not parse routeGuard.ts constants ` + `(prefixes=${LOCAL_ONLY_PREFIXES.length}, patterns=${LOCAL_ONLY_PATTERNS.length}, ` + - `alwaysProtected=${ALWAYS_PROTECTED_PATHS.length})` + `alwaysProtected=${ALWAYS_PROTECTED_PATHS.length}, ` + + `alwaysProtectedPatterns=${ALWAYS_PROTECTED_PATTERNS.length})` ); process.exit(1); } diff --git a/tests/unit/openapi-security-tiers-gate.test.ts b/tests/unit/openapi-security-tiers-gate.test.ts new file mode 100644 index 0000000000..62786cc708 --- /dev/null +++ b/tests/unit/openapi-security-tiers-gate.test.ts @@ -0,0 +1,38 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { execFileSync } from "node:child_process"; +import { fileURLToPath } from "node:url"; +import { dirname, join } from "node:path"; + +const ROOT = join(dirname(fileURLToPath(import.meta.url)), "..", ".."); +const GATE = join(ROOT, "scripts", "check", "check-openapi-security-tiers.mjs"); + +function runGate(): { code: number; out: string } { + try { + const out = execFileSync(process.execPath, [GATE], { + cwd: ROOT, + encoding: "utf8", + stdio: ["ignore", "pipe", "pipe"], + }); + return { code: 0, out }; + } catch (err) { + const e = err as { status?: number; stdout?: string; stderr?: string }; + return { code: e.status ?? 1, out: `${e.stdout ?? ""}${e.stderr ?? ""}` }; + } +} + +// routeGuard protects a path when EITHER list matches — `isAlwaysProtectedPath` +// ORs ALWAYS_PROTECTED_API_PATHS with ALWAYS_PROTECTED_API_PATTERNS. The gate +// used to read only the prefix array, so every regex-covered route was reported +// as an annotation mismatch: the four `{claude,codex}-auth/{export,apply-local}` +// routes turned release/v3.8.51 red while being correctly protected at runtime. +// Same defect class the LOCAL_ONLY arm already had (#12350). +test("openapi-security-tiers accepts routes covered only by ALWAYS_PROTECTED_API_PATTERNS", () => { + const { code, out } = runGate(); + + assert.ok( + !/has x-always-protected but is NOT/.test(out), + `gate reported an always-protected route as uncovered:\n${out}` + ); + assert.equal(code, 0, `gate must pass on a clean tree, got exit ${code}:\n${out}`); +}); From 7b2c9b5548bbc0339bf1e14ce5f257514245df79 Mon Sep 17 00:00:00 2001 From: Diego Rodrigues de Sa e Souza Date: Sat, 5 Sep 2026 03:14:49 -0300 Subject: [PATCH 11/18] fix(sse): redact video transcript in pre-guardrail rejected-request logs (#12150 P2 item 7) (#12710) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Merged. Focused, correct, and tested. `recordRejectedRequestUsage` runs on the path where the request never reached the guardrail chain — circuit-breaker-open and combo-exhausted rejections — so the video-bridge guardrail never got the chance to rewrite the transcript, and the raw cues went straight into `call_logs`. Routing the body through `redactVideoTranscriptFieldsForLog` at the persistence boundary is the right place: a no-op clone for non-video bodies, structured field substitution for video ones, and not bypassable by cue content. The `requestBody == null ? requestBody : …` guard keeps the existing "no body available" case behaving exactly as before, which the neighbouring test still covers. Validated on `release/v3.8.51`: `tests/unit/rejected-request-usage.test.ts` green, including the new case asserting the secret cue text does not survive into the persisted detail and that the field reads `[redacted-video-transcript]`. `typecheck:core` and `lint` clean. --- src/sse/handlers/rejectedRequestUsage.ts | 8 ++- tests/unit/rejected-request-usage.test.ts | 62 +++++++++++++++++++++++ 2 files changed, 69 insertions(+), 1 deletion(-) diff --git a/src/sse/handlers/rejectedRequestUsage.ts b/src/sse/handlers/rejectedRequestUsage.ts index fdde178038..958ee5cbce 100644 --- a/src/sse/handlers/rejectedRequestUsage.ts +++ b/src/sse/handlers/rejectedRequestUsage.ts @@ -18,6 +18,7 @@ * never turn into a second failure on the response path. */ import { saveCallLog, saveRequestUsage } from "@/lib/usageDb"; +import { redactVideoTranscriptFieldsForLog } from "@/lib/guardrails/videoBridgeSnapshotRedaction"; export interface RejectedRequestUsageInput { status: number; @@ -82,7 +83,12 @@ export async function recordRejectedRequestUsage(input: RejectedRequestUsageInpu duration, tokens: {}, error: error || null, - requestBody, + // #12150 P2 item 7: this request was rejected BEFORE the guardrail chain ran + // (circuit-breaker-open / combo-exhausted), so the video-bridge guardrail + // never redacted the transcript. Redact defensively here — a no-op clone for + // any non-video body, structured field substitution (never bypassable by cue + // content) for a video one. See videoBridgeSnapshotRedaction.ts. + requestBody: requestBody == null ? requestBody : redactVideoTranscriptFieldsForLog(requestBody), comboName, comboStepId, comboExecutionKey, diff --git a/tests/unit/rejected-request-usage.test.ts b/tests/unit/rejected-request-usage.test.ts index 1783129e3a..f7d9cdba79 100644 --- a/tests/unit/rejected-request-usage.test.ts +++ b/tests/unit/rejected-request-usage.test.ts @@ -136,6 +136,68 @@ test("combo-exhausted rejection persists the client request body for dashboard i }); }); +// #12150 P2 item 7: recordRejectedRequestUsage persists the raw client body for +// a request rejected BEFORE the guardrail chain runs (circuit-breaker-open / +// combo-exhausted), so the video-bridge guardrail never got a chance to redact +// the transcript. The body is persisted defensively through +// redactVideoTranscriptFieldsForLog, so a rejected video request's stored log +// never retains the raw transcript cues. +test("#12150 P2 item 7: a rejected request's persisted body has its video transcript redacted", async () => { + const SECRET = "top secret cue text"; + await recordRejectedRequestUsage({ + status: 503, + model: "default", + requestedModel: "default", + provider: "-", + endpoint: "/v1/chat/completions", + error: "[503] Pipeline gate rejected", + apiKeyId: "key-video-reject", + apiKeyName: "video-reject-test", + correlationId: "corr-video-reject", + startTime: Date.now() - 10, + requestBody: { + model: "default", + messages: [ + { + role: "user", + content: [ + { type: "text", text: "look at this video" }, + { + type: "input_video", + video_url: "https://example.com/clip.mp4", + transcript: { cues: [{ text: SECRET, startSeconds: 0, endSeconds: 2 }] }, + }, + ], + }, + ], + }, + }); + + let rejected: { id: string } | undefined; + for (let i = 0; i < 50 && !rejected; i++) { + const logs = await callLogs.getCallLogs({}); + const list = (logs.logs ?? logs) as Array<{ apiKeyName?: string | null }>; + const found = (list ?? []).find((l) => l.apiKeyName === "video-reject-test"); + if (found) rejected = found as unknown as { id: string }; + else await new Promise((r) => setTimeout(r, 10)); + } + assert.ok(rejected, "expected a call_logs row for the rejected video request"); + + const detail = await callLogs.getCallLogById(rejected.id); + assert.ok(detail, "expected to load the call log detail"); + assert.equal( + JSON.stringify(detail!.requestBody).includes(SECRET), + false, + "the rejected request's persisted body must not retain the raw video transcript" + ); + const transcriptField = ( + detail!.requestBody as { + messages: Array<{ content: Array<{ transcript?: unknown }> }>; + } + ).messages[0].content[1].transcript; + assert.equal(transcriptField, "[redacted-video-transcript]"); +}); + test("combo-exhausted rejection without a request body still logs cleanly (no request body available)", async () => { await recordRejectedRequestUsage({ status: 503, From d345520d72df041ac70546e4ce8b0f3fd536a563 Mon Sep 17 00:00:00 2001 From: Diego Rodrigues de Sa e Souza Date: Sat, 5 Sep 2026 03:15:03 -0300 Subject: [PATCH 12/18] fix(dashboard): read the combos usage-guide dismissal from an external store (base-red #12581) (#12671) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Merged. This removes the cause that #12607 had to freeze. `react-hooks/set-state-in-effect` on this file was living in `config/quality/eslint-suppressions.json` as a frozen count of 1 — the lint was green because the violation was suppressed, not because it was gone. `useSyncExternalStore` is the sanctioned shape for exactly this problem: `getServerSnapshot` supplies the SSR-safe default, `getSnapshot` reads localStorage after hydration, and the tree commits once instead of twice. The `storage` listener keeping other tabs in sync is a real bonus. The detail that makes this correct rather than merely lint-clean: you kept "hide for now" and "hide forever" as separate concepts — `usageGuideHiddenForNow` stays per-mount local state while only the persisted dismissal goes through the store. A naive conversion would have collapsed them and made the temporary hide survive a reload. Three things I added before merging: 1. **Dropped the `react-hooks/set-state-in-effect` entry from the suppressions file.** With the cause gone it becomes a stale allowlist entry, which is what the Fase 6A.3 stale-enforcement is built to flag. Verified: `eslint` on the file now reports only the 6 pre-existing `no-unused-vars`, which stay frozen. 2. **Updated the rationale comment above the hook** — it still described "correct it client-only, after hydration, in an effect", which is the shape you just removed. 3. **Rebaselined `combos/page.tsx` 5018 → 5066** in `file-size-baseline.json` with a dated annotation. The +48 lines are the module-scope store helpers; the cap is pre-authorized for legitimate growth and this is as legitimate as it gets. Validated on `release/v3.8.51`: `check-file-size` OK, `lint` clean, `typecheck:core` and `check:dashboard-typecheck` clean (207 pre-existing, all within baseline). --- ...v3851-combos-usage-guide-external-store.md | 1 + config/quality/eslint-suppressions.json | 3 - config/quality/file-size-baseline.json | 5 +- src/app/(dashboard)/dashboard/combos/page.tsx | 76 +++++++++++++++---- 4 files changed, 66 insertions(+), 19 deletions(-) create mode 100644 changelog.d/fixes/v3851-combos-usage-guide-external-store.md diff --git a/changelog.d/fixes/v3851-combos-usage-guide-external-store.md b/changelog.d/fixes/v3851-combos-usage-guide-external-store.md new file mode 100644 index 0000000000..ac4c882899 --- /dev/null +++ b/changelog.d/fixes/v3851-combos-usage-guide-external-store.md @@ -0,0 +1 @@ +- **fix(dashboard):** The Combos page usage guide now reads its dismissal through `useSyncExternalStore` instead of correcting SSR state inside an effect, removing an extra commit of the page tree on every load (and the `react-hooks/set-state-in-effect` error it raised). diff --git a/config/quality/eslint-suppressions.json b/config/quality/eslint-suppressions.json index fe963b547c..e10d1f6551 100644 --- a/config/quality/eslint-suppressions.json +++ b/config/quality/eslint-suppressions.json @@ -854,9 +854,6 @@ "src/app/(dashboard)/dashboard/combos/page.tsx": { "@typescript-eslint/no-unused-vars": { "count": 6 - }, - "react-hooks/set-state-in-effect": { - "count": 1 } }, "src/app/(dashboard)/dashboard/costs/CostOverviewTab.tsx": { diff --git a/config/quality/file-size-baseline.json b/config/quality/file-size-baseline.json index 56752d58b9..4bed8fd2e2 100644 --- a/config/quality/file-size-baseline.json +++ b/config/quality/file-size-baseline.json @@ -434,7 +434,7 @@ "open-sse/vendor/codex-chatgpt-web/bridge.ts": 1335, "src/app/(dashboard)/dashboard/HomePageClient.tsx": 1344, "src/app/(dashboard)/dashboard/api-manager/ApiManagerPageClient.tsx": 3186, - "src/app/(dashboard)/dashboard/combos/page.tsx": 5018, + "src/app/(dashboard)/dashboard/combos/page.tsx": 5066, "src/app/(dashboard)/dashboard/costs/CostOverviewTab.tsx": 1319, "src/app/(dashboard)/dashboard/endpoint/EndpointPageClient.tsx": 2491, "src/app/(dashboard)/dashboard/providers/[id]/components/modals/EditConnectionModal.tsx": 1631, @@ -643,5 +643,6 @@ "_rebaseline_2026_09_03_error_boundary_campaign": "Campanha de error-boundary (#12431 #12438 #12444 #12454 #12455 #12456 #12457 #12458 #12459 #12465 #12466 #12467 #12469 #12435), medido no tip com os 14 mergeados. open-sse/executors/codex.ts 1499->1505: os primeiros 4 (1499->1503) sao DRIFT ANTERIOR a esta campanha, ja presente no tip antes dela; os 2 ultimos (1503->1505) sao do #12444, que fecha o boundary de falha da resposta do Codex. Absorver o drift junto foi inevitavel porque o cap e um numero so, mas fica registrado aqui que 4 das 6 linhas nao sao desta leva. open-sse/vendor/codex-chatgpt-web/bridge.ts 1322->1335 (+13): tambem do #12444, no mesmo caminho de falha. NAO cobre open-sse/utils/stream.ts, que segue violando por drift anterior e independente.", "_rebaseline_2026_09_03_12352_apikey_acl": "PR #12352 (fix/api-key-create-acl-12275) crescimento proprio: src/lib/db/apiKeys.ts 1610->1625 (+15). A criacao de API key descartava a ACL enviada no payload; preservar essa ACL exige carregar e persistir o conjunto no mesmo chokepoint de INSERT do modulo de dominio, sem extracao possivel sem partir a funcao de criacao ao meio. Coberto pelos testes do proprio PR (54/54 focados na leva).", "_rebaseline_2026_09_03_houminxi_combo_stacked": "Leva HouMinXi (#12624 #12626 #12632 #12637): open-sse/services/combo.ts 4075->4080 (+5), medido no tip com os quatro mergeados. Cada PR registrou o proprio crescimento contra o tip de onde forkou (o #12637 ja subira o cap para 4075); as 5 linhas restantes so aparecem quando eles empilham, porque mais de um toca o mesmo chokepoint de scoring reset-aware em combo.ts. Fiacao em ponto existente, sem extracao possivel sem partir a funcao de selecao de alvos. Coberto por combo-strategies e reset-aware-request-scope-12600 (119/119 focados na leva).", - "_rebaseline_2026_09_04_12641_continuation_effective_input": "PR #12641 crescimento proprio: src/sse/handlers/chat.ts 2450->2454 (+4). A continuacao por previous_response_id encadeava a partir de clientRawRequest.body.input, que e capturado ANTES da reconstrucao do proprio chat.ts; quando o turno anterior ja era uma continuacao, esse campo guarda so o delta do cliente, e o erro se acumulava a cada salto ate a reconstrucao virar itens de tool sem prefixo. Persistir o input EFETIVO exige as linhas no ponto onde a reconstrucao termina, dentro do fluxo de despacho. Coberto por tests/unit/responses-continuation-store.test.ts (22/22 focados na leva)." + "_rebaseline_2026_09_04_12641_continuation_effective_input": "PR #12641 crescimento proprio: src/sse/handlers/chat.ts 2450->2454 (+4). A continuacao por previous_response_id encadeava a partir de clientRawRequest.body.input, que e capturado ANTES da reconstrucao do proprio chat.ts; quando o turno anterior ja era uma continuacao, esse campo guarda so o delta do cliente, e o erro se acumulava a cada salto ate a reconstrucao virar itens de tool sem prefixo. Persistir o input EFETIVO exige as linhas no ponto onde a reconstrucao termina, dentro do fluxo de despacho. Coberto por tests/unit/responses-continuation-store.test.ts (22/22 focados na leva).", + "_rebaseline_2026_09_05_12671_combos_usage_guide_external_store": "combos/page.tsx 5018 -> 5066: #12671 replaces the effect-based localStorage read with useSyncExternalStore; the +48 lines are the store helpers (subscribe/getSnapshot/getServerSnapshot/emit) hoisted to module scope, which is the sanctioned shape and what let the react-hooks/set-state-in-effect suppression be dropped." } diff --git a/src/app/(dashboard)/dashboard/combos/page.tsx b/src/app/(dashboard)/dashboard/combos/page.tsx index 1a666b3634..21ef2e2134 100644 --- a/src/app/(dashboard)/dashboard/combos/page.tsx +++ b/src/app/(dashboard)/dashboard/combos/page.tsx @@ -1,6 +1,15 @@ "use client"; -import { useState, useEffect, useCallback, useMemo, useRef, memo, Suspense } from "react"; +import { + useState, + useEffect, + useCallback, + useMemo, + useRef, + useSyncExternalStore, + memo, + Suspense, +} from "react"; import dynamic from "next/dynamic"; import Link from "next/link"; import { useRouter, useSearchParams } from "next/navigation"; @@ -388,6 +397,42 @@ const STRATEGY_RECOMMENDATIONS_FALLBACK = { const COMBO_USAGE_GUIDE_STORAGE_KEY = "omniroute:combos:hide-usage-guide"; +// The dismissal lives in localStorage, which SSR cannot read: a lazy useState +// initializer would render "not dismissed" on the server and the real value on +// the client, and correcting that in an effect is a synchronous setState inside +// an effect (react-hooks/set-state-in-effect) that costs an extra commit of this +// whole tree. useSyncExternalStore is the sanctioned shape for exactly this — +// getServerSnapshot supplies the SSR-safe default, getSnapshot reads the store +// after hydration, and the two handlers below notify subscribers instead of +// setting state. The `storage` listener keeps other tabs in sync for free. +const usageGuideListeners = new Set<() => void>(); + +function subscribeUsageGuide(onStoreChange: () => void): () => void { + usageGuideListeners.add(onStoreChange); + globalThis.addEventListener?.("storage", onStoreChange); + return () => { + usageGuideListeners.delete(onStoreChange); + globalThis.removeEventListener?.("storage", onStoreChange); + }; +} + +function emitUsageGuideChange(): void { + for (const listener of usageGuideListeners) listener(); +} + +function getUsageGuideSnapshot(): boolean { + try { + return globalThis.localStorage?.getItem(COMBO_USAGE_GUIDE_STORAGE_KEY) !== "1"; + } catch { + // Storage access errors (privacy mode / restricted environments) show the guide. + return true; + } +} + +function getUsageGuideServerSnapshot(): boolean { + return true; +} + // Pure predicate hoisted out of the page component to keep its cyclomatic budget flat // (check:complexity new-code mode). function isStaleIntelligentSelection( @@ -766,16 +811,18 @@ function CombosPageContent() { // real stored value -- exactly the kind of source React's hydration // mismatch check is built to catch, and in dev mode a mismatch forces a // full client-only re-render of this tree, discarding whatever the fetch - // effects below had already populated. Start with the SSR-safe default on - // both passes and correct it client-only, after hydration, in an effect. - const [showUsageGuide, setShowUsageGuide] = useState(true); - useEffect(() => { - try { - setShowUsageGuide(globalThis.localStorage?.getItem(COMBO_USAGE_GUIDE_STORAGE_KEY) !== "1"); - } catch { - // Ignore storage access errors (privacy mode / restricted environments) - } - }, []); + // effects below had already populated. useSyncExternalStore renders the + // SSR-safe default on both passes and switches to the stored value at + // hydration, without a second commit — see the store helpers above. + const usageGuideNotDismissed = useSyncExternalStore( + subscribeUsageGuide, + getUsageGuideSnapshot, + getUsageGuideServerSnapshot + ); + // "Hide" (as opposed to "hide forever") is intentionally per-mount: it is not + // persisted, and remounting the page brings the guide back — same as before. + const [usageGuideHiddenForNow, setUsageGuideHiddenForNow] = useState(false); + const showUsageGuide = usageGuideNotDismissed && !usageGuideHiddenForNow; const [recentlyCreatedCombo, setRecentlyCreatedCombo] = useState(""); const [creatingKimiPreset, setCreatingKimiPreset] = useState(false); const [comboDragIndex, setComboDragIndex] = useState(null); @@ -1006,17 +1053,18 @@ function CombosPageContent() { }; const handleHideUsageGuideForever = () => { - setShowUsageGuide(false); try { globalThis.localStorage?.setItem(COMBO_USAGE_GUIDE_STORAGE_KEY, "1"); } catch {} + emitUsageGuideChange(); }; const handleShowUsageGuide = () => { - setShowUsageGuide(true); try { globalThis.localStorage?.removeItem(COMBO_USAGE_GUIDE_STORAGE_KEY); } catch {} + setUsageGuideHiddenForNow(false); + emitUsageGuideChange(); }; const handleFilterChange = (nextFilter) => { @@ -1149,7 +1197,7 @@ function CombosPageContent() { {showUsageGuide && ( setShowUsageGuide(false)} + onHide={() => setUsageGuideHiddenForNow(true)} onHideForever={handleHideUsageGuideForever} onCreateCombo={() => setShowCreateModal(true)} /> From a9f7598c606ff9fafab43a166b0ff8e3c9fc33f0 Mon Sep 17 00:00:00 2001 From: Diego Rodrigues de Sa e Souza Date: Sat, 5 Sep 2026 03:15:25 -0300 Subject: [PATCH 13/18] feat(db): fail-closed previous_response_id continuation for redacted video turns (#12150 P2b) (#12707) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Merged, with one column-reconciliation gap closed. The fail-closed reasoning is right and the comments carry it well: a stored snapshot whose cues were replaced by `[redacted-video-transcript]` must not be rehydrated as continuation history, because forwarding placeholder text upstream as if it were the client's real turn is worse than making the client resend. Treating it exactly like `previous_response_not_found` means no new client-visible behaviour to document. Migration 173 does not collide — the tip runs to 172. **What I added:** `video_content_removed` to `ensureCallLogsColumns` in `src/lib/db/schemaColumns.ts`, plus a case in `tests/unit/db-schema-columns-split.test.ts`. `resolvePreviousResponseState` now SELECTs that column on every `previous_response_id` lookup. Migration 173 creates it, but this repo carries a separate reconciliation path for lineages that skipped a migration — and on such a database the SELECT would throw `no such column: video_content_removed` instead of failing closed. That is the same hole #12470 closed for `provider_connections.last_ping_at` earlier today, so the pattern was fresh. Verified red-then-green: stubbing the new reconciliation out drops the suite to 8/9; restored, 9/9. Validated on `release/v3.8.51`: `responses-continuation-store`, `save-call-log-persistence`, `video-bridge-log-redaction` and `db-schema-columns-split` all green (54 focused tests, 0 failures). `typecheck:core` and `lint` clean. The integration run logs `[DB] Added call_logs.video_content_removed column`, which is the reconciliation firing on a fresh test database. --- open-sse/handlers/chatCore.ts | 4 ++ open-sse/handlers/chatCore/attemptLogging.ts | 11 +++ .../173_call_logs_video_content_removed.sql | 16 +++++ src/lib/db/responsesContinuationStore.ts | 14 +++- src/lib/db/schemaColumns.ts | 8 +++ src/lib/usage/callLogs.ts | 11 ++- tests/unit/db-schema-columns-split.test.ts | 25 +++++++ .../unit/responses-continuation-store.test.ts | 68 ++++++++++++++++++- tests/unit/save-call-log-persistence.test.ts | 67 ++++++++++++++++++ tests/unit/video-bridge-log-redaction.test.ts | 35 ++++++++++ 10 files changed, 252 insertions(+), 7 deletions(-) create mode 100644 src/lib/db/migrations/173_call_logs_video_content_removed.sql diff --git a/open-sse/handlers/chatCore.ts b/open-sse/handlers/chatCore.ts index 622e934084..1b99f46cca 100644 --- a/open-sse/handlers/chatCore.ts +++ b/open-sse/handlers/chatCore.ts @@ -1097,6 +1097,10 @@ export async function handleChatCore({ // #12150 P1b surface 1: undefined for every non-video request (byte-identical // to before this param existed) — see applyVideoBridgeLogRedaction. videoBridgeLogRedaction: (videoBridgeLog as VideoBridgeLogParam | undefined)?.redaction, + // #12150 P2 surface 2: mark the persisted call_logs row so + // resolvePreviousResponseState refuses to rehydrate a snapshot whose video + // transcript was redacted. false for every non-video request. + videoContentRemoved: videoBridgeObserved, }); // Primary path: merge client model id + alias target so config on either key applies; resolved diff --git a/open-sse/handlers/chatCore/attemptLogging.ts b/open-sse/handlers/chatCore/attemptLogging.ts index 5ae0876f77..3192ddbd9b 100644 --- a/open-sse/handlers/chatCore/attemptLogging.ts +++ b/open-sse/handlers/chatCore/attemptLogging.ts @@ -250,6 +250,15 @@ export type PersistAttemptLogsContext = { * path) is never touched. Omitted/empty for every non-video request. */ videoBridgeLogRedaction?: VideoBridgeLogRedactionEntry[]; + /** + * #12150 P2 surface 2: true when the video-bridge guardrail observed and + * rewrote video parts on this request, so the persisted client snapshot had + * its transcript cues structurally redacted (videoBridgeObserved in + * chatCore.ts). Written to the `call_logs.video_content_removed` marker so + * `resolvePreviousResponseState` refuses to rehydrate this row as continuation + * history. Omitted/false for every non-video request. + */ + videoContentRemoved?: boolean; }; function toConnectionId(value: unknown): string | null { @@ -368,6 +377,7 @@ export function persistAttemptLogs(args: PersistAttemptLogsArgs, ctx: PersistAtt modelPinned, sessionTag, videoBridgeLogRedaction, + videoContentRemoved, } = ctx; const initialConnectionId = toConnectionId(connectionId); const finalConnectionId = toConnectionId(credentials?.connectionId) || initialConnectionId; @@ -499,6 +509,7 @@ export function persistAttemptLogs(args: PersistAttemptLogsArgs, ctx: PersistAtt modelPinned: modelPinned || false, sessionTag: sessionTag || null, responseId: extractResponsesId(sourceFormat, clientResponse), + videoContentRemoved: videoContentRemoved || false, }).catch(() => {}); // Emit the terminal request-lifecycle event to the live dashboard bus. `request.started` diff --git a/src/lib/db/migrations/173_call_logs_video_content_removed.sql b/src/lib/db/migrations/173_call_logs_video_content_removed.sql new file mode 100644 index 0000000000..f6c244e323 --- /dev/null +++ b/src/lib/db/migrations/173_call_logs_video_content_removed.sql @@ -0,0 +1,16 @@ +-- 173: mark call-log rows whose persisted client-request snapshot had its +-- video transcript content structurally redacted (#12150 P2 surface 2). +-- +-- Set to 1 by the call-log write path when the video-bridge guardrail observed +-- and rewrote video parts on this request (see videoBridgeObserved in +-- open-sse/handlers/chatCore.ts). resolvePreviousResponseState +-- (src/lib/db/responsesContinuationStore.ts) refuses to rehydrate a row so +-- marked: the stored snapshot carries [redacted-video-transcript] placeholders +-- in place of the client's real cues, so reconstructing a continuation off it +-- would forward the placeholder text upstream as if it were real history. +-- Failing closed makes the client resend full history instead, exactly like a +-- real previous_response_not_found. +-- +-- Default 0 (NOT NULL): every existing and non-video row is "nothing removed". + +ALTER TABLE call_logs ADD COLUMN video_content_removed INTEGER NOT NULL DEFAULT 0; diff --git a/src/lib/db/responsesContinuationStore.ts b/src/lib/db/responsesContinuationStore.ts index dce175dc92..90d96e8f6b 100644 --- a/src/lib/db/responsesContinuationStore.ts +++ b/src/lib/db/responsesContinuationStore.ts @@ -66,17 +66,27 @@ export function resolvePreviousResponseState( const db = getDbInstance(); const row = db .prepare( - `SELECT artifact_relpath, api_key_id FROM call_logs + `SELECT artifact_relpath, api_key_id, video_content_removed FROM call_logs WHERE response_id = ? AND detail_state = 'ready' ORDER BY timestamp DESC LIMIT 1` ) - .get(responseId) as { artifact_relpath: string | null; api_key_id: string | null } | undefined; + .get(responseId) as + | { artifact_relpath: string | null; api_key_id: string | null; video_content_removed: number } + | undefined; if (!row || !row.artifact_relpath) return null; // Tenant isolation: a response id is only ever handed back to the API key // that created it. A stored row with no api_key_id at all (no-log/legacy) // can never be resolved by any key -- fail closed rather than guess. if (!apiKeyId || row.api_key_id !== apiKeyId) return null; + // #12150 P2 surface 2: the persisted clientRawRequest snapshot on this row had + // its video transcript cues structurally redacted to [redacted-video-transcript] + // before storage (videoBridgeSnapshotRedaction, marker written by the call-log + // path). The stored input therefore no longer carries the client's real cue + // text -- reconstructing a continuation off it would forward the placeholder + // upstream as if it were genuine history. Fail closed so the client resends + // full history, exactly like a real previous_response_not_found. + if (row.video_content_removed === 1) return null; const { artifact, state } = readCallArtifact(row.artifact_relpath); if (state !== "ready" || !artifact?.pipeline) return null; diff --git a/src/lib/db/schemaColumns.ts b/src/lib/db/schemaColumns.ts index 068c140c69..b288072dac 100644 --- a/src/lib/db/schemaColumns.ts +++ b/src/lib/db/schemaColumns.ts @@ -240,6 +240,14 @@ export function ensureCallLogsColumns(db: SqliteDatabase) { db.exec("ALTER TABLE call_logs ADD COLUMN request_summary TEXT DEFAULT NULL"); console.log("[DB] Added call_logs.request_summary column"); } + // added by 173_call_logs_video_content_removed; back-filled here because + // resolvePreviousResponseState SELECTs it on every continuation lookup — a + // lineage that skipped the migration would throw "no such column" there + // rather than fail closed. Same hole #12470 closed for provider_connections. + if (!columnNames.has("video_content_removed")) { + db.exec("ALTER TABLE call_logs ADD COLUMN video_content_removed INTEGER NOT NULL DEFAULT 0"); + console.log("[DB] Added call_logs.video_content_removed column"); + } if (!columnNames.has("correlation_id")) { db.exec("ALTER TABLE call_logs ADD COLUMN correlation_id TEXT DEFAULT NULL"); console.log("[DB] Added call_logs.correlation_id column"); diff --git a/src/lib/usage/callLogs.ts b/src/lib/usage/callLogs.ts index 5f0e3a03fc..3e1ed314de 100644 --- a/src/lib/usage/callLogs.ts +++ b/src/lib/usage/callLogs.ts @@ -522,6 +522,11 @@ async function saveCallLogOperation(entry: any): Promise { // this row's artifact for OmniRoute-native continuation. See // src/lib/db/responsesContinuationStore.ts. responseId: typeof entry.responseId === "string" ? entry.responseId : null, + // #12150 P2 surface 2: 1 when this request's persisted client snapshot had + // its video transcript cues structurally redacted, so + // resolvePreviousResponseState refuses to rehydrate it as continuation + // history. See src/lib/db/responsesContinuationStore.ts. + videoContentRemoved: entry.videoContentRemoved ? 1 : 0, }; const requestSummary = noLogEnabled @@ -570,7 +575,8 @@ async function saveCallLogOperation(entry: any): Promise { combo_name, combo_step_id, combo_execution_key, error_summary, detail_state, artifact_relpath, artifact_size_bytes, artifact_sha256, has_request_body, has_response_body, has_pipeline_details, request_summary, - correlation_id, model_pinned, session_tag, response_id, error_type + correlation_id, model_pinned, session_tag, response_id, error_type, + video_content_removed ) VALUES ( @id, @timestamp, @method, @path, @status, @model, @requestedModel, @provider, @@ -581,7 +587,8 @@ async function saveCallLogOperation(entry: any): Promise { @comboName, @comboStepId, @comboExecutionKey, @errorSummary, @detailState, @artifactRelPath, @artifactSizeBytes, @artifactSha256, @hasRequestBody, @hasResponseBody, @hasPipelineDetails, @requestSummary, - @correlationId, @modelPinned, @sessionTag, @responseId, @errorType + @correlationId, @modelPinned, @sessionTag, @responseId, @errorType, + @videoContentRemoved ) ` ).run({ diff --git a/tests/unit/db-schema-columns-split.test.ts b/tests/unit/db-schema-columns-split.test.ts index 9e7e249a6a..0587817500 100644 --- a/tests/unit/db-schema-columns-split.test.ts +++ b/tests/unit/db-schema-columns-split.test.ts @@ -11,6 +11,7 @@ import { ensureUsageHistoryColumns, ensureProviderConnectionsColumns, ensureProxyLogsColumns, + ensureCallLogsColumns, hasColumn, hasTable, quoteIdentifier, @@ -182,3 +183,27 @@ test("ensureProviderConnectionsColumns back-fills last_ping columns on a pre-123 db.close?.(); } }); + +// #12150 P2b: `resolvePreviousResponseState` SELECTs `video_content_removed` on +// every previous_response_id lookup. Migration 173 adds it, but a lineage that +// skipped 173 would raise "no such column" there instead of failing closed, so +// the reconciliation has to carry it too — the hole #12470 closed for +// provider_connections. +test("ensureCallLogsColumns back-fills video_content_removed on a pre-173 lineage", () => { + const db = openMemoryDb(); + try { + db.exec("CREATE TABLE call_logs (id TEXT PRIMARY KEY, timestamp TEXT)"); + assert.equal(hasColumn(db, "call_logs", "video_content_removed"), false); + + ensureCallLogsColumns(db); + + assert.equal(hasColumn(db, "call_logs", "video_content_removed"), true); + const row = db + .prepare("SELECT video_content_removed AS v FROM call_logs WHERE id = ?") + .get("missing") as { v: number } | undefined; + assert.equal(row, undefined, "empty table — the column just has to be selectable"); + assert.doesNotThrow(() => ensureCallLogsColumns(db)); + } finally { + db.close?.(); + } +}); diff --git a/tests/unit/responses-continuation-store.test.ts b/tests/unit/responses-continuation-store.test.ts index 0b0bce17c3..6d8cb66c5f 100644 --- a/tests/unit/responses-continuation-store.test.ts +++ b/tests/unit/responses-continuation-store.test.ts @@ -27,13 +27,15 @@ function insertCallLog(row: { apiKeyId: string | null; detailState: string; artifactRelPath: string | null; + videoContentRemoved?: 0 | 1; }) { const db = core.getDbInstance(); db.prepare( `INSERT INTO call_logs (id, timestamp, method, path, status, model, provider, account, duration, - tokens_in, tokens_out, api_key_id, detail_state, artifact_relpath, response_id) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)` + tokens_in, tokens_out, api_key_id, detail_state, artifact_relpath, response_id, + video_content_removed) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)` ).run( row.id, new Date().toISOString(), @@ -49,7 +51,8 @@ function insertCallLog(row: { row.apiKeyId, row.detailState, row.artifactRelPath, - row.responseId + row.responseId, + row.videoContentRemoved ?? 0 ); } @@ -398,6 +401,65 @@ test("resolvePreviousResponseState fails closed on an empty output array even wi assert.equal(store.resolvePreviousResponseState("resp_gen-empty-output", "key-1"), null); }); +test("resolvePreviousResponseState fails closed when the row had video content removed (#12150 P2)", () => { + // #12150 P2 surface 2: the persisted clientRawRequest snapshot had its video + // transcript cues structurally redacted to [redacted-video-transcript] before + // storage (videoBridgeSnapshotRedaction). The stored input therefore no longer + // carries the client's real cue text -- reconstructing a continuation off it + // would forward the placeholder upstream as if it were genuine history. When the + // owning row is marked video_content_removed=1 this must fail closed (return + // null) so the client resends full history, exactly like previous_response_not_found, + // even though the artifact itself is otherwise a perfectly resolvable 'ready' row. + insertCallLog({ + id: "log-video-removed", + responseId: "resp_video_removed", + apiKeyId: "key-1", + detailState: "ready", + artifactRelPath: "2026-01-01/log-video-removed.json", + videoContentRemoved: 1, + }); + writeArtifact("2026-01-01/log-video-removed.json", { + clientRawRequest: { + body: { + input: [{ type: "message", role: "user", content: "[redacted-video-transcript]" }], + }, + }, + providerRequest: { body: { input: [] } }, + clientResponse: { + id: "resp_video_removed", + output: [{ type: "message", role: "assistant", content: "hello" }], + }, + }); + + assert.equal(store.resolvePreviousResponseState("resp_video_removed", "key-1"), null); +}); + +test("resolvePreviousResponseState still resolves a normal row (video_content_removed=0)", () => { + // Guard the fail-closed above does not over-fire: an ordinary row (the default + // 0) resolves exactly as before. + insertCallLog({ + id: "log-video-notremoved", + responseId: "resp_video_notremoved", + apiKeyId: "key-1", + detailState: "ready", + artifactRelPath: "2026-01-01/log-video-notremoved.json", + videoContentRemoved: 0, + }); + writeArtifact("2026-01-01/log-video-notremoved.json", { + clientRawRequest: { body: { input: [{ type: "message", role: "user", content: "hi" }] } }, + providerRequest: { body: { input: [{ type: "message", role: "user", content: "hi" }] } }, + clientResponse: { + id: "resp_video_notremoved", + output: [{ type: "message", role: "assistant", content: "hello" }], + }, + }); + + assert.deepEqual(store.resolvePreviousResponseState("resp_video_notremoved", "key-1"), { + input: [{ type: "message", role: "user", content: "hi" }], + output: [{ type: "message", role: "assistant", content: "hello" }], + }); +}); + test("resolvePreviousResponseState returns null when detail logging was never captured for this row", () => { insertCallLog({ id: "log-5", diff --git a/tests/unit/save-call-log-persistence.test.ts b/tests/unit/save-call-log-persistence.test.ts index 8d59ad18d6..7eb0cef2e5 100644 --- a/tests/unit/save-call-log-persistence.test.ts +++ b/tests/unit/save-call-log-persistence.test.ts @@ -152,6 +152,73 @@ test("saveCallLog persists modelPinned=false as 0", async () => { db.prepare("DELETE FROM call_logs WHERE id = ?").run(testId); }); +test("call_logs table has video_content_removed column", () => { + const db = getDbInstance(); + const columns = db.prepare("PRAGMA table_info(call_logs)").all() as { name: string }[]; + const colNames = columns.map((c) => c.name); + assert.ok( + colNames.includes("video_content_removed"), + "call_logs should have video_content_removed column" + ); +}); + +test("saveCallLog persists videoContentRemoved=true as 1 (#12150 P2)", async () => { + const db = getDbInstance(); + const testId = `test-videoremoved-${Date.now()}`; + + await saveCallLog({ + id: testId, + method: "POST", + path: "/v1/responses", + status: 200, + model: "video-model", + provider: "test-provider", + duration: 500, + tokens: { in: 10, out: 5 }, + videoContentRemoved: true, + }); + + const row = db + .prepare("SELECT id, video_content_removed FROM call_logs WHERE id = ?") + .get(testId) as Record; + assert.ok(row, "row should exist"); + assert.equal( + row.video_content_removed, + 1, + "video_content_removed should be 1 when videoContentRemoved=true" + ); + + db.prepare("DELETE FROM call_logs WHERE id = ?").run(testId); +}); + +test("saveCallLog defaults video_content_removed to 0 when absent (#12150 P2)", async () => { + const db = getDbInstance(); + const testId = `test-novideoremoved-${Date.now()}`; + + await saveCallLog({ + id: testId, + method: "POST", + path: "/v1/chat/completions", + status: 200, + model: "normal-model", + provider: "test-provider", + duration: 500, + tokens: { in: 10, out: 5 }, + }); + + const row = db + .prepare("SELECT id, video_content_removed FROM call_logs WHERE id = ?") + .get(testId) as Record; + assert.ok(row, "row should exist"); + assert.equal( + row.video_content_removed, + 0, + "video_content_removed should default to 0 when not provided" + ); + + db.prepare("DELETE FROM call_logs WHERE id = ?").run(testId); +}); + test("getCallLogs returns modelPinned boolean", async () => { const db = getDbInstance(); const testId = `test-pinned-roundtrip-${Date.now()}`; diff --git a/tests/unit/video-bridge-log-redaction.test.ts b/tests/unit/video-bridge-log-redaction.test.ts index 268102d246..d828cb31a4 100644 --- a/tests/unit/video-bridge-log-redaction.test.ts +++ b/tests/unit/video-bridge-log-redaction.test.ts @@ -149,6 +149,41 @@ test("persisted requestBody carries the placeholder and never the raw transcript ); }); +test("#12150 P2 surface 2: persistAttemptLogs marks the call_logs row video_content_removed=1 when ctx.videoContentRemoved is true", async () => { + // The continuation fail-closed (resolvePreviousResponseState) depends on this + // marker being written for any request whose stored client snapshot had its + // video transcript redacted. This proves the ctx.videoContentRemoved signal + // reaches the persisted row; the row is the exact thing the continuation store + // reads back. + const id = "video-marker-1"; + persistAttemptLogs( + { status: 200, tokens: { input: 1, output: 2 } }, + baseCtx({ pendingRequestId: id, videoContentRemoved: true }) + ); + const row = await pollForCallLog(id); + assert.ok(row, "call log row should be persisted"); + const marker = coreDb + .getDbInstance() + .prepare("SELECT video_content_removed FROM call_logs WHERE id = ?") + .get(id) as { video_content_removed: number }; + assert.equal(marker.video_content_removed, 1); +}); + +test("#12150 P2 surface 2: the marker defaults to 0 for an ordinary (non-video) request", async () => { + const id = "video-marker-control-1"; + persistAttemptLogs( + { status: 200, tokens: { input: 1, output: 2 } }, + baseCtx({ pendingRequestId: id }) + ); + const row = await pollForCallLog(id); + assert.ok(row); + const marker = coreDb + .getDbInstance() + .prepare("SELECT video_content_removed FROM call_logs WHERE id = ?") + .get(id) as { video_content_removed: number }; + assert.equal(marker.video_content_removed, 0); +}); + test("control: without a redaction map the persisted requestBody keeps the original text (model path untouched)", async () => { const id = "video-control-1"; persistAttemptLogs( From 9d1a896c6058b2ade94c9078c2e54377b9aa76d3 Mon Sep 17 00:00:00 2001 From: Diego Rodrigues de Sa e Souza Date: Sat, 5 Sep 2026 03:15:30 -0300 Subject: [PATCH 14/18] fix(tests): retire dead model ids from the chat-pipeline integration suite (base-red #12581) (#12670) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Merged. It does what it says, and it also uncovered something — details below so the follow-up is not mistaken for a regression from this PR. Measured on `release/v3.8.51`, `tests/integration/chat-pipeline.test.ts`: | | line 580 | line 994 | line 1599 | |---|---|---|---| | tip | `410 !== 200` | `410 !== 200` | `502 !== 200` | | tip + this PR | passes | passes | passes | All three were retired model ids reaching the router and coming back 410/502. Swapping them for live ones is exactly the right fix and takes the suite from 25/28 to 27/28. **The one that remains, and why it is not yours:** with the 410 gone, `chat pipeline persists Codex responses cache and reasoning tokens to call logs` now runs past `assert.equal(response.status, 200)` and reaches line 592, where `callLog.provider` is `openai` and the test expects `codex`. That assertion was simply never reached before — the 410 short-circuited the test at line 580. I checked whether the model id chosen here was the cause, since `gpt-5.6-sol` is declared by 12 providers (`openai`, `github`, `cursor`, `kiro`, …). It is not: re-running with `gpt-5.3-codex-spark`, which only the `codex` provider declares, produces the identical `openai !== codex`. So it is provider resolution or the `seedConnection("codex")` harness, not catalog ambiguity. I reverted that experiment — this merged exactly as you wrote it. Filing that as its own issue with the trace. --- tests/integration/chat-pipeline.test.ts | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/tests/integration/chat-pipeline.test.ts b/tests/integration/chat-pipeline.test.ts index a7d441a9a5..2b0bf96d33 100644 --- a/tests/integration/chat-pipeline.test.ts +++ b/tests/integration/chat-pipeline.test.ts @@ -170,7 +170,7 @@ function buildOpenAIToolCallResponse({ ); } -function buildClaudeResponse(text = "ok", model = "claude-3-5-sonnet-20241022") { +function buildClaudeResponse(text = "ok", model = "claude-sonnet-4-6") { return new Response( JSON.stringify({ id: "msg_json", @@ -286,7 +286,7 @@ function buildOpenAIStreamResponse(text = "streamed from openai") { function buildOpenAIResponsesSSE({ text = "responses streamed from codex", - model = "gpt-5.1-codex", + model = "gpt-5.6-sol", usage = null, } = {}) { return new Response( @@ -567,7 +567,7 @@ test("chat pipeline persists Codex responses cache and reasoning tokens to call buildRequest({ url: "http://localhost/v1/responses", body: { - model: "codex/gpt-5.1-codex", + model: "codex/gpt-5.6-sol", stream: false, input: "Persist cache + reasoning usage", }, @@ -983,7 +983,7 @@ test("chat pipeline translates OpenAI requests to Claude and returns OpenAI-shap const response = await handleChat( buildRequest({ body: { - model: "claude/claude-3-5-sonnet-20241022", + model: "claude/claude-sonnet-4-6", stream: false, messages: [{ role: "user", content: "Hello Claude" }], }, @@ -1566,7 +1566,7 @@ test("chat pipeline falls back across combo models when the first provider fails name: "combo-fallback", strategy: "priority", config: { maxRetries: 0, retryDelayMs: 0 }, - models: ["openai/gpt-4o-mini", "claude/claude-3-5-sonnet-20241022"], + models: ["openai/gpt-4o-mini", "claude/claude-sonnet-4-6"], }); const attempts = []; From 92a617c23f47ecb5e82976f0140f9ac133117c60 Mon Sep 17 00:00:00 2001 From: tom <7740810+thomasmaerz@users.noreply.github.com> Date: Sat, 5 Sep 2026 19:30:48 -0700 Subject: [PATCH 15/18] fix(sse): re-enable prompt compression for native Codex passthrough (#12834) Native Codex passthrough (POST /v1/responses, provider=codex) was unconditionally excluded from prompt compression, writing only skip_reason='excluded' analytics rows. Prompt compression now depends only on the operator exclusions list; reactive compaction and combo overflow fail-fast intentionally still bypass (prompt-only scope). Closes #12793 Regression guard: tests/unit/codex-prompt-compression-passthrough.test.ts --- open-sse/handlers/chatCore.ts | 15 ++++- ...dex-prompt-compression-passthrough.test.ts | 59 +++++++++++++++++++ ...context-overflow-compression-probe.test.ts | 11 ++-- 3 files changed, 78 insertions(+), 7 deletions(-) create mode 100644 tests/unit/codex-prompt-compression-passthrough.test.ts diff --git a/open-sse/handlers/chatCore.ts b/open-sse/handlers/chatCore.ts index 1b99f46cca..2c4a8babae 100644 --- a/open-sse/handlers/chatCore.ts +++ b/open-sse/handlers/chatCore.ts @@ -1356,9 +1356,18 @@ export async function handleChatCore({ const compressionSettings: CompressionConfig | null = compressionSettingsResult.settings; // #8034 — operator-named model/endpoint exclusions bypass the whole pipeline, exactly // like compression being globally disabled, so the body is provably byte-identical. - const compressionExcluded = - nativeCodexPassthrough || - isCompressionExcluded({ provider, model: effectiveModel }, compressionSettings?.exclusions); + // Native Codex passthrough is deliberately NOT part of this exclusion: prompt + // compression runs through adaptBodyForCompression() (Responses input[] → messages + // → restore) with codex tool-output eligibility guards, so native contexts still + // compress (regression: #8933 introduced the passthrough bypass, landed on release + // via #11088, silencing codex analytics to skip_reason='excluded'). Reactive + // compaction + combo overflow fail-fast below still bypass native passthrough — + // intentionally left for follow-up. Operators who want byte-identical passthrough + // can add `codex/*` to the exclusions list. + const compressionExcluded = isCompressionExcluded( + { provider, model: effectiveModel }, + compressionSettings?.exclusions + ); // A per-key opt-out is a request-scoped hard kill for prompt compression. It // deliberately does not disable the independent reactive context-fit safety // passes, matching the existing x-omniroute-compression: off contract. diff --git a/tests/unit/codex-prompt-compression-passthrough.test.ts b/tests/unit/codex-prompt-compression-passthrough.test.ts new file mode 100644 index 0000000000..120c4e1b35 --- /dev/null +++ b/tests/unit/codex-prompt-compression-passthrough.test.ts @@ -0,0 +1,59 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { readFileSync } from "node:fs"; +import { + isCompressionExcluded, + normalizeCompressionExclusions, +} from "../../open-sse/services/compression/exclusions.ts"; + +const chatCoreSource = readFileSync( + new URL("../../open-sse/handlers/chatCore.ts", import.meta.url), + "utf8" +); + +// Regression guard for https://github.com/diegosouzapw/OmniRoute/issues/12793: +// native Codex passthrough (`POST /v1/responses`, provider `codex`) silently stopped +// producing compression analytics (100% `skip_reason='excluded'`) after #8933 added +// `nativeCodexPassthrough ||` to the prompt-compression exclusion (landed on release +// via #11088). Prompt compression must depend ONLY on the operator exclusions list. + +test("codex prompt compression is not gated on native passthrough", () => { + const match = chatCoreSource.match(/const compressionExcluded =([\s\S]*?);/); + assert.ok(match, "compressionExcluded assignment must exist in chatCore.ts"); + assert.doesNotMatch( + match[1], + /nativeCodexPassthrough/, + "prompt-compression exclusion must not reference nativeCodexPassthrough" + ); + assert.match(match[1], /isCompressionExcluded/); +}); + +test("codex target is compressible by default; operators can still opt out via exclusions", () => { + assert.equal( + isCompressionExcluded( + { provider: "codex", model: "gpt-5.6-terra" }, + normalizeCompressionExclusions([]) + ), + false + ); + assert.equal( + isCompressionExcluded( + { provider: "codex", model: "gpt-5.6-terra" }, + normalizeCompressionExclusions(["codex/*"]) + ), + true + ); +}); + +test("prompt-only scope: reactive compaction still bypasses native passthrough", () => { + // Deliberately left for follow-up (overflow fail-fast + history-rewriting safety). + // If these gates are ever lifted, update the PR body notes, not just this test. + assert.match( + chatCoreSource, + /reactiveContextCompactionEnabled\s*&&\s*!nativeCodexPassthrough\s*&&\s*estimatedTokens\s*>\s*threshold/ + ); + assert.match( + chatCoreSource, + /reactiveContextCompactionEnabled\s*&&\s*!nativeCodexPassthrough\s*&&\s*finalEstimatedInputTokens\s*>=\s*finalContextLimit/ + ); +}); diff --git a/tests/unit/combo-context-overflow-compression-probe.test.ts b/tests/unit/combo-context-overflow-compression-probe.test.ts index c854339c69..1b422cd9f9 100644 --- a/tests/unit/combo-context-overflow-compression-probe.test.ts +++ b/tests/unit/combo-context-overflow-compression-probe.test.ts @@ -148,12 +148,15 @@ test("#10225 combo keeps the fast 400 when compression is disabled", async () => // #10501-sweep #10503 — the deferral above is NOT target-aware by default: it only // checks operator-named compression exclusions, never whether chatCore will actually -// attempt compression for the resolved target. handleChatCore.ts unconditionally sets -// `compressionExcluded = nativeCodexPassthrough || ...` for a verified native Codex -// Responses passthrough target (open-sse/handlers/chatCore.ts) — deferring the +// attempt compression for the resolved target. chatCore's *reactive* compaction and +// last-resort gates still bypass native Codex passthrough targets +// (open-sse/handlers/chatCore.ts `!nativeCodexPassthrough` checks) — deferring the // preflight there means an oversized request sails past BOTH gates uncompressed. These // tests pin the fix: a native-codex-passthrough target must never count toward "can // compress", so the hard preflight stays active and no upstream dispatch happens. +// NOTE: prompt (proactive) compression DOES run for native passthrough since the +// codex analytics fix (see chatCore.ts `compressionExcluded`); only the reactive / +// combo-deferral bypasses remain. That split is intentional prompt-only scope. // NOTE on `clientManagedResponsesContext: false` below: these tests deliberately do // NOT set it, to isolate the fix from the PRE-EXISTING, unrelated early-return a few // lines above in knownContextOverflow.ts ("Native Codex Responses clients compact @@ -163,7 +166,7 @@ test("#10225 combo keeps the fast 400 when compression is disabled", async () => // circuits to true for `provider === "codex"` regardless of verification (see // passthroughHelpers.ts), so an UNVERIFIED request that nonetheless targets a `codex` // combo member over `/v1/responses` in openai-responses format still hits chatCore's -// compression bypass — exactly the gap `sourceFormat`/`endpointPath` (not the looser +// reactive compression bypass — exactly the gap `sourceFormat`/`endpointPath` (not the looser // `clientManagedResponsesContext` flag) now closes. test("#10503 handleComboChat: native-codex-passthrough pool fails FAST locally, zero upstream dispatches", async () => { saveModelsDevCapabilities({ codex: { "gpt-5.6-terra": capabilityEntry(272_000) } }); From 2b2d34eb53424d8ae24d2053364b035f1e0fd870 Mon Sep 17 00:00:00 2001 From: Soroush Ahmadi Date: Sun, 6 Sep 2026 06:02:02 +0330 Subject: [PATCH 16/18] fix(cursor): guard non-array tool_calls in request translator (#12691) --- changelog.d/fixes/12689-cursor-toolcalls-guard.md | 1 + open-sse/translator/request/openai-to-cursor.ts | 4 ++-- tests/unit/translator-openai-to-cursor.test.ts | 12 ++++++++++++ 3 files changed, 15 insertions(+), 2 deletions(-) create mode 100644 changelog.d/fixes/12689-cursor-toolcalls-guard.md diff --git a/changelog.d/fixes/12689-cursor-toolcalls-guard.md b/changelog.d/fixes/12689-cursor-toolcalls-guard.md new file mode 100644 index 0000000000..2c1e54e4e6 --- /dev/null +++ b/changelog.d/fixes/12689-cursor-toolcalls-guard.md @@ -0,0 +1 @@ +- **fix(cursor):** a non-array `tool_calls` on an assistant message no longer crashes the cursor request translator with a `TypeError`; both loops now require an array ([#12689](https://github.com/diegosouzapw/OmniRoute/issues/12689)) diff --git a/open-sse/translator/request/openai-to-cursor.ts b/open-sse/translator/request/openai-to-cursor.ts index 2c488c4618..0128a0d93a 100644 --- a/open-sse/translator/request/openai-to-cursor.ts +++ b/open-sse/translator/request/openai-to-cursor.ts @@ -80,7 +80,7 @@ function convertMessages(messages) { }; for (const msg of messages) { - if (msg.role === "assistant" && msg.tool_calls) { + if (msg.role === "assistant" && Array.isArray(msg.tool_calls)) { for (const tc of msg.tool_calls) { rememberToolMeta(tc.id || "", tc.function?.name || "tool"); } @@ -166,7 +166,7 @@ function convertMessages(messages) { const content = extractContent(msg.content); - if (msg.role === "assistant" && msg.tool_calls && msg.tool_calls.length > 0) { + if (msg.role === "assistant" && Array.isArray(msg.tool_calls) && msg.tool_calls.length > 0) { const assistantMsg: { role: string; content?: string; diff --git a/tests/unit/translator-openai-to-cursor.test.ts b/tests/unit/translator-openai-to-cursor.test.ts index 2d818f8a09..b4fd51625a 100644 --- a/tests/unit/translator-openai-to-cursor.test.ts +++ b/tests/unit/translator-openai-to-cursor.test.ts @@ -199,3 +199,15 @@ test("OpenAI -> Cursor accepts shorthand image_url string form", () => { { type: "image_url", image_url: { url: "data:image/png;base64,AAAA" } }, ]); }); + +test("#12689: non-array tool_calls is skipped instead of throwing TypeError", () => { + for (const toolCalls of [5, { a: 1 }, "x"]) { + const result = buildCursorRequest( + "gpt-4o", + { messages: [{ role: "assistant", content: "Working", tool_calls: toolCalls }] }, + false, + null + ); + assert.ok(result); + } +}); From f9a1cc8a9b7336e394ef921c753f5da691798df9 Mon Sep 17 00:00:00 2001 From: groovecityJO Date: Sat, 5 Sep 2026 19:32:26 -0700 Subject: [PATCH 17/18] fix: resolve SqliteError no such table compression_run_telemetry during cleanup (#12682) --- src/lib/db/cleanup.ts | 3 +++ src/lib/db/compressionRunTelemetry.ts | 5 ++--- 2 files changed, 5 insertions(+), 3 deletions(-) diff --git a/src/lib/db/cleanup.ts b/src/lib/db/cleanup.ts index 6f0cd95930..a837909df7 100644 --- a/src/lib/db/cleanup.ts +++ b/src/lib/db/cleanup.ts @@ -16,6 +16,7 @@ import { tableExists, type DeleteByPeriodTarget, } from "./cleanup/usagePurge"; +import { ensureCompressionRunTelemetryTable } from "./compressionRunTelemetry"; interface CleanupResult { deleted: number; @@ -380,6 +381,7 @@ export async function cleanupXpAuditLog(): Promise { */ export async function cleanupCompressionRunTelemetry(): Promise { const db = getDbInstance(); + ensureCompressionRunTelemetryTable(); const retention = getRetentionSettings(); const retentionDays = retention.compressionRunTelemetry; @@ -666,6 +668,7 @@ export async function resetUsageHistory(period: string): Promise; } -function ensureCompressionRunTelemetryTable(): void { +export function ensureCompressionRunTelemetryTable(): void { const db = getDbInstance(); // `CREATE TABLE IF NOT EXISTS` is idempotent and cheap; run it unconditionally so the // table self-heals if it was dropped (e.g. test isolation) under the same db handle. @@ -114,8 +114,7 @@ export function getCompressionRunTelemetrySummary(): CompressionRunTelemetrySumm try { const styles = JSON.parse(row.output_styles) as Array<{ id: string }>; for (const style of styles) { - summary.appliedStyleCounts[style.id] = - (summary.appliedStyleCounts[style.id] ?? 0) + 1; + summary.appliedStyleCounts[style.id] = (summary.appliedStyleCounts[style.id] ?? 0) + 1; } } catch { // ignore a corrupt JSON cell From b345c7f6cd4e1590d1177540813302375a75e332 Mon Sep 17 00:00:00 2001 From: Dizzle <112548150+maxmad64bis@users.noreply.github.com> Date: Sun, 6 Sep 2026 23:36:27 +0200 Subject: [PATCH 18/18] feat(opencode): opencode v2 plugin publishing the OmniRoute catalog (#12870) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit opencode v2 loads plugins through a contract the existing @omniroute/opencode-plugin cannot satisfy: v1 exports plugin factories with an auth/provider/config/tool hook object, v2 expects a default define({id, setup}) carrying catalog and integration domains. One package would have to satisfy both loaders from a single entrypoint. An opencode v2 install therefore has no route to an OmniRoute gateway at all: no model discovery, no combos, no enrichment. This adds @omniroute/opencode-plugin-v2, a self-contained package. The v1 plugin is untouched, so v1 users see no move, no migration and no breaking version. The two packages deliberately share no code and no release: the mapping logic here began as a port of v1's and now lives in this package, which keeps either one free to change without a coordinated publish. The plugin publishes models, combos and auto-combos into the host catalog, refreshes them lazily behind a 300s TTL, and keeps serving the last known catalog from an on-disk snapshot when the gateway is unreachable. Publishing is staged: models and combos are what a catalog is, so they go out as soon as they are known, while auto-combos, the provider list and the enrichment overlay fold into the snapshot when they land. Gating the publish on all of them made the catalog hostage to the slowest source — a gateway that accepts the connection and never answers /api/combos/auto left everything unpublished until that fetch timed out, which is longer than a short-lived host stays alive. Display names carry what the gateway knows about a model: the upstream provider it routes to, whether it is free, and the budget that comes with it. Those parts were already fetched and then dropped, so two connections selling the same model looked identical in the picker. The provider prefix can be turned off with `providerTag: false`. The on-disk snapshot carries that overlay too, under a size cap, so a cold start opens on named models rather than raw ids. The host is asked to reload only when the catalog or the overlay actually moved, never once per refresh window. The gateway key comes from the host credential store when one is connected, so connecting the integration from opencode is enough and no secret needs to sit in opencode.json; a plugin option and an environment variable remain as fallbacks, and a host too old to expose a credential store still loads. Nothing is silent when a key is missing or refused: an absent key is named once at startup with the three ways to supply one, and an enrichment source the gateway rejects is reported per endpoint with what the catalog loses. Those three failures used to be empty catch blocks, which turned a management token the gateway refuses into a catalog of raw model ids with no explanation. Tool calling to Gemini keeps working. Gemini answers 400 INVALID_ARGUMENT for an entire request whose tool declarations carry $schema, $ref or additionalProperties. The v1 plugin handled it by wrapping fetch and rewriting the JSON body; v2 does it on the language model, where the tools are still structured data, and only for Gemini models of this provider. It can be turned off with geminiSanitization: false, and a host exposing no aisdk domain loads without it. The catalog contract itself is a moving target, so the plugin adapts to the host instead of assuming one shape. The released CLI keeps the aisdk package, the endpoint (as settings.baseURL), the request headers and the variant options directly on the model and provider; the current SDK types keep the same information inside an api block. Writing only the api block yields a catalog the released CLI lists but cannot route. Rather than key off a version list that goes stale on the next release, the plugin reads the shape the host seeds into the catalog draft and publishes accordingly: a seed with a top-level package and no api block gets both field sets, a seed with an api block gets that block alone, and an undisclosed seed gets both. None of the legacy keys collide with a key of the current types, so the two shapes coexist on one object, variants included. Four v1 behaviours are deliberately not carried over, because v2 either owns them or no longer needs them: the plugin-side debug log (the host has its own logging), the compression-metadata suffix on combo names, the MCP auto-emit (the v2 host owns MCP), and the omni-sync command plus its background timer (the TTL and a content fingerprint drive catalog.reload instead). A refresh never downgrades what is already published: the previous overlay is carried forward until the new one lands, so names, pricing and the usable filter no longer drop out for the length of every TTL window. The disk snapshot is read after the credential is resolved, because it is keyed by that credential — reading it earlier looked up the identity the options carry rather than the one in use, and rejected a perfectly good catalog exactly when the gateway was down. The tool-schema cleaner now knows where a schema ends and a property name begins. Stripping keywords by name anywhere in the tree deleted a tool parameter called `ref` while leaving it in `required`, handing the model a schema it could not satisfy; a `$ref` it cannot resolve now forwards the tool untouched instead of widening it to accept anything. Gemini detection is anchored on the model family, so `gemini-compatible-proxy` is no longer treated as a Gemini model. A source the gateway refuses is reported on the library entry point as well, not only through the plugin, so the usable-provider filter can no longer disable itself in silence. `providerId` is bounded to a safe character set because it reaches a filesystem path, `hiddenModels` covers combos as it already covered models, the Anthropic block gets the gateway root rather than a doubled `/v1`, an unparseable tool schema forwards the tool instead of failing the request, and the package typechecks under the same settings as the v1 plugin. CI mirrors the existing plugin workflow: install, build and test on Node 22 and 24, for both packages. The plugin SDK stays pinned, and the host-shape assertions carry the risk of a contract move rather than a check against a rolling upstream tag. Co-authored-by: Max --- .github/workflows/npm-publish.yml | 89 + .github/workflows/opencode-plugin-ci.yml | 38 +- @omniroute/opencode-plugin-v2/.gitignore | 4 + @omniroute/opencode-plugin-v2/LICENSE | 21 + @omniroute/opencode-plugin-v2/README.md | 107 + @omniroute/opencode-plugin-v2/RELEASE.md | 9 + .../opencode-plugin-v2/package-lock.json | 2364 +++++++++++++++++ @omniroute/opencode-plugin-v2/package.json | 67 + @omniroute/opencode-plugin-v2/src/cache.ts | 211 ++ @omniroute/opencode-plugin-v2/src/catalog.ts | 798 ++++++ @omniroute/opencode-plugin-v2/src/compat.ts | 66 + .../opencode-plugin-v2/src/credentials.ts | 101 + .../src/enrichment-report.ts | 41 + .../opencode-plugin-v2/src/gemini-language.ts | 43 + @omniroute/opencode-plugin-v2/src/index.ts | 539 ++++ @omniroute/opencode-plugin-v2/src/options.ts | 117 + .../src/shared/auto-combos.ts | 219 ++ .../src/shared/combos-map.ts | 254 ++ .../opencode-plugin-v2/src/shared/enrich.ts | 606 +++++ .../src/shared/fingerprint.ts | 127 + .../opencode-plugin-v2/src/shared/gemini.ts | 166 ++ .../opencode-plugin-v2/src/shared/index.ts | 9 + .../opencode-plugin-v2/src/shared/logger.ts | 81 + .../src/shared/models-map.ts | 323 +++ .../opencode-plugin-v2/src/shared/naming.ts | 295 ++ .../opencode-plugin-v2/src/shared/usable.ts | 171 ++ .../tests/api-package.test.ts | 93 + .../tests/auto-combos.test.ts | 196 ++ .../tests/cache-ttl-snapshot.test.ts | 378 +++ .../opencode-plugin-v2/tests/catalog.test.ts | 330 +++ .../opencode-plugin-v2/tests/compat.test.ts | 34 + .../tests/credentials.test.ts | 128 + .../tests/enrichment-attribution.test.ts | 59 + .../tests/enrichment-render.test.ts | 64 + .../tests/enrichment-report.test.ts | 106 + .../tests/enrichment.test.ts | 124 + .../tests/fixtures/catalog.json | 68 + .../tests/fixtures/v1-parity.json | 301 +++ .../tests/gemini-language.test.ts | 226 ++ .../tests/host-contract.test.ts | 164 ++ .../opencode-plugin-v2/tests/index.test.ts | 244 ++ .../tests/management-token.test.ts | 233 ++ .../tests/nested-combos.test.ts | 236 ++ .../opencode-plugin-v2/tests/options.test.ts | 129 + .../opencode-plugin-v2/tests/parity.test.ts | 224 ++ .../tests/publish-guard.test.ts | 137 + .../tests/refresh-failopen.test.ts | 214 ++ .../tests/shared-anthropic-prefixes.test.ts | 165 ++ .../tests/shared-auto-combos.test.ts | 196 ++ .../tests/shared-combos-map.test.ts | 77 + .../tests/shared-enrich.test.ts | 47 + .../tests/shared-enrichment-fetcher.test.ts | 125 + .../shared-enrichment-source-errors.test.ts | 56 + .../tests/shared-fetch-timeout.test.ts | 49 + .../tests/shared-fingerprint.test.ts | 62 + .../tests/shared-gemini.test.ts | 144 + .../tests/shared-logger.test.ts | 130 + .../tests/shared-models-map.test.ts | 94 + .../tests/shared-naming.test.ts | 75 + .../tests/shared-usable.test.ts | 248 ++ .../tests/smoke-types.test.ts | 93 + .../tests/snapshot-stale-entries.test.ts | 219 ++ .../tests/staged-refresh.test.ts | 452 ++++ .../tests/timeouts-logger.test.ts | 161 ++ .../tests/usable-only.test.ts | 222 ++ .../tests/warm-snapshot-identity.test.ts | 88 + @omniroute/opencode-plugin-v2/tsconfig.json | 24 + @omniroute/opencode-plugin-v2/tsup.config.ts | 16 + .../features/12870-opencode-plugin-v2.md | 1 + docs/README.md | 1 + docs/guides/CLI-INTEGRATIONS.md | 10 + docs/guides/OPENCODE-V2-PLUGIN.md | 133 + docs/guides/REMOTE-MODE.md | 5 + 73 files changed, 13440 insertions(+), 7 deletions(-) create mode 100644 @omniroute/opencode-plugin-v2/.gitignore create mode 100644 @omniroute/opencode-plugin-v2/LICENSE create mode 100644 @omniroute/opencode-plugin-v2/README.md create mode 100644 @omniroute/opencode-plugin-v2/RELEASE.md create mode 100644 @omniroute/opencode-plugin-v2/package-lock.json create mode 100644 @omniroute/opencode-plugin-v2/package.json create mode 100644 @omniroute/opencode-plugin-v2/src/cache.ts create mode 100644 @omniroute/opencode-plugin-v2/src/catalog.ts create mode 100644 @omniroute/opencode-plugin-v2/src/compat.ts create mode 100644 @omniroute/opencode-plugin-v2/src/credentials.ts create mode 100644 @omniroute/opencode-plugin-v2/src/enrichment-report.ts create mode 100644 @omniroute/opencode-plugin-v2/src/gemini-language.ts create mode 100644 @omniroute/opencode-plugin-v2/src/index.ts create mode 100644 @omniroute/opencode-plugin-v2/src/options.ts create mode 100644 @omniroute/opencode-plugin-v2/src/shared/auto-combos.ts create mode 100644 @omniroute/opencode-plugin-v2/src/shared/combos-map.ts create mode 100644 @omniroute/opencode-plugin-v2/src/shared/enrich.ts create mode 100644 @omniroute/opencode-plugin-v2/src/shared/fingerprint.ts create mode 100644 @omniroute/opencode-plugin-v2/src/shared/gemini.ts create mode 100644 @omniroute/opencode-plugin-v2/src/shared/index.ts create mode 100644 @omniroute/opencode-plugin-v2/src/shared/logger.ts create mode 100644 @omniroute/opencode-plugin-v2/src/shared/models-map.ts create mode 100644 @omniroute/opencode-plugin-v2/src/shared/naming.ts create mode 100644 @omniroute/opencode-plugin-v2/src/shared/usable.ts create mode 100644 @omniroute/opencode-plugin-v2/tests/api-package.test.ts create mode 100644 @omniroute/opencode-plugin-v2/tests/auto-combos.test.ts create mode 100644 @omniroute/opencode-plugin-v2/tests/cache-ttl-snapshot.test.ts create mode 100644 @omniroute/opencode-plugin-v2/tests/catalog.test.ts create mode 100644 @omniroute/opencode-plugin-v2/tests/compat.test.ts create mode 100644 @omniroute/opencode-plugin-v2/tests/credentials.test.ts create mode 100644 @omniroute/opencode-plugin-v2/tests/enrichment-attribution.test.ts create mode 100644 @omniroute/opencode-plugin-v2/tests/enrichment-render.test.ts create mode 100644 @omniroute/opencode-plugin-v2/tests/enrichment-report.test.ts create mode 100644 @omniroute/opencode-plugin-v2/tests/enrichment.test.ts create mode 100644 @omniroute/opencode-plugin-v2/tests/fixtures/catalog.json create mode 100644 @omniroute/opencode-plugin-v2/tests/fixtures/v1-parity.json create mode 100644 @omniroute/opencode-plugin-v2/tests/gemini-language.test.ts create mode 100644 @omniroute/opencode-plugin-v2/tests/host-contract.test.ts create mode 100644 @omniroute/opencode-plugin-v2/tests/index.test.ts create mode 100644 @omniroute/opencode-plugin-v2/tests/management-token.test.ts create mode 100644 @omniroute/opencode-plugin-v2/tests/nested-combos.test.ts create mode 100644 @omniroute/opencode-plugin-v2/tests/options.test.ts create mode 100644 @omniroute/opencode-plugin-v2/tests/parity.test.ts create mode 100644 @omniroute/opencode-plugin-v2/tests/publish-guard.test.ts create mode 100644 @omniroute/opencode-plugin-v2/tests/refresh-failopen.test.ts create mode 100644 @omniroute/opencode-plugin-v2/tests/shared-anthropic-prefixes.test.ts create mode 100644 @omniroute/opencode-plugin-v2/tests/shared-auto-combos.test.ts create mode 100644 @omniroute/opencode-plugin-v2/tests/shared-combos-map.test.ts create mode 100644 @omniroute/opencode-plugin-v2/tests/shared-enrich.test.ts create mode 100644 @omniroute/opencode-plugin-v2/tests/shared-enrichment-fetcher.test.ts create mode 100644 @omniroute/opencode-plugin-v2/tests/shared-enrichment-source-errors.test.ts create mode 100644 @omniroute/opencode-plugin-v2/tests/shared-fetch-timeout.test.ts create mode 100644 @omniroute/opencode-plugin-v2/tests/shared-fingerprint.test.ts create mode 100644 @omniroute/opencode-plugin-v2/tests/shared-gemini.test.ts create mode 100644 @omniroute/opencode-plugin-v2/tests/shared-logger.test.ts create mode 100644 @omniroute/opencode-plugin-v2/tests/shared-models-map.test.ts create mode 100644 @omniroute/opencode-plugin-v2/tests/shared-naming.test.ts create mode 100644 @omniroute/opencode-plugin-v2/tests/shared-usable.test.ts create mode 100644 @omniroute/opencode-plugin-v2/tests/smoke-types.test.ts create mode 100644 @omniroute/opencode-plugin-v2/tests/snapshot-stale-entries.test.ts create mode 100644 @omniroute/opencode-plugin-v2/tests/staged-refresh.test.ts create mode 100644 @omniroute/opencode-plugin-v2/tests/timeouts-logger.test.ts create mode 100644 @omniroute/opencode-plugin-v2/tests/usable-only.test.ts create mode 100644 @omniroute/opencode-plugin-v2/tests/warm-snapshot-identity.test.ts create mode 100644 @omniroute/opencode-plugin-v2/tsconfig.json create mode 100644 @omniroute/opencode-plugin-v2/tsup.config.ts create mode 100644 changelog.d/features/12870-opencode-plugin-v2.md create mode 100644 docs/guides/OPENCODE-V2-PLUGIN.md diff --git a/.github/workflows/npm-publish.yml b/.github/workflows/npm-publish.yml index 5ba76f069f..fddcff49cc 100644 --- a/.github/workflows/npm-publish.yml +++ b/.github/workflows/npm-publish.yml @@ -573,3 +573,92 @@ jobs: fi npm publish --provenance --access public --ignore-scripts echo "✅ Published ${PKG_NAME}@${PKG_VERSION}" + + publish-opencode-plugin-v2: + runs-on: ubuntu-latest + permissions: + contents: read + id-token: write # npm provenance + steps: + - name: Checkout + uses: actions/checkout@v7 + with: + persist-credentials: false + fetch-depth: 0 + # Full history needed for auto-bump: git diff against previous release tag + + - name: Setup Node.js + uses: actions/setup-node@v7 + with: + node-version: ${{ env.NPM_PUBLISH_NODE_VERSION }} + registry-url: https://registry.npmjs.org + + - name: Auto-bump plugin-v2 version if plugin-v2 changed since last release + id: bump + working-directory: "@omniroute/opencode-plugin-v2" + env: + CURRENT_TAG: ${{ github.ref_name }} + run: | + set -euo pipefail + + PKG_VERSION=$(node -p "require('./package.json').version") + PKG_NAME=$(node -p "require('./package.json').name") + + # 1) Skip if current version is not yet published (no bump needed) + PUBLISHED="$(npm view "${PKG_NAME}@${PKG_VERSION}" version 2>/dev/null || true)" + if [ "$PUBLISHED" != "$PKG_VERSION" ]; then + echo "✅ ${PKG_NAME}@${PKG_VERSION} is new — no bump needed." + echo "bumped=false" >> "$GITHUB_OUTPUT" + exit 0 + fi + + # 2) Find the previous release tag (exclude the current one) + PREV_TAG=$(git tag -l 'v*' --sort=-version:refname \ + | grep -v "^${CURRENT_TAG}$" | head -1 || echo "") + if [ -z "$PREV_TAG" ]; then + echo "No previous tag to compare — skipping bump." + echo "bumped=false" >> "$GITHUB_OUTPUT" + exit 0 + fi + + # 3) Check if plugin-v2 dir actually changed since that tag + if git diff --quiet "$PREV_TAG" -- "@omniroute/opencode-plugin-v2/"; then + echo "⏭️ No plugin-v2 changes since $PREV_TAG — nothing to publish." + echo "bumped=false" >> "$GITHUB_OUTPUT" + exit 0 + fi + + # 4) Auto-bump patch version + npm version patch --no-git-tag-version --allow-same-version + NEW_VERSION=$(node -p "require('./package.json').version") + echo "bumped=true" >> "$GITHUB_OUTPUT" + echo "📦 Auto-bumped ${PKG_NAME} from ${PKG_VERSION} to ${NEW_VERSION}" + + - name: Install plugin-v2 dependencies + working-directory: "@omniroute/opencode-plugin-v2" + run: npm install --no-audit --no-fund + + - name: Build plugin-v2 + working-directory: "@omniroute/opencode-plugin-v2" + run: npm run clean && npm run build + + - name: Test plugin-v2 + working-directory: "@omniroute/opencode-plugin-v2" + run: npm test + + - name: Publish @omniroute/opencode-plugin-v2 to npm + working-directory: "@omniroute/opencode-plugin-v2" + env: + NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} + run: | + set -euo pipefail + PKG_VERSION=$(node -p "require('./package.json').version") + PKG_NAME=$(node -p "require('./package.json').name") + # Same hardened skip-check as the main job (no --silent flag). + PUBLISHED="$(npm view "${PKG_NAME}@${PKG_VERSION}" version 2>/dev/null || true)" + if [ "$PUBLISHED" = "$PKG_VERSION" ]; then + echo "⚠️ ${PKG_NAME}@${PKG_VERSION} is already published on npm — skipping." + exit 0 + fi + npm publish --provenance --access public --ignore-scripts + echo "✅ Published ${PKG_NAME}@${PKG_VERSION}" diff --git a/.github/workflows/opencode-plugin-ci.yml b/.github/workflows/opencode-plugin-ci.yml index 0e26c0e608..9b94b688b6 100644 --- a/.github/workflows/opencode-plugin-ci.yml +++ b/.github/workflows/opencode-plugin-ci.yml @@ -5,10 +5,12 @@ on: branches: [main, "release/**"] paths: - "@omniroute/opencode-plugin/**" + - "@omniroute/opencode-plugin-v2/**" pull_request: branches: [main, "release/**"] paths: - "@omniroute/opencode-plugin/**" + - "@omniroute/opencode-plugin-v2/**" types: [opened, synchronize, reopened, ready_for_review] workflow_dispatch: @@ -44,10 +46,33 @@ jobs: - run: npm run build - run: npm test + test-v2: + name: Test v2 (Node ${{ matrix.node }}) + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + node: ["22", "24"] + defaults: + run: + working-directory: "@omniroute/opencode-plugin-v2" + steps: + - uses: actions/checkout@v7 + with: + persist-credentials: false + - uses: actions/setup-node@v7 + with: + node-version: ${{ matrix.node }} + cache: npm + cache-dependency-path: "@omniroute/opencode-plugin-v2/package-lock.json" + - run: npm ci --no-audit --no-fund + - run: npm run build + - run: npm test + build: name: Build runs-on: ubuntu-latest - needs: test + needs: [test, test-v2] steps: - uses: actions/checkout@v7 with: @@ -55,12 +80,11 @@ jobs: - uses: actions/setup-node@v7 with: node-version: "22" - cache: npm - cache-dependency-path: "@omniroute/opencode-plugin/package-lock.json" - - run: npm install --no-audit --no-fund - - run: npm run build + - name: Build plugin-v2 artifact + working-directory: "@omniroute/opencode-plugin-v2" + run: npm ci --no-audit --no-fund && npm run build - uses: actions/upload-artifact@v7 with: - name: opencode-plugin-dist - path: "@omniroute/opencode-plugin/dist" + name: opencode-plugin-v2-dist + path: "@omniroute/opencode-plugin-v2/dist" retention-days: 7 diff --git a/@omniroute/opencode-plugin-v2/.gitignore b/@omniroute/opencode-plugin-v2/.gitignore new file mode 100644 index 0000000000..7535211682 --- /dev/null +++ b/@omniroute/opencode-plugin-v2/.gitignore @@ -0,0 +1,4 @@ +node_modules +dist +*.log +.DS_Store diff --git a/@omniroute/opencode-plugin-v2/LICENSE b/@omniroute/opencode-plugin-v2/LICENSE new file mode 100644 index 0000000000..e50b22c855 --- /dev/null +++ b/@omniroute/opencode-plugin-v2/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 OmniRoute contributors + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/@omniroute/opencode-plugin-v2/README.md b/@omniroute/opencode-plugin-v2/README.md new file mode 100644 index 0000000000..873290a9d1 --- /dev/null +++ b/@omniroute/opencode-plugin-v2/README.md @@ -0,0 +1,107 @@ +# @omniroute/opencode-plugin-v2 + +OpenCode v2 plugin (`define({ id, setup })`, Promise API) that publishes the live OmniRoute catalog — models from `/v1/models`, combos from `/api/combos` (least-common-denominator join), auto-combos from `/api/combos/auto`, enrichment (names + pricing), and usable-provider filtering — into the v2 `catalog.transform`, with `key` + `env` auth via `integration.transform`. + +Companion to `@omniroute/opencode-plugin` (OpenCode v1, same repo). The two packages are independent: this one carries its own catalog-mapping logic and the v1 plugin is left untouched. + +## Install + +```sh +npm install @omniroute/opencode-plugin-v2 +``` + +`opencode.json`: + +```json +{ + "plugins": [ + { + "package": "@omniroute/opencode-plugin-v2", + "options": { + "providerId": "omniroute", + "baseURL": "http://localhost:20128" + } + } + ] +} +``` + +## Credentials + +The plugin needs a gateway key to read the catalog, and looks for one in this +order: + +1. **The credential you connected in OpenCode.** The plugin registers an + integration, so `opencode auth` (or the Connect action in the model picker) + can store a key for it. Nothing is written to `opencode.json` — this is the + recommended route. +2. **`apiKey` in the plugin options**, when you want a per-project override. + Remember that this puts the key in a config file you may be committing. +3. **`OMNIROUTE_API_KEY` in the environment.** + +If none of the three yields a key, the catalog is empty and the plugin says so +once at startup rather than leaving you with a silent empty model list. + +### The management token is a different key + +Combos, provider health and enrichment (display names, pricing, free-tier +budgets) come from the gateway's `/api/*` endpoints, which most deployments +gate behind a **management** token rather than the inference key. Set it +explicitly: + +```json +"options": { + "baseURL": "http://localhost:20128", + "managementReadToken": "" +} +``` + +Left unset, `managementReadToken` falls back to `apiKey` for backwards +compatibility. When a gateway rejects that fallback, the catalog still +publishes — but with raw model ids instead of display names, no canonical +alias dedupe, no pricing and no combos. The plugin warns once per endpoint +when this happens, naming the endpoint and the consequence, so the degraded +catalog is never a mystery. + +## Options + +| Key | Default | Notes | +| -------------------------------- | ---------------------------------------------- | --------------------------------------------------------------------------------------------------------------------- | +| `providerId` | `"omniroute"` | Provider id and integration id; models publish under `/…` | +| `baseURL` | required | OmniRoute gateway root (no `/v1` suffix needed) | +| `apiKey` | connected credential, then `OMNIROUTE_API_KEY` | Chat key for `/v1/*` — see [Credentials](#credentials) | +| `managementReadToken` | falls back to `apiKey` | Management key for `/api/*` (combos, providers, enrichment) — usually **not** the same key | +| `displayName` | `"OmniRoute"` | Provider display name | +| `timeoutMs` | `10000` | Per-endpoint fetch timeout (auto-combos use 5s) | +| `modelCacheTtlMs` | `300000` | Catalog cache TTL; disk snapshot warms cold starts | +| `timeouts` | per-endpoint override | `{ models, combos, autoCombos, enrichment }` in ms; falls back to `timeoutMs` | +| `enrichment` | `true` | Fetch names + pricing (`/api/pricing*`, `/api/free-tier/summary`) | +| `providerTag` | `true` | Prefix a display name with the upstream provider it routes to | +| `geminiSanitization` | `true` | Strip `$schema`/`additionalProperties` from tool schemas sent to Gemini models (`$ref` tools are forwarded untouched) | +| `usableOnly` | `false` | Filter to healthy provisioned providers (`/api/providers`) | +| `visibleModels` / `hiddenModels` | `[]` | Exact-or-suffix allowlists, deny wins | +| `apiFormat.allowAnthropic` | `false` | Route allowlisted ids to the Anthropic API block | +| `apiFormat.anthropicModels` | `[]` | Full model ids routed to Anthropic | +| `apiFormat.anthropicPrefixes` | v1 defaults | Deprecated, warns once — prefer `anthropicModels` | +| `logLevel` / `startupDebug` | `warn` / `false` | Logger verbosity | + +## Tool calling on Gemini models + +Gemini answers `400 INVALID_ARGUMENT` — for the whole request, not just the +offending tool — when a tool declaration carries `$schema` or +`additionalProperties`. Anything that emits standard JSON Schema therefore +breaks tool calling as soon as the chain routes to Gemini. + +The plugin strips those keywords from tool schemas bound for a Gemini model of +this provider, and leaves every other request untouched. A tool carrying a +`$ref` is forwarded untouched instead of stripped: removing the reference +would widen the schema to "accept anything". Set +`"geminiSanitization": false` to turn it off. + +## Migrating from the v1 plugin + +The v2 plugin publishes provider id `X` bare. The v1 plugin published `opencode-X` (native-adapter gate). Sessions pinned to `opencode-X/...` must re-select the model under `X/...`. + +## License + +MIT diff --git a/@omniroute/opencode-plugin-v2/RELEASE.md b/@omniroute/opencode-plugin-v2/RELEASE.md new file mode 100644 index 0000000000..78d76c6ae3 --- /dev/null +++ b/@omniroute/opencode-plugin-v2/RELEASE.md @@ -0,0 +1,9 @@ +# Release process — `@omniroute/opencode-plugin-v2` + +## Publishing + +One package, no ordering: bump `@omniroute/opencode-plugin-v2` (`npm version patch`) and publish it. The plugin carries its own copy of the mapping logic, so a release never has to be coordinated with another package. + +## Migration note (`opencode-X` → `X`) + +The v1 plugin published provider id `opencode-X` (native-adapter gate). The v2 plugin publishes `X` bare. Sessions pinned to `opencode-X/...` resolve `ModelUnavailableError` — users must re-select the model under `X/...`. diff --git a/@omniroute/opencode-plugin-v2/package-lock.json b/@omniroute/opencode-plugin-v2/package-lock.json new file mode 100644 index 0000000000..d709cd6779 --- /dev/null +++ b/@omniroute/opencode-plugin-v2/package-lock.json @@ -0,0 +1,2364 @@ +{ + "name": "@omniroute/opencode-plugin-v2", + "version": "0.1.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "@omniroute/opencode-plugin-v2", + "version": "0.1.0", + "license": "MIT", + "dependencies": { + "zod": "^4.4.3" + }, + "devDependencies": { + "@opencode-ai/plugin": "1.18.29", + "@types/node": "^22.19.19", + "tsup": "^8.5.1", + "tsx": "^4.22.3", + "typescript": "^5.9.3" + }, + "engines": { + "node": ">=22.22.3" + }, + "peerDependencies": { + "@opencode-ai/plugin": "*" + } + }, + "node_modules/@ai-sdk/provider": { + "version": "3.0.8", + "resolved": "https://registry.npmjs.org/@ai-sdk/provider/-/provider-3.0.8.tgz", + "integrity": "sha512-oGMAgGoQdBXbZqNG0Ze56CHjDZ1IDYOwGYxYjO5KLSlz5HiNQ9udIXsPZ61VWaHGZ5XW/jyjmr6t2xz2jGVwbQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "json-schema": "^0.4.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.1.tgz", + "integrity": "sha512-Svl7tq8k/08+p6CXPpRjQ1fKX+1odH/BQbb48fV6fj3CWHhsoIOoY87w1oHXm0qEpkIK3ZfVgp0hed3XBXzXMQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.1.tgz", + "integrity": "sha512-0k2F129Xdio1TdJfzJ8sy1Q47vUD2NnwdhiAf7drUN1EBTfPf4hsFCtmMgu/6m8JSzsBrlmVjudMBQqOfG8usQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.1.tgz", + "integrity": "sha512-34EGEbCIAgosYz6goLcopX6Mo7NyGv9tfwEM2/7Ce2VcVRk568iSvniGWcUXIy7wEDR1wzolcxcriFVrWYcwBg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.28.1.tgz", + "integrity": "sha512-dbwY7ltSMDWsRatcRpCnES4F+im88OCUgGZjy52shC7GqHRE/cYlxNbB4Z4UpJswpcc4Qxd2oE/ufM0p61IKng==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.28.1.tgz", + "integrity": "sha512-TZbWkQY7kvTAXbXUT7uVACR5cMHsDiSz9z7ZKAX/RTq/WJEk3QyRr0wZpNhBDX+/0CtdqUIJlOiodQcta6tY3Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.28.1.tgz", + "integrity": "sha512-zfdzgK9ACBNZLI/CyHTOx81SyNbM6YXn7rxSgX97VjyiPl9W1i4Ka4fgKECEoFCKGpvBj5qArWIGgQjOwkgskQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.1.tgz", + "integrity": "sha512-wG2EA8ENdEI0qhkSZMjfqrdY+ziCYCPMmtZjjIwOmXFjmyzEHn+UUxk5of+SYsjtfs3VpnlC7QLzSI5hY/rOAw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.28.1.tgz", + "integrity": "sha512-i7dZ9vQgnvSCzi/rYCXNgtF/U+eKZNJBzu3eTQbRgHnM7tNSizLOkRFAl3qzVc/Op/u5YkHHa4pf/3DOYHthLQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.28.1.tgz", + "integrity": "sha512-qVXBOHQS+d5Y722GwJzJUtOLlX7km3CraOaGormF1pDtPd2C/l1SHRPgjLunLGe51Sh5YYWKMFDyV4SxgMQYTQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.28.1.tgz", + "integrity": "sha512-yHs+0uc8+nvEAfAfxrWQKK5peSNzBc4PegcMO0EJ2hT71uA7vB8Ihg2e77R2P7SG5uYjPbHlLLmve4LLLRCf0g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.28.1.tgz", + "integrity": "sha512-d1z4ZuP0ajrfz/FhGT4vv278rX8KnPPJx8i5+AtK7TYbx9Le9F1hyzurZpkEyjkGa9dUGhQow4C1NmeGvqxN2w==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.28.1.tgz", + "integrity": "sha512-M5sRjUVZrkm1OAPR3dlOYzNmN+loZKGVi1VUQGrwuqLcbR6qeAz+famMhjASeH3YVKvZz+zT1jlh/keC3Rj/lg==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.28.1.tgz", + "integrity": "sha512-mRObBZeHh2OxcBFPWE/FjylkRgZdYuiTR3vaTozquCGOH14iP9oN4x4Ge81CoIDYQrXmIxpFumJBu5MtZpnQJQ==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.28.1.tgz", + "integrity": "sha512-slScBsMAb3GFDcdrCgLwZtPYRoH2H/youv10QiZyRjmsP48fznoveWytSgCI/R0ZcUgpc0ZhIUEx6LHts8yrfQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.28.1.tgz", + "integrity": "sha512-kw0owk1o0GFETUJyW0jc0G4Yzs0BHZn0JDZ8JRT088vjJYX777BAs1fDGxAC+q831qOs2DTC96mNsG2opdfyyQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.28.1.tgz", + "integrity": "sha512-/lAIjX8aYFRByhh6L5rYtPEDRqa9de/4V/juOXcta5frjvzXO4/sqEtyytse0g3zZFuWu5cDN0MkLz2qRDD2Ag==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.28.1", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.1.tgz", + "integrity": "sha512-oks0DYbLwWMmaakTsCb+zL4E+aHRVLom9IJZOAthMQEPiQmydXHkziYEsGYRx0uNV/IjEKGAV941JzH02pflqw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.28.1.tgz", + "integrity": "sha512-aeL6lAnN89Hz43Mlh1G8ARasbuoYvSITDEx0tHh5b7jJnHcssqgjy9Yx430GDpmCa6OyrKoS0aNRjKundRizGg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.1.tgz", + "integrity": "sha512-MEFJe5C3R8pwXdZ5Y21oo6m7ePiS0d9pWucn99O/wvyJZChoIQKrQDxKrGeW8F5+T0okTHesAmDeiHDTIq0V/Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.28.1.tgz", + "integrity": "sha512-i/ZLIOafE0Z8cI/XANJAixoJL/uRAoS2xOA3rb0xN+KK0K177cMAsQYkzHtBrtMXAKuAc7HGgcWiZ/sRC1Nxgw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.1.tgz", + "integrity": "sha512-ge+Z7EXFNt2BO1oAMsVpiQ8EwndV9i1xXerAeTIK7AtPs3bKFXQM7nlRxDSIUIMeueR1CNXxqztLzdNeReKBJg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.1.tgz", + "integrity": "sha512-BEjgtECkL3vY+SaSQ6nzVfiALUeFxpawyp8Jmf5PtYhf1Ug40N1h/hxlhts+f1FvSvarEigdxS3BlSMI2PJLcQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.1.tgz", + "integrity": "sha512-lCv9eK/H6ZJWbE7bh2nw54CZ9M2nupBxJcTsdk/QQnWkdSjKGuxmmH8/GWrlT1eMmZfn4dGcCjRte397WqfQXA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.1.tgz", + "integrity": "sha512-zvb/mB2bSCoJOpoCBgYKKpX6YM6mJBlBUVUtVj41DlZJVEB6/0CKlRYxP5wWl1C1ILiCoAU5wZZ4q1P3qeS6Eg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.28.1.tgz", + "integrity": "sha512-bm4Mowrv+GXMlpWX++EcXw/iLyd1o3+bJkC2DkWXYVvgZCqD/bSj9ctZeAMC3cIxgjRVR2Dufaiu4YPxr5gW1A==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@jridgewell/gen-mapping": { + "version": "0.3.13", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.0", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "dev": true, + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@msgpackr-extract/msgpackr-extract-darwin-arm64": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-darwin-arm64/-/msgpackr-extract-darwin-arm64-3.0.4.tgz", + "integrity": "sha512-LCkGo6JDfaBhgST7UpPWgNgLINpcpabaHfyz5OBx75nUYxBsaEPxjnyNjWpeb/xBup/682QnBfRBy2/LvPutZQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@msgpackr-extract/msgpackr-extract-darwin-x64": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-darwin-x64/-/msgpackr-extract-darwin-x64-3.0.4.tgz", + "integrity": "sha512-zExlW9zUJKZH/tOtVMttwjKa4Xm/3KcNjnE3dPN92uCktwavMxpgCA3MoJK/DOnTWsQgo224OaST27/mPNAf+w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@msgpackr-extract/msgpackr-extract-linux-arm": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-linux-arm/-/msgpackr-extract-linux-arm-3.0.4.tgz", + "integrity": "sha512-Tg3yX65f5GbtXLkrYEHE5oibZG9epyYWas7FogTTEJeDEF9JlXJzKgXaNhT3UXlTOeA+AfZpYZYZ0uPj7Cfquw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@msgpackr-extract/msgpackr-extract-linux-arm64": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-linux-arm64/-/msgpackr-extract-linux-arm64-3.0.4.tgz", + "integrity": "sha512-dgX0P/9wGPJeHFBG+ZmhgE6bmtMt7NP5CRBGyyktpopdk/mW4POnrpQsSLtKI1dwpc+pPLuXHDh6vvskyQE/sw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@msgpackr-extract/msgpackr-extract-linux-x64": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-linux-x64/-/msgpackr-extract-linux-x64-3.0.4.tgz", + "integrity": "sha512-8TNXMEjJc3QEy7R/x1INhgiU+XakDAFUzBhaz7+Rbrs8NH5UQeHQxxmzsSBJGyV6I1jW79undiQm8tOI+D+8FQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@msgpackr-extract/msgpackr-extract-win32-x64": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-win32-x64/-/msgpackr-extract-win32-x64-3.0.4.tgz", + "integrity": "sha512-CmCXPQrkbwExx3j946/PtHWHbYJiCRBRDl4BlkRQcJB/YOwQxJRTpoo7aTsortjgoJ1x7opzTSxn7C+ASSLVjQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@opencode-ai/plugin": { + "version": "1.18.29", + "resolved": "https://registry.npmjs.org/@opencode-ai/plugin/-/plugin-1.18.29.tgz", + "integrity": "sha512-IhF83EU4I/ASgWwvm0FIh1O3a8ZVuCLqPrCbmSHdSYq7GHxIYe773i6dHqcbrzCzvlG/lP2H+dyTQ+xPAwFpbw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@ai-sdk/provider": "3.0.8", + "@opencode-ai/sdk": "1.18.29", + "effect": "4.0.0-beta.83", + "zod": "4.1.8" + }, + "peerDependencies": { + "@opentui/core": ">=0.4.5", + "@opentui/keymap": ">=0.4.5", + "@opentui/solid": ">=0.4.5" + }, + "peerDependenciesMeta": { + "@opentui/core": { + "optional": true + }, + "@opentui/keymap": { + "optional": true + }, + "@opentui/solid": { + "optional": true + } + } + }, + "node_modules/@opencode-ai/plugin/node_modules/zod": { + "version": "4.1.8", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } + }, + "node_modules/@opencode-ai/sdk": { + "version": "1.18.29", + "resolved": "https://registry.npmjs.org/@opencode-ai/sdk/-/sdk-1.18.29.tgz", + "integrity": "sha512-4CS+FoLPkymTlcga8jxivGDDb2AbWMIIl3b8+myoe2wtv/1ANYCErslgz1xy5hTVHymWE6CtVNKzRuPU0ED57A==", + "dev": true, + "license": "MIT", + "dependencies": { + "cross-spawn": "7.0.6" + } + }, + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.60.4.tgz", + "integrity": "sha512-F5QXMSiFebS9hKZj02XhWLLnRpJ3B3AROP0tWbFBSj+6kCbg5m9j5JoHKd4mmSVy5mS/IMQloYgYxCuJC0fxEQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.60.4.tgz", + "integrity": "sha512-GxxTKApUpzRhof7poWvCJHRF51C67u1R7D6DiluBE8wKU1u5GWE8t+v81JvJYtbawoBFX1hLv5Ei4eVjkWokaw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.60.4.tgz", + "integrity": "sha512-tua0TaJxMOB1R0V0RS1jFZ/RpURFDJIOR2A6jWwQeawuFyS4gBW+rntLRaQd0EQ4bd6Vp44Z2rXW+YYDBsj6IA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.60.4.tgz", + "integrity": "sha512-CSKq7MsP+5PFIcydhAiR1K0UhEI1A2jWXVKHPCBZ151yOutENwvnPocgVHkivu2kviURtCEB6zUQw0vs8RrhMg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.60.4.tgz", + "integrity": "sha512-+O8OkVdyvXMtJEciu2wS/pzm1IxntEEQx3z5TAVy4l32G0etZn+RsA48ARRrFm6Ri8fvqPQfgrvNxSjKAbnd3g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.60.4.tgz", + "integrity": "sha512-Iw3oMskH3AfNuhU0MSN7vNbdi4me/NiYo2azqPz/Le16zHSa+3RRmliCMWWQmh4lcndccU40xcJuTYJZxNo/lw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.60.4.tgz", + "integrity": "sha512-EIPRXTVQpHyF8WOo219AD2yEltPehLTcTMz2fn6JsatLYSzQf00hj3rulF+yauOlF9/FtM2WpkT/hJh/KJFGhA==", + "cpu": [ + "arm" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.60.4.tgz", + "integrity": "sha512-J3Yh9PzzF1Ovah2At+lHiGQdsYgArxBbXv/zHfSyaiFQEqvNv7DcW98pCrmdjCZBrqBiKrKKe2V+aaSGWuBe/w==", + "cpu": [ + "arm" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.60.4.tgz", + "integrity": "sha512-BFDEZMYfUvLn37ONE1yMBojPxnMlTFsdyNoqncT0qFq1mAfllL+ATMMJd8TeuVMiX84s1KbcxcZbXInmcO2mRg==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.60.4.tgz", + "integrity": "sha512-pc9EYOSlOgdQ2uPl1o9PF6/kLSgaUosia7gOuS8mB69IxJvlclko1MECXysjs5ryez1/5zjYqx3+xYU0TU6R1A==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-gnu": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.60.4.tgz", + "integrity": "sha512-NxnomyxYerDh5n4iLrNa+sH+Z+U4BMEE46V2PgQ/hoB909i8gV1M5wPojWg9fk1jWpO3IQnOs20K4wyZuFLEFQ==", + "cpu": [ + "loong64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-musl": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.60.4.tgz", + "integrity": "sha512-nbJnQ8a3z1mtmrwImCYhc6BGpThAyYVRQxw9uKSKG4wR6aAYno9sVjJ0zaZcW9BPJX1GbrDPf+SvdWjgTuDmnw==", + "cpu": [ + "loong64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.60.4.tgz", + "integrity": "sha512-2EU6acNrQLd8tYvo/LXW535wupT3m6fo7HKo6lr7ktQoItxTyOL1ZCR/GfGCuXl2vR+zmfI6eRXkSemafv+iVg==", + "cpu": [ + "ppc64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-musl": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.60.4.tgz", + "integrity": "sha512-WeBtoMuaMxiiIrO2IYP3xs6GMWkJP2C0EoT8beTLkUPmzV1i/UcOSVw1d5r9KBODtHKilG5yFxsGRnBbK3wJ4A==", + "cpu": [ + "ppc64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.60.4.tgz", + "integrity": "sha512-FJHFfqpKUI3A10WrWKiFbBZ7yVbGT4q4B5o1qKFFojqpaYoh9LrQgqWCmmcxQzVSXYtyB5bzkXrYzlHTs21MYA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.60.4.tgz", + "integrity": "sha512-mcEl6CUT5IAUmQf1m9FYSmVqCJlpQ8r8eyftFUHG8i9OhY7BkBXSUdnLH5DOf0wCOjcP9v/QO93zpmF1SptCCw==", + "cpu": [ + "riscv64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.60.4.tgz", + "integrity": "sha512-ynt3JxVd2w2buzoKDWIyiV1pJW93xlQic1THVLXilz429oijRpSHivZAgp65KBu+cMcgf1eVVjdnTLvPxgCuoQ==", + "cpu": [ + "s390x" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.60.4", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.60.4", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-openbsd-x64": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.60.4.tgz", + "integrity": "sha512-VpTfOPHgVXEBeeR8hZ2O0F3aSso+JDWqTWmTmzcQKted54IAdUVbxE+j/MVxUsKa8L20HJhv3vUezVPoquqWjA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ] + }, + "node_modules/@rollup/rollup-openharmony-arm64": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.60.4.tgz", + "integrity": "sha512-IPOsh5aRYuLv/nkU51X10Bf75Bsf6+gZdx1X+QP5QM6lIJFHHqbHLG0uJn/hWthzo13UAc2umiUorqZy3axoZg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.60.4.tgz", + "integrity": "sha512-4QzE9E81OohJ/HKzHhsqU+zcYYojVOXlFMs1DdyMT6qXl/niOH7AVElmmEdUNHHS/oRkc++d5k6Vy85zFs0DEw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.60.4.tgz", + "integrity": "sha512-zTPgT1YuHHcd+Tmx7h8aml0FWFVelV5N54oHow9SLj+GfoDy/huQ+UV396N/C7KpMDMiPspRktzM1/0r1usYEA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-gnu": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.60.4.tgz", + "integrity": "sha512-DRS4G7mi9lJxqEDezIkKCaUIKCrLUUDCUaCsTPCi/rtqaC6D/jjwslMQyiDU50Ka0JKpeXeRBFBAXwArY52vBw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.60.4.tgz", + "integrity": "sha512-QVTUovf40zgTqlFVrKA1uXMVvU2QWEFWfAH8Wdc48IxLvrJMQVMBRjuQyUpzZCDkakImib9eVazbWlC6ksWtJw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@standard-schema/spec": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", + "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/estree": { + "version": "1.0.8", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/node": { + "version": "22.19.19", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~6.21.0" + } + }, + "node_modules/acorn": { + "version": "8.16.0", + "dev": true, + "license": "MIT", + "bin": { + "acorn": "bin/acorn" + }, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/any-promise": { + "version": "1.3.0", + "dev": true, + "license": "MIT" + }, + "node_modules/bundle-require": { + "version": "5.1.0", + "dev": true, + "license": "MIT", + "dependencies": { + "load-tsconfig": "^0.2.3" + }, + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + }, + "peerDependencies": { + "esbuild": ">=0.18" + } + }, + "node_modules/cac": { + "version": "6.7.14", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/chokidar": { + "version": "4.0.3", + "dev": true, + "license": "MIT", + "dependencies": { + "readdirp": "^4.0.1" + }, + "engines": { + "node": ">= 14.16.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/commander": { + "version": "4.1.1", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 6" + } + }, + "node_modules/confbox": { + "version": "0.1.8", + "dev": true, + "license": "MIT" + }, + "node_modules/consola": { + "version": "3.4.2", + "dev": true, + "license": "MIT", + "engines": { + "node": "^14.18.0 || >=16.10.0" + } + }, + "node_modules/cross-spawn": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", + "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", + "dev": true, + "license": "MIT", + "dependencies": { + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/debug": { + "version": "4.4.3", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "dev": true, + "license": "Apache-2.0", + "optional": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/effect": { + "version": "4.0.0-beta.83", + "resolved": "https://registry.npmjs.org/effect/-/effect-4.0.0-beta.83.tgz", + "integrity": "sha512-0wsak8RtgGAr9UWSbVDgJHZcUqMSvicHcvaZv1MbMM7MCGgW4Rn/137J1MHQbwYPcwYGxT/IqehFd+UbYuj78w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@standard-schema/spec": "^1.1.0", + "fast-check": "^4.8.0", + "find-my-way-ts": "^0.1.6", + "ini": "^7.0.0", + "kubernetes-types": "^1.30.0", + "msgpackr": "^2.0.1", + "multipasta": "^0.2.7", + "toml": "^4.1.1", + "uuid": "^14.0.0", + "yaml": "^2.9.0" + } + }, + "node_modules/esbuild": { + "version": "0.28.1", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.28.1", + "@esbuild/android-arm": "0.28.1", + "@esbuild/android-arm64": "0.28.1", + "@esbuild/android-x64": "0.28.1", + "@esbuild/darwin-arm64": "0.28.1", + "@esbuild/darwin-x64": "0.28.1", + "@esbuild/freebsd-arm64": "0.28.1", + "@esbuild/freebsd-x64": "0.28.1", + "@esbuild/linux-arm": "0.28.1", + "@esbuild/linux-arm64": "0.28.1", + "@esbuild/linux-ia32": "0.28.1", + "@esbuild/linux-loong64": "0.28.1", + "@esbuild/linux-mips64el": "0.28.1", + "@esbuild/linux-ppc64": "0.28.1", + "@esbuild/linux-riscv64": "0.28.1", + "@esbuild/linux-s390x": "0.28.1", + "@esbuild/linux-x64": "0.28.1", + "@esbuild/netbsd-arm64": "0.28.1", + "@esbuild/netbsd-x64": "0.28.1", + "@esbuild/openbsd-arm64": "0.28.1", + "@esbuild/openbsd-x64": "0.28.1", + "@esbuild/openharmony-arm64": "0.28.1", + "@esbuild/sunos-x64": "0.28.1", + "@esbuild/win32-arm64": "0.28.1", + "@esbuild/win32-ia32": "0.28.1", + "@esbuild/win32-x64": "0.28.1" + } + }, + "node_modules/fast-check": { + "version": "4.9.0", + "resolved": "https://registry.npmjs.org/fast-check/-/fast-check-4.9.0.tgz", + "integrity": "sha512-7ms6T7SybUev/PQITciI0yLM2pOSFy5zpG8Ty7tQofcVaQUvrMXp6CBwqF6fThLCLOrfBtuHAtwq6Yu4XPCllg==", + "dev": true, + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/dubzzz" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fast-check" + } + ], + "license": "MIT", + "dependencies": { + "pure-rand": "^8.0.0" + }, + "engines": { + "node": ">=12.17.0" + } + }, + "node_modules/fdir": { + "version": "6.5.0", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/find-my-way-ts": { + "version": "0.1.6", + "resolved": "https://registry.npmjs.org/find-my-way-ts/-/find-my-way-ts-0.1.6.tgz", + "integrity": "sha512-a85L9ZoXtNAey3Y6Z+eBWW658kO/MwR7zIafkIUPUMf3isZG0NCs2pjW2wtjxAKuJPxMAsHUIP4ZPGv0o5gyTA==", + "dev": true, + "license": "MIT" + }, + "node_modules/fix-dts-default-cjs-exports": { + "version": "1.0.1", + "dev": true, + "license": "MIT", + "dependencies": { + "magic-string": "^0.30.17", + "mlly": "^1.7.4", + "rollup": "^4.34.8" + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/ini": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/ini/-/ini-7.0.0.tgz", + "integrity": "sha512-ifK0CgjALofS5bkrcTy4RaQ9Vx2Knf/eLeIO+NaswQEpH1UblrtTSCIvN71qQDMq0PeQ/SSPojvEJp9vvvfr+w==", + "dev": true, + "license": "ISC", + "engines": { + "node": "^22.22.2 || ^24.15.0 || >=26.0.0" + } + }, + "node_modules/isexe": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", + "dev": true, + "license": "ISC" + }, + "node_modules/joycon": { + "version": "3.1.1", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + } + }, + "node_modules/json-schema": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/json-schema/-/json-schema-0.4.0.tgz", + "integrity": "sha512-es94M3nTIfsEPisRafak+HDLfHXnKBhV3vU5eqPcS3flIWqcxJWgXHXiey3YrpaNsanY5ei1VoYEbOzijuq9BA==", + "dev": true, + "license": "(AFL-2.1 OR BSD-3-Clause)" + }, + "node_modules/kubernetes-types": { + "version": "1.30.0", + "resolved": "https://registry.npmjs.org/kubernetes-types/-/kubernetes-types-1.30.0.tgz", + "integrity": "sha512-Dew1okvhM/SQcIa2rcgujNndZwU8VnSapDgdxlYoB84ZlpAD43U6KLAFqYo17ykSFGHNPrg0qry0bP+GJd9v7Q==", + "dev": true, + "license": "Apache-2.0" + }, + "node_modules/lilconfig": { + "version": "3.1.3", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/antonk52" + } + }, + "node_modules/lines-and-columns": { + "version": "1.2.4", + "dev": true, + "license": "MIT" + }, + "node_modules/load-tsconfig": { + "version": "0.2.5", + "dev": true, + "license": "MIT", + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + } + }, + "node_modules/magic-string": { + "version": "0.30.21", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, + "node_modules/mlly": { + "version": "1.8.2", + "dev": true, + "license": "MIT", + "dependencies": { + "acorn": "^8.16.0", + "pathe": "^2.0.3", + "pkg-types": "^1.3.1", + "ufo": "^1.6.3" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "dev": true, + "license": "MIT" + }, + "node_modules/msgpackr": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/msgpackr/-/msgpackr-2.1.0.tgz", + "integrity": "sha512-p/pBCVO63CsvvpkomUnNNag6+n38rULuDA6HHe70o2gtC8ODI52foF/4ko2qQcp6OiErJXTmrZeXmsGGHsIQNQ==", + "dev": true, + "license": "MIT", + "optionalDependencies": { + "msgpackr-extract": "^3.0.4" + } + }, + "node_modules/msgpackr-extract": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/msgpackr-extract/-/msgpackr-extract-3.0.4.tgz", + "integrity": "sha512-4kmO/MdyUIkLIvTPr8VHLil4AtoKIoniWPIEk5+CDy0xnWC84azhSFmuJ7PxZdsYtiP5kEeQsORAVIeMgxT+Hw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "dependencies": { + "node-gyp-build-optional-packages": "5.2.2" + }, + "bin": { + "download-msgpackr-prebuilds": "bin/download-prebuilds.js" + }, + "optionalDependencies": { + "@msgpackr-extract/msgpackr-extract-darwin-arm64": "3.0.4", + "@msgpackr-extract/msgpackr-extract-darwin-x64": "3.0.4", + "@msgpackr-extract/msgpackr-extract-linux-arm": "3.0.4", + "@msgpackr-extract/msgpackr-extract-linux-arm64": "3.0.4", + "@msgpackr-extract/msgpackr-extract-linux-x64": "3.0.4", + "@msgpackr-extract/msgpackr-extract-win32-x64": "3.0.4" + } + }, + "node_modules/multipasta": { + "version": "0.2.8", + "resolved": "https://registry.npmjs.org/multipasta/-/multipasta-0.2.8.tgz", + "integrity": "sha512-ZPWuMKyv0cSO29f7hozp+k6+crZbQijV8ipMvxNxRf2SwtYGTX1ZX89Kd20VV4H9Znonx+EQn+iy1wGQsJ+b+Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/mz": { + "version": "2.7.0", + "dev": true, + "license": "MIT", + "dependencies": { + "any-promise": "^1.0.0", + "object-assign": "^4.0.1", + "thenify-all": "^1.0.0" + } + }, + "node_modules/node-gyp-build-optional-packages": { + "version": "5.2.2", + "resolved": "https://registry.npmjs.org/node-gyp-build-optional-packages/-/node-gyp-build-optional-packages-5.2.2.tgz", + "integrity": "sha512-s+w+rBWnpTMwSFbaE0UXsRlg7hU4FjekKU4eyAih5T8nJuNZT1nNsskXpxmeqSK9UzkBl6UgRlnKc8hz8IEqOw==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "detect-libc": "^2.0.1" + }, + "bin": { + "node-gyp-build-optional-packages": "bin.js", + "node-gyp-build-optional-packages-optional": "optional.js", + "node-gyp-build-optional-packages-test": "build-test.js" + } + }, + "node_modules/object-assign": { + "version": "4.1.1", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/path-key": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", + "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/pathe": { + "version": "2.0.3", + "dev": true, + "license": "MIT" + }, + "node_modules/picocolors": { + "version": "1.1.1", + "dev": true, + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "4.0.4", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/pirates": { + "version": "4.0.7", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 6" + } + }, + "node_modules/pkg-types": { + "version": "1.3.1", + "dev": true, + "license": "MIT", + "dependencies": { + "confbox": "^0.1.8", + "mlly": "^1.7.4", + "pathe": "^2.0.1" + } + }, + "node_modules/postcss-load-config": { + "version": "6.0.1", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "lilconfig": "^3.1.1" + }, + "engines": { + "node": ">= 18" + }, + "peerDependencies": { + "jiti": ">=1.21.0", + "postcss": ">=8.0.9", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "jiti": { + "optional": true + }, + "postcss": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/pure-rand": { + "version": "8.4.2", + "resolved": "https://registry.npmjs.org/pure-rand/-/pure-rand-8.4.2.tgz", + "integrity": "sha512-vvuOGgcuPJAirlHvuQw1TrOiw7ptaIXXmIbNuiNOY6lNGJJH49PQ1Kj4nd783nPdQhQdicgOjVI2yI/9BD6/Ng==", + "dev": true, + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/dubzzz" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fast-check" + } + ], + "license": "MIT" + }, + "node_modules/readdirp": { + "version": "4.1.2", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 14.18.0" + }, + "funding": { + "type": "individual", + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/resolve-from": { + "version": "5.0.0", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/rollup": { + "version": "4.60.4", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "1.0.8" + }, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "@rollup/rollup-android-arm-eabi": "4.60.4", + "@rollup/rollup-android-arm64": "4.60.4", + "@rollup/rollup-darwin-arm64": "4.60.4", + "@rollup/rollup-darwin-x64": "4.60.4", + "@rollup/rollup-freebsd-arm64": "4.60.4", + "@rollup/rollup-freebsd-x64": "4.60.4", + "@rollup/rollup-linux-arm-gnueabihf": "4.60.4", + "@rollup/rollup-linux-arm-musleabihf": "4.60.4", + "@rollup/rollup-linux-arm64-gnu": "4.60.4", + "@rollup/rollup-linux-arm64-musl": "4.60.4", + "@rollup/rollup-linux-loong64-gnu": "4.60.4", + "@rollup/rollup-linux-loong64-musl": "4.60.4", + "@rollup/rollup-linux-ppc64-gnu": "4.60.4", + "@rollup/rollup-linux-ppc64-musl": "4.60.4", + "@rollup/rollup-linux-riscv64-gnu": "4.60.4", + "@rollup/rollup-linux-riscv64-musl": "4.60.4", + "@rollup/rollup-linux-s390x-gnu": "4.60.4", + "@rollup/rollup-linux-x64-gnu": "4.60.4", + "@rollup/rollup-linux-x64-musl": "4.60.4", + "@rollup/rollup-openbsd-x64": "4.60.4", + "@rollup/rollup-openharmony-arm64": "4.60.4", + "@rollup/rollup-win32-arm64-msvc": "4.60.4", + "@rollup/rollup-win32-ia32-msvc": "4.60.4", + "@rollup/rollup-win32-x64-gnu": "4.60.4", + "@rollup/rollup-win32-x64-msvc": "4.60.4", + "fsevents": "~2.3.2" + } + }, + "node_modules/shebang-command": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", + "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", + "dev": true, + "license": "MIT", + "dependencies": { + "shebang-regex": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/shebang-regex": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", + "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/source-map": { + "version": "0.7.6", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">= 12" + } + }, + "node_modules/sucrase": { + "version": "3.35.1", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.2", + "commander": "^4.0.0", + "lines-and-columns": "^1.1.6", + "mz": "^2.7.0", + "pirates": "^4.0.1", + "tinyglobby": "^0.2.11", + "ts-interface-checker": "^0.1.9" + }, + "bin": { + "sucrase": "bin/sucrase", + "sucrase-node": "bin/sucrase-node" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + } + }, + "node_modules/thenify": { + "version": "3.3.1", + "dev": true, + "license": "MIT", + "dependencies": { + "any-promise": "^1.0.0" + } + }, + "node_modules/thenify-all": { + "version": "1.6.0", + "dev": true, + "license": "MIT", + "dependencies": { + "thenify": ">= 3.1.0 < 4" + }, + "engines": { + "node": ">=0.8" + } + }, + "node_modules/tinyexec": { + "version": "0.3.2", + "dev": true, + "license": "MIT" + }, + "node_modules/tinyglobby": { + "version": "0.2.16", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.4" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/toml": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/toml/-/toml-4.3.0.tgz", + "integrity": "sha512-lVb8X9BsPVuH0M4BKeS91tXAmJvCjQ5UIyAbQFaxkKGyUFK2RPkhwaFSQH8vbpl1d23eu/IBH+dwVMHWaq9A5A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=20" + } + }, + "node_modules/tree-kill": { + "version": "1.2.2", + "dev": true, + "license": "MIT", + "bin": { + "tree-kill": "cli.js" + } + }, + "node_modules/ts-interface-checker": { + "version": "0.1.13", + "dev": true, + "license": "Apache-2.0" + }, + "node_modules/tsup": { + "version": "8.5.1", + "dev": true, + "license": "MIT", + "dependencies": { + "bundle-require": "^5.1.0", + "cac": "^6.7.14", + "chokidar": "^4.0.3", + "consola": "^3.4.0", + "debug": "^4.4.0", + "esbuild": "^0.27.0", + "fix-dts-default-cjs-exports": "^1.0.0", + "joycon": "^3.1.1", + "picocolors": "^1.1.1", + "postcss-load-config": "^6.0.1", + "resolve-from": "^5.0.0", + "rollup": "^4.34.8", + "source-map": "^0.7.6", + "sucrase": "^3.35.0", + "tinyexec": "^0.3.2", + "tinyglobby": "^0.2.11", + "tree-kill": "^1.2.2" + }, + "bin": { + "tsup": "dist/cli-default.js", + "tsup-node": "dist/cli-node.js" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@microsoft/api-extractor": "^7.36.0", + "@swc/core": "^1", + "postcss": "^8.4.12", + "typescript": ">=4.5.0" + }, + "peerDependenciesMeta": { + "@microsoft/api-extractor": { + "optional": true + }, + "@swc/core": { + "optional": true + }, + "postcss": { + "optional": true + }, + "typescript": { + "optional": true + } + } + }, + "node_modules/tsup/node_modules/@esbuild/aix-ppc64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.7.tgz", + "integrity": "sha512-EKX3Qwmhz1eMdEJokhALr0YiD0lhQNwDqkPYyPhiSwKrh7/4KRjQc04sZ8db+5DVVnZ1LmbNDI1uAMPEUBnQPg==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsup/node_modules/@esbuild/android-arm": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.27.7.tgz", + "integrity": "sha512-jbPXvB4Yj2yBV7HUfE2KHe4GJX51QplCN1pGbYjvsyCZbQmies29EoJbkEc+vYuU5o45AfQn37vZlyXy4YJ8RQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsup/node_modules/@esbuild/android-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.27.7.tgz", + "integrity": "sha512-62dPZHpIXzvChfvfLJow3q5dDtiNMkwiRzPylSCfriLvZeq0a1bWChrGx/BbUbPwOrsWKMn8idSllklzBy+dgQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsup/node_modules/@esbuild/android-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.27.7.tgz", + "integrity": "sha512-x5VpMODneVDb70PYV2VQOmIUUiBtY3D3mPBG8NxVk5CogneYhkR7MmM3yR/uMdITLrC1ml/NV1rj4bMJuy9MCg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsup/node_modules/@esbuild/darwin-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.27.7.tgz", + "integrity": "sha512-5lckdqeuBPlKUwvoCXIgI2D9/ABmPq3Rdp7IfL70393YgaASt7tbju3Ac+ePVi3KDH6N2RqePfHnXkaDtY9fkw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsup/node_modules/@esbuild/darwin-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.27.7.tgz", + "integrity": "sha512-rYnXrKcXuT7Z+WL5K980jVFdvVKhCHhUwid+dDYQpH+qu+TefcomiMAJpIiC2EM3Rjtq0sO3StMV/+3w3MyyqQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsup/node_modules/@esbuild/freebsd-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.27.7.tgz", + "integrity": "sha512-B48PqeCsEgOtzME2GbNM2roU29AMTuOIN91dsMO30t+Ydis3z/3Ngoj5hhnsOSSwNzS+6JppqWsuhTp6E82l2w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsup/node_modules/@esbuild/freebsd-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.27.7.tgz", + "integrity": "sha512-jOBDK5XEjA4m5IJK3bpAQF9/Lelu/Z9ZcdhTRLf4cajlB+8VEhFFRjWgfy3M1O4rO2GQ/b2dLwCUGpiF/eATNQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsup/node_modules/@esbuild/linux-arm": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.27.7.tgz", + "integrity": "sha512-RkT/YXYBTSULo3+af8Ib0ykH8u2MBh57o7q/DAs3lTJlyVQkgQvlrPTnjIzzRPQyavxtPtfg0EopvDyIt0j1rA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsup/node_modules/@esbuild/linux-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.27.7.tgz", + "integrity": "sha512-RZPHBoxXuNnPQO9rvjh5jdkRmVizktkT7TCDkDmQ0W2SwHInKCAV95GRuvdSvA7w4VMwfCjUiPwDi0ZO6Nfe9A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsup/node_modules/@esbuild/linux-ia32": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.27.7.tgz", + "integrity": "sha512-GA48aKNkyQDbd3KtkplYWT102C5sn/EZTY4XROkxONgruHPU72l+gW+FfF8tf2cFjeHaRbWpOYa/uRBz/Xq1Pg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsup/node_modules/@esbuild/linux-loong64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.27.7.tgz", + "integrity": "sha512-a4POruNM2oWsD4WKvBSEKGIiWQF8fZOAsycHOt6JBpZ+JN2n2JH9WAv56SOyu9X5IqAjqSIPTaJkqN8F7XOQ5Q==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsup/node_modules/@esbuild/linux-mips64el": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.27.7.tgz", + "integrity": "sha512-KabT5I6StirGfIz0FMgl1I+R1H73Gp0ofL9A3nG3i/cYFJzKHhouBV5VWK1CSgKvVaG4q1RNpCTR2LuTVB3fIw==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsup/node_modules/@esbuild/linux-ppc64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.27.7.tgz", + "integrity": "sha512-gRsL4x6wsGHGRqhtI+ifpN/vpOFTQtnbsupUF5R5YTAg+y/lKelYR1hXbnBdzDjGbMYjVJLJTd2OFmMewAgwlQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsup/node_modules/@esbuild/linux-riscv64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.27.7.tgz", + "integrity": "sha512-hL25LbxO1QOngGzu2U5xeXtxXcW+/GvMN3ejANqXkxZ/opySAZMrc+9LY/WyjAan41unrR3YrmtTsUpwT66InQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsup/node_modules/@esbuild/linux-s390x": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.27.7.tgz", + "integrity": "sha512-2k8go8Ycu1Kb46vEelhu1vqEP+UeRVj2zY1pSuPdgvbd5ykAw82Lrro28vXUrRmzEsUV0NzCf54yARIK8r0fdw==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsup/node_modules/@esbuild/linux-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.27.7.tgz", + "integrity": "sha512-hzznmADPt+OmsYzw1EE33ccA+HPdIqiCRq7cQeL1Jlq2gb1+OyWBkMCrYGBJ+sxVzve2ZJEVeePbLM2iEIZSxA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsup/node_modules/@esbuild/netbsd-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.27.7.tgz", + "integrity": "sha512-b6pqtrQdigZBwZxAn1UpazEisvwaIDvdbMbmrly7cDTMFnw/+3lVxxCTGOrkPVnsYIosJJXAsILG9XcQS+Yu6w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsup/node_modules/@esbuild/netbsd-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.27.7.tgz", + "integrity": "sha512-OfatkLojr6U+WN5EDYuoQhtM+1xco+/6FSzJJnuWiUw5eVcicbyK3dq5EeV/QHT1uy6GoDhGbFpprUiHUYggrw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsup/node_modules/@esbuild/openbsd-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.27.7.tgz", + "integrity": "sha512-AFuojMQTxAz75Fo8idVcqoQWEHIXFRbOc1TrVcFSgCZtQfSdc1RXgB3tjOn/krRHENUB4j00bfGjyl2mJrU37A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsup/node_modules/@esbuild/openbsd-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.27.7.tgz", + "integrity": "sha512-+A1NJmfM8WNDv5CLVQYJ5PshuRm/4cI6WMZRg1by1GwPIQPCTs1GLEUHwiiQGT5zDdyLiRM/l1G0Pv54gvtKIg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsup/node_modules/@esbuild/openharmony-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.27.7.tgz", + "integrity": "sha512-+KrvYb/C8zA9CU/g0sR6w2RBw7IGc5J2BPnc3dYc5VJxHCSF1yNMxTV5LQ7GuKteQXZtspjFbiuW5/dOj7H4Yw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsup/node_modules/@esbuild/sunos-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.27.7.tgz", + "integrity": "sha512-ikktIhFBzQNt/QDyOL580ti9+5mL/YZeUPKU2ivGtGjdTYoqz6jObj6nOMfhASpS4GU4Q/Clh1QtxWAvcYKamA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsup/node_modules/@esbuild/win32-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.27.7.tgz", + "integrity": "sha512-7yRhbHvPqSpRUV7Q20VuDwbjW5kIMwTHpptuUzV+AA46kiPze5Z7qgt6CLCK3pWFrHeNfDd1VKgyP4O+ng17CA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsup/node_modules/@esbuild/win32-ia32": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.27.7.tgz", + "integrity": "sha512-SmwKXe6VHIyZYbBLJrhOoCJRB/Z1tckzmgTLfFYOfpMAx63BJEaL9ExI8x7v0oAO3Zh6D/Oi1gVxEYr5oUCFhw==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsup/node_modules/@esbuild/win32-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.27.7.tgz", + "integrity": "sha512-56hiAJPhwQ1R4i+21FVF7V8kSD5zZTdHcVuRFMW0hn753vVfQN8xlx4uOPT4xoGH0Z/oVATuR82AiqSTDIpaHg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsup/node_modules/esbuild": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.7.tgz", + "integrity": "sha512-IxpibTjyVnmrIQo5aqNpCgoACA/dTKLTlhMHihVHhdkxKyPO1uBBthumT0rdHmcsk9uMonIWS0m4FljWzILh3w==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.27.7", + "@esbuild/android-arm": "0.27.7", + "@esbuild/android-arm64": "0.27.7", + "@esbuild/android-x64": "0.27.7", + "@esbuild/darwin-arm64": "0.27.7", + "@esbuild/darwin-x64": "0.27.7", + "@esbuild/freebsd-arm64": "0.27.7", + "@esbuild/freebsd-x64": "0.27.7", + "@esbuild/linux-arm": "0.27.7", + "@esbuild/linux-arm64": "0.27.7", + "@esbuild/linux-ia32": "0.27.7", + "@esbuild/linux-loong64": "0.27.7", + "@esbuild/linux-mips64el": "0.27.7", + "@esbuild/linux-ppc64": "0.27.7", + "@esbuild/linux-riscv64": "0.27.7", + "@esbuild/linux-s390x": "0.27.7", + "@esbuild/linux-x64": "0.27.7", + "@esbuild/netbsd-arm64": "0.27.7", + "@esbuild/netbsd-x64": "0.27.7", + "@esbuild/openbsd-arm64": "0.27.7", + "@esbuild/openbsd-x64": "0.27.7", + "@esbuild/openharmony-arm64": "0.27.7", + "@esbuild/sunos-x64": "0.27.7", + "@esbuild/win32-arm64": "0.27.7", + "@esbuild/win32-ia32": "0.27.7", + "@esbuild/win32-x64": "0.27.7" + } + }, + "node_modules/tsx": { + "version": "4.22.3", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "~0.28.0" + }, + "bin": { + "tsx": "dist/cli.mjs" + }, + "engines": { + "node": ">=18.0.0" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + } + }, + "node_modules/typescript": { + "version": "5.9.3", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/ufo": { + "version": "1.6.4", + "dev": true, + "license": "MIT" + }, + "node_modules/undici-types": { + "version": "6.21.0", + "dev": true, + "license": "MIT" + }, + "node_modules/uuid": { + "version": "14.0.2", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-14.0.2.tgz", + "integrity": "sha512-xZe/16rV4aa+HGSOCiY2YeLT1OybRLrrkL/Rqaq7p7GMVXjFh+6wN4oMYgjFmnSnhY8t6Xpdl2l9qmnHYuMHwQ==", + "dev": true, + "funding": [ + "https://github.com/sponsors/broofa", + "https://github.com/sponsors/ctavan" + ], + "license": "MIT", + "bin": { + "uuid": "dist-node/bin/uuid" + } + }, + "node_modules/which": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", + "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "dev": true, + "license": "ISC", + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "node-which": "bin/node-which" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/yaml": { + "version": "2.9.0", + "dev": true, + "license": "ISC", + "bin": { + "yaml": "bin.mjs" + }, + "engines": { + "node": ">= 14.6" + }, + "funding": { + "url": "https://github.com/sponsors/eemeli" + } + }, + "node_modules/zod": { + "version": "4.4.3", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } + } + } +} diff --git a/@omniroute/opencode-plugin-v2/package.json b/@omniroute/opencode-plugin-v2/package.json new file mode 100644 index 0000000000..da8bc134d6 --- /dev/null +++ b/@omniroute/opencode-plugin-v2/package.json @@ -0,0 +1,67 @@ +{ + "name": "@omniroute/opencode-plugin-v2", + "version": "0.1.0", + "description": "OmniRoute OpenCode plugin (v2 Promise API): catalog transform with models, combos, enrichment, and naming.", + "type": "module", + "main": "./dist/index.js", + "types": "./dist/index.d.ts", + "exports": { + ".": { + "types": "./dist/index.d.ts", + "import": "./dist/index.js" + } + }, + "files": [ + "dist", + "README.md", + "LICENSE" + ], + "scripts": { + "build": "tsup", + "clean": "rm -rf dist", + "test": "node --import tsx/esm --test tests/*.test.ts", + "prepublishOnly": "npm run clean && npm run build && npm test" + }, + "dependencies": { + "zod": "^4.4.3" + }, + "devDependencies": { + "@opencode-ai/plugin": "1.18.29", + "@types/node": "^22.19.19", + "tsup": "^8.5.1", + "tsx": "^4.22.3", + "typescript": "^5.9.3" + }, + "engines": { + "node": ">=22.22.3" + }, + "license": "MIT", + "author": "OmniRoute contributors", + "repository": { + "type": "git", + "url": "https://github.com/diegosouzapw/OmniRoute.git", + "directory": "@omniroute/opencode-plugin-v2" + }, + "homepage": "https://github.com/diegosouzapw/OmniRoute/tree/main/%40omniroute/opencode-plugin-v2#readme", + "bugs": { + "url": "https://github.com/diegosouzapw/OmniRoute/issues" + }, + "keywords": [ + "omniroute", + "opencode", + "opencode-plugin", + "opencode-v2", + "ai-sdk", + "openai-compatible", + "provider", + "catalog", + "combos", + "gemini" + ], + "publishConfig": { + "access": "public" + }, + "peerDependencies": { + "@opencode-ai/plugin": ">=1.18.29 <2" + } +} diff --git a/@omniroute/opencode-plugin-v2/src/cache.ts b/@omniroute/opencode-plugin-v2/src/cache.ts new file mode 100644 index 0000000000..58aca429e4 --- /dev/null +++ b/@omniroute/opencode-plugin-v2/src/cache.ts @@ -0,0 +1,211 @@ +import { createHash } from "node:crypto"; +import { homedir } from "node:os"; +import { mkdir, readFile, unlink, writeFile } from "node:fs/promises"; +import { dirname, join } from "node:path"; +import type { + OmniRouteEnrichmentEntry, + OmniRouteEnrichmentMap, + OmniRouteProviderConnection, + OmniRouteRawAutoCombo, + OmniRouteRawCombo, + OmniRouteRawModelEntry, +} from "./shared/index.js"; + +export const DEFAULT_MODEL_CACHE_TTL_MS = 300_000 as const; + +/** + * Breather after a refresh whose models fetch came back empty (gateway down + * or refusing). Transforms inside the window serve last-known-good without + * re-firing the fetch suite. Short on purpose: it only guards the + * pathological case, normal TTL expiry still refetches every window. + */ +export const UNREACHABLE_COOLDOWN_MS = 15_000 as const; + +export interface CatalogSnapshot { + models: OmniRouteRawModelEntry[]; + combos: OmniRouteRawCombo[]; + autoCombos: OmniRouteRawAutoCombo[]; + providers?: OmniRouteProviderConnection[]; + enrichment?: OmniRouteEnrichmentMap; + fetchedAt: number; +} + +export const SNAPSHOT_FORMAT_VERSION = 2 as const; + +/** + * A raw snapshot entry is stale when it cannot be mapped to a publishable + * model: no string `id` (unroutable) or a pre-mapped `api` block without a + * valid `npm` package (the runner would reject it as `Unsupported package`). + * Plain `/v1/models` entries carry no `api` block -- it is synthesized at + * publish time -- so only a present-but-invalid block drops the entry. + */ +export function isStaleSnapshotModel(entry: unknown): boolean { + if (!entry || typeof entry !== "object") return true; + const id = (entry as { id?: unknown }).id; + if (typeof id !== "string" || id.length === 0) return true; + const api = (entry as { api?: unknown }).api; + if (api === undefined) return false; + if (!api || typeof api !== "object") return true; + const npm = (api as { npm?: unknown }).npm; + return typeof npm !== "string" || npm.length === 0; +} + +interface DiskSnapshotV2 { + v: 2; + identityFingerprint: string; + models: OmniRouteRawModelEntry[]; + combos: OmniRouteRawCombo[]; + autoCombos?: OmniRouteRawAutoCombo[]; + providers?: OmniRouteProviderConnection[]; + /** + * Display names, provider labels, pricing and free-tier budgets, as + * `[key, entry]` pairs (a Map does not survive JSON). Persisted because a + * cold start otherwise publishes raw model ids until the first refresh + * completes — which is the moment the snapshot exists to cover. + */ + enrichment?: [string, OmniRouteEnrichmentEntry][]; + writtenAt: number; +} + +/** + * Ceiling on what one snapshot may occupy on disk. A gateway with thousands of + * models makes this file grow without bound otherwise; past the cap the + * enrichment overlay is dropped first (it is rebuilt on the next refresh) + * rather than losing the catalog itself. + */ +const MAX_SNAPSHOT_BYTES = 32 * 1024 * 1024; + +function trimTrailingSlashes(value: string): string { + let i = value.length; + while (i > 0 && value.charCodeAt(i - 1) === 0x2f) i -= 1; + return i === value.length ? value : value.slice(0, i); +} + +function normalizeBaseURL(baseURL: string): string { + try { + const parsed = new URL(baseURL); + parsed.hash = ""; + parsed.pathname = trimTrailingSlashes(parsed.pathname) || "/"; + return parsed.toString(); + } catch { + return trimTrailingSlashes(baseURL); + } +} + +export function memoryCacheKey(baseURL: string, credentialId: string): string { + return `${baseURL}::${createHash("sha256").update(credentialId).digest("hex")}`; +} + +export function snapshotIdentityFingerprint( + baseURL: string, + apiKey: string, + managementReadToken: string +): string { + return createHash("sha256") + .update(JSON.stringify([normalizeBaseURL(baseURL), apiKey, managementReadToken])) + .digest("hex"); +} + +export function diskSnapshotPath(providerId: string): string { + // OPENCODE_DATA_DIR is honoured verbatim when set: whoever controls the + // process environment already chooses where the process writes, so + // resolving it further would only surprise. The providerId segment stays + // bounded by the options schema (letters, digits, '.', '_' and '-'; never + // "." or ".."), keeping the file inside /plugins/. + const dir = process.env.OPENCODE_DATA_DIR ?? join(homedir(), ".local", "share", "opencode"); + return join(dir, "plugins", `omniroute-${providerId}.json`); +} + +export async function readDiskSnapshot( + providerId: string, + identityFingerprint: string, + logger?: { warn: (message: string) => void } +): Promise { + try { + const body = await readFile(diskSnapshotPath(providerId), "utf8"); + const parsed = JSON.parse(body) as Partial; + if ( + !parsed || + typeof parsed.v !== "number" || + parsed.v < SNAPSHOT_FORMAT_VERSION || + typeof parsed.identityFingerprint !== "string" || + parsed.identityFingerprint !== identityFingerprint + ) { + return undefined; + } + if ( + !Array.isArray(parsed.models) || + parsed.models.length === 0 || + !Array.isArray(parsed.combos) + ) { + return undefined; + } + const stale = (parsed.models as unknown[]).filter(isStaleSnapshotModel).length; + const models = (parsed.models as OmniRouteRawModelEntry[]).filter( + (entry) => !isStaleSnapshotModel(entry) + ); + if (stale > 0) { + logger?.warn(`[omniroute-v2] dropping ${stale} stale snapshot entries without api block`); + } + if (models.length === 0) return undefined; + return { + models, + combos: parsed.combos as OmniRouteRawCombo[], + autoCombos: Array.isArray(parsed.autoCombos) + ? (parsed.autoCombos as OmniRouteRawAutoCombo[]) + : [], + providers: Array.isArray(parsed.providers) + ? (parsed.providers as OmniRouteProviderConnection[]) + : [], + // A snapshot written before this field existed, or one whose overlay was + // dropped for size, simply starts unenriched and recovers on the first + // refresh — the same state as before it was persisted at all. + enrichment: Array.isArray(parsed.enrichment) + ? new Map(parsed.enrichment as [string, OmniRouteEnrichmentEntry][]) + : undefined, + fetchedAt: typeof parsed.writtenAt === "number" ? parsed.writtenAt : Date.now(), + }; + } catch { + return undefined; + } +} + +export async function writeDiskSnapshot( + providerId: string, + snapshot: CatalogSnapshot, + identityFingerprint: string +): Promise { + try { + if (snapshot.models.length === 0) return; + const file = diskSnapshotPath(providerId); + await mkdir(dirname(file), { recursive: true, mode: 0o700 }); + const envelope: DiskSnapshotV2 = { + v: 2, + identityFingerprint, + models: snapshot.models, + combos: snapshot.combos, + autoCombos: snapshot.autoCombos, + providers: snapshot.providers ?? [], + enrichment: snapshot.enrichment ? [...snapshot.enrichment.entries()] : undefined, + writtenAt: Date.now(), + }; + let payload = JSON.stringify(envelope); + if (payload.length > MAX_SNAPSHOT_BYTES && envelope.enrichment !== undefined) { + delete envelope.enrichment; + payload = JSON.stringify(envelope); + } + if (payload.length > MAX_SNAPSHOT_BYTES) return; + await writeFile(file, payload, { encoding: "utf8", mode: 0o600 }); + } catch { + // Best-effort: callers already hold the in-memory entry. + } +} + +export async function clearDiskSnapshot(providerId: string): Promise { + try { + await unlink(diskSnapshotPath(providerId)); + return true; + } catch { + return false; + } +} diff --git a/@omniroute/opencode-plugin-v2/src/catalog.ts b/@omniroute/opencode-plugin-v2/src/catalog.ts new file mode 100644 index 0000000000..73c3f4ab70 --- /dev/null +++ b/@omniroute/opencode-plugin-v2/src/catalog.ts @@ -0,0 +1,798 @@ +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 ApiFormatV2, + type LogLevel, + type Logger, + type OmniRouteAutoCombosFetcher, + type OmniRouteCombosFetcher, + type OmniRouteEnrichmentFetcher, + type OmniRouteEnrichmentMap, + type OmniRouteModelsFetcher, + type OmniRouteProviderConnection, + type OmniRouteProvidersFetcher, + type OmniRouteRawAutoCombo, + type OmniRouteRawCombo, + type OmniRouteRawModelEntry, + applyEnrichment, + buildCanonicalToAliasMap, + canonicalDedupSet, + createLogger, + defaultOmniRouteEnrichmentFetcher, + defaultOmniRouteProvidersFetcher, + ensureV1Suffix, + isUsableCombo, + isUsableRawModelId, + lookupEnrichment, + mapAutoComboToModelV2, + mapComboToModelV2, + mapRawModelToModelV2, + usableProviderAliasSet, +} from "./shared/index.js"; + +export type ModelsFetcher = OmniRouteModelsFetcher; +export type CombosFetcher = OmniRouteCombosFetcher; +export type AutoCombosFetcher = OmniRouteAutoCombosFetcher; +export type ProvidersFetcher = OmniRouteProvidersFetcher; +export type EnrichmentFetcher = OmniRouteEnrichmentFetcher; + +export interface EndpointTimeouts { + models?: number; + combos?: number; + autoCombos?: number; + enrichment?: number; +} + +export interface ResolvedOptions { + providerId: string; + baseURL: string; + apiKey: string; + managementReadToken?: string; + timeoutMs: number; + timeouts?: EndpointTimeouts; + logger?: Logger; + logLevel?: LogLevel; + startupDebug?: boolean; + modelCacheTtlMs: number; + /** v1 parity: prefix the display name with the upstream provider label. */ + providerTag?: boolean; + displayName?: string; + apiFormat?: ApiFormatV2; + visibleModels?: string[]; + hiddenModels?: string[]; + usableOnly: boolean; + enrichment?: OmniRouteEnrichmentMap | boolean; + /** + * Shared collision-warning dedupe set keyed `cacheKey::comboKey`. When + * omitted a fresh per-publish set is used. index.ts passes one setup-wide + * set so a repeated publish (stale replay + refresh) warns once per key. + */ + collisionWarned?: Set; +} + +export interface CatalogFetchers { + fetcher?: ModelsFetcher; + combosFetcher?: CombosFetcher; + autoCombosFetcher?: AutoCombosFetcher; + providersFetcher?: ProvidersFetcher; + enrichmentFetcher?: EnrichmentFetcher; + models?: ModelsFetcher; + combos?: CombosFetcher; + autoCombos?: AutoCombosFetcher; + providers?: ProvidersFetcher; + enrichment?: EnrichmentFetcher; + /** + * Called when a gateway source cannot be read. Without it this function + * degrades silently — the catalog publishes with raw ids and no combos and + * nothing says why, which is the failure the plugin path reports. + */ + 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:"; + +/** Top-level `package` as the legacy contract expects it (`aisdk:`). */ +export interface BinaryCompatPackage { + package: string; +} + +/** + * 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. + */ +export interface BinaryCompatFields extends BinaryCompatPackage { + settings: Record; + headers: Record; +} + +/** Legacy variants read their options from `settings`, not `headers`/`body`. */ +export type BinaryCompatVariant = ModelV2Info["variants"][number] & { + settings: Record; +}; + +export type BinaryCompatModel = ModelV2Info & BinaryCompatFields; +export type BinaryCompatProvider = ProviderV2Info & + BinaryCompatPackage & { + settings: Record; + }; + +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"] { + 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)" + ); + } + 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 legacyCapabilitiesToInfoCapabilities( + caps: LegacyModelV2["capabilities"] +): ModelV2Info["capabilities"] { + const input: string[] = []; + if (caps.input.text) input.push("text"); + if (caps.input.audio) input.push("audio"); + if (caps.input.image) input.push("image"); + if (caps.input.video) input.push("video"); + if (caps.input.pdf) input.push("pdf"); + const output: string[] = []; + if (caps.output.text) output.push("text"); + if (caps.output.audio) output.push("audio"); + if (caps.output.image) output.push("image"); + if (caps.output.video) output.push("video"); + if (caps.output.pdf) output.push("pdf"); + return { tools: caps.toolcall, input, output }; +} + +function legacyToInfo(providerID: string, modelID: string, m: LegacyModelV2): ModelV2Info { + const variants = Object.entries(m.variants ?? {}).map(([id, body]) => ({ + id, + headers: {}, + body: body as Record, + })); + const parsed = Date.parse(m.release_date); + return { + id: 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 } }, + variants, + time: { released: Number.isNaN(parsed) ? 0 : parsed }, + cost: legacyCostToInfoCost(m.cost), + status: m.status, + enabled: true, + limit: { ...m.limit }, + }; +} + +export interface PublishCounts { + models: number; + combos: number; + autoCombos: number; +} + +export interface ModelListFilter { + exact: Set; + suffixes: Set; +} + +export function compileModelListFilter(list?: string[]): ModelListFilter | undefined { + if (!list || list.length === 0) return undefined; + const exact = new Set(); + const suffixes = new Set(); + for (const id of list) { + if (id.includes("/")) { + exact.add(id); + } else { + suffixes.add(id); + } + } + if (exact.size === 0 && suffixes.size === 0) return undefined; + return { exact, suffixes }; +} + +function matchesSuffix(id: string, suffixes: Set): boolean { + if (suffixes.size === 0) return false; + const slash = id.indexOf("/"); + const suffix = slash > 0 ? id.slice(slash + 1) : id; + return suffixes.has(suffix); +} + +export function passesModelAllowlist( + id: string, + visible?: ModelListFilter, + hidden?: ModelListFilter +): boolean { + if (hidden) { + if (hidden.exact.has(id) || matchesSuffix(id, hidden.suffixes)) return false; + } + if (visible) { + if (!visible.exact.has(id) && !matchesSuffix(id, visible.suffixes)) return false; + } + return true; +} + +export function passesComboAllowlist(combo: OmniRouteRawCombo, visible?: ModelListFilter): boolean { + if (!visible) return true; + const steps = Array.isArray(combo.models) ? combo.models : []; + if (steps.length === 0) return true; + let sawResolvableMember = false; + for (const step of steps) { + if (step?.kind === "combo-ref") continue; + const modelId = typeof step?.model === "string" ? step.model : ""; + if (modelId.length === 0) continue; + sawResolvableMember = true; + if (visible.exact.has(modelId) || matchesSuffix(modelId, visible.suffixes)) return true; + } + if (!sawResolvableMember) return true; + return false; +} + +/** + * 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). + */ +function legacyModelFields(info: ModelV2Info): BinaryCompatFields | undefined { + if (info.api.type !== "aisdk") return undefined; + const settings: Record = { + ...(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 +): 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); + } +} + +function assignProviderFields( + target: ProviderV2Info, + source: { name: string; api: ProviderV2Info["api"]; integrationID: string }, + contract: HostContract +): 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:` value as its models, and + // the endpoint as `settings.baseURL`. + if (source.api.type !== "aisdk") return; + const settings: Record = { ...(source.api.settings ?? {}) }; + if (source.api.url !== undefined) settings.baseURL = source.api.url; + Object.assign(target, { package: toBinaryPackage(source.api.package), settings }); +} + +/** A widened capability flag (`boolean | { field }`) read back as a plain flag. */ +function isCapabilityEnabled(value: boolean | { field: string }): boolean { + return value !== false; +} + +/** + * Combo steps reach us from the gateway with a shape the SDK types do not + * describe (`kind`, `comboName`, `model` appear per step kind). One reader + * keeps that single untyped boundary in one place instead of scattering casts. + */ +function readStepField(step: unknown, key: "kind" | "comboName" | "model"): unknown { + return (step as Record | null | undefined)?.[key]; +} + +/** + * Resolve the display-name + pricing overlay. A caller may hand over a + * ready-made map (tests, pre-resolved overlays) or turn the fetch off; a + * failed fetch soft-fails to an empty map so the catalog still publishes, + * with mapper-default names and zeroed pricing rather than nothing at all. + */ +async function resolveEnrichmentOverlay( + opts: ResolvedOptions, + fetchers: CatalogFetchers | undefined, + log: Logger +): Promise { + if (opts.enrichment instanceof Map) return opts.enrichment; + if (opts.enrichment === false) return new Map(); + const fetchEnrichment = + fetchers?.enrichmentFetcher ?? fetchers?.enrichment ?? defaultOmniRouteEnrichmentFetcher; + try { + return await fetchEnrichment( + opts.baseURL, + opts.managementReadToken ?? opts.apiKey, + opts.timeouts?.enrichment ?? opts.timeoutMs, + fetchers?.onSourceError + ); + } catch (err) { + log.warn( + `[omniroute-v2] enrichment fetch failed, continuing without names/pricing: ${err instanceof Error ? err.message : String(err)}` + ); + return new Map(); + } +} + +/** + * Resolve the provider aliases worth publishing when `usableOnly` is on. + * Gated on the flag, so the default configuration issues no request at all. + * The filter subtracts: a failed or empty connections fetch yields + * `undefined` and keeps the whole catalog, because only a prefix proven not + * provisioned may be dropped. + */ +async function resolveUsableAliases( + opts: ResolvedOptions, + providersFetcher: OmniRouteProvidersFetcher | undefined, + onSourceError: ((endpoint: string, reason: string) => void) | undefined, + enrichment: OmniRouteEnrichmentMap, + timeoutMs: number, + log: Logger +): Promise | undefined> { + if (!opts.usableOnly) return undefined; + let rawConnections: OmniRouteProviderConnection[]; + try { + const fetchProviders = providersFetcher ?? defaultOmniRouteProvidersFetcher; + rawConnections = await fetchProviders( + opts.baseURL, + opts.managementReadToken ?? opts.apiKey, + timeoutMs, + onSourceError + ); + } catch (err) { + log.warn( + `[omniroute-v2] providers fetch failed, usableOnly filter disabled for this refresh: ${err instanceof Error ? err.message : String(err)}` + ); + rawConnections = []; + } + return rawConnections.length > 0 ? usableProviderAliasSet(rawConnections, enrichment) : undefined; +} + +/** Everything the combo publishing pass reads, passed as one value. */ +interface PublishContext { + draft: CatalogDraft; + opts: ResolvedOptions; + log: Logger; + providerId: string; + hostContract: HostContract; + enrichment: OmniRouteEnrichmentMap; + rawModelById: Map; + publishedKeys: Set; + publishedModelIds: Map; + visibleFilter: ReturnType; + hiddenFilter: ReturnType; + usable: ReturnType | undefined; + canonicalToAlias: ReturnType; + combosFetcher: CatalogFetchers["combos"] | undefined; + combosTimeout: number; + /** Shared with the auto-combos pass: one collision warning per key, per run. */ + warnedCombos: Set; + cacheKey: string; +} + +/** + * Fetch the gateway's combos and publish them, resolving nested combo-refs to + * a fixpoint first: a combo whose members are themselves combos only knows its + * lowest common denominator once those are known. Combos that never resolve + * are dropped rather than published with a fabricated capability set, and + * reported once. + * + * Returns the number published, or `undefined` when the combos fetch failed — + * the caller then publishes a models-only catalog instead of an empty one. + */ +async function publishCombos(ctx: PublishContext): Promise { + const { + draft, + opts, + log, + providerId: X, + hostContract, + enrichment, + rawModelById, + publishedKeys, + publishedModelIds, + visibleFilter, + hiddenFilter, + usable, + canonicalToAlias, + combosFetcher, + combosTimeout, + warnedCombos, + cacheKey, + } = ctx; + let rawCombos: OmniRouteRawCombo[]; + try { + rawCombos = combosFetcher + ? await combosFetcher(opts.baseURL, opts.managementReadToken ?? opts.apiKey, combosTimeout) + : []; + } catch (err) { + log.warn( + `[omniroute-v2] combos fetch failed, falling back to models-only catalog: ${err instanceof Error ? err.message : String(err)}` + ); + return undefined; + } + + let comboCount = 0; + // Ported from v1 (fixpoint 8 passes + warn once per (cacheKey, comboKey) + // + intentional-dedup exception). Nested combo-refs resolve against the + // friendly combo name; unresolvable combos are dropped (never published + // with a fabricated empty LCD) and reported once. + const MAX_COMBO_PASSES = 8; + const pending = rawCombos.filter((combo) => { + if (!combo || !combo.id) return false; + if (combo.isHidden === true) return false; + if (usable && !isUsableCombo(combo, usable)) return false; + if (visibleFilter && !passesComboAllowlist(combo, visibleFilter)) return false; + // Deny wins for combos too: a user who hides an id expects it gone from + // the picker whether it is a model or a combo built on it. + if (hiddenFilter && passesComboAllowlist(combo, hiddenFilter)) return false; + return true; + }); + const resolvedByName = new Map(); + let unresolved: typeof pending = []; + + for (let pass = 0; pass < MAX_COMBO_PASSES && pending.length > 0; pass++) { + const stillPending: typeof pending = []; + for (const combo of pending) { + const memberSteps = Array.isArray(combo.models) ? combo.models : []; + const memberEntries: OmniRouteRawModelEntry[] = []; + let deferred = false; + for (const step of memberSteps) { + const kind = readStepField(step, "kind"); + if (kind === "combo-ref") { + const comboName = readStepField(step, "comboName"); + if (typeof comboName !== "string" || comboName.length === 0) continue; + const nested = resolvedByName.get(comboName); + if (!nested) { + deferred = true; + break; + } + memberEntries.push(synthesizeNestedMember(comboName, nested)); + continue; + } + const modelId = readStepField(step, "model"); + if (typeof modelId !== "string" || modelId.length === 0) continue; + const member = rawModelById.get(modelId); + if (member) memberEntries.push(member); + } + if (deferred) { + stillPending.push(combo); + continue; + } + const mapped = mapComboToModelV2(combo, memberEntries, X, opts.baseURL, opts.apiFormat); + applyEnrichment(mapped, lookupEnrichment(combo.id, enrichment, canonicalToAlias), { + isCombo: true, + }); + const mid = mapped.id.startsWith(X + "/") ? mapped.id.slice(X.length + 1) : mapped.id; + const key = X + "/" + mid; + if (publishedKeys.has(key)) { + // Intentional dedup (v1 parity): `/v1/models` pre-mirrors combos as + // raw entries, so the combo's friendly NAME matches the overwritten + // entry's model id (bare or provider-prefixed, endsWith to cover + // both). Only warn on a genuine accidental collision (name differs + // from the entry it overwrites). + const existingId = publishedModelIds.get(key) ?? ""; + const friendly = + typeof combo.name === "string" && combo.name.trim().length > 0 + ? combo.name.trim() + : combo.id; + const isIntentionalDedup = + existingId === friendly || + existingId === X + "/" + friendly || + existingId.endsWith("/" + friendly); + if (!isIntentionalDedup) { + const dedupeKey = `${cacheKey}::${key}`; + if (!warnedCombos.has(dedupeKey)) { + warnedCombos.add(dedupeKey); + log.warn(`[omniroute-v2] combo key "${key}" collides with a model id; combo wins.`); + } + } + } + draft.model.update(X, mid, (m) => { + assignModelFields(m, mapped, hostContract); + }); + publishedKeys.add(key); + publishedModelIds.set(key, mapped.id); + comboCount += 1; + const lookupName = + typeof combo.name === "string" && combo.name.trim().length > 0 + ? combo.name.trim() + : combo.id; + if (!resolvedByName.has(lookupName)) resolvedByName.set(lookupName, mapped); + } + if (stillPending.length === pending.length) { + unresolved = stillPending; + break; + } + unresolved = stillPending; + pending.length = 0; + pending.push(...stillPending); + } + + if (unresolved.length > 0) { + log.warn( + `[omniroute-v2] ${unresolved.length} combo(s) could not resolve all nested combo-refs after ${MAX_COMBO_PASSES} passes; dropped to avoid over-claiming.` + ); + } + return comboCount; +} + +/** + * Synthesize a raw-model entry from an already-resolved nested combo so a + * parent combo's LCD folds the whole nested capability vector (context, + * 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 { + const inputModalities: string[] = []; + if (nested.capabilities.input.text) inputModalities.push("text"); + if (nested.capabilities.input.audio) inputModalities.push("audio"); + if (nested.capabilities.input.image) inputModalities.push("image"); + if (nested.capabilities.input.video) inputModalities.push("video"); + if (nested.capabilities.input.pdf) inputModalities.push("pdf"); + const outputModalities: string[] = []; + if (nested.capabilities.output.text) outputModalities.push("text"); + if (nested.capabilities.output.audio) outputModalities.push("audio"); + if (nested.capabilities.output.image) outputModalities.push("image"); + if (nested.capabilities.output.video) outputModalities.push("video"); + if (nested.capabilities.output.pdf) outputModalities.push("pdf"); + return { + id: `combo-ref:${name}`, + context_length: nested.limit.context, + max_output_tokens: nested.limit.output, + ...(nested.limit.input !== undefined ? { max_input_tokens: nested.limit.input } : {}), + owned_by: "combo", + input_modalities: inputModalities, + output_modalities: outputModalities, + capabilities: { + temperature: nested.capabilities.temperature, + // A raw entry carries plain flags; the mapped model widens them to + // `boolean | { field }` (custom reasoning/thinking field). Every + // non-false form means the capability is present, which is all the + // LCD fold reads. + reasoning: isCapabilityEnabled(nested.capabilities.reasoning), + thinking: isCapabilityEnabled(nested.capabilities.interleaved), + attachment: nested.capabilities.attachment, + tool_calling: nested.capabilities.toolcall, + }, + }; +} + +export async function publishCatalog( + draft: CatalogDraft, + opts: ResolvedOptions, + fetchers?: CatalogFetchers +): Promise { + const X = opts.providerId; + const log = opts.logger ?? createLogger(opts.startupDebug ? "debug" : (opts.logLevel ?? "warn")); + const modelsTimeout = opts.timeouts?.models ?? opts.timeoutMs; + const combosTimeout = opts.timeouts?.combos ?? opts.timeoutMs; + // v1 parity keeps the 5s auto-combos budget when no per-endpoint value is + // 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; + + let rawModels: OmniRouteRawModelEntry[]; + try { + rawModels = modelsFetcher ? await modelsFetcher(opts.baseURL, opts.apiKey, modelsTimeout) : []; + } catch (err) { + log.warn( + `[omniroute-v2] models fetch failed, publishing empty catalog: ${err instanceof Error ? err.message : String(err)}` + ); + return { models: 0, combos: 0, autoCombos: 0 }; + } + + const visibleFilter = compileModelListFilter(opts.visibleModels); + const hiddenFilter = compileModelListFilter(opts.hiddenModels); + + const enrichment = await resolveEnrichmentOverlay(opts, fetchers, log); + const canonicalToAlias = buildCanonicalToAliasMap(enrichment); + const canonicalDedup = canonicalDedupSet(rawModels, canonicalToAlias); + + const usable = await resolveUsableAliases( + opts, + providersFetcher, + fetchers?.onSourceError, + enrichment, + modelsTimeout, + log + ); + + const rawModelById = new Map(); + for (const entry of rawModels) { + if (entry.id) rawModelById.set(entry.id, entry); + } + + const publishedKeys = new Set(); + // Mapped model id per published key (models and combos alike). Mirrors + // v1's `models[comboKey]` lookup so the intentional-dedup check sees the + // overwritten entry's id, not just key presence. + const publishedModelIds = new Map(); + let modelCount = 0; + for (const entry of rawModels) { + if (!entry.id) continue; + if (canonicalDedup.has(entry.id)) continue; + if (usable && !isUsableRawModelId(entry.id, usable)) continue; + if (!passesModelAllowlist(entry.id, visibleFilter, hiddenFilter)) continue; + const mapped = mapRawModelToModelV2(entry, { + providerId: X, + baseURL: opts.baseURL, + apiFormat: opts.apiFormat, + }); + applyEnrichment(mapped, lookupEnrichment(entry.id, enrichment, canonicalToAlias), { + 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); + modelCount += 1; + } + + const warnedCombos = opts.collisionWarned ?? new Set(); + const cacheKey = `${opts.baseURL}::${opts.providerId}`; + const comboCount = await publishCombos({ + draft, + opts, + log, + providerId: X, + hostContract, + enrichment, + rawModelById, + publishedKeys, + publishedModelIds, + visibleFilter, + hiddenFilter, + usable, + canonicalToAlias, + combosFetcher, + combosTimeout, + warnedCombos, + cacheKey, + }); + if (comboCount === undefined) return { 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. + // Re-publishing under "opencode-"+X here is FORBIDDEN: a double + // publish would double chat entries in the picker. + + // Auto combos: virtual server-side entries from /api/combos/auto, keyed + // "auto" / "auto/" (v1 parity). Fail-open: a fetcher throw keeps + // models + combos and only warns - old gateways may not serve the + // endpoint at all (the default fetcher maps 404 to [] itself). + let rawAutoCombos: OmniRouteRawAutoCombo[]; + try { + rawAutoCombos = autoCombosFetcher + ? await autoCombosFetcher( + opts.baseURL, + opts.managementReadToken ?? opts.apiKey, + autoCombosTimeout + ) + : []; + } catch (err) { + 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 }; + } + + let autoComboCount = 0; + for (const autoCombo of rawAutoCombos) { + if (!autoCombo || !autoCombo.id) continue; + if (autoCombo.isHidden === true) continue; + // Auto combos are catalog entries like any other: an id a user asked to + // hide must stay hidden, and an allowlist that excludes it must exclude + // it. They used to skip both filters entirely. + if (!passesModelAllowlist(autoCombo.id, visibleFilter, hiddenFilter)) continue; + if (usable && !isUsableRawModelId(autoCombo.id, usable)) continue; + const mapped = mapAutoComboToModelV2(autoCombo, X, opts.baseURL, opts.apiFormat); + applyEnrichment(mapped, lookupEnrichment(autoCombo.id, enrichment, canonicalToAlias), { + isCombo: true, + isAutoCombo: true, + }); + const key = X + "/" + mapped.id; + if (publishedKeys.has(key)) { + const dedupeKey = `${cacheKey}::${key}`; + if (!warnedCombos.has(dedupeKey)) { + warnedCombos.add(dedupeKey); + log.warn( + `[omniroute-v2] auto combo key "${key}" collides with a model id; auto combo wins.` + ); + } + } + draft.model.update(X, mapped.id, (m) => { + assignModelFields(m, mapped, hostContract); + }); + publishedKeys.add(key); + publishedModelIds.set(key, mapped.id); + autoComboCount += 1; + } + + return { models: modelCount, combos: comboCount, autoCombos: autoComboCount }; +} diff --git a/@omniroute/opencode-plugin-v2/src/compat.ts b/@omniroute/opencode-plugin-v2/src/compat.ts new file mode 100644 index 0000000000..1c4aa9fe14 --- /dev/null +++ b/@omniroute/opencode-plugin-v2/src/compat.ts @@ -0,0 +1,66 @@ +function isObject(value: unknown): value is Record { + return typeof value === "object" && value !== null; +} + +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"; +} diff --git a/@omniroute/opencode-plugin-v2/src/credentials.ts b/@omniroute/opencode-plugin-v2/src/credentials.ts new file mode 100644 index 0000000000..9cf2b8536b --- /dev/null +++ b/@omniroute/opencode-plugin-v2/src/credentials.ts @@ -0,0 +1,101 @@ +import type { PluginContext } from "@opencode-ai/plugin/v2/promise"; +import type { Logger } from "./shared/index.js"; + +/** Where a resolved key came from, so the failure message can name the fix. */ +export type ApiKeyOrigin = "connection" | "option" | "env" | "missing"; + +export interface ResolvedApiKey { + key: string; + origin: ApiKeyOrigin; +} + +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. + */ +function connectionApi(ctx: PluginContext): PluginContext["integration"]["connection"] | undefined { + const connection = (ctx.integration as Partial).connection; + if ( + connection === undefined || + typeof connection.active !== "function" || + typeof connection.resolve !== "function" + ) { + return undefined; + } + return connection; +} + +/** + * Read the credential the user stored through the host's own auth flow. + * + * The plugin advertises `key` and `env` methods on its integration, so a user + * can connect it from the UI; without this lookup that connection would only + * 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. + */ +async function keyFromConnection( + ctx: PluginContext, + integrationID: string, + log: Logger +): Promise { + const connection = connectionApi(ctx); + if (connection === undefined) return undefined; + try { + const active = await connection.active(integrationID); + if (active === undefined) return undefined; + const credential = await connection.resolve(active); + 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` + ); + return undefined; + } + return 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)}` + ); + return undefined; + } +} + +/** + * Resolve the gateway key, preferring the credential the host holds over one + * written in config. A key in `opencode.json` still wins over the environment + * so an explicit per-project override keeps working. + */ +export async function resolveApiKey( + ctx: PluginContext, + integrationID: string, + optionKey: string | undefined, + log: Logger +): Promise { + const stored = await keyFromConnection(ctx, integrationID, log); + if (stored !== undefined) return { key: stored, origin: "connection" }; + if (optionKey !== undefined && optionKey.length > 0) return { key: optionKey, origin: "option" }; + const fromEnv = process.env[ENV_VAR]; + if (fromEnv !== undefined && fromEnv.length > 0) return { key: fromEnv, origin: "env" }; + return { key: "", origin: "missing" }; +} + +/** + * A missing key produces an empty catalog and no error the user can see, so + * say it once, and name the three ways to supply one. + */ +export function warnIfMissing(resolved: ResolvedApiKey, integrationID: string, log: Logger): void { + if (resolved.origin !== "missing") return; + log.warn( + `[omniroute-v2] no API key for "${integrationID}": the catalog will be empty. ` + + `Connect the integration from opencode, set "apiKey" in the plugin options, ` + + `or export ${ENV_VAR}.` + ); +} diff --git a/@omniroute/opencode-plugin-v2/src/enrichment-report.ts b/@omniroute/opencode-plugin-v2/src/enrichment-report.ts new file mode 100644 index 0000000000..d2ab275ae8 --- /dev/null +++ b/@omniroute/opencode-plugin-v2/src/enrichment-report.ts @@ -0,0 +1,41 @@ +import type { Logger } from "./shared/index.js"; + +/** What the catalog loses when a given gateway source cannot be read. */ +function consequenceOf(endpoint: string): string { + if (endpoint.includes("/api/providers")) { + return "the usable-provider filter is disabled for this refresh, so unprovisioned providers stay listed"; + } + return "model names, provider tags, canonical dedupe and pricing are degraded"; +} + +/** + * A source the gateway refuses is not fatal — the catalog still publishes — + * but staying quiet about it is: the picker then shows raw ids, or lists + * providers that cannot serve, with nothing telling the user why. Say it once + * per endpoint so a refresh loop cannot spam the log. + * + * `usingFallbackToken` is true when no `managementReadToken` was configured and + * the inference key stands in for it, which is the usual reason a gateway + * answers 401/403 on `/api/*` — the advice differs from a token that was set + * and still got rejected. + */ +export function createSourceErrorReporter( + log: Logger, + usingFallbackToken: boolean +): (endpoint: string, reason: string) => void { + const warned = new Set(); + return (endpoint, reason) => { + if (warned.has(endpoint)) return; + warned.add(endpoint); + const unauthorized = reason.includes("401") || reason.includes("403"); + const hint = !unauthorized + ? "" + : usingFallbackToken + ? ` These endpoints need a management token: set "managementReadToken" in the plugin options ` + + `(it currently falls back to "apiKey", which a gateway usually rejects here).` + : ` The configured "managementReadToken" was rejected — check it grants read access to /api/*.`; + log.warn( + `[omniroute-v2] gateway source ${endpoint} unavailable (${reason}): ${consequenceOf(endpoint)}.${hint}` + ); + }; +} diff --git a/@omniroute/opencode-plugin-v2/src/gemini-language.ts b/@omniroute/opencode-plugin-v2/src/gemini-language.ts new file mode 100644 index 0000000000..c571367166 --- /dev/null +++ b/@omniroute/opencode-plugin-v2/src/gemini-language.ts @@ -0,0 +1,43 @@ +import type { LanguageModelV3 } from "@ai-sdk/provider"; +import { type Logger, isGeminiModelId, sanitizeToolInputSchemas } from "./shared/index.js"; + +type CallOptions = Parameters[0]; + +/** + * Gemini answers `400 INVALID_ARGUMENT` — for the entire request, not just the + * offending tool — when a tool declaration carries `$schema` or + * `additionalProperties`. Anything upstream that emits standard JSON Schema + * therefore breaks tool calling as soon as the chain routes to Gemini. A + * `$ref` is forwarded untouched instead: stripping it would widen the schema + * to "accept anything", which is worse than letting the gateway answer. The + * v1 plugin dealt with this by wrapping `fetch` and rewriting the JSON body; the + * v2 home for it is the language model, where the tools are still structured + * data and no re-parsing is needed. + * + * Returns the model untouched when it is not bound for Gemini, so the wrapper + * costs nothing on every other chain. + */ +export function sanitizeToolSchemasFor( + language: T, + modelId: string, + log: Logger +): T { + if (language === undefined) return language; + if (!isGeminiModelId(modelId)) return language; + + const clean = (options: CallOptions): CallOptions => { + const tools = sanitizeToolInputSchemas(options.tools); + if (tools === undefined) return options; + log.debug( + `[omniroute-v2] stripped Gemini-incompatible schema keywords from ${tools.length} tool declaration(s) for ${modelId}` + ); + return { ...options, tools } as CallOptions; + }; + + // Prototype-linked so every other member of the model — including accessors + // and anything a future SDK version adds — keeps working untouched. + const wrapped: LanguageModelV3 = Object.create(language as object) as LanguageModelV3; + wrapped.doGenerate = (options) => language.doGenerate(clean(options)); + wrapped.doStream = (options) => language.doStream(clean(options)); + return wrapped as T; +} diff --git a/@omniroute/opencode-plugin-v2/src/index.ts b/@omniroute/opencode-plugin-v2/src/index.ts new file mode 100644 index 0000000000..f6c0471baf --- /dev/null +++ b/@omniroute/opencode-plugin-v2/src/index.ts @@ -0,0 +1,539 @@ +import { define, type PluginContext } from "@opencode-ai/plugin/v2/promise"; +import { + optionalTierFingerprint, + catalogContentFingerprint, + createLogger, + defaultOmniRouteAutoCombosFetcher, + defaultOmniRouteCombosFetcher, + defaultOmniRouteEnrichmentFetcher, + defaultOmniRouteModelsFetcher, + defaultOmniRouteProvidersFetcher, + type OmniRouteEnrichmentMap, + type OmniRouteProviderConnection, +} from "./shared/index.js"; +import type { + OmniRouteRawAutoCombo, + OmniRouteRawCombo, + OmniRouteRawModelEntry, +} from "./shared/index.js"; +import type { ResolvedOptions } from "./catalog.js"; +import { publishCatalog } from "./catalog.js"; +import { + DEFAULT_MODEL_CACHE_TTL_MS, + UNREACHABLE_COOLDOWN_MS, + memoryCacheKey, + readDiskSnapshot, + snapshotIdentityFingerprint, + writeDiskSnapshot, + type CatalogSnapshot, +} from "./cache.js"; +import { assertContext } from "./compat.js"; +import { type ApiKeyOrigin, resolveApiKey, warnIfMissing } from "./credentials.js"; +import { createSourceErrorReporter } from "./enrichment-report.js"; +import { sanitizeToolSchemasFor } from "./gemini-language.js"; +import { PLUGIN_ID, parsePluginOptions, resolveTimeouts, type PluginOptions } from "./options.js"; + +/** + * A fetch result that says whether it succeeded. Returning a bare `[]` on + * failure makes an outage indistinguishable from a gateway that legitimately + * has no combos — and the difference decides whether the last known value + * should be kept or dropped. + */ +type SourceResult = { ok: true; value: T } | { ok: false }; + +interface RefreshState { + entries: Map; + inFlight: Map>; + fingerprint: string | undefined; + /** Digest of the optional tier, so a reload only follows a real change. */ + optionalFingerprint: string | undefined; + /** + * When the last refresh found the gateway unreachable, skip the network + * until this timestamp and serve last-known-good instead. Without it every + * transform past TTL re-fires the full fetch suite against a gateway that + * just proved it cannot answer — a self-inflicted retry storm. + */ + unreachableUntil: number; +} + +function toResolvedOptions(parsed: PluginOptions): ResolvedOptions { + return { + providerId: parsed.providerId, + baseURL: parsed.baseURL, + apiKey: parsed.apiKey ?? process.env.OMNIROUTE_API_KEY ?? "", + managementReadToken: parsed.managementReadToken, + timeoutMs: parsed.timeoutMs, + timeouts: parsed.timeouts, + logLevel: parsed.logLevel, + startupDebug: parsed.startupDebug, + providerTag: parsed.providerTag, + modelCacheTtlMs: + typeof parsed.modelCacheTtlMs === "number" && parsed.modelCacheTtlMs > 0 + ? parsed.modelCacheTtlMs + : DEFAULT_MODEL_CACHE_TTL_MS, + displayName: parsed.displayName, + apiFormat: parsed.apiFormat, + visibleModels: parsed.visibleModels, + hiddenModels: parsed.hiddenModels, + usableOnly: parsed.usableOnly, + enrichment: parsed.enrichment, + }; +} + +export default define({ + id: PLUGIN_ID, + setup: async (ctx: PluginContext) => { + assertContext(ctx); + const parsed = parsePluginOptions(ctx.options); + const X = parsed.providerId; + const resolved = toResolvedOptions(parsed); + const timeouts = resolveTimeouts(parsed); + const log = createLogger(parsed.startupDebug ? "debug" : (parsed.logLevel ?? "warn")); + resolved.logger = log; + resolved.logLevel = parsed.logLevel; + resolved.startupDebug = parsed.startupDebug; + log.info(`[omniroute-v2] init providerId=${X}`); + + // v1 parity port: in-memory TTL + disk snapshot. The memory key + // `baseURL::sha256(creds)` isolates credential tuples (prod vs + // staging); the TTL is checked in the transform before any fetch; + // concurrent calls share the refresh promise in the setup closure keyed + // by (providerId, baseURL); the disk snapshot feeds warm-startup and + // the offline fallback. The existing in-memory keep-last-good is kept. + const state: RefreshState = { + entries: new Map(), + inFlight: new Map(), + fingerprint: undefined, + optionalFingerprint: undefined, + unreachableUntil: 0, + }; + + // The credential the host holds wins over one written in config, so a + // user who connected the integration from the UI never has to paste a + // key into `opencode.json`. Reading it is async and the transforms must + // register synchronously, so the lookup happens on the first publish; + // until then the option/env key resolved above stands in. + const credentialsOf = (): { cacheKey: string; identityFingerprint: string } => ({ + cacheKey: memoryCacheKey( + resolved.baseURL, + `${resolved.apiKey}\0${resolved.managementReadToken ?? resolved.apiKey}` + ), + identityFingerprint: snapshotIdentityFingerprint( + resolved.baseURL, + resolved.apiKey, + resolved.managementReadToken ?? resolved.apiKey + ), + }); + let { cacheKey, identityFingerprint } = credentialsOf(); + + // Both keys are derived from the credential: two credentials must never + // share a snapshot, so they are recomputed whenever the key moves. + let credentialChecked = false; + let apiKeyOrigin: ApiKeyOrigin = resolved.apiKey.length > 0 ? "option" : "missing"; + const ensureCredential = async (): Promise => { + // Settled once a key is in hand: re-reading on every refresh would let + // a mid-session change silently repoint the snapshot keys. + if (credentialChecked && apiKeyOrigin !== "missing") return; + const next = await resolveApiKey(ctx, X, parsed.apiKey, log); + const moved = next.key !== resolved.apiKey; + resolved.apiKey = next.key; + apiKeyOrigin = next.origin; + if (moved) ({ cacheKey, identityFingerprint } = credentialsOf()); + if (!credentialChecked) warnIfMissing(next, X, log); + else if (moved) log.info(`[omniroute-v2] API key picked up from the ${next.origin} source`); + credentialChecked = true; + }; + + const fetchModelsSafe = async (): Promise => { + try { + return await defaultOmniRouteModelsFetcher( + resolved.baseURL, + resolved.apiKey, + timeouts.models + ); + } catch (err) { + log.warn( + `[omniroute-v2] models fetch failed, publishing empty catalog: ${err instanceof Error ? err.message : String(err)}` + ); + return []; + } + }; + // Failures are reported once per endpoint (with the management-token hint + // when the inference key stands in), so a gated `/api/*` degrades loudly + // rather than silently. Declared before the wrappers that use it. + const reportSourceError = createSourceErrorReporter( + log, + resolved.managementReadToken === undefined + ); + const fetchCombosSafe = async (): Promise> => { + try { + return { + ok: true, + value: await defaultOmniRouteCombosFetcher( + resolved.baseURL, + resolved.managementReadToken ?? resolved.apiKey, + timeouts.combos + ), + }; + } catch (err) { + const reason = err instanceof Error ? err.message : String(err); + reportSourceError("/api/combos", reason); + log.warn(`[omniroute-v2] combos fetch failed, keeping the last known combos: ${reason}`); + return { ok: false }; + } + }; + // Providers connections follow the same rule: gated on usableOnly (no + // request when false, v1 parity), soft-fail to [] so the filter degrades + // to keep-all instead of hiding the catalog. + const fetchProvidersSafe = async (): Promise> => { + if (!resolved.usableOnly) return { ok: true, value: [] }; + try { + return { + ok: true, + value: await defaultOmniRouteProvidersFetcher( + resolved.baseURL, + resolved.managementReadToken ?? resolved.apiKey, + timeouts.models, + reportSourceError + ), + }; + } catch (err) { + log.warn( + `[omniroute-v2] providers fetch failed, keeping the last known provider list: ${err instanceof Error ? err.message : String(err)}` + ); + return { ok: false }; + } + }; + // Enrichment follows the same rule: gated on the option (default on, + // v1 parity), soft-fail to an empty map so names/pricing degrade to + // mapper defaults instead of hiding the catalog. + const fetchEnrichmentSafe = async (): Promise> => { + if (resolved.enrichment === false) return { ok: true, value: new Map() }; + try { + return { + ok: true, + value: await defaultOmniRouteEnrichmentFetcher( + resolved.baseURL, + resolved.managementReadToken ?? resolved.apiKey, + timeouts.enrichment, + reportSourceError + ), + }; + } catch (err) { + log.warn( + `[omniroute-v2] enrichment fetch failed, keeping the last known names/pricing: ${err instanceof Error ? err.message : String(err)}` + ); + return { ok: false }; + } + }; + const fetchAutoCombosSafe = async (): Promise> => { + try { + return { + ok: true, + value: await defaultOmniRouteAutoCombosFetcher( + resolved.baseURL, + resolved.managementReadToken ?? resolved.apiKey, + timeouts.autoCombos, + log, + reportSourceError + ), + }; + } catch (err) { + // The default fetcher reports the refusal itself (with the + // management-token hint); this warn is the fallback for injected + // stubs that throw without reporting. + const reason = err instanceof Error ? err.message : String(err); + log.warn(`[omniroute-v2] auto combos fetch failed, keeping the last known ones: ${reason}`); + return { ok: false }; + } + }; + + /** + * Fetch in two tiers. Models are what a catalog *is*: without them there + * is nothing to publish. Everything else — combos, auto-combos, the + * provider list, the enrichment overlay — improves an already usable + * catalog, so awaiting any of them before publishing makes the catalog + * hostage to the slowest source: a gateway that accepts the connection + * and never answers one endpoint kept everything unpublished until that + * fetch's own timeout fired, which is longer than some hosts stay alive. + * + * The optional tier therefore keeps running after the publish and upgrades + * the stored snapshot when it lands, so the next transform serves the + * complete catalog. + */ + async function refreshSnapshot(): Promise { + // Models are what a catalog *is*; everything else improves one that + // already works. Combos used to sit here too, so a gateway slow to + // answer /api/combos held the whole picker back — the very thing the + // staged publish exists to prevent. + const essential = fetchModelsSafe(); + const optional = Promise.all([ + fetchCombosSafe(), + fetchAutoCombosSafe(), + fetchProvidersSafe(), + fetchEnrichmentSafe(), + ]); + const models = await essential; + const previous = state.entries.get(cacheKey); + // A gateway that just failed everything gets a short breather: serving + // last-known-good for a few seconds beats hammering it on every + // transform while it is down. Arms whenever the models fetch comes back + // empty — with or without a prior entry to serve — so a totally dead + // gateway stops getting hit every window. Partial degradation (models + // healthy, an optional tier failed) still retries normally next window. + if (models.length === 0) { + state.unreachableUntil = Date.now() + UNREACHABLE_COOLDOWN_MS; + } + // Carry every source forward until its replacement lands, and keep the + // old value when a fetch FAILED — but honour a gateway that legitimately + // returns nothing, which is a different answer from "I could not ask". + const snapshot: CatalogSnapshot = { + models, + combos: previous?.combos ?? [], + autoCombos: previous?.autoCombos ?? [], + providers: previous?.providers ?? [], + enrichment: previous?.enrichment ?? new Map(), + fetchedAt: Date.now(), + }; + if (models.length > 0) { + state.entries.set(cacheKey, snapshot); + await writeDiskSnapshot(X, snapshot, identityFingerprint); + } + void optional.then( + (parts) => upgradeWithOptional(snapshot, parts), + (err) => { + // The wrappers never reject; a throw here would be a bug in them, and + // an unhandled rejection is a worse way to learn about it. + log.warn( + `[omniroute-v2] optional catalog sources failed unexpectedly: ${err instanceof Error ? err.message : String(err)}` + ); + } + ); + return snapshot; + } + + /** + * Fold late optional data into the snapshot that was published without it. + * Skipped when a newer refresh has already replaced that snapshot, so a + * slow tier can never resurrect a stale catalog. + */ + async function upgradeWithOptional( + base: CatalogSnapshot, + [combos, autoCombos, providers, enrichment]: [ + SourceResult, + SourceResult, + SourceResult, + SourceResult, + ] + ): Promise { + if (state.entries.get(cacheKey) !== base) return; + // Per source: a success replaces (even with an empty answer — that is + // the gateway's answer), a failure keeps what we had. + const upgraded: CatalogSnapshot = { + ...base, + combos: combos.ok ? combos.value : base.combos, + autoCombos: autoCombos.ok ? autoCombos.value : base.autoCombos, + providers: providers.ok ? providers.value : base.providers, + enrichment: enrichment.ok ? enrichment.value : base.enrichment, + }; + const unchanged = + upgraded.combos === base.combos && + upgraded.autoCombos === base.autoCombos && + upgraded.providers === base.providers && + upgraded.enrichment === base.enrichment; + if (unchanged) return; + state.entries.set(cacheKey, upgraded); + if (upgraded.models.length > 0) { + await writeDiskSnapshot(X, upgraded, identityFingerprint); + } + // Reload only when the optional tier actually moved: the catalog + // fingerprint covers ids alone, so without this the host would rebuild + // its catalog once per TTL window for an identical result. + const optionalFingerprint = optionalTierFingerprint( + upgraded.autoCombos ?? [], + upgraded.providers ?? [], + upgraded.enrichment, + upgraded.combos + ); + const optionalChanged = state.optionalFingerprint !== optionalFingerprint; + state.optionalFingerprint = optionalFingerprint; + if (optionalChanged && typeof ctx.catalog.reload === "function") { + try { + await ctx.catalog.reload(); + } catch (err) { + log.warn( + `[omniroute-v2] catalog reload after late sources failed, keeping current catalog: ${err instanceof Error ? err.message : String(err)}` + ); + } + } + } + + function loadSnapshot(): Promise { + const now = Date.now(); + const hit = state.entries.get(cacheKey); + if (hit && hit.fetchedAt + resolved.modelCacheTtlMs > now) return Promise.resolve(hit); + // Cooldown after a total models failure: skip the network until it + // lapses. Serves last-known-good when one exists; otherwise the refresh + // below still runs (nothing to serve, no point pretending). + if (now < state.unreachableUntil && hit) return Promise.resolve(hit); + if (now >= state.unreachableUntil) state.unreachableUntil = 0; + const inflight = state.inFlight.get(cacheKey); + if (inflight) return inflight; + const snapshot = refreshSnapshot(); + state.inFlight.set(cacheKey, snapshot); + const clear = () => { + if (state.inFlight.get(cacheKey) === snapshot) state.inFlight.delete(cacheKey); + }; + snapshot.then(clear, clear); + return snapshot; + } + + // Warm-startup: the disk snapshot is read at boot (without blocking + // the synchronous transform registration) to publish the last-known + // catalog before the first successful fetch. + /** + * Warm start: publish the last known catalog from disk before the first + * fetch returns. Deliberately read *after* the credential is resolved — + * the snapshot is keyed by the credential tuple, and resolving the host + * credential changes that key, so reading at setup time would look up the + * wrong identity and reject a perfectly good snapshot. + */ + let warmLoadedFor: string | undefined; + const ensureWarmSnapshot = async (): Promise => { + if (warmLoadedFor === identityFingerprint) return; + warmLoadedFor = identityFingerprint; + const warm = await readDiskSnapshot(X, identityFingerprint, log); + if (warm && !state.entries.has(cacheKey)) state.entries.set(cacheKey, warm); + }; + + // Fail-closed models (keep-last-good, validated): an empty models fetch + // (transient 500/timeout) must not wipe a known catalog. The latest + // non-empty entry (fresh fetch or warm disk snapshot) is replayed + // instead of publishing the empty set. `refreshSnapshot` never overwrites + // 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) => { + await ensureCredential(); + await ensureWarmSnapshot(); + const snapshot = await loadSnapshot(); + let effective = snapshot; + if (snapshot.models.length === 0) { + const stale = state.entries.get(cacheKey); + if (stale !== undefined && stale.models.length > 0) { + log.warn( + `[omniroute-v2] models fetch returned empty, keeping last-known catalog (${stale.models.length} models, ${stale.combos.length} combos)` + ); + effective = stale; + } + } + const counts = await (async (): Promise<{ + models: number; + combos: number; + autoCombos: number; + }> => { + // fetcher-level fail-open covers fetches; this guard covers mapper/draft throws. + try { + return await publishCatalog(draft, resolved, { + onSourceError: reportSourceError, + models: async () => effective.models, + combos: async () => effective.combos, + autoCombos: async () => effective.autoCombos, + providers: async () => effective.providers ?? [], + enrichment: async () => effective.enrichment ?? new Map(), + }); + } catch (err) { + log.warn( + `[omniroute-v2] catalog publish failed, keeping current catalog: ${err instanceof Error ? err.message : String(err)}` + ); + return { models: 0, combos: 0, autoCombos: 0 }; + } + })(); + void counts; + const fingerprint = catalogContentFingerprint( + effective.models, + effective.combos, + effective.autoCombos + ); + const changed = state.fingerprint !== undefined && state.fingerprint !== fingerprint; + state.fingerprint = fingerprint; + if (changed && typeof ctx.catalog.reload === "function") { + await Promise.resolve(); + try { + await ctx.catalog.reload(); + } catch (err) { + log.warn( + `[omniroute-v2] catalog reload failed, keeping current catalog: ${err instanceof Error ? err.message : String(err)}` + ); + } + } + }); + const integrationHook = (ctx.integration as Partial | 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 + // any await, so only a call-site guard catches it (an await-guard alone + // would let a synchronous throw escape setup and kill the catalog). + let integrationRegistration: unknown; + if (typeof integrationHook === "function") { + try { + integrationRegistration = integrationHook((draft) => { + draft.update(X, (integration) => { + integration.name = parsed.displayName ?? "OmniRoute"; + }); + draft.method.update({ integrationID: X, method: { type: "key", label: "API key" } }); + draft.method.update({ + integrationID: X, + method: { type: "env", names: ["OMNIROUTE_API_KEY"] }, + }); + }); + } catch (err) { + log.warn( + `[omniroute-v2] host refused the integration hook, the connect action will be missing: ${err instanceof Error ? err.message : String(err)}` + ); + integrationRegistration = undefined; + } + } + /** + * `aisdk.language` is newer than the catalog domain, so a host may not + * expose it; the plugin must stay loadable there, minus the sanitising. + */ + const languageHook = (ctx.aisdk as Partial | undefined)?.language; + // 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 }> | undefined; + if (parsed.geminiSanitization !== false && typeof languageHook === "function") { + try { + languageRegistration = languageHook((input) => { + if (input.model.providerID !== X) return; + input.language = sanitizeToolSchemasFor(input.language, input.model.id, log); + }); + } catch (err) { + log.warn( + `[omniroute-v2] host refused the language-model hook, Gemini tool schemas will not be cleaned: ${err instanceof Error ? err.message : String(err)}` + ); + } + } + + await catalogRegistration; + if (integrationRegistration !== undefined) { + try { + await integrationRegistration; + } catch (err) { + log.warn( + `[omniroute-v2] host refused the integration hook, the connect action will be missing: ${err instanceof Error ? err.message : String(err)}` + ); + } + } + if (languageRegistration !== undefined) { + try { + await languageRegistration; + } catch (err) { + log.warn( + `[omniroute-v2] language-model hook registration failed, Gemini tool schemas will not be cleaned: ${err instanceof Error ? err.message : String(err)}` + ); + } + } + }, +}); diff --git a/@omniroute/opencode-plugin-v2/src/options.ts b/@omniroute/opencode-plugin-v2/src/options.ts new file mode 100644 index 0000000000..9782ca74e6 --- /dev/null +++ b/@omniroute/opencode-plugin-v2/src/options.ts @@ -0,0 +1,117 @@ +import { z } from "zod"; + +const apiFormatSchema = z + .object({ + allowAnthropic: z.boolean().optional(), + anthropicModels: z.array(z.string()).optional(), + // Deprecated v1 prefix list. Accepted (warn at resolve time) so copied + // v1 configs keep routing; prefer anthropicModels (full IDs). + anthropicPrefixes: z.array(z.string()).optional(), + }) + .strict(); + +const timeoutsSchema = z + .object({ + models: z.number().positive().optional(), + combos: z.number().positive().optional(), + autoCombos: z.number().positive().optional(), + enrichment: z.number().positive().optional(), + }) + .strict(); + +const pluginOptionsSchema = z + .object({ + // Reaches a filesystem path (the on-disk catalog snapshot) and the + // catalog keys, so it is bounded here rather than escaped at each use. + providerId: z + .string() + .regex(/^[A-Za-z0-9._-]+$/, "providerId may only contain letters, digits, '.', '_' and '-'") + .refine((v) => v !== "." && v !== "..", "providerId cannot be a path segment") + .default("omniroute"), + baseURL: z.string().url(), + apiKey: z.string().optional(), + displayName: z.string().optional(), + managementReadToken: z.string().optional(), + timeoutMs: z.number().positive().default(10000), + timeouts: timeoutsSchema.optional(), + logLevel: z.enum(["error", "warn", "info", "debug"]).optional(), + startupDebug: z.boolean().optional(), + modelCacheTtlMs: z.number().positive().optional(), + visibleModels: z.array(z.string()).optional(), + hiddenModels: z.array(z.string()).optional(), + usableOnly: z.boolean().default(false), + // v1 parity: enrichment overlay on by default (names + pricing). + enrichment: z.boolean().default(true), + // v1 parity: strip the JSON-Schema keywords Gemini rejects from tool + // declarations bound for a Gemini model. On by default — leaving them in + // fails the whole request with 400 INVALID_ARGUMENT. + geminiSanitization: z.boolean().default(true), + // v1 parity: prefix a model's display name with the upstream provider it + // routes to, so the same model sold through two connections is + // distinguishable in the picker. + providerTag: z.boolean().default(true), + apiFormat: apiFormatSchema.optional(), + }) + .strict(); + +export type PluginOptions = z.infer; + +/** Per-endpoint timeout defaults (v1 parity). `timeoutMs` is the global fallback. */ +export const DEFAULT_TIMEOUT_MS = 10_000 as const; +/** Auto-combos keep the v1 5s budget; the field is resolved now for the P3 port. */ +export const DEFAULT_AUTO_COMBOS_TIMEOUT_MS = 5_000 as const; + +export interface EndpointTimeouts { + models: number; + combos: number; + autoCombos: number; + enrichment: number; +} + +export function resolveTimeouts( + opts: Pick +): EndpointTimeouts { + const fallback = + typeof opts.timeoutMs === "number" && opts.timeoutMs > 0 ? opts.timeoutMs : DEFAULT_TIMEOUT_MS; + return { + models: opts.timeouts?.models ?? fallback, + combos: opts.timeouts?.combos ?? fallback, + autoCombos: opts.timeouts?.autoCombos ?? DEFAULT_AUTO_COMBOS_TIMEOUT_MS, + enrichment: opts.timeouts?.enrichment ?? fallback, + }; +} + +/** + * Parse the plugin block of `opencode.json`. + * + * A rejected option aborts the whole plugin, and the host reports that as a + * bare load failure with the validator's raw dump attached — which is how a + * single mistyped key turns into a wall of JSON and an empty model picker. The + * schema is strict on purpose (a silently ignored option is worse), so the + * least we owe the user is a first line naming what to fix. + */ +export function parsePluginOptions(raw: unknown): PluginOptions { + const result = pluginOptionsSchema.safeParse(raw); + if (result.success) return result.data; + const problems = result.error.issues.map((issue) => { + const at = issue.path.length > 0 ? issue.path.join(".") : "(root)"; + const unknown = issue.code === "unrecognized_keys" ? issue.keys.join(", ") : undefined; + return unknown !== undefined ? `unknown option "${unknown}"` : `${at}: ${issue.message}`; + }); + throw new Error(`[omniroute-v2] invalid plugin options — ${problems.join("; ")}`); +} + +/** + * The host reads the plugin id from the module, before any option is known, so + * it cannot carry the configured provider id. Publishing two gateways from one + * install is a `providerId` matter — that one does reach the catalog. + */ +export const PLUGIN_ID = "omniroute-v2"; + +export function providerIdFor(providerId: string): string { + return providerId; +} + +export function integrationIdFor(providerId: string): string { + return providerId; +} diff --git a/@omniroute/opencode-plugin-v2/src/shared/auto-combos.ts b/@omniroute/opencode-plugin-v2/src/shared/auto-combos.ts new file mode 100644 index 0000000000..04f70f7625 --- /dev/null +++ b/@omniroute/opencode-plugin-v2/src/shared/auto-combos.ts @@ -0,0 +1,219 @@ +import type { Model as ModelV2 } from "@opencode-ai/sdk/v2"; +import type { ApiFormatV2 } from "./models-map.js"; +import { resolveApiBlockV2 } from "./models-map.js"; +import { autoComboModelId, formatAutoComboName, type AutoVariant } from "./naming.js"; + +export type { AutoVariant }; + +/** + * Raw shape of an auto combo entry as returned by OmniRoute's + * `/api/combos/auto` endpoint. Auto combos are virtual -- they self-manage + * provider selection via scoring/bandit exploration at runtime. + * + * Ported from the v1 plugin (`index.ts:1672-1698`); the shape is unchanged + * so old and new gateways stay wire-compatible. + */ +export interface OmniRouteRawAutoCombo { + /** Stable id (e.g. "auto", "auto/coding"). */ + id: string; + /** Human-readable name (e.g. "Auto", "Auto Coding"). */ + name?: string; + /** Variant key or undefined for the default auto. */ + variant?: AutoVariant; + /** Provider names eligible for this auto combo. */ + candidatePool?: string[]; + /** Number of candidates resolved at fetch time. */ + candidateCount?: number; + /** MAX of candidates' context windows, served by newer gateway builds. + * Absent on older servers -- the mapper falls back to a safe default. */ + context_length?: number; + /** MAX of candidates' max output tokens (same provenance as context_length). */ + max_output_tokens?: number; + /** Whether this auto combo should be hidden from the picker. */ + isHidden?: boolean; + /** Auto-combo configuration. */ + config?: { + auto?: { + candidatePool?: string[]; + explorationRate?: number; + routerStrategy?: string; + }; + }; +} + +/** Minimal warn sink so the fetcher never depends on the plugin logger. */ +export interface AutoCombosWarnSink { + warn: (message: string, ...args: unknown[]) => void; +} + +/** + * Fetcher contract for `/api/combos/auto`. Returns the list of virtual + * auto combos the server can create. Same DI shape as the other fetchers + * so unit tests can inject a stub instead of monkey-patching `fetch`. + * + * HTTP refusals (non-2xx other than 404) and network errors THROW: the caller + * distinguishes "the gateway failed" (keep last-known) from "the gateway + * answered empty" (publish empty). Only 404 stays soft — the endpoint does + * not exist yet on older gateways, and that is an answer, not a failure. + */ +export type OmniRouteAutoCombosFetcher = ( + baseURL: string, + apiKey: string, + timeoutMs?: number, + logger?: AutoCombosWarnSink, + onSourceError?: (endpoint: string, reason: string) => void +) => Promise; + +function trimTrailingSlashes(value: string): string { + let i = value.length; + while (i > 0 && value.charCodeAt(i - 1) === 0x2f /* "/" */) i--; + return i === value.length ? value : value.slice(0, i); +} + +function fallbackWarn(message: string, ...args: unknown[]): void { + console.warn(`[omniroute-plugin] [WARN] ${message}`, ...args); +} + +/** + * Default auto combos fetcher: `GET /api/combos/auto`. + * + * 404 stays soft (endpoint not deployed yet on older gateways — an answer, + * not a failure). Any other non-2xx or network error THROWS so the caller + * keeps last-known instead of publishing an empty tier: a 403 behind a + * management-token gate must not wipe the auto combos the picker had. + * v1 parity keeps the 5s timeout budget. + */ +export const defaultOmniRouteAutoCombosFetcher: OmniRouteAutoCombosFetcher = async ( + baseURL, + apiKey, + timeoutMs = 5_000, + logger?: AutoCombosWarnSink, + onSourceError?: (endpoint: string, reason: string) => void +) => { + if (!apiKey || !baseURL) return []; + const warn = logger?.warn ?? fallbackWarn; + const report = (reason: string): void => { + warn(reason); + onSourceError?.("/api/combos/auto", reason); + }; + + const trimmed = trimTrailingSlashes(baseURL); + const root = trimmed.replace(/\/v\d+$/, ""); + const url = `${root}/api/combos/auto`; + + const controller = new AbortController(); + const timer = setTimeout(() => controller.abort(), timeoutMs); + try { + const res = await fetch(url, { + method: "GET", + headers: { + Authorization: `Bearer ${apiKey}`, + Accept: "application/json", + }, + signal: controller.signal, + }); + // 404 = endpoint not deployed yet -- expected during rollout + if (res.status === 404) { + warn(`/api/combos/auto not available (404) -- auto combos disabled`); + return []; + } + if (!res.ok) { + const reason = `HTTP ${res.status} ${res.statusText}`; + report(`/api/combos/auto refused (${reason}) -- keeping last-known auto combos`); + throw new Error(reason); + } + const body = (await res.json()) as unknown; + const rawList: unknown[] = Array.isArray(body) + ? body + : body && typeof body === "object" && Array.isArray((body as { combos?: unknown }).combos) + ? ((body as { combos: unknown[] }).combos as unknown[]) + : []; + const out: OmniRouteRawAutoCombo[] = []; + for (const r of rawList) { + if (r && typeof r === "object" && typeof (r as { id?: unknown }).id === "string") { + out.push(r as OmniRouteRawAutoCombo); + } + } + return out; + } catch (err) { + // Network error, timeout, abort -- keep last-known, never publish empty. + // (The 404-soft path above returns directly and never reaches this throw.) + const reason = `/api/combos/auto fetch failed: ${err instanceof Error ? err.message : String(err)} -- keeping last-known auto combos`; + report(reason); + throw err instanceof Error ? err : new Error(String(err)); + } finally { + clearTimeout(timer); + } +}; + +/** Fallbacks when the server does not advertise auto-combo limits (older + * gateway builds). MUST be positive: OpenCode's overflow guard treats + * `limit.context === 0` as "never overflow" and silently DISABLES smart + * auto-compaction, letting the session grow until the gateway's destructive + * history purge kicks in. */ +export const AUTO_COMBO_FALLBACK_CONTEXT = 128_000; +export const AUTO_COMBO_FALLBACK_OUTPUT = 8_192; + +/** + * Convert a raw auto combo into a `ModelV2` entry for the picker. + * Auto combos route to capable models, so tool_call and reasoning default + * to true. Context/output limits come from the server (MAX of the + * candidate pool's windows); a safe positive fallback applies when the + * server omits them. Never 0. + */ +export function mapAutoComboToModelV2( + autoCombo: OmniRouteRawAutoCombo, + providerId: string, + baseURL: string, + apiFormat?: ApiFormatV2 +): ModelV2 { + const name = formatAutoComboName(autoCombo.variant, autoCombo.candidateCount); + const context = + typeof autoCombo.context_length === "number" && autoCombo.context_length > 0 + ? autoCombo.context_length + : AUTO_COMBO_FALLBACK_CONTEXT; + const output = + typeof autoCombo.max_output_tokens === "number" && autoCombo.max_output_tokens > 0 + ? autoCombo.max_output_tokens + : AUTO_COMBO_FALLBACK_OUTPUT; + return { + id: autoComboModelId(autoCombo.variant), + providerID: providerId, + api: resolveApiBlockV2(autoComboModelId(autoCombo.variant), baseURL, apiFormat), + name, + capabilities: { + temperature: true, + reasoning: true, + attachment: false, + toolcall: true, + input: { + text: true, + audio: false, + image: false, + video: false, + pdf: false, + }, + output: { + text: true, + audio: false, + image: false, + video: false, + pdf: false, + }, + interleaved: false, + }, + cost: { + input: 0, + output: 0, + cache: { read: 0, write: 0 }, + }, + limit: { + context, + output, + }, + status: "active", + options: {}, + headers: {}, + release_date: "", + }; +} diff --git a/@omniroute/opencode-plugin-v2/src/shared/combos-map.ts b/@omniroute/opencode-plugin-v2/src/shared/combos-map.ts new file mode 100644 index 0000000000..74ad94117f --- /dev/null +++ b/@omniroute/opencode-plugin-v2/src/shared/combos-map.ts @@ -0,0 +1,254 @@ +import type { Model as ModelV2 } from "@opencode-ai/sdk/v2"; +import { type ApiFormatV2, type OmniRouteRawModelEntry, resolveApiBlockV2 } from "./models-map.js"; + +export interface OmniRouteRawComboMemberRef { + /** Step kind: "model" references a raw model id; "combo-ref" nests another combo. */ + kind?: "model" | "combo-ref"; + /** Full model id referenced by this step (when kind === "model"). */ + model?: string; + /** Nested combo name (when kind === "combo-ref"). */ + comboName?: string; + /** Routing weight inside the combo (0–100, advisory at LCD time). */ + weight?: number; + /** Step-local label, distinct from the parent combo's display name. */ + label?: string; +} + +export interface OmniRouteRawCombo { + id: string; + name?: string; + /** Routing strategy. Surfaced for forward-compat but not consumed by LCD. */ + strategy?: string; + /** Member step list. Only `kind: "model"` steps participate in LCD. */ + models?: OmniRouteRawComboMemberRef[]; + /** Hidden combos are excluded from the OC model picker. */ + isHidden?: boolean; + /** When OmniRoute attaches a lifecycle hint we forward it; today it doesn't. */ + release_date?: string; + /** + * Server-computed context window for this combo (aggregated from member + * models using the same logic as /v1/models). When present, the client + * uses this value directly instead of re-aggregating from member models. + * + * Added in 3.9.x — old servers do not send it. + */ + computed_context_length?: number; +} + +/** + * Fetcher contract for `/api/combos`. Same DI shape as + * `OmniRouteModelsFetcher` so unit tests can inject a stub instead of + * monkey-patching global `fetch`. + */ +export type OmniRouteCombosFetcher = ( + baseURL: string, + apiKey: string, + timeoutMs?: number +) => Promise; + +function trimTrailingSlashes(value: string): string { + let i = value.length; + while (i > 0 && value.charCodeAt(i - 1) === 0x2f /* "/" */) i--; + return i === value.length ? value : value.slice(0, i); +} + +/** + * Default fetcher: `GET /api/combos` with bearer auth + + * AbortController timeout. Accepts both the `{combos: [...]}` envelope the + * gateway emits today and a bare-array envelope (defensive — keeps the + * plugin working if a future OmniRoute build trims the wrapper). + * + * Differences from `defaultOmniRouteModelsFetcher`: + * - URL is `/api/combos`, NOT `/v1/combos`. The `/v1/...` namespace is the + * OpenAI-compatible surface (chat completions, models); combo discovery + * lives on the management plane under `/api/...`. We tolerate both + * `https://host` and `https://host/v1` baseURL forms by stripping the + * trailing `/v1` segment before appending `/api/combos`. + * - Combos endpoint requires a management-scoped API key when + * `REQUIRE_API_KEY` is enabled. We don't enforce that here; the + * gateway returns 401/403 with an actionable error which we propagate. + * + * Anything that isn't an object with a string `id` is filtered out silently. + */ +export const defaultOmniRouteCombosFetcher: OmniRouteCombosFetcher = async ( + baseURL, + apiKey, + timeoutMs = 10_000 +) => { + if (!apiKey) throw new Error("[omniroute-v2] apiKey required to fetch /api/combos"); + if (!baseURL) throw new Error("[omniroute-v2] baseURL required to fetch /api/combos"); + + // Strip trailing slashes, then strip a trailing `/v1` so we land on the + // management plane. Models live under `/v1/models`; combos live under + // `/api/combos` from the same gateway root. + const trimmed = trimTrailingSlashes(baseURL); + const root = trimmed.replace(/\/v\d+$/, ""); + const url = `${root}/api/combos`; + + const controller = new AbortController(); + const timer = setTimeout(() => controller.abort(), timeoutMs); + try { + const res = await fetch(url, { + method: "GET", + headers: { + Authorization: `Bearer ${apiKey}`, + Accept: "application/json", + }, + signal: controller.signal, + }); + if (!res.ok) { + throw new Error(`[omniroute-v2] GET ${url} failed: ${res.status} ${res.statusText}`); + } + const body = (await res.json()) as unknown; + const rawList: unknown[] = Array.isArray(body) + ? body + : body && typeof body === "object" && Array.isArray((body as { combos?: unknown }).combos) + ? ((body as { combos: unknown[] }).combos as unknown[]) + : []; + const out: OmniRouteRawCombo[] = []; + for (const r of rawList) { + if (r && typeof r === "object" && typeof (r as { id?: unknown }).id === "string") { + out.push(r as OmniRouteRawCombo); + } + } + return out; + } finally { + clearTimeout(timer); + } +}; + +/** + * Map a raw combo entry → `ModelV2` by computing the lowest-common-denominator + * (LCD) of its underlying member models. The LCD policy is the only way to + * surface a single capability vector to OpenCode without lying: if any member + * lacks a capability, the combo as a whole cannot guarantee it. + * + * LCD rules: + * - `limit.context` = `min(...members.context_length)`. + * - `limit.output` = `min(...members.max_output_tokens)`. + * - `limit.input` = `min(...members.max_input_tokens)` ONLY when every + * member declares one (ModelV2.limit.input is optional — better to + * omit than to fabricate a min over partial data). + * - `capabilities.toolcall` / `reasoning` / `attachment` / `temperature`: + * `every(member ⇒ supports?)`. The `reasoning` axis ORs across + * `reasoning` and `thinking` per member before AND-ing across the + * combo (mirrors `mapRawModelToModelV2`). The `attachment` axis ORs + * across `attachment` and `vision` per member. The `temperature` axis + * uses default-true semantics: a member supports temperature unless + * it explicitly declares `temperature: false`. + * - `capabilities.input.*` / `output.*`: flattened AND across members' + * modality flags. Missing arrays default to `["text"]` (same default + * as `mapRawModelToModelV2`). + * + * Defensive: empty members array → ALL capabilities `false`, limits zero. + * That's an intentional safety posture (you can't route through an empty + * combo, so OC should grey it out in the picker). + * + * Spec mapping: `cost` zeroed; `status = "active"`; + * `release_date = combo.release_date ?? ""`; + * `api = LCD (all-anthropic else openai-compatible)`; + * `name = combo.name ?? combo.id`. + * + * @param combo Raw `/api/combos` entry. + * @param members Raw `/v1/models` entries for THIS combo's member ids. + * Caller resolves `combo.models[].model` ids; unknown ids + * are silently dropped before this call. + * @param providerId OpenCode provider id (multi-instance aware). + * @param baseURL Resolved gateway base URL for ModelV2.api.url. + */ +export function mapComboToModelV2( + combo: OmniRouteRawCombo, + members: OmniRouteRawModelEntry[], + providerId: string, + baseURL: string, + apiFormat?: ApiFormatV2 +): ModelV2 { + // `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; + + const memberInMods = members.map((m) => new Set(m.input_modalities ?? ["text"])); + const memberOutMods = members.map((m) => new Set(m.output_modalities ?? ["text"])); + + const modalityAllHave = (sets: Array>, key: string): boolean => + hasMembers && sets.every((s) => s.has(key)); + + const contextValues = members + .map((m) => m.context_length) + .filter((v): v is number => typeof v === "number" && v > 0); + const outputValues = members + .map((m) => m.max_output_tokens) + .filter((v): v is number => typeof v === "number" && v > 0); + const inputValues = members + .map((m) => m.max_input_tokens) + .filter((v): v is number => typeof v === "number" && v > 0); + + const everyDeclaresInput = hasMembers && inputValues.length === members.length; + + const capabilities: ModelV2["capabilities"] = { + temperature: + hasMembers && members.every((m) => (m.capabilities?.temperature ?? true) !== false), + reasoning: + hasMembers && + members.every((m) => Boolean(m.capabilities?.reasoning || m.capabilities?.thinking)), + attachment: + hasMembers && + members.every((m) => Boolean(m.capabilities?.attachment ?? m.capabilities?.vision ?? false)), + toolcall: hasMembers && members.every((m) => Boolean(m.capabilities?.tool_calling ?? false)), + input: { + text: modalityAllHave(memberInMods, "text"), + audio: modalityAllHave(memberInMods, "audio"), + image: modalityAllHave(memberInMods, "image"), + video: modalityAllHave(memberInMods, "video"), + pdf: modalityAllHave(memberInMods, "pdf"), + }, + output: { + text: modalityAllHave(memberOutMods, "text"), + audio: modalityAllHave(memberOutMods, "audio"), + image: modalityAllHave(memberOutMods, "image"), + video: modalityAllHave(memberOutMods, "video"), + pdf: modalityAllHave(memberOutMods, "pdf"), + }, + interleaved: hasMembers && members.every((m) => Boolean(m.capabilities?.thinking)), + }; + + // Combos span multiple providers. Use Anthropic format only when ALL + // members resolve to Anthropic — otherwise fall back to OpenAI-compat + // (lowest common denominator that every upstream understands). + const comboApiBlock = (() => { + if (!hasMembers) return resolveApiBlockV2(combo.id, baseURL, apiFormat); + const allAnthropic = members.every( + (m) => resolveApiBlockV2(m.id, baseURL, apiFormat).id === "anthropic" + ); + return allAnthropic + ? resolveApiBlockV2(members[0].id, baseURL, apiFormat) + : resolveApiBlockV2(combo.id, baseURL, apiFormat); + })(); + + return { + id: combo.id, + providerID: providerId, + api: comboApiBlock, + name: combo.name && combo.name.trim().length > 0 ? combo.name : combo.id, + capabilities, + cost: { + input: 0, + output: 0, + cache: { read: 0, write: 0 }, + }, + limit: { + context: + typeof combo.computed_context_length === "number" && combo.computed_context_length > 0 + ? combo.computed_context_length + : contextValues.length > 0 + ? Math.min(...contextValues) + : 0, + ...(everyDeclaresInput ? { input: Math.min(...inputValues) } : {}), + output: outputValues.length > 0 ? Math.min(...outputValues) : 0, + }, + status: "active", + options: {}, + headers: {}, + release_date: combo.release_date ?? "", + }; +} diff --git a/@omniroute/opencode-plugin-v2/src/shared/enrich.ts b/@omniroute/opencode-plugin-v2/src/shared/enrich.ts new file mode 100644 index 0000000000..1eec8c61c7 --- /dev/null +++ b/@omniroute/opencode-plugin-v2/src/shared/enrich.ts @@ -0,0 +1,606 @@ +import type { Model as ModelV2 } from "@opencode-ai/sdk/v2"; +import { buildModelDisplayName } from "./naming.js"; +import type { FreeModelFreeType } from "./naming.js"; + +export interface OmniRouteEnrichmentEntry { + /** Human-readable display name. Replaces ModelV2.name when present. */ + name?: string; + /** Per-million-token cost overlay onto ModelV2.cost. */ + pricing?: { + input?: number; + output?: number; + cacheRead?: number; + cacheWrite?: number; + }; + /** + * Provider alias prefix seen in `/v1/models` ids (e.g. `cc`, `gemini`). + * Populated by `defaultOmniRouteEnrichmentFetcher` from + * `/api/pricing/models` keys. Drives the `usableOnly` alias↔canonical + * resolution. + */ + providerAlias?: string; + /** + * Canonical provider id used by `/api/providers` connections (e.g. + * `claude`, `gemini`, `kiro`). Populated from the per-provider + * `entry.id` field inside `/api/pricing/models`. + */ + providerCanonical?: string; + /** + * Human-readable upstream provider label (e.g. `Claude`, `Kiro`, + * `Windsurf`, `GitHub Models`). Populated from the per-provider + * `entry.name` field inside `/api/pricing/models`. Used by the + * `providerTag` feature to suffix `ModelV2.name` with the routing + * destination so the OC TUI picker can differentiate the same + * model id sold through different upstream connections. + */ + providerDisplayName?: string; + /** Free-model budget type (from freeModelCatalog). */ + freeType?: FreeModelFreeType; + /** Monthly token budget for recurring free models. */ + monthlyTokens?: number; + /** Credit token budget for credit-based free models. */ + creditTokens?: number; +} + +/** Map keyed by full model id (possibly namespaced, e.g. `cc/claude-sonnet-4-6`). */ +export type OmniRouteEnrichmentMap = Map; + +/** + * Reverse-index the enrichment map from `providerCanonical → providerAlias`. + * + * OmniRoute's `/api/pricing/models` is keyed by short ALIAS (`cc`, `cx`, + * `pol`). But `/v1/models` exposes some models a SECOND time under their + * CANONICAL name (`claude/claude-opus-4-7`, `codex/gpt-5.5`, + * `pollinations/midjourney`). Without a reverse map, those canonical + * rows miss enrichment entirely and surface as raw ids in the picker. + * + * Built once per refresh from the enrichment entries themselves — no + * hardcoded registry. Only records `canonical → alias` mappings when + * both are present AND distinct (skips slots where alias === canonical + * like `kiro`). + */ +export function buildCanonicalToAliasMap( + enrichment: OmniRouteEnrichmentMap | undefined +): Map { + const out = new Map(); + if (!enrichment) return out; + for (const entry of enrichment.values()) { + const alias = typeof entry.providerAlias === "string" ? entry.providerAlias.trim() : ""; + const canonical = + typeof entry.providerCanonical === "string" ? entry.providerCanonical.trim() : ""; + if (alias.length === 0 || canonical.length === 0) continue; + if (alias === canonical) continue; + if (!out.has(canonical)) out.set(canonical, alias); + } + return out; +} + +/** + * Enrichment lookup with alias-fallback chain. + * + * Resolution order (first hit wins): + * + * 1. `enrichment.get(rawId)` — direct hit on `/` or + * bare id (the fetcher writes under both forms). + * 2. If `rawId` is `/` and `canonicalToAlias` has + * a mapping for `canonical`, try `/`. This rescues + * duplicate rows like `claude/claude-opus-4-7` (canonical) when + * enrichment only indexed under `cc/claude-opus-4-7` (alias). + * 3. Bare `` as a last resort. Already covered by step 1 in + * practice (fetcher writes bare keys), but kept defensive. + * + * Returns `undefined` when no lookup hits. + */ +export function lookupEnrichment( + rawId: string, + enrichment: OmniRouteEnrichmentMap | undefined, + canonicalToAlias: Map +): OmniRouteEnrichmentEntry | undefined { + if (!enrichment) return undefined; + const direct = enrichment.get(rawId); + if (direct) return direct; + const slash = rawId.indexOf("/"); + if (slash > 0) { + const prefix = rawId.slice(0, slash); + const modelId = rawId.slice(slash + 1); + const alias = canonicalToAlias.get(prefix); + if (alias && alias !== prefix) { + const viaAlias = enrichment.get(`${alias}/${modelId}`); + if (viaAlias) return viaAlias; + } + const bare = enrichment.get(modelId); + if (bare) return bare; + } + return undefined; +} + +/** + * Pre-pass: detect raw rows that are the CANONICAL twin of an ALIAS row + * already in the catalog. Returns the set of canonical-keyed ids to skip + * during the raw-model loop so each model surfaces exactly once under + * its enriched alias key. + * + * Example: `/v1/models` returns BOTH `cc/claude-opus-4-7` and + * `claude/claude-opus-4-7`. The former is enriched (alias `cc` exists + * in `/api/pricing/models`); the latter is raw. We keep `cc/...` and + * drop `claude/...`. + * + * Built once per refresh. Cheap — O(M) where M = raw model count. + */ +export function canonicalDedupSet( + rawModels: ReadonlyArray<{ id: string }>, + canonicalToAlias: Map +): Set { + const drop = new Set(); + if (canonicalToAlias.size === 0) return drop; + // Index every alias key present in the raw catalog. + const aliasKeys = new Set(); + for (const m of rawModels) { + if (typeof m.id === "string" && m.id.length > 0) aliasKeys.add(m.id); + } + for (const m of rawModels) { + if (typeof m.id !== "string" || m.id.length === 0) continue; + const slash = m.id.indexOf("/"); + if (slash <= 0) continue; + const prefix = m.id.slice(0, slash); + const modelId = m.id.slice(slash + 1); + const alias = canonicalToAlias.get(prefix); + if (!alias || alias === prefix) continue; + // Canonical row only gets suppressed if the alias row actually + // exists — otherwise we'd hide the model entirely. + if (aliasKeys.has(`${alias}/${modelId}`)) drop.add(m.id); + } + return drop; +} + +/** + * Build a per-alias index of enrichment metadata so we can render the + * provider prefix even for raw models that don't have their own + * curated `/api/pricing/models` entry. + * + * Real example: OmniRoute's `pricing['cohere']` slot lists 10 curated + * models but `/v1/models` also returns `cohere/rerank-multilingual-v3.0` + * and `cohere/rerank-v4.0-fast` (not in the curated 10). Without this + * index, those rows surface in the picker as `cohere/...` with no + * `Cohere - ` prefix because the per-model enrichment lookup misses. + * + * This index records the first non-empty `providerDisplayName` seen + * for each alias, plus the alias itself. Callers use it to synthesize + * a minimal `OmniRouteEnrichmentEntry` whenever the direct lookup + * misses but the raw id's prefix matches a known alias. + * + * Built once per refresh; first-wins on duplicate alias (matches + * `buildCanonicalToAliasMap` semantics). + */ +export function buildAliasIndex( + enrichment: OmniRouteEnrichmentMap | undefined +): Map { + const out = new Map(); + if (!enrichment) return out; + for (const entry of enrichment.values()) { + const alias = typeof entry.providerAlias === "string" ? entry.providerAlias.trim() : ""; + if (alias.length === 0) continue; + if (out.has(alias)) { + // First-wins, but upgrade to the first entry that carries a + // non-empty providerDisplayName so the prefix renders nicely. + const existing = out.get(alias); + if ( + existing && + (!existing.providerDisplayName || existing.providerDisplayName.trim().length === 0) && + typeof entry.providerDisplayName === "string" && + entry.providerDisplayName.trim().length > 0 + ) { + out.set(alias, entry); + } + continue; + } + out.set(alias, entry); + } + return out; +} + +/** + * Resolve a synthesised enrichment entry for `applyProviderTag` / + * `shortProviderLabel` consumption, combining two sources: + * + * 1. The direct per-model enrichment match (if present). + * 2. A per-alias fallback derived from `buildAliasIndex` — covers raw + * ids whose prefix matches a known alias but the specific model + * id wasn't curated in `/api/pricing/models`. Example: + * `cohere/rerank-multilingual-v3.0` falls back to the cohere slot's + * `providerDisplayName='Cohere'` even though that specific id + * isn't in the curated 10-model list. + * + * Returns `undefined` when neither source surfaces an alias. + * + * NOTE: this function is read-only over its inputs; it never mutates + * the underlying `direct` entry. When it falls back to the alias + * index, it constructs a fresh minimal entry exposing only the + * provider-prefix fields (`providerAlias`, `providerCanonical`, + * `providerDisplayName`). Other fields (name, pricing) are explicitly + * left undefined so `applyEnrichment` won't accidentally overwrite a + * model name with the alias-slot label. + */ +export function resolveProviderTagEntry( + rawId: string, + direct: OmniRouteEnrichmentEntry | undefined, + aliasIndex: Map, + canonicalToAlias?: Map +): OmniRouteEnrichmentEntry | undefined { + if (direct) { + const alias = typeof direct.providerAlias === "string" ? direct.providerAlias.trim() : ""; + const display = + typeof direct.providerDisplayName === "string" ? direct.providerDisplayName.trim() : ""; + if (alias.length > 0 || display.length > 0) return direct; + } + const slash = rawId.indexOf("/"); + if (slash <= 0) return direct; + const prefix = rawId.slice(0, slash); + // 1. Direct alias lookup (`cohere/...` → cohere slot keyed by alias=cohere). + let fromAlias = aliasIndex.get(prefix); + // 2. Canonical fallback (`pollinations/...` → look up via alias `pol`). + if (!fromAlias && canonicalToAlias) { + const alias = canonicalToAlias.get(prefix); + if (alias) fromAlias = aliasIndex.get(alias); + } + if (!fromAlias) return direct; + // Synthesize: borrow only the provider-prefix metadata. + return { + providerAlias: fromAlias.providerAlias, + providerCanonical: fromAlias.providerCanonical, + providerDisplayName: fromAlias.providerDisplayName, + }; +} + +/** + * Fetcher contract: resolves the enrichment overlay (display names + + * pricing + free-tier budgets) from a running OmniRoute instance. + */ +/** + * Reports a source that could not be read. Enrichment stays best-effort, but + * a caller that swallows this loses display names, provider tags, canonical + * dedupe and pricing with no way to tell why. + */ +export type OmniRouteEnrichmentSourceError = (endpoint: string, reason: string) => void; + +export type OmniRouteEnrichmentFetcher = ( + baseURL: string, + apiKey: string, + timeoutMs?: number, + onSourceError?: OmniRouteEnrichmentSourceError +) => Promise; + +function trimTrailingSlashes(value: string): string { + let i = value.length; + while (i > 0 && value.charCodeAt(i - 1) === 0x2f /* "/" */) i--; + return i === value.length ? value : value.slice(0, i); +} + +/** + * Default enrichment fetcher — pulls nice display names from + * `GET /api/pricing/models` and merges per-million-token pricing from + * `GET /api/pricing` (the actual pricing source — `/api/pricing/models` is + * a catalog endpoint whose entries are `{id, name, custom}` only). + * + * `/api/pricing/models` shape (catalog): + * - `{ [providerAlias]: { id, alias, name, models: [{ id, name, custom }] } }` + * + * `/api/pricing` shape (pricing only): + * - `{ [providerAlias]: { [modelId]: { input, output, cached, reasoning, cache_creation } } }` + * where values are USD per million tokens. + * + * The two responses are joined on `(providerAlias, modelId)` and the merged + * entries are stored under both `${providerAlias}/${modelId}` and bare + * `${modelId}` keys so downstream lookups against either form succeed. + * + * Soft-fails (returns whatever was collected) on non-2xx or parse errors; + * the two fetches are independent so one missing source still surfaces the + * other. A third best-effort fetch attaches free-tier budgets from + * `/api/free-tier/summary`. + * + * Ported from the v1 plugin (`index.ts:1906-2106`); the shared logger is + * the only intentional difference (no plugin-contract dependency here). + */ +export const defaultOmniRouteEnrichmentFetcher: OmniRouteEnrichmentFetcher = async ( + baseURL, + apiKey, + timeoutMs = 10_000, + onSourceError +) => { + const report = (endpoint: string, reason: unknown): void => { + onSourceError?.(endpoint, reason instanceof Error ? reason.message : String(reason)); + }; + const out: OmniRouteEnrichmentMap = new Map(); + if (!baseURL || !apiKey) return out; + const root = trimTrailingSlashes(baseURL.replace(/\/v1\/?$/, "")); + const headers = { + Authorization: `Bearer ${apiKey}`, + Accept: "application/json", + }; + + // 1. Catalog with nice display names. + const catalogAc = new AbortController(); + const catalogTimer = setTimeout(() => catalogAc.abort(), timeoutMs); + let catalogStatus = 0; + try { + const res = await fetch(`${root}/api/pricing/models`, { + method: "GET", + headers, + signal: catalogAc.signal, + }); + catalogStatus = res.status; + if (res.ok) { + const body = (await res.json()) as unknown; + const providers = + (body as { providers?: Record })?.providers ?? + (body as Record); + if (providers && typeof providers === "object") { + for (const [providerAlias, slot] of Object.entries(providers)) { + if (!slot || typeof slot !== "object") continue; + const models = (slot as { models?: unknown[] }).models; + if (!Array.isArray(models)) continue; + const canonicalRaw = (slot as { id?: unknown }).id; + const providerCanonical = + typeof canonicalRaw === "string" && canonicalRaw.length > 0 + ? canonicalRaw + : providerAlias; + const slotNameRaw = (slot as { name?: unknown }).name; + const providerDisplayName = + typeof slotNameRaw === "string" && slotNameRaw.trim().length > 0 + ? slotNameRaw.trim() + : undefined; + for (const m of models) { + if (!m || typeof m !== "object") continue; + const id = (m as { id?: unknown }).id; + if (typeof id !== "string" || id.length === 0) continue; + const name = (m as { name?: unknown }).name; + const entry: OmniRouteEnrichmentEntry = { + providerAlias, + providerCanonical, + }; + if (providerDisplayName) entry.providerDisplayName = providerDisplayName; + if (typeof name === "string" && name.trim().length > 0) entry.name = name; + const namespaced = `${providerAlias}/${id}`; + if (!out.has(namespaced)) out.set(namespaced, entry); + // The bare id is a fallback for ids that arrive unnamespaced. It + // gets its OWN copy: sharing the object would let a later write + // for one provider — a price, typically — land on another + // provider's entry that happens to sell the same model id. + if (!out.has(id)) out.set(id, { ...entry }); + } + } + } + } + } catch (err) { + // Network error, timeout, abort: nothing collected from THIS source, but + // the pricing fetch below may still succeed — let it try, then decide at + // the end whether the whole overlay failed (see the throw below). + report("/api/pricing/models", err); + catalogStatus = -1; + } finally { + clearTimeout(catalogTimer); + } + if ( + catalogStatus !== 0 && + catalogStatus !== -1 && + (catalogStatus < 200 || catalogStatus >= 300) + ) { + report("/api/pricing/models", `HTTP ${catalogStatus}`); + } + + // 2. Pricing values from /api/pricing. + const priceAc = new AbortController(); + const priceTimer = setTimeout(() => priceAc.abort(), timeoutMs); + let priceStatus = 0; + try { + const res = await fetch(`${root}/api/pricing`, { + method: "GET", + headers, + signal: priceAc.signal, + }); + priceStatus = res.status; + if (res.ok) { + const body = (await res.json()) as unknown; + if (body && typeof body === "object" && !Array.isArray(body)) { + for (const [providerAlias, slot] of Object.entries(body as Record)) { + if (!slot || typeof slot !== "object" || Array.isArray(slot)) continue; + for (const [modelId, raw] of Object.entries(slot as Record)) { + if (!raw || typeof raw !== "object") continue; + const p = raw as Record; + const parsed: NonNullable = {}; + if (typeof p.input === "number") parsed.input = p.input; + if (typeof p.output === "number") parsed.output = p.output; + const cacheRead = + typeof p.cached === "number" + ? p.cached + : typeof p.cacheRead === "number" + ? p.cacheRead + : undefined; + if (typeof cacheRead === "number") parsed.cacheRead = cacheRead; + const cacheWrite = + typeof p.cache_creation === "number" + ? p.cache_creation + : typeof p.cacheWrite === "number" + ? p.cacheWrite + : undefined; + if (typeof cacheWrite === "number") parsed.cacheWrite = cacheWrite; + if (Object.keys(parsed).length === 0) continue; + const namespaced = `${providerAlias}/${modelId}`; + const existingNs = out.get(namespaced); + if (existingNs) { + existingNs.pricing = { ...(existingNs.pricing ?? {}), ...parsed }; + } else { + out.set(namespaced, { pricing: parsed }); + } + const existingBare = out.get(modelId); + // Only the provider that owns the bare entry may price it. + // Otherwise the second provider selling the same model id + // overwrites the first one's price, and the picker shows a cost + // that belongs to a different connection. + const bareBelongsHere = + existingBare === undefined || existingBare.providerAlias === undefined + ? true + : existingBare.providerAlias === providerAlias; + if (bareBelongsHere) { + if (existingBare) { + existingBare.pricing = { ...(existingBare.pricing ?? {}), ...parsed }; + } else { + out.set(modelId, { pricing: parsed }); + } + } + } + } + } + } + } catch (err) { + // Same as above: report, mark this source failed, let the remaining + // sources try before deciding. + report("/api/pricing", err); + priceStatus = -1; + } finally { + clearTimeout(priceTimer); + } + if (priceStatus !== 0 && priceStatus !== -1 && (priceStatus < 200 || priceStatus >= 300)) { + report("/api/pricing", `HTTP ${priceStatus}`); + } + + // 3. Free model budgets from /api/free-tier/summary (best-effort). + const freeAc = new AbortController(); + const freeTimer = setTimeout(() => freeAc.abort(), timeoutMs); + let freeStatus = 0; + try { + const res = await fetch(`${root}/api/free-tier/summary`, { + method: "GET", + headers, + signal: freeAc.signal, + }); + freeStatus = res.status; + if (res.ok) { + const body = (await res.json()) as unknown; + const perModel: unknown[] = + body && typeof body === "object" && Array.isArray((body as { perModel?: unknown }).perModel) + ? ((body as { perModel: unknown[] }).perModel as unknown[]) + : Array.isArray(body) + ? (body as unknown[]) + : []; + for (const fm of perModel) { + if (!fm || typeof fm !== "object") continue; + const fmObj = fm as Record; + const provider = typeof fmObj.provider === "string" ? fmObj.provider : ""; + const modelId = typeof fmObj.modelId === "string" ? fmObj.modelId : ""; + const freeType = typeof fmObj.freeType === "string" ? fmObj.freeType : ""; + if (!modelId || !freeType) continue; + const monthlyTokens = + typeof fmObj.monthlyTokens === "number" ? fmObj.monthlyTokens : undefined; + const creditTokens = + typeof fmObj.creditTokens === "number" ? fmObj.creditTokens : undefined; + const displayName = typeof fmObj.displayName === "string" ? fmObj.displayName : ""; + const candidates = [ + `${provider}/${modelId}`, + modelId, + ...(displayName ? [displayName] : []), + ]; + for (const key of candidates) { + const entry = out.get(key); + if (entry) { + entry.freeType = freeType as FreeModelFreeType; + if (monthlyTokens !== undefined) entry.monthlyTokens = monthlyTokens; + if (creditTokens !== undefined) entry.creditTokens = creditTokens; + break; + } + } + } + } + } catch (err) { + report("/api/free-tier/summary", err); + // Soft-fail; free metadata is optional. + } finally { + clearTimeout(freeTimer); + } + if (freeStatus !== 0 && (freeStatus < 200 || freeStatus >= 300)) { + report("/api/free-tier/summary", `HTTP ${freeStatus}`); + } + + // A source that failed contributes nothing — but the overlay keeps its own + // memory per source: names collected while the catalog endpoint answered + // survive a later pricing outage, and prices collected while pricing + // answered survive a later catalog outage. Without this a single flapping + // source wipes the other source's good data on every refresh. So a failed + // catalog source throws (the caller keeps last-known) UNLESS the pricing + // source brought something on THIS call — then whatever was collected, + // names or prices, is the gateway's answer and ships as-is. (Status alone + // cannot decide: a 2xx pricing answer with zero priced models is still an + // answer, but it carries nothing to save the overlay with.) + const sourceFailed = (status: number): boolean => + status === -1 || (status !== 0 && (status < 200 || status >= 300)); + const catalogFailed = sourceFailed(catalogStatus); + const pricingBroughtSomething = !sourceFailed(priceStatus) && out.size > 0; + if (catalogFailed && !pricingBroughtSomething) { + throw new Error( + `enrichment catalog source failed (pricing/models: ${catalogStatus}, pricing: ${priceStatus})` + ); + } + + return out; +}; + +/** + * Apply enrichment overlay onto a ModelV2 entry. Mutates and returns the + * passed entry for convenience. + */ +/** What the caller knows about the entry that the overlay itself cannot tell. */ +export interface EnrichmentDisplayContext { + /** Combos never carry a provider tag: they route across providers. */ + isCombo?: boolean; + isAutoCombo?: boolean; + /** Set false to publish the bare display name, without the provider tag. */ + providerTag?: boolean; +} + +/** + * Fold the overlay into a mapped model: display name, provider tag, free-tier + * marker and budget, and pricing. + * + * The name is built rather than copied, because the gateway ships the parts + * separately — the pricing catalog gives a display name and an upstream + * provider label, the free-tier summary gives the budget. A picker showing + * `Claude - [Free] Sonnet 4.6 · 1M/mo` tells the user which connection serves + * the model and what it costs them; `claude-sonnet-4-6` tells them nothing. + */ +export function applyEnrichment( + model: ModelV2, + enrichment: OmniRouteEnrichmentEntry | undefined, + context: EnrichmentDisplayContext = {} +): ModelV2 { + if (!enrichment) return model; + const built = buildModelDisplayName({ + rawId: model.name && model.name.length > 0 ? model.name : model.id, + enrichmentName: enrichment.name, + providerAlias: context.providerTag === false ? undefined : enrichment.providerAlias, + providerDisplayName: context.providerTag === false ? undefined : enrichment.providerDisplayName, + isFree: enrichment.freeType !== undefined, + freeType: enrichment.freeType, + monthlyTokens: enrichment.monthlyTokens, + creditTokens: enrichment.creditTokens, + isCombo: context.isCombo, + isAutoCombo: context.isAutoCombo, + }); + if (built.trim().length > 0) { + model.name = built; + } + if (enrichment.pricing) { + if (typeof enrichment.pricing.input === "number") { + model.cost.input = enrichment.pricing.input; + } + if (typeof enrichment.pricing.output === "number") { + model.cost.output = enrichment.pricing.output; + } + if (typeof enrichment.pricing.cacheRead === "number") { + model.cost.cache.read = enrichment.pricing.cacheRead; + } + if (typeof enrichment.pricing.cacheWrite === "number") { + model.cost.cache.write = enrichment.pricing.cacheWrite; + } + } + return model; +} diff --git a/@omniroute/opencode-plugin-v2/src/shared/fingerprint.ts b/@omniroute/opencode-plugin-v2/src/shared/fingerprint.ts new file mode 100644 index 0000000000..58835956bb --- /dev/null +++ b/@omniroute/opencode-plugin-v2/src/shared/fingerprint.ts @@ -0,0 +1,127 @@ +import { createHash } from "node:crypto"; + +/** + * Fingerprint the CONTENT of a catalog snapshot (not endpoint/credential + * identity) so lazy refresh can reload-after-publish only when something + * actually changed. + * + * sha256 over sorted `id + "|" + (release_date ?? "")` lines for models + * plus sorted combo ids, joined with `\n`. Order-insensitive: two + * snapshots with the same entries in different order hash identically. + */ +export function catalogContentFingerprint( + models: { id: string; release_date?: string }[], + combos: { id: string }[], + autoCombos: { id: string }[] = [] +): string { + const modelLines = models + .map((m) => `${m.id}|${m.release_date ?? ""}`) + .sort() + .join("\n"); + const comboLines = combos + .map((c) => c.id) + .sort() + .join("\n"); + const autoLines = autoCombos + .map((c) => c.id) + .sort() + .join("\n"); + return createHash("sha256").update(`${modelLines}\n${comboLines}\n${autoLines}`).digest("hex"); +} + +/** + * Digest of the optional tier (auto-combos, provider connections, enrichment). + * The catalog fingerprint covers model and combo ids only, so an overlay that + * moves — a renamed model, a provider going unusable — leaves it unchanged. + * Reloading on every refresh instead would ask the host to rebuild its catalog + * once per TTL window for nothing. + */ +export function optionalTierFingerprint( + autoCombos: { id: string }[], + providers: { + id?: string; + name?: string; + testStatus?: string; + isActive?: boolean; + providerDisplayName?: string; + }[], + enrichment: + | Map< + string, + { + name?: string; + freeType?: string; + providerDisplayName?: string; + monthlyTokens?: number; + creditTokens?: number; + pricing?: Record; + } + > + | undefined, + combos: { id: string; name?: string; models?: unknown[] }[] = [] +): string { + const parts: string[] = []; + // Membership matters: a combo keeping its id while losing a member is a + // different combo to anyone picking it. + parts.push( + combos + .map((c) => c.id + "|" + (c.name ?? "") + "|" + String(c.models?.length ?? 0)) + .sort() + .join(",") + ); + parts.push( + autoCombos + .map((c) => c.id) + .sort() + .join(",") + ); + // A provider going quiet or getting renamed is as visible to the user as a + // price move: its activity flag and display name belong in the digest. + parts.push( + providers + .map( + (p) => + (p.id ?? p.name ?? "") + + ":" + + (p.testStatus ?? "") + + ":" + + String(p.isActive ?? "") + + ":" + + (p.providerDisplayName ?? "") + ) + .sort() + .join(",") + ); + if (enrichment !== undefined) { + const rows: string[] = []; + for (const [key, entry] of enrichment) { + // Pricing is part of what the user sees, so a price move must reach + // the picker without waiting for an id to change. + const price = entry.pricing + ? Object.entries(entry.pricing) + .map(([k, v]) => k + "=" + String(v ?? "")) + .sort() + .join(";") + : ""; + rows.push( + key + + "|" + + (entry.name ?? "") + + "|" + + (entry.freeType ?? "") + + "|" + + (entry.providerDisplayName ?? "") + + "|" + + String(entry.monthlyTokens ?? "") + + ";" + + String(entry.creditTokens ?? "") + + "|" + + price + ); + } + rows.sort(); + parts.push(String(enrichment.size)); + parts.push(rows.join("\n")); + } + return createHash("sha256").update(parts.join(" ")).digest("hex"); +} diff --git a/@omniroute/opencode-plugin-v2/src/shared/gemini.ts b/@omniroute/opencode-plugin-v2/src/shared/gemini.ts new file mode 100644 index 0000000000..2fb8b42c71 --- /dev/null +++ b/@omniroute/opencode-plugin-v2/src/shared/gemini.ts @@ -0,0 +1,166 @@ +/** + * Gemini rejects several standard JSON-Schema keywords in tool declarations + * and answers `400 INVALID_ARGUMENT` for the whole request when it meets one. + * The keywords carry no meaning Gemini would honour anyway, so stripping them + * costs nothing and is what keeps a tool-calling chain alive. + */ +/** + * Keywords Gemini rejects outright. `$ref` is deliberately NOT here: it + * cannot be stripped without turning the schema into "accept anything", so + * tools carrying one are forwarded untouched (see below). `ref` is not a + * JSON Schema keyword at all, and stripping it by name destroys a legitimate + * tool parameter called `ref` — a walker that cannot tell a keyword from a + * property name mangles the schema it was meant to repair. + */ +const REJECTED_KEYWORDS = new Set(["$schema", "additionalProperties"]); + +/** Keys whose value is itself a schema. */ +const SCHEMA_VALUE_KEYS = [ + "items", + "additionalItems", + "contains", + "not", + "if", + "then", + "else", + "propertyNames", + "contentSchema", + "unevaluatedItems", + "unevaluatedProperties", +]; +/** Keys whose value maps arbitrary NAMES to schemas — never keyword space. */ +const SCHEMA_MAP_KEYS = [ + "properties", + "patternProperties", + "$defs", + "definitions", + "dependentSchemas", + "dependencies", +]; +/** Keys whose value is a list of schemas. */ +const SCHEMA_LIST_KEYS = ["allOf", "anyOf", "oneOf", "prefixItems"]; + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +/** True when any schema in the tree carries a `$ref` we cannot resolve. */ +function hasUnresolvableRef(node: unknown): boolean { + if (Array.isArray(node)) return node.some(hasUnresolvableRef); + if (!isRecord(node)) return false; + if ("$ref" in node) return true; + for (const key of SCHEMA_VALUE_KEYS) if (hasUnresolvableRef(node[key])) return true; + // (arrays are handled by the Array branch at the top of this function) + for (const key of SCHEMA_LIST_KEYS) if (hasUnresolvableRef(node[key])) return true; + for (const key of SCHEMA_MAP_KEYS) { + const map = node[key]; + if (isRecord(map) && Object.values(map).some(hasUnresolvableRef)) return true; + } + return false; +} + +/** + * Strip the rejected keywords in place, walking only the positions where a + * schema can appear. Property names are never treated as keywords, so a tool + * whose parameter happens to be called `additionalProperties` keeps it. + * Returns whether anything was removed. + */ +function stripAtSchemaPositions(node: Record): boolean { + let changed = false; + for (const keyword of REJECTED_KEYWORDS) { + if (keyword in node) { + delete node[keyword]; + changed = true; + } + } + for (const key of SCHEMA_VALUE_KEYS) { + const child = node[key]; + if (isRecord(child)) { + changed = stripAtSchemaPositions(child) || changed; + continue; + } + // `items` also takes the tuple form: an array of schemas, one per position. + if (Array.isArray(child)) { + for (const item of child) { + if (isRecord(item)) changed = stripAtSchemaPositions(item) || changed; + } + } + } + for (const key of SCHEMA_LIST_KEYS) { + const list = node[key]; + if (Array.isArray(list)) { + for (const child of list) { + if (isRecord(child)) changed = stripAtSchemaPositions(child) || changed; + } + } + } + for (const key of SCHEMA_MAP_KEYS) { + const map = node[key]; + if (!isRecord(map)) continue; + for (const child of Object.values(map)) { + if (isRecord(child)) changed = stripAtSchemaPositions(child) || changed; + } + } + return changed; +} + +/** + * Families Google actually ships, anchored on the last path segment. A plain + * substring test also claims `gemini-compatible-proxy` and `my-gemini-wrapper` + * — and since the sanitiser removes keywords, a false positive is not free. + */ +const GEMINI_MODEL_ID = + /^gemini(?:[-_.](?:\d|pro|flash|ultra|nano|exp|thinking|embedding|live|imagen)|$)/i; + +/** + * True for the routing forms a Gemini model reaches a gateway under — bare + * (`gemini-2.5-flash`), canonical (`models/gemini-1.5-pro`) and prefixed + * (`google-vertex/gemini-2.0`). + */ +export function isGeminiModelId(modelId: unknown): boolean { + if (typeof modelId !== "string") return false; + const segment = modelId.split("/").pop() ?? ""; + return GEMINI_MODEL_ID.test(segment); +} + +/** The subset of an AI SDK tool declaration this module reads. */ +export interface ToolWithInputSchema { + readonly type?: string; + readonly inputSchema?: unknown; + readonly [key: string]: unknown; +} + +/** + * Return a copy of `tools` whose input schemas are free of the keywords Gemini + * rejects, or `undefined` when there was nothing to strip — which lets the + * caller forward the original array and skip the clone entirely. + * + * Tools this module cannot read (provider-defined tools, entries without an + * object schema) are carried through unchanged rather than dropped: a tool the + * sanitiser does not understand is still a tool the model needs. + */ +export function sanitizeToolInputSchemas( + tools: readonly T[] | undefined +): T[] | undefined { + if (tools === undefined || tools.length === 0) return undefined; + let changed = false; + const out = tools.map((tool) => { + if (!isRecord(tool.inputSchema)) return tool; + // A schema carrying something uncloneable is not worth failing a request + // over: forward the tool untouched and let the model answer. + // A `$ref` cannot be stripped without turning the schema into "anything + // goes", and cannot be resolved here. Forward the tool untouched and let + // the gateway answer rather than silently widen what the model may send. + if (hasUnresolvableRef(tool.inputSchema)) return tool; + let schema: Record; + try { + schema = structuredClone(tool.inputSchema) as Record; + } catch { + return tool; + } + if (!stripAtSchemaPositions(schema)) return tool; + changed = true; + return { ...tool, inputSchema: schema }; + }); + return changed ? out : undefined; +} diff --git a/@omniroute/opencode-plugin-v2/src/shared/index.ts b/@omniroute/opencode-plugin-v2/src/shared/index.ts new file mode 100644 index 0000000000..d65d093abb --- /dev/null +++ b/@omniroute/opencode-plugin-v2/src/shared/index.ts @@ -0,0 +1,9 @@ +export * from "./models-map.js"; +export * from "./combos-map.js"; +export * from "./auto-combos.js"; +export * from "./naming.js"; +export * from "./enrich.js"; +export * from "./fingerprint.js"; +export * from "./logger.js"; +export * from "./usable.js"; +export * from "./gemini.js"; diff --git a/@omniroute/opencode-plugin-v2/src/shared/logger.ts b/@omniroute/opencode-plugin-v2/src/shared/logger.ts new file mode 100644 index 0000000000..2439ddd34c --- /dev/null +++ b/@omniroute/opencode-plugin-v2/src/shared/logger.ts @@ -0,0 +1,81 @@ +/** + * Namespaced leveled logger shared by the OmniRoute OpenCode packages. + * + * Levels: error < warn < info < debug. Default: warn. + * Ported from the v1 plugin (`logger.ts`) so both new packages share one + * sink instead of raw `console.warn` / `console.log` calls. + */ + +export type LogLevel = "error" | "warn" | "info" | "debug"; + +const LEVEL_ORDER: Record = { + error: 0, + warn: 1, + info: 2, + debug: 3, +}; + +const TAG = "[omniroute-plugin]"; + +function shouldLog(current: LogLevel, target: LogLevel): boolean { + return LEVEL_ORDER[current] >= LEVEL_ORDER[target]; +} + +let _level: LogLevel = "warn"; + +export function setLogLevel(level: LogLevel): void { + _level = level; +} + +export function getLogLevel(): LogLevel { + return _level; +} + +function fmt(level: LogLevel, msg: string, tag?: string): string { + const prefix = tag ? `${TAG}${tag}` : TAG; + return `${prefix} [${level.toUpperCase()}] ${msg}`; +} + +function buildLogger(getLevel: () => LogLevel) { + return { + error(msg: string, ...args: unknown[]): void { + if (shouldLog(getLevel(), "error")) console.error(fmt("error", msg), ...args); + }, + warn(msg: string, ...args: unknown[]): void { + if (shouldLog(getLevel(), "warn")) console.warn(fmt("warn", msg), ...args); + }, + info(msg: string, ...args: unknown[]): void { + if (shouldLog(getLevel(), "info")) console.warn(fmt("info", msg), ...args); + }, + debug(msg: string, ...args: unknown[]): void { + if (shouldLog(getLevel(), "debug")) console.warn(fmt("debug", msg), ...args); + }, + /** Always emit regardless of level (for critical init breadcrumbs). */ + always(msg: string, ...args: unknown[]): void { + console.warn(TAG, msg, ...args); + }, + + child(tag: string) { + return { + error: (msg: string, ...args: unknown[]) => + shouldLog(getLevel(), "error") && console.error(fmt("error", msg, tag), ...args), + warn: (msg: string, ...args: unknown[]) => + shouldLog(getLevel(), "warn") && console.warn(fmt("warn", msg, tag), ...args), + info: (msg: string, ...args: unknown[]) => + shouldLog(getLevel(), "info") && console.warn(fmt("info", msg, tag), ...args), + debug: (msg: string, ...args: unknown[]) => + shouldLog(getLevel(), "debug") && console.warn(fmt("debug", msg, tag), ...args), + }; + }, + }; +} + +export type Logger = ReturnType; + +/** Create an instance-scoped logger whose level cannot be changed by other instances. */ +export function createLogger(level: LogLevel): Logger { + return buildLogger(() => level); +} + +/** Backward-compatible module-global logger controlled by setLogLevel(). */ +export const logger: Logger = buildLogger(() => _level); diff --git a/@omniroute/opencode-plugin-v2/src/shared/models-map.ts b/@omniroute/opencode-plugin-v2/src/shared/models-map.ts new file mode 100644 index 0000000000..625e02f232 --- /dev/null +++ b/@omniroute/opencode-plugin-v2/src/shared/models-map.ts @@ -0,0 +1,323 @@ +import type { Model as ModelV2 } from "@opencode-ai/sdk/v2"; +import { normaliseFreeLabel } from "./naming.js"; + +export interface OmniRouteRawModelEntry { + id: string; + object?: string; + owned_by?: string; + root?: string | null; + parent?: string | null; + context_length?: number; + max_input_tokens?: number; + max_output_tokens?: number; + input_modalities?: string[]; + output_modalities?: string[]; + capabilities?: { + tool_calling?: boolean; + reasoning?: boolean; + vision?: boolean; + thinking?: boolean; + attachment?: boolean; + structured_output?: boolean; + temperature?: boolean; + /** Runtime-learned or synced reasoning tiers (server-gated, blind-mapped). */ + effort_tiers?: string[]; + }; + release_date?: string; + last_updated?: string; + api_format?: string; +} + +/** + * Fetcher contract: returns the raw `/v1/models` entry list from a running + * OmniRoute instance. Surfaced as a dependency so unit tests can inject a + * stub without monkey-patching global `fetch`. + * + * Why we inline this instead of using `@omniroute/opencode-provider`'s + * `fetchLiveModels`: the sibling helper returns a stripped `{id, name, + * contextLength?}` shape that drops the `capabilities` / `*_modalities` / + * `max_*_tokens` blocks the mapping needs for ModelV2 pass-through. + */ +export type OmniRouteModelsFetcher = ( + baseURL: string, + apiKey: string, + timeoutMs?: number +) => Promise; + +/** + * Default fetcher: `GET /v1/models` with bearer auth + AbortController + * timeout. Accepts both the `{object:"list", data:[…]}` envelope OmniRoute + * emits today and a bare-array envelope (defensive — keeps the plugin + * working if a future OmniRoute build trims the wrapper). Anything that + * isn't an object with a string `id` is filtered out silently. + */ +export const defaultOmniRouteModelsFetcher: OmniRouteModelsFetcher = async ( + baseURL, + apiKey, + timeoutMs = 10_000 +) => { + if (!apiKey) throw new Error("[omniroute-v2] apiKey required to fetch /v1/models"); + if (!baseURL) throw new Error("[omniroute-v2] baseURL required to fetch /v1/models"); + + const trimmed = trimTrailingSlashes(baseURL); + // Tolerate both `https://host` and `https://host/v1` forms — the gateway + // exposes /v1/models either way; we just don't want a double `/v1/v1`. + const url = /\/v\d+$/.test(trimmed) ? `${trimmed}/models` : `${trimmed}/v1/models`; + + const controller = new AbortController(); + const timer = setTimeout(() => controller.abort(), timeoutMs); + try { + const res = await fetch(url, { + method: "GET", + headers: { + Authorization: `Bearer ${apiKey}`, + Accept: "application/json", + }, + signal: controller.signal, + }); + if (!res.ok) { + throw new Error(`[omniroute-v2] GET ${url} failed: ${res.status} ${res.statusText}`); + } + const body = (await res.json()) as unknown; + const rawList: unknown[] = Array.isArray(body) + ? body + : body && typeof body === "object" && Array.isArray((body as { data?: unknown }).data) + ? ((body as { data: unknown[] }).data as unknown[]) + : []; + const out: OmniRouteRawModelEntry[] = []; + for (const r of rawList) { + if (r && typeof r === "object" && typeof (r as { id?: unknown }).id === "string") { + out.push(r as OmniRouteRawModelEntry); + } + } + return out; + } finally { + clearTimeout(timer); + } +}; + +// Manual trim helpers avoid polynomial-regex CodeQL warnings on +// user-supplied baseURL strings (string.replace(/\/+$/, "")). The same +// behaviour, no backtracking. +function trimTrailingSlashes(value: string): string { + let i = value.length; + while (i > 0 && value.charCodeAt(i - 1) === 0x2f /* "/" */) i--; + return i === value.length ? value : value.slice(0, i); +} + +/** + * Ensure a baseURL ends with `/v1` so the OpenAI-compat SDK constructs + * `/v1/chat/completions` correctly. The Anthropic SDK does NOT want `/v1` + * (it appends `/v1/messages` automatically), so callers should branch on + * format first. + */ +export function ensureV1Suffix(url: string): string { + const trimmed = trimTrailingSlashes(url); + return trimmed.endsWith("/v1") ? trimmed : `${trimmed}/v1`; +} + +export interface ApiFormatV2 { + allowAnthropic?: boolean; + anthropicModels?: string[]; + /** + * Deprecated v1 prefix list (default v1: + * `cc,claude,anthropic,kiro,kr`). Accepted for backward compatibility: + * prefix OR allowlist routes to anthropic, with a one-time deprecation + * warning pointing at `anthropicModels`. Prefer full IDs. + */ + anthropicPrefixes?: string[]; +} + +/** Default v1 prefix list, kept so copied v1 configs keep routing. */ +export const DEFAULT_ANTHROPIC_PREFIXES_V1 = ["cc", "claude", "anthropic", "kiro", "kr"]; + +const warnedPrefixLists = new Set(); + +function warnDeprecatedPrefixesOnce(prefixes: string[]): void { + const key = [...prefixes].sort().join(","); + if (warnedPrefixLists.has(key)) return; + warnedPrefixLists.add(key); + console.warn( + "[omniroute-plugin] [WARN] apiFormat.anthropicPrefixes is deprecated; convert to anthropicModels (full IDs)" + ); +} + +/** + * The Anthropic SDK block appends `/v1/messages` itself, so it needs the + * gateway root. A config carrying the `/v1` the OpenAI-compatible block wants + * would otherwise produce `/v1/v1/messages`. + */ +function stripV1Suffix(baseURL: string): string { + return baseURL.replace(/\/v1\/?$/, ""); +} + +/** + * Resolve the API block (id + url + npm package) for a given model id. + * + * v2 rule: a model routes to the Anthropic SDK block when + * `apiFormat.allowAnthropic === true` AND (its FULL id is allowlisted in + * `apiFormat.anthropicModels` OR its prefix is listed in the deprecated + * `apiFormat.anthropicPrefixes`, defaulting to the v1 list when prefixes + * are absent). The deprecated path warns once per prefix list. With + * neither allowlist nor prefix match, the model stays openai-compatible. + */ +export function resolveApiBlockV2( + modelId: string, + baseURL: string, + apiFormat?: ApiFormatV2 +): { id: string; url: string; npm: string } { + if (apiFormat?.allowAnthropic === true) { + if ((apiFormat.anthropicModels ?? []).includes(modelId)) { + return { + id: "anthropic", + url: stripV1Suffix(trimTrailingSlashes(baseURL)), + npm: "@ai-sdk/anthropic", + }; + } + const prefixes = apiFormat.anthropicPrefixes ?? DEFAULT_ANTHROPIC_PREFIXES_V1; + if (apiFormat.anthropicPrefixes !== undefined) warnDeprecatedPrefixesOnce(prefixes); + const slash = modelId.indexOf("/"); + const prefix = slash === -1 ? modelId : modelId.slice(0, slash); + if (prefixes.includes(prefix)) { + return { + id: "anthropic", + url: stripV1Suffix(trimTrailingSlashes(baseURL)), + npm: "@ai-sdk/anthropic", + }; + } + } + return { + id: "openai-compatible", + url: ensureV1Suffix(baseURL), + npm: "@ai-sdk/openai-compatible", + }; +} + +/** + * Map a raw `/v1/models` entry → `ModelV2` (the type @opencode-ai/sdk/v2 + * exports as `Model`, re-exported by @opencode-ai/plugin as `ModelV2`). + * + * ModelV2 requires a much richer shape than a flat record. Concretely it + * expects: + * - flat `id`, `name`, `providerID`, `api: {id,url,npm}` + * - nested `capabilities: { temperature, reasoning, attachment, toolcall, + * input:{text,audio,image,video,pdf}, output:{…}, interleaved }` + * - `cost: { input, output, cache:{read,write} }` (NOT optional) + * - `limit: { context, input?, output }` + * - `status: "alpha"|"beta"|"deprecated"|"active"`, `options:{}`, `headers:{}` + * - `release_date: string` + * + * Field adaptations: + * 1. Flat `tool_call` / `reasoning` / `attachment` / `modalities` + * top-level fields don't exist in ModelV2 — folded into + * `capabilities.{toolcall, reasoning, attachment, input.*, output.*}`. + * 2. `cost: undefined` is illegal (cost is required). OmniRoute doesn't + * surface pricing on /v1/models, so we emit a zeroed cost block. + * Downstream opencode reads this for display only — the live pricing + * is OmniRoute's responsibility at routing time. + * 3. `tool_call` → `toolcall` (ModelV2 field name; one word). + * 4. `attachment` maps from `capabilities.vision` per OmniRoute + * convention: vision = ability to receive image attachments. If the + * raw entry happens to expose an explicit `capabilities.attachment`, + * that wins. + * 5. `thinking` from OmniRoute has no 1:1 ModelV2 slot. We OR it into + * `reasoning` so thinking-only models still surface a non-false + * reasoning flag. + * 6. `last_updated` from OmniRoute has no ModelV2 slot — dropped. + * `release_date` lands in ModelV2.release_date with `""` fallback + * (the field is required as `string`). + * 7. `temperature: true` per OmniRoute convention (OpenAI-compat mode + * always supports the temperature knob). If a raw entry sets + * `capabilities.temperature` explicitly, that wins. + * 8. Input/output modality arrays: each known modality flips its boolean. + * Unknown strings (future OmniRoute additions) are ignored — when the + * server adds new modalities we can map them here without breaking + * existing entries. + * 9. `status: "active"` — OmniRoute doesn't tier models alpha/beta on + * /v1/models, and opencode needs a non-deprecated status to expose + * the model in the picker. If a future entry surfaces an explicit + * lifecycle hint we can map it then. + * 10. `options: {}` and `headers: {}` left empty — they're escape hatches + * for opencode users to attach per-model overrides; the provider + * plugin must not preempt them. + * 11. `limit.input` is OPTIONAL on ModelV2 (the `?` modifier). We only + * emit it when OmniRoute supplies `max_input_tokens` — keeps the + * shape clean for combo entries that only carry context_length. + */ +export function mapRawModelToModelV2( + raw: OmniRouteRawModelEntry, + ctx: { providerId: string; baseURL: string; apiFormat?: ApiFormatV2 } +): ModelV2 { + const caps = raw.capabilities ?? {}; + // effort_tiers loop: server-declared tiers become ModelV2 variants so the + // UI offers exactly the tiers OmniRoute vouches for (instead of opencode's + // invented [low, medium, high] fallback). Blind: filtering/exclusion rules + // live server-side. Absent/empty/malformed => key omitted ENTIRELY (an + // empty variants object would suppress opencode's fallback for this model). + const declaredTiers = Array.isArray(caps.effort_tiers) + ? caps.effort_tiers.filter((t): t is string => typeof t === "string" && t.length > 0) + : []; + const variants = + declaredTiers.length > 0 + ? Object.fromEntries(declaredTiers.map((tier) => [tier, { reasoningEffort: tier }])) + : undefined; + const inMods = new Set(raw.input_modalities ?? ["text"]); + const outMods = new Set(raw.output_modalities ?? ["text"]); + + return { + // OC's static-catalog reader parses the key on `/` to recover + // `(providerID, modelID)`. If the raw id is already provider-prefixed + // (e.g. `cc/claude-opus-4-7` from the `cc` Claude Code alias, or + // `nvidia/llama-3-70b` from a provider that ships prefixed ids), leave + // it as-is — double-prefixing breaks OC's lookup. Bare **combo** ids + // (`owned_by: "combo"`, e.g. `gpt-5.6-sol`) must also stay unprefixed: + // OpenCode looks up `-m /` as model id `` under + // the plugin provider. Other bare ids still prefix with + // `providerId` so credentials resolve as `(omniroute, model)`. + id: raw.id.includes("/") || raw.owned_by === "combo" ? raw.id : `${ctx.providerId}/${raw.id}`, + /** + * Display name. Falls back to raw.id when no enrichment is available; + * the caller overlays `/api/pricing/models` data via enrichment when + * the enrichment feature is enabled. + */ + name: normaliseFreeLabel(raw.id), + capabilities: { + temperature: caps.temperature ?? true, + reasoning: Boolean(caps.reasoning || caps.thinking), + attachment: Boolean(caps.attachment ?? caps.vision ?? false), + toolcall: Boolean(caps.tool_calling ?? false), + input: { + text: inMods.has("text"), + audio: inMods.has("audio"), + image: inMods.has("image"), + video: inMods.has("video"), + pdf: inMods.has("pdf"), + }, + output: { + text: outMods.has("text"), + audio: outMods.has("audio"), + image: outMods.has("image"), + video: outMods.has("video"), + pdf: outMods.has("pdf"), + }, + interleaved: Boolean(caps.thinking), + }, + cost: { + input: 0, + output: 0, + cache: { read: 0, write: 0 }, + }, + limit: { + context: typeof raw.context_length === "number" ? raw.context_length : 0, + ...(typeof raw.max_input_tokens === "number" ? { input: raw.max_input_tokens } : {}), + output: typeof raw.max_output_tokens === "number" ? raw.max_output_tokens : 0, + }, + ...(variants ? { variants } : {}), + status: "active", + options: {}, + headers: {}, + release_date: raw.release_date ?? "", + providerID: ctx.providerId, + api: resolveApiBlockV2(raw.id, ctx.baseURL, ctx.apiFormat), + }; +} diff --git a/@omniroute/opencode-plugin-v2/src/shared/naming.ts b/@omniroute/opencode-plugin-v2/src/shared/naming.ts new file mode 100644 index 0000000000..823809cf20 --- /dev/null +++ b/@omniroute/opencode-plugin-v2/src/shared/naming.ts @@ -0,0 +1,295 @@ +/** + * Universal model naming template for the OmniRoute plugin. + * + * Naming pipeline: + * [tag] + * + * [Free] - · ← free model + * Auto: (p) ← auto combo + * Combo: ← DB combo + * - ← regular model + */ + +// ── Constants ──────────────────────────────────────────────────────────── + +/** Separator between provider label and model display name. */ +export const PROVIDER_TAG_SEPARATOR = " - "; + +/** Threshold beyond which providerDisplayName is abbreviated. */ +const PROVIDER_LABEL_MAX_CHARS = 12; + +/** Aliases longer than this get title-case instead of UPPER. */ +const ALIAS_UPPER_MAX_CHARS = 5; + +// ── Auto Combo Types ───────────────────────────────────────────────────── + +export type AutoVariant = "coding" | "fast" | "cheap" | "offline" | "smart" | "lkgp"; + +export const AUTO_VARIANTS: AutoVariant[] = ["coding", "fast", "cheap", "offline", "smart", "lkgp"]; + +export const AUTO_VARIANT_DESCRIPTIONS: Record = { + default: "Best provider via scoring", + coding: "Quality-first for code tasks", + fast: "Latency-optimized routing", + cheap: "Cost-optimized routing", + offline: "Offline-friendly providers", + smart: "Quality-first with exploration", + lkgp: "Last-Known-Good-Provider routing", +}; + +// ── Free Model Types ───────────────────────────────────────────────────── + +export type FreeModelFreeType = + | "recurring-daily" + | "recurring-monthly" + | "recurring-credit" + | "one-time-initial" + | "keyless" + | "discontinued"; + +// ── Provider Label ──────────────────────────────────────────────────────── + +/** + * Title-case a long, lowercase-looking alias. + * `antigravity` → `Antigravity` + */ +function titleCaseAlias(alias: string): string { + if (alias.length === 0) return alias; + return alias.charAt(0).toUpperCase() + alias.slice(1).toLowerCase(); +} + +/** + * Pick the short label for an upstream provider. + * + * Rules: + * 1. Trim `providerDisplayName`. If ≤12 chars → use verbatim. + * 2. Alias ≤5 chars → UPPER(alias). Alias >5 → titleCase. + * 3. Neither → undefined. + */ +export function shortProviderLabel( + enrichment: { providerDisplayName?: string; providerAlias?: string } | undefined +): string | undefined { + if (!enrichment) return undefined; + const raw = + typeof enrichment.providerDisplayName === "string" ? enrichment.providerDisplayName.trim() : ""; + if (raw.length > 0 && raw.length <= PROVIDER_LABEL_MAX_CHARS) return raw; + const alias = typeof enrichment.providerAlias === "string" ? enrichment.providerAlias.trim() : ""; + if (alias.length > 0) { + return alias.length <= ALIAS_UPPER_MAX_CHARS ? alias.toUpperCase() : titleCaseAlias(alias); + } + // Long displayName with no alias to fall back on: keep the long label + // rather than dropping the provider prefix entirely. + return raw.length > 0 ? raw : undefined; +} + +// ── Free Label ──────────────────────────────────────────────────────────── + +/** + * Normalise display name so free-tier models get a consistent `[Free] ` prefix. + * + * "GPT-4.1 (Free)" → "[Free] GPT-4.1" + * "DeepSeek V4 Flash Free" → "[Free] DeepSeek V4 Flash" + * "Claude Opus 4.7" → "Claude Opus 4.7" (unchanged) + */ +export function normaliseFreeLabel(name: string): string { + // Bounded whitespace quantifiers ({0,8}/{1,8}) avoid the polynomial-ReDoS + // backtracking that unbounded \s* before an anchored \s*$ would allow on + // attacker-influenced display names. 8 covers any realistic label spacing. + const cleaned = name + .replace(/\s{0,8}\(free\)\s{0,8}$/i, "") + .replace(/[\s-]{1,8}free\s{0,8}$/i, "") + .trim(); + const wasFree = cleaned.length < name.trim().length; + if (!wasFree) return name; + return `[Free] ${cleaned}`; +} + +// ── Free Budget Formatting ──────────────────────────────────────────────── + +/** Scales, largest first, so the unit is chosen by descending magnitude. */ +const TOKEN_UNITS = [ + [1e9, "B"], + [1e6, "M"], + [1e3, "K"], +] as const; + +/** + * Format a token count as a short magnitude string: `25M`, `1.5K`, `999`. + * + * The unit has to be picked from the value that will actually be *printed*, + * not from the raw input. `toFixed(1)` rounds to the nearest tenth, so at the + * K scale 999_950 and above render as `1000.0` — and by then the M branch has + * already been skipped, producing `1000K` for a number that is `1M`. The same + * carry turns just under a billion into `1000M`. When the rounded value reaches + * the next scale, re-render at that scale instead. + */ +function fmtTokens(n: number): string { + for (let i = 0; i < TOKEN_UNITS.length; i++) { + const [scale, suffix] = TOKEN_UNITS[i]!; + if (n < scale) continue; + const value = Number((n / scale).toFixed(1)); + // `Number()` also drops a trailing `.0`, which the previous regex did. + if (value < 1000 || i === 0) return `${value}${suffix}`; + const [nextScale, nextSuffix] = TOKEN_UNITS[i - 1]!; + return `${Number((n / nextScale).toFixed(1))}${nextSuffix}`; + } + return String(n); +} + +/** + * Format a free model budget into a short human-readable suffix. + * + * recurring-daily → "25M tokens/day" + * recurring-monthly → "25M tokens/month" + * recurring-credit → "10M credits" + * one-time-initial → "1M credits (one-time)" + * keyless → "(keyless)" + * discontinued → "(discontinued)" + */ +export function formatFreeBudget(params: { + freeType: FreeModelFreeType; + monthlyTokens?: number; + creditTokens?: number; +}): string { + const { freeType, monthlyTokens = 0, creditTokens = 0 } = params; + + switch (freeType) { + case "recurring-daily": + return `${fmtTokens(monthlyTokens)} tokens/day`; + case "recurring-monthly": + return `${fmtTokens(monthlyTokens)} tokens/month`; + case "recurring-credit": + return `${fmtTokens(creditTokens)} credits`; + case "one-time-initial": + return `${fmtTokens(creditTokens)} credits (one-time)`; + case "keyless": + return "(keyless)"; + case "discontinued": + return "(discontinued)"; + default: + return ""; + } +} + +// ── Auto Combo Naming ───────────────────────────────────────────────────── + +/** + * Format auto combo display name. + * + * "Auto: Coding (4p)" + * "Auto: Default (6p)" + * "Auto" (no candidate count when unknown) + */ +export function formatAutoComboName( + variant: AutoVariant | undefined, + candidateCount?: number +): string { + const label = variant ? variant.charAt(0).toUpperCase() + variant.slice(1) : "Default"; + const count = + typeof candidateCount === "number" && candidateCount > 0 ? ` (${candidateCount}p)` : ""; + return `Auto: ${label}${count}`; +} + +/** + * Build the model ID for an auto combo entry. + * "auto/coding", "auto/fast", "auto" (default). + */ +export function autoComboModelId(variant: AutoVariant | undefined): string { + return variant ? `auto/${variant}` : "auto"; +} + +// ── Universal Display Name Builder ──────────────────────────────────────── + +export interface ModelDisplayNameParams { + /** Raw model ID (e.g. "cc/claude-sonnet-4-6"). */ + rawId: string; + /** Enrichment display name (e.g. "Claude Sonnet 4.6"). */ + enrichmentName?: string; + /** Provider tag enrichment. */ + providerAlias?: string; + /** Human-readable upstream provider label. */ + providerDisplayName?: string; + /** Whether model is free tier. */ + isFree?: boolean; + /** Free model budget info. */ + freeType?: FreeModelFreeType; + /** Monthly token budget (for recurring free models). */ + monthlyTokens?: number; + /** Credit token budget (for credit-based free models). */ + creditTokens?: number; + /** Whether this is a combo entry (skip provider tag). */ + isCombo?: boolean; + /** Whether this is an auto combo entry. */ + isAutoCombo?: boolean; + /** Auto combo variant. */ + autoVariant?: AutoVariant; + /** Auto combo candidate count. */ + autoCandidateCount?: number; +} + +/** + * Build the final display name following the universal template. + * + * Priority: + * 1. Auto combo → "Auto: (p)" + * 2. DB combo → "Combo: " + * 3. Free + enrichment + provider tag → "[Free]