@ex-machina/opencode-anthropic-auth 1.0.3 → 1.1.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/dist/index.js +70 -56
- package/dist/transform.d.ts +7 -0
- package/dist/transform.js +49 -7
- package/package.json +1 -1
package/dist/index.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { authorize, exchange } from './auth';
|
|
2
2
|
import { CLIENT_ID, TOKEN_URL } from './constants';
|
|
3
|
-
import { createStrippedStream, mergeHeaders, prefixToolNames, rewriteUrl, setOAuthHeaders, } from './transform';
|
|
3
|
+
import { createStrippedStream, isInsecure, mergeHeaders, prefixToolNames, rewriteUrl, setOAuthHeaders, } from './transform';
|
|
4
4
|
export const AnthropicAuthPlugin = async ({ client }) => {
|
|
5
5
|
return {
|
|
6
6
|
'experimental.chat.system.transform': (input, output) => {
|
|
@@ -27,6 +27,9 @@ export const AnthropicAuthPlugin = async ({ client }) => {
|
|
|
27
27
|
},
|
|
28
28
|
};
|
|
29
29
|
}
|
|
30
|
+
// Shared inflight refresh promise — prevents concurrent token refreshes
|
|
31
|
+
// from racing against each other (and causing 401 cascades with token rotation)
|
|
32
|
+
let refreshPromise = null;
|
|
30
33
|
return {
|
|
31
34
|
apiKey: '',
|
|
32
35
|
async fetch(input, init) {
|
|
@@ -34,64 +37,73 @@ export const AnthropicAuthPlugin = async ({ client }) => {
|
|
|
34
37
|
if (auth.type !== 'oauth')
|
|
35
38
|
return fetch(input, init);
|
|
36
39
|
if (!auth.access || !auth.expires || auth.expires < Date.now()) {
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
40
|
+
if (!refreshPromise) {
|
|
41
|
+
refreshPromise = (async () => {
|
|
42
|
+
const maxRetries = 2;
|
|
43
|
+
const baseDelayMs = 500;
|
|
44
|
+
for (let attempt = 0; attempt <= maxRetries; attempt++) {
|
|
45
|
+
try {
|
|
46
|
+
if (attempt > 0) {
|
|
47
|
+
const delay = baseDelayMs * 2 ** (attempt - 1);
|
|
48
|
+
await new Promise((resolve) => setTimeout(resolve, delay));
|
|
49
|
+
}
|
|
50
|
+
const response = await fetch(TOKEN_URL, {
|
|
51
|
+
method: 'POST',
|
|
52
|
+
headers: {
|
|
53
|
+
'Content-Type': 'application/json',
|
|
54
|
+
Accept: 'application/json, text/plain, */*',
|
|
55
|
+
'User-Agent': 'axios/1.13.6',
|
|
56
|
+
},
|
|
57
|
+
body: JSON.stringify({
|
|
58
|
+
grant_type: 'refresh_token',
|
|
59
|
+
refresh_token: auth.refresh,
|
|
60
|
+
client_id: CLIENT_ID,
|
|
61
|
+
}),
|
|
62
|
+
});
|
|
63
|
+
if (!response.ok) {
|
|
64
|
+
if (response.status >= 500 && attempt < maxRetries) {
|
|
65
|
+
await response.body?.cancel();
|
|
66
|
+
continue;
|
|
67
|
+
}
|
|
68
|
+
throw new Error(`Token refresh failed: ${response.status}`);
|
|
69
|
+
}
|
|
70
|
+
const json = (await response.json());
|
|
71
|
+
// biome-ignore lint/suspicious/noExplicitAny: SDK types don't expose auth.set
|
|
72
|
+
await client.auth.set({
|
|
73
|
+
path: {
|
|
74
|
+
id: 'anthropic',
|
|
75
|
+
},
|
|
76
|
+
body: {
|
|
77
|
+
type: 'oauth',
|
|
78
|
+
refresh: json.refresh_token,
|
|
79
|
+
access: json.access_token,
|
|
80
|
+
expires: Date.now() + json.expires_in * 1000,
|
|
81
|
+
},
|
|
82
|
+
});
|
|
83
|
+
return json.access_token;
|
|
84
|
+
}
|
|
85
|
+
catch (error) {
|
|
86
|
+
const isNetworkError = error instanceof Error &&
|
|
87
|
+
(error.message.includes('fetch failed') ||
|
|
88
|
+
('code' in error &&
|
|
89
|
+
(error.code === 'ECONNRESET' ||
|
|
90
|
+
error.code === 'ECONNREFUSED' ||
|
|
91
|
+
error.code === 'ETIMEDOUT' ||
|
|
92
|
+
error.code === 'UND_ERR_CONNECT_TIMEOUT')));
|
|
93
|
+
if (attempt < maxRetries && isNetworkError) {
|
|
94
|
+
continue;
|
|
95
|
+
}
|
|
96
|
+
throw error;
|
|
62
97
|
}
|
|
63
|
-
throw new Error(`Token refresh failed: ${response.status}`);
|
|
64
|
-
}
|
|
65
|
-
const json = (await response.json());
|
|
66
|
-
// biome-ignore lint/suspicious/noExplicitAny: SDK types don't expose auth.set
|
|
67
|
-
await client.auth.set({
|
|
68
|
-
path: {
|
|
69
|
-
id: 'anthropic',
|
|
70
|
-
},
|
|
71
|
-
body: {
|
|
72
|
-
type: 'oauth',
|
|
73
|
-
refresh: json.refresh_token,
|
|
74
|
-
access: json.access_token,
|
|
75
|
-
expires: Date.now() + json.expires_in * 1000,
|
|
76
|
-
},
|
|
77
|
-
});
|
|
78
|
-
auth.access = json.access_token;
|
|
79
|
-
break;
|
|
80
|
-
}
|
|
81
|
-
catch (error) {
|
|
82
|
-
const isNetworkError = error instanceof Error &&
|
|
83
|
-
(error.message.includes('fetch failed') ||
|
|
84
|
-
('code' in error &&
|
|
85
|
-
(error.code === 'ECONNRESET' ||
|
|
86
|
-
error.code === 'ECONNREFUSED' ||
|
|
87
|
-
error.code === 'ETIMEDOUT' ||
|
|
88
|
-
error.code === 'UND_ERR_CONNECT_TIMEOUT')));
|
|
89
|
-
if (attempt < maxRetries && isNetworkError) {
|
|
90
|
-
continue;
|
|
91
98
|
}
|
|
92
|
-
|
|
93
|
-
|
|
99
|
+
// Unreachable — each iteration either returns or throws.
|
|
100
|
+
// Kept as a TypeScript exhaustiveness guard.
|
|
101
|
+
throw new Error('Token refresh exhausted all retries');
|
|
102
|
+
})().finally(() => {
|
|
103
|
+
refreshPromise = null;
|
|
104
|
+
});
|
|
94
105
|
}
|
|
106
|
+
auth.access = await refreshPromise;
|
|
95
107
|
}
|
|
96
108
|
const requestHeaders = mergeHeaders(input, init);
|
|
97
109
|
// biome-ignore lint/style/noNonNullAssertion: access is guaranteed set above
|
|
@@ -105,6 +117,8 @@ export const AnthropicAuthPlugin = async ({ client }) => {
|
|
|
105
117
|
...init,
|
|
106
118
|
body,
|
|
107
119
|
headers: requestHeaders,
|
|
120
|
+
// biome-ignore lint/suspicious/noExplicitAny: tls option is Bun-specific, not in standard RequestInit
|
|
121
|
+
...(isInsecure() && { tls: { rejectUnauthorized: false } }),
|
|
108
122
|
});
|
|
109
123
|
return createStrippedStream(response);
|
|
110
124
|
},
|
package/dist/transform.d.ts
CHANGED
|
@@ -22,8 +22,15 @@ export declare function prefixToolNames(body: string): string;
|
|
|
22
22
|
* Strip TOOL_PREFIX from tool names in streaming response text.
|
|
23
23
|
*/
|
|
24
24
|
export declare function stripToolPrefix(text: string): string;
|
|
25
|
+
/**
|
|
26
|
+
* Check if TLS verification should be skipped for custom API endpoints.
|
|
27
|
+
* Only effective when ANTHROPIC_BASE_URL is also set.
|
|
28
|
+
*/
|
|
29
|
+
export declare function isInsecure(): boolean;
|
|
25
30
|
/**
|
|
26
31
|
* Rewrite the request URL to add ?beta=true for /v1/messages requests.
|
|
32
|
+
* When ANTHROPIC_BASE_URL is set, overrides the origin (protocol + host)
|
|
33
|
+
* for all API requests flowing through the fetch wrapper.
|
|
27
34
|
* Returns the modified input and URL (if applicable).
|
|
28
35
|
*/
|
|
29
36
|
export declare function rewriteUrl(input: FetchInput): {
|
package/dist/transform.js
CHANGED
|
@@ -98,8 +98,41 @@ export function prefixToolNames(body) {
|
|
|
98
98
|
export function stripToolPrefix(text) {
|
|
99
99
|
return text.replace(/"name"\s*:\s*"mcp_([^"]+)"/g, '"name": "$1"');
|
|
100
100
|
}
|
|
101
|
+
/**
|
|
102
|
+
* Check if TLS verification should be skipped for custom API endpoints.
|
|
103
|
+
* Only effective when ANTHROPIC_BASE_URL is also set.
|
|
104
|
+
*/
|
|
105
|
+
export function isInsecure() {
|
|
106
|
+
if (!process.env.ANTHROPIC_BASE_URL?.trim())
|
|
107
|
+
return false;
|
|
108
|
+
const raw = process.env.ANTHROPIC_INSECURE?.trim();
|
|
109
|
+
return raw === '1' || raw === 'true';
|
|
110
|
+
}
|
|
111
|
+
/**
|
|
112
|
+
* Parse ANTHROPIC_BASE_URL from the environment.
|
|
113
|
+
* Returns a valid HTTP(S) URL or null if unset/invalid.
|
|
114
|
+
*/
|
|
115
|
+
function resolveBaseUrl() {
|
|
116
|
+
const raw = process.env.ANTHROPIC_BASE_URL?.trim();
|
|
117
|
+
if (!raw)
|
|
118
|
+
return null;
|
|
119
|
+
try {
|
|
120
|
+
const baseUrl = new URL(raw);
|
|
121
|
+
if ((baseUrl.protocol !== 'http:' && baseUrl.protocol !== 'https:') ||
|
|
122
|
+
baseUrl.username ||
|
|
123
|
+
baseUrl.password) {
|
|
124
|
+
return null;
|
|
125
|
+
}
|
|
126
|
+
return baseUrl;
|
|
127
|
+
}
|
|
128
|
+
catch {
|
|
129
|
+
return null;
|
|
130
|
+
}
|
|
131
|
+
}
|
|
101
132
|
/**
|
|
102
133
|
* Rewrite the request URL to add ?beta=true for /v1/messages requests.
|
|
134
|
+
* When ANTHROPIC_BASE_URL is set, overrides the origin (protocol + host)
|
|
135
|
+
* for all API requests flowing through the fetch wrapper.
|
|
103
136
|
* Returns the modified input and URL (if applicable).
|
|
104
137
|
*/
|
|
105
138
|
export function rewriteUrl(input) {
|
|
@@ -115,16 +148,25 @@ export function rewriteUrl(input) {
|
|
|
115
148
|
catch {
|
|
116
149
|
requestUrl = null;
|
|
117
150
|
}
|
|
118
|
-
if (requestUrl
|
|
119
|
-
|
|
151
|
+
if (!requestUrl)
|
|
152
|
+
return { input, url: null };
|
|
153
|
+
const originalHref = requestUrl.href;
|
|
154
|
+
const baseUrl = resolveBaseUrl();
|
|
155
|
+
if (baseUrl) {
|
|
156
|
+
requestUrl.protocol = baseUrl.protocol;
|
|
157
|
+
requestUrl.host = baseUrl.host;
|
|
158
|
+
}
|
|
159
|
+
if (requestUrl.pathname === '/v1/messages' &&
|
|
120
160
|
!requestUrl.searchParams.has('beta')) {
|
|
121
161
|
requestUrl.searchParams.set('beta', 'true');
|
|
122
|
-
const newInput = input instanceof Request
|
|
123
|
-
? new Request(requestUrl.toString(), input)
|
|
124
|
-
: requestUrl;
|
|
125
|
-
return { input: newInput, url: requestUrl };
|
|
126
162
|
}
|
|
127
|
-
|
|
163
|
+
if (requestUrl.href === originalHref) {
|
|
164
|
+
return { input, url: requestUrl };
|
|
165
|
+
}
|
|
166
|
+
const newInput = input instanceof Request
|
|
167
|
+
? new Request(requestUrl.toString(), input)
|
|
168
|
+
: requestUrl;
|
|
169
|
+
return { input: newInput, url: requestUrl };
|
|
128
170
|
}
|
|
129
171
|
/**
|
|
130
172
|
* Create a streaming response that strips the tool prefix from tool names.
|