Skip to content

Commit 0fdf0b3

Browse files
authored
Visualize context window usage in Ask Sourcebot (#1370)
* feat(web): show context-window usage in Ask Sourcebot Resolve each model's context window from the models.dev catalog (already fetched by the setup wizard) and bake it into chat message metadata. The Details card now renders a usage gauge from the latest step's input tokens. Models with no catalog entry (openai-compatible/self-hosted) fall back to the existing raw token count. * fix(web): resolve contextWindow in the programmatic askCodebase path createMessageStream's MCP/programmatic caller omitted contextWindow, so chats created via ask_codebase rendered the Details card without a usage gauge even for catalogued models — unlike the same chat created from the web API. Resolve it from the already-available languageModelConfig so the gauge is deterministic per model, not per entry point. * feat(web): render context-window usage as a colored ring gauge Replace the horizontal bar with a circular ring showing the percentage inside and the used / total token counts beside it. The arc and percentage are colored by usage — green below 70%, yellow from 70%, red from 90%. * feat(web): make the context-window gauge a compact inline indicator Shrink the ring and move the percentage beside it, reading "<percent>% of <total>" instead of a number-in-ring. The arc and percentage stay colored by usage (green/yellow/red). * feat(web): shrink context gauge ring, gray track, desaturated green Reduce the ring to 14px, switch the track to a solid palette gray (the theme tokens lack an alpha channel, so /opacity on them was ignored and the track rendered at full brightness), and use a desaturated sage green for the in-range percentage. * chore(web): trim verbose comments on the context-window code * docs: add changelog entry for the context-window usage gauge * chore(web): trim comments and lower context-gauge color thresholds Also drop the gauge's yellow/red thresholds from 70/90 to 50/80 so the ring shifts color earlier as the context fills. * docs: use placeholder PR number in context-window changelog entry Real PR number to be filled in once the PR is opened. * docs: fill in PR number for context-window changelog entry * fix(web): serve last-known-good models.dev catalog on fetch failure Previously a failed fetch reset the cache to null, so every chat send re-attempted the fetch and blocked up to the 8s timeout during a models.dev outage; a failed TTL refresh also discarded the previously-cached catalog. Switch loadCatalog to stale-while-revalidate: after the first successful load the request path never blocks (it serves the last-known-good catalog and refreshes in the background), failed refreshes keep the cached value, and a 60s negative-cache window bounds retries during an outage. * docs: mark context-window gauge changelog entry as [EE] --------- Co-authored-by: Jack Minnetian <270441393+BlueBottleLatte@users.noreply.github.com>
1 parent 4f1150a commit 0fdf0b3

8 files changed

Lines changed: 387 additions & 0 deletions

File tree

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
1212

1313
### Added
1414
- Added per-step token cost tracking and estimated tool call token usage to Ask Sourcebot chat history. [#1353](https://github.com/sourcebot-dev/sourcebot/pull/1353)
15+
- [EE] Added a context-window usage gauge to the Ask Sourcebot chat details, showing how much of the selected model's context window each turn occupies. Window sizes are resolved from the models.dev catalog. [#1370](https://github.com/sourcebot-dev/sourcebot/pull/1370)
1516

1617
### Fixed
1718
- Send anonymous server-side PostHog events as personless so unauthenticated requests don't inflate person counts. [#1367](https://github.com/sourcebot-dev/sourcebot/pull/1367)

packages/web/src/app/api/(server)/ee/chat/route.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import { additionalChatRequestParamsSchema } from "@/features/chat/types";
66
import { getLanguageModelKey } from "@/features/chat/utils";
77
import { checkAskEntitlement, getConfiguredLanguageModels, isOwnerOfChat, updateChatMessages } from "@/features/chat/utils.server";
88
import { getAISDKLanguageModelAndOptions } from "@/features/chat/llm.server";
9+
import { resolveContextWindow } from "@/features/chat/modelContextWindow.server";
910
import { apiHandler } from "@/lib/apiHandler";
1011
import { ErrorCode } from "@/lib/errorCodes";
1112
import { captureEvent } from "@/lib/posthog";
@@ -89,6 +90,11 @@ export const POST = apiHandler(async (req: NextRequest) => {
8990

9091
const { model, providerOptions, temperature } = await getAISDKLanguageModelAndOptions(languageModelConfig);
9192

93+
// Total context window for the selected model, used as the
94+
// denominator for the UI's context-usage gauge. Undefined when
95+
// unknown (e.g. self-hosted models).
96+
const contextWindow = await resolveContextWindow(languageModelConfig);
97+
9298
// No-op for non-Anthropic providers / when caching is disabled, so
9399
// it never perturbs other providers' requests.
94100
const promptCacheStrategy = getPromptCacheStrategy(
@@ -139,6 +145,7 @@ export const POST = apiHandler(async (req: NextRequest) => {
139145
disabledMcpServerIds,
140146
model,
141147
modelName: languageModelConfig.displayName ?? languageModelConfig.model,
148+
contextWindow,
142149
promptCacheStrategy,
143150
modelProviderOptions: providerOptions,
144151
modelTemperature: temperature,

packages/web/src/ee/features/chat/agent.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,7 @@ interface CreateMessageStreamResponseProps {
5454
disabledMcpServerIds?: string[];
5555
model: AISDKLanguageModelV3;
5656
modelName: string;
57+
contextWindow?: number;
5758
promptCacheStrategy: PromptCacheStrategy;
5859
onFinish: UIMessageStreamOnFinishCallback<SBChatMessage>;
5960
onError: (error: unknown) => string;
@@ -73,6 +74,7 @@ export const createMessageStream = async ({
7374
disabledMcpServerIds,
7475
model,
7576
modelName,
77+
contextWindow,
7678
promptCacheStrategy,
7779
modelProviderOptions,
7880
modelTemperature,
@@ -279,6 +281,7 @@ export const createMessageStream = async ({
279281
// phases so earlier phases' steps are preserved in order.
280282
stepTokenUsage: [...(priorMetadata?.stepTokenUsage ?? []), ...stepTokenUsage],
281283
modelName,
284+
contextWindow,
282285
traceId,
283286
}
284287
});

packages/web/src/ee/features/chat/components/chatThread/detailsCard.tsx

Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -86,6 +86,18 @@ const DetailsCardComponent = ({
8686
? Math.round((cacheReadTokens / inputTokens) * 100)
8787
: 0;
8888

89+
// Context-window usage gauge. "In use" is the input the model saw on its
90+
// most recent step — i.e. the full accumulated prompt occupying the window
91+
// right now — not the cumulative totalInputTokens.
92+
const stepTokenUsage = metadata?.stepTokenUsage;
93+
const currentContextTokens = stepTokenUsage && stepTokenUsage.length > 0
94+
? stepTokenUsage[stepTokenUsage.length - 1].inputTokens
95+
: undefined;
96+
const contextWindow = metadata?.contextWindow;
97+
const contextUsagePercent = currentContextTokens !== undefined && contextWindow !== undefined && contextWindow > 0
98+
? Math.min(100, Math.round((currentContextTokens / contextWindow) * 100))
99+
: undefined;
100+
89101
const handleExpandedChanged = useCallback((next: boolean) => {
90102
captureEvent('wa_chat_details_card_toggled', { chatId, isExpanded: next });
91103
onExpandedChanged(next);
@@ -193,6 +205,23 @@ const DetailsCardComponent = ({
193205
)}
194206
</div>
195207
)}
208+
{contextUsagePercent !== undefined && currentContextTokens !== undefined && contextWindow !== undefined && (
209+
<Tooltip>
210+
<TooltipTrigger asChild>
211+
<div className="cursor-help">
212+
<ContextWindowGauge
213+
total={contextWindow}
214+
percent={contextUsagePercent}
215+
/>
216+
</div>
217+
</TooltipTrigger>
218+
<TooltipContent side="bottom">
219+
<div className="max-w-xs text-xs">
220+
The most recent step&apos;s prompt used {currentContextTokens.toLocaleString()} of the model&apos;s {contextWindow.toLocaleString()}-token context window ({contextUsagePercent}%).
221+
</div>
222+
</TooltipContent>
223+
</Tooltip>
224+
)}
196225
{metadata?.totalResponseTimeMs && (
197226
<div className="flex items-center text-xs">
198227
<Clock className="w-3 h-3 mr-1 flex-shrink-0" />
@@ -367,6 +396,61 @@ const StepTokenUsage = ({ usage, label = 'step' }: { usage: StepTokenUsageEntry,
367396
);
368397
}
369398

399+
400+
const CONTEXT_USAGE_YELLOW_PERCENT = 50;
401+
const CONTEXT_USAGE_RED_PERCENT = 80;
402+
403+
const getContextUsageColorClass = (percent: number): string => {
404+
if (percent >= CONTEXT_USAGE_RED_PERCENT) {
405+
return "text-red-500";
406+
}
407+
if (percent >= CONTEXT_USAGE_YELLOW_PERCENT) {
408+
return "text-yellow-500";
409+
}
410+
return "text-[#6cb38f]";
411+
};
412+
413+
const ContextWindowGauge = ({ total, percent }: { total: number, percent: number }) => {
414+
const size = 14;
415+
const strokeWidth = 2;
416+
const radius = (size - strokeWidth) / 2;
417+
const circumference = 2 * Math.PI * radius;
418+
const dashOffset = circumference * (1 - Math.min(100, percent) / 100);
419+
const colorClass = getContextUsageColorClass(percent);
420+
421+
return (
422+
<div className="flex items-center gap-1.5 text-xs whitespace-nowrap">
423+
<svg width={size} height={size} className="-rotate-90 flex-shrink-0">
424+
{/* Neutral gray track. */}
425+
<circle
426+
cx={size / 2}
427+
cy={size / 2}
428+
r={radius}
429+
fill="none"
430+
stroke="currentColor"
431+
strokeWidth={strokeWidth}
432+
className="text-zinc-500"
433+
/>
434+
{/* Progress arc. */}
435+
<circle
436+
cx={size / 2}
437+
cy={size / 2}
438+
r={radius}
439+
fill="none"
440+
stroke="currentColor"
441+
strokeWidth={strokeWidth}
442+
strokeLinecap="round"
443+
strokeDasharray={circumference}
444+
strokeDashoffset={dashOffset}
445+
className={cn("transition-all duration-300", colorClass)}
446+
/>
447+
</svg>
448+
<span className={cn("font-semibold", colorClass)}>{percent}%</span>
449+
<span className="text-muted-foreground">of {getShortenedNumberDisplayString(total, 0).toUpperCase()}</span>
450+
</div>
451+
);
452+
}
453+
370454
type GuardedToolType =
371455
| 'tool-read_file'
372456
| 'tool-grep'

packages/web/src/ee/features/mcp/askCodebase.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import { sew } from "@/middleware/sew";
22
import { getConfiguredLanguageModels, updateChatMessages, checkAskEntitlement } from "@/features/chat/utils.server";
33
import { generateChatNameFromMessage } from "@/ee/features/chat/llm.server";
44
import { getAISDKLanguageModelAndOptions } from "@/features/chat/llm.server";
5+
import { resolveContextWindow } from "@/features/chat/modelContextWindow.server";
56
import { LanguageModelInfo, SBChatMessage, SearchScope } from "@/features/chat/types";
67
import { convertLLMOutputToPortableMarkdown, getAnswerPartFromAssistantMessage, getLanguageModelKey } from "@/features/chat/utils";
78
import { ErrorCode } from "@/lib/errorCodes";
@@ -84,6 +85,7 @@ export const askCodebase = (params: AskCodebaseParams): Promise<AskCodebaseResul
8485

8586
const { model, providerOptions, temperature } = await getAISDKLanguageModelAndOptions(languageModelConfig);
8687
const modelName = languageModelConfig.displayName ?? languageModelConfig.model;
88+
const contextWindow = await resolveContextWindow(languageModelConfig);
8789

8890
// No-op for non-Anthropic providers / when caching is disabled.
8991
const promptCacheStrategy = getPromptCacheStrategy(
@@ -182,6 +184,7 @@ export const askCodebase = (params: AskCodebaseParams): Promise<AskCodebaseResul
182184
prisma,
183185
model,
184186
modelName,
187+
contextWindow,
185188
promptCacheStrategy,
186189
modelProviderOptions: providerOptions,
187190
modelTemperature: temperature,
Lines changed: 126 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,126 @@
1+
import 'server-only';
2+
3+
import { LanguageModel } from '@sourcebot/schemas/v3/languageModel.type';
4+
import { createLogger } from '@sourcebot/shared';
5+
6+
const logger = createLogger('model-context-window');
7+
8+
// The same public, unauthenticated catalog the setup wizard already consumes
9+
// (see packages/setupWizard/src/models.ts). Each model entry exposes a
10+
// `limit.context` field holding the total context window in tokens.
11+
const MODELS_DEV_API_URL = 'https://models.dev/api.json';
12+
const FETCH_TIMEOUT_MS = 8000;
13+
// Re-fetch the (~2.4 MB) catalog at most once per this interval per server
14+
// process. New models trickle in daily; a stale window for a few hours is fine.
15+
const CATALOG_TTL_MS = 6 * 60 * 60 * 1000;
16+
// After a failed fetch, don't reattempt for this long. Without it, an outage in
17+
// models.dev would make every chat send pay the fetch timeout on the request path.
18+
const NEGATIVE_CACHE_MS = 60 * 1000;
19+
20+
// Sourcebot provider id -> models.dev top-level catalog key. Only providers
21+
// whose Sourcebot id differs from the models.dev id need an entry; everything
22+
// else (anthropic, openai, azure, amazon-bedrock, mistral, deepseek, xai,
23+
// openrouter, google-vertex, google-vertex-anthropic) matches 1:1.
24+
const PROVIDER_ID_OVERRIDES: Record<string, string> = {
25+
'google-generative-ai': 'google',
26+
};
27+
28+
type ModelsDevModel = {
29+
id: string;
30+
limit?: {
31+
context?: number;
32+
output?: number;
33+
};
34+
};
35+
36+
type ModelsDevProvider = {
37+
id: string;
38+
models?: Record<string, ModelsDevModel>;
39+
};
40+
41+
export type ModelsDevCatalog = Record<string, ModelsDevProvider>;
42+
43+
// Last successfully-fetched catalog. Served while fresh, and kept as a fallback
44+
// when a later refresh fails. `catalogFetchedAt` is when it was fetched (TTL),
45+
// `lastFailedAt` the most recent fetch failure (negative-cache backoff), and
46+
// `inFlightFetch` dedupes concurrent fetches.
47+
let cachedCatalog: ModelsDevCatalog | null = null;
48+
let catalogFetchedAt = 0;
49+
let lastFailedAt = 0;
50+
let inFlightFetch: Promise<ModelsDevCatalog | null> | null = null;
51+
52+
const fetchCatalog = async (): Promise<ModelsDevCatalog | null> => {
53+
try {
54+
const response = await fetch(MODELS_DEV_API_URL, {
55+
signal: AbortSignal.timeout(FETCH_TIMEOUT_MS),
56+
});
57+
if (!response.ok) {
58+
logger.warn(`Failed to fetch models.dev catalog: ${response.status} ${response.statusText}`);
59+
return null;
60+
}
61+
return await response.json() as ModelsDevCatalog;
62+
} catch (error) {
63+
logger.warn(`Failed to fetch models.dev catalog: ${error}`);
64+
return null;
65+
}
66+
};
67+
68+
const loadCatalog = async (): Promise<ModelsDevCatalog | null> => {
69+
const now = Date.now();
70+
const isFresh = cachedCatalog !== null && now - catalogFetchedAt <= CATALOG_TTL_MS;
71+
const isBackingOff = now - lastFailedAt < NEGATIVE_CACHE_MS;
72+
73+
// Kick off a (deduped) refresh when the cache is stale/empty and we're not
74+
// within the post-failure backoff window. On success it replaces the cache;
75+
// on failure it only records the failure time, leaving the last-known-good
76+
// catalog intact.
77+
if (!isFresh && !isBackingOff && !inFlightFetch) {
78+
inFlightFetch = fetchCatalog().then((catalog) => {
79+
if (catalog) {
80+
cachedCatalog = catalog;
81+
catalogFetchedAt = Date.now();
82+
} else {
83+
lastFailedAt = Date.now();
84+
}
85+
inFlightFetch = null;
86+
return catalog;
87+
});
88+
}
89+
90+
// Once a catalog has loaded once, never block the request path on the
91+
// network: serve the last-known-good value (even if stale) and let any
92+
// refresh settle in the background. Only the very first load awaits.
93+
if (cachedCatalog !== null) {
94+
return cachedCatalog;
95+
}
96+
return inFlightFetch ?? null;
97+
};
98+
99+
/**
100+
* Pure lookup of a model's context window in a models.dev catalog. Separated
101+
* from the network fetch so it can be unit-tested directly.
102+
*
103+
* Returns the total context window in tokens, or `undefined` when the model
104+
* isn't catalogued or has no usable window.
105+
*/
106+
export const lookupContextWindow = (
107+
catalog: ModelsDevCatalog | null,
108+
config: Pick<LanguageModel, 'provider' | 'model'>,
109+
): number | undefined => {
110+
if (!catalog) {
111+
return undefined;
112+
}
113+
const providerId = PROVIDER_ID_OVERRIDES[config.provider] ?? config.provider;
114+
const context = catalog[providerId]?.models?.[config.model]?.limit?.context;
115+
// `limit` is schema-optional, and models.dev reports a 0 context window for
116+
// non-text models (image/audio/etc.). Treat both as "unknown" so the UI
117+
// gracefully omits the gauge rather than rendering a bogus denominator.
118+
return typeof context === 'number' && context > 0 ? context : undefined;
119+
};
120+
121+
export const resolveContextWindow = async (
122+
config: Pick<LanguageModel, 'provider' | 'model'>,
123+
): Promise<number | undefined> => {
124+
const catalog = await loadCatalog();
125+
return lookupContextWindow(catalog, config);
126+
};

0 commit comments

Comments
 (0)