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.
- package/package.json +1 -1
- package/src/agent/agent.ts +845 -116
- package/src/agent/context.ts +35 -0
- package/src/agent/delegation.ts +390 -36
- package/src/agent/loop.ts +785 -110
- package/src/agent/subagent.ts +59 -0
- package/src/index.ts +173 -59
- package/src/models/catalog-cache.ts +68 -25
- package/src/models/catalog.ts +123 -18
- package/src/providers/google.ts +152 -43
- package/src/providers/openai-compat.ts +246 -96
- package/src/providers/openai.ts +274 -79
- package/src/providers/openrouter.ts +191 -82
- package/src/providers/registry.ts +16 -12
- package/src/providers/shared.ts +190 -0
- package/src/providers.ts +259 -9
- package/src/session/store.ts +521 -33
- package/src/streaming/event-stream.ts +156 -41
- package/src/tools/executor.ts +248 -15
- package/src/tools/tool.ts +147 -3
- package/src/types/agent.ts +244 -2
- package/src/types/errors.ts +133 -0
- package/src/types/model.ts +22 -1
- package/src/types/response.ts +133 -3
- package/src/types/tool.ts +69 -0
- package/src/update-models.ts +8 -2
- package/src/utils/cache.ts +68 -1
- package/src/utils/env.ts +23 -13
- package/src/utils/errors.ts +15 -1
- package/src/utils/headers.ts +153 -19
- package/src/utils/retry.ts +27 -13
- package/src/utils/session.ts +284 -0
- package/src/providers/openrouter-responses.ts +0 -949
- package/src/types/provider-payloads.ts +0 -352
package/src/session/store.ts
CHANGED
|
@@ -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,
|
|
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
|
|
131
|
-
|
|
132
|
-
|
|
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
|
-
|
|
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
|
|
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.
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
+
}
|