diff --git a/CREDITS.md b/CREDITS.md index 8cff47f965..14bd5c84d5 100644 --- a/CREDITS.md +++ b/CREDITS.md @@ -55,6 +55,7 @@ Code, design, or tests from these pull requests shipped. | [#3078](https://github.com/lidge-jun/opencodex/pull/3078) | [@Veritas-7](https://github.com/Veritas-7) | `0ef04e640` | "reimplements both of your production hunks on `dev`" | | [#3142](https://github.com/lidge-jun/opencodex/pull/3142) | [@olddonkey](https://github.com/olddonkey) | `52d941640` | "That carry keeps the measurement/refusal work and ships the guard default-off" | | [#3300](https://github.com/lidge-jun/opencodex/pull/3300) | [@S0RYUASUKA](https://github.com/S0RYUASUKA) | `15b43e51c` | the same two test files made hermetic | +| [#3284](https://github.com/lidge-jun/opencodex/pull/3284) | [@mdwsk88](https://github.com/mdwsk88) | `3d3c4fe26` | "Core implementation is already on `dev` via #3286 (`3d3c4fe26`), including the suffix wire ladder, picker collapse, Google adapter coverage" | ## Report and diagnosis diff --git a/devlog/_plan/260904_triage_gap_closure/000_research.md b/devlog/_plan/260904_triage_gap_closure/000_research.md new file mode 100644 index 0000000000..0fde3d8e01 --- /dev/null +++ b/devlog/_plan/260904_triage_gap_closure/000_research.md @@ -0,0 +1,111 @@ +# 000 — Research: three gaps the 260904 triage found but did not close + +A triage pass over open PRs and issues closed #3366, #3061, and merged #2877. +Three candidates were deliberately left open because the evidence said they were +NOT done. This unit closes them properly. + +## Gap 1 — PR #3293 shipped half of itself + +`claude-fable-5-1` pricing is on `dev`; its metadata is not. + +```text +git show origin/dev:src/usage/expected-prices.ts | grep -c claude-fable-5-1 -> 4 +git show origin/dev:src/generated/model-metadata.ts | grep -o '"claude-fable-5-1"' -> (none) +git show origin/dev:scripts/model-metadata.source.json | grep -c claude-fable-5-1 -> 0 +``` + +The pricing rows arrived through unrelated commits (`3d3c4fe26`, `ff1ac6b8c`, +`1aa839aa8`) that happened to touch `expected-prices.ts`. PR #3293 by +[@Veritas-7](https://github.com/Veritas-7) is the only source of the metadata +half, and it is still open and MERGEABLE. + +That asymmetry is the actual defect: `expected-prices.ts` asserts an expected +price for a model the generated catalog does not know about. This is a +half-shipped feature, not a superseded one, which is why the triage left it open +rather than closing it as done. + +## Gap 2 — issue #3431 asks for a credit the file's own rule requires + +`CREDITS.md` on `dev` has 19 Carried-work rows and none for #3284. + +Verified independently rather than taken from the issue text: + +- `3d3c4fe26` is an ancestor of `origin/dev` and is the #3286 merge. +- `src/providers/antigravity-models.ts` on `dev` names `gemini-3.8-flash` 16 + times; the suffix ladder shipped. +- #3284 by [@mdwsk88](https://github.com/mdwsk88) is CLOSED, not merged. +- Maintainer [@Ingwannu](https://github.com/Ingwannu) closed it as superseded and + wrote, in that closing comment: "Core implementation is already on `dev` via + #3286 (`3d3c4fe26`), including the suffix wire ladder, picker collapse, Google + adapter coverage, metadata/pricing, and remaining surfaces." + +That last line matters procedurally. `CREDITS.md` says entries "must not be +inferred from diff similarity alone" and that every entry cites the maintainer's +own words. The closing comment IS that citation, so this row is admissible on the +file's own terms. + +## Gap 3 — issue #3429 asks for a feature that was already closed once + +This is the one that needs care. + +PR #2994 by [@Ingwannu](https://github.com/Ingwannu) added `ultrafast` to the +pinned `gpt-5.6-sol` fallback catalog. It was closed unmerged on 2026-08-30 with +the maintainer verdict: + +> Ultrafast는 프런트 노출 오류다. 피커에 칸을 올리면 고를 수는 있는데, 실제 +> Ultrafast 속도가 나오지 않는다. 백엔드가 깨져서가 아니다. + +Issue #2993 was closed with it. So "add ultrafast to the catalog" is a decision +that was already made and reversed, and re-landing it as a default would +reintroduce exactly the defect it was reverted for: a picker row that can be +selected but does not deliver the speed it names. + +What #3429 reports is narrower and still true: + +- `src/codex/catalog/parsing.ts` deletes `service_tier`, `service_tiers`, + `default_service_tier`, and `additional_speed_tiers` from these rows, so the + tier cannot survive a catalog regeneration even if the user edits + `~/.codex/opencodex-catalog.json` by hand. +- When a user forces the tier through anyway, the request completes but the + classification does not recognise it: `fastOutcome` reads `not-requested` and + the confirmation reads `unknown`, with no speed label for ultrafast. + +The second half is a straight defect regardless of the first: the proxy is +carrying a tier it refuses to name. + +## The shape this unit takes + +Opt-in, default OFF. With the setting unset, nothing changes for anyone — the +stripping still happens, the catalog is byte-identical, and #2994's verdict +stands. With the setting on, the operator has said "I know what this is", the +tier survives end-to-end, and the logs name it honestly instead of reporting +`unknown`. + +That is what makes re-landing this defensible after #2994: the reversal was about +showing everyone a row that lies. An operator-enabled flag that also fixes the +classification does not. + +The user additionally asked for the Codex Set page head to be relieved of the +"한도 도달 계정 일시 중지" and "할당량 새로고침" controls, moving them below, with +the Ultra Fast toggle surfacing there. That is the same surface, so it lands in +the same phase. + +## Design read + +Existing dashboard, no new visual language. + +DESIGN_VARIANCE: 2, MOTION_INTENSITY: 1, density D5 — operator console for +repeated expert work. The page head is currently carrying three controls plus a +title on one row, which is what the user is reacting to; moving the two +account-scoped actions down to the section they act on is a density fix, not a +restyle. + +Do's: reuse the existing Switch + label + description row pattern, the existing +`btn btn-ghost btn-sm` controls, and the existing quota-refresh i18n keys. +Don'ts: no new panel, no default-on behavior, no picker row without the wire +support behind it. + +## Out of scope + +PR #3332 (user deferred it explicitly), release scripts, workflows, auth paths, +`gui/dist`. diff --git a/devlog/_plan/260904_triage_gap_closure/010_fable_metadata.md b/devlog/_plan/260904_triage_gap_closure/010_fable_metadata.md new file mode 100644 index 0000000000..407d2544dc --- /dev/null +++ b/devlog/_plan/260904_triage_gap_closure/010_fable_metadata.md @@ -0,0 +1,70 @@ +# 010 — Land the missing claude-fable-5-1 metadata (PR #3293) + +Work-phase `wp1`. Depends on 000. + +## Problem + +`src/usage/expected-prices.ts` on `dev` asserts an expected price for +`claude-fable-5-1` on four surfaces, but neither +`scripts/model-metadata.source.json` nor the `anthropic` row of +`src/generated/model-metadata.ts` knows the model exists. Pricing without +metadata is the wrong half to have. + +## Diff plan + +Carry PR #3293 by [@Veritas-7](https://github.com/Veritas-7). Its diff is exactly +the missing half, so this is a carry rather than a reimplementation. + +### scripts/model-metadata.source.json + +Insert the `claude-fable-5-1` entry into the `anthropic` provider block, in id +order between `claude-fable-5` and `claude-haiku-4-5`: + +```json +"claude-fable-5-1": { + "id": "claude-fable-5-1", + "name": "Anthropic Fable 5.1", + "api": "anthropic-messages", + "provider": "anthropic", + "baseUrl": "https://api.anthropic.com", + "reasoning": true, + "input": ["text", "image"], + "cost": { "input": 10, "output": 50, "cacheRead": 0.25, "cacheWrite": 12.5 }, + "contextWindow": 1000000, + "maxTokens": 128000, + "thinking": { "mode": "anthropic-adaptive", "minLevel": "minimal", "maxLevel": "xhigh" }, + "compat": { "toolChoiceSupport": "auto" } +} +``` + +### src/generated/model-metadata.ts + +This file is generated, so it is REGENERATED rather than hand-edited: +`bun run generate:model-metadata`. The expected delta is one row appended to the +`anthropic` array: + +```text +["claude-fable-5-1",1000000,128000,"text,image",1,null,10,50,0.25,12.5] +``` + +Note the cacheRead of `0.25`, not `1`. `claude-fable-5` carries `1`; Fable 5.1's +published cache-hit rate is 0.025x base input, which the existing +`expected-prices.ts` rows already encode. Regenerating rather than typing the row +is what keeps the two files consistent. + +### Attribution + +`Co-authored-by: Veritas-7 <...>` in the landing commit. Per `CREDITS.md` and +`AGENTS.md`, the trailer is what GitHub reads; prose in the body is read by +nothing. + +## Verification + +- `bun run typecheck` +- `bun test ./tests/usage-cost.test.ts` — the PR's own test file +- Confirm the generated row exists and matches the source entry +- After merge: re-read `origin/dev` and confirm both files carry the model + +## Close-out + +Close #3293 citing the landing SHA and naming the author. diff --git a/devlog/_plan/260904_triage_gap_closure/020_credits_3284.md b/devlog/_plan/260904_triage_gap_closure/020_credits_3284.md new file mode 100644 index 0000000000..3cce9156ab --- /dev/null +++ b/devlog/_plan/260904_triage_gap_closure/020_credits_3284.md @@ -0,0 +1,44 @@ +# 020 — CREDITS row for #3284 (issue #3431) + +Work-phase `wp2`. Depends on 000. Disjoint from every other phase. + +## Why this is admissible + +`CREDITS.md` sets two bars, and both are met: + +1. "If you find a landing that belongs on this page, open an issue" — #3431 is + that issue, opened at maintainer [@Ingwannu](https://github.com/Ingwannu)'s + explicit request. +2. "Every entry cites the maintainer's own words... Nothing here is inferred from + a diff." — the citation is Ingwannu's closing comment on #3284. + +Verified independently rather than taken from the issue body: + +```text +git merge-base --is-ancestor 3d3c4fe26 origin/dev -> true +3d3c4fe26 = "feat(models): roll out Gemini 3.8 Flash ... (#3286)", author JUN +#3284 by @mdwsk88 -> CLOSED, not merged +src/providers/antigravity-models.ts on dev names gemini-3.8-flash 16 times +``` + +## Diff plan + +One row appended to the `## Carried work` table in `CREDITS.md`, in pull-request +number order after the `#3300` row: + +```markdown +| [#3284](https://github.com/lidge-jun/opencodex/pull/3284) | [@mdwsk88](https://github.com/mdwsk88) | \`3d3c4fe26\` | "Core implementation is already on \`dev\` via #3286 (\`3d3c4fe26\`), including the suffix wire ladder, picker collapse, Google adapter coverage" | +``` + +The quoted text is Ingwannu's, verbatim from the #3284 closing comment, matching +how every other row in the table cites its source. + +## Verification + +- The table renders (no broken pipe alignment) +- `bun run privacy:scan` stays green — it reads `CREDITS.md` +- After merge: `git show origin/dev:CREDITS.md | grep 3284` + +## Close-out + +Close #3431 citing the landing SHA. diff --git a/devlog/_plan/260904_triage_gap_closure/030_ultrafast_optin.md b/devlog/_plan/260904_triage_gap_closure/030_ultrafast_optin.md new file mode 100644 index 0000000000..78d42be5f3 --- /dev/null +++ b/devlog/_plan/260904_triage_gap_closure/030_ultrafast_optin.md @@ -0,0 +1,142 @@ +# 030 — Ultra Fast as an explicit opt-in (issue #3429) + +Work-phase `wp3`. Depends on 000. This is the phase that needs judgment, not just +diffs. + +## The constraint #2994 left behind + +PR #2994 added `ultrafast` to the pinned `gpt-5.6-sol` fallback catalog and was +closed unmerged: + +> Ultrafast는 프런트 노출 오류다. 피커에 칸을 올리면 고를 수는 있는데, 실제 +> Ultrafast 속도가 나오지 않는다. + +Exploration of `origin/dev` explains WHY, and the reason is load-bearing for this +plan. `src/codex/data/upstream-models.json` advertises exactly one tier on every +row that has any: + +```json +"service_tiers": [{ "id": "priority", "name": "Fast", "description": "1.5x speed, increased usage" }], +"additional_speed_tiers": ["fast"] +``` + +There is no upstream `ultrafast` tier to forward. So a catalog row advertising +one is fabricated metadata: the picker gains a choice the wire cannot honor. That +is precisely the defect #2994 was closed for, and adding the row back — flag or +no flag — would reproduce it. + +**Therefore this phase does NOT re-add the catalog row by default, and does not +claim Ultra Fast works.** It ships the half that is true today. + +## What is a real defect regardless + +#3429 reports something narrower that holds independently of whether the tier is +advertised: when a caller forces `service_tier: "ultrafast"` through anyway (by +hand-editing `~/.codex/opencodex-catalog.json`, which the reporter did), the +proxy carries the value but refuses to name it. + +Traced on `dev`: + +- `canonicalFastTierMarker` (`src/providers/fastwire.ts:247`) folds only + `priority`/`fast`, so `ultrafast` returns `undefined`. +- `fastIntent` is therefore false, and the request is classified + `fastOutcome: "not-requested"` at `fastwire.ts:344` — not `unknown`, but + actively "no fast tier was asked for", which is false. +- `requestLogSpeedLabel` (`src/server/request-log.ts:594`) returns `undefined` + for anything but `priority`/`fast`, so the Logs page shows no speed badge. + +The proxy is forwarding a tier it will not admit to. That is an observability +lie, and it is fixable without advertising anything. + +## Diff plan + +### 1. Config flag, default OFF + +Follow the `fastRows` precedent exactly (`src/config.ts:1052`, +`src/types/config.ts:384`): + +```ts +// src/config.ts — zod +ultraFastTier: z.boolean().optional().catch(false), +``` + +`.catch(false)` matters: a malformed hand edit degrades to off rather than +rejecting the config. Read it with the house `=== true` idiom, never truthiness. + +A management-route boolean guard goes beside `showCodexSparkQuota` in +`src/server/management/config-routes.ts:408` because this flag gets a dashboard +toggle (`fastRows` has none, which is why it needs no guard). + +### 2. Honest classification (the part that fixes the reported defect) + +`src/providers/fastwire.ts`: widen the canonical union to +`"priority" | "ultrafast"`, fold `ultrafast` in `canonicalFastTierMarker`, add +the `canonicalToWire` entry, and generalize the two `=== "priority"` +comparisons at `:349` and `:377` to "is a recognised canonical tier". + +`src/server/request-log.ts:594`: `requestLogSpeedLabel` returns `"ultrafast"` +for that tier. The existing contract — `"auto"` and `undefined` stay +`undefined` — must not move; `tests/request-log.test.ts:703` pins it. + +No new `fastOutcome` literal is introduced. The four existing values still +describe what happened; what changes is that `ultrafast` now reaches them instead +of being mistaken for "not requested". That keeps +`src/usage/log.ts`'s persisted-row allowlist untouched. + +### 3. Catalog gating — flag-gated, and honest about what it does + +`normalizeServiceTiers` and `normalizeRoutedCatalogEntry` gain a trailing +optional options bag, defaulting to off, threaded from the `sync.ts` call sites +that already hold `config`. With the flag absent, every `delete` runs exactly as +today. + +With the flag ON, the tier is PRESERVED when a catalog already carries it +(the operator's hand edit survives regeneration, which is the reporter's actual +ask). It is still NOT synthesized: `src/codex/catalog/effort.ts:160` keeps +emitting only the `priority` row, because inventing a tier upstream does not +advertise is what #2994 was reverted for. + +### 4. GUI — Codex Set page head + +`CodexAccountPoolPageHead` (`gui/src/components/codex-account-pool-main-card.tsx:207`) +currently carries the title, a status span, the Spark toggle, and two action +buttons on one row. Move "한도 도달 계정 일시 중지" and "할당량 새로고침" out of +the head and into the existing advanced-settings grid +(`CodexAuthAdvancedSettings`, `div#codex-auth-advanced-boxes`), which is already +the page's home for secondary controls, and put the Ultra Fast toggle there as a +`card card-row` matching `CodexAccountPickerSetting`. + +Two constraints from the existing code: + +- The component is dual-mode. `embedded={true}` (Providers workspace) renders a + bare `.row`; only the standalone page renders `.page-head`. The move must not + change the embedded surface. +- `gui/tests/codex-set-page-head-wrap.test.ts` throws `rule not found` if the + `.codex-auth-page-head` selectors disappear, and + `codex-account-pool-toast-tone.test.tsx` queries + `.codex-auth-page-head__feedback`. Keep those class names alive. + +The toggle's description states plainly that upstream advertises no Ultra Fast +tier today and that the setting only preserves a tier the operator supplies. A +switch that implies a speed it cannot deliver is the #2994 defect in a new place. + +## Verification + +- `bun run typecheck`, `bun run lint:gui` +- Flag OFF regression set, which is the proof the default did not move: + `tests/codex-catalog.test.ts`, `tests/fastwire-characterization-routing.test.ts` + (byte golden), `tests/service-tier-capability.test.ts`, + `tests/request-log.test.ts`, `tests/fastwire-observability.test.ts` +- New focused tests: flag OFF strips exactly as before; flag ON preserves a + supplied tier; `ultrafast` classifies instead of reading `not-requested`; + `requestLogSpeedLabel` maps it while `"auto"` still returns `undefined` +- `gui/tests/codex-set-page-head-wrap.test.ts` and the toast-tone suite stay green +- Live: the Codex Set page head no longer carries the two buttons; they and the + toggle are reachable below + +## What this phase will NOT claim + +It will not claim Ultra Fast delivers Ultra Fast speed. If live evidence shows an +opted-in request still cannot get the tier honored end-to-end, that is reported +on #3429 rather than papered over — the same call #2994 made, and the reason it +is closable honestly either way. diff --git a/devlog/_plan/260904_triage_gap_closure/assets/010_codex_set_head_relocated.png b/devlog/_plan/260904_triage_gap_closure/assets/010_codex_set_head_relocated.png new file mode 100644 index 0000000000..cd7a71b43c Binary files /dev/null and b/devlog/_plan/260904_triage_gap_closure/assets/010_codex_set_head_relocated.png differ diff --git a/gui/src/components/CodexAccountPool.tsx b/gui/src/components/CodexAccountPool.tsx index cfedd15013..db2ef62652 100644 --- a/gui/src/components/CodexAccountPool.tsx +++ b/gui/src/components/CodexAccountPool.tsx @@ -14,7 +14,7 @@ import { readJsonIfOk } from "../fetch-json"; import { CodexAccountPoolCards, CodexAccountPoolReauthBanner } from "./codex-account-pool-cards"; import { CodexAccountSwitchModal } from "./codex-account-switch-modal"; import { CodexAccountResetModal } from "./codex-account-reset-modal"; -import { CodexAccountPoolLoadStates, CodexAccountPoolMainCard, CodexAccountPoolPageHead } from "./codex-account-pool-main-card"; +import { CodexAccountPoolActions, CodexAccountPoolLoadStates, CodexAccountPoolMainCard, CodexAccountPoolPageHead } from "./codex-account-pool-main-card"; import { redeemResetCredit } from "./codex-account-pool-handlers"; import type { CodexAccountEntry } from "./codex-account-pool-types"; import { accountNeedsReauth } from "../oauth-health-display"; @@ -348,6 +348,22 @@ export default function CodexAccountPool({ apiBase, accountModeState = null, ban {banner} + {/* + Relocated out of the page head: with two accounts the head carried a title, a + status line, a toggle and two buttons on one row, and the actions sat above the + cards they act on. They belong next to the accounts. + */} + {!embedded && ( + { void refreshQuotas(); }} + onPauseExhausted={() => { void pauseExhausted(); }} + /> + )} + {/* Skeleton must sit where main/pool cards will be — never above the account-mode banner, or the strip collapses on ready and shoves the whole page up (CLS). */} (null); + const savingRef = useRef(false); + const loadGenerationRef = useRef(0); + + const load = useCallback(async () => { + // A poll landing between the optimistic flip and the PUT response must not revert the + // UI to the server's pre-save value. + if (savingRef.current) return; + const generation = ++loadGenerationRef.current; + const bounded = createBoundedFetch(15_000); + try { + const res = await fetch(`${apiBase}/api/settings`, { signal: bounded.signal }); + if (!res.ok) throw new Error("load"); + const payload = await res.json() as { ultraFastTier?: unknown }; + if (savingRef.current || generation !== loadGenerationRef.current) return; + setEnabled(payload.ultraFastTier === true); + setHydrated(true); + setLoadError(false); + } catch { + if (!savingRef.current && generation === loadGenerationRef.current) setLoadError(true); + } finally { + bounded.clear(); + } + }, [apiBase]); + + useEffect(() => { + const timeout = window.setTimeout(() => { void load(); }, 0); + const stop = startVisibilityPoll(() => { void load(); }, 30_000); + return () => { + window.clearTimeout(timeout); + stop(); + }; + }, [load]); + + const toggle = useCallback(async () => { + if (savingRef.current || !hydrated || loadError) return; + const requested = !enabled; + savingRef.current = true; + setSaving(true); + setFeedback(null); + // Optimistic, then reconciled with what the server actually stored. + setEnabled(requested); + const bounded = createBoundedFetch(15_000); + try { + const res = await fetch(`${apiBase}/api/settings`, { + method: "PUT", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ ultraFastTier: requested }), + signal: bounded.signal, + }); + if (!res.ok) throw new Error("save"); + const payload = await res.json() as { ultraFastTier?: unknown }; + const confirmed = typeof payload.ultraFastTier === "boolean" ? payload.ultraFastTier : requested; + setEnabled(confirmed); + setFeedback({ tone: "ok", message: t(confirmed ? "codexAuth.ultraFastEnabled" : "codexAuth.ultraFastDisabled") }); + } catch { + setEnabled(!requested); + setFeedback({ tone: "err", message: t("codexAuth.ultraFastFailed") }); + } finally { + bounded.clear(); + savingRef.current = false; + setSaving(false); + } + }, [apiBase, enabled, hydrated, loadError, t]); + + const controlsDisabled = saving || !hydrated || loadError; + + return ( +
+
+ {t("codexAuth.ultraFastTitle")} +
+ {loadError ? t("codexAuth.ultraFastLoadFailed") : t("codexAuth.ultraFastDesc")} +
+
+
+ {loadError && ( + + )} + +
+ {feedback && ( +
+ {feedback.message} +
+ )} +
+ ); +} diff --git a/gui/src/components/codex-account-pool-main-card.tsx b/gui/src/components/codex-account-pool-main-card.tsx index dba055fa5a..d55094afd1 100644 --- a/gui/src/components/codex-account-pool-main-card.tsx +++ b/gui/src/components/codex-account-pool-main-card.tsx @@ -234,27 +234,85 @@ export function CodexAccountPoolPageHead({ )} - - + {/* + The two account-scoped actions used to live here, beside the page title. On the + standalone page that put four controls plus a heading on one row, and the actions + sat far above the account cards they act on. They render in + CodexAccountPoolActions below instead. The embedded surface keeps them inline, + because there is no title row there to crowd. + */} + {embedded && ( + + )} ); } +/** The pause/refresh pair, shared by the embedded head and the standalone action row. */ +export function CodexAccountPoolActionButtons({ + t, + refreshingQuota, + pausingExhausted, + pauseBusy, + onRefresh, + onPauseExhausted, +}: { + t: TFn; + refreshingQuota: boolean; + pausingExhausted: boolean; + pauseBusy?: boolean; + onRefresh: () => void; + onPauseExhausted: () => void; +}) { + return ( + <> + + + + ); +} + +/** + * Standalone-page action row: the pause/refresh pair, moved out of the page head and + * placed directly above the account cards they operate on. + */ +export function CodexAccountPoolActions(props: { + t: TFn; + refreshingQuota: boolean; + pausingExhausted: boolean; + pauseBusy?: boolean; + onRefresh: () => void; + onPauseExhausted: () => void; +}) { + return ( +
+ +
+ ); +} + export function CodexAccountPoolLoadStates({ t, loadState, diff --git a/gui/src/i18n/de.ts b/gui/src/i18n/de.ts index ab13c622f5..9045e81ef1 100644 --- a/gui/src/i18n/de.ts +++ b/gui/src/i18n/de.ts @@ -1180,6 +1180,12 @@ export const de: Record = { "codexAuth.sparkQuotaHidden": "Codex-Spark-Kontingent ausgeblendet", "codexAuth.sparkQuotaFailed": "Codex-Spark-Kontingent konnte nicht geändert werden", "codexAuth.refreshQuota": "Kontingente aktualisieren", + "codexAuth.ultraFastTitle": "Ultra-Fast-Diensttarif", + "codexAuth.ultraFastDesc": "Verhindert, dass ein selbst konfigurierter ultrafast-Diensttarif beim Neuaufbau des Katalogs entfernt wird, und benennt ihn in den Anfrageprotokollen. Ultra Fast wird nicht in die Modellauswahl aufgenommen: Upstream kündigt nur Fast an, ein Eintrag würde also eine Geschwindigkeit anbieten, die die Leitung nicht liefern kann.", + "codexAuth.ultraFastLoadFailed": "Die Ultra-Fast-Einstellung konnte nicht gelesen werden.", + "codexAuth.ultraFastEnabled": "Ultra-Fast-Tarif aktiviert", + "codexAuth.ultraFastDisabled": "Ultra-Fast-Tarif deaktiviert", + "codexAuth.ultraFastFailed": "Die Ultra-Fast-Einstellung konnte nicht geändert werden", "codexAuth.refreshingQuota": "Aktualisiere…", "codexAuth.quotaRefreshed": "Kontingente aktualisiert", "codexAuth.quotaRefreshFailed": "Kontingente konnten nicht aktualisiert werden", diff --git a/gui/src/i18n/en.ts b/gui/src/i18n/en.ts index 54882c2527..eba44bae1c 100644 --- a/gui/src/i18n/en.ts +++ b/gui/src/i18n/en.ts @@ -1722,6 +1722,12 @@ export const en = { "codexAuth.sparkQuotaHidden": "Codex Spark quota hidden", "codexAuth.sparkQuotaFailed": "Could not change the Codex Spark quota setting", "codexAuth.refreshQuota": "Refresh quotas", + "codexAuth.ultraFastTitle": "Ultra Fast service tier", + "codexAuth.ultraFastDesc": "Keeps an ultrafast service tier you configured yourself from being stripped when the catalog is regenerated, and names it in the request logs. It does not add Ultra Fast to the model picker: upstream advertises only Fast, so a picker row would offer a speed the wire cannot deliver.", + "codexAuth.ultraFastLoadFailed": "Could not read the Ultra Fast setting.", + "codexAuth.ultraFastEnabled": "Ultra Fast tier enabled", + "codexAuth.ultraFastDisabled": "Ultra Fast tier disabled", + "codexAuth.ultraFastFailed": "Could not change the Ultra Fast setting", "codexAuth.refreshingQuota": "Refreshing...", "codexAuth.quotaRefreshed": "Quotas refreshed", "codexAuth.quotaRefreshFailed": "Failed to refresh quotas", diff --git a/gui/src/i18n/fr.ts b/gui/src/i18n/fr.ts index fbdce1062d..56967c3ffb 100644 --- a/gui/src/i18n/fr.ts +++ b/gui/src/i18n/fr.ts @@ -1654,6 +1654,12 @@ export const fr: Record = { "codexAuth.sparkQuotaHidden": "Quota Codex Spark masqué", "codexAuth.sparkQuotaFailed": "Impossible de modifier le réglage du quota Codex Spark", "codexAuth.refreshQuota": "Actualiser les quotas", + "codexAuth.ultraFastTitle": "Niveau de service Ultra Fast", + "codexAuth.ultraFastDesc": "Empêche la suppression d’un niveau de service ultrafast que vous avez configuré vous-même lors de la régénération du catalogue, et le nomme dans les journaux de requêtes. Ultra Fast n’est pas ajouté au sélecteur de modèles : l’amont n’annonce que Fast, une entrée proposerait donc une vitesse que le transport ne peut pas fournir.", + "codexAuth.ultraFastLoadFailed": "Impossible de lire le réglage Ultra Fast.", + "codexAuth.ultraFastEnabled": "Niveau Ultra Fast activé", + "codexAuth.ultraFastDisabled": "Niveau Ultra Fast désactivé", + "codexAuth.ultraFastFailed": "Impossible de modifier le réglage Ultra Fast", "codexAuth.refreshingQuota": "Actualisation…", "codexAuth.quotaRefreshed": "Quotas actualisés", "codexAuth.quotaRefreshFailed": "Échec de l’actualisation des quotas", diff --git a/gui/src/i18n/ja.ts b/gui/src/i18n/ja.ts index 9f619578f0..6cde2b5610 100644 --- a/gui/src/i18n/ja.ts +++ b/gui/src/i18n/ja.ts @@ -1614,6 +1614,12 @@ export const ja: Record = { "codexAuth.sparkQuotaHidden": "Codex Spark 使用量を非表示にしました", "codexAuth.sparkQuotaFailed": "Codex Spark 使用量の設定を変更できませんでした", "codexAuth.refreshQuota": "クォータを更新", + "codexAuth.ultraFastTitle": "Ultra Fast サービスティア", + "codexAuth.ultraFastDesc": "自分で設定した ultrafast サービスティアがカタログ再生成時に削除されないようにし、リクエストログにそのティア名を記録します。モデルピッカーに Ultra Fast は追加しません。アップストリームは Fast しか公開しておらず、ピッカーに項目を出すと実際には出せない速度を選ばせることになるためです。", + "codexAuth.ultraFastLoadFailed": "Ultra Fast 設定を読み取れませんでした。", + "codexAuth.ultraFastEnabled": "Ultra Fast ティアを有効にしました", + "codexAuth.ultraFastDisabled": "Ultra Fast ティアを無効にしました", + "codexAuth.ultraFastFailed": "Ultra Fast 設定を変更できませんでした", "codexAuth.refreshingQuota": "更新中...", "codexAuth.quotaRefreshed": "クォータを更新しました", "codexAuth.quotaRefreshFailed": "クォータの更新に失敗しました", diff --git a/gui/src/i18n/ko.ts b/gui/src/i18n/ko.ts index 281dd3d8c4..237ca5ecda 100644 --- a/gui/src/i18n/ko.ts +++ b/gui/src/i18n/ko.ts @@ -1204,6 +1204,12 @@ export const ko: Record = { "codexAuth.sparkQuotaHidden": "Codex Spark 할당량을 숨겼습니다", "codexAuth.sparkQuotaFailed": "Codex Spark 할당량 설정을 바꾸지 못했습니다", "codexAuth.refreshQuota": "할당량 새로고침", + "codexAuth.ultraFastTitle": "Ultra Fast 서비스 티어", + "codexAuth.ultraFastDesc": "직접 설정한 ultrafast 서비스 티어가 카탈로그를 다시 만들 때 지워지지 않게 하고, 요청 로그에 그 티어 이름을 남깁니다. 모델 피커에 Ultra Fast를 추가하지는 않습니다. 업스트림은 Fast만 알리기 때문에, 피커에 칸을 만들면 실제로 낼 수 없는 속도를 고르게 하는 셈입니다.", + "codexAuth.ultraFastLoadFailed": "Ultra Fast 설정을 읽지 못했습니다.", + "codexAuth.ultraFastEnabled": "Ultra Fast 티어를 켰습니다", + "codexAuth.ultraFastDisabled": "Ultra Fast 티어를 껐습니다", + "codexAuth.ultraFastFailed": "Ultra Fast 설정을 바꾸지 못했습니다", "codexAuth.refreshingQuota": "새로고침 중...", "codexAuth.quotaRefreshed": "할당량을 다시 조회했습니다", "codexAuth.quotaRefreshFailed": "할당량 재조회에 실패했습니다", diff --git a/gui/src/i18n/ru.ts b/gui/src/i18n/ru.ts index 0bae1d4ff9..a1a16ebd75 100644 --- a/gui/src/i18n/ru.ts +++ b/gui/src/i18n/ru.ts @@ -1665,6 +1665,12 @@ export const ru: Record = { "codexAuth.sparkQuotaHidden": "Квота Codex Spark скрыта", "codexAuth.sparkQuotaFailed": "Не удалось изменить настройку квоты Codex Spark", "codexAuth.refreshQuota": "Обновить квоты", + "codexAuth.ultraFastTitle": "Уровень обслуживания Ultra Fast", + "codexAuth.ultraFastDesc": "Не даёт удалить настроенный вами уровень ultrafast при перегенерации каталога и записывает его имя в журналы запросов. Ultra Fast не добавляется в выбор моделей: вышестоящий сервис объявляет только Fast, поэтому пункт в списке предлагал бы скорость, которую канал не может обеспечить.", + "codexAuth.ultraFastLoadFailed": "Не удалось прочитать настройку Ultra Fast.", + "codexAuth.ultraFastEnabled": "Уровень Ultra Fast включён", + "codexAuth.ultraFastDisabled": "Уровень Ultra Fast выключен", + "codexAuth.ultraFastFailed": "Не удалось изменить настройку Ultra Fast", "codexAuth.refreshingQuota": "Обновление...", "codexAuth.quotaRefreshed": "Квоты обновлены", "codexAuth.quotaRefreshFailed": "Не удалось обновить квоты", diff --git a/gui/src/i18n/tr.ts b/gui/src/i18n/tr.ts index 25654fff76..09ffe51110 100644 --- a/gui/src/i18n/tr.ts +++ b/gui/src/i18n/tr.ts @@ -1683,6 +1683,12 @@ export const tr: Record = { "codexAuth.sparkQuotaHidden": "Codex Spark kotası gizlendi", "codexAuth.sparkQuotaFailed": "Codex Spark kotası ayarı değiştirilemedi", "codexAuth.refreshQuota": "Kotaları yenile", + "codexAuth.ultraFastTitle": "Ultra Fast hizmet katmanı", + "codexAuth.ultraFastDesc": "Kendi yapılandırdığınız ultrafast hizmet katmanının katalog yeniden oluşturulurken silinmesini önler ve istek günlüklerinde bu katmanın adını yazar. Ultra Fast’i model seçicisine eklemez: üst kaynak yalnızca Fast duyurur, bu yüzden bir satır eklemek hattın veremeyeceği bir hızı seçtirmek olurdu.", + "codexAuth.ultraFastLoadFailed": "Ultra Fast ayarı okunamadı.", + "codexAuth.ultraFastEnabled": "Ultra Fast katmanı etkinleştirildi", + "codexAuth.ultraFastDisabled": "Ultra Fast katmanı devre dışı bırakıldı", + "codexAuth.ultraFastFailed": "Ultra Fast ayarı değiştirilemedi", "codexAuth.refreshingQuota": "Yenileniyor...", "codexAuth.quotaRefreshed": "Kotalar yenilendi", "codexAuth.quotaRefreshFailed": "Kotalar yenilenemedi", diff --git a/gui/src/i18n/zh-TW.ts b/gui/src/i18n/zh-TW.ts index 21c2c5150f..4c63e861c4 100644 --- a/gui/src/i18n/zh-TW.ts +++ b/gui/src/i18n/zh-TW.ts @@ -1298,6 +1298,12 @@ export const zhTW: Record = { "codexAuth.sparkQuotaHidden": "已隱藏 Codex Spark 配額", "codexAuth.sparkQuotaFailed": "無法變更 Codex Spark 配額設定", "codexAuth.refreshQuota": "重新整理額度", + "codexAuth.ultraFastTitle": "Ultra Fast 服務層級", + "codexAuth.ultraFastDesc": "讓你自行設定的 ultrafast 服務層級在重新產生目錄時不被移除,並在請求記錄中寫下該層級名稱。它不會把 Ultra Fast 加入模型選擇器:上游只公布 Fast,選擇器出現該項等於讓使用者挑一個實際無法提供的速度。", + "codexAuth.ultraFastLoadFailed": "無法讀取 Ultra Fast 設定。", + "codexAuth.ultraFastEnabled": "已啟用 Ultra Fast 層級", + "codexAuth.ultraFastDisabled": "已停用 Ultra Fast 層級", + "codexAuth.ultraFastFailed": "無法變更 Ultra Fast 設定", "codexAuth.refreshingQuota": "重新整理中...", "codexAuth.quotaRefreshed": "額度已重新整理", "codexAuth.quotaRefreshFailed": "額度重新整理失敗", diff --git a/gui/src/i18n/zh.ts b/gui/src/i18n/zh.ts index 2223a66a20..4f338fdcd9 100644 --- a/gui/src/i18n/zh.ts +++ b/gui/src/i18n/zh.ts @@ -1197,6 +1197,12 @@ export const zh: Record = { "codexAuth.sparkQuotaHidden": "已隐藏 Codex Spark 配额", "codexAuth.sparkQuotaFailed": "无法更改 Codex Spark 配额设置", "codexAuth.refreshQuota": "刷新额度", + "codexAuth.ultraFastTitle": "Ultra Fast 服务层级", + "codexAuth.ultraFastDesc": "让你自己配置的 ultrafast 服务层级在重新生成目录时不被删除,并在请求日志中记录该层级名称。它不会把 Ultra Fast 加入模型选择器:上游只公布 Fast,选择器中出现该项等于让用户选择一个实际无法提供的速度。", + "codexAuth.ultraFastLoadFailed": "无法读取 Ultra Fast 设置。", + "codexAuth.ultraFastEnabled": "已启用 Ultra Fast 层级", + "codexAuth.ultraFastDisabled": "已禁用 Ultra Fast 层级", + "codexAuth.ultraFastFailed": "无法更改 Ultra Fast 设置", "codexAuth.refreshingQuota": "刷新中...", "codexAuth.quotaRefreshed": "额度已刷新", "codexAuth.quotaRefreshFailed": "额度刷新失败", diff --git a/gui/src/pages/codex-set-multiauth.tsx b/gui/src/pages/codex-set-multiauth.tsx index c0c4b56b95..8f76f91af5 100644 --- a/gui/src/pages/codex-set-multiauth.tsx +++ b/gui/src/pages/codex-set-multiauth.tsx @@ -2,6 +2,7 @@ import { useCallback, useEffect, useRef, useState } from "react"; import { useT } from "../i18n/shared"; import CodexAccountPool from "../components/CodexAccountPool"; import DefaultModeRequestUserInputSetting from "../components/DefaultModeRequestUserInputSetting"; +import UltraFastTierSetting from "../components/UltraFastTierSetting"; import CodexAccountPickerSetting from "../components/CodexAccountPickerSetting"; import { codexAccountModeState, type CodexAccountModeState } from "../codex-multi-state"; import { navigateHash } from "../hash-routing"; @@ -196,6 +197,7 @@ export default function CodexSetMultiauth({ apiBase }: { apiBase: string }) { advancedExtras={<> + } /> diff --git a/gui/src/styles.css b/gui/src/styles.css index 1c5147171b..027b055c3a 100644 --- a/gui/src/styles.css +++ b/gui/src/styles.css @@ -1618,6 +1618,13 @@ dialog.modal-overlay::backdrop { .codex-request-user-input-controls > .toggle { margin-left: auto; } .codex-request-user-input-feedback { flex: 1 0 100%; margin-top: -8px; color: var(--muted); font-size: var(--text-label); line-height: var(--leading-body); text-align: right; } .codex-request-user-input-feedback.is-error { color: var(--red); } +/* Ultra Fast opt-in: same card-row idiom as the picker and request-user-input switches. */ +.codex-ultrafast-card { gap: 16px; flex-wrap: wrap; } +.codex-ultrafast-copy { flex: 1 1 auto; min-width: 0; } +.codex-ultrafast-copy .card-sub { padding: 2px 0 0; } +.codex-ultrafast-controls { display: flex; align-items: flex-end; gap: 12px; flex: 0 0 auto; margin-left: auto; } +.codex-ultrafast-feedback { flex: 1 0 100%; margin-top: -8px; color: var(--muted); font-size: var(--text-label); line-height: var(--leading-body); text-align: right; } +.codex-ultrafast-feedback.is-error { color: var(--red); } .codex-account-picker-card { gap: 16px; flex-wrap: wrap; margin-top: 16px; } .codex-auth-advanced { margin-top: 8px; } .codex-auth-advanced__toggle { @@ -1809,6 +1816,17 @@ dialog.modal-overlay::backdrop { font-size: 12px; color: var(--muted); } +/* The pause/refresh pair after it moved out of the page head. Right-aligned so it still + reads as chrome for the account list below rather than a heading of its own, and it + wraps because both labels grow substantially in several locales. */ +.codex-auth-actions-row { + display: flex; + align-items: center; + flex-wrap: wrap; + justify-content: flex-end; + gap: 8px; + margin-bottom: 10px; +} /* Account actions (pause / copy doctor / pause-exhausted): clearer hover than plain btn-ghost on card surface — same raised-hover + faint border as icon/list cues. */ .codex-auth-action-btn:hover:not(:disabled) { diff --git a/gui/tests/codex-set-actions-relocation.test.ts b/gui/tests/codex-set-actions-relocation.test.ts new file mode 100644 index 0000000000..30e40a5ef4 --- /dev/null +++ b/gui/tests/codex-set-actions-relocation.test.ts @@ -0,0 +1,64 @@ +/** + * Where the Codex Set account actions live. + * + * "한도 도달 계정 일시 중지" and "할당량 새로고침" used to sit in the page head beside the + * title and the Spark toggle — four controls and a heading on one row, with the actions + * far above the account cards they operate on. They render in their own row below the + * account-mode banner now. + * + * The embedded surface is deliberately excluded: in the Providers workspace the same + * component renders a bare `.row` with no title, so there is nothing to crowd and the + * buttons stay inline. + */ +import { expect, test } from "bun:test"; + +const raw = await Bun.file(new URL("../src/components/codex-account-pool-main-card.tsx", import.meta.url)).text(); +// Comments name these controls; matching prose is not evidence about code. +const src = raw.replace(/\/\*[\s\S]*?\*\//g, "").replace(/^\s*\/\/.*$/gm, ""); +const pool = (await Bun.file(new URL("../src/components/CodexAccountPool.tsx", import.meta.url)).text()) + .replace(/\/\*[\s\S]*?\*\//g, "").replace(/^\s*\/\/.*$/gm, ""); +const css = await Bun.file(new URL("../src/styles.css", import.meta.url)).text(); + +const headStart = src.indexOf("export function CodexAccountPoolPageHead"); +const head = src.slice(headStart, src.indexOf("export function CodexAccountPoolActionButtons", headStart)); + +test("the standalone page head no longer renders the two action buttons inline", () => { + // The head keeps the title, the feedback region and the Spark toggle; the pause and + // refresh labels are reached through the shared component only. + expect(head).not.toContain('t("codexAuth.pauseExhausted")'); + expect(head).not.toContain('t("codexAuth.refreshQuota")'); +}); + +test("the embedded surface keeps them inline, because it has no title row to crowd", () => { + expect(head).toContain("embedded && ("); + expect(head).toContain("CodexAccountPoolActionButtons"); +}); + +test("the standalone page renders them in their own row instead", () => { + expect(pool).toContain("!embedded && ("); + expect(pool).toContain("CodexAccountPoolActions"); +}); + +test("both buttons still exist and keep their disabled contract", () => { + const shared = src.slice(src.indexOf("export function CodexAccountPoolActionButtons")); + expect(shared).toContain('t("codexAuth.pauseExhausted")'); + expect(shared).toContain('t("codexAuth.refreshQuota")'); + // A refresh in flight must not let a second pause/refresh start beside it. + expect(shared.match(/disabled=\{refreshingQuota \|\| pausingExhausted \|\| !!pauseBusy\}/g)?.length).toBe(2); +}); + +test("the head classes the CSS tests pin are still rendered", () => { + // codex-set-page-head-wrap.test.ts throws "rule not found" if these disappear, and the + // toast-tone suite queries the feedback span by class. + expect(head).toContain("page-head codex-auth-page-head"); + expect(head).toContain("codex-auth-page-head__actions"); + expect(head).toContain("codex-auth-page-head__feedback"); +}); + +test("the relocated row has a layout rule that wraps", () => { + const start = css.indexOf(".codex-auth-actions-row {"); + expect(start).toBeGreaterThan(-1); + const rule = css.slice(start, css.indexOf("}", start)); + expect(rule).toContain("flex-wrap: wrap"); + expect(rule).toContain("justify-content: flex-end"); +}); diff --git a/scripts/model-metadata.source.json b/scripts/model-metadata.source.json index 8cc73ca1c3..e171710cb6 100644 --- a/scripts/model-metadata.source.json +++ b/scripts/model-metadata.source.json @@ -3709,6 +3709,34 @@ "toolChoiceSupport": "auto" } }, + "claude-fable-5-1": { + "id": "claude-fable-5-1", + "name": "Anthropic Fable 5.1", + "api": "anthropic-messages", + "provider": "anthropic", + "baseUrl": "https://api.anthropic.com", + "reasoning": true, + "input": [ + "text", + "image" + ], + "cost": { + "input": 10, + "output": 50, + "cacheRead": 0.25, + "cacheWrite": 12.5 + }, + "contextWindow": 1000000, + "maxTokens": 128000, + "thinking": { + "mode": "anthropic-adaptive", + "minLevel": "minimal", + "maxLevel": "xhigh" + }, + "compat": { + "toolChoiceSupport": "auto" + } + }, "claude-haiku-4-5": { "id": "claude-haiku-4-5", "name": "Anthropic Haiku 4.5 (latest)", diff --git a/src/codex/catalog/parsing.ts b/src/codex/catalog/parsing.ts index fecf49c2dd..df05f6a287 100644 --- a/src/codex/catalog/parsing.ts +++ b/src/codex/catalog/parsing.ts @@ -2,7 +2,7 @@ import { execFileSync } from "node:child_process"; import { createHash } from "node:crypto"; import { copyFileSync, existsSync, mkdirSync, readFileSync, realpathSync } from "node:fs"; import { delimiter, dirname, join, resolve } from "node:path"; -import { atomicWriteFile, expandUserPath, getConfigDir, websocketsEnabled } from "../../config"; +import { atomicWriteFile, expandUserPath, getConfigDir, loadConfig, ultraFastTierEnabled, websocketsEnabled } from "../../config"; import { CODEX_CONFIG_PATH, CODEX_MODELS_CACHE_PATH, DEFAULT_CATALOG_PATH, readRootTomlString, resolveCodexConfigPath } from "../paths"; import { clearModelCache, DEFAULT_MODEL_CACHE_TTL_MS, getFreshCached, getStaleCached, isModelsFetchCoolingDown, markModelsFetchFailure, setCached } from "../model-cache"; import { buildModelsRequest, resolveModelsAuthToken } from "../../oauth"; @@ -303,6 +303,87 @@ const NO_FAST_TIER_NATIVE_SLUGS = new Set([ "gpt-5.3-codex-spark", ]); +/** Does this row already carry an `ultrafast` tier the operator put there themselves? */ +/** + * Read the opt-in from the live config, ONCE per catalog build. + * + * `deriveEntry` and its call sites are pure `RawEntry -> RawEntry` transforms with no + * config parameter, so the flag is resolved here rather than threaded through all of them. + * It is memoized because `normalizeRoutedCatalogEntry` runs per entry in a sync loop, and + * `loadConfig()` chmods the config dir, hardens three secrets, reads the file and runs a + * full Zod parse — doing that once per catalog row would be a real cost for one boolean. + * Callers holding a config still pass `opts.ultraFastTier` explicitly, which bypasses this + * entirely and is what the tests do. A read failure means OFF, matching `.catch(false)`. + */ +let ultraFastOptInCache: { value: boolean; at: number } | null = null; +const ULTRA_FAST_OPT_IN_TTL_MS = 5_000; + +function ultraFastTierOptIn(): boolean { + const now = Date.now(); + if (ultraFastOptInCache && now - ultraFastOptInCache.at < ULTRA_FAST_OPT_IN_TTL_MS) { + return ultraFastOptInCache.value; + } + let value = false; + try { + value = ultraFastTierEnabled(loadConfig()); + } catch { + value = false; + } + ultraFastOptInCache = { value, at: now }; + return value; +} + +/** Test seam: drop the memoized opt-in so a config change is observed immediately. */ +export function resetUltraFastTierOptInCache(): void { + ultraFastOptInCache = null; +} + +function entryDeclaresUltraFast(entry: RawEntry): boolean { + const tiers = entry.service_tiers; + const declaredInTiers = Array.isArray(tiers) && tiers.some(tier => ( + !!tier && typeof tier === "object" && "id" in tier + && String((tier as { id?: unknown }).id).trim().toLowerCase() === "ultrafast" + )); + const speeds = entry.additional_speed_tiers; + const declaredInSpeeds = Array.isArray(speeds) && speeds.some(speed => ( + typeof speed === "string" && speed.trim().toLowerCase() === "ultrafast" + )); + return declaredInTiers || declaredInSpeeds; +} + +/** + * Keep the operator's `ultrafast` and drop everything else. + * + * A routed row must not inherit the native template's `priority` tier, which is the whole + * reason this strip exists. Preserving the supplied tier without this narrowing would + * smuggle Fast onto third-party providers under an unrelated flag. + */ +function retainOnlyUltraFastTier(entry: RawEntry): void { + const tiers = entry.service_tiers; + const keptTiers = Array.isArray(tiers) + ? tiers.filter(tier => ( + !!tier && typeof tier === "object" && "id" in tier + && String((tier as { id?: unknown }).id).trim().toLowerCase() === "ultrafast" + )) + : []; + if (keptTiers.length > 0) entry.service_tiers = keptTiers; + else delete entry.service_tiers; + + const speeds = entry.additional_speed_tiers; + const keptSpeeds = Array.isArray(speeds) + ? speeds.filter(speed => typeof speed === "string" && speed.trim().toLowerCase() === "ultrafast") + : []; + if (keptSpeeds.length > 0) entry.additional_speed_tiers = keptSpeeds; + else delete entry.additional_speed_tiers; + + // A default of `priority` on a row that now only offers ultrafast would name a tier the + // row no longer carries. + if (String(entry.service_tier ?? "").trim().toLowerCase() !== "ultrafast") delete entry.service_tier; + if (String(entry.default_service_tier ?? "").trim().toLowerCase() !== "ultrafast") { + delete entry.default_service_tier; + } +} + export function normalizeServiceTiers(entry: RawEntry): RawEntry { // Strip service tiers for models that do not actually support the Fast tier. if (typeof entry.slug === "string" && NO_FAST_TIER_NATIVE_SLUGS.has(entry.slug)) { @@ -612,6 +693,7 @@ export function normalizeRoutedCatalogEntry( entry: RawEntry, parallelToolCalls = false, toolMode?: "code_mode_only" | "shell" | string, + opts?: { ultraFastTier?: boolean }, ): RawEntry { delete entry.model_messages; delete entry.tool_mode; @@ -619,10 +701,25 @@ export function normalizeRoutedCatalogEntry( delete entry.multi_agent_version; delete entry.use_responses_lite; delete entry.supports_websockets; - delete entry.additional_speed_tiers; - delete entry.service_tier; - delete entry.service_tiers; - delete entry.default_service_tier; + /* + * Tier metadata is stripped from routed rows because a row cloned from a native template + * would otherwise hand a third-party provider OpenAI's tiers. + * + * The opt-in carves out exactly one case: an `ultrafast` the OPERATOR put in their own + * catalog. #3429's reporter added it by hand and watched a regeneration delete it every + * time. Preserving what they wrote is not the same as advertising a tier — upstream + * publishes only `priority`, and synthesizing an ultrafast row is what PR #2994 was + * closed for. So this keeps a supplied tier and still never invents one. + */ + const keepUltraFast = (opts?.ultraFastTier ?? ultraFastTierOptIn()) && entryDeclaresUltraFast(entry); + if (!keepUltraFast) { + delete entry.additional_speed_tiers; + delete entry.service_tier; + delete entry.service_tiers; + delete entry.default_service_tier; + } else { + retainOnlyUltraFastTier(entry); + } // Routed rows cloned from native templates must not inherit OpenAI-only summary delivery. // Explicit provider/model metadata is re-applied after this normalization step. delete entry.supports_reasoning_summaries; diff --git a/src/config.ts b/src/config.ts index d69d292c8f..2a1af85020 100644 --- a/src/config.ts +++ b/src/config.ts @@ -1053,6 +1053,9 @@ const configSchema = z.object({ // Same opt-in discipline: a malformed hand edit degrades to off rather than rejecting // every provider. fastRows: z.boolean().optional().catch(false), + // Ultra Fast is opt-in for the same reason and degrades the same way: a malformed hand + // edit turns the tier off rather than rejecting the config that carries it. + ultraFastTier: z.boolean().optional().catch(false), // Future versions remain opaque through passthrough-compatible whole-config saves. // Only version 1 grants deletion authority in the rebase path. configRebaseProvenance: z.unknown().optional(), @@ -3115,6 +3118,14 @@ export function websocketsEnabled(config: Pick): boolea return config.websockets === true; } +/** + * Opt-in Ultra Fast, read with the house `=== true` idiom so an absent key and a + * malformed one both mean off. + */ +export function ultraFastTierEnabled(config: Pick): boolean { + return config.ultraFastTier === true; +} + // --------------------------------------------------------------------------- // Hand-edit protection for the `claudeCode` subtree (devlog 260726_claude_auth_auto/040 H1). // diff --git a/src/generated/model-metadata.ts b/src/generated/model-metadata.ts index 73b9dee5d5..662cf6f1a7 100644 --- a/src/generated/model-metadata.ts +++ b/src/generated/model-metadata.ts @@ -38,7 +38,7 @@ const PROVIDER_ALIASES: Record = { type Row = readonly [id: string, contextWindow?: number | null, maxTokens?: number | null, input?: string | null, reasoning?: 0 | 1 | null, wireModelId?: string | null, costInput?: number | null, costOutput?: number | null, costCacheRead?: number | null, costCacheWrite?: number | null]; const DATA: Record = { "amazon-bedrock": [["anthropic.claude-3-5-haiku-20241022-v1:0",200000,8192,"text,image",0,null,0.8,4,0.08,1],["anthropic.claude-3-5-sonnet-20240620-v1:0",200000,8192,"text,image",0,null,3,15,0.3,3.75],["anthropic.claude-3-5-sonnet-20241022-v2:0",200000,8192,"text,image",0,null,3,15,0.3,3.75],["anthropic.claude-3-haiku-20240307-v1:0",200000,4096,"text,image",0,null,0.25,1.25,0,0],["anthropic.claude-3-opus-20240229-v1:0",200000,4096,"text,image",0,null,15,75,0,0],["anthropic.claude-3-sonnet-20240229-v1:0",200000,4096,"text,image",0,null,3,15,0,0],["anthropic.claude-fable-5",1000000,128000,"text,image",1,null,10,50,1,12.5],["anthropic.claude-opus-4-6-v1",1000000,128000,"text,image",1,null,5,25,0.5,6.25],["anthropic.claude-opus-4-7",1000000,128000,"text,image",1,null,5,25,0.5,6.25],["anthropic.claude-opus-4-8",1000000,128000,"text,image",1,null,5,25,0.5,6.25],["anthropic.claude-opus-5",1000000,128000,"text,image",1,null,5,25,0.5,6.25],["anthropic.claude-sonnet-5",1000000,128000,"text,image",1,null,2,10,0.2,2.5],["au.anthropic.claude-haiku-4-5-20251001-v1:0",200000,64000,"text,image",1,null,1,5,0.1,1.25],["au.anthropic.claude-opus-4-6-v1",1000000,128000,"text,image",1,null,16.5,82.5,0.5,6.25],["au.anthropic.claude-opus-4-8",1000000,128000,"text,image",1,null,5,25,0.5,6.25],["au.anthropic.claude-opus-5",1000000,128000,"text,image",1,null,5,25,0.5,6.25],["au.anthropic.claude-sonnet-4-5-20250929-v1:0",200000,64000,"text,image",1,null,3,15,0.3,3.75],["au.anthropic.claude-sonnet-4-6",1000000,128000,"text,image",1,null,3.3,16.5,0.33,4.125],["au.anthropic.claude-sonnet-5",1000000,128000,"text,image",1,null,2,10,0.2,2.5],["cohere.command-r-plus-v1:0",128000,4096,"text",0,null,3,15,0,0],["cohere.command-r-v1:0",128000,4096,"text",0,null,0.5,1.5,0,0],["deepseek.v3-v1:0",163840,81920,"text",1,null,0.58,1.68,0,0],["deepseek.v3.2",163840,81920,"text",1,null,0.62,1.85,0,0],["deepseek.v3.2-v1:0",163840,81920,"text",1,null,0.62,1.85,0,0],["eu.anthropic.claude-3-5-haiku-20241022-v1:0",200000,8192,"text,image",0,null,0.8,4,0.08,1],["eu.anthropic.claude-3-5-sonnet-20240620-v1:0",200000,8192,"text,image",0,null,3,15,0.3,3.75],["eu.anthropic.claude-3-5-sonnet-20241022-v2:0",200000,8192,"text,image",0,null,3,15,0.3,3.75],["eu.anthropic.claude-3-7-sonnet-20250219-v1:0",200000,8192,"text,image",0,null,3,15,0.3,3.75],["eu.anthropic.claude-3-haiku-20240307-v1:0",200000,4096,"text,image",0,null,0.25,1.25,0,0],["eu.anthropic.claude-3-opus-20240229-v1:0",200000,4096,"text,image",0,null,15,75,0,0],["eu.anthropic.claude-3-sonnet-20240229-v1:0",200000,4096,"text,image",0,null,3,15,0,0],["eu.anthropic.claude-fable-5",1000000,128000,"text,image",1,null,10,50,1,12.5],["eu.anthropic.claude-haiku-4-5-20251001-v1:0",200000,64000,"text,image",1,null,1.1,5.5,0.11,1.375],["eu.anthropic.claude-opus-4-1-20250805-v1:0",200000,32000,"text,image",1,null,15,75,1.5,18.75],["eu.anthropic.claude-opus-4-20250514-v1:0",200000,32000,"text,image",1,null,15,75,1.5,18.75],["eu.anthropic.claude-opus-4-5-20251101-v1:0",200000,64000,"text,image",1,null,5.5,27.5,0.55,6.875],["eu.anthropic.claude-opus-4-6-v1",1000000,128000,"text,image",1,null,5,25,0.5,6.25],["eu.anthropic.claude-opus-4-7",1000000,128000,"text,image",1,null,5.5,27.5,0.55,6.875],["eu.anthropic.claude-opus-4-8",1000000,128000,"text,image",1,null,5.5,27.5,0.55,6.875],["eu.anthropic.claude-opus-5",1000000,128000,"text,image",1,null,5.5,27.5,0.55,6.875],["eu.anthropic.claude-sonnet-4-20250514-v1:0",200000,64000,"text,image",1,null,3,15,0.3,3.75],["eu.anthropic.claude-sonnet-4-5-20250929-v1:0",200000,64000,"text,image",1,null,3,15,0.3,3.75],["eu.anthropic.claude-sonnet-4-6",1000000,64000,"text,image",1,null,3,15,0.3,3.75],["eu.anthropic.claude-sonnet-5",1000000,128000,"text,image",1,null,2,10,0.2,2.5],["global.amazon.nova-2-lite-v1:0",128000,4096,"text,image",1,null,0.33,2.75,0,0],["global.anthropic.claude-fable-5",1000000,128000,"text,image",1,null,10,50,1,12.5],["global.anthropic.claude-haiku-4-5-20251001-v1:0",200000,64000,"text,image",1,null,1,5,0.1,1.25],["global.anthropic.claude-opus-4-5-20251101-v1:0",200000,64000,"text,image",1,null,5,25,0.5,6.25],["global.anthropic.claude-opus-4-6-v1",1000000,128000,"text,image",1,null,5,25,0.5,6.25],["global.anthropic.claude-opus-4-7",1000000,128000,"text,image",1,null,5,25,0.5,6.25],["global.anthropic.claude-opus-4-8",1000000,128000,"text,image",1,null,5,25,0.5,6.25],["global.anthropic.claude-opus-5",1000000,128000,"text,image",1,null,5,25,0.5,6.25],["global.anthropic.claude-sonnet-4-20250514-v1:0",200000,64000,"text,image",1,null,3,15,0.3,3.75],["global.anthropic.claude-sonnet-4-5-20250929-v1:0",200000,64000,"text,image",1,null,3,15,0.3,3.75],["global.anthropic.claude-sonnet-4-6",1000000,64000,"text,image",1,null,3,15,0.3,3.75],["global.anthropic.claude-sonnet-5",1000000,128000,"text,image",1,null,2,10,0.2,2.5],["google.gemma-3-27b-it",202752,8192,"text,image",0,null,0.12,0.2,0,0],["google.gemma-3-4b-it",128000,4096,"text,image",0,null,0.04,0.08,0,0],["jp.anthropic.claude-haiku-4-5-20251001-v1:0",200000,64000,"text,image",1,null,1,5,0.1,1.25],["jp.anthropic.claude-opus-4-7",1000000,128000,"text,image",1,null,5,25,0.5,6.25],["jp.anthropic.claude-opus-4-8",1000000,128000,"text,image",1,null,5,25,0.5,6.25],["jp.anthropic.claude-opus-5",1000000,128000,"text,image",1,null,5,25,0.5,6.25],["jp.anthropic.claude-sonnet-4-5-20250929-v1:0",200000,64000,"text,image",1,null,3,15,0.3,3.75],["jp.anthropic.claude-sonnet-4-6",1000000,64000,"text,image",1,null,3,15,0.3,3.75],["jp.anthropic.claude-sonnet-5",1000000,128000,"text,image",1,null,2,10,0.2,2.5],["meta.llama3-1-405b-instruct-v1:0",128000,4096,"text",0,null,2.4,2.4,0,0],["meta.llama3-1-70b-instruct-v1:0",128000,4096,"text",0,null,0.72,0.72,0,0],["meta.llama3-1-8b-instruct-v1:0",128000,4096,"text",0,null,0.22,0.22,0,0],["minimax.minimax-m2",204608,128000,"text",1,null,0.3,1.2,0,0],["minimax.minimax-m2.1",204800,131072,"text",1,null,0.3,1.2,0,0],["minimax.minimax-m2.5",196608,98304,"text",1,null,0.3,1.2,0,0],["mistral.devstral-2-123b",256000,8192,"text",0,null,0.4,2,0,0],["mistral.magistral-small-2509",128000,40000,"text,image",1,null,0.5,1.5,0,0],["mistral.ministral-3-14b-instruct",128000,4096,"text",0,null,0.2,0.2,0,0],["mistral.ministral-3-3b-instruct",256000,8192,"text,image",0,null,0.1,0.1,0,0],["mistral.ministral-3-8b-instruct",128000,4096,"text",0,null,0.15,0.15,0,0],["mistral.mistral-large-2402-v1:0",128000,4096,"text",0,null,0.5,1.5,0,0],["mistral.mistral-large-3-675b-instruct",256000,8192,"text,image",0,null,0.5,1.5,0,0],["mistral.pixtral-large-2502-v1:0",128000,8192,"text,image",0,null,2,6,0,0],["mistral.voxtral-mini-3b-2507",128000,4096,"text",0,null,0.04,0.04,0,0],["mistral.voxtral-small-24b-2507",32000,8192,"text",0,null,0.15,0.35,0,0],["moonshot.kimi-k2-thinking",262143,16000,"text",1,null,0.6,2.5,0,0],["moonshotai.kimi-k2.5",262143,16000,"text,image",1,null,0.6,3,0,0],["nvidia.nemotron-nano-12b-v2",128000,4096,"text,image",0,null,0.2,0.6,0,0],["nvidia.nemotron-nano-3-30b",128000,4096,"text",1,null,0.06,0.24,0,0],["nvidia.nemotron-nano-9b-v2",128000,4096,"text",0,null,0.06,0.23,0,0],["nvidia.nemotron-super-3-120b",262144,131072,"text",1,null,0.15,0.65,0,0],["openai.gpt-5.4",272000,128000,"text,image",1,null,2.75,16.5,0.275,0],["openai.gpt-5.5",272000,128000,"text,image",1,null,5.5,33,0.55,0],["openai.gpt-5.6-luna",373000,128000,"text,image",1,null,1,6,0.1,1.25],["openai.gpt-5.6-sol",373000,128000,"text,image",1,null,5,30,0.5,6.25],["openai.gpt-5.6-terra",373000,128000,"text,image",1,null,2.5,15,0.25,3.125],["openai.gpt-oss-120b",128000,16384,"text",1,null,0.15,0.6,0,0],["openai.gpt-oss-120b-1:0",128000,16384,"text",1,null,0.15,0.6,0,0],["openai.gpt-oss-20b",128000,16384,"text",1,null,0.07,0.3,0,0],["openai.gpt-oss-20b-1:0",128000,16384,"text",1,null,0.07,0.3,0,0],["openai.gpt-oss-safeguard-120b",128000,16384,"text",0,null,0.15,0.6,0,0],["openai.gpt-oss-safeguard-20b",128000,16384,"text",0,null,0.07,0.2,0,0],["qwen.qwen3-235b-a22b-2507-v1:0",262144,131072,"text",0,null,0.22,0.88,0,0],["qwen.qwen3-32b-v1:0",16384,16384,"text",1,null,0.15,0.6,0,0],["qwen.qwen3-coder-30b-a3b-v1:0",262144,131072,"text",0,null,0.15,0.6,0,0],["qwen.qwen3-coder-480b-a35b-v1:0",131072,65536,"text",0,null,0.22,1.8,0,0],["qwen.qwen3-coder-next",131072,65536,"text",1,null,0.22,1.8,0,0],["qwen.qwen3-next-80b-a3b",262000,262000,"text",0,null,0.14,1.4,0,0],["qwen.qwen3-vl-235b-a22b",262000,262000,"text,image",0,null,0.3,1.5,0,0],["us.amazon.nova-lite-v1:0",300000,8192,"text,image",0,null,0.06,0.24,0.015,0],["us.amazon.nova-micro-v1:0",128000,8192,"text",0,null,0.035,0.14,0.00875,0],["us.amazon.nova-premier-v1:0",1000000,16384,"text,image",1,null,2.5,12.5,0,0],["us.amazon.nova-pro-v1:0",300000,8192,"text,image",0,null,0.8,3.2,0.2,0],["us.anthropic.claude-3-7-sonnet-20250219-v1:0",200000,8192,"text,image",0,null,3,15,0.3,3.75],["us.anthropic.claude-fable-5",1000000,128000,"text,image",1,null,10,50,1,12.5],["us.anthropic.claude-haiku-4-5-20251001-v1:0",200000,64000,"text,image",1,null,1,5,0.1,1.25],["us.anthropic.claude-opus-4-1-20250805-v1:0",200000,32000,"text,image",1,null,15,75,1.5,18.75],["us.anthropic.claude-opus-4-20250514-v1:0",200000,32000,"text,image",1,null,15,75,1.5,18.75],["us.anthropic.claude-opus-4-5-20251101-v1:0",200000,64000,"text,image",1,null,5,25,0.5,6.25],["us.anthropic.claude-opus-4-6-v1",1000000,128000,"text,image",1,null,5,25,0.5,6.25],["us.anthropic.claude-opus-4-7",1000000,128000,"text,image",1,null,5,25,0.5,6.25],["us.anthropic.claude-opus-4-8",1000000,128000,"text,image",1,null,5,25,0.5,6.25],["us.anthropic.claude-opus-5",1000000,128000,"text,image",1,null,5,25,0.5,6.25],["us.anthropic.claude-sonnet-4-20250514-v1:0",200000,64000,"text,image",1,null,3,15,0.3,3.75],["us.anthropic.claude-sonnet-4-5-20250929-v1:0",200000,64000,"text,image",1,null,3,15,0.3,3.75],["us.anthropic.claude-sonnet-4-6",1000000,64000,"text,image",1,null,3,15,0.3,3.75],["us.anthropic.claude-sonnet-5",1000000,128000,"text,image",1,null,2,10,0.2,2.5],["us.deepseek.r1-v1:0",128000,32768,"text",1,null,1.35,5.4,0,0],["us.meta.llama3-2-11b-instruct-v1:0",128000,4096,"text,image",0,null,0.16,0.16,0,0],["us.meta.llama3-2-1b-instruct-v1:0",131000,4096,"text",0,null,0.1,0.1,0,0],["us.meta.llama3-2-3b-instruct-v1:0",131000,4096,"text",0,null,0.15,0.15,0,0],["us.meta.llama3-2-90b-instruct-v1:0",128000,4096,"text,image",0,null,0.72,0.72,0,0],["us.meta.llama3-3-70b-instruct-v1:0",128000,4096,"text",0,null,0.72,0.72,0,0],["us.meta.llama4-maverick-17b-instruct-v1:0",1000000,16384,"text,image",0,null,0.24,0.97,0,0],["us.meta.llama4-scout-17b-instruct-v1:0",3500000,16384,"text,image",0,null,0.17,0.66,0,0],["writer.palmyra-x4-v1:0",122880,8192,"text",1,null,2.5,10,0,0],["writer.palmyra-x5-v1:0",1040000,8192,"text",1,null,0.6,6,0,0],["xai.grok-4.3",1000000,131072,"text,image",1,null,1.25,2.5,0.2,0],["zai.glm-4.7",204800,131072,"text",1,null,0.6,2.2,0,0],["zai.glm-4.7-flash",200000,131072,"text",1,null,0.07,0.4,0,0],["zai.glm-5",202752,101376,"text",1,null,1,3.2,0,0]], - "anthropic": [["claude-3-5-sonnet-20240620",200000,8192,"text,image",0,null,3,15,0.3,3.75],["claude-3-5-sonnet-20241022",200000,8192,"text,image",0,null,3,15,0.3,3.75],["claude-3-haiku-20240307",200000,4096,"text,image",0,null,0.25,1.25,0.03,0.3],["claude-fable-5",1000000,128000,"text,image",1,null,10,50,1,12.5],["claude-haiku-4-5",200000,64000,"text,image",1,null,1,5,0.1,1.25],["claude-haiku-4-5-20251001",200000,64000,"text,image",1,null,1,5,0.1,1.25],["claude-opus-4-0",200000,32000,"text,image",1,null,15,75,1.5,18.75],["claude-opus-4-1",200000,32000,"text,image",1,null,15,75,1.5,18.75],["claude-opus-4-1-20250805",200000,32000,"text,image",1,null,15,75,1.5,18.75],["claude-opus-4-20250514",200000,32000,"text,image",1,null,15,75,1.5,18.75],["claude-opus-4-5",200000,64000,"text,image",1,null,5,25,0.5,6.25],["claude-opus-4-5-20251101",200000,64000,"text,image",1,null,5,25,0.5,6.25],["claude-opus-4-6",1000000,128000,"text,image",1,null,5,25,0.5,6.25],["claude-opus-4-6[1m]",1000000,128000,"text,image",1,"claude-opus-4-6",5,25,0.5,6.25],["claude-opus-4-7",1000000,128000,"text,image",1,null,5,25,0.5,6.25],["claude-opus-4-7[1m]",1000000,128000,"text,image",1,"claude-opus-4-7",5,25,0.5,6.25],["claude-opus-4-8",1000000,128000,"text,image",1,null,5,25,0.5,6.25],["claude-opus-4-8[1m]",1000000,128000,"text,image",1,"claude-opus-4-8",5,25,0.5,6.25],["claude-opus-5",1000000,128000,"text,image",1,null,5,25,0.5,6.25],["claude-sonnet-4-0",200000,64000,"text,image",1,null,3,15,0.3,3.75],["claude-sonnet-4-20250514",200000,64000,"text,image",1,null,3,15,0.3,3.75],["claude-sonnet-4-5",1000000,64000,"text,image",1,null,3,15,0.3,3.75],["claude-sonnet-4-5-20250929",1000000,64000,"text,image",1,null,3,15,0.3,3.75],["claude-sonnet-4-6",1000000,128000,"text,image",1,null,3,15,0.3,3.75],["claude-sonnet-4-6[1m]",1000000,64000,"text,image",1,"claude-sonnet-4-6",3,15,0.3,3.75],["claude-sonnet-5",1000000,128000,"text,image",1,null,2,10,0.2,2.5]], + "anthropic": [["claude-3-5-sonnet-20240620",200000,8192,"text,image",0,null,3,15,0.3,3.75],["claude-3-5-sonnet-20241022",200000,8192,"text,image",0,null,3,15,0.3,3.75],["claude-3-haiku-20240307",200000,4096,"text,image",0,null,0.25,1.25,0.03,0.3],["claude-fable-5",1000000,128000,"text,image",1,null,10,50,1,12.5],["claude-fable-5-1",1000000,128000,"text,image",1,null,10,50,0.25,12.5],["claude-haiku-4-5",200000,64000,"text,image",1,null,1,5,0.1,1.25],["claude-haiku-4-5-20251001",200000,64000,"text,image",1,null,1,5,0.1,1.25],["claude-opus-4-0",200000,32000,"text,image",1,null,15,75,1.5,18.75],["claude-opus-4-1",200000,32000,"text,image",1,null,15,75,1.5,18.75],["claude-opus-4-1-20250805",200000,32000,"text,image",1,null,15,75,1.5,18.75],["claude-opus-4-20250514",200000,32000,"text,image",1,null,15,75,1.5,18.75],["claude-opus-4-5",200000,64000,"text,image",1,null,5,25,0.5,6.25],["claude-opus-4-5-20251101",200000,64000,"text,image",1,null,5,25,0.5,6.25],["claude-opus-4-6",1000000,128000,"text,image",1,null,5,25,0.5,6.25],["claude-opus-4-6[1m]",1000000,128000,"text,image",1,"claude-opus-4-6",5,25,0.5,6.25],["claude-opus-4-7",1000000,128000,"text,image",1,null,5,25,0.5,6.25],["claude-opus-4-7[1m]",1000000,128000,"text,image",1,"claude-opus-4-7",5,25,0.5,6.25],["claude-opus-4-8",1000000,128000,"text,image",1,null,5,25,0.5,6.25],["claude-opus-4-8[1m]",1000000,128000,"text,image",1,"claude-opus-4-8",5,25,0.5,6.25],["claude-opus-5",1000000,128000,"text,image",1,null,5,25,0.5,6.25],["claude-sonnet-4-0",200000,64000,"text,image",1,null,3,15,0.3,3.75],["claude-sonnet-4-20250514",200000,64000,"text,image",1,null,3,15,0.3,3.75],["claude-sonnet-4-5",1000000,64000,"text,image",1,null,3,15,0.3,3.75],["claude-sonnet-4-5-20250929",1000000,64000,"text,image",1,null,3,15,0.3,3.75],["claude-sonnet-4-6",1000000,128000,"text,image",1,null,3,15,0.3,3.75],["claude-sonnet-4-6[1m]",1000000,64000,"text,image",1,"claude-sonnet-4-6",3,15,0.3,3.75],["claude-sonnet-5",1000000,128000,"text,image",1,null,2,10,0.2,2.5]], "azure-openai": [["gpt-4.1",1047576,32768,"text,image",0,null,2,8,0.5,0],["gpt-4o",128000,16384,"text,image",0,null,2.5,10,1.25,0],["gpt-4o-mini",128000,16384,"text,image",0,null,0.15,0.6,0.075,0],["o3",200000,100000,"text,image",1,null,2,8,0.5,0],["o3-mini",200000,100000,"text",1,null,1.1,4.4,0.55,0]], "cerebras": [["gemma-4-31b",131072,40960,"text,image",1,null,0.99,1.49,0,0],["gpt-oss-120b",131072,40960,"text",1,null,0.35,0.75,0,0],["llama3.1-8b",32000,8000,"text",0,null,0.1,0.1,0,0],["qwen-3-235b-a22b-instruct-2507",131000,32000,"text",0,null,0.6,1.2,0,0],["qwen-3-coder-480b",131072,32768,"text",0,null,0,0,0,0],["zai-glm-4.6",131072,32768,"text",0,null,0,0,0,0],["zai-glm-4.7",131072,40960,"text",1,null,2.25,2.75,2.25,0]], "deepseek": [["deepseek-v4-flash",1048576,384000,"text",1,null,0.14,0.28,0.0028,0],["deepseek-v4-pro",1048576,384000,"text",1,null,0.435,0.87,0.003625,0]], diff --git a/src/providers/fastwire.ts b/src/providers/fastwire.ts index 7eae6b0fe1..0621473c19 100644 --- a/src/providers/fastwire.ts +++ b/src/providers/fastwire.ts @@ -244,9 +244,22 @@ export function resolveFastPolicy( }; } -export function canonicalFastTierMarker(callerTier: string | undefined): "priority" | undefined { +/** + * Fold a caller's service tier onto a canonical fast marker. + * + * `ultrafast` is recognised as INTENT even though no shipped catalog advertises it and + * `DEFAULT_SERVICE_TIER_FAST_WIRE` has no wire mapping for it. That asymmetry is + * deliberate: a caller who sends `ultrafast` (which #3429's reporter did, via their own + * catalog edit) was previously folded to `undefined`, which made `fastIntent` false and + * recorded `fastOutcome: "not-requested"` — the log asserting the user asked for nothing. + * Recognising the intent without a wire mapping lands the attempt on `unknown` instead, + * which is the truth: the tier was requested, and we cannot confirm it was honored. + */ +export function canonicalFastTierMarker(callerTier: string | undefined): "priority" | "ultrafast" | undefined { const folded = callerTier?.trim().toLowerCase(); - return folded === "priority" || folded === "fast" ? "priority" : undefined; + if (folded === "priority" || folded === "fast") return "priority"; + if (folded === "ultrafast") return "ultrafast"; + return undefined; } /** Capture Fast demand before the final A1 serialization action rewrites the parsed tier view. */ @@ -298,7 +311,18 @@ export function createAdapterTierMetadata( ): AdapterTierMetadata | undefined { if (!context || !decision) return undefined; - const callerCanonicalFast = canonicalFastTierMarker(context.callerTier) === "priority"; + const callerMarker = canonicalFastTierMarker(context.callerTier); + // Two different questions, and conflating them mislabels the record. + // + // "Did the caller ask for FAST?" governs the drop and suppression facts: the Fast + // toggle suppressing a request is only true of the 1.5x Fast tier, so an `ultrafast` + // caller turned away by `fastMode: false` was NOT a suppressed Fast request and must + // still read as `callerTierDropped`. + // + // "Did the caller ask for SOME fast-family tier?" is the wider question, and only + // `fastIntent` below is entitled to it. + const callerCanonicalFast = callerMarker === "priority"; + const callerFastFamilyIntent = callerMarker !== undefined; const callerTierDropped = context.callerTier !== undefined && !callerCanonicalFast && wireValue === null; @@ -338,7 +362,7 @@ export function createAdapterTierMetadata( // Known-unsupported routes still need a downgrade when the caller/config expressed Fast intent, // but they are deliberately outside the effective-demand calculation above. const fastIntent = context.demandDecision === "force-fast" - || (context.demandDecision === "inherit" && callerCanonicalFast); + || (context.demandDecision === "inherit" && callerFastFamilyIntent); if (!fastIntent) { outcome.fastOutcome = "not-requested"; @@ -421,9 +445,14 @@ export function decideTier( const callerCanonicalFast = canonicalFastTierMarker(callerTier); if (callerCanonicalFast !== undefined) { const value = policy.fastWire.canonicalToWire[callerCanonicalFast]; - return typeof value === "string" && value.length > 0 - ? { kind: "set", value } - : { kind: "drop" }; + if (typeof value === "string" && value.length > 0) return { kind: "set", value }; + // A canonical marker with NO wire mapping is not a reason to drop the tier. + // + // `ultrafast` is recognised as intent but deliberately unmapped, because no wire + // advertises it. Dropping here would have made recognition strictly worse than not + // recognising it at all: before, `ultrafast` was a foreign tier and + // `foreignCallerTiers: "verbatim"` forwarded it untouched. Falling through keeps that + // behavior, so an operator-supplied tier still reaches the provider. } if (callerTier !== undefined && !policy.forwardCallerTier) return { kind: "drop" }; if ( diff --git a/src/server/management/config-routes.ts b/src/server/management/config-routes.ts index 6fe625aa10..2cd99beb69 100644 --- a/src/server/management/config-routes.ts +++ b/src/server/management/config-routes.ts @@ -303,6 +303,9 @@ export async function handleConfigRoutes(ctx: ManagementContext): Promise; /** OpenAI provider-contract migration marker (v2 = single `openai` provider with account mode). */ diff --git a/src/usage/expected-prices.ts b/src/usage/expected-prices.ts index 5d71b6ff4c..ec3776775b 100644 --- a/src/usage/expected-prices.ts +++ b/src/usage/expected-prices.ts @@ -112,9 +112,11 @@ const KIMI_PRICING = "https://platform.kimi.ai/docs/pricing (official table; cac const QWEN38_MAX_PRICING = "https://qwen.ai/blog?id=qwen3.8 (Qwen release announcement; no Model Studio billing row yet; cache rates unpublished -> 0)"; export const EXPECTED_PRICE_OVERLAYS: readonly ExpectedPriceOverlay[] = [ - // claude-fable-5-1 has no jawcode row yet, so both Anthropic surfaces need their own - // overlay (the overlay lookup is keyed by the configured provider id; only the jawcode - // bundle collapses anthropic-apikey onto anthropic). + // claude-fable-5-1 now HAS a generated jawcode row, so the two Anthropic surfaces resolve + // from it and these overlays are the fallback rather than the primary source. They stay: + // the overlay lookup is keyed by the configured provider id, so an account-pool log label + // like anthropic-pb51d9b still needs them, and only the jawcode bundle collapses + // anthropic-apikey onto anthropic. { provider: "anthropic", modelId: "claude-fable-5-1", cost4: CLAUDE_FABLE_51, source: `anthropic official Claude Fable 5.1 ${ANTHROPIC_PRICING}; cache hit = 0.025x base input`, verifiedAt: "2026-09-02", status: "verified" }, { provider: "anthropic-apikey", modelId: "claude-fable-5-1", cost4: CLAUDE_FABLE_51, source: `anthropic official Claude Fable 5.1 ${ANTHROPIC_PRICING}; cache hit = 0.025x base input`, verifiedAt: "2026-09-02", status: "verified" }, // Cursor canonicalizes every Fable 5.1 spelling onto this sole overlay row. diff --git a/tests/ultrafast-tier-honesty.test.ts b/tests/ultrafast-tier-honesty.test.ts new file mode 100644 index 0000000000..a1f97af6de --- /dev/null +++ b/tests/ultrafast-tier-honesty.test.ts @@ -0,0 +1,185 @@ +/** + * Ultra Fast: name the tier the proxy is already carrying. + * + * PR #2994 added an `ultrafast` row to the pinned catalog and was closed unmerged with + * the verdict that the picker gained a choice the wire could not honor — + * `src/codex/data/upstream-models.json` advertises only `priority`, so the row was + * fabricated metadata. That decision stands: nothing here synthesizes a catalog row. + * + * What #3429 reported is separately true and fixable. A caller who supplies + * `service_tier: "ultrafast"` themselves gets the request forwarded, and then the proxy + * records `fastOutcome: "not-requested"` and no speed label — it asserts the user asked + * for nothing. These tests pin the corrected accounting. + */ +import { describe, expect, test } from "bun:test"; +import { canonicalFastTierMarker, createAdapterTierMetadata, decideTier, tierObservationContext } from "../src/providers/fastwire"; +import { requestLogSpeedLabel } from "../src/server/request-log"; +import { normalizeRoutedCatalogEntry } from "../src/codex/catalog/parsing"; + +describe("ultrafast intent is recognised, not mistaken for silence", () => { + test("the caller marker folds ultrafast to its own canonical, not to priority", () => { + // Folding it onto "priority" would be the other lie: claiming a 1.5x Fast tier was + // requested when the caller named a different one. + expect(canonicalFastTierMarker("ultrafast")).toBe("ultrafast"); + expect(canonicalFastTierMarker("UltraFast")).toBe("ultrafast"); + expect(canonicalFastTierMarker(" ultrafast ")).toBe("ultrafast"); + }); + + test("the existing Fast spellings are unchanged", () => { + expect(canonicalFastTierMarker("priority")).toBe("priority"); + expect(canonicalFastTierMarker("fast")).toBe("priority"); + expect(canonicalFastTierMarker(" PRIORITY ")).toBe("priority"); + }); + + test("unrelated tiers still fold to undefined", () => { + // "auto" reaching a canonical marker would turn every default request into Fast intent. + expect(canonicalFastTierMarker("auto")).toBeUndefined(); + expect(canonicalFastTierMarker("default")).toBeUndefined(); + expect(canonicalFastTierMarker("ultra")).toBeUndefined(); + expect(canonicalFastTierMarker("ultra-fast")).toBeUndefined(); + expect(canonicalFastTierMarker(undefined)).toBeUndefined(); + expect(canonicalFastTierMarker("")).toBeUndefined(); + }); +}); + +describe("ultrafast gets a speed label", () => { + test("the label is its own, so Logs cannot read it as Fast", () => { + expect(requestLogSpeedLabel("ultrafast")).toBe("ultrafast"); + expect(requestLogSpeedLabel(" UltraFast ")).toBe("ultrafast"); + }); + + test("the Fast contract is untouched", () => { + expect(requestLogSpeedLabel("priority")).toBe("fast"); + expect(requestLogSpeedLabel("fast")).toBe("fast"); + }); + + test("auto and absent still produce no label", () => { + // A label here would put a speed badge on every ordinary request. + expect(requestLogSpeedLabel("auto")).toBeUndefined(); + expect(requestLogSpeedLabel(undefined)).toBeUndefined(); + expect(requestLogSpeedLabel("")).toBeUndefined(); + }); +}); + +/** + * The catalog half. The flag PRESERVES a tier the operator supplied; it never invents one, + * which is the line PR #2994 was closed for crossing. + */ +describe("routed rows and the ultrafast opt-in", () => { + const operatorRow = () => ({ + slug: "kimi/k3", + service_tier: "ultrafast", + default_service_tier: "ultrafast", + service_tiers: [ + { id: "priority", name: "Fast", description: "1.5x speed, increased usage" }, + { id: "ultrafast", name: "Ultra Fast", description: "operator supplied" }, + ], + additional_speed_tiers: ["fast", "ultrafast"], + }); + + test("with the flag OFF every tier field is stripped, exactly as before", () => { + const entry = normalizeRoutedCatalogEntry(operatorRow(), false, undefined, { ultraFastTier: false }); + expect(entry.service_tier).toBeUndefined(); + expect(entry.service_tiers).toBeUndefined(); + expect(entry.default_service_tier).toBeUndefined(); + expect(entry.additional_speed_tiers).toBeUndefined(); + }); + + test("with the flag ON the operator's ultrafast survives regeneration", () => { + // The whole reported symptom: a hand-edited catalog lost the tier on every sync. + const entry = normalizeRoutedCatalogEntry(operatorRow(), false, undefined, { ultraFastTier: true }); + expect(entry.service_tiers).toEqual([{ id: "ultrafast", name: "Ultra Fast", description: "operator supplied" }]); + expect(entry.additional_speed_tiers).toEqual(["ultrafast"]); + expect(entry.service_tier).toBe("ultrafast"); + expect(entry.default_service_tier).toBe("ultrafast"); + }); + + test("the flag never smuggles Fast onto a routed row", () => { + // Routed rows are stripped because a clone of a native template would otherwise inherit + // OpenAI's priority tier. Preserving ultrafast must not reopen that. + const entry = normalizeRoutedCatalogEntry(operatorRow(), false, undefined, { ultraFastTier: true }); + const ids = (entry.service_tiers as Array<{ id: string }>).map(tier => tier.id); + expect(ids).not.toContain("priority"); + expect(entry.additional_speed_tiers).not.toContain("fast"); + }); + + test("the flag invents nothing when the operator supplied no ultrafast", () => { + // A row carrying only the upstream Fast tier is stripped whether the flag is on or off: + // upstream advertises no ultrafast, so there is nothing to preserve. + const fastOnly = { + slug: "kimi/k3", + service_tiers: [{ id: "priority", name: "Fast" }], + additional_speed_tiers: ["fast"], + }; + const entry = normalizeRoutedCatalogEntry(fastOnly, false, undefined, { ultraFastTier: true }); + expect(entry.service_tiers).toBeUndefined(); + expect(entry.additional_speed_tiers).toBeUndefined(); + }); +}); + +/** + * The wire decision, which the unit tests above cannot see. + * + * Adversarial review caught this: recognising `ultrafast` as a canonical marker routed it + * into the canonical-wire lookup, and because it is deliberately unmapped the lookup fell + * through to `drop`. That made recognition strictly WORSE than leaving it unrecognised — + * before, it was a foreign tier and `foreignCallerTiers: "verbatim"` forwarded it. Every + * suite still passed, because none of them asserted the decision. + */ +describe("an unmapped canonical tier is forwarded, not dropped", () => { + const policy = { + capability: true, + eligibility: "eligible", + fastWire: { + kind: "service-tier", + canonicalToWire: { priority: "priority" }, + foreignCallerTiers: "verbatim", + }, + forwardCallerTier: true, + } as unknown as Parameters[0]; + + test("ultrafast reaches the provider instead of being stripped", () => { + expect(decideTier(policy, undefined, "ultrafast")).toEqual({ kind: "forward-caller" }); + }); + + test("mapped Fast spellings still resolve to the wire value", () => { + expect(decideTier(policy, undefined, "priority")).toEqual({ kind: "set", value: "priority" }); + expect(decideTier(policy, undefined, "fast")).toEqual({ kind: "set", value: "priority" }); + }); + + test("unrelated and absent tiers are unchanged", () => { + expect(decideTier(policy, undefined, "auto")).toEqual({ kind: "forward-caller" }); + expect(decideTier(policy, undefined, undefined)).toEqual({ kind: "forward-caller" }); + }); +}); + +describe("the Fast toggle does not claim to have suppressed a different tier", () => { + const observe = (callerTier: string, fastMode: boolean | undefined) => { + const policy = { + capability: true, + eligibility: "eligible", + fastWire: { + kind: "service-tier", + canonicalToWire: { priority: "priority" }, + foreignCallerTiers: "verbatim", + }, + forwardCallerTier: true, + } as unknown as Parameters[0]; + const context = tierObservationContext(policy, fastMode, callerTier); + const decision = decideTier(policy, fastMode, callerTier); + const wireValue = decision.kind === "set" ? decision.value : decision.kind === "drop" ? null : callerTier; + return createAdapterTierMetadata(context, decision, "service-tier", wireValue)?.outcome; + }; + + test("force-default suppressing a real Fast request is recorded as suppression", () => { + const outcome = observe("priority", false); + expect(outcome?.callerFastSuppressedByConfig).toBe(true); + }); + + test("force-default turning away ultrafast is a dropped tier, not a suppressed Fast", () => { + // The Fast toggle did not suppress a 1.5x Fast request; it turned away a different one. + const outcome = observe("ultrafast", false); + expect(outcome?.callerFastSuppressedByConfig).toBeUndefined(); + expect(outcome?.callerTierDropped).toBe(true); + }); +}); diff --git a/tests/usage-cost.test.ts b/tests/usage-cost.test.ts index d166434a3d..4b98b565de 100644 --- a/tests/usage-cost.test.ts +++ b/tests/usage-cost.test.ts @@ -177,22 +177,23 @@ describe("resolveMatchedPrice", () => { }); // Claude Fable 5.1 (2026-09-02): 10 / 50 / 12.50 cache write, and a cache-hit rate of - // 0.025x base input (0.25) rather than the 0.1x every other family uses. There is no - // jawcode row yet, so both Anthropic surfaces resolve from the shipped overlay; an - // account-pool log label must collapse onto the same price. + // 0.025x base input (0.25) rather than the 0.1x every other family uses. The generated + // Anthropic jawcode row is now canonical for both Anthropic surfaces; an account-pool log + // label must still collapse onto the same price. test("claude-fable-5-1 resolves to the official Fable 5.1 price on both Anthropic surfaces", () => { const COST4 = { input: 10, output: 50, cacheRead: 0.25, cacheWrite: 12.5 }; + expect(findExpectedPriceOverlay("anthropic", "claude-fable-5-1")?.cost4).toEqual(COST4); + expect(findExpectedPriceOverlay("anthropic-apikey", "claude-fable-5-1")?.cost4).toEqual(COST4); for (const provider of ["anthropic", "anthropic-apikey"]) { const price = resolveMatchedPrice(provider, "claude-fable-5-1"); expect(price, provider).toMatchObject({ provider, modelId: "claude-fable-5-1", cost4: COST4, - source: "expected", + source: "jawcode", + jawcodeProvider: "anthropic", status: "verified", }); - expect(price?.sourceRef).toContain("platform.claude.com"); - expect(price?.sourceRef).toContain("0.025x"); } expect(resolveMatchedPrice("anthropic-pb51d9b", "claude-fable-5-1")?.cost4).toEqual(COST4); // Cursor accepts all three spellings but pricing stores one canonical overlay row.