@buyong/pi-devin 0.1.4 → 0.1.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (116) hide show
  1. package/README.ko.md +222 -0
  2. package/README.md +37 -41
  3. package/index.ts +6 -12
  4. package/package.json +4 -1
  5. package/src/core/auth/access.ts +7 -3
  6. package/src/core/auth/callback-server.ts +57 -0
  7. package/src/core/auth/index.ts +2 -1
  8. package/src/core/auth/login.ts +104 -151
  9. package/src/core/auth/pkce.ts +32 -0
  10. package/src/core/auth/rest-exchange.ts +32 -30
  11. package/src/core/cancellation.ts +99 -0
  12. package/src/core/client.ts +127 -122
  13. package/src/core/errors.ts +115 -0
  14. package/src/core/index.ts +18 -4
  15. package/src/core/inference/accumulator.ts +174 -0
  16. package/src/core/inference/images.ts +22 -15
  17. package/src/core/inference/request.ts +59 -0
  18. package/src/core/inference/retry.ts +31 -31
  19. package/src/core/inference/state.ts +7 -3
  20. package/src/core/inference/stream-chat.ts +48 -0
  21. package/src/core/models/access.ts +4 -3
  22. package/src/core/models/catalog.ts +29 -20
  23. package/src/core/models/select.ts +14 -12
  24. package/src/core/models/variants.ts +85 -72
  25. package/src/core/protocol/connect-client.ts +288 -0
  26. package/src/core/protocol/device.ts +33 -34
  27. package/src/core/protocol/endpoint.ts +22 -0
  28. package/src/core/protocol/fingerprint.ts +38 -26
  29. package/src/core/protocol/frames.ts +101 -0
  30. package/src/core/protocol/http/fetch-client.ts +42 -0
  31. package/src/core/protocol/http/node-client.ts +130 -0
  32. package/src/core/protocol/http/response.ts +99 -0
  33. package/src/core/protocol/http.ts +41 -101
  34. package/src/core/protocol/protobuf.ts +93 -74
  35. package/src/core/redaction.ts +33 -0
  36. package/src/core/services/device-identity.ts +15 -0
  37. package/src/core/services/http-transport.ts +28 -0
  38. package/src/core/services/platform.ts +54 -0
  39. package/src/core/types.ts +2 -23
  40. package/src/core/validation.ts +25 -0
  41. package/src/pi/adapters/pi/devin-command.ts +82 -0
  42. package/src/pi/adapters/pi/fusion-command.ts +92 -0
  43. package/src/pi/adapters/pi/oauth.ts +14 -0
  44. package/src/pi/adapters/pi/provider.ts +22 -0
  45. package/src/pi/adapters/pi/registration.ts +89 -0
  46. package/src/pi/adapters/pi/response-stream.ts +164 -0
  47. package/src/pi/adapters/pi/sidekick-tool.ts +45 -0
  48. package/src/pi/adapters/pi/transcript.ts +177 -0
  49. package/src/pi/adapters/pi/types.ts +14 -0
  50. package/src/pi/{devin-panel.ts → adapters/pi/ui/devin-panel.ts} +11 -9
  51. package/src/pi/{fusion-picker.ts → adapters/pi/ui/fusion-picker.ts} +1 -1
  52. package/src/pi/adapters/pi/usage-status.ts +65 -0
  53. package/src/pi/adapters/storage/files.ts +66 -0
  54. package/src/pi/adapters/storage/json-file.ts +31 -0
  55. package/src/pi/app/config.ts +25 -0
  56. package/src/pi/app/environment.ts +30 -0
  57. package/src/pi/app/layer.ts +43 -0
  58. package/src/pi/app/runtime.ts +20 -0
  59. package/src/pi/app/session.ts +26 -0
  60. package/src/pi/features/accounts/service.ts +69 -0
  61. package/src/pi/features/accounts/types.ts +25 -0
  62. package/src/pi/features/auth/credential.ts +40 -0
  63. package/src/pi/features/auth/guard.ts +25 -0
  64. package/src/pi/features/auth/login.ts +116 -0
  65. package/src/pi/features/auth/state.ts +48 -0
  66. package/src/pi/features/auth/types.ts +36 -0
  67. package/src/pi/features/bounded.ts +4 -0
  68. package/src/pi/features/catalog/constants.ts +10 -0
  69. package/src/pi/features/catalog/fusion.ts +45 -0
  70. package/src/pi/features/catalog/ids.ts +79 -0
  71. package/src/pi/features/catalog/projection.ts +93 -0
  72. package/src/pi/features/catalog/remote.ts +15 -0
  73. package/src/pi/features/catalog/restore.ts +53 -0
  74. package/src/pi/features/catalog/service.ts +158 -0
  75. package/src/pi/features/catalog/types.ts +56 -0
  76. package/src/pi/features/clients/service.ts +47 -0
  77. package/src/pi/features/clients/types.ts +22 -0
  78. package/src/pi/features/default-scheduler.ts +12 -0
  79. package/src/pi/features/inference/request.ts +190 -0
  80. package/src/pi/features/inference/service.ts +33 -0
  81. package/src/pi/features/inference/types.ts +11 -0
  82. package/src/pi/features/lifetime/service.ts +19 -0
  83. package/src/pi/features/lifetime/types.ts +13 -0
  84. package/src/pi/features/runs/admission.ts +36 -0
  85. package/src/pi/features/runs/lifecycle.ts +207 -0
  86. package/src/pi/features/runs/service.ts +10 -0
  87. package/src/pi/features/runs/state.ts +37 -0
  88. package/src/pi/features/runs/types.ts +43 -0
  89. package/src/pi/features/settings/service.ts +46 -0
  90. package/src/pi/features/settings/types.ts +36 -0
  91. package/src/pi/features/sidekick/child.ts +228 -0
  92. package/src/pi/features/sidekick/command.ts +80 -0
  93. package/src/pi/features/sidekick/launcher.ts +12 -0
  94. package/src/pi/features/sidekick/prompts.ts +17 -0
  95. package/src/pi/features/sidekick/report.ts +51 -0
  96. package/src/pi/features/sidekick/service.ts +176 -0
  97. package/src/pi/features/sidekick/types.ts +99 -0
  98. package/src/pi/features/usage/changes.ts +20 -0
  99. package/src/pi/{usage.ts → features/usage/report.ts} +3 -34
  100. package/src/pi/features/usage/types.ts +33 -0
  101. package/src/core/inference/chat.ts +0 -234
  102. package/src/core/protocol/transport.ts +0 -513
  103. package/src/pi/auth.ts +0 -145
  104. package/src/pi/devin-command.ts +0 -76
  105. package/src/pi/devin-settings.ts +0 -50
  106. package/src/pi/extension.ts +0 -80
  107. package/src/pi/fusion-command.ts +0 -83
  108. package/src/pi/fusion-settings.ts +0 -44
  109. package/src/pi/lifecycle.ts +0 -217
  110. package/src/pi/messages.ts +0 -131
  111. package/src/pi/models.ts +0 -379
  112. package/src/pi/provider.ts +0 -249
  113. package/src/pi/sidekick-report.ts +0 -47
  114. package/src/pi/sidekick-runtime.ts +0 -300
  115. package/src/pi/sidekick.ts +0 -211
  116. package/src/pi/stream.ts +0 -124
@@ -1,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
  }
@@ -1,4 +1,7 @@
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
  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
- const family = object(config.modelFamilyMetadata ?? {}, "modelFamilyMetadata");
48
- if (family.entries === undefined) return [];
49
- if (!Array.isArray(family.entries)) throw new Error("Model dimension entries are not an array.");
50
- const seen = new Set<string>();
51
- return family.entries.map((item) => {
52
- const entry = object(item, "model dimension"), value = object(entry.value ?? {}, "dimension value");
53
- const key = requiredString(entry.key, "dimension key");
54
- if (seen.has(key)) throw new Error(`Duplicate model dimension: ${key}`);
55
- seen.add(key);
56
- const order = tokenCount(value.order ?? 0, "dimension order");
57
- if (!Number.isSafeInteger(order)) throw new Error("The model dimension order must be an integer.");
58
- return { key, order, name: typeof value.name === "string" ? value.name : "" };
59
- }).sort((a, b) => a.key < b.key ? -1 : a.key > b.key ? 1 : 0);
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(config: JsonObject, model: ModelInfo, familyName: string, dimensions: readonly ModelDimension[]): JsonObject {
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
- ...(typeof info.modelType === "string" ? { modelType: info.modelType } : {}),
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
- const configs = catalog.raw.clientModelConfigs;
103
- if (!Array.isArray(configs) || configs.length !== catalog.models.length) throw new Error("The raw and projected model lists do not match.");
104
- const ids = new Set<string>();
105
- const variants = catalog.models.map((original, index): ModelVariant => {
106
- if (ids.has(original.id)) throw new Error(`Duplicate model UID: ${original.id}`);
107
- ids.add(original.id);
108
- const config = object(configs[index], "model config"), info = object(config.modelInfo, "modelInfo");
109
- if (config.modelUid !== original.id) throw new Error("Model UIDs do not match the raw order.");
110
- const allDimensions = dimensionsOf(config);
111
- const family = object(config.modelFamilyMetadata ?? {}, "modelFamilyMetadata");
112
- const model = { ...original, aliases: [...(original.aliases ?? [])], contextWindow: tokenCount(config.maxTokens ?? original.contextWindow, "context window") };
113
- const familyName = typeof family.modelFamilyLabel === "string" && family.modelFamilyLabel ? family.modelFamilyLabel : model.familyId || model.name;
114
- const classification = typeof info.modelType === "string" && info.modelType !== "MODEL_TYPE_CHAT"
115
- ? { unsupported: "not a chat model" } : classify(model, allDimensions);
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
- model, family: model.familyId || model.id, familyName,
118
- dimensions: allDimensions.filter((entry) => !thinkingDimensions.has(entry.key)), ...classification,
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 projectModelVariants(parseModelCatalog({ clientModelConfigs: [...configs] }));
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
- if (!EFFECTIVE_THINKING_LEVELS.includes(level)) throw new Error(`Unsupported thinking level: ${level}`);
152
- let variant = projection.variants.find((entry) => modelIDForUID(entry.model.id) === modelID);
153
- if (variant) {
154
- if (variant.unsupported || variant.level !== level) throw new Error(`This exact UID does not support the requested thinking level: ${modelID} (${level})`);
155
- } else {
156
- const group = projection.groups.find((entry) => entry.id === modelID);
157
- const uid = group?.canPublish ? group.mappings[level] : undefined;
158
- if (!uid) throw new Error(`Model or thinking level unavailable: ${modelID} (${level})`);
159
- variant = projection.variants.find((entry) => entry.model.id === uid)!;
160
- }
161
- const model = getModelByUID(projection.variants.map((entry) => entry.model), variant.model.id);
162
- return { uid: model.id, model, level, variant };
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
  }