diff --git a/gui/src/i18n/de.ts b/gui/src/i18n/de.ts index b28662b265a..c447332fd3b 100644 --- a/gui/src/i18n/de.ts +++ b/gui/src/i18n/de.ts @@ -942,6 +942,9 @@ export const de: Record = { "logs.col.duration": "Dauer", "logs.modelTooltip.model": "Modell", "logs.modelTooltip.resolvedModel": "aufgelöstes Modell", + "logs.modelTooltip.servedModel": "bereitgestelltes Modell", + "logs.modelTooltip.wireModel": "Wire-Modell", + "logs.modelRerouteTitle": "Upstream hat ein anderes Modell bereitgestellt als gesendet", "logs.modelTooltip.requestedTier": "angeforderte Stufe", "logs.modelTooltip.configuredTier": "konfigurierte Stufe", "logs.modelTooltip.responseTier": "Antwortstufe", diff --git a/gui/src/i18n/en.ts b/gui/src/i18n/en.ts index f3f03e21c79..103aef9c6b9 100644 --- a/gui/src/i18n/en.ts +++ b/gui/src/i18n/en.ts @@ -991,6 +991,9 @@ export const en = { "logs.col.duration": "Duration", "logs.modelTooltip.model": "model", "logs.modelTooltip.resolvedModel": "resolved model", + "logs.modelTooltip.servedModel": "served model", + "logs.modelTooltip.wireModel": "wire model", + "logs.modelRerouteTitle": "Upstream served a different model than sent", "logs.modelTooltip.requestedTier": "requested tier", "logs.modelTooltip.configuredTier": "configured tier", "logs.modelTooltip.responseTier": "response tier", diff --git a/gui/src/i18n/fr.ts b/gui/src/i18n/fr.ts index 34525f7e4a1..9e497307270 100644 --- a/gui/src/i18n/fr.ts +++ b/gui/src/i18n/fr.ts @@ -972,6 +972,9 @@ export const fr: Record = { "logs.col.duration": "Durée", "logs.modelTooltip.model": "modèle", "logs.modelTooltip.resolvedModel": "modèle résolu", + "logs.modelTooltip.servedModel": "modèle servi", + "logs.modelTooltip.wireModel": "modèle wire", + "logs.modelRerouteTitle": "Le modèle servi en amont diffère du modèle envoyé", "logs.modelTooltip.requestedTier": "niveau demandé", "logs.modelTooltip.configuredTier": "niveau configuré", "logs.modelTooltip.responseTier": "niveau de réponse", diff --git a/gui/src/i18n/ja.ts b/gui/src/i18n/ja.ts index dcf3ce28623..1aa32611552 100644 --- a/gui/src/i18n/ja.ts +++ b/gui/src/i18n/ja.ts @@ -902,6 +902,9 @@ export const ja: Record = { "logs.col.duration": "所要時間", "logs.modelTooltip.model": "モデル", "logs.modelTooltip.resolvedModel": "解決後モデル", + "logs.modelTooltip.servedModel": "提供モデル", + "logs.modelTooltip.wireModel": "ワイヤーモデル", + "logs.modelRerouteTitle": "アップストリームが送信したモデルと異なるモデルを提供しました", "logs.modelTooltip.requestedTier": "要求ティア", "logs.modelTooltip.configuredTier": "設定ティア", "logs.modelTooltip.responseTier": "応答ティア", diff --git a/gui/src/i18n/ko.ts b/gui/src/i18n/ko.ts index 68c11214149..3efb431a2eb 100644 --- a/gui/src/i18n/ko.ts +++ b/gui/src/i18n/ko.ts @@ -973,6 +973,9 @@ export const ko: Record = { "logs.col.duration": "소요 시간", "logs.modelTooltip.model": "모델", "logs.modelTooltip.resolvedModel": "해석된 모델", + "logs.modelTooltip.servedModel": "제공된 모델", + "logs.modelTooltip.wireModel": "와이어 모델", + "logs.modelRerouteTitle": "업스트림이 보낸 모델과 다른 모델을 제공했습니다", "logs.modelTooltip.requestedTier": "요청 티어", "logs.modelTooltip.configuredTier": "설정 티어", "logs.modelTooltip.responseTier": "응답 티어", diff --git a/gui/src/i18n/ru.ts b/gui/src/i18n/ru.ts index 966c173fe0a..2b074bb7841 100644 --- a/gui/src/i18n/ru.ts +++ b/gui/src/i18n/ru.ts @@ -959,6 +959,9 @@ export const ru: Record = { "logs.col.duration": "Длительность", "logs.modelTooltip.model": "модель", "logs.modelTooltip.resolvedModel": "разрешённая модель", + "logs.modelTooltip.servedModel": "фактически обслуженная модель", + "logs.modelTooltip.wireModel": "модель, отправленная выше по потоку", + "logs.modelRerouteTitle": "Выше по потоку обслужена другая модель, чем отправлено", "logs.modelTooltip.requestedTier": "запрошенный уровень", "logs.modelTooltip.configuredTier": "настроенный уровень", "logs.modelTooltip.responseTier": "уровень ответа", diff --git a/gui/src/i18n/tr.ts b/gui/src/i18n/tr.ts index 4f1053581f2..634c11c402a 100644 --- a/gui/src/i18n/tr.ts +++ b/gui/src/i18n/tr.ts @@ -978,6 +978,9 @@ export const tr: Record = { "logs.col.duration": "Süre", "logs.modelTooltip.model": "model", "logs.modelTooltip.resolvedModel": "çözümlenen model", + "logs.modelTooltip.servedModel": "sunulan model", + "logs.modelTooltip.wireModel": "wire model", + "logs.modelRerouteTitle": "Yukarı akış, gönderilenden farklı bir model sundu", "logs.modelTooltip.requestedTier": "istenen katman", "logs.modelTooltip.configuredTier": "yapılandırılan katman", "logs.modelTooltip.responseTier": "yanıt katmanı", diff --git a/gui/src/i18n/vi.ts b/gui/src/i18n/vi.ts index 8cb83da7079..ca530dad179 100644 --- a/gui/src/i18n/vi.ts +++ b/gui/src/i18n/vi.ts @@ -965,6 +965,9 @@ export const vi: Record = { "logs.col.duration": "Thời lượng", "logs.modelTooltip.model": "model", "logs.modelTooltip.resolvedModel": "model đã giải quyết (resolved model)", + "logs.modelTooltip.servedModel": "model được phục vụ", + "logs.modelTooltip.wireModel": "model wire", + "logs.modelRerouteTitle": "Upstream đã phục vụ một model khác với model đã gửi", "logs.modelTooltip.requestedTier": "bậc (tier) được yêu cầu", "logs.modelTooltip.configuredTier": "bậc (tier) cấu hình", "logs.modelTooltip.responseTier": "bậc (tier) phản hồi", diff --git a/gui/src/i18n/zh-TW.ts b/gui/src/i18n/zh-TW.ts index c3482290e58..194207e7194 100644 --- a/gui/src/i18n/zh-TW.ts +++ b/gui/src/i18n/zh-TW.ts @@ -749,6 +749,9 @@ export const zhTW: Record = { "logs.col.duration": "耗時", "logs.modelTooltip.model": "模型", "logs.modelTooltip.resolvedModel": "解析後模型", + "logs.modelTooltip.servedModel": "實際服務模型", + "logs.modelTooltip.wireModel": "線路上模型", + "logs.modelRerouteTitle": "上游實際服務的模型與發送的模型不同", "logs.modelTooltip.requestedTier": "請求層級", "logs.modelTooltip.configuredTier": "設定層級", "logs.modelTooltip.responseTier": "回應層級", diff --git a/gui/src/i18n/zh.ts b/gui/src/i18n/zh.ts index 7655f8b09e0..bb7fed19f03 100644 --- a/gui/src/i18n/zh.ts +++ b/gui/src/i18n/zh.ts @@ -954,6 +954,9 @@ export const zh: Record = { "logs.col.duration": "耗时", "logs.modelTooltip.model": "模型", "logs.modelTooltip.resolvedModel": "解析后模型", + "logs.modelTooltip.servedModel": "实际服务模型", + "logs.modelTooltip.wireModel": "线路上模型", + "logs.modelRerouteTitle": "上游实际服务的模型与发送的模型不同", "logs.modelTooltip.requestedTier": "请求层级", "logs.modelTooltip.configuredTier": "配置层级", "logs.modelTooltip.responseTier": "响应层级", diff --git a/gui/src/pages/Logs.tsx b/gui/src/pages/Logs.tsx index 9f55f37e45d..eea73ee89c2 100644 --- a/gui/src/pages/Logs.tsx +++ b/gui/src/pages/Logs.tsx @@ -1,4 +1,4 @@ -import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react"; +import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState, type ReactNode } from "react"; import { useVirtualizer } from "@tanstack/react-virtual"; import { useI18n, LOCALES, type TFn } from "../i18n/shared"; import { formatProviderDisplayName } from "../provider-icons"; @@ -18,7 +18,7 @@ import { DEFAULT_LOG_FILTER_STATE, extractLogFilterOptions, filterLogs, hasActiv import type { LogsTab } from "./logs-tab-keydown"; import { logsTabKeyDown, readTabFromHash, selectLogsTab } from "./logs-tab-keydown"; -import { modelTitle, type ModelTitleTierOutcome } from "./logs-model-title"; +import { isModelRerouted, 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"; @@ -179,6 +179,8 @@ export interface LogEntry extends LogFailureAttribution { // cannot say whether Fast was granted on a backend whose echo is not authoritative. tierOutcome?: ModelTitleTierOutcome; resolvedModel?: string; + servedModel?: string; + wireModel?: string; modelSupportsServiceTier?: boolean; status: number; durationMs: number; @@ -276,6 +278,22 @@ function reasoningWireLabel(log: ReasoningLogFields): string | undefined { return `${log.reasoningWireField}=${log.reasoningWireValue}`; } +// Mirrors the effort column's "requested → effective" form: on a reroute both +// sides keep their own model icon, so the wire id and the upstream-served id +// stay visible at a glance instead of collapsing into one branded label. +function servedModelLabel(log: { model: string; resolvedModel?: string; servedModel?: string; wireModel?: string }): ReactNode { + if (isModelRerouted(log)) { + return ( + <> + {modelLabel(log.wireModel ?? log.model)} + {" \u2192 "} + {modelLabel(log.servedModel!)} + + ); + } + return modelLabel(log.servedModel ?? log.resolvedModel ?? log.model); +} + function formatTokPerSecond(result: TokPerSecondResult | undefined, localeTag?: string): string { if (!result || result.kind === "unavailable" || !Number.isFinite(result.value) || result.value <= 0) return "\u2014"; const digits = result.value >= 100 ? 0 : 1; @@ -954,7 +972,7 @@ export default function Logs({ apiBase }: { apiBase: string }) { - {modelLabel(log.resolvedModel ?? log.model)} + {servedModelLabel(log)} {log.shadowCallRewrittenFrom && ( )} - {t("logs.col.model")}{modelLabel(detail.resolvedModel ?? detail.model)} + {t("logs.col.model")}{servedModelLabel(detail)} {t("logs.col.provider")}{formatProviderDisplayName(detail.provider, t)} {(detail.requestedEffort || detail.effectiveEffort) && ( <>{t("logs.col.effort")}{effortLabel(detail)}{reasoningWire ? ` (${reasoningWire})` : ""} diff --git a/gui/src/pages/logs-model-title.ts b/gui/src/pages/logs-model-title.ts index 8bcf3141197..ea906da8a87 100644 --- a/gui/src/pages/logs-model-title.ts +++ b/gui/src/pages/logs-model-title.ts @@ -8,6 +8,8 @@ export interface ModelTitleTierOutcome { export interface ModelTitleEntry { model: string; resolvedModel?: string; + servedModel?: string; + wireModel?: string; requestedServiceTier?: string; configuredServiceTier?: string; responseServiceTier?: string; @@ -15,6 +17,16 @@ export interface ModelTitleEntry { tierOutcome?: ModelTitleTierOutcome; } +/** + * A reroute means the upstream answered a different model than the one on the + * wire. The wire id is the rewritten request when a route/virtual rewrite + * diverged it from the client-facing model, and the client-facing model + * otherwise. + */ +export function isModelRerouted(log: Pick): boolean { + return log.servedModel !== undefined && log.servedModel !== (log.wireModel ?? log.model); +} + /** * #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 @@ -38,8 +50,11 @@ function tierConfirmationSuffix(outcome: ModelTitleEntry["tierOutcome"], t: TFn) export function modelTitle(log: ModelTitleEntry, t: TFn): string { const details = [ + isModelRerouted(log) ? t("logs.modelRerouteTitle") : undefined, `${t("logs.modelTooltip.model")}=${log.model}`, log.resolvedModel ? `${t("logs.modelTooltip.resolvedModel")}=${log.resolvedModel}` : undefined, + log.servedModel ? `${t("logs.modelTooltip.servedModel")}=${log.servedModel}` : undefined, + log.wireModel ? `${t("logs.modelTooltip.wireModel")}=${log.wireModel}` : undefined, log.requestedServiceTier ? `${t("logs.modelTooltip.requestedTier")}=${log.requestedServiceTier}` : undefined, log.configuredServiceTier ? `${t("logs.modelTooltip.configuredTier")}=${log.configuredServiceTier}` : undefined, log.responseServiceTier diff --git a/gui/tests/logs-model-title.test.ts b/gui/tests/logs-model-title.test.ts index 009433d334e..26a1df7c5e4 100644 --- a/gui/tests/logs-model-title.test.ts +++ b/gui/tests/logs-model-title.test.ts @@ -5,6 +5,9 @@ import { modelTitle, type ModelTitleEntry } from "../src/pages/logs-model-title" const labels: Partial> = { "logs.modelTooltip.model": "模型", "logs.modelTooltip.resolvedModel": "解析后模型", + "logs.modelTooltip.servedModel": "实际服务模型", + "logs.modelTooltip.wireModel": "线上模型", + "logs.modelRerouteTitle": "上游提供的模型与请求不同", "logs.modelTooltip.requestedTier": "请求层级", "logs.modelTooltip.configuredTier": "配置层级", "logs.modelTooltip.responseTier": "响应层级", @@ -35,3 +38,15 @@ test("model diagnostics localize every label and use one Unicode middle dot betw test("model diagnostics do not include an extra Latin capital A with circumflex", () => { expect(modelTitle(entry({ resolvedModel: "gpt-5.6-sol" }), t)).not.toContain("\u00C2"); }); + +test("model diagnostics surface the upstream-served model when it differs from the wire model", () => { + expect(modelTitle(entry({ model: "gpt-5.6-sol", servedModel: "gpt-5.6-luna" }), t)).toBe( + "上游提供的模型与请求不同 · 模型=gpt-5.6-sol · 实际服务模型=gpt-5.6-luna", + ); +}); + +test("model diagnostics omit the reroute notice when the served model matches the wire model", () => { + expect(modelTitle(entry({ model: "gpt-5.6-sol", wireModel: "gpt-5.6-sol", servedModel: "gpt-5.6-sol" }), t)).toBe( + "模型=gpt-5.6-sol · 实际服务模型=gpt-5.6-sol · 线上模型=gpt-5.6-sol", + ); +}); diff --git a/src/providers/openai-virtual-models.ts b/src/providers/openai-virtual-models.ts index 5aabe83907b..e85403949eb 100644 --- a/src/providers/openai-virtual-models.ts +++ b/src/providers/openai-virtual-models.ts @@ -99,6 +99,7 @@ export function applyOpenAiVirtualModel( logCtx.model = resolution.selectedModelId; logCtx.resolvedModel = resolution.wireModelId; + logCtx.wireModel = resolution.wireModelId; route.modelId = resolution.wireModelId; captureOpenAiVirtualWirePolicy(route, resolution, inboundWire); parsed.modelId = resolution.wireModelId; diff --git a/src/server/request-log.ts b/src/server/request-log.ts index ac1ad98270d..62cd3113e7e 100644 --- a/src/server/request-log.ts +++ b/src/server/request-log.ts @@ -168,6 +168,12 @@ export interface RequestLogContext { /** Final-attempt tier summary; attempt rows remain the accounting source of truth. */ tierOutcome?: AttemptTierOutcome; resolvedModel?: string; + /** Model the upstream actually served (openai-model header or response body), recorded even + * when a route/virtual resolution owns resolvedModel, so an upstream reroute stays visible. */ + servedModel?: string; + /** The exact model id sent upstream; recorded when a route/virtual rewrite makes it differ + * from the client-facing `model`, so a served-model mismatch can be judged against the wire. */ + wireModel?: string; /** Internal: client-facing response metadata must not replace the physical routed model. */ preserveResolvedModelFromRoute?: boolean; usage?: OcxUsage; @@ -295,6 +301,10 @@ export interface RequestLogEntry { responseServiceTier?: string; tierOutcome?: AttemptTierOutcome; resolvedModel?: string; + /** Model the upstream actually served (openai-model header or response body). */ + servedModel?: string; + /** The exact model id sent upstream when it differs from the client-facing `model`. */ + wireModel?: string; status: number; durationMs: number; errorCode?: string; @@ -445,6 +455,8 @@ export function requestLogEntryFromPersistedUsage(entry: PersistedUsageEntry): R ...(entry.responseServiceTier ? { responseServiceTier: entry.responseServiceTier } : {}), ...(entry.tierOutcome ? { tierOutcome: entry.tierOutcome } : {}), ...(entry.resolvedModel ? { resolvedModel: entry.resolvedModel } : {}), + ...(entry.servedModel ? { servedModel: entry.servedModel } : {}), + ...(entry.wireModel ? { wireModel: entry.wireModel } : {}), status: entry.status, durationMs: entry.durationMs, ...(entry.errorCode ? { errorCode: entry.errorCode } : {}), @@ -598,6 +610,8 @@ export function addRequestLog(entry: RequestLogEntry) { : {}), ...(entry.conversationId ? { conversationId: entry.conversationId } : {}), ...(entry.resolvedModel ? { resolvedModel: entry.resolvedModel } : {}), + ...(entry.servedModel ? { servedModel: entry.servedModel } : {}), + ...(entry.wireModel ? { wireModel: entry.wireModel } : {}), ...(entry.requestedModel ? { requestedModel: entry.requestedModel } : {}), ...(entry.requestedAlias ? { requestedAlias: entry.requestedAlias } : {}), ...(entry.shadowCallRewrittenFrom @@ -882,11 +896,10 @@ export function applyResponseLogMetadata(logCtx: RequestLogContext, payload: unk : payload; if (!source || typeof source !== "object") return; const model = (source as { model?: unknown }).model; - if ( - !logCtx.preserveResolvedModelFromRoute - && typeof model === "string" - && model.trim() - ) logCtx.resolvedModel = model; + if (typeof model === "string" && model.trim()) { + logCtx.servedModel = model; + if (!logCtx.preserveResolvedModelFromRoute) logCtx.resolvedModel = model; + } const serviceTier = (source as { service_tier?: unknown }).service_tier; if (typeof serviceTier === "string" && serviceTier.trim()) { const sanitized = sanitizeLogMetadataString(serviceTier); @@ -1520,6 +1533,8 @@ export function addFinalRequestLog( ? { tierOutcome: attempts?.at(-1)?.tierOutcome ?? { ...logCtx.tierOutcome! } } : {}), ...(logCtx.resolvedModel ? { resolvedModel: logCtx.resolvedModel } : {}), + ...(logCtx.servedModel ? { servedModel: logCtx.servedModel } : {}), + ...(logCtx.wireModel ? { wireModel: logCtx.wireModel } : {}), status: effectiveStatus, durationMs, ...(logCtx.firstOutputMs !== undefined ? { firstOutputMs: logCtx.firstOutputMs } : {}), diff --git a/src/server/responses/compact.ts b/src/server/responses/compact.ts index 89e40123789..1db534f32ac 100644 --- a/src/server/responses/compact.ts +++ b/src/server/responses/compact.ts @@ -709,6 +709,7 @@ export async function handleResponsesCompact( captureOpenAiVirtualWirePolicy(route, virtual); logCtx.model = virtual.selectedModelId; logCtx.resolvedModel = virtual.wireModelId; + logCtx.wireModel = virtual.wireModelId; } else { logCtx.resolvedModel = route.modelId; } diff --git a/src/server/responses/core-codex-account.ts b/src/server/responses/core-codex-account.ts index 99796846d8f..a32b788c50d 100644 --- a/src/server/responses/core-codex-account.ts +++ b/src/server/responses/core-codex-account.ts @@ -444,6 +444,7 @@ export function applyCodexAccountGatedWireNormalization(parsed: OcxParsedRequest if (logCtx) { logCtx.preserveResolvedModelFromRoute = true; delete logCtx.resolvedModel; + logCtx.wireModel = wireModel; } parsed.modelId = wireModel; if (!parsed._rawBody || typeof parsed._rawBody !== "object") return; diff --git a/src/server/responses/core-normalize.ts b/src/server/responses/core-normalize.ts index 7da5e9d4028..db508ad3f2c 100644 --- a/src/server/responses/core-normalize.ts +++ b/src/server/responses/core-normalize.ts @@ -195,6 +195,7 @@ export async function applyFinalRouteRequestNormalization(args: { if (parsed._responseModelId !== undefined && parsed._responseModelId !== parsed.modelId) { logCtx.resolvedModel = route.modelId; + logCtx.wireModel = route.modelId; logCtx.preserveResolvedModelFromRoute = true; } diff --git a/src/server/responses/passthrough-delivery.ts b/src/server/responses/passthrough-delivery.ts index 969cd84e589..bd3358eef87 100644 --- a/src/server/responses/passthrough-delivery.ts +++ b/src/server/responses/passthrough-delivery.ts @@ -207,7 +207,10 @@ export async function deliverPassthroughResponse( const headers = sanitizePassthroughHeaders(upstreamResponse.headers, codexSafetyBufferingOptions); const resolvedModel = headers.get("openai-model")?.trim(); - if (resolvedModel && !logCtx.preserveResolvedModelFromRoute) logCtx.resolvedModel = resolvedModel; + if (resolvedModel) { + logCtx.servedModel = resolvedModel; + if (!logCtx.preserveResolvedModelFromRoute) logCtx.resolvedModel = resolvedModel; + } if (isUsageDebugEnabled()) { const upstreamContentType = upstreamResponse.headers.get("content-type"); if (upstreamContentType) logCtx.usageDebugContentType = upstreamContentType; diff --git a/src/usage/log.ts b/src/usage/log.ts index 6955db2073a..499c2c4bde6 100644 --- a/src/usage/log.ts +++ b/src/usage/log.ts @@ -284,6 +284,10 @@ export interface PersistedUsageEntry { /** Best-effort chat/session correlation for Logs grouping (#330). */ conversationId?: string; resolvedModel?: string; + /** Model the upstream actually served (openai-model header or response body). */ + servedModel?: string; + /** The exact model id sent upstream when it differs from the client-facing `model`. */ + wireModel?: string; requestedModel?: string; /** Original bare helper model when the opt-in shadow-call route rewrote this request. */ shadowCallRewrittenFrom?: string; @@ -856,6 +860,8 @@ function normalizeUsageEntry(entry: PersistedUsageEntry): PersistedUsageEntry { ? { conversationId: entry.conversationId.trim().slice(0, 128) } : {}), ...(entry.resolvedModel ? { resolvedModel: entry.resolvedModel } : {}), + ...(entry.servedModel ? { servedModel: entry.servedModel } : {}), + ...(entry.wireModel ? { wireModel: entry.wireModel } : {}), ...(entry.requestedModel ? { requestedModel: entry.requestedModel } : {}), ...(shadowCallRewrittenFrom ? { shadowCallRewrittenFrom } : {}), ...(typeof entry.requestedEffort === "string" && entry.requestedEffort