@buyong/pi-devin 0.1.4 → 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 (116) hide show
  1. package/README.ko.md +222 -0
  2. package/README.md +37 -41
  3. package/index.ts +6 -12
  4. package/package.json +4 -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 -145
  104. package/src/pi/devin-command.ts +0 -76
  105. package/src/pi/devin-settings.ts +0 -50
  106. package/src/pi/extension.ts +0 -80
  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 -379
  112. package/src/pi/provider.ts +0 -249
  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
@@ -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
- }
package/src/pi/auth.ts DELETED
@@ -1,145 +0,0 @@
1
- import { createHash } from "node:crypto";
2
- import type { OAuthAuth, OAuthCredential } from "@earendil-works/pi-ai";
3
- import { loginAccount, type LoginOptions } from "../core/auth/index.ts";
4
- import { DevinClient } from "../core/client.ts";
5
- import { awaitWithSignal, endpoint, isAuthenticationError, isTransientConnectionFailure, RPCError } from "../core/protocol/transport.ts";
6
- import { object, requiredString, type Credential, type JsonObject } from "../core/types.ts";
7
-
8
- export const OAUTH_ONLY_MESSAGE = "Devin uses only the OAuth account session from /login. API key authentication is not supported.";
9
-
10
- export interface DevinAccountAuth extends OAuthAuth {
11
- readonly generation: number;
12
- assertUsable(credential: Credential): void;
13
- /** Converts the OAuth credential stored by Pi into the shared credential and remembers it as this process's OAuth session. */
14
- credentialFromHost(credential: OAuthCredential): Credential;
15
- /** Rejects sessions that did not come from an OAuth login or stored credential (API keys etc.) before any network request. */
16
- assertOAuthSession(credential: Credential): void;
17
- /** Remembers only authentication failures confirmed in the same auth generation. The credential file is never deleted. */
18
- invalidate(credential: Credential, error: unknown, generation: number): void;
19
- }
20
-
21
- export function credentialFromOAuth(credential: OAuthCredential): Credential {
22
- if (credential.type !== "oauth" || credential.refresh !== "" || credential.expires !== 0) {
23
- throw new Error("Unsupported Devin account credential. Sign in again with /login.");
24
- }
25
- const metadata = object(credential.devin, "Devin credential metadata");
26
- if (metadata.version !== 1) throw new Error("The Devin account credential version differs. Sign in again with /login.");
27
- return {
28
- version: 1,
29
- sessionToken: requiredString(credential.access, "Devin credential access"),
30
- apiServerURL: endpoint(requiredString(metadata.apiServerURL, "Devin credential API server")),
31
- createdAt: requiredString(metadata.createdAt, "Devin credential creation time"),
32
- };
33
- }
34
-
35
- function credentialKey(credential: Credential): string {
36
- return createHash("sha256").update(credential.apiServerURL + "\0" + credential.sessionToken).digest("hex");
37
- }
38
-
39
- /** Endpoint mismatches are rejected separately by the model list scope. This only checks that the session came from OAuth. */
40
- function sessionKey(credential: Credential): string {
41
- return createHash("sha256").update(credential.sessionToken).digest("hex");
42
- }
43
-
44
- export function createDevinAccountAuth(options: {
45
- loginOptions?: Omit<LoginOptions, "onURL" | "promptCode" | "signal" | "flow" | "encoding">;
46
- createClient?: (credential: Credential) => DevinClient;
47
- onLogin?: () => void;
48
- onValidated?: (credential: Credential, ownerHash: string, status: JsonObject) => void;
49
- onInvalidated?: (credential: Credential, error: unknown) => void;
50
- getSignal?: () => AbortSignal;
51
- /** The account was already checked in this process; each model request checks it again outside Pi's credential lock. */
52
- isAccountChecked?: (credential: Credential) => boolean;
53
- } = {}): DevinAccountAuth {
54
- const invalid = new Set<string>();
55
- // Remembers only a hash, never the token. Pi calls toAuth on every request, so the session in use moves back to the front.
56
- const oauthSessions = new Set<string>();
57
- let generation = 0;
58
- const requireUsable = (credential: Credential) => {
59
- if (invalid.has(credentialKey(credential))) throw new RPCError("unauthenticated", "Authentication failed for this session. Sign in again with /login.");
60
- };
61
- const rememberOAuth = (credential: Credential) => {
62
- const key = sessionKey(credential);
63
- oauthSessions.delete(key);
64
- if (oauthSessions.size >= 64) oauthSessions.delete(oauthSessions.values().next().value!);
65
- oauthSessions.add(key);
66
- return credential;
67
- };
68
- const auth: DevinAccountAuth = {
69
- name: "Devin account",
70
- get generation() { return generation; },
71
- assertUsable: requireUsable,
72
- credentialFromHost: (current) => rememberOAuth(credentialFromOAuth(current)),
73
- assertOAuthSession(credential) {
74
- // Kept as a plain error so it does not invalidate authentication or cancel a running request.
75
- if (!oauthSessions.has(sessionKey(credential))) throw new Error(OAUTH_ONLY_MESSAGE);
76
- },
77
- async login(interaction) {
78
- const lifetime = options.getSignal?.();
79
- const signal = AbortSignal.any([interaction.signal, ...(lifetime ? [lifetime] : [])]);
80
- signal.throwIfAborted();
81
- const choice = await awaitWithSignal(interaction.prompt({ type: "select", signal, message: "Devin account sign-in method", options: [
82
- { id: "browser", label: "Browser sign-in", description: "Choose an account, then return through a loopback callback." },
83
- { id: "manual", label: "Manual authorization code", description: "Enter a one-time code using the Devin CLI manual flow. This is not an API key prompt." },
84
- ] }), signal);
85
- if (choice !== "browser" && choice !== "manual") throw new Error("Unsupported Devin sign-in method.");
86
- const credential = await loginAccount({
87
- ...options.loginOptions, flow: choice, signal,
88
- onURL: (url) => interaction.notify({ type: "auth_url", url, instructions: "Return to the terminal after signing in. Signing in does not guarantee CLI access or quota." }),
89
- promptCode: (signal) => interaction.prompt({ type: "manual_code", message: "Paste the authorization code or callback URL. Do not enter an API key or session token.", signal }),
90
- });
91
- signal.throwIfAborted();
92
- invalid.delete(credentialKey(credential));
93
- rememberOAuth(credential);
94
- generation++;
95
- options.onLogin?.();
96
- interaction.notify({ type: "info", message: "Signed in to the Devin account. The server expiry time and refresh grant are unknown. If authentication fails, sign in again with /login." });
97
- return {
98
- type: "oauth", access: credential.sessionToken, refresh: "", expires: 0,
99
- devin: { version: 1, apiServerURL: credential.apiServerURL, createdAt: credential.createdAt, flow: choice },
100
- };
101
- },
102
- async refresh(current, callerSignal) {
103
- const lifetime = options.getSignal?.();
104
- const signal = AbortSignal.any([callerSignal, ...(lifetime ? [lifetime] : [])]);
105
- signal.throwIfAborted();
106
- const credential = credentialFromOAuth(current);
107
- requireUsable(credential);
108
- rememberOAuth(credential);
109
- // expires 0 makes Pi call this before every request while holding the shared auth.json lock. A network
110
- // check here serializes every Pi process using Devin, so only the first resolution in a process checks.
111
- if (options.isAccountChecked?.(credential)) return current;
112
- const expectedGeneration = generation;
113
- const client = (options.createClient ?? ((value) => new DevinClient(value, { profile: "login" })))(credential);
114
- try {
115
- // 0 is not a server TTL. Pi's refresh entry point only checks the existing session; it never renews the token.
116
- const status = await client.status(signal);
117
- if (expectedGeneration === generation) options.onValidated?.(credential, requiredString(client.accountIdentityHash, "Devin account scope"), status);
118
- return current;
119
- } catch (error) {
120
- auth.invalidate(credential, error, expectedGeneration);
121
- // An unreachable check must not block the request: keep the stored session so the model request
122
- // succeeds or reports its own retryable transport error.
123
- if (!signal.aborted && isTransientConnectionFailure(error)) return current;
124
- throw error;
125
- }
126
- finally { client.transport.close(); }
127
- },
128
- async toAuth(current) {
129
- const credential = credentialFromOAuth(current);
130
- requireUsable(credential);
131
- rememberOAuth(credential);
132
- // apiKey is only ModelAuth's transport field; the sign-in/storage auth type is always oauth.
133
- return { apiKey: credential.sessionToken, baseUrl: credential.apiServerURL };
134
- },
135
- invalidate(credential, error, expectedGeneration) {
136
- if (expectedGeneration !== generation || !isAuthenticationError(error)) return;
137
- const key = credentialKey(credential);
138
- if (invalid.has(key)) return;
139
- if (invalid.size >= 64) invalid.delete(invalid.values().next().value!);
140
- invalid.add(key);
141
- options.onInvalidated?.(credential, error);
142
- },
143
- };
144
- return auth;
145
- }