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
32 changes: 32 additions & 0 deletions .withkids_tracker.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
# withkids tracker

## Goal
Implement missing caching tracking (history cache control, miss/write tokens) and UI indicators (list view cache column, silent degradation warning) for PR #445 / CacheAnalytics.

## Phase
M0: Planning & Setup

## Todos
- [x] T1: Fix Cache Conversation History (apply `cache_control` to history blocks in `litellm.py`) (agent: backend-fixer, deps: [])
- [x] T2: Track misses/writes and silent degradation flag in backend (`litellm.py` & `agent_integration.py`) (agent: backend-fixer, deps: [])
- [x] T3: Update Executions list view to show cache column (agent: frontend-fixer, deps: [T2])
- [x] T4: UI indication for silent degradation (agent: frontend-fixer, deps: [T2])
- [ ] T5: Run tests (cached and uncached) and verify (parent, deps: [T1, T2, T3, T4])
- [ ] T6: Capture visual proof (screenshot) and attach to PR (parent, deps: [T5])

## Blockers
<none>

## Status
Frontend Fixer: Completed T3 & T4.
- Added 'Cached Tokens' sortable column to Executions list table in `Executions.tsx` and updated `agentRunApi.ts` to request and return `cached_tokens`.
- Added `checkCacheableModels` helper in `agentApi.ts`.
- Added silent degradation warning alert in `GeneralTab.tsx` (Agent Form) when prompt caching is enabled on an unsupported model.
- Added silent degradation warning alert in `AgentRunDetailPage.tsx` when an agent run used an unsupported model with prompt caching enabled, and added 'Cached Tokens' column to child runs table.
- Verified frontend build (`npm run build`) succeeded without errors.

Backend Fixer: Completed T1 & T2.
- Updated `huf/ai/providers/litellm.py`: Added `_format_conversation_history` helper to format conversation history blocks with `cache_control: {"type": cache_control_type}` on the history prefix breakpoint when history caching is enabled (in both `run` and `run_stream`).
- Updated `huf/ai/providers/litellm.py`: Set and returned `cache_skipped_unsupported_model` flag when prompt caching is enabled on an agent but unsupported by the model. Extracted and tracked `cache_creation_tokens` and `cache_miss_tokens` in returned usage stats for both `run` and `run_stream`.
- Updated `huf/ai/agent_integration.py`: Recorded `cache_creation_tokens`, `cache_miss_tokens`, and `cache_skipped_unsupported_model` in `Agent Run`'s `usage_snapshot` JSON payload for both synchronous and streaming execution modes.
- Verified python syntax via `py_compile`.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
40 changes: 40 additions & 0 deletions frontend/src/components/agent/GeneralTab.tsx
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { useState, useEffect } from 'react';
import { FormField, FormItem, FormLabel, FormControl, FormDescription, FormMessage } from '@/components/ui/form';
import { Input } from '@/components/ui/input';
import { Textarea } from '@/components/ui/textarea';
Expand All @@ -6,13 +7,16 @@ import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@
import { Switch } from '@/components/ui/switch';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { Accordion, AccordionContent, AccordionItem, AccordionTrigger } from '@/components/ui/accordion';
import { Alert, AlertTitle, AlertDescription } from '@/components/ui/alert';
import { AlertTriangle } from 'lucide-react';
import { UseFormReturn } from 'react-hook-form';
import type { AIProvider, AIModel } from '@/types/agent.types';
import type { AgentFormValues } from './types';
import { InstructionsTextarea } from './InstructionsTextarea';
import { PromptTemplateSection, type AgentPromptOption } from './PromptTemplateSection';
import { LinkFieldControl } from '@/components/ui/link-field-control';
import { linkRoutes } from '@/lib/link-routes';
import { checkCacheableModels, type CacheableModelsResponse } from '@/services/agentApi';

interface GeneralTabProps {
form: UseFormReturn<AgentFormValues>;
Expand All @@ -38,8 +42,27 @@ export function GeneralTab({
showAddNewPrompt = true,
}: GeneralTabProps) {
const watchEnablePromptCaching = form.watch('enable_prompt_caching');
const watchModel = form.watch('model');
const promptMode = form.watch('prompt_mode');

const [cacheStatus, setCacheStatus] = useState<CacheableModelsResponse | null>(null);

useEffect(() => {
if (!watchEnablePromptCaching || !watchProvider || !watchModel) {
setCacheStatus(null);
return;
}
let cancelled = false;
checkCacheableModels(watchProvider, watchModel).then((res) => {
if (!cancelled) {
setCacheStatus(res);
}
});
return () => {
cancelled = true;
};
}, [watchEnablePromptCaching, watchProvider, watchModel]);

return (
<div className="space-y-6">
<Card>
Expand Down Expand Up @@ -310,6 +333,23 @@ We generally recommend altering this or temperature but not both.`}
)}
/>

{watchEnablePromptCaching && watchProvider && watchModel && cacheStatus && !cacheStatus.supported && (
<Alert className="sm:col-span-2 border-amber-500/50 bg-amber-500/10 text-amber-900 dark:text-amber-200">
<AlertTriangle className="h-4 w-4 text-amber-600 dark:text-amber-400" />
<AlertTitle className="font-semibold text-sm">Silent Degradation Warning: Prompt Caching Not Supported</AlertTitle>
<AlertDescription className="text-xs mt-1 space-y-1">
<p>
The selected model <strong>{watchModel}</strong> does not support prompt caching for provider <strong>{watchProvider}</strong>. Prompt caching will be silently skipped during execution.
</p>
{cacheStatus.alternatives.length > 0 && (
<p className="text-steel-soft">
Supported alternative models for {watchProvider}: {cacheStatus.alternatives.slice(0, 5).join(', ')}
</p>
)}
</AlertDescription>
</Alert>
)}

{watchEnablePromptCaching && (
<FormField
control={form.control}
Expand Down
21 changes: 21 additions & 0 deletions frontend/src/components/executions/ExecutionAnalyticsDashboard.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
import { useEffect, useState } from 'react';
import { BarChart3 } from 'lucide-react';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { getExecutionAnalytics } from '@/services/executionAnalyticsApi';
import type { ExecutionAnalyticsResponse } from '@/types/executionAnalytics.types';

const number = new Intl.NumberFormat();

function Metric({ label, value, detail }: { label: string; value: string; detail: string }) {
return <Card><CardHeader className="pb-2"><CardTitle className="text-sm font-medium text-muted-foreground">{label}</CardTitle></CardHeader><CardContent><div className="text-2xl font-bold">{value}</div><p className="mt-1 text-xs text-muted-foreground">{detail}</p></CardContent></Card>;
}

export function ExecutionAnalyticsDashboard() {
const [data, setData] = useState<ExecutionAnalyticsResponse | null>(null);
const [loading, setLoading] = useState(true);
useEffect(() => { getExecutionAnalytics().then((result) => { setData(result); setLoading(false); }); }, []);
if (loading) return <div className="text-sm text-muted-foreground">Loading scheduled execution analytics…</div>;
if (!data) return null;
const summary = data.summary;
return <section className="space-y-3"><div className="flex items-center gap-2"><BarChart3 className="h-4 w-4" /><h2 className="text-lg font-semibold">Execution analytics</h2><span className="text-xs text-muted-foreground">Scheduled rollup · {data.metadata.granularity}</span></div><div className="grid gap-3 md:grid-cols-2 xl:grid-cols-4"><Metric label="Runs" value={number.format(summary.run_count)} detail={`${summary.success_count} successful · ${summary.failed_count} failed`} /><Metric label="Cache ratio" value={summary.cache_ratio === null ? 'Unavailable' : `${summary.cache_ratio.toFixed(1)}%`} detail={`${number.format(summary.cached_tokens)} cached of ${number.format(summary.input_tokens)} input`} /><Metric label="LLM cost" value={`$${summary.total_cost.toFixed(4)}`} detail="Aggregated completed runs" /><Metric label="Average duration" value={summary.average_duration_ms === null ? 'Unavailable' : `${(summary.average_duration_ms / 1000).toFixed(1)}s`} detail={`Success rate ${summary.success_rate?.toFixed(1) ?? '—'}%`} /></div><p className="text-xs text-muted-foreground">Metrics are read from scheduled aggregate buckets, not calculated from raw run rows in the browser.</p></section>;
}
92 changes: 92 additions & 0 deletions frontend/src/components/ui/context-bar.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
import { cn } from '@/lib/utils';
import type { SegmentTokens } from '@/types/runContextMetrics.types';

export interface ContextBarCacheState {
cacheRead: number;
cacheWrite: number;
uncached: number;
}

export interface ContextBarProps {
segments: SegmentTokens;
cacheState?: ContextBarCacheState;
total: number;
size?: 'sm' | 'md';
className?: string;
onClick?: () => void;
}

const SEGMENT_ORDER: Array<{ key: keyof SegmentTokens; label: string; color: string }> = [
{ key: 'system', label: 'System', color: 'bg-slate-500' },
{ key: 'tools', label: 'Tools', color: 'bg-indigo-500' },
{ key: 'knowledge', label: 'Knowledge', color: 'bg-emerald-500' },
{ key: 'history', label: 'History', color: 'bg-amber-500' },
{ key: 'message', label: 'Message', color: 'bg-rose-500' },
];

/**
* Segmented context/cache meter. Top strip is composition (what fills the
* window, by tokens); bottom strip — when cacheState is supplied — is cache
* economics (what each token cost this turn). Same component at every
* scope: chat header (size="sm"), run detail / agent / fleet (size="md").
*/
export function ContextBar({ segments, cacheState, total, size = 'md', className, onClick }: ContextBarProps) {
const height = size === 'sm' ? 'h-1' : 'h-2.5';
const known = SEGMENT_ORDER.reduce((sum, { key }) => sum + (segments[key] ?? 0), 0);
const headroom = total > known ? total - known : 0;

const cacheTotal = cacheState ? cacheState.cacheRead + cacheState.cacheWrite + cacheState.uncached : 0;

const Wrapper = onClick ? 'button' : 'div';

return (
<Wrapper
className={cn('flex flex-col gap-1 w-full text-left', onClick && 'cursor-pointer', className)}
onClick={onClick}
type={onClick ? 'button' : undefined}
>
<div className={cn('flex w-full overflow-hidden rounded-full bg-muted', height)}>
{SEGMENT_ORDER.map(({ key, label, color }) => {
const value = segments[key];
if (!value) return null;
const pct = total > 0 ? (value / total) * 100 : 0;
return (
<div
key={key}
className={color}
style={{ width: `${pct}%` }}
title={`${label}: ${value.toLocaleString()} tokens`}
/>
);
})}
{headroom > 0 && (
<div
className="bg-transparent"
style={{ width: `${(headroom / total) * 100}%` }}
title={`Headroom: ${headroom.toLocaleString()} tokens`}
/>
)}
</div>

{cacheState && cacheTotal > 0 && (
<div className={cn('flex w-full overflow-hidden rounded-full bg-muted', height)}>
<div
className="bg-sky-500"
style={{ width: `${(cacheState.cacheRead / cacheTotal) * 100}%` }}
title={`Cache read (~0.1x): ${cacheState.cacheRead.toLocaleString()} tokens`}
/>
<div
className="bg-orange-500"
style={{ width: `${(cacheState.cacheWrite / cacheTotal) * 100}%` }}
title={`Cache write (~1.25x): ${cacheState.cacheWrite.toLocaleString()} tokens`}
/>
<div
className="bg-red-400"
style={{ width: `${(cacheState.uncached / cacheTotal) * 100}%` }}
title={`Uncached (1x): ${cacheState.uncached.toLocaleString()} tokens`}
/>
</div>
)}
</Wrapper>
);
}
Loading