@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
@@ -0,0 +1,174 @@
1
+ import { randomUUID } from "node:crypto";
2
+ import { Effect, Predicate } from "effect";
3
+ import { DevinError } from "../errors.ts";
4
+ import type { ChatMessage, ChatResult, JsonObject, ToolCall, Usage } from "../types.ts";
5
+ import { failWith, object, requiredString, tokenCount } from "../validation.ts";
6
+
7
+ // Backends mark a finished turn differently: Anthropic text ends with MIN_LOG_PROB, Gemini tool calls with STOP_PATTERN,
8
+ // and OpenAI sends no stop reason (UNSPECIFIED is the proto3 default, so it is not on the wire).
9
+ const COMPLETE_STOP_REASONS = new Set(["STOP_REASON_STOP_PATTERN", "STOP_REASON_FUNCTION_CALL", "STOP_REASON_MIN_LOG_PROB", "STOP_REASON_UNSPECIFIED", ""]);
10
+ const COPIED_STRINGS = [
11
+ ["deltaSignatureType", "signatureType"], ["geminiThoughtSignature", "geminiThoughtSignature"],
12
+ ["outputId", "outputId"], ["thinkingId", "thinkingId"], ["phase", "phase"],
13
+ ] as const;
14
+ const USAGE_FIELDS = [["inputTokens", "input"], ["outputTokens", "output"], ["cacheReadTokens", "cacheRead"], ["cacheWriteTokens", "cacheWrite"]] as const;
15
+
16
+ const SERVER_JSON_ERROR = "The server reported a tool argument JSON error.";
17
+
18
+ /**
19
+ * Fails when a finished result has a call that must not run: arguments that are not a JSON object, or calls cut off at the output limit.
20
+ * The CLI runs tools itself and uses this; Pi reports such calls back to the model instead.
21
+ */
22
+ export function assertRunnableToolCalls(result: ChatResult): Effect.Effect<void, DevinError> {
23
+ return Effect.gen(function* () {
24
+ for (const call of result.message.toolCalls ?? []) {
25
+ const error = result.toolArgumentErrors[call.id];
26
+ if (error) return yield* failWith(error.startsWith(SERVER_JSON_ERROR) ? SERVER_JSON_ERROR : `Arguments of tool ${call.name} are not valid JSON.`);
27
+ }
28
+ if (result.message.toolCalls?.length && !COMPLETE_STOP_REASONS.has(result.stopReason)) return yield* failWith("Tool calls and the stop reason do not match.");
29
+ });
30
+ }
31
+
32
+ /** Pi's wording for arguments that are not a JSON object: the problem, then the text the model sent. */
33
+ function argumentsError(call: ToolCall): string | undefined {
34
+ try {
35
+ const value = JSON.parse(call.argumentsJson) as unknown;
36
+ if (Predicate.isObject(value) && !Array.isArray(value)) return undefined;
37
+ return `Tool arguments must be a JSON object\n\nReceived arguments:\n${call.argumentsJson}`;
38
+ } catch (error) {
39
+ return `${error instanceof Error ? error.message : String(error)}\n\nReceived arguments:\n${call.argumentsJson}`;
40
+ }
41
+ }
42
+
43
+ /** Attaches argument deltas without an ID to the call that started with the first ID. Ambiguous parallel deltas are rejected. */
44
+ export class ChatAccumulator {
45
+ readonly message: ChatMessage = { messageId: "", source: "CHAT_MESSAGE_SOURCE_SYSTEM", prompt: "" };
46
+ readonly usage: Usage = { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, totalTokens: 0 };
47
+ readonly calls = new Map<string, ToolCall>();
48
+ /** Calls the server marked as invalid JSON. Their arguments are reported, never parsed into a runnable call. */
49
+ private readonly serverArgumentErrors = new Set<string>();
50
+ private currentCall?: ToolCall;
51
+ private hasEnded = false;
52
+ private readonly startedAt = performance.now();
53
+ private firstDeltaMs?: number;
54
+ private stopReason = "";
55
+ private usageReported = false;
56
+ private frameCount = 0;
57
+ private stopFrameIndex?: number;
58
+ private lastUsageFrameIndex?: number;
59
+ private idlessToolDeltaCount = 0;
60
+ private actualModelUid?: string;
61
+ private usageModelUid?: string;
62
+
63
+ accept(data: JsonObject, isEnd = false): Effect.Effect<void, DevinError> {
64
+ const self = this;
65
+ return Effect.gen(function* () {
66
+ if (self.hasEnded) return yield* failWith("Cannot accumulate a response after EndStream.");
67
+ self.frameCount++;
68
+ if (isEnd) {
69
+ if (data.error) return yield* failWith("An error EndStream cannot be treated as success.");
70
+ self.hasEnded = true;
71
+ return;
72
+ }
73
+ if (data.redact === true) return yield* failWith("The server redacted the response.");
74
+ const message = self.message;
75
+ if (Predicate.isString(data.messageId) && data.messageId) message.messageId = data.messageId;
76
+ if (data.thinkingRedacted === true) {
77
+ message.thinkingRedacted = true;
78
+ message.thinking = "";
79
+ }
80
+ if (Predicate.isString(data.deltaText)) message.prompt += data.deltaText;
81
+ if (Predicate.isString(data.deltaThinking) && !message.thinkingRedacted) message.thinking = (message.thinking ?? "") + data.deltaThinking;
82
+ if (Predicate.isString(data.deltaSignature)) message.signature = (message.signature ?? "") + data.deltaSignature;
83
+ for (const [source, target] of COPIED_STRINGS) {
84
+ const value = data[source];
85
+ if (Predicate.isString(value)) message[target] = value;
86
+ }
87
+ if (Predicate.isString(data.actualModelUid)) self.actualModelUid = data.actualModelUid;
88
+ if (data.deltaToolCalls !== undefined) yield* self.acceptToolDeltas(data.deltaToolCalls);
89
+ if (data.deltaText || data.deltaThinking || data.deltaToolCalls) self.firstDeltaMs ??= performance.now() - self.startedAt;
90
+ if (Predicate.isString(data.stopReason) && data.stopReason) {
91
+ self.stopReason = data.stopReason;
92
+ self.stopFrameIndex = self.frameCount;
93
+ }
94
+ if (data.usage !== undefined) {
95
+ const usage = yield* object(data.usage, "usage");
96
+ if (Predicate.isString(usage.modelUid)) self.usageModelUid = usage.modelUid;
97
+ for (const [source, target] of USAGE_FIELDS) {
98
+ if (usage[source] === undefined) continue;
99
+ self.usage[target] = yield* tokenCount(usage[source], source);
100
+ self.usageReported = true;
101
+ self.lastUsageFrameIndex = self.frameCount;
102
+ }
103
+ self.usage.totalTokens = self.usage.input + self.usage.output + self.usage.cacheRead + self.usage.cacheWrite;
104
+ }
105
+ });
106
+ }
107
+
108
+ private acceptToolDeltas(deltas: unknown): Effect.Effect<void, DevinError> {
109
+ const self = this;
110
+ return Effect.gen(function* () {
111
+ if (!Array.isArray(deltas)) return yield* failWith("deltaToolCalls must be an array.");
112
+ for (const value of deltas) {
113
+ const delta = yield* object(value, "tool delta");
114
+ const id = Predicate.isString(delta.id) && delta.id ? delta.id : undefined;
115
+ if (id) {
116
+ let call = self.calls.get(id);
117
+ if (!call) {
118
+ call = { id, name: "", argumentsJson: "" };
119
+ self.calls.set(id, call);
120
+ }
121
+ self.currentCall = call;
122
+ } else {
123
+ self.idlessToolDeltaCount++;
124
+ if (deltas.length !== 1 || !self.currentCall || self.calls.size > 1) {
125
+ return yield* failWith("Cannot attach a delta without a tool ID to a specific call.");
126
+ }
127
+ }
128
+ const call = self.currentCall!;
129
+ if (delta.isCustomToolCall) return yield* failWith("Grammar-based custom tools are not supported.");
130
+ if (delta.invalidJsonErr || delta.invalidJsonStr) self.serverArgumentErrors.add(call.id);
131
+ if (Predicate.isString(delta.name)) call.name += delta.name;
132
+ if (Predicate.isString(delta.argumentsJson)) call.argumentsJson += delta.argumentsJson;
133
+ }
134
+ });
135
+ }
136
+
137
+ get finish(): Effect.Effect<ChatResult, DevinError> {
138
+ const self = this;
139
+ return Effect.gen(function* () {
140
+ if (!self.hasEnded) return yield* failWith("No successful EndStream.");
141
+ if (!COMPLETE_STOP_REASONS.has(self.stopReason) && self.stopReason !== "STOP_REASON_MAX_TOKENS") {
142
+ return yield* failWith(`stopReason cannot be treated as success: ${self.stopReason || "(none)"}`);
143
+ }
144
+ const toolArgumentErrors: Record<string, string> = {};
145
+ if (self.calls.size > 0) {
146
+ self.message.toolCalls = [...self.calls.values()];
147
+ for (const call of self.message.toolCalls) {
148
+ yield* requiredString(call.name, "tool name");
149
+ const error = self.serverArgumentErrors.has(call.id) ? `${SERVER_JSON_ERROR}\n\nReceived arguments:\n${call.argumentsJson}` : argumentsError(call);
150
+ if (error) toolArgumentErrors[call.id] = error;
151
+ }
152
+ }
153
+ if (self.stopReason === "STOP_REASON_FUNCTION_CALL" && !self.calls.size) {
154
+ return yield* failWith("The response ended with FUNCTION_CALL but has no tool call.");
155
+ }
156
+ self.message.messageId ||= randomUUID();
157
+ return {
158
+ message: self.message,
159
+ stopReason: self.stopReason,
160
+ usage: self.usage,
161
+ usageReported: self.usageReported,
162
+ frameCount: self.frameCount,
163
+ stopFrameIndex: self.stopFrameIndex,
164
+ lastUsageFrameIndex: self.lastUsageFrameIndex,
165
+ idlessToolDeltaCount: self.idlessToolDeltaCount,
166
+ toolArgumentErrors,
167
+ durationMs: Math.round(performance.now() - self.startedAt),
168
+ firstDeltaMs: self.firstDeltaMs === undefined ? undefined : Math.round(self.firstDeltaMs),
169
+ actualModelUid: self.actualModelUid,
170
+ ...(self.usageModelUid ? { usageModelUid: self.usageModelUid } : {}),
171
+ };
172
+ });
173
+ }
174
+ }
@@ -1,24 +1,31 @@
1
+ import { Effect } from "effect";
2
+ import type { DevinError } from "../errors.ts";
1
3
  import type { ImageData } from "../types.ts";
4
+ import { failWith } from "../validation.ts";
2
5
 
3
6
  export const MAX_IMAGE_BYTES = 4 * 1024 * 1024;
4
7
  export const MAX_IMAGES_BYTES = 5 * 1024 * 1024;
5
8
 
6
- export function imageMimeType(bytes: Uint8Array): ImageData["mimeType"] {
9
+ export function imageMimeType(bytes: Uint8Array): Effect.Effect<ImageData["mimeType"], DevinError> {
7
10
  const data = Buffer.from(bytes.buffer, bytes.byteOffset, bytes.byteLength);
8
- if (data.subarray(0, 8).equals(Buffer.from("89504e470d0a1a0a", "hex"))) return "image/png";
9
- if (data.length >= 3 && data[0] === 0xff && data[1] === 0xd8 && data[2] === 0xff) return "image/jpeg";
10
- if (["GIF87a", "GIF89a"].includes(data.subarray(0, 6).toString("ascii"))) return "image/gif";
11
- if (data.length >= 12 && data.subarray(0, 4).toString("ascii") === "RIFF" && data.subarray(8, 12).toString("ascii") === "WEBP") return "image/webp";
12
- throw new Error("Unsupported image. Use a PNG, JPEG, GIF or WebP file.");
11
+ if (data.subarray(0, 8).equals(Buffer.from("89504e470d0a1a0a", "hex"))) return Effect.succeed("image/png");
12
+ if (data.length >= 3 && data[0] === 0xff && data[1] === 0xd8 && data[2] === 0xff) return Effect.succeed("image/jpeg");
13
+ if (["GIF87a", "GIF89a"].includes(data.subarray(0, 6).toString("ascii"))) return Effect.succeed("image/gif");
14
+ if (data.length >= 12 && data.subarray(0, 4).toString("ascii") === "RIFF" && data.subarray(8, 12).toString("ascii") === "WEBP") return Effect.succeed("image/webp");
15
+ return failWith("Unsupported image. Use a PNG, JPEG, GIF or WebP file.");
13
16
  }
14
17
 
15
- export function imageFromBase64(data: string, mimeType: string): { image: ImageData; sizeBytes: number } {
16
- if (data.length > Math.ceil(MAX_IMAGE_BYTES / 3) * 4 || data.length % 4 === 1 || !/^[A-Za-z0-9+/]*={0,2}$/.test(data)) {
17
- throw new Error("Invalid image Base64 format or size.");
18
- }
19
- const bytes = Buffer.from(data, "base64");
20
- if (bytes.length > MAX_IMAGE_BYTES || bytes.toString("base64").replace(/=+$/, "") !== data.replace(/=+$/, "")) throw new Error("Invalid image Base64 format or size.");
21
- const actualMimeType = imageMimeType(bytes);
22
- if (mimeType !== actualMimeType) throw new Error("The image MIME type does not match its actual format.");
23
- return { image: { base64Data: data, mimeType: actualMimeType }, sizeBytes: bytes.length };
18
+ export function imageFromBase64(data: string, mimeType: string): Effect.Effect<{ image: ImageData; sizeBytes: number }, DevinError> {
19
+ return Effect.gen(function* () {
20
+ if (data.length > Math.ceil(MAX_IMAGE_BYTES / 3) * 4 || data.length % 4 === 1 || !/^[A-Za-z0-9+/]*={0,2}$/.test(data)) {
21
+ return yield* failWith("Invalid image Base64 format or size.");
22
+ }
23
+ const bytes = Buffer.from(data, "base64");
24
+ if (bytes.length > MAX_IMAGE_BYTES || bytes.toString("base64").replace(/=+$/, "") !== data.replace(/=+$/, "")) {
25
+ return yield* failWith("Invalid image Base64 format or size.");
26
+ }
27
+ const actualMimeType = yield* imageMimeType(bytes);
28
+ if (mimeType !== actualMimeType) return yield* failWith("The image MIME type does not match its actual format.");
29
+ return { image: { base64Data: data, mimeType: actualMimeType }, sizeBytes: bytes.length };
30
+ });
24
31
  }
@@ -0,0 +1,59 @@
1
+ import { randomUUID } from "node:crypto";
2
+ import type { ChatMessage, ChatTool, ImageData, JsonObject } from "../types.ts";
3
+ import { wireMessages } from "./messages.ts";
4
+
5
+ export const DEFAULT_SYSTEM_PROMPT = "You are a helpful assistant. Follow the user\'s request concisely.";
6
+
7
+ export function userMessage(prompt: string, images: readonly ImageData[] = []): ChatMessage {
8
+ return {
9
+ messageId: randomUUID(), source: "CHAT_MESSAGE_SOURCE_USER", prompt,
10
+ ...(images.length ? { images: images.map((image) => ({ ...image })) } : {}),
11
+ };
12
+ }
13
+
14
+ export function chatRequest(options: {
15
+ metadata: JsonObject;
16
+ model: string;
17
+ messages: ChatMessage[];
18
+ systemPrompt: string;
19
+ maxTokens: number;
20
+ cascadeId: string;
21
+ trajectoryId: string;
22
+ executionId?: string;
23
+ clientSessionId?: string;
24
+ modelAssignmentJwt?: string;
25
+ stepIndex?: number;
26
+ isAuxiliary?: boolean;
27
+ isRecovery?: boolean;
28
+ includeContext?: boolean;
29
+ cwd?: string;
30
+ tools?: ChatTool[];
31
+ }): JsonObject {
32
+ return {
33
+ metadata: options.metadata,
34
+ prompt: options.systemPrompt,
35
+ chatMessagePrompts: wireMessages(options.messages, { includeContext: options.includeContext, cwd: options.cwd }),
36
+ chatModelUid: options.model,
37
+ requestType: "CHAT_MESSAGE_REQUEST_TYPE_CASCADE",
38
+ configuration: {
39
+ maxTokens: options.maxTokens,
40
+ numCompletions: 1,
41
+ maxNewlines: 400,
42
+ temperature: 1,
43
+ topK: 40,
44
+ topP: Math.fround(0.95),
45
+ },
46
+ trajectoryReference: {
47
+ trajectoryId: options.trajectoryId,
48
+ trajectoryType: "CORTEX_TRAJECTORY_TYPE_CASCADE",
49
+ ...(!options.isAuxiliary && !options.isRecovery && options.messages.at(-1)?.source === "CHAT_MESSAGE_SOURCE_USER" ? { stepType: "CORTEX_STEP_TYPE_USER_INPUT" } : {}),
50
+ ...(options.stepIndex ? { stepIndex: options.stepIndex } : {}),
51
+ },
52
+ cascadeId: options.cascadeId,
53
+ plannerMode: "CONVERSATIONAL_PLANNER_MODE_DEFAULT",
54
+ ...(!options.isAuxiliary && options.executionId ? { executionId: options.executionId } : {}),
55
+ ...(options.clientSessionId ? { clientSessionId: options.clientSessionId } : {}),
56
+ ...(options.modelAssignmentJwt ? { modelAssignmentJwt: options.modelAssignmentJwt } : {}),
57
+ ...(options.tools?.length ? { tools: options.tools } : {}),
58
+ };
59
+ }
@@ -1,5 +1,6 @@
1
- import { setTimeout as delay } from "node:timers/promises";
2
- import { awaitWithSignal, RPCError } from "../protocol/transport.ts";
1
+ import { Duration, Effect } from "effect";
2
+ import { RPCError, type DevinError, type DevinFailure } from "../errors.ts";
3
+ import { failWith } from "../validation.ts";
3
4
 
4
5
  const CREATION_ATTEMPTS = 5;
5
6
  const RECOVERY_ATTEMPTS = 3;
@@ -10,7 +11,8 @@ const RETRYABLE_CONNECT_CODES = new Set(["resource_exhausted", "deadline_exceede
10
11
 
11
12
  export interface InferenceRetryRuntime {
12
13
  random?: () => number;
13
- wait?: (delayMs: number, signal: AbortSignal) => Promise<void>;
14
+ /** Waits between attempts. Defaults to `Effect.sleep`, which the caller's interruption and guards stop. */
15
+ wait?: (delayMs: number) => Effect.Effect<void, DevinFailure>;
14
16
  }
15
17
 
16
18
  export interface InferenceRetryEvent {
@@ -21,48 +23,46 @@ export interface InferenceRetryEvent {
21
23
  }
22
24
 
23
25
  /** Backoff: starts at 2 s, doubles, jitter factor 0.5. The elapsed-time cap is disabled. */
24
- export function inferenceRetryDelayMs(retryIndex: number, random: number): number {
25
- if (!Number.isInteger(retryIndex) || retryIndex < 0 || retryIndex >= CREATION_ATTEMPTS - 1) throw new Error("Invalid retry index.");
26
- if (!Number.isFinite(random) || random < 0 || random >= 1) throw new Error("The retry random value must be at least 0 and below 1.");
26
+ export function inferenceRetryDelayMs(retryIndex: number, random: number): Effect.Effect<number, DevinError> {
27
+ if (!Number.isInteger(retryIndex) || retryIndex < 0 || retryIndex >= CREATION_ATTEMPTS - 1) return failWith("Invalid retry index.");
28
+ if (!Number.isFinite(random) || random < 0 || random >= 1) return failWith("The retry random value must be at least 0 and below 1.");
27
29
  const intervalMs = Math.min(INITIAL_INTERVAL_MS * 2 ** retryIndex, MAX_INTERVAL_MS);
28
30
  const intervalNanos = intervalMs * 1_000_000;
29
- return Math.floor((intervalNanos * 0.5 + random * (intervalNanos + 1)) / 1_000_000);
31
+ return Effect.succeed(Math.floor((intervalNanos * 0.5 + random * (intervalNanos + 1)) / 1_000_000));
30
32
  }
31
33
 
32
- function canRetryCreation(error: unknown): boolean {
34
+ function canRetryCreation(error: DevinFailure): boolean {
33
35
  return error instanceof RPCError && error.httpStatus !== undefined && RETRYABLE_HTTP_STATUSES.has(error.httpStatus);
34
36
  }
35
37
 
36
- function canRecover(error: unknown): boolean {
38
+ function canRecover(error: DevinFailure): boolean {
37
39
  return canRetryCreation(error) || (error instanceof RPCError && error.httpStatus === undefined && RETRYABLE_CONNECT_CODES.has(error.code));
38
40
  }
39
41
 
40
42
  /** Makes each attempt build a new request, separate from the same-body resend of the 415/compression fallback. */
41
- export async function withInferenceRetries<T>(
42
- request: (isRecovery: boolean) => Promise<T>,
43
- options: { signal: AbortSignal; runtime?: InferenceRetryRuntime; onRetry?: (event: InferenceRetryEvent) => void },
44
- ): Promise<T> {
45
- const { signal, runtime = {} } = options;
43
+ export function withInferenceRetries<A>(
44
+ request: (isRecovery: boolean) => Effect.Effect<A, DevinFailure>,
45
+ options: { runtime?: InferenceRetryRuntime; onRetry?: (event: InferenceRetryEvent) => Effect.Effect<void> } = {},
46
+ ): Effect.Effect<A, DevinFailure> {
47
+ const runtime = options.runtime ?? {};
46
48
  const random = runtime.random ?? Math.random;
47
- const wait = runtime.wait ?? ((delayMs: number, signal: AbortSignal) => delay(delayMs, undefined, { signal }));
48
- let lastError: unknown;
49
- for (let recovery = 0; recovery < RECOVERY_ATTEMPTS; recovery++) {
50
- for (let attempt = 0; attempt < CREATION_ATTEMPTS; attempt++) {
51
- signal.throwIfAborted();
52
- try { return await request(recovery > 0); }
53
- catch (error) {
54
- signal.throwIfAborted();
55
- if (!canRecover(error)) throw error;
49
+ const wait = runtime.wait ?? ((delayMs: number) => Effect.sleep(Duration.millis(delayMs)));
50
+ return Effect.gen(function* () {
51
+ let lastError: DevinFailure | undefined;
52
+ for (let recovery = 0; recovery < RECOVERY_ATTEMPTS; recovery++) {
53
+ for (let attempt = 0; attempt < CREATION_ATTEMPTS; attempt++) {
54
+ const outcome = yield* Effect.result(request(recovery > 0));
55
+ if (outcome._tag === "Success") return outcome.success;
56
+ const error = outcome.failure;
57
+ if (!canRecover(error)) return yield* Effect.fail(error);
56
58
  lastError = error;
57
59
  if (!canRetryCreation(error) || attempt + 1 === CREATION_ATTEMPTS) break;
58
- const delayMs = inferenceRetryDelayMs(attempt, random());
59
- options.onRetry?.({ phase: "creation", nextAttempt: attempt + 2, limit: CREATION_ATTEMPTS, delayMs });
60
- try { await awaitWithSignal(wait(delayMs, signal), signal); }
61
- catch (error) { signal.throwIfAborted(); throw error; }
60
+ const delayMs = yield* inferenceRetryDelayMs(attempt, random());
61
+ if (options.onRetry) yield* options.onRetry({ phase: "creation", nextAttempt: attempt + 2, limit: CREATION_ATTEMPTS, delayMs });
62
+ yield* wait(delayMs);
62
63
  }
64
+ if (recovery + 1 < RECOVERY_ATTEMPTS && options.onRetry) yield* options.onRetry({ phase: "recovery", nextAttempt: recovery + 2, limit: RECOVERY_ATTEMPTS, delayMs: 0 });
63
65
  }
64
- signal.throwIfAborted();
65
- if (recovery + 1 < RECOVERY_ATTEMPTS) options.onRetry?.({ phase: "recovery", nextAttempt: recovery + 2, limit: RECOVERY_ATTEMPTS, delayMs: 0 });
66
- }
67
- throw lastError;
66
+ return yield* Effect.fail(lastError!);
67
+ });
68
68
  }
@@ -1,8 +1,12 @@
1
+ import { Effect } from "effect";
2
+ import type { DevinError } from "../errors.ts";
3
+ import { failWith } from "../validation.ts";
4
+
1
5
  export const MAX_STEP_INDEX = 2_147_483_647;
2
6
 
3
7
  export interface StepState { nextStepIndex: number }
4
8
 
5
- export function takeStep(state: StepState): number {
6
- if (state.nextStepIndex >= MAX_STEP_INDEX) throw new Error("The session step limit was exceeded.");
7
- return state.nextStepIndex++;
9
+ export function takeStep(state: StepState): Effect.Effect<number, DevinError> {
10
+ return Effect.suspend(() => state.nextStepIndex >= MAX_STEP_INDEX ? failWith("The session step limit was exceeded.")
11
+ : Effect.succeed(state.nextStepIndex++));
8
12
  }
@@ -0,0 +1,48 @@
1
+ import { Effect, Predicate, Stream } from "effect";
2
+ import type { DevinFailure } from "../errors.ts";
3
+ import type { ConnectClient } from "../protocol/connect-client.ts";
4
+ import { CHAT_METHOD } from "../protocol/protobuf.ts";
5
+ import type { ChatResult, JsonObject } from "../types.ts";
6
+ import { assertRunnableToolCalls, ChatAccumulator } from "./accumulator.ts";
7
+
8
+ export interface StreamChatOptions {
9
+ onText?: (delta: string) => Effect.Effect<void>;
10
+ stage?: string;
11
+ onDispatched?: Effect.Effect<void, DevinFailure>;
12
+ }
13
+
14
+ /** Sends one chat request and folds its frames into a single result. */
15
+ export function streamChat(client: ConnectClient, request: JsonObject, options: StreamChatOptions = {}): Effect.Effect<ChatResult, DevinFailure> {
16
+ return Effect.gen(function* () {
17
+ const accumulator = new ChatAccumulator();
18
+ yield* client.stream(CHAT_METHOD, request, { stage: options.stage, onDispatched: options.onDispatched }).pipe(Stream.runForEach((frame) => Effect.gen(function* () {
19
+ yield* accumulator.accept(frame.data, frame.isEnd);
20
+ if (!frame.isEnd && Predicate.isString(frame.data.deltaText) && options.onText) yield* options.onText(frame.data.deltaText);
21
+ })));
22
+ const result = yield* accumulator.finish;
23
+ yield* assertRunnableToolCalls(result);
24
+ // Field 22 records the model of the generation request, not actualModelUid/usage.modelUid.
25
+ if (Predicate.isString(request.chatModelUid)) result.message.modelUid = request.chatModelUid;
26
+ return result;
27
+ });
28
+ }
29
+
30
+ /** Raw thinking and signatures never go to logs or reports. They are only resent inside requests. */
31
+ export function summarizeChat(result: ChatResult): JsonObject {
32
+ return {
33
+ text: result.message.prompt,
34
+ stopReason: result.stopReason,
35
+ usage: result.usage,
36
+ usageReported: result.usageReported,
37
+ toolCalls: result.message.toolCalls ?? [],
38
+ thinkingChars: result.message.thinking?.length ?? 0,
39
+ hasSignature: Boolean(result.message.signature || result.message.geminiThoughtSignature),
40
+ frameCount: result.frameCount,
41
+ stopFrameIndex: result.stopFrameIndex,
42
+ lastUsageFrameIndex: result.lastUsageFrameIndex,
43
+ idlessToolDeltaCount: result.idlessToolDeltaCount,
44
+ durationMs: result.durationMs,
45
+ firstDeltaMs: result.firstDeltaMs,
46
+ actualModelUid: result.actualModelUid,
47
+ };
48
+ }
@@ -1,7 +1,8 @@
1
+ import { Predicate } from "effect";
1
2
  import type { JsonObject, ModelInfo } from "../types.ts";
2
3
 
3
4
  /** When the server provides a separate allowlist, it is applied together with the catalog's disabled flags. */
4
- export function applyTeamModelPolicy(models: ModelInfo[], team: JsonObject): void {
5
- const allowed = Array.isArray(team.allowedModelUids) ? team.allowedModelUids.filter((id): id is string => typeof id === "string") : [];
6
- if (allowed.length) for (const model of models) model.isDisabled ||= !allowed.includes(model.id);
5
+ export function applyTeamModelPolicy(models: readonly ModelInfo[], team: JsonObject): ModelInfo[] {
6
+ const allowed = Array.isArray(team.allowedModelUids) ? team.allowedModelUids.filter(Predicate.isString) : [];
7
+ return allowed.length ? models.map((model) => ({ ...model, isDisabled: model.isDisabled || !allowed.includes(model.id) })) : [...models];
7
8
  }
@@ -1,35 +1,44 @@
1
- import { object, requiredString, tokenCount, type JsonObject, type ModelInfo } from "../types.ts";
1
+ import { Effect, Predicate } from "effect";
2
+ import type { DevinError } from "../errors.ts";
3
+ import type { JsonObject, ModelInfo } from "../types.ts";
4
+ import { failWith, object, requiredString, tokenCount } from "../validation.ts";
2
5
 
3
6
  export interface ModelCatalog { raw: JsonObject; models: ModelInfo[]; defaultModelUid?: string }
4
7
 
5
- export function parseModelCatalog(response: JsonObject): ModelCatalog {
6
- if (!Array.isArray(response.clientModelConfigs)) throw new Error("clientModelConfigs is missing.");
7
- const override = object(response.defaultOverrideModelConfig ?? {}, "defaultOverrideModelConfig");
8
- const defaultModelUid = typeof override.modelUid === "string" ? override.modelUid : undefined;
9
- const models = response.clientModelConfigs.map((value) => {
10
- const config = object(value, "ClientModelConfig");
11
- const info = object(config.modelInfo, "modelInfo");
12
- const features = object(info.modelFeatures ?? {}, "modelFeatures");
13
- const family = object(config.modelFamilyMetadata ?? {}, "modelFamilyMetadata");
8
+ function parseModel(value: unknown): Effect.Effect<ModelInfo, DevinError> {
9
+ return Effect.gen(function* () {
10
+ const config = yield* object(value, "ClientModelConfig");
11
+ const info = yield* object(config.modelInfo, "modelInfo");
12
+ const features = yield* object(info.modelFeatures ?? {}, "modelFeatures");
13
+ const family = yield* object(config.modelFamilyMetadata ?? {}, "modelFamilyMetadata");
14
14
  return {
15
- id: requiredString(config.modelUid, "modelUid"),
16
- name: requiredString(config.label, "label"),
17
- contextWindow: tokenCount(info.maxTokens ?? 0, "context window"),
18
- maxOutputTokens: tokenCount(info.maxOutputTokens ?? 0, "max output tokens"),
15
+ id: yield* requiredString(config.modelUid, "modelUid"),
16
+ name: yield* requiredString(config.label, "label"),
17
+ contextWindow: yield* tokenCount(info.maxTokens ?? 0, "context window"),
18
+ maxOutputTokens: yield* tokenCount(info.maxOutputTokens ?? 0, "max output tokens"),
19
19
  supportsThinking: features.supportsThinking === true,
20
20
  supportsImages: config.supportsImages === true || features.supportsImages === true,
21
21
  supportsToolCalls: features.supportsToolCalls === true,
22
22
  supportsParallelToolCalls: features.supportsParallelToolCalls === true,
23
23
  isRouter: info.isModelRouter === true,
24
24
  isDisabled: config.disabled === true,
25
- familyId: typeof info.modelFamilyUid === "string" ? info.modelFamilyUid : undefined,
26
- aliases: Array.isArray(info.aliases) ? info.aliases.filter((value): value is string => typeof value === "string") : [],
25
+ familyId: Predicate.isString(info.modelFamilyUid) ? info.modelFamilyUid : undefined,
26
+ aliases: Array.isArray(info.aliases) ? info.aliases.filter(Predicate.isString) : [],
27
27
  isDefaultInFamily: config.isDefaultModelInFamily === true || family.isDefaultModelInFamily === true,
28
28
  isRecommended: config.isRecommended === true,
29
- costTier: typeof config.modelCostTier === "string" ? config.modelCostTier : undefined,
30
- creditMultiplier: typeof config.creditMultiplier === "number" ? config.creditMultiplier : undefined,
31
- pricingType: typeof config.pricingType === "string" ? config.pricingType : undefined,
29
+ costTier: Predicate.isString(config.modelCostTier) ? config.modelCostTier : undefined,
30
+ creditMultiplier: Predicate.isNumber(config.creditMultiplier) ? config.creditMultiplier : undefined,
31
+ pricingType: Predicate.isString(config.pricingType) ? config.pricingType : undefined,
32
32
  };
33
33
  });
34
- return { raw: response, models, defaultModelUid };
34
+ }
35
+
36
+ export function parseModelCatalog(response: JsonObject): Effect.Effect<ModelCatalog, DevinError> {
37
+ return Effect.gen(function* () {
38
+ if (!Array.isArray(response.clientModelConfigs)) return yield* failWith("clientModelConfigs is missing.");
39
+ const override = yield* object(response.defaultOverrideModelConfig ?? {}, "defaultOverrideModelConfig");
40
+ const defaultModelUid = Predicate.isString(override.modelUid) ? override.modelUid : undefined;
41
+ const models = yield* Effect.forEach(response.clientModelConfigs, parseModel);
42
+ return { raw: response, models, defaultModelUid };
43
+ });
35
44
  }
@@ -1,19 +1,23 @@
1
+ import { Effect } from "effect";
2
+ import type { DevinError } from "../errors.ts";
1
3
  import type { ModelInfo } from "../types.ts";
4
+ import { failWith } from "../validation.ts";
2
5
 
3
6
  /** Saved sessions and AssignModel results are already-resolved UIDs, not selectors. */
4
- export function getModelByUID(models: readonly ModelInfo[], uid: string): ModelInfo {
7
+ export function getModelByUID(models: readonly ModelInfo[], uid: string): Effect.Effect<ModelInfo, DevinError> {
5
8
  const model = models.find((entry) => entry.id === uid);
6
- if (!model) throw new Error(`${uid} is not in the model list. It is not replaced with another model automatically.`);
7
- if (model.isDisabled) throw new Error(`Model is disabled: ${uid}`);
8
- return model;
9
+ if (!model) return failWith(`${uid} is not in the model list. It is not replaced with another model automatically.`);
10
+ if (model.isDisabled) return failWith(`Model is disabled: ${uid}`);
11
+ return Effect.succeed(model);
9
12
  }
10
13
 
14
+ const normalize = (name: string) => name.trim().toLowerCase().replaceAll(" ", "-");
15
+ const tokenize = (name: string): string[] => name.toLowerCase().match(/[a-z]+|[0-9]+/g) ?? [];
16
+
11
17
  /** Resolves user selectors to match the CLI's selector semantics. The caller picks the default first. */
12
- export function selectModel(models: readonly ModelInfo[], selector: string): ModelInfo {
13
- const normalize = (name: string) => name.trim().toLowerCase().replaceAll(" ", "-");
14
- const tokenize = (name: string): string[] => name.toLowerCase().match(/[a-z]+|[0-9]+/g) ?? [];
18
+ export function selectModel(models: readonly ModelInfo[], selector: string): Effect.Effect<ModelInfo, DevinError> {
15
19
  const query = normalize(selector);
16
- if (!query) throw new Error("The model name is empty.");
20
+ if (!query) return failWith("The model name is empty.");
17
21
 
18
22
  // Families take precedence over aliases. An alias points to a family, not a declared variant.
19
23
  const family = models.find((model) => model.familyId && normalize(model.familyId) === query)
@@ -21,8 +25,7 @@ export function selectModel(models: readonly ModelInfo[], selector: string): Mod
21
25
  if (family) {
22
26
  const selected = models.find((model) => !model.isDisabled
23
27
  && (family.familyId ? model.familyId === family.familyId : model.id === family.id));
24
- if (!selected) throw new Error(`Model is disabled: ${selector}`);
25
- return selected;
28
+ return selected ? Effect.succeed(selected) : failWith(`Model is disabled: ${selector}`);
26
29
  }
27
30
 
28
31
  const exact = models.find((model) => normalize(model.id) === query)
@@ -38,6 +41,5 @@ export function selectModel(models: readonly ModelInfo[], selector: string): Mod
38
41
  : [];
39
42
  candidates.sort((left, right) => left.tokens.length - right.tokens.length
40
43
  || (left.model.id < right.model.id ? 1 : left.model.id > right.model.id ? -1 : 0));
41
- if (candidates[0]) return candidates[0].model;
42
- throw new Error(`Model unavailable: ${selector}`);
44
+ return candidates[0] ? Effect.succeed(candidates[0].model) : failWith(`Model unavailable: ${selector}`);
43
45
  }