mirror of
https://github.com/exo-explore/exo.git
synced 2026-09-09 12:02:25 -04:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7ca01c5ba8 | ||
|
|
0a6b7e9715 | ||
|
|
3a2d93dc93 | ||
|
|
9f5e0e037e | ||
|
|
e0bb0d276c | ||
|
|
55b90114f8 | ||
|
|
4e7da9ef4f | ||
|
|
9b516029d3 | ||
|
|
1cd7a67cc5 | ||
|
|
b0eec5988a |
No files matched your search
Generated
+1
@@ -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>
|
||||
@@ -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 • {summary.hitsPartial} partial
|
||||
• {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)} • {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"
|
||||
>
|
||||
← 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}
|
||||
• prompt_tokens={trajectory.final_metrics.total_prompt_tokens}
|
||||
• completion_tokens={trajectory.final_metrics
|
||||
.total_completion_tokens}
|
||||
• 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"
|
||||
>
|
||||
← 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}
|
||||
• prompt_tokens: {trajectoryA.final_metrics
|
||||
.total_prompt_tokens}
|
||||
• 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}
|
||||
• prompt_tokens: {trajectoryB.final_metrics
|
||||
.total_prompt_tokens}
|
||||
• 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
@@ -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()})
|
||||
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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",
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
Reference in new issue
Block a user