@ex-machina/opencode-anthropic-auth 2.0.0-next.2 → 2.0.0-next.3

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 CHANGED
@@ -114,9 +114,9 @@ The plugin reads the following environment variables:
114
114
  - **`ANTHROPIC_INSECURE`** — Skips TLS certificate verification. Behavior differs by OpenCode version:
115
115
  - **OpenCode v1** — Set to `1` or `true` to skip verification. Only effective when `ANTHROPIC_BASE_URL` is also set.
116
116
  - **OpenCode v2** — Not supported. OpenCode v2 plugin request hooks cannot disable TLS verification. If set, the plugin logs a warning and leaves verification enabled; requests to an untrusted or self-signed `ANTHROPIC_BASE_URL` will fail.
117
- - **`ANTHROPIC_CLAUDE_CODE_VERSION`** — Overrides the Claude Code version reported to Anthropic for both release lines. Must be `major.minor.patch` (for example, `2.1.275`). Defaults to the bundled version; a malformed value logs an actionable error without echoing its contents, and the bundled version is used instead. A value older than the bundled version is honored but logs a warning, since reporting an older version can make newer models reject the request. Read once when the plugin loads, so restart OpenCode after changing it.
117
+ - **`ANTHROPIC_CLAUDE_CODE_VERSION`** — Overrides the Claude Code version reported to Anthropic for both release lines. Must be `major.minor.patch` (for example, `2.1.280`). Defaults to the bundled version; a malformed value logs an actionable error without echoing its contents, and the bundled version is used instead. A value older than the bundled version is honored but logs a warning, since reporting an older version can make newer models reject the request. Read once when the plugin loads, so restart OpenCode after changing it.
118
118
 
119
- Anthropic gates model access on the reported Claude Code version server-side, returning a 400 `claude_code_version_too_old` error for models that require a newer client. `ANTHROPIC_CLAUDE_CODE_VERSION` lets you raise the reported version without waiting for a plugin release.
119
+ Anthropic gates model access on the reported Claude Code version server-side, returning a 400 `claude_code_version_too_old` error for models that require a newer client. On OpenCode v2, when the exact structured rejection names a newer minimum, the plugin adopts that real version for the rest of the process and retries the initial request once. Other 400 responses and valid explicit `ANTHROPIC_CLAUDE_CODE_VERSION` overrides are never changed automatically. A malformed override is ignored, so its bundled fallback remains eligible for exact-response recovery. The override remains an escape hatch for both release lines.
120
120
 
121
121
  ## How It Works
122
122
 
package/dist/config.d.ts CHANGED
@@ -7,6 +7,16 @@
7
7
  * waiting for a published bump.
8
8
  */
9
9
  export declare const ANTHROPIC_CLAUDE_CODE_VERSION_ENV_VAR = "ANTHROPIC_CLAUDE_CODE_VERSION";
10
+ export declare function isValidClaudeCodeVersion(candidate: string): boolean;
11
+ /**
12
+ * Compare two validated Claude Code releases.
13
+ *
14
+ * Components are compared numerically rather than lexically — `2.1.99` sorts
15
+ * after `2.1.280` as a string but is the older release — and as `BigInt`, so a
16
+ * large bounded component cannot silently lose precision the way `Number`
17
+ * would. Invalid input has no ordering and returns `undefined`.
18
+ */
19
+ export declare function compareClaudeCodeVersions(candidate: string, baseline: string): -1 | 0 | 1 | undefined;
10
20
  /**
11
21
  * Outcome of reading the version override.
12
22
  *
package/dist/config.js CHANGED
@@ -11,20 +11,27 @@ export const ANTHROPIC_CLAUDE_CODE_VERSION_ENV_VAR = 'ANTHROPIC_CLAUDE_CODE_VERS
11
11
  /**
12
12
  * Claude Code releases are `major.minor.patch` with numeric components.
13
13
  *
14
- * Leading zeros are rejected: `02.1.275` is not a release Anthropic publishes,
14
+ * Leading zeros are rejected: `02.1.280` is not a release Anthropic publishes,
15
15
  * so accepting it would report a version string no server-side gate expects.
16
16
  */
17
17
  const VERSION_PATTERN = /^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)$/;
18
18
  const MAX_VERSION_LENGTH = 64;
19
+ export function isValidClaudeCodeVersion(candidate) {
20
+ return (candidate.length <= MAX_VERSION_LENGTH && VERSION_PATTERN.test(candidate));
21
+ }
19
22
  /**
20
- * Is `candidate` an older Claude Code release than `baseline`?
23
+ * Compare two validated Claude Code releases.
21
24
  *
22
- * Both arguments must already match `VERSION_PATTERN`. Components are compared
23
- * numerically rather than lexically — `2.1.99` sorts after `2.1.275` as a
24
- * string but is the older release — and as `BigInt`, so an unbounded component
25
- * cannot silently lose precision the way `Number` would.
25
+ * Components are compared numerically rather than lexically — `2.1.99` sorts
26
+ * after `2.1.280` as a string but is the older release — and as `BigInt`, so a
27
+ * large bounded component cannot silently lose precision the way `Number`
28
+ * would. Invalid input has no ordering and returns `undefined`.
26
29
  */
27
- function isOlderVersion(candidate, baseline) {
30
+ export function compareClaudeCodeVersions(candidate, baseline) {
31
+ if (!isValidClaudeCodeVersion(candidate) ||
32
+ !isValidClaudeCodeVersion(baseline)) {
33
+ return undefined;
34
+ }
28
35
  // The `0n` defaults are unreachable — `VERSION_PATTERN` guarantees exactly
29
36
  // three components — but they keep the destructuring free of assertions.
30
37
  const [major = 0n, minor = 0n, patch = 0n] = candidate
@@ -34,10 +41,12 @@ function isOlderVersion(candidate, baseline) {
34
41
  .split('.')
35
42
  .map((part) => BigInt(part));
36
43
  if (major !== baseMajor)
37
- return major < baseMajor;
44
+ return major < baseMajor ? -1 : 1;
38
45
  if (minor !== baseMinor)
39
- return minor < baseMinor;
40
- return patch < basePatch;
46
+ return minor < baseMinor ? -1 : 1;
47
+ if (patch !== basePatch)
48
+ return patch < basePatch ? -1 : 1;
49
+ return 0;
41
50
  }
42
51
  /**
43
52
  * Resolve the Claude Code version to report to Anthropic.
@@ -54,7 +63,7 @@ export function resolveClaudeCodeVersion(raw = process.env[ANTHROPIC_CLAUDE_CODE
54
63
  return { type: 'success', version: CLAUDE_CODE_VERSION };
55
64
  }
56
65
  const trimmed = raw.length <= MAX_VERSION_LENGTH ? raw.trim() : '';
57
- if (!VERSION_PATTERN.test(trimmed)) {
66
+ if (!isValidClaudeCodeVersion(trimmed)) {
58
67
  return {
59
68
  type: 'invalid',
60
69
  error: `${ANTHROPIC_CLAUDE_CODE_VERSION_ENV_VAR} is not a valid Claude Code version. ` +
@@ -63,7 +72,7 @@ export function resolveClaudeCodeVersion(raw = process.env[ANTHROPIC_CLAUDE_CODE
63
72
  `${ANTHROPIC_CLAUDE_CODE_VERSION_ENV_VAR} and restart OpenCode to use the override.`,
64
73
  };
65
74
  }
66
- if (isOlderVersion(trimmed, CLAUDE_CODE_VERSION)) {
75
+ if (compareClaudeCodeVersions(trimmed, CLAUDE_CODE_VERSION) === -1) {
67
76
  return {
68
77
  type: 'outdated',
69
78
  version: trimmed,
@@ -22,7 +22,7 @@ export declare const CCH_POSITIONS: number[];
22
22
  * newer is required"). Keep this at or above the latest published
23
23
  * `@anthropic-ai/claude-code` release, otherwise new models are unreachable.
24
24
  */
25
- export declare const CLAUDE_CODE_VERSION = "2.1.275";
25
+ export declare const CLAUDE_CODE_VERSION = "2.1.280";
26
26
  export declare const CLAUDE_CODE_ENTRYPOINT = "sdk-cli";
27
27
  /**
28
28
  * Build the `user-agent` value for a reported Claude Code version.
package/dist/constants.js CHANGED
@@ -32,7 +32,7 @@ export const CCH_POSITIONS = [4, 7, 20];
32
32
  * newer is required"). Keep this at or above the latest published
33
33
  * `@anthropic-ai/claude-code` release, otherwise new models are unreachable.
34
34
  */
35
- export const CLAUDE_CODE_VERSION = '2.1.275';
35
+ export const CLAUDE_CODE_VERSION = '2.1.280';
36
36
  export const CLAUDE_CODE_ENTRYPOINT = 'sdk-cli';
37
37
  /**
38
38
  * Build the `user-agent` value for a reported Claude Code version.
package/dist/index.js CHANGED
@@ -2,10 +2,11 @@ import { createHash, createHmac, randomBytes } from 'node:crypto';
2
2
  import { Plugin } from '@opencode/plugin';
3
3
  import { authorize, exchange, refreshToken } from "./auth.js";
4
4
  import { BodyLimitError, contentLength, readBoundedText } from "./bounded.js";
5
- import { resolveClaudeCodeVersion } from "./config.js";
6
- import { CLAUDE_CODE_VERSION, REQUIRED_BETAS } from "./constants.js";
5
+ import { ANTHROPIC_CLAUDE_CODE_VERSION_ENV_VAR, compareClaudeCodeVersions, isValidClaudeCodeVersion, resolveClaudeCodeVersion, } from "./config.js";
6
+ import { CLAUDE_CODE_VERSION, formatUserAgent, REQUIRED_BETAS, } from "./constants.js";
7
7
  import { createConnectionLabel, describeConnection, enhanceRateLimitResponse, } from "./rate-limit.js";
8
8
  import { createStrippedStream, headersAfterBodyTransform, isInsecure, isTrustedAnthropicUrl, mergeHeaders, rewriteRequestBody, rewriteUrl, setOAuthHeaders, ToolNameAliasTable, } from "./transform.js";
9
+ import { detectClaudeCodeVersionRejection } from "./version-rejection.js";
9
10
  const PLUGIN_ID = 'ex-machina.anthropic-auth';
10
11
  const INTEGRATION_ID = 'anthropic';
11
12
  const REFRESH_CACHE_GRACE_MS = 30_000;
@@ -25,6 +26,7 @@ const CONNECTION_TRACKING_TTL_MS = 5 * 60_000;
25
26
  const MAX_ACTIVE_RESPONSE_TRANSFORMS = 256;
26
27
  const MAX_RECONSTRUCTED_ALIAS_LOOKUPS = 32;
27
28
  const MAX_TRACKED_REQUEST_URL_BYTES = 8 * 1024;
29
+ const MAX_VERSION_GATE_RECOVERIES = 256;
28
30
  const UNKNOWN_CONNECTION = 'Unknown OAuth connection';
29
31
  const AMBIGUOUS_CONNECTION = 'Ambiguous OAuth connection';
30
32
  // setup() is location-scoped while the credential store is process-global.
@@ -175,22 +177,47 @@ function warnIfInsecureUnsupported() {
175
177
  'ANTHROPIC_BASE_URL endpoint. TLS verification remains enabled — ' +
176
178
  'requests to an untrusted/self-signed endpoint will fail.');
177
179
  }
180
+ function versionGateRecoveryKey(sessionID, agent, providerID, modelID) {
181
+ return `${sessionID}\u0000${agent}\u0000${providerID}\u0000${modelID}`;
182
+ }
183
+ /** Read the exact Claude Code version carried by a plugin-owned request. */
184
+ function sentClaudeCodeVersion(request) {
185
+ const userAgent = request.headers.get('user-agent');
186
+ if (!userAgent?.startsWith('claude-cli/'))
187
+ return undefined;
188
+ const suffix = ' (external, cli)';
189
+ if (!userAgent.endsWith(suffix))
190
+ return undefined;
191
+ const version = userAgent.slice('claude-cli/'.length, -suffix.length);
192
+ return isValidClaudeCodeVersion(version) &&
193
+ formatUserAgent(version) === userAgent
194
+ ? version
195
+ : undefined;
196
+ }
178
197
  export default Plugin.define({
179
198
  id: PLUGIN_ID,
180
199
  setup: async (ctx) => {
181
200
  warnIfInsecureUnsupported();
182
201
  // Resolve once so user-agent and billing metadata agree for every request
183
202
  // handled by this plugin generation.
184
- const versionResolution = resolveClaudeCodeVersion();
203
+ const rawVersionOverride = process.env[ANTHROPIC_CLAUDE_CODE_VERSION_ENV_VAR];
204
+ const versionResolution = resolveClaudeCodeVersion(rawVersionOverride);
185
205
  if (versionResolution.type === 'invalid') {
186
206
  console.error(`[ex-machina.anthropic-auth] ${versionResolution.error}`);
187
207
  }
188
208
  else if (versionResolution.type === 'outdated') {
189
209
  console.warn(`[ex-machina.anthropic-auth] ${versionResolution.warning}`);
190
210
  }
191
- const claudeCodeVersion = versionResolution.type === 'invalid'
211
+ let claudeCodeVersion = versionResolution.type === 'invalid'
192
212
  ? CLAUDE_CODE_VERSION
193
213
  : versionResolution.version;
214
+ // A valid explicit override is absolute. Automatic adoption is only the
215
+ // fallback path for an unset (or malformed and therefore ignored) value.
216
+ const hasExplicitVersionOverride = rawVersionOverride !== undefined && versionResolution.type !== 'invalid';
217
+ // This set is only an at-most-once limiter. The exact response is marked
218
+ // retryable below, so this key never authorizes an unrelated retry event.
219
+ // On overflow, recovery fails closed rather than forgetting old entries.
220
+ const versionGateRecoveries = new Set();
194
221
  const aliasesByRequest = new WeakMap();
195
222
  const aliasesByFingerprint = new Map();
196
223
  const transformedRequests = new WeakSet();
@@ -584,6 +611,29 @@ export default Plugin.define({
584
611
  if (!event.response.ok) {
585
612
  if (lease)
586
613
  releaseAliasLease(event.request, lease);
614
+ if (!hasExplicitVersionOverride && event.response.status === 400) {
615
+ const sentVersion = sentClaudeCodeVersion(event.request);
616
+ const rejection = sentVersion
617
+ ? await detectClaudeCodeVersionRejection(event.response, sentVersion)
618
+ : undefined;
619
+ if (rejection) {
620
+ if (compareClaudeCodeVersions(rejection.requiredVersion, claudeCodeVersion) === 1) {
621
+ claudeCodeVersion = rejection.requiredVersion;
622
+ }
623
+ const key = versionGateRecoveryKey(event.sessionID, event.agent, event.model.providerID, event.model.id);
624
+ if (!versionGateRecoveries.has(key) &&
625
+ versionGateRecoveries.size < MAX_VERSION_GATE_RECOVERIES) {
626
+ versionGateRecoveries.add(key);
627
+ const headers = new Headers(event.response.headers);
628
+ headers.set('x-should-retry', 'true');
629
+ event.response = new Response(event.response.body, {
630
+ status: event.response.status,
631
+ statusText: event.response.statusText,
632
+ headers,
633
+ });
634
+ }
635
+ }
636
+ }
587
637
  if (event.response.status === 429) {
588
638
  const enhanced = await enhanceRateLimitResponse(event.response, connectionForRequest(event.request));
589
639
  event.response = enhanced.response;
@@ -620,6 +670,7 @@ export default Plugin.define({
620
670
  clearTimeout(entry.timer);
621
671
  }
622
672
  connectionByAuthorization.clear();
673
+ versionGateRecoveries.clear();
623
674
  for (const timer of refreshCacheTimers.values())
624
675
  clearTimeout(timer);
625
676
  refreshCacheTimers.clear();
@@ -0,0 +1,17 @@
1
+ export interface ClaudeCodeVersionRejection {
2
+ readonly rejectedVersion: string;
3
+ readonly requiredVersion: string;
4
+ }
5
+ /**
6
+ * Parse only Anthropic's structured minimum-version rejection.
7
+ *
8
+ * The error code is authoritative; the bounded message supplies the versions.
9
+ * Requiring the rejected version to equal what this setup actually sent keeps
10
+ * an unrelated or stale response from changing later requests.
11
+ */
12
+ export declare function parseClaudeCodeVersionRejection(body: string, reportedVersion: string): ClaudeCodeVersionRejection | undefined;
13
+ /**
14
+ * Inspect a clone so the provider still receives the original response body.
15
+ * Any malformed, oversized, non-JSON, or non-400 response is ignored.
16
+ */
17
+ export declare function detectClaudeCodeVersionRejection(response: Response, reportedVersion: string): Promise<ClaudeCodeVersionRejection | undefined>;
@@ -0,0 +1,75 @@
1
+ import { contentLength, readBoundedText } from "./bounded.js";
2
+ import { compareClaudeCodeVersions, isValidClaudeCodeVersion, } from "./config.js";
3
+ const VERSION_TOO_OLD_CODE = 'claude_code_version_too_old';
4
+ const MAX_REJECTION_BODY_BYTES = 16 * 1024;
5
+ const MAX_REJECTION_MESSAGE_LENGTH = 1024;
6
+ const VERSION_REJECTION_PATTERN = /^Claude Code ([0-9.]{1,64}) does not support this model; version ([0-9.]{1,64}) or newer is required\./;
7
+ function isRecord(value) {
8
+ return typeof value === 'object' && value !== null;
9
+ }
10
+ /**
11
+ * Parse only Anthropic's structured minimum-version rejection.
12
+ *
13
+ * The error code is authoritative; the bounded message supplies the versions.
14
+ * Requiring the rejected version to equal what this setup actually sent keeps
15
+ * an unrelated or stale response from changing later requests.
16
+ */
17
+ export function parseClaudeCodeVersionRejection(body, reportedVersion) {
18
+ if (body.length > MAX_REJECTION_BODY_BYTES)
19
+ return undefined;
20
+ let decoded;
21
+ try {
22
+ decoded = JSON.parse(body);
23
+ }
24
+ catch {
25
+ return undefined;
26
+ }
27
+ if (!isRecord(decoded) || decoded.type !== 'error')
28
+ return undefined;
29
+ const error = decoded.error;
30
+ if (!isRecord(error) || error.type !== 'invalid_request_error') {
31
+ return undefined;
32
+ }
33
+ const details = error.details;
34
+ if (!isRecord(details) || details.error_code !== VERSION_TOO_OLD_CODE) {
35
+ return undefined;
36
+ }
37
+ if (typeof error.message !== 'string' ||
38
+ error.message.length > MAX_REJECTION_MESSAGE_LENGTH) {
39
+ return undefined;
40
+ }
41
+ const match = VERSION_REJECTION_PATTERN.exec(error.message);
42
+ if (!match)
43
+ return undefined;
44
+ const rejectedVersion = match[1];
45
+ const requiredVersion = match[2];
46
+ if (!rejectedVersion ||
47
+ !requiredVersion ||
48
+ !isValidClaudeCodeVersion(rejectedVersion) ||
49
+ !isValidClaudeCodeVersion(requiredVersion) ||
50
+ rejectedVersion !== reportedVersion ||
51
+ compareClaudeCodeVersions(requiredVersion, reportedVersion) !== 1) {
52
+ return undefined;
53
+ }
54
+ return { rejectedVersion, requiredVersion };
55
+ }
56
+ /**
57
+ * Inspect a clone so the provider still receives the original response body.
58
+ * Any malformed, oversized, non-JSON, or non-400 response is ignored.
59
+ */
60
+ export async function detectClaudeCodeVersionRejection(response, reportedVersion) {
61
+ if (response.status !== 400)
62
+ return undefined;
63
+ const declaredLength = contentLength(response.headers);
64
+ if (declaredLength !== undefined &&
65
+ declaredLength > MAX_REJECTION_BODY_BYTES) {
66
+ return undefined;
67
+ }
68
+ try {
69
+ const body = await readBoundedText(response.clone().body, MAX_REJECTION_BODY_BYTES, 'Anthropic version rejection');
70
+ return parseClaudeCodeVersionRejection(body, reportedVersion);
71
+ }
72
+ catch {
73
+ return undefined;
74
+ }
75
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ex-machina/opencode-anthropic-auth",
3
- "version": "2.0.0-next.2",
3
+ "version": "2.0.0-next.3",
4
4
  "type": "module",
5
5
  "repository": {
6
6
  "type": "git",