@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.
- package/README.ko.md +3 -5
- package/README.md +3 -5
- package/index.ts +6 -15
- package/package.json +3 -1
- package/src/core/auth/access.ts +7 -3
- package/src/core/auth/callback-server.ts +57 -0
- package/src/core/auth/index.ts +2 -1
- package/src/core/auth/login.ts +104 -151
- package/src/core/auth/pkce.ts +32 -0
- package/src/core/auth/rest-exchange.ts +32 -30
- package/src/core/cancellation.ts +99 -0
- package/src/core/client.ts +127 -122
- package/src/core/errors.ts +115 -0
- package/src/core/index.ts +18 -4
- package/src/core/inference/accumulator.ts +174 -0
- package/src/core/inference/images.ts +22 -15
- package/src/core/inference/request.ts +59 -0
- package/src/core/inference/retry.ts +31 -31
- package/src/core/inference/state.ts +7 -3
- package/src/core/inference/stream-chat.ts +48 -0
- package/src/core/models/access.ts +4 -3
- package/src/core/models/catalog.ts +29 -20
- package/src/core/models/select.ts +14 -12
- package/src/core/models/variants.ts +85 -72
- package/src/core/protocol/connect-client.ts +288 -0
- package/src/core/protocol/device.ts +33 -34
- package/src/core/protocol/endpoint.ts +22 -0
- package/src/core/protocol/fingerprint.ts +38 -26
- package/src/core/protocol/frames.ts +101 -0
- package/src/core/protocol/http/fetch-client.ts +42 -0
- package/src/core/protocol/http/node-client.ts +130 -0
- package/src/core/protocol/http/response.ts +99 -0
- package/src/core/protocol/http.ts +41 -101
- package/src/core/protocol/protobuf.ts +93 -74
- package/src/core/redaction.ts +33 -0
- package/src/core/services/device-identity.ts +15 -0
- package/src/core/services/http-transport.ts +28 -0
- package/src/core/services/platform.ts +54 -0
- package/src/core/types.ts +2 -23
- package/src/core/validation.ts +25 -0
- package/src/pi/adapters/pi/devin-command.ts +82 -0
- package/src/pi/adapters/pi/fusion-command.ts +92 -0
- package/src/pi/adapters/pi/oauth.ts +14 -0
- package/src/pi/adapters/pi/provider.ts +22 -0
- package/src/pi/adapters/pi/registration.ts +89 -0
- package/src/pi/adapters/pi/response-stream.ts +164 -0
- package/src/pi/adapters/pi/sidekick-tool.ts +45 -0
- package/src/pi/adapters/pi/transcript.ts +177 -0
- package/src/pi/adapters/pi/types.ts +14 -0
- package/src/pi/{devin-panel.ts → adapters/pi/ui/devin-panel.ts} +11 -9
- package/src/pi/{fusion-picker.ts → adapters/pi/ui/fusion-picker.ts} +1 -1
- package/src/pi/adapters/pi/usage-status.ts +65 -0
- package/src/pi/adapters/storage/files.ts +66 -0
- package/src/pi/adapters/storage/json-file.ts +31 -0
- package/src/pi/app/config.ts +25 -0
- package/src/pi/app/environment.ts +30 -0
- package/src/pi/app/layer.ts +43 -0
- package/src/pi/app/runtime.ts +20 -0
- package/src/pi/app/session.ts +26 -0
- package/src/pi/features/accounts/service.ts +69 -0
- package/src/pi/features/accounts/types.ts +25 -0
- package/src/pi/features/auth/credential.ts +40 -0
- package/src/pi/features/auth/guard.ts +25 -0
- package/src/pi/features/auth/login.ts +116 -0
- package/src/pi/features/auth/state.ts +48 -0
- package/src/pi/features/auth/types.ts +36 -0
- package/src/pi/features/bounded.ts +4 -0
- package/src/pi/features/catalog/constants.ts +10 -0
- package/src/pi/features/catalog/fusion.ts +45 -0
- package/src/pi/features/catalog/ids.ts +79 -0
- package/src/pi/features/catalog/projection.ts +93 -0
- package/src/pi/features/catalog/remote.ts +15 -0
- package/src/pi/features/catalog/restore.ts +53 -0
- package/src/pi/features/catalog/service.ts +158 -0
- package/src/pi/features/catalog/types.ts +56 -0
- package/src/pi/features/clients/service.ts +47 -0
- package/src/pi/features/clients/types.ts +22 -0
- package/src/pi/features/default-scheduler.ts +12 -0
- package/src/pi/features/inference/request.ts +190 -0
- package/src/pi/features/inference/service.ts +33 -0
- package/src/pi/features/inference/types.ts +11 -0
- package/src/pi/features/lifetime/service.ts +19 -0
- package/src/pi/features/lifetime/types.ts +13 -0
- package/src/pi/features/runs/admission.ts +36 -0
- package/src/pi/features/runs/lifecycle.ts +207 -0
- package/src/pi/features/runs/service.ts +10 -0
- package/src/pi/features/runs/state.ts +37 -0
- package/src/pi/features/runs/types.ts +43 -0
- package/src/pi/features/settings/service.ts +46 -0
- package/src/pi/features/settings/types.ts +36 -0
- package/src/pi/features/sidekick/child.ts +228 -0
- package/src/pi/features/sidekick/command.ts +80 -0
- package/src/pi/features/sidekick/launcher.ts +12 -0
- package/src/pi/features/sidekick/prompts.ts +17 -0
- package/src/pi/features/sidekick/report.ts +51 -0
- package/src/pi/features/sidekick/service.ts +176 -0
- package/src/pi/features/sidekick/types.ts +99 -0
- package/src/pi/features/usage/changes.ts +20 -0
- package/src/pi/{usage.ts → features/usage/report.ts} +3 -34
- package/src/pi/features/usage/types.ts +33 -0
- package/src/core/inference/chat.ts +0 -234
- package/src/core/protocol/transport.ts +0 -513
- package/src/pi/auth.ts +0 -166
- package/src/pi/devin-command.ts +0 -86
- package/src/pi/devin-settings.ts +0 -50
- package/src/pi/extension.ts +0 -89
- package/src/pi/fusion-command.ts +0 -83
- package/src/pi/fusion-settings.ts +0 -44
- package/src/pi/lifecycle.ts +0 -217
- package/src/pi/messages.ts +0 -131
- package/src/pi/models.ts +0 -415
- package/src/pi/provider.ts +0 -278
- package/src/pi/sidekick-report.ts +0 -47
- package/src/pi/sidekick-runtime.ts +0 -300
- package/src/pi/sidekick.ts +0 -211
- package/src/pi/stream.ts +0 -124
- 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
|
-
|
|
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
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
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 {
|
|
2
|
-
import {
|
|
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
|
-
|
|
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)
|
|
26
|
-
if (!Number.isFinite(random) || random < 0 || random >= 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:
|
|
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:
|
|
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
|
|
42
|
-
request: (isRecovery: boolean) =>
|
|
43
|
-
options: {
|
|
44
|
-
):
|
|
45
|
-
const
|
|
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
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
for (let
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
if (!canRecover(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
|
|
60
|
-
|
|
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
|
-
|
|
65
|
-
|
|
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
|
-
|
|
7
|
-
|
|
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):
|
|
5
|
-
const allowed = Array.isArray(team.allowedModelUids) ? team.allowedModelUids.filter(
|
|
6
|
-
|
|
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 {
|
|
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
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
const
|
|
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:
|
|
26
|
-
aliases: Array.isArray(info.aliases) ? info.aliases.filter(
|
|
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:
|
|
30
|
-
creditMultiplier:
|
|
31
|
-
pricingType:
|
|
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
|
-
|
|
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)
|
|
7
|
-
if (model.isDisabled)
|
|
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)
|
|
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
|
-
|
|
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
|
-
|
|
42
|
-
throw new Error(`Model unavailable: ${selector}`);
|
|
44
|
+
return candidates[0] ? Effect.succeed(candidates[0].model) : failWith(`Model unavailable: ${selector}`);
|
|
43
45
|
}
|