Compare commits

...
10 Commits
Author SHA1 Message Date
Ryuichi Leo Takashige 7ca01c5ba8 Add graph 2026-04-15 18:01:03 +01:00
Ryuichi Leo Takashige 0a6b7e9715 Add dashboard for trajectories 2026-04-15 17:42:06 +01:00
Ryuichi Leo Takashige 3a2d93dc93 Cleanup 2026-04-15 17:36:50 +01:00
Ryuichi Leo Takashige 9f5e0e037e ATIF for internal testing 2026-04-15 17:36:50 +01:00
Ryuichi Leo Takashige e0bb0d276c ATIF for internal testing 2026-04-15 17:36:50 +01:00
Ryuichi Leo Takashige 55b90114f8 Drain accumulated text 2026-04-15 17:36:50 +01:00
Ryuichi Leo Takashige 4e7da9ef4f Fix test 2026-04-15 17:32:01 +01:00
Ryuichi Leo Takashige 9b516029d3 Cleanup 2026-04-15 17:16:42 +01:00
Ryuichi Leo Takashige 1cd7a67cc5 Cleanup 2026-04-15 17:03:22 +01:00
Ryuichi Leo Takashige b0eec5988a Add usage stats to tool calls 2026-04-15 16:55:20 +01:00
13 changed files with 2797 additions and 41 deletions

No files matched your search

Generated
+1
View File
@@ -2,5 +2,6 @@
<project version="4">
<component name="VcsDirectoryMappings">
<mapping directory="" vcs="Git" />
<mapping directory="$PROJECT_DIR$/app/EXO/build/SourcePackages/checkouts/Sparkle" vcs="Git" />
</component>
</project>
@@ -0,0 +1,107 @@
<script lang="ts">
interface Point {
t: number;
v: number | null;
}
interface Props {
title: string;
unit: string;
points: Point[];
color?: string;
formatY?: (v: number) => string;
}
let {
title,
unit,
points,
color = "#facc15",
formatY = (v: number) => v.toFixed(0),
}: Props = $props();
const width = 520;
const height = 120;
const padLeft = 44;
const padRight = 12;
const padTop = 18;
const padBottom = 22;
const plot = $derived.by(() => {
const valid = points.filter((p) => p.v !== null) as {
t: number;
v: number;
}[];
if (valid.length === 0) {
return null;
}
const minT = Math.min(...valid.map((p) => p.t));
const maxT = Math.max(...valid.map((p) => p.t));
const minV = 0;
const maxV = Math.max(...valid.map((p) => p.v), 1);
const xSpan = maxT - minT || 1;
const ySpan = maxV - minV || 1;
const innerW = width - padLeft - padRight;
const innerH = height - padTop - padBottom;
const xs = (t: number) => padLeft + ((t - minT) / xSpan) * innerW;
const ys = (v: number) => padTop + innerH - ((v - minV) / ySpan) * innerH;
const pathD = valid
.map((p, i) => `${i === 0 ? "M" : "L"}${xs(p.t)},${ys(p.v)}`)
.join(" ");
const circles = valid.map((p) => ({ cx: xs(p.t), cy: ys(p.v), v: p.v }));
const yTicks = [0, maxV / 2, maxV].map((v) => ({ y: ys(v), v }));
return { pathD, circles, yTicks, count: valid.length, maxV };
});
</script>
<div
class="rounded border border-exo-medium-gray/30 bg-exo-black/30 p-3 space-y-2"
>
<div class="flex items-baseline justify-between gap-2">
<div class="text-xs font-mono text-exo-light-gray uppercase tracking-wider">
{title}
</div>
<div class="text-[10px] font-mono text-exo-light-gray/70">
{plot ? `${plot.count} points` : "no data"}
</div>
</div>
{#if plot}
<svg
viewBox="0 0 {width} {height}"
class="w-full h-auto"
xmlns="http://www.w3.org/2000/svg"
role="img"
aria-label={title}
>
{#each plot.yTicks as tick}
<line
x1={padLeft}
x2={width - padRight}
y1={tick.y}
y2={tick.y}
stroke="rgba(255,255,255,0.07)"
stroke-width="1"
/>
<text
x={padLeft - 4}
y={tick.y + 3}
text-anchor="end"
font-family="ui-monospace, monospace"
font-size="9"
fill="rgba(255,255,255,0.5)"
>
{formatY(tick.v)}
</text>
{/each}
<path d={plot.pathD} fill="none" stroke={color} stroke-width="1.5" />
{#each plot.circles as c}
<circle cx={c.cx} cy={c.cy} r="2.5" fill={color}>
<title>{formatY(c.v)} {unit}</title>
</circle>
{/each}
</svg>
{:else}
<div class="text-xs font-mono text-exo-light-gray/50 text-center py-6">
no data
</div>
{/if}
</div>
+120
View File
@@ -213,6 +213,83 @@ export interface TraceListResponse {
traces: TraceListItem[];
}
export interface TrajectoryListItem {
sessionId: string;
createdAt: string;
updatedAt: string;
totalSteps: number;
model: string;
totalPromptTokens: number;
totalCompletionTokens: number;
totalCachedTokens: number;
agentStepCount: number;
toolCallCount: number;
avgTtftMs: number | null;
avgPromptTps: number | null;
avgGenerationTps: number | null;
cacheHitNone: number;
cacheHitPartial: number;
cacheHitExact: number;
}
export interface TrajectoryListResponse {
trajectories: TrajectoryListItem[];
}
export interface AtifToolCall {
tool_call_id: string;
function_name: string;
arguments: Record<string, unknown>;
}
export interface AtifObservationResult {
source_call_id: string;
content: string;
}
export interface AtifObservation {
results: AtifObservationResult[];
}
export interface AtifStepMetrics {
prompt_tokens: number;
completion_tokens: number;
cached_tokens: number;
cost: number;
_exo_extensions?: {
prompt_tps?: number;
generation_tps?: number;
peak_memory_bytes?: number;
prefix_cache_hit?: "none" | "partial" | "exact";
reasoning_content?: string;
};
}
export interface AtifStep {
step_id: number;
timestamp: string;
source: "user" | "agent" | "system";
message: string;
reasoning_content?: string;
tool_calls?: AtifToolCall[];
observation?: AtifObservation;
metrics?: AtifStepMetrics;
model_name?: string;
}
export interface AtifTrajectory {
schema_version: string;
session_id: string;
agent: { name: string; model: string; provider: string };
steps: AtifStep[];
final_metrics: {
total_steps: number;
total_prompt_tokens: number;
total_completion_tokens: number;
total_cost: number;
};
}
interface RawStateResponse {
topology?: RawTopology;
instances?: Record<
@@ -3363,6 +3440,42 @@ class AppStore {
getTraceRawUrl(taskId: string): string {
return `/v1/traces/${encodeURIComponent(taskId)}/raw`;
}
async listTrajectories(): Promise<TrajectoryListResponse> {
const response = await fetch("/v1/trajectories");
if (!response.ok) {
throw new Error(`Failed to list trajectories: ${response.status}`);
}
return (await response.json()) as TrajectoryListResponse;
}
async getTrajectory(sessionId: string): Promise<AtifTrajectory> {
const response = await fetch(
`/v1/trajectories/${encodeURIComponent(sessionId)}`,
);
if (!response.ok) {
throw new Error(`Failed to load trajectory: ${response.status}`);
}
return (await response.json()) as AtifTrajectory;
}
async deleteTrajectories(
sessionIds: string[],
): Promise<{ deleted: string[]; notFound: string[] }> {
const response = await fetch("/v1/trajectories/delete", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ sessionIds }),
});
if (!response.ok) {
throw new Error(`Failed to delete trajectories: ${response.status}`);
}
return await response.json();
}
getTrajectoryRawUrl(sessionId: string): string {
return `/v1/trajectories/${encodeURIComponent(sessionId)}/raw`;
}
}
export const appStore = new AppStore();
@@ -3517,3 +3630,10 @@ export const getTraceRawUrl = (taskId: string) =>
appStore.getTraceRawUrl(taskId);
export const deleteTraces = (taskIds: string[]) =>
appStore.deleteTraces(taskIds);
export const listTrajectories = () => appStore.listTrajectories();
export const getTrajectory = (sessionId: string) =>
appStore.getTrajectory(sessionId);
export const deleteTrajectories = (sessionIds: string[]) =>
appStore.deleteTrajectories(sessionIds);
export const getTrajectoryRawUrl = (sessionId: string) =>
appStore.getTrajectoryRawUrl(sessionId);
@@ -0,0 +1,531 @@
<script lang="ts">
import { onMount } from "svelte";
import {
listTrajectories,
deleteTrajectories,
getTrajectoryRawUrl,
type TrajectoryListItem,
} from "$lib/stores/app.svelte";
import HeaderNav from "$lib/components/HeaderNav.svelte";
import TrajectoryTrendChart from "$lib/components/TrajectoryTrendChart.svelte";
let trajectories = $state<TrajectoryListItem[]>([]);
let loading = $state(true);
let error = $state<string | null>(null);
let selectedIds = $state<Set<string>>(new Set());
let deleting = $state(false);
let compareA = $state<string | null>(null);
let compareB = $state<string | null>(null);
let allSelected = $derived(
trajectories.length > 0 && selectedIds.size === trajectories.length,
);
const trendSeries = $derived.by(() => {
const sorted = [...trajectories].sort(
(a, b) =>
new Date(a.updatedAt).getTime() - new Date(b.updatedAt).getTime(),
);
const toPoints = <T extends keyof TrajectoryListItem>(key: T) =>
sorted.map((t) => ({
t: new Date(t.updatedAt).getTime(),
v: (t[key] as number | null) ?? null,
}));
const cacheHitRatePoints = sorted.map((t) => {
const total = t.cacheHitNone + t.cacheHitPartial + t.cacheHitExact;
return {
t: new Date(t.updatedAt).getTime(),
v:
total > 0
? ((t.cacheHitPartial + t.cacheHitExact) / total) * 100
: null,
};
});
const totalTokensPoints = sorted.map((t) => ({
t: new Date(t.updatedAt).getTime(),
v: t.totalPromptTokens + t.totalCompletionTokens,
}));
return {
ttft: toPoints("avgTtftMs"),
genTps: toPoints("avgGenerationTps"),
tokens: totalTokensPoints,
cacheHitRate: cacheHitRatePoints,
};
});
const summary = $derived.by(() => {
if (trajectories.length === 0) {
return null;
}
let totalRequests = 0;
let totalPrompt = 0;
let totalCompletion = 0;
let totalCached = 0;
let totalTools = 0;
const ttft: number[] = [];
const ptps: number[] = [];
const gtps: number[] = [];
let hitsNone = 0;
let hitsPartial = 0;
let hitsExact = 0;
const models = new Set<string>();
for (const t of trajectories) {
totalRequests += t.agentStepCount;
totalPrompt += t.totalPromptTokens;
totalCompletion += t.totalCompletionTokens;
totalCached += t.totalCachedTokens;
totalTools += t.toolCallCount;
if (t.avgTtftMs !== null) ttft.push(t.avgTtftMs);
if (t.avgPromptTps !== null) ptps.push(t.avgPromptTps);
if (t.avgGenerationTps !== null) gtps.push(t.avgGenerationTps);
hitsNone += t.cacheHitNone;
hitsPartial += t.cacheHitPartial;
hitsExact += t.cacheHitExact;
if (t.model) models.add(t.model);
}
const totalHits = hitsNone + hitsPartial + hitsExact;
const cacheHitRate =
totalHits > 0 ? ((hitsPartial + hitsExact) / totalHits) * 100 : null;
const cachedRatio =
totalPrompt > 0 ? (totalCached / totalPrompt) * 100 : null;
const mean = (xs: number[]) =>
xs.length > 0 ? xs.reduce((a, b) => a + b, 0) / xs.length : null;
return {
trajectoryCount: trajectories.length,
totalRequests,
totalPrompt,
totalCompletion,
totalCached,
totalTools,
avgTtftMs: mean(ttft),
avgPromptTps: mean(ptps),
avgGenerationTps: mean(gtps),
cacheHitRate,
cachedRatio,
hitsNone,
hitsPartial,
hitsExact,
models: [...models],
};
});
function toggleSelect(sessionId: string) {
const next = new Set(selectedIds);
if (next.has(sessionId)) next.delete(sessionId);
else next.add(sessionId);
selectedIds = next;
}
function toggleSelectAll() {
selectedIds = allSelected
? new Set()
: new Set(trajectories.map((t) => t.sessionId));
}
async function handleDelete() {
if (selectedIds.size === 0) return;
const count = selectedIds.size;
if (
!confirm(
`Delete ${count} trajector${count === 1 ? "y" : "ies"}? This cannot be undone.`,
)
)
return;
deleting = true;
try {
await deleteTrajectories([...selectedIds]);
selectedIds = new Set();
await refresh();
} catch (e) {
error = e instanceof Error ? e.message : "Failed to delete trajectories";
} finally {
deleting = false;
}
}
function formatDate(isoString: string): string {
return new Date(isoString).toLocaleString();
}
function formatTokens(n: number): string {
if (n < 1000) return String(n);
if (n < 1_000_000) return `${(n / 1000).toFixed(1)}k`;
return `${(n / 1_000_000).toFixed(2)}M`;
}
function formatMs(n: number | null): string {
if (n === null) return "—";
if (n < 1000) return `${n.toFixed(0)}ms`;
return `${(n / 1000).toFixed(2)}s`;
}
function formatPct(n: number | null): string {
return n === null ? "—" : `${n.toFixed(1)}%`;
}
function formatTps(n: number | null): string {
return n === null ? "—" : n.toFixed(1);
}
async function downloadTrajectory(sessionId: string) {
const response = await fetch(getTrajectoryRawUrl(sessionId));
const blob = await response.blob();
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = `${sessionId}.json`;
a.click();
URL.revokeObjectURL(url);
}
function pickForCompare(sessionId: string) {
if (compareA === null) compareA = sessionId;
else if (compareB === null && sessionId !== compareA) compareB = sessionId;
else {
compareA = sessionId;
compareB = null;
}
}
function clearCompare() {
compareA = null;
compareB = null;
}
async function refresh() {
loading = true;
error = null;
try {
const response = await listTrajectories();
trajectories = response.trajectories;
} catch (e) {
error = e instanceof Error ? e.message : "Failed to load trajectories";
} finally {
loading = false;
}
}
onMount(refresh);
</script>
<div class="min-h-screen bg-exo-dark-gray text-white">
<HeaderNav showHome={true} />
<div class="max-w-7xl mx-auto px-4 lg:px-8 py-6 space-y-6">
<div class="flex items-center justify-between gap-4 flex-wrap">
<h1 class="text-2xl font-mono tracking-[0.2em] uppercase text-exo-yellow">
Trajectories
</h1>
<div class="flex items-center gap-3">
{#if compareA && compareB}
<a
href="#/trajectories/compare?a={encodeURIComponent(compareA)}&b={encodeURIComponent(compareB)}"
class="text-xs font-mono text-exo-dark-gray bg-exo-yellow hover:bg-exo-yellow/90 transition-colors uppercase px-2 py-1 rounded font-semibold"
>
Compare
</a>
<button
type="button"
class="text-xs font-mono text-exo-light-gray hover:text-exo-yellow transition-colors uppercase border border-exo-medium-gray/40 px-2 py-1 rounded"
onclick={clearCompare}
>
Clear
</button>
{:else if compareA}
<span class="text-xs font-mono text-exo-light-gray uppercase">
Pick another to compare
</span>
<button
type="button"
class="text-xs font-mono text-exo-light-gray hover:text-exo-yellow transition-colors uppercase border border-exo-medium-gray/40 px-2 py-1 rounded"
onclick={clearCompare}
>
Clear
</button>
{/if}
{#if selectedIds.size > 0}
<button
type="button"
class="text-xs font-mono text-red-400 hover:text-red-300 transition-colors uppercase border border-red-500/40 px-2 py-1 rounded"
onclick={handleDelete}
disabled={deleting}
>
{deleting ? "Deleting..." : `Delete (${selectedIds.size})`}
</button>
{/if}
<button
type="button"
class="text-xs font-mono text-exo-light-gray hover:text-exo-yellow transition-colors uppercase border border-exo-medium-gray/40 px-2 py-1 rounded"
onclick={refresh}
disabled={loading}
>
Refresh
</button>
</div>
</div>
{#if loading}
<div
class="rounded border border-exo-medium-gray/30 bg-exo-black/30 p-6 text-center text-exo-light-gray text-sm"
>
Loading trajectories...
</div>
{:else if error}
<div
class="rounded border border-red-500/30 bg-red-500/10 p-6 text-center text-red-400 text-sm"
>
{error}
</div>
{:else if trajectories.length === 0}
<div
class="rounded border border-exo-medium-gray/30 bg-exo-black/30 p-6 text-center text-exo-light-gray space-y-2"
>
<div class="text-sm">No trajectories found.</div>
<div class="text-xs text-exo-light-gray/70">
Run exo with --trajectories or EXO_TRAJECTORIES=1 to record live chat
completions.
</div>
</div>
{:else if summary}
<!-- Summary dashboard -->
<div
class="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-6 gap-3 text-xs font-mono"
>
<div
class="rounded border border-exo-medium-gray/30 bg-exo-black/30 p-3"
>
<div class="text-exo-light-gray uppercase tracking-wider">
Trajectories
</div>
<div class="text-xl text-exo-yellow mt-1">
{summary.trajectoryCount}
</div>
<div class="text-[10px] text-exo-light-gray/70 mt-1">
{summary.totalRequests} requests
</div>
</div>
<div
class="rounded border border-exo-medium-gray/30 bg-exo-black/30 p-3"
>
<div class="text-exo-light-gray uppercase tracking-wider">
Prompt tokens
</div>
<div class="text-xl text-exo-yellow mt-1">
{formatTokens(summary.totalPrompt)}
</div>
<div class="text-[10px] text-exo-light-gray/70 mt-1">
{formatTokens(summary.totalCached)} cached
({formatPct(summary.cachedRatio)})
</div>
</div>
<div
class="rounded border border-exo-medium-gray/30 bg-exo-black/30 p-3"
>
<div class="text-exo-light-gray uppercase tracking-wider">
Completion tokens
</div>
<div class="text-xl text-exo-yellow mt-1">
{formatTokens(summary.totalCompletion)}
</div>
<div class="text-[10px] text-exo-light-gray/70 mt-1">
{summary.totalTools} tool calls
</div>
</div>
<div
class="rounded border border-exo-medium-gray/30 bg-exo-black/30 p-3"
>
<div class="text-exo-light-gray uppercase tracking-wider">TTFT</div>
<div class="text-xl text-exo-yellow mt-1">
{formatMs(summary.avgTtftMs)}
</div>
<div class="text-[10px] text-exo-light-gray/70 mt-1">avg</div>
</div>
<div
class="rounded border border-exo-medium-gray/30 bg-exo-black/30 p-3"
>
<div class="text-exo-light-gray uppercase tracking-wider">
Prefill / gen tok/s
</div>
<div class="text-xl text-exo-yellow mt-1">
{formatTps(summary.avgPromptTps)} / {formatTps(summary.avgGenerationTps)}
</div>
<div class="text-[10px] text-exo-light-gray/70 mt-1">avg</div>
</div>
<div
class="rounded border border-exo-medium-gray/30 bg-exo-black/30 p-3"
>
<div class="text-exo-light-gray uppercase tracking-wider">
Prefix cache hit
</div>
<div class="text-xl text-exo-yellow mt-1">
{formatPct(summary.cacheHitRate)}
</div>
<div class="text-[10px] text-exo-light-gray/70 mt-1">
{summary.hitsExact} exact &bull; {summary.hitsPartial} partial
&bull; {summary.hitsNone} miss
</div>
</div>
</div>
{#if summary.models.length > 0}
<div class="text-xs font-mono text-exo-light-gray/70">
Models: {summary.models.join(", ")}
</div>
{/if}
<!-- Trends over time -->
<div class="grid grid-cols-1 lg:grid-cols-2 gap-3">
<TrajectoryTrendChart
title="TTFT per trajectory"
unit="ms"
points={trendSeries.ttft}
color="#facc15"
formatY={(v) => (v < 1000 ? `${v.toFixed(0)}` : `${(v / 1000).toFixed(1)}s`)}
/>
<TrajectoryTrendChart
title="Generation tok/s per trajectory"
unit="tok/s"
points={trendSeries.genTps}
color="#60a5fa"
formatY={(v) => v.toFixed(1)}
/>
<TrajectoryTrendChart
title="Total tokens per trajectory"
unit="tokens"
points={trendSeries.tokens}
color="#4ade80"
formatY={(v) =>
v < 1000
? `${v.toFixed(0)}`
: v < 1_000_000
? `${(v / 1000).toFixed(1)}k`
: `${(v / 1_000_000).toFixed(2)}M`}
/>
<TrajectoryTrendChart
title="Prefix cache hit rate per trajectory"
unit="%"
points={trendSeries.cacheHitRate}
color="#c084fc"
formatY={(v) => `${v.toFixed(0)}`}
/>
</div>
<!-- Table -->
<div class="space-y-2">
<div class="flex items-center gap-2 px-1">
<button
type="button"
class="text-xs font-mono uppercase transition-colors {allSelected
? 'text-exo-yellow'
: 'text-exo-light-gray hover:text-exo-yellow'}"
onclick={toggleSelectAll}
>
{allSelected ? "Deselect all" : "Select all"}
</button>
</div>
{#each trajectories as t}
{@const isSelected = selectedIds.has(t.sessionId)}
{@const isCompareA = compareA === t.sessionId}
{@const isCompareB = compareB === t.sessionId}
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div
role="button"
tabindex="0"
class="w-full text-left rounded border-l-2 border-r border-t border-b transition-all p-3 cursor-pointer {isSelected
? 'bg-exo-yellow/10 border-l-exo-yellow border-r-exo-medium-gray/30 border-t-exo-medium-gray/30 border-b-exo-medium-gray/30'
: 'bg-exo-black/30 border-l-transparent border-r-exo-medium-gray/30 border-t-exo-medium-gray/30 border-b-exo-medium-gray/30 hover:bg-white/[0.03]'}"
onclick={() => toggleSelect(t.sessionId)}
onkeydown={(e) => {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
toggleSelect(t.sessionId);
}
}}
>
<div class="flex items-center justify-between gap-4 mb-2">
<div class="min-w-0 flex-1">
<a
href="#/trajectories/{t.sessionId}"
class="text-sm font-mono truncate block transition-colors {isSelected
? 'text-exo-yellow'
: 'text-white hover:text-exo-yellow'}"
onclick={(e) => e.stopPropagation()}
>
{t.sessionId}
{#if isCompareA}
<span class="ml-2 text-xs text-exo-yellow">[A]</span>
{:else if isCompareB}
<span class="ml-2 text-xs text-exo-yellow">[B]</span>
{/if}
</a>
<div class="text-[10px] text-exo-light-gray font-mono mt-1">
{formatDate(t.updatedAt)} &bull; {t.model}
</div>
</div>
<!-- svelte-ignore a11y_click_events_have_key_events -->
<div
class="flex items-center gap-2 shrink-0"
onclick={(e) => e.stopPropagation()}
>
<button
type="button"
class="text-xs font-mono text-exo-light-gray hover:text-exo-yellow transition-colors uppercase border border-exo-medium-gray/40 px-2 py-1 rounded"
onclick={() => pickForCompare(t.sessionId)}
>
{isCompareA ? "Set B" : "Compare"}
</button>
<a
href="#/trajectories/{t.sessionId}"
class="text-xs font-mono text-exo-light-gray hover:text-exo-yellow transition-colors uppercase border border-exo-medium-gray/40 px-2 py-1 rounded"
>
View
</a>
<button
type="button"
class="text-xs font-mono text-exo-light-gray hover:text-exo-yellow transition-colors uppercase border border-exo-medium-gray/40 px-2 py-1 rounded"
onclick={() => downloadTrajectory(t.sessionId)}
>
Download
</button>
</div>
</div>
<div
class="grid grid-cols-3 sm:grid-cols-6 gap-2 text-[10px] font-mono"
>
<div
class="px-2 py-1 rounded bg-exo-medium-gray/20 text-exo-light-gray"
>
steps <span class="text-white">{t.totalSteps}</span>
</div>
<div
class="px-2 py-1 rounded bg-exo-medium-gray/20 text-exo-light-gray"
>
pt <span class="text-white">{formatTokens(t.totalPromptTokens)}</span>
</div>
<div
class="px-2 py-1 rounded bg-exo-medium-gray/20 text-exo-light-gray"
>
ct <span class="text-white">{formatTokens(t.totalCompletionTokens)}</span>
</div>
<div
class="px-2 py-1 rounded bg-exo-medium-gray/20 text-exo-light-gray"
>
cached <span class="text-white">{formatTokens(t.totalCachedTokens)}</span>
</div>
<div
class="px-2 py-1 rounded bg-exo-medium-gray/20 text-exo-light-gray"
>
ttft <span class="text-white">{formatMs(t.avgTtftMs)}</span>
</div>
<div
class="px-2 py-1 rounded bg-exo-medium-gray/20 text-exo-light-gray"
>
gen <span class="text-white">{formatTps(t.avgGenerationTps)}</span>
tok/s
</div>
</div>
</div>
{/each}
</div>
{/if}
</div>
</div>
@@ -0,0 +1,280 @@
<script lang="ts">
import { page } from "$app/stores";
import { onMount } from "svelte";
import { getTrajectory, type AtifTrajectory } from "$lib/stores/app.svelte";
import HeaderNav from "$lib/components/HeaderNav.svelte";
import TrajectoryTrendChart from "$lib/components/TrajectoryTrendChart.svelte";
const sessionId = $derived($page.params.sessionId);
let trajectory = $state<AtifTrajectory | null>(null);
let loading = $state(true);
let error = $state<string | null>(null);
async function load() {
loading = true;
error = null;
try {
trajectory = await getTrajectory(sessionId);
} catch (e) {
error = e instanceof Error ? e.message : "Failed to load trajectory";
} finally {
loading = false;
}
}
onMount(load);
function formatTimestamp(iso: string): string {
return new Date(iso).toLocaleString();
}
const stepSeries = $derived.by(() => {
if (!trajectory) return null;
const agentSteps = trajectory.steps.filter((s) => s.source === "agent");
const memory: { t: number; v: number | null }[] = [];
const promptTps: { t: number; v: number | null }[] = [];
const genTps: { t: number; v: number | null }[] = [];
const ttft: { t: number; v: number | null }[] = [];
const promptTokens: { t: number; v: number | null }[] = [];
const completionTokens: { t: number; v: number | null }[] = [];
const cachedTokens: { t: number; v: number | null }[] = [];
for (const s of agentSteps) {
const id = s.step_id;
const m = s.metrics;
const ext = m?._exo_extensions;
memory.push({
t: id,
v:
ext?.peak_memory_bytes !== undefined
? ext.peak_memory_bytes / (1024 * 1024 * 1024)
: null,
});
promptTps.push({ t: id, v: ext?.prompt_tps ?? null });
genTps.push({ t: id, v: ext?.generation_tps ?? null });
ttft.push({ t: id, v: ext?.ttft_ms ?? null });
promptTokens.push({ t: id, v: m?.prompt_tokens ?? null });
completionTokens.push({ t: id, v: m?.completion_tokens ?? null });
cachedTokens.push({ t: id, v: m?.cached_tokens ?? null });
}
return {
memory,
promptTps,
genTps,
ttft,
promptTokens,
completionTokens,
cachedTokens,
};
});
</script>
<div class="min-h-screen bg-exo-dark-gray text-white">
<HeaderNav showHome={true} />
<div class="max-w-5xl mx-auto px-4 lg:px-8 py-6 space-y-6">
<div>
<a
href="#/trajectories"
class="text-xs font-mono text-exo-light-gray hover:text-exo-yellow uppercase"
>
&larr; Trajectories
</a>
<h1
class="mt-2 text-2xl font-mono tracking-[0.2em] uppercase text-exo-yellow truncate"
>
{sessionId}
</h1>
</div>
{#if loading}
<div
class="rounded border border-exo-medium-gray/30 bg-exo-black/30 p-6 text-center text-exo-light-gray"
>
Loading...
</div>
{:else if error}
<div
class="rounded border border-red-500/30 bg-red-500/10 p-6 text-center text-red-400"
>
{error}
</div>
{:else if trajectory}
<div
class="rounded border border-exo-medium-gray/30 bg-exo-black/30 p-4 text-xs font-mono text-exo-light-gray space-y-1"
>
<div>schema: {trajectory.schema_version}</div>
<div>agent: {trajectory.agent.name} / {trajectory.agent.model}</div>
<div>
totals: steps={trajectory.final_metrics.total_steps}
&bull; prompt_tokens={trajectory.final_metrics.total_prompt_tokens}
&bull; completion_tokens={trajectory.final_metrics
.total_completion_tokens}
&bull; cost=${trajectory.final_metrics.total_cost.toFixed(4)}
</div>
</div>
{#if stepSeries}
<div class="grid grid-cols-1 lg:grid-cols-2 gap-3">
<TrajectoryTrendChart
title="Peak memory per step"
unit="GiB"
points={stepSeries.memory}
color="#facc15"
formatY={(v) => v.toFixed(1)}
/>
<TrajectoryTrendChart
title="Generation tok/s per step"
unit="tok/s"
points={stepSeries.genTps}
color="#60a5fa"
formatY={(v) => v.toFixed(1)}
/>
<TrajectoryTrendChart
title="Prefill tok/s per step"
unit="tok/s"
points={stepSeries.promptTps}
color="#4ade80"
formatY={(v) => v.toFixed(0)}
/>
<TrajectoryTrendChart
title="TTFT per step"
unit="ms"
points={stepSeries.ttft}
color="#f472b6"
formatY={(v) =>
v < 1000 ? `${v.toFixed(0)}` : `${(v / 1000).toFixed(1)}s`}
/>
<TrajectoryTrendChart
title="Prompt tokens per step"
unit="tokens"
points={stepSeries.promptTokens}
color="#c084fc"
formatY={(v) =>
v < 1000
? `${v.toFixed(0)}`
: v < 1_000_000
? `${(v / 1000).toFixed(1)}k`
: `${(v / 1_000_000).toFixed(2)}M`}
/>
<TrajectoryTrendChart
title="Cached tokens per step"
unit="tokens"
points={stepSeries.cachedTokens}
color="#34d399"
formatY={(v) =>
v < 1000
? `${v.toFixed(0)}`
: v < 1_000_000
? `${(v / 1000).toFixed(1)}k`
: `${(v / 1_000_000).toFixed(2)}M`}
/>
</div>
{/if}
<div class="space-y-3">
{#each trajectory.steps as step}
<div
class="rounded border border-exo-medium-gray/30 bg-exo-black/20 p-4 space-y-2"
>
<div class="flex items-center gap-3 text-xs font-mono uppercase">
<span class="text-exo-yellow">#{step.step_id}</span>
<span class="text-exo-light-gray">{step.source}</span>
{#if step.model_name}
<span class="text-exo-light-gray/70">{step.model_name}</span>
{/if}
<span class="text-exo-light-gray/60 ml-auto">
{formatTimestamp(step.timestamp)}
</span>
</div>
{#if step.message}
<pre
class="whitespace-pre-wrap text-sm font-mono text-white/90">{step.message}</pre>
{/if}
{#if step.reasoning_content}
<details
class="text-xs font-mono text-exo-light-gray/80 border border-exo-medium-gray/30 rounded p-2"
>
<summary class="cursor-pointer uppercase"
>reasoning_content</summary
>
<pre
class="whitespace-pre-wrap mt-2">{step.reasoning_content}</pre>
</details>
{/if}
{#if step.tool_calls && step.tool_calls.length > 0}
<div class="space-y-1">
{#each step.tool_calls as tc}
<div
class="rounded border border-exo-yellow/40 bg-exo-yellow/5 p-2 text-xs font-mono"
>
<div class="text-exo-yellow uppercase">
tool_call: {tc.function_name}
</div>
<pre class="whitespace-pre-wrap mt-1">{JSON.stringify(
tc.arguments,
null,
2,
)}</pre>
</div>
{/each}
</div>
{/if}
{#if step.observation && step.observation.results.length > 0}
<div class="space-y-1">
{#each step.observation.results as r}
<div
class="rounded border border-green-500/40 bg-green-500/5 p-2 text-xs font-mono"
>
<div class="text-green-400 uppercase">
observation: {r.source_call_id}
</div>
<pre class="whitespace-pre-wrap mt-1">{r.content}</pre>
</div>
{/each}
</div>
{/if}
{#if step.metrics}
<div class="flex flex-wrap gap-2 text-[10px] font-mono uppercase">
<span
class="px-2 py-1 rounded bg-exo-medium-gray/20 text-exo-light-gray"
>prompt {step.metrics.prompt_tokens}</span
>
<span
class="px-2 py-1 rounded bg-exo-medium-gray/20 text-exo-light-gray"
>completion {step.metrics.completion_tokens}</span
>
<span
class="px-2 py-1 rounded bg-exo-medium-gray/20 text-exo-light-gray"
>cached {step.metrics.cached_tokens}</span
>
{#if step.metrics._exo_extensions?.prompt_tps !== undefined}
<span
class="px-2 py-1 rounded bg-exo-medium-gray/20 text-exo-light-gray"
>prompt_tps {step.metrics._exo_extensions.prompt_tps.toFixed(
1,
)}</span
>
{/if}
{#if step.metrics._exo_extensions?.generation_tps !== undefined}
<span
class="px-2 py-1 rounded bg-exo-medium-gray/20 text-exo-light-gray"
>gen_tps {step.metrics._exo_extensions.generation_tps.toFixed(
1,
)}</span
>
{/if}
{#if step.metrics._exo_extensions?.prefix_cache_hit}
<span
class="px-2 py-1 rounded bg-exo-medium-gray/20 text-exo-light-gray"
>prefix_cache {step.metrics._exo_extensions
.prefix_cache_hit}</span
>
{/if}
</div>
{/if}
</div>
{/each}
</div>
{/if}
</div>
</div>
@@ -0,0 +1,215 @@
<script lang="ts">
import { page } from "$app/stores";
import { onMount } from "svelte";
import {
getTrajectory,
type AtifTrajectory,
type AtifStep,
} from "$lib/stores/app.svelte";
import HeaderNav from "$lib/components/HeaderNav.svelte";
const idA = $derived($page.url.searchParams.get("a") ?? "");
const idB = $derived($page.url.searchParams.get("b") ?? "");
let trajectoryA = $state<AtifTrajectory | null>(null);
let trajectoryB = $state<AtifTrajectory | null>(null);
let loading = $state(true);
let error = $state<string | null>(null);
async function load() {
loading = true;
error = null;
try {
const [a, b] = await Promise.all([
getTrajectory(idA),
getTrajectory(idB),
]);
trajectoryA = a;
trajectoryB = b;
} catch (e) {
error = e instanceof Error ? e.message : "Failed to load trajectories";
} finally {
loading = false;
}
}
onMount(load);
function delta(a: number, b: number): { value: string; cls: string } {
const d = b - a;
const sign = d > 0 ? "+" : "";
const cls =
d === 0
? "text-exo-light-gray"
: d > 0
? "text-red-400"
: "text-green-400";
return { value: `${sign}${d}`, cls };
}
type AlignedRow = { a: AtifStep | null; b: AtifStep | null };
function alignByStepId(
a: AtifTrajectory | null,
b: AtifTrajectory | null,
): AlignedRow[] {
if (!a && !b) return [];
const maxLen = Math.max(a?.steps.length ?? 0, b?.steps.length ?? 0);
const rows: AlignedRow[] = [];
for (let i = 0; i < maxLen; i++) {
rows.push({
a: a?.steps[i] ?? null,
b: b?.steps[i] ?? null,
});
}
return rows;
}
const rows = $derived(alignByStepId(trajectoryA, trajectoryB));
</script>
<div class="min-h-screen bg-exo-dark-gray text-white">
<HeaderNav showHome={true} />
<div class="max-w-7xl mx-auto px-4 lg:px-8 py-6 space-y-6">
<div>
<a
href="#/trajectories"
class="text-xs font-mono text-exo-light-gray hover:text-exo-yellow uppercase"
>
&larr; Trajectories
</a>
<h1
class="mt-2 text-2xl font-mono tracking-[0.2em] uppercase text-exo-yellow"
>
Compare
</h1>
</div>
{#if loading}
<div
class="rounded border border-exo-medium-gray/30 bg-exo-black/30 p-6 text-center text-exo-light-gray"
>
Loading...
</div>
{:else if error}
<div
class="rounded border border-red-500/30 bg-red-500/10 p-6 text-center text-red-400"
>
{error}
</div>
{:else if trajectoryA && trajectoryB}
<div class="grid grid-cols-2 gap-6">
<div
class="rounded border border-exo-yellow/40 bg-exo-black/30 p-4 text-xs font-mono space-y-1"
>
<div class="text-exo-yellow uppercase">A</div>
<div class="truncate text-white">{trajectoryA.session_id}</div>
<div class="text-exo-light-gray">
model: {trajectoryA.agent.model}
</div>
<div class="text-exo-light-gray">
steps: {trajectoryA.final_metrics.total_steps}
&bull; prompt_tokens: {trajectoryA.final_metrics
.total_prompt_tokens}
&bull; completion_tokens: {trajectoryA.final_metrics
.total_completion_tokens}
</div>
</div>
<div
class="rounded border border-exo-yellow/40 bg-exo-black/30 p-4 text-xs font-mono space-y-1"
>
<div class="text-exo-yellow uppercase">B</div>
<div class="truncate text-white">{trajectoryB.session_id}</div>
<div class="text-exo-light-gray">
model: {trajectoryB.agent.model}
</div>
<div class="text-exo-light-gray">
steps: {trajectoryB.final_metrics.total_steps}
&bull; prompt_tokens: {trajectoryB.final_metrics
.total_prompt_tokens}
&bull; completion_tokens: {trajectoryB.final_metrics
.total_completion_tokens}
</div>
</div>
</div>
{@const promptDelta = delta(
trajectoryA.final_metrics.total_prompt_tokens,
trajectoryB.final_metrics.total_prompt_tokens,
)}
{@const completionDelta = delta(
trajectoryA.final_metrics.total_completion_tokens,
trajectoryB.final_metrics.total_completion_tokens,
)}
{@const stepsDelta = delta(
trajectoryA.final_metrics.total_steps,
trajectoryB.final_metrics.total_steps,
)}
<div
class="rounded border border-exo-medium-gray/30 bg-exo-black/20 p-4 text-xs font-mono flex flex-wrap gap-4"
>
<span
>steps Δ <span class={stepsDelta.cls}>{stepsDelta.value}</span></span
>
<span
>prompt_tokens Δ <span class={promptDelta.cls}
>{promptDelta.value}</span
></span
>
<span
>completion_tokens Δ <span class={completionDelta.cls}
>{completionDelta.value}</span
></span
>
</div>
<div class="space-y-3">
{#each rows as row, i}
<div class="grid grid-cols-2 gap-6">
{#each [row.a, row.b] as step, sideIdx}
<div
class="rounded border border-exo-medium-gray/30 bg-exo-black/20 p-3 text-xs font-mono space-y-1"
>
<div class="flex items-center gap-2 uppercase">
<span class="text-exo-yellow"
>{sideIdx === 0 ? "A" : "B"}</span
>
<span class="text-exo-light-gray">#{i + 1}</span>
{#if step}
<span class="text-exo-light-gray">{step.source}</span>
{:else}
<span class="text-exo-light-gray/50"></span>
{/if}
</div>
{#if step}
<pre
class="whitespace-pre-wrap text-white/90 text-sm">{step.message}</pre>
{#if step.metrics}
<div class="flex flex-wrap gap-1 text-[10px]">
<span
class="px-1 py-0.5 rounded bg-exo-medium-gray/20 text-exo-light-gray"
>p {step.metrics.prompt_tokens}</span
>
<span
class="px-1 py-0.5 rounded bg-exo-medium-gray/20 text-exo-light-gray"
>c {step.metrics.completion_tokens}</span
>
{#if step.metrics._exo_extensions?.generation_tps !== undefined}
<span
class="px-1 py-0.5 rounded bg-exo-medium-gray/20 text-exo-light-gray"
>tps {step.metrics._exo_extensions.generation_tps.toFixed(
1,
)}</span
>
{/if}
</div>
{/if}
{/if}
</div>
{/each}
</div>
{/each}
</div>
{/if}
</div>
</div>
+303 -31
View File
@@ -46,6 +46,15 @@ from exo.api.adapters.responses import (
responses_request_to_text_generation,
)
from exo.api.keepalive import with_sse_keepalive
from exo.api.trajectories import (
DeleteTrajectoriesRequest,
DeleteTrajectoriesResponse,
TrajectoryListItem,
TrajectoryListResponse,
get_collector,
summarize_trajectory_for_list,
tap_chunk_stream,
)
from exo.api.types import (
AddCustomModelParams,
AdvancedImageParams,
@@ -87,6 +96,7 @@ from exo.api.types import (
StartDownloadParams,
StartDownloadResponse,
ToolCall,
ToolCallItem,
TraceCategoryStats,
TraceEventResponse,
TraceListItem,
@@ -94,6 +104,7 @@ from exo.api.types import (
TraceRankStats,
TraceResponse,
TraceStatsResponse,
Usage,
normalize_image_size,
)
from exo.api.types.claude_api import (
@@ -127,6 +138,7 @@ from exo.shared.constants import (
EXO_IMAGE_CACHE_DIR,
EXO_MAX_CHUNK_SIZE,
EXO_TRACING_CACHE_DIR,
EXO_TRAJECTORIES_DIR,
)
from exo.shared.election import ElectionMessage
from exo.shared.logging import InterceptLogger
@@ -212,6 +224,136 @@ def _ensure_seed(params: AdvancedImageParams | None) -> AdvancedImageParams:
return params
def _claude_messages_to_chat(
payload: ClaudeMessagesRequest,
) -> list[ChatCompletionMessage]:
msgs: list[ChatCompletionMessage] = []
if payload.system is not None:
if isinstance(payload.system, str):
sys_text = payload.system
else:
sys_text = "\n".join(b.text for b in payload.system)
if sys_text:
msgs.append(ChatCompletionMessage(role="system", content=sys_text))
for m in payload.messages:
if isinstance(m.content, str):
text = m.content
else:
parts: list[str] = []
for block in m.content:
block_type = getattr(block, "type", "")
if block_type == "text":
parts.append(getattr(block, "text", ""))
elif block_type == "tool_result":
tr_content = getattr(block, "content", None)
if isinstance(tr_content, str):
parts.append(tr_content)
elif isinstance(tr_content, list):
for sub in cast(list[object], tr_content):
if hasattr(sub, "text"):
parts.append(cast(str, getattr(sub, "text", "")))
text = "\n".join(parts)
role = "user" if m.role == "user" else "assistant"
msgs.append(ChatCompletionMessage(role=role, content=text))
return msgs
def _responses_to_chat(payload: ResponsesRequest) -> list[ChatCompletionMessage]:
msgs: list[ChatCompletionMessage] = []
if payload.instructions:
msgs.append(ChatCompletionMessage(role="system", content=payload.instructions))
if isinstance(payload.input, str):
msgs.append(ChatCompletionMessage(role="user", content=payload.input))
return msgs
for item in payload.input:
item_type = getattr(item, "type", "")
if item_type == "message":
role = cast(str, getattr(item, "role", "user"))
content = getattr(item, "content", "")
if isinstance(content, str):
text = content
elif isinstance(content, list):
parts: list[str] = []
for part in cast(list[object], content):
if hasattr(part, "text"):
parts.append(cast(str, getattr(part, "text", "")))
text = "\n".join(parts)
else:
text = ""
chat_role: Literal[
"system", "user", "assistant", "developer", "tool", "function"
]
if role == "assistant":
chat_role = "assistant"
elif role == "system":
chat_role = "system"
elif role == "developer":
chat_role = "developer"
else:
chat_role = "user"
msgs.append(ChatCompletionMessage(role=chat_role, content=text))
elif item_type == "function_call_output":
output = cast(str, getattr(item, "output", ""))
call_id = cast(str, getattr(item, "call_id", ""))
msgs.append(
ChatCompletionMessage(role="tool", content=output, tool_call_id=call_id)
)
return msgs
def _ollama_chat_to_chat(
payload: OllamaChatRequest,
) -> list[ChatCompletionMessage]:
msgs: list[ChatCompletionMessage] = []
for m in payload.messages:
role_out: Literal[
"system", "user", "assistant", "developer", "tool", "function"
]
if m.role == "system":
role_out = "system"
elif m.role == "assistant":
role_out = "assistant"
elif m.role == "tool":
role_out = "tool"
else:
role_out = "user"
msgs.append(
ChatCompletionMessage(
role=role_out,
content=m.content or "",
reasoning_content=m.thinking,
)
)
return msgs
def _ollama_generate_to_chat(
payload: OllamaGenerateRequest,
) -> list[ChatCompletionMessage]:
msgs: list[ChatCompletionMessage] = []
if payload.system:
msgs.append(ChatCompletionMessage(role="system", content=payload.system))
msgs.append(ChatCompletionMessage(role="user", content=payload.prompt))
return msgs
def _client_key_from_request(request: Request) -> str:
auth = request.headers.get("authorization", "")
if auth.lower().startswith("bearer "):
token = auth[7:].strip()
if token:
return "auth:" + hashlib.sha256(token.encode()).hexdigest()[:12]
session_header = request.headers.get("x-exo-session-id")
if session_header:
return "session:" + session_header
forwarded = request.headers.get("x-forwarded-for", "")
if forwarded:
return "ip:" + forwarded.split(",", 1)[0].strip()
if request.client is not None:
return "ip:" + request.client.host
return "ip:local"
class API:
def __init__(
self,
@@ -369,6 +511,10 @@ class API:
self.app.get("/v1/traces/{task_id}")(self.get_trace)
self.app.get("/v1/traces/{task_id}/stats")(self.get_trace_stats)
self.app.get("/v1/traces/{task_id}/raw")(self.get_trace_raw)
self.app.get("/v1/trajectories")(self.list_trajectories)
self.app.post("/v1/trajectories/delete")(self.delete_trajectories)
self.app.get("/v1/trajectories/{session_id}")(self.get_trajectory)
self.app.get("/v1/trajectories/{session_id}/raw")(self.get_trajectory_raw)
self.app.get("/onboarding")(self.get_onboarding)
self.app.post("/onboarding")(self.complete_onboarding)
@@ -801,8 +947,56 @@ class API:
await self._send(command)
return command
def _tap_for_trajectory(
self,
request: Request,
messages: list[ChatCompletionMessage],
resolved_model: ModelId,
chunk_stream: AsyncGenerator[
PrefillProgressChunk | ErrorChunk | ToolCallChunk | TokenChunk, None
],
) -> AsyncGenerator[
PrefillProgressChunk | ErrorChunk | ToolCallChunk | TokenChunk, None
]:
collector = get_collector()
client_key = _client_key_from_request(request)
session_id = collector.record_request(
messages=messages,
client_key=client_key,
model=str(resolved_model),
cluster_info={
"node_ids": sorted(self.state.node_identities.keys()),
"instance_count": len(self.state.instances),
},
)
def _on_complete(
text: str,
reasoning: str | None,
tool_calls: list[ToolCallItem],
stats: GenerationStats | None,
usage: Usage | None,
ttft_ms: float | None,
) -> None:
if session_id is None:
return
collector.record_response(
session_id=session_id,
client_key=client_key,
request_messages=messages,
assistant_text=text,
reasoning_content=reasoning,
tool_calls=tool_calls,
stats=stats,
model=str(resolved_model),
usage=usage,
ttft_ms=ttft_ms,
)
return tap_chunk_stream(chunk_stream, _on_complete)
async def chat_completions(
self, payload: ChatCompletionRequest
self, request: Request, payload: ChatCompletionRequest
) -> ChatCompletionResponse | StreamingResponse:
"""OpenAI Chat Completions API - adapter."""
task_params = await chat_request_to_text_generation(payload)
@@ -813,13 +1007,17 @@ class API:
command = await self._send_text_generation_with_images(task_params)
tapped = self._tap_for_trajectory(
request,
payload.messages,
resolved_model,
self._token_chunk_stream(command.command_id),
)
if payload.stream:
return StreamingResponse(
with_sse_keepalive(
generate_chat_stream(
command.command_id,
self._token_chunk_stream(command.command_id),
),
generate_chat_stream(command.command_id, tapped),
),
media_type="text/event-stream",
headers={
@@ -830,10 +1028,7 @@ class API:
)
else:
return StreamingResponse(
collect_chat_response(
command.command_id,
self._token_chunk_stream(command.command_id),
),
collect_chat_response(command.command_id, tapped),
media_type="application/json",
)
@@ -1407,7 +1602,7 @@ class API:
)
async def claude_messages(
self, payload: ClaudeMessagesRequest
self, request: Request, payload: ClaudeMessagesRequest
) -> ClaudeMessagesResponse | StreamingResponse:
"""Claude Messages API - adapter."""
task_params = await claude_request_to_text_generation(payload)
@@ -1418,13 +1613,20 @@ class API:
command = await self._send_text_generation_with_images(task_params)
tapped = self._tap_for_trajectory(
request,
_claude_messages_to_chat(payload),
resolved_model,
self._token_chunk_stream(command.command_id),
)
if payload.stream:
return StreamingResponse(
with_sse_keepalive(
generate_claude_stream(
command.command_id,
payload.model,
self._token_chunk_stream(command.command_id),
tapped,
),
),
media_type="text/event-stream",
@@ -1439,13 +1641,13 @@ class API:
collect_claude_response(
command.command_id,
payload.model,
self._token_chunk_stream(command.command_id),
tapped,
),
media_type="application/json",
)
async def openai_responses(
self, payload: ResponsesRequest
self, request: Request, payload: ResponsesRequest
) -> ResponsesResponse | StreamingResponse:
"""OpenAI Responses API."""
task_params = await responses_request_to_text_generation(payload)
@@ -1454,13 +1656,20 @@ class API:
command = await self._send_text_generation_with_images(task_params)
tapped = self._tap_for_trajectory(
request,
_responses_to_chat(payload),
resolved_model,
self._token_chunk_stream(command.command_id),
)
if payload.stream:
return StreamingResponse(
with_sse_keepalive(
generate_responses_stream(
command.command_id,
payload.model,
self._token_chunk_stream(command.command_id),
tapped,
),
),
media_type="text/event-stream",
@@ -1476,7 +1685,7 @@ class API:
collect_responses_response(
command.command_id,
payload.model,
self._token_chunk_stream(command.command_id),
tapped,
),
media_type="application/json",
)
@@ -1499,12 +1708,16 @@ class API:
command = await self._send_text_generation_with_images(task_params)
tapped = self._tap_for_trajectory(
request,
_ollama_chat_to_chat(payload),
resolved_model,
self._token_chunk_stream(command.command_id),
)
if payload.stream:
return StreamingResponse(
generate_ollama_chat_stream(
command.command_id,
self._token_chunk_stream(command.command_id),
),
generate_ollama_chat_stream(command.command_id, tapped),
media_type="application/x-ndjson",
headers={
"Cache-Control": "no-cache",
@@ -1514,10 +1727,7 @@ class API:
)
else:
return StreamingResponse(
collect_ollama_chat_response(
command.command_id,
self._token_chunk_stream(command.command_id),
),
collect_ollama_chat_response(command.command_id, tapped),
media_type="application/json",
)
@@ -1535,12 +1745,16 @@ class API:
command = await self._send_text_generation_with_images(task_params)
tapped = self._tap_for_trajectory(
request,
_ollama_generate_to_chat(payload),
resolved_model,
self._token_chunk_stream(command.command_id),
)
if payload.stream:
return StreamingResponse(
generate_ollama_generate_stream(
command.command_id,
self._token_chunk_stream(command.command_id),
),
generate_ollama_generate_stream(command.command_id, tapped),
media_type="application/x-ndjson",
headers={
"Cache-Control": "no-cache",
@@ -1550,10 +1764,7 @@ class API:
)
else:
return StreamingResponse(
collect_ollama_generate_response(
command.command_id,
self._token_chunk_stream(command.command_id),
),
collect_ollama_generate_response(command.command_id, tapped),
media_type="application/json",
)
@@ -2035,6 +2246,67 @@ class API:
not_found.append(task_id)
return DeleteTracesResponse(deleted=deleted, not_found=not_found)
@staticmethod
def _get_trajectory_path(session_id: str) -> Path:
path = EXO_TRAJECTORIES_DIR / f"{session_id}.json"
if not path.resolve().is_relative_to(EXO_TRAJECTORIES_DIR.resolve()):
raise HTTPException(
status_code=400, detail=f"Invalid session ID: {session_id}"
)
return path
async def list_trajectories(self) -> TrajectoryListResponse:
items: list[TrajectoryListItem] = []
if not EXO_TRAJECTORIES_DIR.exists():
return TrajectoryListResponse(trajectories=items)
for file in sorted(
EXO_TRAJECTORIES_DIR.glob("*.json"),
key=lambda p: p.stat().st_mtime,
reverse=True,
):
if file.name.endswith(".tmp"):
continue
try:
data = cast(dict[str, object], json.loads(file.read_text()))
except (OSError, json.JSONDecodeError):
continue
items.append(summarize_trajectory_for_list(file, data))
return TrajectoryListResponse(trajectories=items)
async def get_trajectory(self, session_id: str) -> JSONResponse:
path = self._get_trajectory_path(session_id)
if not path.exists():
raise HTTPException(
status_code=404, detail=f"Trajectory not found: {session_id}"
)
return JSONResponse(json.loads(path.read_text()))
async def get_trajectory_raw(self, session_id: str) -> FileResponse:
path = self._get_trajectory_path(session_id)
if not path.exists():
raise HTTPException(
status_code=404, detail=f"Trajectory not found: {session_id}"
)
return FileResponse(
path=path,
media_type="application/json",
filename=f"{session_id}.json",
)
async def delete_trajectories(
self, request: DeleteTrajectoriesRequest
) -> DeleteTrajectoriesResponse:
deleted: list[str] = []
not_found: list[str] = []
for session_id in request.session_ids:
path = self._get_trajectory_path(session_id)
if path.exists():
path.unlink()
deleted.append(session_id)
else:
not_found.append(session_id)
return DeleteTrajectoriesResponse(deleted=deleted, not_found=not_found)
async def get_onboarding(self) -> JSONResponse:
return JSONResponse({"completed": ONBOARDING_COMPLETE_FILE.exists()})
+387
View File
@@ -0,0 +1,387 @@
"""Tests for ATIF trajectory collection."""
from __future__ import annotations
import json
from collections.abc import AsyncGenerator
from pathlib import Path
from typing import Any, Literal, cast
import pytest
from exo.api.trajectories import (
ATIF_SCHEMA_VERSION,
TrajectoryCollector,
tap_chunk_stream,
)
from exo.api.types import (
ChatCompletionMessage,
CompletionTokensDetails,
GenerationStats,
PromptTokensDetails,
ToolCall,
ToolCallItem,
Usage,
)
from exo.shared.types.chunks import (
ErrorChunk,
PrefillProgressChunk,
TokenChunk,
ToolCallChunk,
)
from exo.shared.types.common import ModelId
from exo.shared.types.memory import Memory
def _stats(
prefix_cache_hit: Literal["none", "partial", "exact"] = "none",
) -> GenerationStats:
return GenerationStats(
prompt_tps=100.0,
generation_tps=50.0,
prompt_tokens=10,
generation_tokens=5,
peak_memory_usage=Memory.from_bytes(1024),
prefix_cache_hit=prefix_cache_hit,
)
def _usage(cached: int = 0) -> Usage:
return Usage(
prompt_tokens=10,
completion_tokens=5,
total_tokens=15,
prompt_tokens_details=PromptTokensDetails(cached_tokens=cached),
completion_tokens_details=CompletionTokensDetails(),
)
def _load(path: Path) -> dict[str, Any]:
return cast(dict[str, Any], json.loads(path.read_text()))
def _steps(traj: dict[str, Any]) -> list[dict[str, Any]]:
return cast(list[dict[str, Any]], traj["steps"])
def test_single_turn_produces_user_and_agent_step(tmp_path: Path) -> None:
collector = TrajectoryCollector(enabled=True, directory=tmp_path)
messages = [ChatCompletionMessage(role="user", content="hello")]
session_id = collector.record_request(
messages=messages, client_key="ip:1.1.1.1", model="m"
)
assert session_id is not None
collector.record_response(
session_id=session_id,
client_key="ip:1.1.1.1",
request_messages=messages,
assistant_text="hi",
reasoning_content=None,
tool_calls=[],
stats=_stats(),
model="m",
)
files = list(tmp_path.glob("*.json"))
assert len(files) == 1
traj = _load(files[0])
steps = _steps(traj)
assert traj["schema_version"] == ATIF_SCHEMA_VERSION
assert traj["session_id"] == session_id
assert len(steps) == 2
assert steps[0]["source"] == "user"
assert steps[0]["message"] == "hello"
assert steps[1]["source"] == "agent"
assert steps[1]["message"] == "hi"
assert steps[1]["model_name"] == "m"
final_metrics = cast(dict[str, Any], traj["final_metrics"])
assert final_metrics["total_steps"] == 2
assert final_metrics["total_prompt_tokens"] == 10
assert final_metrics["total_completion_tokens"] == 5
def test_multi_turn_stitches_into_one_trajectory(tmp_path: Path) -> None:
collector = TrajectoryCollector(enabled=True, directory=tmp_path)
client = "ip:2.2.2.2"
m1 = [ChatCompletionMessage(role="user", content="one")]
sid = collector.record_request(messages=m1, client_key=client, model="m")
assert sid is not None
collector.record_response(
session_id=sid,
client_key=client,
request_messages=m1,
assistant_text="A1",
reasoning_content=None,
tool_calls=[],
stats=_stats(),
model="m",
)
m2 = [
ChatCompletionMessage(role="user", content="one"),
ChatCompletionMessage(role="assistant", content="A1"),
ChatCompletionMessage(role="user", content="two"),
]
sid2 = collector.record_request(messages=m2, client_key=client, model="m")
assert sid2 == sid, "continuation must reuse session id"
assert sid2 is not None
collector.record_response(
session_id=sid2,
client_key=client,
request_messages=m2,
assistant_text="A2",
reasoning_content=None,
tool_calls=[],
stats=_stats(),
model="m",
)
files = list(tmp_path.glob("*.json"))
assert len(files) == 1
traj = _load(files[0])
steps = _steps(traj)
sources = [s["source"] for s in steps]
messages_out = [s["message"] for s in steps]
assert sources == ["user", "agent", "user", "agent"]
assert messages_out == ["one", "A1", "two", "A2"]
step_ids = [s["step_id"] for s in steps]
assert step_ids == [1, 2, 3, 4]
def test_tool_call_and_observation_folding(tmp_path: Path) -> None:
collector = TrajectoryCollector(enabled=True, directory=tmp_path)
client = "ip:3.3.3.3"
m1 = [ChatCompletionMessage(role="user", content="compute")]
sid = collector.record_request(messages=m1, client_key=client, model="m")
assert sid is not None
tool_call = ToolCallItem(id="tc_1", name="add", arguments='{"a": 1, "b": 2}')
collector.record_response(
session_id=sid,
client_key=client,
request_messages=m1,
assistant_text="",
reasoning_content=None,
tool_calls=[tool_call],
stats=_stats(),
model="m",
)
assistant_with_tool = ChatCompletionMessage(
role="assistant",
content=None,
tool_calls=[
ToolCall(id="tc_1", function=tool_call),
],
)
m2 = [
ChatCompletionMessage(role="user", content="compute"),
assistant_with_tool,
ChatCompletionMessage(role="tool", tool_call_id="tc_1", content="3"),
]
sid2 = collector.record_request(messages=m2, client_key=client, model="m")
assert sid2 == sid
assert sid2 is not None
collector.record_response(
session_id=sid2,
client_key=client,
request_messages=m2,
assistant_text="result is 3",
reasoning_content=None,
tool_calls=[],
stats=_stats(),
model="m",
)
files = list(tmp_path.glob("*.json"))
assert len(files) == 1
traj = _load(files[0])
steps = _steps(traj)
agent_steps = [s for s in steps if s["source"] == "agent"]
first_agent = agent_steps[0]
tcs = cast(list[dict[str, Any]], first_agent["tool_calls"])
assert tcs is not None
assert tcs[0]["function_name"] == "add"
assert tcs[0]["arguments"] == {"a": 1, "b": 2}
obs = cast(dict[str, Any], first_agent["observation"])
assert obs is not None
results = cast(list[dict[str, Any]], obs["results"])
assert results[0] == {
"source_call_id": "tc_1",
"content": "3",
}
def test_different_clients_get_separate_trajectories(tmp_path: Path) -> None:
collector = TrajectoryCollector(enabled=True, directory=tmp_path)
messages = [ChatCompletionMessage(role="user", content="identical")]
sid_a = collector.record_request(messages=messages, client_key="ip:A", model="m")
sid_b = collector.record_request(messages=messages, client_key="ip:B", model="m")
assert sid_a is not None and sid_b is not None
assert sid_a != sid_b
collector.record_response(
session_id=sid_a,
client_key="ip:A",
request_messages=messages,
assistant_text="a",
reasoning_content=None,
tool_calls=[],
stats=None,
model="m",
)
collector.record_response(
session_id=sid_b,
client_key="ip:B",
request_messages=messages,
assistant_text="b",
reasoning_content=None,
tool_calls=[],
stats=None,
model="m",
)
assert len(list(tmp_path.glob("*.json"))) == 2
def test_lru_eviction_drops_oldest(tmp_path: Path) -> None:
collector = TrajectoryCollector(enabled=True, directory=tmp_path, capacity=2)
for i in range(3):
msgs = [ChatCompletionMessage(role="user", content=f"msg-{i}")]
sid = collector.record_request(messages=msgs, client_key=f"ip:{i}", model="m")
assert sid is not None
collector.record_response(
session_id=sid,
client_key=f"ip:{i}",
request_messages=msgs,
assistant_text=f"r-{i}",
reasoning_content=None,
tool_calls=[],
stats=None,
model="m",
)
assert len(collector._sessions) <= 2 # pyright: ignore[reportPrivateUsage]
def test_disabled_writes_nothing(tmp_path: Path) -> None:
collector = TrajectoryCollector(enabled=False, directory=tmp_path)
messages = [ChatCompletionMessage(role="user", content="hi")]
sid = collector.record_request(messages=messages, client_key="ip:x", model="m")
assert sid is None
collector.record_response(
session_id="nonexistent",
client_key="ip:x",
request_messages=messages,
assistant_text="x",
reasoning_content=None,
tool_calls=[],
stats=None,
model="m",
)
assert list(tmp_path.glob("*.json")) == []
async def _chunks_to_stream(
chunks: list[PrefillProgressChunk | ErrorChunk | ToolCallChunk | TokenChunk],
) -> AsyncGenerator[
PrefillProgressChunk | ErrorChunk | ToolCallChunk | TokenChunk, None
]:
for c in chunks:
yield c
@pytest.mark.asyncio
async def test_tap_chunk_stream_accumulates_text_stats_and_usage() -> None:
model = ModelId("m")
final_usage = _usage(cached=4)
chunks: list[PrefillProgressChunk | ErrorChunk | ToolCallChunk | TokenChunk] = [
TokenChunk(model=model, text="hel", token_id=1, usage=None),
TokenChunk(model=model, text="lo", token_id=2, usage=None),
TokenChunk(
model=model,
text="",
token_id=3,
usage=final_usage,
finish_reason="stop",
stats=_stats(),
),
]
captured: dict[str, Any] = {}
def on_complete(
text: str,
reasoning: str | None,
tool_calls: list[ToolCallItem],
stats: GenerationStats | None,
usage: Usage | None,
ttft_ms: float | None,
) -> None:
captured["text"] = text
captured["reasoning"] = reasoning
captured["tool_calls"] = tool_calls
captured["stats"] = stats
captured["usage"] = usage
captured["ttft_ms"] = ttft_ms
out: list[Any] = []
async for chunk in tap_chunk_stream(_chunks_to_stream(chunks), on_complete):
out.append(chunk)
assert len(out) == 3
assert captured["text"] == "hello"
assert cast(list[Any], captured["tool_calls"]) == []
assert captured["stats"] is not None
captured_usage = cast(Usage, captured["usage"])
assert captured_usage.prompt_tokens_details.cached_tokens == 4
assert captured["ttft_ms"] is not None
assert cast(float, captured["ttft_ms"]) >= 0.0
@pytest.mark.asyncio
async def test_tap_chunk_stream_captures_tool_calls() -> None:
model = ModelId("m")
tool = ToolCallItem(id="t1", name="foo", arguments="{}")
chunks: list[PrefillProgressChunk | ErrorChunk | ToolCallChunk | TokenChunk] = [
ToolCallChunk(model=model, tool_calls=[tool], usage=None, stats=_stats()),
]
captured: dict[str, Any] = {}
def on_complete(
text: str,
reasoning: str | None,
tool_calls: list[ToolCallItem],
stats: GenerationStats | None,
usage: Usage | None,
ttft_ms: float | None,
) -> None:
captured["tool_calls"] = tool_calls
async for _ in tap_chunk_stream(_chunks_to_stream(chunks), on_complete):
pass
captured_tools = cast(list[ToolCallItem], captured["tool_calls"])
assert len(captured_tools) == 1
assert captured_tools[0].id == "t1"
def test_cached_tokens_and_ttft_recorded(tmp_path: Path) -> None:
collector = TrajectoryCollector(enabled=True, directory=tmp_path)
messages = [ChatCompletionMessage(role="user", content="hello")]
sid = collector.record_request(messages=messages, client_key="ip:1", model="m")
assert sid is not None
collector.record_response(
session_id=sid,
client_key="ip:1",
request_messages=messages,
assistant_text="hi",
reasoning_content=None,
tool_calls=[],
stats=_stats("partial"),
model="m",
usage=_usage(cached=7),
ttft_ms=42.5,
)
files = list(tmp_path.glob("*.json"))
assert len(files) == 1
traj = _load(files[0])
steps = _steps(traj)
agent_step = steps[1]
metrics = cast(dict[str, Any], agent_step["metrics"])
assert metrics["cached_tokens"] == 7
ext = cast(dict[str, Any], metrics["_exo_extensions"])
assert ext["prefix_cache_hit"] == "partial"
assert ext["ttft_ms"] == 42.5
+800
View File
@@ -0,0 +1,800 @@
"""ATIF-v1.4 trajectory collection for live chat completions.
Emits one JSON file per trajectory in Harbor Framework's Agent Trajectory
Interchange Format. Multi-turn chat histories are stitched into a single
trajectory by hashing the expected-next-prefix of messages after each response.
exo is self-hosted, so `metrics.cost` is always 0.0; exo-specific observability
lives under `metrics._exo_extensions` so strict ATIF consumers still parse.
"""
from __future__ import annotations
import hashlib
import json
import os
import threading
import time
from collections import OrderedDict
from collections.abc import AsyncGenerator, Callable
from dataclasses import dataclass, field
from datetime import datetime, timezone
from pathlib import Path
from typing import Any, Literal, cast, final
from uuid import uuid4
from loguru import logger
from pydantic import BaseModel, Field
from exo.api.types import (
ChatCompletionMessage,
ChatCompletionMessageImageUrl,
ChatCompletionMessageText,
GenerationStats,
ToolCall,
ToolCallItem,
Usage,
)
from exo.shared.constants import EXO_TRAJECTORIES_DIR
from exo.shared.types.chunks import (
ErrorChunk,
PrefillProgressChunk,
TokenChunk,
ToolCallChunk,
)
from exo.utils.pydantic_ext import CamelCaseModel
ATIF_SCHEMA_VERSION = "ATIF-v1.4"
StepSource = Literal["user", "agent", "system"]
class AtifToolCall(BaseModel):
tool_call_id: str
function_name: str
arguments: dict[str, Any]
class AtifObservationResult(BaseModel):
source_call_id: str
content: str
class AtifObservation(BaseModel):
results: list[AtifObservationResult]
class AtifExoExtensions(BaseModel):
prompt_tps: float | None = None
generation_tps: float | None = None
peak_memory_bytes: int | None = None
prefix_cache_hit: Literal["none", "partial", "exact"] | None = None
reasoning_content: str | None = None
ttft_ms: float | None = None
class AtifStepMetrics(BaseModel):
prompt_tokens: int = 0
completion_tokens: int = 0
cached_tokens: int = 0
cost: float = 0.0
exo_extensions: AtifExoExtensions | None = Field(
default=None, serialization_alias="_exo_extensions"
)
model_config = {"populate_by_name": True}
class AtifStep(BaseModel):
step_id: int
timestamp: str
source: StepSource
message: str = ""
reasoning_content: str | None = None
tool_calls: list[AtifToolCall] | None = None
observation: AtifObservation | None = None
metrics: AtifStepMetrics | None = None
model_name: str | None = None
class AtifAgent(BaseModel):
name: str = "exo"
model: str
provider: str = "exo"
exo_extensions: dict[str, Any] | None = Field(
default=None, serialization_alias="_exo_extensions"
)
model_config = {"populate_by_name": True}
class AtifFinalMetrics(BaseModel):
total_steps: int = 0
total_prompt_tokens: int = 0
total_completion_tokens: int = 0
total_cost: float = 0.0
class AtifTrajectory(BaseModel):
schema_version: str = ATIF_SCHEMA_VERSION
session_id: str
agent: AtifAgent
steps: list[AtifStep] = Field(default_factory=list)
final_metrics: AtifFinalMetrics = Field(default_factory=AtifFinalMetrics)
@final
@dataclass
class SessionState:
session_id: str
file_path: Path
trajectory: AtifTrajectory
last_prefix_len: int = 0
last_activity: float = field(default_factory=time.monotonic)
hash_keys: set[tuple[str, str]] = field(default_factory=set)
def _extract_text(
content: str | ChatCompletionMessageText | list[Any] | Any | None,
) -> str:
if content is None:
return ""
if isinstance(content, str):
return content
if isinstance(content, ChatCompletionMessageText):
return content.text
if isinstance(content, ChatCompletionMessageImageUrl):
return ""
if isinstance(content, list):
parts: list[str] = []
for p in content: # pyright: ignore[reportUnknownVariableType]
if isinstance(p, ChatCompletionMessageText):
parts.append(p.text)
return "\n".join(parts)
return ""
def _canonicalize(m: ChatCompletionMessage) -> dict[str, Any]:
"""Strip everything that can differ between client echo and server record.
Keeps only role, normalized content (empty string ≡ None), tool_call_id,
and semantic tool_calls (id + function name + parsed arguments — no index,
no type since it's always "function"). Drops reasoning_content, name,
function_call, logprobs-adjacent fields. This is what two honest clients
would agree on about a message.
"""
out: dict[str, Any] = {"role": m.role}
text = _extract_text(m.content)
if text:
out["content"] = text
if m.tool_call_id:
out["tool_call_id"] = m.tool_call_id
if m.tool_calls:
normalized_tcs: list[dict[str, Any]] = []
for tc in m.tool_calls:
try:
args = cast(dict[str, Any], json.loads(tc.function.arguments))
args_canon = json.dumps(args, sort_keys=True, separators=(",", ":"))
except (json.JSONDecodeError, TypeError):
args_canon = tc.function.arguments
normalized_tcs.append(
{
"id": tc.id,
"name": tc.function.name,
"arguments": args_canon,
}
)
out["tool_calls"] = normalized_tcs
return out
def _hash_messages(messages: list[ChatCompletionMessage]) -> str:
h = hashlib.sha256()
for m in messages:
h.update(
json.dumps(_canonicalize(m), sort_keys=True, separators=(",", ":")).encode()
)
h.update(b"\x1f")
return h.hexdigest()
def _iso_now() -> str:
return datetime.now(tz=timezone.utc).isoformat()
def _canonical_agent_step_key(
step: AtifStep,
) -> tuple[str, tuple[tuple[str, str], ...]]:
tc_key: tuple[tuple[str, str], ...] = ()
if step.tool_calls:
tc_key = tuple(
sorted((tc.tool_call_id, tc.function_name) for tc in step.tool_calls)
)
return (step.message or "", tc_key)
def _matches_last_agent_step(
trajectory: AtifTrajectory, msg: ChatCompletionMessage
) -> bool:
"""Return True if `msg` is an echo of the most recent agent step we stored.
Handles opencode-style replay where the client repeats our prior assistant
response (with minor formatting differences we've already canonicalized).
"""
last_agent = next(
(s for s in reversed(trajectory.steps) if s.source == "agent"), None
)
if last_agent is None:
return False
text = _extract_text(msg.content)
client_tc_key: tuple[tuple[str, str], ...] = ()
if msg.tool_calls:
client_tc_key = tuple(
sorted((tc.id, tc.function.name) for tc in msg.tool_calls)
)
return _canonical_agent_step_key(last_agent) == (text, client_tc_key)
def _message_to_step(
msg: ChatCompletionMessage, step_id: int
) -> tuple[AtifStep | None, tuple[str, str] | None]:
"""Convert an input message to a step (or a tool-result to be folded)."""
text = _extract_text(msg.content)
if msg.role == "tool":
call_id = msg.tool_call_id or ""
return None, (call_id, text)
source: StepSource
if msg.role == "system" or msg.role == "developer":
source = "system"
elif msg.role == "assistant":
source = "agent"
else:
source = "user"
tool_calls: list[AtifToolCall] | None = None
if msg.tool_calls:
tool_calls = []
for tc in msg.tool_calls:
try:
args = cast(dict[str, Any], json.loads(tc.function.arguments))
except (json.JSONDecodeError, TypeError):
args = {"raw": tc.function.arguments}
tool_calls.append(
AtifToolCall(
tool_call_id=tc.id,
function_name=tc.function.name,
arguments=args,
)
)
return (
AtifStep(
step_id=step_id,
timestamp=_iso_now(),
source=source,
message=text,
reasoning_content=msg.reasoning_content,
tool_calls=tool_calls,
),
None,
)
@final
class TrajectoryCollector:
"""Stitches multi-turn chat histories into ATIF trajectory files.
Keyed by (client_key, prefix_hash); matches the longest prefix of the
incoming request to the hash stored when the previous response was written.
Missing match mints a new session_id.
"""
def __init__(
self,
enabled: bool,
directory: Path,
capacity: int = 1024,
idle_seconds: int = 1800,
) -> None:
self.enabled = enabled
self.directory = directory
self.capacity = capacity
self.idle_seconds = idle_seconds
self._sessions: OrderedDict[str, SessionState] = OrderedDict()
self._by_hash: dict[tuple[str, str], SessionState] = {}
self._lock = threading.Lock()
if enabled:
try:
self.directory.mkdir(parents=True, exist_ok=True)
except OSError as e:
logger.warning("could not create trajectories dir {}: {}", directory, e)
def _drop_session(self, session_id: str) -> None:
state = self._sessions.pop(session_id, None)
if state is None:
return
for key in state.hash_keys:
self._by_hash.pop(key, None)
def _evict(self) -> None:
now = time.monotonic()
stale = [
sid
for sid, s in self._sessions.items()
if now - s.last_activity > self.idle_seconds
]
for sid in stale:
self._drop_session(sid)
while len(self._sessions) > self.capacity:
oldest_sid, _ = next(iter(self._sessions.items()))
self._drop_session(oldest_sid)
def _find_continuation(
self, client_key: str, messages: list[ChatCompletionMessage]
) -> SessionState | None:
for k in range(len(messages), 0, -1):
prefix_hash = _hash_messages(messages[:k])
state = self._by_hash.get((client_key, prefix_hash))
if state is not None:
state.last_prefix_len = k
state.last_activity = time.monotonic()
self._sessions.move_to_end(state.session_id)
return state
return None
def record_request(
self,
messages: list[ChatCompletionMessage],
client_key: str,
model: str,
cluster_info: dict[str, Any] | None = None,
) -> str | None:
if not self.enabled:
return None
with self._lock:
self._evict()
state = self._find_continuation(client_key, messages)
if state is None:
session_id = str(uuid4())
state = SessionState(
session_id=session_id,
file_path=self.directory / f"{session_id}.json",
trajectory=AtifTrajectory(
session_id=session_id,
agent=AtifAgent(
model=model,
exo_extensions={"cluster": cluster_info}
if cluster_info
else None,
),
),
last_prefix_len=0,
)
self._sessions[session_id] = state
self._append_new_input_messages(state, messages, client_key)
self._evict()
return state.session_id
def _append_new_input_messages(
self,
state: SessionState,
messages: list[ChatCompletionMessage],
client_key: str,
) -> None:
new_messages = messages[state.last_prefix_len :]
if len(new_messages) > 3 and state.last_prefix_len > 0:
logger.warning(
"trajectory {}: prefix match fell back — {} new messages "
"(request len={}, last known prefix len={}). Likely system "
"prompt or message format changed between calls.",
state.session_id,
len(new_messages),
len(messages),
state.last_prefix_len,
)
trajectory = state.trajectory
for msg in new_messages:
if msg.role == "assistant" and _matches_last_agent_step(trajectory, msg):
continue
next_id = len(trajectory.steps) + 1
step, tool_result = _message_to_step(msg, next_id)
if step is not None:
trajectory.steps.append(step)
elif tool_result is not None:
call_id, content = tool_result
prev_agent = next(
(s for s in reversed(trajectory.steps) if s.source == "agent"),
None,
)
if prev_agent is not None:
if prev_agent.observation is None:
prev_agent.observation = AtifObservation(results=[])
prev_agent.observation.results.append(
AtifObservationResult(source_call_id=call_id, content=content)
)
state.last_prefix_len = len(messages)
self._reindex_state(state, client_key, messages)
def _reindex_state(
self,
state: SessionState,
client_key: str,
messages: list[ChatCompletionMessage],
) -> None:
prefix_hash = _hash_messages(messages)
key = (client_key, prefix_hash)
self._by_hash[key] = state
state.hash_keys.add(key)
self._sessions.move_to_end(state.session_id)
def record_response(
self,
session_id: str,
client_key: str,
request_messages: list[ChatCompletionMessage],
assistant_text: str,
reasoning_content: str | None,
tool_calls: list[ToolCallItem],
stats: GenerationStats | None,
model: str,
usage: Usage | None = None,
ttft_ms: float | None = None,
) -> None:
if not self.enabled:
return
is_empty = (
not assistant_text
and not tool_calls
and not reasoning_content
and stats is None
and usage is None
and ttft_ms is None
)
if is_empty:
return
with self._lock:
state = self._sessions.get(session_id)
if state is None:
return
trajectory = state.trajectory
atif_tool_calls: list[AtifToolCall] | None = None
if tool_calls:
atif_tool_calls = []
for tc in tool_calls:
try:
args = cast(dict[str, Any], json.loads(tc.arguments))
except (json.JSONDecodeError, TypeError):
args = {"raw": tc.arguments}
atif_tool_calls.append(
AtifToolCall(
tool_call_id=tc.id,
function_name=tc.name,
arguments=args,
)
)
metrics: AtifStepMetrics | None = None
cached_tokens = (
usage.prompt_tokens_details.cached_tokens if usage is not None else 0
)
if stats is not None:
metrics = AtifStepMetrics(
prompt_tokens=stats.prompt_tokens,
completion_tokens=stats.generation_tokens,
cached_tokens=cached_tokens,
cost=0.0,
exo_extensions=AtifExoExtensions(
prompt_tps=stats.prompt_tps,
generation_tps=stats.generation_tps,
peak_memory_bytes=stats.peak_memory_usage.in_bytes,
prefix_cache_hit=stats.prefix_cache_hit,
reasoning_content=reasoning_content,
ttft_ms=ttft_ms,
),
)
elif reasoning_content or ttft_ms is not None:
metrics = AtifStepMetrics(
cached_tokens=cached_tokens,
exo_extensions=AtifExoExtensions(
reasoning_content=reasoning_content,
ttft_ms=ttft_ms,
),
)
step = AtifStep(
step_id=len(trajectory.steps) + 1,
timestamp=_iso_now(),
source="agent",
message=assistant_text,
reasoning_content=reasoning_content,
tool_calls=atif_tool_calls,
metrics=metrics,
model_name=model,
)
trajectory.steps.append(step)
trajectory.final_metrics = _recompute_final_metrics(trajectory.steps)
assistant_msg_tool_calls: list[ToolCall] | None = None
if tool_calls:
assistant_msg_tool_calls = [
ToolCall(id=tc.id, function=tc) for tc in tool_calls
]
assistant_msg = ChatCompletionMessage(
role="assistant",
content=assistant_text or None,
reasoning_content=reasoning_content,
tool_calls=assistant_msg_tool_calls,
)
expected_prefix = request_messages + [assistant_msg]
self._reindex_state(state, client_key, expected_prefix)
state.last_prefix_len = len(expected_prefix)
state.last_activity = time.monotonic()
_atomic_write(state.file_path, trajectory)
def _recompute_final_metrics(steps: list[AtifStep]) -> AtifFinalMetrics:
total_prompt = sum(s.metrics.prompt_tokens for s in steps if s.metrics is not None)
total_completion = sum(
s.metrics.completion_tokens for s in steps if s.metrics is not None
)
total_cost = sum(s.metrics.cost for s in steps if s.metrics is not None)
return AtifFinalMetrics(
total_steps=len(steps),
total_prompt_tokens=total_prompt,
total_completion_tokens=total_completion,
total_cost=total_cost,
)
def _atomic_write(path: Path, trajectory: AtifTrajectory) -> None:
try:
path.parent.mkdir(parents=True, exist_ok=True)
tmp = path.with_suffix(path.suffix + f".tmp.{os.getpid()}.{uuid4().hex[:8]}")
tmp.write_text(
trajectory.model_dump_json(by_alias=True, exclude_none=True, indent=2)
)
tmp.replace(path)
except OSError as e:
logger.warning("failed to write trajectory {}: {}", path, e)
async def tap_chunk_stream(
chunk_stream: AsyncGenerator[
PrefillProgressChunk | ErrorChunk | ToolCallChunk | TokenChunk, None
],
on_complete: Callable[
[
str,
str | None,
list[ToolCallItem],
GenerationStats | None,
Usage | None,
float | None,
],
None,
],
) -> AsyncGenerator[
PrefillProgressChunk | ErrorChunk | ToolCallChunk | TokenChunk, None
]:
"""Passes chunks through verbatim while accumulating the assistant response.
Invokes `on_complete(text, reasoning, tool_calls, stats, usage, ttft_ms)`
once when the underlying stream terminates (normal or exceptional). TTFT is
measured from tap entry to the first produced token/tool-call chunk.
"""
text_parts: list[str] = []
thinking_parts: list[str] = []
tool_calls: list[ToolCallItem] = []
stats: GenerationStats | None = None
last_usage: Usage | None = None
tap_start = time.perf_counter()
ttft_ms: float | None = None
chunk_idx = 0
try:
async for chunk in chunk_stream:
chunk_idx += 1
match chunk:
case TokenChunk():
if ttft_ms is None and chunk.text:
ttft_ms = (time.perf_counter() - tap_start) * 1000.0
if chunk.is_thinking:
thinking_parts.append(chunk.text)
else:
text_parts.append(chunk.text)
if chunk.stats is not None:
stats = chunk.stats
if chunk.usage is not None:
last_usage = chunk.usage
logger.debug(
"[tap] C#{} TokenChunk finish={} stats={} usage={}",
chunk_idx,
chunk.finish_reason,
"Y" if chunk.stats else "N",
"Y" if chunk.usage else "N",
)
case ToolCallChunk():
if ttft_ms is None:
ttft_ms = (time.perf_counter() - tap_start) * 1000.0
tool_calls.extend(chunk.tool_calls)
if chunk.stats is not None:
stats = chunk.stats
if chunk.usage is not None:
last_usage = chunk.usage
logger.info(
"[tap] C#{} ToolCallChunk tools={} stats={} usage={}",
chunk_idx,
[tc.name for tc in chunk.tool_calls],
"Y" if chunk.stats else "N",
"Y" if chunk.usage else "N",
)
case _:
pass
yield chunk
finally:
logger.info(
"[tap] stream ended: {} chunks, text_len={}, tool_calls={}, "
"stats={}, usage={}, ttft={}",
chunk_idx,
sum(len(p) for p in text_parts),
len(tool_calls),
"Y" if stats else "N",
"Y" if last_usage else "N",
f"{ttft_ms:.0f}ms" if ttft_ms else "None",
)
try:
on_complete(
"".join(text_parts),
"".join(thinking_parts) if thinking_parts else None,
tool_calls,
stats,
last_usage,
ttft_ms,
)
except Exception as e: # noqa: BLE001
logger.warning("trajectory record_response failed: {}", e)
class TrajectoryListItem(CamelCaseModel):
session_id: str
created_at: str
updated_at: str
total_steps: int
model: str
total_prompt_tokens: int = 0
total_completion_tokens: int = 0
total_cached_tokens: int = 0
agent_step_count: int = 0
tool_call_count: int = 0
avg_ttft_ms: float | None = None
avg_prompt_tps: float | None = None
avg_generation_tps: float | None = None
cache_hit_none: int = 0
cache_hit_partial: int = 0
cache_hit_exact: int = 0
class TrajectoryListResponse(CamelCaseModel):
trajectories: list[TrajectoryListItem]
def summarize_trajectory_for_list(
path: Path, data: dict[str, object]
) -> TrajectoryListItem:
"""Compute aggregate metrics for a single trajectory JSON for list display."""
stat = path.stat()
steps_raw = data.get("steps", [])
steps: list[dict[str, Any]] = (
cast(list[dict[str, Any]], steps_raw) if isinstance(steps_raw, list) else []
)
agent = data.get("agent", {})
model_name = ""
if isinstance(agent, dict):
model_value = cast(dict[str, object], agent).get("model", "")
model_name = str(model_value) if model_value is not None else ""
final = data.get("final_metrics", {})
if isinstance(final, dict):
fm = cast(dict[str, Any], final)
total_prompt = int(fm.get("total_prompt_tokens", 0) or 0)
total_completion = int(fm.get("total_completion_tokens", 0) or 0)
else:
total_prompt = 0
total_completion = 0
total_cached = 0
agent_step_count = 0
tool_call_count = 0
ttft_values: list[float] = []
prompt_tps_values: list[float] = []
generation_tps_values: list[float] = []
cache_counts: dict[str, int] = {"none": 0, "partial": 0, "exact": 0}
for step in steps:
if step.get("source") != "agent":
continue
agent_step_count += 1
tool_calls: object = step.get("tool_calls") or []
if isinstance(tool_calls, list):
tool_call_count += len(cast(list[object], tool_calls))
metrics: object = step.get("metrics") or {}
if not isinstance(metrics, dict):
continue
metrics_dict = cast(dict[str, object], metrics)
total_cached += int(cast(int | str | None, metrics_dict.get("cached_tokens")) or 0)
ext: object = metrics_dict.get("_exo_extensions") or {}
if not isinstance(ext, dict):
continue
ext_dict = cast(dict[str, object], ext)
ttft = ext_dict.get("ttft_ms")
if isinstance(ttft, (int, float)):
ttft_values.append(float(ttft))
ptps = ext_dict.get("prompt_tps")
if isinstance(ptps, (int, float)):
prompt_tps_values.append(float(ptps))
gtps = ext_dict.get("generation_tps")
if isinstance(gtps, (int, float)):
generation_tps_values.append(float(gtps))
cache_hit = ext_dict.get("prefix_cache_hit")
if isinstance(cache_hit, str) and cache_hit in cache_counts:
cache_counts[cache_hit] += 1
def _avg(vs: list[float]) -> float | None:
return sum(vs) / len(vs) if vs else None
return TrajectoryListItem(
session_id=path.stem,
created_at=datetime.fromtimestamp(stat.st_ctime, tz=timezone.utc).isoformat(),
updated_at=datetime.fromtimestamp(stat.st_mtime, tz=timezone.utc).isoformat(),
total_steps=len(steps),
model=model_name,
total_prompt_tokens=total_prompt,
total_completion_tokens=total_completion,
total_cached_tokens=total_cached,
agent_step_count=agent_step_count,
tool_call_count=tool_call_count,
avg_ttft_ms=_avg(ttft_values),
avg_prompt_tps=_avg(prompt_tps_values),
avg_generation_tps=_avg(generation_tps_values),
cache_hit_none=cache_counts["none"],
cache_hit_partial=cache_counts["partial"],
cache_hit_exact=cache_counts["exact"],
)
class DeleteTrajectoriesRequest(CamelCaseModel):
session_ids: list[str]
class DeleteTrajectoriesResponse(CamelCaseModel):
deleted: list[str]
not_found: list[str]
_global_collector: TrajectoryCollector | None = None
_collector_lock = threading.Lock()
def _env_enabled() -> bool:
return os.environ.get("EXO_TRAJECTORIES", "false").lower() == "true"
def get_collector() -> TrajectoryCollector:
global _global_collector
with _collector_lock:
if _global_collector is None:
_global_collector = TrajectoryCollector(
enabled=_env_enabled(),
directory=EXO_TRAJECTORIES_DIR,
)
else:
_global_collector.enabled = _env_enabled()
if _global_collector.enabled:
try:
_global_collector.directory.mkdir(parents=True, exist_ok=True)
except OSError as e:
logger.warning(
"could not create trajectories dir {}: {}",
_global_collector.directory,
e,
)
return _global_collector
def set_collector(collector: TrajectoryCollector) -> None:
global _global_collector
with _collector_lock:
_global_collector = collector
+12
View File
@@ -287,6 +287,10 @@ def main():
os.environ["EXO_NO_BATCH"] = "1"
logger.info("Continuous batching disabled (--no-batch)")
if args.trajectories:
os.environ["EXO_TRAJECTORIES"] = "true"
logger.info("Trajectory recording enabled (--trajectories)")
# Set FAST_SYNCH override env var for runner subprocesses
if args.fast_synch is True:
os.environ["EXO_FAST_SYNCH"] = "true"
@@ -321,6 +325,7 @@ class Args(CamelCaseModel):
fast_synch: bool | None = None # None = auto, True = force on, False = force off
bootstrap_peers: list[str] = []
libp2p_port: int
trajectories: bool = os.getenv("EXO_TRAJECTORIES", "false").lower() == "true"
@classmethod
def parse(cls) -> Self:
@@ -394,6 +399,13 @@ class Args(CamelCaseModel):
dest="libp2p_port",
help="Fixed TCP port for libp2p to listen on (0 = OS-assigned).",
)
parser.add_argument(
"--trajectories",
action="store_true",
default=os.getenv("EXO_TRAJECTORIES", "false").lower() == "true",
dest="trajectories",
help="Record ATIF-v1.4 trajectory JSON for each /v1/chat/completions request (env: EXO_TRAJECTORIES)",
)
fast_synch_group = parser.add_mutually_exclusive_group()
fast_synch_group.add_argument(
"--fast-synch",
+9
View File
@@ -88,6 +88,13 @@ EXO_EVENT_LOG_DIR = EXO_DATA_HOME / "event_log"
EXO_IMAGE_CACHE_DIR = EXO_CACHE_HOME / "images"
EXO_TRACING_CACHE_DIR = EXO_CACHE_HOME / "traces"
_EXO_TRAJECTORIES_DIR_ENV = os.environ.get("EXO_TRAJECTORIES_DIR", None)
EXO_TRAJECTORIES_DIR = (
Path(_EXO_TRAJECTORIES_DIR_ENV).expanduser()
if _EXO_TRAJECTORIES_DIR_ENV is not None
else EXO_DATA_HOME / "trajectories"
)
EXO_ENABLE_IMAGE_MODELS = (
os.getenv("EXO_ENABLE_IMAGE_MODELS", "false").lower() == "true"
)
@@ -96,6 +103,8 @@ EXO_OFFLINE = os.getenv("EXO_OFFLINE", "false").lower() == "true"
EXO_TRACING_ENABLED = os.getenv("EXO_TRACING_ENABLED", "false").lower() == "true"
EXO_TRAJECTORIES_ENABLED = os.getenv("EXO_TRAJECTORIES", "false").lower() == "true"
EXO_MAX_CONCURRENT_REQUESTS = int(os.getenv("EXO_MAX_CONCURRENT_REQUESTS", "8"))
EXO_MAX_INSTANCE_RETRIES = 5
@@ -379,6 +379,8 @@ def parse_tool_calls(
) -> Generator[GenerationResponse | ToolCallResponse | None]:
in_tool_call = False
tool_call_text_parts: list[str] = []
accumulated_tool_calls: list[ToolCallItem] = []
for response in responses:
if response is None:
yield None
@@ -387,6 +389,19 @@ def parse_tool_calls(
if not in_tool_call and response.text.startswith(tool_parser.start_parsing):
in_tool_call = True
if (
not in_tool_call
and accumulated_tool_calls
and (response.stats is not None or response.finish_reason is not None)
):
yield ToolCallResponse(
tool_calls=accumulated_tool_calls,
usage=response.usage,
stats=response.stats,
)
accumulated_tool_calls.clear()
continue
if not in_tool_call:
yield response
continue
@@ -407,9 +422,16 @@ def parse_tool_calls(
)
break
yield ToolCallResponse(
tool_calls=parsed, usage=response.usage, stats=response.stats
)
accumulated_tool_calls.extend(parsed)
if accumulated_tool_calls and (
response.finish_reason is not None or response.stats is not None
):
yield ToolCallResponse(
tool_calls=accumulated_tool_calls,
usage=response.usage,
stats=response.stats,
)
accumulated_tool_calls.clear()
continue
if response.finish_reason is not None:
@@ -424,3 +446,6 @@ def parse_tool_calls(
}
)
yield response
if not accumulated_tool_calls:
logger.warning("Tool calls should have all been emitted but were not")
@@ -9,17 +9,14 @@ from exo.worker.runner.llm_inference.model_output_parsers import parse_tool_call
from exo.worker.runner.llm_inference.tool_parsers import make_mlx_parser
def _make_responses(
texts: list[str],
finish_on_last: bool = True,
) -> Generator[GenerationResponse]:
def _make_responses(texts: list[str]) -> Generator[GenerationResponse]:
"""Create a sequence of GenerationResponses from text strings."""
for i, text in enumerate(texts):
is_last = i == len(texts) - 1
yield GenerationResponse(
text=text,
token=i,
finish_reason="stop" if (is_last and finish_on_last) else None,
finish_reason="stop" if is_last else None,
usage=None,
)
@@ -39,7 +36,7 @@ class TestParseToolCalls:
texts = ["<tool_call>", "test_fn", "</tool_call>"]
results = list(
parse_tool_calls(
_make_responses(texts, finish_on_last=False),
_make_responses(texts),
_dummy_parser,
tools=None,
)
@@ -78,7 +75,7 @@ class TestParseToolCalls:
texts = ["<tool_call>", "bad content", "</tool_call>"]
results = list(
parse_tool_calls(
_make_responses(texts, finish_on_last=False),
_make_responses(texts),
make_mlx_parser("<tool_call>", "</tool_call>", _failing_parser),
tools=None,
)