pi-umans-provider 1.0.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/index.ts ADDED
@@ -0,0 +1,700 @@
1
+ /**
2
+ * UMANS Provider Extension
3
+ *
4
+ * Registers UMANS (code.umans.ai) as a custom provider using the
5
+ * openai-completions API. Base URL: https://api.code.umans.ai/v1
6
+ *
7
+ * UMANS provides subscription-based access to coding-optimized models.
8
+ * All models support tool use. Reasoning is controlled via `reasoning_effort`
9
+ * and surfaced in a `reasoning_content` field (DeepSeek-style response field).
10
+ *
11
+ * Key API details:
12
+ * - Reasoning via `reasoning_effort` (none/low/medium/high; minimal/xhigh accepted and mapped to nearest). Default thinking level: `medium`.
13
+ * - All reasoning models return `reasoning_content` (parsed regardless of thinking format).
14
+ * - `max_completion_tokens` (and `max_tokens`) are both honored on the OpenAI route; default `max_completion_tokens` is used.
15
+ * - Developer role is NOT supported (use system role instead)
16
+ * - Subscription-based: $0 per-token cost
17
+ * - `umans-flash-beta` is deprecated (sunset 2026-06-07, use `umans-flash`)
18
+ *
19
+ * Model resolution strategy: Stale-While-Revalidate
20
+ * 1. Serve stale immediately: disk cache → embedded models.json (zero-latency)
21
+ * 2. Revalidate in background: live API /v1/models/info → merge with embedded → cache → hot-swap
22
+ * 3. patch.json + custom-models.json applied on top of whichever source won
23
+ *
24
+ * Merge order: [live|cache|embedded] → apply patch.json → merge custom-models.json
25
+ *
26
+ * Usage:
27
+ * # Option 1: Store in auth.json (recommended)
28
+ * # Add to ~/.pi/agent/auth.json:
29
+ * # "umans": { "type": "api_key", "key": "your-api-key" }
30
+ *
31
+ * # Option 2: Set as environment variable
32
+ * export UMANS_API_KEY=your-api-key
33
+ *
34
+ * # Run pi with the extension
35
+ * pi -e /path/to/pi-umans-provider
36
+ *
37
+ * @see https://code.umans.ai
38
+ */
39
+
40
+ import { getAgentDir, type ExtensionAPI, type ModelRegistry } from "@earendil-works/pi-coding-agent";
41
+ import type { OAuthLoginCallbacks } from "@earendil-works/pi-ai";
42
+ import modelsData from "./models.json" with { type: "json" };
43
+ import customModelsData from "./custom-models.json" with { type: "json" };
44
+ import patchData from "./patch.json" with { type: "json" };
45
+ import fs from "fs";
46
+ import path from "path";
47
+
48
+ // ─── Usage/Plan Types ────────────────────────────────────────────────────────
49
+
50
+ const USAGE_API_URL = "https://api.code.umans.ai/v1/usage";
51
+ const USAGE_FETCH_TIMEOUT_MS = 5000;
52
+ const USAGE_THROTTLE_MS = 30_000;
53
+ const END_SETTLE_MS = 2000;
54
+ const IDLE_POLL_MS = 45_000;
55
+
56
+ let sessionPlan: string | null = null;
57
+ let sessionConcurrency: number | null = null;
58
+ let sessionOthers: number | null = null;
59
+ let activeStreams = 0;
60
+ let sessionRequestsInWindow: number | null = null;
61
+ let sessionRemainingRequests: number | null = null;
62
+ let lastUsageFetchTime = 0;
63
+ let usageFetchInFlight = false;
64
+ let idleTimer: ReturnType<typeof setTimeout> | null = null;
65
+ let endFetchTimer: ReturnType<typeof setTimeout> | null = null;
66
+
67
+ interface OAuthCredentials {
68
+ access: string;
69
+ refresh: string;
70
+ expires: number;
71
+ [key: string]: unknown;
72
+ }
73
+
74
+ async function loginUmans(callbacks: OAuthLoginCallbacks): Promise<OAuthCredentials> {
75
+ const apiKey = await callbacks.onPrompt({
76
+ message: "Enter your Umans API key (starts with sk-):",
77
+ });
78
+ const key = apiKey.trim();
79
+ if (!key.startsWith("sk-")) {
80
+ throw new Error("Invalid API key: must start with 'sk-'");
81
+ }
82
+ // API keys don't expire — use a far-future timestamp to prevent unnecessary refresh attempts
83
+ return { refresh: key, access: key, expires: Date.now() + 100 * 365 * 24 * 60 * 60 * 1000 };
84
+ }
85
+
86
+ function refreshUmansToken(credentials: OAuthCredentials): Promise<OAuthCredentials> {
87
+ return Promise.resolve(credentials);
88
+ }
89
+
90
+ function getApiKey(credentials: OAuthCredentials): string {
91
+ return credentials.access;
92
+ }
93
+
94
+ // ─── Types ────────────────────────────────────────────────────────────────────
95
+
96
+ interface JsonModel {
97
+ id: string;
98
+ name: string;
99
+ reasoning: boolean;
100
+ input: ("text" | "image")[];
101
+ cost: {
102
+ input: number;
103
+ output: number;
104
+ cacheRead: number;
105
+ cacheWrite: number;
106
+ };
107
+ contextWindow: number;
108
+ maxTokens: number;
109
+ thinkingLevelMap?: Record<string, string | null>;
110
+ compat?: {
111
+ supportsDeveloperRole?: boolean;
112
+ supportsStore?: boolean;
113
+ maxTokensField?: "max_completion_tokens" | "max_tokens";
114
+ thinkingFormat?: "openai" | "zai" | "qwen" | "qwen-chat-template" | "deepseek";
115
+ supportsReasoningEffort?: boolean;
116
+ requiresReasoningContentOnAssistantMessages?: boolean;
117
+ };
118
+ }
119
+
120
+ interface PatchEntry {
121
+ name?: string;
122
+ reasoning?: boolean;
123
+ input?: ("text" | "image")[];
124
+ cost?: {
125
+ input?: number;
126
+ output?: number;
127
+ cacheRead?: number;
128
+ cacheWrite?: number;
129
+ };
130
+ contextWindow?: number;
131
+ maxTokens?: number;
132
+ thinkingLevelMap?: Record<string, string | null>;
133
+ compat?: Record<string, unknown>;
134
+ }
135
+
136
+ type PatchData = Record<string, PatchEntry>;
137
+
138
+ // Models returned by /v1/models/info
139
+ interface UmansModelInfo {
140
+ name: string;
141
+ display_name?: string;
142
+ description?: string;
143
+ base_model?: { name: string; provider?: string; oss_base?: string; family?: string };
144
+ capabilities?: {
145
+ max_completion_tokens?: number;
146
+ recommended_max_tokens?: number;
147
+ context_window?: number;
148
+ supports_vision?: boolean | string;
149
+ supports_tools?: boolean;
150
+ };
151
+ deprecation?: { sunset_date: string; replacement: string };
152
+ benchmarks?: Record<string, unknown>;
153
+ }
154
+
155
+ type UmansModelsInfoResponse = Record<string, UmansModelInfo>;
156
+
157
+ // ─── Patch Application ────────────────────────────────────────────────────────
158
+
159
+ function applyPatch(model: JsonModel, patch: PatchEntry): JsonModel {
160
+ const result = { ...model };
161
+
162
+ if (patch.name !== undefined) result.name = patch.name;
163
+ if (patch.reasoning !== undefined) result.reasoning = patch.reasoning;
164
+ if (patch.input !== undefined) result.input = patch.input;
165
+ if (patch.contextWindow !== undefined) result.contextWindow = patch.contextWindow;
166
+ if (patch.maxTokens !== undefined) result.maxTokens = patch.maxTokens;
167
+ if (patch.thinkingLevelMap !== undefined) result.thinkingLevelMap = { ...patch.thinkingLevelMap };
168
+
169
+ if (patch.cost) {
170
+ result.cost = {
171
+ input: patch.cost.input ?? result.cost.input,
172
+ output: patch.cost.output ?? result.cost.output,
173
+ cacheRead: patch.cost.cacheRead ?? result.cost.cacheRead,
174
+ cacheWrite: patch.cost.cacheWrite ?? result.cost.cacheWrite,
175
+ };
176
+ }
177
+ if (patch.compat) {
178
+ result.compat = { ...(result.compat || {}), ...patch.compat };
179
+ }
180
+
181
+ if (!result.reasoning && result.compat?.thinkingFormat) {
182
+ delete result.compat.thinkingFormat;
183
+ }
184
+ if (result.compat && Object.keys(result.compat).length === 0) {
185
+ delete result.compat;
186
+ }
187
+
188
+ return result;
189
+ }
190
+
191
+ /** Full pipeline: base models → patch → custom → result */
192
+ function buildModels(base: JsonModel[], custom: JsonModel[], patch: PatchData): JsonModel[] {
193
+ const modelMap = new Map<string, JsonModel>();
194
+
195
+ for (const model of base) {
196
+ modelMap.set(model.id, model);
197
+ }
198
+
199
+ for (const [id, patchEntry] of Object.entries(patch)) {
200
+ const existing = modelMap.get(id);
201
+ if (existing) {
202
+ modelMap.set(id, applyPatch(existing, patchEntry));
203
+ }
204
+ }
205
+
206
+ for (const model of custom) {
207
+ const existing = modelMap.get(model.id);
208
+ const patchEntry = patch[model.id];
209
+ if (existing && patchEntry) {
210
+ modelMap.set(model.id, applyPatch(model, patchEntry));
211
+ } else if (existing) {
212
+ modelMap.set(model.id, model);
213
+ } else if (patchEntry) {
214
+ modelMap.set(model.id, applyPatch(model, patchEntry));
215
+ } else {
216
+ modelMap.set(model.id, model);
217
+ }
218
+ }
219
+
220
+ return Array.from(modelMap.values());
221
+ }
222
+
223
+ // ─── Stale-While-Revalidate Model Sync ────────────────────────────────────────
224
+
225
+ const PROVIDER_ID = "umans";
226
+ const BASE_URL = "https://api.code.umans.ai/v1";
227
+ const MODELS_INFO_URL = `${BASE_URL}/models/info`;
228
+ const CACHE_DIR = path.join(getAgentDir(), "cache");
229
+ const CACHE_PATH = path.join(CACHE_DIR, `${PROVIDER_ID}-models.json`);
230
+ const LIVE_FETCH_TIMEOUT_MS = 8000;
231
+
232
+ const DEPRECATED_MODELS = new Set(["umans-flash-beta"]);
233
+
234
+ /** Transform a model from the UMANS /v1/models/info API. */
235
+ function transformApiModel(id: string, info: UmansModelInfo): JsonModel | null {
236
+ if (info.deprecation || DEPRECATED_MODELS.has(id)) return null;
237
+
238
+ const caps = info.capabilities || {};
239
+ const hasVision = caps.supports_vision === true;
240
+
241
+ return {
242
+ id,
243
+ name: info.display_name || info.name || id,
244
+ reasoning: true,
245
+ input: hasVision ? ["text", "image"] : ["text"],
246
+ cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
247
+ contextWindow: caps.context_window || 131072,
248
+ maxTokens: caps.recommended_max_tokens || caps.max_completion_tokens || 65000,
249
+ };
250
+ }
251
+
252
+ async function fetchLiveModels(apiKey: string, signal?: AbortSignal): Promise<JsonModel[] | null> {
253
+ try {
254
+ const response = await fetch(MODELS_INFO_URL, {
255
+ headers: { Authorization: `Bearer ${apiKey}` },
256
+ signal: signal
257
+ ? AbortSignal.any([AbortSignal.timeout(LIVE_FETCH_TIMEOUT_MS), signal])
258
+ : AbortSignal.timeout(LIVE_FETCH_TIMEOUT_MS),
259
+ });
260
+ if (!response.ok) return null;
261
+ const data = (await response.json()) as UmansModelsInfoResponse;
262
+ if (!data || typeof data !== "object") return null;
263
+ const models = Object.entries(data)
264
+ .map(([id, info]) => transformApiModel(id, info))
265
+ .filter((m): m is JsonModel => m !== null);
266
+ if (models.length === 0) return null;
267
+ return models;
268
+ } catch {
269
+ return null;
270
+ }
271
+ }
272
+
273
+ function loadCachedModels(): JsonModel[] | null {
274
+ try {
275
+ const data = JSON.parse(fs.readFileSync(CACHE_PATH, "utf8"));
276
+ return Array.isArray(data) ? data : null;
277
+ } catch {
278
+ return null;
279
+ }
280
+ }
281
+
282
+ function cacheModels(models: JsonModel[]): void {
283
+ try {
284
+ fs.mkdirSync(CACHE_DIR, { recursive: true });
285
+ fs.writeFileSync(CACHE_PATH, JSON.stringify(models, null, 2) + "\n");
286
+ } catch {
287
+ // Cache write failure is non-fatal
288
+ }
289
+ }
290
+
291
+ function mergeWithEmbedded(liveModels: JsonModel[], embeddedModels: JsonModel[]): JsonModel[] {
292
+ const embeddedMap = new Map(embeddedModels.map((m) => [m.id, m]));
293
+ const seen = new Set<string>();
294
+ const result: JsonModel[] = [];
295
+ for (const liveModel of liveModels) {
296
+ const embedded = embeddedMap.get(liveModel.id);
297
+ seen.add(liveModel.id);
298
+ if (embedded) {
299
+ // Self-heal: live API pricing is authoritative field-by-field. Prefer the
300
+ // live cost when the API reports it (non-zero); fall back to embedded when
301
+ // the API is silent (0) so curated cacheRead/cacheWrite isn't clobbered and
302
+ // providers whose /models endpoint exposes no pricing keep their curated
303
+ // cost. Curation (reasoning/input/compat/name) still wins via ...embedded.
304
+ result.push({
305
+ ...liveModel,
306
+ ...embedded,
307
+ cost: {
308
+ input: liveModel.cost.input || embedded.cost.input,
309
+ output: liveModel.cost.output || embedded.cost.output,
310
+ cacheRead: liveModel.cost.cacheRead || embedded.cost.cacheRead,
311
+ cacheWrite: liveModel.cost.cacheWrite || embedded.cost.cacheWrite,
312
+ },
313
+ contextWindow: liveModel.contextWindow || embedded.contextWindow,
314
+ });
315
+ } else {
316
+ result.push(liveModel);
317
+ }
318
+ }
319
+ for (const em of embeddedModels) {
320
+ if (!seen.has(em.id)) {
321
+ result.push(em);
322
+ }
323
+ }
324
+ return result;
325
+ }
326
+
327
+ function loadStaleModels(embeddedModels: JsonModel[]): JsonModel[] {
328
+ const cached = loadCachedModels();
329
+ if (!cached || cached.length === 0) return embeddedModels;
330
+
331
+ const cachedMap = new Map(cached.map((m) => [m.id, m]));
332
+ for (const em of embeddedModels) {
333
+ if (!cachedMap.has(em.id)) {
334
+ cached.push(em);
335
+ }
336
+ }
337
+ return cached;
338
+ }
339
+
340
+ async function revalidateModels(
341
+ apiKey: string | undefined,
342
+ embeddedModels: JsonModel[],
343
+ signal?: AbortSignal,
344
+ ): Promise<JsonModel[] | null> {
345
+ if (!apiKey) return null;
346
+ const liveModels = await fetchLiveModels(apiKey, signal);
347
+ if (!liveModels || liveModels.length === 0) return null;
348
+ const merged = mergeWithEmbedded(liveModels, embeddedModels);
349
+ cacheModels(merged);
350
+ return merged;
351
+ }
352
+
353
+ // ─── API Key Resolution ────────────────────────────────────────────────────────
354
+
355
+ let cachedApiKey: string | undefined;
356
+ let revalidateAbort: AbortController | null = null;
357
+ // Aborted on session replacement/shutdown so in-flight usage fetches from
358
+ // timers don't resolve against a stale ctx (the timer handle itself can't be
359
+ // clearTimeout'd once the async callback has already fired).
360
+ let usageAbort: AbortController | null = null;
361
+
362
+ async function resolveApiKey(modelRegistry: ModelRegistry): Promise<void> {
363
+ cachedApiKey = (await modelRegistry.getApiKeyForProvider("umans")) ?? undefined;
364
+ }
365
+
366
+ // ─── Usage/Plan Footer ────────────────────────────────────────────────────────
367
+
368
+ interface UmansUsage {
369
+ user_id?: string;
370
+ plan: { slug: string; display_name: string };
371
+ limits: {
372
+ requests: { limit: number | null; window_seconds: number; description: string };
373
+ concurrency: { limit: number; description: string };
374
+ };
375
+ usage: {
376
+ requests_in_window: number;
377
+ weighted_in_window?: number;
378
+ remaining_requests: number | null;
379
+ weighted_remaining_requests?: number | null;
380
+ concurrent_sessions: number;
381
+ weighted_concurrent_sessions?: number;
382
+ tokens_in?: number;
383
+ tokens_out?: number;
384
+ tokens_cached?: number;
385
+ };
386
+ window?: {
387
+ started_at?: string;
388
+ resets_at?: string;
389
+ remaining_minutes?: number;
390
+ };
391
+ }
392
+
393
+ async function fetchUsage(
394
+ apiKey: string | undefined,
395
+ signal?: AbortSignal,
396
+ ): Promise<UmansUsage | null> {
397
+ if (!apiKey) return null;
398
+ try {
399
+ const response = await fetch(USAGE_API_URL, {
400
+ headers: { Authorization: `Bearer ${apiKey}` },
401
+ signal: signal
402
+ ? AbortSignal.any([AbortSignal.timeout(USAGE_FETCH_TIMEOUT_MS), signal])
403
+ : AbortSignal.timeout(USAGE_FETCH_TIMEOUT_MS),
404
+ });
405
+ if (!response.ok) return null;
406
+ const data = (await response.json()) as UmansUsage;
407
+ if (!data.plan?.slug) return null;
408
+ return data;
409
+ } catch {
410
+ return null;
411
+ }
412
+ }
413
+
414
+ function applyUsage(usage: UmansUsage, ctx: any): void {
415
+ sessionPlan = usage.plan.display_name;
416
+ sessionConcurrency = usage.limits?.concurrency?.limit ?? null;
417
+ // Only adopt the server's concurrent count as our idle baseline when we're
418
+ // not streaming — otherwise it already includes us and would double-count
419
+ // against activeStreams in the display.
420
+ if (activeStreams === 0) {
421
+ sessionOthers = usage.usage.concurrent_sessions ?? 0;
422
+ }
423
+ sessionRequestsInWindow = usage.usage.requests_in_window ?? null;
424
+ sessionRemainingRequests = usage.usage.remaining_requests ?? null;
425
+ updateUsageStatus(ctx);
426
+ if (activeStreams === 0) {
427
+ resetIdleTimer(ctx);
428
+ }
429
+ }
430
+
431
+ // Throttled fetch — skips if one is in flight or if the last successful fetch
432
+ // was within USAGE_THROTTLE_MS. Returns null on skip, caller checks.
433
+ async function throttledFetchUsage(
434
+ apiKey: string | undefined,
435
+ options?: { force?: boolean; signal?: AbortSignal },
436
+ ): Promise<UmansUsage | null> {
437
+ const now = Date.now();
438
+ const { force = false, signal } = options ?? {};
439
+ if (!force && (usageFetchInFlight || now - lastUsageFetchTime < USAGE_THROTTLE_MS)) {
440
+ return null;
441
+ }
442
+ usageFetchInFlight = true;
443
+ try {
444
+ const usage = await fetchUsage(apiKey, signal);
445
+ if (usage) {
446
+ lastUsageFetchTime = now;
447
+ }
448
+ return usage;
449
+ } finally {
450
+ usageFetchInFlight = false;
451
+ }
452
+ }
453
+
454
+ function updateUsageStatus(ctx: any): void {
455
+ // Defensive: a stale ctx (post session replacement/reload) throws on access.
456
+ // Never let a timer-driven status refresh take down the process.
457
+ let model: any;
458
+ try {
459
+ model = ctx.model;
460
+ } catch {
461
+ return;
462
+ }
463
+ if (model?.provider !== "umans") {
464
+ try { ctx.ui.setStatus("umans-usage", undefined); } catch {}
465
+ return;
466
+ }
467
+ if (!sessionPlan) {
468
+ try { ctx.ui.setStatus("umans-usage", undefined); } catch {}
469
+ return;
470
+ }
471
+ const parts: string[] = [sessionPlan];
472
+ if (sessionConcurrency != null) {
473
+ parts.push(`\u27e0 ${(sessionOthers ?? 0) + activeStreams}/${sessionConcurrency}`);
474
+ }
475
+ if (sessionRemainingRequests != null) {
476
+ parts.push(`\u21c4 ${sessionRemainingRequests}`);
477
+ }
478
+ try {
479
+ ctx.ui.setStatus("umans-usage", ctx.ui.theme.fg("dim", parts.join(" | ")));
480
+ } catch {}
481
+ }
482
+
483
+ function clearUsageStatus(ctx: any): void {
484
+ try { ctx.ui.setStatus("umans-usage", undefined); } catch {}
485
+ sessionPlan = null;
486
+ sessionConcurrency = null;
487
+ sessionOthers = null;
488
+ activeStreams = 0;
489
+ sessionRequestsInWindow = null;
490
+ sessionRemainingRequests = null;
491
+ if (idleTimer) {
492
+ clearTimeout(idleTimer);
493
+ idleTimer = null;
494
+ }
495
+ if (endFetchTimer) {
496
+ clearTimeout(endFetchTimer);
497
+ endFetchTimer = null;
498
+ }
499
+ }
500
+
501
+ // Light idle poll: refresh the server baseline (others) while nothing is
502
+ // streaming, so the optimistic +active sits on a fresh base. Self-rearming.
503
+ function resetIdleTimer(ctx: any): void {
504
+ if (idleTimer) clearTimeout(idleTimer);
505
+ const signal = usageAbort?.signal;
506
+ idleTimer = setTimeout(async () => {
507
+ idleTimer = null;
508
+ // Skip while streaming — the optimistic value is in effect and agent_end
509
+ // will reconcile with a real fetch.
510
+ if (activeStreams > 0) return;
511
+ if (signal?.aborted) return;
512
+ const usage = await throttledFetchUsage(cachedApiKey, { signal });
513
+ if (!usage || signal?.aborted) return;
514
+ applyUsage(usage, ctx);
515
+ }, IDLE_POLL_MS);
516
+ }
517
+
518
+ // After agent_end the server still counts our session for a brief lag. Wait
519
+ // it out, then take a clean idle baseline and re-arm the idle poll.
520
+ function scheduleEndFetch(ctx: any): void {
521
+ if (endFetchTimer) clearTimeout(endFetchTimer);
522
+ const signal = usageAbort?.signal;
523
+ endFetchTimer = setTimeout(async () => {
524
+ endFetchTimer = null;
525
+ if (signal?.aborted) return;
526
+ const usage = await throttledFetchUsage(cachedApiKey, { force: true, signal });
527
+ if (!usage || signal?.aborted) return;
528
+ applyUsage(usage, ctx);
529
+ }, END_SETTLE_MS);
530
+ }
531
+
532
+ // ─── Extension Entry Point ────────────────────────────────────────────────────
533
+
534
+ export default function (pi: ExtensionAPI) {
535
+ const embeddedModels = modelsData as JsonModel[];
536
+ const customModels = customModelsData as JsonModel[];
537
+ const patches = patchData as PatchData;
538
+
539
+ const staleBase = loadStaleModels(embeddedModels);
540
+ const staleModels = buildModels(staleBase, customModels, patches);
541
+
542
+ pi.registerProvider("umans", {
543
+ baseUrl: BASE_URL,
544
+ apiKey: "$UMANS_API_KEY",
545
+ api: "openai-completions",
546
+ models: staleModels,
547
+ oauth: {
548
+ name: "Umans AI (API Key)",
549
+ login: loginUmans,
550
+ refreshToken: refreshUmansToken,
551
+ getApiKey: getApiKey,
552
+ },
553
+ });
554
+
555
+ function isUmansModel(ctx: any): boolean {
556
+ return ctx.model?.provider === "umans";
557
+ }
558
+
559
+ pi.on("before_provider_request", async (event) => {
560
+ const p = event.payload as Record<string, any>;
561
+ const model: string = p.model ?? "";
562
+ if (!model.startsWith("umans-")) return;
563
+
564
+ const messages = p.messages;
565
+ if (!Array.isArray(messages) || messages.length === 0) return;
566
+
567
+ const toolCallIds = new Set<string>();
568
+ for (const msg of messages) {
569
+ if (msg.role === "assistant" && Array.isArray(msg.tool_calls)) {
570
+ for (const tc of msg.tool_calls) {
571
+ if (tc.id) toolCallIds.add(tc.id);
572
+ }
573
+ }
574
+ }
575
+
576
+ const toolResultIds = new Set<string>();
577
+ for (const msg of messages) {
578
+ if (msg.role === "tool" && msg.tool_call_id) {
579
+ toolResultIds.add(msg.tool_call_id);
580
+ }
581
+ }
582
+
583
+ const orphanedIds = [...toolCallIds].filter((id) => !toolResultIds.has(id));
584
+ if (orphanedIds.length === 0) return;
585
+
586
+ const newMessages = [...messages];
587
+ let insertOffset = 0;
588
+
589
+ for (let i = 0; i < messages.length; i++) {
590
+ const msg = messages[i];
591
+ if (msg.role !== "assistant" || !Array.isArray(msg.tool_calls)) continue;
592
+
593
+ const orphanedCalls = msg.tool_calls.filter((tc: any) =>
594
+ orphanedIds.includes(tc.id),
595
+ );
596
+ if (orphanedCalls.length === 0) continue;
597
+
598
+ const insertIdx = i + insertOffset + 1;
599
+ const syntheticResults = orphanedCalls.map((tc: any) => ({
600
+ role: "tool",
601
+ tool_call_id: tc.id,
602
+ content: "[tool result was lost during context compaction]",
603
+ }));
604
+
605
+ newMessages.splice(insertIdx, 0, ...syntheticResults);
606
+ insertOffset += orphanedCalls.length;
607
+ }
608
+
609
+ p.messages = newMessages;
610
+ return p;
611
+ });
612
+
613
+ pi.on("session_start", async (_event, ctx) => {
614
+ revalidateAbort?.abort();
615
+ revalidateAbort = new AbortController();
616
+ // Cancel any usage fetches/timers still running from a prior session —
617
+ // their captured ctx is now stale.
618
+ usageAbort?.abort();
619
+ usageAbort = new AbortController();
620
+ const signal = revalidateAbort.signal;
621
+ resolveApiKey(ctx.modelRegistry).then(async () => {
622
+ revalidateModels(cachedApiKey, embeddedModels, signal).then((freshBase) => {
623
+ if (freshBase && !signal.aborted) {
624
+ pi.registerProvider("umans", {
625
+ baseUrl: BASE_URL,
626
+ apiKey: "$UMANS_API_KEY",
627
+ api: "openai-completions",
628
+ models: buildModels(freshBase, customModels, patches),
629
+ });
630
+ }
631
+ });
632
+
633
+ if (!isUmansModel(ctx)) {
634
+ clearUsageStatus(ctx);
635
+ return;
636
+ }
637
+
638
+ const usage = await throttledFetchUsage(cachedApiKey, { force: true, signal });
639
+ if (usage && !signal.aborted) {
640
+ applyUsage(usage, ctx);
641
+ }
642
+ });
643
+ });
644
+
645
+ pi.on("model_select", (event, ctx) => {
646
+ if (event.model?.provider === "umans") {
647
+ throttledFetchUsage(cachedApiKey, { force: true }).then((usage) => {
648
+ if (usage) {
649
+ applyUsage(usage, ctx);
650
+ } else {
651
+ updateUsageStatus(ctx);
652
+ }
653
+ });
654
+ } else {
655
+ clearUsageStatus(ctx);
656
+ }
657
+ });
658
+
659
+ // Optimistic +1: the moment our agent starts, we know the real concurrent
660
+ // count is at least (others + 1). No API call — the server has a ~2s
661
+ // registration lag, so fetching now would undercount us anyway. One span
662
+ // per prompt (agent_start → agent_end), so no flicker between tool turns.
663
+ pi.on("agent_start", async (_event, ctx) => {
664
+ if (!isUmansModel(ctx)) return;
665
+ if (idleTimer) {
666
+ clearTimeout(idleTimer);
667
+ idleTimer = null;
668
+ }
669
+ activeStreams++;
670
+ updateUsageStatus(ctx);
671
+ });
672
+
673
+ // Turn ended: drop our optimistic +1 and reconcile with the server after a
674
+ // short settle so the server has dropped our session from its count.
675
+ pi.on("agent_end", async (_event, ctx) => {
676
+ if (!isUmansModel(ctx)) return;
677
+ activeStreams = Math.max(0, activeStreams - 1);
678
+ updateUsageStatus(ctx);
679
+ scheduleEndFetch(ctx);
680
+ });
681
+
682
+ pi.on("session_tree", async (_event, ctx) => {
683
+ updateUsageStatus(ctx);
684
+ });
685
+
686
+ pi.on("session_shutdown", () => {
687
+ revalidateAbort?.abort();
688
+ usageAbort?.abort();
689
+ usageAbort = null;
690
+ activeStreams = 0;
691
+ if (idleTimer) {
692
+ clearTimeout(idleTimer);
693
+ idleTimer = null;
694
+ }
695
+ if (endFetchTimer) {
696
+ clearTimeout(endFetchTimer);
697
+ endFetchTimer = null;
698
+ }
699
+ });
700
+ }