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.
- package/README.md +2 -0
- package/cli.mjs +42 -35
- package/index.mjs +399 -3591
- package/lib/config.test.mjs +298 -1
- package/lib/diagnose.mjs +184 -0
- package/lib/diagnose.test.mjs +122 -0
- package/lib/env.mjs +19 -0
- package/lib/env.test.mjs +25 -0
- package/lib/mimicry/cache.mjs +62 -0
- package/lib/mimicry/cache.test.mjs +112 -0
- package/lib/mimicry/headers.mjs +530 -0
- package/lib/mimicry/headers.test.mjs +50 -0
- package/lib/mimicry/models.mjs +146 -0
- package/lib/mimicry/models.test.mjs +132 -0
- package/lib/mimicry/request-body.mjs +684 -0
- package/lib/mimicry/request-body.test.mjs +34 -0
- package/lib/mimicry/request-helpers.mjs +243 -0
- package/lib/mimicry/request-helpers.test.mjs +155 -0
- package/lib/mimicry/response-stream.mjs +429 -0
- package/lib/mimicry/response-stream.test.mjs +127 -0
- package/lib/mimicry/system-prompt.mjs +638 -0
- package/lib/mimicry/system-prompt.test.mjs +73 -0
- package/lib/redact.mjs +147 -0
- package/lib/redact.test.mjs +154 -0
- package/lib/refresh-lock.test.mjs +52 -0
- package/lib/retry/overload-loop.mjs +83 -0
- package/lib/retry/overload-loop.test.mjs +107 -0
- package/lib/session-metrics.mjs +75 -0
- package/lib/session-metrics.test.mjs +67 -0
- package/lib/storage.test.mjs +82 -0
- package/lib/token-economy/microcompact.mjs +35 -0
- package/lib/token-economy/microcompact.test.mjs +28 -0
- package/lib/token-economy/transforms.mjs +637 -0
- package/lib/token-economy/transforms.test.mjs +192 -0
- package/lib/tuning.mjs +70 -0
- package/lib/tuning.test.mjs +89 -0
- 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
|
+
});
|
package/lib/storage.test.mjs
CHANGED
|
@@ -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
|
+
});
|