@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.
- package/README.ko.md +222 -0
- package/README.md +37 -41
- package/index.ts +6 -12
- package/package.json +4 -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 -145
- package/src/pi/devin-command.ts +0 -76
- package/src/pi/devin-settings.ts +0 -50
- package/src/pi/extension.ts +0 -80
- 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 -379
- package/src/pi/provider.ts +0 -249
- 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
|
@@ -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
|
}
|
|
@@ -1,4 +1,7 @@
|
|
|
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
|
import { parseModelCatalog, type ModelCatalog } from "./catalog.ts";
|
|
3
6
|
import { getModelByUID } from "./select.ts";
|
|
4
7
|
|
|
@@ -43,20 +46,23 @@ export function groupModelID(family: string, dimensions: readonly ModelDimension
|
|
|
43
46
|
return "family:" + encodeURIComponent(family) + (dimensions.length ? ":" + Buffer.from(key).toString("base64url") : "");
|
|
44
47
|
}
|
|
45
48
|
|
|
46
|
-
function dimensionsOf(config: JsonObject): ModelDimension[] {
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
const
|
|
53
|
-
const
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
49
|
+
function dimensionsOf(config: JsonObject): Effect.Effect<ModelDimension[], DevinError> {
|
|
50
|
+
return Effect.gen(function* () {
|
|
51
|
+
const family = yield* object(config.modelFamilyMetadata ?? {}, "modelFamilyMetadata");
|
|
52
|
+
if (family.entries === undefined) return [];
|
|
53
|
+
if (!Array.isArray(family.entries)) return yield* failWith("Model dimension entries are not an array.");
|
|
54
|
+
const seen = new Set<string>();
|
|
55
|
+
const dimensions: ModelDimension[] = [];
|
|
56
|
+
for (const item of family.entries) {
|
|
57
|
+
const entry = yield* object(item, "model dimension"), value = yield* object(entry.value ?? {}, "dimension value");
|
|
58
|
+
const key = yield* requiredString(entry.key, "dimension key");
|
|
59
|
+
if (seen.has(key)) return yield* failWith(`Duplicate model dimension: ${key}`);
|
|
60
|
+
seen.add(key);
|
|
61
|
+
const order = yield* tokenCount(value.order ?? 0, "dimension order");
|
|
62
|
+
dimensions.push({ key, order, name: Predicate.isString(value.name) ? value.name : "" });
|
|
63
|
+
}
|
|
64
|
+
return dimensions.sort((a, b) => a.key < b.key ? -1 : a.key > b.key ? 1 : 0);
|
|
65
|
+
});
|
|
60
66
|
}
|
|
61
67
|
|
|
62
68
|
function classify(model: ModelInfo, dimensions: readonly ModelDimension[]): Pick<ModelVariant, "level" | "unsupported"> {
|
|
@@ -73,8 +79,7 @@ function classify(model: ModelInfo, dimensions: readonly ModelDimension[]): Pick
|
|
|
73
79
|
return { unsupported: "thinking strength metadata is unknown" };
|
|
74
80
|
}
|
|
75
81
|
|
|
76
|
-
function sanitizedConfig(
|
|
77
|
-
const info = object(config.modelInfo, "modelInfo");
|
|
82
|
+
function sanitizedConfig(info: JsonObject, model: ModelInfo, familyName: string, dimensions: readonly ModelDimension[]): JsonObject {
|
|
78
83
|
return {
|
|
79
84
|
modelUid: model.id, label: model.name, disabled: model.isDisabled, maxTokens: model.contextWindow,
|
|
80
85
|
supportsImages: model.supportsImages,
|
|
@@ -83,7 +88,7 @@ function sanitizedConfig(config: JsonObject, model: ModelInfo, familyName: strin
|
|
|
83
88
|
modelInfo: {
|
|
84
89
|
modelUid: model.id, modelFamilyUid: model.familyId || model.id,
|
|
85
90
|
maxTokens: model.contextWindow, maxOutputTokens: model.maxOutputTokens,
|
|
86
|
-
...(
|
|
91
|
+
...(Predicate.isString(info.modelType) ? { modelType: info.modelType } : {}),
|
|
87
92
|
isModelRouter: model.isRouter, aliases: [...(model.aliases ?? [])],
|
|
88
93
|
modelFeatures: {
|
|
89
94
|
supportsImages: model.supportsImages, supportsToolCalls: model.supportsToolCalls,
|
|
@@ -98,66 +103,74 @@ function capabilityKey(model: ModelInfo): string {
|
|
|
98
103
|
return JSON.stringify([model.contextWindow, model.maxOutputTokens, model.supportsImages, model.supportsToolCalls, model.supportsParallelToolCalls]);
|
|
99
104
|
}
|
|
100
105
|
|
|
101
|
-
export function projectModelVariants(catalog: ModelCatalog): VariantProjection {
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
106
|
+
export function projectModelVariants(catalog: ModelCatalog): Effect.Effect<VariantProjection, DevinError> {
|
|
107
|
+
return Effect.gen(function* () {
|
|
108
|
+
const configs = catalog.raw.clientModelConfigs;
|
|
109
|
+
if (!Array.isArray(configs) || configs.length !== catalog.models.length) return yield* failWith("The raw and projected model lists do not match.");
|
|
110
|
+
const ids = new Set<string>();
|
|
111
|
+
const variants: ModelVariant[] = [];
|
|
112
|
+
for (const [index, original] of catalog.models.entries()) {
|
|
113
|
+
if (ids.has(original.id)) return yield* failWith(`Duplicate model UID: ${original.id}`);
|
|
114
|
+
ids.add(original.id);
|
|
115
|
+
const config = yield* object(configs[index], "model config"), info = yield* object(config.modelInfo, "modelInfo");
|
|
116
|
+
if (config.modelUid !== original.id) return yield* failWith("Model UIDs do not match the raw order.");
|
|
117
|
+
const allDimensions = yield* dimensionsOf(config);
|
|
118
|
+
const family = yield* object(config.modelFamilyMetadata ?? {}, "modelFamilyMetadata");
|
|
119
|
+
const model = { ...original, aliases: [...(original.aliases ?? [])], contextWindow: yield* tokenCount(config.maxTokens ?? original.contextWindow, "context window") };
|
|
120
|
+
const familyName = Predicate.isString(family.modelFamilyLabel) && family.modelFamilyLabel ? family.modelFamilyLabel : model.familyId || model.name;
|
|
121
|
+
const classification = Predicate.isString(info.modelType) && info.modelType !== "MODEL_TYPE_CHAT"
|
|
122
|
+
? { unsupported: "not a chat model" } : classify(model, allDimensions);
|
|
123
|
+
variants.push({
|
|
124
|
+
model, family: model.familyId || model.id, familyName,
|
|
125
|
+
dimensions: allDimensions.filter((entry) => !thinkingDimensions.has(entry.key)), ...classification,
|
|
126
|
+
config: sanitizedConfig(info, model, familyName, allDimensions),
|
|
127
|
+
});
|
|
128
|
+
}
|
|
129
|
+
const groupMap = new Map<string, VariantGroup>();
|
|
130
|
+
for (const variant of variants) {
|
|
131
|
+
const id = groupModelID(variant.family, variant.dimensions);
|
|
132
|
+
let group = groupMap.get(id);
|
|
133
|
+
if (!group) {
|
|
134
|
+
group = { id, family: variant.family, name: variant.familyName, dimensions: variant.dimensions, variants: [], mappings: {}, canPublish: true };
|
|
135
|
+
groupMap.set(id, group);
|
|
136
|
+
}
|
|
137
|
+
group.variants.push(variant);
|
|
138
|
+
if (variant.level === undefined || variant.unsupported) continue;
|
|
139
|
+
if (group.mappings[variant.level]) { group.canPublish = false; group.reason = "ambiguous UID for thinking level"; }
|
|
140
|
+
else group.mappings[variant.level] = variant.model.id;
|
|
141
|
+
}
|
|
142
|
+
for (const group of groupMap.values()) {
|
|
143
|
+
const supported = group.variants.filter((variant) => variant.level !== undefined && !variant.unsupported);
|
|
144
|
+
if (!supported.length) { group.canPublish = false; group.reason = "no verified thinking mapping"; }
|
|
145
|
+
else if (new Set(supported.map((variant) => capabilityKey(variant.model))).size !== 1) {
|
|
146
|
+
group.canPublish = false; group.reason = "variant capabilities differ; use exact UID";
|
|
147
|
+
}
|
|
148
|
+
}
|
|
116
149
|
return {
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
config: sanitizedConfig(config, model, familyName, allDimensions),
|
|
150
|
+
variants, groups: [...groupMap.values()],
|
|
151
|
+
unsupported: variants.flatMap((variant) => variant.unsupported ? [{ uid: variant.model.id, reason: variant.unsupported }] : []),
|
|
120
152
|
};
|
|
121
153
|
});
|
|
122
|
-
const groupMap = new Map<string, VariantGroup>();
|
|
123
|
-
for (const variant of variants) {
|
|
124
|
-
const id = groupModelID(variant.family, variant.dimensions);
|
|
125
|
-
let group = groupMap.get(id);
|
|
126
|
-
if (!group) {
|
|
127
|
-
group = { id, family: variant.family, name: variant.familyName, dimensions: variant.dimensions, variants: [], mappings: {}, canPublish: true };
|
|
128
|
-
groupMap.set(id, group);
|
|
129
|
-
}
|
|
130
|
-
group.variants.push(variant);
|
|
131
|
-
if (variant.level === undefined || variant.unsupported) continue;
|
|
132
|
-
if (group.mappings[variant.level]) { group.canPublish = false; group.reason = "ambiguous UID for thinking level"; }
|
|
133
|
-
else group.mappings[variant.level] = variant.model.id;
|
|
134
|
-
}
|
|
135
|
-
for (const group of groupMap.values()) {
|
|
136
|
-
const supported = group.variants.filter((variant) => variant.level !== undefined && !variant.unsupported);
|
|
137
|
-
if (!supported.length) { group.canPublish = false; group.reason = "no verified thinking mapping"; }
|
|
138
|
-
else if (new Set(supported.map((variant) => capabilityKey(variant.model))).size !== 1) {
|
|
139
|
-
group.canPublish = false; group.reason = "variant capabilities differ; use exact UID";
|
|
140
|
-
}
|
|
141
|
-
}
|
|
142
|
-
return { variants, groups: [...groupMap.values()], unsupported: variants.filter((variant) => variant.unsupported).map((variant) => ({ uid: variant.model.id, reason: variant.unsupported! })) };
|
|
143
154
|
}
|
|
144
155
|
|
|
145
|
-
export function restoreModelVariants(configs: readonly JsonObject[]): VariantProjection {
|
|
146
|
-
return
|
|
156
|
+
export function restoreModelVariants(configs: readonly JsonObject[]): Effect.Effect<VariantProjection, DevinError> {
|
|
157
|
+
return Effect.flatMap(parseModelCatalog({ clientModelConfigs: [...configs] }), projectModelVariants);
|
|
147
158
|
}
|
|
148
159
|
|
|
149
160
|
/** Accepts only the effective level that Pi already clamped or selected. Never falls back to an alias, price or default model. */
|
|
150
|
-
export function resolveBackendSelection(projection: VariantProjection, modelID: string, level: EffectiveThinkingLevel): BackendModelSelection {
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
if (variant
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
161
|
+
export function resolveBackendSelection(projection: VariantProjection, modelID: string, level: EffectiveThinkingLevel): Effect.Effect<BackendModelSelection, DevinError> {
|
|
162
|
+
return Effect.gen(function* () {
|
|
163
|
+
if (!EFFECTIVE_THINKING_LEVELS.includes(level)) return yield* failWith(`Unsupported thinking level: ${level}`);
|
|
164
|
+
let variant = projection.variants.find((entry) => modelIDForUID(entry.model.id) === modelID);
|
|
165
|
+
if (variant) {
|
|
166
|
+
if (variant.unsupported || variant.level !== level) return yield* failWith(`This exact UID does not support the requested thinking level: ${modelID} (${level})`);
|
|
167
|
+
} else {
|
|
168
|
+
const group = projection.groups.find((entry) => entry.id === modelID);
|
|
169
|
+
const uid = group?.canPublish ? group.mappings[level] : undefined;
|
|
170
|
+
variant = uid === undefined ? undefined : projection.variants.find((entry) => entry.model.id === uid);
|
|
171
|
+
if (!variant) return yield* failWith(`Model or thinking level unavailable: ${modelID} (${level})`);
|
|
172
|
+
}
|
|
173
|
+
const model = yield* getModelByUID(projection.variants.map((entry) => entry.model), variant.model.id);
|
|
174
|
+
return { uid: model.id, model, level, variant };
|
|
175
|
+
});
|
|
163
176
|
}
|