@link-assistant/agent 0.16.17 → 0.16.18
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/package.json +1 -1
- package/src/auth/claude-oauth.ts +5 -3
- package/src/auth/plugins.ts +56 -48
- package/src/cli/cmd/auth.ts +6 -3
- package/src/cli/continuous-mode.js +5 -1
- package/src/config/config.ts +5 -3
- package/src/file/ripgrep.ts +3 -1
- package/src/index.js +3 -1
- package/src/provider/google-cloudcode.ts +4 -2
- package/src/provider/models.ts +3 -1
- package/src/provider/provider.ts +116 -42
- package/src/tool/codesearch.ts +4 -1
- package/src/tool/webfetch.ts +4 -1
- package/src/tool/websearch.ts +4 -1
- package/src/util/verbose-fetch.ts +303 -0
package/package.json
CHANGED
package/src/auth/claude-oauth.ts
CHANGED
|
@@ -2,6 +2,7 @@ import crypto from 'crypto';
|
|
|
2
2
|
import path from 'path';
|
|
3
3
|
import { Global } from '../global';
|
|
4
4
|
import { Log } from '../util/log';
|
|
5
|
+
import { createVerboseFetch } from '../util/verbose-fetch';
|
|
5
6
|
import z from 'zod';
|
|
6
7
|
|
|
7
8
|
/**
|
|
@@ -24,6 +25,7 @@ import z from 'zod';
|
|
|
24
25
|
*/
|
|
25
26
|
export namespace ClaudeOAuth {
|
|
26
27
|
const log = Log.create({ service: 'claude-oauth' });
|
|
28
|
+
const verboseFetch = createVerboseFetch(fetch, { caller: 'claude-oauth' });
|
|
27
29
|
|
|
28
30
|
/**
|
|
29
31
|
* OAuth Configuration
|
|
@@ -218,7 +220,7 @@ export namespace ClaudeOAuth {
|
|
|
218
220
|
message: 'exchanging authorization code for tokens',
|
|
219
221
|
}));
|
|
220
222
|
|
|
221
|
-
const response = await
|
|
223
|
+
const response = await verboseFetch(Config.tokenUrl, {
|
|
222
224
|
method: 'POST',
|
|
223
225
|
headers: {
|
|
224
226
|
'Content-Type': 'application/x-www-form-urlencoded',
|
|
@@ -402,7 +404,7 @@ export namespace ClaudeOAuth {
|
|
|
402
404
|
log.info(() => ({ message: 'refreshing access token' }));
|
|
403
405
|
|
|
404
406
|
try {
|
|
405
|
-
const response = await
|
|
407
|
+
const response = await verboseFetch(Config.tokenUrl, {
|
|
406
408
|
method: 'POST',
|
|
407
409
|
headers: {
|
|
408
410
|
'Content-Type': 'application/x-www-form-urlencoded',
|
|
@@ -446,7 +448,7 @@ export namespace ClaudeOAuth {
|
|
|
446
448
|
} else {
|
|
447
449
|
headers.set('anthropic-beta', Config.betaHeader);
|
|
448
450
|
}
|
|
449
|
-
return
|
|
451
|
+
return verboseFetch(url, { ...init, headers });
|
|
450
452
|
};
|
|
451
453
|
}
|
|
452
454
|
}
|
package/src/auth/plugins.ts
CHANGED
|
@@ -3,6 +3,7 @@ import * as http from 'node:http';
|
|
|
3
3
|
import * as net from 'node:net';
|
|
4
4
|
import { Auth } from './index';
|
|
5
5
|
import { Log } from '../util/log';
|
|
6
|
+
import { createVerboseFetch } from '../util/verbose-fetch';
|
|
6
7
|
|
|
7
8
|
/**
|
|
8
9
|
* Auth Plugins Module
|
|
@@ -12,6 +13,7 @@ import { Log } from '../util/log';
|
|
|
12
13
|
*/
|
|
13
14
|
|
|
14
15
|
const log = Log.create({ service: 'auth-plugins' });
|
|
16
|
+
const verboseFetch = createVerboseFetch(fetch, { caller: 'auth-plugins' });
|
|
15
17
|
|
|
16
18
|
/**
|
|
17
19
|
* OAuth callback result types
|
|
@@ -142,7 +144,7 @@ const AnthropicPlugin: AuthPlugin = {
|
|
|
142
144
|
if (!code) return { type: 'failed' };
|
|
143
145
|
|
|
144
146
|
const splits = code.split('#');
|
|
145
|
-
const result = await
|
|
147
|
+
const result = await verboseFetch(
|
|
146
148
|
'https://console.anthropic.com/v1/oauth/token',
|
|
147
149
|
{
|
|
148
150
|
method: 'POST',
|
|
@@ -210,7 +212,7 @@ const AnthropicPlugin: AuthPlugin = {
|
|
|
210
212
|
if (!code) return { type: 'failed' };
|
|
211
213
|
|
|
212
214
|
const splits = code.split('#');
|
|
213
|
-
const tokenResult = await
|
|
215
|
+
const tokenResult = await verboseFetch(
|
|
214
216
|
'https://console.anthropic.com/v1/oauth/token',
|
|
215
217
|
{
|
|
216
218
|
method: 'POST',
|
|
@@ -240,7 +242,7 @@ const AnthropicPlugin: AuthPlugin = {
|
|
|
240
242
|
const credentials = await tokenResult.json();
|
|
241
243
|
|
|
242
244
|
// Create API key using the access token
|
|
243
|
-
const apiKeyResult = await
|
|
245
|
+
const apiKeyResult = await verboseFetch(
|
|
244
246
|
'https://api.anthropic.com/api/oauth/claude_cli/create_api_key',
|
|
245
247
|
{
|
|
246
248
|
method: 'POST',
|
|
@@ -291,7 +293,7 @@ const AnthropicPlugin: AuthPlugin = {
|
|
|
291
293
|
log.info(() => ({
|
|
292
294
|
message: 'refreshing anthropic oauth token',
|
|
293
295
|
}));
|
|
294
|
-
const response = await
|
|
296
|
+
const response = await verboseFetch(
|
|
295
297
|
'https://console.anthropic.com/v1/oauth/token',
|
|
296
298
|
{
|
|
297
299
|
method: 'POST',
|
|
@@ -446,7 +448,7 @@ const GitHubCopilotPlugin: AuthPlugin = {
|
|
|
446
448
|
|
|
447
449
|
const urls = getCopilotUrls(domain);
|
|
448
450
|
|
|
449
|
-
const deviceResponse = await
|
|
451
|
+
const deviceResponse = await verboseFetch(urls.DEVICE_CODE_URL, {
|
|
450
452
|
method: 'POST',
|
|
451
453
|
headers: {
|
|
452
454
|
Accept: 'application/json',
|
|
@@ -476,7 +478,7 @@ const GitHubCopilotPlugin: AuthPlugin = {
|
|
|
476
478
|
method: 'auto',
|
|
477
479
|
async callback(): Promise<AuthResult> {
|
|
478
480
|
while (true) {
|
|
479
|
-
const response = await
|
|
481
|
+
const response = await verboseFetch(urls.ACCESS_TOKEN_URL, {
|
|
480
482
|
method: 'POST',
|
|
481
483
|
headers: {
|
|
482
484
|
Accept: 'application/json',
|
|
@@ -571,7 +573,7 @@ const GitHubCopilotPlugin: AuthPlugin = {
|
|
|
571
573
|
const urls = getCopilotUrls(domain);
|
|
572
574
|
|
|
573
575
|
log.info(() => ({ message: 'refreshing github copilot token' }));
|
|
574
|
-
const response = await
|
|
576
|
+
const response = await verboseFetch(urls.COPILOT_API_KEY_URL, {
|
|
575
577
|
headers: {
|
|
576
578
|
Accept: 'application/json',
|
|
577
579
|
Authorization: `Bearer ${currentInfo.refresh}`,
|
|
@@ -731,7 +733,7 @@ const OpenAIPlugin: AuthPlugin = {
|
|
|
731
733
|
}
|
|
732
734
|
|
|
733
735
|
// Exchange authorization code for tokens
|
|
734
|
-
const tokenResult = await
|
|
736
|
+
const tokenResult = await verboseFetch(OPENAI_TOKEN_URL, {
|
|
735
737
|
method: 'POST',
|
|
736
738
|
headers: {
|
|
737
739
|
'Content-Type': 'application/x-www-form-urlencoded',
|
|
@@ -797,7 +799,7 @@ const OpenAIPlugin: AuthPlugin = {
|
|
|
797
799
|
// Refresh token if expired
|
|
798
800
|
if (!currentAuth.access || currentAuth.expires < Date.now()) {
|
|
799
801
|
log.info(() => ({ message: 'refreshing openai oauth token' }));
|
|
800
|
-
const response = await
|
|
802
|
+
const response = await verboseFetch(OPENAI_TOKEN_URL, {
|
|
801
803
|
method: 'POST',
|
|
802
804
|
headers: {
|
|
803
805
|
'Content-Type': 'application/x-www-form-urlencoded',
|
|
@@ -1091,7 +1093,7 @@ const GooglePlugin: AuthPlugin = {
|
|
|
1091
1093
|
const { code } = await authPromise;
|
|
1092
1094
|
|
|
1093
1095
|
// Exchange authorization code for tokens
|
|
1094
|
-
const tokenResult = await
|
|
1096
|
+
const tokenResult = await verboseFetch(GOOGLE_TOKEN_URL, {
|
|
1095
1097
|
method: 'POST',
|
|
1096
1098
|
headers: {
|
|
1097
1099
|
'Content-Type': 'application/x-www-form-urlencoded',
|
|
@@ -1187,7 +1189,7 @@ const GooglePlugin: AuthPlugin = {
|
|
|
1187
1189
|
|
|
1188
1190
|
try {
|
|
1189
1191
|
// Exchange authorization code for tokens
|
|
1190
|
-
const tokenResult = await
|
|
1192
|
+
const tokenResult = await verboseFetch(GOOGLE_TOKEN_URL, {
|
|
1191
1193
|
method: 'POST',
|
|
1192
1194
|
headers: {
|
|
1193
1195
|
'Content-Type': 'application/x-www-form-urlencoded',
|
|
@@ -1348,7 +1350,7 @@ const GooglePlugin: AuthPlugin = {
|
|
|
1348
1350
|
// Call loadCodeAssist to discover project and tier
|
|
1349
1351
|
try {
|
|
1350
1352
|
const loadUrl = `${CLOUD_CODE_ENDPOINT}/${CLOUD_CODE_API_VERSION}:loadCodeAssist`;
|
|
1351
|
-
const loadRes = await
|
|
1353
|
+
const loadRes = await verboseFetch(loadUrl, {
|
|
1352
1354
|
method: 'POST',
|
|
1353
1355
|
headers: {
|
|
1354
1356
|
'Content-Type': 'application/json',
|
|
@@ -1440,7 +1442,7 @@ const GooglePlugin: AuthPlugin = {
|
|
|
1440
1442
|
},
|
|
1441
1443
|
};
|
|
1442
1444
|
|
|
1443
|
-
let lroRes = await
|
|
1445
|
+
let lroRes = await verboseFetch(onboardUrl, {
|
|
1444
1446
|
method: 'POST',
|
|
1445
1447
|
headers: {
|
|
1446
1448
|
'Content-Type': 'application/json',
|
|
@@ -1456,11 +1458,11 @@ const GooglePlugin: AuthPlugin = {
|
|
|
1456
1458
|
if (lroRes.name) {
|
|
1457
1459
|
// Poll operation status
|
|
1458
1460
|
const opUrl = `${CLOUD_CODE_ENDPOINT}/${CLOUD_CODE_API_VERSION}/${lroRes.name}`;
|
|
1459
|
-
lroRes = await
|
|
1461
|
+
lroRes = await verboseFetch(opUrl, {
|
|
1460
1462
|
headers: { Authorization: `Bearer ${accessToken}` },
|
|
1461
1463
|
}).then((r) => r.json());
|
|
1462
1464
|
} else {
|
|
1463
|
-
lroRes = await
|
|
1465
|
+
lroRes = await verboseFetch(onboardUrl, {
|
|
1464
1466
|
method: 'POST',
|
|
1465
1467
|
headers: {
|
|
1466
1468
|
'Content-Type': 'application/json',
|
|
@@ -1924,7 +1926,7 @@ const GooglePlugin: AuthPlugin = {
|
|
|
1924
1926
|
// Invalidate project cache when token changes
|
|
1925
1927
|
cachedProjectContext = null;
|
|
1926
1928
|
|
|
1927
|
-
const response = await
|
|
1929
|
+
const response = await verboseFetch(GOOGLE_TOKEN_URL, {
|
|
1928
1930
|
method: 'POST',
|
|
1929
1931
|
headers: {
|
|
1930
1932
|
'Content-Type': 'application/x-www-form-urlencoded',
|
|
@@ -2043,7 +2045,7 @@ const GooglePlugin: AuthPlugin = {
|
|
|
2043
2045
|
await new Promise((resolve) => setTimeout(resolve, delay));
|
|
2044
2046
|
}
|
|
2045
2047
|
|
|
2046
|
-
const cloudCodeResponse = await
|
|
2048
|
+
const cloudCodeResponse = await verboseFetch(finalCloudCodeUrl, {
|
|
2047
2049
|
...init,
|
|
2048
2050
|
body,
|
|
2049
2051
|
headers,
|
|
@@ -2135,7 +2137,7 @@ const GooglePlugin: AuthPlugin = {
|
|
|
2135
2137
|
};
|
|
2136
2138
|
delete headers['x-goog-api-key'];
|
|
2137
2139
|
|
|
2138
|
-
const oauthResponse = await
|
|
2140
|
+
const oauthResponse = await verboseFetch(input, {
|
|
2139
2141
|
...init,
|
|
2140
2142
|
headers,
|
|
2141
2143
|
});
|
|
@@ -2248,19 +2250,22 @@ const QwenPlugin: AuthPlugin = {
|
|
|
2248
2250
|
const codeChallenge = generateCodeChallenge(codeVerifier);
|
|
2249
2251
|
|
|
2250
2252
|
// Request device code
|
|
2251
|
-
const deviceResponse = await
|
|
2252
|
-
|
|
2253
|
-
|
|
2254
|
-
|
|
2255
|
-
|
|
2256
|
-
|
|
2257
|
-
|
|
2258
|
-
|
|
2259
|
-
|
|
2260
|
-
|
|
2261
|
-
|
|
2262
|
-
|
|
2263
|
-
|
|
2253
|
+
const deviceResponse = await verboseFetch(
|
|
2254
|
+
QWEN_OAUTH_DEVICE_CODE_ENDPOINT,
|
|
2255
|
+
{
|
|
2256
|
+
method: 'POST',
|
|
2257
|
+
headers: {
|
|
2258
|
+
'Content-Type': 'application/x-www-form-urlencoded',
|
|
2259
|
+
Accept: 'application/json',
|
|
2260
|
+
},
|
|
2261
|
+
body: new URLSearchParams({
|
|
2262
|
+
client_id: QWEN_OAUTH_CLIENT_ID,
|
|
2263
|
+
scope: QWEN_OAUTH_SCOPE,
|
|
2264
|
+
code_challenge: codeChallenge,
|
|
2265
|
+
code_challenge_method: 'S256',
|
|
2266
|
+
}).toString(),
|
|
2267
|
+
}
|
|
2268
|
+
);
|
|
2264
2269
|
|
|
2265
2270
|
if (!deviceResponse.ok) {
|
|
2266
2271
|
const errorText = await deviceResponse.text();
|
|
@@ -2308,19 +2313,22 @@ const QwenPlugin: AuthPlugin = {
|
|
|
2308
2313
|
async callback(): Promise<AuthResult> {
|
|
2309
2314
|
// Poll for authorization completion
|
|
2310
2315
|
for (let attempt = 0; attempt < maxPollAttempts; attempt++) {
|
|
2311
|
-
const tokenResponse = await
|
|
2312
|
-
|
|
2313
|
-
|
|
2314
|
-
|
|
2315
|
-
|
|
2316
|
-
|
|
2317
|
-
|
|
2318
|
-
|
|
2319
|
-
|
|
2320
|
-
|
|
2321
|
-
|
|
2322
|
-
|
|
2323
|
-
|
|
2316
|
+
const tokenResponse = await verboseFetch(
|
|
2317
|
+
QWEN_OAUTH_TOKEN_ENDPOINT,
|
|
2318
|
+
{
|
|
2319
|
+
method: 'POST',
|
|
2320
|
+
headers: {
|
|
2321
|
+
'Content-Type': 'application/x-www-form-urlencoded',
|
|
2322
|
+
Accept: 'application/json',
|
|
2323
|
+
},
|
|
2324
|
+
body: new URLSearchParams({
|
|
2325
|
+
client_id: QWEN_OAUTH_CLIENT_ID,
|
|
2326
|
+
device_code: deviceData.device_code,
|
|
2327
|
+
grant_type: 'urn:ietf:params:oauth:grant-type:device_code',
|
|
2328
|
+
code_verifier: codeVerifier,
|
|
2329
|
+
}).toString(),
|
|
2330
|
+
}
|
|
2331
|
+
);
|
|
2324
2332
|
|
|
2325
2333
|
if (!tokenResponse.ok) {
|
|
2326
2334
|
const errorText = await tokenResponse.text();
|
|
@@ -2426,7 +2434,7 @@ const QwenPlugin: AuthPlugin = {
|
|
|
2426
2434
|
: 'token expiring soon',
|
|
2427
2435
|
}));
|
|
2428
2436
|
|
|
2429
|
-
const response = await
|
|
2437
|
+
const response = await verboseFetch(QWEN_OAUTH_TOKEN_ENDPOINT, {
|
|
2430
2438
|
method: 'POST',
|
|
2431
2439
|
headers: {
|
|
2432
2440
|
'Content-Type': 'application/x-www-form-urlencoded',
|
|
@@ -2575,7 +2583,7 @@ const AlibabaPlugin: AuthPlugin = {
|
|
|
2575
2583
|
message: 'refreshing qwen oauth token (alibaba provider)',
|
|
2576
2584
|
}));
|
|
2577
2585
|
|
|
2578
|
-
const response = await
|
|
2586
|
+
const response = await verboseFetch(QWEN_OAUTH_TOKEN_ENDPOINT, {
|
|
2579
2587
|
method: 'POST',
|
|
2580
2588
|
headers: {
|
|
2581
2589
|
'Content-Type': 'application/x-www-form-urlencoded',
|
|
@@ -2651,7 +2659,7 @@ const KiloPlugin: AuthPlugin = {
|
|
|
2651
2659
|
type: 'oauth',
|
|
2652
2660
|
async authorize() {
|
|
2653
2661
|
// Initiate device authorization
|
|
2654
|
-
const initResponse = await
|
|
2662
|
+
const initResponse = await verboseFetch(
|
|
2655
2663
|
`${KILO_API_BASE}/api/device-auth/codes`,
|
|
2656
2664
|
{
|
|
2657
2665
|
method: 'POST',
|
|
@@ -2704,7 +2712,7 @@ const KiloPlugin: AuthPlugin = {
|
|
|
2704
2712
|
setTimeout(resolve, KILO_POLL_INTERVAL_MS)
|
|
2705
2713
|
);
|
|
2706
2714
|
|
|
2707
|
-
const pollResponse = await
|
|
2715
|
+
const pollResponse = await verboseFetch(
|
|
2708
2716
|
`${KILO_API_BASE}/api/device-auth/codes/${authData.code}`
|
|
2709
2717
|
);
|
|
2710
2718
|
|
package/src/cli/cmd/auth.ts
CHANGED
|
@@ -9,6 +9,9 @@ import path from 'path';
|
|
|
9
9
|
import os from 'os';
|
|
10
10
|
import { Global } from '../../global';
|
|
11
11
|
import { map, pipe, sortBy, values } from 'remeda';
|
|
12
|
+
import { createVerboseFetch } from '../../util/verbose-fetch';
|
|
13
|
+
|
|
14
|
+
const verboseFetch = createVerboseFetch(fetch, { caller: 'auth-cmd' });
|
|
12
15
|
|
|
13
16
|
/**
|
|
14
17
|
* Auth Command
|
|
@@ -86,9 +89,9 @@ export const AuthLoginCommand = cmd({
|
|
|
86
89
|
// Handle wellknown URL login
|
|
87
90
|
if (args.url) {
|
|
88
91
|
try {
|
|
89
|
-
const wellknown = await
|
|
90
|
-
|
|
91
|
-
);
|
|
92
|
+
const wellknown = await verboseFetch(
|
|
93
|
+
`${args.url}/.well-known/opencode`
|
|
94
|
+
).then((x) => x.json() as any);
|
|
92
95
|
prompts.log.info(`Running \`${wellknown.auth.command.join(' ')}\``);
|
|
93
96
|
const proc = Bun.spawn({
|
|
94
97
|
cmd: wellknown.auth.command,
|
|
@@ -12,6 +12,7 @@ import { createEventHandler } from '../json-standard/index.ts';
|
|
|
12
12
|
import { createContinuousStdinReader } from './input-queue.js';
|
|
13
13
|
import { Log } from '../util/log.ts';
|
|
14
14
|
import { Flag } from '../flag/flag.ts';
|
|
15
|
+
import { createVerboseFetch } from '../util/verbose-fetch.ts';
|
|
15
16
|
import { outputStatus, outputError, outputInput } from './output.ts';
|
|
16
17
|
|
|
17
18
|
// Shared error tracking
|
|
@@ -215,7 +216,10 @@ export async function runContinuousServerMode(
|
|
|
215
216
|
sessionID = resumeInfo.sessionID;
|
|
216
217
|
} else {
|
|
217
218
|
// Create a new session
|
|
218
|
-
const
|
|
219
|
+
const localVerboseFetch = createVerboseFetch(fetch, {
|
|
220
|
+
caller: 'continuous-mode',
|
|
221
|
+
});
|
|
222
|
+
const createRes = await localVerboseFetch(
|
|
219
223
|
`http://${server.hostname}:${server.port}/session`,
|
|
220
224
|
{
|
|
221
225
|
method: 'POST',
|
package/src/config/config.ts
CHANGED
|
@@ -11,6 +11,7 @@ import { lazy } from '../util/lazy';
|
|
|
11
11
|
import { NamedError } from '../util/error';
|
|
12
12
|
import { Flag } from '../flag/flag';
|
|
13
13
|
import { Auth } from '../auth';
|
|
14
|
+
import { createVerboseFetch } from '../util/verbose-fetch';
|
|
14
15
|
import {
|
|
15
16
|
type ParseError as JsoncParseError,
|
|
16
17
|
parse as parseJsonc,
|
|
@@ -21,6 +22,7 @@ import { ConfigMarkdown } from './markdown';
|
|
|
21
22
|
|
|
22
23
|
export namespace Config {
|
|
23
24
|
const log = Log.create({ service: 'config' });
|
|
25
|
+
const verboseFetch = createVerboseFetch(fetch, { caller: 'config' });
|
|
24
26
|
|
|
25
27
|
/**
|
|
26
28
|
* Automatically migrate .opencode directories to .link-assistant-agent
|
|
@@ -163,9 +165,9 @@ export namespace Config {
|
|
|
163
165
|
for (const [key, value] of Object.entries(auth)) {
|
|
164
166
|
if (value.type === 'wellknown') {
|
|
165
167
|
process.env[value.key] = value.token;
|
|
166
|
-
const wellknown = (await
|
|
167
|
-
|
|
168
|
-
)) as any;
|
|
168
|
+
const wellknown = (await verboseFetch(
|
|
169
|
+
`${key}/.well-known/opencode`
|
|
170
|
+
).then((x) => x.json())) as any;
|
|
169
171
|
result = mergeDeep(
|
|
170
172
|
result,
|
|
171
173
|
await load(JSON.stringify(wellknown.config ?? {}), process.cwd())
|
package/src/file/ripgrep.ts
CHANGED
|
@@ -9,9 +9,11 @@ import { $ } from 'bun';
|
|
|
9
9
|
|
|
10
10
|
import { ZipReader, BlobReader, BlobWriter } from '@zip.js/zip.js';
|
|
11
11
|
import { Log } from '../util/log';
|
|
12
|
+
import { createVerboseFetch } from '../util/verbose-fetch';
|
|
12
13
|
|
|
13
14
|
export namespace Ripgrep {
|
|
14
15
|
const log = Log.create({ service: 'ripgrep' });
|
|
16
|
+
const verboseFetch = createVerboseFetch(fetch, { caller: 'ripgrep' });
|
|
15
17
|
const Stats = z.object({
|
|
16
18
|
elapsed: z.object({
|
|
17
19
|
secs: z.number(),
|
|
@@ -142,7 +144,7 @@ export namespace Ripgrep {
|
|
|
142
144
|
const filename = `ripgrep-${version}-${config.platform}.${config.extension}`;
|
|
143
145
|
const url = `https://github.com/BurntSushi/ripgrep/releases/download/${version}/${filename}`;
|
|
144
146
|
|
|
145
|
-
const response = await
|
|
147
|
+
const response = await verboseFetch(url);
|
|
146
148
|
if (!response.ok)
|
|
147
149
|
throw new DownloadFailedError({ url, status: response.status });
|
|
148
150
|
|
package/src/index.js
CHANGED
|
@@ -21,6 +21,7 @@ import { McpCommand } from './cli/cmd/mcp.ts';
|
|
|
21
21
|
import { AuthCommand } from './cli/cmd/auth.ts';
|
|
22
22
|
import { FormatError } from './cli/error.ts';
|
|
23
23
|
import { UI } from './cli/ui.ts';
|
|
24
|
+
import { createVerboseFetch } from './util/verbose-fetch.ts';
|
|
24
25
|
import {
|
|
25
26
|
runContinuousServerMode,
|
|
26
27
|
runContinuousDirectMode,
|
|
@@ -427,7 +428,8 @@ async function runServerMode(
|
|
|
427
428
|
sessionID = resumeInfo.sessionID;
|
|
428
429
|
} else {
|
|
429
430
|
// Create a new session
|
|
430
|
-
const
|
|
431
|
+
const localVerboseFetch = createVerboseFetch(fetch, { caller: 'cli' });
|
|
432
|
+
const createRes = await localVerboseFetch(
|
|
431
433
|
`http://${server.hostname}:${server.port}/session`,
|
|
432
434
|
{
|
|
433
435
|
method: 'POST',
|
|
@@ -15,8 +15,10 @@
|
|
|
15
15
|
|
|
16
16
|
import { Log } from '../util/log';
|
|
17
17
|
import { Auth } from '../auth';
|
|
18
|
+
import { createVerboseFetch } from '../util/verbose-fetch';
|
|
18
19
|
|
|
19
20
|
const log = Log.create({ service: 'google-cloudcode' });
|
|
21
|
+
const verboseFetch = createVerboseFetch(fetch, { caller: 'google-cloudcode' });
|
|
20
22
|
|
|
21
23
|
// Cloud Code API endpoints (from gemini-cli)
|
|
22
24
|
// Configurable via environment variables for testing or alternative endpoints
|
|
@@ -179,7 +181,7 @@ export class CloudCodeClient {
|
|
|
179
181
|
message: 'refreshing google oauth token for cloud code',
|
|
180
182
|
}));
|
|
181
183
|
|
|
182
|
-
const response = await
|
|
184
|
+
const response = await verboseFetch(GOOGLE_TOKEN_URL, {
|
|
183
185
|
method: 'POST',
|
|
184
186
|
headers: {
|
|
185
187
|
'Content-Type': 'application/x-www-form-urlencoded',
|
|
@@ -223,7 +225,7 @@ export class CloudCodeClient {
|
|
|
223
225
|
const baseUrl = `${CODE_ASSIST_ENDPOINT}/${CODE_ASSIST_API_VERSION}:${method}`;
|
|
224
226
|
const url = options.stream ? `${baseUrl}?alt=sse` : baseUrl;
|
|
225
227
|
|
|
226
|
-
const response = await
|
|
228
|
+
const response = await verboseFetch(url, {
|
|
227
229
|
method: 'POST',
|
|
228
230
|
headers: {
|
|
229
231
|
'Content-Type': 'application/json',
|
package/src/provider/models.ts
CHANGED
|
@@ -1,11 +1,13 @@
|
|
|
1
1
|
import { Global } from '../global';
|
|
2
2
|
import { Log } from '../util/log';
|
|
3
|
+
import { createVerboseFetch } from '../util/verbose-fetch';
|
|
3
4
|
import path from 'path';
|
|
4
5
|
import z from 'zod';
|
|
5
6
|
import { data } from './models-macro';
|
|
6
7
|
|
|
7
8
|
export namespace ModelsDev {
|
|
8
9
|
const log = Log.create({ service: 'models.dev' });
|
|
10
|
+
const verboseFetch = createVerboseFetch(fetch, { caller: 'models.dev' });
|
|
9
11
|
const filepath = path.join(Global.Path.cache, 'models.json');
|
|
10
12
|
|
|
11
13
|
export const Model = z
|
|
@@ -145,7 +147,7 @@ export namespace ModelsDev {
|
|
|
145
147
|
export async function refresh() {
|
|
146
148
|
const file = Bun.file(filepath);
|
|
147
149
|
log.info(() => ({ message: 'refreshing', file }));
|
|
148
|
-
const result = await
|
|
150
|
+
const result = await verboseFetch('https://models.dev/api.json', {
|
|
149
151
|
headers: {
|
|
150
152
|
'User-Agent': 'agent-cli/1.0.0',
|
|
151
153
|
},
|
package/src/provider/provider.ts
CHANGED
|
@@ -1207,8 +1207,22 @@ export namespace Provider {
|
|
|
1207
1207
|
// When verbose is disabled, the wrapper is a no-op passthrough with negligible overhead.
|
|
1208
1208
|
// See: https://github.com/link-assistant/agent/issues/200
|
|
1209
1209
|
// See: https://github.com/link-assistant/agent/issues/206
|
|
1210
|
+
// See: https://github.com/link-assistant/agent/issues/215
|
|
1210
1211
|
{
|
|
1211
1212
|
const innerFetch = options['fetch'];
|
|
1213
|
+
let verboseWrapperConfirmed = false;
|
|
1214
|
+
let httpCallCount = 0;
|
|
1215
|
+
|
|
1216
|
+
// Log at SDK creation time that the fetch wrapper is installed.
|
|
1217
|
+
// This runs once per provider SDK creation (not per request).
|
|
1218
|
+
// If verbose is off at creation time, the per-request check still applies.
|
|
1219
|
+
// See: https://github.com/link-assistant/agent/issues/215
|
|
1220
|
+
log.info('verbose HTTP fetch wrapper installed', {
|
|
1221
|
+
providerID: provider.id,
|
|
1222
|
+
pkg,
|
|
1223
|
+
verboseAtCreation: Flag.OPENCODE_VERBOSE,
|
|
1224
|
+
});
|
|
1225
|
+
|
|
1212
1226
|
options['fetch'] = async (
|
|
1213
1227
|
input: RequestInfo | URL,
|
|
1214
1228
|
init?: RequestInit
|
|
@@ -1218,6 +1232,24 @@ export namespace Provider {
|
|
|
1218
1232
|
return innerFetch(input, init);
|
|
1219
1233
|
}
|
|
1220
1234
|
|
|
1235
|
+
httpCallCount++;
|
|
1236
|
+
const callNum = httpCallCount;
|
|
1237
|
+
|
|
1238
|
+
// Log a one-time confirmation that the verbose wrapper is active for this provider.
|
|
1239
|
+
// This diagnostic breadcrumb confirms the wrapper is in the fetch chain.
|
|
1240
|
+
// Also write to stderr as a redundant channel — stdout JSON may be filtered by wrappers.
|
|
1241
|
+
// See: https://github.com/link-assistant/agent/issues/215
|
|
1242
|
+
if (!verboseWrapperConfirmed) {
|
|
1243
|
+
verboseWrapperConfirmed = true;
|
|
1244
|
+
log.info('verbose HTTP logging active', {
|
|
1245
|
+
providerID: provider.id,
|
|
1246
|
+
});
|
|
1247
|
+
// Redundant stderr confirmation — visible even if stdout is piped/filtered
|
|
1248
|
+
process.stderr.write(
|
|
1249
|
+
`[verbose] HTTP logging active for provider: ${provider.id}\n`
|
|
1250
|
+
);
|
|
1251
|
+
}
|
|
1252
|
+
|
|
1221
1253
|
const url =
|
|
1222
1254
|
typeof input === 'string'
|
|
1223
1255
|
? input
|
|
@@ -1226,50 +1258,64 @@ export namespace Provider {
|
|
|
1226
1258
|
: input.url;
|
|
1227
1259
|
const method = init?.method ?? 'GET';
|
|
1228
1260
|
|
|
1229
|
-
//
|
|
1230
|
-
|
|
1231
|
-
|
|
1232
|
-
|
|
1233
|
-
|
|
1234
|
-
|
|
1235
|
-
|
|
1236
|
-
|
|
1237
|
-
|
|
1238
|
-
|
|
1239
|
-
|
|
1240
|
-
|
|
1241
|
-
|
|
1242
|
-
|
|
1243
|
-
|
|
1244
|
-
|
|
1245
|
-
|
|
1246
|
-
|
|
1247
|
-
|
|
1248
|
-
|
|
1249
|
-
|
|
1250
|
-
|
|
1251
|
-
|
|
1261
|
+
// Wrap all verbose logging in try/catch so it never breaks the actual HTTP request.
|
|
1262
|
+
// If logging fails, the request must still proceed — logging is observability, not control flow.
|
|
1263
|
+
// See: https://github.com/link-assistant/agent/issues/215
|
|
1264
|
+
let sanitizedHeaders: Record<string, string> = {};
|
|
1265
|
+
let bodyPreview: string | undefined;
|
|
1266
|
+
try {
|
|
1267
|
+
// Sanitize headers - mask authorization values
|
|
1268
|
+
const rawHeaders = init?.headers;
|
|
1269
|
+
if (rawHeaders) {
|
|
1270
|
+
const entries =
|
|
1271
|
+
rawHeaders instanceof Headers
|
|
1272
|
+
? Array.from(rawHeaders.entries())
|
|
1273
|
+
: Array.isArray(rawHeaders)
|
|
1274
|
+
? rawHeaders
|
|
1275
|
+
: Object.entries(rawHeaders);
|
|
1276
|
+
for (const [key, value] of entries) {
|
|
1277
|
+
const lower = key.toLowerCase();
|
|
1278
|
+
if (
|
|
1279
|
+
lower === 'authorization' ||
|
|
1280
|
+
lower === 'x-api-key' ||
|
|
1281
|
+
lower === 'api-key'
|
|
1282
|
+
) {
|
|
1283
|
+
sanitizedHeaders[key] =
|
|
1284
|
+
typeof value === 'string' && value.length > 8
|
|
1285
|
+
? value.slice(0, 4) + '...' + value.slice(-4)
|
|
1286
|
+
: '[REDACTED]';
|
|
1287
|
+
} else {
|
|
1288
|
+
sanitizedHeaders[key] = String(value);
|
|
1289
|
+
}
|
|
1252
1290
|
}
|
|
1253
1291
|
}
|
|
1254
|
-
}
|
|
1255
1292
|
|
|
1256
|
-
|
|
1257
|
-
|
|
1258
|
-
|
|
1259
|
-
|
|
1260
|
-
|
|
1261
|
-
|
|
1262
|
-
|
|
1263
|
-
init.body
|
|
1264
|
-
|
|
1265
|
-
|
|
1266
|
-
|
|
1267
|
-
|
|
1268
|
-
|
|
1269
|
-
|
|
1270
|
-
|
|
1271
|
-
|
|
1293
|
+
// Log request body preview (truncated)
|
|
1294
|
+
if (init?.body) {
|
|
1295
|
+
const bodyStr =
|
|
1296
|
+
typeof init.body === 'string'
|
|
1297
|
+
? init.body
|
|
1298
|
+
: init.body instanceof ArrayBuffer ||
|
|
1299
|
+
init.body instanceof Uint8Array
|
|
1300
|
+
? `[binary ${(init.body as ArrayBuffer).byteLength ?? (init.body as Uint8Array).length} bytes]`
|
|
1301
|
+
: undefined;
|
|
1302
|
+
if (bodyStr && typeof bodyStr === 'string') {
|
|
1303
|
+
bodyPreview =
|
|
1304
|
+
bodyStr.length > 2000
|
|
1305
|
+
? bodyStr.slice(0, 2000) +
|
|
1306
|
+
`... [truncated, total ${bodyStr.length} chars]`
|
|
1307
|
+
: bodyStr;
|
|
1308
|
+
}
|
|
1272
1309
|
}
|
|
1310
|
+
} catch (prepError) {
|
|
1311
|
+
// If header/body processing fails, log the error but continue with the request
|
|
1312
|
+
log.warn('verbose logging: failed to prepare request details', {
|
|
1313
|
+
providerID: provider.id,
|
|
1314
|
+
error:
|
|
1315
|
+
prepError instanceof Error
|
|
1316
|
+
? prepError.message
|
|
1317
|
+
: String(prepError),
|
|
1318
|
+
});
|
|
1273
1319
|
}
|
|
1274
1320
|
|
|
1275
1321
|
// Use direct (non-lazy) logging for HTTP request/response to ensure output
|
|
@@ -1277,7 +1323,9 @@ export namespace Provider {
|
|
|
1277
1323
|
// The verbose check is already done above, so lazy evaluation is not needed here.
|
|
1278
1324
|
// See: https://github.com/link-assistant/agent/issues/211
|
|
1279
1325
|
log.info('HTTP request', {
|
|
1326
|
+
caller: `provider/${provider.id}`,
|
|
1280
1327
|
providerID: provider.id,
|
|
1328
|
+
callNum,
|
|
1281
1329
|
method,
|
|
1282
1330
|
url,
|
|
1283
1331
|
headers: sanitizedHeaders,
|
|
@@ -1286,13 +1334,21 @@ export namespace Provider {
|
|
|
1286
1334
|
|
|
1287
1335
|
const startMs = Date.now();
|
|
1288
1336
|
try {
|
|
1289
|
-
|
|
1337
|
+
// Pass Bun-specific verbose:true to get detailed connection debugging
|
|
1338
|
+
// (prints request/response headers to stderr on socket errors).
|
|
1339
|
+
// This is a no-op on non-Bun runtimes.
|
|
1340
|
+
// See: https://bun.sh/docs/api/fetch
|
|
1341
|
+
// See: https://github.com/link-assistant/agent/issues/215
|
|
1342
|
+
const verboseInit = { ...init, verbose: true } as RequestInit;
|
|
1343
|
+
const response = await innerFetch(input, verboseInit);
|
|
1290
1344
|
const durationMs = Date.now() - startMs;
|
|
1291
1345
|
|
|
1292
1346
|
// Use direct (non-lazy) logging to ensure HTTP response details are captured
|
|
1293
1347
|
// See: https://github.com/link-assistant/agent/issues/211
|
|
1294
1348
|
log.info('HTTP response', {
|
|
1349
|
+
caller: `provider/${provider.id}`,
|
|
1295
1350
|
providerID: provider.id,
|
|
1351
|
+
callNum,
|
|
1296
1352
|
method,
|
|
1297
1353
|
url,
|
|
1298
1354
|
status: response.status,
|
|
@@ -1342,7 +1398,9 @@ export namespace Provider {
|
|
|
1342
1398
|
// Use direct (non-lazy) logging for stream body
|
|
1343
1399
|
// See: https://github.com/link-assistant/agent/issues/211
|
|
1344
1400
|
log.info('HTTP response body (stream)', {
|
|
1401
|
+
caller: `provider/${provider.id}`,
|
|
1345
1402
|
providerID: provider.id,
|
|
1403
|
+
callNum,
|
|
1346
1404
|
url,
|
|
1347
1405
|
bodyPreview: truncated
|
|
1348
1406
|
? bodyPreview + `... [truncated]`
|
|
@@ -1370,7 +1428,9 @@ export namespace Provider {
|
|
|
1370
1428
|
// Use direct (non-lazy) logging for non-streaming body
|
|
1371
1429
|
// See: https://github.com/link-assistant/agent/issues/211
|
|
1372
1430
|
log.info('HTTP response body', {
|
|
1431
|
+
caller: `provider/${provider.id}`,
|
|
1373
1432
|
providerID: provider.id,
|
|
1433
|
+
callNum,
|
|
1374
1434
|
url,
|
|
1375
1435
|
bodyPreview,
|
|
1376
1436
|
});
|
|
@@ -1386,15 +1446,29 @@ export namespace Provider {
|
|
|
1386
1446
|
} catch (error) {
|
|
1387
1447
|
const durationMs = Date.now() - startMs;
|
|
1388
1448
|
// Use direct (non-lazy) logging for error path
|
|
1449
|
+
// Include stack trace and error cause for better debugging of socket errors
|
|
1389
1450
|
// See: https://github.com/link-assistant/agent/issues/211
|
|
1451
|
+
// See: https://github.com/link-assistant/agent/issues/215
|
|
1390
1452
|
log.error('HTTP request failed', {
|
|
1453
|
+
caller: `provider/${provider.id}`,
|
|
1391
1454
|
providerID: provider.id,
|
|
1455
|
+
callNum,
|
|
1392
1456
|
method,
|
|
1393
1457
|
url,
|
|
1394
1458
|
durationMs,
|
|
1395
1459
|
error:
|
|
1396
1460
|
error instanceof Error
|
|
1397
|
-
? {
|
|
1461
|
+
? {
|
|
1462
|
+
name: error.name,
|
|
1463
|
+
message: error.message,
|
|
1464
|
+
stack: error.stack,
|
|
1465
|
+
cause:
|
|
1466
|
+
error.cause instanceof Error
|
|
1467
|
+
? error.cause.message
|
|
1468
|
+
: error.cause
|
|
1469
|
+
? String(error.cause)
|
|
1470
|
+
: undefined,
|
|
1471
|
+
}
|
|
1398
1472
|
: String(error),
|
|
1399
1473
|
});
|
|
1400
1474
|
throw error;
|
package/src/tool/codesearch.ts
CHANGED
|
@@ -1,6 +1,9 @@
|
|
|
1
1
|
import z from 'zod';
|
|
2
2
|
import { Tool } from './tool';
|
|
3
3
|
import DESCRIPTION from './codesearch.txt';
|
|
4
|
+
import { createVerboseFetch } from '../util/verbose-fetch';
|
|
5
|
+
|
|
6
|
+
const verboseFetch = createVerboseFetch(fetch, { caller: 'codesearch' });
|
|
4
7
|
|
|
5
8
|
const API_CONFIG = {
|
|
6
9
|
BASE_URL: 'https://mcp.exa.ai',
|
|
@@ -73,7 +76,7 @@ export const CodeSearchTool = Tool.define('codesearch', {
|
|
|
73
76
|
'content-type': 'application/json',
|
|
74
77
|
};
|
|
75
78
|
|
|
76
|
-
const response = await
|
|
79
|
+
const response = await verboseFetch(
|
|
77
80
|
`${API_CONFIG.BASE_URL}${API_CONFIG.ENDPOINTS.CONTEXT}`,
|
|
78
81
|
{
|
|
79
82
|
method: 'POST',
|
package/src/tool/webfetch.ts
CHANGED
|
@@ -2,6 +2,9 @@ import z from 'zod';
|
|
|
2
2
|
import { Tool } from './tool';
|
|
3
3
|
import TurndownService from 'turndown';
|
|
4
4
|
import DESCRIPTION from './webfetch.txt';
|
|
5
|
+
import { createVerboseFetch } from '../util/verbose-fetch';
|
|
6
|
+
|
|
7
|
+
const verboseFetch = createVerboseFetch(fetch, { caller: 'webfetch' });
|
|
5
8
|
|
|
6
9
|
const MAX_RESPONSE_SIZE = 5 * 1024 * 1024; // 5MB
|
|
7
10
|
const DEFAULT_TIMEOUT = 30 * 1000; // 30 seconds
|
|
@@ -59,7 +62,7 @@ export const WebFetchTool = Tool.define('webfetch', {
|
|
|
59
62
|
'text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8';
|
|
60
63
|
}
|
|
61
64
|
|
|
62
|
-
const response = await
|
|
65
|
+
const response = await verboseFetch(params.url, {
|
|
63
66
|
signal: AbortSignal.any([controller.signal, ctx.abort]),
|
|
64
67
|
headers: {
|
|
65
68
|
'User-Agent':
|
package/src/tool/websearch.ts
CHANGED
|
@@ -2,6 +2,9 @@ import z from 'zod';
|
|
|
2
2
|
import { Tool } from './tool';
|
|
3
3
|
import DESCRIPTION from './websearch.txt';
|
|
4
4
|
import { Config } from '../config/config';
|
|
5
|
+
import { createVerboseFetch } from '../util/verbose-fetch';
|
|
6
|
+
|
|
7
|
+
const verboseFetch = createVerboseFetch(fetch, { caller: 'websearch' });
|
|
5
8
|
|
|
6
9
|
const API_CONFIG = {
|
|
7
10
|
BASE_URL: 'https://mcp.exa.ai',
|
|
@@ -91,7 +94,7 @@ export const WebSearchTool = Tool.define('websearch', {
|
|
|
91
94
|
'content-type': 'application/json',
|
|
92
95
|
};
|
|
93
96
|
|
|
94
|
-
const response = await
|
|
97
|
+
const response = await verboseFetch(
|
|
95
98
|
`${API_CONFIG.BASE_URL}${API_CONFIG.ENDPOINTS.SEARCH}`,
|
|
96
99
|
{
|
|
97
100
|
method: 'POST',
|
|
@@ -0,0 +1,303 @@
|
|
|
1
|
+
import { Log } from './log';
|
|
2
|
+
import { Flag } from '../flag/flag';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Shared verbose HTTP fetch wrapper.
|
|
6
|
+
*
|
|
7
|
+
* Intercepts fetch() calls and logs request/response details as JSON objects
|
|
8
|
+
* when --verbose mode is enabled. Used across the entire codebase (tools, auth,
|
|
9
|
+
* config, providers) to ensure uniform and predictable HTTP logging.
|
|
10
|
+
*
|
|
11
|
+
* Features:
|
|
12
|
+
* - Logs HTTP request: method, URL, sanitized headers, body preview
|
|
13
|
+
* - Logs HTTP response: status, headers, duration, body preview
|
|
14
|
+
* - Logs HTTP errors: stack trace, error cause chain
|
|
15
|
+
* - Sequential call numbering for correlation
|
|
16
|
+
* - Error-resilient: logging failures never break the actual HTTP request
|
|
17
|
+
* - Runtime verbose check: respects Flag.OPENCODE_VERBOSE at call time
|
|
18
|
+
*
|
|
19
|
+
* @see https://github.com/link-assistant/agent/issues/215
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
const log = Log.create({ service: 'http' });
|
|
23
|
+
|
|
24
|
+
/** Global call counter shared across all verbose fetch wrappers */
|
|
25
|
+
let globalHttpCallCount = 0;
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Sanitize HTTP headers by masking sensitive values.
|
|
29
|
+
* Masks authorization, x-api-key, and api-key headers.
|
|
30
|
+
*/
|
|
31
|
+
export function sanitizeHeaders(
|
|
32
|
+
rawHeaders: HeadersInit | Record<string, string> | Headers | undefined
|
|
33
|
+
): Record<string, string> {
|
|
34
|
+
const sanitized: Record<string, string> = {};
|
|
35
|
+
if (!rawHeaders) return sanitized;
|
|
36
|
+
|
|
37
|
+
const entries =
|
|
38
|
+
rawHeaders instanceof Headers
|
|
39
|
+
? Array.from(rawHeaders.entries())
|
|
40
|
+
: Array.isArray(rawHeaders)
|
|
41
|
+
? rawHeaders
|
|
42
|
+
: Object.entries(rawHeaders);
|
|
43
|
+
|
|
44
|
+
for (const [key, value] of entries) {
|
|
45
|
+
const lower = key.toLowerCase();
|
|
46
|
+
if (
|
|
47
|
+
lower === 'authorization' ||
|
|
48
|
+
lower === 'x-api-key' ||
|
|
49
|
+
lower === 'api-key'
|
|
50
|
+
) {
|
|
51
|
+
sanitized[key] =
|
|
52
|
+
typeof value === 'string' && value.length > 8
|
|
53
|
+
? value.slice(0, 4) + '...' + value.slice(-4)
|
|
54
|
+
: '[REDACTED]';
|
|
55
|
+
} else {
|
|
56
|
+
sanitized[key] = String(value);
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
return sanitized;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Create a body preview string, truncated to maxChars.
|
|
64
|
+
*/
|
|
65
|
+
export function bodyPreview(
|
|
66
|
+
body: BodyInit | null | undefined,
|
|
67
|
+
maxChars = 2000
|
|
68
|
+
): string | undefined {
|
|
69
|
+
if (!body) return undefined;
|
|
70
|
+
|
|
71
|
+
const bodyStr =
|
|
72
|
+
typeof body === 'string'
|
|
73
|
+
? body
|
|
74
|
+
: body instanceof ArrayBuffer || body instanceof Uint8Array
|
|
75
|
+
? `[binary ${(body as ArrayBuffer).byteLength ?? (body as Uint8Array).length} bytes]`
|
|
76
|
+
: body instanceof URLSearchParams
|
|
77
|
+
? body.toString()
|
|
78
|
+
: undefined;
|
|
79
|
+
|
|
80
|
+
if (bodyStr && typeof bodyStr === 'string') {
|
|
81
|
+
return bodyStr.length > maxChars
|
|
82
|
+
? bodyStr.slice(0, maxChars) +
|
|
83
|
+
`... [truncated, total ${bodyStr.length} chars]`
|
|
84
|
+
: bodyStr;
|
|
85
|
+
}
|
|
86
|
+
return undefined;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
export interface VerboseFetchOptions {
|
|
90
|
+
/** Identifier for the caller (e.g. 'webfetch', 'auth-plugins', 'config') */
|
|
91
|
+
caller: string;
|
|
92
|
+
/** Maximum chars for response body preview (default: 4000) */
|
|
93
|
+
responseBodyMaxChars?: number;
|
|
94
|
+
/** Maximum chars for request body preview (default: 2000) */
|
|
95
|
+
requestBodyMaxChars?: number;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* Wrap a fetch function with verbose HTTP logging.
|
|
100
|
+
*
|
|
101
|
+
* When Flag.OPENCODE_VERBOSE is true, logs all HTTP requests and responses
|
|
102
|
+
* as JSON objects. When verbose is false, returns a no-op passthrough.
|
|
103
|
+
*
|
|
104
|
+
* All logging is wrapped in try/catch so it never breaks the actual HTTP request.
|
|
105
|
+
*
|
|
106
|
+
* @param innerFetch - The fetch function to wrap (defaults to global fetch)
|
|
107
|
+
* @param options - Configuration for the wrapper
|
|
108
|
+
* @returns A wrapped fetch function with verbose logging
|
|
109
|
+
*/
|
|
110
|
+
export function createVerboseFetch(
|
|
111
|
+
innerFetch: typeof fetch = fetch,
|
|
112
|
+
options: VerboseFetchOptions
|
|
113
|
+
): typeof fetch {
|
|
114
|
+
const {
|
|
115
|
+
caller,
|
|
116
|
+
responseBodyMaxChars = 4000,
|
|
117
|
+
requestBodyMaxChars = 2000,
|
|
118
|
+
} = options;
|
|
119
|
+
|
|
120
|
+
return async (
|
|
121
|
+
input: RequestInfo | URL,
|
|
122
|
+
init?: RequestInit
|
|
123
|
+
): Promise<Response> => {
|
|
124
|
+
// Check verbose flag at call time
|
|
125
|
+
if (!Flag.OPENCODE_VERBOSE) {
|
|
126
|
+
return innerFetch(input, init);
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
globalHttpCallCount++;
|
|
130
|
+
const callNum = globalHttpCallCount;
|
|
131
|
+
|
|
132
|
+
const url =
|
|
133
|
+
typeof input === 'string'
|
|
134
|
+
? input
|
|
135
|
+
: input instanceof URL
|
|
136
|
+
? input.toString()
|
|
137
|
+
: input.url;
|
|
138
|
+
const method = init?.method ?? 'GET';
|
|
139
|
+
|
|
140
|
+
// Prepare request details for logging (error-resilient)
|
|
141
|
+
let sanitizedHdrs: Record<string, string> = {};
|
|
142
|
+
let reqBodyPreview: string | undefined;
|
|
143
|
+
try {
|
|
144
|
+
sanitizedHdrs = sanitizeHeaders(init?.headers as HeadersInit | undefined);
|
|
145
|
+
reqBodyPreview = bodyPreview(init?.body, requestBodyMaxChars);
|
|
146
|
+
} catch (prepError) {
|
|
147
|
+
log.warn('verbose logging: failed to prepare request details', {
|
|
148
|
+
caller,
|
|
149
|
+
error:
|
|
150
|
+
prepError instanceof Error ? prepError.message : String(prepError),
|
|
151
|
+
});
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
// Log request
|
|
155
|
+
log.info('HTTP request', {
|
|
156
|
+
caller,
|
|
157
|
+
callNum,
|
|
158
|
+
method,
|
|
159
|
+
url,
|
|
160
|
+
headers: sanitizedHdrs,
|
|
161
|
+
bodyPreview: reqBodyPreview,
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
const startMs = Date.now();
|
|
165
|
+
try {
|
|
166
|
+
const response = await innerFetch(input, init);
|
|
167
|
+
const durationMs = Date.now() - startMs;
|
|
168
|
+
|
|
169
|
+
// Log response
|
|
170
|
+
try {
|
|
171
|
+
log.info('HTTP response', {
|
|
172
|
+
caller,
|
|
173
|
+
callNum,
|
|
174
|
+
method,
|
|
175
|
+
url,
|
|
176
|
+
status: response.status,
|
|
177
|
+
statusText: response.statusText,
|
|
178
|
+
durationMs,
|
|
179
|
+
responseHeaders: Object.fromEntries(response.headers.entries()),
|
|
180
|
+
});
|
|
181
|
+
} catch {
|
|
182
|
+
// Ignore logging errors
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
// Log response body
|
|
186
|
+
const contentType = response.headers.get('content-type') ?? '';
|
|
187
|
+
const isStreaming =
|
|
188
|
+
contentType.includes('event-stream') ||
|
|
189
|
+
contentType.includes('octet-stream');
|
|
190
|
+
|
|
191
|
+
if (response.body) {
|
|
192
|
+
try {
|
|
193
|
+
if (isStreaming) {
|
|
194
|
+
const [sdkStream, logStream] = response.body.tee();
|
|
195
|
+
|
|
196
|
+
// Consume log stream asynchronously
|
|
197
|
+
(async () => {
|
|
198
|
+
try {
|
|
199
|
+
const reader = logStream.getReader();
|
|
200
|
+
const decoder = new TextDecoder();
|
|
201
|
+
let preview = '';
|
|
202
|
+
let truncated = false;
|
|
203
|
+
while (true) {
|
|
204
|
+
const { done, value } = await reader.read();
|
|
205
|
+
if (done) break;
|
|
206
|
+
if (!truncated) {
|
|
207
|
+
const chunk = decoder.decode(value, { stream: true });
|
|
208
|
+
preview += chunk;
|
|
209
|
+
if (preview.length > responseBodyMaxChars) {
|
|
210
|
+
preview = preview.slice(0, responseBodyMaxChars);
|
|
211
|
+
truncated = true;
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
log.info('HTTP response body (stream)', {
|
|
216
|
+
caller,
|
|
217
|
+
callNum,
|
|
218
|
+
url,
|
|
219
|
+
bodyPreview: truncated
|
|
220
|
+
? preview + `... [truncated]`
|
|
221
|
+
: preview,
|
|
222
|
+
});
|
|
223
|
+
} catch {
|
|
224
|
+
// Ignore logging errors
|
|
225
|
+
}
|
|
226
|
+
})();
|
|
227
|
+
|
|
228
|
+
return new Response(sdkStream, {
|
|
229
|
+
status: response.status,
|
|
230
|
+
statusText: response.statusText,
|
|
231
|
+
headers: response.headers,
|
|
232
|
+
});
|
|
233
|
+
} else {
|
|
234
|
+
const bodyText = await response.text();
|
|
235
|
+
const preview =
|
|
236
|
+
bodyText.length > responseBodyMaxChars
|
|
237
|
+
? bodyText.slice(0, responseBodyMaxChars) +
|
|
238
|
+
`... [truncated, total ${bodyText.length} chars]`
|
|
239
|
+
: bodyText;
|
|
240
|
+
log.info('HTTP response body', {
|
|
241
|
+
caller,
|
|
242
|
+
callNum,
|
|
243
|
+
url,
|
|
244
|
+
bodyPreview: preview,
|
|
245
|
+
});
|
|
246
|
+
return new Response(bodyText, {
|
|
247
|
+
status: response.status,
|
|
248
|
+
statusText: response.statusText,
|
|
249
|
+
headers: response.headers,
|
|
250
|
+
});
|
|
251
|
+
}
|
|
252
|
+
} catch {
|
|
253
|
+
// If body logging fails, return original response
|
|
254
|
+
return response;
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
return response;
|
|
259
|
+
} catch (error) {
|
|
260
|
+
const durationMs = Date.now() - startMs;
|
|
261
|
+
try {
|
|
262
|
+
log.error('HTTP request failed', {
|
|
263
|
+
caller,
|
|
264
|
+
callNum,
|
|
265
|
+
method,
|
|
266
|
+
url,
|
|
267
|
+
durationMs,
|
|
268
|
+
error:
|
|
269
|
+
error instanceof Error
|
|
270
|
+
? {
|
|
271
|
+
name: error.name,
|
|
272
|
+
message: error.message,
|
|
273
|
+
stack: error.stack,
|
|
274
|
+
cause:
|
|
275
|
+
error.cause instanceof Error
|
|
276
|
+
? error.cause.message
|
|
277
|
+
: error.cause
|
|
278
|
+
? String(error.cause)
|
|
279
|
+
: undefined,
|
|
280
|
+
}
|
|
281
|
+
: String(error),
|
|
282
|
+
});
|
|
283
|
+
} catch {
|
|
284
|
+
// Ignore logging errors
|
|
285
|
+
}
|
|
286
|
+
throw error;
|
|
287
|
+
}
|
|
288
|
+
};
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
/**
|
|
292
|
+
* Get the current global HTTP call count (for testing).
|
|
293
|
+
*/
|
|
294
|
+
export function getHttpCallCount(): number {
|
|
295
|
+
return globalHttpCallCount;
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
/**
|
|
299
|
+
* Reset the global HTTP call count (for testing).
|
|
300
|
+
*/
|
|
301
|
+
export function resetHttpCallCount(): void {
|
|
302
|
+
globalHttpCallCount = 0;
|
|
303
|
+
}
|