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 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
- // v2.1.107: cch attestation via xxHash64(body, seed) & 0xFFFFF
2917
- const finalBody = await computeAndReplaceCCH(body);
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
 
@@ -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
+ });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "opencode-anthropic-fix",
3
- "version": "0.1.18",
3
+ "version": "0.1.20",
4
4
  "license": "GPL-3.0-or-later",
5
5
  "main": "./index.mjs",
6
6
  "files": [