@buyong/pi-devin 0.0.0-stage → 0.1.1

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 (107) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +214 -2
  3. package/index.ts +16 -0
  4. package/package.json +62 -3
  5. package/proto/README.md +78 -0
  6. package/proto/buf/validate/validate.proto +453 -0
  7. package/proto/cel/expr/checked.proto +109 -0
  8. package/proto/cel/expr/eval.proto +44 -0
  9. package/proto/cel/expr/explain.proto +22 -0
  10. package/proto/cel/expr/syntax.proto +119 -0
  11. package/proto/cel/expr/value.proto +47 -0
  12. package/proto/connectext/grpc/status/v1/status.proto +21 -0
  13. package/proto/errorspb/errors.proto +58 -0
  14. package/proto/errorspb/hintdetail.proto +9 -0
  15. package/proto/errorspb/markers.proto +12 -0
  16. package/proto/errorspb/tags.proto +14 -0
  17. package/proto/errorspb/testing.proto +8 -0
  18. package/proto/exa/analytics_pb/analytics.proto +190 -0
  19. package/proto/exa/api_server_pb/api_server.proto +2590 -0
  20. package/proto/exa/auth_pb/auth.proto +21 -0
  21. package/proto/exa/auto_cascade_common_pb/auto_cascade_common.proto +81 -0
  22. package/proto/exa/browser_preview_pb/browser_preview.proto +26 -0
  23. package/proto/exa/bug_checker_pb/bug_checker.proto +24 -0
  24. package/proto/exa/cascade_plugins_pb/cascade_plugins.proto +264 -0
  25. package/proto/exa/chat_client_server_pb/chat_client_server.proto +57 -0
  26. package/proto/exa/chat_pb/chat.proto +452 -0
  27. package/proto/exa/code_edit/code_edit_pb/code_edit.proto +186 -0
  28. package/proto/exa/codeium_common_pb/browser_context.proto +31 -0
  29. package/proto/exa/codeium_common_pb/codeium_common.proto +4183 -0
  30. package/proto/exa/context_module_pb/context_module.proto +174 -0
  31. package/proto/exa/cortex_pb/cortex.proto +3288 -0
  32. package/proto/exa/dev_pb/dev.proto +28 -0
  33. package/proto/exa/diff_action_pb/diff_action.proto +76 -0
  34. package/proto/exa/eval/pr_eval/datasets_pb/datasets.proto +106 -0
  35. package/proto/exa/eval_pb/eval.proto +1318 -0
  36. package/proto/exa/extension_server_pb/extension_server.proto +555 -0
  37. package/proto/exa/file_system_provider_pb/file_system_provider.proto +75 -0
  38. package/proto/exa/index_pb/index.proto +469 -0
  39. package/proto/exa/knowledge_base_pb/knowledge_base.proto +146 -0
  40. package/proto/exa/language_server_pb/language_server.proto +2435 -0
  41. package/proto/exa/model_management_pb/model_management.proto +184 -0
  42. package/proto/exa/opensearch_clients_pb/opensearch_clients.proto +495 -0
  43. package/proto/exa/product_analytics_pb/product_analytics.proto +39 -0
  44. package/proto/exa/prompt_pb/prompt.proto +97 -0
  45. package/proto/exa/reactive_component_pb/reactive_component.proto +98 -0
  46. package/proto/exa/seat_management_pb/seat_management.proto +2910 -0
  47. package/proto/exa/tokenizer_pb/tokenizer.proto +39 -0
  48. package/proto/exa/trainer_pb/config.proto +643 -0
  49. package/proto/exa/tree_sitter/language_data_pb/language_data.proto +14 -0
  50. package/proto/exa/trust_pb/trust.proto +192 -0
  51. package/proto/exa/user_analytics_pb/user_analytics.proto +752 -0
  52. package/proto/google/api/expr/v1alpha1/checked.proto +108 -0
  53. package/proto/google/api/expr/v1alpha1/eval.proto +38 -0
  54. package/proto/google/api/expr/v1alpha1/explain.proto +22 -0
  55. package/proto/google/api/expr/v1alpha1/syntax.proto +126 -0
  56. package/proto/google/api/expr/v1alpha1/value.proto +47 -0
  57. package/proto/google/protobuf/any.proto +15 -0
  58. package/proto/google/protobuf/cpp_features.proto +22 -0
  59. package/proto/google/protobuf/descriptor.proto +473 -0
  60. package/proto/google/protobuf/duration.proto +16 -0
  61. package/proto/google/protobuf/empty.proto +14 -0
  62. package/proto/google/protobuf/go_features.proto +31 -0
  63. package/proto/google/protobuf/struct.proto +34 -0
  64. package/proto/google/protobuf/timestamp.proto +16 -0
  65. package/proto/google/protobuf/wrappers.proto +47 -0
  66. package/proto/google/rpc/status.proto +18 -0
  67. package/proto/grpc/binlog/v1/binarylog.proto +89 -0
  68. package/proto/io/prometheus/client/metrics.proto +103 -0
  69. package/src/core/auth/access.ts +6 -0
  70. package/src/core/auth/index.ts +2 -0
  71. package/src/core/auth/login.ts +177 -0
  72. package/src/core/auth/rest-exchange.ts +35 -0
  73. package/src/core/client.ts +153 -0
  74. package/src/core/index.ts +12 -0
  75. package/src/core/inference/chat.ts +229 -0
  76. package/src/core/inference/images.ts +24 -0
  77. package/src/core/inference/messages.ts +34 -0
  78. package/src/core/inference/retry.ts +68 -0
  79. package/src/core/inference/state.ts +8 -0
  80. package/src/core/models/access.ts +7 -0
  81. package/src/core/models/catalog.ts +35 -0
  82. package/src/core/models/index.ts +4 -0
  83. package/src/core/models/select.ts +43 -0
  84. package/src/core/models/variants.ts +163 -0
  85. package/src/core/protocol/device.ts +73 -0
  86. package/src/core/protocol/fingerprint.ts +59 -0
  87. package/src/core/protocol/http.ts +109 -0
  88. package/src/core/protocol/protobuf.ts +112 -0
  89. package/src/core/protocol/transport.ts +501 -0
  90. package/src/core/types.ts +115 -0
  91. package/src/pi/auth.ts +134 -0
  92. package/src/pi/devin-command.ts +76 -0
  93. package/src/pi/devin-panel.ts +77 -0
  94. package/src/pi/devin-settings.ts +50 -0
  95. package/src/pi/extension.ts +80 -0
  96. package/src/pi/fusion-command.ts +83 -0
  97. package/src/pi/fusion-picker.ts +171 -0
  98. package/src/pi/fusion-settings.ts +44 -0
  99. package/src/pi/lifecycle.ts +217 -0
  100. package/src/pi/messages.ts +107 -0
  101. package/src/pi/models.ts +379 -0
  102. package/src/pi/provider.ts +241 -0
  103. package/src/pi/sidekick-report.ts +42 -0
  104. package/src/pi/sidekick-runtime.ts +287 -0
  105. package/src/pi/sidekick.ts +190 -0
  106. package/src/pi/stream.ts +124 -0
  107. package/src/pi/usage.ts +153 -0
@@ -0,0 +1,229 @@
1
+ import { randomUUID } from "node:crypto";
2
+ import { object, requiredString, tokenCount, type ChatMessage, type ChatResult, type ChatTool, type ImageData, type JsonObject, type ToolCall, type Usage } from "../types.ts";
3
+ import type { ConnectClient } from "../protocol/transport.ts";
4
+ import { CHAT_METHOD } from "../protocol/protobuf.ts";
5
+ import { wireMessages } from "./messages.ts";
6
+
7
+ export const DEFAULT_SYSTEM_PROMPT = "You are a helpful assistant. Follow the user\'s request concisely.";
8
+
9
+ export function userMessage(prompt: string, images: readonly ImageData[] = []): ChatMessage {
10
+ return {
11
+ messageId: randomUUID(), source: "CHAT_MESSAGE_SOURCE_USER", prompt,
12
+ ...(images.length ? { images: images.map((image) => ({ ...image })) } : {}),
13
+ };
14
+ }
15
+
16
+ export function chatRequest(options: {
17
+ metadata: JsonObject;
18
+ model: string;
19
+ messages: ChatMessage[];
20
+ systemPrompt: string;
21
+ maxTokens: number;
22
+ cascadeId: string;
23
+ trajectoryId: string;
24
+ executionId?: string;
25
+ clientSessionId?: string;
26
+ modelAssignmentJwt?: string;
27
+ stepIndex?: number;
28
+ isAuxiliary?: boolean;
29
+ isRecovery?: boolean;
30
+ includeContext?: boolean;
31
+ cwd?: string;
32
+ tools?: ChatTool[];
33
+ }): JsonObject {
34
+ return {
35
+ metadata: options.metadata,
36
+ prompt: options.systemPrompt,
37
+ chatMessagePrompts: wireMessages(options.messages, { includeContext: options.includeContext, cwd: options.cwd }),
38
+ chatModelUid: options.model,
39
+ requestType: "CHAT_MESSAGE_REQUEST_TYPE_CASCADE",
40
+ configuration: {
41
+ maxTokens: options.maxTokens,
42
+ numCompletions: 1,
43
+ maxNewlines: 400,
44
+ temperature: 1,
45
+ topK: 40,
46
+ topP: Math.fround(0.95),
47
+ },
48
+ trajectoryReference: {
49
+ trajectoryId: options.trajectoryId,
50
+ trajectoryType: "CORTEX_TRAJECTORY_TYPE_CASCADE",
51
+ ...(!options.isAuxiliary && !options.isRecovery && options.messages.at(-1)?.source === "CHAT_MESSAGE_SOURCE_USER" ? { stepType: "CORTEX_STEP_TYPE_USER_INPUT" } : {}),
52
+ ...(options.stepIndex ? { stepIndex: options.stepIndex } : {}),
53
+ },
54
+ cascadeId: options.cascadeId,
55
+ plannerMode: "CONVERSATIONAL_PLANNER_MODE_DEFAULT",
56
+ ...(!options.isAuxiliary && options.executionId ? { executionId: options.executionId } : {}),
57
+ ...(options.clientSessionId ? { clientSessionId: options.clientSessionId } : {}),
58
+ ...(options.modelAssignmentJwt ? { modelAssignmentJwt: options.modelAssignmentJwt } : {}),
59
+ ...(options.tools?.length ? { tools: options.tools } : {}),
60
+ };
61
+ }
62
+
63
+ /** Attaches argument deltas without an ID to the call that started with the first ID. Ambiguous parallel deltas are rejected. */
64
+ export class ChatAccumulator {
65
+ readonly message: ChatMessage = { messageId: "", source: "CHAT_MESSAGE_SOURCE_SYSTEM", prompt: "" };
66
+ readonly usage: Usage = { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, totalTokens: 0 };
67
+ readonly calls = new Map<string, ToolCall>();
68
+ private currentCall?: ToolCall;
69
+ private hasEnded = false;
70
+ private startedAt = performance.now();
71
+ private firstDeltaMs?: number;
72
+ private stopReason = "";
73
+ private usageReported = false;
74
+ private frameCount = 0;
75
+ private stopFrameIndex?: number;
76
+ private lastUsageFrameIndex?: number;
77
+ private idlessToolDeltaCount = 0;
78
+ private actualModelUid?: string;
79
+ private usageModelUid?: string;
80
+
81
+ accept(data: JsonObject, isEnd = false): void {
82
+ if (this.hasEnded) throw new Error("Cannot accumulate a response after EndStream.");
83
+ this.frameCount++;
84
+ if (isEnd) {
85
+ if (data.error) throw new Error("An error EndStream cannot be treated as success.");
86
+ this.hasEnded = true;
87
+ return;
88
+ }
89
+ if (data.redact === true) throw new Error("The server redacted the response.");
90
+ if (typeof data.messageId === "string" && data.messageId) this.message.messageId = data.messageId;
91
+ if (data.thinkingRedacted === true) {
92
+ this.message.thinkingRedacted = true;
93
+ this.message.thinking = "";
94
+ }
95
+ if (typeof data.deltaText === "string") this.message.prompt += data.deltaText;
96
+ if (typeof data.deltaThinking === "string" && !this.message.thinkingRedacted) {
97
+ this.message.thinking = (this.message.thinking ?? "") + data.deltaThinking;
98
+ }
99
+ if (typeof data.deltaSignature === "string") this.message.signature = (this.message.signature ?? "") + data.deltaSignature;
100
+ for (const [source, target] of [
101
+ ["deltaSignatureType", "signatureType"], ["geminiThoughtSignature", "geminiThoughtSignature"],
102
+ ["outputId", "outputId"], ["thinkingId", "thinkingId"], ["phase", "phase"],
103
+ ] as const) {
104
+ if (typeof data[source] === "string") this.message[target] = data[source];
105
+ }
106
+ if (typeof data.actualModelUid === "string") this.actualModelUid = data.actualModelUid;
107
+ if (data.deltaToolCalls !== undefined) {
108
+ if (!Array.isArray(data.deltaToolCalls)) throw new Error("deltaToolCalls must be an array.");
109
+ for (const value of data.deltaToolCalls) {
110
+ const delta = object(value, "tool delta");
111
+ const id = typeof delta.id === "string" && delta.id ? delta.id : undefined;
112
+ if (id) {
113
+ let call = this.calls.get(id);
114
+ if (!call) {
115
+ call = { id, name: "", argumentsJson: "" };
116
+ this.calls.set(id, call);
117
+ }
118
+ this.currentCall = call;
119
+ } else {
120
+ this.idlessToolDeltaCount++;
121
+ if (data.deltaToolCalls.length !== 1 || !this.currentCall || this.calls.size > 1) {
122
+ throw new Error("Cannot attach a delta without a tool ID to a specific call.");
123
+ }
124
+ }
125
+ const call = this.currentCall!;
126
+ if (delta.isCustomToolCall) throw new Error("Grammar-based custom tools are not supported.");
127
+ if (delta.invalidJsonErr || delta.invalidJsonStr) throw new Error("The server reported a tool argument JSON error.");
128
+ if (typeof delta.name === "string") call.name += delta.name;
129
+ if (typeof delta.argumentsJson === "string") call.argumentsJson += delta.argumentsJson;
130
+ }
131
+ }
132
+ if (data.deltaText || data.deltaThinking || data.deltaToolCalls) {
133
+ this.firstDeltaMs ??= performance.now() - this.startedAt;
134
+ }
135
+ if (typeof data.stopReason === "string" && data.stopReason) {
136
+ this.stopReason = data.stopReason;
137
+ this.stopFrameIndex = this.frameCount;
138
+ }
139
+ if (data.usage !== undefined) {
140
+ const usage = object(data.usage, "usage");
141
+ if (typeof usage.modelUid === "string") this.usageModelUid = usage.modelUid;
142
+ for (const [source, target] of [
143
+ ["inputTokens", "input"], ["outputTokens", "output"],
144
+ ["cacheReadTokens", "cacheRead"], ["cacheWriteTokens", "cacheWrite"],
145
+ ] as const) {
146
+ if (usage[source] !== undefined) {
147
+ this.usage[target] = tokenCount(usage[source], source);
148
+ this.usageReported = true;
149
+ this.lastUsageFrameIndex = this.frameCount;
150
+ }
151
+ }
152
+ this.usage.totalTokens = this.usage.input + this.usage.output + this.usage.cacheRead + this.usage.cacheWrite;
153
+ }
154
+ }
155
+
156
+ finish(): ChatResult {
157
+ if (!this.hasEnded) throw new Error("No successful EndStream.");
158
+ if (!["STOP_REASON_STOP_PATTERN", "STOP_REASON_FUNCTION_CALL", "STOP_REASON_MAX_TOKENS"].includes(this.stopReason)) {
159
+ throw new Error(`stopReason cannot be treated as success: ${this.stopReason || "(none)"}`);
160
+ }
161
+ if (this.calls.size > 0) {
162
+ this.message.toolCalls = [...this.calls.values()];
163
+ for (const call of this.message.toolCalls) {
164
+ requiredString(call.name, "tool name");
165
+ let args: unknown;
166
+ try { args = JSON.parse(call.argumentsJson); }
167
+ catch { throw new Error(`Arguments of tool ${call.name} are not valid JSON.`); }
168
+ object(args, "tool arguments");
169
+ }
170
+ }
171
+ if (this.stopReason === "STOP_REASON_FUNCTION_CALL" && !this.calls.size) {
172
+ throw new Error("The response ended with FUNCTION_CALL but has no tool call.");
173
+ }
174
+ if (this.calls.size && this.stopReason !== "STOP_REASON_FUNCTION_CALL") {
175
+ throw new Error("Tool calls and the stop reason do not match.");
176
+ }
177
+ this.message.messageId ||= randomUUID();
178
+ return {
179
+ message: this.message,
180
+ stopReason: this.stopReason,
181
+ usage: this.usage,
182
+ usageReported: this.usageReported,
183
+ frameCount: this.frameCount,
184
+ stopFrameIndex: this.stopFrameIndex,
185
+ lastUsageFrameIndex: this.lastUsageFrameIndex,
186
+ idlessToolDeltaCount: this.idlessToolDeltaCount,
187
+ durationMs: Math.round(performance.now() - this.startedAt),
188
+ firstDeltaMs: this.firstDeltaMs === undefined ? undefined : Math.round(this.firstDeltaMs),
189
+ actualModelUid: this.actualModelUid,
190
+ ...(this.usageModelUid ? { usageModelUid: this.usageModelUid } : {}),
191
+ };
192
+ }
193
+ }
194
+
195
+ export async function streamChat(
196
+ client: ConnectClient,
197
+ request: JsonObject,
198
+ options: { signal?: AbortSignal; onText?: (delta: string) => void; stage?: string; onDispatched?: () => void } = {},
199
+ ): Promise<ChatResult> {
200
+ const accumulator = new ChatAccumulator();
201
+ for await (const frame of client.stream(CHAT_METHOD, request, options.signal, { stage: options.stage, onDispatched: options.onDispatched })) {
202
+ accumulator.accept(frame.data, frame.isEnd);
203
+ if (!frame.isEnd && typeof frame.data.deltaText === "string") options.onText?.(frame.data.deltaText);
204
+ }
205
+ const result = accumulator.finish();
206
+ // Field 22 records the model of the generation request, not actualModelUid/usage.modelUid.
207
+ if (typeof request.chatModelUid === "string") result.message.modelUid = request.chatModelUid;
208
+ return result;
209
+ }
210
+
211
+ /** Raw thinking and signatures never go to logs or reports. They are only resent inside requests. */
212
+ export function summarizeChat(result: ChatResult): JsonObject {
213
+ return {
214
+ text: result.message.prompt,
215
+ stopReason: result.stopReason,
216
+ usage: result.usage,
217
+ usageReported: result.usageReported,
218
+ toolCalls: result.message.toolCalls ?? [],
219
+ thinkingChars: result.message.thinking?.length ?? 0,
220
+ hasSignature: Boolean(result.message.signature || result.message.geminiThoughtSignature),
221
+ frameCount: result.frameCount,
222
+ stopFrameIndex: result.stopFrameIndex,
223
+ lastUsageFrameIndex: result.lastUsageFrameIndex,
224
+ idlessToolDeltaCount: result.idlessToolDeltaCount,
225
+ durationMs: result.durationMs,
226
+ firstDeltaMs: result.firstDeltaMs,
227
+ actualModelUid: result.actualModelUid,
228
+ };
229
+ }
@@ -0,0 +1,24 @@
1
+ import type { ImageData } from "../types.ts";
2
+
3
+ export const MAX_IMAGE_BYTES = 4 * 1024 * 1024;
4
+ export const MAX_IMAGES_BYTES = 5 * 1024 * 1024;
5
+
6
+ export function imageMimeType(bytes: Uint8Array): ImageData["mimeType"] {
7
+ 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.");
13
+ }
14
+
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 };
24
+ }
@@ -0,0 +1,34 @@
1
+ import { randomUUID } from "node:crypto";
2
+ import { platform, release, type as osType } from "node:os";
3
+ import type { ChatMessage, JsonObject } from "../types.ts";
4
+
5
+ export function environmentContext(cwd: string, now = new Date()): string {
6
+ const platformName = platform() === "darwin" ? "macos" : platform() === "win32" ? "windows" : platform();
7
+ const date = `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, "0")}-${String(now.getDate()).padStart(2, "0")}`;
8
+ const weekday = new Intl.DateTimeFormat("en-US", { weekday: "long" }).format(now);
9
+ return `<system_info>\nThe following information is automatically generated context about your current environment.\nCurrent workspace directories:\n ${cwd} (cwd)\n\nPlatform: ${platformName}\nOS Version: ${osType()} ${release()}\nToday's date: ${weekday}, ${date}\n</system_info>`;
10
+ }
11
+
12
+ /** Creates new wire message IDs without mutating history objects. Tool call IDs, signatures and images are preserved. */
13
+ export function wireMessages(messages: readonly ChatMessage[], options: { cwd?: string; includeContext?: boolean } = {}): JsonObject[] {
14
+ const wire = messages.map((message) => {
15
+ const result: JsonObject = { ...message, messageId: randomUUID() };
16
+ if (!message.prompt) delete result.prompt;
17
+ if (message.toolResultIsError === false) delete result.toolResultIsError;
18
+ if (message.thinking === "") delete result.thinking;
19
+ if (message.signature === "") delete result.signature;
20
+ if (message.source === "CHAT_MESSAGE_SOURCE_USER" || message.source === "CHAT_MESSAGE_SOURCE_TOOL") {
21
+ result.promptCacheOptions ??= { type: "CACHE_CONTROL_TYPE_EPHEMERAL" };
22
+ }
23
+ return result;
24
+ });
25
+ if (options.includeContext && wire.length) {
26
+ wire.unshift({ messageId: randomUUID(), source: "CHAT_MESSAGE_SOURCE_USER", prompt: environmentContext(options.cwd ?? process.cwd()) });
27
+ // Does not advertise built-in skills that do not exist here. The content is limited to allowed prompt/tool differences.
28
+ wire.splice(2, 0, {
29
+ messageId: randomUUID(), source: "CHAT_MESSAGE_SOURCE_USER",
30
+ prompt: "<available_skills>\nNo skills are configured for this PoC. Use only tools provided in the request.\n</available_skills>",
31
+ });
32
+ }
33
+ return wire;
34
+ }
@@ -0,0 +1,68 @@
1
+ import { setTimeout as delay } from "node:timers/promises";
2
+ import { awaitWithSignal, RPCError } from "../protocol/transport.ts";
3
+
4
+ const CREATION_ATTEMPTS = 5;
5
+ const RECOVERY_ATTEMPTS = 3;
6
+ const INITIAL_INTERVAL_MS = 2000;
7
+ const MAX_INTERVAL_MS = 60_000;
8
+ const RETRYABLE_HTTP_STATUSES = new Set([408, 429, 500, 502, 503, 504]);
9
+ const RETRYABLE_CONNECT_CODES = new Set(["resource_exhausted", "deadline_exceeded", "internal", "unavailable"]);
10
+
11
+ export interface InferenceRetryRuntime {
12
+ random?: () => number;
13
+ wait?: (delayMs: number, signal: AbortSignal) => Promise<void>;
14
+ }
15
+
16
+ export interface InferenceRetryEvent {
17
+ phase: "creation" | "recovery";
18
+ nextAttempt: number;
19
+ limit: number;
20
+ delayMs: number;
21
+ }
22
+
23
+ /** 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.");
27
+ const intervalMs = Math.min(INITIAL_INTERVAL_MS * 2 ** retryIndex, MAX_INTERVAL_MS);
28
+ const intervalNanos = intervalMs * 1_000_000;
29
+ return Math.floor((intervalNanos * 0.5 + random * (intervalNanos + 1)) / 1_000_000);
30
+ }
31
+
32
+ function canRetryCreation(error: unknown): boolean {
33
+ return error instanceof RPCError && error.httpStatus !== undefined && RETRYABLE_HTTP_STATUSES.has(error.httpStatus);
34
+ }
35
+
36
+ function canRecover(error: unknown): boolean {
37
+ return canRetryCreation(error) || (error instanceof RPCError && error.httpStatus === undefined && RETRYABLE_CONNECT_CODES.has(error.code));
38
+ }
39
+
40
+ /** 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;
46
+ 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;
56
+ lastError = error;
57
+ 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; }
62
+ }
63
+ }
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;
68
+ }
@@ -0,0 +1,8 @@
1
+ export const MAX_STEP_INDEX = 2_147_483_647;
2
+
3
+ export interface StepState { nextStepIndex: number }
4
+
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++;
8
+ }
@@ -0,0 +1,7 @@
1
+ import type { JsonObject, ModelInfo } from "../types.ts";
2
+
3
+ /** 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);
7
+ }
@@ -0,0 +1,35 @@
1
+ import { object, requiredString, tokenCount, type JsonObject, type ModelInfo } from "../types.ts";
2
+
3
+ export interface ModelCatalog { raw: JsonObject; models: ModelInfo[]; defaultModelUid?: string }
4
+
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");
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"),
19
+ supportsThinking: features.supportsThinking === true,
20
+ supportsImages: config.supportsImages === true || features.supportsImages === true,
21
+ supportsToolCalls: features.supportsToolCalls === true,
22
+ supportsParallelToolCalls: features.supportsParallelToolCalls === true,
23
+ isRouter: info.isModelRouter === true,
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") : [],
27
+ isDefaultInFamily: config.isDefaultModelInFamily === true || family.isDefaultModelInFamily === true,
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,
32
+ };
33
+ });
34
+ return { raw: response, models, defaultModelUid };
35
+ }
@@ -0,0 +1,4 @@
1
+ export { getModelByUID, selectModel } from "./select.ts";
2
+ export { parseModelCatalog, type ModelCatalog } from "./catalog.ts";
3
+ export { EFFECTIVE_THINKING_LEVELS, groupModelID, modelIDForUID, projectModelVariants, restoreModelVariants, resolveBackendSelection } from "./variants.ts";
4
+ export type { EffectiveThinkingLevel, ModelDimension, ModelVariant, VariantGroup, VariantProjection, BackendModelSelection } from "./variants.ts";
@@ -0,0 +1,43 @@
1
+ import type { ModelInfo } from "../types.ts";
2
+
3
+ /** Saved sessions and AssignModel results are already-resolved UIDs, not selectors. */
4
+ export function getModelByUID(models: readonly ModelInfo[], uid: string): ModelInfo {
5
+ 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
+ }
10
+
11
+ /** 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) ?? [];
15
+ const query = normalize(selector);
16
+ if (!query) throw new Error("The model name is empty.");
17
+
18
+ // Families take precedence over aliases. An alias points to a family, not a declared variant.
19
+ const family = models.find((model) => model.familyId && normalize(model.familyId) === query)
20
+ ?? models.find((model) => model.aliases?.some((alias) => normalize(alias) === query));
21
+ if (family) {
22
+ const selected = models.find((model) => !model.isDisabled
23
+ && (family.familyId ? model.familyId === family.familyId : model.id === family.id));
24
+ if (!selected) throw new Error(`Model is disabled: ${selector}`);
25
+ return selected;
26
+ }
27
+
28
+ const exact = models.find((model) => normalize(model.id) === query)
29
+ ?? models.find((model) => normalize(model.name) === query);
30
+ if (exact) return getModelByUID(models, exact.id);
31
+
32
+ // Compares whole alphanumeric tokens in any order, not arbitrary substrings.
33
+ const words = tokenize(query);
34
+ const candidates = words.some((word) => /[a-z]/.test(word))
35
+ ? models.filter((model) => !model.isDisabled)
36
+ .map((model) => ({ model, tokens: tokenize(model.id) }))
37
+ .filter(({ tokens }) => words.every((word) => tokens.includes(word)))
38
+ : [];
39
+ candidates.sort((left, right) => left.tokens.length - right.tokens.length
40
+ || (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}`);
43
+ }
@@ -0,0 +1,163 @@
1
+ import { object, requiredString, tokenCount, type JsonObject, type ModelInfo } from "../types.ts";
2
+ import { parseModelCatalog, type ModelCatalog } from "./catalog.ts";
3
+ import { getModelByUID } from "./select.ts";
4
+
5
+ export const EFFECTIVE_THINKING_LEVELS = ["off", "minimal", "low", "medium", "high", "xhigh", "max"] as const;
6
+ export type EffectiveThinkingLevel = typeof EFFECTIVE_THINKING_LEVELS[number];
7
+ export interface ModelDimension { key: string; order: number; name: string }
8
+ export interface ModelVariant {
9
+ model: ModelInfo;
10
+ family: string;
11
+ familyName: string;
12
+ dimensions: ModelDimension[];
13
+ level?: EffectiveThinkingLevel;
14
+ unsupported?: string;
15
+ /** Only the model fields the persistent cache needs. Never the whole raw response or auth metadata. */
16
+ config: JsonObject;
17
+ }
18
+ export interface VariantGroup {
19
+ id: string;
20
+ family: string;
21
+ name: string;
22
+ dimensions: ModelDimension[];
23
+ variants: ModelVariant[];
24
+ mappings: Partial<Record<EffectiveThinkingLevel, string>>;
25
+ canPublish: boolean;
26
+ reason?: string;
27
+ }
28
+ export interface VariantProjection {
29
+ variants: ModelVariant[];
30
+ groups: VariantGroup[];
31
+ unsupported: { uid: string; reason: string }[];
32
+ }
33
+ export interface BackendModelSelection { uid: string; model: ModelInfo; level: EffectiveThinkingLevel; variant: ModelVariant }
34
+ const strengthNames = new Map<string, EffectiveThinkingLevel>([
35
+ ["None", "off"], ["No Thinking", "off"], ["Minimal", "minimal"], ["Low", "low"],
36
+ ["Medium", "medium"], ["High", "high"], ["XHigh", "xhigh"], ["Max", "max"],
37
+ ]);
38
+ const thinkingDimensions = new Set(["Reasoning Effort", "Effort", "Thinking"]);
39
+
40
+ export function modelIDForUID(uid: string): string { return "uid:" + encodeURIComponent(uid); }
41
+ export function groupModelID(family: string, dimensions: readonly ModelDimension[]): string {
42
+ const key = JSON.stringify(dimensions.map(({ key, order, name }) => [key, order, name]));
43
+ return "family:" + encodeURIComponent(family) + (dimensions.length ? ":" + Buffer.from(key).toString("base64url") : "");
44
+ }
45
+
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);
60
+ }
61
+
62
+ function classify(model: ModelInfo, dimensions: readonly ModelDimension[]): Pick<ModelVariant, "level" | "unsupported"> {
63
+ if (model.isDisabled) return { unsupported: "disabled" };
64
+ if (model.isRouter) return { unsupported: "router contract not established for Pi" };
65
+ if (model.contextWindow <= 0 || model.maxOutputTokens <= 0) return { unsupported: "missing context/output limit" };
66
+ const thinking = dimensions.find((entry) => entry.key === "Thinking");
67
+ const effort = dimensions.filter((entry) => entry.key === "Reasoning Effort" || entry.key === "Effort");
68
+ if (effort.length > 1) return { unsupported: "ambiguous thinking dimensions" };
69
+ if (thinking?.order === 0 && !model.supportsThinking) return { level: "off" };
70
+ const level = effort[0] && strengthNames.get(effort[0].name);
71
+ if (level) return { level };
72
+ if (!effort.length && !thinking && !model.supportsThinking) return { level: "off" };
73
+ return { unsupported: "thinking strength metadata is unknown" };
74
+ }
75
+
76
+ function sanitizedConfig(config: JsonObject, model: ModelInfo, familyName: string, dimensions: readonly ModelDimension[]): JsonObject {
77
+ const info = object(config.modelInfo, "modelInfo");
78
+ return {
79
+ modelUid: model.id, label: model.name, disabled: model.isDisabled, maxTokens: model.contextWindow,
80
+ supportsImages: model.supportsImages,
81
+ // The recommended flag must survive offline restore so the list filter gives the same result.
82
+ ...(model.isRecommended ? { isRecommended: true } : {}),
83
+ modelInfo: {
84
+ modelUid: model.id, modelFamilyUid: model.familyId || model.id,
85
+ maxTokens: model.contextWindow, maxOutputTokens: model.maxOutputTokens,
86
+ ...(typeof info.modelType === "string" ? { modelType: info.modelType } : {}),
87
+ isModelRouter: model.isRouter, aliases: [...(model.aliases ?? [])],
88
+ modelFeatures: {
89
+ supportsImages: model.supportsImages, supportsToolCalls: model.supportsToolCalls,
90
+ supportsParallelToolCalls: model.supportsParallelToolCalls, supportsThinking: model.supportsThinking,
91
+ },
92
+ },
93
+ modelFamilyMetadata: { modelFamilyLabel: familyName, entries: dimensions.map(({ key, order, name }) => ({ key, value: { order, name } })) },
94
+ };
95
+ }
96
+
97
+ function capabilityKey(model: ModelInfo): string {
98
+ return JSON.stringify([model.contextWindow, model.maxOutputTokens, model.supportsImages, model.supportsToolCalls, model.supportsParallelToolCalls]);
99
+ }
100
+
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);
116
+ 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),
120
+ };
121
+ });
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
+ }
144
+
145
+ export function restoreModelVariants(configs: readonly JsonObject[]): VariantProjection {
146
+ return projectModelVariants(parseModelCatalog({ clientModelConfigs: [...configs] }));
147
+ }
148
+
149
+ /** 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 };
163
+ }