diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index d0d017f1da..01ceb64eee 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -294,10 +294,10 @@ jobs: bun run build - name: Test - run: bun test --isolate tests --path-ignore-patterns 'tests/api-storage-policy*.test.ts' --path-ignore-patterns 'tests/api-usage.test.ts' --shard=${{ matrix.shard }}/4 + run: bun test --isolate tests --path-ignore-patterns 'tests/api-storage-policy*.test.ts' --path-ignore-patterns 'tests/api-storage.test.ts' --path-ignore-patterns 'tests/api-usage.test.ts' --shard=${{ matrix.shard }}/4 # Bun 1.3.14 has shown a Linux isolate/epoll race around the storage-policy - # harness. Keep the entire five-file family in one fresh process so a runtime + # harness. Keep the entire six-file family in one fresh process so a runtime # failure is bounded to this job instead of poisoning a general test shard. storage-policy: name: storage policy @@ -334,7 +334,8 @@ jobs: ./tests/api-storage-policy-mutation-busy.test.ts \ ./tests/api-storage-policy-put-race.test.ts \ ./tests/api-storage-policy-run.test.ts \ - ./tests/api-storage-policy.test.ts + ./tests/api-storage-policy.test.ts \ + ./tests/api-storage.test.ts # Bun 1.3.14 has shown a Linux isolate wedge around startServer() plus the user # cost overlay reconciler. Keep api-usage in one fresh process so a runtime diff --git a/.gitignore b/.gitignore index 82fb882004..dbeb798ee2 100644 --- a/.gitignore +++ b/.gitignore @@ -4,6 +4,10 @@ dist/ *.log .DS_Store +# Generated CL compatibility identity embedded by prepare:package. It must stay +# untracked so the manifest cannot hash itself. +src/generated/compatibility-version.json + # Maintainer planning notes are TRACKED in this repository (`devlog/`). Security # material is not: see the "Security working notes" section of AGENTS.md. # 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..6601fbb29e 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` | `b96eae83f2a6d1654472aeeef84799070743aeb8` | [#1394](https://github.com/lidge-jun/opencodex/pull/1394) | DRAFT / PENDING CI | 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 31e8557466..60871d54d4 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", @@ -53,8 +61,8 @@ export const de: Record = { "routing.capOutcome.unknown-excluded": "unbekannt (ausgeschlossen)", "routing.exclusion.capability-unsatisfied": "Anforderung nicht erfüllt", "routing.exclusion.unknown-capability": "unbekannte Fähigkeit", - "routing.exclusion.cost-limit": "über Kostenobergrenze", - "routing.exclusion.cost-limit-unknown": "unbekannte Kosten unter Obergrenze", + "routing.exclusion.cost-limit": "Kostenobergrenze überschritten", + "routing.exclusion.cost-limit-unknown": "Kosten unbekannt — Obergrenze nicht prüfbar", "routing.exclusion.cooldown": "Abklingzeit", "routing.exclusion.unknown-health": "unbekannter Zustand", "routing.exclusion.unknown-quota": "unbekanntes Kontingent", diff --git a/gui/src/i18n/en.ts b/gui/src/i18n/en.ts index 2dc6a0793a..34d9b28758 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 catalog…", + "routing.compatibility.catalogUnavailable": "Lab catalog 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 c097ec8b43..9ce146ee0d 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 d81b154313..9830d9c8d7 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/routing-compatibility-labels.ts b/gui/src/i18n/routing-compatibility-labels.ts new file mode 100644 index 0000000000..85a45173b4 --- /dev/null +++ b/gui/src/i18n/routing-compatibility-labels.ts @@ -0,0 +1,51 @@ +import type { Locale } from "./catalogs"; + +type CompatibilityFieldLabels = { + maxEvidenceAgeMs: string; + unknownEvidence: string; + degradedEvidence: string; +}; + +/** + * Small closed translation surface for the CL-06 compatibility editor fields. + * Keeping all supported locales together prevents raw config keys from leaking + * into the UI without widening the compile-checked base catalog for three + * CL-06-only labels. + */ +export const ROUTING_COMPATIBILITY_FIELD_LABELS: Record = { + en: { + maxEvidenceAgeMs: "Maximum evidence age (ms)", + unknownEvidence: "Unknown evidence", + degradedEvidence: "Degraded evidence", + }, + de: { + maxEvidenceAgeMs: "Maximales Evidenzalter (ms)", + unknownEvidence: "Unbekannte Evidenz", + degradedEvidence: "Eingeschränkte Evidenz", + }, + ko: { + maxEvidenceAgeMs: "최대 증거 유효 기간 (ms)", + unknownEvidence: "알 수 없는 증거", + degradedEvidence: "저하된 증거", + }, + zh: { + maxEvidenceAgeMs: "证据最大有效期(毫秒)", + unknownEvidence: "未知证据", + degradedEvidence: "降级证据", + }, + ru: { + maxEvidenceAgeMs: "Максимальный возраст доказательств (мс)", + unknownEvidence: "Неизвестные доказательства", + degradedEvidence: "Ухудшенные доказательства", + }, + ja: { + maxEvidenceAgeMs: "エビデンスの最大有効期間 (ms)", + unknownEvidence: "不明なエビデンス", + degradedEvidence: "低下したエビデンス", + }, + tr: { + maxEvidenceAgeMs: "Maksimum kanıt yaşı (ms)", + unknownEvidence: "Bilinmeyen kanıt", + degradedEvidence: "Bozulmuş kanıt", + }, +}; diff --git a/gui/src/i18n/ru.ts b/gui/src/i18n/ru.ts index 07089f6e4d..ef74895531 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 546aebead9..0470d736da 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 test süitleri", + "routing.compatibility.loadingCatalog": "Lab kataloğu yükleniyor…", + "routing.compatibility.catalogUnavailable": "Lab kataloğu kullanılamıyor — test süiti 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", @@ -96,7 +104,7 @@ export const tr: Record = { "routing.exclusion.capability-unsatisfied": "yetenek karşılanmadı", "routing.exclusion.unknown-capability": "bilinmeyen yetenek", "routing.exclusion.cost-limit": "maliyet tavanı aşıldı", - "routing.exclusion.cost-limit-unknown": "tavan altında bilinmeyen maliyet", + "routing.exclusion.cost-limit-unknown": "maliyet tavanı doğrulanamadı", "routing.exclusion.cooldown": "soğuma süresi", "routing.exclusion.unknown-health": "bilinmeyen sağlık", "routing.exclusion.unknown-quota": "bilinmeyen kota", diff --git a/gui/src/i18n/zh.ts b/gui/src/i18n/zh.ts index 71c41897a0..c1297c95e9 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": "试运行评估", @@ -54,7 +62,7 @@ export const zh: Record = { "routing.exclusion.capability-unsatisfied": "能力未满足", "routing.exclusion.unknown-capability": "能力未知", "routing.exclusion.cost-limit": "超出成本上限", - "routing.exclusion.cost-limit-unknown": "上限下成本未知", + "routing.exclusion.cost-limit-unknown": "无法确认成本是否在上限内", "routing.exclusion.cooldown": "冷却中", "routing.exclusion.unknown-health": "健康状态未知", "routing.exclusion.unknown-quota": "配额未知", diff --git a/gui/src/pages/RoutingProfiles.tsx b/gui/src/pages/RoutingProfiles.tsx index b6a19ac3ce..437371752d 100644 --- a/gui/src/pages/RoutingProfiles.tsx +++ b/gui/src/pages/RoutingProfiles.tsx @@ -13,10 +13,13 @@ import { type RoutingProfileDto, type UnknownCostCapMode, type UnknownEvidenceMode, + type CompatibilitySuiteDraft, + normalizeCompatibilityDto, } from "../routing-profile-editor-data"; import { readJsonIfOk } from "../fetch-json"; import { Notice } from "../ui"; -import { useT } from "../i18n/shared"; +import { useI18n, useT } from "../i18n/shared"; +import { ROUTING_COMPATIBILITY_FIELD_LABELS } from "../i18n/routing-compatibility-labels"; type DryRunCandidate = { provider: string; @@ -73,6 +76,48 @@ const UNKNOWN_EVIDENCE_KEYS = ["capability", "health", "quota", "cost"] as const const UNKNOWN_EVIDENCE_OPTIONS: UnknownEvidenceMode[] = ["allow", "penalize", "exclude"]; const UNKNOWN_COST_CAP_OPTIONS: UnknownCostCapMode[] = ["allow", "exclude"]; +type LabCatalogScenario = CompatibilitySuiteDraft; +type LabCatalogSuiteOption = CompatibilitySuiteDraft & { key: string }; + +function catalogSuiteKey(suite: CompatibilitySuiteDraft): string { + return `${suite.evidenceLayer}:${suite.suiteId}`; +} + +function isPlainObject(value: unknown): value is Record { + return !!value && typeof value === "object" && !Array.isArray(value); +} + +function uniqueCatalogSuites(scenarios: unknown[]): LabCatalogSuiteOption[] { + const seen = new Set(); + const suites: LabCatalogSuiteOption[] = []; + for (const scenario of scenarios) { + if (!isPlainObject(scenario)) continue; + const suiteId = typeof scenario.suiteId === "string" ? scenario.suiteId.trim() : ""; + const evidenceLayer = scenario.evidenceLayer; + if (!suiteId || (evidenceLayer !== "protocol_conformance" && evidenceLayer !== "live_route_compatibility")) { + continue; + } + const suite: LabCatalogScenario = { suiteId, evidenceLayer }; + const key = catalogSuiteKey(suite); + if (seen.has(key)) continue; + seen.add(key); + suites.push({ ...suite, 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`; } @@ -123,9 +168,6 @@ function fmtExclusion(code: string, t: ReturnType): string { } } -function isPlainObject(value: unknown): value is Record { - return !!value && typeof value === "object" && !Array.isArray(value); -} function parseProfiles(raw: unknown): RoutingProfileDto[] { if (!raw || typeof raw !== "object" || Array.isArray(raw)) return []; @@ -144,7 +186,18 @@ function parseProfiles(raw: unknown): RoutingProfileDto[] { && isPlainObject(profile.optimize) && isPlainObject(profile.limits) && isPlainObject(profile.unknownEvidence); - }).map(profile => ({ ...profile, alias: profile.alias ?? null })); + }).map(profile => { + const compatibility = normalizeCompatibilityDto( + "compatibility" in profile ? profile.compatibility : undefined, + ); + const rest = { ...profile }; + delete rest.compatibility; + return { + ...rest, + alias: profile.alias ?? null, + ...(compatibility ? { compatibility } : {}), + }; + }); } function parseModels(raw: unknown): ModelOption[] { @@ -200,7 +253,8 @@ export default function RoutingProfiles({ /** Reports the profile count up to the tab strip. */ onCountChange?: (count: number) => void; }) { - const t = useT(); + const { locale, t } = useI18n(); + const compatibilityFieldLabels = ROUTING_COMPATIBILITY_FIELD_LABELS[locale]; const unavailable = t("routing.unavailable"); const [profiles, setProfiles] = useState([]); const [analytics, setAnalytics] = useState(null); @@ -219,6 +273,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. */ @@ -290,18 +346,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?: unknown[] }>).catch(() => null) + : Promise.resolve(null), ]); if (generation !== loadGenerationRef.current) return; @@ -327,6 +387,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(); } @@ -804,6 +871,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} +
+