opencode-anthropic-fix 0.6.0 → 2.0.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 +30 -3
- package/cli.mjs +77 -24
- package/index.mjs +297 -200
- package/lib/account-state.mjs +48 -0
- package/lib/accounts.mjs +211 -12
- package/lib/betas.mjs +131 -0
- package/lib/config.mjs +90 -12
- package/lib/diagnose.mjs +4 -2
- package/lib/mimicry/adapter-input.mjs +137 -33
- package/lib/mimicry/headers.mjs +143 -15
- package/lib/mimicry/request-body.mjs +51 -14
- package/lib/mimicry/system-prompt.mjs +72 -293
- package/lib/mimicry/wire-compat.mjs +133 -11
- package/lib/oauth-constants.mjs +109 -0
- package/lib/oauth-env.mjs +187 -0
- package/lib/oauth-scopes.mjs +119 -0
- package/lib/oauth.mjs +270 -90
- package/lib/passthrough-headers.mjs +152 -0
- package/lib/token-economy/microcompact.mjs +3 -12
- package/package.json +2 -2
- package/lib/mimicry/models.mjs +0 -146
- package/lib/request-headers.mjs +0 -303
package/README.md
CHANGED
|
@@ -58,7 +58,7 @@ The [original plugin](https://github.com/anomalyco/opencode-anthropic-auth) prov
|
|
|
58
58
|
- **Health scoring** — tracks account reliability and prefers healthy accounts
|
|
59
59
|
- **Standalone CLI** — manage accounts without opening OpenCode
|
|
60
60
|
- **Configurable strategies** — sticky, round-robin, or hybrid account selection
|
|
61
|
-
- **Claude Code signature emulation** — full HTTP header, system prompt, beta flag, and metadata mimicry derived from Claude Code's open source code.
|
|
61
|
+
- **Claude Code signature emulation** — full HTTP header, system prompt, beta flag, and metadata mimicry derived from Claude Code's open source code. Canonical Claude Code protocol composition (headers, body, system prefix, betas, URL) is sourced from [`@tormentalabs/claude-code-wire-compat`](https://www.npmjs.com/package/@tormentalabs/claude-code-wire-compat); system prompt cache scoping stays host policy (`splitSystemCacheScopes`, boundary/global/org) with exact marker detection and scope-aware `cache_control` generation
|
|
62
62
|
- **OAuth endpoint fingerprint parity** — matches the real CLI's bundled axios 1.13.6 HTTP client signature (`Accept`, `User-Agent`, `Content-Type`) on all OAuth token endpoint calls, required since 2026-03-21 server-side enforcement
|
|
63
63
|
- **Billing header fingerprint parity** — `cc_version` suffix uses the real CLI's 3-char fingerprint hash (SHA-256 of salt + first user message chars + version), `cch=00000` is a static placeholder (xxHash64 attestation was removed in Claude Code v2.1.97), system prompt is sanitized to match Claude Code's pattern validation, and `X-Claude-Code-Session-Id` header is sent on all requests
|
|
64
64
|
- **Adaptive thinking for Opus/Sonnet 4.6** — automatically normalizes thinking to `{type: "adaptive"}` for supported models, with `effort-2025-11-24` beta
|
|
@@ -597,9 +597,36 @@ The plugin also:
|
|
|
597
597
|
- Emulates Claude-style request headers and beta flags by default
|
|
598
598
|
- Sanitizes "OpenCode" references to "Claude Code" in system prompts (required by Anthropic's API)
|
|
599
599
|
- In `prompt_compaction="minimal"`, deduplicates repeated/contained system blocks and uses a compact dedicated prompt for internal title-generation requests
|
|
600
|
-
-
|
|
600
|
+
- With signature emulation on, adds `?beta=true` to `/v1/messages` and `/v1/messages/count_tokens` requests (and
|
|
601
|
+
normalizes a `/messages` path to `/v1/messages`). With it off, the host's URL is sent untouched.
|
|
601
602
|
|
|
602
|
-
|
|
603
|
+
### Signature emulation
|
|
604
|
+
|
|
605
|
+
`signature_emulation.enabled` is a strictly binary switch — there is no half-way mode.
|
|
606
|
+
|
|
607
|
+
**On (default):** every `/v1/messages` and `/v1/messages/count_tokens` turn is built by the shared
|
|
608
|
+
`@tormentalabs/claude-code-wire-compat` package: full Claude Code header set, beta list, identity/billing system
|
|
609
|
+
blocks and `metadata.user_id`.
|
|
610
|
+
|
|
611
|
+
**Off: pure passthrough plus the auth envelope.** The plugin forges nothing. Your request goes out as your client wrote
|
|
612
|
+
it — same `user-agent`, same headers, same body bytes — with only three changes, all of which belong to the OAuth
|
|
613
|
+
transport rather than to the disguise:
|
|
614
|
+
|
|
615
|
+
- `authorization: Bearer <account token>` — without it there is no request and no account rotation;
|
|
616
|
+
- `anthropic-beta` — **additive**: your value is preserved verbatim and `oauth-2025-04-20` is appended only when
|
|
617
|
+
missing, because the API rejects an OAuth bearer that does not carry it;
|
|
618
|
+
- `x-api-key` and `x-session-affinity` are removed — a second credential must not travel next to your bearer, and the
|
|
619
|
+
affinity hint leaks session identity upstream.
|
|
620
|
+
|
|
621
|
+
The body is not transformed either. Two fields are stripped because the API rejects them outright: the body-level
|
|
622
|
+
`betas` field and any stainless-helper markers your client attached.
|
|
623
|
+
|
|
624
|
+
> **Changed in the unreleased version.** Emulation-off used to still forge a `claude-cli` user-agent and REPLACE your
|
|
625
|
+
> `anthropic-beta` with a minimal list, and it ran structural body normalizations (which also emptied your `system`
|
|
626
|
+
> prompt and injected a `temperature` you never sent). All of that is gone. See the CHANGELOG.
|
|
627
|
+
|
|
628
|
+
The system-prompt side is unchanged: with emulation off the plugin uses the legacy system transform path, including
|
|
629
|
+
the Claude Code system prompt prefix.
|
|
603
630
|
|
|
604
631
|
## Files
|
|
605
632
|
|
package/cli.mjs
CHANGED
|
@@ -7,8 +7,8 @@
|
|
|
7
7
|
*
|
|
8
8
|
* Auth Commands:
|
|
9
9
|
* login Add a new account via browser OAuth flow
|
|
10
|
-
* logout <N>
|
|
11
|
-
* logout --all
|
|
10
|
+
* logout <N> Remove account N (discards its tokens)
|
|
11
|
+
* logout --all Remove all accounts (discards their tokens)
|
|
12
12
|
* reauth <N> Re-authenticate account N with fresh OAuth tokens
|
|
13
13
|
* refresh <N> Attempt token refresh (no browser needed)
|
|
14
14
|
*
|
|
@@ -31,7 +31,8 @@
|
|
|
31
31
|
import { loadAccounts, saveAccounts, getStoragePath, createDefaultStats } from "./lib/storage.mjs";
|
|
32
32
|
import { loadConfig, saveConfig, getConfigPath, VALID_STRATEGIES } from "./lib/config.mjs";
|
|
33
33
|
import { authorize, exchange, refreshToken, revoke } from "./lib/oauth.mjs";
|
|
34
|
-
import { adjustActiveIndexAfterRemoval } from "./lib/account-state.mjs";
|
|
34
|
+
import { adjustActiveIndexAfterRemoval, isTokenExpired } from "./lib/account-state.mjs";
|
|
35
|
+
import { warnOnEphemeralTokenRotation } from "./lib/accounts.mjs";
|
|
35
36
|
import { AsyncLocalStorage } from "node:async_hooks";
|
|
36
37
|
import { writeFile } from "node:fs/promises";
|
|
37
38
|
import path from "node:path";
|
|
@@ -150,9 +151,16 @@ function rpad(str, width) {
|
|
|
150
151
|
*/
|
|
151
152
|
export async function refreshAccessToken(account) {
|
|
152
153
|
try {
|
|
153
|
-
|
|
154
|
+
// `clientId` is undefined for every normal account, which means "use the
|
|
155
|
+
// default". Only a CLAUDE_CODE_OAUTH_CLIENT_ID-seeded env account carries one
|
|
156
|
+
// (contract §5.2, divergence D10).
|
|
157
|
+
const json = await refreshToken(account.refreshToken, {
|
|
158
|
+
signal: AbortSignal.timeout(5000),
|
|
159
|
+
clientId: account.clientId,
|
|
160
|
+
});
|
|
154
161
|
account.access = json.access_token;
|
|
155
162
|
account.expires = Date.now() + json.expires_in * 1000;
|
|
163
|
+
warnOnEphemeralTokenRotation(account, json.refresh_token);
|
|
156
164
|
if (json.refresh_token) account.refreshToken = json.refresh_token;
|
|
157
165
|
account.token_updated_at = Date.now();
|
|
158
166
|
return json.access_token;
|
|
@@ -194,7 +202,7 @@ export async function ensureTokenAndFetchUsage(account) {
|
|
|
194
202
|
let token = account.access;
|
|
195
203
|
let tokenRefreshed = false;
|
|
196
204
|
|
|
197
|
-
if (!token ||
|
|
205
|
+
if (!token || isTokenExpired(account.expires)) {
|
|
198
206
|
token = await refreshAccessToken(account);
|
|
199
207
|
tokenRefreshed = !!token;
|
|
200
208
|
if (!token) return { usage: null, tokenRefreshed: false };
|
|
@@ -301,7 +309,19 @@ function openBrowser(url) {
|
|
|
301
309
|
* @returns {Promise<{refresh: string, access: string, expires: number, email?: string} | null>}
|
|
302
310
|
*/
|
|
303
311
|
async function runOAuthFlow() {
|
|
304
|
-
|
|
312
|
+
/** @type {{url: string, verifier: string}} */
|
|
313
|
+
let authorization;
|
|
314
|
+
try {
|
|
315
|
+
// `authorize()` rejects on a configuration problem before it does any work —
|
|
316
|
+
// an unapproved CLAUDE_CODE_CUSTOM_OAUTH_URL, or an approved one this plugin
|
|
317
|
+
// cannot honour. Those messages are written for the operator, so print them
|
|
318
|
+
// as an error rather than letting the promise reject into a stack trace.
|
|
319
|
+
authorization = await authorize("max");
|
|
320
|
+
} catch (err) {
|
|
321
|
+
console.error(c.red(`Error: ${err?.message ?? err}`));
|
|
322
|
+
return null;
|
|
323
|
+
}
|
|
324
|
+
const { url, verifier } = authorization;
|
|
305
325
|
|
|
306
326
|
console.log("");
|
|
307
327
|
console.log(c.bold("Opening browser for Anthropic OAuth login..."));
|
|
@@ -412,7 +432,28 @@ export async function cmdLogin() {
|
|
|
412
432
|
}
|
|
413
433
|
|
|
414
434
|
/**
|
|
415
|
-
*
|
|
435
|
+
* Turn a `revoke()` outcome into a line that says what actually happened.
|
|
436
|
+
*
|
|
437
|
+
* Three outcomes, three messages. Collapsing "we did not try" and "we tried and
|
|
438
|
+
* it failed" into one line tells a user who opted into server-side revocation
|
|
439
|
+
* that their token was merely discarded locally, when in fact it is still live
|
|
440
|
+
* on the server. That is the one thing this function exists to prevent.
|
|
441
|
+
*
|
|
442
|
+
* @param {{ attempted: boolean, ok: boolean, status?: number, error?: string }} outcome
|
|
443
|
+
* @returns {string}
|
|
444
|
+
* @see docs/oauth-2.1.280-contract.md §7 divergence D1
|
|
445
|
+
*/
|
|
446
|
+
export function describeRevokeOutcome(outcome) {
|
|
447
|
+
if (!outcome?.attempted) {
|
|
448
|
+
return "Token discarded locally (server-side revocation is off; set oauth.revoke_on_logout to enable).";
|
|
449
|
+
}
|
|
450
|
+
if (outcome.ok) return "Token revoked server-side.";
|
|
451
|
+
const detail = outcome.status ? `HTTP ${outcome.status}` : outcome.error || "unknown error";
|
|
452
|
+
return `Server-side revocation failed (${detail}). Token removed locally; it may still be valid.`;
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
/**
|
|
456
|
+
* Logout: discard tokens and remove an account, or all accounts.
|
|
416
457
|
* @param {string} arg - Account number
|
|
417
458
|
* @param {object} [opts]
|
|
418
459
|
* @param {boolean} [opts.force] Skip confirmation prompt
|
|
@@ -453,7 +494,7 @@ export async function cmdLogout(arg, opts = {}) {
|
|
|
453
494
|
const rl = createInterface({ input: stdin, output: stdout });
|
|
454
495
|
try {
|
|
455
496
|
const answer = await rl.question(
|
|
456
|
-
`Logout account #${n} (${label})? This will
|
|
497
|
+
`Logout account #${n} (${label})? This will discard its tokens and remove the account. [y/N]: `,
|
|
457
498
|
);
|
|
458
499
|
if (answer.trim().toLowerCase() !== "y") {
|
|
459
500
|
console.log(c.dim("Cancelled."));
|
|
@@ -464,13 +505,11 @@ export async function cmdLogout(arg, opts = {}) {
|
|
|
464
505
|
}
|
|
465
506
|
}
|
|
466
507
|
|
|
467
|
-
//
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
console.log(c.dim("Token revocation skipped (server may not support it)."));
|
|
473
|
-
}
|
|
508
|
+
// Server-side revocation is opt-in (oauth.revoke_on_logout, default off): the
|
|
509
|
+
// genuine client has no revoke endpoint. Report what actually happened rather
|
|
510
|
+
// than implying a network call that was never made.
|
|
511
|
+
const outcome = await revoke(stored.accounts[idx].refreshToken);
|
|
512
|
+
console.log(c.dim(describeRevokeOutcome(outcome)));
|
|
474
513
|
|
|
475
514
|
// Remove the account
|
|
476
515
|
stored.accounts.splice(idx, 1);
|
|
@@ -491,7 +530,7 @@ export async function cmdLogout(arg, opts = {}) {
|
|
|
491
530
|
}
|
|
492
531
|
|
|
493
532
|
/**
|
|
494
|
-
* Logout all accounts:
|
|
533
|
+
* Logout all accounts: discard all tokens and clear storage.
|
|
495
534
|
* @param {object} [opts]
|
|
496
535
|
* @param {boolean} [opts.force] Skip confirmation prompt
|
|
497
536
|
* @returns {Promise<number>} exit code
|
|
@@ -514,7 +553,7 @@ async function cmdLogoutAll(opts = {}) {
|
|
|
514
553
|
const rl = createInterface({ input: stdin, output: stdout });
|
|
515
554
|
try {
|
|
516
555
|
const answer = await rl.question(
|
|
517
|
-
`Logout all ${count} account(s)? This will
|
|
556
|
+
`Logout all ${count} account(s)? This will discard their tokens and remove all accounts. [y/N]: `,
|
|
518
557
|
);
|
|
519
558
|
if (answer.trim().toLowerCase() !== "y") {
|
|
520
559
|
console.log(c.dim("Cancelled."));
|
|
@@ -525,12 +564,26 @@ async function cmdLogoutAll(opts = {}) {
|
|
|
525
564
|
}
|
|
526
565
|
}
|
|
527
566
|
|
|
528
|
-
//
|
|
567
|
+
// Server-side revocation is opt-in (oauth.revoke_on_logout, default off).
|
|
529
568
|
const results = await Promise.allSettled(stored.accounts.map((acc) => revoke(acc.refreshToken)));
|
|
530
|
-
const
|
|
531
|
-
|
|
532
|
-
|
|
569
|
+
const outcomes = results.map((r) => (r.status === "fulfilled" ? r.value : { attempted: true, ok: false }));
|
|
570
|
+
const attempted = outcomes.filter((o) => o?.attempted === true).length;
|
|
571
|
+
const revokedCount = outcomes.filter((o) => o?.ok === true).length;
|
|
572
|
+
|
|
573
|
+
if (attempted === 0) {
|
|
574
|
+
// Nothing was sent. Say so, and say how to change it.
|
|
575
|
+
console.log(c.dim(`Discarded ${count} token(s) locally (server-side revocation is off).`));
|
|
576
|
+
console.log(c.dim("Enable it with: oauth.revoke_on_logout = true"));
|
|
577
|
+
} else if (revokedCount === attempted) {
|
|
533
578
|
console.log(c.dim(`Revoked ${revokedCount} of ${count} token(s) server-side.`));
|
|
579
|
+
} else {
|
|
580
|
+
// Partial or total failure must not be reported as a clean local discard.
|
|
581
|
+
console.log(
|
|
582
|
+
c.yellow(
|
|
583
|
+
`Revoked ${revokedCount} of ${attempted} token(s) server-side; ` +
|
|
584
|
+
`${attempted - revokedCount} revocation(s) failed. Tokens removed locally anyway.`,
|
|
585
|
+
),
|
|
586
|
+
);
|
|
534
587
|
}
|
|
535
588
|
|
|
536
589
|
// Write explicit empty state so running plugin instances reconcile immediately.
|
|
@@ -1574,8 +1627,8 @@ ${c.dim("Usage:")}
|
|
|
1574
1627
|
|
|
1575
1628
|
${c.dim("Auth Commands:")}
|
|
1576
1629
|
${pad(c.cyan("login"), 22)}Add a new account via browser OAuth flow
|
|
1577
|
-
${pad(c.cyan("logout") + " <N>", 22)}
|
|
1578
|
-
${pad(c.cyan("logout") + " --all", 22)}
|
|
1630
|
+
${pad(c.cyan("logout") + " <N>", 22)}Remove account N (discards its tokens)
|
|
1631
|
+
${pad(c.cyan("logout") + " --all", 22)}Remove all accounts (discards their tokens)
|
|
1579
1632
|
${pad(c.cyan("reauth") + " <N>", 22)}Re-authenticate account N with fresh tokens
|
|
1580
1633
|
${pad(c.cyan("refresh") + " <N>", 22)}Attempt token refresh (no browser needed)
|
|
1581
1634
|
|
|
@@ -1602,7 +1655,7 @@ ${c.dim("Options:")}
|
|
|
1602
1655
|
|
|
1603
1656
|
${c.dim("Examples:")}
|
|
1604
1657
|
${bin} login ${c.dim("# Add a new account via browser")}
|
|
1605
|
-
${bin} logout 2 ${c.dim("#
|
|
1658
|
+
${bin} logout 2 ${c.dim("# Remove account 2 & discard its tokens")}
|
|
1606
1659
|
${bin} logout --all ${c.dim("# Logout all accounts")}
|
|
1607
1660
|
${bin} reauth 1 ${c.dim("# Re-authenticate account 1")}
|
|
1608
1661
|
${bin} refresh 1 ${c.dim("# Quick token refresh for account 1")}
|