gadget/gadget-code/frontend/src/lib/api.ts
Rob Colbert 00969cf9bc feat: token economics — real API token extraction, stats propagation, and context window fuel gauge
Phase 1: OpenAI API Token Extraction
- Add stream_options: { include_usage: true } to all streaming API calls
- Capture chunk.usage from final streaming chunks and response.usage from non-streaming
- Extend OpenAiChatIterationResult with optional usage field
- Update buildStats() to accept and return real token counts from usage data
- Wire iteration.usage through chat() to both buildStats() call sites

Phase 2: Agent Loop Stats Propagation
- Rename inputTokens/outputTokens to masterInputTokens/masterOutputTokens, add masterThinkingTokens
- Accumulate response.stats.tokenCounts after each master AI call
- Delete all Math.ceil(length/4) crude approximations (master and subagent loops)
- Track startTime/durationMs and emit IWorkOrderCompleteStats with workOrderComplete
- Subagent loop uses response.stats?.tokenCounts instead of Math.ceil

Phase 3: Database Model Changes
- Add contextWindowUsage field to IChatSession, ChatSessionSchema, and frontend ChatSession
- Initialize contextWindowUsage: 0 on session creation

Phase 4: Persist Stats on Turn Completion
- drone-session: accept IWorkOrderCompleteStats, persist turn stats, walk subagent records for aggregate
- drone-session: $inc session stats and contextWindowUsage, add formatDurationLabel() helper
- code-session: accept and forward stats, update in-memory session stats
- message-queue: Redis replay handles 4th stats arg
- Update WorkOrderCompleteMessage type in @gadget/api to accept stats parameter

Phase 5: UI — Context Window Fuel Gauge
- Add contextWindowUsage prop to SessionPanel
- Add fuel gauge bar with E→F labels, green/yellow/red zones, token count display
- Visible for ALL provider types (not gated by apiType)

Phase 6: Frontend Streaming State
- Add IWorkOrderCompleteStats interface to frontend api.ts
- handleWorkOrderComplete accepts stats, updates turn stats and session contextWindowUsage
- Pass contextWindowUsage prop to SessionPanel
2026-05-18 14:36:36 -04:00

499 lines
12 KiB
TypeScript

const API_BASE = "";
const TOKEN_KEY = "dtp_auth_token";
const USER_KEY = "dtp_user";
let isRefreshing = false;
let refreshPromise: Promise<string> | null = null;
/**
* Callback invoked after a successful token refresh so that other
* modules (e.g., the socket client) can update their stored JWT.
* Set via `setOnTokenRefreshed()`.
*/
let onTokenRefreshedCallback: ((newToken: string) => void) | null = null;
/**
* Register a callback to be invoked whenever the JWT is refreshed.
* Used by the socket client to update its auth token for reconnections.
*/
export function setOnTokenRefreshed(cb: (newToken: string) => void): void {
onTokenRefreshedCallback = cb;
}
export interface ApiResponse<T = unknown> {
success: boolean;
message?: string;
user?: T;
token?: string;
data?: T;
}
function getToken(): string | null {
try {
return localStorage.getItem(TOKEN_KEY);
} catch {
return null;
}
}
function setToken(token: string): void {
localStorage.setItem(TOKEN_KEY, token);
}
function signOut(): void {
localStorage.removeItem(TOKEN_KEY);
localStorage.removeItem(USER_KEY);
window.location.href = "/";
}
/**
* Check if the current JWT's `exp` claim is within the refresh threshold.
* Decodes the payload (base64) without cryptographic verification.
* Returns true if the token will expire within `marginMs` milliseconds.
*/
function isTokenExpiringSoon(token: string, marginMs = 5 * 60 * 1000): boolean {
try {
const parts = token.split(".");
if (parts.length < 2) return true;
const payload = JSON.parse(atob(parts[1]!));
if (!payload.exp) return true;
const expiresAt = payload.exp * 1000; // seconds → ms
return Date.now() > (expiresAt - marginMs);
} catch {
return true; // if we can't decode it, treat it as expiring
}
}
async function refreshAuthToken(): Promise<string> {
const response = await fetch(`${API_BASE}/api/v1/auth/renew-token`, {
method: "POST",
headers: {
"Content-Type": "application/json",
},
credentials: "include",
});
if (!response.ok) {
throw new Error(`Token refresh failed: ${response.status}`);
}
const text = await response.text();
try {
const json = JSON.parse(text) as ApiResponse & { token?: string; data?: { token?: string } };
if (!json.success) {
throw new Error(json.message || "Token refresh failed");
}
/*
* The API v1 renew-token endpoint returns { success: true, token: "..." }
* at the top level (not nested under `data`). Handle both formats for
* robustness: check json.token first, then fall back to json.data?.token.
*/
const newToken = json.token ?? json.data?.token;
if (!newToken) {
throw new Error("Token refresh response missing token");
}
return newToken;
} catch (err) {
if (err instanceof Error && err.message.includes("Token refresh")) throw err;
throw new Error(`Invalid refresh response: ${text.slice(0, 200)}`);
}
}
async function request<T>(
method: string,
path: string,
body?: Record<string, unknown>,
retryCount = 0,
): Promise<T> {
let token = getToken();
/*
* Proactive token refresh: if the JWT's `exp` claim shows it will expire
* within 5 minutes, refresh it before making the request. This avoids
* unnecessary 401 errors and the resulting socket disconnections.
*/
if (token && isTokenExpiringSoon(token)) {
try {
if (!isRefreshing) {
isRefreshing = true;
refreshPromise = refreshAuthToken();
}
token = (await refreshPromise)!;
setToken(token);
onTokenRefreshedCallback?.(token);
isRefreshing = false;
refreshPromise = null;
} catch {
isRefreshing = false;
refreshPromise = null;
// Don't sign out on proactive refresh failure — the token may still
// be valid at the DB level even if the JWT exp is close. Let the
// reactive 401 handler below deal with it if needed.
}
}
const headers: Record<string, string> = {
"Content-Type": "application/json",
};
if (token) {
headers["Authorization"] = `Bearer ${token}`;
}
const options: RequestInit = {
method,
headers,
credentials: "include",
};
if (body) {
options.body = JSON.stringify(body);
}
const response = await fetch(`${API_BASE}${path}`, options);
if (response.status === 401 && retryCount === 0) {
try {
if (!isRefreshing) {
isRefreshing = true;
refreshPromise = refreshAuthToken();
}
const newToken = (await refreshPromise)!;
setToken(newToken);
onTokenRefreshedCallback?.(newToken);
isRefreshing = false;
refreshPromise = null;
return request<T>(method, path, body, retryCount + 1);
} catch {
isRefreshing = false;
refreshPromise = null;
signOut();
throw new Error("Session expired. Please sign in again.");
}
}
const text = await response.text();
if (!text) {
throw new Error("Empty response");
}
try {
var json = JSON.parse(text) as ApiResponse<T>;
} catch {
throw new Error(`Invalid JSON: ${text.slice(0, 200)}`);
}
if (!json.success) {
throw new Error(json.message || "Request failed");
}
if (json.token !== undefined && json.user !== undefined) {
return json as T;
}
if (json.data !== undefined) {
return json.data as T;
}
return json as T;
}
export const api = {
get: <T>(path: string) => request<T>("GET", path),
post: <T>(path: string, body?: Record<string, unknown>) =>
request<T>("POST", path, body),
put: <T>(path: string, body: Record<string, unknown>) =>
request<T>("PUT", path, body),
delete: <T>(path: string) => request<T>("DELETE", path),
};
export interface User {
_id: string;
email: string;
displayName: string;
flags: string[];
persona?: string;
}
export interface AuthResponse {
user: User;
token: string;
}
export interface ProjectSkill {
_id: string;
name: string;
content: string;
modes: string[];
}
export interface ProjectTask {
_id: string;
name: string;
provider: string;
selectedModel: string;
mode: string;
crontab: string;
content: string;
enabled: boolean;
lastRun?: string;
}
export interface Project {
_id: string;
createdAt: string;
user: string;
status: "active" | "inactive" | "archived";
name: string;
slug: string;
gitUrl?: string;
description?: string;
system?: string;
skills: ProjectSkill[];
tasks: ProjectTask[];
}
export const userApi = {
updateSettings: (data: {
displayName?: string;
currentPassword?: string;
password?: string;
persona?: string;
}) => api.put<User>("/api/v1/user/settings", data),
};
export const projectApi = {
getAll: () => api.get<Project[]>("/api/v1/projects"),
get: (id: string) => api.get<Project>(`/api/v1/projects/${id}`),
create: (data: { name: string; slug: string; gitUrl?: string }) =>
api.post<Project>("/api/v1/projects", data),
pull: (gitUrl: string) =>
api.post<Project>("/api/v1/projects/pull", { gitUrl }),
update: (
id: string,
data: Partial<{
name: string;
slug: string;
gitUrl: string;
status: string;
description: string;
system: string;
skills: ProjectSkill[];
tasks: ProjectTask[];
}>,
) => api.put<Project>(`/api/v1/projects/${id}`, data),
delete: (id: string) => api.delete<void>(`/api/v1/projects/${id}`),
};
export interface SubProcessStat {
pid: number;
command: string;
args: string[];
projectId: string;
projectSlug: string;
projectName: string;
status: "running" | "stopped" | "error";
createdAt: string;
updatedAt: string;
stdoutFileName: string;
stderrFileName: string;
}
export interface DroneRegistration {
_id: string;
hostname: string;
workspaceDir: string;
status: "starting" | "available" | "busy" | "offline";
user: string;
createdAt: string;
updatedAt: string;
}
export const droneApi = {
getAll: () => api.get<DroneRegistration[]>("/api/v1/drone/registration"),
getForManager: () =>
api.get<DroneRegistration[]>("/api/v1/drone/registration/manager"),
terminate: (registrationId: string) =>
api.post<ApiResponse>(
`/api/v1/drone/registration/${registrationId}/terminate`,
),
};
export interface AiModelCapabilities {
canCallTools: boolean;
hasVision: boolean;
hasEmbedding: boolean;
hasThinking: boolean;
isInstructTuned: boolean;
}
export interface AiModelSettings {
temperature?: number;
topP?: number;
topK?: number;
numCtx?: number;
}
export interface AiModel {
id: string;
name: string;
parameterCount?: number;
parameterLabel?: string;
contextWindow?: number;
capabilities: AiModelCapabilities;
settings?: AiModelSettings;
}
export interface AiProvider {
_id: string;
name: string;
apiType: "ollama" | "openai";
baseUrl: string;
enabled: boolean;
models: AiModel[];
lastModelRefresh: string;
}
export const providerApi = {
getAll: () => api.get<AiProvider[]>("/api/v1/providers"),
get: (id: string) => api.get<AiProvider>(`/api/v1/providers/${id}`),
};
export enum ChatSessionMode {
Plan = "plan",
Build = "build",
Test = "test",
Ship = "ship",
Develop = "dev",
}
export interface ChatSessionStats {
turnCount: number;
toolCallCount: number;
inputTokens: number;
outputTokens: number;
}
export interface ChatSession {
_id: string;
createdAt: string;
lastMessageAt?: string;
user: string | any;
project: string | any;
name: string;
mode: ChatSessionMode;
provider: string | AiProvider;
selectedModel: string;
reasoningEffort?: string;
numCtx?: number;
contextWindowUsage: number;
stats: ChatSessionStats;
pins: Array<{ _id?: string; content: string }>;
}
export interface ChatTurnStats {
toolCallCount: number;
inputTokens: number;
thinkingTokenCount: number;
responseTokens: number;
durationMs: number;
durationLabel: string;
}
export interface IWorkOrderCompleteStats {
masterInputTokens: number;
masterOutputTokens: number;
masterThinkingTokens: number;
toolCallCount: number;
durationMs: number;
}
export interface ChatTurnPrompts {
user: string;
system?: string;
}
export interface ChatTurnBlockThinking {
mode: 'thinking';
createdAt: string;
content: string;
}
export interface ChatTurnBlockResponding {
mode: 'responding';
createdAt: string;
content: string;
}
export interface ChatTurnBlockTool {
mode: 'tool';
createdAt: string;
content: {
callId: string;
name: string;
parameters?: string;
response?: string;
subagent?: {
agentId: string;
thinking: string;
response: string;
toolCalls: Array<{
callId: string;
name: string;
parameters?: string;
response?: string;
}>;
stats: ChatTurnStats;
};
};
}
export type ChatTurnBlock = ChatTurnBlockThinking | ChatTurnBlockResponding | ChatTurnBlockTool;
export interface ChatTurn {
_id: string;
createdAt: string;
user: string | any;
project: string | any;
session: string | ChatSession;
provider: string | AiProvider;
llm: string;
numCtx?: number;
mode: ChatSessionMode;
status: "processing" | "finished" | "aborted" | "error";
prompts: ChatTurnPrompts;
blocks: ChatTurnBlock[];
errorMessage?: string;
toolCalls: Array<{
callId: string;
name: string;
parameters?: string;
response?: string;
subagent?: ChatTurnBlockTool['content']['subagent'];
}>;
subagents: any[];
stats: ChatTurnStats;
}
export const chatSessionApi = {
getAll: (projectId?: string, limit?: number) =>
api.get<ChatSession[]>(
`/api/v1/chat-sessions${projectId ? `?projectId=${projectId}` : ""}${limit ? `${projectId ? "&" : "?"}limit=${limit}` : ""}`,
),
get: (id: string) => api.get<ChatSession>(`/api/v1/chat-sessions/${id}`),
create: (data: {
projectId: string;
providerId: string;
selectedModel: string;
mode?: ChatSessionMode;
name?: string;
}) => api.post<ChatSession>("/api/v1/chat-sessions", data),
update: (id: string, data: Partial<ChatSession>) =>
api.put<ChatSession>(`/api/v1/chat-sessions/${id}`, data),
delete: (id: string) => api.delete<void>(`/api/v1/chat-sessions/${id}`),
getTurns: (id: string) =>
api.get<ChatTurn[]>(`/api/v1/chat-sessions/${id}/turns`),
};