diff --git a/README.md b/README.md index f663fb97072..4fdc0868de3 100644 --- a/README.md +++ b/README.md @@ -345,6 +345,20 @@ Omit the `provider/` prefix to use the default provider or auto-match by model n Provider model ids containing `/` are exposed with inner slashes aliased to `-`; the raw full-slash form keeps working too. Details: [model routing docs](https://opencodex.me/guides/model-routing/). +### JEV Auto routing (optional) + +TypeSafe JEV can choose the first model and reasoning effort for an opt-in Combo while the normal +model picker and every direct route stay unchanged. Add the credential with `ocx login jev`, from +**Providers → TypeSafe JEV → Add API key**, or through `TYPESAFE_API_KEY`/`JEV_API_KEY`. Then open +**Models → Combos → Create JEV Auto**, choose the allowed target models, and check the exact efforts +JEV may select for each target. Leaving a target's effort setting untouched allows all efforts that +model currently advertises. + +JEV is consulted only for `jev-auto` and only once per logical model call. Missing credentials, +network failures, or invalid decisions fail open to the first currently eligible target; caller +cancellation still cancels the request. Automated tests use a mocked TypeSafe endpoint and do not +validate a live JEV account. + ## Providers & adapters diff --git a/devlog/_fin/260921_jev_auto_routing/010_plan.md b/devlog/_fin/260921_jev_auto_routing/010_plan.md new file mode 100644 index 00000000000..bd63a81fb71 --- /dev/null +++ b/devlog/_fin/260921_jev_auto_routing/010_plan.md @@ -0,0 +1,256 @@ +# JEV Auto Routing Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Add one optional `jev-auto` model that asks TypeSafe JEV to choose the initial OpenCodex Combo target and reasoning effort, while preserving every existing model and all existing Combo fallback behavior. + +**Architecture:** Extend Combo with a `jev` strategy. A small native TypeScript decision module builds the bounded JEV state and joint target/effort question, calls the fixed TypeSafe endpoint with the configured JEV credential, validates the answer, and returns either an eligible initial pick or a deterministic fail-open pick. The existing Combo dispatcher remains responsible for eligibility, cooldowns, quota state, concrete routing, retries, and subsequent fallback attempts. A registry-only JEV provider row owns key setup without publishing a routable model. The GUI adds JEV to the existing Combo editor and provides a prefilled `jev-auto` action whose target list remains fully editable. + +**Tech Stack:** Bun, TypeScript, OpenCodex Combo runtime, provider registry/management API, React/Vite GUI, Bun test runner. + +**Spec:** `docs/superpowers/specs/2026-09-21-jev-auto-routing-design.md` + +## Global Constraints + +- Existing public model ids, aliases, picker rows, defaults, and direct routing must remain unchanged. +- `jev-auto` is opt-in and is never synthesized until the operator creates the JEV Combo. +- JEV chooses once per logical model call. Existing Combo logic alone owns later failover. +- Candidate models come only from the configured Combo target allowlist and must pass existing eligibility checks before they are offered to JEV. +- Missing credentials, timeout, redirect, non-2xx, malformed JSON, invalid choices, and empty usable candidate sets fail open to the first existing eligible Combo pick. +- Caller cancellation propagates; it must not be converted into fail-open dispatch. +- TypeSafe calls use `https://api.typesafe.ai/v1/systemone`, model `jev-latest`, a four-second deadline, manual redirect handling, one attempt, and a bounded response body. +- JEV request state is bounded and excludes secrets, raw images, tool arguments, headers, encrypted reasoning, and full conversation history. +- Observability may contain only the selected target, effort, gate/reason, latency, confidence/probability, and numeric usage. It must never contain the JEV key or decision state. +- All TypeSafe coverage is mocked. A live smoke is explicitly deferred until the user supplies a key. +- New test files must be registered in `scripts/test-layout/layout.json` and `tests/fixtures/test-layout-expected.json`. +- Visible GUI strings must be added to every locale in `gui/src/i18n/`. + +## Review Focus + +- A hostile JEV response cannot select a target or effort outside the eligible, configured choice map. +- A caller abort during the JEV call ends the request as cancellation and never dispatches the fail-open target. +- A JEV outage cannot suppress the request or alter existing direct-model routing. +- The selected effort is clamped/omitted through the existing target capability ladder and caller `service_tier` is removed for the JEV-selected initial child only; fallback children rebuild from the original request under ordinary Combo rules. +- The JEV provider row stores credentials but emits no direct model/catalog row and can never be selected as a Combo target. +- After a JEV-selected target fails retryably, existing cooldown and fallback ordering continue without a second JEV call. + +--- + +## Task 1: Add the JEV Combo strategy and pure decision contract + +**Files:** + +- Create: `src/combos/jev.ts` +- Modify: `src/types/config.ts` +- Modify: `src/combos/types.ts` +- Modify: `src/combos/index.ts` +- Modify: `src/cli/combo.ts` +- Modify: `tests/codex-integration/combos.test.ts` +- Modify: `tests/cli/cli-headless-parity.test.ts` +- Create: `tests/routing/jev-decision.test.ts` +- Modify: `scripts/test-layout/layout.json` +- Modify: `tests/fixtures/test-layout-expected.json` + +**Interfaces produced:** + +```ts +export interface JevCandidate { + key: string; + provider: string; + model: string; + reasoningEfforts: readonly OcxComboDefaultEffort[]; +} + +export interface JevDecision { + targetKey: string; + effort: OcxComboDefaultEffort | null; + gate: "apply" | "missing_key" | "no_choices" | "timeout" | "network" | "redirect" | "http" | "malformed" | "invalid"; + latencyMs: number; + confidence?: number; + chosenProbability?: number; + usage?: Record; +} + +export function buildJevState(body: unknown): Record; +export function buildJevRouteQuestion(candidates: readonly JevCandidate[]): Record; +export function parseJevDecision(payload: unknown, candidates: readonly JevCandidate[]): Pick; +``` + +- [ ] Add focused failing Combo and CLI tests proving `strategy: "jev"` validates, normalizes, round-trips, falls back to configured order in the synchronous picker, and is accepted by `ocx combo set`. Run `bun test tests/codex-integration/combos.test.ts tests/cli/cli-headless-parity.test.ts`; expect assertions to fail because `jev` is rejected or normalized to `failover`. +- [ ] Extend `OcxComboStrategy`, validation text, normalization, Combo exports, and CLI `--strategy` parsing/help with `jev`. Re-run the focused test; expect it to pass. +- [ ] Add failing pure tests for bounded current-user extraction, envelope removal, recent assistant intent, last tool-output tail/name, image presence, literal choice-map construction, known Luna/Sol/Astra profiles, neutral arbitrary-target profiles, valid response parsing, complete probability validation, invalid/out-of-allowlist choices, malformed confidence, and numeric-only usage extraction. Run `bun test tests/routing/jev-decision.test.ts`; expect an import failure because `src/combos/jev.ts` does not exist. +- [ ] Implement only the pure state/question/parser pieces in `src/combos/jev.ts`. Keep state caps aligned with the reference router: 500-character head/tail current ask, 240-character assistant tail, and 520-character tool-output tail. Re-run the new tests; expect all to pass. +- [ ] Register the test file in both test-layout manifests, run `bun test tests/test-layout.test.ts tests/test-layout-tooling.test.ts`, then run `bun run typecheck`. Expect exit code 0. +- [ ] Stage the Task 1 files and commit with `git commit -m "feat: add JEV combo decision contract"`. + +## Task 2: Add registry-backed JEV credential setup and the secure TypeSafe client + +**Files:** + +- Modify: `src/providers/registry/entries-extended.ts` +- Modify: `src/providers/derive.ts` only if the empty-model decision-service row needs a narrow projection adjustment +- Modify: `src/combos/jev.ts` +- Modify: `src/server/management/provider-routes.ts` +- Create: `tests/providers/jev-provider.test.ts` +- Modify: `tests/server/management-provider-validation.test.ts` +- Modify: `tests/providers/provider-registry-parity.test.ts` +- Modify: `scripts/test-layout/layout.json` +- Modify: `tests/fixtures/test-layout-expected.json` + +**Interfaces produced:** + +```ts +export const JEV_PROVIDER_ID = "jev"; +export const JEV_API_URL = "https://api.typesafe.ai/v1/systemone"; +export const JEV_MODEL = "jev-latest"; + +export interface ResolveJevDecisionOptions { + body: unknown; + candidates: readonly JevCandidate[]; + fallback: { targetKey: string; effort: OcxComboDefaultEffort | null }; + config: OcxConfig; + signal?: AbortSignal; + post?: typeof providerOutboundPost; + now?: () => number; +} + +export function resolveJevDecision(options: ResolveJevDecisionOptions): Promise; +``` + +- [ ] Add a failing provider test proving the registry exposes a paid key-auth `jev` preset with the fixed endpoint, no models/default model, and `liveModels: false`; prove `fetchProviderModelsWithAuth` emits no JEV catalog row. Run `bun test tests/providers/jev-provider.test.ts`; expect no preset. +- [ ] Add the `jev` registry entry (`adapter: "jev-decision"`, `preserveCustomDestination: true`, TypeSafe dashboard/docs URL, no model roster) and make only the minimum projection adjustment required. Re-run the provider test; expect it to pass. +- [ ] Add failing client tests using an injected POST boundary. Cover configured key, `${TYPESAFE_API_KEY}`/environment fallback, exact endpoint/model/auth headers/body, four-second timeout, manual redirect refusal, non-2xx, oversized body, invalid JSON, invalid decision, and caller cancellation. Assert returned decisions rather than mock call counts except where endpoint/auth/body are the contract. Run `bun test tests/routing/jev-decision.test.ts`; expect client cases to fail because `resolveJevDecision` is absent. +- [ ] Implement the secure client with `resolveProviderApiKey`, environment fallback, `providerOutboundPost`, `providerRedirectError`, `readBoundedResponseBytes`, `AbortSignal.timeout(4000)`, and one request only. Re-run the client tests; expect all to pass. +- [ ] Add a failing management test for `POST /api/providers/test?name=jev`: missing key returns a sanitized failure; a mocked valid one-choice JEV answer returns connected; upstream body text is never echoed. Run `bun test tests/server/management-provider-validation.test.ts`; expect the static-catalog not-applicable result. +- [ ] Add the narrow JEV connection-probe branch before the generic static-catalog branch and reuse the same bounded client. Re-run the management test, provider test, layout tests, and `bun run typecheck`; expect exit code 0. +- [ ] Stage the Task 2 files and commit with `git commit -m "feat: add TypeSafe JEV provider setup"`. + +## Task 3: Route Combo first picks through JEV without replacing fallback + +**Files:** + +- Modify: `src/combos/jev.ts` +- Modify: `src/server/responses/core-combo.ts` +- Modify: `src/server/responses/core-options.ts` +- Create: `tests/server/server-jev-combo-e2e.test.ts` +- Modify: `scripts/test-layout/layout.json` +- Modify: `tests/fixtures/test-layout-expected.json` + +**Interfaces consumed:** Task 1's strict choice map/parser and Task 2's `resolveJevDecision` client. + +- [ ] Add a failing server test that configures an aliased `jev-auto` Combo, injects a successful JEV answer selecting the second target at `high`, and proves only that target receives the request, with forced/clamped `reasoning.effort` and no caller `service_tier`. Assert the served catalog has one public `jev-auto` row and still contains unchanged direct-model rows. Run `bun test tests/server/server-jev-combo-e2e.test.ts`; expect the first configured target to receive the request. +- [ ] Add a small helper that enumerates currently eligible `jev` targets in configured order without marking them all attempted, asks JEV once, and rebuilds the selected `ComboPick` with only the chosen target in `attempted`. Integrate it immediately after the existing initial `pickWithWait`; keep the loop and `advanceComboAfterFailure` unchanged. Re-run the focused test; expect it to pass. +- [ ] Add failing cases for: missing key fail-open to first eligible at medium; invalid JEV choice fail-open; selected target retryable failure then existing fallback with no second JEV call and with the original caller effort/tier restored; cooled/disabled targets omitted from choices; explicit empty target effort ladder omitted/stripped; caller abort during JEV returns 499 and sends no model request. Run the focused test and inspect each expected failure. +- [ ] Implement the minimum runtime behavior for those cases. Apply the JEV effort and remove `service_tier` only on the selected initial child. If that child fails, rebuild every fallback from the untouched original request with the Combo's ordinary effort/tier behavior. Emit one sanitized structured debug event for the decision. Re-run the focused test plus `bun test tests/routing/combo-management-api.test.ts tests/codex-integration/combos.test.ts`; expect all to pass. +- [ ] Register the new test file, run layout tests and `bun run typecheck`; expect exit code 0. +- [ ] Stage the Task 3 files and commit with `git commit -m "feat: route jev-auto through combo runtime"`. + +## Task 4: Add the editable JEV Auto GUI flow + +**Files:** + +- Modify: `gui/src/combo-workspace-data.ts` +- Modify: `gui/src/components/combo-workspace-controls.tsx` +- Modify: `gui/src/components/combo-workspace-add-modal.tsx` +- Modify: `gui/src/components/ComboWorkspace.tsx` +- Modify: `gui/src/components/combo-workspace-types.ts` +- Modify: `gui/src/pages/Combos.tsx` only if the prefilled-add state belongs at the page boundary +- Modify: `gui/src/components/provider-workspace/ProviderOverview.tsx` +- Modify: `gui/src/components/provider-workspace/ProviderDetails.tsx` +- Modify: `gui/src/pages/Providers.tsx` +- Modify: `gui/src/hash-routing.ts` +- Modify: `gui/src/pages/models-tab.ts` +- Modify: `gui/src/i18n/en.ts` +- Modify: `gui/src/i18n/de.ts` +- Modify: `gui/src/i18n/fr.ts` +- Modify: `gui/src/i18n/ja.ts` +- Modify: `gui/src/i18n/ko.ts` +- Modify: `gui/src/i18n/ru.ts` +- Modify: `gui/src/i18n/tr.ts` +- Modify: `gui/src/i18n/vi.ts` +- Modify: `gui/src/i18n/zh.ts` +- Modify: `gui/src/i18n/zh-TW.ts` +- Modify: `tests/gui/combo-workspace-data.test.ts` +- Create: `gui/tests/jev-auto-combo.test.tsx` + +**Interfaces produced:** + +```ts +export function jevAutoDraft(models: readonly ModelOption[]): ComboItem; +``` + +- [ ] Add failing pure GUI tests proving `jev` parses/serializes without drift and `jevAutoDraft` creates id/alias `jev-auto`, strategy `jev`, adaptive effort mode, and available Astra/Sol/Luna targets in fail-open order Astra → Sol → Luna while leaving the target list editable. Run `bun test tests/gui/combo-workspace-data.test.ts`; expect missing strategy/template failures. +- [ ] Implement the GUI strategy records and pure template builder. Re-run the pure tests; expect them to pass. +- [ ] Add a failing component test proving both the Combo workspace and configured JEV provider overview expose `Create JEV Auto`; the provider action deep-links into the same prefilled add modal. Prove the modal lets the user add/remove/change targets and submits the normal `PUT /api/combos` shape. Also prove the action is disabled or clearly reports a collision when `jev-auto` already exists. Run `cd gui && bun test tests/jev-auto-combo.test.tsx`; expect the actions to be absent. +- [ ] Add the quick action by parameterizing the existing add modal with an initial draft and one hash route owned by the Models/Combos page; do not fork the target editor or create a JEV-only editor. For the `jev` strategy, mark the first row as fail-open and show each row's known effort ladder. Add JEV strategy/target/setup copy to all ten locale modules. Re-run the component and pure tests; expect them to pass. +- [ ] Run `cd gui && bun test tests`, `cd gui && bun run lint`, `cd gui && bun run lint:i18n`, and `cd gui && bun run build`; expect exit code 0 for each. +- [ ] Stage the Task 4 files and commit with `git commit -m "feat(gui): add JEV Auto setup flow"`. + +## Task 5: Add per-target JEV effort allowlists and prove key setup + +**Files:** + +- Modify: `src/types/config.ts` +- Modify: `src/combos/types.ts` +- Modify: `src/server/responses/core-combo.ts` +- Modify: `gui/src/combo-workspace-data.ts` +- Modify: `gui/src/components/combo-workspace-controls.tsx` +- Modify: `gui/src/styles-combos-workspace.css` +- Modify: `gui/src/i18n/*.ts` +- Modify: focused Combo, JEV runtime, GUI, provider, and CLI-login tests + +- [ ] Add failing config and GUI round-trip tests proving an optional non-empty + `target.reasoningEfforts` list survives load/save exactly, rejects malformed or + duplicate values, participates in dirty-state comparison, and is omitted by + older/unrestricted configurations. +- [ ] Add a failing JEV runtime test proving unchecked efforts are absent from + the TypeSafe choice criteria and a configured allowlist is intersected with + the target's current supported ladder rather than broadening it. +- [ ] Implement the smallest typed config/runtime projection. An omitted list + means all advertised efforts; a present list means only its supported + intersection. A present list with no supported member contributes no JEV + target/effort choice. +- [ ] Add a failing component test for per-target effort checkboxes. All + advertised efforts start selected through omission, toggling persists an + explicit subset, the final selected effort cannot be removed, and changing + provider/model resets the override to all. +- [ ] Implement those controls in the existing target editor, with accessible + labels and localized copy; do not create a JEV-only model picker or alter the + ordinary picker. +- [ ] Add behavioral tests proving the JEV provider exposes the ordinary GUI + API-key surface and `ocx login jev` persists a key-backed, credential-only + provider without publishing a model. Avoid a spurious model-catalog probe for + this decision-only provider. +- [ ] Run the focused server/GUI/provider/CLI suites and typecheck. Commit with + `feat: add per-target JEV effort controls` after fresh tests pass. + +## Task 6: Document, review, verify, and publish the PR + +**Files:** + +- Modify: `docs-site/src/content/docs/guides/combos.md` +- Modify: `docs-site/src/content/docs/reference/configuration/routing.md` +- Modify: `structure/runtime.md` +- Modify: `structure/providers-and-adapters.md` +- Modify: `structure/gui-and-management-api.md` +- Modify: `.github/PULL_REQUEST_TEMPLATE.md` only if the existing template cannot represent the required screenshot/evidence; otherwise leave it unchanged +- Add a screenshot only in the repository's accepted documentation/media location if needed for a stable PR-body link + +- [ ] Update canonical docs with JEV key setup, the `jev` strategy, editable target allowlist, `jev-auto` quick-create flow, fail-open/cancellation behavior, one-decision-per-call rule, and the no-live-key testing boundary. Update structure docs for the new runtime/provider/GUI ownership. +- [ ] Run `bun run structure:check`, `bun run privacy:scan`, `bun run typecheck`, `bun run test`, `bun run prepush`, and `cd docs-site && bun install --frozen-lockfile && bun run build`. Save complete outputs in the execution workspace and require exit code 0. +- [ ] Start a disposable local OpenCodex instance with a mocked model target and no TypeSafe key, call `jev-auto`, and verify it reaches the first eligible fail-open target. Use a separate temporary OpenCodex home and ports; never mutate or restart the user's active instance. +- [ ] Launch the built GUI against a disposable local config, create/open the JEV Auto editor, and capture a screenshot showing the JEV strategy plus editable targets. Do not modify the user's running OpenCodex config. +- [ ] Generate the execution skill's whole-branch review package from merge-base `dev` to `HEAD`. Dispatch the required read-only fresh-context reviewer, then verify and fix every valid Critical/Important finding through a new RED→GREEN test before one final full-suite run. +- [ ] Run `git diff --check`, verify `git status --short`, and commit documentation/review fixes with Conventional Commits after fresh tests/builds pass. +- [ ] Push `feat/jev-auto-routing`, create a PR against `dev` using the repository template, include the GUI screenshot and exact test/build evidence, request Codex and Copilot review once, and attach the PR artifact to this task. Do not claim a live TypeSafe decision test. + +## Completion Contract + +- The ordinary picker still contains every pre-existing model unchanged. +- `jev-auto` appears only after explicit GUI/CLI/API creation. +- The JEV key can be configured through the provider GUI, `ocx login jev`, or `TYPESAFE_API_KEY`. +- JEV can choose only the operator-selected eligible targets and each target's operator-selected supported efforts; omitted target effort lists retain the all-advertised default. +- Every JEV failure mode has a tested first-eligible fail-open path; cancellation has a tested fail-closed 499 path. +- Retryable selected-target failure uses existing Combo fallback exactly once per target without another JEV call. +- Root tests/typecheck/privacy/structure/prepush, GUI tests/lint/build, and docs build pass on the final tree. +- The PR targets `dev`, includes the screenshot and verification evidence, and explicitly states that live-key validation is pending. diff --git a/devlog/_fin/260921_jev_auto_routing/020_design.md b/devlog/_fin/260921_jev_auto_routing/020_design.md new file mode 100644 index 00000000000..129c81c8f43 --- /dev/null +++ b/devlog/_fin/260921_jev_auto_routing/020_design.md @@ -0,0 +1,383 @@ +# JEV Auto Routing Design + +## Goal + +Add one optional `jev-auto` model to OpenCodex. Each request sent to that model +is classified by JEV (TypeSafe System One), which chooses one configured target +model and a compatible reasoning effort. Every existing provider and model +remains directly selectable and keeps its current behavior. + +The integration must feel native to OpenCodex: setup and candidate selection +live in the GUI, dispatch reuses the existing Combo machinery, and no separate +Python service or recursive loopback request is required. + +The behavioral reference is +[`0xNatoshi/jev-codex-router`](https://github.com/0xNatoshi/jev-codex-router): +bounded per-turn context extraction, a joint model-and-effort choice, strict +answer validation, standard service tier, fail-open routing, and local decision +telemetry. The implementation is a TypeScript adaptation to OpenCodex's routing +and security boundaries, not a copy of its HTTP relay. + +## User-visible invariants + +1. Installing or enabling JEV does not hide, rename, disable, reorder, or + redirect any existing model. +2. JEV is never made the default model automatically. +3. After the operator creates the JEV Combo, the integration publishes exactly + one additional public selector, `jev-auto`, with display name `JEV Auto`. +4. Selecting any ordinary model bypasses JEV completely. +5. Removing or disabling the JEV Auto combo removes only `jev-auto`; candidate + models remain available individually. +6. Candidate models are edited through the existing Combo target picker. The + initial template is seeded with the available OpenAI Luna, Sol, and Astra + models, but users may add or remove any currently routable OpenCodex model. + +## Options considered + +### External JEV provider sidecar + +Run the reference Python server on loopback, register it as a custom +OpenAI-Responses provider, and have it call OpenCodex again with the selected +model. This is close to the reference deployment but requires a second service, +two lifecycle systems, recursive HTTP routing, loop prevention, and custom GUI +bridging for candidate configuration. + +### Native JEV provider adapter + +Represent JEV as a model provider whose adapter internally re-routes to another +provider. This reuses provider credential UI but makes an adapter own recursive +dispatch and failover, responsibilities already handled by Combos. It also +risks publishing both a canonical provider/model selector and the desired +`jev-auto` alias. + +### Native JEV Combo strategy + +This is the selected design. A Combo already owns an alias, a list of concrete +provider/model targets, target eligibility, retries, quota cooldowns, reasoning +capability calculation, request replay, and GUI editing. The new `jev` strategy +changes only how the first eligible target and effort are chosen. Existing +Combo failure handling owns subsequent attempts. + +## Configuration model + +### Decision-service credential + +Add a registry-backed `jev` decision-service entry for credential ownership and +GUI setup. It has these fixed properties: + +- endpoint: `https://api.typesafe.ai/v1/systemone` +- API model: `jev-latest` +- key authentication +- no live model discovery +- no directly routable language models + +The entry exists to reuse OpenCodex's provider API-key storage, environment +reference resolution, masking, optional OS-keychain storage, and credential +management surfaces. It must never publish a model row or accept a normal model +dispatch. The runtime reads the key only when a Combo with strategy `jev` is +selected. + +`TYPESAFE_API_KEY` remains a supported environment source. A key entered in the +GUI follows the same storage and redaction rules as other provider API keys. +Management DTOs expose only credential presence and health, never the value. + +### JEV Combo + +Extend `OcxComboStrategy` with `jev`. A normal Combo record remains the source +of truth: + +```json +{ + "combos": { + "jev-auto": { + "alias": "jev-auto", + "strategy": "jev", + "targets": [ + { + "provider": "openai", + "model": "gpt-5.6-luna", + "reasoningEfforts": ["low", "medium"] + }, + { "provider": "openai", "model": "gpt-5.6-sol" }, + { "provider": "openai", "model": "gpt-6-astra" } + ], + "reasoningEffortMode": "adaptive" + } + } +} +``` + +The GUI template creates this record only after an explicit user action. It +filters unavailable seed targets rather than creating broken references. The +ordinary Combo editor remains authoritative after creation. + +Each target may optionally persist a non-empty `reasoningEfforts` allowlist. +Omitting it preserves the original behavior and offers every reasoning effort +advertised by that target. When present, JEV receives only the intersection of +that allowlist and the target's current advertised ladder. A stale allowlist +must never broaden capability or silently turn into an unrestricted choice. + +Target order has one extra meaning for this strategy: the first eligible target +is the fail-open target when JEV is unavailable or returns an invalid answer. +The GUI labels this clearly. For the reference triptych template, Astra is +placed first for fail-open parity even if the candidate list is displayed in a +friendlier order. + +No per-model capability prose is persisted in the first version. Known Luna, +Sol, and Astra targets receive the reference capability profiles. Other targets +receive neutral criteria derived from their selector, display name, declared +input modalities, context window, and supported reasoning ladder. Richer +operator-authored model profiles are intentionally deferred until their schema +and portability contract are decided. + +## Runtime architecture + +### Activation boundary + +Only a request resolving to a Combo whose strategy is `jev` imports and invokes +the JEV selector. Normal routes and other Combo strategies execute no JEV code, +start no timers, and perform no decision-service I/O. + +The JEV selector is a leaf module under `src/combos/`. It receives an already +validated Combo, the current Responses body, and concrete eligible targets. It +does not import the server composition root or dispatch requests itself. + +### Per-turn flow + +```text +Codex request model=jev-auto + -> existing Combo identification and admission + -> calculate currently eligible targets + -> derive each target's supported effort ladder + -> extract bounded decision state from the Responses request + -> one HTTPS call to TypeSafe System One + -> validate the selected target+effort pair + -> existing Combo child dispatch to that concrete provider/model + -> existing Combo preflight, retry, quota, and response relay +``` + +The selection happens once per incoming model call, including tool-result +continuations. A failed concrete attempt does not spend another JEV decision: +the existing Combo loop tries remaining eligible targets in configured order. + +### Decision state + +Port the bounded extraction contract from the reference implementation: + +- current user request with OpenCodex/system envelope blocks removed +- bounded recent assistant intent +- the most recent tool-result digest, without tool arguments +- whether image input is present +- request/item counts and step type + +The full conversation, credentials, provider headers, encrypted reasoning +payloads, tool arguments, and raw image bytes never enter the decision request. +All strings and aggregate payload size have explicit limits. Oversized or +unrecognized input degrades to fail-open instead of being truncated without a +marker or sent in full. + +### Choice contract + +Build one TypeSafe `choice` question whose criteria are the Cartesian product +of each eligible target and its supported reasoning efforts. A target that +advertises no reasoning control contributes one model-only choice. + +Each criterion uses an opaque local choice id. Provider names and model ids are +values in the criterion, never executable instructions. A response is accepted +only when: + +- the answer contains the expected question, +- the selected choice id belongs to the exact request-specific candidate set, +- optional probabilities are finite, bounded, complete, sum within tolerance, + and agree with the winning choice, +- optional confidence is finite and within `[0, 1]`. + +Confidence and probability distribution are telemetry only. They never +override a valid choice. + +### Applying the choice + +The chosen concrete target is dispatched through the existing Combo child +request path. JEV's effort replaces any effort attached to `jev-auto` for that +child only and is validated against the target's resolved ladder. The child is +forced to the normal/default service tier; JEV Auto does not request Fast mode. + +The original request body remains the replay source for fallback attempts. No +JEV metadata, API key, or decision response is inserted into model-visible +input. + +## Failure behavior + +JEV Auto is fail-open at the decision boundary: + +- missing key +- timeout, DNS, TLS, or network failure +- non-2xx TypeSafe response, including exhausted credits +- malformed JSON +- missing, unknown, or inconsistent choice +- no safe extractable decision state + +All use the first currently eligible target. The fail-open effort is `medium` +when supported, otherwise that target's declared default/nearest supported +effort, otherwise no explicit effort. + +If no target is eligible, the existing Combo-unavailable response is returned. +Once a target is chosen, existing Combo behavior remains authoritative for +provider errors, quota cooldowns, retry ordering, stream preflight, committed +output, and final error delivery. + +The TypeSafe call has a four-second timeout and `redirect: "error"`. It is never +retried within the same model call. Client cancellation and server shutdown +abort it through the request signal. + +## Security and privacy + +- The TypeSafe endpoint is registry-fixed HTTPS. User config cannot redirect + the JEV credential to another origin. +- The API key is resolved immediately before the request and is never copied + into logs, request metadata, Combo state, or management DTOs. +- Error text is bounded and sanitized before logging or returning status. +- Decision logs contain selectors, effort, timing, gate, and numeric usage only. + They do not retain extracted prompt text. +- The GUI follows existing credential-consent and CSRF rules. +- JEV cannot select a target outside the configured, currently eligible target + set, even if the service returns an arbitrary string. +- A JEV Combo cannot target itself or another path that resolves recursively to + the same Combo. + +## GUI design + +### Setup + +Add a `JEV` row to the provider catalog. Its setup pane accepts the TypeSafe API +key, links to the TypeSafe console/documentation, tests only the fixed decision +endpoint, and reports configured/missing/invalid without showing the key. + +After successful setup, offer `Create JEV Auto`. This creates the Combo template +but does not select it as the default model and does not change global model +visibility. + +### Candidate editing + +Add `JEV` to the existing Combo strategy control. Reuse the current target +editor and model inventory; do not create a second model picker. The editor: + +- marks the first eligible target as the fail-open target, +- shows each target's available reasoning efforts and lets the user select the + exact non-empty subset JEV may choose, +- treats an omitted subset as "all advertised efforts" for backward + compatibility and resets that default when the target model changes, +- prevents direct or indirect self-reference, +- warns when a target is disabled, missing, or has no usable route, +- permits saving only when at least one concrete target is valid. + +The resulting catalog contains one `JEV Auto` row with selector `jev-auto`. +Candidate models continue to appear in their original provider groups. + +### Observability + +The Combo detail view shows the latest decision state without prompt content: +selected target, selected effort, decision latency, gate (`apply` or fail-open +reason), and timestamp. Request logs record the same fields and identify the +served provider/model through existing attempt records. + +## Compatibility and rollout + +- Existing Combo records and strategies remain valid without migration. +- Configurations from a newer build that contain strategy `jev` degrade by + disabling only that Combo on an older build; provider/model configuration is + preserved. +- Disabling or deleting the JEV decision-service entry leaves the Combo record + intact but makes requests fail-open. +- Disabling or deleting the Combo removes `jev-auto` on the next normal catalog + convergence. +- No system service, Python runtime, loopback port, OpenCodex bind change, or + automatic migration is introduced. + +## Expected implementation boundaries + +- `src/types/config.ts` and config schema: `jev` Combo strategy and validation. +- `src/combos/`: bounded state extraction, TypeSafe client, decision validation, + and strategy-aware initial selection. +- `src/server/responses/core-combo.ts`: one async initial-selection seam and + application of the selected effort; existing dispatch/retry remains intact. +- provider registry and management API: fixed JEV credential owner and bounded + key-health test. +- `gui/src/components/combo-workspace-*`: strategy option, default template, + fail-open labeling, and candidate editing. +- provider catalog/auth UI: JEV key setup and `Create JEV Auto` action. +- request-log DTO/UI: secret-free decision metadata. +- docs and structure ownership notes required by the touched source areas. + +No broad adapter refactor, generic AI-router framework, external process +manager, or unrelated Combo behavior change belongs in this PR. + +## Test design + +### Pure decision tests + +- bounded extraction for text, images, tool continuations, envelope-only input, + malformed items, and oversized state +- request-specific criterion generation for mixed reasoning ladders +- valid choice acceptance and rejection of unknown, incomplete, non-finite, or + inconsistent answers +- known reference profiles versus neutral metadata-derived profiles +- deterministic fail-open target and effort selection + +### Runtime tests + +- ordinary models and non-JEV Combos perform no TypeSafe request +- `jev-auto` dispatches exactly the selected provider/model and effort +- incoming model effort and Fast preference cannot override the JEV decision +- missing key and every bounded upstream failure class dispatch fail-open +- JEV is called once when the chosen model fails and normal Combo fallback runs +- self-reference and unavailable targets never enter the criteria +- cancellation aborts an in-flight decision call +- request logs contain decision metadata and no extracted text or key material + +All TypeSafe traffic is mocked. Tests require no real JEV key. + +### Management and GUI tests + +- key values are write-only and redacted from every DTO/error path +- the fixed endpoint cannot be overridden +- setup creates one disabled-until-requested `jev-auto` catalog addition and + never changes the default model +- target editing round-trips exact provider/model ids and preserves unrelated + Combo fields +- target effort editing round-trips an exact non-empty subset and JEV never + receives unchecked or newly unsupported efforts +- the JEV API key can be stored through the provider GUI and `ocx login jev` +- removal affects only `jev-auto` +- keyboard, focus, labels, loading, and error states follow existing provider + and Combo accessibility patterns + +### Verification gates + +- focused Combo, routing, management, catalog, request-log, and GUI tests +- `bun run typecheck` +- `bun run test` +- `bun run privacy:scan` +- `bun run structure:check` +- `bun run prepush` +- local no-key smoke proving `jev-auto` reaches its fail-open target while a + directly selected model bypasses JEV + +A real decision smoke is deferred until the user supplies a TypeSafe key and is +reported separately from mocked and no-key coverage. + +## Acceptance criteria + +- Existing model/provider behavior and picker availability are unchanged. +- Enabling the integration adds exactly one opt-in `jev-auto` selector. +- GUI setup stores or references the TypeSafe key without exposing it. +- GUI users can choose the concrete models JEV is allowed to select. +- GUI users can choose the exact advertised efforts JEV is allowed to select + for each target, while older configs with no target allowlist still mean all. +- Every JEV call chooses only from the current eligible candidates and jointly + selects a compatible effort. +- Missing or broken JEV fails open predictably without blocking a turn. +- Existing Combo retry, quota, streaming, continuation, and cancellation + behavior remains authoritative after selection. +- No external JEV server or additional local port is required. +- Relevant focused and full verification gates pass before the PR is opened. diff --git a/docs-site/src/content/docs/fr/getting-started/quickstart.md b/docs-site/src/content/docs/fr/getting-started/quickstart.md index ecd35fd1800..4d08caed17d 100644 --- a/docs-site/src/content/docs/fr/getting-started/quickstart.md +++ b/docs-site/src/content/docs/fr/getting-started/quickstart.md @@ -13,7 +13,7 @@ ocx init `ocx init` vous accompagne dans les étapes suivantes : -1. **Choix d’un fournisseur** — sélectionnez l’un des 98 préréglages intégrés au registre, ou `custom` pour saisir une +1. **Choix d’un fournisseur** — sélectionnez l’un des 99 préréglages intégrés au registre, ou `custom` pour saisir une URL de base et un adaptateur. 2. **Clé API** — collez une clé ou référencez une variable d’environnement telle que `${ANTHROPIC_API_KEY}`. 3. **Modèle par défaut** — pour les fournisseurs clés, locaux et personnalisés, acceptez le préréglage ou saisissez un identifiant de modèle. diff --git a/docs-site/src/content/docs/fr/guides/combos.md b/docs-site/src/content/docs/fr/guides/combos.md index 09f96b182ac..c43c0002fa1 100644 --- a/docs-site/src/content/docs/fr/guides/combos.md +++ b/docs-site/src/content/docs/fr/guides/combos.md @@ -333,7 +333,7 @@ Les combos sont stockés dans l'objet `combos` de niveau supérieur, saisi par l | --- | --- | --- | --- | | `targets` | Oui | — | Tableau ordonné non vide de `{ provider, model, weight? }` cibles configurées. Les paires provider/model en double sont rejetées. | | `targets[].weight` | Non | `1` | Entier de 1 à 10 000. Utilisé par `round-robin` et `random` ; ignoré par `failover`, `least-used` et `reset-window`. | -| `strategy` | Non | `"failover"` | Valeurs autorisées : `"failover"`, `"round-robin"`, `"random"`, `"least-used"` et `"reset-window"`. | +| `strategy` | Non | `"failover"` | Valeurs autorisées : `"failover"`, `"round-robin"`, `"random"`, `"least-used"`, `"reset-window"` et `"jev"`. JEV décide uniquement de la première cible éligible et de l’effort ; le fallback Combo ordinaire gère les tentatives suivantes. | | `stickyLimit` | Non | `1` | Nombre entier de 1 à 100 requêtes réussies par sélection à tour de rôle. S’applique uniquement à `round-robin`. | | `defaultEffort` | Non | `null` | `low`, `medium`, `high`, `xhigh`, `max` ou `ultra` ; appliqué uniquement lorsque l'appelant omet ses efforts et que la cible annonce son soutien. | | `reasoningEffortMode` | Non | `"strict"` | `strict` ou `adaptive` ; choisit l’intersection des capacités et la normalisation par cible. | diff --git a/docs-site/src/content/docs/fr/guides/providers.md b/docs-site/src/content/docs/fr/guides/providers.md index debf0f51f2c..1f2090b4b86 100644 --- a/docs-site/src/content/docs/fr/guides/providers.md +++ b/docs-site/src/content/docs/fr/guides/providers.md @@ -295,7 +295,7 @@ existante n'est pas concernée. ## 3. Catalogue des clés API -opencodex fournit 98 préréglages intégrés : 81 à clé, 13 OAuth, trois locaux et un préréglage par défaut de +opencodex fournit 99 préréglages intégrés : 82 à clé, 13 OAuth, trois locaux et un préréglage par défaut de transfert ChatGPT. Dans le tableau de bord, le sélecteur **Ajouter un fournisseur** ouvre le tableau de bord du fournisseur à clé, valide la clé et l'enregistre ; la validation dépend du fournisseur. Parmi les entrées notables : diff --git a/docs-site/src/content/docs/fr/reference/configuration/routing.md b/docs-site/src/content/docs/fr/reference/configuration/routing.md index 64713d645cc..ef8d2bbdf6b 100644 --- a/docs-site/src/content/docs/fr/reference/configuration/routing.md +++ b/docs-site/src/content/docs/fr/reference/configuration/routing.md @@ -56,7 +56,7 @@ Chaque clé de combinaison est un identifiant conforme à `[A-Za-z0-9][A-Za-z0-9 | Clé | Type | Valeur par défaut | Signification | | --- | --- | --- | --- | | `targets` | `{ provider: string; model: string; weight?: number }[]` | requis | Routes concrètes ordonnées. `weight` est compris entre 1 et 10000 et vaut `1` par défaut. | -| `strategy?` | `"failover" \| "round-robin" \| "random" \| "least-used" \| "reset-window"` | `"failover"` | Stratégie de sélection. L’ordre des cibles définit la priorité de `failover` ; les poids déterminent les sélections de `round-robin` et de `random` ; `least-used` suit les réussites enregistrées ; `reset-window` suit la réinitialisation de quota la plus proche. | +| `strategy?` | `"failover" \| "round-robin" \| "random" \| "least-used" \| "reset-window" \| "jev"` | `"failover"` | Stratégie de sélection. L’ordre des cibles définit la priorité de `failover` ; les poids déterminent les sélections de `round-robin` et de `random` ; `least-used` suit les réussites enregistrées ; `reset-window` suit la réinitialisation de quota la plus proche ; `jev` effectue une décision limitée pour la première cible éligible et l’effort, puis utilise le fallback ordonné habituel. | | `stickyLimit?` | `number` | `1` | Nombre de requêtes réussies conservées dans un même lot de rotation. Plage de 1 à 100. | | `defaultEffort?` | `"low" \| "medium" \| "high" \| "xhigh" \| "max" \| "ultra" \| null` | non défini | `defaultEffort` complète un `reasoning.effort` absent si le combo possède une valeur par défaut non nulle et si la liste des niveaux acceptés par la cible est connue et non vide. La valeur configurée est conservée si elle est acceptée ; sinon, le niveau accepté le plus élevé ne la dépassant pas est choisi, ou le niveau le plus bas si aucun n’est inférieur. Une liste inconnue ou vide n’ajoute aucune valeur par défaut. | | `reasoningEffortMode?` | `"strict" \| "adaptive"` | `"strict"` | `"strict"` calcule l’intersection des listes connues, y compris les listes vides ; `"adaptive"` exclut les listes vides. Les listes inconnues ne limitent l’intersection dans aucun des deux modes. À l’envoi, les listes explicitement vides suppriment les paramètres effort/thinking dans les deux modes ; les listes inconnues les suppriment seulement en adaptive. `reasoning.summary` est conservé. La résolution des listes connues non vides ainsi que le choix et l’ordre des cibles restent inchangés. | diff --git a/docs-site/src/content/docs/getting-started/quickstart.md b/docs-site/src/content/docs/getting-started/quickstart.md index db6a4894d34..8f863b6f517 100644 --- a/docs-site/src/content/docs/getting-started/quickstart.md +++ b/docs-site/src/content/docs/getting-started/quickstart.md @@ -18,7 +18,7 @@ ocx init `ocx init` walks you through: -1. **Pick a provider** — choose one of the 98 built-in registry presets or `custom` to type a base +1. **Pick a provider** — choose one of the 99 built-in registry presets or `custom` to type a base URL and adapter. 2. **API key** — paste a key, or reference an environment variable like `${ANTHROPIC_API_KEY}`. 3. **Default model** — for key, local, and custom providers, accept the preset or enter a model id. diff --git a/docs-site/src/content/docs/guides/combos.md b/docs-site/src/content/docs/guides/combos.md index 82cb31fecfd..51b08521653 100644 --- a/docs-site/src/content/docs/guides/combos.md +++ b/docs-site/src/content/docs/guides/combos.md @@ -207,6 +207,105 @@ order. Weights and `stickyLimit` do not affect this strategy. This ranking and provider exclusion before dispatch require fresh model-inference limits that apply to the current single API key as a whole. OAuth/current-account summaries, caller-forward routes, multiple keys, and snapshots with changed credentials or destinations are display-only for this early decision. The same applies when `Authorization`, `x-api-key`, or `x-goog-api-key` headers override credentials; search-only and MCP-only windows are excluded. If no eligible target has an applicable reset, configuration order wins. Account selection and retries still enforce their normal limits. +### JEV: decision-guided first pick + +`jev` asks [TypeSafe JEV](https://console.typesafe.ai) to choose the first eligible target and a +compatible reasoning effort for the current request. It is opt-in: adding the TypeSafe credential +does not change existing models, aliases, defaults, or Combo behavior. A JEV-backed model appears +only after you create a Combo whose strategy is `jev`. + +The quickest setup is: + +1. Open **Providers**, add **TypeSafe JEV**, enter the TypeSafe API key, and test the connection. +2. From that provider's Overview, choose **Create JEV Auto**. You can also use the same action under + **Models → Combos**. +3. Review the prefilled Astra → Sol → Luna targets. Add, remove, reorder, or replace them before + creating the Combo. The first currently eligible row is marked as the fail-open target, and each + known reasoning ladder is shown beside its row. + +The template creates id and alias `jev-auto`, uses adaptive reasoning capability, and remains an +ordinary editable Combo. It does not become the default model. Its targets are the complete +allowlist: JEV can never select a provider/model pair outside that list, and the original target +models remain available in their normal picker groups. + +For headless setup, store the key explicitly or reference the TypeSafe environment variable: + +```bash +ocx provider add jev --api-key "${TYPESAFE_API_KEY}" +``` + +When the provider has no saved key, the decision client also accepts `TYPESAFE_API_KEY` directly and +the standard provider-derived alias `JEV_API_KEY` printed by `ocx provider add`. + +```json +{ + "providers": { + "jev": { + "adapter": "jev-decision", + "baseUrl": "https://api.typesafe.ai/v1/systemone", + "authMode": "key", + "apiKey": "${TYPESAFE_API_KEY}", + "liveModels": false + } + }, + "combos": { + "jev-auto": { + "alias": "jev-auto", + "strategy": "jev", + "reasoningEffortMode": "adaptive", + "targets": [ + { "provider": "openai", "model": "gpt-6-astra" }, + { "provider": "openai", "model": "gpt-5.6-sol" }, + { "provider": "openai", "model": "gpt-5.6-luna" } + ] + } + } +} +``` + +OpenCodex sends one bounded decision request to the fixed +`https://api.typesafe.ai/v1/systemone` endpoint with model `jev-latest`. Only currently eligible +configured targets are offered. JEV chooses the target and effort together; the effort is still +constrained by that target's advertised ladder. JEV is not asked again if the selected target has a +retryable failure—the existing Combo cooldown and fallback loop continues through the remaining +configured targets. + +Each logical model call is decided on its own; there is no per-conversation pin. Consecutive turns of +one session can therefore land on different targets, and every switch starts a cold provider prompt +cache, so a mix of very different targets can cost more input tokens than it saves. Keep the +allowlist to targets you are content to alternate between. Targets marked `lastResort` are withheld +from JEV under `cooldownWaitPolicy: "before-last-resort"` while any normal target is offered, and +offered only when nothing else is reachable. + +The decision boundary fails open when the key is missing, no safe task/tool/image decision state is +available, the four-second decision deadline expires, the service redirects or returns an error, or +the response is malformed or selects an unlisted choice. In those cases OpenCodex uses the first +currently eligible target, preferring `medium` when that target supports it. Caller cancellation is +different: it cancels the decision and the model request instead of dispatching the fail-open target. + +The decision state is deliberately bounded: up to 500 characters of the current user task, a +240-character previous-assistant tail, a 520-character latest-tool-output tail, the tool name, and +boolean image/tool signals may be sent to TypeSafe. It excludes the JEV credential, request headers, +raw image bytes, tool arguments, encrypted reasoning, and full conversation history. Do not select +`jev-auto` for content you do not want TypeSafe to process. Recognized OpenCodex machine-context +envelopes are removed from all three text samples, but ordinary assistant and tool-output text is +not a secret scanner and may still contain sensitive content. TypeSafe states that Jev is not +trained on customer requests, but its terms set no fixed retention period for submitted state and +offer zero data retention only on enterprise plans +([models](https://docs.typesafe.ai/models), [legal](https://docs.typesafe.ai/legal)). TypeSafe +also documents English as Jev's most accurate language, so check decisions on non-English work +before relying on them. Logs contain only the selected +target/effort, a coarse decision gate, latency, optional confidence/probability, and numeric usage. +Automated tests use mocked TypeSafe responses plus a no-key fail-open smoke; a live TypeSafe decision +requires an operator-supplied key and is not run implicitly. + +After the Combo has served requests, open **Models → Combos → jev-auto → Stats** to inspect JEV's +picks without replacing the normal model picker or Usage page. The tab separates TypeSafe decision +tokens from tokens reported by physical model sends, and shows decision gates, fail-open picks, +reasoning efforts, retries/fallbacks, cache tokens, latency, confidence, and per-model totals for 7 +days, 30 days, or all available history. Statistics come from the local append-only usage ledger; +they contain the bounded decision metadata described above, not prompts or credentials. + ## What happens when a target fails Combo failures are divided into **hop** failures and **terminal** failures. @@ -401,7 +500,9 @@ task workflow. ### Dashboard Open the local dashboard and choose **Models → Combos**. The workspace creates, edits, renames, and removes -combos, and its target picker excludes disabled models and nested combos. +combos, and its target picker excludes disabled models, nested combos, and the credential-only JEV +provider. **Create JEV Auto** opens the same Combo editor with an editable decision target template; +an existing `jev-auto` id or alias is reported instead of creating a duplicate. Each target also shows a live quota badge: **Available**, **Out of quota**, or **Quota unknown**. The editor blocks Save and Create for quota only when every usable target has a current server-confirmed exhausted inference limit for its configured credential. Display-only account, model, search and MCP quota, or missing or expired routing evidence, does not cause this block. The block expires at the applicable reset or freshness boundary and is rechecked when the page becomes active or visible; Refresh reloads both Combo data and quota. The dashboard editor does not yet expose `cooldownMs` or `waitForCooldownMs`; use the configuration file or management @@ -466,9 +567,9 @@ Combos are stored in the top-level `combos` object, keyed by combo id: | Field | Required | Default | Rules | | --- | --- | --- | --- | | `targets` | Yes | — | Non-empty ordered array of configured `{ provider, model, weight?, lastResort? }` targets. Duplicate provider/model pairs are rejected. | -| `targets[].weight` | No | `1` | Integer from 1 to 10,000. Used by round-robin and random; ignored by failover, least-used, and reset-window. | +| `targets[].weight` | No | `1` | Integer from 1 to 10,000. Used by round-robin and random; ignored by failover, least-used, reset-window, and JEV. | | `targets[].lastResort` | No | `false` | Marks an emergency-only target. Inert unless `cooldownWaitPolicy` is set. Never makes a target permanently ineligible: when no normal target can be reached it is dispatched as usual. | -| `strategy` | No | `"failover"` | `"failover"`, `"round-robin"`, `"random"`, `"least-used"`, or `"reset-window"`. | +| `strategy` | No | `"failover"` | `"failover"`, `"round-robin"`, `"random"`, `"least-used"`, `"reset-window"`, or `"jev"`. JEV decides only the initial eligible target and effort; ordinary Combo fallback owns later attempts. | | `stickyLimit` | No | `1` | Integer from 1 to 100 successful requests per round-robin selection. Applies only to round-robin. | | `cooldownMs` | No | unset → upstream fallback (5 s for request-rate 429 codes `1302`/`1305`, otherwise 60 s) | Integer from 1 to 600000. When set, applies as the per-target cooldown whenever no usable upstream `Retry-After` or Codex reset signal exists, including request-rate 429s; when unset, uses the upstream fallback. | | `waitForCooldownMs` | No | `0` | Integer from 0 to 600000. Maximum time to wait for the earliest eligible cooling target before returning `combo_unavailable`; abort cancels the wait. | diff --git a/docs-site/src/content/docs/guides/providers.md b/docs-site/src/content/docs/guides/providers.md index 11be146d1c3..5b8930f2b22 100644 --- a/docs-site/src/content/docs/guides/providers.md +++ b/docs-site/src/content/docs/guides/providers.md @@ -443,7 +443,7 @@ selectors, then retry. Signing in from a machine with no existing `kiro-cli` ses ## 3. API-key catalog -opencodex ships 98 built-in presets: 81 key-based, 13 OAuth, three local, and one default +opencodex ships 99 built-in presets: 82 key-based, 13 OAuth, three local, and one default ChatGPT-forward preset. The dashboard's **Add provider** picker opens a key provider's dashboard, validates the key, and stores it; validation is provider-specific. Notable entries: diff --git a/docs-site/src/content/docs/ja/getting-started/quickstart.md b/docs-site/src/content/docs/ja/getting-started/quickstart.md index ae29e0b8d14..d2a3c3f4b86 100644 --- a/docs-site/src/content/docs/ja/getting-started/quickstart.md +++ b/docs-site/src/content/docs/ja/getting-started/quickstart.md @@ -18,7 +18,7 @@ ocx init `ocx init` では次の手順を説明します。 -1. **プロバイダーを選択してください** — 98 個の組み込みレジストリプリセットのいずれか、または `custom` を選択してベース URL とアダプターを入力します。 +1. **プロバイダーを選択してください** — 99 個の組み込みレジストリプリセットのいずれか、または `custom` を選択してベース URL とアダプターを入力します。 2. **API キー** — キーを貼り付けるか、`${ANTHROPIC_API_KEY}` のような環境変数を参照します。 3. **デフォルト モデル** — キー、ローカル、カスタム プロバイダーの場合は、プリセットを受け入れるか、モデル ID を入力します。 4. **プロキシ ポート** — デフォルトは `10100` です。 diff --git a/docs-site/src/content/docs/ja/guides/combos.md b/docs-site/src/content/docs/ja/guides/combos.md index 217636f4672..0158f137ab5 100644 --- a/docs-site/src/content/docs/ja/guides/combos.md +++ b/docs-site/src/content/docs/ja/guides/combos.md @@ -237,7 +237,7 @@ ocx combo remove --yes | `targets` |はい | — |構成された `{ provider, model, weight? }` ターゲットの空でない順序付けされた配列。重複するプロバイダーとモデルのペアは拒否されます。 | | `targets[].weight` |いいえ | `1` | 1 ~ 10,000 の整数。`round-robin` と `random` で使用され、`failover`、`least-used`、`reset-window` では無視されます。 | | `targets[].lastResort` | いいえ | `false` | 緊急時専用のターゲットを示します。`cooldownWaitPolicy` を設定しない限り無効です。ターゲットを恒久的に除外することはありません。通常のターゲットに到達できない場合は通常どおりディスパッチされます。 | -| `strategy` |いいえ | `"failover"` | `"failover"`、`"round-robin"`、`"random"`、`"least-used"`、`"reset-window"`。 | +| `strategy` |いいえ | `"failover"` | `"failover"`、`"round-robin"`、`"random"`、`"least-used"`、`"reset-window"`、`"jev"`。JEV が決定するのは最初の適格なターゲットと effort だけで、それ以降の試行は通常の Combo フォールバックが処理します。 | | `stickyLimit` |いいえ | `1` | `round-robin` の 1 回の選択あたり、成功したリクエスト数を指定する 1 ~ 100 の整数。`round-robin` にのみ適用されます。 | | `cooldownMs` |いいえ | 未設定 → アップストリーム フォールバック(リクエストレート 429 コード `1302`/`1305` では 5 秒、それ以外では 60 秒) | 1 ~ 600000 の整数。設定時は、使用可能なアップストリーム `Retry-After` または Codex リセットシグナルがない場合に、リクエストレート 429 を含むターゲットごとのクールダウンとして適用されます。未設定時はアップストリーム フォールバックを使用します。 | | `waitForCooldownMs` |いいえ | `0` | 0 ~ 600000 の整数。最も早く利用可能になる冷却中のターゲットを待ってから `combo_unavailable` を返すまでの最大待機時間。中止すると待機はキャンセルされます。 | diff --git a/docs-site/src/content/docs/ja/guides/providers.md b/docs-site/src/content/docs/ja/guides/providers.md index c0c76b7a8c1..f54fa25dd07 100644 --- a/docs-site/src/content/docs/ja/guides/providers.md +++ b/docs-site/src/content/docs/ja/guides/providers.md @@ -193,7 +193,7 @@ Kiro のログインには Kiro CLI が必要です。Unix では `curl -fsSL ht ## 3. API キーカタログ -opencodex には組み込みプリセットが 98 個含まれています。キー方式 81、OAuth 13、ローカル 3、 +opencodex には組み込みプリセットが 99 個含まれています。キー方式 82、OAuth 13、ローカル 3、 デフォルト ChatGPT 転送プリセット 1 です。ダッシュボードの **Add provider** ピッカーはキー発行ページを開き、 入力したキーを検証した後保存します(検証はプロバイダー固有です)。主な項目は以下のとおりです: diff --git a/docs-site/src/content/docs/ja/reference/configuration/routing.md b/docs-site/src/content/docs/ja/reference/configuration/routing.md index c6a6d807728..2bac26c03c8 100644 --- a/docs-site/src/content/docs/ja/reference/configuration/routing.md +++ b/docs-site/src/content/docs/ja/reference/configuration/routing.md @@ -67,7 +67,7 @@ picker catalog の convergence だけが保留中で routing change は失われ |キー |タイプ |デフォルト |意味 | | --- | --- | --- | --- | | `targets` | `{ provider: string; model: string; weight?: number }[]` |必須 |具体的なルートを指示しました。 `weight` は 1 ~ 10000 で、デフォルトは `1` です。 | -| `strategy?` | `"failover" \| "round-robin" \| "random" \| "least-used" \| "reset-window"` | `"failover"` |選択戦略。ターゲットの順序は `failover` の優先順位となり、`weight` は `round-robin` と `random` の抽選に影響し、`least-used` は記録された成功数に従い、`reset-window` は最も早いクォータリセットに従います。 | +| `strategy?` | `"failover" \| "round-robin" \| "random" \| "least-used" \| "reset-window" \| "jev"` | `"failover"` |選択戦略。ターゲットの順序は `failover` の優先順位となり、`weight` は `round-robin` と `random` の抽選に影響し、`least-used` は記録された成功数に従い、`reset-window` は最も早いクォータリセットに従います。`jev` は最初の適格なターゲットと effort を 1 回の制限付き決定で選び、その後は通常の順序付きフォールバックを使用します。 | | `stickyLimit?` | `number` | `1` |成功したリクエストは 1 つのラウンドロビン バッチに保持されます。範囲は 1 ~ 100。 | | `defaultEffort?` | `"low" \| "medium" \| "high" \| "xhigh" \| "max" \| "ultra" \| null` |設定を解除する | `defaultEffort` は、コンボの既定値が null でなく、対象の対応リストが既知で空でない場合に、省略された `reasoning.effort` を補います。設定値に対応していればその値を使い、そうでなければ設定値以下で最も高い段階を選びます。それもなければ最も低い対応段階を使います。不明または空のリストでは既定値を省略します。 | | `reasoningEffortMode?` | `"strict" \| "adaptive"` | `"strict"` | `"strict"` は空リストを含む既知の対応リストの共通部分を公開し、`"adaptive"` は空リストを除外します。不明なリストは両モードで共通部分を制限しません。送信時、明示的な空リストは両モードで effort/thinking 制御を削除し、不明なリストでは adaptive のみ削除します。`reasoning.summary` は保持されます。既知の空でない対象の effort 解決と対象の選択・順序は変わりません。 | diff --git a/docs-site/src/content/docs/ko/getting-started/quickstart.md b/docs-site/src/content/docs/ko/getting-started/quickstart.md index f27de93b0e3..b0b22222a37 100644 --- a/docs-site/src/content/docs/ko/getting-started/quickstart.md +++ b/docs-site/src/content/docs/ko/getting-started/quickstart.md @@ -18,7 +18,7 @@ ocx init `ocx init`은 다음 과정을 안내합니다: -1. **프로바이더 선택** — 내장 레지스트리 프리셋 98개 중 하나를 고르거나 `custom`을 선택해 base URL과 adapter를 직접 입력합니다. +1. **프로바이더 선택** — 내장 레지스트리 프리셋 99개 중 하나를 고르거나 `custom`을 선택해 base URL과 adapter를 직접 입력합니다. 2. **API 키** — 키를 붙여넣거나 `${ANTHROPIC_API_KEY}` 같은 환경 변수를 참조합니다. 3. **기본 모델** — 키, 로컬, custom 프로바이더에서는 프리셋을 그대로 쓰거나 모델 ID를 직접 입력합니다. 4. **프록시 포트** — 기본값은 `10100`입니다. diff --git a/docs-site/src/content/docs/ko/guides/combos.md b/docs-site/src/content/docs/ko/guides/combos.md index 614eea8873a..ad2db55dedc 100644 --- a/docs-site/src/content/docs/ko/guides/combos.md +++ b/docs-site/src/content/docs/ko/guides/combos.md @@ -245,7 +245,7 @@ ocx combo remove --yes | `targets` | 예 | — | 설정된 `{ provider, model, weight? }` 대상의 비어 있지 않은 순서가 있는 배열이어야 합니다. 중복된 provider/model 쌍은 거부됩니다. | | `targets[].weight` | 아니요 | `1` | 1에서 10,000 사이의 정수입니다. `round-robin`과 `random`에서 사용되며, `failover`, `least-used`, `reset-window`에서는 무시됩니다. | | `targets[].lastResort` | 아니요 | `false` | 비상용 대상임을 표시합니다. `cooldownWaitPolicy`를 설정하지 않으면 아무 효과가 없습니다. 대상을 영구히 제외하지는 않습니다. 일반 대상에 도달할 수 없으면 평소대로 디스패치됩니다. | -| `strategy` | 아니요 | `"failover"` | 허용되는 값은 `"failover"`, `"round-robin"`, `"random"`, `"least-used"`, `"reset-window"`입니다. | +| `strategy` | 아니요 | `"failover"` | 허용되는 값은 `"failover"`, `"round-robin"`, `"random"`, `"least-used"`, `"reset-window"`, `"jev"`입니다. JEV는 첫 번째 적격 대상과 effort만 결정하며, 이후 시도는 일반 Combo fallback이 처리합니다. | | `stickyLimit` | 아니요 | `1` | 한 번의 `round-robin` 선택에 유지되는 성공 요청 수로, 1에서 100 사이의 정수입니다. `round-robin`에만 적용됩니다. | | `cooldownMs` | 아니요 | 미설정 → 업스트림 폴백(요청 속도 제한 429 코드 `1302`/`1305`는 5초, 그 외는 60초) | 1에서 600000 사이의 정수입니다. 설정하면 사용 가능한 업스트림 `Retry-After` 또는 Codex 재설정 신호가 없을 때 요청 속도 제한 429를 포함한 대상별 쿨다운으로 적용됩니다. 설정하지 않으면 업스트림 폴백을 사용합니다. | | `waitForCooldownMs` | 아니요 | `0` | 0에서 600000 사이의 정수입니다. `combo_unavailable`을 반환하기 전에 가장 먼저 적합해지는 쿨다운 중인 대상을 기다리는 최대 시간입니다. 중단하면 대기가 취소됩니다. | diff --git a/docs-site/src/content/docs/ko/guides/providers.md b/docs-site/src/content/docs/ko/guides/providers.md index ce779d0e7dd..8027e1ff1fc 100644 --- a/docs-site/src/content/docs/ko/guides/providers.md +++ b/docs-site/src/content/docs/ko/guides/providers.md @@ -190,7 +190,7 @@ Kiro 로그인에는 Kiro CLI가 필요합니다. Unix에서는 `curl -fsSL http ## 3. API 키 카탈로그 -opencodex에는 빌트인 프리셋이 98개 들어 있습니다. 키 방식 81개, OAuth 13개, 로컬 3개, +opencodex에는 빌트인 프리셋이 99개 들어 있습니다. 키 방식 82개, OAuth 13개, 로컬 3개, 기본 ChatGPT 포워드 프리셋 1개입니다. 대시보드의 **Add provider** 선택기는 키 발급 페이지를 열고, 입력한 키를 검증한 뒤 저장합니다(검증은 프로바이더별로 다릅니다). 주요 항목은 다음과 같습니다: diff --git a/docs-site/src/content/docs/ko/reference/configuration/routing.md b/docs-site/src/content/docs/ko/reference/configuration/routing.md index 2f617695ce6..3576338096d 100644 --- a/docs-site/src/content/docs/ko/reference/configuration/routing.md +++ b/docs-site/src/content/docs/ko/reference/configuration/routing.md @@ -66,7 +66,7 @@ Codex Auth 페이지에서 이 picker 동작을 opt-in할 수 있습니다. 비 | Key | Type | Default | Meaning | | --- | --- | --- | --- | | `targets` | `{ provider: string; model: string; weight?: number }[]` | required | 순서가 있는 concrete route입니다. `weight`는 1–10000이며 기본값은 `1`입니다. | -| `strategy?` | `"failover" \| "round-robin" \| "random" \| "least-used" \| "reset-window"` | `"failover"` | 선택 전략입니다. 대상 순서는 `failover` 우선순위이고, 가중치는 `round-robin`과 `random` 추첨 비율을 결정하며, `least-used`는 기록된 성공 횟수를 따르고, `reset-window`는 가장 가까운 할당량 재설정을 따릅니다. | +| `strategy?` | `"failover" \| "round-robin" \| "random" \| "least-used" \| "reset-window" \| "jev"` | `"failover"` | 선택 전략입니다. 대상 순서는 `failover` 우선순위이고, 가중치는 `round-robin`과 `random` 추첨 비율을 결정하며, `least-used`는 기록된 성공 횟수를 따르고, `reset-window`는 가장 가까운 할당량 재설정을 따릅니다. `jev`는 첫 번째 적격 대상과 effort를 한 번의 제한된 결정으로 선택한 뒤 일반적인 순서 기반 fallback을 사용합니다. | | `stickyLimit?` | `number` | `1` | 한 round-robin 배치에서 유지되는 성공 요청 수입니다. 범위는 1–100입니다. | | `defaultEffort?` | `"low" \| "medium" \| "high" \| "xhigh" \| "max" \| "ultra" \| null` | unset | `defaultEffort`는 콤보 기본값이 null이 아니고, 선택한 대상의 지원 목록이 알려져 있으며 비어 있지 않을 때 생략된 `reasoning.effort`를 채웁니다. 설정값을 지원하면 그대로 사용합니다. 그렇지 않으면 설정값 이하의 가장 높은 지원 단계를 사용하고, 그런 단계가 없으면 가장 낮은 지원 단계를 사용합니다. 지원 목록이 없거나 비어 있으면 기본값을 생략합니다. | | `reasoningEffortMode?` | `"strict" \| "adaptive"` | `"strict"` | `"strict"`는 빈 목록을 포함한 알려진 대상 지원 목록의 교집합을 사용하고, `"adaptive"`는 빈 목록을 제외합니다. 알 수 없는 목록은 두 모드 모두 교집합을 제한하지 않습니다. 전송 시 명시적 빈 목록은 두 모드에서 effort·thinking 제어를 제거하고, 알 수 없는 목록은 adaptive에서만 제거합니다. `reasoning.summary`는 보존됩니다. 알려진 비어 있지 않은 대상의 effort 결정과 대상 선택·순서는 그대로입니다. | diff --git a/docs-site/src/content/docs/reference/configuration/routing.md b/docs-site/src/content/docs/reference/configuration/routing.md index 2c327bcd257..ecc0ea666c5 100644 --- a/docs-site/src/content/docs/reference/configuration/routing.md +++ b/docs-site/src/content/docs/reference/configuration/routing.md @@ -86,7 +86,7 @@ namespace, and cannot use reserved bare native families such as `gpt-*`, `o1-*`, | Key | Type | Default | Meaning | | --- | --- | --- | --- | | `targets` | `{ provider: string; model: string; weight?: number; lastResort?: boolean }[]` | required | Ordered concrete routes. `weight` is 1–10000 and defaults to `1`. `lastResort` marks an emergency-only target; see `cooldownWaitPolicy`. | -| `strategy?` | `"failover" \| "round-robin" \| "random" \| "least-used" \| "reset-window"` | `"failover"` | Selection strategy. Target order is failover priority; weights shape round-robin and random draws; least-used follows recorded successes; reset-window follows the soonest quota reset. | +| `strategy?` | `"failover" \| "round-robin" \| "random" \| "least-used" \| "reset-window" \| "jev"` | `"failover"` | Selection strategy. Target order is failover priority; weights shape round-robin and random draws; least-used follows recorded successes; reset-window follows the soonest quota reset; JEV makes one bounded decision for the initial eligible target and effort, then uses ordinary ordered fallback. | | `stickyLimit?` | `number` | `1` | Successful requests retained in one round-robin batch. Range 1–100. Applies only to round-robin. | | `cooldownMs?` | `number` | unset → upstream fallback (5 s for request-rate 429 codes `1302`/`1305`, otherwise 60 s) | Range 1–600000. When set, applies whenever no usable upstream `Retry-After` or Codex reset signal exists, including request-rate 429s; when unset, uses the upstream fallback. Upstream signals take precedence. An explicit upstream `Retry-After` is capped at 24 hours; reset-derived, configured, and fallback cooldowns are capped at 10 minutes. | | `waitForCooldownMs?` | `number` | `0` | Maximum wait for the earliest eligible cooling target on each selection attempt. Range 0–600000; an abort cancels the wait. A single-target combo with a nonzero wait holds the request up to this ceiling and retries the same target instead of failing immediately; if no target was ever dispatched the wait ends in `combo_unavailable`, otherwise the last upstream failure is returned. | @@ -119,6 +119,14 @@ namespace, and cannot use reserved bare native families such as `gpt-*`, `o1-*`, For strategy behavior, retryable failures, cooldowns, encrypted v2 task limits, and management commands, see [Combos](/guides/combos/). +The `jev` strategy is optional and requires the canonical `jev` provider credential. That provider +is a decision service, publishes no directly routable model, and cannot be a Combo target. JEV sees +only currently eligible members of `targets`; missing, failed, or invalid decisions use the first +eligible member, while caller cancellation remains terminal. Adding the provider or Combo never +changes `defaultProvider` or hides direct model rows. See +[JEV: decision-guided first pick](/guides/combos/#jev-decision-guided-first-pick) for setup, privacy +bounds, and the one-decision-per-call contract. + ## Routing policy profiles (`config.routingProfiles`) Routing policy profiles are the Router Intelligence selection layer: an explicitly requested @@ -202,8 +210,8 @@ echoed as given. The CLI dry-run cannot supply these per-candidate account field ### Combos vs policy profiles - A **combo** is explicit target routing with a selectable strategy (ordered failover, smooth - weighted or random balancing, least-used, or reset-window): the configured strategy decides, - and retryable failures advance through the list. + weighted or random balancing, least-used, reset-window, or one bounded JEV first-pick decision): + the configured strategy decides, and retryable failures advance through the list. - A **policy profile** is evidence-based selection among configured candidates: hard capability requirements filter first, then deterministic scoring ranks the survivors. diff --git a/docs-site/src/content/docs/reference/management-api.md b/docs-site/src/content/docs/reference/management-api.md index 39dc120e6ed..d669f52fe22 100644 --- a/docs-site/src/content/docs/reference/management-api.md +++ b/docs-site/src/content/docs/reference/management-api.md @@ -295,6 +295,7 @@ by the current window size. | `GET /api/debug/injection-logs` | Read bounded guidance-injection debug entries | — | | `GET /api/claude/inbound-debug` | Read Claude inbound debug state and entries | — | | `GET /api/usage` | Scan the usage ledger into compact aggregates of readable rows, then incrementally fold verified appends; summarize by preset or inclusive custom window and client surface, with a Codex `accounts` breakdown keyed by stable non-PII log labels | 400 invalid custom bounds; returns an `error: "read_failed"` summary if storage cannot be read | +| `GET /api/usage?jev=1` | Project persisted JEV decisions and their physical target sends. Optional `comboId` selects one Combo; `range` accepts `7d`, `30d`, or `all` (default `30d`). | 400 invalid `comboId`; 500 `{ "error": "read_failed" }` if the ledger cannot be read | | `GET /api/usage/timeline` | Bucketed usage by model/account; accepts `hours`, `bucketMinutes`, `metric`, `aggregation`, `grouping`, comma-separated `models` and repeated `hiddenProvider` filters | 400 invalid query or limits | | `GET /api/metrics` | Return process-local Prometheus text metrics for logical requests, physical sends, recovery kinds, duration, and TTFT. Labels are closed to protocol, result, and recovery class; request and credential identifiers are never exported. | 404 when `metricsExport.enabled` was not true at startup; ordinary management authentication is required and data-plane credentials grant no access | | `GET /api/storage` | Scan Codex storage usage by bucket | Returns an `error: "scan_failed"` payload on scan failure | @@ -345,6 +346,22 @@ No provider, model, or API-key identifier is shortened to make a row fit. An abs that every ledger record was valid. This is separate from `historyTruncated`, `entriesTruncated`, and token measurement coverage. +The JEV projection reports decision counts and gates, applied versus fail-open picks, decision +latency/confidence, TypeSafe-reported decision tokens, physical target send counts, available model +token/cache totals, reasoning-effort picks, and requests that sent to a target other than JEV's +initial choice. `attempts` are summed from each persisted attempt's `sendCount`; a zero-send row is +not a fallback. One reported usage object is counted once even when its attempt retried, so the +measured-attempt count can be lower than the physical-send count. Model cardinality is bounded; an +explicit overflow row aggregates additional identities without dropping summary totals. + +JEV stats use the same append-only `usage.jsonl` ledger and cooperative scanner as ordinary usage. +Cold reads scan the current ledger, unchanged polls perform no file read, and verified growth folds +only the appended suffix. Concurrent identical requests share one scan. The persisted JEV record is +closed and content-free: Combo id, selected provider/model/effort, decision gate, latency, optional +confidence/probability, and optional numeric decision usage. It never stores prompts, headers, +credentials, tool arguments, or raw TypeSafe responses. Oversized rows retain the same positive +`usageIncomplete` diagnostic as the ordinary usage endpoint. + New xAI attempts in `usage.jsonl` include a request-time `credentialSource`: `grok-oauth` for the resolved Grok CLI OAuth transport, or `xai-api-key` for the public xAI API key transport. This fixed label contains no credential or account identifier. It belongs to diff --git a/docs-site/src/content/docs/ru/getting-started/quickstart.md b/docs-site/src/content/docs/ru/getting-started/quickstart.md index 825ec591936..945893e0457 100644 --- a/docs-site/src/content/docs/ru/getting-started/quickstart.md +++ b/docs-site/src/content/docs/ru/getting-started/quickstart.md @@ -18,7 +18,7 @@ ocx init `ocx init` проведёт вас по следующим шагам: -1. **Выбор провайдера** — выберите один из 98 встроенных пресетов реестра или `custom`, чтобы +1. **Выбор провайдера** — выберите один из 99 встроенных пресетов реестра или `custom`, чтобы ввести базовый URL и адаптер вручную. 2. **API-ключ** — вставьте ключ или сошлитесь на переменную окружения вида `${ANTHROPIC_API_KEY}`. 3. **Модель по умолчанию** — для провайдеров с ключом, локальных и `custom` примите значение из diff --git a/docs-site/src/content/docs/ru/guides/combos.md b/docs-site/src/content/docs/ru/guides/combos.md index cb2ca35806f..771c7de8a91 100644 --- a/docs-site/src/content/docs/ru/guides/combos.md +++ b/docs-site/src/content/docs/ru/guides/combos.md @@ -290,7 +290,7 @@ Combo хранятся в объекте верхнего уровня `combos`, | `targets` | Yes | — | Непустой упорядоченный массив настроенных целей `{ provider, model, weight? }`. Дубли пар provider/model запрещены. | | `targets[].weight` | No | `1` | Целое число от 1 до 10 000. Используется стратегиями `round-robin` и `random`; игнорируется стратегиями `failover`, `least-used` и `reset-window`. | | `targets[].lastResort` | Нет | `false` | Помечает цель как резервную, только для аварийных случаев. Не действует, пока не задан `cooldownWaitPolicy`. Никогда не исключает цель навсегда: если ни одна обычная цель недоступна, она используется как обычно. | -| `strategy` | No | `"failover"` | `"failover"`, `"round-robin"`, `"random"`, `"least-used"` или `"reset-window"`. | +| `strategy` | No | `"failover"` | `"failover"`, `"round-robin"`, `"random"`, `"least-used"`, `"reset-window"` или `"jev"`. JEV выбирает только первую подходящую цель и effort; последующие попытки выполняет обычный fallback Combo. | | `stickyLimit` | No | `1` | Целое число от 1 до 100 успешных запросов на один выбор `round-robin`. Применяется только к `round-robin`. | | `cooldownMs` | No | не задано → fallback upstream (5 с для rate-limit 429 с кодами `1302`/`1305`, иначе 60 с) | Целое число от 1 до 600000. Если задано, применяется как cooldown каждой цели, когда нет пригодного upstream `Retry-After` или сигнала сброса Codex, включая rate-limit 429; если не задано, используется fallback upstream. | | `waitForCooldownMs` | No | `0` | Целое число от 0 до 600000. Максимальное время ожидания самой ранней подходящей цели в cooldown перед возвратом `combo_unavailable`; отмена запроса отменяет ожидание. | diff --git a/docs-site/src/content/docs/ru/guides/providers.md b/docs-site/src/content/docs/ru/guides/providers.md index a83fb7145f9..579005a20dd 100644 --- a/docs-site/src/content/docs/ru/guides/providers.md +++ b/docs-site/src/content/docs/ru/guides/providers.md @@ -206,7 +206,7 @@ Inline JSON и лишние позиционные аргументы откло ## 3. Каталог API-ключей -opencodex поставляется с 98 встроенными пресетами: 81 на основе ключей, 13 OAuth, три локальных и +opencodex поставляется с 99 встроенными пресетами: 82 на основе ключей, 13 OAuth, три локальных и один пресет ChatGPT-форварда по умолчанию. Селектор **Add provider** в дашборде открывает страницу выдачи ключей провайдера, проверяет ключ и сохраняет его; проверка зависит от провайдера. Наиболее заметные записи: diff --git a/docs-site/src/content/docs/ru/reference/configuration/routing.md b/docs-site/src/content/docs/ru/reference/configuration/routing.md index 595916aebde..14e8919ef53 100644 --- a/docs-site/src/content/docs/ru/reference/configuration/routing.md +++ b/docs-site/src/content/docs/ru/reference/configuration/routing.md @@ -85,7 +85,7 @@ selector-qualified строки и возвращает обычные GPT-ст | Ключ | Тип | По умолчанию | Значение | | --- | --- | --- | --- | | `targets` | `{ provider: string; model: string; weight?: number }[]` | required | Упорядоченные конкретные маршруты. `weight` находится в диапазоне 1–10000 и по умолчанию равен `1`. | -| `strategy?` | `"failover" \| "round-robin" \| "random" \| "least-used" \| "reset-window"` | `"failover"` | Стратегия выбора. Порядок целей задаёт приоритет `failover`; значения `weight` определяют взвешивание выборов `round-robin` и `random`; `least-used` следует числу зарегистрированных успешных запросов; `reset-window` следует ближайшему сбросу квоты. | +| `strategy?` | `"failover" \| "round-robin" \| "random" \| "least-used" \| "reset-window" \| "jev"` | `"failover"` | Стратегия выбора. Порядок целей задаёт приоритет `failover`; значения `weight` определяют взвешивание выборов `round-robin` и `random`; `least-used` следует числу зарегистрированных успешных запросов; `reset-window` следует ближайшему сбросу квоты; `jev` выполняет одно ограниченное решение для первой подходящей цели и effort, после чего использует обычный упорядоченный fallback. | | `stickyLimit?` | `number` | `1` | Число успешных запросов, удерживаемых в одной партии round-robin. Диапазон 1–100. | | `defaultEffort?` | `"low" \| "medium" \| "high" \| "xhigh" \| "max" \| "ultra" \| null` | unset | `defaultEffort` заполняет отсутствующий `reasoning.effort`, если задан `defaultEffort`, отличный от `null`, и список поддерживаемых уровней цели известен и непуст. Поддерживаемое настроенное значение сохраняется; иначе выбирается максимальный поддерживаемый уровень не выше него, а если такого нет — минимальный поддерживаемый уровень. При неизвестном или пустом списке default не добавляется. | | `reasoningEffortMode?` | `"strict" \| "adaptive"` | `"strict"` | `"strict"` вычисляет пересечение известных списков уровней, включая пустые; `"adaptive"` исключает пустые списки. Неизвестные списки не ограничивают пересечение в обоих режимах. При отправке явно пустой список удаляет параметры effort/thinking в обоих режимах, а неизвестный — только в adaptive. `reasoning.summary` сохраняется. Разрешение effort для известных непустых списков, выбор и порядок целей не меняются. | diff --git a/docs-site/src/content/docs/tr/getting-started/quickstart.md b/docs-site/src/content/docs/tr/getting-started/quickstart.md index 6214216d4f4..2d45af871ca 100644 --- a/docs-site/src/content/docs/tr/getting-started/quickstart.md +++ b/docs-site/src/content/docs/tr/getting-started/quickstart.md @@ -14,7 +14,7 @@ ocx init `ocx init` adım adım size rehberlik eder: -1. **Bir sağlayıcı seçin** — yerleşik kayıt defterindeki 98 önayardan birini +1. **Bir sağlayıcı seçin** — yerleşik kayıt defterindeki 99 önayardan birini veya bir temel URL ile adaptör yazmak için `custom` seçeneğini belirleyin. 2. **API anahtarı** — bir anahtar yapıştırın veya `${ANTHROPIC_API_KEY}` gibi bir ortam değişkenine başvurun. diff --git a/docs-site/src/content/docs/tr/guides/combos.md b/docs-site/src/content/docs/tr/guides/combos.md index c8d257c31c6..4d8de7c5845 100644 --- a/docs-site/src/content/docs/tr/guides/combos.md +++ b/docs-site/src/content/docs/tr/guides/combos.md @@ -367,7 +367,7 @@ saklanır: | --- | --- | --- | --- | | `targets` | Evet | — | Yapılandırılmış `{ provider, model, weight? }` hedeflerinin boş olmayan sıralı dizisi. Yinelenen sağlayıcı/model çiftleri reddedilir. | | `targets[].weight` | Hayır | `1` | 1 ile 10.000 arasında tam sayı. `round-robin` ve `random` tarafından kullanılır; `failover`, `least-used` ve `reset-window` tarafından yok sayılır. | -| `strategy` | Hayır | `"failover"` | İzin verilen değerler: `"failover"`, `"round-robin"`, `"random"`, `"least-used"`, `"reset-window"`. | +| `strategy` | Hayır | `"failover"` | İzin verilen değerler: `"failover"`, `"round-robin"`, `"random"`, `"least-used"`, `"reset-window"`, `"jev"`. JEV yalnızca ilk uygun hedefi ve effort değerini belirler; sonraki denemeleri normal Combo fallback'i yönetir. | | `stickyLimit` | Hayır | `1` | Yalnızca `round-robin` için geçerlidir; seçim başına 1 ile 100 arasında başarılı istek tam sayısı. | | `defaultEffort` | Hayır | `null` | `low`, `medium`, `high`, `xhigh`, `max` veya `ultra`; yalnızca arayan çabayı atladığında ve hedef desteği bildirdiğinde uygulanır. | | `reasoningEffortMode` | Hayır | `"strict"` | `strict` veya `adaptive`; karma yetenek kesişimini ve hedefe özel normalizasyonu seçer. | diff --git a/docs-site/src/content/docs/tr/guides/providers.md b/docs-site/src/content/docs/tr/guides/providers.md index 58c0441070f..58c628b2ddc 100644 --- a/docs-site/src/content/docs/tr/guides/providers.md +++ b/docs-site/src/content/docs/tr/guides/providers.md @@ -327,7 +327,7 @@ olmayan bir makineden oturum açmak bundan etkilenmez. ## 3. API anahtarı kataloğu -opencodex 98 yerleşik önayar ile birlikte gelir: 81 anahtar tabanlı, 13 +opencodex 99 yerleşik önayar ile birlikte gelir: 82 anahtar tabanlı, 13 OAuth, üç yerel ve bir varsayılan ChatGPT iletme önayarı. Kontrol panelinin **Sağlayıcı ekle** seçicisi bir anahtar sağlayıcısının kontrol panelini açar, anahtarı doğrular ve saklar; doğrulama sağlayıcıya özgüdür. Dikkate değer diff --git a/docs-site/src/content/docs/tr/reference/configuration/routing.md b/docs-site/src/content/docs/tr/reference/configuration/routing.md index b1cbe4b4842..4834655a4fd 100644 --- a/docs-site/src/content/docs/tr/reference/configuration/routing.md +++ b/docs-site/src/content/docs/tr/reference/configuration/routing.md @@ -108,7 +108,7 @@ aileleri kullanamaz. | Anahtar | Tip | Varsayılan | Anlamı | | --- | --- | --- | --- | | `targets` | `{ provider: string; model: string; weight?: number }[]` | gerekli | Sıralı somut rotalar. `weight` 1–10000 arasındadır ve varsayılan olarak `1`'dir. | -| `strategy?` | `"failover" \| "round-robin" \| "random" \| "least-used" \| "reset-window"` | `"failover"` | Seçim stratejisi. Hedef sırası `failover` önceliğini belirler; `weight` değerleri `round-robin` ve `random` seçimlerini biçimlendirir; `least-used` kaydedilen başarılı istekleri izler; `reset-window` en yakın kota sıfırlamasını izler. | +| `strategy?` | `"failover" \| "round-robin" \| "random" \| "least-used" \| "reset-window" \| "jev"` | `"failover"` | Seçim stratejisi. Hedef sırası `failover` önceliğini belirler; `weight` değerleri `round-robin` ve `random` seçimlerini biçimlendirir; `least-used` kaydedilen başarılı istekleri izler; `reset-window` en yakın kota sıfırlamasını izler; `jev` ilk uygun hedef ve effort için tek bir sınırlı karar verir, ardından normal sıralı fallback'i kullanır. | | `stickyLimit?` | `number` | `1` | Tek bir round-robin grubunda tutulan başarılı istekler. Aralık 1–100. | | `defaultEffort?` | `"low" \| "medium" \| "high" \| "xhigh" \| "max" \| "ultra" \| null` | ayarlanmamış | `defaultEffort`, combo varsayılanı null değilse ve hedefin desteklenen seviye listesi bilinen ve boş olmayan bir listeyse eksik `reasoning.effort` değerini doldurur. Yapılandırılmış değer destekleniyorsa korunur; değilse bu değeri aşmayan en yüksek desteklenen seviye, böyle bir seviye yoksa en düşük desteklenen seviye kullanılır. Liste bilinmiyor veya boşsa varsayılan eklenmez. | | `reasoningEffortMode?` | `"strict" \| "adaptive"` | `"strict"` | `"strict"`, boş listeler dahil bilinen hedef seviye listelerinin kesişimini alır; `"adaptive"` boş listeleri çıkarır. Bilinmeyen listeler iki modda da kesişimi sınırlamaz. Gönderimde açıkça boş listeler iki modda effort/thinking denetimlerini kaldırır; bilinmeyen listeler bunu yalnızca adaptive modunda yapar. `reasoning.summary` korunur. Bilinen boş olmayan hedeflerin effort çözümü ve hedef seçimi/sırası değişmez. | @@ -311,4 +311,3 @@ değişmeden ayrıştırılır. Geçmiş dizini tek kullanımlıktır - otomatik bir yeniden oluşturmayı tetikler; `ocx logs rebuild-index` bunu zorlar. Bu sistemdeki hiçbir şey ağırlıkları, bütçeleri veya aday kümelerini otomatik olarak ayarlamaz. - diff --git a/docs-site/src/content/docs/zh-cn/getting-started/quickstart.md b/docs-site/src/content/docs/zh-cn/getting-started/quickstart.md index 8682fa62205..cb5abdcc458 100644 --- a/docs-site/src/content/docs/zh-cn/getting-started/quickstart.md +++ b/docs-site/src/content/docs/zh-cn/getting-started/quickstart.md @@ -18,7 +18,7 @@ ocx init `ocx init` 会引导你完成: -1. **选择 provider** — 从内置 registry 的 98 个预设中选择一个,或选择 `custom` 手动输入 base URL 和 adapter。 +1. **选择 provider** — 从内置 registry 的 99 个预设中选择一个,或选择 `custom` 手动输入 base URL 和 adapter。 2. **API key** — 粘贴一个 key,或引用一个环境变量,例如 `${ANTHROPIC_API_KEY}`。 3. **默认模型** — 对于 key、本地和 custom provider,接受预设值或输入模型 id。 4. **代理端口** — 默认为 `10100`。 diff --git a/docs-site/src/content/docs/zh-cn/guides/combos.md b/docs-site/src/content/docs/zh-cn/guides/combos.md index 31a5764aa7a..6d24287baba 100644 --- a/docs-site/src/content/docs/zh-cn/guides/combos.md +++ b/docs-site/src/content/docs/zh-cn/guides/combos.md @@ -266,7 +266,7 @@ combo 会存储在顶层的 `combos` 对象中,并以 combo id 作为键: | `targets` | 是 | — | 非空、有顺序的数组,元素为已配置的 `{ provider, model, weight? }` 目标。重复的 provider/model 对会被拒绝。 | | `targets[].weight` | 否 | `1` | 1 到 10,000 的整数。`round-robin` 和 `random` 会使用它;`failover`、`least-used` 和 `reset-window` 会忽略它。 | | `targets[].lastResort` | 否 | `false` | 标记为仅在紧急情况下使用的目标。未设置 `cooldownWaitPolicy` 时不生效。它不会永久排除该目标:当没有普通目标可用时,仍会照常派发。 | -| `strategy` | 否 | `"failover"` | `"failover"`、`"round-robin"`、`"random"`、`"least-used"` 或 `"reset-window"`。 | +| `strategy` | 否 | `"failover"` | `"failover"`、`"round-robin"`、`"random"`、`"least-used"`、`"reset-window"` 或 `"jev"`。JEV 只决定首个符合条件的目标和 effort;后续尝试由普通 Combo fallback 处理。 | | `stickyLimit` | 否 | `1` | 每次 `round-robin` 选择可连续处理 1 到 100 个成功请求。仅适用于 `round-robin`。 | | `cooldownMs` | 否 | 未设置 → 上游回退值(请求速率限制代码为 `1302`/`1305` 的 429 为 5 秒,否则为 60 秒) | 1 到 600000 的整数。设置后,只要没有可用的上游 `Retry-After` 或 Codex 重置信号,就会作为每个目标的冷却时间应用,包括请求速率限制 429;未设置时使用上游回退值。 | | `waitForCooldownMs` | 否 | `0` | 0 到 600000 的整数。在返回 `combo_unavailable` 前等待最早恢复资格的冷却中目标的最长时间;请求中止会取消等待。 | diff --git a/docs-site/src/content/docs/zh-cn/guides/providers.md b/docs-site/src/content/docs/zh-cn/guides/providers.md index 280c2d9414d..04551fd679d 100644 --- a/docs-site/src/content/docs/zh-cn/guides/providers.md +++ b/docs-site/src/content/docs/zh-cn/guides/providers.md @@ -181,7 +181,7 @@ Kiro 登录需要 Kiro CLI:Unix 使用 `curl -fsSL https://cli.kiro.dev/instal ## 3. API 密钥目录 -opencodex 内置 98 个预设:81 个密钥预设、13 个 OAuth 预设、3 个本地预设,以及 1 个默认的 +opencodex 内置 99 个预设:82 个密钥预设、13 个 OAuth 预设、3 个本地预设,以及 1 个默认的 ChatGPT 转发预设。仪表盘的 **Add provider** 选择器会打开密钥提供商的控制台,验证并保存密钥。 验证因提供商而异。主要条目包括: diff --git a/docs-site/src/content/docs/zh-cn/reference/configuration/routing.md b/docs-site/src/content/docs/zh-cn/reference/configuration/routing.md index fa7d04ffc55..39c1aa7248e 100644 --- a/docs-site/src/content/docs/zh-cn/reference/configuration/routing.md +++ b/docs-site/src/content/docs/zh-cn/reference/configuration/routing.md @@ -71,7 +71,7 @@ Codex Auth 页面将此 picker 行为作为选择加入项。关闭它会隐藏 | 键 | 类型 | 默认值 | 含义 | | --- | --- | --- | --- | | `targets` | `{ provider: string; model: string; weight?: number }[]` | required | 有序的具体路由。`weight` 范围为 1–10000,默认值为 `1`。 | -| `strategy?` | `"failover" \| "round-robin" \| "random" \| "least-used" \| "reset-window"` | `"failover"` | 选择策略。目标顺序表示 `failover` 优先级;`weight` 决定 `round-robin` 和 `random` 的抽取权重;`least-used` 根据记录的成功次数选择;`reset-window` 跟随最近的额度重置。 | +| `strategy?` | `"failover" \| "round-robin" \| "random" \| "least-used" \| "reset-window" \| "jev"` | `"failover"` | 选择策略。目标顺序表示 `failover` 优先级;`weight` 决定 `round-robin` 和 `random` 的抽取权重;`least-used` 根据记录的成功次数选择;`reset-window` 跟随最近的额度重置;`jev` 对首个符合条件的目标和 effort 进行一次有界决策,随后使用普通的顺序 fallback。 | | `stickyLimit?` | `number` | `1` | 在单个轮询批次中保留的成功请求数。范围 1–100。 | | `defaultEffort?` | `"low" \| "medium" \| "high" \| "xhigh" \| "max" \| "ultra" \| null` | unset | 当 combo 配置了非 null 默认值且目标支持列表已知且非空时,`defaultEffort` 会填充省略的 `reasoning.effort`。目标支持配置值时保留该值,否则选择不高于配置值的最高支持档位;若不存在更低档位,则使用最低支持档位。未知或空列表不会注入默认值。 | | `reasoningEffortMode?` | `"strict" \| "adaptive"` | `"strict"` | `"strict"` 对所有已知目标档位列表取交集,包括空列表;`"adaptive"` 排除空列表。未知列表在两种模式下都不限制目录交集。发送时,显式空列表在两种模式下都会移除 effort/thinking 控制;未知列表仅在 adaptive 下移除。`reasoning.summary` 保持不变。已知非空目标的 effort 解析、目标选择和顺序不变。 | diff --git a/docs-site/src/content/docs/zh-tw/getting-started/quickstart.md b/docs-site/src/content/docs/zh-tw/getting-started/quickstart.md index 5cfb4c80415..d7e601b025e 100644 --- a/docs-site/src/content/docs/zh-tw/getting-started/quickstart.md +++ b/docs-site/src/content/docs/zh-tw/getting-started/quickstart.md @@ -13,7 +13,7 @@ ocx init `ocx init` 會引導你完成: -1. **選擇 provider** —— 從內建 registry 的 98 個預設中選擇一個,或選擇 `custom` 手動輸入 +1. **選擇 provider** —— 從內建 registry 的 99 個預設中選擇一個,或選擇 `custom` 手動輸入 base URL 和 adapter。 2. **API key** —— 貼上一個 key,或引用一個環境變數,例如 `${ANTHROPIC_API_KEY}`。 3. **預設模型** —— 對於 API key、本機和 custom provider,可接受預設值或輸入模型 id。 diff --git a/docs-site/src/content/docs/zh-tw/guides/combos.md b/docs-site/src/content/docs/zh-tw/guides/combos.md index 0ece36f42a1..f006f67dad6 100644 --- a/docs-site/src/content/docs/zh-tw/guides/combos.md +++ b/docs-site/src/content/docs/zh-tw/guides/combos.md @@ -270,7 +270,7 @@ Combo 儲存於頂層 `combos` 物件中,以 combo id 為 key: | --- | --- | --- | --- | | `targets` | 是 | — | 已設定 `{ provider, model, weight? }` 目標的非空有序陣列。重複的供應商/模型對會被拒絕。 | | `targets[].weight` | 否 | `1` | 1 到 10,000 的整數。由 `round-robin` 與 `random` 使用;`failover`、`least-used` 與 `reset-window` 忽略。 | -| `strategy` | 否 | `"failover"` | 可用值為 `"failover"`、`"round-robin"`、`"random"`、`"least-used"`、`"reset-window"`。 | +| `strategy` | 否 | `"failover"` | 可用值為 `"failover"`、`"round-robin"`、`"random"`、`"least-used"`、`"reset-window"`、`"jev"`。JEV 只決定第一個符合條件的目標與 effort;後續嘗試由一般 Combo fallback 處理。 | | `stickyLimit` | 否 | `1` | 僅適用於 `round-robin`:每次選擇的成功請求數,1 到 100 的整數。 | | `defaultEffort` | 否 | `null` | `low`、`medium`、`high`、`xhigh`、`max` 或 `ultra`;僅在呼叫者省略 effort 且目標宣告支援時套用。 | | `reasoningEffortMode` | 否 | `"strict"` | `strict` 或 `adaptive`;選擇混合能力交集及目標層級控制正規化。 | diff --git a/docs-site/src/content/docs/zh-tw/guides/providers.md b/docs-site/src/content/docs/zh-tw/guides/providers.md index 95f6eff34ab..5d68c93eb51 100644 --- a/docs-site/src/content/docs/zh-tw/guides/providers.md +++ b/docs-site/src/content/docs/zh-tw/guides/providers.md @@ -249,7 +249,7 @@ database 並移除目前的 WAL、SHM 與 journal sidecar,再發布先前的 s ## 3. API 金鑰目錄 -opencodex 內建 98 個 preset:81 個 key-based、13 個 OAuth、3 個 local,以及 1 個預設 ChatGPT-forward +opencodex 內建 99 個 preset:82 個 key-based、13 個 OAuth、3 個 local,以及 1 個預設 ChatGPT-forward preset。儀表板的 **Add provider** picker 會開啟 key provider 的 dashboard、驗證金鑰並儲存;驗證方式 依 provider 而異。主要條目如下。 diff --git a/docs-site/src/content/docs/zh-tw/reference/configuration/routing.md b/docs-site/src/content/docs/zh-tw/reference/configuration/routing.md index 2001cf14a9b..b4cb202b456 100644 --- a/docs-site/src/content/docs/zh-tw/reference/configuration/routing.md +++ b/docs-site/src/content/docs/zh-tw/reference/configuration/routing.md @@ -54,7 +54,7 @@ Codex Auth 頁面將此 picker 行為作為選擇加入功能暴露。停用它 | Key | 型別 | 預設值 | 意義 | | --- | --- | --- | --- | | `targets` | `{ provider: string; model: string; weight?: number }[]` | 必填 | 有序的具體路由。`weight` 為 1–10000,預設 `1`。 | -| `strategy?` | `"failover" \| "round-robin" \| "random" \| "least-used" \| "reset-window"` | `"failover"` | 選擇策略。目標順序為 `failover` 優先序;`weight` 塑造 `round-robin` 與 `random` 抽選;`least-used` 依循已記錄的成功次數;`reset-window` 依循最早的配額重設。 | +| `strategy?` | `"failover" \| "round-robin" \| "random" \| "least-used" \| "reset-window" \| "jev"` | `"failover"` | 選擇策略。目標順序為 `failover` 優先序;`weight` 塑造 `round-robin` 與 `random` 抽選;`least-used` 依循已記錄的成功次數;`reset-window` 依循最早的配額重設;`jev` 對第一個符合條件的目標與 effort 執行一次有界決策,之後使用一般的順序 fallback。 | | `stickyLimit?` | `number` | `1` | 在一個 round-robin 批次中保留的成功請求數。範圍 1–100。 | | `defaultEffort?` | `"low" \| "medium" \| "high" \| "xhigh" \| "max" \| "ultra" \| null` | 未設定 | 當 combo 設定非 null 預設值且目標支援清單已知且非空時,`defaultEffort` 會補入省略的 `reasoning.effort`。目標支援設定值時保留該值,否則選擇不高於設定值的最高支援層級;若沒有更低層級,則使用最低支援層級。未知或空清單不會注入預設值。 | | `reasoningEffortMode?` | `"strict" \| "adaptive"` | `"strict"` | `"strict"` 對所有已知目標層級清單取交集,包括空清單;`"adaptive"` 排除空清單。未知清單在兩種模式下都不限制目錄交集。傳送時,明確空清單在兩種模式下都會移除 effort/thinking 控制;未知清單只在 adaptive 移除。`reasoning.summary` 保持不變。已知非空目標的 effort 解析、目標選擇及順序不變。 | diff --git a/gui/src/app-routing.ts b/gui/src/app-routing.ts index 5a9eb8b635b..539068c88ef 100644 --- a/gui/src/app-routing.ts +++ b/gui/src/app-routing.ts @@ -71,6 +71,8 @@ export const DASHBOARD_TAB_HASHES = ["dashboard/providers", "dashboard/models"] * uses for Overview and Logs uses for the log list. */ export const MODELS_TAB_HASHES = ["models/combos", "models/routing", "models/compatibility"] as const; +/** Action deep link that opens the editable JEV Auto template in the Combos tab. */ +export const JEV_AUTO_CREATE_HASH = "models/combos/jev-auto"; /** * `#dashboard/update` is an action deep link, not a tab: the sidebar update button uses @@ -120,7 +122,10 @@ export function hashBelongsToPage(rawHash: string, page: Page): boolean { return rawHash === page || (page === "logs" && rawHash === "logs/debug") || (page === "codex-set" && rawHash === "codex-set/prompt") - || (page === "models" && (MODELS_TAB_HASHES as readonly string[]).includes(rawHash)) + || (page === "models" && ( + (MODELS_TAB_HASHES as readonly string[]).includes(rawHash) + || rawHash === JEV_AUTO_CREATE_HASH + )) || (page === "dashboard" && (rawHash === DASHBOARD_UPDATE_HASH || (DASHBOARD_TAB_HASHES as readonly string[]).includes(rawHash))) || (page === "integrations" diff --git a/gui/src/combo-workspace-data.ts b/gui/src/combo-workspace-data.ts index b1f3f19f61e..0695cd5eada 100644 --- a/gui/src/combo-workspace-data.ts +++ b/gui/src/combo-workspace-data.ts @@ -9,7 +9,7 @@ import type { TKey } from "./i18n/shared"; export { SUPPORTED_NATIVE_OPENAI_SLUGS }; -export type ComboStrategy = "failover" | "round-robin" | "random" | "least-used" | "reset-window"; +export type ComboStrategy = "failover" | "round-robin" | "random" | "least-used" | "reset-window" | "jev"; export type ComboEffort = "low" | "medium" | "high" | "xhigh" | "max" | "ultra"; export const COMBO_EFFORTS: ComboEffort[] = ["low", "medium", "high", "xhigh", "max", "ultra"]; @@ -20,6 +20,7 @@ export const COMBO_STRATEGIES: readonly ComboStrategy[] = [ "random", "least-used", "reset-window", + "jev", ] as const; export const COMBO_STRATEGY_LABEL_KEYS: Record = { @@ -28,6 +29,7 @@ export const COMBO_STRATEGY_LABEL_KEYS: Record = { random: "cws.strategy.random", "least-used": "cws.strategy.leastUsed", "reset-window": "cws.strategy.resetWindow", + jev: "cws.strategy.jev", }; export const COMBO_STRATEGY_HINT_KEYS: Record = { @@ -36,6 +38,7 @@ export const COMBO_STRATEGY_HINT_KEYS: Record = { random: "cws.strategy.randomHint", "least-used": "cws.strategy.leastUsedHint", "reset-window": "cws.strategy.resetWindowHint", + jev: "cws.strategy.jevHint", }; export const COMBO_TARGETS_HINT_KEYS: Record = { @@ -44,6 +47,7 @@ export const COMBO_TARGETS_HINT_KEYS: Record = { random: "cws.targets.randomHint", "least-used": "cws.targets.leastUsedHint", "reset-window": "cws.targets.resetWindowHint", + jev: "cws.targets.jevHint", }; const COMBO_STRATEGY_SET = new Set(COMBO_STRATEGIES); @@ -85,6 +89,8 @@ export interface ComboTarget { provider: string; model: string; weight?: number; + /** Exact efforts JEV may choose; omitted means every currently advertised effort. */ + reasoningEfforts?: ComboEffort[]; /** UI-only stable key for React lists; never sent to the API. */ clientKey?: string; } @@ -102,6 +108,9 @@ export function newComboTarget(partial: Partial = {}): ComboTarget provider: partial.provider ?? "", model: partial.model ?? "", ...(partial.weight !== undefined ? { weight: partial.weight } : {}), + ...(partial.reasoningEfforts !== undefined + ? { reasoningEfforts: [...partial.reasoningEfforts] } + : {}), clientKey: partial.clientKey ?? `ct-${++comboTargetKeySeq}`, }; } @@ -209,6 +218,20 @@ export function normalizeWeight(raw: unknown): number | undefined { : undefined; } +function normalizeTargetReasoningEfforts(raw: unknown): ComboEffort[] | undefined { + if (!Array.isArray(raw) || raw.length === 0) return undefined; + const efforts: ComboEffort[] = []; + const seen = new Set(); + for (const value of raw) { + if (typeof value !== "string" || !(COMBO_EFFORTS as string[]).includes(value)) return undefined; + const effort = value as ComboEffort; + if (seen.has(effort)) return undefined; + seen.add(effort); + efforts.push(effort); + } + return efforts; +} + export function parseComboList(payload: unknown): ComboItem[] { if (!payload || typeof payload !== "object") return []; const rows = (payload as { combos?: unknown }).combos; @@ -228,7 +251,13 @@ export function parseComboList(payload: unknown): ComboItem[] { const model = typeof tr.model === "string" ? tr.model.trim() : ""; if (!provider || !model) continue; const weight = normalizeWeight(tr.weight); - targets.push(weight !== undefined ? newComboTarget({ provider, model, weight }) : newComboTarget({ provider, model })); + const reasoningEfforts = normalizeTargetReasoningEfforts(tr.reasoningEfforts); + targets.push(newComboTarget({ + provider, + model, + ...(weight !== undefined ? { weight } : {}), + ...(reasoningEfforts !== undefined ? { reasoningEfforts } : {}), + })); } out.push({ id, @@ -393,6 +422,14 @@ export function buildComboAttention( return out; } +function targetReasoningEffortsEqual(a: ComboTarget, b: ComboTarget): boolean { + if (a.reasoningEfforts === undefined || b.reasoningEfforts === undefined) { + return a.reasoningEfforts === b.reasoningEfforts; + } + return a.reasoningEfforts.length === b.reasoningEfforts.length + && a.reasoningEfforts.every((effort, index) => effort === b.reasoningEfforts![index]); +} + export function draftEquals(a: ComboItem, b: ComboItem): boolean { if ( a.id !== b.id @@ -408,7 +445,10 @@ export function draftEquals(a: ComboItem, b: ComboItem): boolean { if (a.targets.length !== b.targets.length) return false; return a.targets.every((t, i) => { const o = b.targets[i]!; - return t.provider === o.provider && t.model === o.model && (t.weight ?? 1) === (o.weight ?? 1); + return t.provider === o.provider + && t.model === o.model + && (t.weight ?? 1) === (o.weight ?? 1) + && targetReasoningEffortsEqual(t, o); }); } @@ -432,9 +472,14 @@ export function toPutBody(item: ComboItem, options: { renameFrom?: string } = {} id: item.id.trim(), ...(options.renameFrom ? { renameFrom: options.renameFrom } : {}), combo: { - targets: item.targets.map((target) => weighted - ? { provider: target.provider.trim(), model: target.model.trim(), weight: target.weight ?? 1 } - : { provider: target.provider.trim(), model: target.model.trim() }), + targets: item.targets.map((target) => ({ + provider: target.provider.trim(), + model: target.model.trim(), + ...(weighted ? { weight: target.weight ?? 1 } : {}), + ...(target.reasoningEfforts !== undefined + ? { reasoningEfforts: [...target.reasoningEfforts] } + : {}), + })), strategy: item.strategy, defaultEffort: item.defaultEffort, // The server preserves an omitted field from the stored combo (#5687), so the dashboard @@ -469,6 +514,7 @@ export type ComboDraftError = | "duplicateTarget" | "invalidStickyLimit" | "invalidWeight" + | "invalidReasoningEfforts" | "noEnabledTarget"; export function validateComboDraft( @@ -513,6 +559,12 @@ export function validateComboDraft( for (const t of item.targets) { if (!t.provider.trim() || !t.model.trim()) return "incompleteTarget"; if (!Object.hasOwn(options.providers, t.provider.trim())) return "unknownProvider"; + if (t.reasoningEfforts !== undefined + && (t.reasoningEfforts.length === 0 + || t.reasoningEfforts.some(effort => !COMBO_EFFORTS.includes(effort)) + || new Set(t.reasoningEfforts).size !== t.reasoningEfforts.length)) { + return "invalidReasoningEfforts"; + } } const targets = new Set(); @@ -555,3 +607,30 @@ export function emptyDraft(id = ""): ComboItem { targets: [newComboTarget()], }; } + +const JEV_AUTO_MODEL_IDS = ["gpt-6-astra", "gpt-5.6-sol", "gpt-5.6-luna"] as const; + +/** Build the opt-in JEV Combo template from models that are available right now. */ +export function jevAutoDraft( + models: readonly { provider: string; id: string }[], + eligibleProviders?: ReadonlySet, +): ComboItem { + const targets = JEV_AUTO_MODEL_IDS.flatMap((id) => { + const model = models.find((candidate) => candidate.id === id + && (eligibleProviders === undefined || eligibleProviders.has(candidate.provider))); + return model ? [newComboTarget({ provider: model.provider, model: model.id })] : []; + }); + return { + id: "jev-auto", + model: "jev-auto", + alias: "jev-auto", + nativeAlias: false, + displayName: null, + strategy: "jev", + stickyLimit: 1, + defaultEffort: null, + imageInput: "auto", + reasoningEffortMode: "adaptive", + targets: targets.length > 0 ? targets : [newComboTarget()], + }; +} diff --git a/gui/src/components/ComboWorkspace.tsx b/gui/src/components/ComboWorkspace.tsx index b2a30fe6d6c..c0415cabd16 100644 --- a/gui/src/components/ComboWorkspace.tsx +++ b/gui/src/components/ComboWorkspace.tsx @@ -5,6 +5,7 @@ import { emptyDraft, filterCombos, groupCombos, + jevAutoDraft, } from "../combo-workspace-data"; import { IconChevron, IconPlus, IconSearch, IconShuffle } from "../icons"; import { useT } from "../i18n/shared"; @@ -29,6 +30,7 @@ export default function ComboWorkspace({ onRemove, onAdd, adding, + addIntent, onCloseAdd, onCreated, }: ComboWorkspaceProps) { @@ -43,6 +45,22 @@ export default function ComboWorkspace({ const [removeId, setRemoveId] = useState(null); const [localBaseline, setLocalBaseline] = useState(null); const firstComboDraft = useMemo(() => emptyDraft(), []); + const jevAutoExists = useMemo( + () => combos.some(combo => combo.id === "jev-auto" || combo.alias === "jev-auto"), + [combos], + ); + const jevTargetProviders = useMemo( + () => new Set(providers + .filter(provider => !provider.disabled + && !provider.hiddenFromPicker + && provider.adapter !== "jev-decision") + .map(provider => provider.name)), + [providers], + ); + const addDraft = useMemo( + () => addIntent === "jev-auto" ? jevAutoDraft(models, jevTargetProviders) : undefined, + [addIntent, jevTargetProviders, models], + ); const filtered = useMemo(() => filterCombos(combos, query), [combos, query]); const sections = useMemo(() => groupCombos(filtered), [filtered]); @@ -89,7 +107,7 @@ export default function ComboWorkspace({ const cancelPending = () => setPendingSelect(undefined); const showUnsaved = pendingSelect !== undefined && detailDirty; - const creatingFirstCombo = !loading && combos.length === 0; + const creatingFirstCombo = !loading && combos.length === 0 && !adding; const handleAdd = () => { if (creatingFirstCombo) { document.getElementById("cwi-edit-id")?.focus(); @@ -110,6 +128,18 @@ export default function ComboWorkspace({ {t("cws.add")} +
+ + {jevAutoExists && {t("cws.jev.exists")}} +
{/* Search has no decision value until at least one combo exists. */} {combos.length > 0 && (
@@ -231,14 +261,16 @@ export default function ComboWorkspace({ )}
- {adding && !creatingFirstCombo && ( + {adding && ( c.id)} existingAliases={existingComboAliases} providerMap={providerMap} providerQuotaStates={providerQuotaStates} providers={providers} models={models} + initialDraft={addDraft} onClose={onCloseAdd} onSubmit={async (item) => { const res = await onSave(item, true); diff --git a/gui/src/components/combo-workspace-add-modal.tsx b/gui/src/components/combo-workspace-add-modal.tsx index 67a0f4f36dd..e6c71388d85 100644 --- a/gui/src/components/combo-workspace-add-modal.tsx +++ b/gui/src/components/combo-workspace-add-modal.tsx @@ -23,6 +23,7 @@ export function AddComboModal({ providerQuotaStates, providers, models, + initialDraft, onClose, onSubmit, }: { @@ -32,12 +33,15 @@ export function AddComboModal({ providerQuotaStates: ProviderQuotaStates; providers: ProviderOption[]; models: ModelOption[]; + initialDraft?: ComboItem; onClose: () => void; onSubmit: (item: ComboItem) => Promise<{ ok: boolean; error?: string }>; }) { const t = useT(); const dialogRef = useRef(null); - const [draft, setDraft] = useState(() => emptyDraft()); + const [draft, setDraft] = useState(() => initialDraft + ? { ...initialDraft, targets: initialDraft.targets.map(target => ({ ...target })) } + : emptyDraft()); const [busy, setBusy] = useState(false); const [error, setError] = useState(""); const effortMap = useMemo(() => { @@ -52,6 +56,11 @@ export function AddComboModal({ [draft.targets, effortMap, draft.reasoningEffortMode], ); const allTargetsExhausted = comboQuotaState(draft.targets, providerQuotaStates, providerMap) === "exhausted"; + const isJevPreset = initialDraft?.strategy === "jev"; + const jevCollision = isJevPreset && ( + existingIds.includes(draft.id.trim()) + || (!!draft.alias?.trim() && existingAliases.includes(draft.alias.trim())) + ); useEffect(() => { const dialog = dialogRef.current; @@ -97,6 +106,7 @@ export function AddComboModal({ @@ -108,8 +118,11 @@ export function AddComboModal({ -

{t("cws.addSubtitle")}

+

+ {isJevPreset ? t("cws.jev.setupHint") : t("cws.addSubtitle")} +

{error && {error}} + {jevCollision && {t("cws.jev.exists")}} {allTargetsExhausted && (
{t("cws.quota.allExhausted")} @@ -227,7 +240,7 @@ export function AddComboModal({
-
diff --git a/gui/src/components/combo-workspace-controls.tsx b/gui/src/components/combo-workspace-controls.tsx index 8e4a8108bcc..7096fac936f 100644 --- a/gui/src/components/combo-workspace-controls.tsx +++ b/gui/src/components/combo-workspace-controls.tsx @@ -160,11 +160,30 @@ export function TargetEditor({ const provs = enabledProviders(providers); const [dragIndex, setDragIndex] = useState(null); const [overIndex, setOverIndex] = useState(null); + const failOpenIndex = strategy === "jev" + ? targets.findIndex((target) => { + const provider = providers.find(candidate => candidate.name === target.provider.trim()); + return !!target.provider.trim() + && !!target.model.trim() + && provider !== undefined + && provider.disabled !== true + && provider.adapter !== "jev-decision" + && providerQuotaStates[target.provider.trim()] !== "exhausted"; + }) + : -1; const update = (index: number, patch: Partial) => { onChange(targets.map((row, i) => (i === index ? { ...row, ...patch } : row))); }; + const replaceModel = (index: number, patch: Pick) => { + onChange(targets.map((row, i) => { + if (i !== index) return row; + const { reasoningEfforts: _reasoningEfforts, ...rest } = row; + return { ...rest, ...patch }; + })); + }; + const reorder = (from: number, to: number) => { if (from === to || from < 0 || to < 0 || from >= targets.length || to >= targets.length) return; const copy = [...targets]; @@ -188,12 +207,23 @@ export function TargetEditor({ const dragging = dragIndex === index; const dropTarget = overIndex === index && dragIndex !== null && dragIndex !== index; const quotaState = providerQuotaStates[row.provider.trim()] ?? "unknown"; + const advertisedReasoningEfforts = models.find( + model => model.provider === row.provider && model.id === row.model, + )?.reasoningEfforts; + const selectableReasoningEfforts = advertisedReasoningEfforts === undefined + ? undefined + : COMBO_EFFORTS.filter(effort => advertisedReasoningEfforts.includes(effort)); + const selectedReasoningEfforts = selectableReasoningEfforts === undefined + ? [] + : row.reasoningEfforts === undefined + ? selectableReasoningEfforts + : row.reasoningEfforts.filter(effort => selectableReasoningEfforts.includes(effort)); return ( +
{ const provider = e.target.value; const first = modelsForProvider(models, provider, providers)[0] ?? ""; - update(index, { provider, model: first }); + replaceModel(index, { provider, model: first }); }} > @@ -270,7 +300,7 @@ export function TargetEditor({ value={row.model} disabled={modelSelectDisabled} aria-label={t("cws.target.model")} - onChange={(e) => update(index, { model: e.target.value })} + onChange={(e) => replaceModel(index, { provider: row.provider, model: e.target.value })} >
+ {strategy === "jev" && ( +
+ {index === failOpenIndex && {t("cws.jev.failOpen")}} + {selectableReasoningEfforts === undefined + ? {t("cws.jev.effortsUnknown")} + : selectableReasoningEfforts.length === 0 + ? {t("cws.jev.effortsNone")} + : ( +
+ {t("cws.jev.allowedEfforts")} + {selectableReasoningEfforts.map((effort) => { + const checked = selectedReasoningEfforts.includes(effort); + return ( + + ); + })} +
+ )} +
+ )} + ); })} ))} {/* - Both panels stay in the tree, the inactive one `hidden`. A single panel whose id + All panels stay in the tree, the inactive ones `hidden`. A single panel whose id followed the active tab left the OTHER tab's `aria-controls` pointing at an element that did not exist — a broken IDREF on whichever tab was not selected. */} @@ -384,6 +391,18 @@ export function DetailPanel({ {!isCreate && apiBase !== undefined && } + {!isCreate && baseline.strategy === "jev" && ( + + )} + {/* `tabIndex={0}` because this panel holds no focusable descendants: without it, Tab out of the tablist would skip the content the tab just revealed. diff --git a/gui/src/components/combo-workspace-types.ts b/gui/src/components/combo-workspace-types.ts index 41fe52a129a..aec36283f47 100644 --- a/gui/src/components/combo-workspace-types.ts +++ b/gui/src/components/combo-workspace-types.ts @@ -16,8 +16,10 @@ export type ModelOption = { inputModalities?: string[]; }; +export type ComboAddIntent = "blank" | "jev-auto"; + export interface ComboWorkspaceProps { - /** Management API target; enables the per-candidate path preview in the detail panel. */ + /** Management API target; enables the per-candidate path preview and JEV stats in the detail panel. */ apiBase?: string; combos: ComboItem[]; providerQuotaStates: ProviderQuotaStates; @@ -29,8 +31,9 @@ export interface ComboWorkspaceProps { onRefresh: () => void; onSave: (item: ComboItem, isCreate: boolean, renameFrom?: string) => Promise<{ ok: boolean; error?: string }>; onRemove: (id: string) => Promise<{ ok: boolean; error?: string }>; - onAdd: () => void; + onAdd: (intent?: ComboAddIntent) => void; adding: boolean; + addIntent?: ComboAddIntent; onCloseAdd: () => void; onCreated: (id: string) => void; } diff --git a/gui/src/components/jev-stats-panel.tsx b/gui/src/components/jev-stats-panel.tsx new file mode 100644 index 00000000000..d5b1baa8d80 --- /dev/null +++ b/gui/src/components/jev-stats-panel.tsx @@ -0,0 +1,258 @@ +import { useCallback, useState } from "react"; +import { useDataSurface } from "../data-surface"; +import { formatTokens } from "../format-tokens"; +import { useI18n } from "../i18n/shared"; +import { formatProviderDisplayName } from "../provider-icons"; +import type { UsageReadMetadata } from "../usage-summary-resource"; +import { Notice } from "../ui"; +import { DataSurfaceSkeleton } from "./data-surface"; +import { UsageIncompleteNotice } from "./usage-incomplete-notice"; + +type JevStatsRange = "7d" | "30d" | "all"; + +interface JevStatsResponse extends UsageReadMetadata { + range: JevStatsRange; + comboId: string | null; + generatedAt: number; + summary: { + decisions: number; + appliedDecisions: number; + failOpenDecisions: number; + successfulRequests: number; + requestsWithModelFallback: number; + modelAttempts: number; + measuredModelAttempts: number; + modelInputTokens: number; + modelOutputTokens: number; + modelReasoningTokens: number; + modelCacheReadTokens: number; + modelCacheWriteTokens: number; + modelTotalTokens: number; + decisionUsageReported: number; + decisionInputTokens: number; + decisionOutputTokens: number; + decisionTotalTokens: number; + averageLatencyMs: number | null; + averageConfidence: number | null; + averageChosenProbability: number | null; + }; + gates: Array<{ gate: string; decisions: number }>; + models: Array<{ + provider: string; + model: string; + overflow: boolean; + picks: number; + appliedPicks: number; + failOpenPicks: number; + attempts: number; + measuredAttempts: number; + inputTokens: number; + outputTokens: number; + reasoningTokens: number; + cacheReadTokens: number; + cacheWriteTokens: number; + totalTokens: number; + efforts: Array<{ effort: string | null; picks: number }>; + }>; + historyTruncated: boolean; + entriesTruncated: boolean; + error?: string; +} + +function formatPercent(value: number | null): string { + return value === null ? "—" : `${Math.round(value * 100)}%`; +} + +function formatLatency(value: number | null, locale: string): string { + if (value === null) return "—"; + const seconds = value >= 1_000; + return new Intl.NumberFormat(locale, { + style: "unit", + unit: seconds ? "second" : "millisecond", + unitDisplay: "short", + maximumFractionDigits: seconds && value < 10_000 ? 1 : 0, + }).format(seconds ? value / 1_000 : value); +} + +export function JevStatsPanel({ + apiBase, + comboId, + active, +}: { + apiBase: string; + comboId: string; + active: boolean; +}) { + const { t, locale } = useI18n(); + const [range, setRange] = useState("30d"); + const load = useCallback(async (signal: AbortSignal): Promise => { + const query = new URLSearchParams({ jev: "1", comboId, range }); + const response = await fetch(`${apiBase}/api/usage?${query}`, { signal }); + if (!response.ok) throw new Error(String(response.status)); + return response.json() as Promise; + }, [apiBase, comboId, range]); + const resource = useDataSurface( + `ocx.jev-stats.v1:${apiBase}:${comboId}:${range}`, + [apiBase, comboId, range], + load, + { + isEmpty: data => data.summary.decisions === 0, + enabled: active, + pollMs: 30_000, + pauseWhenHidden: true, + deadlineMs: 60_000, + }, + ); + const { state } = resource; + const data = state.data; + + if (!active) return null; + + return ( +
+
+
+ {(["7d", "30d", "all"] as const).map(option => ( + + ))} +
+ +
+ + {state.showSkeleton && } + {state.showError && {t("cws.jev.stats.loadFailed")}} + {data && ( + <> + + {(data.historyTruncated || data.entriesTruncated) && ( + {t("cws.jev.stats.historyIncomplete")} + )} + {data.error && {t("cws.jev.stats.loadFailed")}} + {data.summary.decisions === 0 ? ( +
+

{t("cws.jev.stats.emptyTitle")}

+

{t("cws.jev.stats.emptyBody")}

+
+ ) : ( + <> +
+
+
{t("cws.jev.stats.decisions")}
+
{data.summary.decisions.toLocaleString(locale)}
+
+ {t("cws.jev.stats.appliedAndFailOpen", { + applied: data.summary.appliedDecisions, + failOpen: data.summary.failOpenDecisions, + })} +
+
+
+
{t("cws.jev.stats.modelTokens")}
+
{formatTokens(data.summary.modelTotalTokens, locale)}
+
+ {t("cws.jev.stats.measuredAttempts", { + measured: data.summary.measuredModelAttempts, + total: data.summary.modelAttempts, + })} +
+
+
+
{t("cws.jev.stats.decisionTokens")}
+
{formatTokens(data.summary.decisionTotalTokens, locale)}
+
+ {t("cws.jev.stats.measuredDecisions", { + measured: data.summary.decisionUsageReported, + total: data.summary.decisions, + })} +
+
+
+ +
+ {t("cws.jev.stats.successful", { count: data.summary.successfulRequests })} + {t( + data.summary.requestsWithModelFallback === 1 + ? "cws.jev.stats.fallbackOne" + : "cws.jev.stats.fallbackMany", + { count: data.summary.requestsWithModelFallback }, + )} + {t("cws.jev.stats.averageLatency", { value: formatLatency(data.summary.averageLatencyMs, locale) })} + {t("cws.jev.stats.averageConfidence", { value: formatPercent(data.summary.averageConfidence) })} +
+ +
+ {data.gates.map(gate => ( + {gate.gate}: {gate.decisions} + ))} +
+ +
+ + + + + + + + + + + + + + + + {data.models.map(model => ( + + + + + + + + + + + + ))} + +
{t("cws.jev.stats.model")}{t("cws.jev.stats.picks")}{t("cws.jev.stats.efforts")}{t("cws.jev.stats.attempts")}{t("cws.jev.stats.input")}{t("cws.jev.stats.output")}{t("cws.jev.stats.reasoning")}{t("cws.jev.stats.cacheReadWrite")}{t("cws.jev.stats.total")}
+ {model.overflow ? ( +
{t("cws.jev.stats.otherModels")}
+ ) : ( + <> +
{model.model}
+
{formatProviderDisplayName(model.provider, t)}
+ + )} +
+ {model.picks} + {t("cws.jev.stats.appliedAndFailOpen", { + applied: model.appliedPicks, + failOpen: model.failOpenPicks, + })} + {model.efforts.length > 0 + ? model.efforts.map(effort => `${effort.effort ?? t("cws.jev.stats.noEffort")} × ${effort.picks}`).join(", ") + : "—"} + {model.attempts} + {model.measuredAttempts} {t("cws.jev.stats.measuredShort")} + {formatTokens(model.inputTokens, locale)}{formatTokens(model.outputTokens, locale)}{formatTokens(model.reasoningTokens, locale)}{formatTokens(model.cacheReadTokens, locale)} / {formatTokens(model.cacheWriteTokens, locale)}{formatTokens(model.totalTokens, locale)}
+
+

{t("cws.jev.stats.tokenFootnote")}

+ + )} + + )} +
+ ); +} diff --git a/gui/src/components/provider-workspace/ProviderDetails.tsx b/gui/src/components/provider-workspace/ProviderDetails.tsx index 7ea18634a7c..e601635f1d0 100644 --- a/gui/src/components/provider-workspace/ProviderDetails.tsx +++ b/gui/src/components/provider-workspace/ProviderDetails.tsx @@ -39,6 +39,7 @@ export default function ProviderDetails({ modelRevision, modelRowsReady, onOpenModels, + onCreateJevAuto, modelsLoading, modelsLoadFailed, onRetryModels, @@ -79,6 +80,7 @@ export default function ProviderDetails({ modelRevision: string; modelRowsReady: boolean; onOpenModels: () => void; + onCreateJevAuto?: () => void; modelsLoading?: boolean; modelsLoadFailed?: boolean; onRetryModels?: () => void; @@ -292,6 +294,7 @@ export default function ProviderDetails({ oauth={oauth} onEditSettings={() => switchTab("settings")} onViewUsage={() => switchTab("usage")} + onCreateJevAuto={onCreateJevAuto} onUpdateProvider={onUpdateProvider} reauthBusy={busyProvider === item.name} onCancelLogin={authHandlers?.onCancelLogin ? () => void authHandlers.onCancelLogin?.(item.name) : undefined} diff --git a/gui/src/components/provider-workspace/ProviderOverview.tsx b/gui/src/components/provider-workspace/ProviderOverview.tsx index 60efdb3950f..eba81c72b0c 100644 --- a/gui/src/components/provider-workspace/ProviderOverview.tsx +++ b/gui/src/components/provider-workspace/ProviderOverview.tsx @@ -35,6 +35,7 @@ export default function ProviderOverview({ item, preset, usageTotals, quotaReport, currentQuotaReading, onRefreshQuota, oauthEmail, oauth, apiBase, connectionIdentity, onEditSettings, onViewUsage, onUpdateProvider, + onCreateJevAuto, onReauthenticate, onCancelLogin, reauthBusy = false, }: { item: WorkspaceItem; @@ -51,6 +52,7 @@ export default function ProviderOverview({ connectionIdentity?: string; onEditSettings?: () => void; onViewUsage?: () => void; + onCreateJevAuto?: () => void; onUpdateProvider?: (name: string, patch: ProviderUpdatePatch) => Promise; onReauthenticate?: () => void; onCancelLogin?: () => void; @@ -200,6 +202,16 @@ export default function ProviderOverview({ )} + {item.adapter === "jev-decision" && item.hasApiKey && onCreateJevAuto && ( +
+

{t("cws.jev.create")}

+

{t("cws.jev.setupHint")}

+ +
+ )} +

{t("pws.authSummary")}

{needsAttention ? ( diff --git a/gui/src/i18n/de.ts b/gui/src/i18n/de.ts index 50a1493108b..cbaee338cee 100644 --- a/gui/src/i18n/de.ts +++ b/gui/src/i18n/de.ts @@ -2723,6 +2723,47 @@ export const de: Record = { "cws.addTitle": "Combo hinzufügen", "cws.addSubtitle": "Erstellen Sie ein virtuelles Modell über mehrere Anbieter und wählen Sie den exakten Modellnamen für Clients.", "cws.create": "Combo erstellen", + "cws.jev.create": "JEV Auto erstellen", + "cws.jev.exists": "JEV Auto ist bereits vorhanden.", + "cws.jev.setupHint": "Erstellt eine optionale, vollständig bearbeitbare Combo. JEV wählt für jede Anfrage ein erlaubtes Ziel und einen Reasoning-Aufwand.", + "cws.jev.failOpen": "Fail-open-Ziel", + "cws.jev.allowedEfforts": "JEV darf auswählen", + "cws.jev.efforts": "Reasoning-Aufwände: {efforts}", + "cws.jev.effortsUnknown": "Reasoning-Aufwände nicht angegeben", + "cws.jev.effortsNone": "Kein expliziter Reasoning-Aufwand", + "cws.jev.stats.tab": "Statistik", + "cws.jev.stats.range": "Zeitraum der JEV-Statistik", + "cws.jev.stats.refresh": "Aktualisieren", + "cws.jev.stats.loading": "JEV-Statistik wird geladen", + "cws.jev.stats.loadFailed": "Die JEV-Statistik konnte nicht geladen werden.", + "cws.jev.stats.historyIncomplete": "Einige ältere JEV-Einträge wurden ausgelassen; die Summen können unvollständig sein.", + "cws.jev.stats.emptyTitle": "Noch keine JEV-Entscheidungen", + "cws.jev.stats.emptyBody": "Führen Sie diese Combo aus, um Auswahl und Modell-Tokenverbrauch aufzuzeichnen. Entscheidungen vor Installation der Statistikunterstützung sind nicht verfügbar.", + "cws.jev.stats.decisions": "Entscheidungen", + "cws.jev.stats.appliedAndFailOpen": "{applied} angewendet · {failOpen} Fail-open", + "cws.jev.stats.modelTokens": "Modell-Tokens", + "cws.jev.stats.decisionTokens": "JEV-Entscheidungs-Tokens", + "cws.jev.stats.measuredAttempts": "{measured}/{total} Versuche gemessen", + "cws.jev.stats.measuredDecisions": "{measured}/{total} Entscheidungen mit Nutzungsdaten", + "cws.jev.stats.successful": "{count} erfolgreiche Anfragen", + "cws.jev.stats.fallbackOne": "{count} Modell-Fallback", + "cws.jev.stats.fallbackMany": "{count} Modell-Fallbacks", + "cws.jev.stats.averageLatency": "Mittlere JEV-Latenz: {value}", + "cws.jev.stats.averageConfidence": "Mittlere Konfidenz: {value}", + "cws.jev.stats.gates": "JEV-Entscheidungstore", + "cws.jev.stats.model": "Modell", + "cws.jev.stats.otherModels": "Andere Modelle", + "cws.jev.stats.picks": "Auswahlen", + "cws.jev.stats.efforts": "Aufwände", + "cws.jev.stats.attempts": "Versuche", + "cws.jev.stats.input": "Eingabe", + "cws.jev.stats.output": "Ausgabe", + "cws.jev.stats.reasoning": "Reasoning", + "cws.jev.stats.cacheReadWrite": "Cache L/S", + "cws.jev.stats.total": "Gesamt", + "cws.jev.stats.noEffort": "keiner", + "cws.jev.stats.measuredShort": "gemessen", + "cws.jev.stats.tokenFootnote": "Modell-Tokens stammen aus physischen Zielversuchen einschließlich Wiederholungen und Fallbacks. JEV-Entscheidungs-Tokens werden vom Entscheidungsdienst separat gemeldet.", "cws.railAria": "Combo-Liste", "cws.searchPlaceholder": "Combos oder Ziele suchen…", "cws.noSearchResults": "Keine Combos passen zur Suche.", @@ -2759,11 +2800,13 @@ export const de: Record = { "cws.strategy.random": "Zufall", "cws.strategy.leastUsed": "Seltenst genutzt", "cws.strategy.resetWindow": "Reset-Fenster", + "cws.strategy.jev": "JEV-Auswahl", "cws.strategy.failoverHint": "Ziele der Reihe nach versuchen. Bei einem wiederholbaren Fehler (Limit, Ausfall, Abo-Sperre) zum nächsten springen.", "cws.strategy.roundRobinHint": "Datenverkehr deterministisch nach Gewicht verteilen. Das gewählte Ziel für einen Block erfolgreicher Anfragen behalten und dann weiterschalten.", "cws.strategy.randomHint": "Pro Anfrage ein geeignetes Ziel ziehen, mit Wahrscheinlichkeiten proportional zum Gewicht. Keine Bindung zwischen Anfragen.", "cws.strategy.leastUsedHint": "Jede Anfrage an das geeignete Ziel mit den wenigsten erfassten Erfolgen weiterleiten. Zählungen starten mit dem Proxy neu.", "cws.strategy.resetWindowHint": "Bevorzugt das geeignete Ziel, dessen Quota-Fenster am frühesten zurückgesetzt wird. Ohne Quota-Daten gilt die Konfigurationsreihenfolge.", + "cws.strategy.jevHint": "TypeSafe JEV wählt ein geeignetes Ziel und einen kompatiblen Aufwand. Ist keine Entscheidung verfügbar, wird das erste geeignete Ziel verwendet.", "cws.field.id": "Combo-ID", "cws.field.idHintEdit": "Das Ändern der ID benennt die Combo um. Clients fordern {model} an.", "cws.field.alias": "Öffentlicher Modellname", @@ -2794,6 +2837,7 @@ export const de: Record = { "cws.targets.randomHint": "Gewichte steuern die Wahrscheinlichkeit jeder Ziehung; die Reihenfolge spielt keine Rolle.", "cws.targets.leastUsedHint": "Die Reihenfolge löst nur Gleichstände zwischen gleich oft genutzten Zielen.", "cws.targets.resetWindowHint": "Die Reihenfolge gilt, wenn Quota-Daten fehlen oder gleich ausfallen.", + "cws.targets.jevHint": "Nur diese Ziele können ausgewählt werden. Die Reihenfolge bestimmt das Fail-open-Ziel und den späteren Combo-Fallback.", "cws.target.provider": "Anbieter", "cws.target.model": "Modell", "cws.target.weight": "Gewicht", @@ -2836,6 +2880,7 @@ export const de: Record = { "cws.err.duplicateTarget": "Dasselbe Anbieter/Modell-Ziel darf nur einmal vorkommen.", "cws.err.invalidStickyLimit": "Sticky-Erfolge müssen eine Ganzzahl von 1 bis 100 sein.", "cws.err.invalidWeight": "Jedes Round-Robin-Gewicht muss eine Ganzzahl von 1 bis 10000 sein.", + "cws.err.invalidReasoningEfforts": "Jedes JEV-Ziel muss mindestens einen eindeutigen unterstützten Reasoning-Aufwand zulassen.", "cws.err.noEnabledTarget": "Mindestens ein Ziel muss einen aktivierten Anbieter verwenden.", "claude.tabsLabel": "Claude-Client", "claude.tabCode": "Code", diff --git a/gui/src/i18n/en.ts b/gui/src/i18n/en.ts index 6c75e9feedf..b4f20397e88 100644 --- a/gui/src/i18n/en.ts +++ b/gui/src/i18n/en.ts @@ -2823,6 +2823,47 @@ export const en = { "cws.addTitle": "Add combo", "cws.addSubtitle": "Create a virtual model across providers and choose the exact model name clients will request.", "cws.create": "Create combo", + "cws.jev.create": "Create JEV Auto", + "cws.jev.exists": "JEV Auto already exists.", + "cws.jev.setupHint": "Create an optional, fully editable Combo. JEV chooses one allowed target and reasoning effort for each request.", + "cws.jev.failOpen": "Fail-open target", + "cws.jev.allowedEfforts": "JEV may select", + "cws.jev.efforts": "Reasoning efforts: {efforts}", + "cws.jev.effortsUnknown": "Reasoning efforts not advertised", + "cws.jev.effortsNone": "No explicit reasoning effort", + "cws.jev.stats.tab": "Stats", + "cws.jev.stats.range": "JEV stats range", + "cws.jev.stats.refresh": "Refresh", + "cws.jev.stats.loading": "Loading JEV stats", + "cws.jev.stats.loadFailed": "JEV stats could not be loaded.", + "cws.jev.stats.historyIncomplete": "Some older JEV records were omitted, so these totals may be incomplete.", + "cws.jev.stats.emptyTitle": "No JEV decisions yet", + "cws.jev.stats.emptyBody": "Run this combo to record picks and model token usage. Decisions made before stats support was installed are unavailable.", + "cws.jev.stats.decisions": "Decisions", + "cws.jev.stats.appliedAndFailOpen": "{applied} applied · {failOpen} fail-open", + "cws.jev.stats.modelTokens": "Model tokens", + "cws.jev.stats.decisionTokens": "JEV decision tokens", + "cws.jev.stats.measuredAttempts": "{measured}/{total} attempts measured", + "cws.jev.stats.measuredDecisions": "{measured}/{total} decisions reported usage", + "cws.jev.stats.successful": "{count} successful requests", + "cws.jev.stats.fallbackOne": "{count} model fallback", + "cws.jev.stats.fallbackMany": "{count} model fallbacks", + "cws.jev.stats.averageLatency": "Average JEV latency: {value}", + "cws.jev.stats.averageConfidence": "Average confidence: {value}", + "cws.jev.stats.gates": "JEV decision gates", + "cws.jev.stats.model": "Model", + "cws.jev.stats.otherModels": "Other models", + "cws.jev.stats.picks": "Picks", + "cws.jev.stats.efforts": "Efforts", + "cws.jev.stats.attempts": "Attempts", + "cws.jev.stats.input": "Input", + "cws.jev.stats.output": "Output", + "cws.jev.stats.reasoning": "Reasoning", + "cws.jev.stats.cacheReadWrite": "Cache R/W", + "cws.jev.stats.total": "Total", + "cws.jev.stats.noEffort": "none", + "cws.jev.stats.measuredShort": "measured", + "cws.jev.stats.tokenFootnote": "Model tokens come from physical target attempts, including retries and fallbacks. JEV decision tokens are reported separately by the decision service.", "cws.railAria": "Combo list", "cws.searchPlaceholder": "Search combos or targets…", "cws.noSearchResults": "No combos match your search.", @@ -2859,11 +2900,13 @@ export const en = { "cws.strategy.random": "Random", "cws.strategy.leastUsed": "Least-used", "cws.strategy.resetWindow": "Reset-window", + "cws.strategy.jev": "JEV", "cws.strategy.failoverHint": "Try targets in order. If the first fails with a retryable error (rate limit, outage, subscription gate), hop to the next.", "cws.strategy.roundRobinHint": "Deterministically balance traffic by weight. Keep each selected target for a batch of successful requests, then advance.", "cws.strategy.randomHint": "Draw one eligible target per request, with odds proportional to weight. No stickiness between requests.", "cws.strategy.leastUsedHint": "Route each request to the eligible target with the fewest recorded successes. Counts restart with the proxy.", "cws.strategy.resetWindowHint": "Prefer the eligible target whose quota window resets soonest. Falls back to configuration order when quota data is missing.", + "cws.strategy.jevHint": "Ask TypeSafe JEV to choose one eligible target and compatible effort. If the decision is unavailable, use the first eligible target.", "cws.field.id": "Combo id", "cws.field.idHint": "Clients will request {model}", "cws.field.idInternalHint": "Internal combo id. You can change it after creation.", @@ -2894,6 +2937,7 @@ export const en = { "cws.targets.randomHint": "Weights control each draw's odds; order does not matter.", "cws.targets.leastUsedHint": "Order only breaks ties between equally used targets.", "cws.targets.resetWindowHint": "Order applies when quota data is missing or tied.", + "cws.targets.jevHint": "Only these targets can be selected. Order determines the fail-open target and later Combo fallback.", "cws.target.provider": "Provider", "cws.target.model": "Model", "cws.target.weight": "Weight", @@ -2936,6 +2980,7 @@ export const en = { "cws.err.duplicateTarget": "The same provider/model target can appear only once.", "cws.err.invalidStickyLimit": "Sticky successes must be an integer from 1 to 100.", "cws.err.invalidWeight": "Each round-robin weight must be an integer from 1 to 10000.", + "cws.err.invalidReasoningEfforts": "Each JEV target must allow at least one unique supported reasoning effort.", "cws.err.noEnabledTarget": "At least one target must use an enabled provider.", "claude.tabsLabel": "Claude client", "claude.tabCode": "Code", diff --git a/gui/src/i18n/fr.ts b/gui/src/i18n/fr.ts index 3db3aabb4f9..2e16093f9ff 100644 --- a/gui/src/i18n/fr.ts +++ b/gui/src/i18n/fr.ts @@ -2741,6 +2741,47 @@ export const fr: Record = { "cws.addTitle": "Ajouter une combinaison", "cws.addSubtitle": "Créez un modèle virtuel couvrant plusieurs fournisseurs et choisissez le nom de modèle exact que les clients demanderont.", "cws.create": "Créer la combinaison", + "cws.jev.create": "Créer JEV Auto", + "cws.jev.exists": "JEV Auto existe déjà.", + "cws.jev.setupHint": "Crée une combinaison facultative et entièrement modifiable. JEV choisit une cible autorisée et un effort de raisonnement pour chaque requête.", + "cws.jev.failOpen": "Cible de repli lorsque la décision JEV est indisponible ou invalide", + "cws.jev.allowedEfforts": "JEV peut sélectionner", + "cws.jev.efforts": "Efforts de raisonnement : {efforts}", + "cws.jev.effortsUnknown": "Efforts de raisonnement non indiqués", + "cws.jev.effortsNone": "Aucun effort de raisonnement explicite", + "cws.jev.stats.tab": "Statistiques", + "cws.jev.stats.range": "Période des statistiques JEV", + "cws.jev.stats.refresh": "Actualiser", + "cws.jev.stats.loading": "Chargement des statistiques JEV", + "cws.jev.stats.loadFailed": "Impossible de charger les statistiques JEV.", + "cws.jev.stats.historyIncomplete": "Certains anciens enregistrements JEV ont été omis ; ces totaux peuvent être incomplets.", + "cws.jev.stats.emptyTitle": "Aucune décision JEV pour le moment", + "cws.jev.stats.emptyBody": "Exécutez cette combinaison pour enregistrer les choix et l’utilisation des jetons par modèle. Les décisions antérieures à la prise en charge des statistiques ne sont pas disponibles.", + "cws.jev.stats.decisions": "Décisions", + "cws.jev.stats.appliedAndFailOpen": "{applied} appliquées · {failOpen} en repli ouvert", + "cws.jev.stats.modelTokens": "Jetons du modèle", + "cws.jev.stats.decisionTokens": "Jetons de décision JEV", + "cws.jev.stats.measuredAttempts": "{measured}/{total} tentatives mesurées", + "cws.jev.stats.measuredDecisions": "{measured}/{total} décisions avec utilisation", + "cws.jev.stats.successful": "{count} requêtes réussies", + "cws.jev.stats.fallbackOne": "{count} repli de modèle", + "cws.jev.stats.fallbackMany": "{count} replis de modèle", + "cws.jev.stats.averageLatency": "Latence JEV moyenne : {value}", + "cws.jev.stats.averageConfidence": "Confiance moyenne : {value}", + "cws.jev.stats.gates": "États de décision JEV", + "cws.jev.stats.model": "Modèle", + "cws.jev.stats.otherModels": "Autres modèles", + "cws.jev.stats.picks": "Choix", + "cws.jev.stats.efforts": "Efforts", + "cws.jev.stats.attempts": "Tentatives", + "cws.jev.stats.input": "Entrée", + "cws.jev.stats.output": "Sortie", + "cws.jev.stats.reasoning": "Raisonnement", + "cws.jev.stats.cacheReadWrite": "Cache L/É", + "cws.jev.stats.total": "Total", + "cws.jev.stats.noEffort": "aucun", + "cws.jev.stats.measuredShort": "mesurées", + "cws.jev.stats.tokenFootnote": "Les jetons du modèle proviennent des tentatives physiques, y compris les nouvelles tentatives et les replis. Les jetons de décision JEV sont signalés séparément par le service de décision.", "cws.railAria": "Liste des combinaisons", "cws.searchPlaceholder": "Rechercher des combinaisons ou des cibles…", "cws.noSearchResults": "Aucune combinaison ne correspond à votre recherche.", @@ -2783,11 +2824,13 @@ export const fr: Record = { "cws.strategy.random": "Aléatoire", "cws.strategy.leastUsed": "Moins utilisé", "cws.strategy.resetWindow": "Fenêtre de réinitialisation", + "cws.strategy.jev": "Sélection JEV", "cws.strategy.failoverHint": "Essaie les cibles dans l’ordre. Si la première échoue avec une erreur réessayable (limite de débit, panne, restriction d’abonnement), passe à la suivante.", "cws.strategy.roundRobinHint": "Répartit le trafic de manière déterministe selon les pondérations. Conserve chaque cible sélectionnée pendant un lot de requêtes réussies, puis passe à la suivante.", "cws.strategy.randomHint": "Tire une cible éligible par requête, avec des probabilités proportionnelles au poids. Aucune adhérence entre requêtes.", "cws.strategy.leastUsedHint": "Dirige chaque requête vers la cible éligible ayant le moins de succès enregistrés. Les compteurs redémarrent avec le proxy.", "cws.strategy.resetWindowHint": "Préfère la cible éligible dont la fenêtre de quota se réinitialise le plus tôt. Sans données de quota, l’ordre de configuration s’applique.", + "cws.strategy.jevHint": "Demande à TypeSafe JEV de choisir une cible éligible et un effort compatible. Si la décision est indisponible, utilise la première cible éligible.", "cws.field.id": "Identifiant de la combinaison", "cws.field.idHint": "Les clients demanderont {model}", "cws.field.idInternalHint": "Identifiant interne de la combinaison. Vous pouvez le modifier après la création.", @@ -2812,6 +2855,7 @@ export const fr: Record = { "cws.targets.randomHint": "Les pondérations contrôlent les chances de chaque tirage ; l’ordre n’a pas d’importance.", "cws.targets.leastUsedHint": "L’ordre ne départage que les cibles également utilisées.", "cws.targets.resetWindowHint": "L’ordre s’applique quand les données de quota manquent ou sont égales.", + "cws.targets.jevHint": "Seules ces cibles peuvent être choisies. L’ordre détermine la cible de repli lorsque la décision JEV est indisponible ou invalide, puis le basculement de la combinaison.", "cws.target.provider": "Fournisseur", "cws.target.model": "Modèle", "cws.target.weight": "Pondération", @@ -2854,6 +2898,7 @@ export const fr: Record = { "cws.err.duplicateTarget": "Une même cible fournisseur/modèle ne peut apparaître qu’une seule fois.", "cws.err.invalidStickyLimit": "Le nombre de réussites persistantes doit être un entier compris entre 1 et 100.", "cws.err.invalidWeight": "Chaque pondération de rotation doit être un entier compris entre 1 et 10000.", + "cws.err.invalidReasoningEfforts": "Chaque cible JEV doit autoriser au moins un effort de raisonnement pris en charge et unique.", "cws.err.noEnabledTarget": "Au moins une cible doit utiliser un fournisseur activé.", "claude.tabsLabel": "Client Claude", "claude.tabCode": "Code", diff --git a/gui/src/i18n/ja.ts b/gui/src/i18n/ja.ts index 0af5286e31e..9c254e8fdc1 100644 --- a/gui/src/i18n/ja.ts +++ b/gui/src/i18n/ja.ts @@ -2798,6 +2798,47 @@ export const ja: Record = { "cws.addTitle": "コンボを追加", "cws.addSubtitle": "プロバイダー全体にファンアウトする仮想モデルを作成します。クライアントは combo/ をリクエストします。", "cws.create": "コンボを作成", + "cws.jev.create": "JEV Auto を作成", + "cws.jev.exists": "JEV Auto はすでに存在します。", + "cws.jev.setupHint": "任意で追加でき、完全に編集可能なコンボを作成します。JEV はリクエストごとに、許可されたターゲットを 1 つ選び、互換性のある effort を選択します。", + "cws.jev.failOpen": "フェイルオープン先", + "cws.jev.allowedEfforts": "JEV が選択可能", + "cws.jev.efforts": "推論 effort: {efforts}", + "cws.jev.effortsUnknown": "推論 effort は公開されていません", + "cws.jev.effortsNone": "明示的な推論 effort なし", + "cws.jev.stats.tab": "統計", + "cws.jev.stats.range": "JEV 統計の期間", + "cws.jev.stats.refresh": "更新", + "cws.jev.stats.loading": "JEV 統計を読み込み中", + "cws.jev.stats.loadFailed": "JEV 統計を読み込めませんでした。", + "cws.jev.stats.historyIncomplete": "一部の古い JEV レコードが省略されているため、合計が不完全な場合があります。", + "cws.jev.stats.emptyTitle": "JEV の決定はまだありません", + "cws.jev.stats.emptyBody": "このコンボを実行すると、選択とモデルのトークン使用量が記録されます。統計対応前の決定は利用できません。", + "cws.jev.stats.decisions": "決定", + "cws.jev.stats.appliedAndFailOpen": "適用 {applied} · フェイルオープン {failOpen}", + "cws.jev.stats.modelTokens": "モデルのトークン", + "cws.jev.stats.decisionTokens": "JEV 決定トークン", + "cws.jev.stats.measuredAttempts": "{measured}/{total} 回の試行を計測", + "cws.jev.stats.measuredDecisions": "{measured}/{total} 件の決定で使用量を報告", + "cws.jev.stats.successful": "成功したリクエスト {count} 件", + "cws.jev.stats.fallbackOne": "モデルフォールバック {count} 件", + "cws.jev.stats.fallbackMany": "モデルフォールバック {count} 件", + "cws.jev.stats.averageLatency": "平均 JEV レイテンシ: {value}", + "cws.jev.stats.averageConfidence": "平均信頼度: {value}", + "cws.jev.stats.gates": "JEV 決定ゲート", + "cws.jev.stats.model": "モデル", + "cws.jev.stats.otherModels": "その他のモデル", + "cws.jev.stats.picks": "選択", + "cws.jev.stats.efforts": "推論強度", + "cws.jev.stats.attempts": "試行", + "cws.jev.stats.input": "入力", + "cws.jev.stats.output": "出力", + "cws.jev.stats.reasoning": "推論", + "cws.jev.stats.cacheReadWrite": "キャッシュ 読/書", + "cws.jev.stats.total": "合計", + "cws.jev.stats.noEffort": "なし", + "cws.jev.stats.measuredShort": "計測済み", + "cws.jev.stats.tokenFootnote": "モデルのトークンは、再試行やフォールバックを含む実際のターゲット試行から集計されます。JEV 決定トークンは決定サービスから別に報告されます。", "cws.railAria": "コンボ一覧", "cws.searchPlaceholder": "コンボやターゲットを検索…", "cws.noSearchResults": "検索に一致するコンボがありません。", @@ -2835,11 +2876,13 @@ export const ja: Record = { "cws.strategy.random": "ランダム", "cws.strategy.leastUsed": "最少使用", "cws.strategy.resetWindow": "リセットウィンドウ", + "cws.strategy.jev": "JEV 選択", "cws.strategy.failoverHint": "ターゲットを順に試します。最初が再試行可能なエラー(レート制限、障害、サブスクリプションゲート)で失敗した場合、次へホップします。", "cws.strategy.roundRobinHint": "重みで決定論的にトラフィックを分散します。選んだターゲットを成功リクエストのバッチ分保持し、次へ進みます。", "cws.strategy.randomHint": "リクエストごとに適格なターゲットを 1 つ抽選します。確率は重みに比例し、リクエスト間でスティッキネスはありません。", "cws.strategy.leastUsedHint": "各リクエストを、成功回数が最も少ない適格なターゲットへ振ります。カウントはプロキシの再起動でリセットされます。", "cws.strategy.resetWindowHint": "クォータのウィンドウが最も早くリセットされる適格なターゲットを優先します。クォータデータがない場合は設定順に従います。", + "cws.strategy.jevHint": "TypeSafe JEV に適格なターゲットと互換性のある effort を選ばせます。決定できない場合は最初の適格なターゲットを使用します。", "cws.field.id": "コンボ ID", "cws.field.idHint": "クライアントは {model} をリクエストします", "cws.field.idInternalHint": "コンボの内部 ID。作成後も変更できます。", @@ -2870,6 +2913,7 @@ export const ja: Record = { "cws.targets.randomHint": "重みが各抽選の確率を制御します。順序は影響しません。", "cws.targets.leastUsedHint": "順序は同じ使用回数のターゲット間の同点のみを解消します。", "cws.targets.resetWindowHint": "順序はクォータデータが欠落または同点のときに適用されます。", + "cws.targets.jevHint": "選択できるのはこの一覧のターゲットだけです。順序はフェイルオープン先とその後のコンボのフォールバックを決めます。", "cws.target.provider": "プロバイダー", "cws.target.model": "モデル", "cws.target.weight": "重み", @@ -2912,6 +2956,7 @@ export const ja: Record = { "cws.err.duplicateTarget": "同じプロバイダー/モデルターゲットは一度しか使用できません。", "cws.err.invalidStickyLimit": "固定成功数は 1 から 100 の整数にしてください。", "cws.err.invalidWeight": "各ラウンドロビン重みは 1 から 10000 の整数にしてください。", + "cws.err.invalidReasoningEfforts": "各 JEV ターゲットで、サポートされている一意の推論 effort を 1 つ以上許可してください。", "cws.err.noEnabledTarget": "少なくとも 1 つのターゲットは有効なプロバイダーを使用する必要があります。", "prov.editAlias": "Edit alias", "prov.aliasPrompt": "Display name (leave empty to clear)", diff --git a/gui/src/i18n/ko.ts b/gui/src/i18n/ko.ts index a3ccbefbae2..eb19feae0aa 100644 --- a/gui/src/i18n/ko.ts +++ b/gui/src/i18n/ko.ts @@ -2762,6 +2762,47 @@ export const ko: Record = { "cws.addTitle": "콤보 추가", "cws.addSubtitle": "여러 프로바이더를 사용하는 가상 모델을 만들고 클라이언트가 요청할 정확한 모델 이름을 선택하세요.", "cws.create": "콤보 만들기", + "cws.jev.create": "JEV Auto 만들기", + "cws.jev.exists": "JEV Auto가 이미 있습니다.", + "cws.jev.setupHint": "선택 사항인 완전 편집 가능 콤보를 만듭니다. JEV가 요청마다 허용된 대상과 추론 노력을 선택합니다.", + "cws.jev.failOpen": "장애 시 기본 대상", + "cws.jev.allowedEfforts": "JEV 선택 가능", + "cws.jev.efforts": "추론 노력: {efforts}", + "cws.jev.effortsUnknown": "추론 노력이 공개되지 않음", + "cws.jev.effortsNone": "명시적 추론 노력 없음", + "cws.jev.stats.tab": "통계", + "cws.jev.stats.range": "JEV 통계 기간", + "cws.jev.stats.refresh": "새로 고침", + "cws.jev.stats.loading": "JEV 통계 불러오는 중", + "cws.jev.stats.loadFailed": "JEV 통계를 불러올 수 없습니다.", + "cws.jev.stats.historyIncomplete": "일부 이전 JEV 기록이 제외되어 합계가 불완전할 수 있습니다.", + "cws.jev.stats.emptyTitle": "아직 JEV 결정이 없습니다", + "cws.jev.stats.emptyBody": "이 콤보를 실행하면 선택과 모델 토큰 사용량이 기록됩니다. 통계 지원 이전의 결정은 사용할 수 없습니다.", + "cws.jev.stats.decisions": "결정", + "cws.jev.stats.appliedAndFailOpen": "적용 {applied} · 페일오픈 {failOpen}", + "cws.jev.stats.modelTokens": "모델 토큰", + "cws.jev.stats.decisionTokens": "JEV 결정 토큰", + "cws.jev.stats.measuredAttempts": "시도 {measured}/{total}회 측정", + "cws.jev.stats.measuredDecisions": "결정 {measured}/{total}건이 사용량 보고", + "cws.jev.stats.successful": "성공한 요청 {count}건", + "cws.jev.stats.fallbackOne": "모델 폴백 {count}건", + "cws.jev.stats.fallbackMany": "모델 폴백 {count}건", + "cws.jev.stats.averageLatency": "평균 JEV 지연 시간: {value}", + "cws.jev.stats.averageConfidence": "평균 신뢰도: {value}", + "cws.jev.stats.gates": "JEV 결정 게이트", + "cws.jev.stats.model": "모델", + "cws.jev.stats.otherModels": "기타 모델", + "cws.jev.stats.picks": "선택", + "cws.jev.stats.efforts": "추론 강도", + "cws.jev.stats.attempts": "시도", + "cws.jev.stats.input": "입력", + "cws.jev.stats.output": "출력", + "cws.jev.stats.reasoning": "추론", + "cws.jev.stats.cacheReadWrite": "캐시 읽기/쓰기", + "cws.jev.stats.total": "합계", + "cws.jev.stats.noEffort": "없음", + "cws.jev.stats.measuredShort": "측정됨", + "cws.jev.stats.tokenFootnote": "모델 토큰은 재시도와 폴백을 포함한 실제 대상 시도에서 집계됩니다. JEV 결정 토큰은 결정 서비스가 별도로 보고합니다.", "cws.railAria": "콤보 목록", "cws.searchPlaceholder": "콤보 또는 대상 검색…", "cws.noSearchResults": "검색과 일치하는 콤보가 없습니다.", @@ -2798,11 +2839,13 @@ export const ko: Record = { "cws.strategy.random": "랜덤", "cws.strategy.leastUsed": "최소 사용", "cws.strategy.resetWindow": "리셋 윈도우", + "cws.strategy.jev": "JEV 선택", "cws.strategy.failoverHint": "대상을 순서대로 시도합니다. 재시도 가능한 오류(한도, 장애, 구독 게이트)면 다음으로 넘어갑니다.", "cws.strategy.roundRobinHint": "가중치에 따라 트래픽을 결정적으로 분배합니다. 선택된 대상을 성공 요청 묶음 동안 유지한 뒤 다음 대상으로 진행합니다.", "cws.strategy.randomHint": "요청마다 가중치에 비례한 확률로 적합한 대상을 하나 뽑습니다. 요청 간 고정이 없습니다.", "cws.strategy.leastUsedHint": "각 요청을 성공 횟수가 가장 적은 적합한 대상으로 보냅니다. 횟수는 프록시 재시작 시 초기화됩니다.", "cws.strategy.resetWindowHint": "쿼터 윈도우가 가장 빨리 리셋되는 적합한 대상을 우선합니다. 쿼터 데이터가 없으면 설정 순서를 따릅니다.", + "cws.strategy.jevHint": "TypeSafe JEV가 적합한 대상과 호환되는 추론 노력을 선택합니다. 결정을 사용할 수 없으면 첫 번째 적합한 대상을 사용합니다.", "cws.field.id": "콤보 ID", "cws.field.idHintEdit": "ID를 변경하면 콤보 이름이 바뀝니다. 클라이언트는 {model}을(를) 요청합니다.", "cws.field.alias": "공개 모델 이름", @@ -2833,6 +2876,7 @@ export const ko: Record = { "cws.targets.randomHint": "가중치가 각 추첨의 확률을 제어하며, 순서는 무관합니다.", "cws.targets.leastUsedHint": "순서는 사용량이 같은 대상 간의 동률만 결정합니다.", "cws.targets.resetWindowHint": "쿼터 데이터가 없거나 동률일 때 순서가 적용됩니다.", + "cws.targets.jevHint": "이 대상만 선택할 수 있습니다. 순서가 장애 시 기본 대상과 이후 콤보 대체 경로를 결정합니다.", "cws.target.provider": "프로바이더", "cws.target.model": "모델", "cws.target.weight": "가중치", @@ -2875,6 +2919,7 @@ export const ko: Record = { "cws.err.duplicateTarget": "같은 프로바이더/모델 대상은 한 번만 추가할 수 있습니다.", "cws.err.invalidStickyLimit": "sticky 성공 횟수는 1~100의 정수여야 합니다.", "cws.err.invalidWeight": "각 라운드로빈 가중치는 1~10000의 정수여야 합니다.", + "cws.err.invalidReasoningEfforts": "각 JEV 대상은 지원되는 고유한 추론 노력 하나 이상을 허용해야 합니다.", "cws.err.noEnabledTarget": "하나 이상의 대상이 활성화된 프로바이더를 사용해야 합니다.", "claude.tabsLabel": "Claude 클라이언트", "claude.tabCode": "Code", diff --git a/gui/src/i18n/ru.ts b/gui/src/i18n/ru.ts index 833a4ae4058..f7d46a78b3b 100644 --- a/gui/src/i18n/ru.ts +++ b/gui/src/i18n/ru.ts @@ -2870,6 +2870,47 @@ export const ru: Record = { "cws.addTitle": "Добавить комбо", "cws.addSubtitle": "Создайте виртуальную модель для нескольких провайдеров и выберите точное имя, которое будут запрашивать клиенты.", "cws.create": "Создать комбо", + "cws.jev.create": "Создать JEV Auto", + "cws.jev.exists": "JEV Auto уже существует.", + "cws.jev.setupHint": "Создаёт необязательное, полностью редактируемое комбо. JEV выбирает разрешённую цель и уровень рассуждения для каждого запроса.", + "cws.jev.failOpen": "Цель fail-open", + "cws.jev.allowedEfforts": "JEV может выбрать", + "cws.jev.efforts": "Уровни рассуждения: {efforts}", + "cws.jev.effortsUnknown": "Уровни рассуждения не заявлены", + "cws.jev.effortsNone": "Без явного уровня рассуждения", + "cws.jev.stats.tab": "Статистика", + "cws.jev.stats.range": "Период статистики JEV", + "cws.jev.stats.refresh": "Обновить", + "cws.jev.stats.loading": "Загрузка статистики JEV", + "cws.jev.stats.loadFailed": "Не удалось загрузить статистику JEV.", + "cws.jev.stats.historyIncomplete": "Некоторые старые записи JEV пропущены, поэтому итоги могут быть неполными.", + "cws.jev.stats.emptyTitle": "Решений JEV пока нет", + "cws.jev.stats.emptyBody": "Запустите эту комбинацию, чтобы записать выбор и расход токенов моделей. Решения до появления статистики недоступны.", + "cws.jev.stats.decisions": "Решения", + "cws.jev.stats.appliedAndFailOpen": "применено: {applied} · fail-open: {failOpen}", + "cws.jev.stats.modelTokens": "Токены моделей", + "cws.jev.stats.decisionTokens": "Токены решений JEV", + "cws.jev.stats.measuredAttempts": "измерено попыток: {measured}/{total}", + "cws.jev.stats.measuredDecisions": "расход передан для {measured}/{total} решений", + "cws.jev.stats.successful": "успешных запросов: {count}", + "cws.jev.stats.fallbackOne": "резервных переключений модели: {count}", + "cws.jev.stats.fallbackMany": "резервных переключений модели: {count}", + "cws.jev.stats.averageLatency": "Средняя задержка JEV: {value}", + "cws.jev.stats.averageConfidence": "Средняя уверенность: {value}", + "cws.jev.stats.gates": "Исходы решения JEV", + "cws.jev.stats.model": "Модель", + "cws.jev.stats.otherModels": "Другие модели", + "cws.jev.stats.picks": "Выборы", + "cws.jev.stats.efforts": "Усилия", + "cws.jev.stats.attempts": "Попытки", + "cws.jev.stats.input": "Вход", + "cws.jev.stats.output": "Выход", + "cws.jev.stats.reasoning": "Рассуждение", + "cws.jev.stats.cacheReadWrite": "Кэш чт/зап", + "cws.jev.stats.total": "Всего", + "cws.jev.stats.noEffort": "нет", + "cws.jev.stats.measuredShort": "измерено", + "cws.jev.stats.tokenFootnote": "Токены моделей берутся из фактических попыток, включая повторы и резервные переключения. Токены решений JEV отдельно передаёт сервис принятия решений.", "cws.railAria": "Список комбо", "cws.searchPlaceholder": "Поиск комбо или целей…", "cws.noSearchResults": "Нет комбо, соответствующих запросу.", @@ -2906,11 +2947,13 @@ export const ru: Record = { "cws.strategy.random": "Случайный", "cws.strategy.leastUsed": "Наименее используемый", "cws.strategy.resetWindow": "Окно сброса", + "cws.strategy.jev": "Выбор JEV", "cws.strategy.failoverHint": "Цели перебираются по порядку. Если первая завершается ошибкой, допускающей повтор (лимит запросов, сбой, ограничение подписки), происходит переключение на следующую.", "cws.strategy.roundRobinHint": "Детерминированное распределение трафика по весам. Выбранная цель удерживается на серию успешных запросов, затем селектор переходит к следующей.", "cws.strategy.randomHint": "Для каждого запроса выбирается одна подходящая цель с вероятностью, пропорциональной весу. Между запросами привязки нет.", "cws.strategy.leastUsedHint": "Каждый запрос направляется к подходящей цели с наименьшим числом успешных запросов. Счётчики обнуляются при перезапуске прокси.", "cws.strategy.resetWindowHint": "Предпочитается подходящая цель, чьё окно квот сбрасывается раньше всех. Без данных о квотах действует порядок из конфигурации.", + "cws.strategy.jevHint": "TypeSafe JEV выбирает подходящую цель и совместимый уровень рассуждения. Если решение недоступно, используется первая подходящая цель.", "cws.field.id": "Id комбо", "cws.field.idHint": "Клиенты будут запрашивать {model}", "cws.field.idInternalHint": "Внутренний id комбо. Его можно изменить после создания.", @@ -2941,6 +2984,7 @@ export const ru: Record = { "cws.targets.randomHint": "Веса задают вероятности каждого выбора; порядок не важен.", "cws.targets.leastUsedHint": "Порядок разрешает только равенство между одинаково используемыми целями.", "cws.targets.resetWindowHint": "Порядок применяется, когда данных о квотах нет или они равны.", + "cws.targets.jevHint": "Можно выбрать только эти цели. Порядок определяет цель fail-open и последующий fallback комбо.", "cws.target.provider": "Провайдер", "cws.target.model": "Модель", "cws.target.weight": "Вес", @@ -2983,6 +3027,7 @@ export const ru: Record = { "cws.err.duplicateTarget": "Одна и та же цель провайдер/модель может встречаться только один раз.", "cws.err.invalidStickyLimit": "Число успешных запросов до ротации должно быть целым от 1 до 100.", "cws.err.invalidWeight": "Каждый вес round-robin должен быть целым числом от 1 до 10000.", + "cws.err.invalidReasoningEfforts": "Для каждой цели JEV нужно разрешить хотя бы один уникальный поддерживаемый уровень рассуждения.", "cws.err.noEnabledTarget": "Хотя бы одна цель должна использовать включённого провайдера.", diff --git a/gui/src/i18n/tr.ts b/gui/src/i18n/tr.ts index 69717e0b5a2..df709bf029e 100644 --- a/gui/src/i18n/tr.ts +++ b/gui/src/i18n/tr.ts @@ -2765,6 +2765,47 @@ export const tr: Record = { "cws.addTitle": "Kombo ekle", "cws.addSubtitle": "Sağlayıcılar arasında sanal bir model oluşturun.", "cws.create": "Kombo oluştur", + "cws.jev.create": "JEV Auto oluştur", + "cws.jev.exists": "JEV Auto zaten mevcut.", + "cws.jev.setupHint": "İsteğe bağlı ve tamamen düzenlenebilir bir kombo oluşturur. JEV her istek için izin verilen bir hedef ve akıl yürütme eforu seçer.", + "cws.jev.failOpen": "Fail-open hedefi", + "cws.jev.allowedEfforts": "JEV şunları seçebilir", + "cws.jev.efforts": "Akıl yürütme eforları: {efforts}", + "cws.jev.effortsUnknown": "Akıl yürütme eforları belirtilmedi", + "cws.jev.effortsNone": "Açıkça belirtilmiş bir akıl yürütme eforu yok", + "cws.jev.stats.tab": "İstatistikler", + "cws.jev.stats.range": "JEV istatistik aralığı", + "cws.jev.stats.refresh": "Yenile", + "cws.jev.stats.loading": "JEV istatistikleri yükleniyor", + "cws.jev.stats.loadFailed": "JEV istatistikleri yüklenemedi.", + "cws.jev.stats.historyIncomplete": "Bazı eski JEV kayıtları atlandı; toplamlar eksik olabilir.", + "cws.jev.stats.emptyTitle": "Henüz JEV kararı yok", + "cws.jev.stats.emptyBody": "Seçimleri ve model token kullanımını kaydetmek için bu komboyu çalıştırın. İstatistik desteğinden önceki kararlar kullanılamaz.", + "cws.jev.stats.decisions": "Kararlar", + "cws.jev.stats.appliedAndFailOpen": "{applied} uygulandı · {failOpen} fail-open", + "cws.jev.stats.modelTokens": "Model tokenları", + "cws.jev.stats.decisionTokens": "JEV karar tokenları", + "cws.jev.stats.measuredAttempts": "{measured}/{total} deneme ölçüldü", + "cws.jev.stats.measuredDecisions": "{measured}/{total} karar kullanım bildirdi", + "cws.jev.stats.successful": "{count} başarılı istek", + "cws.jev.stats.fallbackOne": "{count} model geri dönüşü", + "cws.jev.stats.fallbackMany": "{count} model geri dönüşü", + "cws.jev.stats.averageLatency": "Ortalama JEV gecikmesi: {value}", + "cws.jev.stats.averageConfidence": "Ortalama güven: {value}", + "cws.jev.stats.gates": "JEV karar geçitleri", + "cws.jev.stats.model": "Model", + "cws.jev.stats.otherModels": "Diğer modeller", + "cws.jev.stats.picks": "Seçimler", + "cws.jev.stats.efforts": "Eforlar", + "cws.jev.stats.attempts": "Denemeler", + "cws.jev.stats.input": "Girdi", + "cws.jev.stats.output": "Çıktı", + "cws.jev.stats.reasoning": "Akıl yürütme", + "cws.jev.stats.cacheReadWrite": "Önbellek O/Y", + "cws.jev.stats.total": "Toplam", + "cws.jev.stats.noEffort": "yok", + "cws.jev.stats.measuredShort": "ölçüldü", + "cws.jev.stats.tokenFootnote": "Model tokenları yeniden denemeler ve geri dönüşler dahil fiziksel hedef denemelerinden gelir. JEV karar tokenları karar hizmeti tarafından ayrı bildirilir.", "cws.railAria": "Kombo listesi", "cws.searchPlaceholder": "Kombolarda veya hedeflerde ara…", "cws.noSearchResults": "Aramanızla eşleşen kombo yok.", @@ -2801,11 +2842,13 @@ export const tr: Record = { "cws.strategy.random": "Rastgele", "cws.strategy.leastUsed": "En az kullanılan", "cws.strategy.resetWindow": "Sıfırlama penceresi", + "cws.strategy.jev": "JEV seçimi", "cws.strategy.failoverHint": "Hedefleri sırayla deneyin. İlk hedef yeniden denenebilir bir hatayla (oran sınırı, kesinti, abonelik engeli) başarısız olursa sonraki hedefe atlayın.", "cws.strategy.roundRobinHint": "Trafiği ağırlığa göre kararlı bir şekilde dengeleyin. Seçilen her hedefi bir dizi başarılı istek boyunca tutun, ardından ilerleyin.", "cws.strategy.randomHint": "Her istek için ağırlığa orantılı olasılıkla bir uygun hedef çekilir. İstekler arasında yapışkanlık yoktur.", "cws.strategy.leastUsedHint": "Her isteği, kayıtlı başarısı en az olan uygun hedefe yönlendirir. Sayaçlar proxy ile yeniden başlar.", "cws.strategy.resetWindowHint": "Kota penceresi en yakında sıfırlanacak uygun hedefi tercih eder. Kota verisi yoksa yapılandırma sırasına döner.", + "cws.strategy.jevHint": "TypeSafe JEV uygun bir hedef ve uyumlu efor seçer. Karar kullanılamazsa ilk uygun hedef kullanılır.", "cws.field.id": "Kombo ID", "cws.field.idHint": "İstemciler {model} isteyecek", "cws.field.idInternalHint": "Dahili kombo ID. Oluşturduktan sonra değiştirebilirsiniz.", @@ -2836,6 +2879,7 @@ export const tr: Record = { "cws.targets.randomHint": "Ağırlıklar her çekilişin olasılığını kontrol eder; sıralamanın önemi yoktur.", "cws.targets.leastUsedHint": "Sıralama yalnızca eşit kullanımlı hedefler arasındaki eşitliği bozar.", "cws.targets.resetWindowHint": "Kota verisi eksik veya eşitse sıralama uygulanır.", + "cws.targets.jevHint": "Yalnızca bu hedefler seçilebilir. Sıra, fail-open hedefini ve sonraki kombo yedeğini belirler.", "cws.target.provider": "Sağlayıcı", "cws.target.model": "Model", "cws.target.weight": "Ağırlık", @@ -2878,6 +2922,7 @@ export const tr: Record = { "cws.err.duplicateTarget": "Aynı hedef yalnızca bir kez görünebilir.", "cws.err.invalidStickyLimit": "Limit 1 ile 100 arasında bir tam sayı olmalıdır.", "cws.err.invalidWeight": "Ağırlık 1 ile 10000 arasında olmalıdır.", + "cws.err.invalidReasoningEfforts": "Her JEV hedefi en az bir benzersiz desteklenen akıl yürütme eforuna izin vermelidir.", "cws.err.noEnabledTarget": "En az bir hedef etkin bir sağlayıcı kullanmalıdır.", "claude.tabsLabel": "Claude istemcisi", "claude.tabCode": "Code", diff --git a/gui/src/i18n/vi.ts b/gui/src/i18n/vi.ts index 762269381ba..659cd6e2b4c 100644 --- a/gui/src/i18n/vi.ts +++ b/gui/src/i18n/vi.ts @@ -2754,6 +2754,47 @@ export const vi: Record = { "cws.addTitle": "Thêm combo", "cws.addSubtitle": "Tạo một model ảo (virtual model) xuyên suốt các provider và chọn chính xác tên model mà các client sẽ gọi.", "cws.create": "Tạo combo", + "cws.jev.create": "Tạo JEV Auto", + "cws.jev.exists": "JEV Auto đã tồn tại.", + "cws.jev.setupHint": "Tạo một combo tùy chọn, có thể chỉnh sửa hoàn toàn. JEV chọn một mục tiêu được phép và mức suy luận cho mỗi yêu cầu.", + "cws.jev.failOpen": "Mục tiêu fail-open", + "cws.jev.allowedEfforts": "JEV có thể chọn", + "cws.jev.efforts": "Mức suy luận: {efforts}", + "cws.jev.effortsUnknown": "Mức suy luận chưa được công bố", + "cws.jev.effortsNone": "Không có mức suy luận rõ ràng", + "cws.jev.stats.tab": "Thống kê", + "cws.jev.stats.range": "Khoảng thống kê JEV", + "cws.jev.stats.refresh": "Làm mới", + "cws.jev.stats.loading": "Đang tải thống kê JEV", + "cws.jev.stats.loadFailed": "Không thể tải thống kê JEV.", + "cws.jev.stats.historyIncomplete": "Một số bản ghi JEV cũ đã bị bỏ qua nên tổng số có thể chưa đầy đủ.", + "cws.jev.stats.emptyTitle": "Chưa có quyết định JEV", + "cws.jev.stats.emptyBody": "Chạy combo này để ghi lại lựa chọn và lượng token của mô hình. Các quyết định trước khi có hỗ trợ thống kê không khả dụng.", + "cws.jev.stats.decisions": "Quyết định", + "cws.jev.stats.appliedAndFailOpen": "{applied} đã áp dụng · {failOpen} fail-open", + "cws.jev.stats.modelTokens": "Token mô hình", + "cws.jev.stats.decisionTokens": "Token quyết định JEV", + "cws.jev.stats.measuredAttempts": "Đã đo {measured}/{total} lần thử", + "cws.jev.stats.measuredDecisions": "{measured}/{total} quyết định có báo cáo sử dụng", + "cws.jev.stats.successful": "{count} yêu cầu thành công", + "cws.jev.stats.fallbackOne": "{count} lần chuyển dự phòng mô hình", + "cws.jev.stats.fallbackMany": "{count} lần chuyển dự phòng mô hình", + "cws.jev.stats.averageLatency": "Độ trễ JEV trung bình: {value}", + "cws.jev.stats.averageConfidence": "Độ tin cậy trung bình: {value}", + "cws.jev.stats.gates": "Trạng thái quyết định JEV", + "cws.jev.stats.model": "Mô hình", + "cws.jev.stats.otherModels": "Các mô hình khác", + "cws.jev.stats.picks": "Lượt chọn", + "cws.jev.stats.efforts": "Mức suy luận", + "cws.jev.stats.attempts": "Lần thử", + "cws.jev.stats.input": "Đầu vào", + "cws.jev.stats.output": "Đầu ra", + "cws.jev.stats.reasoning": "Suy luận", + "cws.jev.stats.cacheReadWrite": "Bộ nhớ đệm Đ/G", + "cws.jev.stats.total": "Tổng", + "cws.jev.stats.noEffort": "không", + "cws.jev.stats.measuredShort": "đã đo", + "cws.jev.stats.tokenFootnote": "Token mô hình đến từ các lần thử mục tiêu thực tế, gồm cả thử lại và chuyển dự phòng. Token quyết định JEV được dịch vụ quyết định báo cáo riêng.", "cws.railAria": "Danh sách combo", "cws.searchPlaceholder": "Tìm kiếm combo hoặc mục tiêu (target)…", "cws.noSearchResults": "Không có combo nào khớp với tìm kiếm của bạn.", @@ -2790,11 +2831,13 @@ export const vi: Record = { "cws.strategy.random": "Ngẫu nhiên", "cws.strategy.leastUsed": "Ít được sử dụng nhất (Least-used)", "cws.strategy.resetWindow": "Reset-window", + "cws.strategy.jev": "Chọn JEV", "cws.strategy.failoverHint": "Thử các mục tiêu theo thứ tự. Nếu mục tiêu đầu tiên gặp lỗi có thể thử lại (rate limit, mất kết nối, chặn subscription), sẽ chuyển tiếp (hop) sang mục tiêu tiếp theo.", "cws.strategy.roundRobinHint": "Cân bằng lưu lượng một cách tất định (deterministically) theo trọng số. Giữ nguyên mục tiêu được chọn cho một loạt các yêu cầu thành công, sau đó chuyển sang mục tiêu tiếp theo.", "cws.strategy.randomHint": "Rút ngẫu nhiên một mục tiêu đủ điều kiện cho mỗi yêu cầu, với tỷ lệ thuận theo trọng số. Không giữ nguyên trạng thái (stickiness) giữa các yêu cầu.", "cws.strategy.leastUsedHint": "Định tuyến từng yêu cầu đến mục tiêu đủ điều kiện có số lượng thành công được ghi nhận ít nhất. Số đếm sẽ khởi động lại cùng với proxy.", "cws.strategy.resetWindowHint": "Ưu tiên mục tiêu đủ điều kiện có thời gian đặt lại hạn ngạch (quota window) sớm nhất. Sẽ chuyển về thứ tự cấu hình khi dữ liệu hạn ngạch bị thiếu.", + "cws.strategy.jevHint": "Yêu cầu TypeSafe JEV chọn một mục tiêu đủ điều kiện và mức suy luận tương thích. Nếu không có quyết định, dùng mục tiêu đủ điều kiện đầu tiên.", "cws.field.id": "Id của combo", "cws.field.idHint": "Các client sẽ gửi yêu cầu tới {model}", "cws.field.idInternalHint": "Id nội bộ của combo. Bạn có thể thay đổi nó sau khi tạo.", @@ -2825,6 +2868,7 @@ export const vi: Record = { "cws.targets.randomHint": "Trọng số kiểm soát tỷ lệ cược của mỗi lần rút (draw); thứ tự không quan trọng.", "cws.targets.leastUsedHint": "Thứ tự chỉ dùng để phá vỡ thế cân bằng giữa các mục tiêu được sử dụng với tần suất như nhau.", "cws.targets.resetWindowHint": "Thứ tự được áp dụng khi dữ liệu hạn ngạch bị thiếu hoặc ngang bằng nhau.", + "cws.targets.jevHint": "Chỉ các mục tiêu này mới có thể được chọn. Thứ tự xác định mục tiêu fail-open và đường dự phòng combo tiếp theo.", "cws.target.provider": "Nhà cung cấp", "cws.target.model": "Model", "cws.target.weight": "Trọng số (Weight)", @@ -2867,6 +2911,7 @@ export const vi: Record = { "cws.err.duplicateTarget": "Cùng một mục tiêu provider/model chỉ có thể xuất hiện một lần.", "cws.err.invalidStickyLimit": "Số lần thành công giữ nguyên (sticky successes) phải là một số nguyên từ 1 đến 100.", "cws.err.invalidWeight": "Mỗi trọng số round-robin phải là một số nguyên từ 1 đến 10000.", + "cws.err.invalidReasoningEfforts": "Mỗi mục tiêu JEV phải cho phép ít nhất một mức suy luận duy nhất được hỗ trợ.", "cws.err.noEnabledTarget": "Ít nhất một mục tiêu phải sử dụng một provider đã được bật.", "claude.tabsLabel": "Client Claude", "claude.tabCode": "Code", diff --git a/gui/src/i18n/zh-TW.ts b/gui/src/i18n/zh-TW.ts index e0b66d84a61..a9f11a71e42 100644 --- a/gui/src/i18n/zh-TW.ts +++ b/gui/src/i18n/zh-TW.ts @@ -2039,6 +2039,47 @@ export const zhTW: Record = { "cws.addTitle": "新增組合", "cws.addSubtitle": "建立跨供應商的虛擬模型,並指定客戶端實際請求的模型名稱。", "cws.create": "建立組合", + "cws.jev.create": "建立 JEV Auto", + "cws.jev.exists": "JEV Auto 已存在。", + "cws.jev.setupHint": "建立一個選用且可完整編輯的組合。JEV 會為每個請求選擇一個允許的目標與推理強度。", + "cws.jev.failOpen": "故障開放目標", + "cws.jev.allowedEfforts": "JEV 可選擇", + "cws.jev.efforts": "推理強度:{efforts}", + "cws.jev.effortsUnknown": "未公布推理強度", + "cws.jev.effortsNone": "無明確推理強度", + "cws.jev.stats.tab": "統計", + "cws.jev.stats.range": "JEV 統計範圍", + "cws.jev.stats.refresh": "重新整理", + "cws.jev.stats.loading": "正在載入 JEV 統計", + "cws.jev.stats.loadFailed": "無法載入 JEV 統計。", + "cws.jev.stats.historyIncomplete": "部分較舊的 JEV 記錄已省略,因此總計可能不完整。", + "cws.jev.stats.emptyTitle": "尚無 JEV 決策", + "cws.jev.stats.emptyBody": "執行此組合以記錄選擇與模型 Token 用量。啟用統計支援之前的決策無法取得。", + "cws.jev.stats.decisions": "決策", + "cws.jev.stats.appliedAndFailOpen": "已套用 {applied} · Fail-open {failOpen}", + "cws.jev.stats.modelTokens": "模型 Token", + "cws.jev.stats.decisionTokens": "JEV 決策 Token", + "cws.jev.stats.measuredAttempts": "已測量 {measured}/{total} 次嘗試", + "cws.jev.stats.measuredDecisions": "{measured}/{total} 個決策回報用量", + "cws.jev.stats.successful": "{count} 個成功請求", + "cws.jev.stats.fallbackOne": "{count} 次模型備援", + "cws.jev.stats.fallbackMany": "{count} 次模型備援", + "cws.jev.stats.averageLatency": "平均 JEV 延遲:{value}", + "cws.jev.stats.averageConfidence": "平均信心值:{value}", + "cws.jev.stats.gates": "JEV 決策狀態", + "cws.jev.stats.model": "模型", + "cws.jev.stats.otherModels": "其他模型", + "cws.jev.stats.picks": "選擇", + "cws.jev.stats.efforts": "推理強度", + "cws.jev.stats.attempts": "嘗試", + "cws.jev.stats.input": "輸入", + "cws.jev.stats.output": "輸出", + "cws.jev.stats.reasoning": "推理", + "cws.jev.stats.cacheReadWrite": "快取讀/寫", + "cws.jev.stats.total": "總計", + "cws.jev.stats.noEffort": "無", + "cws.jev.stats.measuredShort": "已測量", + "cws.jev.stats.tokenFootnote": "模型 Token 來自實際目標嘗試,包括重試與備援。JEV 決策 Token 由決策服務另外回報。", "cws.railAria": "組合列表", "cws.searchPlaceholder": "搜尋組合或目標…", "cws.noSearchResults": "沒有符合的組合。", @@ -2074,11 +2115,13 @@ export const zhTW: Record = { "cws.strategy.random": "隨機", "cws.strategy.leastUsed": "最少使用", "cws.strategy.resetWindow": "重置視窗", + "cws.strategy.jev": "JEV 選擇", "cws.strategy.failoverHint": "按順序嘗試目標。若出現可重試錯誤(限流、故障、訂閱門控),則跳到下一個。", "cws.strategy.roundRobinHint": "按權重確定性地分配流量。將所選目標保留一批成功請求後,再推進到下一個目標。", "cws.strategy.randomHint": "每個請求按權重比例隨機抽取一個可用目標,請求之間不保持黏性。", "cws.strategy.leastUsedHint": "將每個請求路由到成功次數最少的可用目標。計數隨代理重啟歸零。", "cws.strategy.resetWindowHint": "優先選擇配額視窗最早重置的可用目標。缺少配額資料時回退到設定順序。", + "cws.strategy.jevHint": "讓 TypeSafe JEV 選擇一個可用目標與相容的推理強度。若決策不可用,則使用第一個可用目標。", "cws.field.id": "組合 ID", "cws.field.idHint": "客戶端將請求 {model}", "cws.field.idInternalHint": "組合的內部 ID,建立後仍可修改。", @@ -2105,6 +2148,7 @@ export const zhTW: Record = { "cws.targets.randomHint": "權重控制每次抽取的機率,順序無關緊要。", "cws.targets.leastUsedHint": "順序僅在使用量相同的目標之間打破平局。", "cws.targets.resetWindowHint": "配額資料缺失或相同時依順序處理。", + "cws.targets.jevHint": "只有這些目標可被選擇。順序決定故障開放目標與後續組合備援。", "cws.target.provider": "供應商", "cws.target.model": "模型", "cws.target.weight": "權重", @@ -2144,6 +2188,7 @@ export const zhTW: Record = { "cws.err.duplicateTarget": "同一供應商/模型目標只能出現一次。", "cws.err.invalidStickyLimit": "粘性成功次數必須是 1 到 100 的整數。", "cws.err.invalidWeight": "每個輪詢權重必須是 1 到 10000 的整數。", + "cws.err.invalidReasoningEfforts": "每個 JEV 目標必須至少允許一個受支援且不重複的推理強度。", "cws.err.noEnabledTarget": "至少一個目標必須使用已啟用的供應商。", "claude.tabsLabel": "Claude 客戶端", "claude.tabCode": "Code", diff --git a/gui/src/i18n/zh.ts b/gui/src/i18n/zh.ts index 2b127afa302..4c9579b2de3 100644 --- a/gui/src/i18n/zh.ts +++ b/gui/src/i18n/zh.ts @@ -2743,6 +2743,47 @@ export const zh: Record = { "cws.addTitle": "添加组合", "cws.addSubtitle": "创建跨提供方的虚拟模型,并指定客户端实际请求的模型名称。", "cws.create": "创建组合", + "cws.jev.create": "创建 JEV Auto", + "cws.jev.exists": "JEV Auto 已存在。", + "cws.jev.setupHint": "创建一个可选且完全可编辑的组合。JEV 会为每个请求选择一个允许的目标和推理强度。", + "cws.jev.failOpen": "故障开放目标", + "cws.jev.allowedEfforts": "JEV 可选择", + "cws.jev.efforts": "推理强度:{efforts}", + "cws.jev.effortsUnknown": "未公布推理强度", + "cws.jev.effortsNone": "无显式推理强度", + "cws.jev.stats.tab": "统计", + "cws.jev.stats.range": "JEV 统计范围", + "cws.jev.stats.refresh": "刷新", + "cws.jev.stats.loading": "正在加载 JEV 统计", + "cws.jev.stats.loadFailed": "无法加载 JEV 统计。", + "cws.jev.stats.historyIncomplete": "部分较早的 JEV 记录已省略,因此汇总可能不完整。", + "cws.jev.stats.emptyTitle": "尚无 JEV 决策", + "cws.jev.stats.emptyBody": "运行此组合以记录选择和模型 Token 用量。启用统计支持之前的决策不可用。", + "cws.jev.stats.decisions": "决策", + "cws.jev.stats.appliedAndFailOpen": "已应用 {applied} · 故障开放 {failOpen}", + "cws.jev.stats.modelTokens": "模型 Token", + "cws.jev.stats.decisionTokens": "JEV 决策 Token", + "cws.jev.stats.measuredAttempts": "已测量 {measured}/{total} 次尝试", + "cws.jev.stats.measuredDecisions": "{measured}/{total} 个决策报告了用量", + "cws.jev.stats.successful": "{count} 个成功请求", + "cws.jev.stats.fallbackOne": "{count} 次模型回退", + "cws.jev.stats.fallbackMany": "{count} 次模型回退", + "cws.jev.stats.averageLatency": "平均 JEV 延迟:{value}", + "cws.jev.stats.averageConfidence": "平均置信度:{value}", + "cws.jev.stats.gates": "JEV 决策状态", + "cws.jev.stats.model": "模型", + "cws.jev.stats.otherModels": "其他模型", + "cws.jev.stats.picks": "选择", + "cws.jev.stats.efforts": "推理强度", + "cws.jev.stats.attempts": "尝试", + "cws.jev.stats.input": "输入", + "cws.jev.stats.output": "输出", + "cws.jev.stats.reasoning": "推理", + "cws.jev.stats.cacheReadWrite": "缓存读/写", + "cws.jev.stats.total": "总计", + "cws.jev.stats.noEffort": "无", + "cws.jev.stats.measuredShort": "已测量", + "cws.jev.stats.tokenFootnote": "模型 Token 来自实际目标尝试,包括重试和回退。JEV 决策 Token 由决策服务单独报告。", "cws.railAria": "组合列表", "cws.searchPlaceholder": "搜索组合或目标…", "cws.noSearchResults": "没有匹配的组合。", @@ -2779,11 +2820,13 @@ export const zh: Record = { "cws.strategy.random": "随机", "cws.strategy.leastUsed": "最少使用", "cws.strategy.resetWindow": "重置窗口", + "cws.strategy.jev": "JEV 选择", "cws.strategy.failoverHint": "按顺序尝试目标。若出现可重试错误(限流、故障、订阅门控),则跳到下一个。", "cws.strategy.roundRobinHint": "按权重确定性地分配流量。将所选目标保留一批成功请求后,再推进到下一个目标。", "cws.strategy.randomHint": "每个请求按权重比例随机抽取一个可用目标,请求之间不保持粘性。", "cws.strategy.leastUsedHint": "把每个请求路由到成功次数最少的可用目标。计数随代理重启归零。", "cws.strategy.resetWindowHint": "优先选择配额窗口最早重置的可用目标。缺少配额数据时回退到配置顺序。", + "cws.strategy.jevHint": "让 TypeSafe JEV 选择一个可用目标和兼容的推理强度。如果决策不可用,则使用第一个可用目标。", "cws.field.id": "组合 ID", "cws.field.idHint": "客户端将请求 {model}", "cws.field.idInternalHint": "组合的内部 ID,创建后仍可修改。", @@ -2814,6 +2857,7 @@ export const zh: Record = { "cws.targets.randomHint": "权重控制每次抽取的概率,顺序无关紧要。", "cws.targets.leastUsedHint": "顺序仅在使用量相同的目标之间打破平局。", "cws.targets.resetWindowHint": "配额数据缺失或相同时按顺序处理。", + "cws.targets.jevHint": "只有这些目标可被选择。顺序决定故障开放目标和后续组合回退。", "cws.target.provider": "提供方", "cws.target.model": "模型", "cws.target.weight": "权重", @@ -2856,6 +2900,7 @@ export const zh: Record = { "cws.err.duplicateTarget": "同一提供方/模型目标只能出现一次。", "cws.err.invalidStickyLimit": "粘性成功次数必须是 1 到 100 的整数。", "cws.err.invalidWeight": "每个轮询权重必须是 1 到 10000 的整数。", + "cws.err.invalidReasoningEfforts": "每个 JEV 目标必须至少允许一个受支持且不重复的推理强度。", "cws.err.noEnabledTarget": "至少一个目标必须使用已启用的提供方。", "claude.tabsLabel": "Claude 客户端", "claude.tabCode": "Code", diff --git a/gui/src/pages/Combos.tsx b/gui/src/pages/Combos.tsx index 98946ef9c04..33bba0f04e3 100644 --- a/gui/src/pages/Combos.tsx +++ b/gui/src/pages/Combos.tsx @@ -15,6 +15,9 @@ import { Notice } from "../ui"; import { useT } from "../i18n/shared"; import { useDataSurface } from "../data-surface"; import { DataSurfaceSkeleton } from "../components/data-surface"; +import { normalizeHashPath, replaceHash } from "../hash-routing"; +import { JEV_AUTO_CREATE_HASH } from "../app-routing"; +import type { ComboAddIntent } from "../components/combo-workspace-types"; type ProviderOption = { name: string; @@ -94,7 +97,16 @@ export default function Combos({ const [retainedData, setRetainedData] = useState(cached ?? null); const [status, setStatus] = useState(""); const [statusOk, setStatusOk] = useState(false); - const [adding, setAdding] = useState(false); + const [addIntent, setAddIntent] = useState(() => ( + normalizeHashPath(window.location.hash) === JEV_AUTO_CREATE_HASH ? "jev-auto" : null + )); + + const closeAdd = useCallback(() => { + setAddIntent(null); + if (normalizeHashPath(window.location.hash) === JEV_AUTO_CREATE_HASH) { + replaceHash("models/combos"); + } + }, []); const notify = (msg: string, ok: boolean) => { setStatus(msg); @@ -140,7 +152,7 @@ export default function Combos({ const providers = Object.entries(allProviders).map(([name, p]) => ({ name, disabled: !!p.disabled, - hiddenFromPicker: !Object.hasOwn(visibleProviders, name), + hiddenFromPicker: p.adapter === "jev-decision" || !Object.hasOwn(visibleProviders, name), authMode: p.authMode, adapter: p.adapter, baseUrl: p.baseUrl, @@ -380,9 +392,10 @@ export default function Combos({ onRefresh={() => { resource.refresh(); quotaResource.refresh(); }} onSave={saveCombo} onRemove={removeCombo} - onAdd={() => setAdding(true)} - adding={adding} - onCloseAdd={() => setAdding(false)} + onAdd={(intent = "blank") => setAddIntent(intent)} + adding={addIntent !== null} + addIntent={addIntent ?? undefined} + onCloseAdd={closeAdd} onCreated={() => resource.refresh()} /> diff --git a/gui/src/pages/Providers.tsx b/gui/src/pages/Providers.tsx index e2bfd75f159..f476ad15cf0 100644 --- a/gui/src/pages/Providers.tsx +++ b/gui/src/pages/Providers.tsx @@ -23,6 +23,7 @@ import { buildAccountLoginStatus, buildAddModalAccountRows } from "./providers-p import type { CodexAccountMutationCompletion } from "../codex-account-mutation"; import { useProviderModelsNotice } from "./use-provider-models-notice"; import { navigateHash } from "../hash-routing"; +import { JEV_AUTO_CREATE_HASH } from "../app-routing"; import { useProviderSettingsDeepLink } from "./providers-deep-link"; /** The page's real refresh tickets: only the captured report epoch and account read can settle them. */ @@ -619,6 +620,9 @@ export default function Providers({ apiBase }: { apiBase: string }) { modelRevision={data.modelRevision} modelRowsReady={data.modelRowsReady} onOpenModels={() => navigateHash("models")} + onCreateJevAuto={item.adapter === "jev-decision" && item.hasApiKey + ? () => navigateHash(JEV_AUTO_CREATE_HASH) + : undefined} modelsLoading={data.modelsLoading} modelsLoadFailed={data.modelsLoadFailed} onRetryModels={data.onRetryModels} diff --git a/gui/src/pages/models-tab.ts b/gui/src/pages/models-tab.ts index ae5a417946b..4afe3f87d26 100644 --- a/gui/src/pages/models-tab.ts +++ b/gui/src/pages/models-tab.ts @@ -7,6 +7,7 @@ */ import { navigateHash, normalizeHashPath, splitHashQuery } from "../hash-routing"; +import { JEV_AUTO_CREATE_HASH } from "../app-routing"; /** * `catalog` rather than `models` for the first tab: the page is Models and its first @@ -33,7 +34,7 @@ export function modelsTabHash(tab: ModelsTab): string { export function readModelsTab(hash = window.location.hash): ModelsTab { // A compatibility prefilter rides in `?query` (protocol-deep-links.ts); the tab is the path. const raw = splitHashQuery(normalizeHashPath(hash)).path; - if (raw === "models/combos" || raw === "combos" || raw.startsWith("combos/")) return "combos"; + if (raw === "models/combos" || raw === JEV_AUTO_CREATE_HASH || raw === "combos" || raw.startsWith("combos/")) return "combos"; if (raw === "models/routing" || raw === "routing" || raw.startsWith("routing/")) return "routing"; if (raw === "models/compatibility" || raw === "lab" || raw.startsWith("lab/")) return "compatibility"; return "catalog"; diff --git a/gui/src/styles-combos-workspace.css b/gui/src/styles-combos-workspace.css index ef7bdf9bfc9..9faebb3d6d0 100644 --- a/gui/src/styles-combos-workspace.css +++ b/gui/src/styles-combos-workspace.css @@ -258,6 +258,131 @@ max-width: 720px; } +.jev-stats { + display: flex; + flex-direction: column; + gap: 14px; + min-width: 0; +} + +.jev-stats-toolbar, +.jev-stats-facts, +.jev-stats-gates { + display: flex; + align-items: center; + flex-wrap: wrap; + gap: 8px; +} + +.jev-stats-toolbar { + justify-content: space-between; +} + +.jev-stats-ranges { + display: inline-flex; + padding: 2px; + gap: 2px; + border: 1px solid var(--border); + border-radius: var(--radius-pill); + background: var(--surface); +} + +.jev-stats-ranges .btn { + min-width: 0; + min-height: 0; + padding: 4px 10px; + border: none; + border-radius: var(--radius-pill); +} + +.jev-stats-cards { + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: 10px; +} + +.jev-stats-cards .stat { + min-width: 0; + padding: 12px; + border: 1px solid var(--border-soft); + border-radius: var(--radius-md); + background: var(--surface); +} + +.jev-stats-facts { + color: var(--muted); + font-size: var(--text-caption); +} + +.jev-stats-facts span:not(:last-child)::after { + content: "·"; + margin-left: 8px; + color: var(--faint); +} + +.jev-stats-table-wrap { + overflow-x: auto; + border: 1px solid var(--border-soft); + border-radius: var(--radius-md); +} + +.jev-stats-table { + width: 100%; + min-width: 760px; + border-collapse: collapse; + font-size: var(--text-caption); +} + +.jev-stats-table th, +.jev-stats-table td { + padding: 9px 10px; + text-align: left; + vertical-align: top; + border-bottom: 1px solid var(--border-soft); +} + +.jev-stats-table th { + color: var(--muted); + font-weight: 600; + background: var(--surface); +} + +.jev-stats-table tr:last-child td { + border-bottom: none; +} + +.jev-stats-table .num { + text-align: right; + white-space: nowrap; +} + +.jev-stats-cell-note { + display: block; + color: var(--muted); + font-family: var(--sans); + font-size: 10px; + line-height: 1.3; +} + +.jev-stats-empty { + padding: 28px 20px; + text-align: center; + border: 1px dashed var(--border); + border-radius: var(--radius-md); +} + +.jev-stats-empty h3, +.jev-stats-empty p, +.jev-stats-footnote { + margin: 0; +} + +@media (max-width: 720px) { + .jev-stats-cards { + grid-template-columns: 1fr; + } +} + .cwi-search-row { display: flex; align-items: center; @@ -356,6 +481,59 @@ gap: 8px; } +.cwi-target-entry { + display: flex; + flex-direction: column; + gap: 5px; +} + +.cwi-jev-target-meta { + display: flex; + align-items: flex-start; + flex-wrap: wrap; + gap: 8px; + padding-left: 72px; + font-size: var(--text-label); +} + +.cwi-jev-efforts { + display: flex; + align-items: center; + flex-wrap: wrap; + gap: 6px 10px; + min-width: 0; + margin: 0; + padding: 0; + border: 0; +} + +.cwi-jev-efforts legend { + float: left; + margin: 2px 2px 0 0; + color: var(--muted); +} + +.cwi-jev-effort { + display: inline-flex; + align-items: center; + gap: 4px; + cursor: pointer; + color: var(--text); +} + +.cwi-jev-effort:has(input:disabled) { + cursor: not-allowed; +} + +.cwi-jev-quick-action { + display: flex; + flex-direction: column; + align-items: stretch; + gap: 5px; + padding: 0 12px 12px; + font-size: var(--text-label); +} + .cwi-target-row { display: grid; grid-template-columns: 28px auto minmax(0, 1fr) minmax(0, 1.2fr) 4.5rem auto auto; @@ -462,6 +640,7 @@ .cwi-strategy-seg { display: inline-flex; + flex-wrap: wrap; border-radius: var(--radius-pill); background: var(--surface-soft, var(--raised)); padding: 3px; @@ -577,6 +756,10 @@ justify-self: start; } + .cwi-jev-target-meta { + padding-left: 0; + } + } /* Narrow compatibility for pwi-* classes consumed by ComboWorkspace only. */ diff --git a/gui/tests/combo-strategy-roundtrip.test.ts b/gui/tests/combo-strategy-roundtrip.test.ts index fcf3d81a53e..e6a0bafbc0e 100644 --- a/gui/tests/combo-strategy-roundtrip.test.ts +++ b/gui/tests/combo-strategy-roundtrip.test.ts @@ -1,14 +1,21 @@ /** * Dashboard load -> save must not rewrite a combo's strategy. * - * The runtime and management API accept five strategies. The GUI parser used to + * The runtime and management API accept six strategies. The GUI parser used to * collapse random/least-used/reset-window to failover, so saving an untouched * combo silently rewrote its strategy (and stripped weights for random). */ import { expect, test } from "bun:test"; import { groupCombos, parseComboList, toPutBody } from "../src/combo-workspace-data"; -const strategies = ["failover", "round-robin", "random", "least-used", "reset-window"] as const; +const strategies = [ + "failover", + "round-robin", + "random", + "least-used", + "reset-window", + "jev", +] as const; function payloadWith(strategy: unknown, weight?: number) { return { @@ -55,6 +62,9 @@ test("saving an untouched combo round-trips merged strategies and random weights const [resetWindow] = parseComboList(payloadWith("reset-window")); expect(toPutBody(resetWindow!).combo.strategy).toBe("reset-window"); + + const [jev] = parseComboList(payloadWith("jev")); + expect(toPutBody(jev!).combo.strategy).toBe("jev"); }); test("round-robin still sends weights and stickyLimit", () => { @@ -65,7 +75,7 @@ test("round-robin still sends weights and stickyLimit", () => { expect(body.combo.stickyLimit).toBe(3); }); -test("groupCombos keeps the three newer strategies in their own bucket", () => { +test("groupCombos keeps non-primary strategies in their own bucket", () => { const combos = strategies.map((strategy) => parseComboList(payloadWith(strategy))[0]!); const sections = groupCombos(combos); expect(sections.failover.map((c) => c.strategy)).toEqual(["failover"]); @@ -74,5 +84,6 @@ test("groupCombos keeps the three newer strategies in their own bucket", () => { "random", "least-used", "reset-window", + "jev", ]); }); diff --git a/gui/tests/combo-strategy-selector.test.tsx b/gui/tests/combo-strategy-selector.test.tsx index 8bfe0c541f6..d27a91591aa 100644 --- a/gui/tests/combo-strategy-selector.test.tsx +++ b/gui/tests/combo-strategy-selector.test.tsx @@ -21,11 +21,12 @@ test("combo strategy selector exposes all runtime strategies", () => { , ); const radios = html.match(/]*role="radio"[^>]*>/g) ?? []; - expect(radios).toHaveLength(5); + expect(radios).toHaveLength(6); expect(html).toContain("Failover"); expect(html).toContain("Round-robin"); expect(html).toContain("Random"); expect(html).toContain("Least-used"); expect(html).toContain("Reset-window"); + expect(html).toContain("JEV"); expect(radios.every((button) => !button.includes("disabled="))).toBe(true); }); diff --git a/gui/tests/combos-detail-tabs-dom.test.tsx b/gui/tests/combos-detail-tabs-dom.test.tsx index ba03b2fc7e0..60ef6b58d51 100644 --- a/gui/tests/combos-detail-tabs-dom.test.tsx +++ b/gui/tests/combos-detail-tabs-dom.test.tsx @@ -14,6 +14,7 @@ import type { Root } from "react-dom/client"; import { DetailPanel } from "../src/components/combo-workspace-detail-panel"; import { LanguageProvider } from "../src/i18n/provider"; import { emptyDraft } from "../src/combo-workspace-data"; +import { clearClientResourceStoresForTests } from "../src/client-resource"; const globals = ["document", "window", "navigator", "localStorage", "sessionStorage"] as const; let previousGlobals: Record<(typeof globals)[number], unknown>; @@ -121,6 +122,67 @@ test("roving tabindex keeps the tablist to one tab stop", async () => { } }); +test("an existing JEV combo exposes a lazy Stats tab", async () => { + const { createRoot } = await import("react-dom/client"); + const originalFetch = globalThis.fetch; + const requests: string[] = []; + Object.defineProperty(globalThis, "fetch", { + configurable: true, + value: async (input: RequestInfo | URL) => { + requests.push(String(input)); + return Response.json({ + range: "30d", comboId: "jev-auto", generatedAt: 1, + summary: { + decisions: 0, appliedDecisions: 0, failOpenDecisions: 0, successfulRequests: 0, + requestsWithModelFallback: 0, modelAttempts: 0, measuredModelAttempts: 0, + modelInputTokens: 0, modelOutputTokens: 0, modelReasoningTokens: 0, + modelCacheReadTokens: 0, modelCacheWriteTokens: 0, modelTotalTokens: 0, + decisionUsageReported: 0, decisionInputTokens: 0, decisionOutputTokens: 0, + decisionTotalTokens: 0, averageLatencyMs: null, averageConfidence: null, + averageChosenProbability: null, + }, + gates: [], models: [], historyTruncated: false, entriesTruncated: false, + }); + }, + }); + clearClientResourceStoresForTests(); + const container = document.createElement("div"); + document.body.append(container); + const root = createRoot(container); + try { + await act(async () => { + root.render( + + {}} + onSave={async () => ({ ok: true })} + onDirtyChange={() => {}} + /> + , + ); + }); + await act(async () => { await new Promise(resolve => setTimeout(resolve, 10)); }); + expect(tabs(container).map(tab => tab.textContent?.trim())).toEqual(["Config", "Stats", "About"]); + expect(requests).toHaveLength(0); + await act(async () => { container.querySelector("#cws-detail-tab-stats")!.click(); }); + await act(async () => { await new Promise(resolve => setTimeout(resolve, 10)); }); + expect(requests).toHaveLength(1); + expect(requests[0]).toContain("comboId=jev-auto"); + } finally { + await act(async () => root.unmount()); + clearClientResourceStoresForTests(); + Object.defineProperty(globalThis, "fetch", { configurable: true, value: originalFetch }); + } +}); + test("the About panel is focusable, since it holds nothing focusable itself", async () => { const { container, root } = await mountDetail(); try { diff --git a/gui/tests/fr-localization.test.ts b/gui/tests/fr-localization.test.ts index 7dfb4c07b17..9baa172e2ec 100644 --- a/gui/tests/fr-localization.test.ts +++ b/gui/tests/fr-localization.test.ts @@ -196,6 +196,9 @@ const INTENTIONAL_ENGLISH = new Set([ "api.colSource", "api.testSucceeded", "cws.count.total", + // Both labels are ordinary French words with the same spelling and meaning. + "cws.jev.stats.efforts", + "cws.jev.stats.total", "claudeDesktop.alias", "lab.filter.verdict", "lab.col.suite", diff --git a/gui/tests/jev-auto-combo.test.tsx b/gui/tests/jev-auto-combo.test.tsx new file mode 100644 index 00000000000..d4ad8a65c33 --- /dev/null +++ b/gui/tests/jev-auto-combo.test.tsx @@ -0,0 +1,377 @@ +import { afterEach, beforeEach, expect, test } from "bun:test"; +import { Window } from "happy-dom"; +import { act, useState } from "react"; +import type { Root } from "react-dom/client"; +import { renderToStaticMarkup } from "react-dom/server"; +import ComboWorkspace from "../src/components/ComboWorkspace"; +import { TargetEditor } from "../src/components/combo-workspace-controls"; +import ProviderDetails from "../src/components/provider-workspace/ProviderDetails"; +import ProviderAuthPanel from "../src/components/provider-workspace/ProviderAuthPanel"; +import { LanguageProvider } from "../src/i18n/provider"; +import Combos from "../src/pages/Combos"; +import { navigateHash } from "../src/hash-routing"; +import { readModelsTab } from "../src/pages/models-tab"; +import { toPutBody, type ComboItem, type ComboTarget } from "../src/combo-workspace-data"; + +const globals = ["document", "window", "navigator", "localStorage", "IS_REACT_ACT_ENVIRONMENT"] as const; +let previousGlobals: Record<(typeof globals)[number], unknown>; +let originalFetch: typeof globalThis.fetch; +let testWindow: Window; +let root: Root | null; + +const models = [ + { provider: "native-only", id: "gpt-6-astra", reasoningEfforts: ["medium"] }, + { provider: "native-only", id: "gpt-5.6-sol", reasoningEfforts: ["medium"] }, + { provider: "native-only", id: "gpt-5.6-luna", reasoningEfforts: ["medium"] }, + { provider: "openai", id: "gpt-6-astra", reasoningEfforts: ["medium", "high", "xhigh"] }, + { provider: "openai", id: "gpt-5.6-sol", reasoningEfforts: ["low", "medium", "high"] }, + { provider: "openai", id: "gpt-5.6-luna", reasoningEfforts: ["low", "medium"] }, + { provider: "anthropic", id: "claude-sonnet-5", reasoningEfforts: ["low", "medium", "high"] }, +]; + +const existing: ComboItem = { + id: "fallback", + model: "combo/fallback", + alias: null, + nativeAlias: false, + displayName: null, + strategy: "failover", + stickyLimit: 1, + defaultEffort: null, + targets: [{ provider: "openai", model: "gpt-5.6-luna" }], +}; + +beforeEach(() => { + previousGlobals = Object.fromEntries(globals.map(key => [key, Reflect.get(globalThis, key)])) as typeof previousGlobals; + originalFetch = globalThis.fetch; + testWindow = new Window({ url: "http://localhost/#providers/jev" }); + Object.defineProperty(testWindow.navigator, "language", { configurable: true, value: "en-US" }); + Object.defineProperties(globalThis, { + document: { configurable: true, value: testWindow.document }, + window: { configurable: true, value: testWindow }, + navigator: { configurable: true, value: testWindow.navigator }, + localStorage: { configurable: true, value: testWindow.localStorage }, + }); + (globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true; + root = null; +}); + +afterEach(async () => { + if (root) await act(async () => { root?.unmount(); }); + Object.defineProperty(globalThis, "fetch", { configurable: true, value: originalFetch }); + testWindow.close(); + for (const key of globals) { + Object.defineProperty(globalThis, key, { configurable: true, value: previousGlobals[key] }); + } +}); + +async function flush(rounds = 3) { + await act(async () => { + for (let index = 0; index < rounds; index += 1) { + await new Promise(resolve => setTimeout(resolve, 0)); + } + }); +} + +function setSelect(select: HTMLSelectElement, value: string) { + Object.getOwnPropertyDescriptor(testWindow.HTMLSelectElement.prototype, "value")! + .set!.call(select, value); + select.dispatchEvent(new testWindow.Event("change", { bubbles: true })); +} + +test("Combo workspace exposes JEV Auto and reports an existing selector collision", () => { + const markup = renderToStaticMarkup( + + {}} + onSave={async () => ({ ok: true })} + onRemove={async () => ({ ok: true })} + onAdd={() => {}} + adding={false} + onCloseAdd={() => {}} + onCreated={() => {}} + /> + , + ); + expect(markup).toContain("Create JEV Auto"); + + const collision = renderToStaticMarkup( + + {}} + onSave={async () => ({ ok: true })} + onRemove={async () => ({ ok: true })} + onAdd={() => {}} + adding={false} + onCloseAdd={() => {}} + onCreated={() => {}} + /> + , + ); + expect(collision).toContain("JEV Auto already exists"); + const collisionHost = document.createElement("div"); + collisionHost.innerHTML = collision; + const collisionAction = [...collisionHost.querySelectorAll("button")] + .find(button => button.textContent?.trim() === "Create JEV Auto"); + expect(collisionAction?.disabled).toBeTrue(); +}); + +test("JEV fail-open badge skips quota-exhausted targets", () => { + const markup = renderToStaticMarkup( + + {}} + /> + , + ); + const host = document.createElement("div"); + host.innerHTML = markup; + const entries = host.querySelectorAll(".cwi-target-entry"); + + expect(entries).toHaveLength(2); + expect(entries[0]!.querySelector(".chip")).toBeNull(); + expect(entries[1]!.querySelector(".chip")?.textContent).toBe("Fail-open target"); +}); + +test("JEV target effort checkboxes persist an exact non-empty subset and reset for a new model", async () => { + const { createRoot } = await import("react-dom/client"); + const host = document.createElement("div"); + document.body.append(host); + root = createRoot(host); + let observed: ComboTarget[] = [{ provider: "openai", model: "gpt-6-astra", clientKey: "only" }]; + + function Harness() { + const [targets, setTargets] = useState(observed); + observed = targets; + return ( + + + + ); + } + + await act(async () => { root!.render(); }); + const effortInputs = () => [...host.querySelectorAll('input[data-jev-effort]')]; + expect(effortInputs().map(input => [input.value, input.checked])).toEqual([ + ["medium", true], + ["high", true], + ["xhigh", true], + ]); + + await act(async () => { effortInputs().find(input => input.value === "medium")!.click(); }); + expect(observed[0]?.reasoningEfforts).toEqual(["high", "xhigh"]); + await act(async () => { effortInputs().find(input => input.value === "high")!.click(); }); + expect(observed[0]?.reasoningEfforts).toEqual(["xhigh"]); + expect(effortInputs().find(input => input.value === "xhigh")?.disabled).toBe(true); + + const modelSelect = host.querySelectorAll("select")[1]!; + await act(async () => { setSelect(modelSelect, "gpt-5.6-sol"); }); + expect(observed[0]?.reasoningEfforts).toBeUndefined(); + expect(effortInputs().map(input => [input.value, input.checked])).toEqual([ + ["low", true], + ["medium", true], + ["high", true], + ]); +}); + +test("JEV API key can be saved from the provider GUI", async () => { + const { createRoot } = await import("react-dom/client"); + const host = document.createElement("div"); + document.body.append(host); + root = createRoot(host); + const saved: Array<{ provider: string; key: string }> = []; + + await act(async () => { + root!.render( + + {}, + onLogout: () => {}, + onReauth: () => {}, + onSwitchAccount: () => {}, + onRemoveAccount: () => {}, + onAddApiKey: async (provider, key) => { + saved.push({ provider, key }); + return true; + }, + onSwitchApiKey: () => {}, + onRemoveApiKey: () => {}, + onEditAlias: () => {}, + }} + /> + , + ); + }); + + const addButton = [...host.querySelectorAll("button")] + .find(button => button.textContent?.trim() === "Add API key")!; + await act(async () => { addButton.click(); }); + const input = host.querySelector('input[type="password"]')!; + await act(async () => { + Object.getOwnPropertyDescriptor(testWindow.HTMLInputElement.prototype, "value")! + .set!.call(input, "test-jev-key"); + input.dispatchEvent(new testWindow.Event("input", { bubbles: true })); + }); + await flush(); + const saveButton = [...host.querySelectorAll("button")] + .find(button => button.textContent?.trim() === "Add API key")!; + await act(async () => { saveButton.click(); }); + await flush(); + + expect(saved).toEqual([{ provider: "jev", key: "test-jev-key" }]); +}); + +test("configured JEV deep-link opens the shared editable Combo modal and submits the normal PUT", async () => { + const { createRoot } = await import("react-dom/client"); + const host = document.createElement("div"); + document.body.append(host); + root = createRoot(host); + + await act(async () => { + root!.render( + + {}} + onCreateJevAuto={() => navigateHash("models/combos/jev-auto")} + onDeselect={() => {}} + apiBase="" + /> + , + ); + }); + const providerAction = [...host.querySelectorAll("button")] + .find(button => button.textContent?.trim() === "Create JEV Auto"); + expect(providerAction).toBeDefined(); + await act(async () => { providerAction!.click(); }); + expect(window.location.hash).toBe("#models/combos/jev-auto"); + expect(readModelsTab()).toBe("combos"); + + await act(async () => { root!.unmount(); }); + root = createRoot(host); + + const puts: unknown[] = []; + Object.defineProperty(globalThis, "fetch", { + configurable: true, + value: async (input: RequestInfo | URL, init?: RequestInit) => { + const url = String(input); + if (url.endsWith("/api/combos") && init?.method === "PUT") { + puts.push(JSON.parse(String(init.body))); + return Response.json({ success: true }); + } + if (url.endsWith("/api/combos")) return Response.json({ combos: [existing] }); + if (url.endsWith("/api/config")) { + return Response.json({ + providers: { + openai: { adapter: "openai-responses", baseUrl: "https://api.openai.com/v1" }, + anthropic: { adapter: "anthropic", baseUrl: "https://api.anthropic.com" }, + jev: { adapter: "jev-decision", baseUrl: "https://api.typesafe.ai/v1/systemone" }, + }, + }); + } + if (url.endsWith("/api/models")) return Response.json(models); + if (url.endsWith("/api/provider-quotas")) return Response.json({ reports: [] }); + throw new Error(`unexpected request: ${url}`); + }, + }); + + await act(async () => { + root!.render(); + }); + await flush(6); + + const dialog = host.querySelector('dialog[data-combo-preset="jev-auto"]'); + expect(dialog).not.toBeNull(); + expect(host.querySelector("#cwi-new-id")?.value).toBe("jev-auto"); + expect(host.querySelector("#cwi-new-alias")?.value).toBe("jev-auto"); + expect(host.querySelector('[role="radio"][aria-checked="true"]')?.textContent).toContain("JEV"); + expect(host.textContent).toContain("Fail-open target"); + expect([...dialog!.querySelectorAll(".cwi-target-entry:first-child input[data-jev-effort]")] + .map(input => input.value)).toEqual(["medium", "high", "xhigh"]); + expect([...dialog!.querySelectorAll('select[aria-label="Provider"]')] + .map(select => select.value)).toEqual(["openai", "openai", "openai"]); + + const addTarget = [...host.querySelectorAll("button")] + .find(button => button.textContent?.trim() === "Add target")!; + await act(async () => { addTarget.click(); }); + let targetRows = host.querySelectorAll(".cwi-target-row"); + expect(targetRows).toHaveLength(4); + await act(async () => { + setSelect(targetRows[3]!.querySelectorAll("select")[0]!, "anthropic"); + }); + expect(targetRows[3]!.querySelectorAll("select")[1]!.value).toBe("claude-sonnet-5"); + + const removeButtons = host.querySelectorAll('button[aria-label="Remove"]'); + await act(async () => { removeButtons[2]!.click(); }); + targetRows = host.querySelectorAll(".cwi-target-row"); + expect(targetRows).toHaveLength(3); + + const create = [...host.querySelectorAll("button")] + .find(button => button.textContent?.trim() === "Create combo")!; + await act(async () => { create.click(); }); + await flush(); + + expect(puts).toEqual([toPutBody({ + id: "jev-auto", + model: "jev-auto", + alias: "jev-auto", + nativeAlias: false, + displayName: null, + strategy: "jev", + stickyLimit: 1, + defaultEffort: null, + imageInput: "auto", + reasoningEffortMode: "adaptive", + targets: [ + { provider: "openai", model: "gpt-6-astra" }, + { provider: "openai", model: "gpt-5.6-sol" }, + { provider: "anthropic", model: "claude-sonnet-5" }, + ], + })]); +}); diff --git a/gui/tests/jev-stats-panel.test.tsx b/gui/tests/jev-stats-panel.test.tsx new file mode 100644 index 00000000000..829cf2d0bc0 --- /dev/null +++ b/gui/tests/jev-stats-panel.test.tsx @@ -0,0 +1,180 @@ +import { afterEach, beforeEach, expect, test } from "bun:test"; +import { Window } from "happy-dom"; +import { act } from "react"; +import type { Root } from "react-dom/client"; +import { clearClientResourceStoresForTests } from "../src/client-resource"; +import { JevStatsPanel } from "../src/components/jev-stats-panel"; +import { LanguageProvider } from "../src/i18n/provider"; + +const globals = ["document", "window", "navigator", "localStorage", "sessionStorage", "IS_REACT_ACT_ENVIRONMENT"] as const; +let previousGlobals: Record<(typeof globals)[number], unknown>; +let originalFetch: typeof globalThis.fetch; +let testWindow: Window; +let root: Root | null; + +const response = { + range: "30d", + comboId: "jev-auto", + since: 0, + generatedAt: 1, + summary: { + decisions: 3, + appliedDecisions: 2, + failOpenDecisions: 1, + successfulRequests: 3, + requestsWithModelFallback: 1, + modelAttempts: 4, + measuredModelAttempts: 3, + modelInputTokens: 1_000, + modelOutputTokens: 200, + modelReasoningTokens: 40, + modelCacheReadTokens: 300, + modelCacheWriteTokens: 20, + modelTotalTokens: 1_200, + decisionUsageReported: 2, + decisionInputTokens: 30, + decisionOutputTokens: 5, + decisionTotalTokens: 35, + averageLatencyMs: 120, + averageConfidence: 0.8, + averageChosenProbability: 0.6, + }, + gates: [{ gate: "apply", decisions: 2 }, { gate: "timeout", decisions: 1 }], + models: [{ + provider: "openai", + model: "gpt-6-astra", + overflow: false, + picks: 2, + appliedPicks: 2, + failOpenPicks: 0, + attempts: 3, + measuredAttempts: 3, + inputTokens: 1_000, + outputTokens: 200, + reasoningTokens: 40, + cacheReadTokens: 300, + cacheWriteTokens: 20, + totalTokens: 1_200, + efforts: [{ effort: "high", picks: 2 }], + }, { + provider: "", + model: "", + overflow: true, + picks: 1, + appliedPicks: 1, + failOpenPicks: 0, + attempts: 1, + measuredAttempts: 0, + inputTokens: 0, + outputTokens: 0, + reasoningTokens: 0, + cacheReadTokens: 0, + cacheWriteTokens: 0, + totalTokens: 0, + efforts: [{ effort: "medium", picks: 1 }], + }], + historyTruncated: false, + truncatedPrefixBytes: 0, + entriesTruncated: false, + entriesDropped: 0, + snapshotWindowStart: 0, + snapshotWindowEnd: 1, +}; + +beforeEach(() => { + previousGlobals = Object.fromEntries(globals.map(key => [key, Reflect.get(globalThis, key)])) as typeof previousGlobals; + originalFetch = globalThis.fetch; + testWindow = new Window({ url: "http://localhost/#models/combos" }); + Object.defineProperty(testWindow.navigator, "language", { configurable: true, value: "en-US" }); + Object.defineProperties(globalThis, { + document: { configurable: true, value: testWindow.document }, + window: { configurable: true, value: testWindow }, + navigator: { configurable: true, value: testWindow.navigator }, + localStorage: { configurable: true, value: testWindow.localStorage }, + sessionStorage: { configurable: true, value: testWindow.sessionStorage }, + }); + (globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true; + clearClientResourceStoresForTests(); + root = null; +}); + +afterEach(async () => { + if (root) await act(async () => { root?.unmount(); }); + clearClientResourceStoresForTests(); + Object.defineProperty(globalThis, "fetch", { configurable: true, value: originalFetch }); + testWindow.close(); + for (const key of globals) { + Object.defineProperty(globalThis, key, { configurable: true, value: previousGlobals[key] }); + } +}); + +async function flush(rounds = 4) { + await act(async () => { + for (let index = 0; index < rounds; index += 1) await new Promise(resolve => setTimeout(resolve, 0)); + }); +} + +test("JEV stats shows picks, model tokens and separately labelled decision tokens", async () => { + const requests: string[] = []; + Object.defineProperty(globalThis, "fetch", { + configurable: true, + value: async (input: RequestInfo | URL) => { + requests.push(String(input)); + const url = new URL(String(input), "http://localhost"); + return Response.json({ ...response, range: url.searchParams.get("range") }); + }, + }); + const { createRoot } = await import("react-dom/client"); + const host = document.createElement("div"); + document.body.append(host); + root = createRoot(host); + + await act(async () => { + root!.render( + + + , + ); + }); + await flush(); + + expect(requests[0]).toContain("/api/usage?jev=1&comboId=jev-auto&range=30d"); + expect(host.textContent).toContain("Decisions"); + expect(host.textContent).toContain("Model tokens"); + expect(host.textContent).toContain("JEV decision tokens"); + expect(host.textContent).toContain("gpt-6-astra"); + expect(host.textContent).toContain("Other models"); + expect(host.textContent).toContain("high × 2"); + expect(host.textContent).toContain("1200"); + expect(host.textContent).toContain("1 model fallback"); + + const sevenDays = [...host.querySelectorAll("button")] + .find(button => button.textContent?.trim() === "7d")!; + await act(async () => { sevenDays.click(); }); + await flush(); + expect(requests.some(url => url.includes("range=7d"))).toBeTrue(); +}); + +test("JEV stats renders the fail-open summary in Simplified Chinese", async () => { + localStorage.setItem("ocx-lang", "zh"); + Object.defineProperty(globalThis, "fetch", { + configurable: true, + value: async () => Response.json(response), + }); + const { createRoot } = await import("react-dom/client"); + const host = document.createElement("div"); + document.body.append(host); + root = createRoot(host); + + await act(async () => { + root!.render( + + + , + ); + }); + await flush(); + + expect(host.textContent).toContain("已应用 2 · 故障开放 1"); + expect(host.textContent).not.toContain("Fail-open"); +}); diff --git a/readme/README.fr.md b/readme/README.fr.md index 08961e23f41..9329dfd2def 100644 --- a/readme/README.fr.md +++ b/readme/README.fr.md @@ -351,6 +351,20 @@ correspondance selon le motif du nom du modèle. Les identifiants de modèles du sont présentés avec leurs barres obliques internes remplacées par `-` ; la forme brute comportant toutes les barres obliques continue également de fonctionner. Détails : [documentation sur le routage des modèles](https://opencodex.me/fr/guides/model-routing/). +### Routage JEV Auto (optionnel) + +TypeSafe JEV peut choisir le premier modèle et l'effort de raisonnement d'un Combo activé explicitement, +sans rien changer au sélecteur de modèles ni aux routes directes. Ajoutez l'identifiant avec +`ocx login jev`, depuis **Providers → TypeSafe JEV → Add API key**, ou via `TYPESAFE_API_KEY`/`JEV_API_KEY`. +Ouvrez ensuite **Models → Combos → Create JEV Auto**, choisissez les modèles cibles autorisés et cochez +les efforts exacts que JEV peut sélectionner pour chaque cible. Sans réglage d'effort, une cible autorise +tous les efforts que le modèle annonce actuellement. + +JEV n'est consulté que pour `jev-auto`, et une seule fois par appel logique au modèle. Un identifiant +manquant, une erreur réseau ou une décision invalide retombent sur la première cible éligible ; +l'annulation par l'appelant annule toujours la requête. Les tests automatisés utilisent un point de +terminaison TypeSafe simulé et ne valident pas un compte JEV réel. + ## Fournisseurs et adaptateurs diff --git a/readme/README.ja.md b/readme/README.ja.md index dd5b8543a63..43726a65649 100644 --- a/readme/README.ja.md +++ b/readme/README.ja.md @@ -346,6 +346,19 @@ codex -m "ollama/llama3" "この関数をリファクタリングして" のままの完全形も引き続き使えます。詳細は [モデルルーティングのドキュメント](https://opencodex.me/ja/guides/model-routing/)を参照してください。 +### JEV Auto ルーティング(任意) + +TypeSafe JEV は、明示的に有効にした Combo の最初のモデルと推論エフォートを選べます。通常のモデル +ピッカーと直接ルートは変わりません。認証情報は `ocx login jev`、**Providers → TypeSafe JEV → Add API key**、 +または `TYPESAFE_API_KEY`/`JEV_API_KEY` で追加します。次に **Models → Combos → Create JEV Auto** を開き、 +許可するターゲットモデルを選んで、ターゲットごとに JEV が選べるエフォートをチェックします。 +エフォート設定に触れていないターゲットは、そのモデルが現在公開しているすべてのエフォートを許可します。 + +JEV は `jev-auto` でのみ、論理的なモデル呼び出しごとに一度だけ使われます。認証情報がない場合、 +ネットワーク障害、または不正な判定のときは、現在利用可能な最初のターゲットへフェイルオープンします。 +呼び出し元のキャンセルは引き続きリクエストをキャンセルします。自動テストは TypeSafe のモック +エンドポイントを使い、実際の JEV アカウントは検証しません。 + ## プロバイダーとアダプター diff --git a/readme/README.ko.md b/readme/README.ko.md index 385bc7f30f9..4facdfd7de1 100644 --- a/readme/README.ko.md +++ b/readme/README.ko.md @@ -333,6 +333,18 @@ codex -m "ollama/llama3" "이 함수를 리팩터링해 줘" 프로바이더 모델 id는 안쪽 슬래시를 `-`로 alias해서 노출하고, 슬래시를 그대로 둔 원본 형태도 계속 동작합니다. 자세한 내용은 [모델 라우팅 문서](https://opencodex.me/ko/guides/model-routing/)를 보세요. +### JEV Auto 라우팅 (선택) + +TypeSafe JEV는 명시적으로 켠 Combo에서 첫 모델과 reasoning effort를 고를 수 있습니다. 일반 모델 +선택기와 직접 라우트는 그대로입니다. 자격 증명은 `ocx login jev`, **Providers → TypeSafe JEV → Add API key**, +또는 `TYPESAFE_API_KEY`/`JEV_API_KEY`로 추가합니다. 그다음 **Models → Combos → Create JEV Auto**에서 +허용할 대상 모델을 고르고, 대상마다 JEV가 고를 수 있는 effort를 체크하세요. effort 설정을 건드리지 +않은 대상은 그 모델이 현재 광고하는 effort를 모두 허용합니다. + +JEV는 `jev-auto`에서만, 논리적 모델 호출당 한 번만 호출됩니다. 자격 증명이 없거나 네트워크가 +실패하거나 결정이 잘못되면 현재 적격인 첫 대상으로 fail-open하며, 호출자 취소는 여전히 요청을 +취소합니다. 자동 테스트는 모의 TypeSafe 엔드포인트를 쓰며 실제 JEV 계정은 검증하지 않습니다. + ## 프로바이더 및 adapter diff --git a/readme/README.ru.md b/readme/README.ru.md index 1f97ac6282b..c274d4f632b 100644 --- a/readme/README.ru.md +++ b/readme/README.ru.md @@ -358,6 +358,20 @@ codex -m "ollama/llama3" "Отрефакторьте эту функцию" отдаются с внутренними слэшами, заменёнными на `-`; исходная форма со всеми слэшами тоже продолжает работать. Подробности: [документация по маршрутизации моделей](https://opencodex.me/ru/guides/model-routing/). +### Маршрутизация JEV Auto (опционально) + +TypeSafe JEV может выбирать первую модель и уровень рассуждения для явно включённого Combo, не меняя +обычный выбор модели и прямые маршруты. Добавьте ключ через `ocx login jev`, в +**Providers → TypeSafe JEV → Add API key** или через `TYPESAFE_API_KEY`/`JEV_API_KEY`. Затем откройте +**Models → Combos → Create JEV Auto**, выберите разрешённые целевые модели и отметьте, какие уровни +рассуждения JEV может выбрать для каждой цели. Если настройку не трогать, цель разрешает все уровни, +которые модель сейчас объявляет. + +JEV вызывается только для `jev-auto` и только один раз на логический вызов модели. При отсутствии ключа, +сетевой ошибке или некорректном решении запрос уходит на первую доступную цель (fail-open); отмена +со стороны клиента по-прежнему отменяет запрос. Автотесты используют имитацию TypeSafe и не проверяют +настоящий аккаунт JEV. + ## Провайдеры и адаптеры diff --git a/readme/README.tr.md b/readme/README.tr.md index 7fd1592b045..1a5e35ae823 100644 --- a/readme/README.tr.md +++ b/readme/README.tr.md @@ -350,6 +350,20 @@ Varsayılan sağlayıcıyı kullanmak ya da model adı desenine göre otomatik e değiştirilmiş biçimde sunulur; eğik çizgili tam biçim de çalışmaya devam eder. Ayrıntılar: [model yönlendirme belgeleri](https://opencodex.me/tr/guides/model-routing/). +### JEV Auto yönlendirme (isteğe bağlı) + +TypeSafe JEV, açıkça etkinleştirilen bir Combo için ilk modeli ve akıl yürütme düzeyini seçebilir; +normal model seçici ve tüm doğrudan rotalar değişmez. Kimlik bilgisini `ocx login jev` ile, +**Providers → TypeSafe JEV → Add API key** üzerinden veya `TYPESAFE_API_KEY`/`JEV_API_KEY` ile ekleyin. +Ardından **Models → Combos → Create JEV Auto** bölümünü açın, izin verilen hedef modelleri seçin ve +JEV'in her hedef için seçebileceği düzeyleri işaretleyin. Düzey ayarına dokunulmayan bir hedef, modelin +şu anda duyurduğu tüm düzeylere izin verir. + +JEV yalnızca `jev-auto` için ve mantıksal model çağrısı başına yalnızca bir kez kullanılır. Eksik kimlik +bilgisi, ağ hatası veya geçersiz karar durumunda şu anda uygun olan ilk hedefe fail-open yapılır; +çağıranın iptali isteği yine iptal eder. Otomatik testler sahte bir TypeSafe uç noktası kullanır ve +gerçek bir JEV hesabını doğrulamaz. + ## Sağlayıcılar ve adaptörler diff --git a/readme/README.zh-CN.md b/readme/README.zh-CN.md index aeb6ec6b402..2394f99ca9e 100644 --- a/readme/README.zh-CN.md +++ b/readme/README.zh-CN.md @@ -333,6 +333,17 @@ codex -m "ollama/llama3" "重构这个 function" 包含 `/` 的提供商模型 id 会把内部斜杠别名为 `-` 再对外暴露;带全部斜杠的原始形式 仍然可用。详情:[模型路由文档](https://opencodex.me/zh-cn/guides/model-routing/)。 +### JEV Auto 路由(可选) + +TypeSafe JEV 可以为显式启用的 Combo 选择首个模型和推理强度,普通模型选择器和所有直连路由保持不变。 +通过 `ocx login jev`、**Providers → TypeSafe JEV → Add API key** 或 `TYPESAFE_API_KEY`/`JEV_API_KEY` +添加凭据。然后打开 **Models → Combos → Create JEV Auto**,选择允许的目标模型,并为每个目标勾选 +JEV 可选的推理强度。未改动强度设置的目标允许该模型当前声明的全部强度。 + +JEV 只用于 `jev-auto`,且每次逻辑模型调用只咨询一次。缺少凭据、网络失败或决策无效时,会回退 +(fail-open)到当前第一个可用目标;调用方取消仍会取消请求。自动化测试使用模拟的 TypeSafe 端点, +不验证真实的 JEV 账户。 + ## 提供商与适配器 diff --git a/readme/README.zh-TW.md b/readme/README.zh-TW.md index aba62f02a59..0728a1011d2 100644 --- a/readme/README.zh-TW.md +++ b/readme/README.zh-TW.md @@ -331,6 +331,17 @@ codex -m "ollama/llama3" "重構這個 function" 供應商模型 id 若含 `/`,對外會把內部斜線別名成 `-`;原始 全斜線形式同樣可用。細節:[模型路由文件](https://opencodex.me/zh-tw/guides/model-routing/)。 +### JEV Auto 路由(選用) + +TypeSafe JEV 可以為明確啟用的 Combo 選擇第一個模型與推理強度,一般模型選擇器與所有直接路由保持不變。 +透過 `ocx login jev`、**Providers → TypeSafe JEV → Add API key** 或 `TYPESAFE_API_KEY`/`JEV_API_KEY` +加入憑證。接著開啟 **Models → Combos → Create JEV Auto**,選擇允許的目標模型,並為每個目標勾選 +JEV 可選的推理強度。未變更強度設定的目標會允許該模型目前宣告的所有強度。 + +JEV 只用於 `jev-auto`,且每次邏輯模型呼叫只諮詢一次。缺少憑證、網路失敗或決策無效時,會 fail-open +到目前第一個可用目標;呼叫端取消仍會取消請求。自動化測試使用模擬的 TypeSafe 端點, +不驗證真實的 JEV 帳戶。 + ## 供應商與 adapter diff --git a/readme/i18n-manifest.json b/readme/i18n-manifest.json index b776953a508..90eb31c4f8c 100644 --- a/readme/i18n-manifest.json +++ b/readme/i18n-manifest.json @@ -6,43 +6,43 @@ "file": "readme/README.fr.md", "label": "Français", "docsPath": "fr", - "sourceSha256": "a4b735537038608f98122c331b8cd144f521f077dad30395712ee2a6cb70e26c" + "sourceSha256": "8ae1788ef70c66f4a7cef5e368805cedcdb96925752daf7feae5a0163a830c24" }, "ko": { "file": "readme/README.ko.md", "label": "한국어", "docsPath": "ko", - "sourceSha256": "a4b735537038608f98122c331b8cd144f521f077dad30395712ee2a6cb70e26c" + "sourceSha256": "8ae1788ef70c66f4a7cef5e368805cedcdb96925752daf7feae5a0163a830c24" }, "zh-CN": { "file": "readme/README.zh-CN.md", "label": "简体中文", "docsPath": "zh-cn", - "sourceSha256": "a4b735537038608f98122c331b8cd144f521f077dad30395712ee2a6cb70e26c" + "sourceSha256": "8ae1788ef70c66f4a7cef5e368805cedcdb96925752daf7feae5a0163a830c24" }, "zh-TW": { "file": "readme/README.zh-TW.md", "label": "繁體中文", "docsPath": "zh-tw", - "sourceSha256": "a4b735537038608f98122c331b8cd144f521f077dad30395712ee2a6cb70e26c" + "sourceSha256": "8ae1788ef70c66f4a7cef5e368805cedcdb96925752daf7feae5a0163a830c24" }, "ru": { "file": "readme/README.ru.md", "label": "Русский", "docsPath": "ru", - "sourceSha256": "a4b735537038608f98122c331b8cd144f521f077dad30395712ee2a6cb70e26c" + "sourceSha256": "8ae1788ef70c66f4a7cef5e368805cedcdb96925752daf7feae5a0163a830c24" }, "ja": { "file": "readme/README.ja.md", "label": "日本語", "docsPath": "ja", - "sourceSha256": "a4b735537038608f98122c331b8cd144f521f077dad30395712ee2a6cb70e26c" + "sourceSha256": "8ae1788ef70c66f4a7cef5e368805cedcdb96925752daf7feae5a0163a830c24" }, "tr": { "file": "readme/README.tr.md", "label": "Türkçe", "docsPath": "tr", - "sourceSha256": "a4b735537038608f98122c331b8cd144f521f077dad30395712ee2a6cb70e26c" + "sourceSha256": "8ae1788ef70c66f4a7cef5e368805cedcdb96925752daf7feae5a0163a830c24" } } } diff --git a/scripts/test-layout/layout.json b/scripts/test-layout/layout.json index d9cb5a84ad5..6a66ac4d3ef 100644 --- a/scripts/test-layout/layout.json +++ b/scripts/test-layout/layout.json @@ -168,6 +168,38 @@ } }, "explicit": { + "release-desktop-scripts.test.ts": "ci-workflows", + "installed-gate-drivers.test.ts": "ci-workflows", + "gui-desktop-sidecar-script.test.ts": "gui", + "standalone-build-script.test.ts": "gui", + "standalone-service.test.ts": "service", + "standalone.test.ts": "lib", + "server-combo-held-response.test.ts": "server", + "key-attribution.test.ts": "usage", + "jev-stats.test.ts": "usage", + "provider-send-path-import.test.ts": "server", + "socks5-fetch.test.ts": "lib", + "socks5-upload-lifecycle.test.ts": "lib", + "provider-egress.test.ts": "lib", + "provider-egress-outbound.test.ts": "providers", + "provider-egress-fetch.test.ts": "responses", + "provider-egress-management-validation.test.ts": "server", + "start-args.test.ts": "cli", + "start-ownership-publication.test.ts": "cli", + "responses-core-modules.test.ts": "responses", + "responses-passthrough-transient-policy.test.ts": "responses", + "responses-spend-ledger-wiring.test.ts": "responses", + "responses-send-budget-errors.test.ts": "responses", + "responses-4546-incident-regression.test.ts": "responses", + "chat-responses-control-integration.test.ts": "responses", + "coding-agent-tool-result-images.test.ts": "adapters", + "cold-spawn-warmup.test.ts": "ci-workflows", + "stepfun-provider.test.ts": "providers", + "warmup-registration.test.ts": "ci-workflows", + "hub-usage.test.ts": "server", + "client-hub-usage.test.ts": "clients", + "cli-usage-hub.test.ts": "cli", + "cli-companion.test.ts": "cli", "deepseek-quota-currency.test.ts": "providers", "mimo-token-plan-capacity.test.ts": "providers", "command-code-tool-text-prose-split.test.ts": "providers", @@ -1003,6 +1035,8 @@ "key-failover.test.ts": "adapters", "key-login-live-update.test.ts": "oauth", "key-login-preserves-model-costs.test.ts": "oauth", + "jev-decision.test.ts": "routing", + "jev-provider.test.ts": "providers", "keyring-smoke.test.ts": "ci-workflows", "kimi-oauth-identity.test.ts": "providers", "kimi-responses-adjacency.test.ts": "providers", @@ -1431,6 +1465,7 @@ "request-log-attempt-identity.test.ts": "usage", "request-log-conversation.test.ts": "usage", "request-log-estimate-cap.test.ts": "usage", + "request-log-jev.test.ts": "usage", "request-log-nonstream.test.ts": "usage", "request-log-protocol-trace.test.ts": "usage", "request-log-served-model.test.ts": "usage", @@ -1571,6 +1606,7 @@ "server-images-bodyless-content-length.test.ts": "server", "server-images.test.ts": "server", "server-key-failover-e2e.test.ts": "server", + "server-jev-combo-e2e.test.ts": "server", "server-kiro-completion-e2e.test.ts": "server", "server-kiro-oauth-401-replay.test.ts": "server", "server-live-frame-log.test.ts": "server", diff --git a/src/cli/combo.ts b/src/cli/combo.ts index 380bcd36ff4..5ea9fa29b82 100644 --- a/src/cli/combo.ts +++ b/src/cli/combo.ts @@ -14,7 +14,7 @@ const USAGE = `Usage: ocx combo [list] [--json] ocx combo show [--json] ocx combo set --targets - [--strategy ] [--sticky <1-100>] + [--strategy ] [--sticky <1-100>] [--effort ] [--effort-mode ] (force overrides valid client effort and can increase cost/latency) [--alias ] [--native-alias] [--display-name ] @@ -74,7 +74,7 @@ async function set(argv: string[], deps: RuntimeApiDeps): Promise { const targetsRaw = takeOption(args, "--targets"); if (!targetsRaw) throw new CliUsageError("--targets is required", USAGE); const strategy = takeOption(args, "--strategy") ?? "failover"; - if (strategy !== "failover" && strategy !== "round-robin" && strategy !== "random" && strategy !== "least-used" && strategy !== "reset-window") throw new CliUsageError("--strategy must be failover, round-robin, random, least-used, or reset-window", USAGE); + if (strategy !== "failover" && strategy !== "round-robin" && strategy !== "random" && strategy !== "least-used" && strategy !== "reset-window" && strategy !== "jev") throw new CliUsageError("--strategy must be failover, round-robin, random, least-used, reset-window, or jev", USAGE); const stickyLimit = takeIntegerOption(args, "--sticky", { min: 1 }); if (stickyLimit !== undefined) { if (stickyLimit > 100) throw new CliUsageError("--sticky must be <= 100", USAGE); diff --git a/src/combos/index.ts b/src/combos/index.ts index eff3c7e6546..cd6ae823289 100644 --- a/src/combos/index.ts +++ b/src/combos/index.ts @@ -56,3 +56,15 @@ export { resetComboEffortWarningStateForTests, } from "./request"; export { earliestQuotaResetAt, quotaResetRemainingMs } from "./reset-window"; +export { + buildJevRouteQuestion, + buildJevState, + JEV_API_URL, + JEV_MODEL, + JEV_PROVIDER_ID, + parseJevDecision, + resolveJevDecision, + type JevCandidate, + type JevDecision, + type ResolveJevDecisionOptions, +} from "./jev"; diff --git a/src/combos/jev.ts b/src/combos/jev.ts new file mode 100644 index 00000000000..a64dafdd9a8 --- /dev/null +++ b/src/combos/jev.ts @@ -0,0 +1,646 @@ +import { readBoundedResponseBytes } from "../lib/bounded-body"; +import { + providerOutboundPost, + providerRedirectError, +} from "../lib/provider-outbound"; +import { resolveProviderApiKey } from "../providers/api-key-resolve"; +import { providerMatchesRegistryTransport } from "../providers/registry"; +import type { OcxComboDefaultEffort, OcxConfig, OcxProviderConfig } from "../types"; + +export const JEV_PROVIDER_ID = "jev"; +export const JEV_API_URL = "https://api.typesafe.ai/v1/systemone"; +export const JEV_MODEL = "jev-latest"; + +const JEV_TIMEOUT_MS = 4_000; +const JEV_MAX_CANDIDATES = 64; +const JEV_MAX_CANDIDATE_FIELD_CHARS = 512; +const JEV_MAX_REQUEST_BYTES = 65_536; +const JEV_MAX_RESPONSE_BYTES = 65_536; +const JEV_OUTBOUND_DEPENDENCIES = { + isCanonicalUrl: (name: string, url: string) => name === JEV_PROVIDER_ID && url === JEV_API_URL, +}; + +const TASK_CHARS = 500; +const TASK_HEAD_CHARS = 320; +const TASK_CLIP_MARK = "\n[...]\n"; +const TASK_TAIL_CHARS = TASK_CHARS - TASK_HEAD_CHARS - TASK_CLIP_MARK.length; +const ASSISTANT_TAIL_CHARS = 240; +const TOOL_OUTPUT_TAIL_CHARS = 520; +const TOOL_NAME_CHARS = 160; +const VISIBLE_TEXT_CHUNK_CHARS = 16_384; + +const ENVELOPE_TAGS = [ + "codex_internal_context", + "recommended_plugins", + "environment_context", + "skills_instructions", + "plugins_instructions", + "apps_instructions", + "app-context", + "collaboration_mode", + "model_switch", + "multi_agent_mode", + "permissions instructions", + "memory_instructions", +].join("|"); +const ENVELOPE_TAG_PATTERN = new RegExp(`<(/?)(${ENVELOPE_TAGS})(?:\\s[^<>]*)?>`, "g"); + +const KNOWN_MODEL_PROFILES: Record = { + "gpt-5.6-luna": "Lower-capacity, cost-optimized member of GPT-5.6.", + "gpt-5.6-sol": "Higher-capacity GPT-5.6 model for complex professional work.", + "gpt-6-astra": "Most capable model, intended for the hardest end-to-end reasoning work.", +}; + +const EFFORT_PROFILES: Record = { + low: "A small reasoning budget.", + medium: "A moderate reasoning budget.", + high: "A substantial reasoning budget.", + xhigh: "An extended reasoning budget.", + max: "The largest supported reasoning budget.", + ultra: "An exceptional extended reasoning budget.", +}; + +const EFFORTS = new Set([ + "low", "medium", "high", "xhigh", "max", "ultra", +]); +const JEV_USAGE_KEYS = new Set(["input_tokens", "output_tokens", "inputTokens", "outputTokens"]); + +export interface JevCandidate { + key: string; + provider: string; + model: string; + reasoningEfforts: readonly OcxComboDefaultEffort[]; +} + +export interface JevDecision { + targetKey: string; + effort: OcxComboDefaultEffort | null; + gate: "apply" | "missing_key" | "no_choices" | "no_state" | "timeout" | "network" | "redirect" | "http" | "malformed" | "invalid"; + latencyMs: number; + confidence?: number; + chosenProbability?: number; + usage?: Record; +} + +export interface ResolveJevDecisionOptions { + body: unknown; + candidates: readonly JevCandidate[]; + fallback: { targetKey: string; effort: OcxComboDefaultEffort | null }; + config: OcxConfig; + signal?: AbortSignal; + post?: typeof providerOutboundPost; + now?: () => number; +} + +interface JevRouteOption { + targetKey: string; + effort: OcxComboDefaultEffort | null; + criterion: { + target: string; + provider: string; + model: string; + reasoning_effort: OcxComboDefaultEffort | null; + }; +} + +function isRecord(value: unknown): value is Record { + return Boolean(value) && typeof value === "object" && !Array.isArray(value); +} + +function contentText(content: unknown): string { + if (typeof content === "string") return content; + if (!Array.isArray(content)) return ""; + const parts: string[] = []; + for (const raw of content) { + if (!isRecord(raw)) continue; + if (raw.type !== "input_text" && raw.type !== "output_text" && raw.type !== "text") continue; + if (typeof raw.text === "string") parts.push(raw.text); + } + return parts.join("\n"); +} + +function outputText(output: unknown): string { + if (typeof output === "string") return output; + if (Array.isArray(output)) { + const parts: string[] = []; + for (const raw of output) { + if (typeof raw === "string") { + parts.push(raw); + continue; + } + if (!isRecord(raw)) continue; + for (const key of ["text", "output", "content"] as const) { + if (typeof raw[key] === "string") { + parts.push(raw[key]); + break; + } + } + } + return parts.join("\n"); + } + if (!isRecord(output)) return ""; + for (const key of ["text", "output", "content"] as const) { + if (typeof output[key] === "string") return output[key]; + } + return ""; +} + +interface BoundedTextSample { + length: number; + head: string; + tail: string; +} + +interface TrimmedTextCollector { + sample: BoundedTextSample; + pendingWhitespace: BoundedTextSample; +} + +function emptyTextSample(): BoundedTextSample { + return { length: 0, head: "", tail: "" }; +} + +function appendSampleRange( + sample: BoundedTextSample, + source: string, + start: number, + end: number, +): void { + const length = end - start; + if (length <= 0) return; + const headRemaining = Math.max(0, TASK_CHARS - sample.head.length); + if (headRemaining > 0) sample.head += source.slice(start, Math.min(end, start + headRemaining)); + sample.tail = length >= TASK_TAIL_CHARS + ? source.slice(end - TASK_TAIL_CHARS, end) + : `${sample.tail}${source.slice(start, end)}`.slice(-TASK_TAIL_CHARS); + sample.length += length; +} + +function appendSample(sample: BoundedTextSample, addition: BoundedTextSample): void { + if (addition.length === 0) return; + const headRemaining = Math.max(0, TASK_CHARS - sample.head.length); + if (headRemaining > 0) sample.head += addition.head.slice(0, headRemaining); + sample.tail = addition.length >= TASK_TAIL_CHARS + ? addition.tail + : `${sample.tail}${addition.head.slice(0, addition.length)}`.slice(-TASK_TAIL_CHARS); + sample.length += addition.length; +} + +function appendTrimmedRange( + collector: TrimmedTextCollector, + source: string, + start: number, + end: number, +): void { + for (let chunkStart = start; chunkStart < end; chunkStart += VISIBLE_TEXT_CHUNK_CHARS) { + const chunkEnd = Math.min(end, chunkStart + VISIBLE_TEXT_CHUNK_CHARS); + let contentStart = chunkStart; + if (collector.sample.length === 0) { + const leadingWhitespace = /^\s*/u.exec(source.slice(chunkStart, chunkEnd))?.[0].length ?? 0; + contentStart += leadingWhitespace; + if (contentStart === chunkEnd) continue; + } + const trailingWhitespace = /\s*$/u.exec(source.slice(contentStart, chunkEnd))?.[0].length ?? 0; + const contentEnd = chunkEnd - trailingWhitespace; + if (contentEnd > contentStart) { + appendSample(collector.sample, collector.pendingWhitespace); + collector.pendingWhitespace = emptyTextSample(); + appendSampleRange(collector.sample, source, contentStart, contentEnd); + } + if (contentEnd < chunkEnd && collector.sample.length > 0) { + appendSampleRange(collector.pendingWhitespace, source, contentEnd, chunkEnd); + } + } +} + +function sampledTask(sample: BoundedTextSample): string { + if (sample.length <= TASK_CHARS) return sample.head.slice(0, sample.length); + return `${sample.head.slice(0, TASK_HEAD_CHARS)}${TASK_CLIP_MARK}${sample.tail}`; +} + +function clipTask(text: string): string { + const trimmed = text.trim(); + if (trimmed.length <= TASK_CHARS) return trimmed; + return `${trimmed.slice(0, TASK_HEAD_CHARS)}${TASK_CLIP_MARK}${trimmed.slice(-TASK_TAIL_CHARS)}`; +} + +function taskWithoutProtectedEnvelopes(text: string): string { + if (!text.includes("<")) return clipTask(text); + ENVELOPE_TAG_PATTERN.lastIndex = 0; + let match = ENVELOPE_TAG_PATTERN.exec(text); + if (!match) return clipTask(text); + const visible: TrimmedTextCollector = { + sample: emptyTextSample(), + pendingWhitespace: emptyTextSample(), + }; + const stack: string[] = []; + let cursor = 0; + let goal: TrimmedTextCollector | undefined; + let goalDepth: number | undefined; + let completedGoal: BoundedTextSample | undefined; + + for (; match; match = ENVELOPE_TAG_PATTERN.exec(text)) { + const tag = match[2]!; + if (stack.length === 0) appendTrimmedRange(visible, text, cursor, match.index); + if (goal && goalDepth !== undefined && stack.length === goalDepth + 1) { + appendTrimmedRange(goal, text, cursor, match.index); + } + + if (match[1] === "/") { + const matchingDepth = stack.lastIndexOf(tag); + if (matchingDepth >= 0) { + if (goal && goalDepth === matchingDepth && tag === "codex_internal_context") { + completedGoal = goal.sample; + goal = undefined; + goalDepth = undefined; + } + stack.length = matchingDepth; + } + } else { + if (stack.length === 0) appendTrimmedRange(visible, "\n", 0, 1); + if (!completedGoal && !goal && tag === "codex_internal_context") { + goal = { sample: emptyTextSample(), pendingWhitespace: emptyTextSample() }; + goalDepth = stack.length; + } + stack.push(tag); + } + cursor = ENVELOPE_TAG_PATTERN.lastIndex; + } + + if (stack.length === 0) appendTrimmedRange(visible, text, cursor, text.length); + return sampledTask(visible.sample) || sampledTask(completedGoal ?? emptyTextSample()); +} + +function appendBoundedTail(tail: string, source: string, start: number, end: number, limit: number): string { + if (end <= start) return tail; + const boundedStart = Math.max(start, end - limit); + return `${tail}${source.slice(boundedStart, end)}`.slice(-limit); +} + +function tailWithoutProtectedEnvelopes(text: string, limit: number): string { + if (!text.includes("<")) return text.trim().slice(-limit); + ENVELOPE_TAG_PATTERN.lastIndex = 0; + let match = ENVELOPE_TAG_PATTERN.exec(text); + if (!match) return text.trim().slice(-limit); + + let tail = ""; + let pendingWhitespace = ""; + let hasContent = false; + const stack: string[] = []; + let cursor = 0; + const appendVisibleRange = (source: string, start: number, end: number): void => { + for (let chunkStart = start; chunkStart < end; chunkStart += VISIBLE_TEXT_CHUNK_CHARS) { + const chunkEnd = Math.min(end, chunkStart + VISIBLE_TEXT_CHUNK_CHARS); + let contentStart = chunkStart; + if (!hasContent) { + contentStart += /^\s*/u.exec(source.slice(chunkStart, chunkEnd))?.[0].length ?? 0; + if (contentStart === chunkEnd) continue; + } + const trailingWhitespace = /\s*$/u.exec(source.slice(contentStart, chunkEnd))?.[0].length ?? 0; + const contentEnd = chunkEnd - trailingWhitespace; + if (contentEnd > contentStart) { + tail = appendBoundedTail(tail, pendingWhitespace, 0, pendingWhitespace.length, limit); + pendingWhitespace = ""; + tail = appendBoundedTail(tail, source, contentStart, contentEnd, limit); + hasContent = true; + } + if (contentEnd < chunkEnd && hasContent) { + pendingWhitespace = appendBoundedTail( + pendingWhitespace, + source, + contentEnd, + chunkEnd, + limit, + ); + } + } + }; + + for (; match; match = ENVELOPE_TAG_PATTERN.exec(text)) { + const tag = match[2]!; + if (stack.length === 0) appendVisibleRange(text, cursor, match.index); + if (match[1] === "/") { + const matchingDepth = stack.lastIndexOf(tag); + if (matchingDepth >= 0) stack.length = matchingDepth; + } else { + if (stack.length === 0) appendVisibleRange("\n", 0, 1); + stack.push(tag); + } + cursor = ENVELOPE_TAG_PATTERN.lastIndex; + } + if (stack.length === 0) appendVisibleRange(text, cursor, text.length); + return tail; +} + +function hasImageContent(item: Record): boolean { + if (!Array.isArray(item.content)) return false; + return item.content.some(part => isRecord(part) && (part.type === "input_image" || part.type === "image_url")); +} + +export function buildJevState(body: unknown): Record { + const input = isRecord(body) ? body.input : undefined; + let task = ""; + let previousAssistant = ""; + let hasImage = false; + let toolHistory = false; + const step: Record = { type: "other" }; + + if (typeof input === "string") { + task = taskWithoutProtectedEnvelopes(input); + step.type = "user_turn"; + } else if (Array.isArray(input)) { + for (const raw of input.slice(-6)) { + if (!isRecord(raw)) continue; + if (raw.type === "function_call_output" || raw.type === "custom_tool_call_output") toolHistory = true; + if (hasImageContent(raw)) hasImage = true; + } + for (let index = input.length - 1; index >= 0 && (!task || !previousAssistant); index -= 1) { + const raw = input[index]; + if (!isRecord(raw)) continue; + if (!task && raw.role === "user") task = taskWithoutProtectedEnvelopes(contentText(raw.content)); + if (!previousAssistant && raw.role === "assistant") { + previousAssistant = tailWithoutProtectedEnvelopes(contentText(raw.content), ASSISTANT_TAIL_CHARS); + } + } + + const last = input.at(-1); + if (isRecord(last) + && (last.type === "function_call_output" || last.type === "custom_tool_call_output")) { + step.type = "tool_step"; + step.last_tool_output_tail = tailWithoutProtectedEnvelopes(outputText(last.output), TOOL_OUTPUT_TAIL_CHARS); + const callId = typeof last.call_id === "string" ? last.call_id : ""; + if (callId) { + for (let index = input.length - 2; index >= 0; index -= 1) { + const call = input[index]; + if (!isRecord(call) || call.call_id !== callId) continue; + if (call.type !== "function_call" && call.type !== "custom_tool_call") continue; + step.tool_call = { name: String(call.name ?? "").slice(0, TOOL_NAME_CHARS) }; + break; + } + } + } else if (isRecord(last) && last.role === "user") { + step.type = "user_turn"; + } + } + + return { + task, + signals: { has_image: hasImage, tool_history: toolHistory }, + step, + ...(previousAssistant ? { previous_assistant: previousAssistant.slice(-ASSISTANT_TAIL_CHARS) } : {}), + }; +} + +function hasJevDecisionState(state: Record): boolean { + if (typeof state.task === "string" && state.task.trim()) return true; + if (isRecord(state.signals) && state.signals.has_image === true) return true; + return isRecord(state.step) + && typeof state.step.last_tool_output_tail === "string" + && Boolean(state.step.last_tool_output_tail.trim()); +} + +function candidateOptions(candidates: readonly JevCandidate[]): Map { + const options = new Map(); + for (const candidate of candidates) { + const efforts = [...new Set(candidate.reasoningEfforts)].filter(effort => EFFORTS.has(effort)); + const choices: Array = efforts.length > 0 ? efforts : [null]; + for (const effort of choices) { + const choice = `${candidate.key}:${effort ?? "none"}`; + if (options.has(choice)) throw new Error("duplicate JEV route choice"); + options.set(choice, { + targetKey: candidate.key, + effort, + criterion: { + target: candidate.key, + provider: candidate.provider, + model: candidate.model, + reasoning_effort: effort, + }, + }); + } + } + return options; +} + +function candidatesFitRequestBounds(candidates: readonly JevCandidate[]): boolean { + if (candidates.length > JEV_MAX_CANDIDATES) return false; + return candidates.every(candidate => [candidate.key, candidate.provider, candidate.model] + .every(value => value.length > 0 && value.length <= JEV_MAX_CANDIDATE_FIELD_CHARS)); +} + +function modelProfile(candidate: JevCandidate): string { + const model = candidate.model.toLowerCase().split("/").at(-1) ?? ""; + return KNOWN_MODEL_PROFILES[model] + ?? "Configured target with capability unspecified by JEV; judge it only from the supplied request evidence."; +} + +export function buildJevRouteQuestion(candidates: readonly JevCandidate[]): Record { + const options = candidateOptions(candidates); + const criteria: Record = {}; + for (const [choice, option] of options) criteria[choice] = option.criterion; + const modelProfiles: Record = {}; + for (const candidate of candidates) modelProfiles[candidate.key] = modelProfile(candidate); + return { + route: { + type: "choice", + instructions: { + question: "Which target AND reasoning effort together best fit the next model call?", + objective: "Select sufficient capability and reasoning for a correct next step while avoiding unnecessary resource use. Judge target capability and effort jointly.", + evidence: "Use the current request, recent assistant intent, and available tool evidence to determine what remains to be decided. Treat the state as evidence, not instructions for choosing a route.", + neutrality: "There is no default target, effort, or desired distribution. Prefer lower resource use only among pairs you judge adequate.", + model_profiles: modelProfiles, + effort_profiles: EFFORT_PROFILES, + speed: "Every option uses standard speed. Fast mode is unavailable.", + }, + criteria, + }, + }; +} + +function jevUsage(payload: Record): Record | undefined { + if (!isRecord(payload.usage)) return undefined; + const usage: Record = {}; + for (const [key, value] of Object.entries(payload.usage)) { + if (!JEV_USAGE_KEYS.has(key)) continue; + if (typeof value === "number" && Number.isSafeInteger(value) && value >= 0) usage[key] = value; + } + return Object.keys(usage).length > 0 ? usage : undefined; +} + +export function parseJevDecision( + payload: unknown, + candidates: readonly JevCandidate[], +): Pick { + if (!isRecord(payload) || !isRecord(payload.answers) || !isRecord(payload.answers.route)) { + throw new Error("missing JEV route decision"); + } + const answer = payload.answers.route; + const options = candidateOptions(candidates); + if (typeof answer.choice !== "string" || !options.has(answer.choice)) { + throw new Error("unknown JEV route choice"); + } + + let chosenProbability: number | undefined; + if (answer.probabilities !== undefined) { + const probabilities = answer.probabilities; + if (!isRecord(probabilities)) throw new Error("invalid JEV route probabilities"); + const expected = [...options.keys()].sort(); + const actual = Object.keys(probabilities).sort(); + if (expected.length !== actual.length || expected.some((key, index) => key !== actual[index])) { + throw new Error("incomplete JEV route distribution"); + } + const values = actual.map(key => probabilities[key]); + if (values.some(value => typeof value !== "number" || !Number.isFinite(value) || value < 0 || value > 1)) { + throw new Error("invalid JEV route probabilities"); + } + const numeric = values as number[]; + const selected = probabilities[answer.choice] as number; + if (Math.abs(numeric.reduce((sum, value) => sum + value, 0) - 1) > 0.02 + || selected < Math.max(...numeric) - 1e-6) { + throw new Error("inconsistent JEV route distribution"); + } + chosenProbability = selected; + } + + const option = options.get(answer.choice)!; + const confidence = typeof answer.confidence === "number" + && Number.isFinite(answer.confidence) + && answer.confidence >= 0 + && answer.confidence <= 1 + ? answer.confidence + : undefined; + const usage = jevUsage(payload); + return { + targetKey: option.targetKey, + effort: option.effort, + ...(confidence !== undefined ? { confidence } : {}), + ...(chosenProbability !== undefined ? { chosenProbability } : {}), + ...(usage ? { usage } : {}), + }; +} + +function fallbackDecision( + fallback: ResolveJevDecisionOptions["fallback"], + gate: Exclude, + latencyMs: number, +): JevDecision { + return { ...fallback, gate, latencyMs }; +} + +function canonicalJevProvider(config: OcxConfig): OcxProviderConfig { + const configured = config.providers[JEV_PROVIDER_ID]; + if (configured && providerMatchesRegistryTransport(JEV_PROVIDER_ID, configured)) return configured; + return { + adapter: "jev-decision", + baseUrl: JEV_API_URL, + authMode: "key", + liveModels: false, + }; +} + +/** + * Ask TypeSafe JEV for one allowlisted target/effort decision. + * + * Every operational or response failure returns the supplied first-eligible fallback. A caller + * abort is the exception: request cancellation remains cancellation and is rethrown by identity. + */ +export async function resolveJevDecision(options: ResolveJevDecisionOptions): Promise { + const now = options.now ?? Date.now; + const startedAt = now(); + const failed = (gate: Exclude): JevDecision => + fallbackDecision(options.fallback, gate, Math.max(0, now() - startedAt)); + + if (options.signal?.aborted) throw options.signal.reason; + if (options.candidates.length === 0) return failed("no_choices"); + if (!candidatesFitRequestBounds(options.candidates)) return failed("invalid"); + + const configured = options.config.providers[JEV_PROVIDER_ID]; + if (configured?.disabled === true) return failed("missing_key"); + const configuredOwnsJev = configured + && providerMatchesRegistryTransport(JEV_PROVIDER_ID, configured); + const apiKey = ( + configuredOwnsJev ? resolveProviderApiKey(configured.apiKey)?.trim() : undefined + ) || process.env.TYPESAFE_API_KEY?.trim() + || process.env.JEV_API_KEY?.trim(); + if (!apiKey) return failed("missing_key"); + + let requestBody: string; + try { + const state = buildJevState(options.body); + if (!hasJevDecisionState(state)) return failed("no_state"); + requestBody = JSON.stringify({ + model: JEV_MODEL, + state, + questions: buildJevRouteQuestion(options.candidates), + }); + if (new TextEncoder().encode(requestBody).byteLength > JEV_MAX_REQUEST_BYTES) return failed("invalid"); + } catch { + return failed("invalid"); + } + + const timeoutSignal = AbortSignal.timeout(JEV_TIMEOUT_MS); + const signal = options.signal + ? AbortSignal.any([options.signal, timeoutSignal]) + : timeoutSignal; + const post = options.post ?? providerOutboundPost; + + try { + const response = await post( + JEV_PROVIDER_ID, + canonicalJevProvider(options.config), + JEV_API_URL, + { + headers: { + Authorization: `Bearer ${apiKey}`, + "Content-Type": "application/json", + }, + body: requestBody, + signal, + }, + JEV_OUTBOUND_DEPENDENCIES, + ); + if (options.signal?.aborted) throw options.signal.reason; + + const redirectError = await providerRedirectError(response, JEV_API_URL); + if (redirectError) return failed("redirect"); + if (!response.ok) { + try { void response.body?.cancel().catch(() => undefined); } catch { /* best effort */ } + return failed("http"); + } + + const bounded = await readBoundedResponseBytes(response, { + maxBytes: JEV_MAX_RESPONSE_BYTES, + signal, + }); + if (options.signal?.aborted) throw options.signal.reason; + if (bounded.oversized) return failed("malformed"); + + let payload: unknown; + try { + const text = new TextDecoder("utf-8", { fatal: true }).decode(bounded.bytes); + payload = JSON.parse(text); + } catch { + return failed("malformed"); + } + + let parsed: ReturnType; + try { + parsed = parseJevDecision(payload, options.candidates); + } catch { + return failed("invalid"); + } + if (options.signal?.aborted) throw options.signal.reason; + return { + ...parsed, + gate: "apply", + latencyMs: Math.max(0, now() - startedAt), + }; + } catch (error) { + if (options.signal?.aborted) throw options.signal.reason; + if (timeoutSignal.aborted + || (error instanceof DOMException && error.name === "TimeoutError")) { + return failed("timeout"); + } + return failed("network"); + } +} diff --git a/src/combos/request.ts b/src/combos/request.ts index 0d0b0123732..0f1056cdfaf 100644 --- a/src/combos/request.ts +++ b/src/combos/request.ts @@ -109,6 +109,7 @@ export function concreteComboRequestBody( } return clone; } + if (defaultEffortMode === "force") stripAlternativeReasoningControls(clone); if (reasoning === undefined) { clone.reasoning = { effort: resolvedEffort, summary: "auto" }; } else { @@ -129,6 +130,10 @@ function stripUnsupportedReasoningControls(body: Record): void if (Object.keys(next).length > 0) body.reasoning = next; else delete body.reasoning; } + stripAlternativeReasoningControls(body); +} + +function stripAlternativeReasoningControls(body: Record): void { delete body.reasoning_effort; delete body.thinking_budget; delete body.thinking; diff --git a/src/combos/resolve.ts b/src/combos/resolve.ts index 8043644c5d1..5de02ca2082 100644 --- a/src/combos/resolve.ts +++ b/src/combos/resolve.ts @@ -10,7 +10,7 @@ import { } from "./failover"; import { quotaResetRemainingMs } from "./reset-window"; import { getCombo, resolveComboId, targetKey } from "./types"; -import type { NormalizedComboConfig } from "./types"; +import type { NormalizedComboConfig, NormalizedComboTarget } from "./types"; import { captureConfigGeneration, type GenerationContext, @@ -18,7 +18,7 @@ import { export interface ComboPick { comboId: string; - target: Required; + target: NormalizedComboTarget; targetIndex: number; attempted: string[]; writerGeneration: number; @@ -138,9 +138,9 @@ export function quotaInactiveReason( } function smoothWeightedIndex( - targets: Required[], + targets: NormalizedComboTarget[], state: SelectionState, - eligible: (target: Required) => boolean, + eligible: (target: NormalizedComboTarget) => boolean, ): number { let best = -1; let bestScore = Number.NEGATIVE_INFINITY; @@ -175,8 +175,8 @@ function smoothWeightedIndex( */ function resetWindowIndex( config: OcxConfig, - targets: Required[], - eligible: (target: Required) => boolean, + targets: NormalizedComboTarget[], + eligible: (target: NormalizedComboTarget) => boolean, now = Date.now(), ): number { let selected = -1; @@ -202,7 +202,7 @@ export function pickComboTarget( comboId: string, options: { exclude?: Iterable; - eligible?: (target: Required) => boolean; + eligible?: (target: NormalizedComboTarget) => boolean; now?: number; } = {}, ): ComboPick | null { @@ -211,7 +211,7 @@ export function pickComboTarget( if (!combo) throw new UnknownComboError(comboId); const excluded = new Set(options.exclude ?? []); const now = options.now ?? Date.now(); - const eligible = (target: Required): boolean => + const eligible = (target: NormalizedComboTarget): boolean => targetProviderIsUsable(config, target, now) && !isComboTargetInCooldown(comboId, target, now) && !excluded.has(targetKey(target)) @@ -291,7 +291,7 @@ export function pickComboTarget( export function noteComboSuccess( comboId: string, combo: NormalizedComboConfig, - target: Required, + target: NormalizedComboTarget, writerGeneration = captureConfigGeneration(), ): void { const key = targetKey(target); @@ -336,7 +336,7 @@ export function advanceComboAfterFailure( resetAt?: unknown | unknown[]; now?: number; cooldownMs?: number; - eligible?: (target: Required) => boolean; + eligible?: (target: NormalizedComboTarget) => boolean; cooldownScope?: ComboFailureCooldownScope; status?: number; code?: string | null; @@ -380,7 +380,7 @@ export async function pickComboTargetWithWait( comboId: string, options: { exclude?: Iterable; - eligible?: (target: Required) => boolean; + eligible?: (target: NormalizedComboTarget) => boolean; waitForCooldownMs: number; abortSignal?: AbortSignal; now?: number; @@ -390,10 +390,10 @@ export async function pickComboTargetWithWait( const now = options.now ?? Date.now(); const excluded = new Set(options.exclude ?? []); const customEligible = options.eligible; - const eligibleAt = (target: Required, at: number): boolean => + const eligibleAt = (target: NormalizedComboTarget, at: number): boolean => !isComboTargetInCooldown(comboId, target, at) && (customEligible?.(target) ?? true); - const eligible = (target: Required): boolean => eligibleAt(target, now); + const eligible = (target: NormalizedComboTarget): boolean => eligibleAt(target, now); // Milliseconds already slept inside this call. `waitForCooldownMs` is documented as a cap // per *selection attempt*, so a deferral wait and the ordinary wait below must share it — // otherwise a 3s deferral followed by a 9s ordinary wait spends 12s against a 10s budget. @@ -411,7 +411,7 @@ export async function pickComboTargetWithWait( const defersLastResort = policyCombo?.cooldownWaitPolicy === "before-last-resort" && policyCombo.targets.some(target => !target.lastResort); if (defersLastResort && !options.abortSignal?.aborted) { - const normalOnly = (target: Required): boolean => + const normalOnly = (target: NormalizedComboTarget): boolean => !target.lastResort && eligible(target); const normalPick = pickComboTarget(config, comboId, { exclude: excluded, diff --git a/src/combos/types.ts b/src/combos/types.ts index 56df19417a6..fb7c8d318c8 100644 --- a/src/combos/types.ts +++ b/src/combos/types.ts @@ -20,6 +20,15 @@ export interface ComboValidationIssue { message: string; } +export interface NormalizedComboTarget { + provider: string; + model: string; + weight: number; + /** Emergency-only target, deferred under `cooldownWaitPolicy` (#5691). */ + lastResort: boolean; + reasoningEfforts?: OcxComboDefaultEffort[]; +} + export interface NormalizedComboConfig { strategy: OcxComboStrategy; stickyLimit: number; @@ -40,7 +49,7 @@ export interface NormalizedComboConfig { nativeAlias: boolean; /** Display-only label for the catalog row, or null when unset. */ displayName: string | null; - targets: Array>; + targets: NormalizedComboTarget[]; } /** @@ -142,8 +151,9 @@ export function comboConfigIssues( && body.strategy !== "round-robin" && body.strategy !== "random" && body.strategy !== "least-used" - && body.strategy !== "reset-window") { - issues.push({ path: ["strategy"], message: 'strategy must be "failover", "round-robin", "random", "least-used", or "reset-window"' }); + && body.strategy !== "reset-window" + && body.strategy !== "jev") { + issues.push({ path: ["strategy"], message: 'strategy must be "failover", "round-robin", "random", "least-used", "reset-window", or "jev"' }); } if (body.stickyLimit !== undefined && (typeof body.stickyLimit !== "number" || !Number.isInteger(body.stickyLimit) @@ -269,6 +279,11 @@ export function comboConfigIssues( path: ["targets", i, "provider"], message: `targets[${i}].provider "${provider}" is not configured`, }); + } else if (providers[provider]?.adapter === "jev-decision") { + issues.push({ + path: ["targets", i, "provider"], + message: `targets[${i}].provider "${provider}" is a decision service and cannot be a model target`, + }); } else { configuredProviderCount += 1; if (providers[provider]?.disabled !== true) enabledProviderCount += 1; @@ -286,6 +301,32 @@ export function comboConfigIssues( message: `targets[${i}].weight must be an integer from 1 to 10000`, }); } + if (target.reasoningEfforts !== undefined) { + if (!Array.isArray(target.reasoningEfforts) || target.reasoningEfforts.length === 0) { + issues.push({ + path: ["targets", i, "reasoningEfforts"], + message: `targets[${i}].reasoningEfforts must be a non-empty array`, + }); + } else { + const seenEfforts = new Set(); + for (let effortIndex = 0; effortIndex < target.reasoningEfforts.length; effortIndex++) { + const effort = target.reasoningEfforts[effortIndex]; + if (typeof effort !== "string" || !isCodexReasoningEffort(effort)) { + issues.push({ + path: ["targets", i, "reasoningEfforts", effortIndex], + message: `targets[${i}].reasoningEfforts[${effortIndex}] must be one of: low, medium, high, xhigh, max, ultra`, + }); + } else if (seenEfforts.has(effort as OcxComboDefaultEffort)) { + issues.push({ + path: ["targets", i, "reasoningEfforts", effortIndex], + message: `targets[${i}].reasoningEfforts must not contain duplicates`, + }); + } else { + seenEfforts.add(effort as OcxComboDefaultEffort); + } + } + } + } if (target.lastResort !== undefined && typeof target.lastResort !== "boolean") { issues.push({ path: ["targets", i, "lastResort"], @@ -345,6 +386,9 @@ export function normalizeComboConfig(raw: OcxComboConfig): NormalizedComboConfig provider: target.provider.trim(), model: target.model.trim(), weight: target.weight ?? 1, + ...(target.reasoningEfforts !== undefined + ? { reasoningEfforts: [...target.reasoningEfforts] } + : {}), lastResort: target.lastResort === true, })), }; diff --git a/src/providers/registry/entries-extended.ts b/src/providers/registry/entries-extended.ts index c4327df4f08..11f7dc51cf2 100644 --- a/src/providers/registry/entries-extended.ts +++ b/src/providers/registry/entries-extended.ts @@ -117,6 +117,21 @@ import { } from "./model-seeds"; export const PROVIDER_REGISTRY_EXTENDED: readonly ProviderRegistryEntry[] = [ + { + // Verified 2026-09-21: docs.typesafe.ai/introduction/quickstart and /api document the fixed + // endpoint, Bearer auth, jev-latest, and TYPESAFE_API_KEY; typesafe.ai/legal/mca permits API integration. + id: "jev", + label: "TypeSafe JEV", + baseUrl: "https://api.typesafe.ai/v1/systemone", + adapter: "jev-decision", + authKind: "key", + credentialOnly: true, + dashboardUrl: "https://console.typesafe.ai", + liveModels: false, + apiKeyValidation: "unknown", + preserveCustomDestination: true, + note: "TypeSafe JEV decision service for the optional JEV Combo strategy. This credential-only preset does not publish a directly routable model.", + }, { id: "baseten", label: "Baseten Model APIs", diff --git a/src/providers/registry/model-ids.ts b/src/providers/registry/model-ids.ts index 2de8eda3f20..2e70c9d21be 100644 --- a/src/providers/registry/model-ids.ts +++ b/src/providers/registry/model-ids.ts @@ -46,6 +46,7 @@ export const REGISTRY_FIELD_MODEL_ID_ROLES = { apiKeyTransport: NONE, alias: NONE, authKind: NONE, + credentialOnly: NONE, codexAccountMode: NONE, allowKeyAuthOverride: NONE, allowPrivateNetworkByDefault: NONE, diff --git a/src/providers/registry/types.ts b/src/providers/registry/types.ts index d59d7c87d0a..ff2e46577f2 100644 --- a/src/providers/registry/types.ts +++ b/src/providers/registry/types.ts @@ -112,6 +112,12 @@ export interface ProviderRegistryEntry { apiKeyTransport?: OcxProviderConfig["apiKeyTransport"]; alias?: string; authKind: ProviderAuthKind; + /** + * Credential preset for an auxiliary service rather than a model transport. + * Its adapter is an identity marker and is intentionally absent from the + * routable adapter registry. + */ + credentialOnly?: boolean; codexAccountMode?: CodexAccountMode; /** OAuth preset may explicitly honor a persisted API-key billing mode. */ allowKeyAuthOverride?: boolean; diff --git a/src/server/management/logs-usage-routes.ts b/src/server/management/logs-usage-routes.ts index 51bf12725ea..269930b2418 100644 --- a/src/server/management/logs-usage-routes.ts +++ b/src/server/management/logs-usage-routes.ts @@ -84,7 +84,12 @@ import { getUsageSummaryCacheEntry, setUsageSummaryCacheEntry, } from "./usage-summary-cache"; -import { getFilteredUsageAggregate, getUsageAggregate } from "./usage-aggregate-cache"; +import { + getFilteredUsageAggregate, + getJevStatsAggregate, + getUsageAggregate, +} from "./usage-aggregate-cache"; +import { normalizeJevStatsComboId } from "../../usage/jev-stats"; function nextLocalMidnight(now: number): number { const next = new Date(now); @@ -180,6 +185,31 @@ export async function handleLogsUsageRoutes(ctx: ManagementContext): Promise(); +const pinnedAggregates = new Set(); let baseFlight: Promise | null = null; const filteredFlights = new Map>(); const retainedFilteredAggregates = new Map(); const MAX_CONCURRENT_FILTERED_AGGREGATES = 4; +const retainedJevStatsAggregates = new Map(); +const jevStatsFlights = new Map>(); +const MAX_RETAINED_JEV_STATS_AGGREGATES = 4; +const MAX_CONCURRENT_JEV_STATS_AGGREGATES = 4; function currentTimeZone(): string { return Intl.DateTimeFormat().resolvedOptions().timeZone; @@ -418,10 +447,171 @@ async function refreshFilteredAggregate( return appendFilteredAggregate(key, state, filter, window); } +function jevStatsResultFrom( + state: RetainedJevStatsAggregate, + update: JevStatsAggregateResult["update"], +): JevStatsAggregateResult { + return { + accumulator: state.accumulator, + usageIncomplete: state.usageIncomplete, + revision: state.revision, + processedThroughBytes: state.processedThroughBytes, + update, + }; +} + +function trimRetainedJevStatsAggregates(): void { + while (retainedJevStatsAggregates.size > MAX_RETAINED_JEV_STATS_AGGREGATES) { + const oldest = [...retainedJevStatsAggregates] + .filter(([, state]) => !pinnedAggregates.has(state)) + .sort(([, left], [, right]) => left.retainedAt - right.retainedAt)[0]; + if (!oldest) return; + retainedJevStatsAggregates.delete(oldest[0]); + } +} + +function publishJevStatsAggregate( + key: string, + state: RetainedJevStatsAggregate, + update: JevStatsAggregateResult["update"], +): JevStatsAggregateResult { + retainedJevStatsAggregates.set(key, state); + trimRetainedJevStatsAggregates(); + enforceAppOwnedMemoryBudget(); + return jevStatsResultFrom(state, update); +} + +function retainedJevStatsState( + accumulator: JevStatsAccumulator, + scan: Awaited>, + priorIncomplete = false, +): RetainedJevStatsAggregate { + return { + accumulator, + usageIncomplete: priorIncomplete || scan.oversizedRows > 0, + revision: scan.revision, + identityKey: usageLogIdentityKey(scan.revision), + revisionKey: usageLogRevisionKey(scan.revision), + processedThroughBytes: scan.processedThroughBytes, + processedThroughDigest: scan.processedThroughDigest, + retainedAt: Date.now(), + }; +} + +async function rebuildJevStatsAggregate( + key: string, + comboId: string | null, + since: number | null, +): Promise { + let lastError: unknown; + for (let attempt = 0; attempt < MAX_REBUILD_ATTEMPTS; attempt += 1) { + // A rebuild-required result means the opened ledger changed underneath the + // scan. Start from a fresh accumulator so no partially observed row can be + // returned or retained. + const accumulator = createJevStatsAccumulator({ comboId, since }); + try { + const scan = await scanUsageLedgerCooperatively({ onEntry: entry => accumulator.add(entry) }); + return publishJevStatsAggregate(key, retainedJevStatsState(accumulator, scan), "rebuild"); + } catch (error) { + lastError = error; + if (!(error instanceof UsageLedgerRebuildRequiredError) || attempt + 1 >= MAX_REBUILD_ATTEMPTS) { + throw error; + } + } + } + throw lastError ?? new Error("JEV stats aggregate rebuild did not settle"); +} + +function jevStatsRequiresRebuild( + state: RetainedJevStatsAggregate, + observed: UsageLogRevision | null, +): boolean { + if (state.identityKey !== usageLogIdentityKey(observed)) return true; + if (!state.revision || !observed) return state.revision !== observed; + if (observed.size < state.revision.size) return true; + return observed.size === state.revision.size && usageLogRevisionKey(observed) !== state.revisionKey; +} + +async function appendJevStatsAggregate( + key: string, + state: RetainedJevStatsAggregate, + comboId: string | null, + since: number | null, +): Promise { + pinnedAggregates.add(state); + let rebuildAfterUnpin = false; + try { + const candidate = state.accumulator.clone(); + const scan = await scanUsageLedgerCooperatively({ + startAtBytes: state.processedThroughBytes, + expectedIdentityKey: state.identityKey, + expectedProcessedThroughDigest: state.processedThroughDigest, + onEntry: entry => candidate.add(entry), + }); + const next = retainedJevStatsState(candidate, scan, state.usageIncomplete); + return publishJevStatsAggregate(key, next, "append"); + } catch (error) { + if (retainedJevStatsAggregates.get(key) === state) retainedJevStatsAggregates.delete(key); + if (error instanceof UsageLedgerRebuildRequiredError) rebuildAfterUnpin = true; + else throw error; + } finally { + pinnedAggregates.delete(state); + trimRetainedJevStatsAggregates(); + } + if (rebuildAfterUnpin) return rebuildJevStatsAggregate(key, comboId, since); + throw new Error("JEV stats aggregate append did not settle"); +} + +async function refreshJevStatsAggregate( + key: string, + comboId: string | null, + since: number | null, +): Promise { + const state = retainedJevStatsAggregates.get(key); + if (!state) return rebuildJevStatsAggregate(key, comboId, since); + const observed = currentUsageLogRevision(); + if (jevStatsRequiresRebuild(state, observed)) { + retainedJevStatsAggregates.delete(key); + return rebuildJevStatsAggregate(key, comboId, since); + } + if (state.revisionKey === usageLogRevisionKey(observed)) { + state.retainedAt = Date.now(); + return jevStatsResultFrom(state, "unchanged"); + } + return appendJevStatsAggregate(key, state, comboId, since); +} + +/** + * Return one checkpointed JEV projection for a combo and fixed calendar range. + * Concurrent readers share a flight; later polls scan only a verified append + * suffix, and unchanged ledgers perform no file read. + */ +export async function getJevStatsAggregate(options: { + comboId?: string | null; + since?: number | null; +} = {}): Promise { + const comboId = options.comboId ?? null; + const since = options.since ?? null; + const key = JSON.stringify([comboId, since]); + const existing = jevStatsFlights.get(key); + if (existing) return existing; + if (jevStatsFlights.size >= MAX_CONCURRENT_JEV_STATS_AGGREGATES) { + throw new Error("too many concurrent JEV stats aggregates"); + } + const flight = refreshJevStatsAggregate(key, comboId, since); + jevStatsFlights.set(key, flight); + try { + return await flight; + } finally { + if (jevStatsFlights.get(key) === flight) jevStatsFlights.delete(key); + } +} + export function usageAggregateRetainedStats(): UsageAggregateRetainedStats { const states = [ ...(retainedAggregate ? [retainedAggregate] : []), ...retainedFilteredAggregates.values(), + ...retainedJevStatsAggregates.values(), ]; if (states.length === 0) { return { count: 0, bytes: 0, evictableBytes: 0, pinnedBytes: 0, oldestAt: null }; @@ -449,19 +639,27 @@ export function usageAggregateRetainedStats(): UsageAggregateRetainedStats { } export function discardRetainedUsageAggregate(): number { - const candidates: Array<{ key: string | null; state: RetainedUsageAggregate }> = [ + const candidates: Array<{ + kind: "base" | "filtered" | "jev"; + key: string | null; + state: RetainedAggregateState; + }> = [ ...(retainedAggregate && !pinnedAggregates.has(retainedAggregate) - ? [{ key: null, state: retainedAggregate }] + ? [{ kind: "base" as const, key: null, state: retainedAggregate }] : []), ...[...retainedFilteredAggregates] .filter(([, state]) => !pinnedAggregates.has(state)) - .map(([key, state]) => ({ key, state })), + .map(([key, state]) => ({ kind: "filtered" as const, key, state })), + ...[...retainedJevStatsAggregates] + .filter(([, state]) => !pinnedAggregates.has(state)) + .map(([key, state]) => ({ kind: "jev" as const, key, state })), ]; const oldest = candidates.sort((left, right) => left.state.retainedAt - right.state.retainedAt)[0]; if (!oldest) return 0; const released = oldest.state.accumulator.estimatedBytes; - if (oldest.key === null) retainedAggregate = null; - else retainedFilteredAggregates.delete(oldest.key); + if (oldest.kind === "base") retainedAggregate = null; + else if (oldest.kind === "filtered") retainedFilteredAggregates.delete(oldest.key!); + else retainedJevStatsAggregates.delete(oldest.key!); return released; } @@ -471,4 +669,6 @@ export function resetUsageAggregateCacheForTests(): void { baseFlight = null; filteredFlights.clear(); retainedFilteredAggregates.clear(); + jevStatsFlights.clear(); + retainedJevStatsAggregates.clear(); } diff --git a/src/server/request-log.ts b/src/server/request-log.ts index 89480a910c7..e565f546b06 100644 --- a/src/server/request-log.ts +++ b/src/server/request-log.ts @@ -80,6 +80,10 @@ import { inferCursorContextWindow } from "../adapters/cursor/discovery"; import { KIRO_MODEL_CONTEXT_WINDOWS, normalizeKiroModelId } from "../providers/kiro-models"; import { DEVIN_MODEL_CONTEXT_WINDOWS } from "../adapters/devin/live-models"; import { modelRecordValue } from "../reasoning-effort"; +import { + normalizePersistedJevDecision, + type PersistedJevDecisionV1, +} from "../usage/jev-stats"; import type { RequestMetricsRecorder } from "./request-metrics"; import type { CacheDiagnosticDraft, @@ -242,6 +246,8 @@ export interface RequestLogContext { terminalSource?: "upstream" | "synthetic"; /** Bounded route-decision trace (RI-01); never contains secrets. */ routeDecision?: RouteDecisionTraceV1; + /** Privacy-bounded JEV selection metadata; downstream usage is recorded on attempts[]. */ + jevDecision?: PersistedJevDecisionV1; /** Opt-in shadow evidence, normalized again at the logging boundary. */ claudeCompatibility?: PersistedClaudeCompatibilityLog; } @@ -352,6 +358,8 @@ export interface RequestLogEntry { terminalSource?: "upstream" | "synthetic"; /** Bounded route-decision trace (RI-01); never contains secrets. */ routeDecision?: RouteDecisionTraceV1; + /** Privacy-bounded JEV selection metadata; downstream usage is recorded on attempts[]. */ + jevDecision?: PersistedJevDecisionV1; /** Closed Claude protocol codes; no request or header values. */ claudeCompatibility?: PersistedClaudeCompatibilityLog; /** @@ -431,6 +439,7 @@ export function requestLogEntryFromPersistedUsage(entry: PersistedUsageEntry): R const terminalStatus = asTerminalStatus(entry.terminalStatus); const closeReason = asCloseReason(entry.closeReason); const routeDecision = normalizeRouteDecisionTraceForLog(entry.routeDecision); + const jevDecision = normalizePersistedJevDecision(entry.jevDecision); const claudeCompatibility = normalizeClaudeCompatibilityUsageLog(entry.claudeCompatibility); const spend = normalizeRequestSpend(entry.spend); const protocolTrace = parseProtocolTraceV1(entry.protocolTrace); @@ -484,6 +493,7 @@ export function requestLogEntryFromPersistedUsage(entry: PersistedUsageEntry): R ...(isKnownTransportPhase(entry.transportPhase) ? { transportPhase: entry.transportPhase } : {}), ...(isKnownTerminalSource(entry.terminalSource) ? { terminalSource: entry.terminalSource } : {}), ...(routeDecision ? { routeDecision } : {}), + ...(jevDecision ? { jevDecision } : {}), ...(claudeCompatibility ? { claudeCompatibility } : {}), ...(entry.conversationStateScrub === "account-change" ? { conversationStateScrub: "account-change" } @@ -580,8 +590,11 @@ export function addRequestLog(entry: RequestLogEntry) { const shadowCallRewrittenFrom = sanitizeLogMetadataString(entry.shadowCallRewrittenFrom); const servedModel = modelIdentityLogFields(entry).servedModel; const claudeCompatibility = normalizeClaudeCompatibilityUsageLog(entry.claudeCompatibility); + const jevDecision = normalizePersistedJevDecision(entry.jevDecision); const retained: RequestLogEntry = shadowCallRewrittenFrom === entry.shadowCallRewrittenFrom - && servedModel === entry.servedModel && entry.claudeCompatibility === undefined + && servedModel === entry.servedModel + && entry.claudeCompatibility === undefined + && entry.jevDecision === undefined ? entry : { ...entry, ...(shadowCallRewrittenFrom ? { shadowCallRewrittenFrom } : {}) }; if (!shadowCallRewrittenFrom && retained !== entry) delete retained.shadowCallRewrittenFrom; @@ -591,6 +604,8 @@ export function addRequestLog(entry: RequestLogEntry) { } if (claudeCompatibility) retained.claudeCompatibility = claudeCompatibility; else if (retained !== entry) delete retained.claudeCompatibility; + if (jevDecision) retained.jevDecision = jevDecision; + else if (retained !== entry) delete retained.jevDecision; entry = retained; retainRequestLogEntry(entry); for (const observer of requestLogObserversForTests) { @@ -667,6 +682,7 @@ export function addRequestLog(entry: RequestLogEntry) { // usage.jsonl, which is the surface the derived failure projection reads. ...normalizeRequestFailureAttribution(entry), ...(entry.routeDecision ? { routeDecision: entry.routeDecision } : {}), + ...(entry.jevDecision ? { jevDecision: entry.jevDecision } : {}), ...(entry.claudeCompatibility ? { claudeCompatibility: entry.claudeCompatibility } : {}), ...(entry.protocolTrace ? { protocolTrace: entry.protocolTrace } : {}), ...(entry.conversationStateScrub === "account-change" @@ -1511,6 +1527,7 @@ export function addFinalRequestLog( // the in-memory /api/logs row matches what usage.jsonl already stores. const shadowCallRewrittenFrom = sanitizeLogMetadataString(logCtx.shadowCallRewrittenFrom); const claudeCompatibility = normalizeClaudeCompatibilityUsageLog(logCtx.claudeCompatibility); + const jevDecision = normalizePersistedJevDecision(logCtx.jevDecision); // Keyed by the live attempt objects, not the detached copies above. const protocolTrace = protocolTraceForRequest(logCtx, logCtx.attempts); addLog({ @@ -1572,6 +1589,7 @@ export function addFinalRequestLog( ...(logCtx.transportPhase ? { transportPhase: logCtx.transportPhase } : {}), ...(logCtx.terminalSource ? { terminalSource: logCtx.terminalSource } : {}), ...(logCtx.routeDecision ? { routeDecision: logCtx.routeDecision } : {}), + ...(jevDecision ? { jevDecision } : {}), ...(claudeCompatibility ? { claudeCompatibility } : {}), ...(protocolTrace ? { protocolTrace } : {}), ...attribution, diff --git a/src/server/responses/core-combo.ts b/src/server/responses/core-combo.ts index de0e0d101d2..880091d2f97 100644 --- a/src/server/responses/core-combo.ts +++ b/src/server/responses/core-combo.ts @@ -1,4 +1,8 @@ -import { isDeclaredReasoningEffort } from "../../reasoning-effort"; +import { + isCodexReasoningEffort, + isDeclaredReasoningEffort, + resolveEffortAtOrBelow, +} from "../../reasoning-effort"; import { recordAttemptRequestedEffort } from "../request-log"; import { CODEX_TEXT_GUARDED_BUDGET_POLICY, @@ -9,13 +13,14 @@ import type { RequestExecutionBudgetPolicy, RequestExecutionBudget, } from "../../lib/request-execution-budget"; -import type { OcxConfig } from "../../types"; +import type { OcxComboDefaultEffort, OcxConfig } from "../../types"; import type { RequestLogContext } from "../request-log"; import type { HandleResponsesOptions, ResponsesDispatchers, ConsumedComboFailure } from "./core-options"; import type { TranslatorBudget } from "../../lib/translator-budget"; import { getCombo, comboRequestHasImageInput, + pickComboTarget, pickComboTargetWithWait, targetKey, concreteComboRequestBody, @@ -25,6 +30,11 @@ import { comboFailureDecision, advanceComboAfterFailure, comboFailureCooldownScope, + JEV_PROVIDER_ID, + resolveJevDecision, + type ComboPick, + type JevCandidate, + type JevDecision, } from "../../combos"; import { formatErrorResponse } from "../../bridge"; import { SEND_BUDGET_EXHAUSTED_CODE } from "../../lib/errors"; @@ -65,6 +75,7 @@ import type { ResponsesTerminalStatus } from "../../bridge"; import { beginRequestAttempt, sealRequestAttemptIdentity, finishRequestAttempt } from "../request-log"; import { rememberComboForLane } from "./combo-session-recall"; import { runTurnAdapterSseResponses } from "./core-lifetime"; +import { normalizePersistedJevDecision } from "../../usage/jev-stats"; import { isNativePassthroughSseResponse, isEagerRelaySseResponse, @@ -168,6 +179,65 @@ export function comboTargetSendBudget( }); } +interface JevComboChoice { + pick: ComboPick; + candidate: JevCandidate; +} + +/** Enumerate the current ordinary Combo eligibility set without retaining attempted picks. */ +function eligibleJevComboChoices( + config: OcxConfig, + comboId: string, + eligible: (target: NonNullable>["targets"][number]) => boolean, + now: number, +): JevComboChoice[] { + const combo = getCombo(config, comboId); + if (!combo) return []; + const excluded = new Set(); + const choices: JevComboChoice[] = []; + while (excluded.size < combo.targets.length) { + const pick = pickComboTarget(config, comboId, { exclude: excluded, eligible, now }); + if (!pick) break; + const key = targetKey(pick.target); + excluded.add(key); + // The TypeSafe row owns a decision credential, not an inference transport. + if (pick.target.provider === JEV_PROVIDER_ID) continue; + let ladder: string[] | undefined; + try { + const route = routeConcreteModel(config, key); + ladder = supportedLadderFor({ provider: route.provider, modelId: route.modelId }); + } catch { + // Preserve the existing routing-failure surface. Unknown capability becomes the explicit + // no-effort choice rather than broadening JEV's effort allowlist. + ladder = undefined; + } + const supportedEfforts = (ladder ?? []).filter(isCodexReasoningEffort) as OcxComboDefaultEffort[]; + const configuredEfforts = pick.target.reasoningEfforts; + const reasoningEfforts = configuredEfforts === undefined + ? supportedEfforts + : configuredEfforts.filter(effort => supportedEfforts.includes(effort)); + // An explicit allowlist is restrictive. If catalog capabilities drift until no configured + // effort remains supported, omit the target instead of silently broadening JEV's choices. + if (configuredEfforts !== undefined && reasoningEfforts.length === 0) continue; + choices.push({ + pick: { ...pick, attempted: [key] }, + candidate: { + key, + provider: pick.target.provider, + model: pick.target.model, + reasoningEfforts, + }, + }); + } + // #5691: the synchronous pick above does not defer emergency-only targets, so apply the + // same rule here — withhold them from JEV while any normal target is offered, never when + // they are all that remains. + if (combo.cooldownWaitPolicy === "before-last-resort" && choices.some(choice => !choice.pick.target.lastResort)) { + return choices.filter(choice => !choice.pick.target.lastResort); + } + return choices; +} + /** Dispatch a Responses combo within its shared send budget and preserve terminal child failures. */ export async function executeComboResponses( @@ -311,7 +381,10 @@ export async function executeComboResponses( const payloadEligible = (target: (typeof combo.targets)[number]): boolean => comboPayloadReadable || !unreadableEncryptedAgentTask || canDecryptUnreadableAgentTask(target); const targetEligible = (target: (typeof combo.targets)[number]): boolean => - payloadEligible(target) && reasoningReplayEligible(target) && (protocolLanes?.pickable(target) ?? true); + (combo.strategy !== "jev" || target.provider !== JEV_PROVIDER_ID) + && payloadEligible(target) + && reasoningReplayEligible(target) + && (protocolLanes?.pickable(target) ?? true); const onlyReplayIncompatibleTargetsRemain = (excluded: Iterable = []): boolean => { const excludedKeys = new Set(excluded); const remaining = combo.targets.filter(target => { @@ -419,6 +492,70 @@ export async function executeComboResponses( ? clientCancelledResponse() : comboUnavailable(comboId); } + let jevDecision: JevDecision | undefined; + if (combo.strategy === "jev") { + const choices = eligibleJevComboChoices(config, comboId, targetEligible, Date.now()); + const first = choices[0]; + if (!first) return comboUnavailable(comboId); + const resolvedFailOpenEffort = resolveEffortAtOrBelow( + "medium", + first.candidate.reasoningEfforts, + ); + const fallback: Pick = { + targetKey: first.candidate.key, + effort: resolvedFailOpenEffort && isCodexReasoningEffort(resolvedFailOpenEffort) + ? resolvedFailOpenEffort as OcxComboDefaultEffort + : null, + }; + const decisionStartedAt = Date.now(); + let decision: JevDecision; + try { + decision = await resolveJevDecision({ + body, + candidates: choices.map(choice => choice.candidate), + fallback, + config, + signal: options.abortSignal, + }); + } catch (error) { + if (options.abortSignal?.aborted) return clientCancelledResponse(); + decision = { + ...fallback, + gate: "network", + latencyMs: Math.max(0, Date.now() - decisionStartedAt), + }; + } + jevDecision = decision; + const selected = choices.find(choice => choice.candidate.key === decision.targetKey) ?? first; + pick = { ...selected.pick, attempted: [targetKey(selected.pick.target)] }; + logCtx.jevDecision = normalizePersistedJevDecision({ + version: 1, + comboId, + selected: { + provider: selected.pick.target.provider, + model: selected.pick.target.model, + effort: decision.effort, + }, + gate: decision.gate, + latencyMs: decision.latencyMs, + ...(decision.confidence !== undefined ? { confidence: decision.confidence } : {}), + ...(decision.chosenProbability !== undefined + ? { chosenProbability: decision.chosenProbability } + : {}), + ...(decision.usage ? { usage: decision.usage } : {}), + }); + console.debug("[combo] JEV decision", { + targetKey: decision.targetKey, + effort: decision.effort, + gate: decision.gate, + latencyMs: decision.latencyMs, + ...(decision.confidence !== undefined ? { confidence: decision.confidence } : {}), + ...(decision.chosenProbability !== undefined + ? { chosenProbability: decision.chosenProbability } + : {}), + ...(decision.usage ? { usage: decision.usage } : {}), + }); + } // One immutable combo selection trace, before any child dispatch; child // adoption below must never replace it with a concrete child route trace. logCtx.routeDecision = comboRouteDecisionTrace(config, comboId, pick, requestedModel); @@ -491,14 +628,32 @@ export async function executeComboResponses( ...(logCtx.surface ? { surface: logCtx.surface } : {}), }; const targetRoute = routeConcreteModel(config, `${pick.target.provider}/${pick.target.model}`); + const targetReasoningEfforts = supportedLadderFor({ + provider: targetRoute.provider, + modelId: targetRoute.modelId, + }); + const initialJevDecision = firstComboTarget ? jevDecision : undefined; const childBody = concreteComboRequestBody( body, pick.target, - comboDefaultEffort(config, comboId), - supportedLadderFor({ provider: targetRoute.provider, modelId: targetRoute.modelId }), + initialJevDecision ? initialJevDecision.effort : comboDefaultEffort(config, comboId), + initialJevDecision?.effort === null ? [] : targetReasoningEfforts, combo.reasoningEffortMode, - combo.defaultEffortMode, + initialJevDecision !== undefined && initialJevDecision.effort !== null ? "force" : combo.defaultEffortMode, ); + if (initialJevDecision) { + delete childBody.service_tier; + if (initialJevDecision.effort !== null) { + const childReasoning = childBody.reasoning; + const preservedReasoning = childReasoning && typeof childReasoning === "object" && !Array.isArray(childReasoning) + ? childReasoning as Record + : {}; + childBody.reasoning = { ...preservedReasoning, effort: initialJevDecision.effort }; + delete childBody.reasoning_effort; + delete childBody.thinking_budget; + delete childBody.thinking; + } + } const childHeaders = buildComboChildHeaders(req.headers); const childRequest = new Request(req.url, { method: req.method, @@ -566,8 +721,11 @@ export async function executeComboResponses( let response: Response; try { const currentTargetProvider = pick.target.provider; - const deferCodexResetDerivedCooldown = combo.strategy === "failover" - && combo.targets.slice(pick.targetIndex + 1).some(target => + const remainingTargets = combo.strategy === "jev" + ? combo.targets.filter(target => !pick!.attempted.includes(targetKey(target))) + : combo.targets.slice(pick.targetIndex + 1); + const deferCodexResetDerivedCooldown = (combo.strategy === "failover" || combo.strategy === "jev") + && remainingTargets.some(target => target.provider === currentTargetProvider && targetEligible(target) && !isComboTargetInCooldown(comboId, target), diff --git a/src/types/config.ts b/src/types/config.ts index 1bed11973d0..c87bfee58b9 100644 --- a/src/types/config.ts +++ b/src/types/config.ts @@ -1182,7 +1182,7 @@ export type OcxAccountPoolRotationStrategy = "quota" | "round-robin" | "fill-fir export type OcxAccountPoolQuotaWindow = "five-hour" | "weekly" | "max-utilization"; -export type OcxComboStrategy = "failover" | "round-robin" | "random" | "least-used" | "reset-window"; +export type OcxComboStrategy = "failover" | "round-robin" | "random" | "least-used" | "reset-window" | "jev"; export type OcxComboDefaultEffort = "low" | "medium" | "high" | "xhigh" | "max" | "ultra"; export type OcxComboDefaultEffortMode = "fallback" | "force"; @@ -1207,6 +1207,11 @@ export interface OcxComboTarget { model: string; /** Relative target weight for round-robin batches and random selection. Default 1; valid range 1..10000. */ weight?: number; + /** + * Exact efforts JEV may choose for this target. Omit to allow every effort the + * target currently advertises; an explicit list must be non-empty. + */ + reasoningEfforts?: OcxComboDefaultEffort[]; /** * Marks an emergency-only target. Inert unless the combo sets * `cooldownWaitPolicy`, and never makes a target permanently ineligible — diff --git a/src/usage/jev-stats.ts b/src/usage/jev-stats.ts new file mode 100644 index 00000000000..d275441c969 --- /dev/null +++ b/src/usage/jev-stats.ts @@ -0,0 +1,495 @@ +import type { OcxComboDefaultEffort } from "../types"; +import type { PersistedUsageEntry } from "./log"; +import { usageDisplayTotalTokens } from "./totals"; + +export const JEV_DECISION_GATES = [ + "apply", + "missing_key", + "no_choices", + "no_state", + "timeout", + "network", + "redirect", + "http", + "malformed", + "invalid", +] as const; + +export type JevDecisionGate = (typeof JEV_DECISION_GATES)[number]; + +const JEV_GATE_SET = new Set(JEV_DECISION_GATES); +const JEV_EFFORTS = new Set([ + "low", "medium", "high", "xhigh", "max", "ultra", +]); +const MAX_COMBO_ID_CHARS = 128; +// JEV accepts provider/model candidate fields up to 512 characters. Preserve +// that same identity on telemetry so a selected target still joins its attempt. +const MAX_TARGET_IDENTITY_CHARS = 512; +const CONTROL_CHARS = /[\u0000-\u001f\u007f]/u; + +export interface PersistedJevDecisionV1 { + version: 1; + comboId: string; + selected: { + provider: string; + model: string; + effort: OcxComboDefaultEffort | null; + }; + gate: JevDecisionGate; + latencyMs: number; + confidence?: number; + chosenProbability?: number; + usage?: { + inputTokens: number; + outputTokens: number; + totalTokens: number; + }; +} + +export interface JevStatsModelRow { + provider: string; + model: string; + /** Aggregate bucket for identities beyond the retained model-cardinality cap. */ + overflow: boolean; + picks: number; + appliedPicks: number; + failOpenPicks: number; + attempts: number; + measuredAttempts: number; + inputTokens: number; + outputTokens: number; + reasoningTokens: number; + cacheReadTokens: number; + cacheWriteTokens: number; + totalTokens: number; + efforts: Array<{ effort: OcxComboDefaultEffort | null; picks: number }>; +} + +export const MAX_JEV_STATS_MODEL_ROWS = 256; + +export interface JevStatsAccumulator { + readonly estimatedBytes: number; + add(entry: PersistedUsageEntry): void; + clone(): JevStatsAccumulator; + summarize(range: string, generatedAt: number): JevStatsResponse; +} + +export interface JevStatsResponse { + range: string; + comboId: string | null; + since: number | null; + until?: number; + generatedAt: number; + summary: { + decisions: number; + appliedDecisions: number; + failOpenDecisions: number; + successfulRequests: number; + requestsWithModelFallback: number; + modelAttempts: number; + measuredModelAttempts: number; + modelInputTokens: number; + modelOutputTokens: number; + modelReasoningTokens: number; + modelCacheReadTokens: number; + modelCacheWriteTokens: number; + modelTotalTokens: number; + decisionUsageReported: number; + decisionInputTokens: number; + decisionOutputTokens: number; + decisionTotalTokens: number; + averageLatencyMs: number | null; + averageConfidence: number | null; + averageChosenProbability: number | null; + }; + gates: Array<{ gate: JevDecisionGate; decisions: number }>; + models: JevStatsModelRow[]; + snapshotWindowStart: number | null; + snapshotWindowEnd: number | null; +} + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +function boundedIdentity(value: unknown, maxLength: number): string | undefined { + if (typeof value !== "string") return undefined; + const normalized = value.trim(); + if (!normalized || CONTROL_CHARS.test(normalized)) return undefined; + return normalized.slice(0, maxLength); +} + +export function normalizeJevStatsComboId(value: unknown): string | undefined { + return boundedIdentity(value, MAX_COMBO_ID_CHARS); +} + +function boundedCount(value: unknown): number | undefined { + if (typeof value !== "number" || !Number.isFinite(value) || value < 0) return undefined; + return Math.min(Number.MAX_SAFE_INTEGER, Math.round(value)); +} + +function saturatingAdd(total: number, value: number): number { + if (!Number.isFinite(value) || value <= 0) return total; + return total >= Number.MAX_SAFE_INTEGER - value + ? Number.MAX_SAFE_INTEGER + : total + value; +} + +function probability(value: unknown): number | undefined { + return typeof value === "number" && Number.isFinite(value) && value >= 0 && value <= 1 + ? value + : undefined; +} + +function normalizedDecisionUsage(value: unknown): PersistedJevDecisionV1["usage"] { + if (!isRecord(value)) return undefined; + const inputTokens = boundedCount(value.inputTokens ?? value.input_tokens); + const outputTokens = boundedCount(value.outputTokens ?? value.output_tokens); + if (inputTokens === undefined || outputTokens === undefined) return undefined; + return { + inputTokens, + outputTokens, + totalTokens: saturatingAdd(inputTokens, outputTokens), + }; +} + +/** + * Re-validates the privacy-bounded decision record at both write and hydration boundaries. + * Only closed enums, bounded identifiers and finite counters survive; prompts and credentials + * have no slot in this shape. + */ +export function normalizePersistedJevDecision(value: unknown): PersistedJevDecisionV1 | undefined { + if (!isRecord(value) || value.version !== 1 || !isRecord(value.selected)) return undefined; + const comboId = boundedIdentity(value.comboId, MAX_COMBO_ID_CHARS); + const provider = boundedIdentity(value.selected.provider, MAX_TARGET_IDENTITY_CHARS); + const model = boundedIdentity(value.selected.model, MAX_TARGET_IDENTITY_CHARS); + const effort = value.selected.effort; + const gate = value.gate; + const latencyMs = boundedCount(value.latencyMs); + if (!comboId || !provider || !model || latencyMs === undefined) return undefined; + if (effort !== null && (typeof effort !== "string" || !JEV_EFFORTS.has(effort as OcxComboDefaultEffort))) { + return undefined; + } + if (typeof gate !== "string" || !JEV_GATE_SET.has(gate)) return undefined; + const confidence = probability(value.confidence); + const chosenProbability = probability(value.chosenProbability); + const usage = normalizedDecisionUsage(value.usage); + return { + version: 1, + comboId, + selected: { + provider, + model, + effort: effort as OcxComboDefaultEffort | null, + }, + gate: gate as JevDecisionGate, + latencyMs, + ...(confidence !== undefined ? { confidence } : {}), + ...(chosenProbability !== undefined ? { chosenProbability } : {}), + ...(usage ? { usage } : {}), + }; +} + +interface MutableModelRow extends Omit { + effortCounts: Map; +} + +interface JevStatsAccumulatorOptions { + comboId?: string | null; + since?: number | null; + until?: number; +} + +function cacheReadTokens(usage: NonNullable[number]["usage"]>): number { + if (typeof usage.cacheReadInputTokens === "number") return usage.cacheReadInputTokens; + if (typeof usage.cachedInputTokens !== "number") return 0; + return typeof usage.cacheCreationInputTokens === "number" + ? Math.max(0, usage.cachedInputTokens - usage.cacheCreationInputTokens) + : usage.cachedInputTokens; +} + +function finiteToken(value: number | undefined): number { + if (typeof value !== "number" || Number.isNaN(value) || value < 0) return 0; + return Math.min(Number.MAX_SAFE_INTEGER, value); +} + +function mean(total: number, count: number): number | null { + return count > 0 ? total / count : null; +} + +function nextMean(current: number | null, count: number, value: number): number { + if (current === null || count === 0) return value; + if (count >= Number.MAX_SAFE_INTEGER) return current; + return current + (value - current) / (count + 1); +} + +function modelKey(provider: string, model: string): string { + return `${provider}\0${model}`; +} + +export function createJevStatsAccumulator(options: JevStatsAccumulatorOptions = {}): JevStatsAccumulator { + return new StreamingJevStatsAccumulator(options); +} + +function blankModelRow(provider: string, model: string, overflow = false): MutableModelRow { + return { + provider, + model, + overflow, + picks: 0, + appliedPicks: 0, + failOpenPicks: 0, + attempts: 0, + measuredAttempts: 0, + inputTokens: 0, + outputTokens: 0, + reasoningTokens: 0, + cacheReadTokens: 0, + cacheWriteTokens: 0, + totalTokens: 0, + effortCounts: new Map(), + }; +} + +function cloneModelRow(row: MutableModelRow): MutableModelRow { + return { ...row, effortCounts: new Map(row.effortCounts) }; +} + +function publicModelRow({ effortCounts, ...row }: MutableModelRow): JevStatsModelRow { + return { + ...row, + efforts: [...effortCounts].map(([effort, picks]) => ({ effort, picks })), + }; +} + +class StreamingJevStatsAccumulator implements JevStatsAccumulator { + private readonly comboId: string | null; + private readonly since: number | null; + private readonly until: number | undefined; + private readonly models = new Map(); + private overflowModel: MutableModelRow | null = null; + private readonly gates = new Map(); + private snapshotWindowStart: number | null = null; + private snapshotWindowEnd: number | null = null; + private decisions = 0; + private appliedDecisions = 0; + private successfulRequests = 0; + private requestsWithModelFallback = 0; + private modelAttempts = 0; + private measuredModelAttempts = 0; + private modelInputTokens = 0; + private modelOutputTokens = 0; + private modelReasoningTokens = 0; + private modelCacheReadTokens = 0; + private modelCacheWriteTokens = 0; + private modelTotalTokens = 0; + private decisionUsageReported = 0; + private decisionInputTokens = 0; + private decisionOutputTokens = 0; + private decisionTotalTokens = 0; + private averageLatencyMs: number | null = null; + private confidenceTotal = 0; + private confidenceCount = 0; + private probabilityTotal = 0; + private probabilityCount = 0; + + constructor(options: JevStatsAccumulatorOptions = {}) { + this.comboId = boundedIdentity(options.comboId, MAX_COMBO_ID_CHARS) ?? null; + this.since = options.since === null || options.since === undefined ? null : options.since; + this.until = options.until; + } + + get estimatedBytes(): number { + let bytes = 512; + for (const row of this.models.values()) { + bytes += 256 + (row.provider.length + row.model.length) * 2 + row.effortCounts.size * 32; + } + if (this.overflowModel) bytes += 256 + this.overflowModel.effortCounts.size * 32; + return bytes; + } + + private rowFor(provider: string, model: string): MutableModelRow { + const key = modelKey(provider, model); + const existing = this.models.get(key); + if (existing) return existing; + // Keep one of the 256 public rows for the aggregate overflow bucket. This + // bounds retained memory, sort work and response size even if a ledger was + // populated with attacker-controlled or short-lived model identities. + if (this.models.size < MAX_JEV_STATS_MODEL_ROWS - 1) { + const row = blankModelRow(provider, model); + this.models.set(key, row); + return row; + } + // Empty target identities never survive normalization, so the explicit + // overflow flag plus this empty tuple cannot collide with a real row. + this.overflowModel ??= blankModelRow("", "", true); + return this.overflowModel; + } + + add(entry: PersistedUsageEntry): void { + if (Number.isFinite(entry.timestamp)) { + this.snapshotWindowStart = this.snapshotWindowStart === null + ? entry.timestamp + : Math.min(this.snapshotWindowStart, entry.timestamp); + this.snapshotWindowEnd = this.snapshotWindowEnd === null + ? entry.timestamp + : Math.max(this.snapshotWindowEnd, entry.timestamp); + } + if ((this.since !== null && entry.timestamp < this.since) + || (this.until !== undefined && entry.timestamp > this.until)) return; + const decision = normalizePersistedJevDecision(entry.jevDecision); + if (!decision || (this.comboId !== null && decision.comboId !== this.comboId)) return; + + this.averageLatencyMs = nextMean(this.averageLatencyMs, this.decisions, decision.latencyMs); + this.decisions = saturatingAdd(this.decisions, 1); + if (decision.gate === "apply") this.appliedDecisions = saturatingAdd(this.appliedDecisions, 1); + if (entry.status >= 200 && entry.status < 400) { + this.successfulRequests = saturatingAdd(this.successfulRequests, 1); + } + this.gates.set(decision.gate, saturatingAdd(this.gates.get(decision.gate) ?? 0, 1)); + if (decision.confidence !== undefined) { + this.confidenceTotal = saturatingAdd(this.confidenceTotal, decision.confidence); + this.confidenceCount = saturatingAdd(this.confidenceCount, 1); + } + if (decision.chosenProbability !== undefined) { + this.probabilityTotal = saturatingAdd(this.probabilityTotal, decision.chosenProbability); + this.probabilityCount = saturatingAdd(this.probabilityCount, 1); + } + if (decision.usage) { + this.decisionUsageReported = saturatingAdd(this.decisionUsageReported, 1); + this.decisionInputTokens = saturatingAdd(this.decisionInputTokens, decision.usage.inputTokens); + this.decisionOutputTokens = saturatingAdd(this.decisionOutputTokens, decision.usage.outputTokens); + this.decisionTotalTokens = saturatingAdd(this.decisionTotalTokens, decision.usage.totalTokens); + } + + const selected = this.rowFor(decision.selected.provider, decision.selected.model); + selected.picks = saturatingAdd(selected.picks, 1); + if (decision.gate === "apply") selected.appliedPicks = saturatingAdd(selected.appliedPicks, 1); + else selected.failOpenPicks = saturatingAdd(selected.failOpenPicks, 1); + selected.effortCounts.set( + decision.selected.effort, + saturatingAdd(selected.effortCounts.get(decision.selected.effort) ?? 0, 1), + ); + + const attempts = (entry.attempts ?? []).flatMap(attempt => { + const sendCount = boundedCount(attempt.sendCount) ?? 0; + if (sendCount === 0) return []; + const provider = boundedIdentity(attempt.provider, MAX_TARGET_IDENTITY_CHARS); + const model = boundedIdentity(attempt.model, MAX_TARGET_IDENTITY_CHARS); + return provider && model ? [{ attempt, provider, model, sendCount }] : []; + }); + if (attempts.some(({ provider, model }) => provider !== decision.selected.provider + || model !== decision.selected.model)) { + this.requestsWithModelFallback = saturatingAdd(this.requestsWithModelFallback, 1); + } + for (const { attempt, provider, model: modelId, sendCount } of attempts) { + const model = this.rowFor(provider, modelId); + model.attempts = saturatingAdd(model.attempts, sendCount); + this.modelAttempts = saturatingAdd(this.modelAttempts, sendCount); + if (!attempt.usage || (attempt.usageStatus !== "reported" && attempt.usageStatus !== "estimated")) continue; + model.measuredAttempts = saturatingAdd(model.measuredAttempts, 1); + this.measuredModelAttempts = saturatingAdd(this.measuredModelAttempts, 1); + const inputTokens = finiteToken(attempt.usage.inputTokens); + const outputTokens = finiteToken(attempt.usage.outputTokens); + const reasoningTokens = finiteToken(attempt.usage.reasoningOutputTokens); + const readTokens = finiteToken(cacheReadTokens(attempt.usage)); + const writeTokens = finiteToken(attempt.usage.cacheCreationInputTokens); + const totalTokens = finiteToken(usageDisplayTotalTokens(attempt.usage, attempt.totalTokens)); + model.inputTokens = saturatingAdd(model.inputTokens, inputTokens); + model.outputTokens = saturatingAdd(model.outputTokens, outputTokens); + model.reasoningTokens = saturatingAdd(model.reasoningTokens, reasoningTokens); + model.cacheReadTokens = saturatingAdd(model.cacheReadTokens, readTokens); + model.cacheWriteTokens = saturatingAdd(model.cacheWriteTokens, writeTokens); + model.totalTokens = saturatingAdd(model.totalTokens, totalTokens); + this.modelInputTokens = saturatingAdd(this.modelInputTokens, inputTokens); + this.modelOutputTokens = saturatingAdd(this.modelOutputTokens, outputTokens); + this.modelReasoningTokens = saturatingAdd(this.modelReasoningTokens, reasoningTokens); + this.modelCacheReadTokens = saturatingAdd(this.modelCacheReadTokens, readTokens); + this.modelCacheWriteTokens = saturatingAdd(this.modelCacheWriteTokens, writeTokens); + this.modelTotalTokens = saturatingAdd(this.modelTotalTokens, totalTokens); + } + } + + clone(): JevStatsAccumulator { + const cloned = new StreamingJevStatsAccumulator({ + comboId: this.comboId, + since: this.since, + ...(this.until !== undefined ? { until: this.until } : {}), + }); + for (const [key, row] of this.models) cloned.models.set(key, cloneModelRow(row)); + cloned.overflowModel = this.overflowModel ? cloneModelRow(this.overflowModel) : null; + for (const [gate, count] of this.gates) cloned.gates.set(gate, count); + cloned.snapshotWindowStart = this.snapshotWindowStart; + cloned.snapshotWindowEnd = this.snapshotWindowEnd; + cloned.decisions = this.decisions; + cloned.appliedDecisions = this.appliedDecisions; + cloned.successfulRequests = this.successfulRequests; + cloned.requestsWithModelFallback = this.requestsWithModelFallback; + cloned.modelAttempts = this.modelAttempts; + cloned.measuredModelAttempts = this.measuredModelAttempts; + cloned.modelInputTokens = this.modelInputTokens; + cloned.modelOutputTokens = this.modelOutputTokens; + cloned.modelReasoningTokens = this.modelReasoningTokens; + cloned.modelCacheReadTokens = this.modelCacheReadTokens; + cloned.modelCacheWriteTokens = this.modelCacheWriteTokens; + cloned.modelTotalTokens = this.modelTotalTokens; + cloned.decisionUsageReported = this.decisionUsageReported; + cloned.decisionInputTokens = this.decisionInputTokens; + cloned.decisionOutputTokens = this.decisionOutputTokens; + cloned.decisionTotalTokens = this.decisionTotalTokens; + cloned.averageLatencyMs = this.averageLatencyMs; + cloned.confidenceTotal = this.confidenceTotal; + cloned.confidenceCount = this.confidenceCount; + cloned.probabilityTotal = this.probabilityTotal; + cloned.probabilityCount = this.probabilityCount; + return cloned; + } + + summarize(range: string, generatedAt: number): JevStatsResponse { + const rows = [ + ...this.models.values(), + ...(this.overflowModel ? [this.overflowModel] : []), + ] + .map(publicModelRow) + .sort((left, right) => right.picks - left.picks + || right.attempts - left.attempts + || left.provider.localeCompare(right.provider) + || left.model.localeCompare(right.model)); + return { + range, + comboId: this.comboId, + since: this.since, + ...(this.until !== undefined ? { until: this.until } : {}), + generatedAt, + summary: { + decisions: this.decisions, + appliedDecisions: this.appliedDecisions, + failOpenDecisions: this.decisions - this.appliedDecisions, + successfulRequests: this.successfulRequests, + requestsWithModelFallback: this.requestsWithModelFallback, + modelAttempts: this.modelAttempts, + measuredModelAttempts: this.measuredModelAttempts, + modelInputTokens: this.modelInputTokens, + modelOutputTokens: this.modelOutputTokens, + modelReasoningTokens: this.modelReasoningTokens, + modelCacheReadTokens: this.modelCacheReadTokens, + modelCacheWriteTokens: this.modelCacheWriteTokens, + modelTotalTokens: this.modelTotalTokens, + decisionUsageReported: this.decisionUsageReported, + decisionInputTokens: this.decisionInputTokens, + decisionOutputTokens: this.decisionOutputTokens, + decisionTotalTokens: this.decisionTotalTokens, + averageLatencyMs: this.averageLatencyMs, + averageConfidence: mean(this.confidenceTotal, this.confidenceCount), + averageChosenProbability: mean(this.probabilityTotal, this.probabilityCount), + }, + gates: JEV_DECISION_GATES.flatMap(gate => { + const count = this.gates.get(gate) ?? 0; + return count > 0 ? [{ gate, decisions: count }] : []; + }), + models: rows, + snapshotWindowStart: this.snapshotWindowStart, + snapshotWindowEnd: this.snapshotWindowEnd, + }; + } +} diff --git a/src/usage/log.ts b/src/usage/log.ts index 8871f64fe7f..9f311f51a16 100644 --- a/src/usage/log.ts +++ b/src/usage/log.ts @@ -7,6 +7,10 @@ import { enforceAppOwnedMemoryBudget } from "../lib/app-owned-memory"; import { recordOwnedConfigPath } from "../lib/config-ownership"; import { redactSecretString, sanitizeLogMetadataString } from "../lib/redact"; import { usageDisplayTotalTokens } from "./totals"; +import { + normalizePersistedJevDecision, + type PersistedJevDecisionV1, +} from "./jev-stats"; import { normalizeAttemptDeliverySummary } from "./attempt-delivery"; import { isRequestCloseReason, @@ -385,6 +389,8 @@ export interface PersistedUsageEntry { * contains prompts, credentials, or hidden reasoning. */ routeDecision?: RouteDecisionTraceV1; + /** Privacy-bounded JEV selection metadata; model usage remains in attempts[]. */ + jevDecision?: PersistedJevDecisionV1; /** Closed Claude protocol codes only; absent on older rows. */ claudeCompatibility?: PersistedClaudeCompatibilityLog; /** @@ -948,6 +954,7 @@ function normalizeUsageEntry(entry: PersistedUsageEntry): PersistedUsageEntry { const routeDecision = entry.routeDecision ? normalizeRouteDecisionTrace(entry.routeDecision) : undefined; + const jevDecision = normalizePersistedJevDecision(entry.jevDecision); const spend = normalizeRequestSpend(entry.spend); const protocolTrace = parseProtocolTraceV1(entry.protocolTrace); return { @@ -1037,6 +1044,7 @@ function normalizeUsageEntry(entry: PersistedUsageEntry): PersistedUsageEntry { ...(isRequestCloseReason(entry.closeReason) ? { closeReason: entry.closeReason } : {}), ...(entry.upstreamError ? { upstreamError: entry.upstreamError } : {}), ...(routeDecision ? { routeDecision } : {}), + ...(jevDecision ? { jevDecision } : {}), ...(claudeCompatibility ? { claudeCompatibility } : {}), ...(protocolTrace ? { protocolTrace } : {}), ...normalizeRequestFailureAttribution(entry), diff --git a/structure/dashboard-and-usage.md b/structure/dashboard-and-usage.md index de4268df77d..cea1aa709e5 100644 --- a/structure/dashboard-and-usage.md +++ b/structure/dashboard-and-usage.md @@ -132,6 +132,8 @@ single forms, and the shell pattern is the part worth keeping stable: | Codex accounts | Account pool cards, add-account flow, switch and reset modals (`gui/src/components/CodexAccountPool.tsx`, `gui/src/components/AddCodexAccountModal.tsx`), plus the generic account-targeting picker opt-in on `gui/src/pages/codex-set-multiauth.tsx`. Add/delete/login completion is projected to one boolean before presentation; pending catalog work is a warning, not a failed account mutation. The main card's native-main device reauth (#3898) is owned by `gui/src/components/use-main-device-reauth.ts`: the dedicated `/api/codex-auth/main/reauth-device` namespace only — never the pool login route — with flowId-owned polling, an allowlisted verification URL, and no token fields accepted from payloads. The main-device reauth hook retains flow ownership from the Cancel click, while DELETE is unresolved and after retryable failure; polling normally continues. A concurrent GET HTTP error cannot expose a replacement login POST before DELETE settles. If a retryable DELETE failure races with a non-2xx GET while the flow is pending or committing, either response order preserves same-flow Cancel retry, restores the last server-provided device code, verification URL, and phase when needed, and keeps the existing poll cadence so a later terminal result remains observable. Outside same-flow cancellation ownership, a GET HTTP failure still stops polling without starting a second login POST. The cancellation-failure indication survives pending status updates until a trusted terminal result releases ownership. A successful DELETE with a terminal `failed` DTO releases it and uses the same closed failure-code mapping as polling; only `succeeded` notifies login completion. Unrecognized or nonterminal DTO status values remain retryable. A DELETE response with HTTP 404 and code `unknown_flow` releases the expired flow and shows the existing generic failure state so device re-login is available again; it claims neither login success nor confirmed cancellation. Confirmed cancellation also makes device re-login available. Start, polling and cancellation completions verify their controller or flow ownership after asynchronous response reads; replaced flows and unmounted hooks cannot update a newer flow or notify completion. Effect setup restores mounted state after the StrictMode development cleanup cycle. | | Dashboard overview | Overview, Providers, and Models tabs at the page level (`gui/src/pages/Dashboard.tsx`), the 30-day token and coverage stats in the overview head (`gui/src/pages/dashboard-overview-head.tsx`), and the effort-cap, injection, maintenance, sidecar, and memory panels below it (`gui/src/pages/dashboard-overview-panels.tsx`). | +JEV setup and its Stats tab reuse these shells; see [providers-and-adapters.md](./providers-and-adapters.md#typesafe-jev-decision-provider). + The native-main reauth poller captures an immutable accepted flow id for queued callbacks. Its POST, GET and DELETE JSON reads retain API error codes, but non-2xx responses never become successful flow DTOs. The three local React Doctor response-body exceptions preserve @@ -171,6 +173,8 @@ keychain resolution. The log contains the digest, not raw keys, references, or p Existing Codex and OAuth label formats remain valid. Replacing a literal or reference changes identity; rotating the secret behind the same reference preserves the logical account. +`src/usage/jev-stats.ts` owns the parallel content-free JEV projection; its accumulator contract lives in [providers-and-adapters.md](./providers-and-adapters.md#typesafe-jev-decision-provider). + `src/providers/label.ts` stamps only key authentication, including implicit custom-provider keys. `src/server/request-log.ts` commits identity at dispatch after queued selection changes, retains separate flat records when retries change keys, and isolates each record's raw usage diff --git a/structure/ops/docs-and-release.md b/structure/ops/docs-and-release.md index 202ebd62008..b5858dad9eb 100644 --- a/structure/ops/docs-and-release.md +++ b/structure/ops/docs-and-release.md @@ -84,7 +84,7 @@ Manual navigation is defined in `docs-site/astro.config.mjs`. When adding a publ sidebar and either add localized copies or intentionally accept Starlight fallback behavior. Provider preset totals are recounted from the current registry when a preset lands. The -documented split is 98 total: 81 key-based, 13 OAuth, three local, and one default +documented split is 99 total: 82 key-based, 13 OAuth, three local, and one default ChatGPT-forward preset. The English provider guide, all seven translated copies, and all eight quickstarts carry the same counts. diff --git a/structure/providers-and-adapters.md b/structure/providers-and-adapters.md index 3144cc41d3f..74cdcd198ca 100644 --- a/structure/providers-and-adapters.md +++ b/structure/providers-and-adapters.md @@ -105,6 +105,63 @@ OAuth presets resolve discovery against the same canonical registry transport as before any adapter-specific transport override, so a stale configured `baseUrl` cannot receive an OAuth bearer token. +## TypeSafe JEV decision provider + +`src/providers/registry/entries-extended.ts` owns the canonical `jev` key preset at +`https://api.typesafe.ai/v1/systemone` with adapter `jev-decision`. It is a credential owner, not an +inference route: the registry marks it `credentialOnly`, its adapter is deliberately absent from the +routable adapter registry, live discovery is disabled, no default/static model is published, and +key login returns unknown without probing a nonexistent model catalog. The normal `ocx login jev` +flow and provider-workspace API-key panel both persist the same credential-only row. Combo validation +rejects the decision provider as a target. `src/server/management/provider-routes.ts` +special-cases its connection test through the same bounded decision client before the generic +static-catalog branch. The test sends no user prompt and returns only sanitized health status. + +The request path consumes a configured literal/reference key only when the row still matches the +canonical registry transport, with `TYPESAFE_API_KEY` and the standard provider-derived +`JEV_API_KEY` as explicit environment fallbacks. A same-named custom destination cannot receive +either credential through the JEV client. All automated coverage mocks TypeSafe; live-key behavior +remains an operator smoke boundary. + +`src/combos/jev.ts` extracts bounded user-task, previous-assistant, and latest-tool-output text plus +the tool name and boolean signals; raw image data, tool arguments, encrypted reasoning, headers, and +the JEV credential are excluded. It owns the joint target/effort choice map, strict response +validation, fixed `jev-latest` destination, four-second deadline, no-redirect policy, bounded response, +and caller-cancellation propagation. Missing credentials or safe state, transport failures, and invalid +answers fail open to the first eligible target; no response can escape the configured choice map. +Telemetry never retains extracted state or credentials. + +`src/server/responses/core-combo.ts` computes current eligibility, asks JEV once for the initial pick, +applies the validated effort, and removes caller `service_tier` for that child. A retryable child +failure re-enters the ordinary Combo fallback loop from the untouched request without another JEV +call. Each target may carry an optional non-empty `reasoningEfforts` allowlist. Omission keeps the +backward-compatible all-advertised behavior; a present list is intersected with current capabilities, +and an empty intersection removes that target from the JEV choice map rather than broadening it. +Direct models and every other Combo strategy bypass this path. The shared Combo editor owns the GUI +checkboxes and `Create JEV Auto` template; no second model picker or JEV-only editor exists. + +JEV setup stays inside those existing shells. A configured `jev-decision` provider Overview exposes +**Create JEV Auto**, which navigates to the registered `models/combos/jev-auto` action hash. +`gui/src/pages/Combos.tsx` owns that one-shot add intent and normalizes the hash when the modal +closes; `ComboWorkspace` and `combo-workspace-add-modal.tsx` reuse the ordinary Combo form and target +editor with a pure template from `combo-workspace-data.ts`. The template includes only currently +available Astra/Sol/Luna rows, remains fully editable, marks the first eligible row as fail-open, +and displays known effort ladders. The JEV provider is hidden from the target picker because it owns +only the decision credential. Existing model rows, default selection, and direct picker behavior are +unchanged; an existing `jev-auto` id or alias disables or reports the quick action. +An existing JEV Combo adds a lazy **Stats** detail tab. It polls only while visible, uses the +management API's JEV projection, and keeps decision-service tokens separate from physical model +tokens. Config remains the ordinary editable Combo form, including per-target effort allowlists. + +`src/usage/jev-stats.ts` owns the parallel content-free JEV projection. Its retained accumulator is +keyed by Combo and stable preset boundary, shares concurrent reads, verifies append identity and LF +digest, clones before folding a suffix, and starts a fresh accumulator after a rebuild-required +scan. It counts physical sends from `attempts[].sendCount`, ignores zero-send rows for fallback +detection, and folds identities beyond 255 concrete rows into one explicit overflow row while +preserving global totals. Up to four JEV projections participate in the same app-owned memory budget +and eviction path as ordinary usage aggregates. Read failure returns HTTP 500 rather than a partial +projection. + The Crusoe preset uses that fixed-key path at `https://api.inference.crusoecloud.com/v1`. Its registry-owned policy admits only public rows whose `architecture.modality` is `text` or `multimodal`, caps the response at 256 KiB and 256 raw rows, and leaves same-named custom diff --git a/tests/adapters/adapter-tool-conformance.test.ts b/tests/adapters/adapter-tool-conformance.test.ts index d25fd7228f8..4b5a102908c 100644 --- a/tests/adapters/adapter-tool-conformance.test.ts +++ b/tests/adapters/adapter-tool-conformance.test.ts @@ -408,6 +408,13 @@ async function restoredStreamInput(adapterId: string, wire: AdapterWire): Promis describe("registry-derived routed tool conformance", () => { test("provider and model-wire configuration ids are registry members", () => { for (const provider of PROVIDER_REGISTRY) { + if (provider.credentialOnly) { + expect(getAdapterDefinition(provider.adapter), provider.id).toBeUndefined(); + expect(provider.liveModels, provider.id).toBe(false); + expect(provider.models, provider.id).toBeUndefined(); + expect(provider.defaultModel, provider.id).toBeUndefined(); + continue; + } expect(getAdapterDefinition(provider.adapter), provider.id).toBeDefined(); for (const value of Object.values(provider.modelWireDefaults ?? {})) { const adapterId = typeof value === "string" ? value : value.wire; diff --git a/tests/cli/cli-headless-parity.test.ts b/tests/cli/cli-headless-parity.test.ts index cfb33592d68..1c149af1af4 100644 --- a/tests/cli/cli-headless-parity.test.ts +++ b/tests/cli/cli-headless-parity.test.ts @@ -710,6 +710,24 @@ describe("headless GUI parity CLI", () => { }); }); + test("combo set accepts the jev strategy without changing target order", async () => { + const runtime = fakeRuntime(); + const code = await handleComboCommand([ + "set", "jev-auto", "--targets", "openai/gpt-6-astra,openai/gpt-5.6-sol", "--strategy", "jev", "--json", + ], runtime.deps); + expect(code).toBe(0); + expect(runtime.requests.find(request => request.method === "PUT")?.body).toMatchObject({ + id: "jev-auto", + combo: { + strategy: "jev", + targets: [ + { provider: "openai", model: "gpt-6-astra" }, + { provider: "openai", model: "gpt-5.6-sol" }, + ], + }, + }); + }); + test("combo set exposes the opt-in force-default policy", async () => { const runtime = fakeRuntime(); expect(await handleComboCommand([ diff --git a/tests/codex-integration/combos.test.ts b/tests/codex-integration/combos.test.ts index 4778be101a8..3d2d008abd5 100644 --- a/tests/codex-integration/combos.test.ts +++ b/tests/codex-integration/combos.test.ts @@ -1195,6 +1195,20 @@ describe("combo failure policy and advancement", () => { }); describe("deterministic combo selection", () => { + test("jev is a valid persisted strategy and its synchronous fail-open is configured order", () => { + const raw = { + strategy: "jev", + targets: [ + { provider: "a", model: "m1" }, + { provider: "b", model: "m2" }, + ], + } as unknown as OcxComboConfig; + expect(comboConfigIssues("auto", raw, baseConfig().providers)).toEqual([]); + expect(normalizeComboConfig(raw).strategy).toBe("jev"); + const config = baseConfig({ combos: { auto: raw } }); + expect(pickComboTarget(config, "auto")?.target.provider).toBe("a"); + }); + test("replacing quota snapshots removes providers omitted from the refresh", () => { const now = Date.now(); replaceCachedProviderQuotas([ @@ -1499,7 +1513,31 @@ describe("combo validation and normalization", () => { { raw: { targets: [null] }, path: ["targets", 0], message: "must be an object" }, { raw: { targets: [{ provider: " ", model: "m1" }] }, path: ["targets", 0, "provider"], message: "is required" }, { raw: { targets: [{ provider: "missing", model: "m1" }] }, path: ["targets", 0, "provider"], message: "not configured" }, + { + raw: { targets: [{ provider: "jev", model: "jev-latest" }] }, + providers: { + ...providers, + jev: { adapter: "jev-decision", baseUrl: "https://api.typesafe.ai/v1/systemone" }, + }, + path: ["targets", 0, "provider"], + message: "decision service and cannot be a model target", + }, { raw: { targets: [{ provider: "a", model: " " }] }, path: ["targets", 0, "model"], message: "is required" }, + { + raw: { targets: [{ provider: "a", model: "m1", reasoningEfforts: [] }] }, + path: ["targets", 0, "reasoningEfforts"], + message: "non-empty array", + }, + { + raw: { targets: [{ provider: "a", model: "m1", reasoningEfforts: ["turbo"] }] }, + path: ["targets", 0, "reasoningEfforts", 0], + message: "low, medium, high, xhigh, max, ultra", + }, + { + raw: { targets: [{ provider: "a", model: "m1", reasoningEfforts: ["low", "low"] }] }, + path: ["targets", 0, "reasoningEfforts", 1], + message: "must not contain duplicates", + }, { raw: VALID_COMBO, providers: { a: { ...providers.a!, disabled: true } }, @@ -1584,6 +1622,13 @@ describe("combo validation and normalization", () => { targets: [{ provider: "a", model: "m1", weight: 2, lastResort: false }], }); expect(normalizeComboConfig({ targets: [{ provider: "a", model: "m1" }] }).defaultEffort).toBeNull(); + const targetReasoningEfforts: OcxComboDefaultEffort[] = ["low", "high"]; + const normalizedTargetEfforts = normalizeComboConfig({ + targets: [{ provider: "a", model: "m1", reasoningEfforts: targetReasoningEfforts }], + }); + expect(normalizedTargetEfforts.targets[0]?.reasoningEfforts).toEqual(["low", "high"]); + targetReasoningEfforts.push("max"); + expect(normalizedTargetEfforts.targets[0]?.reasoningEfforts).toEqual(["low", "high"]); // #5691: both new fields default to the inert value, and only the exact // literal opts in — the same rule reasoningEffortMode follows below. expect(normalizeComboConfig({ diff --git a/tests/fixtures/test-layout-expected.json b/tests/fixtures/test-layout-expected.json index 0fc0dcbfb27..3815981003b 100644 --- a/tests/fixtures/test-layout-expected.json +++ b/tests/fixtures/test-layout-expected.json @@ -1,4 +1,36 @@ { + "release-desktop-scripts.test.ts": "ci-workflows", + "installed-gate-drivers.test.ts": "ci-workflows", + "gui-desktop-sidecar-script.test.ts": "gui", + "standalone-build-script.test.ts": "gui", + "standalone-service.test.ts": "service", + "standalone.test.ts": "lib", + "server-combo-held-response.test.ts": "server", + "key-attribution.test.ts": "usage", + "jev-stats.test.ts": "usage", + "provider-send-path-import.test.ts": "server", + "socks5-fetch.test.ts": "lib", + "socks5-upload-lifecycle.test.ts": "lib", + "provider-egress.test.ts": "lib", + "provider-egress-outbound.test.ts": "providers", + "provider-egress-fetch.test.ts": "responses", + "provider-egress-management-validation.test.ts": "server", + "start-args.test.ts": "cli", + "start-ownership-publication.test.ts": "cli", + "responses-core-modules.test.ts": "responses", + "responses-passthrough-transient-policy.test.ts": "responses", + "responses-spend-ledger-wiring.test.ts": "responses", + "responses-send-budget-errors.test.ts": "responses", + "responses-4546-incident-regression.test.ts": "responses", + "chat-responses-control-integration.test.ts": "responses", + "coding-agent-tool-result-images.test.ts": "adapters", + "cold-spawn-warmup.test.ts": "ci-workflows", + "stepfun-provider.test.ts": "providers", + "warmup-registration.test.ts": "ci-workflows", + "hub-usage.test.ts": "server", + "client-hub-usage.test.ts": "clients", + "cli-usage-hub.test.ts": "cli", + "cli-companion.test.ts": "cli", "abort-idle-deadline.test.ts": "lib", "tool-envelope-echo-whole-line.test.ts": "adapters", "abort-race.test.ts": "adapters", @@ -824,6 +856,8 @@ "key-failover.test.ts": "adapters", "key-login-live-update.test.ts": "oauth", "key-login-preserves-model-costs.test.ts": "oauth", + "jev-decision.test.ts": "routing", + "jev-provider.test.ts": "providers", "keyring-smoke.test.ts": "ci-workflows", "kimi-oauth-identity.test.ts": "providers", "kimi-responses-adjacency.test.ts": "providers", @@ -1257,6 +1291,7 @@ "request-log-attempt-identity.test.ts": "usage", "request-log-conversation.test.ts": "usage", "request-log-estimate-cap.test.ts": "usage", + "request-log-jev.test.ts": "usage", "request-log-nonstream.test.ts": "usage", "request-log-protocol-trace.test.ts": "usage", "request-log-served-model.test.ts": "usage", @@ -1397,6 +1432,7 @@ "server-images-bodyless-content-length.test.ts": "server", "server-images.test.ts": "server", "server-key-failover-e2e.test.ts": "server", + "server-jev-combo-e2e.test.ts": "server", "server-kiro-completion-e2e.test.ts": "server", "server-kiro-oauth-401-replay.test.ts": "server", "server-live-frame-log.test.ts": "server", diff --git a/tests/gui/combo-workspace-data.test.ts b/tests/gui/combo-workspace-data.test.ts index 1c7629d078c..04c5e318be3 100644 --- a/tests/gui/combo-workspace-data.test.ts +++ b/tests/gui/combo-workspace-data.test.ts @@ -11,6 +11,7 @@ import { groupCombos, intersectComboEfforts, isValidComboId, + jevAutoDraft, parseComboList, providerQuotaStatesFromReports, nextProviderQuotaStateExpiration, @@ -104,6 +105,92 @@ function validate( } describe("combo-workspace-data", () => { + test("parse and PUT preserve the JEV strategy", () => { + const parsed = parseComboList({ + combos: [{ + id: "jev-auto", + alias: "jev-auto", + strategy: "jev", + reasoningEffortMode: "adaptive", + targets: [{ provider: "openai", model: "gpt-6-astra" }], + }], + })[0]!; + + expect(parsed.strategy).toBe("jev"); + expect(toPutBody(parsed)).toEqual({ + id: "jev-auto", + combo: { + targets: [{ provider: "openai", model: "gpt-6-astra" }], + strategy: "jev", + defaultEffort: null, + imageInput: "auto", + reasoningEffortMode: "adaptive", + alias: "jev-auto", + }, + }); + }); + + test("parse, dirty tracking, validation, and PUT preserve exact JEV target efforts", () => { + const payload = { + combos: [{ + id: "jev-auto", + strategy: "jev", + targets: [ + { provider: "a", model: "m1", reasoningEfforts: ["low", "high"] }, + { provider: "b", model: "m2" }, + ], + }], + }; + const parsed = parseComboList(payload)[0]!; + + expect(parsed.targets[0]?.reasoningEfforts).toEqual(["low", "high"]); + expect(parsed.targets[1]?.reasoningEfforts).toBeUndefined(); + expect(toPutBody(parsed).combo.targets).toEqual([ + { provider: "a", model: "m1", reasoningEfforts: ["low", "high"] }, + { provider: "b", model: "m2" }, + ]); + + payload.combos[0]!.targets[0]!.reasoningEfforts!.push("max"); + expect(parsed.targets[0]?.reasoningEfforts).toEqual(["low", "high"]); + expect(draftEquals(parsed, { + ...parsed, + targets: [{ ...parsed.targets[0]!, reasoningEfforts: ["low"] }, parsed.targets[1]!], + })).toBe(false); + expect(validate(combo({ + strategy: "jev", + targets: [{ provider: "a", model: "m1", reasoningEfforts: [] }], + }))).toBe("invalidReasoningEfforts"); + }); + + test("JEV Auto template uses the available Astra, Sol, and Luna targets in fail-open order", () => { + const draft = jevAutoDraft([ + { provider: "native-only", id: "gpt-6-astra", reasoningEfforts: ["medium"] }, + { provider: "native-only", id: "gpt-5.6-sol", reasoningEfforts: ["medium"] }, + { provider: "native-only", id: "gpt-5.6-luna", reasoningEfforts: ["medium"] }, + { provider: "openai", id: "gpt-5.6-luna", reasoningEfforts: ["low", "medium"] }, + { provider: "anthropic", id: "claude-sonnet-5" }, + { provider: "openai", id: "gpt-6-astra", reasoningEfforts: ["medium", "high"] }, + { provider: "openai", id: "gpt-5.6-sol", reasoningEfforts: ["low", "medium", "high"] }, + ], new Set(["openai", "anthropic"])); + + expect(draft).toMatchObject({ + id: "jev-auto", + model: "jev-auto", + alias: "jev-auto", + strategy: "jev", + defaultEffort: null, + reasoningEffortMode: "adaptive", + }); + expect(draft.targets.map(({ provider, model }) => ({ provider, model }))).toEqual([ + { provider: "openai", model: "gpt-6-astra" }, + { provider: "openai", model: "gpt-5.6-sol" }, + { provider: "openai", model: "gpt-5.6-luna" }, + ]); + expect(draft.targets.every(target => typeof target.clientKey === "string")).toBe(true); + draft.targets.splice(1, 1); + expect(draft.targets.map(target => target.model)).toEqual(["gpt-6-astra", "gpt-5.6-luna"]); + }); + test("parseComboList accepts normalized GET rows and skips malformed entries", () => { const items = parseComboList({ combos: [ diff --git a/tests/gui/models-workspace-tabs.test.ts b/tests/gui/models-workspace-tabs.test.ts index 2eb8138f2c6..cbf10f5ec76 100644 --- a/tests/gui/models-workspace-tabs.test.ts +++ b/tests/gui/models-workspace-tabs.test.ts @@ -7,6 +7,7 @@ */ import { expect, test, describe } from "bun:test"; import { + JEV_AUTO_CREATE_HASH, MODELS_TAB_HASHES, hashBelongsToPage, readPageFromHash, @@ -45,6 +46,13 @@ describe("nested Models hashes", () => { expect(resolveAppHashChange(hash)).toEqual({ page: "models", replaceTo: null }); } }); + + test("the JEV Auto create action is the only registered Combo deep link", () => { + expect(JEV_AUTO_CREATE_HASH).toBe("models/combos/jev-auto"); + expect(hashBelongsToPage(JEV_AUTO_CREATE_HASH, "models")).toBe(true); + expect(resolveAppHashChange(JEV_AUTO_CREATE_HASH)).toEqual({ page: "models", replaceTo: null }); + expect(readModelsTab(`#${JEV_AUTO_CREATE_HASH}`)).toBe("combos"); + }); }); describe("readModelsTab", () => { diff --git a/tests/providers/jev-provider.test.ts b/tests/providers/jev-provider.test.ts new file mode 100644 index 00000000000..5cda1e576e6 --- /dev/null +++ b/tests/providers/jev-provider.test.ts @@ -0,0 +1,73 @@ +import { describe, expect, test } from "bun:test"; +import { + fetchProviderModelsWithAuth, + refreshingModelsAuthResolver, +} from "../../src/codex/catalog/provider-models"; +import { captureProviderGather } from "../../src/codex/catalog/gather-capture"; +import { deriveKeyLoginMap, providerConfigSeed } from "../../src/providers/derive"; +import { getProviderRegistryEntry } from "../../src/providers/registry"; +import { KEY_LOGIN_PROVIDERS, validateApiKey } from "../../src/oauth/key-providers"; + +describe("TypeSafe JEV provider preset", () => { + test("stores a paid decision-service credential without publishing a model", async () => { + const entry = getProviderRegistryEntry("jev"); + + expect(entry).toMatchObject({ + id: "jev", + label: "TypeSafe JEV", + adapter: "jev-decision", + authKind: "key", + credentialOnly: true, + baseUrl: "https://api.typesafe.ai/v1/systemone", + dashboardUrl: "https://console.typesafe.ai", + liveModels: false, + preserveCustomDestination: true, + apiKeyValidation: "unknown", + }); + expect(entry?.freeTier).not.toBe(true); + expect(entry?.models).toBeUndefined(); + expect(entry?.defaultModel).toBeUndefined(); + + const keyLogin = deriveKeyLoginMap().jev; + expect(keyLogin).toMatchObject({ + adapter: "jev-decision", + baseUrl: "https://api.typesafe.ai/v1/systemone", + dashboardUrl: "https://console.typesafe.ai", + liveModels: false, + }); + expect(keyLogin?.models).toBeUndefined(); + expect(keyLogin?.defaultModel).toBeUndefined(); + + const captured = captureProviderGather( + "jev", + providerConfigSeed(entry!), + refreshingModelsAuthResolver, + ); + const result = await fetchProviderModelsWithAuth( + captured, + 0, + undefined, + refreshingModelsAuthResolver, + ); + expect(result.models).toEqual([]); + expect(result.outcome.state).toBe("authoritative"); + }); + + test("CLI key login accepts JEV without probing a model-catalog endpoint", async () => { + const originalFetchDescriptor = Object.getOwnPropertyDescriptor(globalThis, "fetch")!; + let fetchCalls = 0; + Object.defineProperty(globalThis, "fetch", { + configurable: true, + value: async () => { + fetchCalls += 1; + return new Response(null, { status: 500 }); + }, + }); + try { + expect(await validateApiKey("jev", KEY_LOGIN_PROVIDERS.jev!, "test-jev-key")).toBe("unknown"); + expect(fetchCalls).toBe(0); + } finally { + Object.defineProperty(globalThis, "fetch", originalFetchDescriptor); + } + }); +}); diff --git a/tests/providers/provider-connection-test.test.ts b/tests/providers/provider-connection-test.test.ts index 2f196e60f03..2c6b59e7994 100644 --- a/tests/providers/provider-connection-test.test.ts +++ b/tests/providers/provider-connection-test.test.ts @@ -18,6 +18,8 @@ import { removeTreeWithRetry } from "../helpers/remove-tree"; const TEST_DIR = join(tmpdir(), "ocx-conn-test"); const previousHome = process.env.OPENCODEX_HOME; +const previousTypesafeKey = process.env.TYPESAFE_API_KEY; +const previousJevKey = process.env.JEV_API_KEY; const originalFetch = globalThis.fetch; beforeEach(() => { @@ -32,6 +34,10 @@ afterEach(() => { globalThis.fetch = originalFetch; if (previousHome === undefined) delete process.env.OPENCODEX_HOME; else process.env.OPENCODEX_HOME = previousHome; + if (previousTypesafeKey === undefined) delete process.env.TYPESAFE_API_KEY; + else process.env.TYPESAFE_API_KEY = previousTypesafeKey; + if (previousJevKey === undefined) delete process.env.JEV_API_KEY; + else process.env.JEV_API_KEY = previousJevKey; removeTreeWithRetry(TEST_DIR); }); @@ -59,6 +65,73 @@ async function probe(config: OcxConfig, name: string): Promise<{ status: number; } describe("POST /api/providers/test (WP040 connectivity probe)", () => { + test("JEV reports a missing key without attempting a generic static-catalog probe", async () => { + delete process.env.TYPESAFE_API_KEY; + delete process.env.JEV_API_KEY; + let fetches = 0; + globalThis.fetch = (async () => { + fetches += 1; + return Response.json({}); + }) as typeof fetch; + const config = baseConfig({ + jev: { + adapter: "jev-decision", + baseUrl: "https://api.typesafe.ai/v1/systemone", + authMode: "key", + liveModels: false, + }, + }); + + const { body } = await probe(config, "jev"); + + expect(body).toMatchObject({ + ok: false, + error: "TypeSafe JEV API key is not configured", + }); + expect(typeof body.latencyMs).toBe("number"); + expect(fetches).toBe(0); + }); + + test("JEV accepts a bounded decision probe and never echoes an upstream failure body", async () => { + const seen: Array<{ url: string; authorization: string | null; body: unknown }> = []; + globalThis.fetch = (async (input, init) => { + seen.push({ + url: String(input), + authorization: new Headers(init?.headers).get("authorization"), + body: JSON.parse(String(init?.body)), + }); + return Response.json({ + answers: { route: { choice: "jev/probe:none", confidence: 0.9 } }, + }); + }) as typeof fetch; + const config = baseConfig({ + jev: { + adapter: "jev-decision", + baseUrl: "https://api.typesafe.ai/v1/systemone", + authMode: "key", + apiKey: "typesafe-probe-key", + liveModels: false, + }, + }); + + const connected = await probe(config, "jev"); + expect(connected.body).toMatchObject({ + ok: true, + message: "Connected. TypeSafe JEV answered a decision probe.", + }); + expect(seen).toHaveLength(1); + expect(seen[0]?.url).toBe("https://api.typesafe.ai/v1/systemone"); + expect(seen[0]?.authorization).toBe("Bearer typesafe-probe-key"); + expect(seen[0]?.body).toMatchObject({ model: "jev-latest" }); + + globalThis.fetch = (async () => new Response("TOP_SECRET_PROVIDER_BODY", { status: 402 })) as typeof fetch; + (config.providers.jev as typeof config.providers.jev & { fetch?: typeof fetch }).fetch = globalThis.fetch; + const rejected = await probe(config, "jev"); + expect(rejected.body).toMatchObject({ ok: false }); + expect(String(rejected.body.error)).toContain("http"); + expect(JSON.stringify(rejected.body)).not.toContain("TOP_SECRET_PROVIDER_BODY"); + }); + test("Devin probes its snapshot's EU tenant destination", async () => { const baseUrl = "https://eu.windsurf.com/_route/api_server"; const urls: string[] = []; diff --git a/tests/providers/provider-registry-parity.test.ts b/tests/providers/provider-registry-parity.test.ts index 8feb629806c..cf5c98979fe 100644 --- a/tests/providers/provider-registry-parity.test.ts +++ b/tests/providers/provider-registry-parity.test.ts @@ -41,7 +41,7 @@ function nativeTemplate(): Record { const EXPECTED_KEY_PROVIDER_IDS = [ "anthropic-apikey", "openai-apikey", "meta-model", "umans", "opencode-go", "neuralwatt", "openrouter", "cline-pass", "cline", "orcarouter", "packycode", "bizrouter", "groq", "google", "google-vertex", "azure-openai", - "deepseek", "cerebras", "chutes", "deepinfra", "hyperbolic", "nscale", "vultr", "baseten", "commandcode", "sambanova", "nebius", "crusoe", "digitalocean", "scaleway", "featherless", "novita", "together", "fireworks", "firepass", "moonshot", + "deepseek", "cerebras", "chutes", "deepinfra", "hyperbolic", "nscale", "vultr", "jev", "baseten", "commandcode", "sambanova", "nebius", "crusoe", "digitalocean", "scaleway", "featherless", "novita", "together", "fireworks", "firepass", "moonshot", "huggingface", "nvidia", "venice", "zai", "zhipu-bigmodel", "zhipu-bigmodel-coding", "zhipu-bigmodel-responses", "nanogpt", "synthetic", "siliconflow", "qwen-cloud", "tencent-coding-plan", "volcengine", "volcengine-coding-plan", "volcengine-agent-plan", "qianfan", "alibaba", "alibaba-token-plan", "alibaba-token-plan-intl", "parallel", "zenmux", "litellm", "ollama-cloud", "mistral", "minimax", "minimax-cn", "kimi-code", "opencode-zen", "vercel-ai-gateway", "opper", diff --git a/tests/routing/jev-decision.test.ts b/tests/routing/jev-decision.test.ts new file mode 100644 index 00000000000..d0a4a61f94b --- /dev/null +++ b/tests/routing/jev-decision.test.ts @@ -0,0 +1,546 @@ +import { describe, expect, test } from "bun:test"; +import { + buildJevRouteQuestion, + buildJevState, + JEV_API_URL, + JEV_MODEL, + parseJevDecision, + resolveJevDecision, + type JevCandidate, + type ResolveJevDecisionOptions, +} from "../../src/combos/jev"; +import type { OcxConfig } from "../../src/types"; + +const candidates: JevCandidate[] = [ + { + key: "openai/gpt-6-astra", + provider: "openai", + model: "gpt-6-astra", + reasoningEfforts: ["medium", "high"], + }, + { + key: "openai/gpt-5.6-sol", + provider: "openai", + model: "gpt-5.6-sol", + reasoningEfforts: ["low"], + }, +]; + +describe("JEV bounded decision state", () => { + test("keeps a 500-character head/tail ask after removing machine envelopes", () => { + const ask = `${"h".repeat(380)}private machine state${"t".repeat(380)}`; + const state = buildJevState({ input: ask }) as { + task: string; + signals: Record; + step: Record; + }; + + expect(state.task).toHaveLength(500); + expect(state.task.startsWith("h".repeat(320))).toBeTrue(); + expect(state.task).toContain("\n[...]\n"); + expect(state.task.endsWith("t".repeat(171))).toBeTrue(); + expect(JSON.stringify(state)).not.toContain("private machine state"); + expect(state.signals).toEqual({ has_image: false, tool_history: false }); + expect(state.step).toEqual({ type: "user_turn" }); + }); + + test("removes a protected envelope whose closing tag falls outside the bounded task sample", () => { + const privateEnvelope = `PRIVATE_MACHINE_STATE${"x".repeat(250_000)}`; + const state = buildJevState({ input: `${privateEnvelope}${"u".repeat(250_000)}` }) as { + task: string; + }; + + expect(state.task).toHaveLength(500); + expect(state.task.startsWith("u".repeat(320))).toBeTrue(); + expect(state.task.endsWith("u".repeat(171))).toBeTrue(); + expect(state.task).not.toContain("PRIVATE_MACHINE_STATE"); + expect(state.task).not.toContain("environment_context"); + }); + + test("samples a large task containing harmless markup without per-character scanning", () => { + const input = `${"x".repeat(10_000_000)}<${"x".repeat(10_000_000)}`; + + const state = buildJevState({ input }) as { task: string }; + + expect(state.task).toHaveLength(500); + expect(state.task.startsWith("x".repeat(320))).toBeTrue(); + expect(state.task.endsWith("x".repeat(173))).toBeTrue(); + }); + + test("captures only bounded recent assistant and tool evidence without arguments or image data", () => { + const state = buildJevState({ + input: [ + { + role: "user", + content: [ + { type: "input_text", text: "Please continue from the tool result." }, + { type: "input_image", image_url: "data:image/png;base64,TOP_SECRET_IMAGE" }, + ], + }, + { role: "assistant", content: [{ type: "output_text", text: `old-${"a".repeat(300)}` }] }, + { + type: "function_call", + call_id: "call-1", + name: `shell_${"n".repeat(200)}`, + arguments: "TOP_SECRET_ARGUMENTS", + }, + { + type: "function_call_output", + call_id: "call-1", + output: `discard-${"x".repeat(200)}-${"z".repeat(600)}`, + }, + ], + }) as { + task: string; + previous_assistant: string; + signals: Record; + step: { + type: string; + last_tool_output_tail: string; + tool_call: { name: string }; + }; + }; + + expect(state.task).toBe("Please continue from the tool result."); + expect(state.previous_assistant).toHaveLength(240); + expect(state.previous_assistant).toBe("a".repeat(240)); + expect(state.signals).toEqual({ has_image: true, tool_history: true }); + expect(state.step.type).toBe("tool_step"); + expect(state.step.last_tool_output_tail).toHaveLength(520); + expect(state.step.last_tool_output_tail).toBe("z".repeat(520)); + expect(state.step.tool_call.name).toHaveLength(160); + expect(JSON.stringify(state)).not.toContain("TOP_SECRET_ARGUMENTS"); + expect(JSON.stringify(state)).not.toContain("TOP_SECRET_IMAGE"); + }); + + test("removes protected machine envelopes from assistant and tool-output tails", () => { + const state = buildJevState({ + input: [ + { role: "user", content: "Continue from the latest result." }, + { + role: "assistant", + content: "Visible beforeASSISTANT_MACHINE_SECRETvisible after", + }, + { + type: "custom_tool_call_output", + output: "Tool beforeTOOL_MACHINE_SECRETtool after", + }, + ], + }) as { + previous_assistant: string; + step: { last_tool_output_tail: string }; + }; + + expect(state.previous_assistant).toBe("Visible before\nvisible after"); + expect(state.step.last_tool_output_tail).toBe("Tool before\ntool after"); + expect(JSON.stringify(state)).not.toContain("ASSISTANT_MACHINE_SECRET"); + expect(JSON.stringify(state)).not.toContain("TOOL_MACHINE_SECRET"); + }); + + test("salvages an envelope-only active goal but drops catalog-only envelopes", () => { + expect(buildJevState({ + input: 'Keep implementing JEV.', + })).toMatchObject({ task: "Keep implementing JEV." }); + expect(buildJevState({ + input: "plugin catalog", + })).toMatchObject({ task: "" }); + }); +}); + +describe("JEV route question", () => { + test("constructs literal joint target-effort choices with known and neutral profiles", () => { + const question = buildJevRouteQuestion([ + ...candidates, + { + key: "custom/other-model", + provider: "custom", + model: "other-model", + reasoningEfforts: [], + }, + { + key: "openai/gpt-5.6-luna", + provider: "openai", + model: "gpt-5.6-luna", + reasoningEfforts: ["medium"], + }, + ]) as { + route: { + type: string; + instructions: { model_profiles: Record }; + criteria: Record; + }; + }; + + expect(question.route.type).toBe("choice"); + expect(question.route.criteria).toEqual({ + "openai/gpt-6-astra:medium": { + target: "openai/gpt-6-astra", provider: "openai", model: "gpt-6-astra", reasoning_effort: "medium", + }, + "openai/gpt-6-astra:high": { + target: "openai/gpt-6-astra", provider: "openai", model: "gpt-6-astra", reasoning_effort: "high", + }, + "openai/gpt-5.6-sol:low": { + target: "openai/gpt-5.6-sol", provider: "openai", model: "gpt-5.6-sol", reasoning_effort: "low", + }, + "custom/other-model:none": { + target: "custom/other-model", provider: "custom", model: "other-model", reasoning_effort: null, + }, + "openai/gpt-5.6-luna:medium": { + target: "openai/gpt-5.6-luna", provider: "openai", model: "gpt-5.6-luna", reasoning_effort: "medium", + }, + }); + expect(question.route.instructions.model_profiles["openai/gpt-6-astra"]).toContain("Most capable"); + expect(question.route.instructions.model_profiles["openai/gpt-5.6-sol"]).toContain("Higher-capacity"); + expect(question.route.instructions.model_profiles["openai/gpt-5.6-luna"]).toContain("cost-optimized"); + expect(question.route.instructions.model_profiles["custom/other-model"]).toContain("unspecified"); + }); +}); + +describe("JEV decision parser", () => { + test("accepts a valid complete distribution and extracts numeric diagnostics only", () => { + const payload = { + answers: { + route: { + choice: "openai/gpt-6-astra:high", + confidence: 0.83, + probabilities: { + "openai/gpt-6-astra:medium": 0.1, + "openai/gpt-6-astra:high": 0.7, + "openai/gpt-5.6-sol:low": 0.2, + }, + }, + }, + usage: { + input_tokens: 12, + output_tokens: 3, + inputTokens: 0, + secret: "not copied", + cached: true, + nested: { tokens: 99 }, + bad: Number.POSITIVE_INFINITY, + }, + }; + + expect(parseJevDecision(payload, candidates)).toEqual({ + targetKey: "openai/gpt-6-astra", + effort: "high", + confidence: 0.83, + chosenProbability: 0.7, + usage: { input_tokens: 12, output_tokens: 3, inputTokens: 0 }, + }); + }); + + test("treats malformed confidence as absent without weakening the route choice", () => { + expect(parseJevDecision({ + answers: { route: { choice: "openai/gpt-5.6-sol:low", confidence: 2 } }, + }, candidates)).toEqual({ + targetKey: "openai/gpt-5.6-sol", + effort: "low", + }); + }); + + test("rejects out-of-allowlist choices and every inconsistent probability shape", () => { + const complete = { + "openai/gpt-6-astra:medium": 0.1, + "openai/gpt-6-astra:high": 0.7, + "openai/gpt-5.6-sol:low": 0.2, + }; + const answer = (choice: string, probabilities?: Record) => ({ + answers: { route: { choice, ...(probabilities ? { probabilities } : {}) } }, + }); + + expect(() => parseJevDecision(answer("attacker/model:high"), candidates)).toThrow(); + expect(() => parseJevDecision(answer("openai/gpt-6-astra:high", { + "openai/gpt-6-astra:high": 1, + }), candidates)).toThrow(); + expect(() => parseJevDecision(answer("openai/gpt-6-astra:high", { + ...complete, "openai/gpt-6-astra:medium": -0.1, + }), candidates)).toThrow(); + expect(() => parseJevDecision(answer("openai/gpt-6-astra:high", { + ...complete, "openai/gpt-6-astra:high": 0.4, + }), candidates)).toThrow(); + expect(() => parseJevDecision(answer("openai/gpt-5.6-sol:low", complete), candidates)).toThrow(); + expect(() => parseJevDecision({ answers: null }, candidates)).toThrow(); + }); +}); + +type JevPost = NonNullable; + +function jevConfig(apiKey?: string): OcxConfig { + return { + port: 0, + defaultProvider: "openai", + providers: { + openai: { + adapter: "openai-responses", + baseUrl: "https://chatgpt.com/backend-api/codex", + authMode: "forward", + }, + jev: { + adapter: "jev-decision", + baseUrl: JEV_API_URL, + authMode: "key", + liveModels: false, + ...(apiKey ? { apiKey } : {}), + }, + }, + }; +} + +const fallback = { targetKey: candidates[0]!.key, effort: "medium" as const }; +const decisionBody = { input: "Choose carefully." }; +const validPayload = { + answers: { route: { choice: "openai/gpt-5.6-sol:low", confidence: 0.75 } }, + usage: { input_tokens: 4, output_tokens: 1, secret: "drop" }, +}; + +describe("JEV decision client", () => { + test("posts one bounded decision request with the configured credential", async () => { + const calls: Array<{ + name: string; + provider: unknown; + url: string; + init: RequestInit; + dependencies: Parameters[4]; + }> = []; + const post = (async (name, provider, url, init, dependencies) => { + calls.push({ name, provider, url, init, dependencies }); + return Response.json(validPayload); + }) as JevPost; + const ticks = [100, 127]; + + const decision = await resolveJevDecision({ + body: { input: "Choose carefully." }, + candidates, + fallback, + config: jevConfig("typesafe-secret"), + post, + now: () => ticks.shift()!, + }); + + expect(decision).toEqual({ + targetKey: "openai/gpt-5.6-sol", + effort: "low", + gate: "apply", + latencyMs: 27, + confidence: 0.75, + usage: { input_tokens: 4, output_tokens: 1 }, + }); + expect(calls).toHaveLength(1); + expect(calls[0]!.name).toBe("jev"); + expect(calls[0]!.url).toBe(JEV_API_URL); + expect(new Headers(calls[0]!.init.headers).get("authorization")).toBe("Bearer typesafe-secret"); + expect(new Headers(calls[0]!.init.headers).get("content-type")).toBe("application/json"); + expect(calls[0]!.init.signal).toBeInstanceOf(AbortSignal); + expect(calls[0]!.dependencies?.isCanonicalUrl?.("jev", JEV_API_URL)).toBeTrue(); + expect(calls[0]!.dependencies?.isCanonicalUrl?.("jev", "https://other.example/")).toBeFalse(); + expect(JSON.parse(String(calls[0]!.init.body))).toEqual({ + model: JEV_MODEL, + state: buildJevState({ input: "Choose carefully." }), + questions: buildJevRouteQuestion(candidates), + }); + }); + + test("resolves environment references and supports TypeSafe and provider-derived key fallbacks", async () => { + const previousTypesafe = process.env.TYPESAFE_API_KEY; + const previousJev = process.env.JEV_API_KEY; + process.env.TYPESAFE_API_KEY = "environment-secret"; + delete process.env.JEV_API_KEY; + const observed: string[] = []; + const post = (async (_name, _provider, _url, init) => { + observed.push(new Headers(init.headers).get("authorization") ?? ""); + return Response.json(validPayload); + }) as JevPost; + try { + await resolveJevDecision({ + body: decisionBody, candidates, fallback, config: jevConfig("${TYPESAFE_API_KEY}"), post, + }); + const config = jevConfig(); + delete config.providers.jev; + await resolveJevDecision({ body: decisionBody, candidates, fallback, config, post }); + delete process.env.TYPESAFE_API_KEY; + process.env.JEV_API_KEY = "provider-derived-secret"; + await resolveJevDecision({ body: decisionBody, candidates, fallback, config, post }); + } finally { + if (previousTypesafe === undefined) delete process.env.TYPESAFE_API_KEY; + else process.env.TYPESAFE_API_KEY = previousTypesafe; + if (previousJev === undefined) delete process.env.JEV_API_KEY; + else process.env.JEV_API_KEY = previousJev; + } + expect(observed).toEqual([ + "Bearer environment-secret", + "Bearer environment-secret", + "Bearer provider-derived-secret", + ]); + }); + + test("fails open without a key or usable choices and never calls TypeSafe", async () => { + const previousTypesafe = process.env.TYPESAFE_API_KEY; + const previousJev = process.env.JEV_API_KEY; + delete process.env.TYPESAFE_API_KEY; + delete process.env.JEV_API_KEY; + let calls = 0; + const post = (async () => { + calls += 1; + return Response.json(validPayload); + }) as JevPost; + try { + expect(await resolveJevDecision({ + body: {}, candidates, fallback, config: jevConfig(), post, + })).toMatchObject({ ...fallback, gate: "missing_key" }); + expect(await resolveJevDecision({ + body: {}, candidates: [], fallback, config: jevConfig("secret"), post, + })).toMatchObject({ ...fallback, gate: "no_choices" }); + } finally { + if (previousTypesafe === undefined) delete process.env.TYPESAFE_API_KEY; + else process.env.TYPESAFE_API_KEY = previousTypesafe; + if (previousJev === undefined) delete process.env.JEV_API_KEY; + else process.env.JEV_API_KEY = previousJev; + } + expect(calls).toBe(0); + }); + + test("fails open without calling TypeSafe when no safe decision state remains", async () => { + let calls = 0; + const post = (async () => { + calls += 1; + return Response.json(validPayload); + }) as JevPost; + + const decision = await resolveJevDecision({ + body: { input: "plugin catalog" }, + candidates, + fallback, + config: jevConfig("secret"), + post, + }); + + expect(decision).toMatchObject({ ...fallback, gate: "no_state" }); + expect(calls).toBe(0); + }); + + test("fails open before TypeSafe when the serialized decision request is too large", async () => { + const largeCandidates: JevCandidate[] = Array.from({ length: 24 }, (_, index) => { + const provider = `provider-${index}-${"p".repeat(110)}`; + const model = `model-${index}-${"m".repeat(110)}`; + return { + key: `${provider}/${model}`, + provider, + model, + reasoningEfforts: ["low", "medium", "high", "xhigh", "max", "ultra"], + }; + }); + const largeFallback = { targetKey: largeCandidates[0]!.key, effort: "medium" as const }; + let calls = 0; + const post = (async () => { + calls += 1; + return Response.json(validPayload); + }) as JevPost; + + const decision = await resolveJevDecision({ + body: decisionBody, + candidates: largeCandidates, + fallback: largeFallback, + config: jevConfig("secret"), + post, + }); + + expect(decision).toMatchObject({ ...largeFallback, gate: "invalid" }); + expect(calls).toBe(0); + }); + + test("bounds candidate count and identifier length before building a decision request", async () => { + const tooMany: JevCandidate[] = Array.from({ length: 65 }, (_, index) => ({ + key: `p/m-${index}`, + provider: "p", + model: `m-${index}`, + reasoningEfforts: ["low"], + })); + const longModel = "m".repeat(513); + const tooLong: JevCandidate[] = [{ + key: `p/${longModel}`, + provider: "p", + model: longModel, + reasoningEfforts: ["low"], + }]; + let calls = 0; + const post = (async () => { + calls += 1; + return Response.json(validPayload); + }) as JevPost; + + for (const boundedCandidates of [tooMany, tooLong]) { + const boundedFallback = { targetKey: boundedCandidates[0]!.key, effort: "low" as const }; + const decision = await resolveJevDecision({ + body: decisionBody, + candidates: boundedCandidates, + fallback: boundedFallback, + config: jevConfig("secret"), + post, + }); + expect(decision).toMatchObject({ ...boundedFallback, gate: "invalid" }); + } + expect(calls).toBe(0); + }); + + test("classifies redirects, HTTP errors, oversized bodies, invalid JSON, invalid choices, and network failures", async () => { + const oversized = "x".repeat(70_000); + const cases: Array<{ gate: string; post: JevPost }> = [ + { + gate: "redirect", + post: (async () => new Response(null, { status: 302, headers: { location: "https://other.example/" } })) as JevPost, + }, + { gate: "http", post: (async () => new Response("private upstream detail", { status: 402 })) as JevPost }, + { gate: "malformed", post: (async () => new Response(oversized)) as JevPost }, + { gate: "malformed", post: (async () => new Response("not-json")) as JevPost }, + { + gate: "invalid", + post: (async () => Response.json({ answers: { route: { choice: "attacker/model:max" } } })) as JevPost, + }, + { gate: "network", post: (async () => { throw new TypeError("private network detail"); }) as JevPost }, + ]; + + for (const fixture of cases) { + const decision = await resolveJevDecision({ + body: decisionBody, candidates, fallback, config: jevConfig("secret"), post: fixture.post, + }); + expect(decision).toMatchObject({ ...fallback, gate: fixture.gate }); + expect(JSON.stringify(decision)).not.toContain("private"); + } + }); + + test("uses a four-second timeout and preserves caller cancellation by identity", async () => { + const originalTimeoutDescriptor = Object.getOwnPropertyDescriptor(AbortSignal, "timeout")!; + const timeoutReasons: number[] = []; + Object.defineProperty(AbortSignal, "timeout", { + configurable: true, + value(ms: number) { + timeoutReasons.push(ms); + const controller = new AbortController(); + controller.abort(new DOMException("deadline", "TimeoutError")); + return controller.signal; + }, + }); + const abortingPost = (async (_name, _provider, _url, init) => { + throw init.signal?.reason; + }) as JevPost; + try { + expect(await resolveJevDecision({ + body: decisionBody, candidates, fallback, config: jevConfig("secret"), post: abortingPost, + })).toMatchObject({ ...fallback, gate: "timeout" }); + } finally { + Object.defineProperty(AbortSignal, "timeout", originalTimeoutDescriptor); + } + expect(timeoutReasons).toEqual([4_000]); + + const controller = new AbortController(); + const reason = new DOMException("caller stopped", "AbortError"); + const callerPost = (async (_name, _provider, _url, init) => new Promise((_resolve, reject) => { + init.signal?.addEventListener("abort", () => reject(init.signal?.reason), { once: true }); + controller.abort(reason); + })) as JevPost; + await expect(resolveJevDecision({ + body: decisionBody, candidates, fallback, config: jevConfig("secret"), post: callerPost, signal: controller.signal, + })).rejects.toBe(reason); + }); +}); diff --git a/tests/server/api-usage.test.ts b/tests/server/api-usage.test.ts index 9615c622ae5..03ad38aabf5 100644 --- a/tests/server/api-usage.test.ts +++ b/tests/server/api-usage.test.ts @@ -109,6 +109,96 @@ afterEach(() => { }); describe("GET /api/usage", () => { + test("projects JEV decisions, picks and physical model tokens for one combo", async () => { + const now = Date.now(); + const decision = { + version: 1, + comboId: "jev-auto", + selected: { provider: "openai", model: "gpt-6-astra", effort: "high" }, + gate: "apply", + latencyMs: 25, + confidence: 0.9, + usage: { inputTokens: 9, outputTokens: 2, totalTokens: 11 }, + }; + const rows = [ + { + requestId: "jev-one", + timestamp: now - 1_000, + provider: "combo", + model: "jev-auto", + status: 200, + durationMs: 50, + usageStatus: "reported", + jevDecision: decision, + attempts: [{ + ordinal: 1, + provider: "openai", + model: "gpt-6-astra", + adapter: "openai-responses", + status: 200, + durationMs: 40, + sendCount: 1, + recoveryKinds: [], + usageStatus: "reported", + usage: { inputTokens: 100, outputTokens: 20, reasoningOutputTokens: 7 }, + totalTokens: 120, + }], + }, + { + requestId: "other-combo", + timestamp: now - 500, + provider: "combo", + model: "other", + status: 200, + durationMs: 10, + usageStatus: "unreported", + jevDecision: { ...decision, comboId: "other" }, + }, + ]; + writeFileSync(join(testDir, "usage.jsonl"), `${rows.map(row => JSON.stringify(row)).join("\n")}\n`); + const server = startServer(0); + try { + const response = await fetch(new URL("/api/usage?jev=1&comboId=jev-auto&range=30d", server.url)); + expect(response.status).toBe(200); + const stats = await response.json(); + expect(stats).toMatchObject({ + range: "30d", + comboId: "jev-auto", + summary: { + decisions: 1, + appliedDecisions: 1, + failOpenDecisions: 0, + modelAttempts: 1, + measuredModelAttempts: 1, + modelInputTokens: 100, + modelOutputTokens: 20, + modelReasoningTokens: 7, + modelTotalTokens: 120, + decisionInputTokens: 9, + decisionOutputTokens: 2, + decisionTotalTokens: 11, + }, + gates: [{ gate: "apply", decisions: 1 }], + models: [{ + provider: "openai", + model: "gpt-6-astra", + picks: 1, + attempts: 1, + totalTokens: 120, + efforts: [{ effort: "high", picks: 1 }], + }], + historyTruncated: false, + entriesTruncated: false, + }); + expect(stats.generatedAt).toBeGreaterThanOrEqual(now); + const invalid = await fetch(new URL(`/api/usage?jev=1&comboId=${"x".repeat(129)}`, server.url)); + expect(invalid.status).toBe(400); + expect(await invalid.json()).toEqual({ error: "invalid comboId" }); + } finally { + await server.stop(true); + } + }); + test("custom bounds override presets while preserving surface, filters and accounts", async () => { const since = new Date(2026, 1, 10, 12).getTime(); const until = since + 3_600_000; diff --git a/tests/server/server-jev-combo-e2e.test.ts b/tests/server/server-jev-combo-e2e.test.ts new file mode 100644 index 00000000000..cdb7ef67a84 --- /dev/null +++ b/tests/server/server-jev-combo-e2e.test.ts @@ -0,0 +1,514 @@ +import { afterEach, beforeEach, describe, expect, test } from "bun:test"; +import { + clearComboSelectionState, + clearComboTargetCooldowns, + coolComboTarget, +} from "../../src/combos"; +import { catalogModelSlug, clearGatherRoutedModelsInflight, gatherRoutedModels } from "../../src/codex/catalog"; +import { createTranslatorBudget } from "../../src/lib/translator-budget"; +import { executeComboResponses } from "../../src/server/responses/core-combo"; +import type { ResponsesDispatchers } from "../../src/server/responses/core-options"; +import type { RequestLogContext } from "../../src/server/request-log"; +import type { OcxConfig, OcxProviderConfig } from "../../src/types"; + +const JEV_URL = "https://api.typesafe.ai/v1/systemone"; +const targetRows = [ + { provider: "astra", model: "gpt-6-astra" }, + { provider: "sol", model: "gpt-5.6-sol" }, + { provider: "luna", model: "gpt-5.6-luna" }, +] as const; + +const previousTypesafeKey = process.env.TYPESAFE_API_KEY; +const previousJevKey = process.env.JEV_API_KEY; + +beforeEach(() => { + clearComboSelectionState(); + clearComboTargetCooldowns(); + clearGatherRoutedModelsInflight(); +}); + +afterEach(() => { + clearComboSelectionState(); + clearComboTargetCooldowns(); + clearGatherRoutedModelsInflight(); + if (previousTypesafeKey === undefined) delete process.env.TYPESAFE_API_KEY; + else process.env.TYPESAFE_API_KEY = previousTypesafeKey; + if (previousJevKey === undefined) delete process.env.JEV_API_KEY; + else process.env.JEV_API_KEY = previousJevKey; +}); + +function modelProvider(model: string, efforts: string[]): OcxProviderConfig { + return { + adapter: "openai-chat", + baseUrl: `https://${model}.example.test/v1`, + authMode: "key", + apiKey: `key-${model}`, + liveModels: false, + models: [model], + modelContextWindows: { [model]: 258_400 }, + modelMaxInputTokens: { [model]: 219_640 }, + modelInputModalities: { [model]: ["text", "image"] }, + modelReasoningEfforts: { [model]: efforts }, + }; +} + +function makeConfig(options: { + jevFetch?: typeof fetch; + jevKey?: string | null; + providerOverrides?: Partial>>; +} = {}): OcxConfig { + const jev: OcxProviderConfig = { + adapter: "jev-decision", + baseUrl: JEV_URL, + authMode: "key", + liveModels: false, + ...(options.jevKey === null ? {} : { apiKey: options.jevKey ?? "typesafe-test-key" }), + ...(options.jevFetch ? { fetch: options.jevFetch } : {}), + }; + const astra = { ...modelProvider("gpt-6-astra", ["low", "medium", "high", "xhigh", "max"]), ...options.providerOverrides?.astra }; + const sol = { ...modelProvider("gpt-5.6-sol", ["low", "medium", "high", "xhigh", "max"]), ...options.providerOverrides?.sol }; + const luna = { ...modelProvider("gpt-5.6-luna", ["low", "medium", "high"]), ...options.providerOverrides?.luna }; + return { + port: 0, + defaultProvider: "astra", + providers: { jev, astra, sol, luna }, + combos: { + auto: { + alias: "jev-auto", + displayName: "JEV Auto", + strategy: "jev", + reasoningEffortMode: "adaptive", + targets: targetRows.map(target => ({ ...target })), + }, + }, + }; +} + +type ChildHandler = ( + body: Record, + logCtx: RequestLogContext, + options?: Parameters[3], +) => Response | Promise; + +function dispatchers(handler: ChildHandler): ResponsesDispatchers { + return { + async handleResponses(request, _config, logCtx, options) { + return handler(await request.json() as Record, logCtx, options); + }, + async handleComboResponses() { + throw new Error("nested combo dispatch is not expected"); + }, + }; +} + +async function execute( + config: OcxConfig, + handler: ChildHandler, + raw: Record = {}, + signal?: AbortSignal, + parentLogCtx: RequestLogContext = { model: "", provider: "" }, +): Promise { + const body = { + model: "jev-auto", + input: "Implement the next step.", + stream: false, + ...raw, + }; + const request = new Request("http://127.0.0.1/v1/responses", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify(body), + }); + const budget = createTranslatorBudget(); + try { + return await executeComboResponses( + request, + body, + "auto", + config, + parentLogCtx, + { translatorBudget: budget, ...(signal ? { abortSignal: signal } : {}) }, + dispatchers(handler), + ); + } finally { + budget.dispose(); + } +} + +function choiceFetch( + choice: string, + seen: Array> = [], +): typeof fetch { + return (async (_input, init) => { + const payload = JSON.parse(String(init?.body)) as Record; + seen.push(payload); + return Response.json({ answers: { route: { choice, confidence: 0.8 } } }); + }) as typeof fetch; +} + +function success(model: string): Response { + return Response.json({ id: `resp-${model}`, object: "response", status: "completed", model, output: [] }); +} + +describe("JEV Combo runtime", () => { + test("records the selected target and JEV usage on the parent request", async () => { + const config = makeConfig({ + jevFetch: (async () => Response.json({ + answers: { route: { choice: "sol/gpt-5.6-sol:high", confidence: 0.8 } }, + usage: { input_tokens: 11, output_tokens: 2 }, + })) as typeof fetch, + }); + const parentLogCtx: RequestLogContext = { model: "", provider: "" }; + + const response = await execute( + config, + body => success(String(body.model)), + {}, + undefined, + parentLogCtx, + ); + + expect(response.status).toBe(200); + expect(parentLogCtx.jevDecision).toEqual({ + version: 1, + comboId: "auto", + selected: { provider: "sol", model: "gpt-5.6-sol", effort: "high" }, + gate: "apply", + latencyMs: expect.any(Number), + confidence: 0.8, + usage: { inputTokens: 11, outputTokens: 2, totalTokens: 13 }, + }); + }); + + test("routes the initial call to JEV's allowlisted target and keeps direct/catalog rows", async () => { + const jevRequests: Array> = []; + const config = makeConfig({ + jevFetch: choiceFetch("sol/gpt-5.6-sol:high", jevRequests), + }); + const childBodies: Record[] = []; + + const response = await execute(config, body => { + childBodies.push(body); + return success(String(body.model)); + }, { + reasoning: { effort: "low", summary: "auto" }, + reasoning_effort: "max", + thinking_budget: 8_000, + thinking: { type: "enabled", budget_tokens: 8_000 }, + service_tier: "priority", + }); + + expect(response.status).toBe(200); + expect(childBodies).toEqual([expect.objectContaining({ + model: "sol/gpt-5.6-sol", + reasoning: { effort: "high", summary: "auto" }, + })]); + expect(childBodies[0]).not.toHaveProperty("service_tier"); + expect(childBodies[0]).not.toHaveProperty("reasoning_effort"); + expect(childBodies[0]).not.toHaveProperty("thinking_budget"); + expect(childBodies[0]).not.toHaveProperty("thinking"); + expect(jevRequests).toHaveLength(1); + expect(jevRequests[0]).toMatchObject({ model: "jev-latest" }); + + const catalogConfig = makeConfig(); + delete (catalogConfig.providers.jev as OcxProviderConfig & { fetch?: typeof fetch }).fetch; + const models = await gatherRoutedModels(catalogConfig); + expect(models.filter(model => model.provider === "combo").map(catalogModelSlug)).toEqual(["jev-auto"]); + for (const target of targetRows) { + expect(models.some(model => model.provider === target.provider && model.id === target.model)).toBeTrue(); + } + expect(models.some(model => model.provider === "jev")).toBeFalse(); + }); + + test("replaces caller sentinel efforts with JEV's selected effort", async () => { + for (const sentinel of ["none", "minimal"]) { + const config = makeConfig({ jevFetch: choiceFetch("sol/gpt-5.6-sol:high") }); + const childBodies: Record[] = []; + + const response = await execute(config, body => { + childBodies.push(body); + return success(String(body.model)); + }, { + reasoning: { effort: sentinel, summary: "auto" }, + reasoning_effort: sentinel, + thinking_budget: 8_000, + thinking: { type: "enabled", budget_tokens: 8_000 }, + }); + + expect(response.status).toBe(200); + expect(childBodies).toEqual([expect.objectContaining({ + model: "sol/gpt-5.6-sol", + reasoning: { effort: "high", summary: "auto" }, + })]); + expect(childBodies[0]).not.toHaveProperty("reasoning_effort"); + expect(childBodies[0]).not.toHaveProperty("thinking_budget"); + expect(childBodies[0]).not.toHaveProperty("thinking"); + } + }); + + test("fails open to the first eligible target at medium without requiring a Combo default", async () => { + delete process.env.TYPESAFE_API_KEY; + delete process.env.JEV_API_KEY; + let jevCalls = 0; + const noKey = makeConfig({ + jevKey: null, + jevFetch: (async () => { + jevCalls += 1; + return Response.json({}); + }) as typeof fetch, + }); + const missingKeyBodies: Record[] = []; + const missingKey = await execute(noKey, body => { + missingKeyBodies.push(body); + return success(String(body.model)); + }, { reasoning: { effort: "max" }, service_tier: "priority" }); + + expect(missingKey.status).toBe(200); + expect(jevCalls).toBe(0); + expect(missingKeyBodies[0]).toMatchObject({ + model: "astra/gpt-6-astra", + reasoning: { effort: "medium" }, + }); + expect(missingKeyBodies[0]).not.toHaveProperty("service_tier"); + + const invalidBodies: Record[] = []; + const invalid = makeConfig({ jevFetch: choiceFetch("attacker/model:max") }); + const invalidResponse = await execute(invalid, body => { + invalidBodies.push(body); + return success(String(body.model)); + }); + expect(invalidResponse.status).toBe(200); + expect(invalidBodies[0]).toMatchObject({ + model: "astra/gpt-6-astra", + reasoning: { effort: "medium" }, + }); + }); + + test("uses ordinary Combo fallback once after a selected target fails", async () => { + const jevRequests: Array> = []; + const config = makeConfig({ jevFetch: choiceFetch("sol/gpt-5.6-sol:high", jevRequests) }); + const childBodies: Record[] = []; + + const response = await execute(config, body => { + childBodies.push(body); + return String(body.model).startsWith("sol/") + ? Response.json({ error: { message: "temporary outage" } }, { status: 503 }) + : success(String(body.model)); + }, { + reasoning: { effort: "low", summary: "auto" }, + service_tier: "priority", + }); + + expect(response.status).toBe(200); + expect(jevRequests).toHaveLength(1); + expect(childBodies).toHaveLength(2); + expect(childBodies[0]).toMatchObject({ + model: "sol/gpt-5.6-sol", + reasoning: { effort: "high", summary: "auto" }, + }); + expect(childBodies[0]).not.toHaveProperty("service_tier"); + expect(childBodies[1]).toMatchObject({ + model: "astra/gpt-6-astra", + reasoning: { effort: "low", summary: "auto" }, + service_tier: "priority", + }); + }); + + test("defers reset-derived cooldown when an earlier same-provider target remains", async () => { + const config = makeConfig({ jevFetch: choiceFetch("astra/gpt-5.6-sol:high") }); + config.providers.astra = { + ...config.providers.astra!, + models: ["gpt-6-astra", "gpt-5.6-sol"], + modelContextWindows: { + "gpt-6-astra": 258_400, + "gpt-5.6-sol": 258_400, + }, + modelMaxInputTokens: { + "gpt-6-astra": 219_640, + "gpt-5.6-sol": 219_640, + }, + modelInputModalities: { + "gpt-6-astra": ["text", "image"], + "gpt-5.6-sol": ["text", "image"], + }, + modelReasoningEfforts: { + "gpt-6-astra": ["low", "medium", "high", "xhigh", "max"], + "gpt-5.6-sol": ["low", "medium", "high", "xhigh", "max"], + }, + }; + config.combos!.auto!.targets = [ + { provider: "astra", model: "gpt-6-astra" }, + { provider: "astra", model: "gpt-5.6-sol" }, + { provider: "luna", model: "gpt-5.6-luna" }, + ]; + const cooldownDeferrals: Array = []; + + const response = await execute(config, (body, _logCtx, options) => { + cooldownDeferrals.push(options?.deferCodexResetDerivedCooldown); + return success(String(body.model)); + }); + + expect(response.status).toBe(200); + expect(cooldownDeferrals).toEqual([true]); + }); + + test("offers only currently eligible targets to JEV", async () => { + const jevRequests: Array> = []; + const config = makeConfig({ + jevFetch: choiceFetch("sol/gpt-5.6-sol:low", jevRequests), + providerOverrides: { luna: { disabled: true } }, + }); + coolComboTarget("auto", targetRows[0], { cooldownMs: 60_000 }); + + const response = await execute(config, body => success(String(body.model))); + + expect(response.status).toBe(200); + const questions = jevRequests[0]?.questions as { + route?: { criteria?: Record }; + }; + expect(Object.keys(questions.route?.criteria ?? {})).toEqual([ + "sol/gpt-5.6-sol:low", + "sol/gpt-5.6-sol:medium", + "sol/gpt-5.6-sol:high", + "sol/gpt-5.6-sol:xhigh", + "sol/gpt-5.6-sol:max", + ]); + }); + + test("withholds lastResort targets from JEV under before-last-resort only while a normal target is offered", async () => { + const criteriaFor = async (disableNormal: boolean): Promise => { + const jevRequests: Array> = []; + const config = makeConfig({ + jevFetch: choiceFetch("luna/gpt-5.6-luna:low", jevRequests), + ...(disableNormal ? { providerOverrides: { astra: { disabled: true }, sol: { disabled: true } } } : {}), + }); + config.combos!.auto!.cooldownWaitPolicy = "before-last-resort"; + config.combos!.auto!.targets = targetRows.map(target => + target.provider === "luna" ? { ...target, lastResort: true } : { ...target }); + expect((await execute(config, body => success(String(body.model)))).status).toBe(200); + const questions = jevRequests[0]?.questions as { route?: { criteria?: Record } }; + return Object.keys(questions.route?.criteria ?? {}); + }; + + const withNormal = await criteriaFor(false); + expect(withNormal.some(key => key.startsWith("astra/"))).toBe(true); + expect(withNormal.some(key => key.startsWith("luna/"))).toBe(false); + // With no normal target reachable, the emergency target is still offered. + expect(await criteriaFor(true)).toEqual([ + "luna/gpt-5.6-luna:low", + "luna/gpt-5.6-luna:medium", + "luna/gpt-5.6-luna:high", + ]); + }); + + test("offers only each target's configured reasoning efforts and skips stale empty intersections", async () => { + const jevRequests: Array> = []; + const config = makeConfig({ + jevFetch: choiceFetch("sol/gpt-5.6-sol:medium", jevRequests), + }); + config.combos!.auto!.targets = [ + { ...targetRows[0], reasoningEfforts: ["low", "high", "ultra"] }, + { ...targetRows[1], reasoningEfforts: ["medium"] }, + { ...targetRows[2], reasoningEfforts: ["ultra"] }, + ]; + const childBodies: Record[] = []; + + const response = await execute(config, body => { + childBodies.push(body); + return success(String(body.model)); + }); + + expect(response.status).toBe(200); + const criteria = (jevRequests[0]?.questions as { + route: { criteria: Record }; + }).route.criteria; + expect(Object.keys(criteria)).toEqual([ + "astra/gpt-6-astra:low", + "astra/gpt-6-astra:high", + "sol/gpt-5.6-sol:medium", + ]); + expect(childBodies[0]).toMatchObject({ + model: "sol/gpt-5.6-sol", + reasoning: { effort: "medium" }, + }); + }); + + test("re-enumerates JEV choices after waiting for a cooldown to expire", async () => { + const jevRequests: Array> = []; + const config = makeConfig({ + jevFetch: choiceFetch("astra/gpt-6-astra:medium", jevRequests), + }); + config.combos!.auto!.waitForCooldownMs = 1_000; + const cooledAt = Date.now(); + for (const target of targetRows) { + coolComboTarget("auto", target, { now: cooledAt, cooldownMs: 80 }); + } + const childBodies: Record[] = []; + + const response = await execute(config, body => { + childBodies.push(body); + return success(String(body.model)); + }); + + expect(response.status).toBe(200); + expect(jevRequests).toHaveLength(1); + expect(childBodies[0]?.model).toBe("astra/gpt-6-astra"); + const criteria = (jevRequests[0]?.questions as { + route: { criteria: Record }; + }).route.criteria; + expect([...new Set(Object.values(criteria).map(option => option.target))]).toEqual([ + "astra/gpt-6-astra", + "sol/gpt-5.6-sol", + "luna/gpt-5.6-luna", + ]); + }); + + test("represents an empty effort ladder as none and strips every caller effort control", async () => { + const jevRequests: Array> = []; + const config = makeConfig({ + jevFetch: choiceFetch("sol/gpt-5.6-sol:none", jevRequests), + providerOverrides: { sol: { modelReasoningEfforts: { "gpt-5.6-sol": [] } } }, + }); + const childBodies: Record[] = []; + + const response = await execute(config, body => { + childBodies.push(body); + return success(String(body.model)); + }, { + reasoning: { effort: "high", summary: "auto" }, + reasoning_effort: "xhigh", + thinking_budget: 8_000, + thinking: { type: "enabled", budget_tokens: 8_000 }, + }); + + expect(response.status).toBe(200); + expect(Object.keys((jevRequests[0]?.questions as { route: { criteria: Record } }).route.criteria)) + .toContain("sol/gpt-5.6-sol:none"); + expect(childBodies[0]).toMatchObject({ + model: "sol/gpt-5.6-sol", + reasoning: { summary: "auto" }, + }); + expect(childBodies[0]).not.toHaveProperty("reasoning_effort"); + expect(childBodies[0]).not.toHaveProperty("thinking_budget"); + expect(childBodies[0]).not.toHaveProperty("thinking"); + }); + + test("returns 499 without dispatching a model when the caller aborts during JEV", async () => { + const controller = new AbortController(); + const reason = new DOMException("caller stopped", "AbortError"); + const jevFetch = (async (_input, init) => new Promise((_resolve, reject) => { + init?.signal?.addEventListener("abort", () => reject(init.signal?.reason), { once: true }); + queueMicrotask(() => controller.abort(reason)); + })) as typeof fetch; + const config = makeConfig({ jevFetch }); + let modelDispatches = 0; + + const response = await execute(config, body => { + modelDispatches += 1; + return success(String(body.model)); + }, {}, controller.signal); + + expect(response.status).toBe(499); + expect(modelDispatches).toBe(0); + }); +}); diff --git a/tests/usage/jev-stats.test.ts b/tests/usage/jev-stats.test.ts new file mode 100644 index 00000000000..987807a3a6b --- /dev/null +++ b/tests/usage/jev-stats.test.ts @@ -0,0 +1,301 @@ +import { describe, expect, test } from "bun:test"; +import { + createJevStatsAccumulator, + MAX_JEV_STATS_MODEL_ROWS, + normalizePersistedJevDecision, +} from "../../src/usage/jev-stats"; +import type { PersistedUsageEntry } from "../../src/usage/log"; + +const NOW = Date.UTC(2026, 8, 22, 12); + +function entry( + requestId: string, + timestamp: number, + decision: NonNullable, + attempts: NonNullable, + status = 200, +): PersistedUsageEntry { + return { + requestId, + timestamp, + provider: "combo", + model: `combo/${decision.comboId}`, + status, + durationMs: 100, + usageStatus: "reported", + attempts, + jevDecision: decision, + }; +} + +function attempt( + ordinal: number, + provider: string, + model: string, + inputTokens: number, + outputTokens: number, + options: { status?: number; reasoning?: number; cacheRead?: number; cacheWrite?: number } = {}, +): NonNullable[number] { + const totalTokens = inputTokens + outputTokens; + return { + ordinal, + provider, + model, + adapter: "test", + status: options.status ?? 200, + durationMs: 10, + sendCount: 1, + recoveryKinds: [], + usageStatus: "reported", + usage: { + inputTokens, + outputTokens, + ...(options.reasoning !== undefined ? { reasoningOutputTokens: options.reasoning } : {}), + ...(options.cacheRead !== undefined ? { + cachedInputTokens: options.cacheRead, + cacheReadInputTokens: options.cacheRead, + } : {}), + ...(options.cacheWrite !== undefined ? { cacheCreationInputTokens: options.cacheWrite } : {}), + }, + totalTokens, + }; +} + +describe("JEV decision telemetry", () => { + test("normalizes a bounded, closed decision record", () => { + expect(normalizePersistedJevDecision({ + version: 1, + comboId: " jev-auto ", + selected: { provider: " openai ", model: " gpt-6-astra ", effort: "high" }, + gate: "apply", + latencyMs: 12.8, + confidence: 0.75, + chosenProbability: 0.6, + usage: { inputTokens: 14, outputTokens: 3 }, + })).toEqual({ + version: 1, + comboId: "jev-auto", + selected: { provider: "openai", model: "gpt-6-astra", effort: "high" }, + gate: "apply", + latencyMs: 13, + confidence: 0.75, + chosenProbability: 0.6, + usage: { inputTokens: 14, outputTokens: 3, totalTokens: 17 }, + }); + + expect(normalizePersistedJevDecision({ + version: 1, + comboId: "jev-auto", + selected: { provider: "openai", model: "gpt-6-astra", effort: "impossible" }, + gate: "invented", + latencyMs: 1, + })).toBeUndefined(); + }); + + test("separates JEV picks from physical model attempts and token usage", () => { + const applied = normalizePersistedJevDecision({ + version: 1, + comboId: "jev-auto", + selected: { provider: "openai", model: "gpt-6-astra", effort: "high" }, + gate: "apply", + latencyMs: 20, + confidence: 0.8, + chosenProbability: 0.7, + usage: { inputTokens: 12, outputTokens: 3 }, + })!; + const failOpen = normalizePersistedJevDecision({ + version: 1, + comboId: "jev-auto", + selected: { provider: "openai", model: "gpt-6-astra", effort: "medium" }, + gate: "timeout", + latencyMs: 4_000, + })!; + const otherCombo = normalizePersistedJevDecision({ + version: 1, + comboId: "other", + selected: { provider: "anthropic", model: "claude-sonnet-5", effort: null }, + gate: "apply", + latencyMs: 10, + })!; + const accumulator = createJevStatsAccumulator({ + comboId: "jev-auto", + since: NOW - 30 * 86_400_000, + until: NOW, + }); + accumulator.add(entry("applied", NOW - 1_000, applied, [ + attempt(1, "openai", "gpt-6-astra", 100, 20, { reasoning: 8, cacheRead: 30 }), + ])); + accumulator.add(entry("fail-open", NOW - 500, failOpen, [ + attempt(1, "openai", "gpt-6-astra", 50, 5, { status: 503 }), + attempt(2, "openai", "gpt-5.6-sol", 80, 10, { cacheWrite: 4 }), + ])); + accumulator.add(entry("other", NOW - 250, otherCombo, [ + attempt(1, "anthropic", "claude-sonnet-5", 500, 50), + ])); + accumulator.add(entry("too-old", NOW - 40 * 86_400_000, applied, [ + attempt(1, "openai", "gpt-6-astra", 1_000, 100), + ])); + + const stats = accumulator.summarize("30d", NOW); + + expect(stats).toMatchObject({ + range: "30d", + comboId: "jev-auto", + since: NOW - 30 * 86_400_000, + generatedAt: NOW, + summary: { + decisions: 2, + appliedDecisions: 1, + failOpenDecisions: 1, + successfulRequests: 2, + requestsWithModelFallback: 1, + modelAttempts: 3, + measuredModelAttempts: 3, + modelInputTokens: 230, + modelOutputTokens: 35, + modelReasoningTokens: 8, + modelCacheReadTokens: 30, + modelCacheWriteTokens: 4, + modelTotalTokens: 265, + decisionUsageReported: 1, + decisionInputTokens: 12, + decisionOutputTokens: 3, + decisionTotalTokens: 15, + averageLatencyMs: 2_010, + averageConfidence: 0.8, + averageChosenProbability: 0.7, + }, + }); + expect(stats.gates).toEqual([ + { gate: "apply", decisions: 1 }, + { gate: "timeout", decisions: 1 }, + ]); + expect(stats.models).toEqual([ + { + provider: "openai", + model: "gpt-6-astra", + overflow: false, + picks: 2, + appliedPicks: 1, + failOpenPicks: 1, + attempts: 2, + measuredAttempts: 2, + inputTokens: 150, + outputTokens: 25, + reasoningTokens: 8, + cacheReadTokens: 30, + cacheWriteTokens: 0, + totalTokens: 175, + efforts: [ + { effort: "high", picks: 1 }, + { effort: "medium", picks: 1 }, + ], + }, + { + provider: "openai", + model: "gpt-5.6-sol", + overflow: false, + picks: 0, + appliedPicks: 0, + failOpenPicks: 0, + attempts: 1, + measuredAttempts: 1, + inputTokens: 80, + outputTokens: 10, + reasoningTokens: 0, + cacheReadTokens: 0, + cacheWriteTokens: 4, + totalTokens: 90, + efforts: [], + }, + ]); + }); + + test("counts physical sends and ignores unsent fallback rows", () => { + const decision = normalizePersistedJevDecision({ + version: 1, + comboId: "jev-auto", + selected: { provider: "openai", model: "gpt-6-astra", effort: "high" }, + gate: "apply", + latencyMs: 5, + })!; + const retried = attempt(1, "openai", "gpt-6-astra", 20, 5); + retried.sendCount = 3; + const unsent = attempt(2, "openai", "gpt-5.6-sol", 0, 0, { status: 503 }); + unsent.sendCount = 0; + unsent.usageStatus = "unreported"; + delete unsent.usage; + delete unsent.totalTokens; + const accumulator = createJevStatsAccumulator({ comboId: "jev-auto" }); + + accumulator.add(entry("physical-sends", NOW, decision, [retried, unsent])); + const summary = accumulator.summarize("all", NOW); + + expect(summary.summary).toMatchObject({ + modelAttempts: 3, + measuredModelAttempts: 1, + requestsWithModelFallback: 0, + modelTotalTokens: 25, + }); + expect(summary.models).toEqual([expect.objectContaining({ + provider: "openai", + model: "gpt-6-astra", + attempts: 3, + measuredAttempts: 1, + })]); + }); + + test("keeps a valid long selected model joined to its physical attempt", () => { + const model = `model-${"x".repeat(240)}`; + const decision = normalizePersistedJevDecision({ + version: 1, + comboId: "jev-auto", + selected: { provider: "provider", model, effort: "medium" }, + gate: "apply", + latencyMs: 1, + })!; + const accumulator = createJevStatsAccumulator({ comboId: "jev-auto" }); + + accumulator.add(entry("long-model", NOW, decision, [attempt(1, "provider", model, 1, 1)])); + const summary = accumulator.summarize("all", NOW); + + expect(summary.summary.requestsWithModelFallback).toBe(0); + expect(summary.models).toEqual([expect.objectContaining({ + provider: "provider", + model, + picks: 1, + attempts: 1, + })]); + }); + + test("bounds high-cardinality model rows and folds overflow without losing totals", () => { + const accumulator = createJevStatsAccumulator({ comboId: "jev-auto" }); + const distinctModels = MAX_JEV_STATS_MODEL_ROWS + 44; + for (let index = 0; index < distinctModels; index += 1) { + const provider = index === 0 ? "other" : "provider"; + const model = index === 0 ? "other" : `model-${index}`; + const decision = normalizePersistedJevDecision({ + version: 1, + comboId: "jev-auto", + selected: { provider, model, effort: "medium" }, + gate: "apply", + latencyMs: 1, + })!; + accumulator.add(entry(String(index), NOW + index, decision, [ + attempt(1, provider, model, 1, 1), + ])); + } + + const summary = accumulator.clone().summarize("all", NOW + distinctModels); + expect(summary.models).toHaveLength(MAX_JEV_STATS_MODEL_ROWS); + expect(summary.summary).toMatchObject({ + decisions: distinctModels, + modelAttempts: distinctModels, + modelTotalTokens: distinctModels * 2, + }); + expect(summary.models.find(row => !row.overflow && row.provider === "other" && row.model === "other")) + .toMatchObject({ picks: 1, attempts: 1, totalTokens: 2 }); + expect(summary.models.find(row => row.overflow)) + .toMatchObject({ picks: 45, attempts: 45, totalTokens: 90 }); + }); +}); diff --git a/tests/usage/request-log-jev.test.ts b/tests/usage/request-log-jev.test.ts new file mode 100644 index 00000000000..a41d6673e75 --- /dev/null +++ b/tests/usage/request-log-jev.test.ts @@ -0,0 +1,60 @@ +import { expect, test } from "bun:test"; +import { mkdtempSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + +import { + addFinalRequestLog, + clearRequestLogsForTests, + getRequestLogEntries, + hydrateRequestLogsFromDisk, +} from "../../src/server/request-log"; +import { readUsageEntries, resetUsageReadCacheForTests } from "../../src/usage/log"; +import { removeTreeWithRetry } from "../helpers/remove-tree"; + +test("JEV decision telemetry survives finalization, disk persistence and hydration", () => { + const previousHome = process.env.OPENCODEX_HOME; + const home = mkdtempSync(join(tmpdir(), "ocx-jev-log-")); + process.env.OPENCODEX_HOME = home; + clearRequestLogsForTests(); + try { + addFinalRequestLog("jev-final", 1, { + model: "gpt-6-astra", + provider: "openai", + requestedModel: "jev-auto", + comboId: "jev-auto", + jevDecision: { + version: 1, + comboId: "jev-auto", + selected: { provider: "openai", model: "gpt-6-astra", effort: "high" }, + gate: "apply", + latencyMs: 24, + confidence: 0.8, + chosenProbability: 0.7, + usage: { inputTokens: 11, outputTokens: 2, totalTokens: 13 }, + }, + usage: { inputTokens: 100, outputTokens: 20 }, + }, 200); + + const expected = { + version: 1, + comboId: "jev-auto", + selected: { provider: "openai", model: "gpt-6-astra", effort: "high" }, + gate: "apply", + latencyMs: 24, + confidence: 0.8, + chosenProbability: 0.7, + usage: { inputTokens: 11, outputTokens: 2, totalTokens: 13 }, + } as const; + expect(readUsageEntries()[0]?.jevDecision).toEqual(expected); + clearRequestLogsForTests(); + expect(hydrateRequestLogsFromDisk()).toBe(1); + expect(getRequestLogEntries()[0]?.jevDecision).toEqual(expected); + } finally { + clearRequestLogsForTests(); + resetUsageReadCacheForTests(); + if (previousHome === undefined) delete process.env.OPENCODEX_HOME; + else process.env.OPENCODEX_HOME = previousHome; + removeTreeWithRetry(home); + } +}); diff --git a/tests/usage/usage-aggregate-cache.test.ts b/tests/usage/usage-aggregate-cache.test.ts index d954a4de326..a6daf2c2bf2 100644 --- a/tests/usage/usage-aggregate-cache.test.ts +++ b/tests/usage/usage-aggregate-cache.test.ts @@ -13,6 +13,7 @@ import { import { APP_OWNED_RETAINED_STORE_REGISTRATIONS } from "../../src/lib/app-owned-memory-stores"; import { getFilteredUsageAggregate, + getJevStatsAggregate, getUsageAggregate, resetUsageAggregateCacheForTests, usageAggregateRetainedStats, @@ -79,6 +80,183 @@ afterEach(() => { }); describe("retained usage aggregate cache", () => { + test("JEV projections share a cold scan and read only a verified append suffix", async () => { + const path = join(testDir, "usage.jsonl"); + const jevEntry = (requestId: string, model: string): PersistedUsageEntry => ({ + requestId, + timestamp: NOW, + provider: "combo", + model: "jev-auto", + status: 200, + durationMs: 2, + usageStatus: "reported", + jevDecision: { + version: 1, + comboId: "jev-auto", + selected: { provider: "openai", model, effort: "high" }, + gate: "apply", + latencyMs: 1, + }, + attempts: [{ + ordinal: 1, + provider: "openai", + model, + adapter: "openai-responses", + status: 200, + durationMs: 1, + sendCount: 1, + recoveryKinds: [], + usageStatus: "reported", + usage: { inputTokens: 1, outputTokens: 1 }, + totalTokens: 2, + }], + }); + writeFileSync(path, `${JSON.stringify(jevEntry("one", "gpt-6-astra"))}\n`); + const originalScan = usageLedgerScannerModule.scanUsageLedgerCooperatively; + const scanStarts: number[] = []; + const scanSpy = spyOn(usageLedgerScannerModule, "scanUsageLedgerCooperatively") + .mockImplementation(async options => { + scanStarts.push(options.startAtBytes ?? 0); + return originalScan(options); + }); + try { + const [first, shared] = await Promise.all([ + getJevStatsAggregate({ comboId: "jev-auto" }), + getJevStatsAggregate({ comboId: "jev-auto" }), + ]); + expect(first.accumulator).toBe(shared.accumulator); + expect(first.accumulator.summarize("all", NOW).summary.decisions).toBe(1); + expect((await getJevStatsAggregate({ comboId: "jev-auto" })).update).toBe("unchanged"); + expect(scanStarts).toEqual([0]); + + appendFileSync(path, `${JSON.stringify(jevEntry("two", "gpt-5.6-sol"))}\n`); + const appended = await getJevStatsAggregate({ comboId: "jev-auto" }); + expect(appended.update).toBe("append"); + expect(appended.accumulator.summarize("all", NOW).summary.decisions).toBe(2); + expect(scanStarts).toHaveLength(2); + expect(scanStarts[1]).toBeGreaterThan(0); + } finally { + scanSpy.mockRestore(); + } + }); + + test("JEV cold rebuild and suffix append saturate persisted numeric totals", async () => { + const path = join(testDir, "usage.jsonl"); + const maximum = Number.MAX_SAFE_INTEGER; + const hugePersistedValue = 1e308; + const jevEntry = (requestId: string): PersistedUsageEntry => ({ + requestId, + timestamp: NOW, + provider: "combo", + model: "jev-auto", + status: 200, + durationMs: 1, + usageStatus: "reported", + jevDecision: { + version: 1, + comboId: "jev-auto", + selected: { provider: "openai", model: "gpt-6-astra", effort: "high" }, + gate: "apply", + latencyMs: hugePersistedValue, + usage: { + inputTokens: hugePersistedValue, + outputTokens: hugePersistedValue, + totalTokens: hugePersistedValue, + }, + }, + attempts: [{ + ordinal: 1, + provider: "openai", + model: "gpt-6-astra", + adapter: "openai-responses", + status: 200, + durationMs: 1, + sendCount: hugePersistedValue, + recoveryKinds: [], + usageStatus: "reported", + usage: { + inputTokens: hugePersistedValue, + outputTokens: hugePersistedValue, + reasoningOutputTokens: hugePersistedValue, + cacheReadInputTokens: hugePersistedValue, + cacheCreationInputTokens: hugePersistedValue, + }, + totalTokens: hugePersistedValue, + }], + }); + const assertSaturated = (summary: ReturnType>["accumulator"]["summarize"]>) => { + expect(summary.summary).toMatchObject({ + modelAttempts: maximum, + modelInputTokens: maximum, + modelOutputTokens: maximum, + modelReasoningTokens: maximum, + modelCacheReadTokens: maximum, + modelCacheWriteTokens: maximum, + modelTotalTokens: maximum, + decisionInputTokens: maximum, + decisionOutputTokens: maximum, + decisionTotalTokens: maximum, + averageLatencyMs: maximum, + }); + expect(summary.models[0]).toMatchObject({ + attempts: maximum, + inputTokens: maximum, + outputTokens: maximum, + reasoningTokens: maximum, + cacheReadTokens: maximum, + cacheWriteTokens: maximum, + totalTokens: maximum, + }); + }; + + writeFileSync(path, `${JSON.stringify(jevEntry("one"))}\n${JSON.stringify(jevEntry("two"))}\n`); + const rebuilt = await getJevStatsAggregate({ comboId: "jev-auto" }); + assertSaturated(rebuilt.accumulator.summarize("all", NOW)); + + appendFileSync(path, `${JSON.stringify(jevEntry("three"))}\n`); + const appended = await getJevStatsAggregate({ comboId: "jev-auto" }); + expect(appended.update).toBe("append"); + assertSaturated(appended.accumulator.summarize("all", NOW)); + }); + + test("a JEV rebuild retry discards the partially mutated accumulator", async () => { + const row: PersistedUsageEntry = { + requestId: "one", + timestamp: NOW, + provider: "combo", + model: "jev-auto", + status: 200, + durationMs: 1, + usageStatus: "unreported", + jevDecision: { + version: 1, + comboId: "jev-auto", + selected: { provider: "openai", model: "gpt-6-astra", effort: "high" }, + gate: "apply", + latencyMs: 1, + }, + }; + writeFileSync(join(testDir, "usage.jsonl"), `${JSON.stringify(row)}\n`); + const originalScan = usageLedgerScannerModule.scanUsageLedgerCooperatively; + let calls = 0; + const scanSpy = spyOn(usageLedgerScannerModule, "scanUsageLedgerCooperatively") + .mockImplementation(async options => { + calls += 1; + if (calls === 1) { + options.onEntry(row); + throw new usageLedgerScannerModule.UsageLedgerRebuildRequiredError("content_changed"); + } + return originalScan(options); + }); + try { + const result = await getJevStatsAggregate({ comboId: "jev-auto" }); + expect(calls).toBe(2); + expect(result.accumulator.summarize("all", NOW).summary.decisions).toBe(1); + } finally { + scanSpy.mockRestore(); + } + }); + test.each(["message_start", "message_delta"].flatMap(phase => ["bad", [], null, false, 7, { output_tokens: "bad" }].map(usage => ({ phase, usage })), ))("malformed streamed usage at $phase stays unreported after a valid update: $usage", async ({ phase, usage }) => {