@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 +2 -2
- package/dist/config.d.ts +10 -0
- package/dist/config.js +21 -12
- package/dist/constants.d.ts +1 -1
- package/dist/constants.js +1 -1
- package/dist/index.js +55 -4
- package/dist/version-rejection.d.ts +17 -0
- package/dist/version-rejection.js +75 -0
- package/package.json +1 -1
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.
|
|
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`
|
|
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.
|
|
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
|
-
*
|
|
23
|
+
* Compare two validated Claude Code releases.
|
|
21
24
|
*
|
|
22
|
-
*
|
|
23
|
-
*
|
|
24
|
-
*
|
|
25
|
-
*
|
|
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
|
|
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
|
-
|
|
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 (!
|
|
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 (
|
|
75
|
+
if (compareClaudeCodeVersions(trimmed, CLAUDE_CODE_VERSION) === -1) {
|
|
67
76
|
return {
|
|
68
77
|
type: 'outdated',
|
|
69
78
|
version: trimmed,
|
package/dist/constants.d.ts
CHANGED
|
@@ -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.
|
|
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.
|
|
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
|
|
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
|
-
|
|
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
|
+
}
|