opencode-anthropic-fix 0.1.18 → 0.1.20
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/index.mjs +97 -2
- package/lib/config.mjs +10 -0
- package/lib/config.test.mjs +31 -0
- package/lib/haiku-call.mjs +44 -0
- package/lib/haiku-call.test.mjs +72 -0
- package/package.json +1 -1
package/index.mjs
CHANGED
|
@@ -19,6 +19,8 @@ import {
|
|
|
19
19
|
parseShouldRetryHeader,
|
|
20
20
|
TRANSIENT_RETRY_THRESHOLD_MS,
|
|
21
21
|
} from "./lib/backoff.mjs";
|
|
22
|
+
import { callHaiku } from "./lib/haiku-call.mjs";
|
|
23
|
+
import { summarize as rollingSummarize } from "./lib/rolling-summarizer.mjs";
|
|
22
24
|
|
|
23
25
|
// ---------------------------------------------------------------------------
|
|
24
26
|
// Account management CLI prompts
|
|
@@ -110,6 +112,58 @@ async function promptManageAccounts(accountManager) {
|
|
|
110
112
|
}
|
|
111
113
|
}
|
|
112
114
|
|
|
115
|
+
/**
|
|
116
|
+
* Pure driver for experimental.session.summarize. Extracted from the handler
|
|
117
|
+
* closure so it can be unit-tested without bootstrapping the full plugin.
|
|
118
|
+
* All external dependencies are injected.
|
|
119
|
+
*
|
|
120
|
+
* @param {object} deps
|
|
121
|
+
* @param {object|null} deps.config - Plugin config; handler no-ops if config.token_economy_strategies.haiku_rolling_summary is not true.
|
|
122
|
+
* @param {() => Promise<string>} deps.getAccessToken - Resolves to a Bearer OAuth token (or throws).
|
|
123
|
+
* @param {typeof globalThis.fetch} deps.fetchFn - HTTP transport.
|
|
124
|
+
* @param {typeof callHaiku} deps.callHaikuFn - Haiku API caller.
|
|
125
|
+
* @param {typeof rollingSummarize} deps.rollingSummarizeFn - Deterministic summarizer.
|
|
126
|
+
* @param {{warn: (msg: string) => void}} [deps.logger] - For fall-through warnings.
|
|
127
|
+
* @param {{sessionID: string, messages: unknown[], model: unknown}} input
|
|
128
|
+
* @param {{summary?: string, modelID?: string, providerID?: string, tokens?: {input: number, output: number}, cost?: number}} output
|
|
129
|
+
*/
|
|
130
|
+
async function runHaikuSessionSummarize(
|
|
131
|
+
{ config, getAccessToken, fetchFn, callHaikuFn, rollingSummarizeFn, logger },
|
|
132
|
+
input,
|
|
133
|
+
output,
|
|
134
|
+
) {
|
|
135
|
+
if (!config?.token_economy_strategies?.haiku_rolling_summary) return;
|
|
136
|
+
|
|
137
|
+
try {
|
|
138
|
+
let capturedTokens = { input: 0, output: 0 };
|
|
139
|
+
let capturedCost = 0;
|
|
140
|
+
const haikuCall = async (request) => {
|
|
141
|
+
const r = await callHaikuFn({
|
|
142
|
+
prompt: request.prompt,
|
|
143
|
+
fetch: fetchFn,
|
|
144
|
+
getAccessToken,
|
|
145
|
+
});
|
|
146
|
+
capturedTokens = r.tokens;
|
|
147
|
+
capturedCost = r.cost;
|
|
148
|
+
return r.text;
|
|
149
|
+
};
|
|
150
|
+
|
|
151
|
+
const summaryText = await rollingSummarizeFn(input.messages, { haikuCall });
|
|
152
|
+
if (typeof summaryText !== "string" || summaryText.length === 0) return;
|
|
153
|
+
|
|
154
|
+
output.summary = summaryText;
|
|
155
|
+
output.modelID = "claude-haiku-4-5-20251001";
|
|
156
|
+
output.providerID = "anthropic";
|
|
157
|
+
output.tokens = capturedTokens;
|
|
158
|
+
output.cost = capturedCost;
|
|
159
|
+
} catch (err) {
|
|
160
|
+
if (logger && typeof logger.warn === "function") {
|
|
161
|
+
const msg = err && typeof err === "object" && "message" in err ? err.message : String(err);
|
|
162
|
+
logger.warn(`[opencode-anthropic-fix] haiku rolling summary failed; falling back to default compaction: ${msg}`);
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
|
|
113
167
|
export async function AnthropicAuthPlugin({ client, project, directory, worktree, serverUrl, $ }) {
|
|
114
168
|
const config = loadConfig();
|
|
115
169
|
_pluginConfig = config; // expose to module-level functions (cache stats, response headers)
|
|
@@ -2913,8 +2967,12 @@ export async function AnthropicAuthPlugin({ client, project, directory, worktree
|
|
|
2913
2967
|
_adaptiveOverride,
|
|
2914
2968
|
_tokenEconomy,
|
|
2915
2969
|
);
|
|
2916
|
-
//
|
|
2917
|
-
|
|
2970
|
+
// cch stays as the static "00000" placeholder — cc-107 and cc-108
|
|
2971
|
+
// JS bundles both emit `cch=00000;` unconditionally in the billing
|
|
2972
|
+
// header. The Bun-binary Attestation.zig xxHash64 mechanism lives in
|
|
2973
|
+
// a SEPARATE header path, not in this body field. Re-hashing here
|
|
2974
|
+
// mutates system[0] each turn, invalidating the prompt cache.
|
|
2975
|
+
const finalBody = body;
|
|
2918
2976
|
|
|
2919
2977
|
// Opt-in: dump the OUTGOING body (post-cch) so diagnostics reflect
|
|
2920
2978
|
// exactly what went on the wire. Previously dumped `body` which
|
|
@@ -4041,6 +4099,41 @@ export async function AnthropicAuthPlugin({ client, project, directory, worktree
|
|
|
4041
4099
|
// lib/rolling-summarizer.mjs — call summarize(messages, {haikuCall}) here
|
|
4042
4100
|
// behind config.token_economy.rolling_summarizer when messages are available.
|
|
4043
4101
|
},
|
|
4102
|
+
/**
|
|
4103
|
+
* B3 L2 Option C: Plugin-generated compaction summary via Haiku.
|
|
4104
|
+
* Gated on token_economy_strategies.haiku_rolling_summary. See
|
|
4105
|
+
* runHaikuSessionSummarize at the top of this file for the full driver
|
|
4106
|
+
* — the closure here only binds account/token/config state.
|
|
4107
|
+
*/
|
|
4108
|
+
"experimental.session.summarize": async (input, output) => {
|
|
4109
|
+
if (!config?.token_economy_strategies?.haiku_rolling_summary) return;
|
|
4110
|
+
if (!accountManager) return;
|
|
4111
|
+
|
|
4112
|
+
const account = accountManager.getCurrentAccount();
|
|
4113
|
+
if (!account) return;
|
|
4114
|
+
|
|
4115
|
+
const getAccessToken = async () => {
|
|
4116
|
+
let tok = account.access;
|
|
4117
|
+
if (!tok || !account.expires || account.expires < Date.now()) {
|
|
4118
|
+
tok = await refreshAccountTokenSingleFlight(account);
|
|
4119
|
+
}
|
|
4120
|
+
if (!tok) throw new Error("no access token available for Haiku call");
|
|
4121
|
+
return tok;
|
|
4122
|
+
};
|
|
4123
|
+
|
|
4124
|
+
await runHaikuSessionSummarize(
|
|
4125
|
+
{
|
|
4126
|
+
config,
|
|
4127
|
+
getAccessToken,
|
|
4128
|
+
fetchFn: globalThis.fetch,
|
|
4129
|
+
callHaikuFn: callHaiku,
|
|
4130
|
+
rollingSummarizeFn: rollingSummarize,
|
|
4131
|
+
logger: typeof console !== "undefined" ? console : undefined,
|
|
4132
|
+
},
|
|
4133
|
+
input,
|
|
4134
|
+
output,
|
|
4135
|
+
);
|
|
4136
|
+
},
|
|
4044
4137
|
};
|
|
4045
4138
|
}
|
|
4046
4139
|
|
|
@@ -8839,6 +8932,8 @@ AnthropicAuthPlugin.__testing__ = {
|
|
|
8839
8932
|
// exposed for session-dedupe regression tests (phase C3)
|
|
8840
8933
|
applySessionToolResultDedupe,
|
|
8841
8934
|
maybeApplySessionToolResultDedupe,
|
|
8935
|
+
// exposed for experimental.session.summarize integration tests
|
|
8936
|
+
runHaikuSessionSummarize,
|
|
8842
8937
|
get cachedCCPrompt() {
|
|
8843
8938
|
return cachedCCPrompt;
|
|
8844
8939
|
},
|
package/lib/config.mjs
CHANGED
|
@@ -305,6 +305,12 @@ export const DEFAULT_CONFIG = {
|
|
|
305
305
|
* 10-20% on long sessions. Pure over message history → cache-stable.
|
|
306
306
|
* Off by default (conservative mode territory). */
|
|
307
307
|
tool_result_dedupe_session_wide: false,
|
|
308
|
+
/** Haiku rolling-summary compaction: when a matching opencode fork
|
|
309
|
+
* exposes the `experimental.session.summarize` hook, the plugin calls
|
|
310
|
+
* claude-haiku-4-5-20251001 at temperature 0 to produce the compaction
|
|
311
|
+
* summary, bypassing the main model. Requires opencode fork support
|
|
312
|
+
* — no-op without it. Off by default. */
|
|
313
|
+
haiku_rolling_summary: false,
|
|
308
314
|
},
|
|
309
315
|
/** Output cap: default max_tokens to save context window. */
|
|
310
316
|
output_cap: {
|
|
@@ -781,6 +787,10 @@ function validateConfig(raw) {
|
|
|
781
787
|
typeof tes.tool_result_dedupe_session_wide === "boolean"
|
|
782
788
|
? tes.tool_result_dedupe_session_wide
|
|
783
789
|
: DEFAULT_CONFIG.token_economy_strategies.tool_result_dedupe_session_wide,
|
|
790
|
+
haiku_rolling_summary:
|
|
791
|
+
typeof tes.haiku_rolling_summary === "boolean"
|
|
792
|
+
? tes.haiku_rolling_summary
|
|
793
|
+
: DEFAULT_CONFIG.token_economy_strategies.haiku_rolling_summary,
|
|
784
794
|
};
|
|
785
795
|
}
|
|
786
796
|
|
package/lib/config.test.mjs
CHANGED
|
@@ -379,3 +379,34 @@ describe("loadConfig", () => {
|
|
|
379
379
|
expect(config.adaptive_context.enabled).toBe(false);
|
|
380
380
|
});
|
|
381
381
|
});
|
|
382
|
+
|
|
383
|
+
describe("token_economy_strategies.haiku_rolling_summary", () => {
|
|
384
|
+
beforeEach(() => {
|
|
385
|
+
vi.resetAllMocks();
|
|
386
|
+
});
|
|
387
|
+
|
|
388
|
+
it("defaults to false in DEFAULT_CONFIG", () => {
|
|
389
|
+
expect(DEFAULT_CONFIG.token_economy_strategies.haiku_rolling_summary).toBe(false);
|
|
390
|
+
});
|
|
391
|
+
|
|
392
|
+
it("defaults to false when config file omits the flag", () => {
|
|
393
|
+
existsSync.mockReturnValue(true);
|
|
394
|
+
readFileSync.mockReturnValue(JSON.stringify({}));
|
|
395
|
+
const cfg = loadConfig();
|
|
396
|
+
expect(cfg.token_economy_strategies.haiku_rolling_summary).toBe(false);
|
|
397
|
+
});
|
|
398
|
+
|
|
399
|
+
it("accepts true when explicitly set in config file", () => {
|
|
400
|
+
existsSync.mockReturnValue(true);
|
|
401
|
+
readFileSync.mockReturnValue(JSON.stringify({ token_economy_strategies: { haiku_rolling_summary: true } }));
|
|
402
|
+
const cfg = loadConfig();
|
|
403
|
+
expect(cfg.token_economy_strategies.haiku_rolling_summary).toBe(true);
|
|
404
|
+
});
|
|
405
|
+
|
|
406
|
+
it("falls back to default on non-boolean value (graceful, does not throw)", () => {
|
|
407
|
+
existsSync.mockReturnValue(true);
|
|
408
|
+
readFileSync.mockReturnValue(JSON.stringify({ token_economy_strategies: { haiku_rolling_summary: "yes" } }));
|
|
409
|
+
const cfg = loadConfig();
|
|
410
|
+
expect(cfg.token_economy_strategies.haiku_rolling_summary).toBe(false);
|
|
411
|
+
});
|
|
412
|
+
});
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
export const MODEL = "claude-haiku-4-5-20251001";
|
|
2
|
+
export const TEMPERATURE = 0;
|
|
3
|
+
export const MAX_TOKENS = 2048;
|
|
4
|
+
export const ANTHROPIC_VERSION = "2023-06-01";
|
|
5
|
+
export const API_URL = "https://api.anthropic.com/v1/messages";
|
|
6
|
+
|
|
7
|
+
// Haiku 4.5 pricing as of 2026-04-18 (USD per million tokens).
|
|
8
|
+
const PRICE_INPUT_PER_MTOK = 1.0;
|
|
9
|
+
const PRICE_OUTPUT_PER_MTOK = 5.0;
|
|
10
|
+
|
|
11
|
+
export async function callHaiku({ prompt, fetch, getAccessToken }) {
|
|
12
|
+
const token = await getAccessToken();
|
|
13
|
+
|
|
14
|
+
const res = await fetch(API_URL, {
|
|
15
|
+
method: "POST",
|
|
16
|
+
headers: {
|
|
17
|
+
"content-type": "application/json",
|
|
18
|
+
authorization: `Bearer ${token}`,
|
|
19
|
+
"anthropic-version": ANTHROPIC_VERSION,
|
|
20
|
+
},
|
|
21
|
+
body: JSON.stringify({
|
|
22
|
+
model: MODEL,
|
|
23
|
+
temperature: TEMPERATURE,
|
|
24
|
+
max_tokens: MAX_TOKENS,
|
|
25
|
+
messages: [{ role: "user", content: [{ type: "text", text: prompt }] }],
|
|
26
|
+
}),
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
if (!res.ok) {
|
|
30
|
+
const body = await res.text().catch(() => "");
|
|
31
|
+
throw new Error(`Haiku call failed: HTTP ${res.status} ${body}`);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
const json = await res.json();
|
|
35
|
+
const textBlock = (json.content ?? []).find((b) => b.type === "text");
|
|
36
|
+
if (!textBlock || typeof textBlock.text !== "string" || textBlock.text.length === 0) {
|
|
37
|
+
throw new Error("Haiku response has no text content");
|
|
38
|
+
}
|
|
39
|
+
const input = json.usage?.input_tokens ?? 0;
|
|
40
|
+
const output = json.usage?.output_tokens ?? 0;
|
|
41
|
+
const cost = (input / 1e6) * PRICE_INPUT_PER_MTOK + (output / 1e6) * PRICE_OUTPUT_PER_MTOK;
|
|
42
|
+
|
|
43
|
+
return { text: textBlock.text, tokens: { input, output }, cost };
|
|
44
|
+
}
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
import { describe, expect, it, vi } from "vitest";
|
|
2
|
+
import { callHaiku } from "./haiku-call.mjs";
|
|
3
|
+
|
|
4
|
+
describe("callHaiku", () => {
|
|
5
|
+
it("returns text + tokens + cost on 200 response", async () => {
|
|
6
|
+
const fetchMock = vi.fn(async () => ({
|
|
7
|
+
ok: true,
|
|
8
|
+
status: 200,
|
|
9
|
+
json: async () => ({
|
|
10
|
+
content: [{ type: "text", text: "summary body here" }],
|
|
11
|
+
usage: { input_tokens: 100, output_tokens: 30 },
|
|
12
|
+
}),
|
|
13
|
+
}));
|
|
14
|
+
const getAccessToken = vi.fn(async () => "oauth-token-abc");
|
|
15
|
+
|
|
16
|
+
const result = await callHaiku({
|
|
17
|
+
prompt: "summarize this",
|
|
18
|
+
fetch: fetchMock,
|
|
19
|
+
getAccessToken,
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
expect(result.text).toBe("summary body here");
|
|
23
|
+
expect(result.tokens).toEqual({ input: 100, output: 30 });
|
|
24
|
+
// Haiku 4.5 pricing: $1/MTok input, $5/MTok output (2026-04-18)
|
|
25
|
+
// cost = 100/1e6 * 1 + 30/1e6 * 5 = 0.0001 + 0.00015 = 0.00025
|
|
26
|
+
expect(result.cost).toBeCloseTo(0.00025, 8);
|
|
27
|
+
|
|
28
|
+
expect(fetchMock).toHaveBeenCalledTimes(1);
|
|
29
|
+
const [url, opts] = fetchMock.mock.calls[0];
|
|
30
|
+
expect(url).toBe("https://api.anthropic.com/v1/messages");
|
|
31
|
+
expect(opts.method).toBe("POST");
|
|
32
|
+
expect(opts.headers.authorization).toBe("Bearer oauth-token-abc");
|
|
33
|
+
expect(opts.headers["anthropic-version"]).toBe("2023-06-01");
|
|
34
|
+
const body = JSON.parse(opts.body);
|
|
35
|
+
expect(body.model).toBe("claude-haiku-4-5-20251001");
|
|
36
|
+
expect(body.temperature).toBe(0);
|
|
37
|
+
expect(body.max_tokens).toBe(2048);
|
|
38
|
+
expect(body.messages).toEqual([{ role: "user", content: [{ type: "text", text: "summarize this" }] }]);
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
it("throws on non-2xx HTTP response", async () => {
|
|
42
|
+
const fetchMock = vi.fn(async () => ({
|
|
43
|
+
ok: false,
|
|
44
|
+
status: 429,
|
|
45
|
+
text: async () => "rate limited",
|
|
46
|
+
}));
|
|
47
|
+
const getAccessToken = vi.fn(async () => "oauth-token-abc");
|
|
48
|
+
|
|
49
|
+
await expect(callHaiku({ prompt: "x", fetch: fetchMock, getAccessToken })).rejects.toThrow(/HTTP 429/);
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
it("throws when response content is missing or not text", async () => {
|
|
53
|
+
const fetchMock = vi.fn(async () => ({
|
|
54
|
+
ok: true,
|
|
55
|
+
status: 200,
|
|
56
|
+
json: async () => ({ content: [], usage: { input_tokens: 0, output_tokens: 0 } }),
|
|
57
|
+
}));
|
|
58
|
+
const getAccessToken = vi.fn(async () => "oauth-token-abc");
|
|
59
|
+
|
|
60
|
+
await expect(callHaiku({ prompt: "x", fetch: fetchMock, getAccessToken })).rejects.toThrow(/no text content/i);
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
it("throws when getAccessToken rejects", async () => {
|
|
64
|
+
const fetchMock = vi.fn();
|
|
65
|
+
const getAccessToken = vi.fn(async () => {
|
|
66
|
+
throw new Error("oauth expired");
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
await expect(callHaiku({ prompt: "x", fetch: fetchMock, getAccessToken })).rejects.toThrow(/oauth expired/);
|
|
70
|
+
expect(fetchMock).not.toHaveBeenCalled();
|
|
71
|
+
});
|
|
72
|
+
});
|