@oh-my-pi/pi-ai 17.1.1 → 17.1.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.
@@ -15,6 +15,7 @@ import type { AuthStorage, StoredCredentialBlock } from "../auth-storage";
15
15
  import { parseBind } from "../utils/parse-bind";
16
16
  import { AuthBrokerRefresher, type AuthBrokerRefresherSchedule } from "./refresher";
17
17
  import type {
18
+ ClientUsageReportRequest,
18
19
  CredentialBlockResponse,
19
20
  CredentialBlockSnapshot,
20
21
  CredentialBlocksDeleteResponse,
@@ -37,6 +38,7 @@ import {
37
38
  DEFAULT_STREAM_KEEPALIVE_MS,
38
39
  } from "./types";
39
40
  import {
41
+ clientUsageReportRequestSchema,
40
42
  credentialBlockRequestSchema,
41
43
  credentialDisableRequestSchema,
42
44
  credentialUploadRequestSchema,
@@ -608,6 +610,44 @@ export function startAuthBroker(opts: AuthBrokerServerOptions): AuthBrokerServer
608
610
  return json(502, { error: message });
609
611
  }
610
612
  }
613
+ if (req.method === "GET" && pathname === "/v1/usage/history") {
614
+ const sinceMsRaw = url.searchParams.get("sinceMs");
615
+ const sinceMsParsed = sinceMsRaw === null ? undefined : Number.parseInt(sinceMsRaw, 10);
616
+ const sinceMs =
617
+ sinceMsParsed !== undefined && Number.isFinite(sinceMsParsed) ? sinceMsParsed : undefined;
618
+ const provider = url.searchParams.get("provider") ?? undefined;
619
+ const entries = opts.storage.listUsageHistory({ sinceMs, provider });
620
+ logger.info("auth-broker usage history served", { peer, entries: entries.length, sinceMs, provider });
621
+ return json(200, { generatedAt: Date.now(), entries });
622
+ }
623
+ if (req.method === "POST" && pathname === "/v1/usage/observed") {
624
+ const parsed = await parseBody(req, clientUsageReportRequestSchema);
625
+ if (!parsed.ok) return parsed.response;
626
+ // Arktype's inferred union collides the `entries` field with
627
+ // Array.prototype.entries; the schema already validated the shape.
628
+ const report = parsed.data as ClientUsageReportRequest;
629
+ try {
630
+ const recorded = opts.storage.recordClientUsage(report);
631
+ if (!recorded) return json(501, { error: "broker store does not persist client usage" });
632
+ logger.debug("auth-broker client usage recorded", {
633
+ peer,
634
+ installId: report.installId,
635
+ hostname: report.hostname,
636
+ entries: report.entries.length,
637
+ });
638
+ return json(200, { ok: true });
639
+ } catch (error) {
640
+ const message = error instanceof Error ? error.message : String(error);
641
+ logger.warn("auth-broker client usage record failed", { peer, error: message });
642
+ return json(500, { error: message });
643
+ }
644
+ }
645
+ if (req.method === "GET" && pathname === "/v1/usage/clients") {
646
+ const sinceMsRaw = url.searchParams.get("sinceMs");
647
+ const sinceMsParsed = sinceMsRaw === null ? Number.NaN : Number.parseInt(sinceMsRaw, 10);
648
+ const summary = opts.storage.getClientUsageSummary(Number.isFinite(sinceMsParsed) ? sinceMsParsed : 0);
649
+ return json(200, { generatedAt: Date.now(), clients: summary.clients });
650
+ }
611
651
  if (req.method === "POST" && pathname === "/v1/usage/stale") {
612
652
  try {
613
653
  opts.storage.invalidateUsageCache?.();
@@ -12,7 +12,7 @@ import type {
12
12
  AuthCredentialSnapshotEntry,
13
13
  StoredCredentialBlock,
14
14
  } from "../auth-storage";
15
- import type { UsageReport } from "../usage";
15
+ import type { ClientUsageClientSummary, ClientUsageReport, UsageHistoryEntry, UsageReport } from "../usage";
16
16
 
17
17
  /** GET /v1/healthz response body. */
18
18
  export interface HealthzResponse {
@@ -47,6 +47,30 @@ export interface UsageResponse {
47
47
  reports: UsageReport[];
48
48
  }
49
49
 
50
+ /**
51
+ * GET /v1/usage/history response body. Entries come from the broker host's
52
+ * durable `usage_history` — the broker performs every upstream usage fetch in
53
+ * broker deployments, so this is the only complete utilization record.
54
+ */
55
+ export interface UsageHistoryResponse {
56
+ generatedAt: number;
57
+ entries: UsageHistoryEntry[];
58
+ }
59
+
60
+ /** POST /v1/usage/observed request body — one client's batched observed usage. */
61
+ export type ClientUsageReportRequest = ClientUsageReport;
62
+
63
+ /** POST /v1/usage/observed response body. */
64
+ export interface ClientUsageReportResponse {
65
+ ok: boolean;
66
+ }
67
+
68
+ /** GET /v1/usage/clients response body — per-client token burn aggregates. */
69
+ export interface ClientUsageSummaryResponse {
70
+ generatedAt: number;
71
+ clients: ClientUsageClientSummary[];
72
+ }
73
+
50
74
  /** POST /v1/credential/:id/refresh response body. */
51
75
  export interface CredentialRefreshResponse {
52
76
  entry: AuthCredentialSnapshotEntry;
@@ -230,6 +230,77 @@ export const usageResponseSchema = type({
230
230
  reports: arkUsageReportSchema.array(),
231
231
  });
232
232
 
233
+ const usageHistoryEntrySchema = type({
234
+ recordedAt: "number",
235
+ provider: "string",
236
+ accountKey: "string",
237
+ "email?": "string",
238
+ "accountId?": "string",
239
+ limitId: "string",
240
+ label: "string",
241
+ "windowLabel?": "string",
242
+ "usedFraction?": "number",
243
+ "status?": "'ok' | 'warning' | 'exhausted' | 'unknown'",
244
+ "resetsAt?": "number",
245
+ });
246
+
247
+ /** Broker `/v1/usage/history` response — recorded usage-limit snapshots, oldest first. */
248
+ export const usageHistoryResponseSchema = type({
249
+ "+": "reject",
250
+ generatedAt: "number",
251
+ entries: usageHistoryEntrySchema.array(),
252
+ });
253
+
254
+ const observedUsageEntrySchema = type({
255
+ at: "number",
256
+ provider: "string",
257
+ model: "string",
258
+ requests: "number",
259
+ inputTokens: "number",
260
+ outputTokens: "number",
261
+ cacheReadTokens: "number",
262
+ cacheWriteTokens: "number",
263
+ costUsd: "number",
264
+ });
265
+
266
+ /** Broker `POST /v1/usage/observed` request — one client's batched observed usage. */
267
+ export const clientUsageReportRequestSchema = type({
268
+ "+": "reject",
269
+ installId: "string",
270
+ "hostname?": "string",
271
+ entries: observedUsageEntrySchema.array(),
272
+ });
273
+
274
+ export const clientUsageReportResponseSchema = type({
275
+ "+": "reject",
276
+ ok: "boolean",
277
+ });
278
+
279
+ const clientProviderUsageSchema = type({
280
+ provider: "string",
281
+ requests: "number",
282
+ inputTokens: "number",
283
+ outputTokens: "number",
284
+ cacheReadTokens: "number",
285
+ cacheWriteTokens: "number",
286
+ costUsd: "number",
287
+ });
288
+
289
+ const clientUsageClientSummarySchema = type({
290
+ installId: "string",
291
+ "hostname?": "string",
292
+ firstSeen: "number",
293
+ lastSeen: "number",
294
+ providers: clientProviderUsageSchema.array(),
295
+ });
296
+
297
+ /** Broker `GET /v1/usage/clients` response — per-client token burn aggregates. */
298
+ export const clientUsageSummaryResponseSchema = type({
299
+ "+": "reject",
300
+ generatedAt: "number",
301
+ clients: clientUsageClientSummarySchema.array(),
302
+ });
303
+
233
304
  // ─── Refresh ───────────────────────────────────────────────────────────────
234
305
 
235
306
  export const credentialRefreshResponseSchema = type({
@@ -28,8 +28,12 @@ import type {
28
28
  import { getEnvApiKey, getEnvApiKeyName } from "./stream";
29
29
  import type { Provider } from "./types";
30
30
  import type {
31
+ ClientProviderUsage,
32
+ ClientUsageReport,
33
+ ClientUsageSummary,
31
34
  CredentialRankingContext,
32
35
  CredentialRankingStrategy,
36
+ ObservedUsageEntry,
33
37
  UsageCostHistoryEntry,
34
38
  UsageCostHistoryQuery,
35
39
  UsageCredential,
@@ -399,6 +403,16 @@ export interface AuthCredentialStore {
399
403
  listUsageCosts?(query?: UsageCostHistoryQuery): UsageCostHistoryEntry[];
400
404
  /** Read recorded usage-limit snapshots, oldest first. */
401
405
  listUsageHistory?(query?: UsageHistoryQuery): UsageHistoryEntry[];
406
+ /**
407
+ * Client hook: forward locally observed request usage. Remote broker stores
408
+ * batch these to the broker so it can attribute token burn per install;
409
+ * local stores omit it and observation is skipped.
410
+ */
411
+ recordObservedUsage?(entries: ObservedUsageEntry[]): void;
412
+ /** Broker host: persist one client's observed-usage report. */
413
+ recordClientUsage?(report: ClientUsageReport): void;
414
+ /** Broker host: aggregate recorded per-client usage since a timestamp. */
415
+ getClientUsageSummary?(sinceMs: number): ClientUsageSummary;
402
416
  /**
403
417
  * Optional store-supplied OAuth refresh. When present, `AuthStorage` uses
404
418
  * it before the per-provider local refresh path. `RemoteAuthCredentialStore`
@@ -624,6 +638,12 @@ const USAGE_LAST_GOOD_RETENTION_MS = 24 * 60 * 60_000;
624
638
  * unnecessary — 1 row/hour is ~9k rows per account window per year.
625
639
  */
626
640
  const USAGE_HISTORY_BUCKET_MS = 60 * 60_000;
641
+ /**
642
+ * Merge client observed-usage flushes into at most one row per 5 minutes per
643
+ * (install, provider, model): ~300 rows/day per active model per client
644
+ * instead of one row per 10s flush.
645
+ */
646
+ const CLIENT_USAGE_BUCKET_MS = 5 * 60_000;
627
647
  /**
628
648
  * Per-credential cool-down after a usage fetch fails. While this window is
629
649
  * active we serve the last successful value to avoid dropping the credential
@@ -3142,6 +3162,56 @@ export class AuthStorage {
3142
3162
  }
3143
3163
  }
3144
3164
 
3165
+ /**
3166
+ * Forward one completed request's usage to the store's observer hook.
3167
+ * Broker-backed stores batch these into per-install reports so the broker
3168
+ * can track actual token burn per client; local stores have no hook and
3169
+ * the call is a no-op.
3170
+ */
3171
+ recordObservedUsage(entry: {
3172
+ provider: Provider;
3173
+ model: string;
3174
+ usage: { input: number; output: number; cacheRead: number; cacheWrite: number };
3175
+ costUsd?: number;
3176
+ at?: number;
3177
+ }): void {
3178
+ const record = this.#store.recordObservedUsage;
3179
+ if (!record) return;
3180
+ try {
3181
+ record.call(this.#store, [
3182
+ {
3183
+ at: entry.at ?? Date.now(),
3184
+ provider: entry.provider,
3185
+ model: entry.model,
3186
+ requests: 1,
3187
+ inputTokens: entry.usage.input,
3188
+ outputTokens: entry.usage.output,
3189
+ cacheReadTokens: entry.usage.cacheRead,
3190
+ cacheWriteTokens: entry.usage.cacheWrite,
3191
+ costUsd: Number.isFinite(entry.costUsd) ? (entry.costUsd ?? 0) : 0,
3192
+ },
3193
+ ]);
3194
+ } catch (error) {
3195
+ this.#usageLogger?.debug("observed usage record failed", {
3196
+ provider: entry.provider,
3197
+ error: String(error),
3198
+ });
3199
+ }
3200
+ }
3201
+
3202
+ /** Broker host: persist one client's observed-usage report (per-install token burn). */
3203
+ recordClientUsage(report: ClientUsageReport): boolean {
3204
+ const record = this.#store.recordClientUsage;
3205
+ if (!record) return false;
3206
+ record.call(this.#store, report);
3207
+ return true;
3208
+ }
3209
+
3210
+ /** Broker host: aggregate recorded per-client usage since `sinceMs`. */
3211
+ getClientUsageSummary(sinceMs: number): ClientUsageSummary {
3212
+ return this.#store.getClientUsageSummary?.(sinceMs) ?? { clients: [] };
3213
+ }
3214
+
3145
3215
  #resolveObservedUsageCredential(provider: Provider, sessionId?: string): UsageCredential | undefined {
3146
3216
  const entries = this.#getStoredCredentials(provider);
3147
3217
  const sessionCredential = this.#getSessionCredential(provider, sessionId);
@@ -6603,6 +6673,27 @@ export class SqliteAuthCredentialStore implements AuthCredentialStore {
6603
6673
  );
6604
6674
  CREATE INDEX IF NOT EXISTS idx_usage_cost_history_lookup ON usage_cost_history(provider, account_key, recorded_at);
6605
6675
  CREATE INDEX IF NOT EXISTS idx_usage_history_recorded ON usage_history(recorded_at);
6676
+ CREATE TABLE IF NOT EXISTS clients (
6677
+ install_id TEXT PRIMARY KEY,
6678
+ hostname TEXT,
6679
+ first_seen INTEGER NOT NULL,
6680
+ last_seen INTEGER NOT NULL
6681
+ );
6682
+ CREATE TABLE IF NOT EXISTS client_usage (
6683
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
6684
+ recorded_at INTEGER NOT NULL,
6685
+ install_id TEXT NOT NULL,
6686
+ provider TEXT NOT NULL,
6687
+ model TEXT NOT NULL,
6688
+ requests INTEGER NOT NULL,
6689
+ input_tokens INTEGER NOT NULL,
6690
+ output_tokens INTEGER NOT NULL,
6691
+ cache_read_tokens INTEGER NOT NULL,
6692
+ cache_write_tokens INTEGER NOT NULL,
6693
+ cost_usd REAL NOT NULL DEFAULT 0
6694
+ );
6695
+ CREATE INDEX IF NOT EXISTS idx_client_usage_series ON client_usage(install_id, provider, model, recorded_at);
6696
+ CREATE INDEX IF NOT EXISTS idx_client_usage_recorded ON client_usage(recorded_at);
6606
6697
  `);
6607
6698
 
6608
6699
  if (!this.#authCredentialsTableExists()) {
@@ -7381,6 +7472,113 @@ export class SqliteAuthCredentialStore implements AuthCredentialStore {
7381
7472
  }
7382
7473
  }
7383
7474
 
7475
+ recordClientUsage(report: ClientUsageReport): void {
7476
+ const now = Date.now();
7477
+ this.#db
7478
+ .query(
7479
+ `INSERT INTO clients (install_id, hostname, first_seen, last_seen) VALUES (?, ?, ?, ?)
7480
+ ON CONFLICT(install_id) DO UPDATE SET hostname = COALESCE(excluded.hostname, hostname), last_seen = excluded.last_seen`,
7481
+ )
7482
+ .run(report.installId, report.hostname ?? null, now, now);
7483
+ const findBucket = this.#db.query(
7484
+ `SELECT id FROM client_usage
7485
+ WHERE install_id = ? AND provider = ? AND model = ? AND recorded_at >= ?
7486
+ ORDER BY recorded_at DESC LIMIT 1`,
7487
+ );
7488
+ const merge = this.#db.query(
7489
+ `UPDATE client_usage SET recorded_at = ?, requests = requests + ?, input_tokens = input_tokens + ?,
7490
+ output_tokens = output_tokens + ?, cache_read_tokens = cache_read_tokens + ?,
7491
+ cache_write_tokens = cache_write_tokens + ?, cost_usd = cost_usd + ? WHERE id = ?`,
7492
+ );
7493
+ const insert = this.#db.query(
7494
+ `INSERT INTO client_usage (recorded_at, install_id, provider, model, requests, input_tokens, output_tokens, cache_read_tokens, cache_write_tokens, cost_usd)
7495
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
7496
+ );
7497
+ for (const entry of report.entries) {
7498
+ // Merge into the newest row of the same (install, provider, model)
7499
+ // bucket so 10s client flushes don't accrete one row apiece forever.
7500
+ const bucketFloor = entry.at - CLIENT_USAGE_BUCKET_MS;
7501
+ const existing = findBucket.get(report.installId, entry.provider, entry.model, bucketFloor) as {
7502
+ id: number;
7503
+ } | null;
7504
+ if (existing) {
7505
+ merge.run(
7506
+ entry.at,
7507
+ entry.requests,
7508
+ entry.inputTokens,
7509
+ entry.outputTokens,
7510
+ entry.cacheReadTokens,
7511
+ entry.cacheWriteTokens,
7512
+ entry.costUsd,
7513
+ existing.id,
7514
+ );
7515
+ continue;
7516
+ }
7517
+ insert.run(
7518
+ entry.at,
7519
+ report.installId,
7520
+ entry.provider,
7521
+ entry.model,
7522
+ entry.requests,
7523
+ entry.inputTokens,
7524
+ entry.outputTokens,
7525
+ entry.cacheReadTokens,
7526
+ entry.cacheWriteTokens,
7527
+ entry.costUsd,
7528
+ );
7529
+ }
7530
+ }
7531
+
7532
+ getClientUsageSummary(sinceMs: number): ClientUsageSummary {
7533
+ const clients = this.#db
7534
+ .query("SELECT install_id, hostname, first_seen, last_seen FROM clients ORDER BY last_seen DESC")
7535
+ .all() as Array<{ install_id: string; hostname: string | null; first_seen: number; last_seen: number }>;
7536
+ const aggregates = this.#db
7537
+ .query(
7538
+ `SELECT install_id, provider, SUM(requests) requests, SUM(input_tokens) input_tokens,
7539
+ SUM(output_tokens) output_tokens, SUM(cache_read_tokens) cache_read_tokens,
7540
+ SUM(cache_write_tokens) cache_write_tokens, SUM(cost_usd) cost_usd
7541
+ FROM client_usage WHERE recorded_at >= ? GROUP BY install_id, provider
7542
+ ORDER BY install_id, SUM(input_tokens + output_tokens + cache_read_tokens + cache_write_tokens) DESC`,
7543
+ )
7544
+ .all(sinceMs) as Array<{
7545
+ install_id: string;
7546
+ provider: string;
7547
+ requests: number;
7548
+ input_tokens: number;
7549
+ output_tokens: number;
7550
+ cache_read_tokens: number;
7551
+ cache_write_tokens: number;
7552
+ cost_usd: number;
7553
+ }>;
7554
+ const providersByInstall = new Map<string, ClientProviderUsage[]>();
7555
+ for (const row of aggregates) {
7556
+ let list = providersByInstall.get(row.install_id);
7557
+ if (!list) {
7558
+ list = [];
7559
+ providersByInstall.set(row.install_id, list);
7560
+ }
7561
+ list.push({
7562
+ provider: row.provider,
7563
+ requests: row.requests,
7564
+ inputTokens: row.input_tokens,
7565
+ outputTokens: row.output_tokens,
7566
+ cacheReadTokens: row.cache_read_tokens,
7567
+ cacheWriteTokens: row.cache_write_tokens,
7568
+ costUsd: row.cost_usd,
7569
+ });
7570
+ }
7571
+ return {
7572
+ clients: clients.map(client => ({
7573
+ installId: client.install_id,
7574
+ hostname: client.hostname ?? undefined,
7575
+ firstSeen: client.first_seen,
7576
+ lastSeen: client.last_seen,
7577
+ providers: providersByInstall.get(client.install_id) ?? [],
7578
+ })),
7579
+ };
7580
+ }
7581
+
7384
7582
  // ─── Convenience methods for CLI ────────────────────────────────────────
7385
7583
 
7386
7584
  /**
@@ -4,6 +4,7 @@ import { type } from "arktype";
4
4
  import { captureRequestHeaders, resolvePromptCacheKey } from "../auth-gateway/http";
5
5
  import * as AIError from "../error";
6
6
  import type {
7
+ AnthropicServerToolContent,
7
8
  AssistantMessage,
8
9
  AssistantMessageEventStream,
9
10
  Message,
@@ -26,6 +27,7 @@ import {
26
27
  type AnthropicUserContentBlock,
27
28
  anthropicMessagesRequestSchema,
28
29
  } from "./anthropic-messages-server-schema";
30
+ import { isAnthropicWebSearchHistoryBlock } from "./anthropic-wire";
29
31
 
30
32
  /**
31
33
  * Anthropic Messages API (https://docs.anthropic.com/en/api/messages) ↔ pi-ai
@@ -181,8 +183,8 @@ function walkUserContent(
181
183
 
182
184
  function walkAssistantContent(
183
185
  blocks: string | AnthropicAssistantContentBlock[],
184
- ): (TextContent | ThinkingContent | RedactedThinkingContent | ToolCall)[] {
185
- const out: (TextContent | ThinkingContent | RedactedThinkingContent | ToolCall)[] = [];
186
+ ): (TextContent | ThinkingContent | RedactedThinkingContent | AnthropicServerToolContent | ToolCall)[] {
187
+ const out: (TextContent | ThinkingContent | RedactedThinkingContent | AnthropicServerToolContent | ToolCall)[] = [];
186
188
  if (typeof blocks === "string") {
187
189
  if (blocks.length > 0) out.push({ type: "text", text: blocks });
188
190
  return out;
@@ -212,8 +214,24 @@ function walkAssistantContent(
212
214
  : {},
213
215
  });
214
216
  break;
217
+ case "server_tool_use":
218
+ case "web_search_tool_result":
219
+ if (isAnthropicWebSearchHistoryBlock(block)) {
220
+ // Native web-search call/result. Anthropic requires these
221
+ // replayed verbatim (encrypted_content included), so retain
222
+ // the block instead of flattening it to text.
223
+ out.push({ type: "anthropicServerTool", block: { ...block } });
224
+ } else {
225
+ // Other server tools use distinct result block types that omp
226
+ // cannot yet replay atomically. Flatten both sides rather than
227
+ // persisting a lone server_tool_use without its matching result.
228
+ const unknown = block as { type: string };
229
+ warnUnknownBlockType("assistant", unknown.type);
230
+ out.push({ type: "text", text: describeUnknownBlock(unknown) });
231
+ }
232
+ break;
215
233
  default: {
216
- // Unknown assistant variant (server_tool_use, mcp_tool_use, …).
234
+ // Unknown assistant variant (mcp_tool_use, code_execution_*, …).
217
235
  // Flatten to a text placeholder; warn once per unknown type.
218
236
  const unknown = block as { type: string };
219
237
  warnUnknownBlockType("assistant", unknown.type);
@@ -457,6 +475,9 @@ function encodeContentBlocks(message: AssistantMessage): Record<string, unknown>
457
475
  case "redactedThinking":
458
476
  blocks.push({ type: "redacted_thinking", data: c.data });
459
477
  break;
478
+ case "anthropicServerTool":
479
+ blocks.push(c.block);
480
+ break;
460
481
  case "toolCall":
461
482
  blocks.push({ type: "tool_use", id: c.id, name: c.name, input: c.arguments ?? {} });
462
483
  break;
@@ -575,6 +596,25 @@ export function encodeStream(
575
596
  );
576
597
  };
577
598
 
599
+ let nextContentIndexToInspect = 0;
600
+ const emitServerToolBlocksBefore = (message: AssistantMessage, beforeIndex: number) => {
601
+ const limit = Math.min(beforeIndex, message.content.length);
602
+ while (nextContentIndexToInspect < limit) {
603
+ const index = nextContentIndexToInspect++;
604
+ const content = message.content[index];
605
+ if (content?.type !== "anthropicServerTool") continue;
606
+ ensureStart(message);
607
+ controller.enqueue(
608
+ sseFrame("content_block_start", {
609
+ type: "content_block_start",
610
+ index,
611
+ content_block: content.block,
612
+ }),
613
+ );
614
+ controller.enqueue(sseFrame("content_block_stop", { type: "content_block_stop", index }));
615
+ }
616
+ };
617
+
578
618
  const closeBlock = (index: number) => {
579
619
  if (!open.has(index)) return;
580
620
  controller.enqueue(sseFrame("content_block_stop", { type: "content_block_stop", index }));
@@ -606,6 +646,7 @@ export function encodeStream(
606
646
  ensureStart(ev.partial);
607
647
  break;
608
648
  case "text_start": {
649
+ emitServerToolBlocksBefore(ev.partial, ev.contentIndex);
609
650
  ensureStart(ev.partial);
610
651
  open.set(ev.contentIndex, { index: ev.contentIndex, kind: "text" });
611
652
  controller.enqueue(
@@ -630,6 +671,7 @@ export function encodeStream(
630
671
  closeBlock(ev.contentIndex);
631
672
  break;
632
673
  case "thinking_start": {
674
+ emitServerToolBlocksBefore(ev.partial, ev.contentIndex);
633
675
  ensureStart(ev.partial);
634
676
  open.set(ev.contentIndex, { index: ev.contentIndex, kind: "thinking" });
635
677
  controller.enqueue(
@@ -665,6 +707,7 @@ export function encodeStream(
665
707
  break;
666
708
  }
667
709
  case "toolcall_start": {
710
+ emitServerToolBlocksBefore(ev.partial, ev.contentIndex);
668
711
  ensureStart(ev.partial);
669
712
  const tc = ev.partial.content[ev.contentIndex] as ToolCall | undefined;
670
713
  open.set(ev.contentIndex, { index: ev.contentIndex, kind: "tool_use" });
@@ -696,6 +739,7 @@ export function encodeStream(
696
739
  break;
697
740
  case "done": {
698
741
  for (const idx of [...open.keys()]) closeBlock(idx);
742
+ emitServerToolBlocksBefore(ev.message, ev.message.content.length);
699
743
  controller.enqueue(
700
744
  sseFrame("message_delta", {
701
745
  type: "message_delta",
@@ -65,6 +65,43 @@ export type ToolResultBlockParam = {
65
65
  cache_control?: CacheControlEphemeral | null;
66
66
  };
67
67
 
68
+ /** Anthropic-executed server tool call replayed inside an assistant turn. */
69
+ export type ServerToolUseBlockParam = {
70
+ type: "server_tool_use";
71
+ id: string;
72
+ name: string;
73
+ input?: Record<string, unknown> | null;
74
+ [key: string]: unknown;
75
+ };
76
+
77
+ /** Web-search server-tool call whose matching result is replayable by omp. */
78
+ export type WebSearchServerToolUseBlockParam = ServerToolUseBlockParam & { name: "web_search" };
79
+
80
+ /** Native web-search result replayed inside an assistant turn. */
81
+ export type WebSearchToolResultBlockParam = {
82
+ type: "web_search_tool_result";
83
+ tool_use_id: string;
84
+ content: unknown;
85
+ [key: string]: unknown;
86
+ };
87
+
88
+ /** True for the complete native web-search history variants omp can replay. */
89
+ export function isAnthropicWebSearchHistoryBlock(block: {
90
+ type: string;
91
+ name?: unknown;
92
+ id?: unknown;
93
+ tool_use_id?: unknown;
94
+ content?: unknown;
95
+ }): block is WebSearchServerToolUseBlockParam | WebSearchToolResultBlockParam {
96
+ if (block.type === "server_tool_use") {
97
+ return block.name === "web_search" && typeof block.id === "string" && block.id.length > 0;
98
+ }
99
+ if (block.type === "web_search_tool_result") {
100
+ return typeof block.tool_use_id === "string" && block.tool_use_id.length > 0 && Object.hasOwn(block, "content");
101
+ }
102
+ return false;
103
+ }
104
+
68
105
  export type ThinkingBlockParam = {
69
106
  type: "thinking";
70
107
  thinking: string;
@@ -93,6 +130,8 @@ export type ContentBlockParam =
93
130
  | ImageBlockParam
94
131
  | ToolUseBlockParam
95
132
  | ToolResultBlockParam
133
+ | ServerToolUseBlockParam
134
+ | WebSearchToolResultBlockParam
96
135
  | ThinkingBlockParam
97
136
  | RedactedThinkingBlockParam
98
137
  | FallbackBlockParam;
@@ -278,6 +317,8 @@ export type ResponseContentBlock =
278
317
  | { type: "thinking"; thinking: string; signature?: string }
279
318
  | { type: "redacted_thinking"; data: string }
280
319
  | { type: "tool_use"; id: string; name: string; input?: Record<string, unknown> | null }
320
+ | ServerToolUseBlockParam
321
+ | WebSearchToolResultBlockParam
281
322
  | { type: "fallback"; from: { model: string }; to: { model: string } };
282
323
 
283
324
  export type ContentBlockDelta =