Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion gui/src/i18n/de.ts
Original file line number Diff line number Diff line change
Expand Up @@ -656,7 +656,9 @@ export const de: Record<TKey, string> = {
"logs.badge.interceptedHelperTitle": "Abgefangene Helfer-Anfrage",
"logs.filter.conversation.label": "Konversation",
"logs.filter.conversation.placeholder": "Konversations-ID einfügen",
"logs.filter.conversation.clear": "Löschen",
"logs.filter.conversation.clear": "Löschen",
"logs.filter.model.label": "Modell",
"logs.filter.model.placeholder": "Modell oder Anbieter filtern",
"logs.filter.conversation.apply": "Logs filtern",
"logs.conversation.totals": "{requests} Anfragen · {tokens} Tokens · {cost}",
"logs.conversation.scope": "Summen gelten nur für den aktuell geladenen Logs-Ring.",
Expand Down
4 changes: 3 additions & 1 deletion gui/src/i18n/en.ts
Original file line number Diff line number Diff line change
Expand Up @@ -689,7 +689,9 @@ export const en = {
"logs.badge.interceptedHelperTitle": "Intercepted helper request",
"logs.filter.conversation.label": "Conversation",
"logs.filter.conversation.placeholder": "Paste conversation id",
"logs.filter.conversation.clear": "Clear",
"logs.filter.conversation.clear": "Clear",
"logs.filter.model.label": "Model",
"logs.filter.model.placeholder": "Filter by model or provider",
"logs.filter.conversation.apply": "Filter logs",
"logs.conversation.totals": "{requests} requests · {tokens} tokens · {cost}",
"logs.conversation.scope": "Totals cover the currently loaded Logs ring only.",
Expand Down
4 changes: 3 additions & 1 deletion gui/src/i18n/fr.ts
Original file line number Diff line number Diff line change
Expand Up @@ -670,7 +670,9 @@ export const fr: Record<TKey, string> = {
"logs.badge.interceptedHelperTitle": "Requête d'assistant interceptée",
"logs.filter.conversation.label": "Conversation",
"logs.filter.conversation.placeholder": "Coller l’ID de conversation",
"logs.filter.conversation.clear": "Effacer",
"logs.filter.conversation.clear": "Effacer",
"logs.filter.model.label": "Modèle",
"logs.filter.model.placeholder": "Filtrer par modèle ou fournisseur",
"logs.filter.conversation.apply": "Filtrer les journaux",
"logs.conversation.totals": "{requests} requêtes · {tokens} jetons · {cost}",
"logs.conversation.scope": "Les totaux couvrent uniquement le tampon circulaire des journaux actuellement chargé.",
Expand Down
4 changes: 3 additions & 1 deletion gui/src/i18n/ja.ts
Original file line number Diff line number Diff line change
Expand Up @@ -632,7 +632,9 @@ export const ja: Record<TKey, string> = {
"logs.badge.interceptedHelperTitle": "インターセプトされたヘルパー要求",
"logs.filter.conversation.label": "会話",
"logs.filter.conversation.placeholder": "会話 ID を貼り付け",
"logs.filter.conversation.clear": "クリア",
"logs.filter.conversation.clear": "クリア",
"logs.filter.model.label": "モデル",
"logs.filter.model.placeholder": "モデルまたはプロバイダーで絞り込む",
"logs.filter.conversation.apply": "ログを絞り込み",
"logs.conversation.totals": "{requests} 件 · {tokens} トークン · {cost}",
"logs.conversation.scope": "合計は現在読み込まれている Logs リングのみです。",
Expand Down
4 changes: 3 additions & 1 deletion gui/src/i18n/ko.ts
Original file line number Diff line number Diff line change
Expand Up @@ -675,7 +675,9 @@ export const ko: Record<TKey, string> = {
"logs.badge.interceptedHelperTitle": "가로챈 헬퍼 요청",
"logs.filter.conversation.label": "대화",
"logs.filter.conversation.placeholder": "대화 ID 붙여넣기",
"logs.filter.conversation.clear": "지우기",
"logs.filter.conversation.clear": "지우기",
"logs.filter.model.label": "모델",
"logs.filter.model.placeholder": "모델 또는 공급자로 거르기",
"logs.filter.conversation.apply": "로그 필터",
"logs.conversation.totals": "{requests}건 요청 · {tokens} 토큰 · {cost}",
"logs.conversation.scope": "합계는 현재 로드된 Logs 링만 포함합니다.",
Expand Down
4 changes: 3 additions & 1 deletion gui/src/i18n/ru.ts
Original file line number Diff line number Diff line change
Expand Up @@ -673,7 +673,9 @@ export const ru: Record<TKey, string> = {
"logs.badge.interceptedHelperTitle": "Перехваченный запрос помощника",
"logs.filter.conversation.label": "Диалог",
"logs.filter.conversation.placeholder": "Вставьте ID диалога",
"logs.filter.conversation.clear": "Сбросить",
"logs.filter.conversation.clear": "Сбросить",
"logs.filter.model.label": "Модель",
"logs.filter.model.placeholder": "Фильтр по модели или провайдеру",
"logs.filter.conversation.apply": "Фильтровать логи",
"logs.conversation.totals": "{requests} запросов · {tokens} токенов · {cost}",
"logs.conversation.scope": "Итоги только по загруженному кольцу Logs.",
Expand Down
4 changes: 3 additions & 1 deletion gui/src/i18n/tr.ts
Original file line number Diff line number Diff line change
Expand Up @@ -680,7 +680,9 @@ export const tr: Record<TKey, string> = {
"logs.badge.interceptedHelperTitle": "Yakalanan yardimci istegi",
"logs.filter.conversation.label": "Sohbet",
"logs.filter.conversation.placeholder": "Sohbet ID'sini yapıştırın",
"logs.filter.conversation.clear": "Temizle",
"logs.filter.conversation.clear": "Temizle",
"logs.filter.model.label": "Model",
"logs.filter.model.placeholder": "Modele veya sağlayıcıya göre filtrele",
"logs.filter.conversation.apply": "Günlükleri filtrele",
"logs.conversation.totals": "{requests} istek · {tokens} jeton · {cost}",
"logs.conversation.scope": "Toplamlar yalnızca yüklü günlükleri kapsar.",
Expand Down
4 changes: 3 additions & 1 deletion gui/src/i18n/zh-TW.ts
Original file line number Diff line number Diff line change
Expand Up @@ -527,7 +527,9 @@ export const zhTW: Record<TKey, string> = {
"logs.badge.interceptedHelperTitle": "已攔截的輔助請求",
"logs.filter.conversation.label": "對話",
"logs.filter.conversation.placeholder": "貼上對話 ID",
"logs.filter.conversation.clear": "清除",
"logs.filter.conversation.clear": "清除",
"logs.filter.model.label": "模型",
"logs.filter.model.placeholder": "依模型或供應商篩選",
"logs.filter.conversation.apply": "篩選日誌",
"logs.conversation.totals": "{requests} 次請求 · {tokens} tokens · {cost}",
"logs.conversation.scope": "合計僅涵蓋目前已載入的 Logs 環形緩衝。",
Expand Down
4 changes: 3 additions & 1 deletion gui/src/i18n/zh.ts
Original file line number Diff line number Diff line change
Expand Up @@ -668,7 +668,9 @@ export const zh: Record<TKey, string> = {
"logs.badge.interceptedHelperTitle": "已拦截的辅助请求",
"logs.filter.conversation.label": "会话",
"logs.filter.conversation.placeholder": "粘贴会话 ID",
"logs.filter.conversation.clear": "清除",
"logs.filter.conversation.clear": "清除",
"logs.filter.model.label": "模型",
"logs.filter.model.placeholder": "按模型或供应商筛选",
"logs.filter.conversation.apply": "筛选日志",
"logs.conversation.totals": "{requests} 次请求 · {tokens} tokens · {cost}",
"logs.conversation.scope": "合计仅覆盖当前已加载的 Logs 环形缓冲。",
Expand Down
14 changes: 14 additions & 0 deletions gui/src/pages/Logs.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ import { formatEstimatedUsd, formatEstimatedUsdValue, summarizeEstimatedCosts }
import { cacheSplit, isCursorUsageProvider, tokensTitle } from "./logs-token-title";
import type { LogSurface, LogSurfaceFilter } from "./logs-surface-filter";
import { logMatchesSurface } from "./logs-surface-filter";
import { logMatchesModelQuery } from "./logs-model-filter";
import {
sanitizeLogEntryRouteDecision,
validCachedRouteDecision,
Expand Down Expand Up @@ -372,6 +373,7 @@ export default function Logs({ apiBase }: { apiBase: string }) {
const [surfaceFilter, setSurfaceFilter] = useState<LogSurfaceFilter>("all");
const [interceptedHelpersOnly, setInterceptedHelpersOnly] = useState(false);
const [conversationFilter, setConversationFilter] = useState("");
const [modelFilter, setModelFilter] = useState("");
const [conversationQueryHash, setConversationQueryHash] = useState<string | undefined>();
const scrollContainerRef = useRef<HTMLDivElement>(null);
const logRetryRef = useRef<{ key: string; failures: number; nextAttemptAt: number; error: unknown }>(
Expand Down Expand Up @@ -511,6 +513,7 @@ export default function Logs({ apiBase }: { apiBase: string }) {
const filteredLogs = logs.filter(log => (
logMatchesSurface(log, surfaceFilter)
&& (!interceptedHelpersOnly || Boolean(log.shadowCallRewrittenFrom))
&& logMatchesModelQuery(log, modelFilter)
&& (!conversationQuery || matchesLogConversationId(log.conversationId, conversationQuery, conversationQueryHash))
));
const conversationTotals = conversationQuery ? summarizeFilteredLogs(filteredLogs) : null;
Expand Down Expand Up @@ -630,6 +633,17 @@ export default function Logs({ apiBase }: { apiBase: string }) {
aria-label={t("logs.filter.conversation.label")}
/>
</label>
<label className="muted text-control logs-filter-field">
{t("logs.filter.model.label")}
<input
type="search"
className="input mono"
value={modelFilter}
onChange={e => setModelFilter(e.target.value)}
placeholder={t("logs.filter.model.placeholder")}
aria-label={t("logs.filter.model.label")}
/>
</label>
{conversationQuery && (
<button type="button" className="btn btn-ghost btn-sm" onClick={() => setConversationFilter("")}>
{t("logs.filter.conversation.clear")}
Expand Down
45 changes: 45 additions & 0 deletions gui/src/pages/logs-model-filter.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
/**
* Free-text matching over a log row's model and provider, kept out of Logs.tsx so that
* module exports components only (react-refresh/only-export-components).
*
* #3070: an operator running custom providers saw their OpenAI monthly window shrink
* and could not find which turns were responsible. `?model=` filtering exists on the
* CLI and the API (b68edc077) but never reached the dashboard, and the one filter the
* page did have — the intercepted-helpers toggle — keys on `shadowCallRewrittenFrom`,
* which a plain account-gated turn does not carry. So the rows that explain the bill
* were exactly the rows no control could isolate.
*/
export interface LogModelFields {
model?: string;
resolvedModel?: string;
provider?: string;
attempts?: Array<{
provider?: string;
model?: string;
}>;
}

/**
* Case-insensitive substring over the requested model, resolved target, provider, and
* every failover attempt target.
*
* `resolvedModel` is matched as well as `model` on purpose. They differ precisely when
* routing redirected the turn, which is the case this filter exists to expose — matching
* only the requested id would hide the redirect that caused the charge.
*
* Substring rather than exact: an operator types `terra`, not
* `gpt-5.6-terra-2026-08-01`. Empty or whitespace-only query matches everything, so the
* control is inert until it is used.
*/
export function logMatchesModelQuery(log: LogModelFields, query: string): boolean {
const needle = query.trim().toLowerCase();
if (!needle) return true;
const attemptTargets = Array.isArray(log.attempts)
? log.attempts.flatMap(attempt => (
attempt && typeof attempt === "object" ? [attempt.provider, attempt.model] : []
))
: [];
return [log.model, log.resolvedModel, log.provider, ...attemptTargets].some(
value => typeof value === "string" && value.toLowerCase().includes(needle),
);
}
74 changes: 74 additions & 0 deletions gui/tests/logs-model-filter.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
import { describe, expect, test } from "bun:test";
import { logMatchesModelQuery } from "../src/pages/logs-model-filter";

/**
* #3070: an operator running custom providers watched their OpenAI monthly window
* shrink and had no way to find the responsible turns in the dashboard. `?model=`
* filtering landed for the CLI and the API in b68edc077 but never reached Logs.tsx,
* and the page's only related control — intercepted-helpers — keys on
* `shadowCallRewrittenFrom`, which an ordinary account-gated turn does not carry.
*/
describe("logs model filter", () => {
test("an empty or whitespace query matches everything", () => {
const log = { model: "gpt-5.6-terra" };
expect(logMatchesModelQuery(log, "")).toBe(true);
expect(logMatchesModelQuery(log, " ")).toBe(true);
// Inert until used: a blank control must not hide rows.
expect(logMatchesModelQuery({}, "")).toBe(true);
});

test("matches a substring of the requested model, case-insensitively", () => {
const log = { model: "gpt-5.6-terra" };
expect(logMatchesModelQuery(log, "terra")).toBe(true);
expect(logMatchesModelQuery(log, "TERRA")).toBe(true);
expect(logMatchesModelQuery(log, "luna")).toBe(false);
});

test("matches the resolved model even when the requested one differs", () => {
// The case the filter exists for: routing redirected the turn, and the model
// that got billed is the resolved one. Matching only `model` would hide it.
const redirected = { model: "combo/free", resolvedModel: "gpt-5.6-terra" };
expect(logMatchesModelQuery(redirected, "terra")).toBe(true);
expect(logMatchesModelQuery(redirected, "combo")).toBe(true);
});

test("matches the provider and model of a winning failover attempt", () => {
const failedOver = {
provider: "primary",
model: "combo/reliable",
attempts: [
{ provider: "anthropic", model: "claude-sonnet-4.6" },
{ provider: "xai", model: "grok-4.6" },
],
};
expect(logMatchesModelQuery(failedOver, "XAI")).toBe(true);
expect(logMatchesModelQuery(failedOver, "GROK-4.6")).toBe(true);
});

test("ignores malformed attempt containers and entries", () => {
expect(logMatchesModelQuery({
model: "primary",
attempts: "not-an-array" as unknown as Array<{ provider?: string; model?: string }>,
}, "xai")).toBe(false);
expect(logMatchesModelQuery({
attempts: [null as unknown as { provider?: string; model?: string }, { provider: "xai" }],
}, "xai")).toBe(true);
});

test("matches the provider", () => {
expect(logMatchesModelQuery({ provider: "xai", model: "grok-4.6" }, "xai")).toBe(true);
expect(logMatchesModelQuery({ provider: "xai", model: "grok-4.6" }, "anthropic")).toBe(false);
});

test("a row missing every field matches nothing but the empty query", () => {
expect(logMatchesModelQuery({}, "terra")).toBe(false);
});

test("a non-string field cannot throw or match", () => {
// Log rows arrive from the API; the page validates shape but this helper is
// called per row on every render and must not be the thing that crashes it.
const malformed = { model: 42 as unknown as string, resolvedModel: "gpt-5.6-terra" };
expect(logMatchesModelQuery(malformed, "terra")).toBe(true);
expect(logMatchesModelQuery(malformed, "42")).toBe(false);
});
});
Loading