From 80856802726494fbeea2ea299109fb07c91cb68a Mon Sep 17 00:00:00 2001 From: Wibias <37517432+Wibias@users.noreply.github.com> Date: Mon, 10 Aug 2026 05:03:56 +0200 Subject: [PATCH 01/28] feat(lab): CL-06 routing compatibility policy consumption Extend Routing Profiles with optional Compatibility Lab evidence gates, bounded read-only verdict lookup, evaluator penalties, trace explainability, management API parity, and Models editor controls without routing-path probes. --- .../001_pr_stack_status.md | 6 +- .../006_cl06_routing_compatibility.md | 332 ++++++++++++++++++ gui/src/i18n/de.ts | 8 + gui/src/i18n/en.ts | 8 + gui/src/i18n/ja.ts | 8 + gui/src/i18n/ko.ts | 8 + gui/src/i18n/ru.ts | 8 + gui/src/i18n/tr.ts | 8 + gui/src/i18n/zh.ts | 8 + gui/src/pages/RoutingProfiles.tsx | 178 +++++++++- gui/src/routing-profile-editor-data.ts | 49 ++- src/router.ts | 25 +- src/routing/compatibility/assemble.ts | 125 +++++++ src/routing/compatibility/behavior.ts | 127 +++++++ src/routing/compatibility/endpoint.ts | 51 +++ src/routing/compatibility/policy.ts | 183 ++++++++++ src/routing/compatibility/reader.ts | 96 +++++ src/routing/compatibility/subject.ts | 84 +++++ src/routing/compatibility/types.ts | 50 +++ src/routing/compatibility/version.ts | 64 ++++ src/routing/evaluator.ts | 23 ++ src/routing/profile.ts | 111 ++++++ src/routing/trace.ts | 61 +++- .../management/routing-profile-routes.ts | 28 +- src/types.ts | 15 + tests/routing-compatibility.test.ts | 297 ++++++++++++++++ tests/routing-profile-editor-data.test.ts | 43 +++ .../routing-profile-management-editor.test.ts | 62 ++++ 28 files changed, 2018 insertions(+), 48 deletions(-) create mode 100644 devlog/_plan/260807_compatibility_lab/006_cl06_routing_compatibility.md create mode 100644 src/routing/compatibility/assemble.ts create mode 100644 src/routing/compatibility/behavior.ts create mode 100644 src/routing/compatibility/endpoint.ts create mode 100644 src/routing/compatibility/policy.ts create mode 100644 src/routing/compatibility/reader.ts create mode 100644 src/routing/compatibility/subject.ts create mode 100644 src/routing/compatibility/types.ts create mode 100644 src/routing/compatibility/version.ts create mode 100644 tests/routing-compatibility.test.ts diff --git a/devlog/_plan/260807_compatibility_lab/001_pr_stack_status.md b/devlog/_plan/260807_compatibility_lab/001_pr_stack_status.md index cdd56426b7..a529155127 100644 --- a/devlog/_plan/260807_compatibility_lab/001_pr_stack_status.md +++ b/devlog/_plan/260807_compatibility_lab/001_pr_stack_status.md @@ -24,7 +24,8 @@ independent review, blockers, and whether a later phase is authorized. | CL-02 | `feat/cl-02-evidence-ledger` | `4bb249b756abd468c675d2d92fffe4da95ad3e2a` | NOT RECORDED | [#1333](https://github.com/lidge-jun/opencodex/pull/1333) | MERGED TO `dev` at `025c37916225dd685d9217e5b40190600f06d278`; POST-MERGE HARDENING [#1343](https://github.com/lidge-jun/opencodex/pull/1343) MERGED at `eee2dab4d1bbacefce56057adad51d734f346702`; FINAL CLOSURE GATE [#1348](https://github.com/lidge-jun/opencodex/pull/1348) | | CL-03 | `feat/cl-03-live-route-probes` | `4f746d13799888ea0a8c7a111aa2ad61c2126ea0` | `003f7402f49bfe8dd710a7beba52f717051bfadf` | [#1352](https://github.com/lidge-jun/opencodex/pull/1352) | MERGED TO `dev` at `68c71a4e9cdf882d812f09fd94783a28749db629`; ACCEPTED/CLOSED | | CL-04 | `feat/cl-04-lab-read-surfaces` | `68c71a4e9cdf882d812f09fd94783a28749db629` | NOT RECORDED | [#1378](https://github.com/lidge-jun/opencodex/pull/1378) | MERGED TO `dev` at `d517161aeaa3a974ad3c0360ff0c97b03b4c4520` | -| CL-05 | `feat/cl-05-compatibility-matrix-ui` | `d517161aeaa3a974ad3c0360ff0c97b03b4c4520` | `2a159b8b7` (Models tab placement) | [#1384](https://github.com/lidge-jun/opencodex/pull/1384) | IMPLEMENTATION IN PROGRESS (not accepted) | +| CL-05 | `feat/cl-05-compatibility-matrix-ui` | `d517161aeaa3a974ad3c0360ff0c97b03b4c4520` | `2a159b8b7` (Models tab placement) | [#1384](https://github.com/lidge-jun/opencodex/pull/1384) | MERGED TO `dev` at `1072b9c39c48a4982229131613ac300560740742` | +| CL-06 | `feat/cl-06-routing-profile-compatibility` | `1072b9c39c48a4982229131613ac300560740742` | (in progress) | (draft pending) | AUTHORIZED / IN PROGRESS | The CL-01 starting SHA is the exact CL-00 tip recorded when CL-01 began. Its moving base-ref name is not a substitute for that historical SHA. @@ -160,7 +161,8 @@ Claims cannot produce `PROBED`/`VERIFIED`. - CL-02: **MERGED** via #1333 at `025c37916225dd685d9217e5b40190600f06d278`; post-merge hardening #1343 is also **MERGED** at `eee2dab4d1bbacefce56057adad51d734f346702`; final closure is tracked in #1348. - CL-03: **ACCEPTED/CLOSED** via [#1352](https://github.com/lidge-jun/opencodex/pull/1352), merged to `dev` at `68c71a4e9cdf882d812f09fd94783a28749db629`. - CL-04: **MERGED** via #1378 at `d517161aeaa3a974ad3c0360ff0c97b03b4c4520`. -- CL-05: **IMPLEMENTATION IN PROGRESS**, authorized from CL-04 merge `d517161aeaa3a974ad3c0360ff0c97b03b4c4520`. +- CL-05: **MERGED** via #1384 at `1072b9c39c48a4982229131613ac300560740742`. +- CL-06: **AUTHORIZED / IN PROGRESS** from CL-05 merge `1072b9c39c48a4982229131613ac300560740742`; branch `feat/cl-06-routing-profile-compatibility`; plan `006_cl06_routing_compatibility.md`. ## CL-03 implementation log (2026-08-09) diff --git a/devlog/_plan/260807_compatibility_lab/006_cl06_routing_compatibility.md b/devlog/_plan/260807_compatibility_lab/006_cl06_routing_compatibility.md new file mode 100644 index 0000000000..83e3db9144 --- /dev/null +++ b/devlog/_plan/260807_compatibility_lab/006_cl06_routing_compatibility.md @@ -0,0 +1,332 @@ +# CL-06 implementation record — Routing Profile compatibility policy + +## Programme position + +| Field | Value | +|---|---| +| **Phase** | CL-06 | +| **Starting SHA** | `1072b9c39c48a4982229131613ac300560740742` (CL-05 merge #1384) | +| **Branch** | `feat/cl-06-routing-profile-compatibility` | +| **Target** | `lidge-jun/opencodex:dev` | +| **CL-07** | **Not started** (explicit non-goal) | + +## A. Frozen Routing Profile compatibility schema + +Compatibility policy extends the existing `OcxRoutingProfileConfig` via an optional +`compatibility` object. There is **no** separate compatibility profile store. + +```typescript +interface OcxRoutingProfileCompatibilitySuite { + /** Lab suite identifier, e.g. "responses-core". */ + suiteId: string; + /** Evidence layer for this requirement. */ + evidenceLayer: "protocol_conformance" | "live_route_compatibility"; +} + +interface OcxRoutingProfileCompatibility { + /** Required compatibility suites (may be empty array → no requirements). */ + requiredSuites?: OcxRoutingProfileCompatibilitySuite[]; + /** + * Minimum positive compatibility status. Only PROBED and VERIFIED are legal + * positive thresholds. Omitted = no positive threshold (suites still listed + * for trace/explainability only when explicitly configured). + */ + minStatus?: "PROBED" | "VERIFIED"; + /** + * Profile-level maximum evidence age (ms). Tightens scenario/suite freshness; + * never extends it. Omitted = no profile tightening. + */ + maxEvidenceAgeMs?: number; + /** + * Behavior for UNKNOWN, CLAIMED, and BLOCKED verdicts on a required suite. + * Default: "exclude" (fail closed). + */ + unknownEvidence?: "allow" | "penalize" | "exclude"; + /** + * Behavior for DEGRADED verdict on a required suite. + * Default: "penalize". + */ + degradedEvidence?: "allow" | "penalize" | "exclude"; +} +``` + +### Verdict semantics (CL-00 aligned) + +| Verdict | Required-suite treatment | +|---|---| +| `VERIFIED` | Satisfies `minStatus: VERIFIED` and `minStatus: PROBED` | +| `PROBED` | Satisfies `minStatus: PROBED`; fails `minStatus: VERIFIED` | +| `DEGRADED` | Governed solely by `degradedEvidence` (never by `minStatus`) | +| `UNKNOWN` | Follows `unknownEvidence` | +| `CLAIMED` | Follows `unknownEvidence` (not a positive threshold) | +| `BLOCKED` | Follows `unknownEvidence` (environmental; not capability proof) | +| `UNSUPPORTED` | **Always excludes** for a required suite | + +There is **no** universal compatibility score and **no** total ordering across all +verdicts. `minStatus` applies only to positive `PROBED`/`VERIFIED` thresholds. + +### Freshness composition + +Effective max age for a suite requirement: + +```text +effectiveMaxAgeMs = min( + finite scenario.maxAgeMs, + finite suite.maxAgeMs, + finite profile.maxEvidenceAgeMs +) +``` + +`null` at any layer means no bound at that layer. A stale prior `VERIFIED` or +`PROBED` row does **not** satisfy a current requirement. + +## B. Backward compatibility and revision hashing + +1. Profiles that **omit** `compatibility` entirely retain pre-CL-06 validation, + eligibility, scoring, and routing behavior. +2. Normalization **does not** inject a default/empty `compatibility` object into + stored config or revision input. +3. `profileRevision()` includes `compatibility` **only** when the normalized + profile has at least one effective compatibility control: + - non-empty `requiredSuites`, or + - `minStatus`, or + - `maxEvidenceAgeMs`, or + - explicit `unknownEvidence` / `degradedEvidence` overrides. +4. Adding CL-06 code to a deployment must not change revisions for profiles that + never set compatibility fields (regression-tested). + +## C. Exact route-subject identity + +Compatibility evidence matches the exact Lab `RouteSubjectV1` / `subjectId`, not +`provider/model` alone. + +### Production derivation (read-only, no DNS/network) + +Shared pure helpers extracted from CL-03 (`src/lab/subject/route-subject.ts`, +`src/lab/live/destination.ts` endpoint fingerprint primitive): + +1. **`resolveRoutedProvider(config, provider, model)`** — reuse `routedProviderConfig` + discipline from `src/router.ts` (registry merge, baseUrl resolution, adapter pin). +2. **`resolveEffectiveWire(config, provider, model)`** — `resolveWireProtocolOverride` + with policy inbound default `openai-responses`. +3. **`upstreamProtocolForAdapter(adapter)`** — closed map matching Lab conventions + (`openai-responses`, `openai-chat`, `anthropic-messages`, …). +4. **`surfaceForRoute(inbound, upstream)`** — closed map (`responses-http`, …). +5. **`providerInstanceKey(provider, routed)`** — stable config-owner identity: + `providerId` + resolved `baseUrl` + effective `adapter` (no secrets). +6. **`endpointFingerprintFromBaseUrl(baseUrl)`** — **DNS-free** URL parse + + `localFingerprint("endpoint", {scheme, host, port, basePath})` (same algorithm as + `createLabDestination` snapshot, without address resolution). +7. **`resolveProductionBehaviorValues(...)`** — production resolver emitting closed + `LabBehaviorValues` with the same keys required by `buildBehaviorFingerprintV1`. +8. **`readOpenCodexCompatibilityVersion()`** — embedded/generated 64-hex manifest + hash per CL-00 §4 (not package marketing version). +9. **`buildRouteSubjectV1(routeContext, destinationSnapshot)`** — existing CL-03 + builder; routing supplies a frozen `LabDestinationV1`-shaped snapshot with + `addresses: []` (fingerprint-only; not used for network). + +`subjectIdForSubject(subject)` from `src/lab/digest.ts` is the lookup key. + +### Identity invariants + +- Adapter/config/endpoint/model-behavior changes → new `subjectId` → no evidence reuse. +- Credential rotation alone does not change subject (provider instance key excludes secrets). +- Routing consumption uses the **same** fingerprint algorithms as Lab evidence writers. + +## D. No routing-path side effects + +Production routing and dry-run **must not** synchronously: + +- run Compatibility Lab probes; +- run protocol conformance; +- run live-route tests; +- contact upstream for compatibility; +- perform Lab DNS resolution; +- execute Agent Fabric work; +- rebuild the Lab projection; +- replay the JSONL ledger; +- mutate Lab state. + +CL-06 reads an **existing** SQLite projection snapshot only. + +## E. Compatibility evidence read path + +### Bounded reader (`src/routing/compatibility/reader.ts`) + +- One `openLabReadConnection` per policy evaluation. +- One SQL query for all candidate subject IDs: + `SELECT … FROM verdicts WHERE subject_id IN (…)` (plus optional layer/suite filters). +- Returns a frozen `CompatibilityEvidenceSnapshot` passed into the pure evaluator. +- Fail-safe when projection missing/incompatible/corrupt: treat as **no evidence** + (follows `unknownEvidence`), never throw through routing. + +### Evaluator purity + +`evaluatePolicyProfile` remains pure: it receives `compatibility` evidence on each +`PolicyCandidateEvidence` assembled **before** evaluation. It does not open SQLite. + +### Missing/malformed evidence behavior + +| Condition | Routing behavior | +|---|---| +| Projection available, verdict row present | Evaluate normally | +| Projection missing | `unknownEvidence` policy per required suite | +| Projection incompatible | Same as missing (no rebuild) | +| Subject construction fails | `unknownEvidence` (exclude by default) | +| Suite verdict missing | `unknownEvidence` | +| Corrupt/unusable row ignored by projection | `unknownEvidence` | + +## F. Freshness + +Implemented per CL-00 §4 and §Freshness above. Reader supplies `asOf` from verdict +row; evaluator compares `now - asOf` against `effectiveMaxAgeMs` loaded from Lab +catalogue metadata (in-memory `queryLabCatalog`, not per-row SQLite). + +Stale positive verdicts are treated as **missing positive evidence** → `unknownEvidence` +path (not as current `PROBED`/`VERIFIED`). + +## G. Penalty semantics + +Penalties are **deterministic, bounded, and explainable** — separate from health/quota/cost. + +Constants (mirroring RI-06 unknown floors): + +| Policy | Effect | +|---|---| +| `unknownEvidence: allow` | No exclusion; no compatibility score component | +| `unknownEvidence: penalize` | Eligible; `score.components.compatibility = 0.3` | +| `unknownEvidence: exclude` | Hard exclusion `compatibility-unknown` | +| `degradedEvidence: allow` | No exclusion; no penalty | +| `degradedEvidence: penalize` | Eligible; `score.components.compatibility = 0.3` | +| `degradedEvidence: exclude` | Hard exclusion `compatibility-degraded` | + +`UNSUPPORTED` always excludes (`compatibility-unsupported`). Failed positive +threshold excludes (`compatibility-insufficient`). Stale positive excludes +(`compatibility-stale`). + +Compatibility weight is **not** added to `optimize` — penalty renormalizes like +health/quota unknown penalties (spent weight returns to `configuredPriority` share). + +## H. Routing flow (preserved ordering) + +```text +Routing Profile + → configured candidates + → hard capability gates (existing require + request evidence) + → compatibility requirements / penalties ← CL-06 + → eligible candidates + → health / quota / cost / latency scoring + → deterministic winner +``` + +Compatibility never bypasses capability hard gates. + +## I. RouteDecisionTraceV1 extensions + +Extend existing trace (no parallel Lab routing trace): + +```typescript +interface RouteCompatibilitySuiteTrace { + suiteId: string; + evidenceLayer: string; + verdict?: string; // observed + minStatus?: string; // threshold + fresh?: boolean; // freshness classification + unknownPolicy?: string; + degradedPolicy?: string; + outcome: "satisfied" | "penalized" | "excluded" | "unknown"; + reason?: string; // stable wire code, bounded +} + +interface RouteCompatibilityEvidence { + subjectId?: string; // truncated hash prefix optional for privacy + suites: RouteCompatibilitySuiteTrace[]; // max 8 suites +} +``` + +Added to `RouteCandidateTrace` as optional `compatibility?: RouteCompatibilityEvidence`. +Suite rows capped; overflow sets `truncated.compatibility`. Existing 16 KiB / 8 candidate / +16 exclusion bounds remain authoritative. + +## J. Dry-run parity + +`assembleCandidateEvidence` in `routing-profile-routes.ts` and `router.ts` call the +same `assemblePolicyCandidateEvidence(config, profile, now)` helper that attaches +compatibility snapshots. Dry-run and production evaluation share `evaluatePolicyProfile`. + +## K. Routing Profiles GUI + +Extend Models → Routing profile editor (`RoutingProfiles.tsx`): + +- Compact **Compatibility** section (not a second editor/store). +- Required suites: multi-select from `GET /api/lab/catalog` grouped by layer. +- `minStatus`, `maxEvidenceAgeMs`, `unknownEvidence`, `degradedEvidence` controls. +- Omitting compatibility on save preserves backward-compatible PUT body. + +CL-05 Compatibility Matrix tab remains read-only and unchanged. + +## L. Explicit non-goals + +CL-06 does **not** implement: Agent Fabric, task execution, task-effectiveness +producers, CL-07, automatic/background probing, shadow probes, public evidence +workflows, CL-08. + +--- + +## Implementation readiness review (pre-coding) + +Pressure-tested against CL-00/CL-05 codebase at `1072b9c`: + +| # | Risk | Resolution | +|---|---|---| +| 1 | Production route subject without DNS | **Accepted** — endpoint fingerprint uses URL parse only; same `localFingerprint` as Lab destination snapshot. DNS results are not part of `endpointFingerprint`. | +| 2 | Fingerprint parity Lab ↔ routing | **Accepted** — reuse `buildRouteSubjectV1`, `buildBehaviorFingerprintV1`, `subjectIdForSubject`; extract DNS-free endpoint helper from `destination.ts`. | +| 3 | Profiles without compatibility unchanged | **Accepted** — guard all CL-06 paths on `profile.compatibility` presence; regression tests required. | +| 4 | Revision backward compatibility | **Accepted** — omit empty compatibility from revision digest; test legacy profiles. | +| 5 | Stale VERIFIED passing | **Rejected risk** — freshness enforced in evaluator before positive threshold check. | +| 6 | Verdict semantics | **Accepted** — table above matches CL-00; CLAIMED/BLOCKED → unknown policy. | +| 7 | Penalize → universal score | **Rejected risk** — bounded per-dimension `compatibility` component only; no cross-layer collapse. | +| 8 | N+1 projection reads | **Rejected risk** — single `IN (subject_ids)` query per evaluation. | +| 9 | Missing projection crashes routing | **Rejected risk** — fail-safe reader returns empty snapshot. | +| 10 | Dry-run vs production divergence | **Rejected risk** — shared assembly helper. | +| 11 | Trace overflow | **Accepted** — suite cap + existing byte budget enforcement. | +| 12 | CL-07 leakage | **None** — no Fabric types, APIs, or task subjects in CL-06. | + +### Rejected alternatives + +- **Separate compatibility profile store** — rejected; violates CL-00 Routing Profiles boundary. +- **Provider/model verdict lookup** — rejected; weakens exact-route identity. +- **Synchronous projection rebuild on miss** — rejected; violates consumer boundary. +- **Universal compatibility weight in `optimize`** — rejected; not in CL-00 contract. + +### Design decisions + +- Policy inbound default `openai-responses` for subject construction (policy routes via Responses API). +- Compatibility catalogue metadata loaded once per evaluation for freshness ceilings. +- `compatibility` trace stores suite-level outcomes only (no raw subject JSON). + +--- + +## Implementation status + +| Area | Status | +|---|---| +| Plan frozen | ✅ This document | +| Types / profile validation | ✅ | +| Route subject resolver | ✅ | +| Evidence reader | ✅ | +| Evaluator + trace | ✅ | +| Management API + dry-run | ✅ | +| GUI editor | ✅ | +| Tests (25+ cases) | ✅ | +| Draft PR | 🔄 Pending push | + +## Validation checklist (pre-acceptance) + +- [x] `bun x tsc --noEmit` +- [x] `bun test tests/routing-compatibility.test.ts` +- [x] `bun test tests/routing-profile.test.ts tests/route-decision-trace.test.ts` +- [x] `bun test tests/lab-read-surfaces.test.ts` +- [x] GUI lint/build +- [x] `bun run privacy:scan` +- [ ] Cross-platform CI on draft PR diff --git a/gui/src/i18n/de.ts b/gui/src/i18n/de.ts index a53882bef9..52f0b0c6f0 100644 --- a/gui/src/i18n/de.ts +++ b/gui/src/i18n/de.ts @@ -35,6 +35,14 @@ export const de: Record = { "routing.optimize": "Optimierungsgewichte", "routing.limits": "Grenzen", "routing.unknownEvidence": "Richtlinie für unbekannte Evidenz", + "routing.compatibility.title": "Kompatibilitätsrichtlinie", + "routing.compatibility.enabled": "Compatibility-Lab-Evidenz erforderlich", + "routing.compatibility.requiredSuites": "Erforderliche Suites", + "routing.compatibility.loadingCatalog": "Lab-Katalog wird geladen…", + "routing.compatibility.catalogUnavailable": "Lab-Katalog nicht verfügbar — Suite-IDs in config.json manuell eintragen.", + "routing.compatibility.layer.protocol_conformance": "Protokollkonformität", + "routing.compatibility.layer.live_route_compatibility": "Live-Route-Kompatibilität", + "routing.compatibility.minStatus": "Mindest-Kompatibilitätsstatus", "routing.none": "keine", "routing.unavailable": "–", "routing.dryRun": "Trockenlauf-Bewertung", diff --git a/gui/src/i18n/en.ts b/gui/src/i18n/en.ts index 6d4347316f..34c76ca40b 100644 --- a/gui/src/i18n/en.ts +++ b/gui/src/i18n/en.ts @@ -78,6 +78,14 @@ export const en = { "routing.optimize": "Optimization weights", "routing.limits": "Limits", "routing.unknownEvidence": "Unknown evidence policy", + "routing.compatibility.title": "Compatibility policy", + "routing.compatibility.enabled": "Require Compatibility Lab evidence", + "routing.compatibility.requiredSuites": "Required suites", + "routing.compatibility.loadingCatalog": "Loading lab catalogue…", + "routing.compatibility.catalogUnavailable": "Lab catalogue unavailable — enter suite ids manually in config.json.", + "routing.compatibility.layer.protocol_conformance": "Protocol conformance", + "routing.compatibility.layer.live_route_compatibility": "Live route compatibility", + "routing.compatibility.minStatus": "Minimum compatibility status", "routing.none": "none", "routing.unavailable": "–", "routing.dryRun": "Dry-run evaluation", diff --git a/gui/src/i18n/ja.ts b/gui/src/i18n/ja.ts index 3770c07224..f55bfbfe9a 100644 --- a/gui/src/i18n/ja.ts +++ b/gui/src/i18n/ja.ts @@ -35,6 +35,14 @@ export const ja: Record = { "routing.optimize": "最適化ウェイト", "routing.limits": "制限", "routing.unknownEvidence": "不明なエビデンスのポリシー", + "routing.compatibility.title": "互換性ポリシー", + "routing.compatibility.enabled": "Compatibility Lab エビデンスを必須にする", + "routing.compatibility.requiredSuites": "必須スイート", + "routing.compatibility.loadingCatalog": "Lab カタログを読み込み中…", + "routing.compatibility.catalogUnavailable": "Lab カタログを利用できません — config.json でスイート ID を手動入力してください。", + "routing.compatibility.layer.protocol_conformance": "プロトコル適合", + "routing.compatibility.layer.live_route_compatibility": "ライブルート互換性", + "routing.compatibility.minStatus": "最低互換性ステータス", "routing.none": "なし", "routing.unavailable": "–", "routing.dryRun": "ドライラン評価", diff --git a/gui/src/i18n/ko.ts b/gui/src/i18n/ko.ts index 1021288915..cdada2c5bc 100644 --- a/gui/src/i18n/ko.ts +++ b/gui/src/i18n/ko.ts @@ -35,6 +35,14 @@ export const ko: Record = { "routing.optimize": "최적화 가중치", "routing.limits": "제한", "routing.unknownEvidence": "알 수 없는 증거 정책", + "routing.compatibility.title": "호환성 정책", + "routing.compatibility.enabled": "Compatibility Lab 증거 필요", + "routing.compatibility.requiredSuites": "필수 스위트", + "routing.compatibility.loadingCatalog": "Lab 카탈로그 로드 중…", + "routing.compatibility.catalogUnavailable": "Lab 카탈로그를 사용할 수 없습니다 — config.json에서 스위트 ID를 수동으로 입력하세요.", + "routing.compatibility.layer.protocol_conformance": "프로토콜 적합성", + "routing.compatibility.layer.live_route_compatibility": "라이브 라우트 호환성", + "routing.compatibility.minStatus": "최소 호환성 상태", "routing.none": "없음", "routing.unavailable": "–", "routing.dryRun": "드라이런 평가", diff --git a/gui/src/i18n/ru.ts b/gui/src/i18n/ru.ts index 2f9fc958d1..0f9da35f4d 100644 --- a/gui/src/i18n/ru.ts +++ b/gui/src/i18n/ru.ts @@ -35,6 +35,14 @@ export const ru: Record = { "routing.optimize": "Веса оптимизации", "routing.limits": "Лимиты", "routing.unknownEvidence": "Политика неизвестных данных", + "routing.compatibility.title": "Политика совместимости", + "routing.compatibility.enabled": "Требовать доказательства Compatibility Lab", + "routing.compatibility.requiredSuites": "Обязательные наборы", + "routing.compatibility.loadingCatalog": "Загрузка каталога Lab…", + "routing.compatibility.catalogUnavailable": "Каталог Lab недоступен — укажите id наборов вручную в config.json.", + "routing.compatibility.layer.protocol_conformance": "Соответствие протоколу", + "routing.compatibility.layer.live_route_compatibility": "Совместимость живого маршрута", + "routing.compatibility.minStatus": "Минимальный статус совместимости", "routing.none": "нет", "routing.unavailable": "–", "routing.dryRun": "Пробная оценка", diff --git a/gui/src/i18n/tr.ts b/gui/src/i18n/tr.ts index 6fd435e533..34db3fda6e 100644 --- a/gui/src/i18n/tr.ts +++ b/gui/src/i18n/tr.ts @@ -77,6 +77,14 @@ export const tr: Record = { "routing.optimize": "Optimizasyon ağırlıkları", "routing.limits": "Limitler", "routing.unknownEvidence": "Bilinmeyen kanıt politikası", + "routing.compatibility.title": "Uyumluluk politikası", + "routing.compatibility.enabled": "Compatibility Lab kanıtı gerekli", + "routing.compatibility.requiredSuites": "Gerekli paketler", + "routing.compatibility.loadingCatalog": "Lab kataloğu yükleniyor…", + "routing.compatibility.catalogUnavailable": "Lab kataloğu kullanılamıyor — suite kimliklerini config.json içinde elle girin.", + "routing.compatibility.layer.protocol_conformance": "Protokol uyumu", + "routing.compatibility.layer.live_route_compatibility": "Canlı rota uyumluluğu", + "routing.compatibility.minStatus": "Minimum uyumluluk durumu", "routing.none": "yok", "routing.unavailable": "–", "routing.dryRun": "Simülasyon değerlendirmesi", diff --git a/gui/src/i18n/zh.ts b/gui/src/i18n/zh.ts index 2ae8b6ac2c..3c8211b531 100644 --- a/gui/src/i18n/zh.ts +++ b/gui/src/i18n/zh.ts @@ -35,6 +35,14 @@ export const zh: Record = { "routing.optimize": "优化权重", "routing.limits": "限制", "routing.unknownEvidence": "未知证据策略", + "routing.compatibility.title": "兼容性策略", + "routing.compatibility.enabled": "要求 Compatibility Lab 证据", + "routing.compatibility.requiredSuites": "必需套件", + "routing.compatibility.loadingCatalog": "正在加载 Lab 目录…", + "routing.compatibility.catalogUnavailable": "Lab 目录不可用 — 请在 config.json 中手动输入套件 ID。", + "routing.compatibility.layer.protocol_conformance": "协议一致性", + "routing.compatibility.layer.live_route_compatibility": "实时路由兼容性", + "routing.compatibility.minStatus": "最低兼容性状态", "routing.none": "无", "routing.unavailable": "–", "routing.dryRun": "试运行评估", diff --git a/gui/src/pages/RoutingProfiles.tsx b/gui/src/pages/RoutingProfiles.tsx index 56543c8919..9f721f34d7 100644 --- a/gui/src/pages/RoutingProfiles.tsx +++ b/gui/src/pages/RoutingProfiles.tsx @@ -12,6 +12,7 @@ import { type RoutingProfileDraft, type RoutingProfileDto, type UnknownEvidenceMode, + type CompatibilitySuiteDraft, } from "../routing-profile-editor-data"; import { readJsonIfOk } from "../fetch-json"; import { Notice } from "../ui"; @@ -70,6 +71,41 @@ const OPTIMIZE_KEYS = ["latency", "health", "cost", "quota"] as const; const UNKNOWN_EVIDENCE_KEYS = ["capability", "health", "quota", "cost"] as const; const UNKNOWN_EVIDENCE_OPTIONS: UnknownEvidenceMode[] = ["allow", "penalize", "exclude"]; +type LabCatalogScenario = { + suiteId: string; + evidenceLayer: "protocol_conformance" | "live_route_compatibility"; +}; + +type LabCatalogSuiteOption = CompatibilitySuiteDraft & { key: string }; + +function catalogSuiteKey(suite: CompatibilitySuiteDraft): string { + return `${suite.evidenceLayer}:${suite.suiteId}`; +} + +function uniqueCatalogSuites(scenarios: LabCatalogScenario[]): LabCatalogSuiteOption[] { + const seen = new Set(); + const suites: LabCatalogSuiteOption[] = []; + for (const scenario of scenarios) { + const key = catalogSuiteKey(scenario); + if (seen.has(key)) continue; + seen.add(key); + suites.push({ suiteId: scenario.suiteId, evidenceLayer: scenario.evidenceLayer, key }); + } + return suites.sort((a, b) => { + const layerCmp = a.evidenceLayer.localeCompare(b.evidenceLayer); + if (layerCmp !== 0) return layerCmp; + return a.suiteId.localeCompare(b.suiteId); + }); +} + +function suiteSelected( + requiredSuites: CompatibilitySuiteDraft[], + suite: CompatibilitySuiteDraft, +): boolean { + return requiredSuites.some(row => + row.suiteId === suite.suiteId && row.evidenceLayer === suite.evidenceLayer); +} + function fmtMs(value: number | undefined, unavailable: string): string { return value === undefined ? unavailable : `${Math.round(value)}ms`; } @@ -174,6 +210,8 @@ export default function RoutingProfiles({ const [dryRunResult, setDryRunResult] = useState(null); const [dryRunError, setDryRunError] = useState(""); const [running, setRunning] = useState(false); + const [catalogSuites, setCatalogSuites] = useState([]); + const [catalogError, setCatalogError] = useState(false); const selectedRef = useRef(null); const loadGenerationRef = useRef(0); /** Owned by `load` so every entry point — mount, Retry, save, delete — is cancellable. */ @@ -245,18 +283,22 @@ export default function RoutingProfiles({ const generation = ++loadGenerationRef.current; setLoadError(""); try { - const [profilesRes, analyticsRes, configRes, modelsRes] = await Promise.all([ + const [profilesRes, analyticsRes, configRes, modelsRes, catalogRes] = await Promise.all([ fetch(`${apiBase}/api/routing-profiles`, { signal }), fetch(`${apiBase}/api/routing-analytics`, { signal }), fetch(`${apiBase}/api/config`, { signal }), fetch(`${apiBase}/api/models`, { signal }), + fetch(`${apiBase}/api/lab/catalog`, { signal }), ]); if (!profilesRes.ok) throw new Error(`load-${profilesRes.status}`); - const [profilesJson, analyticsJson, configJson, modelsJson] = await Promise.all([ + const [profilesJson, analyticsJson, configJson, modelsJson, catalogJson] = await Promise.all([ profilesRes.json() as Promise, analyticsRes.ok ? analyticsRes.json() as Promise : Promise.resolve(null), configRes.ok ? configRes.json() as Promise : Promise.resolve({} as ConfigDto), modelsRes.ok ? modelsRes.json() as Promise : Promise.resolve([]), + catalogRes.ok + ? catalogRes.json() as Promise<{ scenarios?: LabCatalogScenario[] }> + : Promise.resolve(null), ]); if (generation !== loadGenerationRef.current) return; @@ -282,6 +324,13 @@ export default function RoutingProfiles({ setProviderNames(nextProviderNames); setProviderDefaults(nextDefaults); setModels(parseModels(modelsJson)); + if (catalogJson && Array.isArray(catalogJson.scenarios)) { + setCatalogSuites(uniqueCatalogSuites(catalogJson.scenarios)); + setCatalogError(false); + } else { + setCatalogSuites([]); + setCatalogError(true); + } if (!current || !refreshed || current.id !== refreshed.id || current.revision !== refreshed.revision) { clearDryRun(); } @@ -741,6 +790,131 @@ export default function RoutingProfiles({ +
+ {t("routing.compatibility.title")} + + {draft.compatibility.enabled ? ( +
+
+ {t("routing.compatibility.requiredSuites")} + {catalogError ? ( +
+ {t("routing.compatibility.catalogUnavailable")} +
+ ) : null} + {catalogSuites.length > 0 ? ( +
+ {catalogSuites.map(suite => ( + + ))} +
+ ) : null} +
+ + + + +
+ ) : null} +
+
@@ -676,47 +600,33 @@ export default function RoutingProfiles({ {NUMERIC_REQUIREMENTS.map(key => ( ))} {STRING_REQUIREMENTS.map(key => ( ))} {BOOLEAN_REQUIREMENTS.map(key => ( ))} @@ -753,17 +656,11 @@ export default function RoutingProfiles({ {t("routing.limits")} @@ -773,17 +670,11 @@ export default function RoutingProfiles({ {UNKNOWN_EVIDENCE_KEYS.map(key => ( {draft.compatibility.enabled ? ( @@ -819,28 +707,18 @@ export default function RoutingProfiles({
{catalogSuites.map(suite => ( ))} @@ -849,66 +727,46 @@ export default function RoutingProfiles({
@@ -600,33 +678,47 @@ export default function RoutingProfiles({ {NUMERIC_REQUIREMENTS.map(key => ( ))} {STRING_REQUIREMENTS.map(key => ( ))} {BOOLEAN_REQUIREMENTS.map(key => ( ))} @@ -656,11 +755,17 @@ export default function RoutingProfiles({ {t("routing.limits")} @@ -670,11 +775,17 @@ export default function RoutingProfiles({ {UNKNOWN_EVIDENCE_KEYS.map(key => ( {draft.compatibility.enabled ? ( @@ -707,18 +821,28 @@ export default function RoutingProfiles({
{catalogSuites.map(suite => ( ))} @@ -727,14 +851,17 @@ export default function RoutingProfiles({