diff --git a/gui/src/i18n/de.ts b/gui/src/i18n/de.ts index f0eb85fcee..07100850ac 100644 --- a/gui/src/i18n/de.ts +++ b/gui/src/i18n/de.ts @@ -742,6 +742,10 @@ export const de: Record = { "logs.modelTooltip.configuredTier": "konfigurierte Stufe", "logs.modelTooltip.responseTier": "Antwortstufe", "logs.modelTooltip.supportsTier": "Stufenunterstützung", + "logs.modelTooltip.tierOutcome.confirmed": "bestätigt", + "logs.modelTooltip.tierOutcome.assumed": "angenommen", + "logs.modelTooltip.tierOutcome.downgraded": "herabgestuft", + "logs.modelTooltip.tierOutcome.unknown": "unbekannt", "logs.tokens.reported": "gemeldet", "logs.tokens.unreported": "nicht gemeldet", "logs.tokens.unsupported": "nicht unterstützt", diff --git a/gui/src/i18n/en.ts b/gui/src/i18n/en.ts index 8c49a81248..95a68e618c 100644 --- a/gui/src/i18n/en.ts +++ b/gui/src/i18n/en.ts @@ -775,6 +775,10 @@ export const en = { "logs.modelTooltip.configuredTier": "configured tier", "logs.modelTooltip.responseTier": "response tier", "logs.modelTooltip.supportsTier": "tier support", + "logs.modelTooltip.tierOutcome.confirmed": "confirmed", + "logs.modelTooltip.tierOutcome.assumed": "assumed", + "logs.modelTooltip.tierOutcome.downgraded": "downgraded", + "logs.modelTooltip.tierOutcome.unknown": "unknown", "logs.tokens.reported": "reported", "logs.tokens.unreported": "unreported", "logs.tokens.unsupported": "unsupported", diff --git a/gui/src/i18n/fr.ts b/gui/src/i18n/fr.ts index a05872c155..540675641e 100644 --- a/gui/src/i18n/fr.ts +++ b/gui/src/i18n/fr.ts @@ -756,6 +756,10 @@ export const fr: Record = { "logs.modelTooltip.configuredTier": "niveau configuré", "logs.modelTooltip.responseTier": "niveau de réponse", "logs.modelTooltip.supportsTier": "prise en charge du niveau", + "logs.modelTooltip.tierOutcome.confirmed": "confirmé", + "logs.modelTooltip.tierOutcome.assumed": "supposé", + "logs.modelTooltip.tierOutcome.downgraded": "rétrogradé", + "logs.modelTooltip.tierOutcome.unknown": "inconnu", "logs.tokens.reported": "communiqués", "logs.tokens.unreported": "non communiqués", "logs.tokens.unsupported": "non pris en charge", diff --git a/gui/src/i18n/ja.ts b/gui/src/i18n/ja.ts index ce861c5c47..7005308026 100644 --- a/gui/src/i18n/ja.ts +++ b/gui/src/i18n/ja.ts @@ -718,6 +718,10 @@ export const ja: Record = { "logs.modelTooltip.configuredTier": "設定ティア", "logs.modelTooltip.responseTier": "応答ティア", "logs.modelTooltip.supportsTier": "ティア対応", + "logs.modelTooltip.tierOutcome.confirmed": "確認済み", + "logs.modelTooltip.tierOutcome.assumed": "推定", + "logs.modelTooltip.tierOutcome.downgraded": "降格", + "logs.modelTooltip.tierOutcome.unknown": "不明", "logs.tokens.reported": "報告済み", "logs.tokens.unreported": "未報告", "logs.tokens.unsupported": "非対応", diff --git a/gui/src/i18n/ko.ts b/gui/src/i18n/ko.ts index bf7fe01e24..e1ac145324 100644 --- a/gui/src/i18n/ko.ts +++ b/gui/src/i18n/ko.ts @@ -761,6 +761,10 @@ export const ko: Record = { "logs.modelTooltip.configuredTier": "설정 티어", "logs.modelTooltip.responseTier": "응답 티어", "logs.modelTooltip.supportsTier": "티어 지원", + "logs.modelTooltip.tierOutcome.confirmed": "확인됨", + "logs.modelTooltip.tierOutcome.assumed": "추정됨", + "logs.modelTooltip.tierOutcome.downgraded": "강등됨", + "logs.modelTooltip.tierOutcome.unknown": "알 수 없음", "logs.tokens.reported": "측정됨", "logs.tokens.unreported": "미보고", "logs.tokens.unsupported": "미지원", diff --git a/gui/src/i18n/ru.ts b/gui/src/i18n/ru.ts index 19ebbf68b2..868dc32720 100644 --- a/gui/src/i18n/ru.ts +++ b/gui/src/i18n/ru.ts @@ -759,6 +759,10 @@ export const ru: Record = { "logs.modelTooltip.configuredTier": "настроенный уровень", "logs.modelTooltip.responseTier": "уровень ответа", "logs.modelTooltip.supportsTier": "поддержка уровня", + "logs.modelTooltip.tierOutcome.confirmed": "подтверждено", + "logs.modelTooltip.tierOutcome.assumed": "предполагается", + "logs.modelTooltip.tierOutcome.downgraded": "понижено", + "logs.modelTooltip.tierOutcome.unknown": "неизвестно", "logs.tokens.reported": "сообщено", "logs.tokens.unreported": "не сообщено", "logs.tokens.unsupported": "не поддерживается", diff --git a/gui/src/i18n/tr.ts b/gui/src/i18n/tr.ts index 21c70d54fd..297a4812af 100644 --- a/gui/src/i18n/tr.ts +++ b/gui/src/i18n/tr.ts @@ -766,6 +766,10 @@ export const tr: Record = { "logs.modelTooltip.configuredTier": "yapılandırılan katman", "logs.modelTooltip.responseTier": "yanıt katmanı", "logs.modelTooltip.supportsTier": "katman desteği", + "logs.modelTooltip.tierOutcome.confirmed": "doğrulandı", + "logs.modelTooltip.tierOutcome.assumed": "varsayıldı", + "logs.modelTooltip.tierOutcome.downgraded": "düşürüldü", + "logs.modelTooltip.tierOutcome.unknown": "bilinmiyor", "logs.tokens.reported": "bildirilen", "logs.tokens.unreported": "bildirilmeyen", "logs.tokens.unsupported": "desteklenmeyen", diff --git a/gui/src/i18n/zh-TW.ts b/gui/src/i18n/zh-TW.ts index 588b09cac0..c47cae1f87 100644 --- a/gui/src/i18n/zh-TW.ts +++ b/gui/src/i18n/zh-TW.ts @@ -594,6 +594,10 @@ export const zhTW: Record = { "logs.modelTooltip.configuredTier": "設定層級", "logs.modelTooltip.responseTier": "回應層級", "logs.modelTooltip.supportsTier": "支援層級", + "logs.modelTooltip.tierOutcome.confirmed": "已確認", + "logs.modelTooltip.tierOutcome.assumed": "假定", + "logs.modelTooltip.tierOutcome.downgraded": "已降級", + "logs.modelTooltip.tierOutcome.unknown": "未知", "logs.tokens.reported": "已上報", "logs.tokens.unreported": "未上報", "logs.tokens.unsupported": "不支援", diff --git a/gui/src/i18n/zh.ts b/gui/src/i18n/zh.ts index 136c60b254..340b7cc31f 100644 --- a/gui/src/i18n/zh.ts +++ b/gui/src/i18n/zh.ts @@ -754,6 +754,10 @@ export const zh: Record = { "logs.modelTooltip.configuredTier": "配置层级", "logs.modelTooltip.responseTier": "响应层级", "logs.modelTooltip.supportsTier": "支持层级", + "logs.modelTooltip.tierOutcome.confirmed": "已确认", + "logs.modelTooltip.tierOutcome.assumed": "假定", + "logs.modelTooltip.tierOutcome.downgraded": "已降级", + "logs.modelTooltip.tierOutcome.unknown": "未知", "logs.tokens.reported": "已上报", "logs.tokens.unreported": "未上报", "logs.tokens.unsupported": "不支持", diff --git a/gui/src/pages/Logs.tsx b/gui/src/pages/Logs.tsx index 384231f698..358f06a217 100644 --- a/gui/src/pages/Logs.tsx +++ b/gui/src/pages/Logs.tsx @@ -15,7 +15,7 @@ import Debug from "./Debug"; import type { LogsTab } from "./logs-tab-keydown"; import { logsTabKeyDown, readTabFromHash, selectLogsTab } from "./logs-tab-keydown"; -import { modelTitle } from "./logs-model-title"; +import { modelTitle, type ModelTitleTierOutcome } from "./logs-model-title"; import { speedLabel } from "./logs-speed-label"; import { formatEstimatedUsd, formatEstimatedUsdValue, summarizeEstimatedCosts } from "./logs-cost-format"; import { cacheSplit, isCursorUsageProvider, tokensTitle } from "./logs-token-title"; @@ -152,6 +152,9 @@ export interface LogEntry { configuredServiceTier?: string; configuredSpeedLabel?: string; responseServiceTier?: string; + // #2455: qualifies responseServiceTier in the model tooltip — the echoed tier alone + // cannot say whether Fast was granted on a backend whose echo is not authoritative. + tierOutcome?: ModelTitleTierOutcome; resolvedModel?: string; modelSupportsServiceTier?: boolean; status: number; diff --git a/gui/src/pages/logs-model-title.ts b/gui/src/pages/logs-model-title.ts index 77b1fe0577..8bcf314119 100644 --- a/gui/src/pages/logs-model-title.ts +++ b/gui/src/pages/logs-model-title.ts @@ -1,5 +1,10 @@ import type { TFn } from "../i18n/shared"; +export interface ModelTitleTierOutcome { + confirmation?: "confirmed" | "assumed" | "downgraded" | "unknown"; + fastDowngradeReason?: string; +} + export interface ModelTitleEntry { model: string; resolvedModel?: string; @@ -7,6 +12,28 @@ export interface ModelTitleEntry { configuredServiceTier?: string; responseServiceTier?: string; modelSupportsServiceTier?: boolean; + tierOutcome?: ModelTitleTierOutcome; +} + +/** + * #2455: the echoed tier alone does not say whether Fast was granted. The ChatGPT + * backend answers `default` on turns it in fact scheduled as priority, so its echo is + * marked non-authoritative and the outcome stays `assumed` (#2558) — which is the + * honest answer, but only if the operator can see it. Qualify the echoed value with + * how much it is worth, and name the reason when the tier was actually declined. + * + * The confirmation word is this proxy's own judgement about the turn, not a value the + * upstream returned, so it is translated like any other visible string. The downgrade + * reason stays verbatim: it is a diagnostic identifier (`response-declined`) that maps + * to `fastDowngradeReason` in the source, and translating it would break that link. + */ +function tierConfirmationSuffix(outcome: ModelTitleEntry["tierOutcome"], t: TFn): string { + const confirmation = outcome?.confirmation; + if (!confirmation) return ""; + const reason = confirmation === "downgraded" && outcome?.fastDowngradeReason + ? `: ${outcome.fastDowngradeReason}` + : ""; + return ` (${t(`logs.modelTooltip.tierOutcome.${confirmation}`)}${reason})`; } export function modelTitle(log: ModelTitleEntry, t: TFn): string { @@ -15,7 +42,9 @@ export function modelTitle(log: ModelTitleEntry, t: TFn): string { log.resolvedModel ? `${t("logs.modelTooltip.resolvedModel")}=${log.resolvedModel}` : undefined, log.requestedServiceTier ? `${t("logs.modelTooltip.requestedTier")}=${log.requestedServiceTier}` : undefined, log.configuredServiceTier ? `${t("logs.modelTooltip.configuredTier")}=${log.configuredServiceTier}` : undefined, - log.responseServiceTier ? `${t("logs.modelTooltip.responseTier")}=${log.responseServiceTier}` : undefined, + log.responseServiceTier + ? `${t("logs.modelTooltip.responseTier")}=${log.responseServiceTier}${tierConfirmationSuffix(log.tierOutcome, t)}` + : undefined, log.modelSupportsServiceTier !== undefined ? `${t("logs.modelTooltip.supportsTier")}=${log.modelSupportsServiceTier}` : undefined, diff --git a/tests/logs-model-tier-confirmation.test.ts b/tests/logs-model-tier-confirmation.test.ts new file mode 100644 index 0000000000..995b76f793 --- /dev/null +++ b/tests/logs-model-tier-confirmation.test.ts @@ -0,0 +1,124 @@ +import { describe, expect, test } from "bun:test"; +import { modelTitle, type ModelTitleEntry } from "../gui/src/pages/logs-model-title"; +import { readFileSync, readdirSync } from "node:fs"; +import { join } from "node:path"; + +/** + * #2455: a user routing gpt-5.x through the proxy asked for `service_tier: priority`, + * saw the backend echo something else, and had no way to tell whether Fast was granted. + * + * The echo alone cannot answer it. The ChatGPT-internal Codex backend returns + * `service_tier: "default"` on turns it in fact scheduled as priority, so its echo is + * marked non-authoritative and the outcome stays `assumed` rather than being read as a + * downgrade (#2558). That is the honest answer — but it was computed in `tierOutcome` + * and never shown, so the tooltip printed a bare echoed value that looked like a denial. + */ + +/** + * Stub translator. The tier-outcome labels are marked with a `t:` prefix on purpose: a + * hardcoded English label would render the same bare word as a translated one, so + * without the marker these assertions could not tell the two apart. The real catalogs + * are checked separately, by file, in the locale-coverage test. + */ +const t = ((key: string) => { + const leaf = key.split(".").pop() ?? key; + return key.includes("tierOutcome.") ? `t:${leaf}` : leaf; +}) as never; + +const CONFIRMATIONS = ["confirmed", "assumed", "downgraded", "unknown"] as const; + +function entry(over: Partial = {}): ModelTitleEntry { + return { model: "gpt-5.6-terra", ...over }; +} + +describe("model tooltip tier confirmation (#2455)", () => { + test("qualifies the echoed tier with the outcome", () => { + const title = modelTitle( + entry({ responseServiceTier: "default", tierOutcome: { confirmation: "assumed" } }), + t, + ); + expect(title).toContain("=default (t:assumed)"); + }); + + test("names the reason when the tier was actually declined", () => { + const title = modelTitle( + entry({ + responseServiceTier: "default", + tierOutcome: { confirmation: "downgraded", fastDowngradeReason: "response-declined" }, + }), + t, + ); + expect(title).toContain("=default (t:downgraded: response-declined)"); + }); + + test("a confirmed grant reads as confirmed", () => { + const title = modelTitle( + entry({ responseServiceTier: "priority", tierOutcome: { confirmation: "confirmed" } }), + t, + ); + expect(title).toContain("=priority (t:confirmed)"); + }); + + test("no outcome leaves the existing tooltip unchanged", () => { + const before = modelTitle(entry({ responseServiceTier: "default" }), t); + expect(before).toContain("=default"); + expect(before).not.toContain("("); + }); + + test("an outcome without an echoed tier adds nothing", () => { + // The qualifier explains an echoed value; with nothing echoed there is nothing to + // qualify, and a lone parenthesis would read as a malformed field. + const title = modelTitle(entry({ tierOutcome: { confirmation: "assumed" } }), t); + expect(title).not.toContain("assumed"); + expect(title).toBe("model=gpt-5.6-terra"); + }); + + test("a downgrade with no recorded reason omits the colon", () => { + const title = modelTitle( + entry({ responseServiceTier: "default", tierOutcome: { confirmation: "downgraded" } }), + t, + ); + expect(title).toContain("=default (t:downgraded)"); + expect(title).not.toContain("downgraded:"); + }); + + test("every confirmation value is translated in every locale", () => { + // The label is this proxy's own judgement about the turn, so it is visible text and + // must not fall back to a raw key in any dashboard language. Read the catalogs as + // files: importing the barrel pulls in the whole GUI dependency graph. + // Named explicitly: the directory also holds label modules that are not catalogs, + // and this list is the same one LOCALES declares in i18n/shared.ts. + const LOCALE_FILES = [ + "en.ts", "de.ts", "fr.ts", "ko.ts", "zh.ts", "zh-TW.ts", "ru.ts", "ja.ts", "tr.ts", + ]; + const dir = join(import.meta.dir, "..", "gui", "src", "i18n"); + const present = readdirSync(dir); + for (const file of LOCALE_FILES) { + expect(present, `${file} disappeared from i18n/`).toContain(file); + } + + for (const file of LOCALE_FILES) { + const text = readFileSync(join(dir, file), "utf8"); + for (const confirmation of CONFIRMATIONS) { + const key = `"logs.modelTooltip.tierOutcome.${confirmation}":`; + const line = text.split("\n").find(l => l.includes(key)); + expect(line, `${file} is missing ${key}`).toBeTruthy(); + const value = line!.slice(line!.indexOf(key) + key.length).trim().replace(/^"|",?$/g, ""); + expect(value.length, `${file} ${key} is empty`).toBeGreaterThan(0); + } + } + }); + + test("the downgrade reason stays a verbatim identifier", () => { + // `response-declined` maps to fastDowngradeReason in the source; translating it + // would break the link between what the operator reads and what to grep for. + const title = modelTitle( + entry({ + responseServiceTier: "default", + tierOutcome: { confirmation: "downgraded", fastDowngradeReason: "wire-unavailable" }, + }), + t, + ); + expect(title).toContain("wire-unavailable"); + }); +});