@oh-my-pi/pi-ai 6.9.0 → 7.0.0

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.
@@ -50,6 +50,10 @@ function clampReasoningEffort(model: string, effort: ReasoningConfig["effort"]):
50
50
  return "high";
51
51
  }
52
52
 
53
+ if ((modelId === "gpt-5.2" || modelId === "gpt-5.2-codex") && effort === "minimal") {
54
+ return "low";
55
+ }
56
+
53
57
  // gpt-5.1-codex-mini only supports medium/high.
54
58
  if (modelId === "gpt-5.1-codex-mini") {
55
59
  return effort === "high" || effort === "xhigh" ? "high" : "medium";
@@ -105,6 +105,9 @@ export const streamOpenAICodexResponses: StreamFunction<"openai-codex-responses"
105
105
  const stream = new AssistantMessageEventStream();
106
106
 
107
107
  (async () => {
108
+ const startTime = Date.now();
109
+ let firstTokenTime: number | undefined;
110
+
108
111
  const output: AssistantMessage = {
109
112
  role: "assistant",
110
113
  content: [],
@@ -225,6 +228,7 @@ export const streamOpenAICodexResponses: StreamFunction<"openai-codex-responses"
225
228
  if (!eventType) continue;
226
229
 
227
230
  if (eventType === "response.output_item.added") {
231
+ if (!firstTokenTime) firstTokenTime = Date.now();
228
232
  const item = rawEvent.item as ResponseReasoningItem | ResponseOutputMessage | ResponseFunctionToolCall;
229
233
  if (item.type === "reasoning") {
230
234
  currentItem = item;
@@ -412,12 +416,16 @@ export const streamOpenAICodexResponses: StreamFunction<"openai-codex-responses"
412
416
  throw new Error("Codex response failed");
413
417
  }
414
418
 
419
+ output.duration = Date.now() - startTime;
420
+ if (firstTokenTime) output.ttft = firstTokenTime - startTime;
415
421
  stream.push({ type: "done", reason: output.stopReason, message: output });
416
422
  stream.end();
417
423
  } catch (error) {
418
424
  for (const block of output.content) delete (block as { index?: number }).index;
419
425
  output.stopReason = options?.signal?.aborted ? "aborted" : "error";
420
426
  output.errorMessage = formatErrorMessageWithRetryAfter(error);
427
+ output.duration = Date.now() - startTime;
428
+ if (firstTokenTime) output.ttft = firstTokenTime - startTime;
421
429
  stream.push({ type: "error", reason: output.stopReason, error: output });
422
430
  stream.end();
423
431
  }
@@ -563,6 +571,8 @@ function convertMessages(model: Model<"openai-codex-responses">, context: Contex
563
571
  for (const msg of transformedMessages) {
564
572
  if (msg.role === "user") {
565
573
  if (typeof msg.content === "string") {
574
+ // Skip empty user messages
575
+ if (!msg.content || msg.content.trim() === "") continue;
566
576
  messages.push({
567
577
  role: "user",
568
578
  content: [{ type: "input_text", text: sanitizeSurrogates(msg.content) }],
@@ -581,9 +591,16 @@ function convertMessages(model: Model<"openai-codex-responses">, context: Contex
581
591
  image_url: `data:${item.mimeType};base64,${item.data}`,
582
592
  } satisfies ResponseInputImage;
583
593
  });
584
- const filteredContent = !model.input.includes("image")
594
+ // Filter out images if model doesn't support them, and empty text blocks
595
+ let filteredContent = !model.input.includes("image")
585
596
  ? content.filter((c) => c.type !== "input_image")
586
597
  : content;
598
+ filteredContent = filteredContent.filter((c) => {
599
+ if (c.type === "input_text") {
600
+ return c.text.trim().length > 0;
601
+ }
602
+ return true; // Keep non-text content (images)
603
+ });
587
604
  if (filteredContent.length === 0) continue;
588
605
  messages.push({
589
606
  role: "user",
@@ -81,6 +81,9 @@ export const streamOpenAICompletions: StreamFunction<"openai-completions"> = (
81
81
  const stream = new AssistantMessageEventStream();
82
82
 
83
83
  (async () => {
84
+ const startTime = Date.now();
85
+ let firstTokenTime: number | undefined;
86
+
84
87
  const output: AssistantMessage = {
85
88
  role: "assistant",
86
89
  content: [],
@@ -178,6 +181,7 @@ export const streamOpenAICompletions: StreamFunction<"openai-completions"> = (
178
181
  choice.delta.content !== undefined &&
179
182
  choice.delta.content.length > 0
180
183
  ) {
184
+ if (!firstTokenTime) firstTokenTime = Date.now();
181
185
  if (!currentBlock || currentBlock.type !== "text") {
182
186
  finishCurrentBlock(currentBlock);
183
187
  currentBlock = { type: "text", text: "" };
@@ -303,6 +307,8 @@ export const streamOpenAICompletions: StreamFunction<"openai-completions"> = (
303
307
  throw new Error("An unkown error ocurred");
304
308
  }
305
309
 
310
+ output.duration = Date.now() - startTime;
311
+ if (firstTokenTime) output.ttft = firstTokenTime - startTime;
306
312
  stream.push({ type: "done", reason: output.stopReason, message: output });
307
313
  stream.end();
308
314
  } catch (error) {
@@ -312,6 +318,8 @@ export const streamOpenAICompletions: StreamFunction<"openai-completions"> = (
312
318
  // Some providers via OpenRouter include extra details here.
313
319
  const rawMetadata = (error as { error?: { metadata?: { raw?: string } } })?.error?.metadata?.raw;
314
320
  if (rawMetadata) output.errorMessage += `\n${rawMetadata}`;
321
+ output.duration = Date.now() - startTime;
322
+ if (firstTokenTime) output.ttft = firstTokenTime - startTime;
315
323
  stream.push({ type: "error", reason: output.stopReason, error: output });
316
324
  stream.end();
317
325
  }
@@ -69,6 +69,9 @@ export const streamOpenAIResponses: StreamFunction<"openai-responses"> = (
69
69
 
70
70
  // Start async processing
71
71
  (async () => {
72
+ const startTime = Date.now();
73
+ let firstTokenTime: number | undefined;
74
+
72
75
  const output: AssistantMessage = {
73
76
  role: "assistant",
74
77
  content: [],
@@ -107,6 +110,7 @@ export const streamOpenAIResponses: StreamFunction<"openai-responses"> = (
107
110
  for await (const event of openaiStream) {
108
111
  // Handle output item start
109
112
  if (event.type === "response.output_item.added") {
113
+ if (!firstTokenTime) firstTokenTime = Date.now();
110
114
  const item = event.item;
111
115
  if (item.type === "reasoning") {
112
116
  currentItem = item;
@@ -309,12 +313,16 @@ export const streamOpenAIResponses: StreamFunction<"openai-responses"> = (
309
313
  throw new Error("An unkown error ocurred");
310
314
  }
311
315
 
316
+ output.duration = Date.now() - startTime;
317
+ if (firstTokenTime) output.ttft = firstTokenTime - startTime;
312
318
  stream.push({ type: "done", reason: output.stopReason, message: output });
313
319
  stream.end();
314
320
  } catch (error) {
315
321
  for (const block of output.content) delete (block as any).index;
316
322
  output.stopReason = options?.signal?.aborted ? "aborted" : "error";
317
323
  output.errorMessage = formatErrorMessageWithRetryAfter(error);
324
+ output.duration = Date.now() - startTime;
325
+ if (firstTokenTime) output.ttft = firstTokenTime - startTime;
318
326
  stream.push({ type: "error", reason: output.stopReason, error: output });
319
327
  stream.end();
320
328
  }
@@ -460,6 +468,8 @@ function convertMessages(
460
468
  for (const msg of transformedMessages) {
461
469
  if (msg.role === "user") {
462
470
  if (typeof msg.content === "string") {
471
+ // Skip empty user messages
472
+ if (!msg.content || msg.content.trim() === "") continue;
463
473
  messages.push({
464
474
  role: "user",
465
475
  content: [{ type: "input_text", text: sanitizeSurrogates(msg.content) }],
@@ -479,9 +489,16 @@ function convertMessages(
479
489
  } satisfies ResponseInputImage;
480
490
  }
481
491
  });
482
- const filteredContent = !model.input.includes("image")
492
+ // Filter out images if model doesn't support them, and empty text blocks
493
+ let filteredContent = !model.input.includes("image")
483
494
  ? content.filter((c) => c.type !== "input_image")
484
495
  : content;
496
+ filteredContent = filteredContent.filter((c) => {
497
+ if (c.type === "input_text") {
498
+ return c.text.trim().length > 0;
499
+ }
500
+ return true; // Keep non-text content (images)
501
+ });
485
502
  if (filteredContent.length === 0) continue;
486
503
  messages.push({
487
504
  role: "user",
package/src/types.ts CHANGED
@@ -193,6 +193,8 @@ export interface AssistantMessage {
193
193
  stopReason: StopReason;
194
194
  errorMessage?: string;
195
195
  timestamp: number; // Unix timestamp in milliseconds
196
+ duration?: number; // Request duration in milliseconds
197
+ ttft?: number; // Time to first token in milliseconds
196
198
  }
197
199
 
198
200
  export interface ToolResultMessage<TDetails = any, TInput = unknown> {
@@ -0,0 +1,355 @@
1
+ import type {
2
+ UsageAmount,
3
+ UsageFetchContext,
4
+ UsageFetchParams,
5
+ UsageLimit,
6
+ UsageProvider,
7
+ UsageReport,
8
+ UsageStatus,
9
+ UsageWindow,
10
+ } from "../usage";
11
+
12
+ const DEFAULT_ENDPOINT = "https://api.anthropic.com/api/oauth";
13
+ const DEFAULT_CACHE_TTL_MS = 60_000;
14
+ const FIVE_HOURS_MS = 5 * 60 * 60 * 1000;
15
+ const SEVEN_DAYS_MS = 7 * 24 * 60 * 60 * 1000;
16
+ const MAX_RETRIES = 3;
17
+ const BASE_RETRY_DELAY_MS = 500;
18
+
19
+ const CLAUDE_HEADERS = {
20
+ accept: "application/json, text/plain, */*",
21
+ "accept-encoding": "gzip, compress, deflate, br",
22
+ "anthropic-beta": "oauth-2025-04-20",
23
+ "content-type": "application/json",
24
+ "user-agent": "claude-code/2.0.20",
25
+ connection: "keep-alive",
26
+ } as const;
27
+
28
+ function normalizeClaudeBaseUrl(baseUrl?: string): string {
29
+ if (!baseUrl || !baseUrl.trim()) return DEFAULT_ENDPOINT;
30
+ const trimmed = baseUrl.trim().replace(/\/+$/, "");
31
+ const lower = trimmed.toLowerCase();
32
+ if (lower.endsWith("/api/oauth")) return trimmed;
33
+ let url: URL;
34
+ try {
35
+ url = new URL(trimmed);
36
+ } catch {
37
+ return DEFAULT_ENDPOINT;
38
+ }
39
+ let path = url.pathname.replace(/\/+$/, "");
40
+ if (path === "/") path = "";
41
+ if (path.toLowerCase().endsWith("/v1")) {
42
+ path = path.slice(0, -3);
43
+ }
44
+ if (!path) return `${url.origin}/api/oauth`;
45
+ return `${url.origin}${path}/api/oauth`;
46
+ }
47
+
48
+ interface ClaudeUsageBucket {
49
+ utilization?: number;
50
+ resets_at?: string;
51
+ }
52
+
53
+ interface ParsedUsageBucket {
54
+ utilization?: number;
55
+ resetsAt?: number;
56
+ }
57
+
58
+ interface ClaudeUsageResponse {
59
+ five_hour?: ClaudeUsageBucket | null;
60
+ seven_day?: ClaudeUsageBucket | null;
61
+ seven_day_opus?: ClaudeUsageBucket | null;
62
+ seven_day_sonnet?: ClaudeUsageBucket | null;
63
+ }
64
+
65
+ type ClaudeUsagePayload = {
66
+ payload: ClaudeUsageResponse;
67
+ orgId?: string;
68
+ };
69
+
70
+ function isRecord(value: unknown): value is Record<string, unknown> {
71
+ return typeof value === "object" && value !== null && !Array.isArray(value);
72
+ }
73
+
74
+ function toNumber(value: unknown): number | undefined {
75
+ if (typeof value === "number" && Number.isFinite(value)) return value;
76
+ if (typeof value === "string" && value.trim()) {
77
+ const parsed = Number(value);
78
+ return Number.isFinite(parsed) ? parsed : undefined;
79
+ }
80
+ return undefined;
81
+ }
82
+
83
+ function parseIsoTime(value: string | undefined): number | undefined {
84
+ if (!value) return undefined;
85
+ const parsed = Date.parse(value);
86
+ return Number.isFinite(parsed) ? parsed : undefined;
87
+ }
88
+
89
+ function parseBucket(bucket: unknown): ParsedUsageBucket | undefined {
90
+ if (!isRecord(bucket)) return undefined;
91
+ const utilization = toNumber(bucket.utilization);
92
+ const resetsAt = parseIsoTime(typeof bucket.resets_at === "string" ? bucket.resets_at : undefined);
93
+ if (utilization === undefined && resetsAt === undefined) {
94
+ if ("utilization" in bucket || "resets_at" in bucket) {
95
+ return { utilization: 0, resetsAt: undefined };
96
+ }
97
+ return undefined;
98
+ }
99
+ return { utilization, resetsAt };
100
+ }
101
+
102
+ function getPayloadString(payload: Record<string, unknown>, key: string): string | undefined {
103
+ const value = payload[key];
104
+ return typeof value === "string" && value.trim() ? value.trim() : undefined;
105
+ }
106
+
107
+ function extractUsageIdentity(payload: ClaudeUsageResponse, orgId?: string): { accountId?: string; email?: string } {
108
+ if (!isRecord(payload)) return { accountId: orgId };
109
+ const accountId =
110
+ getPayloadString(payload, "account_id") ??
111
+ getPayloadString(payload, "accountId") ??
112
+ getPayloadString(payload, "user_id") ??
113
+ getPayloadString(payload, "userId") ??
114
+ getPayloadString(payload, "org_id") ??
115
+ getPayloadString(payload, "orgId") ??
116
+ orgId;
117
+ const email =
118
+ getPayloadString(payload, "email") ??
119
+ getPayloadString(payload, "user_email") ??
120
+ getPayloadString(payload, "userEmail");
121
+ return { accountId, email };
122
+ }
123
+
124
+ function hasUsageData(payload: ClaudeUsageResponse): boolean {
125
+ return Boolean(payload.five_hour || payload.seven_day || payload.seven_day_opus || payload.seven_day_sonnet);
126
+ }
127
+
128
+ async function fetchUsagePayload(
129
+ url: string,
130
+ headers: Record<string, string>,
131
+ ctx: UsageFetchContext,
132
+ signal?: AbortSignal,
133
+ ): Promise<ClaudeUsagePayload | null> {
134
+ let lastPayload: ClaudeUsageResponse | null = null;
135
+ let lastOrgId: string | undefined;
136
+ for (let attempt = 0; attempt < MAX_RETRIES; attempt++) {
137
+ try {
138
+ const response = await ctx.fetch(url, { headers, signal });
139
+ if (!response.ok) {
140
+ ctx.logger?.warn("Claude usage fetch failed", { status: response.status, statusText: response.statusText });
141
+ return null;
142
+ }
143
+ const payload = (await response.json()) as ClaudeUsageResponse;
144
+ lastPayload = payload;
145
+ const orgId = response.headers.get("anthropic-organization-id")?.trim() || undefined;
146
+ lastOrgId = orgId ?? lastOrgId;
147
+ if (payload && isRecord(payload) && hasUsageData(payload)) {
148
+ return { payload, orgId };
149
+ }
150
+ } catch (error) {
151
+ ctx.logger?.warn("Claude usage fetch error", { error: String(error) });
152
+ return null;
153
+ }
154
+
155
+ if (attempt < MAX_RETRIES - 1) {
156
+ await Bun.sleep(BASE_RETRY_DELAY_MS * 2 ** attempt);
157
+ }
158
+ }
159
+
160
+ return lastPayload ? { payload: lastPayload, orgId: lastOrgId } : null;
161
+ }
162
+
163
+ function buildUsageAmount(utilization: number | undefined): UsageAmount | undefined {
164
+ if (utilization === undefined) return undefined;
165
+ const clamped = Math.min(Math.max(utilization, 0), 100);
166
+ const usedFraction = clamped / 100;
167
+ return {
168
+ used: clamped,
169
+ limit: 100,
170
+ remaining: Math.max(0, 100 - clamped),
171
+ usedFraction,
172
+ remainingFraction: Math.max(0, 1 - usedFraction),
173
+ unit: "percent",
174
+ };
175
+ }
176
+
177
+ function buildUsageWindow(
178
+ id: string,
179
+ label: string,
180
+ durationMs: number,
181
+ resetsAt: number | undefined,
182
+ now: number,
183
+ ): UsageWindow {
184
+ const resolvedResetAt = resetsAt ?? now + durationMs;
185
+ const resetInMs = Math.max(0, resolvedResetAt - now);
186
+ return {
187
+ id,
188
+ label,
189
+ durationMs,
190
+ resetsAt: resolvedResetAt,
191
+ resetInMs,
192
+ };
193
+ }
194
+
195
+ function buildUsageStatus(usedFraction: number | undefined): UsageStatus | undefined {
196
+ if (usedFraction === undefined) return undefined;
197
+ if (usedFraction >= 1) return "exhausted";
198
+ if (usedFraction >= 0.9) return "warning";
199
+ return "ok";
200
+ }
201
+
202
+ function buildUsageLimit(args: {
203
+ id: string;
204
+ label: string;
205
+ windowId: string;
206
+ windowLabel: string;
207
+ durationMs: number;
208
+ bucket: ParsedUsageBucket | undefined;
209
+ provider: "anthropic";
210
+ tier?: string;
211
+ shared?: boolean;
212
+ now: number;
213
+ }): UsageLimit | null {
214
+ if (!args.bucket) return null;
215
+ const amount = buildUsageAmount(args.bucket.utilization);
216
+ if (!amount) return null;
217
+ const window = buildUsageWindow(args.windowId, args.windowLabel, args.durationMs, args.bucket.resetsAt, args.now);
218
+ return {
219
+ id: args.id,
220
+ label: args.label,
221
+ scope: {
222
+ provider: args.provider,
223
+ windowId: args.windowId,
224
+ tier: args.tier,
225
+ shared: args.shared,
226
+ },
227
+ window,
228
+ amount,
229
+ status: buildUsageStatus(amount.usedFraction),
230
+ };
231
+ }
232
+
233
+ function buildCacheKey(params: UsageFetchParams): string {
234
+ const credential = params.credential;
235
+ const account = credential.accountId ?? credential.email ?? "unknown";
236
+ const token = credential.accessToken ?? credential.refreshToken;
237
+ const fingerprint = token && typeof token === "string" ? Bun.hash(token).toString(16) : "anonymous";
238
+ const baseUrl = params.baseUrl ?? DEFAULT_ENDPOINT;
239
+ return `usage:${params.provider}:${account}:${fingerprint}:${baseUrl}`;
240
+ }
241
+
242
+ function resolveCacheExpiry(now: number, limits: UsageLimit[]): number {
243
+ const earliestReset = limits
244
+ .map((limit) => limit.window?.resetsAt)
245
+ .filter((value): value is number => typeof value === "number" && Number.isFinite(value))
246
+ .reduce((min, value) => (min === undefined ? value : Math.min(min, value)), undefined as number | undefined);
247
+ const exhausted = limits.some((limit) => limit.status === "exhausted");
248
+ if (earliestReset === undefined) return now + DEFAULT_CACHE_TTL_MS;
249
+ if (exhausted) return earliestReset;
250
+ return Math.min(now + DEFAULT_CACHE_TTL_MS, earliestReset);
251
+ }
252
+
253
+ async function fetchClaudeUsage(params: UsageFetchParams, ctx: UsageFetchContext): Promise<UsageReport | null> {
254
+ if (params.provider !== "anthropic") return null;
255
+ const credential = params.credential;
256
+ if (credential.type !== "oauth" || !credential.accessToken) return null;
257
+
258
+ const cacheKey = buildCacheKey(params);
259
+ const cachedEntry = await ctx.cache.get(cacheKey);
260
+ const now = ctx.now();
261
+ if (cachedEntry && cachedEntry.expiresAt > now) {
262
+ return cachedEntry.value;
263
+ }
264
+ const cachedValue = cachedEntry?.value ?? null;
265
+
266
+ const baseUrl = normalizeClaudeBaseUrl(params.baseUrl);
267
+ const url = `${baseUrl}/usage`;
268
+ const headers: Record<string, string> = {
269
+ ...CLAUDE_HEADERS,
270
+ authorization: `Bearer ${credential.accessToken}`,
271
+ };
272
+
273
+ const payloadResult = await fetchUsagePayload(url, headers, ctx, params.signal);
274
+ if (!payloadResult || !isRecord(payloadResult.payload)) return cachedValue;
275
+ const { payload, orgId } = payloadResult;
276
+
277
+ const fiveHour = parseBucket(payload.five_hour);
278
+ const sevenDay = parseBucket(payload.seven_day);
279
+ const sevenDayOpus = parseBucket(payload.seven_day_opus);
280
+ const sevenDaySonnet = parseBucket(payload.seven_day_sonnet);
281
+
282
+ const limits = [
283
+ buildUsageLimit({
284
+ id: "anthropic:5h",
285
+ label: "Claude 5 Hour",
286
+ windowId: "5h",
287
+ windowLabel: "5 Hour",
288
+ durationMs: FIVE_HOURS_MS,
289
+ bucket: fiveHour,
290
+ provider: "anthropic",
291
+ shared: true,
292
+ now,
293
+ }),
294
+ buildUsageLimit({
295
+ id: "anthropic:7d",
296
+ label: "Claude 7 Day",
297
+ windowId: "7d",
298
+ windowLabel: "7 Day",
299
+ durationMs: SEVEN_DAYS_MS,
300
+ bucket: sevenDay,
301
+ provider: "anthropic",
302
+ shared: true,
303
+ now,
304
+ }),
305
+ buildUsageLimit({
306
+ id: "anthropic:7d:opus",
307
+ label: "Claude 7 Day (Opus)",
308
+ windowId: "7d",
309
+ windowLabel: "7 Day",
310
+ durationMs: SEVEN_DAYS_MS,
311
+ bucket: sevenDayOpus,
312
+ provider: "anthropic",
313
+ tier: "opus",
314
+ now,
315
+ }),
316
+ buildUsageLimit({
317
+ id: "anthropic:7d:sonnet",
318
+ label: "Claude 7 Day (Sonnet)",
319
+ windowId: "7d",
320
+ windowLabel: "7 Day",
321
+ durationMs: SEVEN_DAYS_MS,
322
+ bucket: sevenDaySonnet,
323
+ provider: "anthropic",
324
+ tier: "sonnet",
325
+ now,
326
+ }),
327
+ ].filter((limit): limit is UsageLimit => limit !== null);
328
+
329
+ if (limits.length === 0) return cachedValue;
330
+ const identity = extractUsageIdentity(payload, orgId);
331
+ const accountId = identity.accountId ?? credential.accountId;
332
+ const email = identity.email ?? credential.email;
333
+
334
+ const report: UsageReport = {
335
+ provider: params.provider,
336
+ fetchedAt: now,
337
+ limits,
338
+ metadata: {
339
+ accountId,
340
+ email,
341
+ endpoint: url,
342
+ },
343
+ raw: payload,
344
+ };
345
+
346
+ const expiresAt = resolveCacheExpiry(now, limits);
347
+ await ctx.cache.set(cacheKey, { value: report, expiresAt });
348
+ return report;
349
+ }
350
+
351
+ export const claudeUsageProvider: UsageProvider = {
352
+ id: "anthropic",
353
+ fetchUsage: fetchClaudeUsage,
354
+ supports: (params) => params.provider === "anthropic" && params.credential.type === "oauth",
355
+ };