agent-accelerator 0.3.0 → 0.4.5

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.
@@ -24,7 +24,8 @@
24
24
  import * as fs from "node:fs";
25
25
  import * as path from "node:path";
26
26
  import type { Message, ContentPart } from "../types/message.ts";
27
- import type { TokenUsage, ThinkingLevel, CacheConfig } from "../types/core.ts";
27
+ import type { TokenUsage, CacheConfig, ServiceTier } from "../types/core.ts";
28
+ import type { ModelSpec } from "../types/model.ts";
28
29
  import type { AgentResponse } from "../types/response.ts";
29
30
  import { base64ToBytes } from "../utils/base64.ts";
30
31
  import { getModelFromCatalog } from "../models/catalog.ts";
@@ -111,7 +112,10 @@ function specFor(modelStr: string): ReturnType<typeof getModelFromCatalog> {
111
112
  }
112
113
  }
113
114
 
114
- /** Context window for telemetry display. Falls back to 1M like the chat CLI. */
115
+ /** Context window for telemetry display. Falls back to 1M like the chat CLI.
116
+ * @deprecated (Q-34) Kept for compat; new code should use
117
+ * `getSessionContextWindowOrUndefined` and handle `undefined` explicitly.
118
+ */
115
119
  export function getSessionContextWindow(modelStr: string): number {
116
120
  try {
117
121
  const spec = specFor(modelStr);
@@ -127,20 +131,11 @@ export function computeSessionTurnCost(usage: TokenUsage, modelStr: string): num
127
131
  if (reported > 0) return reported;
128
132
  try {
129
133
  const spec = specFor(modelStr);
130
- const inputP = spec?.pricing?.inputPerMillion ?? spec?.cost?.input ?? 0;
131
- const outputP = spec?.pricing?.outputPerMillion ?? spec?.cost?.output ?? 0;
132
- const crP = spec?.pricing?.cacheReadPerMillion ?? spec?.cost?.cache_read ?? 0;
133
- const cwP = spec?.pricing?.cacheWritePerMillion ?? spec?.cost?.cache_write ?? 0;
134
- if (!inputP && !outputP && !crP && !cwP) return 0;
135
- const cr = usage.cachedTokens ?? usage.cacheReadTokens ?? 0;
136
- const uncachedIn = Math.max(0, (usage.inputTokens ?? 0) - cr);
137
- return (
138
- (uncachedIn / 1e6) * inputP +
139
- (cr / 1e6) * crP +
140
- ((usage.cacheWriteTokens ?? 0) / 1e6) * cwP +
141
- ((usage.outputTokens ?? 0) / 1e6) * outputP
142
- );
134
+ const priced = priceUsage(usage, spec);
135
+ if (priced && typeof priced.totalCost === "number" && priced.totalCost > 0) return priced.totalCost;
136
+ return 0;
143
137
  } catch {
138
+ // why: cost is observability-only — pricing failures must never break turns.
144
139
  return 0;
145
140
  }
146
141
  }
@@ -404,6 +399,8 @@ export function serializeSession(data: PersistedAgentSession): string {
404
399
  export function deserializeSession(raw: string): PersistedAgentSession | null {
405
400
  const text = (raw ?? "").trim();
406
401
  if (!text) return null;
402
+ // Q-10: reject oversized payloads (>10MB JSON) — return null, no console.
403
+ if (text.length > MAX_SESSION_JSON_BYTES) return null;
407
404
  try {
408
405
  // Legacy JSONL: lines shaped {type: session|mainModel|subagentModel|metrics|message|...}
409
406
  if (text.startsWith('{"type"')) {
@@ -456,6 +453,8 @@ export function deserializeSession(raw: string): PersistedAgentSession | null {
456
453
  }
457
454
  }
458
455
  if (!sessionId && messages.length === 0) return null;
456
+ // Q-10: oversize guard — return null, no console.
457
+ if (messages.length > MAX_SESSION_MESSAGES) return null;
459
458
  return {
460
459
  version: 1,
461
460
  sessionId: sessionId || `accel-${Date.now()}`,
@@ -474,8 +473,12 @@ export function deserializeSession(raw: string): PersistedAgentSession | null {
474
473
  }
475
474
  const obj: any = JSON.parse(text);
476
475
  if (!obj || typeof obj !== "object") return null;
476
+ // Q-10: version check — reject unknown versions, accept legacy shapes
477
+ // that predate versioning (no version field).
478
+ if ("version" in obj && (obj as { version?: unknown }).version !== 1) return null;
477
479
  // Very old single-JSON shape without version: {sessionId, model, context:{messages}}
478
480
  if (obj.context && Array.isArray(obj.context.messages) && !Array.isArray(obj.messages)) {
481
+ if ((obj.context.messages as unknown[]).length > MAX_SESSION_MESSAGES) return null;
479
482
  return {
480
483
  version: 1,
481
484
  sessionId: obj.sessionId ?? obj.id ?? `accel-${Date.now()}`,
@@ -493,6 +496,9 @@ export function deserializeSession(raw: string): PersistedAgentSession | null {
493
496
  };
494
497
  }
495
498
  if (!obj.sessionId && !Array.isArray(obj.messages)) return null;
499
+ if (Array.isArray(obj.messages) && (obj.messages as unknown[]).length > MAX_SESSION_MESSAGES) return null;
500
+ if ("sessionId" in obj && obj.sessionId !== undefined && typeof obj.sessionId !== "string") return null;
501
+ if ("messages" in obj && obj.messages !== undefined && !Array.isArray(obj.messages)) return null;
496
502
  return {
497
503
  version: 1,
498
504
  sessionId: obj.sessionId ?? `accel-${Date.now()}`,
@@ -528,20 +534,29 @@ export function loadSessionFile(filePath: string): PersistedAgentSession | null
528
534
  export function writeFileAtomic(filePath: string, content: string): void {
529
535
  const dir = path.dirname(filePath);
530
536
  try {
531
- fs.mkdirSync(dir, { recursive: true });
537
+ fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
532
538
  } catch {}
533
539
  let tag = "tmp";
534
540
  try {
535
541
  const pid = (globalThis as any)?.process?.pid;
536
- tag = `${typeof pid === "number" ? pid : "np"}-${Math.random().toString(36).slice(2, 8)}`;
542
+ const rand = (globalThis as any)?.crypto?.randomUUID?.().slice(0, 8) ?? Math.random().toString(36).slice(2, 8);
543
+ tag = `${typeof pid === "number" ? pid : "np"}-${rand}`;
537
544
  } catch {
538
545
  tag = Math.random().toString(36).slice(2, 8);
539
546
  }
540
547
  const tmp = `${filePath}.tmp-${tag}`;
541
- fs.writeFileSync(tmp, content, "utf8");
548
+ fs.writeFileSync(tmp, content, { encoding: "utf8", mode: 0o600 });
549
+ try { fs.chmodSync(tmp, 0o600); } catch {}
542
550
  fs.renameSync(tmp, filePath);
543
551
  }
544
552
 
553
+ /** Session id grammar: no traversal, no slashes, no hidden files. */
554
+ export function assertValidSessionId(sessionId: string): void {
555
+ if (!/^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$/.test(sessionId)) {
556
+ throw new Error(`[Agent Accelerator] Invalid sessionId "${sessionId}": must match /^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$/`);
557
+ }
558
+ }
559
+
545
560
  /** Saves an agent + telemetry snapshot as a pretty-printed `.json` document (2-space indent). */
546
561
  export function saveSessionFile(
547
562
  filePath: string,
@@ -549,6 +564,18 @@ export function saveSessionFile(
549
564
  telemetry?: SessionTelemetry | SessionTotals | null
550
565
  ): void {
551
566
  const resolved = path.resolve(filePath);
567
+ // Q-28-partial: never overwrite a corrupt file in place — quarantine it
568
+ // as `*.corrupt-<ts>` first (load returns null for corrupt payloads).
569
+ try {
570
+ if (fs.existsSync(resolved)) {
571
+ try {
572
+ const raw = fs.readFileSync(resolved, "utf8");
573
+ if (raw.trim().length > 0 && deserializeSession(raw) === null) {
574
+ try { fs.renameSync(resolved, `${resolved}.corrupt-${Date.now()}`); } catch {}
575
+ }
576
+ } catch {}
577
+ }
578
+ } catch {}
552
579
  const data = buildSessionData(agent, telemetry);
553
580
  try {
554
581
  writeFileAtomic(resolved, serializeSession(data) + "\n");
@@ -633,9 +660,21 @@ export function extractMediaToDir(
633
660
  sessionDir: string
634
661
  ): { messages: Message[]; wrote: string[] } {
635
662
  const cloned = cloneMessages(messages);
636
- const mediaDir = path.join(sessionDir, "media");
663
+ const root = path.resolve(sessionDir);
664
+ const mediaDir = path.resolve(root, "media");
665
+ if (!mediaDir.startsWith(root + path.sep) && mediaDir !== root) {
666
+ throw new Error("[Agent Accelerator] Media dir escapes session dir");
667
+ }
637
668
  try {
638
- fs.rmSync(mediaDir, { recursive: true, force: true });
669
+ fs.mkdirSync(mediaDir, { recursive: true, mode: 0o700 });
670
+ } catch {}
671
+ // Never rm -rf: remove only previously written media files matching our pattern.
672
+ try {
673
+ for (const f of fs.readdirSync(mediaDir)) {
674
+ if (/^(image|audio|video|file)-\d+\.[a-z0-9]{1,5}$/.test(f)) {
675
+ try { fs.unlinkSync(path.join(mediaDir, f)); } catch {}
676
+ }
677
+ }
639
678
  } catch {}
640
679
  const wrote: string[] = [];
641
680
  const counters: Record<string, number> = {};
@@ -669,8 +708,11 @@ export function extractMediaToDir(
669
708
  const ext = extForMedia(mimeType, typeof p.filename === "string" ? p.filename : undefined);
670
709
  const file = `${key}-${String(n).padStart(3, "0")}.${ext}`;
671
710
  try {
672
- fs.mkdirSync(mediaDir, { recursive: true });
673
- fs.writeFileSync(path.join(mediaDir, file), bytes);
711
+ fs.mkdirSync(mediaDir, { recursive: true, mode: 0o700 });
712
+ const dest = path.resolve(mediaDir, file);
713
+ if (!dest.startsWith(mediaDir + path.sep)) continue;
714
+ fs.writeFileSync(dest, bytes, { mode: 0o600 });
715
+ try { fs.chmodSync(dest, 0o600); } catch {}
674
716
  } catch {
675
717
  continue;
676
718
  }
@@ -680,22 +722,17 @@ export function extractMediaToDir(
680
722
  }
681
723
  }
682
724
 
683
- // No embedded bytes: don't leave an empty media/ behind.
684
- if (!hasBinary) {
685
- try {
686
- fs.rmSync(mediaDir, { recursive: true, force: true });
687
- } catch {}
688
- }
689
725
  return { messages: cloned, wrote };
690
726
  }
691
727
 
692
728
  /**
693
729
  * Resolves `media/…` relative refs to absolute paths anchored at the session
694
730
  * folder, so a resumed session runs from any cwd. Absolute paths, URLs, and
695
- * non-media strings pass through untouched.
731
+ * non-media strings pass through untouched. Rejects traversal outside dir.
696
732
  */
697
733
  export function resolveMediaPaths(messages: Message[], sessionDir: string): Message[] {
698
734
  const cloned = cloneMessages(messages);
735
+ const root = path.resolve(sessionDir);
699
736
  for (const msg of cloned) {
700
737
  if (!Array.isArray((msg as Message).content)) continue;
701
738
  for (const part of (msg as Message).content as ContentPart[]) {
@@ -704,16 +741,26 @@ export function resolveMediaPaths(messages: Message[], sessionDir: string): Mess
704
741
  if (!(MEDIA_PART_KEYS as readonly string[]).includes(p.type)) continue;
705
742
  const v = p[p.type] as unknown;
706
743
  if (typeof v === "string" && (v === "media" || v.startsWith("media/"))) {
707
- p[p.type] = path.join(sessionDir, v);
744
+ if (v.includes("..") || path.isAbsolute(v)) continue;
745
+ const resolved = path.resolve(root, v);
746
+ if (!resolved.startsWith(root + path.sep) && resolved !== root) continue;
747
+ p[p.type] = resolved;
708
748
  }
709
749
  }
710
750
  }
711
751
  return cloned;
712
752
  }
713
753
 
714
- /** `<rootDir>/<sessionId>` — one folder per session. */
754
+ /** `<rootDir>/<sessionId>` — one folder per session. Validates traversal. */
715
755
  export function sessionDirFor(rootDir: string, sessionId: string): string {
716
- return path.join(path.resolve(rootDir), sessionId);
756
+ assertValidSessionId(sessionId);
757
+ const root = path.resolve(rootDir);
758
+ const dir = path.resolve(root, sessionId);
759
+ const rel = path.relative(root, dir);
760
+ if (rel === "" || rel.startsWith("..") || path.isAbsolute(rel)) {
761
+ throw new Error(`[Agent Accelerator] Session dir escapes root: ${sessionId}`);
762
+ }
763
+ return dir;
717
764
  }
718
765
 
719
766
  /**
@@ -726,6 +773,7 @@ export function saveSessionDir(
726
773
  agent: SessionAgentLike,
727
774
  telemetry?: SessionTelemetry | SessionTotals | null
728
775
  ): string {
776
+ // Q-28-partial: sessionDirFor already enforces assertValidSessionId.
729
777
  const sessionDir = sessionDirFor(rootDir, agent.sessionId);
730
778
  try {
731
779
  fs.mkdirSync(sessionDir, { recursive: true });
@@ -736,7 +784,19 @@ export function saveSessionDir(
736
784
  telemetry
737
785
  );
738
786
  try {
739
- writeFileAtomic(path.join(sessionDir, "session.json"), serializeSession(data) + "\n");
787
+ const target = path.join(sessionDir, "session.json");
788
+ // Q-28-partial: quarantine corrupt targets instead of overwriting.
789
+ try {
790
+ if (fs.existsSync(target)) {
791
+ try {
792
+ const raw = fs.readFileSync(target, "utf8");
793
+ if (raw.trim().length > 0 && deserializeSession(raw) === null) {
794
+ try { fs.renameSync(target, `${target}.corrupt-${Date.now()}`); } catch {}
795
+ }
796
+ } catch {}
797
+ }
798
+ } catch {}
799
+ writeFileAtomic(target, serializeSession(data) + "\n");
740
800
  } catch {
741
801
  // persistence must never crash a chat turn
742
802
  }
@@ -804,3 +864,431 @@ export function findLatestSessionDir(rootDir: string): string | null {
804
864
  return null;
805
865
  }
806
866
  }
867
+
868
+ // ---------------------------------------------------------------------------
869
+ // Q-10: session validation (append-only; deserializeSession above only gained
870
+ // size/version guards, no behavior change otherwise).
871
+ // ---------------------------------------------------------------------------
872
+
873
+ /** Max messages accepted in a session snapshot (Q-10). */
874
+ export const MAX_SESSION_MESSAGES = 5000;
875
+ /** Max raw JSON bytes accepted in deserializeSession (Q-10). */
876
+ export const MAX_SESSION_JSON_BYTES = 10 * 1024 * 1024;
877
+
878
+ function sessionLoadError(message: string): Error {
879
+ const e = new Error(`[Agent Accelerator] Invalid session: ${message}`);
880
+ e.name = "SessionLoadError";
881
+ return e;
882
+ }
883
+
884
+ /**
885
+ * Manual Zod-like validation for session snapshots (Q-10, no new deps).
886
+ * Checks `version===1`, string `sessionId`, array `messages`, and oversize
887
+ * (>5000 messages or >10MB JSON). Throws a `SessionLoadError`-named Error
888
+ * on failure; returns the typed session on success.
889
+ */
890
+ export function validateSessionData(data: unknown): PersistedAgentSession {
891
+ if (!data || typeof data !== "object" || Array.isArray(data)) {
892
+ throw sessionLoadError("expected an object");
893
+ }
894
+ const obj = data as Record<string, unknown>;
895
+ if (obj.version !== 1) {
896
+ throw sessionLoadError(`unsupported version ${String((obj as { version?: unknown }).version)} (expected 1)`);
897
+ }
898
+ if (typeof obj.sessionId !== "string" || obj.sessionId.length === 0) {
899
+ throw sessionLoadError("sessionId must be a non-empty string");
900
+ }
901
+ if (!Array.isArray(obj.messages)) {
902
+ throw sessionLoadError("messages must be an array");
903
+ }
904
+ if ((obj.messages as unknown[]).length > MAX_SESSION_MESSAGES) {
905
+ throw sessionLoadError(`too many messages (${(obj.messages as unknown[]).length} > ${MAX_SESSION_MESSAGES})`);
906
+ }
907
+ let approxBytes = 0;
908
+ try {
909
+ approxBytes = JSON.stringify(data)?.length ?? 0;
910
+ } catch {
911
+ throw sessionLoadError("session is not JSON-serializable");
912
+ }
913
+ if (approxBytes > MAX_SESSION_JSON_BYTES) {
914
+ throw sessionLoadError(`session JSON too large (${approxBytes} > ${MAX_SESSION_JSON_BYTES} bytes)`);
915
+ }
916
+ return data as PersistedAgentSession;
917
+ }
918
+
919
+ // ---------------------------------------------------------------------------
920
+ // Q-34: context-window lookup without the legacy 1M fallback.
921
+ // ---------------------------------------------------------------------------
922
+
923
+ /**
924
+ * Returns the catalog context window for a model, or `undefined` when unknown
925
+ * (Q-34). Prefer this over `getSessionContextWindow` for new code.
926
+ */
927
+ export function getSessionContextWindowOrUndefined(modelStr: string): number | undefined {
928
+ try {
929
+ const spec = specFor(modelStr);
930
+ if (spec?.limit?.context) return spec.limit.context;
931
+ if (spec?.contextWindow) return spec.contextWindow;
932
+ } catch {
933
+ // why: catalog lookup is best-effort — unknown models yield undefined.
934
+ }
935
+ return undefined;
936
+ }
937
+
938
+ // ---------------------------------------------------------------------------
939
+ // Q-41: telemetry bus (no console.* in library code).
940
+ // ---------------------------------------------------------------------------
941
+
942
+ /** Telemetry event emitted via the in-process bus (Q-41). */
943
+ export interface TelemetryEvent {
944
+ code: string;
945
+ message: string;
946
+ level?: "info" | "warn" | "error";
947
+ data?: unknown;
948
+ timestamp: number;
949
+ }
950
+
951
+ /** Listener subscribed via `telemetryBus.onEvent`. */
952
+ export type TelemetryListener = (event: TelemetryEvent) => void;
953
+
954
+ interface TelemetryBus {
955
+ /** Subscribes to telemetry events. Returns an unsubscribe function. Default: no-op delivery (no listeners, no console). */
956
+ onEvent(listener: TelemetryListener): () => void;
957
+ /** Emits a warning (level `warn`). Never throws, never logs to console. */
958
+ emitWarning(code: string, message: string, data?: unknown): void;
959
+ /** Emits a generic event. Never throws, never logs to console. */
960
+ emit(event: Omit<TelemetryEvent, "timestamp"> & { timestamp?: number }): void;
961
+ /** Removes all listeners (useful in tests). */
962
+ clear(): void;
963
+ }
964
+
965
+ /**
966
+ * In-process telemetry bus (Q-41). Library code routes warnings/anomalies
967
+ * here instead of `console.*`. Default has zero listeners so emission is a
968
+ * no-op; CLIs/tests attach a pretty logger via `onEvent`.
969
+ */
970
+ export const telemetryBus: TelemetryBus = (() => {
971
+ const listeners = new Set<TelemetryListener>();
972
+ const deliver = (event: TelemetryEvent): void => {
973
+ for (const l of [...listeners]) {
974
+ try { l(event); } catch {
975
+ // why: a faulty telemetry listener must not break the emitting run.
976
+ }
977
+ }
978
+ };
979
+ return {
980
+ onEvent(listener: TelemetryListener): () => void {
981
+ listeners.add(listener);
982
+ return () => { listeners.delete(listener); };
983
+ },
984
+ emitWarning(code: string, message: string, data?: unknown): void {
985
+ try {
986
+ deliver({ code, message, level: "warn", data, timestamp: Date.now() });
987
+ } catch {
988
+ // why: telemetry emission is observability-only.
989
+ }
990
+ },
991
+ emit(event: Omit<TelemetryEvent, "timestamp"> & { timestamp?: number }): void {
992
+ try {
993
+ deliver({ level: "info", ...event, timestamp: event.timestamp ?? Date.now() });
994
+ } catch {
995
+ // why: telemetry emission is observability-only.
996
+ }
997
+ },
998
+ clear(): void {
999
+ listeners.clear();
1000
+ },
1001
+ };
1002
+ })();
1003
+
1004
+ /** Reports a usage/cost anomaly via the global hook + telemetry bus (Q-35). Never logs. */
1005
+ export function reportUsageAnomaly(detail: { type: string; [k: string]: unknown }): void {
1006
+ try {
1007
+ (globalThis as unknown as { __agentAccelAnomaly?: (d: unknown) => void }).__agentAccelAnomaly?.(detail);
1008
+ } catch {
1009
+ // why: anomaly hooks are third-party — must never throw into the loop.
1010
+ }
1011
+ try {
1012
+ telemetryBus.emitWarning(detail.type, `usage anomaly: ${detail.type}`, detail);
1013
+ } catch {
1014
+ // why: telemetry emission is observability-only.
1015
+ }
1016
+ }
1017
+
1018
+ // ---------------------------------------------------------------------------
1019
+ // Q-35: canonical usage invariants.
1020
+ // ---------------------------------------------------------------------------
1021
+ /**
1022
+ * Canonical usage invariants (provider-agnostic, Q-35):
1023
+ * - all token counts are finite integers >= 0;
1024
+ * - `cachedTokens` / `cacheReadTokens` are a SUBSET of `inputTokens`
1025
+ * (cache hits cannot exceed input; aggregates clamp — see `accumulateUsage`
1026
+ * in `agent/loop.ts`), so hit rates never exceed 100%;
1027
+ * - `thinkingTokens` is a SUBSET of `outputTokens` when both are reported
1028
+ * (reasoning is generated output); providers that omit one side are
1029
+ * accepted, only impossible `thinking > output` with both present fails.
1030
+ *
1031
+ * Test helper: throws an Error describing the first violation, returns void
1032
+ * when invariants hold. Not called in hot paths (validation only).
1033
+ */
1034
+ export function assertUsageInvariants(usage: TokenUsage): void {
1035
+ const fail = (msg: string): never => {
1036
+ throw new Error(`[Agent Accelerator] Invalid usage: ${msg}`);
1037
+ };
1038
+ const ints: Array<[string, unknown]> = [
1039
+ ["inputTokens", usage.inputTokens],
1040
+ ["outputTokens", usage.outputTokens],
1041
+ ["totalTokens", usage.totalTokens],
1042
+ ];
1043
+ for (const [k, v] of ints) {
1044
+ if (typeof v !== "number" || !Number.isFinite(v as number) || (v as number) < 0) {
1045
+ fail(`${k} must be a finite number >= 0 (got ${String(v)})`);
1046
+ }
1047
+ }
1048
+ const optional: Array<[string, unknown]> = [
1049
+ ["cachedTokens", usage.cachedTokens],
1050
+ ["cacheReadTokens", usage.cacheReadTokens],
1051
+ ["cacheWriteTokens", usage.cacheWriteTokens],
1052
+ ["thinkingTokens", usage.thinkingTokens],
1053
+ ];
1054
+ for (const [k, v] of optional) {
1055
+ if (v === undefined) continue;
1056
+ if (typeof v !== "number" || !Number.isFinite(v as number) || (v as number) < 0) {
1057
+ fail(`${k} must be a finite number >= 0 when present (got ${String(v)})`);
1058
+ }
1059
+ }
1060
+ const input = usage.inputTokens ?? 0;
1061
+ for (const k of ["cachedTokens", "cacheReadTokens"] as const) {
1062
+ const v = usage[k] ?? 0;
1063
+ if (v > input) fail(`${k} (${v}) exceeds inputTokens (${input})`);
1064
+ }
1065
+ if (usage.thinkingTokens !== undefined && usage.outputTokens !== undefined) {
1066
+ if (usage.thinkingTokens > usage.outputTokens) {
1067
+ fail(`thinkingTokens (${usage.thinkingTokens}) exceeds outputTokens (${usage.outputTokens})`);
1068
+ }
1069
+ }
1070
+ }
1071
+
1072
+ // ---------------------------------------------------------------------------
1073
+ // Q-36: one cost model — integer micro-USD accumulation.
1074
+ // ---------------------------------------------------------------------------
1075
+
1076
+ /** Where a priced cost came from (Q-36). Unknown stays unknown, never 0. */
1077
+ export type CostSource = "provider" | "catalog" | "unknown";
1078
+
1079
+ /** Integer micro-USD breakdown behind a priced cost (Q-36). */
1080
+ export interface CostBreakdown {
1081
+ inputMicroUSD: number;
1082
+ outputMicroUSD: number;
1083
+ cacheReadMicroUSD: number;
1084
+ cacheWriteMicroUSD: number;
1085
+ audioMicroUSD: number;
1086
+ totalMicroUSD: number;
1087
+ /** Which price tier applied (`base` or e.g. `context_over_200k` / `tiers:<size>`). */
1088
+ tier: string;
1089
+ /** Multiplier applied for the service tier (estimate — see below). */
1090
+ serviceTierMultiplier: number;
1091
+ serviceTier?: string;
1092
+ }
1093
+
1094
+ /** Cost result of `priceUsage`: compat cost fields + source + breakdown. */
1095
+ export type PricedCost = NonNullable<TokenUsage["cost"]> & {
1096
+ source: CostSource;
1097
+ breakdown: CostBreakdown;
1098
+ };
1099
+
1100
+ /**
1101
+ * Service-tier price multipliers (Q-36). Aligned with pi
1102
+ * (`api/openai-responses.ts:387`): `flex` trades latency for ~0.5x price,
1103
+ * `priority`/`fast` reserve capacity at ~2x (`2.5x` for `gpt-5.5`).
1104
+ * Standard/default is 1.0. Estimates because vendors do not publish exact
1105
+ * tier multipliers in the catalog.
1106
+ */
1107
+ const SERVICE_TIER_MULTIPLIERS: Record<string, number> = {
1108
+ flex: 0.5,
1109
+ priority: 2,
1110
+ fast: 2,
1111
+ };
1112
+
1113
+ function serviceTierMultiplier(serviceTier?: string, modelId?: string): { multiplier: number; tier?: string } {
1114
+ if (!serviceTier) return { multiplier: 1 };
1115
+ const lower = serviceTier.toLowerCase();
1116
+ if (lower === "priority" || lower === "fast") {
1117
+ if (typeof modelId === "string" && modelId.includes("gpt-5.5")) {
1118
+ return { multiplier: 2.5, tier: serviceTier };
1119
+ }
1120
+ return { multiplier: 2, tier: serviceTier };
1121
+ }
1122
+ const m = SERVICE_TIER_MULTIPLIERS[lower];
1123
+ return m !== undefined ? { multiplier: m, tier: serviceTier } : { multiplier: 1, tier: serviceTier };
1124
+ }
1125
+
1126
+ function toDollars(microUSD: number): number {
1127
+ return microUSD / 1_000_000;
1128
+ }
1129
+
1130
+ /**
1131
+ * Resolves effective per-1M pricing for a usage snapshot (Q-36). When
1132
+ * `usage.inputTokens > 200_000` and the catalog carries `cost.tiers` or
1133
+ * `cost.context_over_200k`, the over-200k tier prices apply; otherwise base
1134
+ * `pricing.*` (falling back to legacy `cost.*`) applies.
1135
+ */
1136
+ function resolveEffectivePricing(
1137
+ spec: ModelSpec | undefined,
1138
+ inputTokens: number
1139
+ ): { input: number; output: number; cacheRead: number; cacheWrite: number; audio: number; tier: string } {
1140
+ const base = {
1141
+ input: spec?.pricing?.inputPerMillion ?? (spec?.cost?.input as number | undefined) ?? 0,
1142
+ output: spec?.pricing?.outputPerMillion ?? (spec?.cost?.output as number | undefined) ?? 0,
1143
+ cacheRead: spec?.pricing?.cacheReadPerMillion ?? (spec?.cost?.cache_read as number | undefined) ?? 0,
1144
+ cacheWrite: spec?.pricing?.cacheWritePerMillion ?? (spec?.cost?.cache_write as number | undefined) ?? 0,
1145
+ audio:
1146
+ spec?.pricing?.inputAudioPerMillion ?? (spec?.cost as { input_audio?: number } | undefined)?.input_audio ?? 0,
1147
+ };
1148
+ if (!spec || inputTokens <= 200_000) return { ...base, tier: "base" };
1149
+ const over = spec.cost?.context_over_200k as { input?: number; output?: number; cache_read?: number } | undefined;
1150
+ if (over && (over.input || over.output || over.cache_read)) {
1151
+ return {
1152
+ input: over.input ?? base.input,
1153
+ output: over.output ?? base.output,
1154
+ cacheRead: over.cache_read ?? base.cacheRead,
1155
+ cacheWrite: base.cacheWrite,
1156
+ audio: base.audio,
1157
+ tier: "context_over_200k",
1158
+ };
1159
+ }
1160
+ const tiers = spec.cost?.tiers;
1161
+ if (Array.isArray(tiers) && tiers.length > 0) {
1162
+ let best: { input: number; output: number; cache_read?: number; size: number } | undefined;
1163
+ for (const t of tiers as Array<{ tier?: { type?: string; size?: number }; input?: number; output?: number; cache_read?: number }>) {
1164
+ const size = typeof t?.tier?.size === "number" ? t.tier.size : undefined;
1165
+ if (size === undefined || size > inputTokens) continue;
1166
+ const type = String(t?.tier?.type ?? "").toLowerCase();
1167
+ if (type && type !== "context" && !type.includes("context")) continue;
1168
+ if (!best || size > best.size) {
1169
+ best = { input: t.input ?? base.input, output: t.output ?? base.output, cache_read: t.cache_read, size };
1170
+ }
1171
+ }
1172
+ if (best) {
1173
+ return {
1174
+ input: best.input,
1175
+ output: best.output,
1176
+ cacheRead: best.cache_read ?? base.cacheRead,
1177
+ cacheWrite: base.cacheWrite,
1178
+ audio: base.audio,
1179
+ tier: `tiers:${best.size}`,
1180
+ };
1181
+ }
1182
+ }
1183
+ return { ...base, tier: "base" };
1184
+ }
1185
+
1186
+ /**
1187
+ * Prices one usage snapshot with integer micro-USD accumulation (Q-36).
1188
+ *
1189
+ * - Provider-reported `usage.cost.totalCost > 0` wins (source `provider`).
1190
+ * - Else catalog pricing via `resolveEffectivePricing` (source `catalog`).
1191
+ * - Else returns `undefined` (source `unknown` is implicit) — unknown stays
1192
+ * unknown, never a fabricated 0 object.
1193
+ * - Audio tokens (`usage.audioTokens` / `usage.inputAudioTokens` when present)
1194
+ * price at `pricing.inputAudioPerMillion` / `cost.input_audio` and fold into
1195
+ * the total (separately itemized in `breakdown.audioMicroUSD`).
1196
+ * - `opts.serviceTier` (`flex` ~0.5x, `priority` ~1.5x, estimates) scales the
1197
+ * catalog total; provider-reported costs are never rescaled.
1198
+ */
1199
+ export function priceUsage(
1200
+ usage: TokenUsage,
1201
+ spec?: ModelSpec,
1202
+ opts?: { serviceTier?: ServiceTier | string; modelId?: string }
1203
+ ): PricedCost | undefined {
1204
+ const reported = usage?.cost;
1205
+ if (reported && typeof reported.totalCost === "number" && reported.totalCost > 0) {
1206
+ const micro = (d?: number): number => (typeof d === "number" && d > 0 ? Math.round(d * 1_000_000) : 0);
1207
+ const inputMicroUSD = micro(reported.inputCost);
1208
+ const outputMicroUSD = micro(reported.outputCost);
1209
+ const cacheReadMicroUSD = micro(reported.cacheReadCost);
1210
+ const cacheWriteMicroUSD = micro(reported.cacheWriteCost);
1211
+ const totalMicroUSD = Math.round(reported.totalCost * 1_000_000);
1212
+ return {
1213
+ inputCost: reported.inputCost,
1214
+ outputCost: reported.outputCost,
1215
+ cacheReadCost: reported.cacheReadCost,
1216
+ cacheWriteCost: reported.cacheWriteCost,
1217
+ totalCost: reported.totalCost,
1218
+ source: "provider",
1219
+ breakdown: {
1220
+ inputMicroUSD,
1221
+ outputMicroUSD,
1222
+ cacheReadMicroUSD,
1223
+ cacheWriteMicroUSD,
1224
+ audioMicroUSD: 0,
1225
+ totalMicroUSD,
1226
+ tier: "provider-reported",
1227
+ serviceTierMultiplier: 1,
1228
+ },
1229
+ };
1230
+ }
1231
+ if (!spec) return undefined;
1232
+ const inputTokens = usage.inputTokens ?? 0;
1233
+ const outputTokens = usage.outputTokens ?? 0;
1234
+ const cachedTokens = usage.cachedTokens ?? usage.cacheReadTokens ?? 0;
1235
+ const cacheWriteTokens = usage.cacheWriteTokens ?? 0;
1236
+ const audioTokens =
1237
+ (usage as { audioTokens?: unknown }).audioTokens ??
1238
+ (usage as { inputAudioTokens?: unknown }).inputAudioTokens ??
1239
+ 0;
1240
+ const audioCount = typeof audioTokens === "number" && Number.isFinite(audioTokens) && audioTokens > 0 ? Math.round(audioTokens) : 0;
1241
+ const pricing = resolveEffectivePricing(spec, inputTokens);
1242
+ if (!pricing.input && !pricing.output && !pricing.cacheRead && !pricing.cacheWrite && !pricing.audio) {
1243
+ return undefined;
1244
+ }
1245
+ const { multiplier, tier: serviceTier } = serviceTierMultiplier(opts?.serviceTier, opts?.modelId ?? spec?.id);
1246
+ const nonCachedInput = Math.max(0, inputTokens - cachedTokens);
1247
+ // Integer micro-USD accumulation: microUSD = tokens * pricePerMillion
1248
+ // ($P per 1M tokens == $P/1e6 per token == P micro-USD per token).
1249
+ const inputMicroUSD = Math.round(nonCachedInput * pricing.input * multiplier);
1250
+ const cacheReadMicroUSD = Math.round(cachedTokens * pricing.cacheRead * multiplier);
1251
+ const cacheWriteMicroUSD = Math.round(cacheWriteTokens * pricing.cacheWrite * multiplier);
1252
+ const outputMicroUSD = Math.round(outputTokens * pricing.output * multiplier);
1253
+ const audioMicroUSD = Math.round(audioCount * pricing.audio * multiplier);
1254
+ const totalMicroUSD = inputMicroUSD + cacheReadMicroUSD + cacheWriteMicroUSD + outputMicroUSD + audioMicroUSD;
1255
+ return {
1256
+ inputCost: toDollars(inputMicroUSD),
1257
+ outputCost: toDollars(outputMicroUSD + audioMicroUSD),
1258
+ cacheReadCost: toDollars(cacheReadMicroUSD),
1259
+ cacheWriteCost: toDollars(cacheWriteMicroUSD),
1260
+ totalCost: toDollars(totalMicroUSD),
1261
+ source: "catalog",
1262
+ breakdown: {
1263
+ inputMicroUSD,
1264
+ outputMicroUSD,
1265
+ cacheReadMicroUSD,
1266
+ cacheWriteMicroUSD,
1267
+ audioMicroUSD,
1268
+ totalMicroUSD,
1269
+ tier: pricing.tier,
1270
+ serviceTierMultiplier: multiplier,
1271
+ ...(serviceTier ? { serviceTier } : {}),
1272
+ },
1273
+ };
1274
+ }
1275
+
1276
+ /**
1277
+ * Computes and attaches catalog pricing to a usage snapshot (Q-36).
1278
+ * Keeps provider-reported costs untouched; returns the cost (or undefined
1279
+ * when unknown). Never fabricates a 0 object for unknown pricing.
1280
+ */
1281
+ export function addCostToUsage(
1282
+ usage: TokenUsage,
1283
+ spec?: ModelSpec,
1284
+ opts?: { serviceTier?: ServiceTier | string }
1285
+ ): TokenUsage["cost"] | undefined {
1286
+ if (usage?.cost && typeof usage.cost.totalCost === "number" && usage.cost.totalCost > 0) {
1287
+ return usage.cost;
1288
+ }
1289
+ const priced = priceUsage(usage, spec, opts);
1290
+ if (!priced) return usage.cost;
1291
+ const { source: _source, breakdown: _breakdown, ...cost } = priced;
1292
+ usage.cost = cost;
1293
+ return usage.cost;
1294
+ }