diff --git a/devlog/_plan/260818_fastwire_b2_openrouter/evidence/010_logs_openrouter_priority_lower_bound.png b/devlog/_plan/260818_fastwire_b2_openrouter/evidence/010_logs_openrouter_priority_lower_bound.png new file mode 100644 index 0000000000..5ee905fea5 Binary files /dev/null and b/devlog/_plan/260818_fastwire_b2_openrouter/evidence/010_logs_openrouter_priority_lower_bound.png differ diff --git a/devlog/_plan/260818_fastwire_b2_openrouter/evidence/README.md b/devlog/_plan/260818_fastwire_b2_openrouter/evidence/README.md new file mode 100644 index 0000000000..a713c0f453 --- /dev/null +++ b/devlog/_plan/260818_fastwire_b2_openrouter/evidence/README.md @@ -0,0 +1,16 @@ +# Evidence — FastWire B2 (OpenRouter) + +`010_logs_openrouter_priority_lower_bound.png` — Logs table with three seeded OpenRouter +requests on `openai/gpt-5.6-sol`, captured against a local proxy with a temporary +`OPENCODEX_HOME`: + +| Row | Attempt outcome | Rendering | +| --- | --- | --- | +| `or-priority-confirmed` | upstream echoed `service_tier: "priority"` | `≥$0.1105` — priced at the standard rate but marked a floor, because OpenRouter publishes no bundled tier price and documents priority as higher cost | +| `or-priority-declined` | upstream echoed `service_tier: "default"` | `~$0.1105` — a real downgrade, so no floor marker | +| `or-standard` | no tier requested | `~$0.1105` | + +All three totals match on purpose: without a bundled priority price every row is computed at +the standard rate, so the only thing that differs is whether the cost is presented as an +estimate (`~$`) or as a known lower bound (`≥$`). The marker matches the convention used by +the parallel xAI unit (#2072). diff --git a/docs-site/src/content/docs/reference/configuration/providers.md b/docs-site/src/content/docs/reference/configuration/providers.md index d467b5cb5f..a12ed68f3b 100644 --- a/docs-site/src/content/docs/reference/configuration/providers.md +++ b/docs-site/src/content/docs/reference/configuration/providers.md @@ -151,6 +151,35 @@ contract; existing configurations see these migration deltas: Explicit capability `false` and Responses caller-tier forwarding retain their existing contracts. +### OpenRouter Fast + +The canonical `https://openrouter.ai/api/v1` preset advertises Fast only for these exact +OpenAI-backed model slugs: + +- `openai/gpt-5.6-sol` +- `openai/gpt-5.6-terra` +- `openai/gpt-5.6-luna` + +`anthropic/claude-sonnet-5` and undeclared OpenRouter models remain unclassified. A provider-level +`supportsServiceTier` default is intentionally absent, and a user-set `supportsServiceTier: false` +still disables the exact-model declarations. The registry declarations apply only while the +provider still targets the canonical OpenRouter base URL; a same-named custom destination is not +assumed to share OpenRouter's contract. + +Fast sends `service_tier: "priority"`. It does not add or rewrite `provider.only`, +`provider.order`, or `provider.allow_fallbacks`. OpenRouter documents priority endpoints as the +first routing choice, followed by graceful fallback to other endpoints when priority capacity is +unavailable. Billing follows the endpoint actually used, and the response reports the actual +top-level `service_tier`. Pinning tier endpoints and disabling fallback would therefore reduce +availability without improving billing safety. + +Request logs use that response echo as the authority. `priority` confirms Fast as applied; +`default` records a downgrade and uses the standard-price estimate; a missing field leaves the +attempt assumed rather than guessing a downgrade. OpenRouter's priority multiplier varies by +upstream and is not bundled here. When priority is confirmed but no exact priority price is known, +the dashboard keeps the standard-price estimate as a documented lower bound and prefixes it with +`≥`; downgraded attempts have no lower-bound marker. + API-key providers may hold a literal key or an environment reference. OAuth providers use the credential store populated by `ocx login`; subscription-backed Claude Code launch behavior is configured under [`claudeCode.authMode`](/reference/configuration/server/#claude-code). diff --git a/gui/src/i18n/de.ts b/gui/src/i18n/de.ts index 41374c710f..113fc419d2 100644 --- a/gui/src/i18n/de.ts +++ b/gui/src/i18n/de.ts @@ -645,6 +645,9 @@ export const de: Record = { "logs.conversation.totals": "{requests} Anfragen · {tokens} Tokens · {cost}", "logs.conversation.scope": "Summen gelten nur für den aktuell geladenen Logs-Ring.", "logs.conversation.excluded": "({unpriced} ohne Preis, {unmetered} ohne Messung vom ~$ ausgenommen)", + "logs.cost.approximate": "ca. {amount}", + "logs.cost.lowerBound": "mind. {amount}", + "logs.cost.unavailable": "nicht verfügbar", "logs.detail.conversation": "Konversation", "logs.badge.claude": "Claude", "logs.badge.grok": "Grok", @@ -711,6 +714,7 @@ export const de: Record = { "logs.detail.estimate.cache_detail_missing": "Cache-Details fehlen; Eingabe ist als Obergrenze geschätzt.", "logs.detail.estimate.expected_price_overlay": "Ein verifizierter Expected-Listenpreis wurde verwendet.", "logs.detail.estimate.provider_cost_overlay": "Ein vom Anbieter konfiguriertes Preis-Overlay wurde verwendet.", + "logs.detail.estimate.priority_lower_bound": "Der bestätigte OpenRouter-Priority-Preis ist nicht verfügbar; die angezeigte Standardpreisschätzung ist eine bekannte Untergrenze.", "logs.col.error": "Fehler", "logs.col.upstreamReason": "Upstream-Grund", "logs.col.duration": "Dauer", diff --git a/gui/src/i18n/en.ts b/gui/src/i18n/en.ts index 067556a432..997fcdc806 100644 --- a/gui/src/i18n/en.ts +++ b/gui/src/i18n/en.ts @@ -678,6 +678,9 @@ export const en = { "logs.conversation.totals": "{requests} requests · {tokens} tokens · {cost}", "logs.conversation.scope": "Totals cover the currently loaded Logs ring only.", "logs.conversation.excluded": "({unpriced} unpriced, {unmetered} unmetered excluded from ~$)", + "logs.cost.approximate": "~{amount}", + "logs.cost.lowerBound": "≥{amount}", + "logs.cost.unavailable": "—", "logs.detail.conversation": "Conversation", "logs.badge.claude": "Claude", "logs.badge.grok": "Grok", @@ -744,6 +747,7 @@ export const en = { "logs.detail.estimate.cache_detail_missing": "Cache details were unavailable; input is an upper-bound estimate.", "logs.detail.estimate.expected_price_overlay": "A verified expected list price was used.", "logs.detail.estimate.provider_cost_overlay": "A provider-configured price overlay was used.", + "logs.detail.estimate.priority_lower_bound": "The confirmed OpenRouter priority price is unavailable; the displayed standard-price estimate is a known lower bound.", "logs.col.error": "Error", "logs.col.upstreamReason": "Upstream reason", "logs.col.duration": "Duration", diff --git a/gui/src/i18n/fr.ts b/gui/src/i18n/fr.ts index 3239e8327f..112fd7ad2a 100644 --- a/gui/src/i18n/fr.ts +++ b/gui/src/i18n/fr.ts @@ -659,6 +659,9 @@ export const fr: Record = { "logs.conversation.totals": "{requests} requêtes · {tokens} jetons · {cost}", "logs.conversation.scope": "Les totaux couvrent uniquement le tampon circulaire des journaux actuellement chargé.", "logs.conversation.excluded": "({unpriced} sans tarif, {unmetered} sans mesure exclus du total en ~$)", + "logs.cost.approximate": "env. {amount}", + "logs.cost.lowerBound": "au moins {amount}", + "logs.cost.unavailable": "indisponible", "logs.detail.conversation": "Conversation", "logs.badge.claude": "Claude", "logs.badge.grok": "Grok", @@ -725,6 +728,7 @@ export const fr: Record = { "logs.detail.estimate.cache_detail_missing": "Les détails du cache n’étaient pas disponibles ; l’entrée est une estimation de la limite supérieure.", "logs.detail.estimate.expected_price_overlay": "Un tarif catalogue attendu et vérifié a été utilisé.", "logs.detail.estimate.provider_cost_overlay": "Un remplacement de tarif configuré pour le fournisseur a été utilisé.", + "logs.detail.estimate.priority_lower_bound": "Le tarif Priority OpenRouter confirmé n’est pas disponible ; l’estimation au tarif standard affichée est une borne inférieure connue.", "logs.col.error": "Erreur", "logs.col.upstreamReason": "Motif en amont", "logs.col.duration": "Durée", diff --git a/gui/src/i18n/ja.ts b/gui/src/i18n/ja.ts index 3d0788c991..69fdac1c93 100644 --- a/gui/src/i18n/ja.ts +++ b/gui/src/i18n/ja.ts @@ -621,6 +621,9 @@ export const ja: Record = { "logs.conversation.totals": "{requests} 件 · {tokens} トークン · {cost}", "logs.conversation.scope": "合計は現在読み込まれている Logs リングのみです。", "logs.conversation.excluded": "(~$ から価格なし {unpriced} / 未計測 {unmetered} を除外)", + "logs.cost.approximate": "約{amount}", + "logs.cost.lowerBound": "最低{amount}", + "logs.cost.unavailable": "利用不可", "logs.detail.conversation": "会話", "logs.badge.claude": "Claude", "logs.badge.grok": "Grok", @@ -687,6 +690,7 @@ export const ja: Record = { "logs.detail.estimate.cache_detail_missing": "キャッシュの詳細が利用できませんでした; 入力は上限の推定です。", "logs.detail.estimate.expected_price_overlay": "検証済みの予想定価が使用されました。", "logs.detail.estimate.provider_cost_overlay": "プロバイダー設定の価格オーバーレイが使用されました。", + "logs.detail.estimate.priority_lower_bound": "確認済みの OpenRouter Priority 価格は取得できないため、表示される標準価格の見積もりは既知の下限です。", "logs.col.error": "エラー", "logs.col.upstreamReason": "上流の理由", "logs.col.duration": "所要時間", diff --git a/gui/src/i18n/ko.ts b/gui/src/i18n/ko.ts index 9cf41d65f5..9f491a1efc 100644 --- a/gui/src/i18n/ko.ts +++ b/gui/src/i18n/ko.ts @@ -664,6 +664,9 @@ export const ko: Record = { "logs.conversation.totals": "{requests}건 요청 · {tokens} 토큰 · {cost}", "logs.conversation.scope": "합계는 현재 로드된 Logs 링만 포함합니다.", "logs.conversation.excluded": "(~$에서 가격 없음 {unpriced}건, 미측정 {unmetered}건 제외)", + "logs.cost.approximate": "약 {amount}", + "logs.cost.lowerBound": "최소 {amount}", + "logs.cost.unavailable": "사용 불가", "logs.detail.conversation": "대화", "logs.badge.claude": "Claude", "logs.badge.grok": "Grok", @@ -730,6 +733,7 @@ export const ko: Record = { "logs.detail.estimate.cache_detail_missing": "캐시 상세가 없어 입력 전액을 상한으로 추정했습니다.", "logs.detail.estimate.expected_price_overlay": "검증된 expected 정가를 사용했습니다.", "logs.detail.estimate.provider_cost_overlay": "프로바이더 구성 가격 오버레이를 사용했습니다.", + "logs.detail.estimate.priority_lower_bound": "확인된 OpenRouter Priority 가격을 사용할 수 없어 표시된 표준 가격 추정치는 알려진 하한입니다.", "logs.col.error": "오류", "logs.col.upstreamReason": "업스트림 원인", "logs.col.duration": "소요 시간", diff --git a/gui/src/i18n/ru.ts b/gui/src/i18n/ru.ts index 7edb4a0efc..00aa9cb06b 100644 --- a/gui/src/i18n/ru.ts +++ b/gui/src/i18n/ru.ts @@ -662,6 +662,9 @@ export const ru: Record = { "logs.conversation.totals": "{requests} запросов · {tokens} токенов · {cost}", "logs.conversation.scope": "Итоги только по загруженному кольцу Logs.", "logs.conversation.excluded": "(из ~$ исключены {unpriced} без цены, {unmetered} без учёта)", + "logs.cost.approximate": "около {amount}", + "logs.cost.lowerBound": "не менее {amount}", + "logs.cost.unavailable": "недоступно", "logs.detail.conversation": "Диалог", "logs.badge.claude": "Claude", "logs.badge.grok": "Grok", @@ -728,6 +731,7 @@ export const ru: Record = { "logs.detail.estimate.cache_detail_missing": "Детализация кэша недоступна; входные токены оценены по верхней границе.", "logs.detail.estimate.expected_price_overlay": "Использована подтверждённая ожидаемая цена из прайс-листа.", "logs.detail.estimate.provider_cost_overlay": "Использован ценовой оверлей провайдера.", + "logs.detail.estimate.priority_lower_bound": "Подтверждённая цена OpenRouter Priority недоступна; показанная оценка по стандартной цене является известной нижней границей.", "logs.col.error": "Ошибка", "logs.col.upstreamReason": "Причина от провайдера", "logs.col.duration": "Длительность", diff --git a/gui/src/i18n/tr.ts b/gui/src/i18n/tr.ts index 882707f876..743be2af77 100644 --- a/gui/src/i18n/tr.ts +++ b/gui/src/i18n/tr.ts @@ -669,6 +669,9 @@ export const tr: Record = { "logs.conversation.totals": "{requests} istek · {tokens} jeton · {cost}", "logs.conversation.scope": "Toplamlar yalnızca yüklü günlükleri kapsar.", "logs.conversation.excluded": "({unpriced} fiyatlandırılmamış, {unmetered} ölçülmemiş hariç)", + "logs.cost.approximate": "yaklaşık {amount}", + "logs.cost.lowerBound": "en az {amount}", + "logs.cost.unavailable": "kullanılamıyor", "logs.detail.conversation": "Sohbet", "logs.badge.claude": "Claude", "logs.badge.grok": "Grok", @@ -735,6 +738,7 @@ export const tr: Record = { "logs.detail.estimate.cache_detail_missing": "Önbellek detayları eksik.", "logs.detail.estimate.expected_price_overlay": "Doğrulanmış liste fiyatı kullanıldı.", "logs.detail.estimate.provider_cost_overlay": "Kullanıcı tarafından yapılandırılan bir sağlayıcı fiyat katmanı kullanıldı.", + "logs.detail.estimate.priority_lower_bound": "Doğrulanan OpenRouter Priority fiyatı kullanılamıyor; gösterilen standart fiyat tahmini bilinen bir alt sınırdır.", "logs.col.error": "Hata", "logs.col.upstreamReason": "Yukarı akış nedeni", "logs.col.duration": "Süre", diff --git a/gui/src/i18n/zh-TW.ts b/gui/src/i18n/zh-TW.ts index 9af8782f60..c195aead79 100644 --- a/gui/src/i18n/zh-TW.ts +++ b/gui/src/i18n/zh-TW.ts @@ -516,6 +516,9 @@ export const zhTW: Record = { "logs.conversation.totals": "{requests} 次請求 · {tokens} tokens · {cost}", "logs.conversation.scope": "合計僅涵蓋目前已載入的 Logs 環形緩衝。", "logs.conversation.excluded": "(~$ 已排除 {unpriced} 筆無定價、{unmetered} 筆無計量)", + "logs.cost.approximate": "約 {amount}", + "logs.cost.lowerBound": "至少 {amount}", + "logs.cost.unavailable": "無法估算", "logs.detail.conversation": "對話", "logs.badge.claude": "Claude", "logs.col.time": "時間", @@ -1809,6 +1812,7 @@ export const zhTW: Record = { "logs.detail.attempt.recovery.emptyCompletion": "空白完成重試", "logs.detail.attempt.recovery.unknown": "未知的復原原因", "logs.detail.estimate.provider_cost_overlay": "已使用供應商設定的價格覆蓋。", + "logs.detail.estimate.priority_lower_bound": "無法取得已確認的 OpenRouter Priority 價格;目前顯示的標準價格估算是已知下限。", "pws.cockpitImportDescription": "從此裝置匯入 Cockpit Tools Antigravity JSON 匯出檔。不會顯示檔案內容。", "pws.cockpitImportFileLabel": "Cockpit Tools Antigravity JSON 匯出檔", "pws.cockpitImportChooseFile": "選擇 JSON 檔案", diff --git a/gui/src/i18n/zh.ts b/gui/src/i18n/zh.ts index 99c7ca641e..7dd4beb860 100644 --- a/gui/src/i18n/zh.ts +++ b/gui/src/i18n/zh.ts @@ -657,6 +657,9 @@ export const zh: Record = { "logs.conversation.totals": "{requests} 次请求 · {tokens} tokens · {cost}", "logs.conversation.scope": "合计仅覆盖当前已加载的 Logs 环形缓冲。", "logs.conversation.excluded": "(~$ 已排除 {unpriced} 条无定价、{unmetered} 条无计量)", + "logs.cost.approximate": "约 {amount}", + "logs.cost.lowerBound": "至少 {amount}", + "logs.cost.unavailable": "无法估算", "logs.detail.conversation": "会话", "logs.badge.claude": "Claude", "logs.badge.grok": "Grok", @@ -723,6 +726,7 @@ export const zh: Record = { "logs.detail.estimate.cache_detail_missing": "缺少缓存明细;输入费用按上限估算。", "logs.detail.estimate.expected_price_overlay": "使用了已验证的 Expected 标价。", "logs.detail.estimate.provider_cost_overlay": "使用了用户配置的提供方价格覆盖。", + "logs.detail.estimate.priority_lower_bound": "暂无已确认的 OpenRouter Priority 价格;当前显示的标准价估算是已知下界。", "logs.col.error": "错误", "logs.col.upstreamReason": "上游原因", "logs.col.duration": "耗时", diff --git a/gui/src/pages/Logs.tsx b/gui/src/pages/Logs.tsx index b7acbb4d02..55a7ef634d 100644 --- a/gui/src/pages/Logs.tsx +++ b/gui/src/pages/Logs.tsx @@ -17,6 +17,7 @@ import type { LogsTab } from "./logs-tab-keydown"; import { logsTabKeyDown, readTabFromHash, selectLogsTab } from "./logs-tab-keydown"; import { modelTitle } 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"; import type { LogSurface, LogSurfaceFilter } from "./logs-surface-filter"; import { logMatchesSurface } from "./logs-surface-filter"; @@ -53,7 +54,8 @@ type CostEstimateReason = | "usage_estimated" | "cache_detail_missing" | "expected_price_overlay" - | "provider_cost_overlay"; + | "provider_cost_overlay" + | "priority_lower_bound"; type TokPerSecondResult = | { kind: "value"; value: number; estimated: boolean } @@ -75,6 +77,7 @@ type CostResult = estimate: { cost: { input: number; output: number; cacheRead: number; cacheWrite: number; total: number }; estimated: boolean; + priorityLowerBound?: boolean; price?: MatchedPriceInfo; attempts?: Array<{ ordinal: number; price: MatchedPriceInfo }>; }; @@ -237,23 +240,6 @@ function formatTokPerSecond(result: TokPerSecondResult | undefined, localeTag?: return `${result.estimated ? "~" : ""}${value}`; } -function formatEstimatedUsd(result: CostResult | undefined, localeTag?: string): string { - if (!result || result.kind === "unavailable" || !Number.isFinite(result.estimate.cost.total) || result.estimate.cost.total < 0) return "\u2014"; - const totalUsd = result.estimate.cost.total; - return `~$${new Intl.NumberFormat(localeTag, { - minimumFractionDigits: 4, - maximumFractionDigits: 4, - }).format(totalUsd)}`; -} - -function formatEstimatedUsdValue(value: number, localeTag?: string): string { - if (!Number.isFinite(value) || value < 0) return "\u2014"; - return `~$${new Intl.NumberFormat(localeTag, { - minimumFractionDigits: 4, - maximumFractionDigits: 4, - }).format(value)}`; -} - /** Consecutive failed polls before a stale table is called out. Two seconds each, so ~6s. */ const STALE_POLL_FAILURE_LIMIT = 3; @@ -273,6 +259,7 @@ const ESTIMATE_REASON_KEYS = { cache_detail_missing: "logs.detail.estimate.cache_detail_missing", expected_price_overlay: "logs.detail.estimate.expected_price_overlay", provider_cost_overlay: "logs.detail.estimate.provider_cost_overlay", + priority_lower_bound: "logs.detail.estimate.priority_lower_bound", } as const satisfies Record; /** @@ -345,29 +332,20 @@ function summarizeFilteredLogs(entries: LogEntry[]): { requests: number; totalTokens: number; estimatedCostUsd: number; + priorityLowerBound: boolean; unpricedRequests: number; unmeteredRequests: number; } { let totalTokens = 0; - let estimatedCostUsd = 0; - let unpricedRequests = 0; - let unmeteredRequests = 0; for (const entry of entries) { const tokens = displayTokenTotal(entry); if (tokens !== undefined) totalTokens += tokens; - if (entry.usageStatus === "unsupported") { - unmeteredRequests += 1; - continue; - } - const cost = entry.displayMetrics?.cost; - const total = cost?.kind === "value" ? cost.estimate.cost.total : undefined; - if (total !== undefined && Number.isFinite(total) && total >= 0) { - estimatedCostUsd += total; - continue; - } - unpricedRequests += 1; } - return { requests: entries.length, totalTokens, estimatedCostUsd, unpricedRequests, unmeteredRequests }; + return { + requests: entries.length, + totalTokens, + ...summarizeEstimatedCosts(entries), + }; } export default function Logs({ apiBase }: { apiBase: string }) { @@ -612,7 +590,12 @@ export default function Logs({ apiBase }: { apiBase: string }) { {t("logs.conversation.totals", { requests: conversationTotals.requests, tokens: formatTokens(conversationTotals.totalTokens, localeTag ?? locale), - cost: formatEstimatedUsdValue(conversationTotals.estimatedCostUsd, localeTag), + cost: formatEstimatedUsdValue( + conversationTotals.estimatedCostUsd, + t, + localeTag, + conversationTotals.priorityLowerBound, + ), })} {" "} @@ -731,7 +714,7 @@ export default function Logs({ apiBase }: { apiBase: string }) { {formatTokPerSecond(log.displayMetrics?.tokPerSecond, localeTag)} - {formatEstimatedUsd(log.displayMetrics?.cost, localeTag)} + {formatEstimatedUsd(log.displayMetrics?.cost, t, localeTag)} @@ -956,11 +939,11 @@ function LogDetailDialog({ {cost?.kind === "value" ? ( <>
- {t("logs.detail.costTotal")}{formatEstimatedUsdValue(cost.estimate.cost.total, localeTag)} - {t("logs.tokens.input")}{formatEstimatedUsdValue(cost.estimate.cost.input, localeTag)} - {t("logs.tokens.cacheRead")}{formatEstimatedUsdValue(cost.estimate.cost.cacheRead, localeTag)} - {t("logs.tokens.cacheWrite")}{formatEstimatedUsdValue(cost.estimate.cost.cacheWrite, localeTag)} - {t("logs.tokens.output")}{formatEstimatedUsdValue(cost.estimate.cost.output, localeTag)} + {t("logs.detail.costTotal")}{formatEstimatedUsdValue(cost.estimate.cost.total, t, localeTag, cost.estimate.priorityLowerBound)} + {t("logs.tokens.input")}{formatEstimatedUsdValue(cost.estimate.cost.input, t, localeTag, cost.estimate.priorityLowerBound)} + {t("logs.tokens.cacheRead")}{formatEstimatedUsdValue(cost.estimate.cost.cacheRead, t, localeTag, cost.estimate.priorityLowerBound)} + {t("logs.tokens.cacheWrite")}{formatEstimatedUsdValue(cost.estimate.cost.cacheWrite, t, localeTag, cost.estimate.priorityLowerBound)} + {t("logs.tokens.output")}{formatEstimatedUsdValue(cost.estimate.cost.output, t, localeTag, cost.estimate.priorityLowerBound)} {cost.estimate.price && ( <> {t("logs.detail.matchedKey")} @@ -978,7 +961,7 @@ function LogDetailDialog({ ) : (
- {t("logs.detail.costTotal")}{"\u2014"} + {t("logs.detail.costTotal")}{t("logs.cost.unavailable")} {t("logs.detail.unavailableReason")} {cost?.kind === "unavailable" ? t(metricReasonKey(cost.reason)) : t("logs.detail.reason.usage_missing")}
@@ -1033,7 +1016,7 @@ function LogDetailDialog({ {attempt.durationMs}ms {formatTokPerSecond(attempt.displayMetrics?.tokPerSecond, localeTag)} - {formatEstimatedUsd(attemptCost, localeTag)} + {formatEstimatedUsd(attemptCost, t, localeTag)} {reason} ); diff --git a/gui/src/pages/logs-cost-format.ts b/gui/src/pages/logs-cost-format.ts new file mode 100644 index 0000000000..48a0814097 --- /dev/null +++ b/gui/src/pages/logs-cost-format.ts @@ -0,0 +1,77 @@ +import type { TFn } from "../i18n/shared"; + +type EstimatedCostResult = { + kind: "value"; + estimate: { cost: { total: number }; priorityLowerBound?: boolean }; +} | { kind: "unavailable" }; + +export function formatEstimatedUsdValue( + value: number, + t: TFn, + localeTag?: string, + priorityLowerBound = false, +): string { + if (!Number.isFinite(value) || value < 0) return t("logs.cost.unavailable"); + const amount = new Intl.NumberFormat(localeTag, { + style: "currency", + currency: "USD", + minimumFractionDigits: 4, + maximumFractionDigits: 4, + }).format(value); + return t(priorityLowerBound ? "logs.cost.lowerBound" : "logs.cost.approximate", { amount }); +} + +export function formatEstimatedUsd( + result: EstimatedCostResult | undefined, + t: TFn, + localeTag?: string, +): string { + if (!result || result.kind === "unavailable") return t("logs.cost.unavailable"); + return formatEstimatedUsdValue( + result.estimate.cost.total, + t, + localeTag, + result.estimate.priorityLowerBound, + ); +} + +interface CostSummaryEntry { + usageStatus?: string; + displayMetrics?: { cost: EstimatedCostResult }; +} + +export function summarizeEstimatedCosts(entries: readonly CostSummaryEntry[]): { + estimatedCostUsd: number; + priorityLowerBound: boolean; + unpricedRequests: number; + unmeteredRequests: number; +} { + let estimatedCostUsd = 0; + let everyPricedEstimateIsLowerBound = true; + let pricedEstimates = 0; + let unpricedRequests = 0; + let unmeteredRequests = 0; + for (const entry of entries) { + if (entry.usageStatus === "unsupported") { + unmeteredRequests += 1; + continue; + } + const cost = entry.displayMetrics?.cost; + if (cost?.kind === "value") { + const total = cost.estimate.cost.total; + if (Number.isFinite(total) && total >= 0) { + estimatedCostUsd += total; + pricedEstimates += 1; + everyPricedEstimateIsLowerBound &&= cost.estimate.priorityLowerBound === true; + continue; + } + } + unpricedRequests += 1; + } + return { + estimatedCostUsd, + priorityLowerBound: pricedEstimates > 0 && everyPricedEstimateIsLowerBound, + unpricedRequests, + unmeteredRequests, + }; +} diff --git a/gui/tests/logs-priority-lower-bound.test.ts b/gui/tests/logs-priority-lower-bound.test.ts new file mode 100644 index 0000000000..877252c07f --- /dev/null +++ b/gui/tests/logs-priority-lower-bound.test.ts @@ -0,0 +1,111 @@ +import { describe, expect, test } from "bun:test"; +import { DICTS } from "../src/i18n/catalogs"; +import { interpolate, type TFn } from "../src/i18n/shared"; +import { + formatEstimatedUsd, + formatEstimatedUsdValue, + summarizeEstimatedCosts, +} from "../src/pages/logs-cost-format"; + +function translator(locale: keyof typeof DICTS): TFn { + return (key, vars) => interpolate(DICTS[locale][key], vars); +} + +const en = translator("en"); +const de = translator("de"); + +describe("Logs priority lower-bound formatting", () => { + test("prefixes confirmed unpriced priority estimates with the lower-bound marker", () => { + expect(formatEstimatedUsdValue(1.6, en, "en-US", true)).toBe("≥$1.6000"); + }); + + test("keeps ordinary standard-price estimates unchanged", () => { + expect(formatEstimatedUsdValue(1.6, en, "en-US", false)).toBe("~$1.6000"); + }); + + test("uses locale-aware USD placement and separators", () => { + expect(formatEstimatedUsdValue(1.6, de, "de-DE", false)).toBe("ca. 1,6000\u00a0$"); + }); +}); + +describe("Logs table cost formatting", () => { + test("uses the shared value formatter for lower bounds, ordinary estimates, and unavailable costs", () => { + expect(formatEstimatedUsd({ + kind: "value", + estimate: { cost: { total: 1.6 }, priorityLowerBound: true }, + }, en, "en-US")).toBe("≥$1.6000"); + expect(formatEstimatedUsd({ + kind: "value", + estimate: { cost: { total: 1.6 } }, + }, en, "en-US")).toBe("~$1.6000"); + expect(formatEstimatedUsd({ kind: "unavailable" }, en, "en-US")).toBe("—"); + expect(formatEstimatedUsdValue(Number.NaN, en, "en-US")).toBe("—"); + }); +}); + +describe("Logs conversation cost aggregation", () => { + test("does not mark a mixed ordinary and lower-bound total as a lower bound", () => { + const summary = summarizeEstimatedCosts([ + { + usageStatus: "reported", + displayMetrics: { + cost: { + kind: "value", + estimate: { cost: { total: 1.6 }, priorityLowerBound: true }, + }, + }, + }, + { + usageStatus: "reported", + displayMetrics: { + cost: { + kind: "value", + estimate: { cost: { total: 0.4 } }, + }, + }, + }, + ]); + + expect(summary.estimatedCostUsd).toBe(2); + expect(summary.priorityLowerBound).toBe(false); + }); + + test("marks a total as a lower bound only when every priced estimate is one", () => { + const summary = summarizeEstimatedCosts([ + { + usageStatus: "reported", + displayMetrics: { + cost: { + kind: "value", + estimate: { cost: { total: 1.6 }, priorityLowerBound: true }, + }, + }, + }, + { + usageStatus: "reported", + displayMetrics: { + cost: { + kind: "value", + estimate: { cost: { total: 0.4 }, priorityLowerBound: true }, + }, + }, + }, + ]); + + expect(summary.estimatedCostUsd).toBe(2); + expect(summary.priorityLowerBound).toBe(true); + }); + + test("does not mark an unpriced-only total as a lower bound", () => { + const summary = summarizeEstimatedCosts([ + { + usageStatus: "reported", + displayMetrics: { cost: { kind: "unavailable" } }, + }, + ]); + + expect(summary.estimatedCostUsd).toBe(0); + expect(summary.priorityLowerBound).toBe(false); + expect(summary.unpricedRequests).toBe(1); + }); +}); diff --git a/src/adapters/base.ts b/src/adapters/base.ts index bcdd657344..7d3a8be07b 100644 --- a/src/adapters/base.ts +++ b/src/adapters/base.ts @@ -39,8 +39,20 @@ export interface ProviderAdapter { fetchResponse?(request: AdapterRequest, ctx?: AdapterFetchContext): Promise; - parseStream(response: Response, budget: TranslatorBudget): AsyncGenerator; - parseResponse?(response: Response, budget: TranslatorBudget): Promise; + /** + * Parse one upstream response. `tierMetadata` is the same live observer returned on the + * corresponding AdapterRequest; adapters that receive a documented tier echo may update it. + */ + parseStream( + response: Response, + budget: TranslatorBudget, + tierMetadata?: AdapterTierMetadata, + ): AsyncGenerator; + parseResponse?( + response: Response, + budget: TranslatorBudget, + tierMetadata?: AdapterTierMetadata, + ): Promise; runTurn?( parsed: OcxParsedRequest, incoming: IncomingMeta, diff --git a/src/adapters/openai-chat.ts b/src/adapters/openai-chat.ts index b93222d6ac..2e99805b2f 100644 --- a/src/adapters/openai-chat.ts +++ b/src/adapters/openai-chat.ts @@ -19,6 +19,7 @@ import { import { canonicalFastTierMarker, createAdapterTierMetadata, + type AdapterTierMetadata, } from "../providers/fastwire"; import { openaiChatCompletionsUrl } from "./openai-chat-url"; import { stripResponsesOnlyEncryptedMarker } from "./responses-tool-schema"; @@ -1478,7 +1479,11 @@ export function createOpenAIChatAdapter(provider: OcxProviderConfig): ProviderAd }; }, - async *parseStream(response: Response, budget: TranslatorBudget): AsyncGenerator { + async *parseStream( + response: Response, + budget: TranslatorBudget, + tierMetadata?: AdapterTierMetadata, + ): AsyncGenerator { if (!response.body) { yield { type: "error", message: "No response body" }; return; @@ -1547,11 +1552,15 @@ export function createOpenAIChatAdapter(provider: OcxProviderConfig): ProviderAd try { parsed = JSON.parse(payload); } catch { + tierMetadata?.markResponseUnparseable(); yield { type: "error", message: "malformed upstream SSE data frame" }; return "terminate"; } if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) return "continue"; const chunk = parsed as Record; + if (Object.hasOwn(chunk, "service_tier")) { + tierMetadata?.observeResponseServiceTier(chunk.service_tier); + } if (chunk.error !== undefined && chunk.error !== null) { const event = upstreamErrorEvent(chunk.error, pendingUsage); @@ -1752,8 +1761,26 @@ export function createOpenAIChatAdapter(provider: OcxProviderConfig): ProviderAd } }, - async parseResponse(response: Response, budget: TranslatorBudget): Promise { - const json = await response.json() as Record; + async parseResponse( + response: Response, + budget: TranslatorBudget, + tierMetadata?: AdapterTierMetadata, + ): Promise { + let parsed: unknown; + try { + parsed = await response.json(); + } catch (error) { + tierMetadata?.markResponseUnparseable(); + throw error; + } + if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) { + tierMetadata?.markResponseUnparseable(); + throw new Error("upstream response was not a JSON object"); + } + const json = parsed as Record; + if (Object.hasOwn(json, "service_tier")) { + tierMetadata?.observeResponseServiceTier(json.service_tier); + } const responseBytes = new TextEncoder().encode(JSON.stringify(json)).byteLength; budget.chargeRetained(responseBytes, { kind: "retained_collectors" }); try { diff --git a/src/codex/catalog/provider-fetch.ts b/src/codex/catalog/provider-fetch.ts index 93d36ac8ae..18cf154a15 100644 --- a/src/codex/catalog/provider-fetch.ts +++ b/src/codex/catalog/provider-fetch.ts @@ -413,6 +413,7 @@ function captureProviderGather( name, provider, registryTransportMatch, + configured, ); const observedAuth = authResolver.kind === "observed" && provider.authMode !== "forward" diff --git a/src/config.ts b/src/config.ts index 4d5547f5fd..a0fa0a3b25 100644 --- a/src/config.ts +++ b/src/config.ts @@ -74,6 +74,7 @@ import { getProviderRegistryEntry, providerMatchesRegistryTransport, providerModelWireDefault, + registryModelServiceTierCapabilityApplies, } from "./providers/registry"; import { resolveOpenAiVirtualModel } from "./providers/openai-virtual-models"; import { parseDesktopProfile } from "./claude/desktop-profile"; @@ -2159,7 +2160,9 @@ function inheritedFastWireConflictProviderNames( if (!registry) continue; const effectiveProviderCapability = provider.supportsServiceTier ?? registry.supportsServiceTier; const effectiveModelCapabilities = { - ...(registry.modelSupportsServiceTier ?? {}), + ...(registryModelServiceTierCapabilityApplies(registry, provider) + ? registry.modelSupportsServiceTier ?? {} + : {}), ...(provider.modelSupportsServiceTier ?? {}), }; if ( diff --git a/src/providers/derive.ts b/src/providers/derive.ts index c5e08065b3..3db91f73df 100644 --- a/src/providers/derive.ts +++ b/src/providers/derive.ts @@ -3,6 +3,7 @@ import { cloneFastWire } from "./fastwire"; import { PROVIDER_REGISTRY, registryEntryForProviderDestination, + registryModelServiceTierCapabilityApplies, type ProviderRegistryEntry, } from "./registry"; import { @@ -377,6 +378,15 @@ function applyServiceTierModelDefaults( }; } +function serviceTierModelDefaultsFor( + entry: ProviderRegistryEntry | undefined, + prov: OcxProviderConfig, +): Readonly> | undefined { + return entry && registryModelServiceTierCapabilityApplies(entry, prov) + ? entry.modelSupportsServiceTier + : undefined; +} + /** * Last-resort enrichment for a provider whose NAME matches no registry id. * @@ -412,7 +422,7 @@ export function enrichProviderFromRegistry(name: string, prov: OcxProviderConfig // which vendor endpoint is this row talking to — and is already restricted to fixed key // destinations, so a templated or overridable base URL cannot be claimed by it. enrichReasoningSummariesByDestination(prov); - applyServiceTierModelDefaults(prov, registryEntryForProviderDestination(prov)?.modelSupportsServiceTier); + applyServiceTierModelDefaults(prov, serviceTierModelDefaultsFor(registryEntryForProviderDestination(prov), prov)); return; } const explicitDirectReasoning: DirectReasoningEffortOverrides = { @@ -466,7 +476,7 @@ export function enrichProviderFromRegistry(name: string, prov: OcxProviderConfig if (prov.supportsServiceTier === undefined && entry.supportsServiceTier !== undefined) prov.supportsServiceTier = entry.supportsServiceTier; if (prov.preserveResponsesReasoningContent === undefined && entry.preserveResponsesReasoningContent !== undefined) prov.preserveResponsesReasoningContent = entry.preserveResponsesReasoningContent; applyReasoningSummaryDefaults(prov, entry.modelSupportsReasoningSummaries); - applyServiceTierModelDefaults(prov, entry.modelSupportsServiceTier); + applyServiceTierModelDefaults(prov, serviceTierModelDefaultsFor(entry, prov)); // Registry-only repair policy (#938): fill only when the runtime provider has // no explicit policy, and deep-clone so saved/user values never alias the // registry constant. diff --git a/src/providers/registry.ts b/src/providers/registry.ts index c81735ba15..364478143d 100644 --- a/src/providers/registry.ts +++ b/src/providers/registry.ts @@ -17,6 +17,7 @@ import { cursorModelReasoningEfforts, } from "../adapters/cursor/discovery"; import { COMMAND_CODE_MODEL_REASONING_EFFORTS } from "./command-code-efforts"; +import { isCanonicalOpenRouterTarget } from "./openrouter-routing"; export type ProviderAuthKind = "forward" | "oauth" | "key" | "local"; export type MetadataModelIdNormalize = "case-insensitive"; @@ -217,6 +218,11 @@ export interface ProviderRegistryEntry { supportsServiceTier?: boolean; /** Registry default for exact model service-tier capability; explicit config keys win. */ modelSupportsServiceTier?: Record; + /** + * Registry-only destination guard for `modelSupportsServiceTier`. This scopes vendor evidence + * without changing provider ownership, routing, authentication, or config validation. + */ + modelServiceTierCapabilityBaseUrlGuard?: (baseUrl: string) => boolean; /** Registry default for plaintext reasoning replay; see `OcxProviderConfig.preserveResponsesReasoningContent`. Registry-only like `supportsServiceTier`. */ preserveResponsesReasoningContent?: boolean; /** Registry defaults for per-model Codex reasoning propagation; explicit user keys win during enrichment. */ @@ -1349,7 +1355,33 @@ export const PROVIDER_REGISTRY: readonly ProviderRegistryEntry[] = [ autoToolChoiceOnlyModels: ["kimi-k2.7-code"], preserveReasoningContentModels: NEURALWATT_REASONING_HISTORY_MODELS, }, - { id: "openrouter", label: "OpenRouter", adapter: "openai-chat", baseUrl: "https://openrouter.ai/api/v1", authKind: "key", featured: true, dashboardUrl: "https://openrouter.ai/keys", jawcodeBundle: "openrouter", models: ["anthropic/claude-sonnet-5", ...OPENROUTER_GPT56_MODELS], modelContextWindows: { "anthropic/claude-sonnet-5": 1_000_000, ...OPENROUTER_GPT56_CONTEXT_WINDOWS } }, + { + id: "openrouter", + label: "OpenRouter", + adapter: "openai-chat", + baseUrl: "https://openrouter.ai/api/v1", + authKind: "key", + featured: true, + dashboardUrl: "https://openrouter.ai/keys", + jawcodeBundle: "openrouter", + models: ["anthropic/claude-sonnet-5", ...OPENROUTER_GPT56_MODELS], + modelContextWindows: { + "anthropic/claude-sonnet-5": 1_000_000, + ...OPENROUTER_GPT56_CONTEXT_WINDOWS, + }, + // OpenRouter documents priority support for OpenAI endpoints, but not Anthropic. Keep the + // provider unclassified and opt in only the exact OpenAI-backed slugs we ship. These facts + // belong only to the canonical destination; a same-named custom gateway is unknown to us. + modelServiceTierCapabilityBaseUrlGuard: isCanonicalOpenRouterTarget, + modelSupportsServiceTier: { + "openai/gpt-5.6-sol": true, + "openai/gpt-5.6-terra": true, + "openai/gpt-5.6-luna": true, + }, + // Deliberately no OpenRouter route pin: it bills the endpoint actually used and reports the + // actual service_tier. B0 confirmation therefore owns downgrade safety. Forcing `only` plus + // `allow_fallbacks:false` would turn a graceful priority-capacity fallback into a hard failure. + }, { // Primary sources checked 2026-08-02: // - docs.cline.bot/getting-started/clinepass publishes this exact catalog and explicitly @@ -2555,6 +2587,15 @@ export function getProviderRegistryEntry(id: string): ProviderRegistryEntry | un return PROVIDER_REGISTRY.find(entry => entry.id === id); } +/** Whether this registry row's per-model service-tier evidence applies to one configured target. */ +export function registryModelServiceTierCapabilityApplies( + entry: Pick, + provider: Pick, +): boolean { + const guard = entry.modelServiceTierCapabilityBaseUrlGuard; + return guard === undefined || guard(provider.baseUrl); +} + function normalizedProviderEndpoint(value: string): string { const trimmed = value.trim(); try { diff --git a/src/providers/service-tier.ts b/src/providers/service-tier.ts index a06c42c7b4..278f89394e 100644 --- a/src/providers/service-tier.ts +++ b/src/providers/service-tier.ts @@ -4,6 +4,7 @@ import { isCanonicalOpenAiForwardProvider } from "./openai-tiers"; import { getProviderRegistryEntry, providerMatchesRegistryTransport, + registryModelServiceTierCapabilityApplies, type InboundWire, type ModelWireDefault, } from "./registry"; @@ -57,8 +58,14 @@ function buildFastPolicyAuthority( providerName: string, provider: ServiceTierCapabilityProvider, registryTransportMatch: boolean, + capabilityProvider: ServiceTierCapabilityProvider = provider, ): FastPolicyAuthority { const registry = registryTransportMatch ? getProviderRegistryEntry(providerName) : undefined; + const registryModelCapabilities = registry + && registryModelServiceTierCapabilityApplies(registry, capabilityProvider) + ? registry.modelSupportsServiceTier + : undefined; + const providerCapability = capabilityProvider.supportsServiceTier ?? registry?.supportsServiceTier; const authority: FastPolicyAuthority = Object.freeze({ providerAdapter: provider.adapter, fastWireDeclaration: cloneFastWire( @@ -72,8 +79,11 @@ function buildFastPolicyAuthority( provider.apiKeyTransport, ), capability: Object.freeze({ - ...(provider.supportsServiceTier !== undefined ? { provider: provider.supportsServiceTier } : {}), - models: Object.freeze({ ...(provider.modelSupportsServiceTier ?? {}) }), + ...(providerCapability !== undefined ? { provider: providerCapability } : {}), + models: Object.freeze({ + ...(registryModelCapabilities ?? {}), + ...(capabilityProvider.modelSupportsServiceTier ?? {}), + }), ...(provider.chatServiceTier !== undefined ? { chatServiceTier: provider.chatServiceTier } : {}), }), modelAdapters: Object.freeze({ ...(provider.modelAdapters ?? {}) }), @@ -87,8 +97,14 @@ export function captureFastPolicyAuthority( providerName: string, provider: ServiceTierCapabilityProvider, registryTransportMatch: boolean, + capabilityProvider: ServiceTierCapabilityProvider = provider, ): FastPolicyAuthority { - const authority = buildFastPolicyAuthority(providerName, provider, registryTransportMatch); + const authority = buildFastPolicyAuthority( + providerName, + provider, + registryTransportMatch, + capabilityProvider, + ); if (Object.isFrozen(provider)) capturedFastPolicyAuthorities.set(provider, authority); return authority; } @@ -106,12 +122,13 @@ export function captureServiceTierAdapterAuthority( function authorityForProvider( provider: ServiceTierCapabilityProvider, providerName?: string, + capabilityProvider?: ServiceTierCapabilityProvider, ): FastPolicyAuthority { // Preserve the legacy no-name short circuit: serviceTierSupportForModel() used the // provider adapter directly when no provider identity was available, so no configured // override, hard pin, or registry default may participate on this path in A1. if (providerName === undefined) { - const authority = buildFastPolicyAuthority("", provider, false); + const authority = buildFastPolicyAuthority("", provider, false, capabilityProvider ?? provider); return Object.freeze({ ...authority, modelAdapters: Object.freeze({}), @@ -119,12 +136,17 @@ function authorityForProvider( registryWireDefaults: Object.freeze({}), }); } - const captured = Object.isFrozen(provider) + const captured = capabilityProvider === undefined && Object.isFrozen(provider) ? capturedFastPolicyAuthorities.get(provider) : undefined; if (captured) return captured; const registryTransportMatch = providerMatchesRegistryTransport(providerName, provider); - const authority = buildFastPolicyAuthority(providerName, provider, registryTransportMatch); + const authority = buildFastPolicyAuthority( + providerName, + provider, + registryTransportMatch, + capabilityProvider ?? provider, + ); // Frozen provider snapshots cannot drift, so repeated catalog/runtime projections may safely // reuse the registry lookup and detached declaration maps. Mutable configs still rebuild. if (Object.isFrozen(provider)) capturedFastPolicyAuthorities.set(provider, authority); @@ -137,8 +159,13 @@ export function fastPolicyForModel( modelId: string, providerName?: string, inbound: InboundWire = "responses", + capabilityProvider?: ServiceTierCapabilityProvider, ): ResolvedFastPolicy { - return resolveFastPolicy(authorityForProvider(provider, providerName), modelId, inbound); + return resolveFastPolicy( + authorityForProvider(provider, providerName, capabilityProvider), + modelId, + inbound, + ); } /** diff --git a/src/router.ts b/src/router.ts index 723ff8ca84..be06d6c478 100644 --- a/src/router.ts +++ b/src/router.ts @@ -11,7 +11,11 @@ import type { NormalizedComboConfig } from "./combos/types"; import { hasOwnProvider, resolveEnvValue } from "./config"; import { assertProviderDestinationAllowed } from "./lib/destination-policy"; import { redactSecretString, redactUrlForLog } from "./lib/redact"; -import { PROVIDER_REGISTRY, providerCodexAccountMode } from "./providers/registry"; +import { + PROVIDER_REGISTRY, + providerCodexAccountMode, + registryModelServiceTierCapabilityApplies, +} from "./providers/registry"; import { applyDirectReasoningEffortContracts, hasLegacyClinePassReasoningEfforts } from "./providers/derive"; import { cloneFastWire } from "./providers/fastwire"; import { @@ -296,7 +300,9 @@ export function routedProviderConfig(providerName: string, provider: OcxProvider : mergeRecordFill(registryEntry.modelMaxInputTokens, provider.modelMaxInputTokens); const modelMaxOutputTokens = mergeRecordFill(registryEntry.modelMaxOutputTokens, provider.modelMaxOutputTokens); const modelSupportsServiceTier = mergeRecordFill( - registryEntry.modelSupportsServiceTier, + registryModelServiceTierCapabilityApplies(registryEntry, provider) + ? registryEntry.modelSupportsServiceTier + : undefined, provider.modelSupportsServiceTier, ); const noVisionModels = mergeStringArray(registryEntry.noVisionModels, provider.noVisionModels); diff --git a/src/routing/compatibility/behavior.ts b/src/routing/compatibility/behavior.ts index fce2ff004d..5abf08f17d 100644 --- a/src/routing/compatibility/behavior.ts +++ b/src/routing/compatibility/behavior.ts @@ -1,7 +1,7 @@ import { modelInList } from "../../types"; import type { OcxConfig, OcxProviderConfig } from "../../types"; import { PROVIDER_REGISTRY } from "../../providers/registry"; -import { fastPolicyForModel, serviceTierSupportForModel } from "../../providers/service-tier"; +import { fastPolicyForModel, serviceTierSupportFromPolicy } from "../../providers/service-tier"; import { resolveProviderAuthTransport } from "../../providers/fastwire"; import { localFingerprint } from "../../lab/digest"; import type { LabBehaviorSource, LabBehaviorValues } from "../../lab/live/types"; @@ -103,7 +103,7 @@ export function resolveProductionBehaviorValues( const project = typeof effective.project === "string" && effective.project ? effective.project : null; const location = typeof effective.location === "string" && effective.location ? effective.location : null; const nativeLocalExec = effective.nativeLocalExec === "on" || effective.unsafeAllowNativeLocalExec === true; - const fastPolicy = fastPolicyForModel(effective, modelId, providerName); + const fastPolicy = fastPolicyForModel(effective, modelId, providerName, "responses", provider); const values: LabBehaviorValues = { "wire.adapter": behaviorRow("provider_config", adapter), @@ -122,7 +122,7 @@ export function resolveProductionBehaviorValues( "responses.stateful": behaviorRow("provider_config", effective.statelessResponses !== true), "responses.serviceTier": behaviorRow( "provider_config", - serviceTierSupportForModel(effective, modelId, providerName) ?? null, + serviceTierSupportFromPolicy(fastPolicy) ?? null, ), "responses.fastWireKind": behaviorRow( "provider_config", diff --git a/src/server/management/shared.ts b/src/server/management/shared.ts index 77d1f74264..3ebea685e8 100644 --- a/src/server/management/shared.ts +++ b/src/server/management/shared.ts @@ -86,7 +86,8 @@ export type CostEstimateReason = | "usage_estimated" | "cache_detail_missing" | "expected_price_overlay" - | "provider_cost_overlay"; + | "provider_cost_overlay" + | "priority_lower_bound"; export type CostResult = | { kind: "value"; estimate: NonNullable>; estimateReasons: CostEstimateReason[] } @@ -143,6 +144,7 @@ export function costResult(entry: MetricSource): CostResult { ? "expected_price_overlay" as const : undefined, estimate.price?.source === "user" || estimate.attempts?.some(a => a.price.source === "user") ? "provider_cost_overlay" as const : undefined, + estimate.priorityLowerBound ? "priority_lower_bound" as const : undefined, ].filter((reason): reason is CostEstimateReason => reason !== undefined); return { kind: "value", estimate, estimateReasons }; } diff --git a/src/server/responses/core.ts b/src/server/responses/core.ts index 8d2906e536..9e2813d0b5 100644 --- a/src/server/responses/core.ts +++ b/src/server/responses/core.ts @@ -1238,6 +1238,7 @@ async function applyFinalRouteRequestNormalization(args: { route.modelId, route.providerName, inboundWire, + config.providers[route.providerName], ); const modelServiceTierSupport = serviceTierSupportFromPolicy(fastPolicy); const callerTier = parsed.options.serviceTier; @@ -4348,9 +4349,9 @@ async function handleResponsesInner( const detachContinuationBodyGuard = cancelBodyOnAbort(response.body, upstream.signal); try { if (nextParsed.stream) { - yield* activeAdapter.parseStream(response, translatorBudget); + yield* activeAdapter.parseStream(response, translatorBudget, logCtx.activeTierMetadata); } else if (activeAdapter.parseResponse) { - yield* await activeAdapter.parseResponse(response, translatorBudget); + yield* await activeAdapter.parseResponse(response, translatorBudget, logCtx.activeTierMetadata); } else { yield { type: "error", message: "Provider continuation does not support response parsing" }; } @@ -4380,7 +4381,11 @@ async function handleResponsesInner( }; if (parsed.stream) { - const initialEventStream = activeAdapter.parseStream(upstreamResponse, translatorBudget); + const initialEventStream = activeAdapter.parseStream( + upstreamResponse, + translatorBudget, + logCtx.activeTierMetadata, + ); const eventStream = terminalGuardEnabled ? guardTerminalEventStream({ parsed, @@ -4448,7 +4453,11 @@ async function handleResponsesInner( if (activeAdapter.parseResponse) { let events: AdapterEvent[]; try { - const initialEvents = await activeAdapter.parseResponse(upstreamResponse, translatorBudget); + const initialEvents = await activeAdapter.parseResponse( + upstreamResponse, + translatorBudget, + logCtx.activeTierMetadata, + ); let guardedEvents: AdapterEvent[]; if (terminalGuardEnabled) { guardedEvents = []; diff --git a/src/usage/cost.ts b/src/usage/cost.ts index 5cf798e144..608dea31ac 100644 --- a/src/usage/cost.ts +++ b/src/usage/cost.ts @@ -83,6 +83,8 @@ export interface AttemptCostEstimate { estimated: boolean; /** Applied OpenAI priority-tier multiplier (undefined or 1 = standard). */ priorityMultiplier?: number; + /** Standard-price estimate is a known floor for a confirmed, unpriced priority endpoint. */ + priorityLowerBound?: boolean; /** Set when the published long-context rate was applied (#908). */ contextTier?: ContextTierName; } @@ -95,6 +97,8 @@ export interface CostEstimate { price?: MatchedPrice; /** Applied OpenAI priority-tier multiplier (undefined or 1 = standard). */ priorityMultiplier?: number; + /** Standard-price estimate is a known floor for a confirmed, unpriced priority endpoint. */ + priorityLowerBound?: boolean; /** Set when any priced attempt used the published long-context rate (#908). */ contextTier?: ContextTierName; } @@ -350,7 +354,9 @@ export type ServiceTierInput = string | ServiceTierContext; * and long-context exclusivity depends on that distinction. */ export function serviceTierContext(entry: ServiceTierContext): ServiceTierContext { - if (entry.tierOutcome) return serviceTierContextFromOutcome(entry.tierOutcome); + if (entry.tierOutcome) { + return { ...serviceTierContextFromOutcome(entry.tierOutcome), tierOutcome: entry.tierOutcome }; + } return { responseServiceTier: entry.responseServiceTier, requestedServiceTier: entry.requestedServiceTier, @@ -448,6 +454,28 @@ function applyPriorityMultiplier( }, multiplier]; } +/** + * OpenRouter confirms the actual endpoint tier and documents priority as higher cost, but this + * branch does not bundle its provider-specific priority endpoint prices. A confirmed canonical + * priority result can therefore use the standard price only as a provable lower bound. Do not + * extend this to flex (cheaper) or to other providers without the same pricing contract. + * + * An ASSUMED priority attempt needs the same marker for a different reason. There the provider + * never echoed a tier at all, so the standard price is not merely a lower bound on a known + * premium — it is a floor under an outcome we did not observe. Returning it unmarked reports a + * definite standard cost for a request that may well have been billed as priority, which is the + * one thing a cost estimate must never do. + */ +function isOpenRouterPriorityLowerBound( + provider: string, + outcome: AttemptTierOutcome | undefined, +): boolean { + return baseProviderLabel(provider) === "openrouter" + && outcome?.canonical === "priority" + && outcome.fastOutcome === "applied" + && (outcome.confirmation === "confirmed" || outcome.confirmation === "assumed"); +} + /** * Per-attempt cost estimate: tokens normalized, price resolved (user overlay → * catalogs), priority/long-context tiers applied. Null when usage or price is @@ -476,6 +504,7 @@ export function estimateAttemptCost( const [effectiveCost4, multiplier] = contextTier ? [tieredCost4, 1] as const : applyPriorityMultiplier(tieredCost4, attempt.provider, attempt.model, attemptServiceTier); + const priorityLowerBound = isOpenRouterPriorityLowerBound(attempt.provider, attempt.tierOutcome); return { ordinal: attempt.ordinal, provider: attempt.provider, @@ -485,6 +514,7 @@ export function estimateAttemptCost( cost: calculateCost(tokens, effectiveCost4), estimated: isEstimated(attempt.usage, attempt.usageStatus, price.status), ...(multiplier !== 1 ? { priorityMultiplier: multiplier } : {}), + ...(priorityLowerBound ? { priorityLowerBound: true } : {}), ...(contextTier ? { contextTier } : {}), }; } @@ -527,6 +557,7 @@ export function estimateComboCost( ...(estimates.some(est => est.priorityMultiplier && est.priorityMultiplier !== 1) ? { priorityMultiplier: estimates.find(est => est.priorityMultiplier)?.priorityMultiplier } : {}), + ...(estimates.some(est => est.priorityLowerBound) ? { priorityLowerBound: true } : {}), ...(estimates.some(est => est.contextTier) ? { contextTier: "long" as const } : {}), }; } @@ -554,12 +585,17 @@ export function estimateRequestCost( const [effectiveCost4, multiplier] = contextTier ? [tieredCost4, 1] as const : applyPriorityMultiplier(tieredCost4, input.provider, input.model, input.serviceTier); + const priorityLowerBound = isOpenRouterPriorityLowerBound( + input.provider, + typeof input.serviceTier === "object" ? input.serviceTier.tierOutcome : undefined, + ); return { tokens, price, cost: calculateCost(tokens, effectiveCost4), estimated: isEstimated(input.usage, input.usageStatus, price.status), ...(multiplier !== 1 ? { priorityMultiplier: multiplier } : {}), + ...(priorityLowerBound ? { priorityLowerBound: true } : {}), ...(contextTier ? { contextTier } : {}), }; } diff --git a/tests/fastwire-observability.test.ts b/tests/fastwire-observability.test.ts index 058ca6c961..d712a038ad 100644 --- a/tests/fastwire-observability.test.ts +++ b/tests/fastwire-observability.test.ts @@ -1,5 +1,6 @@ import { describe, expect, test } from "bun:test"; import type { AdapterRequest } from "../src/adapters/base"; +import { createOpenAIChatAdapter } from "../src/adapters/openai-chat"; import { createResponsesPassthroughAdapter } from "../src/adapters/openai-responses"; import { buildBehaviorFingerprintV1 } from "../src/lab/subject/behavior-fingerprint"; import { sanitizeLogMetadataString } from "../src/lib/redact"; @@ -18,12 +19,13 @@ import { type RequestLogContext, type RequestLogEntry, } from "../src/server/request-log"; -import { applyServiceTierGate } from "../src/server/responses/core"; +import { applyServiceTierGate, handleResponses } from "../src/server/responses/core"; +import { costResult } from "../src/server/management/shared"; import type { OcxConfig, OcxParsedRequest, TierObservationContext } from "../src/types"; import { estimateComboCost, serviceTierContextFromOutcome } from "../src/usage/cost"; import type { ExpectedPriceOverlay } from "../src/usage/expected-prices"; import { normalizeUsageEntryForTest } from "../src/usage/log"; -import { withTestTranslatorBudget } from "./helpers/translator-budget"; +import { createTestTranslatorBudget, withTestTranslatorBudget } from "./helpers/translator-budget"; const SERVICE_WIRE = { kind: "service-tier" as const, @@ -408,6 +410,120 @@ describe("FastWire logging and persistence", () => { expect(normalized.responseServiceTier).toBe(expected); expect(normalized.tierOutcome?.responseServiceTier).toBe(expected); }); + + test.each([ + { wire: "stream", responseTier: "priority", fastOutcome: "applied", confirmation: "confirmed", canonical: "priority" }, + { wire: "stream", responseTier: "default", fastOutcome: "downgraded", confirmation: "downgraded", canonical: undefined }, + { wire: "stream", responseTier: undefined, fastOutcome: "applied", confirmation: "assumed", canonical: "priority" }, + { wire: "non-stream", responseTier: "priority", fastOutcome: "applied", confirmation: "confirmed", canonical: "priority" }, + { wire: "non-stream", responseTier: "default", fastOutcome: "downgraded", confirmation: "downgraded", canonical: undefined }, + { wire: "non-stream", responseTier: undefined, fastOutcome: "applied", confirmation: "assumed", canonical: "priority" }, + ] as const)( + "OpenAI Chat $wire response tier=$responseTier updates the existing adapter observer", + async ({ wire, responseTier, fastOutcome, confirmation, canonical }) => { + const tracker = createAdapterTierMetadata( + observation(), + { kind: "set", value: "priority" }, + "service-tier", + "priority", + )!; + const adapter = createOpenAIChatAdapter({ + adapter: "openai-chat", + baseUrl: "https://openrouter.ai/api/v1", + }); + const tierField = responseTier === undefined ? {} : { service_tier: responseTier }; + const budget = createTestTranslatorBudget(); + + if (wire === "stream") { + const chunk = { + id: "chatcmpl-tier", + object: "chat.completion.chunk", + ...tierField, + choices: [{ index: 0, delta: { content: "ok" }, finish_reason: "stop" }], + usage: { prompt_tokens: 1, completion_tokens: 1 }, + }; + for await (const _ of adapter.parseStream(new Response( + `data: ${JSON.stringify(chunk)}\n\ndata: [DONE]\n\n`, + ), budget, tracker)) { + // Drain the adapter so the final chunk and tier echo are observed. + } + } else { + await adapter.parseResponse(new Response(JSON.stringify({ + id: "chatcmpl-tier", + object: "chat.completion", + ...tierField, + choices: [{ index: 0, message: { role: "assistant", content: "ok" }, finish_reason: "stop" }], + usage: { prompt_tokens: 1, completion_tokens: 1 }, + })), budget, tracker); + } + + expect(tracker.outcome).toMatchObject({ fastOutcome, confirmation }); + if (canonical === undefined) expect(tracker.outcome).not.toHaveProperty("canonical"); + else expect(tracker.outcome.canonical).toBe(canonical); + if (responseTier === undefined) expect(tracker.outcome).not.toHaveProperty("responseServiceTier"); + else expect(tracker.outcome.responseServiceTier).toBe(responseTier); + }, + ); + + test.each([ + { stream: true, responseTier: "priority", fastOutcome: "applied", confirmation: "confirmed" }, + { stream: false, responseTier: "default", fastOutcome: "downgraded", confirmation: "downgraded" }, + ] as const)( + "the Responses bridge passes the live observer into Chat parsing (stream=$stream)", + async ({ stream, responseTier, fastOutcome, confirmation }) => { + const originalFetch = globalThis.fetch; + globalThis.fetch = (async () => { + const payload = stream + ? `data: ${JSON.stringify({ + service_tier: responseTier, + choices: [{ index: 0, delta: { content: "ok" }, finish_reason: "stop" }], + usage: { prompt_tokens: 1, completion_tokens: 1 }, + })}\n\ndata: [DONE]\n\n` + : JSON.stringify({ + service_tier: responseTier, + choices: [{ index: 0, message: { role: "assistant", content: "ok" }, finish_reason: "stop" }], + usage: { prompt_tokens: 1, completion_tokens: 1 }, + }); + return new Response(payload, { + status: 200, + headers: { "content-type": stream ? "text/event-stream" : "application/json" }, + }); + }) as typeof fetch; + const logCtx: RequestLogContext = { model: "", provider: "" }; + try { + const response = await handleResponses( + new Request("http://localhost/v1/responses", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ model: "fixture/model", input: "ping", stream }), + }), + { + port: 10100, + defaultProvider: "fixture", + fastMode: true, + providers: { + fixture: { + adapter: "openai-chat", + baseUrl: "https://fixture.example.test/v1", + apiKey: "sk-test", + supportsServiceTier: true, + }, + }, + }, + logCtx, + {}, + ); + await response.text(); + } finally { + globalThis.fetch = originalFetch; + } + expect(logCtx.activeAttempt?.tierOutcome).toMatchObject({ + fastOutcome, + confirmation, + responseServiceTier: responseTier, + }); + }, + ); }); describe("FastWire per-attempt cost", () => { @@ -498,6 +614,140 @@ describe("FastWire per-attempt cost", () => { expect(estimate.cost.total).toBeCloseTo(6.4, 9); expect(estimate.attempts?.every(attempt => attempt.priorityMultiplier === 2)).toBe(true); }); + + test("confirmed OpenRouter priority is a standard-price lower bound, while downgrade and OpenAI stay unchanged", () => { + const openRouterOverlays: ExpectedPriceOverlay[] = [{ + provider: "openrouter", + modelId: "openai/gpt-5.6-sol", + cost4: { input: 5, output: 30, cacheRead: 0.5, cacheWrite: 6.25 }, + source: "test", + verifiedAt: "2026-08-18", + status: "verified", + }]; + const confirmedPriority = { + canonical: "priority" as const, + wireKind: "service-tier" as const, + wireValue: "priority", + fastOutcome: "applied" as const, + confirmation: "confirmed" as const, + responseServiceTier: "priority", + }; + const downgraded = { + wireKind: "service-tier" as const, + wireValue: "priority", + fastOutcome: "downgraded" as const, + fastDowngradeReason: "response-declined" as const, + confirmation: "downgraded" as const, + responseServiceTier: "default", + }; + + const priority = estimateComboCost([{ + ordinal: 1, + provider: "openrouter", + model: "openai/gpt-5.6-sol", + usageStatus: "reported", + usage, + tierOutcome: confirmedPriority, + }], openRouterOverlays)!; + expect(priority.cost.total).toBeCloseTo(1.6, 9); + expect(priority.priorityMultiplier).toBeUndefined(); + expect(priority.priorityLowerBound).toBe(true); + expect(priority.attempts?.[0]?.priorityLowerBound).toBe(true); + + const standard = estimateComboCost([{ + ordinal: 1, + provider: "openrouter", + model: "openai/gpt-5.6-sol", + usageStatus: "reported", + usage, + tierOutcome: downgraded, + }], openRouterOverlays)!; + expect(standard.cost.total).toBeCloseTo(1.6, 9); + expect(standard.priorityLowerBound).toBeUndefined(); + + const flex = estimateComboCost([{ + ordinal: 1, + provider: "openrouter", + model: "openai/gpt-5.6-sol", + usageStatus: "reported", + usage, + tierOutcome: { ...downgraded, responseServiceTier: "flex" }, + }], openRouterOverlays)!; + expect(flex.cost.total).toBeCloseTo(1.6, 9); + expect(flex.priorityLowerBound).toBeUndefined(); + + const openAi = estimateComboCost([{ + ordinal: 1, + provider: "openai", + model: "gpt-5.6-sol", + usageStatus: "reported", + usage, + tierOutcome: confirmedPriority, + }], overlays)!; + expect(openAi.cost.total).toBeCloseTo(3.2, 9); + expect(openAi.priorityMultiplier).toBe(2); + expect(openAi.priorityLowerBound).toBeUndefined(); + }); + + test("an ASSUMED OpenRouter priority attempt is a lower bound, not a definite standard price", () => { + // The provider never echoed a tier, so we do not know which price was billed. Reporting + // the standard total unmarked would tell the UI "this cost 1.6" for a request that may + // have been served -- and billed -- as priority. The confirmed case is a lower bound + // because the premium price is not bundled; the assumed case is a lower bound because + // the OUTCOME itself was never observed. Both must carry the marker. + const usage = { inputTokens: 1_000_000, outputTokens: 0, cachedInputTokens: 0 }; + const assumedPriority = { + canonical: "priority" as const, + wireKind: "service-tier" as const, + wireValue: "priority", + fastOutcome: "applied" as const, + confirmation: "assumed" as const, + }; + const openRouterOverlays: ExpectedPriceOverlay[] = [{ + provider: "openrouter", + modelId: "openai/gpt-5.6-sol", + cost4: { input: 5, output: 30, cacheRead: 0.5, cacheWrite: 6.25 }, + source: "test", + verifiedAt: "2026-08-18", + status: "verified", + }]; + + const assumed = estimateComboCost([{ + ordinal: 1, + provider: "openrouter", + model: "openai/gpt-5.6-sol", + usageStatus: "reported", + usage, + tierOutcome: assumedPriority, + }], openRouterOverlays)!; + + expect(assumed.priorityLowerBound).toBe(true); + expect(assumed.attempts?.[0]?.priorityLowerBound).toBe(true); + // No premium multiplier is applied — the standard rate IS the floor being reported. + expect(assumed.priorityMultiplier).toBeUndefined(); + }); + + test("management cost metadata exposes the aligned priority_lower_bound reason", () => { + const result = costResult({ + provider: "openrouter", + model: "openai/gpt-5.6-sol", + durationMs: 1, + usageStatus: "reported", + usage, + tierOutcome: { + canonical: "priority", + wireKind: "service-tier", + wireValue: "priority", + fastOutcome: "applied", + confirmation: "confirmed", + responseServiceTier: "priority", + }, + }); + expect(result.kind).toBe("value"); + if (result.kind !== "value") throw new Error("expected a priced OpenRouter result"); + expect(result.estimate.priorityLowerBound).toBe(true); + expect(result.estimateReasons).toContain("priority_lower_bound"); + }); }); describe("FastWire gate and compatibility fingerprint", () => { diff --git a/tests/service-tier-capability.test.ts b/tests/service-tier-capability.test.ts index 2e397be86b..49c051b8dc 100644 --- a/tests/service-tier-capability.test.ts +++ b/tests/service-tier-capability.test.ts @@ -7,6 +7,9 @@ * ever receiving an injection (PR #860 family). */ import { afterEach, describe, expect, test } from "bun:test"; +import { applyProviderConfigHints } from "../src/codex/catalog"; +import { applyCatalogModelMetadata } from "../src/codex/catalog/effort"; +import type { RawEntry } from "../src/codex/catalog/parsing"; import { providerConfigSeed, enrichProviderFromRegistry } from "../src/providers/derive"; import { getProviderRegistryEntry } from "../src/providers/registry"; import type { RequestLogContext } from "../src/server/request-log"; @@ -45,6 +48,30 @@ describe("registry capability reaches saved configs without overriding them", () enrichProviderFromRegistry("deepseek", optedIn); expect(optedIn.supportsServiceTier).toBe(true); }); + + test("OpenRouter stays provider-unclassified and declares only its three OpenAI-backed slugs", () => { + const entry = getProviderRegistryEntry("openrouter")!; + expect(entry.supportsServiceTier).toBeUndefined(); + expect(entry.chatServiceTier).toBeUndefined(); + expect(entry.modelSupportsServiceTier).toEqual({ + "openai/gpt-5.6-sol": true, + "openai/gpt-5.6-terra": true, + "openai/gpt-5.6-luna": true, + }); + expect(entry.modelSupportsServiceTier).not.toHaveProperty("anthropic/claude-sonnet-5"); + expect(providerConfigSeed(entry).modelSupportsServiceTier).toBeUndefined(); + }); + + test("OpenRouter registry capability is not inherited by a same-named noncanonical destination", () => { + const provider: OcxProviderConfig = { + ...providerConfigSeed(getProviderRegistryEntry("openrouter")!), + baseUrl: "https://openrouter-proxy.example.test/v1", + apiKey: "sk-test", + }; + enrichProviderFromRegistry("openrouter", provider); + expect(provider.modelSupportsServiceTier).toBeUndefined(); + expect(supportsServiceTierForModel(provider, "openai/gpt-5.6-sol")).toBeUndefined(); + }); }); describe("service-tier capability is exact-model and provider-scoped", () => { @@ -237,6 +264,14 @@ describe("the gate fires on the live handleResponses path", () => { ({ ...providerConfigSeed(getProviderRegistryEntry("deepseek")!), apiKey: "sk-test" }); const openAiKeyProvider = (): OcxProviderConfig => ({ ...providerConfigSeed(getProviderRegistryEntry("openai-apikey")!), apiKey: "sk-test" }); + const openRouterProvider = (overrides: Partial = {}): OcxProviderConfig => { + const provider: OcxProviderConfig = { + ...providerConfigSeed(getProviderRegistryEntry("openrouter")!), + apiKey: "sk-test", + ...overrides, + }; + return provider; + }; test("DeepSeek never receives service_tier, even with fastMode on", async () => { const body = await drive("deepseek", deepseekProvider(), "deepseek-v4-flash", {}, true); @@ -310,6 +345,53 @@ describe("the gate fires on the live handleResponses path", () => { const undeclared = await drive("custom-chat", custom(), "undeclared-model", { service_tier: "priority" }); expect(undeclared).not.toHaveProperty("service_tier"); }); + + test.each([ + "openai/gpt-5.6-sol", + "openai/gpt-5.6-terra", + "openai/gpt-5.6-luna", + ])("OpenRouter %s publishes and injects canonical Fast without route pins", async modelId => { + const provider = openRouterProvider(); + const model = applyProviderConfigHints("openrouter", provider, { id: modelId, provider: "openrouter" }); + const catalogEntry: RawEntry = {}; + applyCatalogModelMetadata(catalogEntry, model); + expect(catalogEntry.service_tiers).toEqual([expect.objectContaining({ id: "priority", name: "Fast" })]); + + const body = await drive("openrouter", provider, modelId, {}, true); + expect(body.service_tier).toBe("priority"); + expect(body).not.toHaveProperty("provider"); + }); + + test("OpenRouter Anthropic and undeclared slugs stay unclassified and receive no Fast injection", async () => { + const provider = openRouterProvider(); + for (const modelId of ["anthropic/claude-sonnet-5", "google/gemini-unknown"]) { + const model = applyProviderConfigHints("openrouter", provider, { id: modelId, provider: "openrouter" }); + const catalogEntry: RawEntry = {}; + applyCatalogModelMetadata(catalogEntry, model); + expect(catalogEntry).not.toHaveProperty("service_tiers"); + expect(await drive("openrouter", provider, modelId, {}, true)).not.toHaveProperty("service_tier"); + } + }); + + test("an explicit OpenRouter provider-level false remains fail-closed", async () => { + const provider = openRouterProvider({ supportsServiceTier: false }); + expect(supportsServiceTierForModel(provider, "openai/gpt-5.6-sol")).toBe(false); + const body = await drive("openrouter", provider, "openai/gpt-5.6-sol", {}, true); + expect(body).not.toHaveProperty("service_tier"); + }); + + test("a same-named noncanonical OpenRouter destination neither publishes nor injects registry Fast", async () => { + const provider = openRouterProvider({ baseUrl: "https://openrouter-proxy.example.test/v1" }); + const model = applyProviderConfigHints("openrouter", provider, { + id: "openai/gpt-5.6-sol", + provider: "openrouter", + }); + const catalogEntry: RawEntry = {}; + applyCatalogModelMetadata(catalogEntry, model); + expect(catalogEntry).not.toHaveProperty("service_tiers"); + const body = await drive("openrouter", provider, "openai/gpt-5.6-sol", {}, true); + expect(body).not.toHaveProperty("service_tier"); + }); }); describe("unclassified chat-wire tier projection (release-audit fix)", () => { @@ -328,4 +410,3 @@ describe("unclassified chat-wire tier projection (release-audit fix)", () => { expect(serviceTierSupportForModel(provider, "some-model")).toBeUndefined(); }); }); -