opencode-anthropic-fix 0.1.32 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (37) hide show
  1. package/README.md +2 -0
  2. package/cli.mjs +42 -35
  3. package/index.mjs +399 -3591
  4. package/lib/config.test.mjs +298 -1
  5. package/lib/diagnose.mjs +184 -0
  6. package/lib/diagnose.test.mjs +122 -0
  7. package/lib/env.mjs +19 -0
  8. package/lib/env.test.mjs +25 -0
  9. package/lib/mimicry/cache.mjs +62 -0
  10. package/lib/mimicry/cache.test.mjs +112 -0
  11. package/lib/mimicry/headers.mjs +530 -0
  12. package/lib/mimicry/headers.test.mjs +50 -0
  13. package/lib/mimicry/models.mjs +146 -0
  14. package/lib/mimicry/models.test.mjs +132 -0
  15. package/lib/mimicry/request-body.mjs +684 -0
  16. package/lib/mimicry/request-body.test.mjs +34 -0
  17. package/lib/mimicry/request-helpers.mjs +243 -0
  18. package/lib/mimicry/request-helpers.test.mjs +155 -0
  19. package/lib/mimicry/response-stream.mjs +429 -0
  20. package/lib/mimicry/response-stream.test.mjs +127 -0
  21. package/lib/mimicry/system-prompt.mjs +638 -0
  22. package/lib/mimicry/system-prompt.test.mjs +73 -0
  23. package/lib/redact.mjs +147 -0
  24. package/lib/redact.test.mjs +154 -0
  25. package/lib/refresh-lock.test.mjs +52 -0
  26. package/lib/retry/overload-loop.mjs +83 -0
  27. package/lib/retry/overload-loop.test.mjs +107 -0
  28. package/lib/session-metrics.mjs +75 -0
  29. package/lib/session-metrics.test.mjs +67 -0
  30. package/lib/storage.test.mjs +82 -0
  31. package/lib/token-economy/microcompact.mjs +35 -0
  32. package/lib/token-economy/microcompact.test.mjs +28 -0
  33. package/lib/token-economy/transforms.mjs +637 -0
  34. package/lib/token-economy/transforms.test.mjs +192 -0
  35. package/lib/tuning.mjs +70 -0
  36. package/lib/tuning.test.mjs +89 -0
  37. package/package.json +4 -1
@@ -0,0 +1,75 @@
1
+ // ---------------------------------------------------------------------------
2
+ // Session-level cache & cost tracking (Phase 4)
3
+ // ---------------------------------------------------------------------------
4
+ //
5
+ // Extracted from index.mjs (W3·P3.3). `sessionMetrics` is a process-lifetime
6
+ // singleton shared BY REFERENCE across the plugin and the extracted lib
7
+ // modules: importers mutate `sessionMetrics.<field>` in place and it is never
8
+ // reassigned, so a plain `import { sessionMetrics }` observes the same live
9
+ // object everywhere. The test reset helper mutates in place (delete keys +
10
+ // Object.assign) precisely to preserve this shared identity.
11
+
12
+ /**
13
+ * Factory for the initial sessionMetrics shape. Returns a fresh object each
14
+ * call so the reset helper (and any future test hook) doesn't alias nested
15
+ * state (lastQuota, perModel, costBreakdown, tokenBudget, usedTools Set).
16
+ * Keep this in sync with the type annotation below.
17
+ */
18
+ export function createInitialSessionMetrics() {
19
+ return {
20
+ turns: 0,
21
+ totalInput: 0,
22
+ totalOutput: 0,
23
+ totalCacheRead: 0,
24
+ totalCacheWrite: 0,
25
+ totalWebSearchRequests: 0,
26
+ recentCacheRates: [], // rolling window of last 5 turns
27
+ sessionCostUsd: 0,
28
+ costBreakdown: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
29
+ sessionStartTime: Date.now(),
30
+ lastQuota: {
31
+ tokens: 0,
32
+ requests: 0,
33
+ inputTokens: 0,
34
+ updatedAt: 0,
35
+ // Window-based unified headers from response
36
+ fiveHour: { utilization: 0, resets_at: null, status: null, surpassedThreshold: null },
37
+ sevenDay: { utilization: 0, resets_at: null, status: null, surpassedThreshold: null },
38
+ // Overall/fallback/overage from response headers
39
+ overallStatus: null,
40
+ representativeClaim: null,
41
+ fallback: null,
42
+ fallbackPercentage: null,
43
+ overageStatus: null,
44
+ overageReason: null,
45
+ // Usage endpoint polling (A6)
46
+ lastPollAt: 0,
47
+ },
48
+ lastStopReason: null, // tracks most recent stop_reason for output cap escalation
49
+ perModel: {}, // Map<modelId, { input, output, cacheRead, cacheWrite, costUsd, turns }>
50
+ lastModelId: null,
51
+ lastRequestBody: null, // Last intercepted request body (JSON string, capped 2MB) for /anthropic context
52
+ /** Token budget tracking (A9) */
53
+ tokenBudget: {
54
+ limit: 0, // 0 = unset
55
+ used: 0, // accumulated output tokens
56
+ continuations: 0,
57
+ outputHistory: [], // last 5 output token deltas
58
+ },
59
+ /** Tools used in this session (populated from assistant tool_use blocks in messages) */
60
+ usedTools: new Set(),
61
+ };
62
+ }
63
+
64
+ /** @type {{turns: number, totalInput: number, totalOutput: number, totalCacheRead: number, totalCacheWrite: number, totalWebSearchRequests: number, recentCacheRates: number[], sessionCostUsd: number, costBreakdown: {input: number, output: number, cacheRead: number, cacheWrite: number}, sessionStartTime: number, lastQuota: {tokens: number, requests: number, inputTokens: number, updatedAt: number, fiveHour: {utilization: number, resets_at: string|null, status: string|null, surpassedThreshold: number|null}, sevenDay: {utilization: number, resets_at: string|null, status: string|null, surpassedThreshold: number|null}, overallStatus: string|null, representativeClaim: string|null, fallback: string|null, fallbackPercentage: number|null, overageStatus: string|null, overageReason: string|null, lastPollAt: number}, lastStopReason: string | null, perModel: Record<string, {input: number, output: number, cacheRead: number, cacheWrite: number, costUsd: number, turns: number}>, lastModelId: string | null, lastRequestBody: string | null, tokenBudget: {limit: number, used: number, continuations: number, outputHistory: number[]}, usedTools: Set<string>}} */
65
+ export const sessionMetrics = createInitialSessionMetrics();
66
+
67
+ /**
68
+ * Get rolling average cache hit rate over last 5 turns.
69
+ * @returns {number} 0-1
70
+ */
71
+ export function getAverageCacheHitRate() {
72
+ const rates = sessionMetrics.recentCacheRates;
73
+ if (rates.length === 0) return 0;
74
+ return rates.reduce((a, b) => a + b, 0) / rates.length;
75
+ }
@@ -0,0 +1,67 @@
1
+ import { describe, it, expect } from "vitest";
2
+ import { createInitialSessionMetrics, sessionMetrics, getAverageCacheHitRate } from "./session-metrics.mjs";
3
+
4
+ describe("session-metrics", () => {
5
+ describe("createInitialSessionMetrics", () => {
6
+ it("returns a fresh object with the documented zeroed shape", () => {
7
+ const m = createInitialSessionMetrics();
8
+ expect(m.turns).toBe(0);
9
+ expect(m.totalInput).toBe(0);
10
+ expect(m.totalOutput).toBe(0);
11
+ expect(m.totalCacheRead).toBe(0);
12
+ expect(m.totalCacheWrite).toBe(0);
13
+ expect(m.recentCacheRates).toEqual([]);
14
+ expect(m.sessionCostUsd).toBe(0);
15
+ expect(m.costBreakdown).toEqual({ input: 0, output: 0, cacheRead: 0, cacheWrite: 0 });
16
+ expect(m.lastStopReason).toBeNull();
17
+ expect(m.perModel).toEqual({});
18
+ expect(m.lastModelId).toBeNull();
19
+ expect(m.lastRequestBody).toBeNull();
20
+ expect(m.tokenBudget).toEqual({ limit: 0, used: 0, continuations: 0, outputHistory: [] });
21
+ expect(m.usedTools).toBeInstanceOf(Set);
22
+ expect(m.usedTools.size).toBe(0);
23
+ expect(m.lastQuota.fiveHour).toEqual({
24
+ utilization: 0,
25
+ resets_at: null,
26
+ status: null,
27
+ surpassedThreshold: null,
28
+ });
29
+ });
30
+
31
+ it("does not alias nested state between calls (fresh Set/objects/arrays)", () => {
32
+ const a = createInitialSessionMetrics();
33
+ const b = createInitialSessionMetrics();
34
+ expect(a.usedTools).not.toBe(b.usedTools);
35
+ expect(a.lastQuota).not.toBe(b.lastQuota);
36
+ expect(a.tokenBudget).not.toBe(b.tokenBudget);
37
+ expect(a.recentCacheRates).not.toBe(b.recentCacheRates);
38
+ a.usedTools.add("Bash");
39
+ expect(b.usedTools.size).toBe(0);
40
+ });
41
+ });
42
+
43
+ describe("sessionMetrics singleton", () => {
44
+ it("is a shared object reference whose mutations persist", () => {
45
+ const before = sessionMetrics.turns;
46
+ sessionMetrics.turns = before + 1;
47
+ expect(sessionMetrics.turns).toBe(before + 1);
48
+ sessionMetrics.turns = before; // restore
49
+ });
50
+ });
51
+
52
+ describe("getAverageCacheHitRate", () => {
53
+ it("returns 0 when there are no recent rates", () => {
54
+ const saved = sessionMetrics.recentCacheRates;
55
+ sessionMetrics.recentCacheRates = [];
56
+ expect(getAverageCacheHitRate()).toBe(0);
57
+ sessionMetrics.recentCacheRates = saved;
58
+ });
59
+
60
+ it("returns the arithmetic mean of the rolling window", () => {
61
+ const saved = sessionMetrics.recentCacheRates;
62
+ sessionMetrics.recentCacheRates = [0.2, 0.4, 0.6];
63
+ expect(getAverageCacheHitRate()).toBeCloseTo(0.4, 10);
64
+ sessionMetrics.recentCacheRates = saved;
65
+ });
66
+ });
67
+ });
@@ -10,6 +10,9 @@ import {
10
10
  } from "./storage.mjs";
11
11
  import { promises as fs } from "node:fs";
12
12
  import { existsSync, readFileSync, appendFileSync, writeFileSync } from "node:fs";
13
+ import { randomBytes } from "node:crypto";
14
+
15
+ const itWindows = process.platform === "win32" ? it : it.skip;
13
16
 
14
17
  // Mock fs modules
15
18
  vi.mock("node:fs", () => ({
@@ -364,6 +367,85 @@ describe("saveAccounts", () => {
364
367
  expect(fs.unlink).toHaveBeenCalledWith(expect.stringContaining(".tmp"));
365
368
  });
366
369
 
370
+ it("keeps persisted JSON consistent when concurrent saves commit out of order", async () => {
371
+ randomBytes
372
+ .mockReturnValueOnce({ toString: () => "writer-one" })
373
+ .mockReturnValueOnce({ toString: () => "writer-two" });
374
+
375
+ const files = new Map();
376
+ let markFirstWriteStarted;
377
+ const firstWriteStarted = new Promise((resolve) => {
378
+ markFirstWriteStarted = resolve;
379
+ });
380
+ let releaseFirstWrite;
381
+ const firstWriteBlocked = new Promise((resolve) => {
382
+ releaseFirstWrite = resolve;
383
+ });
384
+
385
+ fs.writeFile.mockImplementation(async (path, content) => {
386
+ if (path.includes("writer-one")) {
387
+ markFirstWriteStarted();
388
+ await firstWriteBlocked;
389
+ }
390
+ files.set(path, content);
391
+ });
392
+ fs.rename.mockImplementation(async (tempPath, storagePath) => {
393
+ const content = files.get(tempPath);
394
+ if (content === undefined) throw new Error("temp file missing");
395
+ files.set(storagePath, content);
396
+ files.delete(tempPath);
397
+ });
398
+
399
+ const firstStorage = {
400
+ version: 1,
401
+ accounts: [{ refreshToken: "first-writer" }],
402
+ activeIndex: 0,
403
+ };
404
+ const secondStorage = {
405
+ version: 1,
406
+ accounts: [{ refreshToken: "second-writer" }],
407
+ activeIndex: 0,
408
+ };
409
+ const emptyDisk = { version: 1, accounts: [], activeIndex: 0 };
410
+
411
+ const firstSave = saveAccounts(firstStorage, emptyDisk);
412
+ await firstWriteStarted;
413
+ await saveAccounts(secondStorage, emptyDisk);
414
+ releaseFirstWrite();
415
+ await firstSave;
416
+
417
+ const storagePath = fs.rename.mock.calls.at(-1)[1];
418
+ const persisted = JSON.parse(files.get(storagePath));
419
+ expect(persisted).toEqual(firstStorage);
420
+ expect([...files.keys()].filter((path) => path.endsWith(".tmp"))).toEqual([]);
421
+ });
422
+
423
+ itWindows("propagates a Windows ACL-denied write and removes the temp file", async () => {
424
+ const accessDenied = Object.assign(new Error("access denied"), { code: "EACCES" });
425
+ fs.writeFile.mockRejectedValue(accessDenied);
426
+ fs.unlink.mockResolvedValue(undefined);
427
+
428
+ const storage = { version: 1, accounts: [], activeIndex: 0 };
429
+ await expect(saveAccounts(storage, storage)).rejects.toBe(accessDenied);
430
+
431
+ const tempPath = fs.writeFile.mock.calls[0][0];
432
+ expect(fs.rename).not.toHaveBeenCalled();
433
+ expect(fs.unlink).toHaveBeenCalledWith(tempPath);
434
+ });
435
+
436
+ it("cleans up the temp file when atomic rename fails", async () => {
437
+ const renameError = Object.assign(new Error("rename failed"), { code: "EPERM" });
438
+ fs.rename.mockRejectedValue(renameError);
439
+ fs.unlink.mockResolvedValue(undefined);
440
+
441
+ const storage = { version: 1, accounts: [], activeIndex: 0 };
442
+ await expect(saveAccounts(storage, storage)).rejects.toBe(renameError);
443
+
444
+ const tempPath = fs.writeFile.mock.calls[0][0];
445
+ expect(fs.rename).toHaveBeenCalledWith(tempPath, expect.stringMatching(/anthropic-accounts\.json$/));
446
+ expect(fs.unlink).toHaveBeenCalledWith(tempPath);
447
+ });
448
+
367
449
  it("merges auth fields from fresher disk state", async () => {
368
450
  fs.readFile.mockResolvedValue(
369
451
  JSON.stringify({
@@ -0,0 +1,35 @@
1
+ /**
2
+ * Microcompact decision helpers (token-economy).
3
+ *
4
+ * Pure helpers extracted from index.mjs: whether to inject microcompact betas
5
+ * based on estimated prompt token usage, and the fixed microcompact beta list.
6
+ * The stateful microcompact gating (microcompactState) remains in the
7
+ * interceptor shell in index.mjs.
8
+ *
9
+ * @module token-economy/microcompact
10
+ */
11
+
12
+ /**
13
+ * Determine if microcompact betas should be injected based on estimated token usage.
14
+ * @param {number} estimatedTokens - Estimated prompt token count
15
+ * @param {object} config - Plugin config
16
+ * @returns {boolean}
17
+ */
18
+ export function shouldMicrocompact(estimatedTokens, config) {
19
+ if (!config.microcompact?.enabled) return false;
20
+ const thresholdPct = config.microcompact.threshold_percent ?? 80;
21
+ // Use the model's context window. Default to 200K if unknown.
22
+ // Adaptive context may escalate to 1M, but we use the base 200K for threshold
23
+ // to be conservative (microcompact at 160K tokens is still valuable).
24
+ const contextWindow = 200_000;
25
+ const threshold = contextWindow * (thresholdPct / 100);
26
+ return estimatedTokens >= threshold;
27
+ }
28
+
29
+ /**
30
+ * Build the list of microcompact betas to inject.
31
+ * @returns {string[]} Array of beta flag strings
32
+ */
33
+ export function buildMicrocompactBetas() {
34
+ return ["clear_tool_uses_20250919", "clear_thinking_20251015"];
35
+ }
@@ -0,0 +1,28 @@
1
+ import { describe, it, expect } from "vitest";
2
+ import { shouldMicrocompact, buildMicrocompactBetas } from "./microcompact.mjs";
3
+
4
+ describe("shouldMicrocompact", () => {
5
+ it("returns false when microcompact is disabled or unconfigured", () => {
6
+ expect(shouldMicrocompact(1_000_000, {})).toBe(false);
7
+ expect(shouldMicrocompact(1_000_000, { microcompact: { enabled: false } })).toBe(false);
8
+ });
9
+
10
+ it("uses the default 80% threshold of a 200K window (=160K)", () => {
11
+ const config = { microcompact: { enabled: true } };
12
+ expect(shouldMicrocompact(159_999, config)).toBe(false);
13
+ expect(shouldMicrocompact(160_000, config)).toBe(true);
14
+ expect(shouldMicrocompact(200_000, config)).toBe(true);
15
+ });
16
+
17
+ it("honors a custom threshold_percent", () => {
18
+ const config = { microcompact: { enabled: true, threshold_percent: 50 } };
19
+ expect(shouldMicrocompact(99_999, config)).toBe(false);
20
+ expect(shouldMicrocompact(100_000, config)).toBe(true);
21
+ });
22
+ });
23
+
24
+ describe("buildMicrocompactBetas", () => {
25
+ it("returns the fixed microcompact beta flag list", () => {
26
+ expect(buildMicrocompactBetas()).toEqual(["clear_tool_uses_20250919", "clear_thinking_20251015"]);
27
+ });
28
+ });