Studio: render cache stats for external providers too
Reviewer round on the original PR caught three asymmetric-fix sites where the producer side surfaced external prompt-cache stats but the consumer side still gated on ggufContextLength (which is only ever set for the local llama-server runtime). Result: the entire cache-stats PR shipped invisible for Anthropic / OpenAI Responses / Gemini, which is exactly the set of providers it was added for. - chat-page.tsx: drop the ggufContextLength precondition on the ContextUsageBar mount. The bar already tracks usage; let it decide what to render based on what it knows. - context-usage-bar.tsx: make `total` optional. When absent, drop the "/ total" ratio + percentage progress bar + "approaching limit" helper, and just show per-turn counters + cache stats. Bootstrap guard tightened so an all-zero, all-undefined state still renders nothing. - runtime-provider.tsx: external-provider rehydration was rejected by the `store.ggufContextLength` check. Keep the "fits inside window" sanity check when a local context window IS known, drop it when it isn't. - message-timing.tsx: the per-message timing popover used a separate "Cache hits" code path that only read llama-server's timings.cache_n. Fall through to custom.contextUsage for external providers, and add a parallel "Cache writes" line for Anthropic cache_creation events.
This commit is contained in:
parent
9be1fc00b4
commit
79da85ba7f
4 changed files with 117 additions and 31 deletions
|
|
@ -33,10 +33,27 @@ export const MessageTiming: FC<{
|
|||
|
||||
if (timing?.totalStreamTime === undefined) return null;
|
||||
|
||||
const serverTimings = (
|
||||
const custom = (
|
||||
message.metadata as Record<string, unknown> | undefined
|
||||
)?.custom as { serverTimings?: Record<string, number> } | undefined;
|
||||
const st = serverTimings?.serverTimings;
|
||||
)?.custom as
|
||||
| {
|
||||
serverTimings?: Record<string, number>;
|
||||
contextUsage?: {
|
||||
cachedTokens?: number;
|
||||
cacheWriteTokens?: number;
|
||||
};
|
||||
}
|
||||
| undefined;
|
||||
const st = custom?.serverTimings;
|
||||
// Cache-hit / cache-write counts. llama-server reports hits on
|
||||
// timings.cache_n; external providers (Anthropic / OpenAI Responses /
|
||||
// Gemini) report them on the include_usage envelope that the adapter
|
||||
// normalizes into custom.contextUsage. Prefer the local-runtime value
|
||||
// when present (so llama.cpp keeps populating the badge mid-stream)
|
||||
// and fall back to the external envelope otherwise.
|
||||
const cacheHits = (st?.cache_n ?? 0) || (custom?.contextUsage?.cachedTokens ?? 0);
|
||||
// Anthropic-only: tokens written into the prompt cache on this turn.
|
||||
const cacheWrites = custom?.contextUsage?.cacheWriteTokens ?? 0;
|
||||
|
||||
// Guard unphysical tok/s: llama.cpp emits predicted_ms=0 on no-op
|
||||
// turns, blowing the rate up to Infinity. Require >=1 token AND a
|
||||
|
|
@ -122,11 +139,19 @@ export const MessageTiming: FC<{
|
|||
</span>
|
||||
</div>
|
||||
)}
|
||||
{(st?.cache_n ?? 0) > 0 && (
|
||||
{cacheHits > 0 && (
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
<span className="text-muted-foreground">Cache hits</span>
|
||||
<span className="font-mono tabular-nums">
|
||||
{formatNumber(st!.cache_n)}
|
||||
{formatNumber(cacheHits)}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
{cacheWrites > 0 && (
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
<span className="text-muted-foreground">Cache writes</span>
|
||||
<span className="font-mono tabular-nums">
|
||||
{formatNumber(cacheWrites)}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
|
@ -146,7 +171,7 @@ export const MessageTiming: FC<{
|
|||
</>
|
||||
) : (
|
||||
<>
|
||||
{/* Client-side metrics (safetensors fallback) */}
|
||||
{/* Client-side metrics (safetensors + external provider fallback) */}
|
||||
{timing.firstTokenTime !== undefined && (
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
<span className="text-muted-foreground">First token</span>
|
||||
|
|
@ -155,6 +180,22 @@ export const MessageTiming: FC<{
|
|||
</span>
|
||||
</div>
|
||||
)}
|
||||
{cacheHits > 0 && (
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
<span className="text-muted-foreground">Cache hits</span>
|
||||
<span className="font-mono tabular-nums">
|
||||
{formatNumber(cacheHits)}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
{cacheWrites > 0 && (
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
<span className="text-muted-foreground">Cache writes</span>
|
||||
<span className="font-mono tabular-nums">
|
||||
{formatNumber(cacheWrites)}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
<span className="text-muted-foreground">Total</span>
|
||||
<span className="font-mono tabular-nums">
|
||||
|
|
|
|||
|
|
@ -1491,9 +1491,15 @@ export function ChatPage(): ReactElement {
|
|||
) : null}
|
||||
</div>
|
||||
<div className="ml-auto flex items-center gap-2">
|
||||
{view.mode === "single" && ggufContextLength && contextUsage ? (
|
||||
{view.mode === "single" && contextUsage ? (
|
||||
<ContextUsageBar
|
||||
used={contextUsage.totalTokens}
|
||||
// ggufContextLength is the local llama-server's KV-cache size;
|
||||
// external providers (Anthropic / OpenAI Responses / Gemini)
|
||||
// don't expose a stable per-model context window through the
|
||||
// picker, so it is null in that mode. Pass it as-is -- the bar
|
||||
// drops the "/ total" ratio + percentage when total is absent
|
||||
// and still renders the per-turn counters + cache stats.
|
||||
total={ggufContextLength}
|
||||
cached={contextUsage.cachedTokens}
|
||||
cacheWrites={contextUsage.cacheWriteTokens}
|
||||
|
|
|
|||
|
|
@ -28,7 +28,15 @@ function getSeverityColor(percent: number): {
|
|||
|
||||
export const ContextUsageBar: FC<{
|
||||
used: number;
|
||||
total: number;
|
||||
/**
|
||||
* Context window size. Optional because external providers don't expose a
|
||||
* stable per-model limit through the chat picker -- the local llama-server
|
||||
* path is the only one that populates ggufContextLength. When omitted, the
|
||||
* bar drops the "/ total" ratio + percentage bar and just shows the token
|
||||
* counts (so cache hits / writes from Anthropic / OpenAI Responses still
|
||||
* land in the tooltip).
|
||||
*/
|
||||
total?: number | null;
|
||||
cached?: number;
|
||||
/**
|
||||
* Anthropic-only cache-write count (tokens written into the prompt cache
|
||||
|
|
@ -48,31 +56,49 @@ export const ContextUsageBar: FC<{
|
|||
completionTokens,
|
||||
className,
|
||||
}) => {
|
||||
if (total <= 0) return null;
|
||||
const hasKnownLimit = typeof total === "number" && total > 0;
|
||||
const hasUsageDetails =
|
||||
promptTokens !== undefined ||
|
||||
completionTokens !== undefined ||
|
||||
(cached !== undefined && cached > 0) ||
|
||||
(cacheWrites !== undefined && cacheWrites > 0);
|
||||
|
||||
const percent = Math.min((used / total) * 100, 100);
|
||||
const severity = getSeverityColor(percent);
|
||||
// Nothing to show: no limit and no per-turn counters.
|
||||
if (!hasKnownLimit && used <= 0 && !hasUsageDetails) return null;
|
||||
|
||||
const percent = hasKnownLimit
|
||||
? Math.min((used / (total as number)) * 100, 100)
|
||||
: null;
|
||||
const severity = getSeverityColor(percent ?? 0);
|
||||
|
||||
return (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
aria-label={`Context usage: ${formatTokenCount(used)} of ${formatTokenCount(total)} tokens`}
|
||||
aria-label={
|
||||
hasKnownLimit
|
||||
? `Context usage: ${formatTokenCount(used)} of ${formatTokenCount(total as number)} tokens`
|
||||
: `Token usage: ${formatTokenCount(used)} tokens`
|
||||
}
|
||||
className={cn(
|
||||
"flex items-center gap-2 rounded-[10px] px-2.5 py-1 font-mono text-chat-icon-fg text-[13px] tabular-nums transition-colors hover:bg-chat-icon-bg-hover hover:text-chat-icon-fg-hover",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
<span>
|
||||
{formatTokenCount(used)} / {formatTokenCount(total)}
|
||||
{hasKnownLimit
|
||||
? `${formatTokenCount(used)} / ${formatTokenCount(total as number)}`
|
||||
: `${formatTokenCount(used)} tokens`}
|
||||
</span>
|
||||
<div className="h-1.5 w-16 rounded-full bg-black/10 dark:bg-white/15 overflow-hidden">
|
||||
<div
|
||||
className={cn("h-full rounded-full transition-all", severity.bar)}
|
||||
style={{ width: `${percent}%` }}
|
||||
/>
|
||||
</div>
|
||||
{hasKnownLimit && percent !== null ? (
|
||||
<div className="h-1.5 w-16 rounded-full bg-black/10 dark:bg-white/15 overflow-hidden">
|
||||
<div
|
||||
className={cn("h-full rounded-full transition-all", severity.bar)}
|
||||
style={{ width: `${percent}%` }}
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent
|
||||
|
|
@ -82,12 +108,14 @@ export const ContextUsageBar: FC<{
|
|||
className="[&_span>svg]:hidden!"
|
||||
>
|
||||
<div className="grid min-w-44 gap-1.5 text-xs">
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
<span className="text-muted-foreground">Context usage</span>
|
||||
<span className={cn("font-mono tabular-nums font-medium", severity.text)}>
|
||||
{percent.toFixed(1)}%
|
||||
</span>
|
||||
</div>
|
||||
{hasKnownLimit && percent !== null ? (
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
<span className="text-muted-foreground">Context usage</span>
|
||||
<span className={cn("font-mono tabular-nums font-medium", severity.text)}>
|
||||
{percent.toFixed(1)}%
|
||||
</span>
|
||||
</div>
|
||||
) : null}
|
||||
{promptTokens !== undefined && (
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
<span className="text-muted-foreground">Prompt tokens</span>
|
||||
|
|
@ -122,18 +150,22 @@ export const ContextUsageBar: FC<{
|
|||
)}
|
||||
<div className="my-0.5 border-t border-border/40" />
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
<span className="text-muted-foreground">Total</span>
|
||||
<span className="text-muted-foreground">
|
||||
{hasKnownLimit ? "Total" : "Total tokens"}
|
||||
</span>
|
||||
<span className="font-mono tabular-nums">
|
||||
{formatTokenCountFull(used)} / {formatTokenCountFull(total)}
|
||||
{hasKnownLimit
|
||||
? `${formatTokenCountFull(used)} / ${formatTokenCountFull(total as number)}`
|
||||
: formatTokenCountFull(used)}
|
||||
</span>
|
||||
</div>
|
||||
{percent > 85 && (
|
||||
{hasKnownLimit && percent !== null && percent > 85 ? (
|
||||
<div className="mt-1 max-w-64 text-[11px] leading-snug text-muted-foreground/90">
|
||||
Close to the context limit. Generation will stop at 100%.
|
||||
Increase <span className="font-medium">Context Length</span> in
|
||||
the chat Settings panel to keep going.
|
||||
</div>
|
||||
)}
|
||||
) : null}
|
||||
</div>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
|
|
|
|||
|
|
@ -831,10 +831,17 @@ function useStudioRuntimeAdapters(): StudioRuntimeAdapters {
|
|||
}
|
||||
| undefined;
|
||||
const store = useChatRuntimeStore.getState();
|
||||
// External-provider threads have ggufContextLength === null because
|
||||
// external picker selections don't carry a context-window number.
|
||||
// Restore the persisted usage as long as it belongs to the active
|
||||
// checkpoint; when a local GGUF context window IS known, also keep
|
||||
// the original sanity check that the saved total fits inside it.
|
||||
const withinLocalLimit =
|
||||
!store.ggufContextLength ||
|
||||
(savedUsage?.totalTokens ?? 0) <= store.ggufContextLength;
|
||||
if (
|
||||
savedUsage &&
|
||||
store.ggufContextLength &&
|
||||
savedUsage.totalTokens <= store.ggufContextLength &&
|
||||
withinLocalLimit &&
|
||||
(!savedUsage.modelId ||
|
||||
savedUsage.modelId === store.params.checkpoint)
|
||||
) {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue