@otto-assistant/bridge 0.4.97 → 0.4.101
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/agent-model.e2e.test.js +7 -1
- package/dist/anthropic-auth-plugin.js +227 -176
- package/dist/cli-send-thread.e2e.test.js +4 -7
- package/dist/cli.js +2 -2
- package/dist/commands/login.js +6 -4
- package/dist/commands/screenshare.js +1 -1
- package/dist/commands/screenshare.test.js +2 -2
- package/dist/commands/vscode.js +269 -0
- package/dist/context-awareness-plugin.js +8 -38
- package/dist/db.js +1 -0
- package/dist/discord-command-registration.js +5 -0
- package/dist/gateway-proxy-reconnect.e2e.test.js +2 -2
- package/dist/interaction-handler.js +4 -0
- package/dist/kimaki-opencode-plugin.js +3 -1
- package/dist/memory-overview-plugin.js +126 -0
- package/dist/system-message.js +23 -22
- package/dist/system-message.test.js +23 -22
- package/dist/system-prompt-drift-plugin.js +41 -11
- package/dist/utils.js +1 -1
- package/package.json +1 -1
- package/src/agent-model.e2e.test.ts +8 -1
- package/src/anthropic-auth-plugin.ts +574 -451
- package/src/cli-send-thread.e2e.test.ts +6 -7
- package/src/cli.ts +2 -2
- package/src/commands/login.ts +6 -4
- package/src/commands/screenshare.test.ts +2 -2
- package/src/commands/screenshare.ts +1 -1
- package/src/commands/vscode.ts +342 -0
- package/src/context-awareness-plugin.ts +11 -42
- package/src/db.ts +1 -0
- package/src/discord-command-registration.ts +7 -0
- package/src/gateway-proxy-reconnect.e2e.test.ts +2 -2
- package/src/interaction-handler.ts +5 -0
- package/src/kimaki-opencode-plugin.ts +3 -1
- package/src/memory-overview-plugin.ts +161 -0
- package/src/system-message.test.ts +23 -22
- package/src/system-message.ts +23 -22
- package/src/system-prompt-drift-plugin.ts +48 -12
- package/src/utils.ts +1 -1
|
@@ -23,7 +23,7 @@
|
|
|
23
23
|
* - https://github.com/badlogic/pi-mono/blob/main/packages/ai/src/providers/anthropic.ts
|
|
24
24
|
*/
|
|
25
25
|
|
|
26
|
-
import type { Plugin } from
|
|
26
|
+
import type { Plugin } from "@opencode-ai/plugin";
|
|
27
27
|
import {
|
|
28
28
|
loadAccountStore,
|
|
29
29
|
rememberAnthropicOAuth,
|
|
@@ -34,100 +34,113 @@ import {
|
|
|
34
34
|
type OAuthStored,
|
|
35
35
|
upsertAccount,
|
|
36
36
|
withAuthStateLock,
|
|
37
|
-
} from
|
|
37
|
+
} from "./anthropic-auth-state.js";
|
|
38
38
|
import {
|
|
39
39
|
extractAnthropicAccountIdentity,
|
|
40
40
|
type AnthropicAccountIdentity,
|
|
41
|
-
} from
|
|
41
|
+
} from "./anthropic-account-identity.js";
|
|
42
42
|
// PKCE (Proof Key for Code Exchange) using Web Crypto API.
|
|
43
43
|
// Reference: https://github.com/badlogic/pi-mono/blob/main/packages/ai/src/utils/oauth/pkce.ts
|
|
44
44
|
function base64urlEncode(bytes: Uint8Array): string {
|
|
45
|
-
let binary =
|
|
45
|
+
let binary = "";
|
|
46
46
|
for (const byte of bytes) {
|
|
47
|
-
binary += String.fromCharCode(byte)
|
|
47
|
+
binary += String.fromCharCode(byte);
|
|
48
48
|
}
|
|
49
|
-
return btoa(binary).replace(/\+/g,
|
|
49
|
+
return btoa(binary).replace(/\+/g, "-").replace(/\//g, "_").replace(/=/g, "");
|
|
50
50
|
}
|
|
51
51
|
|
|
52
|
-
async function generatePKCE(): Promise<{
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
const
|
|
57
|
-
|
|
58
|
-
const
|
|
59
|
-
|
|
52
|
+
async function generatePKCE(): Promise<{
|
|
53
|
+
verifier: string;
|
|
54
|
+
challenge: string;
|
|
55
|
+
}> {
|
|
56
|
+
const verifierBytes = new Uint8Array(32);
|
|
57
|
+
crypto.getRandomValues(verifierBytes);
|
|
58
|
+
const verifier = base64urlEncode(verifierBytes);
|
|
59
|
+
const data = new TextEncoder().encode(verifier);
|
|
60
|
+
const hashBuffer = await crypto.subtle.digest("SHA-256", data);
|
|
61
|
+
const challenge = base64urlEncode(new Uint8Array(hashBuffer));
|
|
62
|
+
return { verifier, challenge };
|
|
60
63
|
}
|
|
61
|
-
import { spawn } from
|
|
62
|
-
import { createServer, type Server } from
|
|
64
|
+
import { spawn } from "node:child_process";
|
|
65
|
+
import { createServer, type Server } from "node:http";
|
|
63
66
|
|
|
64
67
|
// --- Constants ---
|
|
65
68
|
|
|
66
69
|
const CLIENT_ID = (() => {
|
|
67
|
-
const encoded =
|
|
68
|
-
return typeof atob ===
|
|
70
|
+
const encoded = "OWQxYzI1MGEtZTYxYi00NGQ5LTg4ZWQtNTk0NGQxOTYyZjVl";
|
|
71
|
+
return typeof atob === "function"
|
|
69
72
|
? atob(encoded)
|
|
70
|
-
: Buffer.from(encoded,
|
|
71
|
-
})()
|
|
72
|
-
|
|
73
|
-
const TOKEN_URL =
|
|
74
|
-
const CREATE_API_KEY_URL =
|
|
75
|
-
|
|
76
|
-
const
|
|
77
|
-
|
|
78
|
-
const
|
|
79
|
-
const
|
|
73
|
+
: Buffer.from(encoded, "base64").toString("utf8");
|
|
74
|
+
})();
|
|
75
|
+
|
|
76
|
+
const TOKEN_URL = "https://platform.claude.com/v1/oauth/token";
|
|
77
|
+
const CREATE_API_KEY_URL =
|
|
78
|
+
"https://api.anthropic.com/api/oauth/claude_cli/create_api_key";
|
|
79
|
+
const CLIENT_DATA_URL =
|
|
80
|
+
"https://api.anthropic.com/api/oauth/claude_cli/client_data";
|
|
81
|
+
const PROFILE_URL = "https://api.anthropic.com/api/oauth/profile";
|
|
82
|
+
const CALLBACK_PORT = 53692;
|
|
83
|
+
const CALLBACK_PATH = "/callback";
|
|
84
|
+
const REDIRECT_URI = `http://localhost:${CALLBACK_PORT}${CALLBACK_PATH}`;
|
|
80
85
|
const SCOPES =
|
|
81
|
-
|
|
82
|
-
const OAUTH_TIMEOUT_MS = 5 * 60 * 1000
|
|
83
|
-
const CLAUDE_CODE_VERSION =
|
|
84
|
-
const CLAUDE_CODE_IDENTITY =
|
|
85
|
-
|
|
86
|
-
const
|
|
87
|
-
|
|
88
|
-
const
|
|
89
|
-
const
|
|
90
|
-
const
|
|
86
|
+
"org:create_api_key user:profile user:inference user:sessions:claude_code user:mcp_servers user:file_upload";
|
|
87
|
+
const OAUTH_TIMEOUT_MS = 5 * 60 * 1000;
|
|
88
|
+
const CLAUDE_CODE_VERSION = "2.1.75";
|
|
89
|
+
const CLAUDE_CODE_IDENTITY =
|
|
90
|
+
"You are Claude Code, Anthropic's official CLI for Claude.";
|
|
91
|
+
const OPENCODE_IDENTITY =
|
|
92
|
+
"You are OpenCode, the best coding agent on the planet.";
|
|
93
|
+
const ANTHROPIC_PROMPT_MARKER = "Skills provide specialized instructions";
|
|
94
|
+
const CLAUDE_CODE_BETA = "claude-code-20250219";
|
|
95
|
+
const OAUTH_BETA = "oauth-2025-04-20";
|
|
96
|
+
const FINE_GRAINED_TOOL_STREAMING_BETA =
|
|
97
|
+
"fine-grained-tool-streaming-2025-05-14";
|
|
98
|
+
const INTERLEAVED_THINKING_BETA = "interleaved-thinking-2025-05-14";
|
|
99
|
+
const TOAST_SESSION_HEADER = "x-kimaki-session-id";
|
|
91
100
|
|
|
92
101
|
const ANTHROPIC_HOSTS = new Set([
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
])
|
|
102
|
+
"api.anthropic.com",
|
|
103
|
+
"claude.ai",
|
|
104
|
+
"console.anthropic.com",
|
|
105
|
+
"platform.claude.com",
|
|
106
|
+
]);
|
|
98
107
|
|
|
99
108
|
const OPENCODE_TO_CLAUDE_CODE_TOOL_NAME: Record<string, string> = {
|
|
100
|
-
bash:
|
|
101
|
-
edit:
|
|
102
|
-
glob:
|
|
103
|
-
grep:
|
|
104
|
-
question:
|
|
105
|
-
read:
|
|
106
|
-
skill:
|
|
107
|
-
task:
|
|
108
|
-
todowrite:
|
|
109
|
-
webfetch:
|
|
110
|
-
websearch:
|
|
111
|
-
write:
|
|
112
|
-
}
|
|
109
|
+
bash: "Bash",
|
|
110
|
+
edit: "Edit",
|
|
111
|
+
glob: "Glob",
|
|
112
|
+
grep: "Grep",
|
|
113
|
+
question: "AskUserQuestion",
|
|
114
|
+
read: "Read",
|
|
115
|
+
skill: "Skill",
|
|
116
|
+
task: "Task",
|
|
117
|
+
todowrite: "TodoWrite",
|
|
118
|
+
webfetch: "WebFetch",
|
|
119
|
+
websearch: "WebSearch",
|
|
120
|
+
write: "Write",
|
|
121
|
+
};
|
|
113
122
|
|
|
114
123
|
// --- Types ---
|
|
115
124
|
|
|
116
125
|
type OAuthSuccess = {
|
|
117
|
-
type:
|
|
118
|
-
provider?: string
|
|
119
|
-
refresh: string
|
|
120
|
-
access: string
|
|
121
|
-
expires: number
|
|
122
|
-
}
|
|
126
|
+
type: "success";
|
|
127
|
+
provider?: string;
|
|
128
|
+
refresh: string;
|
|
129
|
+
access: string;
|
|
130
|
+
expires: number;
|
|
131
|
+
};
|
|
123
132
|
|
|
124
133
|
type ApiKeySuccess = {
|
|
125
|
-
type:
|
|
126
|
-
provider?: string
|
|
127
|
-
key: string
|
|
128
|
-
}
|
|
134
|
+
type: "success";
|
|
135
|
+
provider?: string;
|
|
136
|
+
key: string;
|
|
137
|
+
};
|
|
129
138
|
|
|
130
|
-
type AuthResult = OAuthSuccess | ApiKeySuccess | { type:
|
|
139
|
+
type AuthResult = OAuthSuccess | ApiKeySuccess | { type: "failed" };
|
|
140
|
+
type PluginHooks = Awaited<ReturnType<Plugin>>;
|
|
141
|
+
type SystemTransformHook = NonNullable<
|
|
142
|
+
PluginHooks["experimental.chat.system.transform"]
|
|
143
|
+
>;
|
|
131
144
|
|
|
132
145
|
// --- HTTP helpers ---
|
|
133
146
|
|
|
@@ -138,9 +151,9 @@ type AuthResult = OAuthSuccess | ApiKeySuccess | { type: 'failed' }
|
|
|
138
151
|
async function requestText(
|
|
139
152
|
urlString: string,
|
|
140
153
|
options: {
|
|
141
|
-
method: string
|
|
142
|
-
headers?: Record<string, string
|
|
143
|
-
body?: string
|
|
154
|
+
method: string;
|
|
155
|
+
headers?: Record<string, string>;
|
|
156
|
+
body?: string;
|
|
144
157
|
},
|
|
145
158
|
): Promise<string> {
|
|
146
159
|
return new Promise((resolve, reject) => {
|
|
@@ -149,11 +162,11 @@ async function requestText(
|
|
|
149
162
|
headers: options.headers,
|
|
150
163
|
method: options.method,
|
|
151
164
|
url: urlString,
|
|
152
|
-
})
|
|
165
|
+
});
|
|
153
166
|
const child = spawn(
|
|
154
|
-
|
|
167
|
+
"node",
|
|
155
168
|
[
|
|
156
|
-
|
|
169
|
+
"-e",
|
|
157
170
|
`
|
|
158
171
|
const input = JSON.parse(process.argv[1]);
|
|
159
172
|
(async () => {
|
|
@@ -176,82 +189,96 @@ const input = JSON.parse(process.argv[1]);
|
|
|
176
189
|
payload,
|
|
177
190
|
],
|
|
178
191
|
{
|
|
179
|
-
stdio: [
|
|
192
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
180
193
|
},
|
|
181
|
-
)
|
|
194
|
+
);
|
|
182
195
|
|
|
183
|
-
let stdout =
|
|
184
|
-
let stderr =
|
|
196
|
+
let stdout = "";
|
|
197
|
+
let stderr = "";
|
|
185
198
|
const timeout = setTimeout(() => {
|
|
186
|
-
child.kill()
|
|
187
|
-
reject(new Error(`Request timed out. url=${urlString}`))
|
|
188
|
-
}, 30_000)
|
|
189
|
-
|
|
190
|
-
child.stdout.on(
|
|
191
|
-
stdout += String(chunk)
|
|
192
|
-
})
|
|
193
|
-
child.stderr.on(
|
|
194
|
-
stderr += String(chunk)
|
|
195
|
-
})
|
|
196
|
-
|
|
197
|
-
child.on(
|
|
198
|
-
clearTimeout(timeout)
|
|
199
|
-
reject(error)
|
|
200
|
-
})
|
|
201
|
-
|
|
202
|
-
child.on(
|
|
203
|
-
clearTimeout(timeout)
|
|
199
|
+
child.kill();
|
|
200
|
+
reject(new Error(`Request timed out. url=${urlString}`));
|
|
201
|
+
}, 30_000);
|
|
202
|
+
|
|
203
|
+
child.stdout.on("data", (chunk) => {
|
|
204
|
+
stdout += String(chunk);
|
|
205
|
+
});
|
|
206
|
+
child.stderr.on("data", (chunk) => {
|
|
207
|
+
stderr += String(chunk);
|
|
208
|
+
});
|
|
209
|
+
|
|
210
|
+
child.on("error", (error) => {
|
|
211
|
+
clearTimeout(timeout);
|
|
212
|
+
reject(error);
|
|
213
|
+
});
|
|
214
|
+
|
|
215
|
+
child.on("close", (code) => {
|
|
216
|
+
clearTimeout(timeout);
|
|
204
217
|
if (code !== 0) {
|
|
205
|
-
let details = stderr.trim()
|
|
218
|
+
let details = stderr.trim();
|
|
206
219
|
try {
|
|
207
|
-
const parsed = JSON.parse(details) as {
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
220
|
+
const parsed = JSON.parse(details) as {
|
|
221
|
+
status?: number;
|
|
222
|
+
body?: string;
|
|
223
|
+
};
|
|
224
|
+
if (typeof parsed.status === "number") {
|
|
225
|
+
reject(
|
|
226
|
+
new Error(
|
|
227
|
+
`HTTP ${parsed.status} from ${urlString}: ${parsed.body ?? ""}`,
|
|
228
|
+
),
|
|
229
|
+
);
|
|
230
|
+
return;
|
|
211
231
|
}
|
|
212
232
|
} catch {
|
|
213
233
|
// fall back to raw stderr
|
|
214
234
|
}
|
|
215
|
-
reject(new Error(details || `Node helper exited with code ${code}`))
|
|
216
|
-
return
|
|
235
|
+
reject(new Error(details || `Node helper exited with code ${code}`));
|
|
236
|
+
return;
|
|
217
237
|
}
|
|
218
|
-
resolve(stdout)
|
|
219
|
-
})
|
|
220
|
-
})
|
|
238
|
+
resolve(stdout);
|
|
239
|
+
});
|
|
240
|
+
});
|
|
221
241
|
}
|
|
222
242
|
|
|
223
|
-
async function postJson(
|
|
224
|
-
|
|
243
|
+
async function postJson(
|
|
244
|
+
url: string,
|
|
245
|
+
body: Record<string, string | number>,
|
|
246
|
+
): Promise<unknown> {
|
|
247
|
+
const requestBody = JSON.stringify(body);
|
|
225
248
|
const responseText = await requestText(url, {
|
|
226
|
-
method:
|
|
249
|
+
method: "POST",
|
|
227
250
|
headers: {
|
|
228
|
-
Accept:
|
|
229
|
-
|
|
230
|
-
|
|
251
|
+
Accept: "application/json",
|
|
252
|
+
"Content-Length": String(Buffer.byteLength(requestBody)),
|
|
253
|
+
"Content-Type": "application/json",
|
|
231
254
|
},
|
|
232
255
|
body: requestBody,
|
|
233
|
-
})
|
|
234
|
-
return JSON.parse(responseText) as unknown
|
|
256
|
+
});
|
|
257
|
+
return JSON.parse(responseText) as unknown;
|
|
235
258
|
}
|
|
236
259
|
|
|
237
|
-
const pendingRefresh = new Map<string, Promise<OAuthStored>>()
|
|
260
|
+
const pendingRefresh = new Map<string, Promise<OAuthStored>>();
|
|
238
261
|
|
|
239
262
|
// --- OAuth token exchange & refresh ---
|
|
240
263
|
|
|
241
264
|
function parseTokenResponse(json: unknown): {
|
|
242
|
-
access_token: string
|
|
243
|
-
refresh_token: string
|
|
244
|
-
expires_in: number
|
|
265
|
+
access_token: string;
|
|
266
|
+
refresh_token: string;
|
|
267
|
+
expires_in: number;
|
|
245
268
|
} {
|
|
246
|
-
const data = json as {
|
|
269
|
+
const data = json as {
|
|
270
|
+
access_token: string;
|
|
271
|
+
refresh_token: string;
|
|
272
|
+
expires_in: number;
|
|
273
|
+
};
|
|
247
274
|
if (!data.access_token || !data.refresh_token) {
|
|
248
|
-
throw new Error(`Invalid token response: ${JSON.stringify(json)}`)
|
|
275
|
+
throw new Error(`Invalid token response: ${JSON.stringify(json)}`);
|
|
249
276
|
}
|
|
250
|
-
return data
|
|
277
|
+
return data;
|
|
251
278
|
}
|
|
252
279
|
|
|
253
280
|
function tokenExpiry(expiresIn: number) {
|
|
254
|
-
return Date.now() + expiresIn * 1000 - 5 * 60 * 1000
|
|
281
|
+
return Date.now() + expiresIn * 1000 - 5 * 60 * 1000;
|
|
255
282
|
}
|
|
256
283
|
|
|
257
284
|
async function exchangeAuthorizationCode(
|
|
@@ -261,134 +288,140 @@ async function exchangeAuthorizationCode(
|
|
|
261
288
|
redirectUri: string,
|
|
262
289
|
): Promise<OAuthSuccess> {
|
|
263
290
|
const json = await postJson(TOKEN_URL, {
|
|
264
|
-
grant_type:
|
|
291
|
+
grant_type: "authorization_code",
|
|
265
292
|
client_id: CLIENT_ID,
|
|
266
293
|
code,
|
|
267
294
|
state,
|
|
268
295
|
redirect_uri: redirectUri,
|
|
269
296
|
code_verifier: verifier,
|
|
270
|
-
})
|
|
271
|
-
const data = parseTokenResponse(json)
|
|
297
|
+
});
|
|
298
|
+
const data = parseTokenResponse(json);
|
|
272
299
|
return {
|
|
273
|
-
type:
|
|
300
|
+
type: "success",
|
|
274
301
|
refresh: data.refresh_token,
|
|
275
302
|
access: data.access_token,
|
|
276
303
|
expires: tokenExpiry(data.expires_in),
|
|
277
|
-
}
|
|
304
|
+
};
|
|
278
305
|
}
|
|
279
306
|
|
|
280
|
-
async function refreshAnthropicToken(
|
|
307
|
+
async function refreshAnthropicToken(
|
|
308
|
+
refreshToken: string,
|
|
309
|
+
): Promise<OAuthStored> {
|
|
281
310
|
const json = await postJson(TOKEN_URL, {
|
|
282
|
-
grant_type:
|
|
311
|
+
grant_type: "refresh_token",
|
|
283
312
|
client_id: CLIENT_ID,
|
|
284
313
|
refresh_token: refreshToken,
|
|
285
|
-
})
|
|
286
|
-
const data = parseTokenResponse(json)
|
|
314
|
+
});
|
|
315
|
+
const data = parseTokenResponse(json);
|
|
287
316
|
return {
|
|
288
|
-
type:
|
|
317
|
+
type: "oauth",
|
|
289
318
|
refresh: data.refresh_token,
|
|
290
319
|
access: data.access_token,
|
|
291
320
|
expires: tokenExpiry(data.expires_in),
|
|
292
|
-
}
|
|
321
|
+
};
|
|
293
322
|
}
|
|
294
323
|
|
|
295
324
|
async function createApiKey(accessToken: string): Promise<ApiKeySuccess> {
|
|
296
325
|
const responseText = await requestText(CREATE_API_KEY_URL, {
|
|
297
|
-
method:
|
|
326
|
+
method: "POST",
|
|
298
327
|
headers: {
|
|
299
|
-
Accept:
|
|
328
|
+
Accept: "application/json",
|
|
300
329
|
authorization: `Bearer ${accessToken}`,
|
|
301
|
-
|
|
330
|
+
"Content-Type": "application/json",
|
|
302
331
|
},
|
|
303
|
-
})
|
|
304
|
-
const json = JSON.parse(responseText) as { raw_key: string }
|
|
305
|
-
return { type:
|
|
332
|
+
});
|
|
333
|
+
const json = JSON.parse(responseText) as { raw_key: string };
|
|
334
|
+
return { type: "success", key: json.raw_key };
|
|
306
335
|
}
|
|
307
336
|
|
|
308
337
|
async function fetchAnthropicAccountIdentity(accessToken: string) {
|
|
309
|
-
const urls = [CLIENT_DATA_URL, PROFILE_URL]
|
|
338
|
+
const urls = [CLIENT_DATA_URL, PROFILE_URL];
|
|
310
339
|
for (const url of urls) {
|
|
311
340
|
const responseText = await requestText(url, {
|
|
312
|
-
method:
|
|
341
|
+
method: "GET",
|
|
313
342
|
headers: {
|
|
314
|
-
Accept:
|
|
343
|
+
Accept: "application/json",
|
|
315
344
|
authorization: `Bearer ${accessToken}`,
|
|
316
|
-
|
|
317
|
-
|
|
345
|
+
"user-agent":
|
|
346
|
+
process.env.OPENCODE_ANTHROPIC_USER_AGENT ||
|
|
347
|
+
`claude-cli/${CLAUDE_CODE_VERSION}`,
|
|
348
|
+
"x-app": "cli",
|
|
318
349
|
},
|
|
319
350
|
}).catch(() => {
|
|
320
|
-
return undefined
|
|
321
|
-
})
|
|
322
|
-
if (!responseText) continue
|
|
323
|
-
const parsed = JSON.parse(responseText) as unknown
|
|
324
|
-
const identity = extractAnthropicAccountIdentity(parsed)
|
|
325
|
-
if (identity) return identity
|
|
351
|
+
return undefined;
|
|
352
|
+
});
|
|
353
|
+
if (!responseText) continue;
|
|
354
|
+
const parsed = JSON.parse(responseText) as unknown;
|
|
355
|
+
const identity = extractAnthropicAccountIdentity(parsed);
|
|
356
|
+
if (identity) return identity;
|
|
326
357
|
}
|
|
327
|
-
return undefined
|
|
358
|
+
return undefined;
|
|
328
359
|
}
|
|
329
360
|
|
|
330
361
|
// --- Localhost callback server ---
|
|
331
362
|
|
|
332
|
-
type CallbackResult = { code: string; state: string }
|
|
363
|
+
type CallbackResult = { code: string; state: string };
|
|
333
364
|
|
|
334
365
|
async function startCallbackServer(expectedState: string) {
|
|
335
366
|
return new Promise<{
|
|
336
|
-
server: Server
|
|
337
|
-
cancelWait: () => void
|
|
338
|
-
waitForCode: () => Promise<CallbackResult | null
|
|
367
|
+
server: Server;
|
|
368
|
+
cancelWait: () => void;
|
|
369
|
+
waitForCode: () => Promise<CallbackResult | null>;
|
|
339
370
|
}>((resolve, reject) => {
|
|
340
|
-
let settle: ((value: CallbackResult | null) => void) | undefined
|
|
341
|
-
let settled = false
|
|
371
|
+
let settle: ((value: CallbackResult | null) => void) | undefined;
|
|
372
|
+
let settled = false;
|
|
342
373
|
const waitPromise = new Promise<CallbackResult | null>((res) => {
|
|
343
374
|
settle = (v) => {
|
|
344
|
-
if (settled) return
|
|
345
|
-
settled = true
|
|
346
|
-
res(v)
|
|
347
|
-
}
|
|
348
|
-
})
|
|
375
|
+
if (settled) return;
|
|
376
|
+
settled = true;
|
|
377
|
+
res(v);
|
|
378
|
+
};
|
|
379
|
+
});
|
|
349
380
|
|
|
350
381
|
const server = createServer((req, res) => {
|
|
351
382
|
try {
|
|
352
|
-
const url = new URL(req.url ||
|
|
383
|
+
const url = new URL(req.url || "", "http://localhost");
|
|
353
384
|
if (url.pathname !== CALLBACK_PATH) {
|
|
354
|
-
res.writeHead(404).end(
|
|
355
|
-
return
|
|
385
|
+
res.writeHead(404).end("Not found");
|
|
386
|
+
return;
|
|
356
387
|
}
|
|
357
|
-
const code = url.searchParams.get(
|
|
358
|
-
const state = url.searchParams.get(
|
|
359
|
-
const error = url.searchParams.get(
|
|
388
|
+
const code = url.searchParams.get("code");
|
|
389
|
+
const state = url.searchParams.get("state");
|
|
390
|
+
const error = url.searchParams.get("error");
|
|
360
391
|
if (error || !code || !state || state !== expectedState) {
|
|
361
|
-
res
|
|
362
|
-
|
|
392
|
+
res
|
|
393
|
+
.writeHead(400)
|
|
394
|
+
.end("Authentication failed: " + (error || "missing code/state"));
|
|
395
|
+
return;
|
|
363
396
|
}
|
|
364
397
|
res
|
|
365
|
-
.writeHead(200, {
|
|
366
|
-
.end(
|
|
367
|
-
settle?.({ code, state })
|
|
398
|
+
.writeHead(200, { "Content-Type": "text/plain" })
|
|
399
|
+
.end("Authentication successful. You can close this window.");
|
|
400
|
+
settle?.({ code, state });
|
|
368
401
|
} catch {
|
|
369
|
-
res.writeHead(500).end(
|
|
402
|
+
res.writeHead(500).end("Internal error");
|
|
370
403
|
}
|
|
371
|
-
})
|
|
404
|
+
});
|
|
372
405
|
|
|
373
|
-
server.once(
|
|
374
|
-
server.listen(CALLBACK_PORT,
|
|
406
|
+
server.once("error", reject);
|
|
407
|
+
server.listen(CALLBACK_PORT, "127.0.0.1", () => {
|
|
375
408
|
resolve({
|
|
376
409
|
server,
|
|
377
410
|
cancelWait: () => {
|
|
378
|
-
settle?.(null)
|
|
411
|
+
settle?.(null);
|
|
379
412
|
},
|
|
380
413
|
waitForCode: () => waitPromise,
|
|
381
|
-
})
|
|
382
|
-
})
|
|
383
|
-
})
|
|
414
|
+
});
|
|
415
|
+
});
|
|
416
|
+
});
|
|
384
417
|
}
|
|
385
418
|
|
|
386
419
|
function closeServer(server: Server) {
|
|
387
420
|
return new Promise<void>((resolve) => {
|
|
388
421
|
server.close(() => {
|
|
389
|
-
resolve()
|
|
390
|
-
})
|
|
391
|
-
})
|
|
422
|
+
resolve();
|
|
423
|
+
});
|
|
424
|
+
});
|
|
392
425
|
}
|
|
393
426
|
|
|
394
427
|
// --- Authorization flow ---
|
|
@@ -396,25 +429,25 @@ function closeServer(server: Server) {
|
|
|
396
429
|
// then waitForCallback handles both auto (localhost) and manual (pasted code) paths.
|
|
397
430
|
|
|
398
431
|
async function beginAuthorizationFlow() {
|
|
399
|
-
const pkce = await generatePKCE()
|
|
400
|
-
const callbackServer = await startCallbackServer(pkce.verifier)
|
|
432
|
+
const pkce = await generatePKCE();
|
|
433
|
+
const callbackServer = await startCallbackServer(pkce.verifier);
|
|
401
434
|
|
|
402
435
|
const authParams = new URLSearchParams({
|
|
403
|
-
code:
|
|
436
|
+
code: "true",
|
|
404
437
|
client_id: CLIENT_ID,
|
|
405
|
-
response_type:
|
|
438
|
+
response_type: "code",
|
|
406
439
|
redirect_uri: REDIRECT_URI,
|
|
407
440
|
scope: SCOPES,
|
|
408
441
|
code_challenge: pkce.challenge,
|
|
409
|
-
code_challenge_method:
|
|
442
|
+
code_challenge_method: "S256",
|
|
410
443
|
state: pkce.verifier,
|
|
411
|
-
})
|
|
444
|
+
});
|
|
412
445
|
|
|
413
446
|
return {
|
|
414
447
|
url: `https://claude.ai/oauth/authorize?${authParams.toString()}`,
|
|
415
448
|
verifier: pkce.verifier,
|
|
416
449
|
callbackServer,
|
|
417
|
-
}
|
|
450
|
+
};
|
|
418
451
|
}
|
|
419
452
|
|
|
420
453
|
async function waitForCallback(
|
|
@@ -427,16 +460,16 @@ async function waitForCallback(
|
|
|
427
460
|
callbackServer.waitForCode(),
|
|
428
461
|
new Promise<null>((r) => {
|
|
429
462
|
setTimeout(() => {
|
|
430
|
-
r(null)
|
|
431
|
-
}, 50)
|
|
463
|
+
r(null);
|
|
464
|
+
}, 50);
|
|
432
465
|
}),
|
|
433
|
-
])
|
|
434
|
-
if (quick?.code) return quick
|
|
466
|
+
]);
|
|
467
|
+
if (quick?.code) return quick;
|
|
435
468
|
|
|
436
469
|
// If manual input was provided, parse it
|
|
437
|
-
const trimmed = manualInput?.trim()
|
|
470
|
+
const trimmed = manualInput?.trim();
|
|
438
471
|
if (trimmed) {
|
|
439
|
-
return parseManualInput(trimmed)
|
|
472
|
+
return parseManualInput(trimmed);
|
|
440
473
|
}
|
|
441
474
|
|
|
442
475
|
// Wait for localhost callback with timeout
|
|
@@ -444,108 +477,111 @@ async function waitForCallback(
|
|
|
444
477
|
callbackServer.waitForCode(),
|
|
445
478
|
new Promise<null>((r) => {
|
|
446
479
|
setTimeout(() => {
|
|
447
|
-
r(null)
|
|
448
|
-
}, OAUTH_TIMEOUT_MS)
|
|
480
|
+
r(null);
|
|
481
|
+
}, OAUTH_TIMEOUT_MS);
|
|
449
482
|
}),
|
|
450
|
-
])
|
|
483
|
+
]);
|
|
451
484
|
if (!result?.code) {
|
|
452
|
-
throw new Error(
|
|
485
|
+
throw new Error("Timed out waiting for OAuth callback");
|
|
453
486
|
}
|
|
454
|
-
return result
|
|
487
|
+
return result;
|
|
455
488
|
} finally {
|
|
456
|
-
callbackServer.cancelWait()
|
|
457
|
-
await closeServer(callbackServer.server)
|
|
489
|
+
callbackServer.cancelWait();
|
|
490
|
+
await closeServer(callbackServer.server);
|
|
458
491
|
}
|
|
459
492
|
}
|
|
460
493
|
|
|
461
494
|
function parseManualInput(input: string): CallbackResult {
|
|
462
495
|
try {
|
|
463
|
-
const url = new URL(input)
|
|
464
|
-
const code = url.searchParams.get(
|
|
465
|
-
const state = url.searchParams.get(
|
|
466
|
-
if (code) return { code, state: state ||
|
|
496
|
+
const url = new URL(input);
|
|
497
|
+
const code = url.searchParams.get("code");
|
|
498
|
+
const state = url.searchParams.get("state");
|
|
499
|
+
if (code) return { code, state: state || "" };
|
|
467
500
|
} catch {
|
|
468
501
|
// not a URL
|
|
469
502
|
}
|
|
470
|
-
if (input.includes(
|
|
471
|
-
const [code =
|
|
472
|
-
return { code, state }
|
|
503
|
+
if (input.includes("#")) {
|
|
504
|
+
const [code = "", state = ""] = input.split("#", 2);
|
|
505
|
+
return { code, state };
|
|
473
506
|
}
|
|
474
|
-
if (input.includes(
|
|
475
|
-
const params = new URLSearchParams(input)
|
|
476
|
-
const code = params.get(
|
|
477
|
-
if (code) return { code, state: params.get(
|
|
507
|
+
if (input.includes("code=")) {
|
|
508
|
+
const params = new URLSearchParams(input);
|
|
509
|
+
const code = params.get("code");
|
|
510
|
+
if (code) return { code, state: params.get("state") || "" };
|
|
478
511
|
}
|
|
479
|
-
return { code: input, state:
|
|
512
|
+
return { code: input, state: "" };
|
|
480
513
|
}
|
|
481
514
|
|
|
482
515
|
// Unified authorize handler: returns either OAuth tokens or an API key,
|
|
483
516
|
// for both auto and remote-first modes.
|
|
484
|
-
function buildAuthorizeHandler(mode:
|
|
517
|
+
function buildAuthorizeHandler(mode: "oauth" | "apikey") {
|
|
485
518
|
return async () => {
|
|
486
|
-
const auth = await beginAuthorizationFlow()
|
|
487
|
-
const isRemote = Boolean(process.env.KIMAKI)
|
|
488
|
-
let pendingAuthResult: Promise<AuthResult> | undefined
|
|
519
|
+
const auth = await beginAuthorizationFlow();
|
|
520
|
+
const isRemote = Boolean(process.env.KIMAKI);
|
|
521
|
+
let pendingAuthResult: Promise<AuthResult> | undefined;
|
|
489
522
|
|
|
490
523
|
const finalize = async (result: CallbackResult): Promise<AuthResult> => {
|
|
491
|
-
const verifier = auth.verifier
|
|
524
|
+
const verifier = auth.verifier;
|
|
492
525
|
const creds = await exchangeAuthorizationCode(
|
|
493
526
|
result.code,
|
|
494
527
|
result.state || verifier,
|
|
495
528
|
verifier,
|
|
496
529
|
REDIRECT_URI,
|
|
497
|
-
)
|
|
498
|
-
if (mode ===
|
|
499
|
-
return createApiKey(creds.access)
|
|
530
|
+
);
|
|
531
|
+
if (mode === "apikey") {
|
|
532
|
+
return createApiKey(creds.access);
|
|
500
533
|
}
|
|
501
|
-
const identity = await fetchAnthropicAccountIdentity(creds.access)
|
|
502
|
-
await rememberAnthropicOAuth(
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
534
|
+
const identity = await fetchAnthropicAccountIdentity(creds.access);
|
|
535
|
+
await rememberAnthropicOAuth(
|
|
536
|
+
{
|
|
537
|
+
type: "oauth",
|
|
538
|
+
refresh: creds.refresh,
|
|
539
|
+
access: creds.access,
|
|
540
|
+
expires: creds.expires,
|
|
541
|
+
},
|
|
542
|
+
identity,
|
|
543
|
+
);
|
|
544
|
+
return creds;
|
|
545
|
+
};
|
|
510
546
|
|
|
511
547
|
if (!isRemote) {
|
|
512
548
|
return {
|
|
513
549
|
url: auth.url,
|
|
514
550
|
instructions:
|
|
515
|
-
|
|
516
|
-
method:
|
|
551
|
+
"Complete login in your browser on this machine. OpenCode will catch the localhost callback automatically.",
|
|
552
|
+
method: "auto" as const,
|
|
517
553
|
callback: async (): Promise<AuthResult> => {
|
|
518
554
|
pendingAuthResult ??= (async () => {
|
|
519
555
|
try {
|
|
520
|
-
const result = await waitForCallback(auth.callbackServer)
|
|
521
|
-
return await finalize(result)
|
|
556
|
+
const result = await waitForCallback(auth.callbackServer);
|
|
557
|
+
return await finalize(result);
|
|
522
558
|
} catch {
|
|
523
|
-
return { type:
|
|
559
|
+
return { type: "failed" };
|
|
524
560
|
}
|
|
525
|
-
})()
|
|
526
|
-
return pendingAuthResult
|
|
561
|
+
})();
|
|
562
|
+
return pendingAuthResult;
|
|
527
563
|
},
|
|
528
|
-
}
|
|
564
|
+
};
|
|
529
565
|
}
|
|
530
566
|
|
|
531
567
|
return {
|
|
532
568
|
url: auth.url,
|
|
533
569
|
instructions:
|
|
534
|
-
|
|
535
|
-
method:
|
|
570
|
+
"Complete login in your browser, then paste the final redirect URL from the address bar here. Pasting just the authorization code also works.",
|
|
571
|
+
method: "code" as const,
|
|
536
572
|
callback: async (input: string): Promise<AuthResult> => {
|
|
537
573
|
pendingAuthResult ??= (async () => {
|
|
538
574
|
try {
|
|
539
|
-
const result = await waitForCallback(auth.callbackServer, input)
|
|
540
|
-
return await finalize(result)
|
|
575
|
+
const result = await waitForCallback(auth.callbackServer, input);
|
|
576
|
+
return await finalize(result);
|
|
541
577
|
} catch {
|
|
542
|
-
return { type:
|
|
578
|
+
return { type: "failed" };
|
|
543
579
|
}
|
|
544
|
-
})()
|
|
545
|
-
return pendingAuthResult
|
|
580
|
+
})();
|
|
581
|
+
return pendingAuthResult;
|
|
546
582
|
},
|
|
547
|
-
}
|
|
548
|
-
}
|
|
583
|
+
};
|
|
584
|
+
};
|
|
549
585
|
}
|
|
550
586
|
|
|
551
587
|
// --- Request/response rewriting ---
|
|
@@ -553,397 +589,484 @@ function buildAuthorizeHandler(mode: 'oauth' | 'apikey') {
|
|
|
553
589
|
// and reverses the mapping in streamed responses.
|
|
554
590
|
|
|
555
591
|
function toClaudeCodeToolName(name: string) {
|
|
556
|
-
return OPENCODE_TO_CLAUDE_CODE_TOOL_NAME[name.toLowerCase()] ?? name
|
|
592
|
+
return OPENCODE_TO_CLAUDE_CODE_TOOL_NAME[name.toLowerCase()] ?? name;
|
|
557
593
|
}
|
|
558
594
|
|
|
559
|
-
function
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
const
|
|
595
|
+
function sanitizeAnthropicSystemText(
|
|
596
|
+
text: string,
|
|
597
|
+
onError?: (msg: string) => void,
|
|
598
|
+
) {
|
|
599
|
+
const startIdx = text.indexOf(OPENCODE_IDENTITY);
|
|
600
|
+
if (startIdx === -1) return text;
|
|
601
|
+
|
|
602
|
+
// Keep the marker aligned with the current OpenCode Anthropic prompt.
|
|
603
|
+
const endIdx = text.indexOf(ANTHROPIC_PROMPT_MARKER, startIdx);
|
|
564
604
|
if (endIdx === -1) {
|
|
565
|
-
onError?.(
|
|
566
|
-
|
|
605
|
+
onError?.(
|
|
606
|
+
"sanitizeAnthropicSystemText: could not find Anthropic prompt marker after OpenCode identity",
|
|
607
|
+
);
|
|
608
|
+
return text;
|
|
567
609
|
}
|
|
568
|
-
|
|
569
|
-
return text.slice(0, startIdx) + text.slice(endIdx)
|
|
610
|
+
|
|
611
|
+
return (text.slice(0, startIdx) + text.slice(endIdx)).replaceAll(
|
|
612
|
+
"opencode",
|
|
613
|
+
"openc0de",
|
|
614
|
+
);
|
|
570
615
|
}
|
|
571
616
|
|
|
572
|
-
function
|
|
573
|
-
|
|
617
|
+
function mapSystemTextPart(
|
|
618
|
+
part: unknown,
|
|
619
|
+
onError?: (msg: string) => void,
|
|
620
|
+
): unknown {
|
|
621
|
+
if (typeof part === "string") {
|
|
622
|
+
return { type: "text", text: sanitizeAnthropicSystemText(part, onError) };
|
|
623
|
+
}
|
|
574
624
|
|
|
575
|
-
if (
|
|
625
|
+
if (
|
|
626
|
+
part &&
|
|
627
|
+
typeof part === "object" &&
|
|
628
|
+
"type" in part &&
|
|
629
|
+
part.type === "text" &&
|
|
630
|
+
"text" in part &&
|
|
631
|
+
typeof part.text === "string"
|
|
632
|
+
) {
|
|
633
|
+
return {
|
|
634
|
+
...part,
|
|
635
|
+
text: sanitizeAnthropicSystemText(part.text, onError),
|
|
636
|
+
};
|
|
637
|
+
}
|
|
638
|
+
|
|
639
|
+
return part;
|
|
640
|
+
}
|
|
576
641
|
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
642
|
+
|
|
643
|
+
function prependClaudeCodeIdentity(
|
|
644
|
+
system: unknown,
|
|
645
|
+
onError?: (msg: string) => void,
|
|
646
|
+
) {
|
|
647
|
+
const identityBlock = { type: "text", text: CLAUDE_CODE_IDENTITY };
|
|
648
|
+
|
|
649
|
+
if (typeof system === "undefined") return [identityBlock];
|
|
650
|
+
|
|
651
|
+
if (typeof system === "string") {
|
|
652
|
+
const sanitized = sanitizeAnthropicSystemText(system, onError);
|
|
653
|
+
if (sanitized === CLAUDE_CODE_IDENTITY) return [identityBlock];
|
|
654
|
+
return [identityBlock, { type: "text", text: sanitized }];
|
|
581
655
|
}
|
|
582
656
|
|
|
583
|
-
if (!Array.isArray(system)) return [identityBlock, system]
|
|
657
|
+
if (!Array.isArray(system)) return [identityBlock, system];
|
|
584
658
|
|
|
585
659
|
const sanitized = system.map((item) => {
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
const text = (item as { text?: unknown }).text
|
|
589
|
-
if (typeof text === 'string') {
|
|
590
|
-
return { ...(item as Record<string, unknown>), text: sanitizeSystemText(text, onError) }
|
|
591
|
-
}
|
|
592
|
-
}
|
|
593
|
-
return item
|
|
594
|
-
})
|
|
660
|
+
return mapSystemTextPart(item, onError);
|
|
661
|
+
});
|
|
595
662
|
|
|
596
|
-
const first = sanitized[0]
|
|
663
|
+
const first = sanitized[0];
|
|
597
664
|
if (
|
|
598
665
|
first &&
|
|
599
|
-
typeof first ===
|
|
600
|
-
|
|
601
|
-
|
|
666
|
+
typeof first === "object" &&
|
|
667
|
+
"type" in first &&
|
|
668
|
+
first.type === "text" &&
|
|
669
|
+
"text" in first &&
|
|
670
|
+
first.text === CLAUDE_CODE_IDENTITY
|
|
602
671
|
) {
|
|
603
|
-
return sanitized
|
|
672
|
+
return sanitized;
|
|
604
673
|
}
|
|
605
|
-
return [identityBlock, ...sanitized]
|
|
674
|
+
return [identityBlock, ...sanitized];
|
|
606
675
|
}
|
|
607
676
|
|
|
608
|
-
function rewriteRequestPayload(
|
|
609
|
-
|
|
677
|
+
function rewriteRequestPayload(
|
|
678
|
+
body: string | undefined,
|
|
679
|
+
onError?: (msg: string) => void,
|
|
680
|
+
) {
|
|
681
|
+
if (!body)
|
|
682
|
+
return {
|
|
683
|
+
body,
|
|
684
|
+
modelId: undefined,
|
|
685
|
+
reverseToolNameMap: new Map<string, string>(),
|
|
686
|
+
};
|
|
610
687
|
|
|
611
688
|
try {
|
|
612
|
-
const payload = JSON.parse(body) as Record<string, unknown
|
|
613
|
-
const reverseToolNameMap = new Map<string, string>()
|
|
614
|
-
const modelId =
|
|
689
|
+
const payload = JSON.parse(body) as Record<string, unknown>;
|
|
690
|
+
const reverseToolNameMap = new Map<string, string>();
|
|
691
|
+
const modelId =
|
|
692
|
+
typeof payload.model === "string" ? payload.model : undefined;
|
|
615
693
|
|
|
616
694
|
// Build reverse map and rename tools
|
|
617
695
|
if (Array.isArray(payload.tools)) {
|
|
618
696
|
payload.tools = payload.tools.map((tool) => {
|
|
619
|
-
if (!tool || typeof tool !==
|
|
620
|
-
const name = (tool as { name?: unknown }).name
|
|
621
|
-
if (typeof name !==
|
|
622
|
-
const mapped = toClaudeCodeToolName(name)
|
|
623
|
-
reverseToolNameMap.set(mapped, name)
|
|
624
|
-
return { ...(tool as Record<string, unknown>), name: mapped }
|
|
625
|
-
})
|
|
697
|
+
if (!tool || typeof tool !== "object") return tool;
|
|
698
|
+
const name = (tool as { name?: unknown }).name;
|
|
699
|
+
if (typeof name !== "string") return tool;
|
|
700
|
+
const mapped = toClaudeCodeToolName(name);
|
|
701
|
+
reverseToolNameMap.set(mapped, name);
|
|
702
|
+
return { ...(tool as Record<string, unknown>), name: mapped };
|
|
703
|
+
});
|
|
626
704
|
}
|
|
627
705
|
|
|
628
706
|
// Rename system prompt
|
|
629
|
-
payload.system = prependClaudeCodeIdentity(payload.system, onError)
|
|
707
|
+
payload.system = prependClaudeCodeIdentity(payload.system, onError);
|
|
630
708
|
|
|
631
709
|
// Rename tool_choice
|
|
632
710
|
if (
|
|
633
711
|
payload.tool_choice &&
|
|
634
|
-
typeof payload.tool_choice ===
|
|
635
|
-
(payload.tool_choice as { type?: unknown }).type ===
|
|
712
|
+
typeof payload.tool_choice === "object" &&
|
|
713
|
+
(payload.tool_choice as { type?: unknown }).type === "tool"
|
|
636
714
|
) {
|
|
637
|
-
const name = (payload.tool_choice as { name?: unknown }).name
|
|
638
|
-
if (typeof name ===
|
|
715
|
+
const name = (payload.tool_choice as { name?: unknown }).name;
|
|
716
|
+
if (typeof name === "string") {
|
|
639
717
|
payload.tool_choice = {
|
|
640
718
|
...(payload.tool_choice as Record<string, unknown>),
|
|
641
719
|
name: toClaudeCodeToolName(name),
|
|
642
|
-
}
|
|
720
|
+
};
|
|
643
721
|
}
|
|
644
722
|
}
|
|
645
723
|
|
|
646
724
|
// Rename tool_use blocks in messages
|
|
647
725
|
if (Array.isArray(payload.messages)) {
|
|
648
726
|
payload.messages = payload.messages.map((message) => {
|
|
649
|
-
if (!message || typeof message !==
|
|
650
|
-
const content = (message as { content?: unknown }).content
|
|
651
|
-
if (!Array.isArray(content)) return message
|
|
727
|
+
if (!message || typeof message !== "object") return message;
|
|
728
|
+
const content = (message as { content?: unknown }).content;
|
|
729
|
+
if (!Array.isArray(content)) return message;
|
|
652
730
|
return {
|
|
653
731
|
...(message as Record<string, unknown>),
|
|
654
732
|
content: content.map((block) => {
|
|
655
|
-
if (!block || typeof block !==
|
|
656
|
-
const b = block as { type?: unknown; name?: unknown }
|
|
657
|
-
if (b.type !==
|
|
658
|
-
|
|
733
|
+
if (!block || typeof block !== "object") return block;
|
|
734
|
+
const b = block as { type?: unknown; name?: unknown };
|
|
735
|
+
if (b.type !== "tool_use" || typeof b.name !== "string")
|
|
736
|
+
return block;
|
|
737
|
+
return {
|
|
738
|
+
...(block as Record<string, unknown>),
|
|
739
|
+
name: toClaudeCodeToolName(b.name),
|
|
740
|
+
};
|
|
659
741
|
}),
|
|
660
|
-
}
|
|
661
|
-
})
|
|
742
|
+
};
|
|
743
|
+
});
|
|
662
744
|
}
|
|
663
745
|
|
|
664
|
-
return { body: JSON.stringify(payload), modelId, reverseToolNameMap }
|
|
746
|
+
return { body: JSON.stringify(payload), modelId, reverseToolNameMap };
|
|
665
747
|
} catch {
|
|
666
|
-
return {
|
|
748
|
+
return {
|
|
749
|
+
body,
|
|
750
|
+
modelId: undefined,
|
|
751
|
+
reverseToolNameMap: new Map<string, string>(),
|
|
752
|
+
};
|
|
667
753
|
}
|
|
668
754
|
}
|
|
669
755
|
|
|
670
|
-
function wrapResponseStream(
|
|
671
|
-
|
|
756
|
+
function wrapResponseStream(
|
|
757
|
+
response: Response,
|
|
758
|
+
reverseToolNameMap: Map<string, string>,
|
|
759
|
+
) {
|
|
760
|
+
if (!response.body || reverseToolNameMap.size === 0) return response;
|
|
672
761
|
|
|
673
|
-
const reader = response.body.getReader()
|
|
674
|
-
const decoder = new TextDecoder()
|
|
675
|
-
const encoder = new TextEncoder()
|
|
676
|
-
let carry =
|
|
762
|
+
const reader = response.body.getReader();
|
|
763
|
+
const decoder = new TextDecoder();
|
|
764
|
+
const encoder = new TextEncoder();
|
|
765
|
+
let carry = "";
|
|
677
766
|
|
|
678
767
|
const transform = (text: string) => {
|
|
679
768
|
return text.replace(/"name"\s*:\s*"([^"]+)"/g, (full, name: string) => {
|
|
680
|
-
const original = reverseToolNameMap.get(name)
|
|
681
|
-
return original ? full.replace(`"${name}"`, `"${original}"`) : full
|
|
682
|
-
})
|
|
683
|
-
}
|
|
769
|
+
const original = reverseToolNameMap.get(name);
|
|
770
|
+
return original ? full.replace(`"${name}"`, `"${original}"`) : full;
|
|
771
|
+
});
|
|
772
|
+
};
|
|
684
773
|
|
|
685
774
|
const stream = new ReadableStream<Uint8Array>({
|
|
686
775
|
async pull(controller) {
|
|
687
|
-
const { done, value } = await reader.read()
|
|
776
|
+
const { done, value } = await reader.read();
|
|
688
777
|
if (done) {
|
|
689
|
-
const finalText = carry + decoder.decode()
|
|
690
|
-
if (finalText) controller.enqueue(encoder.encode(transform(finalText)))
|
|
691
|
-
controller.close()
|
|
692
|
-
return
|
|
778
|
+
const finalText = carry + decoder.decode();
|
|
779
|
+
if (finalText) controller.enqueue(encoder.encode(transform(finalText)));
|
|
780
|
+
controller.close();
|
|
781
|
+
return;
|
|
693
782
|
}
|
|
694
|
-
carry += decoder.decode(value, { stream: true })
|
|
783
|
+
carry += decoder.decode(value, { stream: true });
|
|
695
784
|
// Buffer 256 chars to avoid splitting JSON keys across chunks
|
|
696
|
-
if (carry.length <= 256) return
|
|
697
|
-
const output = carry.slice(0, -256)
|
|
698
|
-
carry = carry.slice(-256)
|
|
699
|
-
controller.enqueue(encoder.encode(transform(output)))
|
|
785
|
+
if (carry.length <= 256) return;
|
|
786
|
+
const output = carry.slice(0, -256);
|
|
787
|
+
carry = carry.slice(-256);
|
|
788
|
+
controller.enqueue(encoder.encode(transform(output)));
|
|
700
789
|
},
|
|
701
790
|
async cancel(reason) {
|
|
702
|
-
await reader.cancel(reason)
|
|
791
|
+
await reader.cancel(reason);
|
|
703
792
|
},
|
|
704
|
-
})
|
|
793
|
+
});
|
|
705
794
|
|
|
706
795
|
return new Response(stream, {
|
|
707
796
|
status: response.status,
|
|
708
797
|
statusText: response.statusText,
|
|
709
798
|
headers: response.headers,
|
|
710
|
-
})
|
|
799
|
+
});
|
|
711
800
|
}
|
|
712
801
|
|
|
713
802
|
function appendToastSessionMarker({
|
|
714
803
|
message,
|
|
715
804
|
sessionId,
|
|
716
805
|
}: {
|
|
717
|
-
message: string
|
|
718
|
-
sessionId: string | undefined
|
|
806
|
+
message: string;
|
|
807
|
+
sessionId: string | undefined;
|
|
719
808
|
}) {
|
|
720
809
|
if (!sessionId) {
|
|
721
|
-
return message
|
|
810
|
+
return message;
|
|
722
811
|
}
|
|
723
|
-
return `${message} ${sessionId}
|
|
812
|
+
return `${message} ${sessionId}`;
|
|
724
813
|
}
|
|
725
814
|
|
|
726
815
|
// --- Beta headers ---
|
|
727
816
|
|
|
728
817
|
function getRequiredBetas(modelId: string | undefined) {
|
|
729
|
-
const betas = [
|
|
818
|
+
const betas = [
|
|
819
|
+
CLAUDE_CODE_BETA,
|
|
820
|
+
OAUTH_BETA,
|
|
821
|
+
FINE_GRAINED_TOOL_STREAMING_BETA,
|
|
822
|
+
];
|
|
730
823
|
const isAdaptive =
|
|
731
|
-
modelId?.includes(
|
|
732
|
-
modelId?.includes(
|
|
733
|
-
modelId?.includes(
|
|
734
|
-
modelId?.includes(
|
|
735
|
-
if (!isAdaptive) betas.push(INTERLEAVED_THINKING_BETA)
|
|
736
|
-
return betas
|
|
824
|
+
modelId?.includes("opus-4-6") ||
|
|
825
|
+
modelId?.includes("opus-4.6") ||
|
|
826
|
+
modelId?.includes("sonnet-4-6") ||
|
|
827
|
+
modelId?.includes("sonnet-4.6");
|
|
828
|
+
if (!isAdaptive) betas.push(INTERLEAVED_THINKING_BETA);
|
|
829
|
+
return betas;
|
|
737
830
|
}
|
|
738
831
|
|
|
739
832
|
function mergeBetas(existing: string | null, required: string[]) {
|
|
740
833
|
return [
|
|
741
834
|
...new Set([
|
|
742
835
|
...required,
|
|
743
|
-
...(existing ||
|
|
744
|
-
.split(
|
|
836
|
+
...(existing || "")
|
|
837
|
+
.split(",")
|
|
745
838
|
.map((s) => s.trim())
|
|
746
839
|
.filter(Boolean),
|
|
747
840
|
]),
|
|
748
|
-
].join(
|
|
841
|
+
].join(",");
|
|
749
842
|
}
|
|
750
843
|
|
|
751
844
|
// --- Token refresh with dedup ---
|
|
752
845
|
|
|
753
846
|
function isOAuthStored(auth: { type: string }): auth is OAuthStored {
|
|
754
|
-
return auth.type ===
|
|
847
|
+
return auth.type === "oauth";
|
|
755
848
|
}
|
|
756
849
|
|
|
757
850
|
async function getFreshOAuth(
|
|
758
851
|
getAuth: () => Promise<OAuthStored | { type: string }>,
|
|
759
|
-
client: Parameters<Plugin>[0][
|
|
852
|
+
client: Parameters<Plugin>[0]["client"],
|
|
760
853
|
) {
|
|
761
|
-
const auth = await getAuth()
|
|
762
|
-
if (!isOAuthStored(auth)) return undefined
|
|
763
|
-
if (auth.access && auth.expires > Date.now()) return auth
|
|
854
|
+
const auth = await getAuth();
|
|
855
|
+
if (!isOAuthStored(auth)) return undefined;
|
|
856
|
+
if (auth.access && auth.expires > Date.now()) return auth;
|
|
764
857
|
|
|
765
|
-
const pending = pendingRefresh.get(auth.refresh)
|
|
858
|
+
const pending = pendingRefresh.get(auth.refresh);
|
|
766
859
|
if (pending) {
|
|
767
|
-
return pending
|
|
860
|
+
return pending;
|
|
768
861
|
}
|
|
769
862
|
|
|
770
863
|
const refreshPromise = withAuthStateLock(async () => {
|
|
771
|
-
const latest = await getAuth()
|
|
864
|
+
const latest = await getAuth();
|
|
772
865
|
if (!isOAuthStored(latest)) {
|
|
773
|
-
throw new Error(
|
|
866
|
+
throw new Error("Anthropic OAuth credentials disappeared during refresh");
|
|
774
867
|
}
|
|
775
|
-
if (latest.access && latest.expires > Date.now()) return latest
|
|
868
|
+
if (latest.access && latest.expires > Date.now()) return latest;
|
|
776
869
|
|
|
777
|
-
const refreshed = await refreshAnthropicToken(latest.refresh)
|
|
778
|
-
await setAnthropicAuth(refreshed, client)
|
|
779
|
-
const store = await loadAccountStore()
|
|
870
|
+
const refreshed = await refreshAnthropicToken(latest.refresh);
|
|
871
|
+
await setAnthropicAuth(refreshed, client);
|
|
872
|
+
const store = await loadAccountStore();
|
|
780
873
|
if (store.accounts.length > 0) {
|
|
781
874
|
const identity: AnthropicAccountIdentity | undefined = (() => {
|
|
782
875
|
const currentIndex = store.accounts.findIndex((account) => {
|
|
783
|
-
return
|
|
784
|
-
|
|
785
|
-
|
|
786
|
-
|
|
876
|
+
return (
|
|
877
|
+
account.refresh === latest.refresh ||
|
|
878
|
+
account.access === latest.access
|
|
879
|
+
);
|
|
880
|
+
});
|
|
881
|
+
const current =
|
|
882
|
+
currentIndex >= 0 ? store.accounts[currentIndex] : undefined;
|
|
883
|
+
if (!current) return undefined;
|
|
787
884
|
return {
|
|
788
885
|
...(current.email ? { email: current.email } : {}),
|
|
789
886
|
...(current.accountId ? { accountId: current.accountId } : {}),
|
|
790
|
-
}
|
|
791
|
-
})()
|
|
792
|
-
upsertAccount(store, { ...refreshed, ...identity })
|
|
793
|
-
await saveAccountStore(store)
|
|
887
|
+
};
|
|
888
|
+
})();
|
|
889
|
+
upsertAccount(store, { ...refreshed, ...identity });
|
|
890
|
+
await saveAccountStore(store);
|
|
794
891
|
}
|
|
795
|
-
return refreshed
|
|
796
|
-
})
|
|
797
|
-
pendingRefresh.set(auth.refresh, refreshPromise)
|
|
892
|
+
return refreshed;
|
|
893
|
+
});
|
|
894
|
+
pendingRefresh.set(auth.refresh, refreshPromise);
|
|
798
895
|
return refreshPromise.finally(() => {
|
|
799
|
-
pendingRefresh.delete(auth.refresh)
|
|
800
|
-
})
|
|
896
|
+
pendingRefresh.delete(auth.refresh);
|
|
897
|
+
});
|
|
801
898
|
}
|
|
802
899
|
|
|
803
|
-
// --- Plugin export ---
|
|
804
|
-
|
|
805
900
|
const AnthropicAuthPlugin: Plugin = async ({ client }) => {
|
|
806
901
|
return {
|
|
807
|
-
|
|
808
|
-
if (input.model.providerID !==
|
|
809
|
-
return
|
|
902
|
+
"chat.headers": async (input, output) => {
|
|
903
|
+
if (input.model.providerID !== "anthropic") {
|
|
904
|
+
return;
|
|
810
905
|
}
|
|
811
|
-
output.headers[TOAST_SESSION_HEADER] = input.sessionID
|
|
906
|
+
output.headers[TOAST_SESSION_HEADER] = input.sessionID;
|
|
812
907
|
},
|
|
813
908
|
auth: {
|
|
814
|
-
provider:
|
|
909
|
+
provider: "anthropic",
|
|
815
910
|
async loader(
|
|
816
911
|
getAuth: () => Promise<OAuthStored | { type: string }>,
|
|
817
912
|
provider: { models: Record<string, { cost?: unknown }> },
|
|
818
913
|
) {
|
|
819
|
-
const auth = await getAuth()
|
|
820
|
-
if (auth.type !==
|
|
914
|
+
const auth = await getAuth();
|
|
915
|
+
if (auth.type !== "oauth") return {};
|
|
821
916
|
|
|
822
917
|
// Zero out costs for OAuth users (Claude Pro/Max subscription)
|
|
823
918
|
for (const model of Object.values(provider.models)) {
|
|
824
|
-
model.cost = { input: 0, output: 0, cache: { read: 0, write: 0 } }
|
|
919
|
+
model.cost = { input: 0, output: 0, cache: { read: 0, write: 0 } };
|
|
825
920
|
}
|
|
826
921
|
|
|
827
922
|
return {
|
|
828
|
-
apiKey:
|
|
923
|
+
apiKey: "",
|
|
829
924
|
async fetch(input: Request | string | URL, init?: RequestInit) {
|
|
830
925
|
const url = (() => {
|
|
831
926
|
try {
|
|
832
|
-
return new URL(
|
|
927
|
+
return new URL(
|
|
928
|
+
input instanceof Request ? input.url : input.toString(),
|
|
929
|
+
);
|
|
833
930
|
} catch {
|
|
834
|
-
return null
|
|
931
|
+
return null;
|
|
835
932
|
}
|
|
836
|
-
})()
|
|
837
|
-
if (!url || !ANTHROPIC_HOSTS.has(url.hostname))
|
|
933
|
+
})();
|
|
934
|
+
if (!url || !ANTHROPIC_HOSTS.has(url.hostname))
|
|
935
|
+
return fetch(input, init);
|
|
838
936
|
|
|
839
937
|
const originalBody =
|
|
840
|
-
typeof init?.body ===
|
|
938
|
+
typeof init?.body === "string"
|
|
841
939
|
? init.body
|
|
842
940
|
: input instanceof Request
|
|
843
941
|
? await input
|
|
844
942
|
.clone()
|
|
845
943
|
.text()
|
|
846
944
|
.catch(() => undefined)
|
|
847
|
-
: undefined
|
|
945
|
+
: undefined;
|
|
848
946
|
|
|
849
|
-
const headers = new Headers(init?.headers)
|
|
947
|
+
const headers = new Headers(init?.headers);
|
|
850
948
|
if (input instanceof Request) {
|
|
851
949
|
input.headers.forEach((v, k) => {
|
|
852
|
-
if (!headers.has(k)) headers.set(k, v)
|
|
853
|
-
})
|
|
950
|
+
if (!headers.has(k)) headers.set(k, v);
|
|
951
|
+
});
|
|
854
952
|
}
|
|
855
|
-
const sessionId = headers.get(TOAST_SESSION_HEADER) ?? undefined
|
|
953
|
+
const sessionId = headers.get(TOAST_SESSION_HEADER) ?? undefined;
|
|
856
954
|
|
|
857
955
|
const rewritten = rewriteRequestPayload(originalBody, (msg) => {
|
|
858
|
-
client.tui
|
|
859
|
-
|
|
860
|
-
|
|
861
|
-
|
|
862
|
-
|
|
863
|
-
|
|
864
|
-
|
|
865
|
-
|
|
956
|
+
client.tui
|
|
957
|
+
.showToast({
|
|
958
|
+
body: {
|
|
959
|
+
message: appendToastSessionMarker({
|
|
960
|
+
message: msg,
|
|
961
|
+
sessionId,
|
|
962
|
+
}),
|
|
963
|
+
variant: "error",
|
|
964
|
+
},
|
|
965
|
+
})
|
|
966
|
+
.catch(() => {});
|
|
967
|
+
});
|
|
968
|
+
const betas = getRequiredBetas(rewritten.modelId);
|
|
866
969
|
|
|
867
970
|
const runRequest = async (auth: OAuthStored) => {
|
|
868
|
-
const requestHeaders = new Headers(headers)
|
|
869
|
-
requestHeaders.delete(TOAST_SESSION_HEADER)
|
|
870
|
-
requestHeaders.set(
|
|
971
|
+
const requestHeaders = new Headers(headers);
|
|
972
|
+
requestHeaders.delete(TOAST_SESSION_HEADER);
|
|
973
|
+
requestHeaders.set("accept", "application/json");
|
|
974
|
+
requestHeaders.set(
|
|
975
|
+
"anthropic-beta",
|
|
976
|
+
mergeBetas(requestHeaders.get("anthropic-beta"), betas),
|
|
977
|
+
);
|
|
871
978
|
requestHeaders.set(
|
|
872
|
-
|
|
873
|
-
|
|
874
|
-
)
|
|
875
|
-
requestHeaders.set(
|
|
876
|
-
requestHeaders.set('authorization', `Bearer ${auth.access}`)
|
|
979
|
+
"anthropic-dangerous-direct-browser-access",
|
|
980
|
+
"true",
|
|
981
|
+
);
|
|
982
|
+
requestHeaders.set("authorization", `Bearer ${auth.access}`);
|
|
877
983
|
requestHeaders.set(
|
|
878
|
-
|
|
879
|
-
process.env.OPENCODE_ANTHROPIC_USER_AGENT ||
|
|
880
|
-
|
|
881
|
-
|
|
882
|
-
requestHeaders.
|
|
984
|
+
"user-agent",
|
|
985
|
+
process.env.OPENCODE_ANTHROPIC_USER_AGENT ||
|
|
986
|
+
`claude-cli/${CLAUDE_CODE_VERSION}`,
|
|
987
|
+
);
|
|
988
|
+
requestHeaders.set("x-app", "cli");
|
|
989
|
+
requestHeaders.delete("x-api-key");
|
|
883
990
|
|
|
884
991
|
return fetch(input, {
|
|
885
992
|
...(init ?? {}),
|
|
886
993
|
body: rewritten.body,
|
|
887
994
|
headers: requestHeaders,
|
|
888
|
-
})
|
|
889
|
-
}
|
|
995
|
+
});
|
|
996
|
+
};
|
|
890
997
|
|
|
891
|
-
const freshAuth = await getFreshOAuth(getAuth, client)
|
|
892
|
-
if (!freshAuth) return fetch(input, init)
|
|
998
|
+
const freshAuth = await getFreshOAuth(getAuth, client);
|
|
999
|
+
if (!freshAuth) return fetch(input, init);
|
|
893
1000
|
|
|
894
|
-
let response = await runRequest(freshAuth)
|
|
1001
|
+
let response = await runRequest(freshAuth);
|
|
895
1002
|
if (!response.ok) {
|
|
896
1003
|
const bodyText = await response
|
|
897
1004
|
.clone()
|
|
898
1005
|
.text()
|
|
899
|
-
.catch(() =>
|
|
1006
|
+
.catch(() => "");
|
|
900
1007
|
if (shouldRotateAuth(response.status, bodyText)) {
|
|
901
|
-
const rotated = await rotateAnthropicAccount(freshAuth, client)
|
|
1008
|
+
const rotated = await rotateAnthropicAccount(freshAuth, client);
|
|
902
1009
|
if (rotated) {
|
|
903
1010
|
// Show toast notification so Discord thread shows the rotation
|
|
904
|
-
client.tui
|
|
905
|
-
|
|
906
|
-
|
|
907
|
-
message:
|
|
908
|
-
|
|
909
|
-
|
|
910
|
-
|
|
911
|
-
|
|
912
|
-
|
|
913
|
-
|
|
914
|
-
|
|
1011
|
+
client.tui
|
|
1012
|
+
.showToast({
|
|
1013
|
+
body: {
|
|
1014
|
+
message: appendToastSessionMarker({
|
|
1015
|
+
message: `Switching from account ${rotated.fromLabel} to account ${rotated.toLabel}`,
|
|
1016
|
+
sessionId,
|
|
1017
|
+
}),
|
|
1018
|
+
variant: "info",
|
|
1019
|
+
},
|
|
1020
|
+
})
|
|
1021
|
+
.catch(() => {});
|
|
1022
|
+
const retryAuth = await getFreshOAuth(getAuth, client);
|
|
915
1023
|
if (retryAuth) {
|
|
916
|
-
response = await runRequest(retryAuth)
|
|
1024
|
+
response = await runRequest(retryAuth);
|
|
917
1025
|
}
|
|
918
1026
|
}
|
|
919
1027
|
}
|
|
920
1028
|
}
|
|
921
1029
|
|
|
922
|
-
return wrapResponseStream(response, rewritten.reverseToolNameMap)
|
|
1030
|
+
return wrapResponseStream(response, rewritten.reverseToolNameMap);
|
|
923
1031
|
},
|
|
924
|
-
}
|
|
1032
|
+
};
|
|
925
1033
|
},
|
|
926
1034
|
methods: [
|
|
927
1035
|
{
|
|
928
|
-
label:
|
|
929
|
-
type:
|
|
930
|
-
authorize: buildAuthorizeHandler(
|
|
1036
|
+
label: "Claude Pro/Max",
|
|
1037
|
+
type: "oauth",
|
|
1038
|
+
authorize: buildAuthorizeHandler("oauth"),
|
|
931
1039
|
},
|
|
932
1040
|
{
|
|
933
|
-
label:
|
|
934
|
-
type:
|
|
935
|
-
authorize: buildAuthorizeHandler(
|
|
1041
|
+
label: "Create an API Key",
|
|
1042
|
+
type: "oauth",
|
|
1043
|
+
authorize: buildAuthorizeHandler("apikey"),
|
|
936
1044
|
},
|
|
937
1045
|
{
|
|
938
|
-
provider:
|
|
939
|
-
label:
|
|
940
|
-
type:
|
|
1046
|
+
provider: "anthropic",
|
|
1047
|
+
label: "Manually enter API Key",
|
|
1048
|
+
type: "api",
|
|
941
1049
|
},
|
|
942
1050
|
],
|
|
943
1051
|
},
|
|
944
|
-
}
|
|
945
|
-
}
|
|
1052
|
+
};
|
|
1053
|
+
};
|
|
946
1054
|
|
|
947
|
-
|
|
948
|
-
|
|
949
|
-
|
|
1055
|
+
const replacer: Plugin = async () => {
|
|
1056
|
+
return {
|
|
1057
|
+
"experimental.chat.system.transform": (async (input, output) => {
|
|
1058
|
+
if (input.model.providerID !== "anthropic") return;
|
|
1059
|
+
const textIndex = output.system.findIndex((x) =>
|
|
1060
|
+
x.includes(OPENCODE_IDENTITY),
|
|
1061
|
+
);
|
|
1062
|
+
const text = output.system[textIndex];
|
|
1063
|
+
if (!text) {
|
|
1064
|
+
return;
|
|
1065
|
+
}
|
|
1066
|
+
|
|
1067
|
+
output.system[textIndex] = sanitizeAnthropicSystemText(text);
|
|
1068
|
+
}) satisfies SystemTransformHook,
|
|
1069
|
+
};
|
|
1070
|
+
};
|
|
1071
|
+
|
|
1072
|
+
export { replacer, AnthropicAuthPlugin as anthropicAuthPlugin };
|