@buyong/pi-devin 0.1.5 → 0.1.6

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.
Files changed (117) hide show
  1. package/README.ko.md +3 -5
  2. package/README.md +3 -5
  3. package/index.ts +6 -15
  4. package/package.json +3 -1
  5. package/src/core/auth/access.ts +7 -3
  6. package/src/core/auth/callback-server.ts +57 -0
  7. package/src/core/auth/index.ts +2 -1
  8. package/src/core/auth/login.ts +104 -151
  9. package/src/core/auth/pkce.ts +32 -0
  10. package/src/core/auth/rest-exchange.ts +32 -30
  11. package/src/core/cancellation.ts +99 -0
  12. package/src/core/client.ts +127 -122
  13. package/src/core/errors.ts +115 -0
  14. package/src/core/index.ts +18 -4
  15. package/src/core/inference/accumulator.ts +174 -0
  16. package/src/core/inference/images.ts +22 -15
  17. package/src/core/inference/request.ts +59 -0
  18. package/src/core/inference/retry.ts +31 -31
  19. package/src/core/inference/state.ts +7 -3
  20. package/src/core/inference/stream-chat.ts +48 -0
  21. package/src/core/models/access.ts +4 -3
  22. package/src/core/models/catalog.ts +29 -20
  23. package/src/core/models/select.ts +14 -12
  24. package/src/core/models/variants.ts +85 -72
  25. package/src/core/protocol/connect-client.ts +288 -0
  26. package/src/core/protocol/device.ts +33 -34
  27. package/src/core/protocol/endpoint.ts +22 -0
  28. package/src/core/protocol/fingerprint.ts +38 -26
  29. package/src/core/protocol/frames.ts +101 -0
  30. package/src/core/protocol/http/fetch-client.ts +42 -0
  31. package/src/core/protocol/http/node-client.ts +130 -0
  32. package/src/core/protocol/http/response.ts +99 -0
  33. package/src/core/protocol/http.ts +41 -101
  34. package/src/core/protocol/protobuf.ts +93 -74
  35. package/src/core/redaction.ts +33 -0
  36. package/src/core/services/device-identity.ts +15 -0
  37. package/src/core/services/http-transport.ts +28 -0
  38. package/src/core/services/platform.ts +54 -0
  39. package/src/core/types.ts +2 -23
  40. package/src/core/validation.ts +25 -0
  41. package/src/pi/adapters/pi/devin-command.ts +82 -0
  42. package/src/pi/adapters/pi/fusion-command.ts +92 -0
  43. package/src/pi/adapters/pi/oauth.ts +14 -0
  44. package/src/pi/adapters/pi/provider.ts +22 -0
  45. package/src/pi/adapters/pi/registration.ts +89 -0
  46. package/src/pi/adapters/pi/response-stream.ts +164 -0
  47. package/src/pi/adapters/pi/sidekick-tool.ts +45 -0
  48. package/src/pi/adapters/pi/transcript.ts +177 -0
  49. package/src/pi/adapters/pi/types.ts +14 -0
  50. package/src/pi/{devin-panel.ts → adapters/pi/ui/devin-panel.ts} +11 -9
  51. package/src/pi/{fusion-picker.ts → adapters/pi/ui/fusion-picker.ts} +1 -1
  52. package/src/pi/adapters/pi/usage-status.ts +65 -0
  53. package/src/pi/adapters/storage/files.ts +66 -0
  54. package/src/pi/adapters/storage/json-file.ts +31 -0
  55. package/src/pi/app/config.ts +25 -0
  56. package/src/pi/app/environment.ts +30 -0
  57. package/src/pi/app/layer.ts +43 -0
  58. package/src/pi/app/runtime.ts +20 -0
  59. package/src/pi/app/session.ts +26 -0
  60. package/src/pi/features/accounts/service.ts +69 -0
  61. package/src/pi/features/accounts/types.ts +25 -0
  62. package/src/pi/features/auth/credential.ts +40 -0
  63. package/src/pi/features/auth/guard.ts +25 -0
  64. package/src/pi/features/auth/login.ts +116 -0
  65. package/src/pi/features/auth/state.ts +48 -0
  66. package/src/pi/features/auth/types.ts +36 -0
  67. package/src/pi/features/bounded.ts +4 -0
  68. package/src/pi/features/catalog/constants.ts +10 -0
  69. package/src/pi/features/catalog/fusion.ts +45 -0
  70. package/src/pi/features/catalog/ids.ts +79 -0
  71. package/src/pi/features/catalog/projection.ts +93 -0
  72. package/src/pi/features/catalog/remote.ts +15 -0
  73. package/src/pi/features/catalog/restore.ts +53 -0
  74. package/src/pi/features/catalog/service.ts +158 -0
  75. package/src/pi/features/catalog/types.ts +56 -0
  76. package/src/pi/features/clients/service.ts +47 -0
  77. package/src/pi/features/clients/types.ts +22 -0
  78. package/src/pi/features/default-scheduler.ts +12 -0
  79. package/src/pi/features/inference/request.ts +190 -0
  80. package/src/pi/features/inference/service.ts +33 -0
  81. package/src/pi/features/inference/types.ts +11 -0
  82. package/src/pi/features/lifetime/service.ts +19 -0
  83. package/src/pi/features/lifetime/types.ts +13 -0
  84. package/src/pi/features/runs/admission.ts +36 -0
  85. package/src/pi/features/runs/lifecycle.ts +207 -0
  86. package/src/pi/features/runs/service.ts +10 -0
  87. package/src/pi/features/runs/state.ts +37 -0
  88. package/src/pi/features/runs/types.ts +43 -0
  89. package/src/pi/features/settings/service.ts +46 -0
  90. package/src/pi/features/settings/types.ts +36 -0
  91. package/src/pi/features/sidekick/child.ts +228 -0
  92. package/src/pi/features/sidekick/command.ts +80 -0
  93. package/src/pi/features/sidekick/launcher.ts +12 -0
  94. package/src/pi/features/sidekick/prompts.ts +17 -0
  95. package/src/pi/features/sidekick/report.ts +51 -0
  96. package/src/pi/features/sidekick/service.ts +176 -0
  97. package/src/pi/features/sidekick/types.ts +99 -0
  98. package/src/pi/features/usage/changes.ts +20 -0
  99. package/src/pi/{usage.ts → features/usage/report.ts} +3 -34
  100. package/src/pi/features/usage/types.ts +33 -0
  101. package/src/core/inference/chat.ts +0 -234
  102. package/src/core/protocol/transport.ts +0 -513
  103. package/src/pi/auth.ts +0 -166
  104. package/src/pi/devin-command.ts +0 -86
  105. package/src/pi/devin-settings.ts +0 -50
  106. package/src/pi/extension.ts +0 -89
  107. package/src/pi/fusion-command.ts +0 -83
  108. package/src/pi/fusion-settings.ts +0 -44
  109. package/src/pi/lifecycle.ts +0 -217
  110. package/src/pi/messages.ts +0 -131
  111. package/src/pi/models.ts +0 -415
  112. package/src/pi/provider.ts +0 -278
  113. package/src/pi/sidekick-report.ts +0 -47
  114. package/src/pi/sidekick-runtime.ts +0 -300
  115. package/src/pi/sidekick.ts +0 -211
  116. package/src/pi/stream.ts +0 -124
  117. package/src/pi/usage-store.ts +0 -75
@@ -1,513 +0,0 @@
1
- import { randomBytes } from "node:crypto";
2
- import { gzipSync, gunzipSync } from "node:zlib";
3
- import { createHTTPTransport, type HTTPTransport, type HTTPTransportOptions } from "./http.ts";
4
- import { CHAT_METHOD, chatRequestCodec, chatResponseCodec, unaryCodecs } from "./protobuf.ts";
5
- import { object, type JsonObject } from "../types.ts";
6
-
7
- export const MAX_FRAME_BYTES = 8 * 1024 * 1024;
8
- const MAX_RESPONSE_BYTES = 64 * 1024 * 1024;
9
- // The CLI compresses frames only when they are at least 8192 bytes.
10
- export const DEFAULT_COMPRESSION_THRESHOLD_BYTES = 8 * 1024;
11
- export const ASSIGN_MODEL_METHOD = "exa.api_server_pb.ApiServerService/AssignModel";
12
- type Encoding = "proto" | "json" | "stream";
13
-
14
- export function safeError(error: unknown, secrets: readonly string[] = []): string {
15
- let text = error instanceof Error ? error.message : String(error);
16
- for (const secret of secrets) if (secret) text = text.split(secret).join("[REDACTED]");
17
- return text.replace(/devin-session-token\$[^\s"'<>]+/g, "[REDACTED]")
18
- .replace(/[\u0000-\u0008\u000b-\u001f\u007f]/g, "").slice(0, 800);
19
- }
20
-
21
- export function requestSecrets(body: JsonObject, token?: string): string[] {
22
- const metadata = body.metadata && typeof body.metadata === "object" && !Array.isArray(body.metadata)
23
- ? body.metadata as JsonObject : {};
24
- // f is ciphertext that contains the API token. Treat it as sensitive as the token itself.
25
- const uppercaseFingerprint = typeof metadata.f === "string" ? metadata.f.toUpperCase() : undefined;
26
- return [token, metadata.apiKey, metadata.f, uppercaseFingerprint, body.apiKey, body.modelAssignmentJwt, body.code, body.authorizationCode, body.codeVerifier]
27
- .filter((value): value is string => typeof value === "string" && value.length > 0);
28
- }
29
-
30
- export type RPCFailureKind = "authentication" | "permission" | "rate_limit" | "timeout" | "rpc";
31
-
32
- export class RPCError extends Error {
33
- code: string;
34
- httpStatus?: number;
35
- retryAfterMs?: number;
36
- constructor(code: string, message: string, httpStatus?: number, retryAfterMs?: number) {
37
- super(`${code}: ${message}`);
38
- this.name = "RPCError";
39
- this.code = code;
40
- this.httpStatus = httpStatus;
41
- this.retryAfterMs = retryAfterMs;
42
- if (this.kind === "authentication") {
43
- this.message = this.message.slice(0, 600) + " The authentication credential is not valid. It may have expired or been revoked; sign in again with login.";
44
- }
45
- }
46
- get kind(): RPCFailureKind {
47
- // An error body that contradicts the HTTP status does not invalidate authentication.
48
- if (this.httpStatus !== undefined && this.httpStatus >= 400) {
49
- if (this.httpStatus === 401) return "authentication";
50
- if (this.httpStatus === 403) return "permission";
51
- if (this.httpStatus === 429) return "rate_limit";
52
- if (this.httpStatus === 408 || this.httpStatus === 504) return "timeout";
53
- return "rpc";
54
- }
55
- if (this.code === "unauthenticated") return "authentication";
56
- if (this.code === "permission_denied") return "permission";
57
- if (this.code === "resource_exhausted") return "rate_limit";
58
- if (this.code === "deadline_exceeded") return "timeout";
59
- return "rpc";
60
- }
61
- }
62
-
63
- export function isAuthenticationError(error: unknown): error is RPCError {
64
- return error instanceof RPCError && error.kind === "authentication";
65
- }
66
-
67
- export function failureDetails(error: unknown): JsonObject {
68
- if (error instanceof RPCError) return {
69
- category: error.kind, code: error.code,
70
- ...(error.httpStatus === undefined ? {} : { httpStatus: error.httpStatus }),
71
- ...(error.retryAfterMs === undefined ? {} : { retryAfterMs: error.retryAfterMs }),
72
- ...(isAuthenticationError(error) ? { requiresLogin: true } : {}),
73
- };
74
- if (error instanceof Error && error.name === "TimeoutError") return { category: "timeout", code: "deadline_exceeded" };
75
- if (error instanceof Error && error.name === "AbortError") return { category: "cancelled" };
76
- if (error instanceof Error && "code" in error && typeof error.code === "string" &&
77
- /^(ECONN|ENET|EHOST|EPIPE$|ENOTFOUND$|EAI_|ETIMEDOUT$|ERR_TLS_|CERT_|DEPTH_ZERO_SELF_SIGNED_CERT$|SELF_SIGNED_CERT_IN_CHAIN$|UNABLE_TO_)/.test(error.code)) {
78
- return { category: "network", code: error.code };
79
- }
80
- return { category: "error" };
81
- }
82
-
83
- const TRANSIENT_CONNECTION_CODES = /^(ECONNREFUSED|ECONNRESET|ECONNABORTED|EPIPE|ETIMEDOUT|EHOSTUNREACH|ENETUNREACH|ENETDOWN|ENOTFOUND|EAI_AGAIN|ERR_STREAM_PREMATURE_CLOSE)$/;
84
-
85
- /** The request got no usable answer from a working server, so sending it again may succeed. TLS and certificate failures are not included. */
86
- export function isTransientConnectionFailure(error: unknown): boolean {
87
- if (error instanceof RPCError) return error.code === "unavailable" || [502, 503, 504].includes(error.httpStatus ?? 0);
88
- return error instanceof Error && "code" in error && typeof error.code === "string" && TRANSIENT_CONNECTION_CODES.test(error.code);
89
- }
90
-
91
- function prematureClose(message: string): Error {
92
- return Object.assign(new Error(message), { code: "ERR_STREAM_PREMATURE_CLOSE" });
93
- }
94
-
95
- function retryAfterMs(headers: Headers): number | undefined {
96
- const value = headers.get("retry-after")?.trim();
97
- if (!value) return undefined;
98
- const milliseconds = /^\d+$/.test(value) ? Number(value) * 1000
99
- : /^(Mon|Tue|Wed|Thu|Fri|Sat|Sun), /.test(value) ? Date.parse(value) - Date.now() : NaN;
100
- if (!Number.isFinite(milliseconds) || !Number.isSafeInteger(milliseconds)) return undefined;
101
- return Math.max(0, milliseconds);
102
- }
103
-
104
- export function discardResponse(response: Response): void {
105
- void response.body?.cancel().catch(() => {});
106
- }
107
-
108
- /** Stops waiting even if an injected transport ignores the signal, and discards responses that arrive late. */
109
- export function awaitWithSignal<T>(promise: Promise<T>, signal: AbortSignal, onAbandoned?: (value: T) => void): Promise<T> {
110
- return new Promise<T>((resolve, reject) => {
111
- let isSettled = false;
112
- const abort = () => {
113
- if (isSettled) return;
114
- isSettled = true;
115
- signal.removeEventListener("abort", abort);
116
- reject(signal.reason);
117
- };
118
- signal.addEventListener("abort", abort, { once: true });
119
- promise.then((value) => {
120
- if (isSettled) { try { onAbandoned?.(value); } catch { /* cleanup of an already cancelled response */ } return; }
121
- isSettled = true;
122
- signal.removeEventListener("abort", abort);
123
- resolve(value);
124
- }, (error) => {
125
- if (isSettled) return;
126
- isSettled = true;
127
- signal.removeEventListener("abort", abort);
128
- reject(error);
129
- });
130
- if (signal.aborted) abort();
131
- });
132
- }
133
-
134
- export function endpoint(value: string): string {
135
- const url = new URL(value);
136
- const isLoopback = ["127.0.0.1", "[::1]", "localhost"].includes(url.hostname);
137
- if (url.protocol !== "https:" && !(url.protocol === "http:" && isLoopback)) {
138
- throw new Error("The endpoint must be HTTPS, or loopback HTTP for local verification.");
139
- }
140
- if (url.username || url.password || url.search || url.hash || url.pathname !== "/") {
141
- throw new Error("The endpoint must not contain credentials, a path, a query, or a fragment.");
142
- }
143
- return url.origin;
144
- }
145
-
146
- export function encodeFrame(bytes: Uint8Array, flags = 0): Buffer {
147
- const payload = Buffer.from(bytes);
148
- if (payload.length > MAX_FRAME_BYTES) throw new Error("The request frame exceeds 8 MiB.");
149
- const header = Buffer.alloc(5);
150
- header[0] = flags;
151
- header.writeUInt32BE(payload.length, 1);
152
- return Buffer.concat([header, payload]);
153
- }
154
-
155
- export interface Frame {
156
- isEnd: boolean;
157
- data: JsonObject;
158
- }
159
-
160
- /** HTTP chunk boundaries and Connect frame boundaries are independent. */
161
- export async function* decodeFrames(
162
- chunks: AsyncIterable<Uint8Array>,
163
- options: { compression?: "identity" | "gzip" } = {},
164
- ): AsyncGenerator<Frame> {
165
- let buffered: Buffer = Buffer.alloc(0);
166
- let hasEnded = false;
167
- let wireBytes = 0;
168
- let decodedBytes = 0;
169
- const decoder = new TextDecoder("utf-8", { fatal: true });
170
- for await (const chunk of chunks) {
171
- wireBytes += chunk.byteLength;
172
- if (wireBytes > MAX_RESPONSE_BYTES) throw new Error("The response exceeds 64 MiB.");
173
- if (hasEnded && chunk.byteLength > 0) throw new Error("Data follows EndStream.");
174
- buffered = Buffer.concat([buffered, chunk]);
175
- while (buffered.length >= 5) {
176
- if (hasEnded) throw new Error("A frame follows EndStream.");
177
- const flags = buffered[0];
178
- if ((flags & ~3) !== 0) throw new Error(`Unknown Connect flags: ${flags}`);
179
- const payloadBytes = buffered.readUInt32BE(1);
180
- if (payloadBytes > MAX_FRAME_BYTES) throw new Error("The response frame exceeds 8 MiB.");
181
- if (buffered.length < 5 + payloadBytes) break;
182
- let payload: Buffer = buffered.subarray(5, 5 + payloadBytes);
183
- buffered = buffered.subarray(5 + payloadBytes);
184
- if (flags & 1) {
185
- if (options.compression !== "gzip") throw new Error("A compressed frame has no valid gzip encoding header.");
186
- try { payload = gunzipSync(payload, { maxOutputLength: MAX_FRAME_BYTES }); }
187
- catch { throw new Error("The gzip frame is corrupt or exceeds the decompression limit."); }
188
- }
189
- decodedBytes += payload.byteLength;
190
- if (decodedBytes > MAX_RESPONSE_BYTES) throw new Error("The decompressed response exceeds 64 MiB.");
191
- hasEnded = Boolean(flags & 2);
192
- let data: JsonObject;
193
- if (hasEnded) {
194
- try { data = object(JSON.parse(decoder.decode(payload)), "EndStream"); }
195
- catch { throw new Error("EndStream is not a valid UTF-8 JSON object."); }
196
- } else data = chatResponseCodec.decode(payload);
197
- yield { isEnd: hasEnded, data };
198
- }
199
- }
200
- if (buffered.length !== 0) throw prematureClose("The connection ended with an incomplete Connect frame.");
201
- if (!hasEnded) throw prematureClose("The connection ended without EndStream.");
202
- }
203
-
204
- async function* responseChunks(response: Response, signal: AbortSignal): AsyncGenerator<Uint8Array> {
205
- if (!response.body) throw new Error("The response has no body.");
206
- const reader = response.body.getReader();
207
- let isComplete = false;
208
- try {
209
- while (true) {
210
- signal.throwIfAborted();
211
- const item = await awaitWithSignal(reader.read(), signal);
212
- if (item.done) { isComplete = true; return; }
213
- yield item.value;
214
- }
215
- } finally {
216
- try {
217
- if (!isComplete) {
218
- const cancelled = reader.cancel().catch(() => {});
219
- // Do not wait past the deadline even if the injected stream's cancel never settles.
220
- if (!signal.aborted) await awaitWithSignal(cancelled, signal);
221
- }
222
- } finally { reader.releaseLock(); }
223
- }
224
- }
225
-
226
- async function boundedBytes(response: Response, maxBytes: number, signal: AbortSignal): Promise<Buffer> {
227
- const chunks: Uint8Array[] = [];
228
- let bytes = 0;
229
- for await (const chunk of responseChunks(response, signal)) {
230
- bytes += chunk.byteLength;
231
- if (bytes > maxBytes) throw new Error("The response size limit was exceeded.");
232
- chunks.push(chunk);
233
- }
234
- return Buffer.concat(chunks);
235
- }
236
-
237
- export async function boundedJSON(response: Response, maxBytes: number, signal: AbortSignal): Promise<unknown> {
238
- const bytes = await boundedBytes(response, maxBytes, signal);
239
- try { return JSON.parse(new TextDecoder("utf-8", { fatal: true }).decode(bytes)); }
240
- catch { throw new Error("The server did not return valid JSON."); }
241
- }
242
-
243
- export interface RequestAttempt {
244
- method: "POST" | "GET";
245
- path: string;
246
- attempt: number;
247
- isMetered: boolean;
248
- isCompressed: boolean;
249
- stage?: string;
250
- modelUid?: string;
251
- }
252
-
253
- export interface StreamRequestContext {
254
- stage?: string;
255
- onDispatched?: () => void;
256
- headers?: Record<string, string | null>;
257
- onPayload?: (payload: JsonObject, signal: AbortSignal) => JsonObject | undefined | Promise<JsonObject | undefined>;
258
- onResponse?: (response: { status: number; headers: Record<string, string> }, signal: AbortSignal) => void | Promise<void>;
259
- /** Observes each parsed frame before normalization and error classification. */
260
- onFrame?: (frame: Frame, signal: AbortSignal) => void | Promise<void>;
261
- }
262
-
263
- function requestHeaders(defaults: Record<string, string>, overrides: StreamRequestContext["headers"]): Record<string, string> {
264
- const headers = new Headers(defaults);
265
- for (const [name, value] of Object.entries(overrides ?? {})) {
266
- if (["host", "content-length"].includes(name.toLowerCase()) && value !== headers.get(name)) {
267
- throw new Error("Host and Content-Length are managed by the transport from the actual destination and body.");
268
- }
269
- if (value === null) headers.delete(name); else headers.set(name, value);
270
- }
271
- return Object.fromEntries(headers.entries());
272
- }
273
-
274
- export interface ConnectClientOptions {
275
- baseURL: string;
276
- token?: string;
277
- timeoutMs?: number;
278
- fetch?: typeof fetch;
279
- http?: HTTPTransportOptions;
280
- compressionThresholdBytes?: number;
281
- trace?: () => string;
282
- onRequest?: (attempt: RequestAttempt) => void;
283
- }
284
-
285
- interface RequestScope {
286
- signal: AbortSignal;
287
- abort(reason: unknown): void;
288
- dispose(): void;
289
- }
290
-
291
- export class ConnectClient {
292
- readonly baseURL: string;
293
- readonly token?: string;
294
- readonly timeoutMs: number;
295
- readonly fetchImpl: typeof fetch;
296
- readonly compressionThresholdBytes: number;
297
- readonly trace: () => string;
298
- readonly onRequest?: ConnectClientOptions["onRequest"];
299
- private readonly httpTransport?: HTTPTransport;
300
- private readonly lifetime = new AbortController();
301
- private authenticationError?: RPCError;
302
- private isClosed = false;
303
-
304
- constructor(options: ConnectClientOptions) {
305
- this.baseURL = endpoint(options.baseURL);
306
- this.token = options.token;
307
- this.timeoutMs = options.timeoutMs ?? 90_000;
308
- if (!Number.isSafeInteger(this.timeoutMs) || this.timeoutMs < 1 || this.timeoutMs > 2_147_483_647) {
309
- throw new Error("The request timeout must be an integer from 1 to 2147483647ms.");
310
- }
311
- if (options.fetch && options.http) throw new Error("An injected fetch and the HTTP transport option cannot be used together.");
312
- this.httpTransport = options.fetch ? undefined : createHTTPTransport(options.http);
313
- this.fetchImpl = options.fetch ?? this.httpTransport!.fetch;
314
- this.compressionThresholdBytes = options.compressionThresholdBytes ?? DEFAULT_COMPRESSION_THRESHOLD_BYTES;
315
- if (!Number.isSafeInteger(this.compressionThresholdBytes) || this.compressionThresholdBytes < 1) {
316
- throw new Error("The compression threshold must be a positive integer number of bytes.");
317
- }
318
- this.trace = options.trace ?? (() => `${randomBytes(16).toString("hex")}-${randomBytes(8).toString("hex")}-0`);
319
- this.onRequest = options.onRequest;
320
- }
321
-
322
- get isAuthenticationInvalid(): boolean { return this.authenticationError !== undefined; }
323
-
324
- /** Retry waits between requests are also tied to authentication invalidation and close(). */
325
- get abortSignal(): AbortSignal { return this.lifetime.signal; }
326
-
327
- assertAuthenticated(): void {
328
- if (this.authenticationError) throw this.authenticationError;
329
- if (this.isClosed) throw new Error("The client is closed.");
330
- }
331
-
332
- close(): void {
333
- if (this.isClosed) return;
334
- this.isClosed = true;
335
- this.lifetime.abort(new DOMException("The client is closed.", "AbortError"));
336
- this.httpTransport?.close();
337
- }
338
-
339
- private requestScope(callerSignal?: AbortSignal): RequestScope {
340
- callerSignal?.throwIfAborted();
341
- this.assertAuthenticated();
342
- const deadline = new AbortController();
343
- const timer = setTimeout(() => deadline.abort(new DOMException(`The request exceeded the ${this.timeoutMs}ms timeout.`, "TimeoutError")), this.timeoutMs);
344
- return {
345
- signal: AbortSignal.any([...(callerSignal ? [callerSignal] : []), this.lifetime.signal, deadline.signal]),
346
- abort: (reason) => deadline.abort(reason),
347
- dispose: () => clearTimeout(timer),
348
- };
349
- }
350
-
351
- private failure(error: unknown, scope: RequestScope, secrets: string[]): Error {
352
- const failure = scope.signal.aborted ? scope.signal.reason : error;
353
- if (isAuthenticationError(failure) && this.token && !this.authenticationError) {
354
- this.authenticationError = failure;
355
- // Other in-flight requests are aborted too. The stored token is not deleted and no automatic re-login happens.
356
- this.lifetime.abort(failure);
357
- }
358
- scope.abort(failure);
359
- if (failure instanceof RPCError) {
360
- failure.message = safeError(failure, secrets); failure.code = safeError(failure.code, secrets);
361
- return failure;
362
- }
363
- const result = new Error(safeError(failure, secrets)) as Error & { code?: string };
364
- if (failure instanceof Error) result.name = failure.name;
365
- const cause = failure instanceof Error ? failure.cause : undefined;
366
- const coded = failure && typeof failure === "object" && "code" in failure ? failure
367
- : cause && typeof cause === "object" && "code" in cause ? cause : undefined;
368
- if (coded && typeof coded.code === "string") result.code = safeError(coded.code, secrets);
369
- return result;
370
- }
371
-
372
- private async post(method: string, body: JsonObject, encoding: Encoding, signal: AbortSignal, context: StreamRequestContext = {}): Promise<Response> {
373
- signal.throwIfAborted();
374
- this.assertAuthenticated();
375
- const isStreaming = encoding === "stream";
376
- if (isStreaming && method !== CHAT_METHOD) throw new Error("Unsupported streaming RPC.");
377
- const bytes = isStreaming ? chatRequestCodec.encode(body)
378
- : encoding === "proto" ? unaryCodecs(method).request.encode(body) : Buffer.from(JSON.stringify(body));
379
- if (bytes.length > MAX_FRAME_BYTES) throw new Error("The request body exceeds 8 MiB.");
380
- const isMetered = method === CHAT_METHOD || method === ASSIGN_MODEL_METHOD;
381
- const trace = method === CHAT_METHOD ? this.trace() : undefined;
382
- const secrets = requestSecrets(body, this.token);
383
- let isCompressed = isStreaming && bytes.length >= this.compressionThresholdBytes;
384
- const contentType = isStreaming ? "application/connect+proto" : encoding === "proto" ? "application/proto" : "application/json";
385
- for (let attempt = 1; ; attempt++) {
386
- signal.throwIfAborted();
387
- this.assertAuthenticated();
388
- const payload = isStreaming ? encodeFrame(isCompressed ? gzipSync(bytes) : bytes, isCompressed ? 1 : 0) : Buffer.from(bytes);
389
- const headers = requestHeaders({
390
- ...(this.token ? { authorization: `Basic ${this.token}-${this.token}` } : {}),
391
- ...(trace ? { "sentry-trace": trace } : {}),
392
- ...(isCompressed ? { "connect-content-encoding": "gzip" } : {}),
393
- "content-type": contentType,
394
- "connect-protocol-version": "1",
395
- accept: "*/*",
396
- "accept-encoding": "gzip",
397
- "content-length": String(payload.byteLength),
398
- }, context.headers);
399
- this.onRequest?.({ method: "POST", path: method, attempt, isMetered, isCompressed, stage: context.stage,
400
- modelUid: typeof body.chatModelUid === "string" ? body.chatModelUid : typeof body.modelRouterUid === "string" ? body.modelRouterUid : undefined });
401
- const responsePromise = this.fetchImpl(`${this.baseURL}/${method}`, {
402
- method: "POST", redirect: "error", headers,
403
- body: encoding === "json" ? payload.toString("utf8") : new Uint8Array(payload), signal,
404
- });
405
- try { if (attempt === 1) context.onDispatched?.(); }
406
- catch (error) { void responsePromise.then(discardResponse, () => {}); throw error; }
407
- const response = await awaitWithSignal(responsePromise, signal, discardResponse);
408
- try {
409
- if (context.onResponse) await awaitWithSignal(Promise.resolve(context.onResponse({ status: response.status, headers: Object.fromEntries(response.headers.entries()) }, signal)), signal);
410
- } catch (error) { discardResponse(response); throw error; }
411
- // Compression fallback the CLI uses. Keeps the same deadline, body, fingerprint, trace and per-attempt budget.
412
- // 401/403/429 and one-time login exchanges are never resent through this path.
413
- if (attempt === 1 && isCompressed && [408, 415, 500, 502, 503, 504].includes(response.status)) {
414
- discardResponse(response);
415
- isCompressed = false;
416
- continue;
417
- }
418
- if (response.status === 401) {
419
- discardResponse(response);
420
- throw new RPCError("unauthenticated", "HTTP 401: the server rejected the authentication credential.", 401);
421
- }
422
- if (!response.ok) {
423
- let details: JsonObject = {};
424
- try { details = object(await boundedJSON(response, 64 * 1024, signal), "RPC error"); }
425
- catch { signal.throwIfAborted(); }
426
- throw new RPCError(typeof details.code === "string" ? safeError(details.code, secrets) : `http_${response.status}`,
427
- safeError(typeof details.message === "string" ? details.message : `HTTP ${response.status}`, secrets),
428
- response.status, response.status === 429 ? retryAfterMs(response.headers) : undefined);
429
- }
430
- if (response.headers.get("content-type")?.split(";")[0].trim().toLowerCase() !== contentType) {
431
- discardResponse(response);
432
- throw new Error(`Unexpected Content-Type (expected: ${contentType})`);
433
- }
434
- return response;
435
- }
436
- }
437
-
438
- async unary(method: string, body: JsonObject, signal?: AbortSignal, options: { encoding?: "proto" | "json" } = {}): Promise<JsonObject> {
439
- const scope = this.requestScope(signal);
440
- try {
441
- const encoding = options.encoding ?? "proto";
442
- const response = await this.post(method, body, encoding, scope.signal);
443
- const result = encoding === "json" ? object(await boundedJSON(response, 4 * 1024 * 1024, scope.signal), "RPC response")
444
- : unaryCodecs(method).response.decode(await boundedBytes(response, 4 * 1024 * 1024, scope.signal));
445
- scope.signal.throwIfAborted();
446
- return result;
447
- } catch (error) { throw this.failure(error, scope, requestSecrets(body, this.token)); }
448
- finally { scope.dispose(); }
449
- }
450
-
451
- async *stream(method: string, body: JsonObject, signal?: AbortSignal, context: StreamRequestContext = {}): AsyncGenerator<Frame> {
452
- const scope = this.requestScope(signal);
453
- let payload = body;
454
- const secrets = requestSecrets(body, this.token);
455
- // A custom header name alone cannot tell whether its value is secret.
456
- for (const value of Object.values(context.headers ?? {})) if (value) secrets.push(value);
457
- let frames: AsyncGenerator<Frame> | undefined;
458
- try {
459
- if (context.onPayload) {
460
- const replacement = await awaitWithSignal(Promise.resolve(context.onPayload(body, scope.signal)), scope.signal);
461
- if (replacement !== undefined) payload = object(replacement, "request payload");
462
- }
463
- secrets.push(...requestSecrets(payload, this.token));
464
- const response = await this.post(method, payload, "stream", scope.signal, context);
465
- const compression = response.headers.get("connect-content-encoding") ?? "identity";
466
- if (compression !== "identity" && compression !== "gzip") {
467
- discardResponse(response);
468
- throw new Error("Unsupported Connect response compression.");
469
- }
470
- frames = decodeFrames(responseChunks(response, scope.signal), { compression });
471
- while (true) {
472
- const item = await frames.next();
473
- if (item.done) break;
474
- const frame = item.value;
475
- scope.signal.throwIfAborted();
476
- if (context.onFrame) await awaitWithSignal(Promise.resolve(context.onFrame(frame, scope.signal)), scope.signal);
477
- if (frame.isEnd && frame.data.error) {
478
- const error = object(frame.data.error, "EndStream error");
479
- throw new RPCError(typeof error.code === "string" ? safeError(error.code, secrets) : "unknown",
480
- safeError(typeof error.message === "string" ? error.message : "Connect stream failed", secrets));
481
- }
482
- yield frame;
483
- }
484
- } catch (error) { throw this.failure(error, scope, [...secrets, ...requestSecrets(payload, this.token)]); }
485
- finally {
486
- // An observer failure aborts first in the catch above. Cleanup after a voluntary return keeps the existing RPC deadline.
487
- try { await frames?.return(undefined); } finally { scope.dispose(); }
488
- }
489
- }
490
-
491
- async getJSON(origin: string, path: string, signal?: AbortSignal): Promise<JsonObject> {
492
- const base = endpoint(origin);
493
- if (!path.startsWith("/v3/") || path.includes("..") || path.includes("?") || path.includes("#")) {
494
- throw new Error("Unsupported Devin REST path.");
495
- }
496
- const scope = this.requestScope(signal);
497
- try {
498
- this.onRequest?.({ method: "GET", path, attempt: 1, isMetered: false, isCompressed: false });
499
- const response = await awaitWithSignal(this.fetchImpl(`${base}${path}`, {
500
- method: "GET", redirect: "error", signal: scope.signal,
501
- headers: { ...(this.token ? { authorization: `Bearer ${this.token}` } : {}), accept: "*/*",
502
- ...(path === "/v3/self" ? {} : { "accept-encoding": "gzip" }) },
503
- }), scope.signal, discardResponse);
504
- if (!response.ok) {
505
- discardResponse(response);
506
- throw new RPCError(response.status === 401 ? "unauthenticated" : `http_${response.status}`,
507
- `Devin REST HTTP ${response.status}`, response.status, response.status === 429 ? retryAfterMs(response.headers) : undefined);
508
- }
509
- return object(await boundedJSON(response, 4 * 1024 * 1024, scope.signal), "Devin REST response");
510
- } catch (error) { throw this.failure(error, scope, this.token ? [this.token] : []); }
511
- finally { scope.dispose(); }
512
- }
513
- }